Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
08f0323
Add the foundation to check user age upon launching the app
JorgeMucientes Dec 8, 2025
6a60107
Add missing dependency and logic to fetch ageSignalsResult
JorgeMucientes Dec 9, 2025
f680250
Moved dependency to library catalog
JorgeMucientes Dec 10, 2025
4d566b1
Add unit tests
JorgeMucientes Dec 10, 2025
cc805e4
Add logic to check if user age range is eligible to use the app
JorgeMucientes Dec 11, 2025
ffbb702
Update Age Signal library to 0.0.2 version
JorgeMucientes Dec 11, 2025
49c3021
Convert viewmodel into a regular collaborator
JorgeMucientes Dec 11, 2025
a190803
Update age restriction dialog texts
JorgeMucientes Dec 11, 2025
c821da4
Ensure age is checked when app is initialized
JorgeMucientes Dec 11, 2025
47cb999
Remove showing age restriction dialog logic from AppInitializer
JorgeMucientes Dec 11, 2025
61c5e09
Log user out when non eligible due to age restrictions
JorgeMucientes Dec 11, 2025
e4c033b
Adds unit tests for all the different userStates the age API returns
JorgeMucientes Dec 11, 2025
5b3a3f7
Fix detekt issues
JorgeMucientes Dec 11, 2025
805fb39
Make exception catching more specific
JorgeMucientes Dec 11, 2025
6fd2512
If the user status age becomes unavailable, reset prefs to default
JorgeMucientes Dec 11, 2025
c881c76
If the user status age becomes unavailable, reset prefs to default
JorgeMucientes Dec 11, 2025
02abd20
Fix MainActivityViewModelTest failing tests
JorgeMucientes Dec 12, 2025
df33859
Handle ageUpper null values for SUPERVISED users
JorgeMucientes Dec 12, 2025
a2b9a02
Merge branch 'trunk' into issue/woomob-1859-declared-age-range-apis-f…
JorgeMucientes Dec 12, 2025
904429e
Rename and rearrange google age signal dep
JorgeMucientes Dec 18, 2025
fdf7ad7
Fix wrong formatting
JorgeMucientes Dec 18, 2025
6b052c2
Remove duplicated cancelable settings
JorgeMucientes Dec 18, 2025
645e704
Improve the copy used in the restricted access dialog
JorgeMucientes Dec 18, 2025
eddcc4a
Remove duplicated test and add missing one
JorgeMucientes Dec 18, 2025
c6f2983
Revert accidentally commited change
JorgeMucientes Dec 18, 2025
8d765d9
Add missing backslash to string value
JorgeMucientes Dec 18, 2025
f71eeb7
Add tracking far age checks
JorgeMucientes Dec 19, 2025
66ca6a6
Make age eligibility check reactive
JorgeMucientes Dec 19, 2025
e3520e0
Fix unit tests compile issues
JorgeMucientes Dec 19, 2025
1600031
Make unit tests verify tracking is correct for each case
JorgeMucientes Dec 19, 2025
4476fa4
Remove extra spacing
JorgeMucientes Dec 19, 2025
1e0843f
Track when age restriction dialog is shown
JorgeMucientes Dec 19, 2025
31dfd3c
Rename the age signal API implementation client
JorgeMucientes Dec 19, 2025
2715066
Remove unnecessary field declaration
JorgeMucientes Dec 23, 2025
d5e76dc
Add the correct tracking value
JorgeMucientes Dec 23, 2025
3788e0d
Display a different error message
JorgeMucientes Dec 24, 2025
986f3b4
Fix unit tests after refactor of AgeEligibilityChecker
JorgeMucientes Dec 24, 2025
fd93c49
Merge branch 'trunk' into issue/woomob-1859-declared-age-range-apis-f…
JorgeMucientes Jan 26, 2026
b9f9b18
Adds a feature flag for age eligiblity checks
JorgeMucientes Jan 26, 2026
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 WooCommerce/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ dependencies {
implementation(libs.google.firebase.analytics)

implementation(libs.google.play.services.auth)
implementation libs.google.play.ageSignals

// Support library
implementation(libs.androidx.core.ktx)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import com.woocommerce.android.tools.SiteConnectionType.ApplicationPasswords
import com.woocommerce.android.tools.connectionType
import com.woocommerce.android.tracker.SendTelemetry
import com.woocommerce.android.tracker.TrackStoreSnapshot
import com.woocommerce.android.ui.ageeligibility.AgeEligibilityChecker
import com.woocommerce.android.ui.appwidgets.getWidgetName
import com.woocommerce.android.ui.blaze.notification.BlazeCampaignsObserver
import com.woocommerce.android.ui.common.UserEligibilityFetcher
Expand Down Expand Up @@ -149,6 +150,8 @@ class AppInitializer @Inject constructor() : ApplicationLifecycleListener {

@Inject lateinit var getWooVersion: GetWooCorePluginCachedVersion

@Inject lateinit var ageEligibilityChecker: AgeEligibilityChecker

@Inject
@AppCoroutineScope
lateinit var appCoroutineScope: CoroutineScope
Expand Down Expand Up @@ -335,6 +338,9 @@ class AppInitializer @Inject constructor() : ApplicationLifecycleListener {
}
}
}
appCoroutineScope.launch {
ageEligibilityChecker.checkAge()
}
Comment on lines +341 to +343

