From b27c2c503354550abad2d13e1522cbb91a9c7826 Mon Sep 17 00:00:00 2001 From: Uwe Date: Fri, 7 Nov 2025 09:38:51 +0100 Subject: [PATCH 1/2] PackageTools: generalize installing package getter, add to app container --- .../seriesguide/SgAppContainer.kt | 8 +++-- .../seriesguide/util/PackageTools.kt | 29 +++++++++++++++---- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/SgAppContainer.kt b/app/src/main/java/com/battlelancer/seriesguide/SgAppContainer.kt index a6c69de835..0980a05553 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/SgAppContainer.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/SgAppContainer.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// Copyright 2025 Uwe Trottmann +// SPDX-FileCopyrightText: Copyright © 2025 Uwe Trottmann package com.battlelancer.seriesguide @@ -27,12 +27,16 @@ class SgAppContainer(context: Context, coroutineScope: CoroutineScope) { ) } + val installedBy: PackageTools.InstallingPackage by lazy { + PackageTools.getInstallingPackage(context) + } + /** * If true, should not display links to third-party websites that in any way link to a website * that accepts payments. */ val preventExternalLinks by lazy { - val installedByPlay = PackageTools.wasInstalledByPlayStore(context) + val installedByPlay = installedBy == PackageTools.InstalledByPlayStore val region = PackageTools.getDeviceRegion(context) val isEEA = region.isEuropeanEconomicArea(context) val isUS = region.isUnitedStates() 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 bba72f8492..be90aa0a91 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/util/PackageTools.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/util/PackageTools.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// Copyright 2023-2025 Uwe Trottmann +// SPDX-FileCopyrightText: Copyright © 2023 Uwe Trottmann package com.battlelancer.seriesguide.util @@ -21,6 +21,7 @@ object PackageTools { private const val FLAVOR_AMAZON = "amazon" private const val PACKAGE_NAME_PASS = "com.battlelancer.seriesguide.x" private const val SIGNATURE_HASH_PASS = 528716598 + private const val PACKAGE_NAME_AMAZON_STORE = "com.amazon.venezia" private const val PACKAGE_NAME_PLAY_STORE = "com.android.vending" /** @@ -92,14 +93,24 @@ object PackageTools { return false } - fun wasInstalledByPlayStore(context: Context): Boolean { - return getInstallerPackageName(context) == PACKAGE_NAME_PLAY_STORE + sealed interface InstallingPackage { + val packageName: String } - private fun getInstallerPackageName(context: Context): String { + object InstalledByAmazonStore : InstallingPackage { + override val packageName = PACKAGE_NAME_AMAZON_STORE + } + + object InstalledByPlayStore : InstallingPackage { + override val packageName = PACKAGE_NAME_PLAY_STORE + } + + class InstalledByOther(override val packageName: String) : InstallingPackage + + fun getInstallingPackage(context: Context): InstallingPackage { val packageName = context.packageName val packageManager = context.packageManager - return try { + val installingPackage = try { if (AndroidUtils.isAtLeastR) { packageManager.getInstallSourceInfo(packageName).installingPackageName } else { @@ -109,7 +120,13 @@ object PackageTools { } catch (e: Exception) { Timber.e(e, "Failed to get installer package name") "" - }.also { Timber.d("installingPackageName = '%s'", it) } + } + Timber.d("installingPackageName = '%s'", installingPackage) + return when (installingPackage) { + PACKAGE_NAME_AMAZON_STORE -> InstalledByAmazonStore + PACKAGE_NAME_PLAY_STORE -> InstalledByPlayStore + else -> InstalledByOther(installingPackage) + } } @JvmInline From d65a1ad838a36aa85a63ada6ec0a86aee14da074 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 20 Nov 2025 08:07:52 +0100 Subject: [PATCH 2/2] Support required age check when installed by Play or Amazon store --- app/build.gradle.kts | 3 + .../com/battlelancer/seriesguide/SgApp.kt | 5 + .../seriesguide/SgAppContainer.kt | 5 + .../seriesguide/ui/BaseTopActivity.kt | 3 + .../battlelancer/seriesguide/util/AgeCheck.kt | 133 ++++++++++++++++++ .../seriesguide/util/AmazonAgeCheck.kt | 104 ++++++++++++++ app/src/main/res/values/themes.xml | 4 + gradle/libs.versions.toml | 2 + 8 files changed, 259 insertions(+) create mode 100644 app/src/main/java/com/battlelancer/seriesguide/util/AgeCheck.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/util/AmazonAgeCheck.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 735835fa52..527118e13b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -285,6 +285,9 @@ dependencies { // Play Billing implementation(libs.billing) + // Play Age Signals + implementation(libs.play.agesignals) + // Instrumented unit tests androidTestImplementation(libs.androidx.annotation) // Core library diff --git a/app/src/main/java/com/battlelancer/seriesguide/SgApp.kt b/app/src/main/java/com/battlelancer/seriesguide/SgApp.kt index 70ee124dc5..0162b5ce1e 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/SgApp.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/SgApp.kt @@ -166,6 +166,11 @@ class SgApp : Application() { appContainer.billingRepository.startAndConnectToBillingService() } } + + // If necessary, run age checks. BaseTopActivity will check for the result. + coroutineScope.launch { + appContainer.ageCheck.run(appContainer.installedBy) + } } /** diff --git a/app/src/main/java/com/battlelancer/seriesguide/SgAppContainer.kt b/app/src/main/java/com/battlelancer/seriesguide/SgAppContainer.kt index 0980a05553..3cd8d5038b 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/SgAppContainer.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/SgAppContainer.kt @@ -7,6 +7,7 @@ import android.content.Context import com.battlelancer.seriesguide.billing.BillingRepository import com.battlelancer.seriesguide.diagnostics.DebugLogBuffer import com.battlelancer.seriesguide.diagnostics.DebugLogDatabase +import com.battlelancer.seriesguide.util.AgeCheck import com.battlelancer.seriesguide.util.PackageTools import com.battlelancer.seriesguide.util.PackageTools.isEuropeanEconomicArea import com.battlelancer.seriesguide.util.PackageTools.isUnitedStates @@ -54,6 +55,10 @@ class SgAppContainer(context: Context, coroutineScope: CoroutineScope) { // .let { if (BuildConfig.DEBUG) true else it } } + val ageCheck by lazy { + AgeCheck(context) + } + val billingRepository by lazy { BillingRepository(context, coroutineScope) } 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 8708f7d091..56127ad55b 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/ui/BaseTopActivity.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/ui/BaseTopActivity.kt @@ -22,6 +22,7 @@ import com.battlelancer.seriesguide.backend.settings.HexagonSettings import com.battlelancer.seriesguide.billing.BillingTools import com.battlelancer.seriesguide.dataliberation.BackupSettings import com.battlelancer.seriesguide.dataliberation.DataLiberationActivity +import com.battlelancer.seriesguide.getSgAppContainer import com.battlelancer.seriesguide.preferences.MoreOptionsActivity import com.battlelancer.seriesguide.stats.StatsActivity import com.battlelancer.seriesguide.sync.AccountUtils @@ -182,6 +183,8 @@ abstract class BaseTopActivity : BaseMessageActivity() { // manually check whenever returning to a top-level activity. BillingTools.updateUnlockStateAsync(this) + getSgAppContainer().ageCheck.showDialogIfUserDoesNotPassAgeCheck(this) + // Display important notifications, most important first as others will not display if there // already is a notification. if (BillingTools.isNotifyUnlockAllExpired()) { diff --git a/app/src/main/java/com/battlelancer/seriesguide/util/AgeCheck.kt b/app/src/main/java/com/battlelancer/seriesguide/util/AgeCheck.kt new file mode 100644 index 0000000000..658fe64565 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/util/AgeCheck.kt @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Uwe Trottmann + +package com.battlelancer.seriesguide.util + +import android.app.Activity +import android.content.Context +import com.battlelancer.seriesguide.R +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.play.agesignals.AgeSignalsManagerFactory +import com.google.android.play.agesignals.AgeSignalsRequest +import com.google.android.play.agesignals.AgeSignalsResult +import com.google.android.play.agesignals.model.AgeSignalsVerificationStatus +import com.google.android.play.agesignals.testing.FakeAgeSignalsManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.threeten.bp.LocalDate +import org.threeten.bp.ZoneOffset +import timber.log.Timber +import java.util.Date +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * Performs age checks and helps prevent using the app if not approved by a parent when installed by + * stores and used in specific regions. + * + * - https://support.google.com/googleplay/android-developer/answer/16569691 + * - https://developer.amazon.com/docs/app-submission/user-age-verification.html + */ +class AgeCheck(private val context: Context) { + + /** + * Whether the user should be prevented from using the app. + */ + private var preventUsingApp: Boolean = false + + suspend fun run(installedBy: PackageTools.InstallingPackage) { + withContext(Dispatchers.Default) { + when (installedBy) { + PackageTools.InstalledByAmazonStore -> { + Timber.i("Age check using Amazon App Store") + preventUsingApp = AmazonAgeCheck(context).run() + } + + PackageTools.InstalledByPlayStore -> { + Timber.i("Age check using Google Play Store") + preventUsingApp = playAgeCheck() + } + + else -> { + Timber.i("Age check not necessary") + preventUsingApp = false + } + } + } + } + + /** + * Returns whether app access should be prevented. + */ + private suspend fun playAgeCheck(): Boolean = suspendCoroutine { continuation -> + try { + val manager = if (TEST) { + FakeAgeSignalsManager().apply { + // Test SUPERVISED_APPROVAL_DENIED + val fakeSupervisedApprovalDeniedUser = AgeSignalsResult.builder() + .setUserStatus(AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_DENIED) + .setAgeLower(13) + .setAgeUpper(17) + .setMostRecentApprovalDate( + Date( + LocalDate.of(2025, 2, 1) + .atStartOfDay(ZoneOffset.UTC) + .toInstant() + .toEpochMilli() + ) + ) + .setInstallId("fake_install_id") + .build() + setNextAgeSignalsResult(fakeSupervisedApprovalDeniedUser) + // Test network error +// setNextAgeSignalsException( +// AgeSignalsException(AgeSignalsErrorCode.NETWORK_ERROR) +// ) + } + } else { + AgeSignalsManagerFactory.create(context) + } + manager + .checkAgeSignals(AgeSignalsRequest.builder().build()) + .addOnSuccessListener { ageSignalsResult -> + val preventUsingApp = + ageSignalsResult.userStatus() == AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_DENIED + continuation.resume(preventUsingApp) + } + .addOnFailureListener { e -> + Timber.e(e, "Google Play Age check failed") + continuation.resume(false) + } + } catch (e: Exception) { + Timber.e(e, "Google Play Age check error") + continuation.resume(false) + } + } + + fun showDialogIfUserDoesNotPassAgeCheck(activity: Activity) { + if (!preventUsingApp) return + + Timber.i("User does not pass age check, prevent using app") + + // Note: currently not translating as it only affects US states + MaterialAlertDialogBuilder( + activity, + R.style.ThemeOverlay_SeriesGuide_Dialog_PositiveButton_Primary + ) + .setCancelable(false) + .setTitle("Access denied") + .setMessage("Your supervisor has denied access to this app.") + .setPositiveButton("Close app") { dialog, which -> + // While not perfect (when launching into for ex. the movies screen the app will + // create the main activity and show the dialog again) it's a simple, working + // solution. + activity.finishAndRemoveTask() + } + .show() + } + + companion object { + private const val TEST = true + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/util/AmazonAgeCheck.kt b/app/src/main/java/com/battlelancer/seriesguide/util/AmazonAgeCheck.kt new file mode 100644 index 0000000000..17959692ee --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/util/AmazonAgeCheck.kt @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Uwe Trottmann + +package com.battlelancer.seriesguide.util + +import android.content.Context +import android.net.Uri +import android.os.CancellationSignal +import kotlinx.coroutines.withTimeout +import timber.log.Timber + +/** + * Runs an age check against an Amazon App Store content provider. + * + * - https://developer.amazon.com/docs/app-submission/user-age-verification.html#getuseragedata-api + */ +class AmazonAgeCheck( + private val context: Context +) { + + /** + * Returns whether app access should be prevented. + */ + suspend fun run(): Boolean { + val signal = CancellationSignal() + try { + return withTimeout(5000) { + val userData = queryUserDataInternal(signal) + if (userData != null) { + // TODO Is UNKNOWN even the correct state to deny access? Is it possible this + // API doesn't have a state to deny access (unlike the Play API)? + return@withTimeout userData.responseStatus == "SUCCESS" + && userData.userStatus == "UNKNOWN" + } + return@withTimeout false + } + } finally { + signal.cancel() + } + } + + private fun queryUserDataInternal(signal: CancellationSignal): UserData? { + try { + context.contentResolver + .query( + CONTENT_URI, + null, + null, + null, + null, + signal + ) + .use { cursor -> + if (cursor == null || !cursor.moveToFirst()) { + Timber.e("Amazon Age check returned no data") + return null + } + + val ageLowerColumnIndex = + cursor.getColumnIndex(UserAgeDataResponse.COLUMN_AGE_LOWER) + val ageUpperColumnIndex = + cursor.getColumnIndex(UserAgeDataResponse.COLUMN_AGE_UPPER) + + return UserData( + cursor.getString(cursor.getColumnIndexOrThrow(UserAgeDataResponse.COLUMN_RESPONSE_STATUS)), + cursor.getString(cursor.getColumnIndexOrThrow(UserAgeDataResponse.COLUMN_USER_STATUS)), + if (!cursor.isNull(ageLowerColumnIndex)) cursor.getInt(ageLowerColumnIndex) else null, + if (!cursor.isNull(ageUpperColumnIndex)) cursor.getInt(ageUpperColumnIndex) else null, + cursor.getString(cursor.getColumnIndexOrThrow(UserAgeDataResponse.COLUMN_USER_ID)), + cursor.getString(cursor.getColumnIndexOrThrow(UserAgeDataResponse.COLUMN_MOST_RECENT_APPROVAL_DATE)) + ) + } + } catch (e: Exception) { + Timber.e(e, "Amazon Age check failed") + return null + } + } + + companion object { + private const val AUTHORITY: String = "amzn_appstore" + private const val PATH = "/getUserAgeData" + private val CONTENT_URI: Uri = Uri.parse("content://$AUTHORITY$PATH") + } +} + +object UserAgeDataResponse { + const val COLUMN_RESPONSE_STATUS: String = "responseStatus" + const val COLUMN_USER_STATUS: String = "userStatus" + const val COLUMN_USER_ID: String = "userId" + const val COLUMN_MOST_RECENT_APPROVAL_DATE: String = "mostRecentApprovalDate" + const val COLUMN_AGE_LOWER: String = "ageLower" + const val COLUMN_AGE_UPPER: String = "ageUpper" +} + +data class UserData( + val responseStatus: String?, + val userStatus: String?, + val ageLower: Int?, + val ageUpper: Int?, + val userId: String?, + val mostRecentApprovalDate: String? +) + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index f2e6b23170..9d114ebd15 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -215,6 +215,10 @@ @style/Animation.CheckinDialog + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0acd4a3f23..8a8868f44d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -92,6 +92,8 @@ androidx-room-testing = "androidx.room:room-testing:2.8.1" androidutils = "com.uwetrottmann.androidutils:androidutils:4.0.0" photoview = "com.github.chrisbanes:PhotoView:2.3.0" +# https://developer.android.com/google/play/age-signals/release-notes +play-agesignals = "com.google.android.play:age-signals:0.0.1-beta02" # https://developers.google.com/android/guides/releases # 21.0.0 removes Credentials API used by firebase-ui-auth play-services-auth = "com.google.android.gms:play-services-auth:20.7.0"