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