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
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

25.4
-----
- [*****] [Internal] Migrated Google Play Age Signals to SDK 0.0.4 with recoverable verification and privacy-bounded monitoring [https://github.com/woocommerce/woocommerce-android/pull/16377]
- [*] Fixed bundled variable products not asking for a variation when configuring a bundle during order creation
- [Internal] Improved recovery when the selected store is no longer available through WordPress.com, including when its Jetpack connection has been removed. [https://github.com/woocommerce/woocommerce-android/pull/16327]
- [Internal] Attach a Mobile Status Report (device, OS, connectivity, notifications, feature flags) to support tickets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,14 @@ open class AppPrefsWrapper @Inject constructor() {

var isWooPosSurveyNotificationCurrentUserShown by AppPrefs::isWooPosSurveyNotificationCurrentUserShown

var isUserAgeEligibleForAppUse by AppPrefs::isUserAgeEligibleForAppUse
val isUserAgeEligibleForAppUse by AppPrefs::isUserAgeEligibleForAppUse

var userAgeRestrictionReason by AppPrefs::userAgeRestrictionReason

fun clearLegacyAgeRestriction() {
AppPrefs.isUserAgeEligibleForAppUse = true
}

var isAiAssistantEarlyAccessNoticeDismissed by AppPrefs::isAiAssistantEarlyAccessNoticeDismissed

var hasSeenAnalyticsScheduledImportInfo by AppPrefs::hasSeenAnalyticsScheduledImportInfo
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1172,7 +1172,8 @@ enum class AnalyticsEvent(override val siteless: Boolean = false) : IAnalyticsEv

// Age restriction check
ACCOUNT_AGE_RESTRICTION_CHECKED,
ACCOUNT_AGE_RESTRICTION_DIALOG_SHOWN;
ACCOUNT_AGE_RESTRICTION_DIALOG_SHOWN,
ACCOUNT_AGE_VERIFICATION_ACTION;

override val isPosEvent: Boolean = false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,17 @@ class AnalyticsTracker private constructor(
const val KEY_ERROR_DESC = "error_description"
const val KEY_ERROR_TYPE = "error_type"
const val KEY_ERROR_CODE = "error_code"
const val KEY_AGE_SIGNALS_REQUEST_STAGE = "request_stage"
const val KEY_AGE_SIGNALS_ACCESS_STATUS = "access_status"
const val KEY_AGE_SIGNALS_RANGE_OUTCOME = "age_range_outcome"
const val KEY_AGE_SIGNALS_SIGNIFICANT_CHANGE_STATUS = "significant_change_status"
const val KEY_AGE_SIGNALS_FINAL_DECISION = "final_decision"
const val KEY_AGE_SIGNALS_RESTRICTION_REASON = "restriction_reason"
const val KEY_AGE_SIGNALS_SDK_ERROR_CODE = "sdk_error_code"
const val KEY_AGE_SIGNALS_RETRY_COUNT = "retry_count"
const val KEY_AGE_SIGNALS_IS_RECOVERY = "is_recovery"
const val KEY_AGE_SIGNALS_ACCESS_RESTRICTED = "access_restricted"
const val KEY_AGE_VERIFICATION_ACTION = "action"
const val KEY_NETWORK_STATUS_CODE = "network_status_code"
const val KEY_FROM = "from"
const val KEY_HAS_UNFULFILLED_ORDERS = "has_unfulfilled_orders"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import android.app.Activity
import androidx.annotation.StringRes
import com.woocommerce.android.AppPrefsWrapper
import com.woocommerce.android.R
import com.woocommerce.android.analytics.AnalyticsEvent
import com.woocommerce.android.analytics.AnalyticsTrackerWrapper
import com.woocommerce.android.ui.login.AccountRepository
import com.woocommerce.android.util.FeatureFlag
import com.woocommerce.android.util.FeatureFlagRepository
Expand All @@ -25,7 +23,7 @@ class AgeEligibilityChecker @Inject constructor(
private val prefsWrapper: AppPrefsWrapper,
private val accountRepository: AccountRepository,
private val featureFlagRepository: FeatureFlagRepository,
private val trackerWrapper: AnalyticsTrackerWrapper,
private val analyticsTracker: AgeSignalsAnalyticsTracker,
private val evaluator: AgeEligibilityEvaluator
) {
private val isCheckInProgress = AtomicBoolean(false)
Expand Down Expand Up @@ -60,6 +58,7 @@ class AgeEligibilityChecker @Inject constructor(

fun onPlayStoreOpenedForVerification() {
retryAfterPlayStore.set(true)
analyticsTracker.trackPlayStoreOpened()
}

suspend fun retryAfterReturningFromPlayStore(activity: Activity) {
Expand All @@ -86,7 +85,8 @@ class AgeEligibilityChecker @Inject constructor(

try {
onStarted()
checkAgeSingleFlight(activity)
analyticsTracker.trackVerificationAction(trigger)
checkAgeSingleFlight(activity, trigger)
return true
} catch (exception: CancellationException) {
onCancelled()
Expand All @@ -96,28 +96,34 @@ class AgeEligibilityChecker @Inject constructor(
}
}

private suspend fun checkAgeSingleFlight(activity: Activity) {
private suspend fun checkAgeSingleFlight(activity: Activity, trigger: AgeCheckTrigger) {
if (!featureFlagRepository.isEnabled(FeatureFlag.AGE_ELIGIBILITY_CHECKS)) {
_ageEligibilityState.update { it.copy(decision = AgeEligibilityDecision.Allowed) }
return
}

val evaluation = try {
val outcome = try {
val result = client.requestAgeSignals(activity)
evaluator.evaluate(result, persistedRestriction)
AgeCheckOutcome(
evaluation = evaluator.evaluate(result, persistedRestriction),
result = result
)
} catch (exception: AgeSignalsRequestFailure) {
preservePriorRestriction(exception)
AgeCheckOutcome(
evaluation = preservePriorRestriction(exception),
failure = exception
)
}

applyEvaluation(evaluation)

val isAccessRestricted = evaluation.decision is AgeEligibilityDecision.Restricted
trackerWrapper.track(
AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED,
properties = mapOf("access_restricted" to isAccessRestricted)
applyEvaluation(outcome.evaluation)
analyticsTracker.trackCheck(
result = outcome.result,
failure = outcome.failure,
evaluation = outcome.evaluation,
trigger = trigger
)

if (isAccessRestricted) {
if (outcome.evaluation.decision is AgeEligibilityDecision.Restricted) {
accountRepository.logout()
}
}
Expand All @@ -134,7 +140,9 @@ class AgeEligibilityChecker @Inject constructor(
if (evaluation.isAuthoritative) {
persistedRestriction = restriction
prefsWrapper.userAgeRestrictionReason = restriction?.name.orEmpty()
prefsWrapper.isUserAgeEligibleForAppUse = restriction == null
if (restriction == null) {
prefsWrapper.clearLegacyAgeRestriction()
}
}
}

Expand Down Expand Up @@ -184,4 +192,10 @@ class AgeEligibilityChecker @Inject constructor(
val isUserAgeRangeEligible: Boolean
get() = decision is AgeEligibilityDecision.Allowed
}

private data class AgeCheckOutcome(
val evaluation: AgeEligibilityEvaluation,
val result: AgeSignalsRequestResult? = null,
val failure: AgeSignalsRequestFailure? = null
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,37 +25,77 @@ class AgeEligibilityEvaluator @Inject constructor() {
signals: SharedAgeSignals?,
priorRestriction: AgeRestrictionReason?
): AgeEligibilityEvaluation {
val ageUpper = signals?.ageUpper
val ageRangeOutcome = signals.toAgeRangeOutcome()
return when (ageRangeOutcome) {
AgeRangeOutcome.BELOW_13 -> authoritativeRestriction(
reason = AgeRestrictionReason.BELOW_MINIMUM_AGE,
ageRangeOutcome = ageRangeOutcome
)

AgeRangeOutcome.AMBIGUOUS -> nonAuthoritative(priorRestriction, ageRangeOutcome)
AgeRangeOutcome.AGE_13_15,
AgeRangeOutcome.AGE_16_17,
AgeRangeOutcome.AGE_18_PLUS,
AgeRangeOutcome.ELIGIBLE -> authoritativeAllowed(ageRangeOutcome)
}
}

private fun SharedAgeSignals?.toAgeRangeOutcome(): AgeRangeOutcome {
val ageUpper = this?.ageUpper
if (ageUpper != null && ageUpper < WOOCOMMERCE_TOS_MINIMUM_AGE_FOR_APP_USE) {
return authoritativeRestriction(AgeRestrictionReason.BELOW_MINIMUM_AGE)
return AgeRangeOutcome.BELOW_13
}

val ageLower = signals?.ageLower ?: return nonAuthoritative(priorRestriction)
if (ageUpper != null && ageLower > ageUpper) return nonAuthoritative(priorRestriction)
val ageLower = this?.ageLower ?: return AgeRangeOutcome.AMBIGUOUS
if (ageUpper != null && ageLower > ageUpper) return AgeRangeOutcome.AMBIGUOUS

return when {
ageLower >= WOOCOMMERCE_TOS_MINIMUM_AGE_FOR_APP_USE -> authoritativeAllowed()
else -> nonAuthoritative(priorRestriction)
ageLower >= MINIMUM_ADULT_AGE -> AgeRangeOutcome.AGE_18_PLUS
ageLower.isWithin(ageUpper, OLDER_TEEN_MINIMUM, OLDER_TEEN_MAXIMUM) -> AgeRangeOutcome.AGE_16_17
ageLower.isWithin(
ageUpper,
WOOCOMMERCE_TOS_MINIMUM_AGE_FOR_APP_USE,
YOUNGER_TEEN_MAXIMUM
) -> AgeRangeOutcome.AGE_13_15

ageLower >= WOOCOMMERCE_TOS_MINIMUM_AGE_FOR_APP_USE -> AgeRangeOutcome.ELIGIBLE
else -> AgeRangeOutcome.AMBIGUOUS
}
}

private fun authoritativeAllowed() = AgeEligibilityEvaluation(
private fun Int.isWithin(upper: Int?, minimum: Int, maximum: Int) =
this >= minimum && upper != null && upper <= maximum

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

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

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

companion object {
private const val WOOCOMMERCE_TOS_MINIMUM_AGE_FOR_APP_USE = 13
private const val YOUNGER_TEEN_MAXIMUM = 15
private const val OLDER_TEEN_MINIMUM = 16
private const val OLDER_TEEN_MAXIMUM = 17
private const val MINIMUM_ADULT_AGE = 18
}
}

Expand All @@ -69,8 +109,7 @@ sealed interface AgeEligibilityDecision {

enum class AgeRestrictionReason {
BELOW_MINIMUM_AGE,
LEGACY_RESTRICTION_UNKNOWN_REASON,
SUPERVISED_APPROVAL_DENIED
LEGACY_RESTRICTION_UNKNOWN_REASON
}

enum class AgeCheckTrigger {
Expand All @@ -81,5 +120,15 @@ enum class AgeCheckTrigger {

data class AgeEligibilityEvaluation(
val decision: AgeEligibilityDecision,
val isAuthoritative: Boolean
val isAuthoritative: Boolean,
val ageRangeOutcome: AgeRangeOutcome? = null
)

enum class AgeRangeOutcome {
BELOW_13,
AGE_13_15,
AGE_16_17,
AGE_18_PLUS,
ELIGIBLE,
AMBIGUOUS
}
Loading
Loading