This comment was marked as outdated.

}

override fun onAppGoesToBackground() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ object AppPrefs {

WOO_POS_SURVEY_NOTIFICATION_CURRENT_USER_SHOWN,

WOO_POS_SURVEY_NOTIFICATION_POTENTIAL_USER_SHOWN
WOO_POS_SURVEY_NOTIFICATION_POTENTIAL_USER_SHOWN,

IS_USER_AGE_ELIGIBLE_FOR_APP_USE
}

fun init(context: Context) {
Expand Down Expand Up @@ -323,6 +325,10 @@ object AppPrefs {
get() = getBoolean(UndeletablePrefKey.WOO_POS_SURVEY_NOTIFICATION_POTENTIAL_USER_SHOWN, false)
set(value) = setBoolean(UndeletablePrefKey.WOO_POS_SURVEY_NOTIFICATION_POTENTIAL_USER_SHOWN, value)

var isUserAgeEligibleForAppUse: Boolean
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)

fun getProductSortingChoice(currentSiteId: Int) = getString(getProductSortingKey(currentSiteId)).orNullIfEmpty()

fun setProductSortingChoice(currentSiteId: Int, value: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ open class AppPrefsWrapper @Inject constructor() {

var isWooPosSurveyNotificationCurrentUserShown by AppPrefs::isWooPosSurveyNotificationCurrentUserShown

var isUserAgeEligibleForAppUse by AppPrefs::isUserAgeEligibleForAppUse

open var orderSummaryMigrated by AppPrefs::orderSummaryMigrated
open var gatewayMigrated by AppPrefs::gatewayMigrated

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1118,7 +1118,10 @@ enum class AnalyticsEvent(override val siteless: Boolean = false) : IAnalyticsEv
ORDER_VIEW_CUSTOM_FIELDS_TAPPED,

// Black-flagged sites
BLACK_FLAGGED_WEBSITE_DETECTED;
BLACK_FLAGGED_WEBSITE_DETECTED,

// Age restriction check
ACCOUNT_AGE_RESTRICTION_CHECKED;

override val isPosEvent: Boolean = false
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.woocommerce.android.ui.ageeligibility

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.analytics.AnalyticsEvent
import com.woocommerce.android.analytics.AnalyticsTrackerWrapper
import com.woocommerce.android.ui.login.AccountRepository
import com.woocommerce.android.util.WooLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
Copy link
Contributor

Choose a reason for hiding this comment

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

❓ Is the singleton annotation needed here? From what I understand we inject the checker just once in the initializer. If we declare it as singleton, it'll never be garbage collected. It's a minor memory optimization, but unless we need it I'd personally replace it with @Reusable.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point @malinajirka. However, AgeEligibilityChecker.kt is injected in several places were we subscribe to its state like LoginActivity.kt or MainActivityViewModel.kt. Removing the @Singleton would mean I'll need to save and expose the state in an observable DataStore. This is not a big deal but I believe it's overkill for this feature.

It's a minor memory optimization, but unless we need it I'd personally replace it with @reusable.

Curious about that. Given AgeEligibilityChecker is injected in AppInitializer class which afaik will survive as long as the app is not killed, setting AgeEligibilityChecker as a singleton will prevent the app from creating multiple instances of the same class, were as @Resuable wouldn't guarantee that. So technically, in terms of memory usage, @reusable could be worse?

Copy link
Contributor

Choose a reason for hiding this comment

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

Curious about that. Given AgeEligibilityChecker is injected in AppInitializer class which afaik will survive as long as the app is not killed

Good point, I didn't realize we hold onto the instance in AppInitializer. So it doesn't really matter which one we use.

setting AgeEligibilityChecker as a singleton will prevent the app from creating multiple instances of the same class, were as @Resuable wouldn't guarantee that.

Reusable annotation also guarantees maximum one instance at a time, but it can be garbage collected and re-created when needed later. However, as you correctly pointed out, we reference it so we never let the GC to collect it anyway.

class AgeEligibilityChecker @Inject constructor(
private val client: AgeSignalsClient,
private val prefsWrapper: AppPrefsWrapper,
private val accountRepository: AccountRepository,
private val trackerWrapper: AnalyticsTrackerWrapper
) {

private val _isUserAgeRangeEligible = MutableStateFlow(prefsWrapper.isUserAgeEligibleForAppUse)
val isUserAgeRangeEligible: StateFlow<Boolean> = _isUserAgeRangeEligible.asStateFlow()

suspend fun checkAge() {
val trackingProperties = mutableMapOf<String, Any>()
try {
val result = client.checkAge()
processAgeCheck(result.userStatus, result.ageUpper)
trackingProperties["retrieved_age"] = result.ageUpper ?: -1
trackingProperties["user_status"] = getUserStatusAsString(result.userStatus)
} catch (exception: ApiException) {
WooLog.i(
WooLog.T.UTILS,
"AgeEligibilityChecker ${exception.javaClass.simpleName} while checking user " +
"age: ${exception.message}, reverting user eligibility to true"
)
prefsWrapper.isUserAgeEligibleForAppUse = true
_isUserAgeRangeEligible.value = true
}
if (isUserAgeRangeEligible.value.not()) {
accountRepository.logout()
}
trackingProperties["access_restricted"] = _isUserAgeRangeEligible.value
trackerWrapper.track(AnalyticsEvent.ACCOUNT_AGE_RESTRICTION_CHECKED, properties = trackingProperties)
}

private fun processAgeCheck(userStatus: Int?, ageUpper: Int?) {
val isUserAgeEligible = 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
}
}

AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_DENIED -> false

AgeSignalsVerificationStatus.UNKNOWN -> true // Safe default: allow access if unknown
else -> true // Handle any other cases as default
}

prefsWrapper.isUserAgeEligibleForAppUse = isUserAgeEligible
_isUserAgeRangeEligible.value = isUserAgeEligible
}

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"
}
}

