diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt index 8b1b040f4519..2247f5c9bb5d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt @@ -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 @@ -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()) { @@ -353,8 +356,11 @@ class AppInitializer @Inject constructor() : ApplicationLifecycleListener { } } } - appCoroutineScope.launch { - ageEligibilityChecker.checkAge() + } + + override fun onActivityResumed(activity: Activity) { + (activity as? LifecycleOwner)?.lifecycleScope?.launch { + ageEligibilityChecker.checkAgeOnStartup(activity) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefs.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefs.kt index c08c211ccecb..6a6040f3ca08 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefs.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefs.kt @@ -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 @@ -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, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefsWrapper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefsWrapper.kt index 809a8fc74c88..e28858cf65ed 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefsWrapper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefsWrapper.kt @@ -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 diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/ageeligibility/AgeEligibilityChecker.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/ageeligibility/AgeEligibilityChecker.kt index 6c5cf7e9c73f..a5bffc888e5d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/ageeligibility/AgeEligibilityChecker.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/ageeligibility/AgeEligibilityChecker.kt @@ -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 @@ -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 @@ -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.asStateFlow() - suspend fun checkAge() { - if (featureFlagRepository.isEnabled(FeatureFlag.AGE_ELIGIBILITY_CHECKS)) { - val trackingProperties = mutableMapOf() - 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() + 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 } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/ageeligibility/AgeEligibilityEvaluator.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/ageeligibility/AgeEligibilityEvaluator.kt new file mode 100644 index 000000000000..ed64defcbae8 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/ageeligibility/AgeEligibilityEvaluator.kt @@ -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 + + 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 +) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/ageeligibility/AgeSignalsClient.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/ageeligibility/AgeSignalsClient.kt index 55a7d8a47716..dc9203b5a1b1 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/ageeligibility/AgeSignalsClient.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/ageeligibility/AgeSignalsClient.kt @@ -1,29 +1,56 @@ package com.woocommerce.android.ui.ageeligibility +import android.app.Activity import android.content.Context import com.google.android.play.agesignals.AgeSignalsManagerFactory import com.google.android.play.agesignals.AgeSignalsRequest +import com.google.android.play.agesignals.model.AgeSignalsVerificationStatus import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.tasks.await import javax.inject.Inject import javax.inject.Singleton interface AgeSignalsClient { - suspend fun checkAge(): AgeCheckResult + suspend fun checkAge(activity: Activity): AgeCheckResult } data class AgeCheckResult( - val userStatus: Int?, + val verificationStatus: LegacyAgeVerificationStatus, val ageUpper: Int? ) +enum class LegacyAgeVerificationStatus { + VERIFIED, + SUPERVISED, + SUPERVISED_APPROVAL_PENDING, + SUPERVISED_APPROVAL_DENIED, + UNKNOWN, + UNEXPECTED +} + @Singleton class GoogleAgeSignalsClient @Inject constructor( @ApplicationContext private val context: Context ) : AgeSignalsClient { - override suspend fun checkAge(): AgeCheckResult { + override suspend fun checkAge(activity: Activity): AgeCheckResult { val manager = AgeSignalsManagerFactory.create(context) val result = manager.checkAgeSignals(AgeSignalsRequest.builder().build()).await() - return AgeCheckResult(result.userStatus(), result.ageUpper()) + return AgeCheckResult( + verificationStatus = result.userStatus().toLegacyAgeVerificationStatus(), + ageUpper = result.ageUpper() + ) + } + + private fun Int?.toLegacyAgeVerificationStatus(): LegacyAgeVerificationStatus = when (this) { + AgeSignalsVerificationStatus.VERIFIED -> LegacyAgeVerificationStatus.VERIFIED + AgeSignalsVerificationStatus.SUPERVISED -> LegacyAgeVerificationStatus.SUPERVISED + AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_PENDING -> + LegacyAgeVerificationStatus.SUPERVISED_APPROVAL_PENDING + + AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_DENIED -> + LegacyAgeVerificationStatus.SUPERVISED_APPROVAL_DENIED + + AgeSignalsVerificationStatus.UNKNOWN -> LegacyAgeVerificationStatus.UNKNOWN + else -> LegacyAgeVerificationStatus.UNEXPECTED } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/ApplicationLifecycleMonitor.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/ApplicationLifecycleMonitor.kt index cf81434decff..8d86479813f8 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/ApplicationLifecycleMonitor.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/ApplicationLifecycleMonitor.kt @@ -11,7 +11,8 @@ class ApplicationLifecycleMonitor( ) : Application.ActivityLifecycleCallbacks, ComponentCallbacks2 { interface ApplicationLifecycleListener { fun onAppComesFromBackground() - fun onFirstActivityResumed() + fun onFirstActivityResumed(activity: Activity) + fun onActivityResumed(activity: Activity) fun onAppGoesToBackground() } @@ -32,8 +33,10 @@ class ApplicationLifecycleMonitor( if (firstActivityResumed) { firstActivityResumed = false - lifecycleListener.onFirstActivityResumed() + lifecycleListener.onFirstActivityResumed(activity) } + + lifecycleListener.onActivityResumed(activity) } override fun onActivityStarted(activity: Activity) {} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/ageeligibility/AgeEligibilityCheckerTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/ageeligibility/AgeEligibilityCheckerTest.kt index eadbea8352e7..16dc20af1e94 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/ageeligibility/AgeEligibilityCheckerTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/ageeligibility/AgeEligibilityCheckerTest.kt @@ -1,8 +1,7 @@ package com.woocommerce.android.ui.ageeligibility +import android.app.Activity import android.os.RemoteException -import com.google.android.play.agesignals.AgeSignalsException -import com.google.android.play.agesignals.model.AgeSignalsVerificationStatus import com.woocommerce.android.AppPrefsWrapper import com.woocommerce.android.analytics.AnalyticsEvent import com.woocommerce.android.analytics.AnalyticsTrackerWrapper @@ -10,46 +9,48 @@ import com.woocommerce.android.ui.login.AccountRepository import com.woocommerce.android.util.FeatureFlag import com.woocommerce.android.util.FeatureFlagRepository import com.woocommerce.android.viewmodel.BaseUnitTest +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi -import org.junit.Assert.assertEquals +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @ExperimentalCoroutinesApi class AgeEligibilityCheckerTest : BaseUnitTest() { - - private lateinit var ageEligibilityChecker: AgeEligibilityChecker + private val activity: Activity = mock() private val client = FakeAgeSignalsClient() private val prefsWrapper: AppPrefsWrapper = mock() private val accountRepository: AccountRepository = mock() private val featureFlagRepository: FeatureFlagRepository = mock() private val trackerWrapper: AnalyticsTrackerWrapper = mock() + private val evaluator = AgeEligibilityEvaluator() @Before - fun setup() = testBlocking { + fun setup() { + whenever(prefsWrapper.userAgeRestrictionReason).thenReturn("") whenever(prefsWrapper.isUserAgeEligibleForAppUse).thenReturn(true) whenever(featureFlagRepository.isEnabled(FeatureFlag.AGE_ELIGIBILITY_CHECKS)).thenReturn(true) - ageEligibilityChecker = AgeEligibilityChecker( - client, - prefsWrapper, - accountRepository, - featureFlagRepository, - trackerWrapper - ) } @Test - fun `given user is verified, when checkAge called, then user is eligible`() = testBlocking { - client.setExpectedValues(AgeSignalsVerificationStatus.VERIFIED, DEFAULT_USER_AGE_UPPER) + fun `given verified result, when age is checked, then access is allowed authoritatively`() = testBlocking { + val checker = createChecker() + client.result = AgeCheckResult(LegacyAgeVerificationStatus.VERIFIED, DEFAULT_USER_AGE_UPPER) - ageEligibilityChecker.checkAge() + checker.checkAge(activity) - assertEquals(true, ageEligibilityChecker.ageEligibilityState.value.isUserAgeRangeEligible) + assertThat(checker.ageEligibilityState.value.decision).isEqualTo(AgeEligibilityDecision.Allowed) + assertThat(client.receivedActivity).isSameAs(activity) + verify(prefsWrapper).userAgeRestrictionReason = "" verify(prefsWrapper).isUserAgeEligibleForAppUse = true - + verify(accountRepository, never()).logout() verify(trackerWrapper).track( AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED, mapOf( @@ -61,218 +62,169 @@ class AgeEligibilityCheckerTest : BaseUnitTest() { } @Test - fun `given user is supervised and age is under 13, when checkAge called, then user is NOT eligible`() = + fun `given supervised user under 13, when age is checked, then restriction is persisted and user logs out`() = testBlocking { - client.setExpectedValues(AgeSignalsVerificationStatus.SUPERVISED, 12) + val checker = createChecker() + client.result = AgeCheckResult(LegacyAgeVerificationStatus.SUPERVISED, 12) - ageEligibilityChecker.checkAge() + checker.checkAge(activity) - assertEquals(false, ageEligibilityChecker.ageEligibilityState.value.isUserAgeRangeEligible) + assertThat(checker.ageEligibilityState.value.decision).isEqualTo( + AgeEligibilityDecision.Restricted(AgeRestrictionReason.BELOW_MINIMUM_AGE) + ) + verify(prefsWrapper).userAgeRestrictionReason = AgeRestrictionReason.BELOW_MINIMUM_AGE.name verify(prefsWrapper).isUserAgeEligibleForAppUse = false verify(accountRepository).logout() - - verify(trackerWrapper).track( - AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED, - mapOf( - "retrieved_age" to 12, - "user_status" to "SUPERVISED", - "access_restricted" to true - ) - ) } @Test - fun `given user is supervised and age is 13 or over, when checkAge called, then user is eligible`() = testBlocking { - client.setExpectedValues(AgeSignalsVerificationStatus.SUPERVISED, 13) + fun `given legacy false preference, when checker is created, then it migrates to an authoritative restriction`() { + whenever(prefsWrapper.isUserAgeEligibleForAppUse).thenReturn(false) - ageEligibilityChecker.checkAge() + val checker = createChecker() - assertEquals(true, ageEligibilityChecker.ageEligibilityState.value.isUserAgeRangeEligible) - verify(prefsWrapper).isUserAgeEligibleForAppUse = true - - verify(trackerWrapper).track( - AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED, - mapOf( - "retrieved_age" to 13, - "user_status" to "SUPERVISED", - "access_restricted" to false - ) + assertThat(checker.ageEligibilityState.value.decision).isEqualTo( + AgeEligibilityDecision.Restricted(AgeRestrictionReason.LEGACY_RESTRICTION_UNKNOWN_REASON) ) + verify(prefsWrapper).userAgeRestrictionReason = AgeRestrictionReason.LEGACY_RESTRICTION_UNKNOWN_REASON.name } @Test - fun `given user is supervised approval pending and age is under 13, when checkAge called, then user is NOT eligible`() = - testBlocking { - client.setExpectedValues(AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_PENDING, 12) - - ageEligibilityChecker.checkAge() - - assertEquals(false, ageEligibilityChecker.ageEligibilityState.value.isUserAgeRangeEligible) - verify(prefsWrapper).isUserAgeEligibleForAppUse = false - verify(accountRepository).logout() - - verify(trackerWrapper).track( - AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED, - mapOf( - "retrieved_age" to 12, - "user_status" to "SUPERVISED_APPROVAL_PENDING", - "access_restricted" to true - ) - ) - } - - @Test - fun `given user is supervised approval pending and age is 13 or over, when checkAge called, then user is eligible`() = + fun `given prior restriction and unknown result, when age is checked, then restriction is retained`() = testBlocking { - client.setExpectedValues(AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_PENDING, 13) - - ageEligibilityChecker.checkAge() + stubPriorRestriction(AgeRestrictionReason.BELOW_MINIMUM_AGE) + val checker = createChecker() + client.result = AgeCheckResult(LegacyAgeVerificationStatus.UNKNOWN, DEFAULT_USER_AGE_UPPER) - assertEquals(true, ageEligibilityChecker.ageEligibilityState.value.isUserAgeRangeEligible) - verify(prefsWrapper).isUserAgeEligibleForAppUse = true + checker.checkAge(activity) - verify(trackerWrapper).track( - AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED, - mapOf( - "retrieved_age" to 13, - "user_status" to "SUPERVISED_APPROVAL_PENDING", - "access_restricted" to false - ) + assertThat(checker.ageEligibilityState.value.decision).isEqualTo( + AgeEligibilityDecision.Restricted(AgeRestrictionReason.BELOW_MINIMUM_AGE) ) + verify(prefsWrapper, never()).isUserAgeEligibleForAppUse = true + verify(accountRepository).logout() } @Test - fun `given user is supervised approval denied, when checkAge called, then user is NOT eligible`() = testBlocking { - client.setExpectedValues(AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_DENIED, DEFAULT_USER_AGE_UPPER) - - ageEligibilityChecker.checkAge() + fun `given prior restriction and SDK failure, when age is checked, then restriction is retained`() = testBlocking { + stubPriorRestriction(AgeRestrictionReason.BELOW_MINIMUM_AGE) + val checker = createChecker() + client.exception = mock() - assertEquals(false, ageEligibilityChecker.ageEligibilityState.value.isUserAgeRangeEligible) - verify(prefsWrapper).isUserAgeEligibleForAppUse = false - verify(accountRepository).logout() + checker.checkAge(activity) - verify(trackerWrapper).track( - AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED, - mapOf( - "retrieved_age" to DEFAULT_USER_AGE_UPPER, - "user_status" to "SUPERVISED_APPROVAL_DENIED", - "access_restricted" to true - ) + assertThat(checker.ageEligibilityState.value.decision).isEqualTo( + AgeEligibilityDecision.Restricted(AgeRestrictionReason.BELOW_MINIMUM_AGE) ) + verify(prefsWrapper, never()).isUserAgeEligibleForAppUse = true + verify(accountRepository).logout() } @Test - fun `given user status is unknown, when checkAge called, then user is eligible`() = testBlocking { - client.setExpectedValues(AgeSignalsVerificationStatus.UNKNOWN, DEFAULT_USER_AGE_UPPER) - - ageEligibilityChecker.checkAge() + fun `given prior restriction and conclusive eligible result, when checked, then restriction is cleared`() = + testBlocking { + stubPriorRestriction(AgeRestrictionReason.LEGACY_RESTRICTION_UNKNOWN_REASON) + val checker = createChecker() + client.result = AgeCheckResult(LegacyAgeVerificationStatus.SUPERVISED, 13) - assertEquals(true, ageEligibilityChecker.ageEligibilityState.value.isUserAgeRangeEligible) - verify(prefsWrapper).isUserAgeEligibleForAppUse = true + checker.checkAge(activity) - verify(trackerWrapper).track( - AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED, - mapOf( - "retrieved_age" to DEFAULT_USER_AGE_UPPER, - "user_status" to "UNKNOWN", - "access_restricted" to false - ) - ) - } + assertThat(checker.ageEligibilityState.value.decision).isEqualTo(AgeEligibilityDecision.Allowed) + verify(prefsWrapper).userAgeRestrictionReason = "" + verify(prefsWrapper).isUserAgeEligibleForAppUse = true + verify(accountRepository, never()).logout() + } @Test - fun `given checkAge throws exception, when checkAge called, then user is eligible`() = testBlocking { - client.setThrowException(AgeSignalsException(-5)) - - ageEligibilityChecker.checkAge() + fun `given feature is disabled, when age is checked, then persisted restriction is bypassed without clearing it`() = + testBlocking { + stubPriorRestriction(AgeRestrictionReason.BELOW_MINIMUM_AGE) + whenever(featureFlagRepository.isEnabled(FeatureFlag.AGE_ELIGIBILITY_CHECKS)).thenReturn(false) + val checker = createChecker() - assertEquals(true, ageEligibilityChecker.ageEligibilityState.value.isUserAgeRangeEligible) + checker.checkAge(activity) - verify(trackerWrapper).track( - AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED, - mapOf( - "access_restricted" to false - ) - ) - } + assertThat(checker.ageEligibilityState.value.decision).isEqualTo(AgeEligibilityDecision.Allowed) + assertThat(client.callCount).isZero() + verify(prefsWrapper, never()).userAgeRestrictionReason = "" + verify(accountRepository, never()).logout() + } @Test - fun `given age signals service binder dies, when checkAge called, then user is eligible`() = testBlocking { - client.setThrowException(mock()) - - ageEligibilityChecker.checkAge() - - assertEquals(true, ageEligibilityChecker.ageEligibilityState.value.isUserAgeRangeEligible) - - verify(trackerWrapper).track( - AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED, - mapOf( - "access_restricted" to false - ) - ) - } + fun `given a check is running, when checks overlap, then concurrent request is skipped until first finishes`() = + testBlocking { + val checker = createChecker() + client.gate = CompletableDeferred() - @Test - fun `given user is supervised and ageUpper is null, when checkAge called, then user is eligible`() = testBlocking { - client.setExpectedValues(AgeSignalsVerificationStatus.SUPERVISED, null) + val firstCheck = launch { checker.checkAge(activity) } + yield() + checker.checkAge(activity, AgeCheckTrigger.MANUAL_RETRY) - ageEligibilityChecker.checkAge() + assertThat(client.callCount).isEqualTo(1) + client.gate?.complete(Unit) + firstCheck.join() - assertEquals(true, ageEligibilityChecker.ageEligibilityState.value.isUserAgeRangeEligible) - verify(prefsWrapper).isUserAgeEligibleForAppUse = true + checker.checkAge(activity, AgeCheckTrigger.MANUAL_RETRY) - verify(trackerWrapper).track( - AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED, - mapOf( - "retrieved_age" to -1, - "user_status" to "SUPERVISED", - "access_restricted" to false - ) - ) - } + assertThat(client.callCount).isEqualTo(2) + } @Test - fun `given user is supervised approval pending and ageUpper is null, when checkAge called, then user is eligible`() = + fun `given startup check is cancelled, when another activity resumes, then startup check is retried`() = testBlocking { - client.setExpectedValues(AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_PENDING, null) - - ageEligibilityChecker.checkAge() - - assertEquals(true, ageEligibilityChecker.ageEligibilityState.value.isUserAgeRangeEligible) - verify(prefsWrapper).isUserAgeEligibleForAppUse = true - - verify(trackerWrapper).track( - AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED, - mapOf( - "retrieved_age" to -1, - "user_status" to "SUPERVISED_APPROVAL_PENDING", - "access_restricted" to false - ) - ) + // GIVEN + val checker = createChecker() + val nextActivity: Activity = mock() + client.gate = CompletableDeferred() + val cancelledCheck = launch { checker.checkAgeOnStartup(activity) } + yield() + + // WHEN + cancelledCheck.cancelAndJoin() + client.gate = null + checker.checkAgeOnStartup(nextActivity) + + // THEN + assertThat(client.callCount).isEqualTo(2) + assertThat(client.receivedActivity).isSameAs(nextActivity) + + // WHEN + checker.checkAgeOnStartup(activity) + + // THEN + assertThat(client.callCount).isEqualTo(2) } - class FakeAgeSignalsClient : AgeSignalsClient { - private var exceptionToThrow: Exception? = null - private var userStatus: Int = DEFAULT_USER_AGE_STATUS - private var ageUpper: Int? = DEFAULT_USER_AGE_UPPER - - fun setExpectedValues(userStatus: Int, ageUpper: Int?) { - this.userStatus = userStatus - this.ageUpper = ageUpper - } - - fun setThrowException(exception: Exception) { - this.exceptionToThrow = exception - } + private fun createChecker() = AgeEligibilityChecker( + client = client, + prefsWrapper = prefsWrapper, + accountRepository = accountRepository, + featureFlagRepository = featureFlagRepository, + trackerWrapper = trackerWrapper, + evaluator = evaluator + ) + + private fun stubPriorRestriction(reason: AgeRestrictionReason) { + whenever(prefsWrapper.userAgeRestrictionReason).thenReturn(reason.name) + } - override suspend fun checkAge(): AgeCheckResult { - exceptionToThrow?.let { throw it } - return AgeCheckResult(userStatus, ageUpper) + private class FakeAgeSignalsClient : AgeSignalsClient { + var result = AgeCheckResult(LegacyAgeVerificationStatus.VERIFIED, DEFAULT_USER_AGE_UPPER) + var exception: Exception? = null + var gate: CompletableDeferred? = null + var callCount = 0 + var receivedActivity: Activity? = null + + override suspend fun checkAge(activity: Activity): AgeCheckResult { + callCount++ + receivedActivity = activity + gate?.await() + exception?.let { throw it } + return result } } companion object { - private const val DEFAULT_USER_AGE_STATUS = AgeSignalsVerificationStatus.VERIFIED private const val DEFAULT_USER_AGE_UPPER = 19 } } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/ageeligibility/AgeEligibilityEvaluatorTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/ageeligibility/AgeEligibilityEvaluatorTest.kt new file mode 100644 index 000000000000..4ea8d92342f3 --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/ageeligibility/AgeEligibilityEvaluatorTest.kt @@ -0,0 +1,102 @@ +package com.woocommerce.android.ui.ageeligibility + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class AgeEligibilityEvaluatorTest { + private val evaluator = AgeEligibilityEvaluator() + + @Test + fun `when status is verified, then result is authoritatively allowed`() { + val evaluation = evaluate(LegacyAgeVerificationStatus.VERIFIED, ageUpper = null) + + assertThat(evaluation).isEqualTo( + AgeEligibilityEvaluation(AgeEligibilityDecision.Allowed, isAuthoritative = true) + ) + } + + @Test + fun `given supervised user under 13, when evaluated, then result is authoritatively restricted`() { + val evaluation = evaluate(LegacyAgeVerificationStatus.SUPERVISED, ageUpper = 12) + + assertThat(evaluation).isEqualTo( + AgeEligibilityEvaluation( + AgeEligibilityDecision.Restricted(AgeRestrictionReason.BELOW_MINIMUM_AGE), + isAuthoritative = true + ) + ) + } + + @Test + fun `given supervised user age 13, when evaluated, then result is authoritatively allowed`() { + val evaluation = evaluate(LegacyAgeVerificationStatus.SUPERVISED, ageUpper = 13) + + assertThat(evaluation).isEqualTo( + AgeEligibilityEvaluation(AgeEligibilityDecision.Allowed, isAuthoritative = true) + ) + } + + @Test + fun `given approval is pending, when evaluated, then legacy age behavior is preserved`() { + val evaluation = evaluate(LegacyAgeVerificationStatus.SUPERVISED_APPROVAL_PENDING, ageUpper = 12) + + assertThat(evaluation.decision).isEqualTo( + AgeEligibilityDecision.Restricted(AgeRestrictionReason.BELOW_MINIMUM_AGE) + ) + } + + @Test + fun `given approval is denied, when evaluated, then result is authoritatively restricted`() { + val evaluation = evaluate(LegacyAgeVerificationStatus.SUPERVISED_APPROVAL_DENIED, ageUpper = 18) + + assertThat(evaluation).isEqualTo( + AgeEligibilityEvaluation( + AgeEligibilityDecision.Restricted(AgeRestrictionReason.SUPERVISED_APPROVAL_DENIED), + isAuthoritative = true + ) + ) + } + + @Test + fun `given missing age, when evaluated, then prior restriction is preserved non-authoritatively`() { + val priorRestriction = AgeRestrictionReason.LEGACY_RESTRICTION_UNKNOWN_REASON + + val evaluation = evaluate( + verificationStatus = LegacyAgeVerificationStatus.SUPERVISED, + ageUpper = null, + priorRestriction = priorRestriction + ) + + assertThat(evaluation).isEqualTo( + AgeEligibilityEvaluation( + AgeEligibilityDecision.Restricted(priorRestriction), + isAuthoritative = false + ) + ) + } + + @Test + fun `given unknown status, when evaluated without prior restriction, then result allows non-authoritatively`() { + val evaluation = evaluate(LegacyAgeVerificationStatus.UNKNOWN, ageUpper = 18) + + assertThat(evaluation).isEqualTo( + AgeEligibilityEvaluation(AgeEligibilityDecision.Allowed, isAuthoritative = false) + ) + } + + @Test + fun `given unexpected status, when evaluated, then result is non-authoritative`() { + val evaluation = evaluate(LegacyAgeVerificationStatus.UNEXPECTED, ageUpper = 18) + + assertThat(evaluation.isAuthoritative).isFalse() + } + + private fun evaluate( + verificationStatus: LegacyAgeVerificationStatus, + ageUpper: Int?, + priorRestriction: AgeRestrictionReason? = null + ) = evaluator.evaluateLegacyResult( + result = AgeCheckResult(verificationStatus, ageUpper), + priorRestriction = priorRestriction + ) +} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/main/MainActivityViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/main/MainActivityViewModelTest.kt index ffeb7c168d4b..b1e7ef1ca237 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/main/MainActivityViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/main/MainActivityViewModelTest.kt @@ -17,6 +17,7 @@ import com.woocommerce.android.notifications.push.NotificationMessageHandler import com.woocommerce.android.notifications.push.NotificationTestUtils import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.ageeligibility.AgeEligibilityChecker +import com.woocommerce.android.ui.ageeligibility.AgeEligibilityDecision import com.woocommerce.android.ui.main.MainActivityViewModel.MoreMenuBadgeState.Hidden import com.woocommerce.android.ui.main.MainActivityViewModel.MoreMenuBadgeState.UnseenReviews import com.woocommerce.android.ui.main.MainActivityViewModel.RestartActivityForPushNotification @@ -174,7 +175,7 @@ class MainActivityViewModelTest : BaseUnitTest() { private val ageEligibilityChecker: AgeEligibilityChecker = mock { on { ageEligibilityState } doReturn MutableStateFlow( AgeEligibilityChecker.AgeEligibilityState( - isUserAgeRangeEligible = true, + decision = AgeEligibilityDecision.Allowed, ageRestrictedTitle = 0, ageRestrictedMessage = 0 )