From 6cb2def3d594ca61771050f65c30fb986dbd4928 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 14 Aug 2025 10:03:37 +0200 Subject: [PATCH 001/127] Move Amazon specific code to main source set for easier maintenance --- app/build.gradle.kts | 4 +- app/src/amazon/README.md | 2 +- .../billing/amazon/AmazonBillingActivity.kt | 0 .../amazon/AmazonBillingSQLiteHelper.java | 0 .../billing/amazon/AmazonIapManager.java | 0 .../amazon/AmazonPurchasingListener.java | 0 .../seriesguide/billing/amazon/AmazonSku.java | 0 .../billing/amazon/PurchaseDataSource.java | 0 .../billing/amazon/PurchaseRecord.java | 0 .../billing/amazon/UserIapData.java | 0 .../res/layout/activity_amazon_billing.xml | 0 .../billing/amazon/AmazonBillingActivity.kt | 11 ------ .../billing/amazon/AmazonIapManager.kt | 38 ------------------- 13 files changed, 3 insertions(+), 52 deletions(-) rename app/src/{amazon => main}/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingActivity.kt (100%) rename app/src/{amazon => main}/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingSQLiteHelper.java (100%) rename app/src/{amazon => main}/java/com/battlelancer/seriesguide/billing/amazon/AmazonIapManager.java (100%) rename app/src/{amazon => main}/java/com/battlelancer/seriesguide/billing/amazon/AmazonPurchasingListener.java (100%) rename app/src/{amazon => main}/java/com/battlelancer/seriesguide/billing/amazon/AmazonSku.java (100%) rename app/src/{amazon => main}/java/com/battlelancer/seriesguide/billing/amazon/PurchaseDataSource.java (100%) rename app/src/{amazon => main}/java/com/battlelancer/seriesguide/billing/amazon/PurchaseRecord.java (100%) rename app/src/{amazon => main}/java/com/battlelancer/seriesguide/billing/amazon/UserIapData.java (100%) rename app/src/{amazon => main}/res/layout/activity_amazon_billing.xml (100%) delete mode 100644 app/src/pure/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingActivity.kt delete mode 100644 app/src/pure/java/com/battlelancer/seriesguide/billing/amazon/AmazonIapManager.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 42c1e0fef2..843455534c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -266,9 +266,9 @@ dependencies { implementation(libs.firebase.auth) implementation(libs.play.services.auth) - // Amazon flavor specific + // Amazon Billing // Note: requires to add AppstoreAuthenticationKey.pem into amazon/assets. - "amazonImplementation"(libs.amazon.appstore.sdk) + implementation(libs.amazon.appstore.sdk) // Instrumented unit tests androidTestImplementation(libs.androidx.annotation) diff --git a/app/src/amazon/README.md b/app/src/amazon/README.md index e690579e42..1917030bcf 100644 --- a/app/src/amazon/README.md +++ b/app/src/amazon/README.md @@ -1,6 +1,6 @@ # Amazon Appstore variant -To test in-app billing see notes in [AmazonBillingActivity.kt](java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingActivity.kt) +To test in-app billing see notes in [AmazonBillingActivity.kt](../main/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingActivity.kt) As the Appstore SDK does not include ProGuard rules, [those are added manually](https://developer.amazon.com/docs/in-app-purchasing/iap-obfuscate-the-code.html). diff --git a/app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingActivity.kt b/app/src/main/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingActivity.kt similarity index 100% rename from app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingActivity.kt rename to app/src/main/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingActivity.kt diff --git a/app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingSQLiteHelper.java b/app/src/main/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingSQLiteHelper.java similarity index 100% rename from app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingSQLiteHelper.java rename to app/src/main/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingSQLiteHelper.java diff --git a/app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/AmazonIapManager.java b/app/src/main/java/com/battlelancer/seriesguide/billing/amazon/AmazonIapManager.java similarity index 100% rename from app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/AmazonIapManager.java rename to app/src/main/java/com/battlelancer/seriesguide/billing/amazon/AmazonIapManager.java diff --git a/app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/AmazonPurchasingListener.java b/app/src/main/java/com/battlelancer/seriesguide/billing/amazon/AmazonPurchasingListener.java similarity index 100% rename from app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/AmazonPurchasingListener.java rename to app/src/main/java/com/battlelancer/seriesguide/billing/amazon/AmazonPurchasingListener.java diff --git a/app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/AmazonSku.java b/app/src/main/java/com/battlelancer/seriesguide/billing/amazon/AmazonSku.java similarity index 100% rename from app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/AmazonSku.java rename to app/src/main/java/com/battlelancer/seriesguide/billing/amazon/AmazonSku.java diff --git a/app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/PurchaseDataSource.java b/app/src/main/java/com/battlelancer/seriesguide/billing/amazon/PurchaseDataSource.java similarity index 100% rename from app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/PurchaseDataSource.java rename to app/src/main/java/com/battlelancer/seriesguide/billing/amazon/PurchaseDataSource.java diff --git a/app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/PurchaseRecord.java b/app/src/main/java/com/battlelancer/seriesguide/billing/amazon/PurchaseRecord.java similarity index 100% rename from app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/PurchaseRecord.java rename to app/src/main/java/com/battlelancer/seriesguide/billing/amazon/PurchaseRecord.java diff --git a/app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/UserIapData.java b/app/src/main/java/com/battlelancer/seriesguide/billing/amazon/UserIapData.java similarity index 100% rename from app/src/amazon/java/com/battlelancer/seriesguide/billing/amazon/UserIapData.java rename to app/src/main/java/com/battlelancer/seriesguide/billing/amazon/UserIapData.java diff --git a/app/src/amazon/res/layout/activity_amazon_billing.xml b/app/src/main/res/layout/activity_amazon_billing.xml similarity index 100% rename from app/src/amazon/res/layout/activity_amazon_billing.xml rename to app/src/main/res/layout/activity_amazon_billing.xml diff --git a/app/src/pure/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingActivity.kt b/app/src/pure/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingActivity.kt deleted file mode 100644 index 2f678413fb..0000000000 --- a/app/src/pure/java/com/battlelancer/seriesguide/billing/amazon/AmazonBillingActivity.kt +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright 2014 Uwe Trottmann -// SPDX-License-Identifier: Apache-2.0 - -package com.battlelancer.seriesguide.billing.amazon - -import com.battlelancer.seriesguide.ui.BaseActivity - -/** - * No-op dummy. Only available in amazon build. - */ -class AmazonBillingActivity : BaseActivity() \ No newline at end of file diff --git a/app/src/pure/java/com/battlelancer/seriesguide/billing/amazon/AmazonIapManager.kt b/app/src/pure/java/com/battlelancer/seriesguide/billing/amazon/AmazonIapManager.kt deleted file mode 100644 index ac5ead0df0..0000000000 --- a/app/src/pure/java/com/battlelancer/seriesguide/billing/amazon/AmazonIapManager.kt +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2014 Uwe Trottmann -// SPDX-License-Identifier: Apache-2.0 - -package com.battlelancer.seriesguide.billing.amazon - -import android.app.Activity -import android.content.Context - -/** - * No-op dummy of Amazon IAP manager. - */ -class AmazonIapManager(@Suppress("UNUSED_PARAMETER") context: Context) : AmazonIapManagerInterface { - - override fun register() { - // no op - } - - override fun requestProductData() { - // no op - } - - override fun requestUserDataAndPurchaseUpdates() { - // no op - } - - override fun activate() { - // no op - } - - override fun deactivate() { - // no op - } - - override fun validateSupporterState(activity: Activity) { - // no op - } - -} From caae058859a5e9287cad7ea52af1750c6714ad93 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 14 Aug 2025 10:25:42 +0200 Subject: [PATCH 002/127] Move Amazon specific links to main source set for easier maintenance --- app/src/amazon/res/values/donottranslate.xml | 7 ------- .../extensions/ExtensionsConfigurationFragment.kt | 2 +- .../seriesguide/shows/overview/OverviewFragment.kt | 14 +++++++++----- app/src/main/res/values/donottranslate.xml | 7 +++++-- 4 files changed, 15 insertions(+), 15 deletions(-) delete mode 100644 app/src/amazon/res/values/donottranslate.xml diff --git a/app/src/amazon/res/values/donottranslate.xml b/app/src/amazon/res/values/donottranslate.xml deleted file mode 100644 index e65413f0c6..0000000000 --- a/app/src/amazon/res/values/donottranslate.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - http://www.amazon.com/gp/mas/dl/android?p=com.uwetrottmann.seriesguide.amzn - - - - diff --git a/app/src/main/java/com/battlelancer/seriesguide/extensions/ExtensionsConfigurationFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/extensions/ExtensionsConfigurationFragment.kt index 322fbce338..aeb007bcdc 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/extensions/ExtensionsConfigurationFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/extensions/ExtensionsConfigurationFragment.kt @@ -265,7 +265,7 @@ class ExtensionsConfigurationFragment : Fragment() { // special item: search for more extensions WebTools.openInApp( requireContext(), - getString(R.string.url_extensions_search) + getString(R.string.url_play_extensions_search) ) return@setOnMenuItemClickListener true } diff --git a/app/src/main/java/com/battlelancer/seriesguide/shows/overview/OverviewFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/shows/overview/OverviewFragment.kt index f3d96b7b1e..b8011662d3 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/shows/overview/OverviewFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/shows/overview/OverviewFragment.kt @@ -62,6 +62,7 @@ import com.battlelancer.seriesguide.ui.BaseMessageActivity.ServiceActiveEvent import com.battlelancer.seriesguide.ui.BaseMessageActivity.ServiceCompletedEvent import com.battlelancer.seriesguide.util.ImageTools import com.battlelancer.seriesguide.util.LanguageTools +import com.battlelancer.seriesguide.util.PackageTools import com.battlelancer.seriesguide.util.RatingsTools.initialize import com.battlelancer.seriesguide.util.RatingsTools.setLink import com.battlelancer.seriesguide.util.RatingsTools.setValuesFor @@ -661,7 +662,8 @@ class OverviewFragment() : Fragment(), EpisodeActionsContract { ImageTools.buildEpisodeImageUrl(imagePath, requireContext()) ) .error(R.drawable.ic_photo_gray_24dp) - .into(imageView, + .into( + imageView, object : Callback { override fun onSuccess() { imageView.scaleType = ImageView.ScaleType.CENTER_CROP @@ -750,10 +752,12 @@ class OverviewFragment() : Fragment(), EpisodeActionsContract { feedbackView = it it.setCallback(object : FeedbackView.Callback { override fun onRate() { - if (WebTools.openInApp( - requireContext(), - getString(R.string.url_store_page) - )) { + val urlRes = if (PackageTools.isAmazonVersion()) { + R.string.url_amazon_listing + } else { + R.string.url_play_listing + } + if (WebTools.openInApp(requireContext(), getString(urlRes))) { hideFeedbackView() } } diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 26f508b271..6d0501fd87 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -51,8 +51,11 @@ Trakt terms - https://play.google.com/store/apps/details?id=com.battlelancer.seriesguide - https://play.google.com/store/search?q=SeriesGuide%20Extension&c=apps + https://play.google.com/store/apps/details?id=com.battlelancer.seriesguide + https://play.google.com/store/search?q=SeriesGuide%20Extension&c=apps + + + http://www.amazon.com/gp/mas/dl/android?p=com.uwetrottmann.seriesguide.amzn [Debug] Enable extensions From d5e3d66aa1a223fd6b4ead69a684570158235cde Mon Sep 17 00:00:00 2001 From: Uwe Date: Fri, 15 Aug 2025 07:40:11 +0200 Subject: [PATCH 003/127] BillingTools: prepare global unlock state, update only on demand --- .../com/battlelancer/seriesguide/SgApp.kt | 4 ++ .../seriesguide/billing/BillingActivity.kt | 5 +- .../seriesguide/billing/BillingTools.kt | 62 +++++++++++++++---- .../seriesguide/shows/ShowsActivityImpl.kt | 4 -- .../seriesguide/ui/BaseTopActivity.kt | 8 ++- .../seriesguide/util/PackageTools.kt | 2 +- 6 files changed, 64 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/SgApp.kt b/app/src/main/java/com/battlelancer/seriesguide/SgApp.kt index 6c534e8eab..ae1f1f3328 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/SgApp.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/SgApp.kt @@ -14,6 +14,7 @@ import android.os.StrictMode import android.os.StrictMode.ThreadPolicy import android.os.StrictMode.VmPolicy import androidx.annotation.RequiresApi +import com.battlelancer.seriesguide.billing.BillingTools import com.battlelancer.seriesguide.modules.AppModule import com.battlelancer.seriesguide.modules.DaggerServicesComponent import com.battlelancer.seriesguide.modules.HttpClientModule @@ -149,6 +150,9 @@ class SgApp : Application() { // Update security provider before building HTTP client (for Picasso and in HttpClientModule). initializeSecurityProvider() initializePicasso() + + // Initialize unlock state + BillingTools.updateUnlockStateAsync(this) } /** diff --git a/app/src/main/java/com/battlelancer/seriesguide/billing/BillingActivity.kt b/app/src/main/java/com/battlelancer/seriesguide/billing/BillingActivity.kt index aa36b170e4..a356dfba9c 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/billing/BillingActivity.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/billing/BillingActivity.kt @@ -22,6 +22,7 @@ import com.battlelancer.seriesguide.BuildConfig import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.SgApp import com.battlelancer.seriesguide.ui.BaseActivity +import com.battlelancer.seriesguide.util.PackageTools import com.battlelancer.seriesguide.util.ThemeUtils import com.battlelancer.seriesguide.util.ViewTools.openUriOnClick import com.battlelancer.seriesguide.util.WebTools @@ -86,7 +87,7 @@ class BillingActivity : BaseActivity() { } } // Only use subscription state if unlock app is not installed. - if (BillingTools.hasUnlockKey(this)) { + if (PackageTools.hasUnlockKeyInstalled(this)) { setWaitMode(false) updateViewStates(hasUpgrade = true, unlockAppDetected = true) } else { @@ -150,7 +151,7 @@ class BillingActivity : BaseActivity() { super.onStart() // Check if user has installed key app. - if (BillingTools.hasUnlockKey(this)) { + if (PackageTools.hasUnlockKeyInstalled(this)) { updateViewStates(hasUpgrade = true, unlockAppDetected = true) } } diff --git a/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt b/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt index 8c0e329047..67f13586dc 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt @@ -8,38 +8,74 @@ import android.content.Intent import android.widget.Toast import com.battlelancer.seriesguide.BuildConfig import com.battlelancer.seriesguide.R +import com.battlelancer.seriesguide.SgApp import com.battlelancer.seriesguide.billing.amazon.AmazonBillingActivity import com.battlelancer.seriesguide.settings.AdvancedSettings import com.battlelancer.seriesguide.util.PackageTools import com.uwetrottmann.seriesguide.billing.localdb.LocalBillingDb +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.concurrent.CountDownLatch +/** + * Singleton to help manage unlock all features state and billing. + */ object BillingTools { + // At most 1 coroutine at a time should update the unlock state + private val unlockStateUpdateDispatcher = Dispatchers.Default.limitedParallelism(1) + private val unlockStateInitialized = CountDownLatch(1) + private val unlockState = MutableStateFlow(false) + /** * Returns if the user should get access to paid features. + * + * Note: this method will block until the unlock state is initialized. */ fun hasAccessToPaidFeatures(context: Context): Boolean { + try { + unlockStateInitialized.await() + } catch (_: InterruptedException) { + // Caller was interrupted, so should not care about the result + Timber.d("hasAccessToPaidFeatures: interrupted while waiting") + return false + } + + return unlockState.value + } + + fun updateUnlockStateAsync(context: Context) { + SgApp.coroutineScope.launch(unlockStateUpdateDispatcher) { + updateUnlockState(context) + } + } + + private fun updateUnlockState(context: Context) { // Debug builds, installed X Pass key or subscription unlock all features - if (PackageTools.isAmazonVersion()) { + val newUnlockState = if (PackageTools.isAmazonVersion()) { // Amazon version only supports all access as in-app purchase, so skip key check - return AdvancedSettings.getLastSupporterState(context) + AdvancedSettings.getLastSupporterState(context) } else { - if (hasUnlockKey(context)) { - return true + if (PackageTools.hasUnlockKeyInstalled(context)) { + true } else { + // TODO Auto-expire after 1 year if not updated by Play Billing (for ex. when user + // plans to switch billing provider after changing installer source) val goldStatus = LocalBillingDb.getInstance(context).entitlementsDao() .getGoldStatus() - return goldStatus != null && goldStatus.entitled + goldStatus != null && goldStatus.entitled } } - } - - /** - * Returns if X pass is installed and a purchase check with Google Play is not necessary to - * determine access to paid features. - */ - fun hasUnlockKey(context: Context): Boolean { - return BuildConfig.DEBUG || PackageTools.hasUnlockKeyInstalled(context) + Timber.i( + "updateUnlockState: unlockState=%s, newUnlockState=%s%s", + unlockState.value, + newUnlockState, + if (BuildConfig.DEBUG) " (debug mode: overridden to true)" else "" + ) + unlockState.value = if (BuildConfig.DEBUG) true else newUnlockState + unlockStateInitialized.countDown() } /** diff --git a/app/src/main/java/com/battlelancer/seriesguide/shows/ShowsActivityImpl.kt b/app/src/main/java/com/battlelancer/seriesguide/shows/ShowsActivityImpl.kt index cdd2244279..b5aea013a5 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/shows/ShowsActivityImpl.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/shows/ShowsActivityImpl.kt @@ -14,7 +14,6 @@ import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.SgApp import com.battlelancer.seriesguide.api.Intents import com.battlelancer.seriesguide.billing.BillingActivity -import com.battlelancer.seriesguide.billing.BillingTools import com.battlelancer.seriesguide.billing.amazon.AmazonHelper import com.battlelancer.seriesguide.notifications.NotificationService import com.battlelancer.seriesguide.provider.SgRoomDatabase @@ -260,9 +259,6 @@ open class ShowsActivityImpl : BaseTopActivity() { } private fun checkGooglePlayPurchase() { - if (BillingTools.hasUnlockKey(this)) { - return - } // Automatically starts checking all access status. // Ends connection if activity is finished (and was not ended elsewhere already). billingViewModel = diff --git a/app/src/main/java/com/battlelancer/seriesguide/ui/BaseTopActivity.kt b/app/src/main/java/com/battlelancer/seriesguide/ui/BaseTopActivity.kt index 3342c139f3..2be7066c45 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/ui/BaseTopActivity.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/ui/BaseTopActivity.kt @@ -25,7 +25,6 @@ import com.battlelancer.seriesguide.dataliberation.DataLiberationActivity import com.battlelancer.seriesguide.preferences.MoreOptionsActivity import com.battlelancer.seriesguide.stats.StatsActivity import com.battlelancer.seriesguide.sync.AccountUtils -import com.battlelancer.seriesguide.ui.ShowsActivity import com.battlelancer.seriesguide.util.SupportTheDev import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.snackbar.Snackbar @@ -178,6 +177,13 @@ abstract class BaseTopActivity : BaseMessageActivity() { override fun onStart() { super.onStart() + + // Users might have installed X Pass and as there is no trigger for this event + // manually check whenever returning to a top-level activity. As a side-effect don't have + // to trigger an update when any billing provider detects a change in its unlock status + // (which also avoids having to throttle updating the unlock state). + BillingTools.updateUnlockStateAsync(this) + if (BillingTools.hasAccessToPaidFeatures(this) && HexagonSettings.shouldValidateAccount(this)) { onShowCloudAccountWarning() } diff --git a/app/src/main/java/com/battlelancer/seriesguide/util/PackageTools.kt b/app/src/main/java/com/battlelancer/seriesguide/util/PackageTools.kt index 904707d098..f983705de6 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/util/PackageTools.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/util/PackageTools.kt @@ -62,7 +62,7 @@ object PackageTools { } /** - * Returns if the user has a valid copy of X Pass installed. + * Returns if the user has a valid copy of the X Pass app installed. */ @JvmStatic fun hasUnlockKeyInstalled(context: Context): Boolean { From 5341c456f86f585f22f9e86dd7dabe11fb1e0337 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 28 Aug 2025 09:21:23 +0200 Subject: [PATCH 004/127] Follow-up: remove context parameter from hasAccessToPaidFeatures --- .../seriesguide/appwidget/ListWidgetPreferenceFragment.kt | 2 +- .../battlelancer/seriesguide/backend/CloudSetupFragment.kt | 6 +++--- .../seriesguide/backend/settings/HexagonSettings.kt | 2 +- .../com/battlelancer/seriesguide/billing/BillingTools.kt | 2 +- .../seriesguide/movies/MovieClickListenerImpl.kt | 2 +- .../seriesguide/movies/details/MovieDetailsFragment.kt | 2 +- .../seriesguide/notifications/NotificationService.kt | 2 +- .../seriesguide/preferences/MoreOptionsActivity.kt | 2 +- .../seriesguide/preferences/SgPreferencesFragment.kt | 4 ++-- .../seriesguide/shows/episodes/EpisodeDetailsFragment.kt | 2 +- .../seriesguide/shows/episodes/EpisodesFragment.kt | 2 +- .../seriesguide/shows/overview/ShowViewModel.kt | 2 +- .../java/com/battlelancer/seriesguide/ui/BaseTopActivity.kt | 2 +- .../java/com/battlelancer/seriesguide/util/SupportTheDev.kt | 2 +- 14 files changed, 17 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/appwidget/ListWidgetPreferenceFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/appwidget/ListWidgetPreferenceFragment.kt index 7cd082deb3..4802b407cc 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/appwidget/ListWidgetPreferenceFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/appwidget/ListWidgetPreferenceFragment.kt @@ -158,7 +158,7 @@ class ListWidgetPreferenceFragment : BasePreferencesFragment() { bindPreferenceSummaryToValue(themePref) // Disable saving some prefs not available for non-supporters. - if (!BillingTools.hasAccessToPaidFeatures(requireContext())) { + if (!BillingTools.hasAccessToPaidFeatures()) { val onDisablePreferenceChangeListener = Preference.OnPreferenceChangeListener { _: Preference?, _: Any? -> BillingTools.advertiseSubscription(requireContext()) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/CloudSetupFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/CloudSetupFragment.kt index da6ef7f9b9..e2a9c75a0f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/CloudSetupFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/CloudSetupFragment.kt @@ -73,12 +73,12 @@ class CloudSetupFragment : Fragment() { binding!!.apply { ThemeUtils.applyBottomPaddingForNavigationBar(scrollViewCloud) buttonCloudSignIn.apply { - if (!BillingTools.hasAccessToPaidFeatures(requireContext())) { + if (!BillingTools.hasAccessToPaidFeatures()) { (buttonCloudSignIn as MaterialButton).setIconResource(R.drawable.ic_awesome_black_24dp) } setOnClickListener { // restrict access to supporters - if (BillingTools.hasAccessToPaidFeatures(requireContext())) { + if (BillingTools.hasAccessToPaidFeatures()) { startHexagonSetup() } else { BillingTools.advertiseSubscription(requireContext()) @@ -174,7 +174,7 @@ class CloudSetupFragment : Fragment() { setProgressVisible(false) updateViews() - if (signedIn && BillingTools.hasAccessToPaidFeatures(requireContext())) { + if (signedIn && BillingTools.hasAccessToPaidFeatures()) { if (!HexagonSettings.isEnabled(requireContext()) || HexagonSettings.shouldValidateAccount(requireContext())) { Timber.i("Auto-start Cloud setup.") diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/settings/HexagonSettings.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/settings/HexagonSettings.kt index d7d98ac4ee..f156350b79 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/settings/HexagonSettings.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/settings/HexagonSettings.kt @@ -38,7 +38,7 @@ object HexagonSettings { * If Cloud is enabled and Cloud specific actions should be performed or UI be shown. */ fun isEnabled(context: Context): Boolean = - BillingTools.hasAccessToPaidFeatures(context) + BillingTools.hasAccessToPaidFeatures() && PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(KEY_ENABLED, false) diff --git a/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt b/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt index 67f13586dc..b93e611cc3 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt @@ -34,7 +34,7 @@ object BillingTools { * * Note: this method will block until the unlock state is initialized. */ - fun hasAccessToPaidFeatures(context: Context): Boolean { + fun hasAccessToPaidFeatures(): Boolean { try { unlockStateInitialized.await() } catch (_: InterruptedException) { diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/MovieClickListenerImpl.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/MovieClickListenerImpl.kt index 872c791c16..d869ebb4af 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/movies/MovieClickListenerImpl.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/MovieClickListenerImpl.kt @@ -47,7 +47,7 @@ open class MovieClickListenerImpl(val context: Context) : MovieClickListener { return@setOnMenuItemClickListener when (item.itemId) { R.id.menu_action_movies_set_watched -> { // Multiple plays only for supporters. - if (movieFlags.watched && !BillingTools.hasAccessToPaidFeatures(context)) { + if (movieFlags.watched && !BillingTools.hasAccessToPaidFeatures()) { BillingTools.advertiseSubscription(context) return@setOnMenuItemClickListener true } diff --git a/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetailsFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetailsFragment.kt index 10a6b92ffa..6cc9cf8e0f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetailsFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/movies/details/MovieDetailsFragment.kt @@ -601,7 +601,7 @@ class MovieDetailsFragment : Fragment(), MovieActionsContract { ) : PopupMenu.OnMenuItemClickListener { override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { - R.id.watched_popup_menu_watch_again -> if (BillingTools.hasAccessToPaidFeatures(context)) { + R.id.watched_popup_menu_watch_again -> if (BillingTools.hasAccessToPaidFeatures()) { MovieTools.watchedMovie(context, movieTmdbId, plays, inWatchlist) } else { BillingTools.advertiseSubscription(context) diff --git a/app/src/main/java/com/battlelancer/seriesguide/notifications/NotificationService.kt b/app/src/main/java/com/battlelancer/seriesguide/notifications/NotificationService.kt index 7e2b096ff8..f49a407f17 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/notifications/NotificationService.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/notifications/NotificationService.kt @@ -96,7 +96,7 @@ class NotificationService(context: Context) { // remove notification service wake-up alarm if notifications are disabled or not unlocked if (!NotificationSettings.isNotificationsEnabled(context) - || !BillingTools.hasAccessToPaidFeatures(context)) { + || !BillingTools.hasAccessToPaidFeatures()) { Timber.d("Notifications disabled, removing wake-up alarm") val am = context.getSystemService() am?.cancel(wakeUpPendingIntent) diff --git a/app/src/main/java/com/battlelancer/seriesguide/preferences/MoreOptionsActivity.kt b/app/src/main/java/com/battlelancer/seriesguide/preferences/MoreOptionsActivity.kt index c3eb29c190..7edf0cb849 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/preferences/MoreOptionsActivity.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/preferences/MoreOptionsActivity.kt @@ -133,7 +133,7 @@ class MoreOptionsActivity : BaseTopActivity() { } // Update supporter status. - binding.textViewThankYouSupporters.isGone = !BillingTools.hasAccessToPaidFeatures(this) + binding.textViewThankYouSupporters.isGone = !BillingTools.hasAccessToPaidFeatures() // Show debug log button if debug mode is on. binding.buttonDebugLog.isGone = !AppSettings.isUserDebugModeEnabled(this) diff --git a/app/src/main/java/com/battlelancer/seriesguide/preferences/SgPreferencesFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/preferences/SgPreferencesFragment.kt index 440fcd50bc..f9ded71a17 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/preferences/SgPreferencesFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/preferences/SgPreferencesFragment.kt @@ -122,7 +122,7 @@ class SgPreferencesFragment : BasePreferencesFragment(), } private fun updateRootSettings() { - val hasAllFeatures = BillingTools.hasAccessToPaidFeatures(requireContext()) + val hasAllFeatures = BillingTools.hasAccessToPaidFeatures() // notifications link findPreference(KEY_SCREEN_NOTIFICATIONS)!!.apply { @@ -181,7 +181,7 @@ class SgPreferencesFragment : BasePreferencesFragment(), updateThresholdSummary(findPreference(NotificationSettings.KEY_THRESHOLD)!!) updateSelectionSummary(findPreference(NotificationSettings.KEY_SELECTION)!!) - val hasAllFeatures = BillingTools.hasAccessToPaidFeatures(requireContext()) + val hasAllFeatures = BillingTools.hasAccessToPaidFeatures() if (hasAllFeatures) { // Disable advanced notification settings if notifications are disabled. enableAdvancedNotificationSettings( diff --git a/app/src/main/java/com/battlelancer/seriesguide/shows/episodes/EpisodeDetailsFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/shows/episodes/EpisodeDetailsFragment.kt index 5bc318932d..85aa5f3917 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/shows/episodes/EpisodeDetailsFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/shows/episodes/EpisodeDetailsFragment.kt @@ -269,7 +269,7 @@ class EpisodeDetailsFragment : Fragment(), EpisodeActionsContract { val itemId = item.itemId if (itemId == R.id.watched_popup_menu_watch_again) { // Multiple plays are for supporters only. - if (!BillingTools.hasAccessToPaidFeatures(requireContext())) { + if (!BillingTools.hasAccessToPaidFeatures()) { BillingTools.advertiseSubscription(requireContext()) } else { changeEpisodeFlag(EpisodeFlags.WATCHED) diff --git a/app/src/main/java/com/battlelancer/seriesguide/shows/episodes/EpisodesFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/shows/episodes/EpisodesFragment.kt index 5358196344..94d0f2831a 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/shows/episodes/EpisodesFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/shows/episodes/EpisodesFragment.kt @@ -199,7 +199,7 @@ class EpisodesFragment : Fragment() { val itemId = item.itemId if (itemId == R.id.watched_popup_menu_watch_again) { // Multiple plays are for supporters only. - if (!BillingTools.hasAccessToPaidFeatures(requireContext())) { + if (!BillingTools.hasAccessToPaidFeatures()) { BillingTools.advertiseSubscription(requireContext()) } else { onFlagEpisodeWatched(episodeId, true) diff --git a/app/src/main/java/com/battlelancer/seriesguide/shows/overview/ShowViewModel.kt b/app/src/main/java/com/battlelancer/seriesguide/shows/overview/ShowViewModel.kt index db6e34edf8..8040371a7f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/shows/overview/ShowViewModel.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/shows/overview/ShowViewModel.kt @@ -162,7 +162,7 @@ class ShowViewModel(application: Application) : AndroidViewModel(application) { fun updateUserStatus() { viewModelScope.launch(Dispatchers.IO) { val currentState = hasAllFeatures.value - val newState = BillingTools.hasAccessToPaidFeatures(getApplication()) + val newState = BillingTools.hasAccessToPaidFeatures() if (currentState != newState) { hasAllFeatures.postValue(newState) } diff --git a/app/src/main/java/com/battlelancer/seriesguide/ui/BaseTopActivity.kt b/app/src/main/java/com/battlelancer/seriesguide/ui/BaseTopActivity.kt index 2be7066c45..46250b230d 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/ui/BaseTopActivity.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/ui/BaseTopActivity.kt @@ -184,7 +184,7 @@ abstract class BaseTopActivity : BaseMessageActivity() { // (which also avoids having to throttle updating the unlock state). BillingTools.updateUnlockStateAsync(this) - if (BillingTools.hasAccessToPaidFeatures(this) && HexagonSettings.shouldValidateAccount(this)) { + if (BillingTools.hasAccessToPaidFeatures() && HexagonSettings.shouldValidateAccount(this)) { onShowCloudAccountWarning() } if (SupportTheDev.shouldAsk(this)) { diff --git a/app/src/main/java/com/battlelancer/seriesguide/util/SupportTheDev.kt b/app/src/main/java/com/battlelancer/seriesguide/util/SupportTheDev.kt index 2dc27f66d4..6f15f40829 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/util/SupportTheDev.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/util/SupportTheDev.kt @@ -28,7 +28,7 @@ object SupportTheDev { val lastDismissed = prefs.getLong(PREF_SUPPORT_DEV_LAST_DISMISSED, 0) - if (BillingTools.hasAccessToPaidFeatures(context)) { + if (BillingTools.hasAccessToPaidFeatures()) { // Reset to ask again after access expired if (lastDismissed != 0L) { prefs.saveLastDismissed(0) From 616d1969b57b55dcdb3ed58be18814acda792551 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 21 Aug 2025 07:44:45 +0200 Subject: [PATCH 005/127] LocalBillingDb: document versions --- .../billing/localdb/LocalBillingDb.kt | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt index cdf72ada49..55c87b6b3c 100644 --- a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt +++ b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt @@ -1,6 +1,7 @@ -// Copyright (C) 2018 Google Inc. All Rights Reserved. -// Copyright 2019, 2020, 2023 Uwe Trottmann // SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2018 Google Inc. All Rights Reserved. +// Copyright 2019-2025 Uwe Trottmann + package com.uwetrottmann.seriesguide.billing.localdb @@ -15,7 +16,7 @@ import androidx.room.TypeConverters CachedPurchase::class, GoldStatus::class ], - version = 3, + version = LocalBillingDb.VERSION_3, exportSchema = false ) @TypeConverters(PurchaseTypeConverter::class) @@ -28,6 +29,21 @@ abstract class LocalBillingDb : RoomDatabase() { private var INSTANCE: LocalBillingDb? = null private const val DATABASE_NAME = "purchase_db" + /** + * Original version. + */ + const val VERSION_1 = 1 + + /** + * Store subscription purchase token for up/downgrades. + */ + const val VERSION_2 = 2 + + /** + * Drop AugmentedSkuDetails table, stored in a StateFlow instead. + */ + const val VERSION_3 = 3 + @JvmStatic fun getInstance(context: Context): LocalBillingDb = INSTANCE ?: synchronized(this) { From 2fd3febdeaf77b5cb998490037e01594039f155f Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 21 Aug 2025 08:01:56 +0200 Subject: [PATCH 006/127] LocalBillingDb: rename GoldStatus to PlayUnlockState --- .../seriesguide/billing/BillingActivity.kt | 8 ++-- .../seriesguide/billing/BillingTools.kt | 6 +-- .../seriesguide/billing/BillingRepository.kt | 39 ++++++++-------- .../seriesguide/billing/BillingViewModel.kt | 6 +-- .../billing/localdb/Entitlements.kt | 20 +++------ .../billing/localdb/EntitlementsDao.kt | 45 +++---------------- .../billing/localdb/LocalBillingDb.kt | 2 +- 7 files changed, 42 insertions(+), 84 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/billing/BillingActivity.kt b/app/src/main/java/com/battlelancer/seriesguide/billing/BillingActivity.kt index a356dfba9c..39cfba5174 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/billing/BillingActivity.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/billing/BillingActivity.kt @@ -92,12 +92,12 @@ class BillingActivity : BaseActivity() { updateViewStates(hasUpgrade = true, unlockAppDetected = true) } else { setWaitMode(true) - billingViewModel.subStatusLiveData.observe(this) { goldStatus -> + billingViewModel.playUnlockStateLiveData.observe(this) { playUnlockState -> setWaitMode(false) - updateViewStates(goldStatus != null && goldStatus.entitled, false) + updateViewStates(playUnlockState != null && playUnlockState.entitled, false) manageSubscriptionUrl = - if (goldStatus?.isSub == true && goldStatus.sku != null) { - PLAY_MANAGE_SUBS_ONE + goldStatus.sku + if (playUnlockState?.isSub == true && playUnlockState.sku != null) { + PLAY_MANAGE_SUBS_ONE + playUnlockState.sku } else { PLAY_MANAGE_SUBS_ALL } diff --git a/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt b/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt index b93e611cc3..9423047867 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt @@ -63,9 +63,9 @@ object BillingTools { } else { // TODO Auto-expire after 1 year if not updated by Play Billing (for ex. when user // plans to switch billing provider after changing installer source) - val goldStatus = LocalBillingDb.getInstance(context).entitlementsDao() - .getGoldStatus() - goldStatus != null && goldStatus.entitled + val playUnlockState = LocalBillingDb.getInstance(context).entitlementsDao() + .getPlayUnlockState() + playUnlockState != null && playUnlockState.entitled } } Timber.i( diff --git a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingRepository.kt b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingRepository.kt index 1346873714..b97fbcbc39 100644 --- a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingRepository.kt +++ b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingRepository.kt @@ -22,8 +22,8 @@ import com.android.billingclient.api.QueryPurchasesParams import com.android.billingclient.api.queryProductDetails import com.android.billingclient.api.queryPurchasesAsync import com.uwetrottmann.seriesguide.billing.localdb.Entitlement -import com.uwetrottmann.seriesguide.billing.localdb.GoldStatus import com.uwetrottmann.seriesguide.billing.localdb.LocalBillingDb +import com.uwetrottmann.seriesguide.billing.localdb.PlayUnlockState import com.uwetrottmann.seriesguide.common.SingleLiveEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -92,17 +92,18 @@ class BillingRepository private constructor( val productDetails: StateFlow> = _productDetails /** - * Tracks whether this user is entitled to gold status. This call returns data from the app's - * own local DB; this way if Play and the secure server are unavailable, users still have - * access to features they purchased. Normally this would be a good place to update the local - * cache to make sure it's always up-to-date. However, onBillingSetupFinished already called - * queryPurchasesAsync for you; so no need. + * Tracks whether this user is entitled to unlock all features based on a subscription or + * one-time purchase using Play Billing. The state is cached in a local database so if Play and + * the secure server are unavailable, the user still has access to features they purchased. + * + * Normally this would be a good place to update the local cache to make sure it's always + * up-to-date. However, onBillingSetupFinished already calls queryPurchasesAsync. */ - val goldStatusLiveData: LiveData by lazy { + val playUnlockStateLiveData: LiveData by lazy { if (!::localCacheBillingClient.isInitialized) { localCacheBillingClient = LocalBillingDb.getInstance(applicationContext) } - localCacheBillingClient.entitlementsDao().getGoldStatusLiveData() + localCacheBillingClient.entitlementsDao().getPlayUnlockStateLiveData() } /** Triggered when the entitlement was revoked. Use only with one observer at a time. */ @@ -283,9 +284,9 @@ class BillingRepository private constructor( if (supportedProductOrNull != null) { val isSub = supportedProductOrNull != SeriesGuideSku.X_PASS_IN_APP - val subStatus = - GoldStatus(true, isSub, supportedProductOrNull, purchase.purchaseToken) - insertSubStatus(subStatus) + val unlockState = + PlayUnlockState(true, isSub, supportedProductOrNull, purchase.purchaseToken) + insertUnlockState(unlockState) // A user must only have one active subscription. // Prevent re-purchase of active one (or the equivalent tier for legacy products), @@ -318,10 +319,10 @@ class BillingRepository private constructor( coroutineScope.launch(Dispatchers.IO) { // Save if existing entitlement is getting revoked. val wasEntitled = - localCacheBillingClient.entitlementsDao().getGoldStatus()?.entitled ?: false + localCacheBillingClient.entitlementsDao().getPlayUnlockState()?.entitled ?: false - val subStatus = GoldStatus(false, isSub = true, sku = null, purchaseToken = null) - insertSubStatus(subStatus) + val unlockState = PlayUnlockState(false, isSub = true, sku = null, purchaseToken = null) + insertUnlockState(unlockState) // Enable all available subscriptions. enableAllProductsForPurchase() @@ -341,8 +342,8 @@ class BillingRepository private constructor( } @WorkerThread - private suspend fun insertSubStatus(entitlement: Entitlement) = withContext(Dispatchers.IO) { - localCacheBillingClient.entitlementsDao().insert(entitlement) + private suspend fun insertUnlockState(unlockState: PlayUnlockState) = withContext(Dispatchers.IO) { + localCacheBillingClient.entitlementsDao().insert(unlockState) } /** @@ -423,9 +424,9 @@ class BillingRepository private constructor( } // Check if this is a subscription up- or downgrade. - val subStatusOrNull = localCacheBillingClient.entitlementsDao().getGoldStatus() - val oldSubProductId = subStatusOrNull?.let { if (it.isSub) it.sku else null } - val oldPurchaseToken = subStatusOrNull?.purchaseToken + val unlockStateOrNull = localCacheBillingClient.entitlementsDao().getPlayUnlockState() + val oldSubProductId = unlockStateOrNull?.let { if (it.isSub) it.sku else null } + val oldPurchaseToken = unlockStateOrNull?.purchaseToken val purchaseParams = BillingFlowParams.newBuilder().apply { setProductDetailsParamsList( diff --git a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingViewModel.kt b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingViewModel.kt index 59e4b86fe9..5741595bb5 100644 --- a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingViewModel.kt +++ b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingViewModel.kt @@ -9,7 +9,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import com.uwetrottmann.seriesguide.billing.localdb.GoldStatus +import com.uwetrottmann.seriesguide.billing.localdb.PlayUnlockState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -21,7 +21,7 @@ class BillingViewModel( coroutineScope: CoroutineScope ) : AndroidViewModel(application) { - val subStatusLiveData: LiveData + val playUnlockStateLiveData: LiveData /** * A list of supported products filtered to only contain those @@ -36,7 +36,7 @@ class BillingViewModel( init { repository.startDataSourceConnections() - subStatusLiveData = repository.goldStatusLiveData + playUnlockStateLiveData = repository.playUnlockStateLiveData availableProducts = repository.productDetails .map { products -> products.mapNotNull { product -> diff --git a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/Entitlements.kt b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/Entitlements.kt index 3c61f5c422..c7b2f987f8 100644 --- a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/Entitlements.kt +++ b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/Entitlements.kt @@ -1,6 +1,6 @@ -// Copyright (C) 2018 Google Inc. All Rights Reserved. -// Copyright 2019, 2020 Uwe Trottmann // SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2018 Google Inc. All Rights Reserved. +// Copyright 2019-2025 Uwe Trottmann package com.uwetrottmann.seriesguide.billing.localdb @@ -15,26 +15,16 @@ import androidx.room.PrimaryKey abstract class Entitlement { @PrimaryKey var id: Int = 1 - - /** - * This method tells clients whether a user __should__ buy a particular item at the moment. For - * example, if the gas tank is full the user should not be buying gas. This method is __not__ - * a reflection on whether Google Play Billing can make a purchase. - */ - abstract fun mayPurchase(): Boolean } /** - * Subscription is kept simple in this project. And so here the user either has a subscription - * to gold status or s/he doesn't. For more on subscriptions, see the Classy Taxi sample app. + * Stores unlock state obtained via a subscription or one-time purchase using Play Billing. */ @Entity(tableName = "gold_status") -data class GoldStatus( +data class PlayUnlockState( val entitled: Boolean, val isSub: Boolean, val sku: String?, val purchaseToken: String? -) : Entitlement() { - override fun mayPurchase(): Boolean = !entitled -} +) : Entitlement() diff --git a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/EntitlementsDao.kt b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/EntitlementsDao.kt index 9826a95615..c57ab8d566 100644 --- a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/EntitlementsDao.kt +++ b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/EntitlementsDao.kt @@ -1,58 +1,25 @@ -// Copyright (C) 2018 Google Inc. All Rights Reserved. -// Copyright 2019 Uwe Trottmann // SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2018 Google Inc. All Rights Reserved. +// Copyright 2019-2025 Uwe Trottmann + package com.uwetrottmann.seriesguide.billing.localdb import androidx.lifecycle.LiveData import androidx.room.Dao -import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Update -/** - * No update methods necessary since for each table there is ever expecting one row, hence why - * the primary key is hardcoded. - */ @Dao interface EntitlementsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insert(goldStatus: GoldStatus) - - @Update - fun update(goldStatus: GoldStatus) + fun insert(playUnlockState: PlayUnlockState) @Query("SELECT * FROM gold_status LIMIT 1") - fun getGoldStatus(): GoldStatus? + fun getPlayUnlockState(): PlayUnlockState? @Query("SELECT * FROM gold_status LIMIT 1") - fun getGoldStatusLiveData(): LiveData - - @Delete - fun delete(goldStatus: GoldStatus) - - /** - * This is purely for convenience. The clients of this DAO - * can simply send in a list of [entitlements][Entitlement]. - */ - @Transaction - fun insert(vararg entitlements: Entitlement) { - entitlements.forEach { - when (it) { - is GoldStatus -> insert(it) - } - } - } + fun getPlayUnlockStateLiveData(): LiveData - @Transaction - fun update(vararg entitlements: Entitlement) { - entitlements.forEach { - when (it) { - is GoldStatus -> update(it) - } - } - } } \ No newline at end of file diff --git a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt index 55c87b6b3c..cb7ff2e0be 100644 --- a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt +++ b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt @@ -14,7 +14,7 @@ import androidx.room.TypeConverters @Database( entities = [ CachedPurchase::class, - GoldStatus::class + PlayUnlockState::class ], version = LocalBillingDb.VERSION_3, exportSchema = false From f36a7ef6d4e2b731e63167ed24b00d7b86825164 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 21 Aug 2025 08:20:52 +0200 Subject: [PATCH 007/127] LocalBillingDb: export schema for safe keeping --- billing/build.gradle.kts | 8 ++ .../3.json | 77 +++++++++++++++++++ .../billing/localdb/LocalBillingDb.kt | 3 +- 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 billing/schemas/com.uwetrottmann.seriesguide.billing.localdb.LocalBillingDb/3.json diff --git a/billing/build.gradle.kts b/billing/build.gradle.kts index dc65c3acb7..8a9cf526e0 100644 --- a/billing/build.gradle.kts +++ b/billing/build.gradle.kts @@ -43,6 +43,14 @@ android { } +kapt { + arguments { + // Export schema just in case the database ever needs to be built manually + // (like when migrating away from Room). + arg("room.schemaLocation", "$projectDir/schemas") + } +} + dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) diff --git a/billing/schemas/com.uwetrottmann.seriesguide.billing.localdb.LocalBillingDb/3.json b/billing/schemas/com.uwetrottmann.seriesguide.billing.localdb.LocalBillingDb/3.json new file mode 100644 index 0000000000..3252a2ca27 --- /dev/null +++ b/billing/schemas/com.uwetrottmann.seriesguide.billing.localdb.LocalBillingDb/3.json @@ -0,0 +1,77 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "a9f7a9e082618e7eaad59d2a86547f67", + "entities": [ + { + "tableName": "purchase_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`data` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "gold_status", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`entitled` INTEGER NOT NULL, `isSub` INTEGER NOT NULL, `sku` TEXT, `purchaseToken` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "entitled", + "columnName": "entitled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSub", + "columnName": "isSub", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sku", + "columnName": "sku", + "affinity": "TEXT" + }, + { + "fieldPath": "purchaseToken", + "columnName": "purchaseToken", + "affinity": "TEXT" + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a9f7a9e082618e7eaad59d2a86547f67')" + ] + } +} \ No newline at end of file diff --git a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt index cb7ff2e0be..361c1786a6 100644 --- a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt +++ b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt @@ -16,8 +16,7 @@ import androidx.room.TypeConverters CachedPurchase::class, PlayUnlockState::class ], - version = LocalBillingDb.VERSION_3, - exportSchema = false + version = LocalBillingDb.VERSION_3 ) @TypeConverters(PurchaseTypeConverter::class) abstract class LocalBillingDb : RoomDatabase() { From aa83173a60e77ff20cf40cc6e630daa8db72f1dd Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 21 Aug 2025 08:29:20 +0200 Subject: [PATCH 008/127] LocalBillingDb: rename EntitlementsDao to UnlockStateHelper --- .../com/battlelancer/seriesguide/billing/BillingTools.kt | 2 +- .../uwetrottmann/seriesguide/billing/BillingRepository.kt | 8 ++++---- .../seriesguide/billing/localdb/LocalBillingDb.kt | 2 +- .../localdb/{EntitlementsDao.kt => UnlockStateHelper.kt} | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) rename billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/{EntitlementsDao.kt => UnlockStateHelper.kt} (95%) diff --git a/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt b/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt index 9423047867..fe565c4fd9 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt @@ -63,7 +63,7 @@ object BillingTools { } else { // TODO Auto-expire after 1 year if not updated by Play Billing (for ex. when user // plans to switch billing provider after changing installer source) - val playUnlockState = LocalBillingDb.getInstance(context).entitlementsDao() + val playUnlockState = LocalBillingDb.getInstance(context).unlockStateHelper() .getPlayUnlockState() playUnlockState != null && playUnlockState.entitled } diff --git a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingRepository.kt b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingRepository.kt index b97fbcbc39..cfe95c42a1 100644 --- a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingRepository.kt +++ b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingRepository.kt @@ -103,7 +103,7 @@ class BillingRepository private constructor( if (!::localCacheBillingClient.isInitialized) { localCacheBillingClient = LocalBillingDb.getInstance(applicationContext) } - localCacheBillingClient.entitlementsDao().getPlayUnlockStateLiveData() + localCacheBillingClient.unlockStateHelper().getPlayUnlockStateLiveData() } /** Triggered when the entitlement was revoked. Use only with one observer at a time. */ @@ -319,7 +319,7 @@ class BillingRepository private constructor( coroutineScope.launch(Dispatchers.IO) { // Save if existing entitlement is getting revoked. val wasEntitled = - localCacheBillingClient.entitlementsDao().getPlayUnlockState()?.entitled ?: false + localCacheBillingClient.unlockStateHelper().getPlayUnlockState()?.entitled ?: false val unlockState = PlayUnlockState(false, isSub = true, sku = null, purchaseToken = null) insertUnlockState(unlockState) @@ -343,7 +343,7 @@ class BillingRepository private constructor( @WorkerThread private suspend fun insertUnlockState(unlockState: PlayUnlockState) = withContext(Dispatchers.IO) { - localCacheBillingClient.entitlementsDao().insert(unlockState) + localCacheBillingClient.unlockStateHelper().insert(unlockState) } /** @@ -424,7 +424,7 @@ class BillingRepository private constructor( } // Check if this is a subscription up- or downgrade. - val unlockStateOrNull = localCacheBillingClient.entitlementsDao().getPlayUnlockState() + val unlockStateOrNull = localCacheBillingClient.unlockStateHelper().getPlayUnlockState() val oldSubProductId = unlockStateOrNull?.let { if (it.isSub) it.sku else null } val oldPurchaseToken = unlockStateOrNull?.purchaseToken diff --git a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt index 361c1786a6..9b28b6e532 100644 --- a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt +++ b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt @@ -21,7 +21,7 @@ import androidx.room.TypeConverters @TypeConverters(PurchaseTypeConverter::class) abstract class LocalBillingDb : RoomDatabase() { abstract fun purchaseDao(): PurchaseDao - abstract fun entitlementsDao(): EntitlementsDao + abstract fun unlockStateHelper(): UnlockStateHelper companion object { @Volatile diff --git a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/EntitlementsDao.kt b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/UnlockStateHelper.kt similarity index 95% rename from billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/EntitlementsDao.kt rename to billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/UnlockStateHelper.kt index c57ab8d566..30cfd94f60 100644 --- a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/EntitlementsDao.kt +++ b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/UnlockStateHelper.kt @@ -11,7 +11,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query @Dao -interface EntitlementsDao { +interface UnlockStateHelper { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(playUnlockState: PlayUnlockState) From 21d9f91dcc2f37b3140c9df75347c6e53ce71fbe Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 21 Aug 2025 08:52:12 +0200 Subject: [PATCH 009/127] LocalBillingDb: add global unlock state --- .../seriesguide/billing/BillingTools.kt | 49 +++++++- .../seriesguide/billing/BillingToolsTest.kt | 95 +++++++++++++++ .../4.json | 113 ++++++++++++++++++ .../seriesguide/billing/BillingViewModel.kt | 3 + .../billing/localdb/Entitlements.kt | 19 +++ .../billing/localdb/LocalBillingDb.kt | 29 ++++- .../billing/localdb/UnlockStateHelper.kt | 6 + 7 files changed, 305 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/com/battlelancer/seriesguide/billing/BillingToolsTest.kt create mode 100644 billing/schemas/com.uwetrottmann.seriesguide.billing.localdb.LocalBillingDb/4.json diff --git a/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt b/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt index fe565c4fd9..e8c71e2e05 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/billing/BillingTools.kt @@ -13,9 +13,12 @@ import com.battlelancer.seriesguide.billing.amazon.AmazonBillingActivity import com.battlelancer.seriesguide.settings.AdvancedSettings import com.battlelancer.seriesguide.util.PackageTools import com.uwetrottmann.seriesguide.billing.localdb.LocalBillingDb +import com.uwetrottmann.seriesguide.billing.localdb.UnlockState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import org.threeten.bp.Clock +import org.threeten.bp.Instant import timber.log.Timber import java.util.concurrent.CountDownLatch @@ -53,8 +56,10 @@ object BillingTools { } private fun updateUnlockState(context: Context) { + val unlockStateHelper = LocalBillingDb.getInstance(context).unlockStateHelper() + // Debug builds, installed X Pass key or subscription unlock all features - val newUnlockState = if (PackageTools.isAmazonVersion()) { + val isUnlockAll = if (PackageTools.isAmazonVersion()) { // Amazon version only supports all access as in-app purchase, so skip key check AdvancedSettings.getLastSupporterState(context) } else { @@ -63,21 +68,55 @@ object BillingTools { } else { // TODO Auto-expire after 1 year if not updated by Play Billing (for ex. when user // plans to switch billing provider after changing installer source) - val playUnlockState = LocalBillingDb.getInstance(context).unlockStateHelper() - .getPlayUnlockState() + val playUnlockState = unlockStateHelper.getPlayUnlockState() playUnlockState != null && playUnlockState.entitled } } + + val oldUnlockState = unlockStateHelper.getUnlockState() ?: UnlockState() + + unlockStateHelper.insert(getNewUnlockState(Clock.systemUTC(), oldUnlockState, isUnlockAll)) + Timber.i( "updateUnlockState: unlockState=%s, newUnlockState=%s%s", unlockState.value, - newUnlockState, + isUnlockAll, if (BuildConfig.DEBUG) " (debug mode: overridden to true)" else "" ) - unlockState.value = if (BuildConfig.DEBUG) true else newUnlockState + unlockState.value = if (BuildConfig.DEBUG) true else isUnlockAll unlockStateInitialized.countDown() } + fun getNewUnlockState( + clock: Clock, + oldUnlockState: UnlockState, + isUnlockAll: Boolean + ): UnlockState { + // TODO Grace period? But likely notify already and support purchasing? +// val lastUnlockedInstant = Instant.ofEpochMilli(oldUnlockState.lastUnlockedAllMs) +// val aDayAgo = now.minus(24, ChronoUnit.HOURS) +// if (lastUnlockedInstant.isBefore(aDayAgo)) { +// true +// } else { +// false +// } + + // Only change if unlock state changes + val notifyUnlockAllExpired = if (isUnlockAll != oldUnlockState.isUnlockAll) { + !isUnlockAll + } else oldUnlockState.notifyUnlockAllExpired + + return UnlockState( + isUnlockAll = isUnlockAll, + lastUnlockedAllMs = if (isUnlockAll) { + Instant.now(clock).toEpochMilli() + } else { + oldUnlockState.lastUnlockedAllMs + }, + notifyUnlockAllExpired = notifyUnlockAllExpired + ) + } + /** * Notifies that something is only available with a subscription and launches * [AmazonBillingActivity] or [BillingActivity]. diff --git a/app/src/test/java/com/battlelancer/seriesguide/billing/BillingToolsTest.kt b/app/src/test/java/com/battlelancer/seriesguide/billing/BillingToolsTest.kt new file mode 100644 index 0000000000..a9eea01cd3 --- /dev/null +++ b/app/src/test/java/com/battlelancer/seriesguide/billing/BillingToolsTest.kt @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Uwe Trottmann + +package com.battlelancer.seriesguide.billing + +import com.google.common.truth.Truth.assertThat +import com.uwetrottmann.seriesguide.billing.localdb.UnlockState +import org.junit.Test +import org.threeten.bp.Clock +import org.threeten.bp.Instant +import org.threeten.bp.ZoneOffset + +class BillingToolsTest { + + @Test + fun unlockStateChanges() { + val testClock = Clock.fixed(Instant.now(), ZoneOffset.UTC) + + // TODO Test lastUnlockedAllMs and notifyUnlockAllExpired cases if used + + // default -> unlocked + BillingTools.getNewUnlockState(testClock, UnlockState(), isUnlockAll = true) + .also { + assertThat(it.isUnlockAll).isTrue() + assertThat(it.lastUnlockedAllMs).isEqualTo(testClock.millis()) + assertThat(it.notifyUnlockAllExpired).isFalse() + } + + // default -> not unlocked + BillingTools.getNewUnlockState(testClock, UnlockState(), isUnlockAll = false) + .also { + assertThat(it.isUnlockAll).isFalse() + assertThat(it.notifyUnlockAllExpired).isFalse() + } + + // unlocked -> not unlocked: notifies + BillingTools.getNewUnlockState( + testClock, + UnlockState(isUnlockAll = true), + isUnlockAll = false + ).also { + assertThat(it.isUnlockAll).isFalse() + assertThat(it.notifyUnlockAllExpired).isTrue() + } + + // not unlocked -> unlocked: clears notify flag + BillingTools.getNewUnlockState( + testClock, + UnlockState(notifyUnlockAllExpired = true), + isUnlockAll = true + ).also { + assertThat(it.isUnlockAll).isTrue() + assertThat(it.notifyUnlockAllExpired).isFalse() + } + + // notify flag not changed if unlock state remains + // locked + false + BillingTools.getNewUnlockState( + testClock, + UnlockState(), + isUnlockAll = false + ).also { + assertThat(it.isUnlockAll).isFalse() + assertThat(it.notifyUnlockAllExpired).isFalse() + } + // locked + true + BillingTools.getNewUnlockState( + testClock, + UnlockState(notifyUnlockAllExpired = true), + isUnlockAll = false + ).also { + assertThat(it.isUnlockAll).isFalse() + assertThat(it.notifyUnlockAllExpired).isTrue() + } + // unlocked + true + BillingTools.getNewUnlockState( + testClock, + UnlockState(isUnlockAll = true, notifyUnlockAllExpired = true), + isUnlockAll = true + ).also { + assertThat(it.isUnlockAll).isTrue() + assertThat(it.notifyUnlockAllExpired).isTrue() + } + // unlocked + false + BillingTools.getNewUnlockState( + testClock, + UnlockState(isUnlockAll = true), + isUnlockAll = true + ).also { + assertThat(it.isUnlockAll).isTrue() + assertThat(it.notifyUnlockAllExpired).isFalse() + } + } + +} \ No newline at end of file diff --git a/billing/schemas/com.uwetrottmann.seriesguide.billing.localdb.LocalBillingDb/4.json b/billing/schemas/com.uwetrottmann.seriesguide.billing.localdb.LocalBillingDb/4.json new file mode 100644 index 0000000000..14868c06e4 --- /dev/null +++ b/billing/schemas/com.uwetrottmann.seriesguide.billing.localdb.LocalBillingDb/4.json @@ -0,0 +1,113 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "3b23d15ddb8ebd0716f31c4b636372bd", + "entities": [ + { + "tableName": "purchase_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`data` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "gold_status", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`entitled` INTEGER NOT NULL, `isSub` INTEGER NOT NULL, `sku` TEXT, `purchaseToken` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "entitled", + "columnName": "entitled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSub", + "columnName": "isSub", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sku", + "columnName": "sku", + "affinity": "TEXT" + }, + { + "fieldPath": "purchaseToken", + "columnName": "purchaseToken", + "affinity": "TEXT" + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "unlock_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`isUnlockAll` INTEGER NOT NULL, `lastUnlockedAllMs` INTEGER NOT NULL, `notifyUnlockAllExpired` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "isUnlockAll", + "columnName": "isUnlockAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUnlockedAllMs", + "columnName": "lastUnlockedAllMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notifyUnlockAllExpired", + "columnName": "notifyUnlockAllExpired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3b23d15ddb8ebd0716f31c4b636372bd')" + ] + } +} \ No newline at end of file diff --git a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingViewModel.kt b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingViewModel.kt index 5741595bb5..0e650e25c7 100644 --- a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingViewModel.kt +++ b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/BillingViewModel.kt @@ -16,6 +16,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +/** + * Helps fetch current purchases and available products from Play billing provider. + */ class BillingViewModel( application: Application, coroutineScope: CoroutineScope diff --git a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/Entitlements.kt b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/Entitlements.kt index c7b2f987f8..16d7d844de 100644 --- a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/Entitlements.kt +++ b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/Entitlements.kt @@ -17,6 +17,25 @@ abstract class Entitlement { var id: Int = 1 } +@Entity(tableName = "unlock_state") +data class UnlockState( + /** + * Whether all features should be unlocked because there is an X pass install, active + * subscription, one-time purchase from any billing method. + */ + val isUnlockAll: Boolean = false, + /** + * The last time (in milliseconds) all features were unlocked. Use to give a grace period, for + * ex. in case a billing provider is temporarily unavailable. + */ + val lastUnlockedAllMs: Long = 0L, + /** + * If the user should be notified upon app launch that access to all features has expired, for + * ex. when a subscription has expired or X Pass was uninstalled. + */ + val notifyUnlockAllExpired: Boolean = false +) : Entitlement() + /** * Stores unlock state obtained via a subscription or one-time purchase using Play Billing. */ diff --git a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt index 9b28b6e532..8a836d20ef 100644 --- a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt +++ b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/LocalBillingDb.kt @@ -10,13 +10,17 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import timber.log.Timber @Database( entities = [ CachedPurchase::class, - PlayUnlockState::class + PlayUnlockState::class, + UnlockState::class ], - version = LocalBillingDb.VERSION_3 + version = LocalBillingDb.VERSION_4 ) @TypeConverters(PurchaseTypeConverter::class) abstract class LocalBillingDb : RoomDatabase() { @@ -43,6 +47,11 @@ abstract class LocalBillingDb : RoomDatabase() { */ const val VERSION_3 = 3 + /** + * Add table for global unlock state. + */ + const val VERSION_4 = 4 + @JvmStatic fun getInstance(context: Context): LocalBillingDb = INSTANCE ?: synchronized(this) { @@ -53,9 +62,21 @@ abstract class LocalBillingDb : RoomDatabase() { private fun buildDatabase(appContext: Context): LocalBillingDb { return Room.databaseBuilder(appContext, LocalBillingDb::class.java, DATABASE_NAME) - .allowMainThreadQueries() // Gold status detection currently runs on main thread. - .fallbackToDestructiveMigration(dropAllTables = true) // Data is cache, so it is OK to delete + .allowMainThreadQueries() // Only small objects, main thread queries are fine + .fallbackToDestructiveMigrationFrom(dropAllTables = true, VERSION_1, VERSION_2) + .addMigrations(MIGRATION_3_4) .build() } + + @JvmField + val MIGRATION_3_4: Migration = object : + Migration(VERSION_3, VERSION_4) { + override fun migrate(db: SupportSQLiteDatabase) { + Timber.d("Migrating database from $VERSION_3 to $VERSION_4") + + // Create new table, copied from exported schema JSON + db.execSQL("CREATE TABLE IF NOT EXISTS `unlock_state` (`isUnlockAll` INTEGER NOT NULL, `lastUnlockedAllMs` INTEGER NOT NULL, `notifyUnlockAllExpired` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))") + } + } } } diff --git a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/UnlockStateHelper.kt b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/UnlockStateHelper.kt index 30cfd94f60..aca67700ab 100644 --- a/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/UnlockStateHelper.kt +++ b/billing/src/main/java/com/uwetrottmann/seriesguide/billing/localdb/UnlockStateHelper.kt @@ -13,6 +13,12 @@ import androidx.room.Query @Dao interface UnlockStateHelper { + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(unlockState: UnlockState) + + @Query("SELECT * FROM unlock_state LIMIT 1") + fun getUnlockState(): UnlockState? + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(playUnlockState: PlayUnlockState) From d2b22078ee53879fdb26afd029ca8c1a9a48d406 Mon Sep 17 00:00:00 2001 From: Uwe Date: Fri, 22 Aug 2025 07:47:59 +0200 Subject: [PATCH 010/127] Debug view: options to insert PlayUnlockState --- .../preferences/DebugViewFragment.kt | 35 +++++++++++++++++++ .../main/res/layout/fragment_debug_view.xml | 20 +++++++++++ .../seriesguide/billing/BillingRepository.kt | 2 +- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/preferences/DebugViewFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/preferences/DebugViewFragment.kt index e1b36aa7fa..fd6d7969d1 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/preferences/DebugViewFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/preferences/DebugViewFragment.kt @@ -10,6 +10,9 @@ import android.widget.Toast import androidx.appcompat.app.AppCompatDialogFragment import androidx.lifecycle.lifecycleScope import androidx.sqlite.db.SimpleSQLiteQuery +import com.battlelancer.seriesguide.BuildConfig +import com.battlelancer.seriesguide.SgApp +import com.battlelancer.seriesguide.billing.BillingTools import com.battlelancer.seriesguide.databinding.FragmentDebugViewBinding import com.battlelancer.seriesguide.diagnostics.DebugLogActivity import com.battlelancer.seriesguide.notifications.NotificationService @@ -20,8 +23,12 @@ import com.battlelancer.seriesguide.sync.SgSyncAdapter import com.battlelancer.seriesguide.traktapi.TraktCredentials import com.battlelancer.seriesguide.traktapi.TraktOAuthSettings import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.uwetrottmann.seriesguide.billing.BillingRepository +import com.uwetrottmann.seriesguide.billing.localdb.LocalBillingDb +import com.uwetrottmann.seriesguide.billing.localdb.PlayUnlockState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import timber.log.Timber /** * Displays debug actions. Notably allows to display and share logs. @@ -67,11 +74,39 @@ class DebugViewFragment : AppCompatDialogFragment() { toggleDemoMode() } + binding.buttonDebugViewPlayUnlockTrue.setOnClickListener { + insertPlayUnlockState(true) + } + + binding.buttonDebugViewPlayUnlockFalse.setOnClickListener { + insertPlayUnlockState(false) + } + return MaterialAlertDialogBuilder(requireContext()) .setView(binding.getRoot()) .create() } + private fun insertPlayUnlockState(isEntitled: Boolean) { + if (BuildConfig.DEBUG) { + // Coroutine might run after the Context of this is destroyed + val context = requireContext().applicationContext + SgApp.coroutineScope.launch { + Timber.i("insertPlayUnlockState: isEntitled=%s", isEntitled) + LocalBillingDb.getInstance(context).unlockStateHelper() + .insert( + PlayUnlockState( + entitled = isEntitled, + isSub = true, + sku = BillingRepository.SeriesGuideSku.X_SUB_SUPPORTER, + purchaseToken = null + ) + ) + BillingTools.updateUnlockStateAsync(context) + } + } + } + private fun showTestNotification(episodeCount: Int) { lifecycleScope.launch(Dispatchers.IO) { // To use different episodes for one vs. multiple just use OFFSET diff --git a/app/src/main/res/layout/fragment_debug_view.xml b/app/src/main/res/layout/fragment_debug_view.xml index 7b5906c1e0..9798de3cd6 100644 --- a/app/src/main/res/layout/fragment_debug_view.xml +++ b/app/src/main/res/layout/fragment_debug_view.xml @@ -110,6 +110,26 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/buttonDebugViewTraktInvalidateAccessToken" /> +