Add phone number verification settings and usage record

This commit is contained in:
Opstic 2026-08-01 23:38:50 -04:00
parent 4510ced43d
commit 395864f0bf
No known key found for this signature in database
GPG key ID: D9CEB93EDD7C6B8D
14 changed files with 391 additions and 0 deletions

View file

@ -220,6 +220,24 @@ object SettingsContract {
)
}
object Constellation {
const val ID = "constellation"
fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), ID)
fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$ID"
const val PHONE_NUMBER_VERIFICATION_ENABLED = "phone_number_verification_enabled"
const val PHONE_NUMBER_VERIFICATION_LAST_PACKAGE = "phone_number_verification_last_package"
const val PHONE_NUMBER_VERIFICATION_LAST_USED_AT_MS = "phone_number_verification_last_used_at_ms"
const val PHONE_NUMBER_VERIFICATION_LAST_SUCCESSFUL = "phone_number_verification_last_successful"
val PROJECTION = arrayOf(
PHONE_NUMBER_VERIFICATION_ENABLED,
PHONE_NUMBER_VERIFICATION_LAST_PACKAGE,
PHONE_NUMBER_VERIFICATION_LAST_USED_AT_MS,
PHONE_NUMBER_VERIFICATION_LAST_SUCCESSFUL,
)
}
object Profile {
const val ID = "profile"
fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), ID)

View file