companion object {
private const val WOOCOMMERCE_TOS_MINIMUM_AGE_FOR_APP_USE = 13
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.woocommerce.android.ui.ageeligibility

import android.content.Context
import com.google.android.play.agesignals.AgeSignalsManagerFactory
import com.google.android.play.agesignals.AgeSignalsRequest
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
}

data class AgeCheckResult(
val userStatus: Int?,
val ageUpper: Int?
)

@Singleton
class DefaultAgeSignalsClient @Inject constructor(
@ApplicationContext private val context: Context
) : AgeSignalsClient {
override suspend fun checkAge(): AgeCheckResult {
val manager = AgeSignalsManagerFactory.create(context)
val result = manager.checkAgeSignals(AgeSignalsRequest.builder().build()).await()
return AgeCheckResult(result.userStatus(), result.ageUpper())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.woocommerce.android.ui.ageeligibility

import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class AgeSignalsModule {
@Binds
@Singleton
abstract fun bindAgeSignalsClient(impl: DefaultAgeSignalsClient): AgeSignalsClient
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import android.view.MenuItem
import androidx.activity.OnBackPressedCallback
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
Expand Down Expand Up @@ -206,6 +207,9 @@ class LoginActivity :
unifiedLoginTracker.setFlow(ss.getString(KEY_UNIFIED_TRACKER_FLOW))
connectSiteInfo = ss.parcelable(KEY_CONNECT_SITE_INFO)
}
if (appPrefsWrapper.isUserAgeEligibleForAppUse.not()) {
showAgeRestrictionDialog()
}
}

private fun applyDefaultWindowInsets() {
Expand Down Expand Up @@ -1043,6 +1047,16 @@ class LoginActivity :
}
}

