diff --git a/app/build.gradle b/app/build.gradle
index ef82c231669..dd4a7779979 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -286,6 +286,9 @@ dependencies {
androidTestImplementation(composeBom)
debugImplementation(libs.compose.debug.test)
debugImplementation(composeBom)
+
+ // Age Signals
+ implementation(libs.google.play.age.signals)
}
private setSigningConfigKey(config, Properties props) {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 46df73e7304..317d645f926 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -50,6 +50,14 @@
+
+
+
+
+
+
+
+
@@ -86,6 +94,16 @@
+
+
+
+
+
+ resultText += "Google Play Age Signals Live API Success:\n$ageSignalsResult\n"
+ }
+ .addOnFailureListener { error ->
+ resultText += "Live Age Signals API Called"
+ resultText += "Error:\n$error\n"
+ }
+
+ val fakeVerifiedUser =
+ AgeSignalsResult.builder()
+ .setUserStatus(AgeSignalsVerificationStatus.VERIFIED)
+ .build()
+ val manager = FakeAgeSignalsManager()
+ manager.setNextAgeSignalsResult(fakeVerifiedUser)
+ manager.checkAgeSignals(AgeSignalsRequest.builder().build())
+ .addOnSuccessListener { ageSignalsResult ->
+ resultText += "VERIFIED user response :\n$ageSignalsResult\n\n"
+ }
+
+ val fakeSupervisedUser =
+ AgeSignalsResult.builder()
+ .setUserStatus(AgeSignalsVerificationStatus.SUPERVISED)
+ .setAgeLower(13)
+ .setAgeUpper(17)
+ .setInstallId("fake_install_id")
+ .build()
+ manager.setNextAgeSignalsResult(fakeSupervisedUser)
+ manager.checkAgeSignals(AgeSignalsRequest.builder().build())
+ .addOnSuccessListener { ageSignalsResult ->
+ resultText += "SUPERVISED user age 13 - 17 response :\n$ageSignalsResult\n\n"
+ }
+
+ val fakeSupervisedApprovalPendingUser =
+ AgeSignalsResult.builder()
+ .setUserStatus(AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_PENDING)
+ .setAgeLower(13)
+ .setAgeUpper(17)
+ .setInstallId("fake_install_id")
+ .build()
+ manager.setNextAgeSignalsResult(fakeSupervisedApprovalPendingUser)
+ manager.checkAgeSignals(AgeSignalsRequest.builder().build())
+ .addOnSuccessListener { ageSignalsResult ->
+ resultText += "SUPERVISED_APPROVAL_PENDING user age 13 - 17 response :\n$ageSignalsResult\n\n"
+ }
+ val fakeSupervisedApprovalDeniedUser =
+ AgeSignalsResult.builder()
+ .setUserStatus(AgeSignalsVerificationStatus.SUPERVISED_APPROVAL_DENIED)
+ .setAgeLower(13)
+ .setAgeUpper(17)
+ .setMostRecentApprovalDate(
+ Date.from(LocalDate.of(2025, 2, 1).atStartOfDay(ZoneOffset.UTC).toInstant())
+ )
+ .setInstallId("fake_install_id")
+ .build()
+ manager.setNextAgeSignalsResult(fakeSupervisedApprovalDeniedUser)
+ manager.checkAgeSignals(AgeSignalsRequest.builder().build())
+ .addOnSuccessListener { ageSignalsResult ->
+ resultText += "SUPERVISED_APPROVAL_DENIED user age 13 - 17 response :\n$ageSignalsResult\n\n"
+ }
+ val fakeUnknownUser =
+ AgeSignalsResult.builder().setUserStatus(AgeSignalsVerificationStatus.UNKNOWN).build()
+ manager.setNextAgeSignalsResult(fakeUnknownUser)
+ manager.checkAgeSignals(AgeSignalsRequest.builder().build())
+ .addOnSuccessListener { ageSignalsResult ->
+ resultText += "UNKNOWN user response :\n$ageSignalsResult\n\n"
+ }
+ },
+ onAmazonAgeSignalClick = {
+ lifecycleScope.launch {
+ val client = AmazonUserDataClient(this@AgeSignalsActivity)
+ resultText = "Amazon Age Signals\n\n"
+ for (testOption in 1..6) {
+ val userData = client.getUserData(testOption)
+ if (userData != null) {
+ resultText += "$userData\n\n"
+ } else {
+ resultText = "Error: No data returned\n\n"
+ }
+ }
+ }
+ },
+ onSamsungAgeSignalClick = {
+ lifecycleScope.launch {
+ val client = SamsungAgeSignalsClient(this@AgeSignalsActivity)
+ val result = client.getAgeSignals()
+ resultText = "Samsung Age Signals\n\n"
+ resultText += when (result) {
+ is SamsungAgeSignalResult.Success -> {
+ "${result.data}\n\n"
+ }
+
+ is SamsungAgeSignalResult.Failure -> {
+ "Failure: $result\n\n"
+ }
+
+ SamsungAgeSignalResult.ProviderNotAvailable -> {
+ "Galaxy Store not available or outdated\n\n"
+ }
+ }
+ }
+ }
+ )
+ }
+ }
+}
+
+@Composable
+fun AgeSignalsScreen(
+ resultText: String,
+ modifier: Modifier = Modifier,
+ onNavigationClick: () -> Unit,
+ onGooglePlayAgeSignalClick: () -> Unit,
+ onAmazonAgeSignalClick: () -> Unit,
+ onSamsungAgeSignalClick: () -> Unit
+) {
+ Scaffold(
+ topBar = {
+ WikiTopAppBar(
+ title = "Age Signals",
+ onNavigationClick = onNavigationClick
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "Select an Age Signal Provider:",
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ AssistChip(
+ onClick = onGooglePlayAgeSignalClick,
+ label = { Text("Google Play") }
+ )
+
+ AssistChip(onAmazonAgeSignalClick, { Text("Amazon") }
+ )
+
+ AssistChip(
+ onClick = onSamsungAgeSignalClick,
+ label = { Text("Samsung") }
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = "Result:",
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+
+ Text(
+ text = resultText,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/agesignals/AmazonAgeSignals.kt b/app/src/main/java/org/wikipedia/agesignals/AmazonAgeSignals.kt
new file mode 100644
index 00000000000..f56c906e3dc
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/agesignals/AmazonAgeSignals.kt
@@ -0,0 +1,157 @@
+package org.wikipedia.agesignals
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Context
+import android.content.UriMatcher
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.util.Log
+import androidx.core.net.toUri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+// UserData.kt
+data class UserData(
+ val responseStatus: String,
+ val userStatus: String,
+ val ageLower: Int?,
+ val ageUpper: Int?,
+ val userId: String?,
+ val mostRecentApprovalDate: String?
+)
+
+// UserAgeDataResponse.kt
+object UserAgeDataResponse {
+ const val COLUMN_RESPONSE_STATUS = "responseStatus"
+ const val COLUMN_USER_STATUS = "userStatus"
+ const val COLUMN_USER_ID = "userId"
+ const val COLUMN_MOST_RECENT_APPROVAL_DATE = "mostRecentApprovalDate"
+ const val COLUMN_AGE_LOWER = "ageLower"
+ const val COLUMN_AGE_UPPER = "ageUpper"
+}
+
+class AmazonUserDataClient(private val context: Context) {
+
+ companion object {
+ // For testing - returns mock data
+ private const val TEST_AUTHORITY = "amzn_test_appstore"
+
+ // For production - returns real data after Jan 1, 2026
+ private const val PROD_AUTHORITY = "amzn_appstore"
+ private const val PROD_PATH = "/getUserAgeData"
+
+ // Toggle this for testing vs production
+ private const val USE_TEST_MODE = true
+ }
+
+ suspend fun getUserData(testOption: Int = 1): UserData? = withContext(Dispatchers.IO) {
+ val authority = if (USE_TEST_MODE) TEST_AUTHORITY else PROD_AUTHORITY
+ val path = if (USE_TEST_MODE) "/getUserAgeData?testOption=$testOption" else PROD_PATH
+ val uri = "content://$authority$path".toUri()
+
+ try {
+ val cursor = context.contentResolver.query(uri, null, null, null, null)
+
+ cursor?.use {
+ if (it.moveToFirst()) {
+ // Get column indices with validation
+ val responseStatusIndex = it.getColumnIndex(UserAgeDataResponse.COLUMN_RESPONSE_STATUS)
+ val userStatusIndex = it.getColumnIndex(UserAgeDataResponse.COLUMN_USER_STATUS)
+ val ageLowerIndex = it.getColumnIndex(UserAgeDataResponse.COLUMN_AGE_LOWER)
+ val ageUpperIndex = it.getColumnIndex(UserAgeDataResponse.COLUMN_AGE_UPPER)
+ val userIdIndex = it.getColumnIndex(UserAgeDataResponse.COLUMN_USER_ID)
+ val approvalDateIndex = it.getColumnIndex(UserAgeDataResponse.COLUMN_MOST_RECENT_APPROVAL_DATE)
+
+ UserData(
+ responseStatus = if (responseStatusIndex >= 0) {
+ it.getString(responseStatusIndex) ?: ""
+ } else "",
+
+ userStatus = if (userStatusIndex >= 0) {
+ it.getString(userStatusIndex) ?: ""
+ } else "",
+
+ ageLower = if (ageLowerIndex >= 0 && !it.isNull(ageLowerIndex)) {
+ it.getInt(ageLowerIndex)
+ } else null,
+
+ ageUpper = if (ageUpperIndex >= 0 && !it.isNull(ageUpperIndex)) {
+ it.getInt(ageUpperIndex)
+ } else null,
+
+ userId = if (userIdIndex >= 0) {
+ it.getString(userIdIndex)
+ } else null,
+
+ mostRecentApprovalDate = if (approvalDateIndex >= 0) {
+ it.getString(approvalDateIndex)
+ } else null
+ )
+ } else null
+ }
+ } catch (e: Exception) {
+ Log.e("AmazonAgeAPI", "Error querying user data", e)
+ null
+ }
+ }
+}
+
+class AmazonAgeSignalsTestContentProvider : ContentProvider() {
+
+ companion object {
+ private const val AUTHORITY = "amzn_test_appstore"
+ private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
+ addURI(AUTHORITY, "getUserAgeData", 1)
+ }
+ }
+
+ override fun onCreate(): Boolean = true
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?
+ ): Cursor? {
+ if (uriMatcher.match(uri) != 1) {
+ throw IllegalArgumentException("Unknown URI: $uri")
+ }
+
+ val testOption = uri.getQueryParameter("testOption")?.toIntOrNull() ?: 1
+
+ val cursor = MatrixCursor(arrayOf(
+ UserAgeDataResponse.COLUMN_RESPONSE_STATUS,
+ UserAgeDataResponse.COLUMN_USER_STATUS,
+ UserAgeDataResponse.COLUMN_AGE_LOWER,
+ UserAgeDataResponse.COLUMN_AGE_UPPER,
+ UserAgeDataResponse.COLUMN_USER_ID,
+ UserAgeDataResponse.COLUMN_MOST_RECENT_APPROVAL_DATE
+ ))
+
+ cursor.addRow(getResponse(testOption))
+ return cursor
+ }
+
+ private fun getResponse(option: Int): Array {
+ return when (option) {
+ 1 -> arrayOf("SUCCESS", "VERIFIED", 18, null, null, null) // 18+ verified
+ 2 -> arrayOf("SUCCESS", "UNKNOWN", null, null, null, null) // Unknown
+ 3 -> arrayOf("SUCCESS", "SUPERVISED", 0, 12, "testUserId123", "2023-07-01T00:00:00.008Z") // 0-12
+ 4 -> arrayOf("SUCCESS", "SUPERVISED", 13, 15, "testUserId456", "2023-07-01T00:00:00.008Z") // 13-15
+ 5 -> arrayOf("SUCCESS", "SUPERVISED", 16, 17, "testUserId789", "2023-07-01T00:00:00.008Z") // 16-17
+ 6 -> arrayOf("SUCCESS", "CONSENT_NOT_GRANTED", 13, 15, "testUserId999", "2023-07-01T00:00:00.008Z") // No consent
+ else -> arrayOf("SUCCESS", "", null, null, null, null) // Not applicable
+ }
+ }
+
+ override fun getType(uri: Uri): String? = null
+ override fun insert(uri: Uri, values: ContentValues?): Uri? =
+ throw UnsupportedOperationException("Insert not supported")
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int =
+ throw UnsupportedOperationException("Delete not supported")
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int =
+ throw UnsupportedOperationException("Update not supported")
+}
diff --git a/app/src/main/java/org/wikipedia/agesignals/SamsungAgeSignals.kt b/app/src/main/java/org/wikipedia/agesignals/SamsungAgeSignals.kt
new file mode 100644
index 00000000000..19f8cc61572
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/agesignals/SamsungAgeSignals.kt
@@ -0,0 +1,132 @@
+package org.wikipedia.agesignals
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.util.Log
+import androidx.core.net.toUri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+data class SamsungAgeSignalData(
+ val userStatus: String,
+ val ageLower: String?,
+ val ageUpper: String?,
+ val mostRecentApprovalDate: String?,
+ val installId: String?
+)
+
+sealed class SamsungAgeSignalResult {
+ data class Success(val data: SamsungAgeSignalData) : SamsungAgeSignalResult()
+ data class Failure(val message: String) : SamsungAgeSignalResult()
+ object ProviderNotAvailable : SamsungAgeSignalResult()
+}
+
+class SamsungAgeSignalsClient(private val context: Context) {
+
+ companion object {
+ private const val TAG = "SamsungAgeSignals"
+
+ // Samsung Galaxy Store package name
+ private const val GALAXY_STORE = "com.sec.android.app.samsungapps"
+
+ // ContentProvider authority
+ private const val ASAA_AUTHORITY = "com.sec.android.app.samsungapps.provider.ASAA"
+
+ // Metadata key for version check
+ private const val ASAA_META = "$GALAXY_STORE.AccountabilityActProvider.version"
+
+ // URI for settings
+ private const val QUERY_SETTINGS = "settings"
+ private const val URI_ASAA_SETTINGS = "content://$ASAA_AUTHORITY/$QUERY_SETTINGS"
+
+ // Method name to call
+ private const val METHOD_GET_AGE_SIGNAL_RESULT = "getAgeSignalResult"
+
+ // Result bundle keys
+ private const val KEY_RESULT_CODE = "result_code"
+ private const val KEY_RESULT_MESSAGE = "result_message"
+ private const val KEY_RESULT_USER_STATUS = "userStatus"
+ private const val KEY_RESULT_AGE_LOWER = "ageLower"
+ private const val KEY_RESULT_AGE_UPPER = "ageUpper"
+ private const val KEY_RESULT_APPROVAL_DATE = "mostRecentApprovalDate"
+ private const val KEY_RESULT_INSTALL_ID = "installID"
+
+ // Minimum required version
+ private const val MIN_PROVIDER_VERSION = 1.0f
+ }
+
+ /**
+ * Check if Galaxy Store supports the Age Signals API
+ * Requires Galaxy Store version 4.6.03.1 or higher
+ */
+ private fun checkProviderAvailable(): Boolean {
+ return try {
+ val appInfo = context.packageManager.getApplicationInfo(
+ GALAXY_STORE,
+ PackageManager.GET_META_DATA
+ )
+
+ val version = appInfo.metaData?.getFloat(ASAA_META, 0f) ?: 0f
+ val isSupported = version >= MIN_PROVIDER_VERSION
+
+ Log.d(TAG, "Galaxy Store provider version: $version, supported: $isSupported")
+ isSupported
+ } catch (e: PackageManager.NameNotFoundException) {
+ Log.e(TAG, "Galaxy Store not installed", e)
+ false
+ }
+ }
+
+ /**
+ * Get age signals from Samsung Galaxy Store
+ * This uses ContentResolver.call() instead of query()
+ */
+ suspend fun getAgeSignals(): SamsungAgeSignalResult = withContext(Dispatchers.IO) {
+ // Step 1: Check if provider is available
+ if (!checkProviderAvailable()) {
+ Log.w(TAG, "Galaxy Store provider not available")
+ return@withContext SamsungAgeSignalResult.ProviderNotAvailable
+ }
+
+ try {
+ // Step 2: Make the call to Samsung's ContentProvider
+ val uri = URI_ASAA_SETTINGS.toUri()
+ val resultBundle = context.contentResolver.call(uri, METHOD_GET_AGE_SIGNAL_RESULT, null, null)
+
+ // Step 3: Parse the response
+ if (resultBundle != null) {
+ val resultCode = resultBundle.getInt(KEY_RESULT_CODE, 1)
+
+ if (resultCode == 0) {
+ // Success - extract age signal data
+ val userStatus = resultBundle.getString(KEY_RESULT_USER_STATUS, "")
+ val ageLower = resultBundle.getString(KEY_RESULT_AGE_LOWER)
+ val ageUpper = resultBundle.getString(KEY_RESULT_AGE_UPPER)
+ val approvalDate = resultBundle.getString(KEY_RESULT_APPROVAL_DATE)
+ val installId = resultBundle.getString(KEY_RESULT_INSTALL_ID)
+
+ SamsungAgeSignalResult.Success(
+ SamsungAgeSignalData(
+ userStatus = userStatus,
+ ageLower = ageLower,
+ ageUpper = ageUpper,
+ mostRecentApprovalDate = approvalDate,
+ installId = installId
+ )
+ )
+ } else {
+ // Failure - get error message
+ val errorMessage = resultBundle.getString(KEY_RESULT_MESSAGE, "Unknown error")
+ Log.e(TAG, "Failure: $errorMessage")
+ SamsungAgeSignalResult.Failure(errorMessage)
+ }
+ } else {
+ Log.e(TAG, "Result bundle is null")
+ SamsungAgeSignalResult.Failure("No response from Galaxy Store")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Exception: ${e.message}", e)
+ SamsungAgeSignalResult.Failure(e.message ?: "Unknown error")
+ }
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt b/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt
index 1d8d71134d5..8bac1785721 100644
--- a/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt
+++ b/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt
@@ -15,6 +15,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.wikipedia.R
import org.wikipedia.WikipediaApp
+import org.wikipedia.agesignals.AgeSignalsActivity
import org.wikipedia.database.AppDatabase
import org.wikipedia.dataclient.WikiSite
import org.wikipedia.donate.donationreminder.DonationReminderConfig
@@ -273,6 +274,13 @@ internal class DeveloperSettingsPreferenceLoader(fragment: PreferenceFragmentCom
findPreference(R.string.preference_key_event_platform_intake_base_uri).summary = selectedState
true
}
+
+ // Experiments
+ // Age Signals
+ findPreference(R.string.preference_key_age_signals).onPreferenceClickListener = Preference.OnPreferenceClickListener {
+ fragment.startActivity(Intent(fragment.requireActivity(), AgeSignalsActivity::class.java))
+ true
+ }
}
private fun setUpMediaWikiSettings() {
diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml
index d213cdfe222..af57ed1addf 100644
--- a/app/src/main/res/values/preference_keys.xml
+++ b/app/src/main/res/values/preference_keys.xml
@@ -207,5 +207,6 @@
yirReadingListVisitCount
yirReadingListSurveyShown
exploreFeedSurveyShown
+ ageSignals
diff --git a/app/src/main/res/xml/developer_preferences.xml b/app/src/main/res/xml/developer_preferences.xml
index fe330c3962b..46424499a1a 100644
--- a/app/src/main/res/xml/developer_preferences.xml
+++ b/app/src/main/res/xml/developer_preferences.xml
@@ -570,6 +570,11 @@
+
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index eff7c18233f..6ed46ad7d5a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -49,7 +49,7 @@ workRuntimeKtx = "2.11.0"
composeBom = "2025.12.00"
composeActivity = "1.12.1"
composeViewModel = "2.10.0"
-
+googlePlayAgeSignals = "0.0.1"
[libraries]
androidx-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espressoVersion" }
@@ -124,6 +124,7 @@ compose-activity = { module = "androidx.activity:activity-compose", version.ref
compose-view-model = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "composeViewModel" }
compose-test = { module = "androidx.compose.ui:ui-test-junit4" }
compose-debug-test = { module = "androidx.compose.ui:ui-test-manifest" }
+google-play-age-signals = { module = "com.google.android.play:age-signals", version.ref = "googlePlayAgeSignals" }
[plugins]
android-application = { id = "com.android.application", version.ref = "gradle" }