@ -18,6 +18,7 @@ import android.preference.PreferenceManager
import org.microg.gms.common.PackageUtils.warnIfNotMainProcess
import org.microg.gms.settings.SettingsContract.Auth
import org.microg.gms.settings.SettingsContract.CheckIn
import org.microg.gms.settings.SettingsContract.Constellation
import org.microg.gms.settings.SettingsContract.DroidGuard
import org.microg.gms.settings.SettingsContract.Exposure
import org.microg.gms.settings.SettingsContract.GameProfile
@ -80,6 +81,7 @@ class SettingsProvider : ContentProvider() {
Exposure.ID -> queryExposure(projection ?: Exposure.PROJECTION)
SafetyNet.ID -> querySafetyNet(projection ?: SafetyNet.PROJECTION)
DroidGuard.ID -> queryDroidGuard(projection ?: DroidGuard.PROJECTION)
Constellation.ID -> queryConstellation(projection ?: Constellation.PROJECTION)
Profile.ID -> queryProfile(projection ?: Profile.PROJECTION)
Location.ID -> queryLocation(projection ?: Location.PROJECTION)
Vending.ID -> queryVending(projection ?: Vending.PROJECTION)
@ -103,6 +105,7 @@ class SettingsProvider : ContentProvider() {
Exposure.ID -> updateExposure(values)
SafetyNet.ID -> updateSafetyNet(values)
DroidGuard.ID -> updateDroidGuard(values)
Constellation.ID -> updateConstellation(values)
Profile.ID -> updateProfile(values)
Location.ID -> updateLocation(values)
Vending.ID -> updateVending(values)
@ -298,6 +301,31 @@ class SettingsProvider : ContentProvider() {
editor.apply()
}
private fun queryConstellation(p: Array<out String>): Cursor = MatrixCursor(p).addRow(p) { key ->
when (key) {
Constellation.PHONE_NUMBER_VERIFICATION_ENABLED -> getSettingsBoolean(key, false)
Constellation.PHONE_NUMBER_VERIFICATION_LAST_PACKAGE -> getSettingsString(key)
Constellation.PHONE_NUMBER_VERIFICATION_LAST_USED_AT_MS -> getSettingsLong(key, 0L)
Constellation.PHONE_NUMBER_VERIFICATION_LAST_SUCCESSFUL -> getSettingsBoolean(key, false)
else -> throw IllegalArgumentException("Unknown key: $key")
}
}
private fun updateConstellation(values: ContentValues) {
if (values.size() == 0) return
val editor = preferences.edit()
values.valueSet().forEach { (key, value) ->
when (key) {
Constellation.PHONE_NUMBER_VERIFICATION_ENABLED -> editor.putBoolean(key, value as Boolean)
Constellation.PHONE_NUMBER_VERIFICATION_LAST_PACKAGE -> editor.putString(key, value as String?)
Constellation.PHONE_NUMBER_VERIFICATION_LAST_USED_AT_MS -> editor.putLong(key, value as Long)
Constellation.PHONE_NUMBER_VERIFICATION_LAST_SUCCESSFUL -> editor.putBoolean(key, value as Boolean)
else -> throw IllegalArgumentException("Unknown key: $key")
}
}
editor.apply()
}
private fun queryProfile(p: Array<out String>): Cursor = MatrixCursor(p).addRow(p) { key ->
when (key) {
Profile.PROFILE -> getSettingsString(key, "auto")

View file

@ -59,4 +59,6 @@ dependencies {
implementation "com.squareup.okhttp3:okhttp:$okHttpVersion"
api "com.squareup.wire:wire-runtime:$wireVersion"
api "com.squareup.wire:wire-grpc-client:$wireVersion"
implementation "androidx.preference:preference-ktx:$preferenceVersion"
}

View file

@ -18,6 +18,8 @@ import org.microg.gms.constellation.core.proto.ProceedResponse
import org.microg.gms.constellation.core.proto.ServerTimestamp
import org.microg.gms.constellation.core.proto.SyncResponse
import org.microg.gms.constellation.core.proto.VerificationToken
import org.microg.gms.settings.SettingsContract
import org.microg.gms.settings.SettingsContract.Constellation
private const val STATE_PREFS_NAME = "constellation_prefs"
private const val TOKEN_PREFS_NAME = "com.google.android.gms.constellation"
@ -30,6 +32,11 @@ private const val KEY_PNVR_NOTICE_CONSENT = "pnvr_notice_consent"
private const val KEY_PNVR_NOTICE_SOURCE = "pnvr_notice_source"
private const val KEY_PNVR_NOTICE_VERSION = "pnvr_notice_version"
private const val KEY_PNVR_NOTICE_UPDATED_AT_MS = "pnvr_notice_updated_at_ms"
data class PhoneNumberVerificationRecord(
val packageName: String,
val usedAtMillis: Long,
val successful: Boolean
)
object ConstellationStateStore {
fun loadVerificationTokens(context: Context): List<VerificationToken> {
@ -130,6 +137,43 @@ object ConstellationStateStore {
}
}
fun isPhoneNumberVerificationEnabled(context: Context): Boolean =
SettingsContract.getSettings(
context,
Constellation.getContentUri(context),
arrayOf(Constellation.PHONE_NUMBER_VERIFICATION_ENABLED)
) { it.getInt(0) != 0 }
fun setPhoneNumberVerificationEnabled(context: Context, enabled: Boolean) {
SettingsContract.setSettings(context, Constellation.getContentUri(context)) {
put(Constellation.PHONE_NUMBER_VERIFICATION_ENABLED, enabled)
}
}
fun recordPhoneNumberVerification(context: Context, packageName: String, successful: Boolean) {
SettingsContract.setSettings(context, Constellation.getContentUri(context)) {
put(Constellation.PHONE_NUMBER_VERIFICATION_LAST_PACKAGE, packageName)
put(Constellation.PHONE_NUMBER_VERIFICATION_LAST_USED_AT_MS, System.currentTimeMillis())
put(Constellation.PHONE_NUMBER_VERIFICATION_LAST_SUCCESSFUL, successful)
}
}
fun loadLastPhoneNumberVerification(context: Context): PhoneNumberVerificationRecord? {
return SettingsContract.getSettings(
context,
Constellation.getContentUri(context),
Constellation.PROJECTION
) { cursor ->
cursor.getString(1)?.let { packageName ->
PhoneNumberVerificationRecord(
packageName,
cursor.getLong(2),
cursor.getInt(3) != 0
)
}
}
}
private fun storeVerificationTokens(context: Context, tokens: List<VerificationToken>) {
if (tokens.isEmpty()) return
val filtered = tokens.filter {

View file

@ -176,6 +176,10 @@ private suspend fun handleVerifyPhoneNumberRequest(
var phoneNumbers = emptyList<PhoneNumberInfo>()
var verifications = emptyArray<PhoneNumberVerification>()
val status = try {
if (!ConstellationStateStore.isPhoneNumberVerificationEnabled(context)) {
throw PhoneNumberVerificationDisabledException()
}
when (readCallbackMode) {
ReadCallbackMode.LEGACY -> {
Log.d(TAG, "Using read-only mode")
@ -205,6 +209,7 @@ private suspend fun handleVerifyPhoneNumberRequest(
} catch (e: Exception) {
Log.e(TAG, "verifyPhoneNumber failed", e)
when {
e is PhoneNumberVerificationDisabledException -> Status(5000)
readCallbackMode != ReadCallbackMode.NONE -> Status.INTERNAL_ERROR
e is GrpcException -> handleRpcError(e)
e is NoConsentException -> Status(5001)
@ -212,6 +217,18 @@ private suspend fun handleVerifyPhoneNumberRequest(
}
}
val successful = status.isSuccess && when (readCallbackMode) {
ReadCallbackMode.NONE -> verifications.isNotEmpty() &&
(request.targetedSims.isEmpty() ||
verifications.size == request.targetedSims.size) &&
verifications.all {
it.verificationStatus == Verification.Status.STATUS_VERIFIED.toClientStatus()
}
else -> true
}
ConstellationStateStore.recordPhoneNumberVerification(context, callingPackage, successful)
if (readCallbackMode == ReadCallbackMode.LEGACY ||
readCallbackMode == ReadCallbackMode.NONE && legacyCallbackOnFullFlow
) {
@ -232,6 +249,8 @@ private suspend fun handleVerifyPhoneNumberRequest(
)
}
private class PhoneNumberVerificationDisabledException : Exception("Phone number verification is disabled")
private fun handleRpcError(error: GrpcException): Status {
val statusCode = when (error.grpcStatus) {
GrpcStatus.RESOURCE_EXHAUSTED -> 5008

View file

@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: 2026, microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.constellation.core.ui
import android.content.Context
import android.text.format.DateUtils
import android.util.AttributeSet
import android.util.DisplayMetrics
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import org.microg.gms.constellation.core.R
import org.microg.gms.ui.getApplicationInfoIfExists
class PhoneNumberVerificationAppPreference : Preference {
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context) : super(context)
var packageName: String? = null
set(value) {
field = value
val packageManager = context.packageManager
val applicationInfo = packageManager.getApplicationInfoIfExists(value)
title = applicationInfo?.loadLabel(packageManager)?.toString() ?: value
icon = applicationInfo?.loadIcon(packageManager)
?: AppCompatResources.getDrawable(context, android.R.mipmap.sym_def_app_icon)
notifyChanged()
}
var usedAtMillis: Long = 0L
set(value) {
field = value
notifyChanged()
}
init {
isPersistent = false
layoutResource = R.layout.preference_phone_number_verification_last_use
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
val icon = holder.findViewById(android.R.id.icon)
if (icon is ImageView) {
icon.adjustViewBounds = true
icon.scaleType = ImageView.ScaleType.CENTER_INSIDE
icon.maxHeight = (32.0 * context.resources.displayMetrics.densityDpi / DisplayMetrics.DENSITY_DEFAULT).toInt()
}
holder.findViewById(R.id.phone_number_verification_last_use_time)?.let {
(it as TextView).text = if (usedAtMillis > 0L) {
DateUtils.getRelativeTimeSpanString(usedAtMillis)
} else {
null
}
}
}
}

View file

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2026, microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.constellation.core.ui
import android.os.Bundle
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import org.microg.gms.constellation.core.ConstellationStateStore
import org.microg.gms.constellation.core.R
import org.microg.gms.ui.SwitchBarPreference
class PhoneNumberVerificationPreferencesFragment : PreferenceFragmentCompat() {
private lateinit var enabled: SwitchBarPreference
private lateinit var lastUseCategory: PreferenceCategory
private lateinit var app: PhoneNumberVerificationAppPreference
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.preferences_phone_number_verification)
}
override fun onBindPreferences() {
enabled = preferenceScreen.findPreference(PREF_ENABLED) ?: enabled
lastUseCategory = preferenceScreen.findPreference(PREF_LAST_USE_CATEGORY) ?: lastUseCategory
app = preferenceScreen.findPreference(PREF_LAST_USE) ?: app
enabled.setOnPreferenceChangeListener { _, newValue ->
ConstellationStateStore.setPhoneNumberVerificationEnabled(
requireContext(),
newValue as Boolean
)
true
}
}
override fun onResume() {
super.onResume()
enabled.isChecked =
ConstellationStateStore.isPhoneNumberVerificationEnabled(requireContext())
val record = ConstellationStateStore.loadLastPhoneNumberVerification(requireContext())
lastUseCategory.isVisible = record != null
if (record != null) {
app.packageName = record.packageName
app.summary = getString(
if (record.successful) {
R.string.phone_number_verification_result_success
} else {
R.string.phone_number_verification_result_failure
}
)
app.usedAtMillis = record.usedAtMillis
}
}
companion object {
private const val PREF_ENABLED = "pref_phone_number_verification_enabled"
private const val PREF_LAST_USE_CATEGORY = "prefcat_phone_number_verification_last_use"
private const val PREF_LAST_USE = "pref_phone_number_verification_last_use"
}
}

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: 2026, microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1C10.61,21 3,13.39 3,4c0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.25 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z" />
</vector>

View file

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: 2026, microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:baselineAligned="false"
android:clipToPadding="false"
android:gravity="center_vertical"
android:minHeight="?android:attr/listPreferredItemHeightSmall"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd">
<LinearLayout
android:id="@+id/icon_frame"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="start|center_vertical"
android:minWidth="56dp"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingEnd="12dp"
android:paddingBottom="4dp">
<ImageView
android:id="@android:id/icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="48dp"
android:maxHeight="48dp" />
</LinearLayout>
<RelativeLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingTop="16dp"
android:paddingBottom="16dp">
<TextView
android:id="@android:id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="marquee"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceListItem" />
<TextView
android:id="@android:id/summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@android:id/title"
android:layout_alignStart="@android:id/title"
android:maxLines="2"
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
android:textColor="?android:attr/textColorSecondary" />
</RelativeLayout>
<TextView
android:id="@+id/phone_number_verification_last_use_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:gravity="end|center_vertical"
android:maxLines="1"
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
android:textColor="?android:attr/textColorSecondary" />
</LinearLayout>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: 2026, microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<resources>
<string name="phone_number_verification_title">Phone Number Verification</string>
<string name="phone_number_verification_enable">Enable phone number verification</string>
<string name="phone_number_verification_last_use_title">Last use</string>
<string name="phone_number_verification_result_success">Successful</string>
<string name="phone_number_verification_result_failure">Failed</string>
<string name="phone_number_verification_carrier_notice">Your carrier may charge you for texts and calls used for verification.</string>
</resources>

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: 2026, microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<org.microg.gms.ui.SwitchBarPreference
android:key="pref_phone_number_verification_enabled"
android:persistent="false"
android:title="@string/phone_number_verification_enable" />
<PreferenceCategory
android:key="prefcat_phone_number_verification_last_use"
android:title="@string/phone_number_verification_last_use_title"
app:isPreferenceVisible="false">
<org.microg.gms.constellation.core.ui.PhoneNumberVerificationAppPreference
android:key="pref_phone_number_verification_last_use"
android:selectable="false"
app:iconSpaceReserved="false" />
</PreferenceCategory>
<PreferenceCategory android:layout="@layout/preference_category_no_label">
<org.microg.gms.ui.FooterPreference
android:selectable="false"
android:title="@string/phone_number_verification_carrier_notice" />
</PreferenceCategory>
</PreferenceScreen>

View file

@ -13,6 +13,7 @@ import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import com.google.android.gms.R
import org.microg.gms.checkin.CheckinPreferences
import org.microg.gms.constellation.core.ConstellationStateStore
import org.microg.gms.gcm.GcmDatabase
import org.microg.gms.gcm.GcmPrefs
import org.microg.gms.safetynet.SafetyNetPreferences
@ -42,6 +43,10 @@ class SettingsFragment : ResourceSettingsFragment() {
findNavController().navigate(requireContext(), R.id.openSafetyNetSettings)
true
}
findPreference<Preference>(PREF_PHONE_NUMBER_VERIFICATION)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener {
findNavController().navigate(requireContext(), R.id.openPhoneNumberVerificationSettings)
true
}
findPreference<Preference>(PREF_LOCATION)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener {
findNavController().navigate(requireContext(), R.id.openLocationSettings)
true
@ -116,6 +121,13 @@ class SettingsFragment : ResourceSettingsFragment() {
findPreference<Preference>(PREF_CHECKIN)!!.setSummary(if (CheckinPreferences.isEnabled(requireContext())) org.microg.gms.base.core.R.string.service_status_enabled_short else org.microg.gms.base.core.R.string.service_status_disabled_short)
findPreference<Preference>(PREF_SNET)!!.setSummary(if (SafetyNetPreferences.isEnabled(requireContext())) org.microg.gms.base.core.R.string.service_status_enabled_short else org.microg.gms.base.core.R.string.service_status_disabled_short)
findPreference<Preference>(PREF_PHONE_NUMBER_VERIFICATION)!!.setSummary(
if (ConstellationStateStore.isPhoneNumberVerificationEnabled(requireContext())) {
org.microg.gms.base.core.R.string.service_status_enabled_short
} else {
org.microg.gms.base.core.R.string.service_status_disabled_short
}
)
lifecycleScope.launchWhenResumed {
val entries = getAllSettingsProviders(requireContext()).flatMap { it.getEntriesDynamic(requireContext()) }
@ -134,6 +146,7 @@ class SettingsFragment : ResourceSettingsFragment() {
const val PREF_ABOUT = "pref_about"
const val PREF_GCM = "pref_gcm"
const val PREF_SNET = "pref_snet"
const val PREF_PHONE_NUMBER_VERIFICATION = "pref_phone_number_verification"
const val PREF_LOCATION = "pref_location"
const val PREF_CHECKIN = "pref_checkin"
const val PREF_VENDING = "pref_vending"

View file

@ -23,6 +23,9 @@
<action
android:id="@+id/openSafetyNetSettings"
app:destination="@id/safetyNetFragment" />
<action
android:id="@+id/openPhoneNumberVerificationSettings"
app:destination="@id/phoneNumberVerificationFragment" />
<action
android:id="@+id/openVendingSettings"
app:destination="@id/vendingFragment" />
@ -163,6 +166,11 @@
app:argType="string" />
</fragment>
<fragment
android:id="@+id/phoneNumberVerificationFragment"
android:name="org.microg.gms.constellation.core.ui.PhoneNumberVerificationPreferencesFragment"
android:label="@string/phone_number_verification_title" />
<!-- Play Store services -->
<fragment

View file

@ -44,6 +44,10 @@
android:icon="@drawable/ic_certificate"
android:key="pref_snet"
android:title="@string/service_name_device_attestation" />
<Preference
android:icon="@drawable/ic_phone"
android:key="pref_phone_number_verification"
android:title="@string/phone_number_verification_title" />
<Preference
android:icon="@drawable/ic_shop"
android:key="pref_vending"