Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package com.woocommerce.android

import android.app.Activity
import android.app.Application
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
Expand Down Expand Up @@ -299,7 +302,7 @@ class AppInitializer @Inject constructor() : ApplicationLifecycleListener {
}
}

override fun onFirstActivityResumed() {
override fun onFirstActivityResumed(activity: Activity) {
// App is completely restarted
if (networkStatus.isConnected()) {
if (accountStore.hasAccessToken()) {
Expand Down Expand Up @@ -353,8 +356,11 @@ class AppInitializer @Inject constructor() : ApplicationLifecycleListener {
}
}
}
appCoroutineScope.launch {
ageEligibilityChecker.checkAge()
}

override fun onActivityResumed(activity: Activity) {
(activity as? LifecycleOwner)?.lifecycleScope?.launch {
Comment thread
JorgeMucientes marked this conversation as resolved.
ageEligibilityChecker.checkAgeOnStartup(activity)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ object AppPrefs {

IS_USER_AGE_ELIGIBLE_FOR_APP_USE,

USER_AGE_RESTRICTION_REASON,

QR_LOGIN_ROLLOUT_BUCKET,

// Anonymous device id sent in remote feature flag requests to keep rollout bucketing stable
Expand Down Expand Up @@ -374,6 +376,10 @@ object AppPrefs {
get() = getBoolean(key = UndeletablePrefKey.IS_USER_AGE_ELIGIBLE_FOR_APP_USE, default = true)
set(value) = setBoolean(key = UndeletablePrefKey.IS_USER_AGE_ELIGIBLE_FOR_APP_USE, value = value)

var userAgeRestrictionReason: String
get() = getString(key = UndeletablePrefKey.USER_AGE_RESTRICTION_REASON, defaultValue = "")
set(value) = setString(key = UndeletablePrefKey.USER_AGE_RESTRICTION_REASON, value = value)

var isAiAssistantEarlyAccessNoticeDismissed: Boolean
get() = getBoolean(
key = DeletableSitePrefKey.AI_ASSISTANT_EARLY_ACCESS_NOTICE_DISMISSED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ open class AppPrefsWrapper @Inject constructor() {

var isUserAgeEligibleForAppUse by AppPrefs::isUserAgeEligibleForAppUse

var userAgeRestrictionReason by AppPrefs::userAgeRestrictionReason

var isAiAssistantEarlyAccessNoticeDismissed by AppPrefs::isAiAssistantEarlyAccessNoticeDismissed

var hasSeenAnalyticsScheduledImportInfo by AppPrefs::hasSeenAnalyticsScheduledImportInfo
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.woocommerce.android.ui.ageeligibility

import android.app.Activity
import android.os.RemoteException
import androidx.annotation.StringRes
import com.google.android.gms.common.api.ApiException
import com.google.android.play.agesignals.model.AgeSignalsVerificationStatus
import com.woocommerce.android.AppPrefsWrapper
import com.woocommerce.android.R
import com.woocommerce.android.analytics.AnalyticsEvent
Expand All @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -25,107 +26,132 @@ class AgeEligibilityChecker @Inject constructor(
private val prefsWrapper: AppPrefsWrapper,
private val accountRepository: AccountRepository,
private val featureFlagRepository: FeatureFlagRepository,
private val trackerWrapper: AnalyticsTrackerWrapper
private val trackerWrapper: AnalyticsTrackerWrapper,
private val evaluator: AgeEligibilityEvaluator
) {
private val isCheckInProgress = AtomicBoolean(false)
private val isStartupCheckPending = AtomicBoolean(true)
private var persistedRestriction = readPersistedRestriction()

private val _ageEligibilityState = MutableStateFlow(
AgeEligibilityState(
isUserAgeRangeEligible = prefsWrapper.isUserAgeEligibleForAppUse,
decision = persistedRestriction.toDecision(),
ageRestrictedTitle = R.string.age_restriction_dialog_title,
ageRestrictedMessage = R.string.age_restriction_supervised_user_account_dialog_message
ageRestrictedMessage = persistedRestriction.toMessage()
)
)
val ageEligibilityState: StateFlow<AgeEligibilityState> = _ageEligibilityState.asStateFlow()

suspend fun checkAge() {
if (featureFlagRepository.isEnabled(FeatureFlag.AGE_ELIGIBILITY_CHECKS)) {
val trackingProperties = mutableMapOf<String, Any>()
try {
val result = client.checkAge()
val isUserAgeEligible = isUserAgeEligibleForAppUse(result.userStatus, result.ageUpper)

_ageEligibilityState.update {
ageEligibilityState.value.copy(
isUserAgeRangeEligible = isUserAgeEligible,
ageRestrictedMessage = if (isAgeBelowWooCommerceTOSMinimum(result.ageUpper)) {
R.string.age_restriction_user_below_tos_minimum_age_dialog_message
} else {
R.string.age_restriction_supervised_user_account_dialog_message
}
)
}

prefsWrapper.isUserAgeEligibleForAppUse = _ageEligibilityState.value.isUserAgeRangeEligible
trackingProperties["retrieved_age"] = result.ageUpper ?: -1
trackingProperties["user_status"] = getUserStatusAsString(result.userStatus)
} catch (exception: ApiException) {
revertEligibilityToDefault(exception)
} catch (exception: RemoteException) {
// The age signals service is backed by a Play Store binder that can die at any
// time (e.g. Play Store killed or updated); the pending check then fails with a
// plain RemoteException instead of an ApiException
revertEligibilityToDefault(exception)
}

val isAccessRestricted = _ageEligibilityState.value.isUserAgeRangeEligible.not()
trackingProperties["access_restricted"] = isAccessRestricted
trackerWrapper.track(AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED, properties = trackingProperties)

if (isAccessRestricted) {
accountRepository.logout()
}
} else {
_ageEligibilityState.update { _ageEligibilityState.value.copy(isUserAgeRangeEligible = true) }
init {
if (persistedRestriction == AgeRestrictionReason.LEGACY_RESTRICTION_UNKNOWN_REASON) {
prefsWrapper.userAgeRestrictionReason = persistedRestriction?.name.orEmpty()
}
}

private fun revertEligibilityToDefault(exception: Exception) {
WooLog.i(
WooLog.T.UTILS,
"AgeEligibilityChecker ${exception.javaClass.simpleName} while checking user " +
"age: ${exception.message}, reverting user eligibility to default true"
)
_ageEligibilityState.update { _ageEligibilityState.value.copy(isUserAgeRangeEligible = true) }
suspend fun checkAge(activity: Activity, trigger: AgeCheckTrigger = AgeCheckTrigger.STARTUP) {
runAgeCheckIfIdle(activity, trigger)
}

private fun isAgeBelowWooCommerceTOSMinimum(ageUpper: Int?): Boolean =
ageUpper != null && ageUpper < WOOCOMMERCE_TOS_MINIMUM_AGE_FOR_APP_USE

private fun isUserAgeEligibleForAppUse(userStatus: Int?, ageUpper: Int?) = when (userStatus) {
AgeSignalsVerificationStatus.VERIFIED -> true
AgeSignalsVerificationStatus.SUPERVISED,
AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_PENDING -> {
if (ageUpper == null) {
true // If we can't determine the age return true
} else {
ageUpper >= WOOCOMMERCE_TOS_MINIMUM_AGE_FOR_APP_USE
}
suspend fun checkAgeOnStartup(activity: Activity) {
if (isStartupCheckPending.get() && runAgeCheckIfIdle(activity, AgeCheckTrigger.STARTUP)) {
isStartupCheckPending.set(false)
}
}

AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_DENIED -> false
private suspend fun runAgeCheckIfIdle(activity: Activity, trigger: AgeCheckTrigger): Boolean {
if (!isCheckInProgress.compareAndSet(false, true)) {
WooLog.i(WooLog.T.UTILS, "Skipping concurrent age check triggered by ${trigger.name}")
return false
}

AgeSignalsVerificationStatus.UNKNOWN -> true // Safe default: allow access if unknown
else -> true // Handle any other cases as default
try {
checkAgeSingleFlight(activity)
return true
} finally {
isCheckInProgress.set(false)
}
}

private fun getUserStatusAsString(userStatus: Int?): String {
return when (userStatus) {
AgeSignalsVerificationStatus.VERIFIED -> "VERIFIED"
AgeSignalsVerificationStatus.SUPERVISED -> "SUPERVISED"
AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_PENDING -> "SUPERVISED_APPROVAL_PENDING"
AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_DENIED -> "SUPERVISED_APPROVAL_DENIED"
AgeSignalsVerificationStatus.UNKNOWN -> "UNKNOWN"
else -> "UNKNOWN"
private suspend fun checkAgeSingleFlight(activity: Activity) {
if (!featureFlagRepository.isEnabled(FeatureFlag.AGE_ELIGIBILITY_CHECKS)) {
_ageEligibilityState.update { it.copy(decision = AgeEligibilityDecision.Allowed) }
return
}

val trackingProperties = mutableMapOf<String, Any>()
val evaluation = try {
val result = client.checkAge(activity)
trackingProperties["retrieved_age"] = result.ageUpper ?: -1
trackingProperties["user_status"] = result.verificationStatus.name
evaluator.evaluateLegacyResult(result, persistedRestriction)
} catch (exception: ApiException) {
preservePriorRestriction(exception)
} catch (exception: RemoteException) {
preservePriorRestriction(exception)
}

applyEvaluation(evaluation)

val isAccessRestricted = evaluation.decision is AgeEligibilityDecision.Restricted
trackingProperties["access_restricted"] = isAccessRestricted
trackerWrapper.track(AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED, properties = trackingProperties)

if (isAccessRestricted) {
accountRepository.logout()
}
}

private fun applyEvaluation(evaluation: AgeEligibilityEvaluation) {
val restriction = (evaluation.decision as? AgeEligibilityDecision.Restricted)?.reason
_ageEligibilityState.update {
it.copy(
decision = evaluation.decision,
ageRestrictedMessage = restriction.toMessage()
)
}

if (evaluation.isAuthoritative) {
persistedRestriction = restriction
prefsWrapper.userAgeRestrictionReason = restriction?.name.orEmpty()
prefsWrapper.isUserAgeEligibleForAppUse = restriction == null
}
}

private fun preservePriorRestriction(exception: Exception): AgeEligibilityEvaluation {
WooLog.i(
WooLog.T.UTILS,
"AgeEligibilityChecker ${exception.javaClass.simpleName} while checking user age; preserving prior decision"
)
return evaluator.preservePriorRestriction(persistedRestriction)
}

private fun readPersistedRestriction(): AgeRestrictionReason? {
val typedRestriction = AgeRestrictionReason.entries.firstOrNull {
it.name == prefsWrapper.userAgeRestrictionReason
}
return typedRestriction ?: if (prefsWrapper.isUserAgeEligibleForAppUse) {
null
} else {
AgeRestrictionReason.LEGACY_RESTRICTION_UNKNOWN_REASON
}
}

private fun AgeRestrictionReason?.toDecision(): AgeEligibilityDecision =
this?.let(AgeEligibilityDecision::Restricted) ?: AgeEligibilityDecision.Allowed

@StringRes
private fun AgeRestrictionReason?.toMessage(): Int = if (this == AgeRestrictionReason.BELOW_MINIMUM_AGE) {
R.string.age_restriction_user_below_tos_minimum_age_dialog_message
} else {
R.string.age_restriction_supervised_user_account_dialog_message
}

data class AgeEligibilityState(
val isUserAgeRangeEligible: Boolean,
val decision: AgeEligibilityDecision,
@StringRes val ageRestrictedTitle: Int,
@StringRes val ageRestrictedMessage: Int
)

companion object {
private const val WOOCOMMERCE_TOS_MINIMUM_AGE_FOR_APP_USE = 13
) {
val isUserAgeRangeEligible: Boolean
get() = decision is AgeEligibilityDecision.Allowed
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.woocommerce.android.ui.ageeligibility

import javax.inject.Inject

class AgeEligibilityEvaluator @Inject constructor() {
fun evaluateLegacyResult(
result: AgeCheckResult,
priorRestriction: AgeRestrictionReason?
): AgeEligibilityEvaluation = when (result.verificationStatus) {
LegacyAgeVerificationStatus.VERIFIED -> authoritativeAllowed()
LegacyAgeVerificationStatus.SUPERVISED,
LegacyAgeVerificationStatus.SUPERVISED_APPROVAL_PENDING -> evaluateAgeUpper(
ageUpper = result.ageUpper,
priorRestriction = priorRestriction
)

LegacyAgeVerificationStatus.SUPERVISED_APPROVAL_DENIED -> authoritativeRestriction(
AgeRestrictionReason.SUPERVISED_APPROVAL_DENIED
)

LegacyAgeVerificationStatus.UNKNOWN,
LegacyAgeVerificationStatus.UNEXPECTED -> nonAuthoritative(priorRestriction)
}

fun preservePriorRestriction(priorRestriction: AgeRestrictionReason?): AgeEligibilityEvaluation =
nonAuthoritative(priorRestriction)

private fun evaluateAgeUpper(
ageUpper: Int?,
priorRestriction: AgeRestrictionReason?
): AgeEligibilityEvaluation = when {
ageUpper == null -> nonAuthoritative(priorRestriction)
ageUpper < WOOCOMMERCE_TOS_MINIMUM_AGE_FOR_APP_USE -> authoritativeRestriction(
AgeRestrictionReason.BELOW_MINIMUM_AGE
)

else -> authoritativeAllowed()
}

private fun authoritativeAllowed() = AgeEligibilityEvaluation(
decision = AgeEligibilityDecision.Allowed,
isAuthoritative = true
)

private fun authoritativeRestriction(reason: AgeRestrictionReason) = AgeEligibilityEvaluation(
decision = AgeEligibilityDecision.Restricted(reason),
isAuthoritative = true
)

private fun nonAuthoritative(priorRestriction: AgeRestrictionReason?) = AgeEligibilityEvaluation(
decision = priorRestriction?.let(AgeEligibilityDecision::Restricted) ?: AgeEligibilityDecision.Allowed,
isAuthoritative = false
)

companion object {
private const val WOOCOMMERCE_TOS_MINIMUM_AGE_FOR_APP_USE = 13
}
}

sealed interface AgeEligibilityDecision {
data object Allowed : AgeEligibilityDecision

data object VerificationRequired : AgeEligibilityDecision

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Code Review [nit]

Issue: AgeEligibilityDecision.VerificationRequired is declared but never produced by the evaluator or handled anywhere. AgeEligibilityState.isUserAgeRangeEligible returns false for any non-Allowed decision, so if this variant were ever emitted it would restrict access with the generic supervised message and no dedicated handling.

Suggestion: Remove the unused variant, or wire it up (evaluator output + message mapping + UI handling) if it's intended for an upcoming step.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping it. It is temporary dormant scaffolding wired immediately in the next PR


data class Restricted(val reason: AgeRestrictionReason) : AgeEligibilityDecision
}

enum class AgeRestrictionReason {
BELOW_MINIMUM_AGE,
LEGACY_RESTRICTION_UNKNOWN_REASON,
SUPERVISED_APPROVAL_DENIED
}

enum class AgeCheckTrigger {
STARTUP,
MANUAL_RETRY,
RETURN_FROM_PLAY_STORE
}

data class AgeEligibilityEvaluation(
val decision: AgeEligibilityDecision,
val isAuthoritative: Boolean
)
Loading
Loading