private fun showAgeRestrictionDialog() {
val dialog = AlertDialog.Builder(this)
.setTitle(R.string.age_restriction_dialog_title)
.setMessage(R.string.age_restriction_dialog_message)
.setCancelable(false)
.setPositiveButton(android.R.string.ok) { _, _ -> finishAffinity() }
.create()
dialog.show()
}

/**
* Show a DialogFragment using the current Fragment's childFragmentManager.
* This is useful to make sure the dialog's lifecycle is linked to the Fragment that invokes it and that it would
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,7 @@ class MainActivity :
observeMoreMenuBadgeStateEvent()
observeTrialStatus()
observeBottomBarState()
observeUserAgeEligibilityState()
}

private fun showBlazeCampaignList(campaignId: String?) {
Expand Down Expand Up @@ -966,6 +967,14 @@ class MainActivity :
}
}

private fun observeUserAgeEligibilityState() {
viewModel.isUserAgeRangeEligible.observe(this) { isUserAgeEligible ->
if (isUserAgeEligible.not()) {
showLoginScreen()
}
}
}

private fun requestNotificationsPermission() {
if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
WooPermissionUtils.requestNotificationsPermission(launcher)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.woocommerce.android.notifications.push.NotificationMessageHandler
import com.woocommerce.android.tools.SelectedSite
import com.woocommerce.android.tools.SiteConnectionType.Jetpack
import com.woocommerce.android.tools.connectionType
import com.woocommerce.android.ui.ageeligibility.AgeEligibilityChecker
import com.woocommerce.android.ui.feedback.SurveyType
import com.woocommerce.android.ui.main.MainActivityViewModel.MoreMenuBadgeState.Hidden
import com.woocommerce.android.ui.main.MainActivityViewModel.MoreMenuBadgeState.NewFeature
Expand Down Expand Up @@ -62,6 +63,7 @@ class MainActivityViewModel @Inject constructor(
private val analyticsTrackerWrapper: AnalyticsTrackerWrapper,
private val resolveAppLink: ResolveAppLink,
private val privacyRepository: PrivacySettingsRepository,
private val ageEligibilityChecker: AgeEligibilityChecker,
moreMenuNewFeatureHandler: MoreMenuNewFeatureHandler,
unseenReviewsCountHandler: UnseenReviewsCountHandler,
determineTrialStatusBarState: DetermineTrialStatusBarState,
Expand All @@ -84,6 +86,8 @@ class MainActivityViewModel @Inject constructor(

val trialStatusBarState = determineTrialStatusBarState(_bottomBarState).asLiveData()

val isUserAgeRangeEligible = ageEligibilityChecker.isUserAgeRangeEligible.asLiveData()

fun handleShortcutAction(action: String?) {
if (!selectedSite.exists()) return
when (action) {
Expand Down
2 changes: 2 additions & 0 deletions WooCommerce/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4348,4 +4348,6 @@
<string name="about_automattic_logo_description">Automattic logo</string>
<string name="about_automattic_back_icon_description">Back icon</string>
<string name="about_automattic_app_icon_description">App icon</string>
<string name="age_restriction_dialog_title">Account Access Required</string>
<string name="age_restriction_dialog_message">Your Google Account settings indicate that you need a parent or guardian\'s permission to continue. They can grant access using their Google account to get you back online.</string>
</resources>
Loading