Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/battlelancer/seriesguide/SgApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

/**
Expand Down
13 changes: 11 additions & 2 deletions app/src/main/java/com/battlelancer/seriesguide/SgAppContainer.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2025 Uwe Trottmann
// SPDX-FileCopyrightText: Copyright © 2025 Uwe Trottmann <[email protected]>

package com.battlelancer.seriesguide

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
Expand All @@ -27,12 +28,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()
Expand All @@ -50,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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) {
Expand Down
133 changes: 133 additions & 0 deletions app/src/main/java/com/battlelancer/seriesguide/util/AgeCheck.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright © 2025 Uwe Trottmann <[email protected]>

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
}

}
104 changes: 104 additions & 0 deletions app/src/main/java/com/battlelancer/seriesguide/util/AmazonAgeCheck.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright © 2025 Uwe Trottmann <[email protected]>

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?
)


Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2023-2025 Uwe Trottmann
// SPDX-FileCopyrightText: Copyright © 2023 Uwe Trottmann <[email protected]>

package com.battlelancer.seriesguide.util

Expand All @@ -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"

/**
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@
<item name="android:windowAnimationStyle">@style/Animation.CheckinDialog</item>
</style>

<style name="ThemeOverlay.SeriesGuide.Dialog.PositiveButton.Primary" parent="ThemeOverlay.Material3.MaterialAlertDialog">
<item name="buttonBarPositiveButtonStyle">@style/Widget.SeriesGuide.Button.Dialog.Primary</item>
</style>

<style name="ThemeOverlay.SeriesGuide.Dialog.PositiveButton.Warn" parent="ThemeOverlay.Material3.MaterialAlertDialog">
<item name="buttonBarPositiveButtonStyle">@style/Widget.SeriesGuide.Button.Dialog.Error</item>
</style>
Expand Down
Loading