diff --git a/play-services-constellation/build.gradle b/play-services-constellation/build.gradle
new file mode 100644
index 0000000000..f2bacc16a6
--- /dev/null
+++ b/play-services-constellation/build.gradle
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: 2026, microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+apply plugin: 'com.android.library'
+apply plugin: 'maven-publish'
+apply plugin: 'signing'
+
+android {
+ namespace "org.microg.gms.constellation"
+
+ compileSdkVersion androidCompileSdk
+ buildToolsVersion "$androidBuildVersionTools"
+
+ buildFeatures {
+ aidl = true
+ }
+
+ defaultConfig {
+ versionName version
+ minSdkVersion androidMinSdk
+ targetSdkVersion androidTargetSdk
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+}
+
+apply from: '../gradle/publish-android.gradle'
+
+description = 'microG implementation of play-services-constellation'
+
+dependencies {
+ implementation project(':play-services-basement')
+
+ annotationProcessor project(":safe-parcel-processor")
+}
diff --git a/play-services-constellation/core/build.gradle b/play-services-constellation/core/build.gradle
new file mode 100644
index 0000000000..cd7df4f4db
--- /dev/null
+++ b/play-services-constellation/core/build.gradle
@@ -0,0 +1,62 @@
+/*
+ * SPDX-FileCopyrightText: 2026, microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+apply plugin: 'com.android.library'
+apply plugin: 'maven-publish'
+apply plugin: 'kotlin-android'
+apply plugin: 'signing'
+apply plugin: 'com.squareup.wire'
+
+android {
+ namespace "org.microg.gms.constellation.core"
+
+ compileSdkVersion androidCompileSdk
+ buildToolsVersion "$androidBuildVersionTools"
+
+ defaultConfig {
+ versionName version
+ minSdkVersion androidMinSdk
+ targetSdkVersion androidTargetSdk
+ }
+
+ sourceSets {
+ main.java.srcDirs += 'src/main/kotlin'
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = 1.8
+ }
+}
+
+wire {
+ kotlin {
+ rpcRole = 'client'
+ rpcCallStyle = 'suspending'
+ }
+}
+
+apply from: '../../gradle/publish-android.gradle'
+
+description = 'microG service implementation for play-services-constellation'
+
+dependencies {
+ api project(':play-services-constellation')
+
+ implementation project(':play-services-base-core')
+ implementation project(':play-services-iid')
+ implementation project(':play-services-auth-base')
+
+ implementation project(':play-services-droidguard')
+ implementation project(':play-services-tasks-ktx')
+
+ implementation "com.squareup.okhttp3:okhttp:$okHttpVersion"
+ api "com.squareup.wire:wire-runtime:$wireVersion"
+ api "com.squareup.wire:wire-grpc-client:$wireVersion"
+}
diff --git a/play-services-constellation/core/src/main/AndroidManifest.xml b/play-services-constellation/core/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..943034c187
--- /dev/null
+++ b/play-services-constellation/core/src/main/AndroidManifest.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/play-services-constellation/core/src/main/kotlin/com/google/android/gms/constellation/GetPnvCapabilities.kt b/play-services-constellation/core/src/main/kotlin/com/google/android/gms/constellation/GetPnvCapabilities.kt
new file mode 100644
index 0000000000..79565ca454
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/com/google/android/gms/constellation/GetPnvCapabilities.kt
@@ -0,0 +1,29 @@
+package com.google.android.gms.constellation
+
+import com.google.android.gms.constellation.GetPnvCapabilitiesResponse.VerificationCapability
+
+enum class VerificationStatus(val value: Int) {
+ SUPPORTED(1),
+ UNSUPPORTED_CARRIER(2),
+ UNSUPPORTED_API_VERSION(3),
+ UNSUPPORTED_SIM_NOT_READY(4);
+
+ companion object {
+ fun fromInt(value: Int): VerificationStatus = when (value) {
+ 2 -> UNSUPPORTED_CARRIER
+ 3 -> UNSUPPORTED_API_VERSION
+ 4 -> UNSUPPORTED_SIM_NOT_READY
+ else -> SUPPORTED
+ }
+ }
+}
+
+fun verificationCapability(
+ verificationMethod: Int,
+ status: VerificationStatus
+): VerificationCapability {
+ return VerificationCapability(verificationMethod, status.value)
+}
+
+val VerificationCapability.status: VerificationStatus
+ get() = VerificationStatus.fromInt(statusValue)
\ No newline at end of file
diff --git a/play-services-constellation/core/src/main/kotlin/com/google/android/gms/constellation/VerifyPhoneNumber.kt b/play-services-constellation/core/src/main/kotlin/com/google/android/gms/constellation/VerifyPhoneNumber.kt
new file mode 100644
index 0000000000..95c87e2688
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/com/google/android/gms/constellation/VerifyPhoneNumber.kt
@@ -0,0 +1,6 @@
+package com.google.android.gms.constellation
+
+import org.microg.gms.constellation.core.proto.VerificationMethod
+
+val VerifyPhoneNumberRequest.verificationMethods: List
+ get() = verificationMethodsValues.mapNotNull { VerificationMethod.fromValue(it) }
\ No newline at end of file
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/AuthManager.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/AuthManager.kt
new file mode 100644
index 0000000000..6f2a151427
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/AuthManager.kt
@@ -0,0 +1,120 @@
+package org.microg.gms.constellation.core
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Build
+import android.util.Base64
+import androidx.annotation.RequiresApi
+import androidx.core.content.edit
+import com.google.android.gms.iid.InstanceID
+import com.squareup.wire.Instant
+import java.nio.charset.StandardCharsets
+import java.security.KeyFactory
+import java.security.KeyPair
+import java.security.KeyPairGenerator
+import java.security.Signature
+import java.security.spec.PKCS8EncodedKeySpec
+import java.security.spec.X509EncodedKeySpec
+
+val Context.authManager: AuthManager get() = AuthManager.get(this)
+
+class AuthManager private constructor(context: Context) {
+ private val context = context.applicationContext
+ private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ companion object {
+ private const val PREFS_NAME = "constellation_prefs"
+ private const val KEY_PRIVATE = "private_key"
+ private const val KEY_PUBLIC = "public_key"
+
+ // This is safe as the Context is immediately converted to the application context.
+ @SuppressLint("StaticFieldLeak")
+ @Volatile
+ private var instance: AuthManager? = null
+
+ fun get(context: Context): AuthManager = instance ?: synchronized(this) {
+ instance ?: AuthManager(context).also { instance = it }
+ }
+ }
+
+ // GMS signing format: {iidToken}:{seconds}:{nanos}
+ fun signIidTokenCompat(iidToken: String): Pair {
+ val currentTimeMillis = System.currentTimeMillis()
+
+ val epochSecond = currentTimeMillis / 1000
+ val nano = (currentTimeMillis % 1000) * 1_000_000
+
+ val content = "$iidToken:$epochSecond:$nano"
+ return sign(content) to currentTimeMillis
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ fun signIidToken(iidToken: String): Pair {
+ val (bytes, millis) = signIidTokenCompat(iidToken)
+ return bytes to Instant.ofEpochMilli(millis)
+ }
+
+ fun getIidToken(projectNumber: String? = null): String {
+ return try {
+ val sender = projectNumber ?: IidTokenPhenotypes.DEFAULT_PROJECT_NUMBER
+ InstanceID.getInstance(context).getToken(sender, "GCM")
+ } catch (_: Exception) {
+ ""
+ }
+ }
+
+ fun getOrCreateKeyPair(): KeyPair {
+ val privateKeyStr = sharedPrefs.getString(KEY_PRIVATE, null)
+ val publicKeyStr = sharedPrefs.getString(KEY_PUBLIC, null)
+
+ if (privateKeyStr != null && publicKeyStr != null) {
+ try {
+ val kf = KeyFactory.getInstance("EC")
+ val privateKey = kf.generatePrivate(
+ PKCS8EncodedKeySpec(
+ Base64.decode(
+ privateKeyStr,
+ Base64.DEFAULT
+ )
+ )
+ )
+ val publicKey = kf.generatePublic(
+ X509EncodedKeySpec(
+ Base64.decode(
+ publicKeyStr,
+ Base64.DEFAULT
+ )
+ )
+ )
+ return KeyPair(publicKey, privateKey)
+ } catch (_: Exception) {
+ // Fall through to regeneration on failure
+ }
+ }
+
+ val kpg = KeyPairGenerator.getInstance("EC")
+ kpg.initialize(256)
+ val kp = kpg.generateKeyPair()
+
+ sharedPrefs.edit {
+ putString(KEY_PRIVATE, Base64.encodeToString(kp.private.encoded, Base64.NO_WRAP))
+ putString(KEY_PUBLIC, Base64.encodeToString(kp.public.encoded, Base64.NO_WRAP))
+ }
+
+ return kp
+ }
+
+ fun sign(content: String): ByteArray {
+ return try {
+ val kp = getOrCreateKeyPair()
+ val signature = Signature.getInstance("SHA256withECDSA")
+ signature.initSign(kp.private)
+ signature.update(content.toByteArray(StandardCharsets.UTF_8))
+ signature.sign()
+ } catch (_: Exception) {
+ ByteArray(0)
+ }
+ }
+
+ fun getFid(): String = InstanceID.getInstance(context).id
+}
\ No newline at end of file
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationApiService.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationApiService.kt
new file mode 100644
index 0000000000..39334ec569
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationApiService.kt
@@ -0,0 +1,129 @@
+package org.microg.gms.constellation.core
+
+import android.content.Context
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.common.Feature
+import com.google.android.gms.common.api.ApiMetadata
+import com.google.android.gms.common.internal.ConnectionInfo
+import com.google.android.gms.common.internal.GetServiceRequest
+import com.google.android.gms.common.internal.IGmsCallbacks
+import com.google.android.gms.constellation.GetIidTokenRequest
+import com.google.android.gms.constellation.GetPnvCapabilitiesRequest
+import com.google.android.gms.constellation.VerifyPhoneNumberRequest
+import com.google.android.gms.constellation.internal.IConstellationApiService
+import com.google.android.gms.constellation.internal.IConstellationCallbacks
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import org.microg.gms.BaseService
+import org.microg.gms.common.GmsService
+import org.microg.gms.common.PackageUtils
+
+private const val TAG = "C11NApiService"
+
+class ConstellationApiService : BaseService(TAG, GmsService.CONSTELLATION) {
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun handleServiceRequest(
+ callback: IGmsCallbacks?,
+ request: GetServiceRequest?,
+ service: GmsService?
+ ) {
+ val packageName = PackageUtils.getAndCheckCallingPackage(this, request?.packageName)
+ if (!PackageUtils.isGooglePackage(this, packageName)) {
+ throw SecurityException("$packageName is not a Google package")
+ }
+ callback!!.onPostInitCompleteWithConnectionInfo(
+ 0,
+ ConstellationApiServiceImpl(this, packageName, serviceScope).asBinder(),
+ ConnectionInfo().apply {
+ features = arrayOf(
+ Feature("asterism_consent", 3),
+ Feature("one_time_verification", 1),
+ Feature("carrier_auth", 1),
+ Feature("verify_phone_number", 2),
+ Feature("get_iid_token", 1),
+ Feature("get_pnv_capabilities", 1),
+ Feature("ts43", 1),
+ Feature("verify_phone_number_local_read", 1)
+ )
+ })
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ serviceScope.cancel("ConstellationApiService destroyed")
+ }
+}
+
+class ConstellationApiServiceImpl(
+ private val context: Context,
+ private val packageName: String?,
+ private val serviceScope: CoroutineScope
+) : IConstellationApiService.Stub() {
+ override fun verifyPhoneNumberV1(
+ cb: IConstellationCallbacks?,
+ bundle: Bundle?,
+ apiMetadata: ApiMetadata?
+ ) {
+ Log.i(
+ TAG,
+ "verifyPhoneNumberV1(): mode=${bundle?.getInt("verification_mode")}, policy=${
+ bundle?.getString("policy_id")
+ }"
+ )
+ if (cb == null || bundle == null) return
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ serviceScope.launch { handleVerifyPhoneNumberV1(context, cb, bundle, packageName) }
+ }
+
+ override fun verifyPhoneNumberSingleUse(
+ cb: IConstellationCallbacks?,
+ bundle: Bundle?,
+ apiMetadata: ApiMetadata?
+ ) {
+ Log.i(TAG, "verifyPhoneNumberSingleUse()")
+ if (cb == null || bundle == null) return
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ serviceScope.launch { handleVerifyPhoneNumberSingleUse(context, cb, bundle, packageName) }
+ }
+
+ override fun verifyPhoneNumber(
+ cb: IConstellationCallbacks?,
+ request: VerifyPhoneNumberRequest?,
+ apiMetadata: ApiMetadata?
+ ) {
+ Log.i(
+ TAG,
+ "verifyPhoneNumber(): apiVersion=${request?.apiVersion}, policy=${request?.policyId}"
+ )
+ if (cb == null || request == null) return
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ serviceScope.launch { handleVerifyPhoneNumberRequest(context, cb, request, packageName) }
+ }
+
+ override fun getIidToken(
+ cb: IConstellationCallbacks?,
+ request: GetIidTokenRequest?,
+ apiMetadata: ApiMetadata?,
+ ) {
+ Log.i(TAG, "getIidToken(): $request")
+ if (cb == null || request == null) return
+ serviceScope.launch { handleGetIidToken(context, cb, request) }
+ }
+
+ override fun getPnvCapabilities(
+ cb: IConstellationCallbacks?,
+ request: GetPnvCapabilitiesRequest?,
+ apiMetadata: ApiMetadata?,
+ ) {
+ Log.i(TAG, "getPnvCapabilities(): $request")
+ if (cb == null || request == null) return
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) return
+ serviceScope.launch { handleGetPnvCapabilities(context, cb, request) }
+ }
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationStateStore.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationStateStore.kt
new file mode 100644
index 0000000000..ef2ebe25d1
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationStateStore.kt
@@ -0,0 +1,185 @@
+@file:RequiresApi(Build.VERSION_CODES.O)
+
+package org.microg.gms.constellation.core
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Build
+import android.util.Base64
+import androidx.annotation.RequiresApi
+import androidx.core.content.edit
+import com.squareup.wire.Instant
+import okio.ByteString.Companion.toByteString
+import org.microg.gms.constellation.core.proto.AsterismConsent.DeviceConsentVersion
+import org.microg.gms.constellation.core.proto.Consent
+import org.microg.gms.constellation.core.proto.ConsentSource
+import org.microg.gms.constellation.core.proto.DroidguardToken
+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
+
+private const val STATE_PREFS_NAME = "constellation_prefs"
+private const val TOKEN_PREFS_NAME = "com.google.android.gms.constellation"
+private const val KEY_VERIFICATION_TOKENS = "verification_tokens_v1"
+private const val KEY_DROIDGUARD_TOKEN = "droidguard_token"
+private const val KEY_DROIDGUARD_TOKEN_TTL = "droidguard_token_ttl"
+private const val KEY_NEXT_SYNC_TIMESTAMP_MS = "next_sync_timestamp_in_millis"
+private const val KEY_PUBLIC_KEY_ACKED = "is_public_key_acked"
+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"
+
+object ConstellationStateStore {
+ fun loadVerificationTokens(context: Context): List {
+ val prefs = tokenPrefs(context)
+ val serialized = prefs.getString(KEY_VERIFICATION_TOKENS, null) ?: return emptyList()
+ return serialized.split(",").mapNotNull { entry ->
+ val parts = entry.split("|", limit = 2)
+ if (parts.size != 2) return@mapNotNull null
+ val tokenBytes = runCatching {
+ Base64.decode(parts[0], Base64.DEFAULT).toByteString()
+ }.getOrNull() ?: return@mapNotNull null
+ val expirationMillis = parts[1].toLongOrNull() ?: return@mapNotNull null
+ if (expirationMillis <= System.currentTimeMillis()) return@mapNotNull null
+ VerificationToken(
+ token = tokenBytes,
+ expiration_time = Instant.ofEpochMilli(expirationMillis)
+ )
+ }
+ }
+
+ fun storeSyncResponse(context: Context, response: SyncResponse) {
+ storeVerificationTokens(context, response.verification_tokens)
+ storeDroidGuardToken(context, response.droidguard_token)
+ storeNextSyncTime(context, response.next_sync_time)
+ }
+
+ fun storeProceedResponse(context: Context, response: ProceedResponse) {
+ storeDroidGuardToken(context, response.droidguard_token)
+ storeNextSyncTime(context, response.next_sync_time)
+ }
+
+ fun loadDroidGuardToken(context: Context): String? {
+ val statePrefs = statePrefs(context)
+ var token = statePrefs.getString(KEY_DROIDGUARD_TOKEN, null)
+ var expiration = statePrefs.getLong(KEY_DROIDGUARD_TOKEN_TTL, 0L)
+
+ if (token.isNullOrBlank() || expiration == 0L) {
+ val legacyPrefs = tokenPrefs(context)
+ val legacyToken = legacyPrefs.getString(KEY_DROIDGUARD_TOKEN, null)
+ val legacyExpiration = legacyPrefs.getLong(KEY_DROIDGUARD_TOKEN_TTL, 0L)
+ if (!legacyToken.isNullOrBlank() && legacyExpiration > 0L) {
+ statePrefs.edit {
+ putString(KEY_DROIDGUARD_TOKEN, legacyToken)
+ putLong(KEY_DROIDGUARD_TOKEN_TTL, legacyExpiration)
+ }
+ token = legacyToken
+ expiration = legacyExpiration
+ }
+ }
+
+ if (!token.isNullOrBlank() && expiration > System.currentTimeMillis()) {
+ return token
+ }
+
+ if (!token.isNullOrBlank() || expiration > 0L) {
+ clearDroidGuardToken(context)
+ }
+ return null
+ }
+
+ private fun storeCachedDroidGuardToken(
+ context: Context,
+ token: String,
+ expirationMillis: Long
+ ) {
+ if (token.isBlank() || expirationMillis <= System.currentTimeMillis()) return
+ statePrefs(context).edit {
+ putString(KEY_DROIDGUARD_TOKEN, token)
+ putLong(KEY_DROIDGUARD_TOKEN_TTL, expirationMillis)
+ }
+ }
+
+ fun clearDroidGuardToken(context: Context) {
+ statePrefs(context).edit {
+ remove(KEY_DROIDGUARD_TOKEN)
+ remove(KEY_DROIDGUARD_TOKEN_TTL)
+ }
+ tokenPrefs(context).edit {
+ remove(KEY_DROIDGUARD_TOKEN)
+ remove(KEY_DROIDGUARD_TOKEN_TTL)
+ }
+ }
+
+ fun isPublicKeyAcked(context: Context): Boolean {
+ return statePrefs(context).getBoolean(KEY_PUBLIC_KEY_ACKED, false)
+ }
+
+ @SuppressLint("ApplySharedPref")
+ fun setPublicKeyAcked(context: Context, acked: Boolean) {
+ statePrefs(context).edit { putBoolean(KEY_PUBLIC_KEY_ACKED, acked) }
+ }
+
+ fun loadPnvrNoticeConsent(context: Context): Consent {
+ val value =
+ statePrefs(context).getInt(KEY_PNVR_NOTICE_CONSENT, Consent.CONSENT_UNKNOWN.value)
+ return Consent.fromValue(value) ?: Consent.CONSENT_UNKNOWN
+ }
+
+ fun storePnvrNotice(
+ context: Context,
+ consent: Consent,
+ source: ConsentSource,
+ version: DeviceConsentVersion
+ ) {
+ statePrefs(context).edit {
+ putInt(KEY_PNVR_NOTICE_CONSENT, consent.value)
+ putInt(KEY_PNVR_NOTICE_SOURCE, source.value)
+ putInt(KEY_PNVR_NOTICE_VERSION, version.value)
+ putLong(KEY_PNVR_NOTICE_UPDATED_AT_MS, System.currentTimeMillis())
+ }
+ }
+
+ private fun storeVerificationTokens(context: Context, tokens: List) {
+ if (tokens.isEmpty()) return
+ val filtered = tokens.filter {
+ (it.expiration_time?.toEpochMilli() ?: 0L) > System.currentTimeMillis()
+ }
+ if (filtered.isEmpty()) return
+
+ val serialized = filtered.joinToString(",") { token ->
+ val encoded = Base64.encodeToString(token.token.toByteArray(), Base64.NO_WRAP)
+ val expiration = token.expiration_time?.toEpochMilli() ?: 0L
+ "$encoded|$expiration"
+ }
+ tokenPrefs(context).edit { putString(KEY_VERIFICATION_TOKENS, serialized) }
+ }
+
+ private fun storeDroidGuardToken(context: Context, token: DroidguardToken?) {
+ val tokenValue = token?.token?.takeIf { it.isNotEmpty() } ?: return
+ val expiration = token.ttl?.toEpochMilli() ?: return
+ storeCachedDroidGuardToken(context, tokenValue, expiration)
+ }
+
+ private fun storeNextSyncTime(context: Context, timestamp: ServerTimestamp?) {
+ val nextSyncDelayMillis = timestamp?.let(::nextSyncDelayMillis) ?: return
+ statePrefs(context).edit {
+ // GMS stores the next sync deadline as an absolute wall-clock timestamp
+ putLong(KEY_NEXT_SYNC_TIMESTAMP_MS, System.currentTimeMillis() + nextSyncDelayMillis)
+ }
+ }
+
+ private fun nextSyncDelayMillis(timestamp: ServerTimestamp): Long {
+ val serverMillis = timestamp.timestamp?.toEpochMilli() ?: 0L
+ val localMillis = timestamp.now?.toEpochMilli() ?: 0L
+ return serverMillis - localMillis
+ }
+
+ private fun statePrefs(context: Context) =
+ context.getSharedPreferences(STATE_PREFS_NAME, Context.MODE_PRIVATE)
+
+ private fun tokenPrefs(context: Context) =
+ context.getSharedPreferences(TOKEN_PREFS_NAME, Context.MODE_PRIVATE)
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GServices.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GServices.kt
new file mode 100644
index 0000000000..a8c3c64481
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GServices.kt
@@ -0,0 +1,33 @@
+package org.microg.gms.constellation.core
+
+import android.content.ContentResolver
+import android.net.Uri
+import androidx.core.net.toUri
+
+// TODO: This is taken from vending-app, can we have a common client for GServices please?
+
+object GServices {
+ private val CONTENT_URI: Uri = "content://com.google.android.gsf.gservices".toUri()
+
+ fun getString(resolver: ContentResolver, key: String, defaultValue: String?): String? {
+ var result = defaultValue
+ val cursor = resolver.query(CONTENT_URI, null, null, arrayOf(key), null)
+ cursor?.use {
+ if (cursor.moveToNext()) {
+ result = cursor.getString(1)
+ }
+ }
+ return result
+ }
+
+ fun getLong(resolver: ContentResolver, key: String, defaultValue: Long): Long {
+ val result = getString(resolver, key, null)
+ if (result != null) {
+ try {
+ return result.toLong()
+ } catch (_: NumberFormatException) {
+ }
+ }
+ return defaultValue
+ }
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetIidToken.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetIidToken.kt
new file mode 100644
index 0000000000..5ee8a6ac71
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetIidToken.kt
@@ -0,0 +1,35 @@
+package org.microg.gms.constellation.core
+
+import android.content.Context
+import android.util.Log
+import com.google.android.gms.common.api.ApiMetadata
+import com.google.android.gms.common.api.Status
+import com.google.android.gms.constellation.GetIidTokenRequest
+import com.google.android.gms.constellation.GetIidTokenResponse
+import com.google.android.gms.constellation.internal.IConstellationCallbacks
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+private const val TAG = "GetIidToken"
+
+suspend fun handleGetIidToken(
+ context: Context,
+ callbacks: IConstellationCallbacks,
+ request: GetIidTokenRequest
+) = withContext(Dispatchers.IO) {
+ try {
+ val authManager = context.authManager
+ val iidToken = authManager.getIidToken(request.projectNumber?.toString())
+ val fid = authManager.getFid()
+ val (signature, timestamp) = authManager.signIidTokenCompat(iidToken)
+
+ callbacks.onIidTokenGenerated(
+ Status.SUCCESS,
+ GetIidTokenResponse(iidToken, fid, signature, timestamp),
+ ApiMetadata.DEFAULT
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "getIidToken failed", e)
+ callbacks.onIidTokenGenerated(Status.INTERNAL_ERROR, null, ApiMetadata.DEFAULT)
+ }
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetPnvCapabilities.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetPnvCapabilities.kt
new file mode 100644
index 0000000000..5aeacfcf12
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetPnvCapabilities.kt
@@ -0,0 +1,114 @@
+package org.microg.gms.constellation.core
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Build
+import android.telephony.SubscriptionManager
+import android.telephony.TelephonyManager
+import android.util.Base64
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.content.getSystemService
+import com.google.android.gms.common.api.ApiMetadata
+import com.google.android.gms.common.api.Status
+import com.google.android.gms.constellation.GetPnvCapabilitiesRequest
+import com.google.android.gms.constellation.GetPnvCapabilitiesResponse
+import com.google.android.gms.constellation.GetPnvCapabilitiesResponse.SimCapability
+import com.google.android.gms.constellation.VerificationStatus
+import com.google.android.gms.constellation.internal.IConstellationCallbacks
+import com.google.android.gms.constellation.verificationCapability
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.security.MessageDigest
+
+private const val TAG = "GetPnvCapabilities"
+
+@SuppressLint("HardwareIds")
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1)
+suspend fun handleGetPnvCapabilities(
+ context: Context,
+ callbacks: IConstellationCallbacks,
+ request: GetPnvCapabilitiesRequest
+) = withContext(Dispatchers.IO) {
+ try {
+ val baseTelephonyManager =
+ context.getSystemService()
+ ?: throw IllegalStateException("TelephonyManager unavailable")
+ val subscriptionManager =
+ context.getSystemService()
+ ?: throw IllegalStateException("SubscriptionManager unavailable")
+ val simCapabilities = subscriptionManager.activeSubscriptionInfoList
+ .orEmpty()
+ .filter { request.simSlotIndices.isEmpty() || it.simSlotIndex in request.simSlotIndices }
+ .map { info ->
+ val telephonyManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ baseTelephonyManager.createForSubscriptionId(info.subscriptionId)
+ } else {
+ baseTelephonyManager
+ }
+
+ val carrierId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ telephonyManager.simCarrierId
+ } else {
+ 0
+ }
+
+ // GMS hardcodes public verification method 9 for the Firebase PNV TS43 capability path.
+ val verificationCapabilities = if (9 in request.verificationTypes) {
+ listOf(
+ verificationCapability(
+ 9,
+ when {
+ !GetPnvCapabilitiesApiPhenotype.FPNV_ALLOWED_CARRIER_IDS.contains(
+ carrierId
+ ) ->
+ VerificationStatus.UNSUPPORTED_CARRIER
+
+ telephonyManager.simState != TelephonyManager.SIM_STATE_READY ->
+ VerificationStatus.UNSUPPORTED_SIM_NOT_READY
+
+ else -> VerificationStatus.SUPPORTED
+ }
+ )
+ )
+ } else {
+ emptyList()
+ }
+
+ // TODO: Reflection should be used to call telephonyManager.getSubscriberId(it.subscriptionId) for SDK < N
+ val subscriberIdDigest = MessageDigest.getInstance("SHA-256")
+ .digest(telephonyManager.subscriberId.orEmpty().toByteArray())
+ val subscriberIdDigestEncoded =
+ Base64.encodeToString(subscriberIdDigest, Base64.NO_WRAP)
+
+ SimCapability(
+ info.simSlotIndex,
+ subscriberIdDigestEncoded,
+ carrierId,
+ // TODO: SDK < N is TelephonyManager.getSimOperatorNameForSubscription
+ telephonyManager.simOperatorName.orEmpty(),
+ verificationCapabilities
+ )
+ }
+
+ callbacks.onGetPnvCapabilitiesCompleted(
+ Status.SUCCESS,
+ GetPnvCapabilitiesResponse(simCapabilities),
+ ApiMetadata.DEFAULT
+ )
+ } catch (e: SecurityException) {
+ Log.e(TAG, "getPnvCapabilities missing permission", e)
+ callbacks.onGetPnvCapabilitiesCompleted(
+ Status(5000),
+ GetPnvCapabilitiesResponse(emptyList()),
+ ApiMetadata.DEFAULT
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "getPnvCapabilities failed", e)
+ callbacks.onGetPnvCapabilitiesCompleted(
+ Status.INTERNAL_ERROR,
+ GetPnvCapabilitiesResponse(emptyList()),
+ ApiMetadata.DEFAULT
+ )
+ }
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetPnvCapabilitiesApiPhenotype.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetPnvCapabilitiesApiPhenotype.kt
new file mode 100644
index 0000000000..9a7f8b21bd
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetPnvCapabilitiesApiPhenotype.kt
@@ -0,0 +1,5 @@
+package org.microg.gms.constellation.core
+
+object GetPnvCapabilitiesApiPhenotype {
+ val FPNV_ALLOWED_CARRIER_IDS = ArrayList()
+}
\ No newline at end of file
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetVerifiedPhoneNumbers.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetVerifiedPhoneNumbers.kt
new file mode 100644
index 0000000000..5f73229910
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetVerifiedPhoneNumbers.kt
@@ -0,0 +1,149 @@
+@file:SuppressLint("NewApi")
+
+package org.microg.gms.constellation.core
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.common.api.ApiMetadata
+import com.google.android.gms.common.api.Status
+import com.google.android.gms.constellation.PhoneNumberInfo
+import com.google.android.gms.constellation.VerifyPhoneNumberResponse
+import com.google.android.gms.constellation.VerifyPhoneNumberResponse.PhoneNumberVerification
+import com.google.android.gms.constellation.internal.IConstellationCallbacks
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okio.ByteString.Companion.toByteString
+import org.microg.gms.common.Constants
+import org.microg.gms.constellation.core.proto.GetVerifiedPhoneNumbersRequest
+import org.microg.gms.constellation.core.proto.GetVerifiedPhoneNumbersRequest.PhoneNumberSelection
+import org.microg.gms.constellation.core.proto.IIDTokenAuth
+import org.microg.gms.constellation.core.proto.TokenOption
+import org.microg.gms.constellation.core.proto.VerifiedPhoneNumber
+import java.util.UUID
+
+private const val TAG = "GetVerifiedPhoneNumbers"
+
+suspend fun handleGetVerifiedPhoneNumbers(
+ context: Context,
+ callbacks: IConstellationCallbacks,
+ bundle: Bundle
+) = withContext(Dispatchers.IO) {
+ try {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ throw Exception("Unsupported SDK")
+ }
+
+ val phoneNumbers = fetchVerifiedPhoneNumbers(context, bundle).map { it.toPhoneNumberInfo() }
+
+ callbacks.onPhoneNumberVerified(Status.SUCCESS, phoneNumbers, ApiMetadata.DEFAULT)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error in GetVerifiedPhoneNumbers (read-only)", e)
+ callbacks.onPhoneNumberVerified(Status.INTERNAL_ERROR, emptyList(), ApiMetadata.DEFAULT)
+ }
+}
+
+internal suspend fun fetchVerifiedPhoneNumbers(
+ context: Context,
+ bundle: Bundle,
+ callingPackage: String = bundle.getString("calling_package") ?: Constants.GMS_PACKAGE_NAME
+): List = withContext(Dispatchers.IO) {
+ val authManager = context.authManager
+ val sessionId = UUID.randomUUID().toString()
+ val selections = extractPhoneNumberSelections(bundle)
+ val certificateHash = bundle.getString("certificate_hash") ?: ""
+ val tokenNonce = bundle.getString("token_nonce") ?: ""
+
+ val iidToken = authManager.getIidToken(IidTokenPhenotypes.READ_ONLY_PROJECT_NUMBER)
+ val iidTokenAuth = if (VerifyPhoneNumberApiPhenotypes.ENABLE_CLIENT_SIGNATURE) {
+ val (signatureBytes, signTimestamp) = authManager.signIidToken(iidToken)
+ IIDTokenAuth(
+ iid_token = iidToken,
+ client_sign = signatureBytes.toByteString(),
+ sign_timestamp = signTimestamp
+ )
+ } else {
+ IIDTokenAuth(iid_token = iidToken)
+ }
+
+ val getRequest = GetVerifiedPhoneNumbersRequest(
+ session_id = sessionId,
+ iid_token_auth = iidTokenAuth,
+ phone_number_selections = selections,
+ token_option = TokenOption(
+ certificate_hash = certificateHash,
+ token_nonce = tokenNonce,
+ package_name = callingPackage
+ )
+ )
+
+ Log.d(TAG, "Calling GetVerifiedPhoneNumbers RPC (read-only mode)...")
+ val response = RpcClient.phoneNumberClient
+ .GetVerifiedPhoneNumbers()
+ .execute(getRequest)
+ Log.d(TAG, "GetVerifiedPhoneNumbers response: ${response.phone_numbers.size} numbers")
+ response.phone_numbers
+}
+
+internal fun List.toVerifyPhoneNumberResponse(): VerifyPhoneNumberResponse {
+ return VerifyPhoneNumberResponse(
+ map { it.toPhoneNumberVerification() }.toTypedArray(),
+ Bundle.EMPTY
+ )
+}
+
+private fun VerifiedPhoneNumber.toPhoneNumberInfo(): PhoneNumberInfo {
+ val extras = Bundle().apply {
+ if (id_token.isNotEmpty()) {
+ putString("id_token", id_token)
+ }
+ putInt("rcs_state", rcs_state.value)
+ }
+
+ return PhoneNumberInfo(
+ 1,
+ phone_number,
+ verification_time?.toEpochMilli() ?: 0L,
+ extras
+ )
+}
+
+private fun VerifiedPhoneNumber.toPhoneNumberVerification(): PhoneNumberVerification {
+ val extras = Bundle().apply {
+ putInt("rcs_state", rcs_state.value)
+ }
+
+ // GMS read-only V2 leaves method/slot unset and returns a verified record directly.
+ return PhoneNumberVerification(
+ phone_number,
+ verification_time?.toEpochMilli() ?: 0L,
+ 0,
+ -1,
+ id_token.ifEmpty { null },
+ extras,
+ 1,
+ -1L
+ )
+}
+
+private fun extractPhoneNumberSelections(bundle: Bundle): List {
+ val selections = mutableListOf()
+ val selectionInts = bundle.getIntegerArrayList("phone_number_selection")
+
+ if (!selectionInts.isNullOrEmpty()) {
+ selections.addAll(selectionInts.mapNotNull { PhoneNumberSelection.fromValue(it) })
+ } else {
+ when (bundle.getString("rcs_read_option", "")) {
+ "READ_PROVISIONED" -> {
+ selections.add(PhoneNumberSelection.CONSTELLATION)
+ selections.add(PhoneNumberSelection.RCS)
+ }
+
+ "READ_PROVISIONED_ONLY" -> selections.add(PhoneNumberSelection.RCS)
+ else -> selections.add(PhoneNumberSelection.CONSTELLATION)
+ }
+ }
+ return selections
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/IidTokenPhenotypes.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/IidTokenPhenotypes.kt
new file mode 100644
index 0000000000..4b42087420
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/IidTokenPhenotypes.kt
@@ -0,0 +1,9 @@
+package org.microg.gms.constellation.core
+
+object IidTokenPhenotypes {
+ const val ASTERISM_PROJECT_NUMBER = "496232013492"
+ const val DEFAULT_PROJECT_NUMBER = "496232013492"
+ const val EXTERNAL_CONSENT_ACTIVITY_PROJECT_NUMBER = "496232013492"
+ const val MESSAGES_PROJECT_NUMBER = "496232013492"
+ const val READ_ONLY_PROJECT_NUMBER = "745476177629"
+}
\ No newline at end of file
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/RpcClient.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/RpcClient.kt
new file mode 100644
index 0000000000..e5030bac66
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/RpcClient.kt
@@ -0,0 +1,33 @@
+package org.microg.gms.constellation.core
+
+import com.squareup.wire.GrpcClient
+import okhttp3.OkHttpClient
+import org.microg.gms.common.Constants
+import org.microg.gms.constellation.core.proto.PhoneDeviceVerificationClient
+import org.microg.gms.constellation.core.proto.PhoneNumberClient
+
+object RpcClient {
+ private val client: OkHttpClient = OkHttpClient.Builder()
+ .addInterceptor { chain ->
+ val originalRequest = chain.request()
+ val builder = originalRequest.newBuilder()
+ .header("X-Goog-Api-Key", "AIzaSyAP-gfH3qvi6vgHZbSYwQ_XHqV_mXHhzIk")
+ .header("X-Android-Package", Constants.GMS_PACKAGE_NAME)
+ .header("X-Android-Cert", Constants.GMS_PACKAGE_SIGNATURE_SHA1.uppercase())
+ chain.proceed(builder.build())
+ }
+ .build()
+
+ private val grpcClient: GrpcClient = GrpcClient.Builder()
+ .client(client)
+ // Google's constellationserver does NOT like compressed requests
+ .minMessageToCompress(Long.MAX_VALUE)
+ .baseUrl("https://phonedeviceverification-pa.googleapis.com/")
+ .build()
+
+ val phoneDeviceVerificationClient: PhoneDeviceVerificationClient =
+ grpcClient.create(PhoneDeviceVerificationClient::class)
+
+ val phoneNumberClient: PhoneNumberClient =
+ grpcClient.create(PhoneNumberClient::class)
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerificationMappings.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerificationMappings.kt
new file mode 100644
index 0000000000..edf9a5de0b
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerificationMappings.kt
@@ -0,0 +1,158 @@
+package org.microg.gms.constellation.core
+
+import android.os.Build
+import android.os.Bundle
+import androidx.annotation.RequiresApi
+import com.google.android.gms.constellation.VerifyPhoneNumberResponse.PhoneNumberVerification
+import org.microg.gms.constellation.core.proto.Param
+import org.microg.gms.constellation.core.proto.UnverifiedInfo
+import org.microg.gms.constellation.core.proto.Verification
+import org.microg.gms.constellation.core.proto.VerificationMethod
+
+fun UnverifiedInfo.Reason.toVerificationStatus(): Verification.Status {
+ return when (this) {
+ UnverifiedInfo.Reason.UNKNOWN_REASON -> Verification.Status.STATUS_UNKNOWN
+ UnverifiedInfo.Reason.THROTTLED -> Verification.Status.STATUS_THROTTLED
+ UnverifiedInfo.Reason.FAILED -> Verification.Status.STATUS_FAILED
+ UnverifiedInfo.Reason.SKIPPED -> Verification.Status.STATUS_SKIPPED
+ UnverifiedInfo.Reason.NOT_REQUIRED -> Verification.Status.STATUS_NOT_REQUIRED
+ UnverifiedInfo.Reason.PHONE_NUMBER_ENTRY_REQUIRED ->
+ Verification.Status.STATUS_PHONE_NUMBER_ENTRY_REQUIRED
+
+ UnverifiedInfo.Reason.INELIGIBLE -> Verification.Status.STATUS_INELIGIBLE
+ UnverifiedInfo.Reason.DENIED -> Verification.Status.STATUS_DENIED
+ UnverifiedInfo.Reason.NOT_IN_SERVICE -> Verification.Status.STATUS_NOT_IN_SERVICE
+ }
+}
+
+val Verification.state: Verification.State
+ get() = when {
+ verification_info != null -> Verification.State.VERIFIED
+ pending_verification_info != null -> Verification.State.PENDING
+ unverified_info != null -> Verification.State.NONE
+ else -> Verification.State.UNKNOWN
+ }
+
+val Verification.effectiveStatus: Verification.Status
+ get() = when (state) {
+ Verification.State.VERIFIED -> Verification.Status.STATUS_VERIFIED
+ Verification.State.PENDING ->
+ if (status != Verification.Status.STATUS_UNKNOWN) {
+ status
+ } else {
+ Verification.Status.STATUS_PENDING
+ }
+
+ Verification.State.NONE ->
+ unverified_info?.reason?.toVerificationStatus() ?: Verification.Status.STATUS_UNKNOWN
+
+ Verification.State.UNKNOWN -> Verification.Status.STATUS_UNKNOWN
+ }
+
+@RequiresApi(Build.VERSION_CODES.O)
+fun Verification.toClientVerification(imsiToSlotMap: Map): PhoneNumberVerification {
+ val clientStatus = effectiveStatus.toClientStatus()
+ val extras = buildClientExtras()
+ val simImsi = association?.sim?.sim_info?.imsi?.firstOrNull()
+ val simSlot = if (simImsi != null) imsiToSlotMap[simImsi] ?: -1 else -1
+
+ var phoneNumber = ""
+ var timestampMillis = 0L
+ var verificationMethod = VerificationMethod.UNKNOWN
+ var verificationToken: String? = null
+ var retryAfterSeconds = -1L
+
+ when (state) {
+ Verification.State.VERIFIED -> {
+ val info = verification_info
+ val verifiedPhoneNumber = info?.phone_number
+ require(!verifiedPhoneNumber.isNullOrEmpty()) { "Verified phone number is empty" }
+ phoneNumber = verifiedPhoneNumber
+ timestampMillis = info.verification_time?.toEpochMilli() ?: 0L
+ verificationMethod = info.challenge_method
+ verificationToken = extras.getString("id_token")
+ extras.remove("phone_number")
+ extras.remove("id_token")
+ extras.remove("verification_time_millis")
+ }
+
+ Verification.State.PENDING -> {
+ verificationMethod =
+ pending_verification_info?.challenge?.type ?: VerificationMethod.UNKNOWN
+ }
+
+ Verification.State.NONE -> {
+ val info = unverified_info
+ verificationMethod = info?.challenge_method ?: VerificationMethod.UNKNOWN
+ retryAfterSeconds = info?.retry_after_time?.let { ts ->
+ val now = System.currentTimeMillis() / 1000L
+ (ts.epochSecond - now).coerceAtLeast(0L)
+ } ?: -1L
+ }
+
+ Verification.State.UNKNOWN -> Unit
+ }
+
+ extras.remove("verification_method")
+ extras.remove("sim_slot_index")
+
+ return PhoneNumberVerification(
+ phoneNumber,
+ timestampMillis,
+ verificationMethod.toClientMethod(),
+ simSlot,
+ verificationToken,
+ extras,
+ clientStatus,
+ retryAfterSeconds
+ )
+}
+
+private fun Verification.buildClientExtras(): Bundle {
+ val bundle = Bundle()
+ for (param in api_params) {
+ bundle.putParam(param)
+ }
+
+ val slotIndex = association?.sim?.sim_slot?.slot_index
+ if (slotIndex != null && slotIndex >= 0) {
+ bundle.putString("sim_slot_index", slotIndex.toString())
+ }
+ return bundle
+}
+
+private fun Bundle.putParam(param: Param) {
+ if (param.key == "verification_method") {
+ param.value_.toIntOrNull()?.let {
+ putInt(param.key, it)
+ return
+ }
+ }
+ putString(param.key, param.value_)
+}
+
+fun VerificationMethod.toClientMethod(): Int {
+ return when (this) {
+ VerificationMethod.UNKNOWN -> 0
+ VerificationMethod.TS43 -> 9
+ else -> value
+ }
+}
+
+fun Verification.Status.toClientStatus(): Int {
+ return when (this) {
+ Verification.Status.STATUS_UNKNOWN,
+ Verification.Status.STATUS_NONE -> 0
+
+ Verification.Status.STATUS_PENDING -> 6
+ Verification.Status.STATUS_VERIFIED -> 1
+ Verification.Status.STATUS_THROTTLED -> 3
+ Verification.Status.STATUS_FAILED -> 2
+ Verification.Status.STATUS_SKIPPED -> 4
+ Verification.Status.STATUS_NOT_REQUIRED -> 5
+ Verification.Status.STATUS_PHONE_NUMBER_ENTRY_REQUIRED -> 7
+ Verification.Status.STATUS_INELIGIBLE -> 8
+ Verification.Status.STATUS_DENIED -> 9
+ Verification.Status.STATUS_NOT_IN_SERVICE -> 10
+ }
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerificationSettingsPhenotypes.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerificationSettingsPhenotypes.kt
new file mode 100644
index 0000000000..f8deaa5a7f
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerificationSettingsPhenotypes.kt
@@ -0,0 +1,6 @@
+package org.microg.gms.constellation.core
+
+object VerificationSettingsPhenotypes {
+ const val A2P_SMS_SIGNAL_GRANULARITY_HRS = 1L
+ const val A2P_HISTORY_WINDOW_HOURS = 168L
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerifyPhoneNumber.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerifyPhoneNumber.kt
new file mode 100644
index 0000000000..848978f6c9
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerifyPhoneNumber.kt
@@ -0,0 +1,467 @@
+@file:RequiresApi(Build.VERSION_CODES.O)
+
+package org.microg.gms.constellation.core
+
+import android.content.Context
+import android.os.Build
+import android.os.Bundle
+import android.telephony.SubscriptionInfo
+import android.util.Log
+import androidx.annotation.RequiresApi
+import com.google.android.gms.common.api.ApiMetadata
+import com.google.android.gms.common.api.Status
+import com.google.android.gms.constellation.PhoneNumberInfo
+import com.google.android.gms.constellation.VerifyPhoneNumberRequest
+import com.google.android.gms.constellation.VerifyPhoneNumberRequest.IdTokenRequest
+import com.google.android.gms.constellation.VerifyPhoneNumberResponse
+import com.google.android.gms.constellation.VerifyPhoneNumberResponse.PhoneNumberVerification
+import com.google.android.gms.constellation.internal.IConstellationCallbacks
+import com.squareup.wire.GrpcException
+import com.squareup.wire.GrpcStatus
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.microg.gms.common.Constants
+import org.microg.gms.constellation.core.proto.AsterismClient
+import org.microg.gms.constellation.core.proto.Consent
+import org.microg.gms.constellation.core.proto.DeviceID
+import org.microg.gms.constellation.core.proto.GetConsentRequest
+import org.microg.gms.constellation.core.proto.GetConsentResponse
+import org.microg.gms.constellation.core.proto.RequestHeader
+import org.microg.gms.constellation.core.proto.SyncRequest
+import org.microg.gms.constellation.core.proto.Verification
+import org.microg.gms.constellation.core.proto.builder.RequestBuildContext
+import org.microg.gms.constellation.core.proto.builder.buildImsiToSubscriptionInfoMap
+import org.microg.gms.constellation.core.proto.builder.buildRequestContext
+import org.microg.gms.constellation.core.proto.builder.invoke
+import org.microg.gms.constellation.core.verification.ChallengeProcessor
+import org.microg.gms.constellation.core.verification.MtSmsInboxRegistry
+import java.util.UUID
+
+private const val TAG = "VerifyPhoneNumber"
+
+private enum class ReadCallbackMode {
+ NONE,
+ LEGACY,
+ TYPED
+}
+
+@Suppress("DEPRECATION")
+suspend fun handleVerifyPhoneNumberV1(
+ context: Context,
+ callbacks: IConstellationCallbacks,
+ bundle: Bundle,
+ packageName: String?
+) {
+ val callingPackage =
+ packageName ?: bundle.getString("calling_package") ?: Constants.GMS_PACKAGE_NAME
+ val extras = Bundle(bundle).apply {
+ putString("calling_package", callingPackage)
+ putString("calling_api", "verifyPhoneNumber")
+ }
+ val timeout = when (val timeoutValue = extras.get("timeout")) {
+ is Long -> timeoutValue
+ is Int -> timeoutValue.toLong()
+ else -> 300L
+ }
+ val request = VerifyPhoneNumberRequest(
+ extras.getString("policy_id", ""),
+ timeout,
+ IdTokenRequest(
+ extras.getString("certificate_hash", ""),
+ extras.getString("token_nonce", "")
+ ),
+ extras,
+ emptyList(),
+ false,
+ 2,
+ emptyList()
+ )
+ val policyId = bundle.getString("policy_id", "")
+ val mode = bundle.getInt("verification_mode", 0)
+ val useReadPath = when (mode) {
+ 0 -> policyId in VerifyPhoneNumberApiPhenotypes.READ_ONLY_POLICY_IDS
+ 2 -> VerifyPhoneNumberApiPhenotypes.ENABLE_READ_FLOW
+
+ else -> false
+ }
+
+ handleVerifyPhoneNumberRequest(
+ context,
+ callbacks,
+ request,
+ callingPackage,
+ if (useReadPath) ReadCallbackMode.LEGACY else ReadCallbackMode.NONE,
+ legacyCallbackOnFullFlow = true
+ )
+}
+
+@Suppress("DEPRECATION")
+suspend fun handleVerifyPhoneNumberSingleUse(
+ context: Context,
+ callbacks: IConstellationCallbacks,
+ bundle: Bundle,
+ packageName: String?
+) {
+ val callingPackage =
+ packageName ?: bundle.getString("calling_package") ?: Constants.GMS_PACKAGE_NAME
+ val extras = Bundle(bundle).apply {
+ putString("calling_package", callingPackage)
+ putString("calling_api", "verifyPhoneNumberSingleUse")
+ putString("one_time_verification", "True")
+ }
+ val timeout = when (val timeoutValue = extras.get("timeout")) {
+ is Long -> timeoutValue
+ is Int -> timeoutValue.toLong()
+ else -> 300L
+ }
+ val request = VerifyPhoneNumberRequest(
+ extras.getString("policy_id", ""),
+ timeout,
+ IdTokenRequest(
+ extras.getString("certificate_hash", ""),
+ extras.getString("token_nonce", "")
+ ),
+ extras,
+ emptyList(),
+ false,
+ 2,
+ emptyList()
+ )
+
+ handleVerifyPhoneNumberRequest(
+ context,
+ callbacks,
+ request,
+ callingPackage,
+ ReadCallbackMode.NONE,
+ legacyCallbackOnFullFlow = true
+ )
+}
+
+suspend fun handleVerifyPhoneNumberRequest(
+ context: Context,
+ callbacks: IConstellationCallbacks,
+ request: VerifyPhoneNumberRequest,
+ packageName: String?
+) {
+ val callingPackage = packageName ?: Constants.GMS_PACKAGE_NAME
+ request.extras.putString("calling_api", "verifyPhoneNumber")
+ val useReadPath = when (request.apiVersion) {
+ 0 -> request.policyId in VerifyPhoneNumberApiPhenotypes.READ_ONLY_POLICY_IDS
+ 2 -> VerifyPhoneNumberApiPhenotypes.ENABLE_READ_FLOW
+ 3 -> request.policyId in VerifyPhoneNumberApiPhenotypes.POLICY_IDS_ALLOWED_FOR_LOCAL_READ
+ else -> false
+ }
+
+ handleVerifyPhoneNumberRequest(
+ context,
+ callbacks,
+ request,
+ callingPackage,
+ if (useReadPath) ReadCallbackMode.TYPED else ReadCallbackMode.NONE,
+ localReadFallback = request.apiVersion == 3 && useReadPath,
+ legacyCallbackOnFullFlow = false
+ )
+}
+
+private suspend fun handleVerifyPhoneNumberRequest(
+ context: Context,
+ callbacks: IConstellationCallbacks,
+ request: VerifyPhoneNumberRequest,
+ callingPackage: String,
+ readCallbackMode: ReadCallbackMode,
+ localReadFallback: Boolean = false,
+ legacyCallbackOnFullFlow: Boolean = false
+) {
+ try {
+ when (readCallbackMode) {
+ ReadCallbackMode.LEGACY -> {
+ Log.d(TAG, "Using read-only mode")
+ handleGetVerifiedPhoneNumbers(context, callbacks, request.extras)
+ }
+
+ ReadCallbackMode.TYPED -> {
+ if (localReadFallback) {
+ Log.w(TAG, "Local-read mode not implemented, falling back to read-only RPC")
+ } else {
+ Log.d(TAG, "Using typed read-only mode")
+ }
+ val response = fetchVerifiedPhoneNumbers(context, request.extras, callingPackage)
+ .toVerifyPhoneNumberResponse()
+ callbacks.onPhoneNumberVerificationsCompleted(
+ Status.SUCCESS,
+ response,
+ ApiMetadata.DEFAULT
+ )
+ }
+
+ ReadCallbackMode.NONE -> {
+ Log.d(TAG, "Using full verification mode")
+ runVerificationFlow(
+ context,
+ request,
+ callingPackage,
+ callbacks,
+ legacyCallback = legacyCallbackOnFullFlow
+ )
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "verifyPhoneNumber failed", e)
+ val status = when {
+ readCallbackMode != ReadCallbackMode.NONE -> Status.INTERNAL_ERROR
+ e is GrpcException -> handleRpcError(e)
+ e is NoConsentException -> Status(5001)
+ else -> Status.INTERNAL_ERROR
+ }
+ when (readCallbackMode) {
+ ReadCallbackMode.LEGACY -> {
+ callbacks.onPhoneNumberVerified(
+ status,
+ emptyList(),
+ ApiMetadata.DEFAULT
+ )
+ }
+
+ ReadCallbackMode.NONE -> {
+ if (legacyCallbackOnFullFlow) {
+ callbacks.onPhoneNumberVerified(
+ status,
+ emptyList(),
+ ApiMetadata.DEFAULT
+ )
+ } else {
+ callbacks.onPhoneNumberVerificationsCompleted(
+ status,
+ VerifyPhoneNumberResponse(emptyArray(), Bundle()),
+ ApiMetadata.DEFAULT
+ )
+ }
+ }
+
+ ReadCallbackMode.TYPED -> {
+ callbacks.onPhoneNumberVerificationsCompleted(
+ status,
+ VerifyPhoneNumberResponse(emptyArray(), Bundle()),
+ ApiMetadata.DEFAULT
+ )
+ }
+ }
+ }
+}
+
+private fun handleRpcError(error: GrpcException): Status {
+ val statusCode = when (error.grpcStatus) {
+ GrpcStatus.RESOURCE_EXHAUSTED -> 5008
+ GrpcStatus.DEADLINE_EXCEEDED,
+ GrpcStatus.ABORTED,
+ GrpcStatus.UNAVAILABLE -> 5007
+
+ GrpcStatus.PERMISSION_DENIED -> 5009
+ else -> 5002
+ }
+ return Status(statusCode, error.message)
+}
+
+private class NoConsentException : Exception("No consent")
+
+private suspend fun runVerificationFlow(
+ context: Context,
+ request: VerifyPhoneNumberRequest,
+ callingPackage: String,
+ callbacks: IConstellationCallbacks,
+ legacyCallback: Boolean
+) {
+ val sessionId = UUID.randomUUID().toString()
+
+ val imsiToInfoMap = buildImsiToSubscriptionInfoMap(context)
+ val buildContext = buildRequestContext(context)
+
+ val asterismClient = when (request.extras.getString("required_consumer_consent")) {
+ "RCS" -> AsterismClient.RCS
+ else -> AsterismClient.UNKNOWN_CLIENT
+ }
+
+ if (
+ request.extras.getString("one_time_verification") != "True" &&
+ asterismClient != AsterismClient.UNKNOWN_CLIENT
+ ) {
+ val consent = getConsent(
+ context,
+ buildContext,
+ asterismClient,
+ sessionId,
+ )
+
+ val consented = consent.rcs_consent?.consent == Consent.CONSENTED ||
+ consent.gaia_consents.any {
+ it.asterism_client == asterismClient && it.consent == Consent.CONSENTED
+ }
+
+ if (!consented) {
+ Log.e(TAG, "Consent has not been set. Not running verification.")
+ throw NoConsentException()
+ }
+ }
+
+ val syncRequest = SyncRequest(
+ context,
+ sessionId,
+ request,
+ buildContext,
+ imsiToInfoMap = imsiToInfoMap,
+ includeClientAuth = ConstellationStateStore.isPublicKeyAcked(context),
+ callingPackage = callingPackage
+ )
+
+ MtSmsInboxRegistry.prepare(
+ context.applicationContext,
+ imsiToInfoMap.values.map { it.subscriptionId })
+
+ val verifications = try {
+ executeSyncFlow(
+ context,
+ sessionId,
+ request,
+ syncRequest,
+ buildContext,
+ imsiToInfoMap
+ )
+ } finally {
+ MtSmsInboxRegistry.dispose()
+ }
+
+ if (legacyCallback) {
+ callbacks.onPhoneNumberVerified(
+ Status.SUCCESS,
+ verifications.mapNotNull { it.toLegacyPhoneNumberInfoOrNull() },
+ ApiMetadata.DEFAULT
+ )
+ } else {
+ callbacks.onPhoneNumberVerificationsCompleted(
+ Status.SUCCESS,
+ VerifyPhoneNumberResponse(verifications, Bundle()),
+ ApiMetadata.DEFAULT
+ )
+ }
+}
+
+private fun PhoneNumberVerification.toLegacyPhoneNumberInfoOrNull(): PhoneNumberInfo? {
+ if (
+ verificationStatus != Verification.Status.STATUS_VERIFIED.toClientStatus() ||
+ phoneNumber.isNullOrEmpty()
+ ) {
+ return null
+ }
+ val extras = Bundle(this.extras ?: Bundle.EMPTY).apply {
+ verificationToken?.let { putString("id_token", it) }
+ }
+ return PhoneNumberInfo(1, phoneNumber, timestampMillis, extras)
+}
+
+private suspend fun executeSyncFlow(
+ context: Context,
+ sessionId: String,
+ request: VerifyPhoneNumberRequest,
+ syncRequest: SyncRequest,
+ buildContext: RequestBuildContext,
+ imsiToInfoMap: Map
+): Array = withContext(Dispatchers.IO) {
+ Log.d(TAG, "Sending Sync request")
+ val syncResponse = try {
+ RpcClient.phoneDeviceVerificationClient.Sync().execute(syncRequest)
+ } catch (e: GrpcException) {
+ if (e.grpcStatus == GrpcStatus.PERMISSION_DENIED ||
+ e.grpcStatus == GrpcStatus.UNAUTHENTICATED
+ ) {
+ Log.w(
+ TAG,
+ "Suspicious client status ${e.grpcStatus.name}. Clearing DroidGuard cache..."
+ )
+ ConstellationStateStore.clearDroidGuardToken(context)
+ }
+ throw e
+ }
+ Log.d(TAG, "Sync response: ${syncResponse.responses.size} verifications")
+ ConstellationStateStore.storeSyncResponse(context, syncResponse)
+
+ val isPublicKeyAcked = syncResponse.header_?.status?.code == 1
+ val imsiToSlotMap = imsiToInfoMap.mapValues { it.value.simSlotIndex }
+ val requestedImsis = request.targetedSims.map { it.imsi }.toSet()
+
+ val verifications = syncResponse.responses.mapNotNull { result ->
+ val verification = result.verification ?: Verification()
+ val verificationImsis = verification.association?.sim?.sim_info?.imsi.orEmpty()
+ if (requestedImsis.isNotEmpty() && verificationImsis.none { it in requestedImsis }) {
+ Log.w(
+ TAG,
+ "Skipping verification for IMSIs=$verificationImsis because it does not match requested IMSIs=$requestedImsis"
+ )
+ return@mapNotNull null
+ }
+
+ val finalVerification = if (verification.state == Verification.State.PENDING) {
+ ChallengeProcessor.process(
+ context,
+ sessionId,
+ imsiToInfoMap,
+ buildContext,
+ verification
+ )
+ } else {
+ verification
+ }
+
+ if (finalVerification.state != Verification.State.VERIFIED) {
+ Log.w(TAG, "Unverified. State: ${finalVerification.state}")
+ (finalVerification.pending_verification_info
+ ?: finalVerification.unverified_info)?.let { Log.w(TAG, it.toString()) }
+
+ if (!request.includeUnverified) {
+ return@mapNotNull null
+ }
+ }
+
+ finalVerification.toClientVerification(imsiToSlotMap)
+ }.toTypedArray()
+
+ if (isPublicKeyAcked) {
+ Log.d(TAG, "Server acknowledged client public key")
+ ConstellationStateStore.setPublicKeyAcked(context, true)
+ }
+
+ verifications
+}
+
+suspend fun getConsent(
+ context: Context,
+ buildContext: RequestBuildContext,
+ asterismClient: AsterismClient,
+ sessionId: String = UUID.randomUUID().toString()
+): GetConsentResponse {
+ val request = GetConsentRequest(
+ device_id = DeviceID(context, buildContext.iidToken),
+ header_ = RequestHeader(
+ context,
+ sessionId,
+ buildContext,
+ "getConsent"
+ ),
+ asterism_client = asterismClient
+ )
+
+ return try {
+ RpcClient.phoneDeviceVerificationClient.GetConsent().execute(request)
+ } catch (e: GrpcException) {
+ if (e.grpcStatus == GrpcStatus.PERMISSION_DENIED ||
+ e.grpcStatus == GrpcStatus.UNAUTHENTICATED
+ ) {
+ Log.w(
+ TAG,
+ "Suspicious client status ${e.grpcStatus.name}. Clearing DroidGuard cache..."
+ )
+ ConstellationStateStore.clearDroidGuardToken(context)
+ }
+ throw e
+ }
+}
\ No newline at end of file
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerifyPhoneNumberApiPhenotypes.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerifyPhoneNumberApiPhenotypes.kt
new file mode 100644
index 0000000000..ceebfcb771
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerifyPhoneNumberApiPhenotypes.kt
@@ -0,0 +1,37 @@
+package org.microg.gms.constellation.core
+
+object VerifyPhoneNumberApiPhenotypes {
+ val PACKAGES_ALLOWED_TO_CALL = listOf(
+ "com.google.android.gms",
+ "com.google.android.apps.messaging",
+ "com.google.android.ims",
+ "com.google.android.apps.tachyon",
+ "com.google.android.dialer",
+ "com.google.android.apps.nbu.paisa.user.dev",
+ "com.google.android.apps.nbu.paisa.user.qa",
+ "com.google.android.apps.nbu.paisa.user.teamfood2",
+ "com.google.android.apps.nbu.paisa.user.partner",
+ "com.google.android.apps.nbu.paisa.user",
+ "com.google.android.gms.constellation.getiidtoken",
+ "com.google.android.gms.constellation.ondemandconsent",
+ "com.google.android.gms.constellation.ondemandconsentv2",
+ "com.google.android.gms.constellation.readphonenumber",
+ "com.google.android.gms.constellation.verifyphonenumberlite",
+ "com.google.android.gms.constellation.verifyphonenumber",
+ "com.google.android.gms.test",
+ "com.google.android.apps.stargate",
+ "com.google.android.gms.firebase.fpnv",
+ "com.google.firebase.pnv.testapp",
+ "com.google.firebase.pnv"
+ )
+ val POLICY_IDS_ALLOWED_FOR_LOCAL_READ = listOf("emergency_location")
+ val READ_ONLY_POLICY_IDS = listOf(
+ "business_voice",
+ "verifiedsmsconsent",
+ "hint",
+ "nearbysharing"
+ )
+ const val ENABLE_CLIENT_SIGNATURE = true
+ const val ENABLE_LOCAL_READ_FLOW = true
+ const val ENABLE_READ_FLOW = true
+}
\ No newline at end of file
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/ClientInfoBuilder.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/ClientInfoBuilder.kt
new file mode 100644
index 0000000000..b1644dc893
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/ClientInfoBuilder.kt
@@ -0,0 +1,176 @@
+@file:RequiresApi(Build.VERSION_CODES.O)
+
+package org.microg.gms.constellation.core.proto.builder
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Build
+import android.os.Process
+import android.os.UserManager
+import android.telephony.SubscriptionManager
+import android.telephony.TelephonyManager
+import android.util.Base64
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.content.edit
+import androidx.core.content.getSystemService
+import com.google.android.gms.droidguard.DroidGuard
+import com.google.android.gms.tasks.await
+import okio.ByteString.Companion.toByteString
+import org.microg.gms.common.Constants
+import org.microg.gms.constellation.core.ConstellationStateStore
+import org.microg.gms.constellation.core.GServices
+import org.microg.gms.constellation.core.authManager
+import org.microg.gms.constellation.core.proto.ClientInfo
+import org.microg.gms.constellation.core.proto.CountryInfo
+import org.microg.gms.constellation.core.proto.DeviceID
+import org.microg.gms.constellation.core.proto.DeviceType
+import org.microg.gms.constellation.core.proto.DroidGuardSignals
+import org.microg.gms.constellation.core.proto.GaiaSignals
+import org.microg.gms.constellation.core.proto.NetworkSignal
+import org.microg.gms.constellation.core.proto.SimOperatorInfo
+import org.microg.gms.constellation.core.proto.UserProfileType
+import java.security.MessageDigest
+import java.util.Locale
+
+private const val TAG = "ClientInfoBuilder"
+private const val PREFS_NAME = "constellation_prefs"
+
+@SuppressLint("HardwareIds")
+suspend operator fun ClientInfo.Companion.invoke(
+ context: Context,
+ buildContext: RequestBuildContext,
+ rpc: String
+): ClientInfo {
+ val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
+ val locale = Locale.getDefault().let { "${it.language}_${it.country}" }
+
+ return ClientInfo(
+ device_id = DeviceID(context, buildContext.iidToken),
+ client_public_key = context.authManager.getOrCreateKeyPair().public.encoded.toByteString(),
+ locale = locale,
+ gmscore_version_number = Constants.GMS_VERSION_CODE / 1000,
+ gmscore_version = packageInfo.versionName ?: "",
+ android_sdk_version = Build.VERSION.SDK_INT,
+ user_profile_type = UserProfileType.REGULAR_USER,
+ gaia_tokens = buildContext.gaiaTokens,
+ country_info = CountryInfo(context),
+ connectivity_infos = NetworkSignal.getList(context),
+ model = Build.MODEL,
+ manufacturer = Build.MANUFACTURER,
+ partial_sim_infos = SimOperatorInfo.getList(context),
+ device_type = DeviceType.DEVICE_TYPE_PHONE,
+ is_wearable_standalone = false,
+ gaia_signals = GaiaSignals(context),
+ device_fingerprint = Build.FINGERPRINT,
+ droidguard_signals = DroidGuardSignals(context, buildContext.iidToken, rpc),
+ )
+}
+
+@SuppressLint("HardwareIds")
+operator fun DeviceID.Companion.invoke(context: Context, iidToken: String): DeviceID {
+ val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ var gmsAndroidId = prefs.getLong("gms_android_id", 0L)
+ if (gmsAndroidId == 0L) {
+ gmsAndroidId = GServices.getLong(context.contentResolver, "android_id", 0L)
+ if (gmsAndroidId == 0L) {
+ val androidIdStr =
+ android.provider.Settings.Secure.getString(context.contentResolver, "android_id")
+ gmsAndroidId =
+ androidIdStr?.toLongOrNull(16) ?: (Build.ID.hashCode()
+ .toLong() and 0x7FFFFFFFFFFFFFFFL)
+ }
+ prefs.edit { putLong("gms_android_id", gmsAndroidId) }
+ }
+
+ val userSerial = try {
+ val userManager = context.getSystemService()
+ userManager?.getSerialNumberForUser(Process.myUserHandle()) ?: 0L
+ } catch (_: Exception) {
+ 0L
+ }
+
+ var primaryDeviceId = prefs.getLong("primary_device_id", 0L)
+ val isSystemUser = try {
+ val userManager = context.getSystemService()
+ userManager?.isSystemUser ?: true
+ } catch (_: Exception) {
+ true
+ }
+ if (primaryDeviceId == 0L && isSystemUser) {
+ primaryDeviceId = gmsAndroidId
+ prefs.edit { putLong("primary_device_id", primaryDeviceId) }
+ }
+
+ return DeviceID(
+ iid_token = iidToken,
+ primary_device_id = primaryDeviceId,
+ user_serial = userSerial,
+ gms_android_id = gmsAndroidId
+ )
+}
+
+operator fun CountryInfo.Companion.invoke(context: Context): CountryInfo {
+ val simCountries = mutableListOf()
+ val networkCountries = mutableListOf()
+
+ try {
+ val sm = context.getSystemService()
+ val tm = context.getSystemService()
+
+ sm?.activeSubscriptionInfoList?.forEach { info ->
+ val targetTM =
+ tm?.createForSubscriptionId(info.subscriptionId)
+
+ targetTM?.networkCountryIso?.let { networkCountries.add(it.lowercase()) }
+ info.countryIso?.let { simCountries.add(it.lowercase()) }
+ }
+ } catch (e: SecurityException) {
+ Log.w(TAG, "No permission to access country info", e)
+ }
+
+ if (simCountries.isEmpty()) {
+ val tm = context.getSystemService()
+ val simCountry = tm?.simCountryIso?.lowercase()?.takeIf { it.isNotBlank() }
+ if (simCountry != null) simCountries.add(simCountry)
+ }
+ if (networkCountries.isEmpty()) {
+ val tm = context.getSystemService()
+ val networkCountry = tm?.networkCountryIso?.lowercase()?.takeIf { it.isNotBlank() }
+ if (networkCountry != null) networkCountries.add(networkCountry)
+ }
+
+ return CountryInfo(sim_countries = simCountries, network_countries = networkCountries)
+}
+
+suspend operator fun DroidGuardSignals.Companion.invoke(
+ context: Context,
+ iidToken: String,
+ rpc: String
+): DroidGuardSignals? {
+ val cachedToken = ConstellationStateStore.loadDroidGuardToken(context)
+ if (!cachedToken.isNullOrBlank()) {
+ return DroidGuardSignals(droidguard_token = cachedToken, droidguard_result = "")
+ }
+
+ val md = MessageDigest.getInstance("SHA-256")
+ val digest = md.digest(iidToken.toByteArray(Charsets.UTF_8))
+ val iidHash = Base64.encodeToString(
+ digest.copyOf(64),
+ Base64.NO_PADDING or Base64.NO_WRAP
+ ).substring(0, 32)
+
+ return try {
+ val client = DroidGuard.getClient(context)
+ val data = mapOf(
+ "iidHash" to iidHash,
+ "rpc" to rpc
+ )
+ val result = client.getResults("constellation_verify", data, null).await()
+ DroidGuardSignals(droidguard_result = result, droidguard_token = "")
+ } catch (e: Exception) {
+ Log.w(TAG, "DroidGuard generation failed", e)
+ null
+ }
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/CommonBuilders.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/CommonBuilders.kt
new file mode 100644
index 0000000000..f93d8f21ed
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/CommonBuilders.kt
@@ -0,0 +1,71 @@
+package org.microg.gms.constellation.core.proto.builder
+
+import android.content.Context
+import android.os.Build
+import android.os.Bundle
+import androidx.annotation.RequiresApi
+import okio.ByteString.Companion.toByteString
+import org.microg.gms.constellation.core.authManager
+import org.microg.gms.constellation.core.proto.AuditToken
+import org.microg.gms.constellation.core.proto.AuditTokenMetadata
+import org.microg.gms.constellation.core.proto.AuditUuid
+import org.microg.gms.constellation.core.proto.ClientInfo
+import org.microg.gms.constellation.core.proto.DeviceID
+import org.microg.gms.constellation.core.proto.Param
+import org.microg.gms.constellation.core.proto.RequestHeader
+import org.microg.gms.constellation.core.proto.RequestTrigger
+
+fun Param.Companion.getList(extras: Bundle?): List {
+ if (extras == null) return emptyList()
+ val params = mutableListOf()
+ val ignoreKeys = setOf("consent_variant_key", "consent_trigger_key", "gaia_access_token")
+ for (key in extras.keySet()) {
+ if (key !in ignoreKeys) {
+ extras.getString(key)?.let { value ->
+ params.add(Param(key = key, value_ = value))
+ }
+ }
+ }
+ return params
+}
+
+fun AuditToken.Companion.generate(): AuditToken {
+ val uuid = java.util.UUID.randomUUID()
+ return AuditToken(
+ metadata = AuditTokenMetadata(
+ uuid = AuditUuid(
+ uuid_msb = uuid.mostSignificantBits,
+ uuid_lsb = uuid.leastSignificantBits
+ )
+ )
+ )
+}
+
+@RequiresApi(Build.VERSION_CODES.O)
+suspend operator fun RequestHeader.Companion.invoke(
+ context: Context,
+ sessionId: String,
+ buildContext: RequestBuildContext,
+ rpc: String,
+ triggerType: RequestTrigger.Type = RequestTrigger.Type.TRIGGER_API_CALL,
+ includeClientAuth: Boolean = false,
+): RequestHeader {
+ val authManager = if (includeClientAuth) context.authManager else null
+ val clientAuth = if (includeClientAuth) {
+ val (signature, timestamp) = authManager!!.signIidToken(buildContext.iidToken)
+ org.microg.gms.constellation.core.proto.ClientAuth(
+ device_id = DeviceID(context, buildContext.iidToken),
+ signature = signature.toByteString(),
+ sign_timestamp = timestamp
+ )
+ } else {
+ null
+ }
+
+ return RequestHeader(
+ client_info = ClientInfo(context, buildContext, rpc),
+ client_auth = clientAuth,
+ session_id = sessionId,
+ trigger = RequestTrigger(type = triggerType)
+ )
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/GaiaInfoBuilder.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/GaiaInfoBuilder.kt
new file mode 100644
index 0000000000..9c7c7134b0
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/GaiaInfoBuilder.kt
@@ -0,0 +1,85 @@
+package org.microg.gms.constellation.core.proto.builder
+
+import android.accounts.AccountManager
+import android.content.Context
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import com.squareup.wire.Instant
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.microg.gms.constellation.core.proto.GaiaAccountSignalType
+import org.microg.gms.constellation.core.proto.GaiaSignalEntry
+import org.microg.gms.constellation.core.proto.GaiaSignals
+import org.microg.gms.constellation.core.proto.GaiaToken
+
+private const val TAG = "GaiaInfoBuilder"
+
+
+@RequiresApi(Build.VERSION_CODES.O)
+suspend operator fun GaiaSignals.Companion.invoke(context: Context): GaiaSignals? =
+ withContext(Dispatchers.IO) {
+ val entries = mutableListOf()
+ val accountManager = AccountManager.get(context)
+
+ try {
+ val accounts = accountManager.getAccountsByType("com.google")
+
+ for (account in accounts) {
+ var id = accountManager.getUserData(account, "GoogleUserId")
+ if (id == "") {
+ try {
+ val future = accountManager.getAuthToken(
+ account,
+ "^^_account_id_^^",
+ null,
+ false,
+ null,
+ null
+ )
+ id = future.result?.getString(AccountManager.KEY_AUTHTOKEN)
+ accountManager.setUserData(account, "GoogleUserId", id)
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not retrieve Gaia ID for account ${account.name}", e)
+ continue
+ }
+ }
+ entries.add(
+ GaiaSignalEntry(
+ gaia_id = id,
+ signal_type = GaiaAccountSignalType.GAIA_ACCOUNT_SIGNAL_AUTHENTICATED,
+ timestamp = Instant.now()
+ )
+ )
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not build Gaia signals", e)
+ }
+ if (entries.isNotEmpty()) GaiaSignals(gaia_signals = entries) else null
+ }
+
+@Suppress("DEPRECATION")
+suspend fun GaiaToken.Companion.getList(context: Context): List =
+ withContext(Dispatchers.IO) {
+ val accountManager = AccountManager.get(context)
+ val accounts = accountManager.getAccountsByType("com.google")
+ accounts.mapNotNull { account ->
+ try {
+ val future = accountManager.getAuthToken(
+ account,
+ "oauth2:https://www.googleapis.com/auth/numberer",
+ null,
+ false,
+ null,
+ null
+ )
+ val token = future.result?.getString(AccountManager.KEY_AUTHTOKEN)
+
+ if (!token.isNullOrBlank()) GaiaToken(token = token) else null
+
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not retrieve Gaia token for account ${account.name}", e)
+ null
+ }
+ }
+ }
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/RequestBuildContext.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/RequestBuildContext.kt
new file mode 100644
index 0000000000..951255aa86
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/RequestBuildContext.kt
@@ -0,0 +1,21 @@
+package org.microg.gms.constellation.core.proto.builder
+
+import android.content.Context
+import org.microg.gms.constellation.core.AuthManager
+import org.microg.gms.constellation.core.authManager
+import org.microg.gms.constellation.core.proto.GaiaToken
+
+data class RequestBuildContext(
+ val iidToken: String,
+ val gaiaTokens: List
+)
+
+suspend fun buildRequestContext(
+ context: Context,
+ authManager: AuthManager = context.authManager
+): RequestBuildContext {
+ return RequestBuildContext(
+ iidToken = authManager.getIidToken(),
+ gaiaTokens = GaiaToken.getList(context)
+ )
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/SyncRequestBuilder.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/SyncRequestBuilder.kt
new file mode 100644
index 0000000000..b68f0d5991
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/SyncRequestBuilder.kt
@@ -0,0 +1,205 @@
+@file:RequiresApi(Build.VERSION_CODES.O)
+@file:SuppressLint("HardwareIds")
+
+package org.microg.gms.constellation.core.proto.builder
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.telephony.SubscriptionInfo
+import android.telephony.SubscriptionManager
+import android.telephony.TelephonyManager
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import com.google.android.gms.constellation.VerifyPhoneNumberRequest
+import com.google.android.gms.constellation.verificationMethods
+import org.microg.gms.common.Constants
+import org.microg.gms.constellation.core.ConstellationStateStore
+import org.microg.gms.constellation.core.proto.ChallengePreference
+import org.microg.gms.constellation.core.proto.ChallengePreferenceMetadata
+import org.microg.gms.constellation.core.proto.IdTokenRequest
+import org.microg.gms.constellation.core.proto.Param
+import org.microg.gms.constellation.core.proto.RequestHeader
+import org.microg.gms.constellation.core.proto.RequestTrigger
+import org.microg.gms.constellation.core.proto.SIMAssociation
+import org.microg.gms.constellation.core.proto.SIMSlotInfo
+import org.microg.gms.constellation.core.proto.SyncRequest
+import org.microg.gms.constellation.core.proto.TelephonyInfo
+import org.microg.gms.constellation.core.proto.TelephonyPhoneNumberType
+import org.microg.gms.constellation.core.proto.Verification
+import org.microg.gms.constellation.core.proto.VerificationAssociation
+import org.microg.gms.constellation.core.proto.VerificationParam
+import org.microg.gms.constellation.core.proto.VerificationPolicy
+
+private const val TAG = "SyncRequestBuilder"
+
+fun buildImsiToSubscriptionInfoMap(context: Context): Map {
+ val subscriptionManager =
+ context.getSystemService() ?: return emptyMap()
+ val telephonyManager =
+ context.getSystemService() ?: return emptyMap()
+ val map = mutableMapOf()
+
+ try {
+ subscriptionManager.activeSubscriptionInfoList?.forEach { info ->
+ val subsTelephonyManager =
+ telephonyManager.createForSubscriptionId(info.subscriptionId)
+ subsTelephonyManager.subscriberId?.let { imsi ->
+ map[imsi] = info
+ }
+ }
+ } catch (e: SecurityException) {
+ Log.w(TAG, "No permission to read SIM info for SubscriptionInfo mapping", e)
+ }
+ return map
+}
+
+fun getTelephonyPhoneNumbers(
+ context: Context,
+ subscriptionId: Int
+): List {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return emptyList()
+
+ val permissions =
+ listOf(Manifest.permission.READ_PHONE_NUMBERS, Manifest.permission.READ_PHONE_STATE)
+ if (permissions.all {
+ ContextCompat.checkSelfPermission(
+ context,
+ it
+ ) == PackageManager.PERMISSION_DENIED
+ }) return emptyList()
+
+ val subscriptionManager =
+ context.getSystemService() ?: return emptyList()
+
+ val sources = intArrayOf(
+ SubscriptionManager.PHONE_NUMBER_SOURCE_CARRIER,
+ SubscriptionManager.PHONE_NUMBER_SOURCE_UICC,
+ SubscriptionManager.PHONE_NUMBER_SOURCE_IMS
+ )
+
+ return try {
+ sources.map { source ->
+ val number = subscriptionManager.getPhoneNumber(subscriptionId, source)
+ if (number.isNotEmpty()) {
+ SIMAssociation.TelephonyPhoneNumber(
+ phone_number = number,
+ phone_number_type = when (source) {
+ SubscriptionManager.PHONE_NUMBER_SOURCE_CARRIER ->
+ TelephonyPhoneNumberType.PHONE_NUMBER_SOURCE_CARRIER
+
+ SubscriptionManager.PHONE_NUMBER_SOURCE_UICC ->
+ TelephonyPhoneNumberType.PHONE_NUMBER_SOURCE_UICC
+
+ SubscriptionManager.PHONE_NUMBER_SOURCE_IMS ->
+ TelephonyPhoneNumberType.PHONE_NUMBER_SOURCE_IMS
+
+ else -> TelephonyPhoneNumberType.PHONE_NUMBER_SOURCE_UNSPECIFIED
+ }
+ )
+ } else null
+ }.filterNotNull()
+ } catch (e: Exception) {
+ Log.w(TAG, "Error getting telephony phone numbers", e)
+ emptyList()
+ }
+}
+
+suspend operator fun SyncRequest.Companion.invoke(
+ context: Context,
+ sessionId: String,
+ request: VerifyPhoneNumberRequest,
+ includeClientAuth: Boolean = false,
+ callingPackage: String = Constants.GMS_PACKAGE_NAME,
+ triggerType: RequestTrigger.Type = RequestTrigger.Type.TRIGGER_API_CALL
+): SyncRequest {
+ val buildContext = buildRequestContext(context)
+ return SyncRequest(
+ context = context,
+ sessionId = sessionId,
+ request = request,
+ buildContext = buildContext,
+ includeClientAuth = includeClientAuth,
+ callingPackage = callingPackage,
+ triggerType = triggerType
+ )
+}
+
+@Suppress("DEPRECATION")
+suspend operator fun SyncRequest.Companion.invoke(
+ context: Context,
+ sessionId: String,
+ request: VerifyPhoneNumberRequest,
+ buildContext: RequestBuildContext,
+ imsiToInfoMap: Map = buildImsiToSubscriptionInfoMap(context),
+ includeClientAuth: Boolean = false,
+ callingPackage: String = Constants.GMS_PACKAGE_NAME,
+ triggerType: RequestTrigger.Type = RequestTrigger.Type.TRIGGER_API_CALL
+): SyncRequest {
+ val apiParamsList = Param.getList(request.extras)
+
+ val verificationParams = request.targetedSims.map {
+ VerificationParam(key = it.imsi, value_ = it.phoneNumberHint)
+ }
+
+ val structuredParams = VerificationPolicy(
+ policy_id = request.policyId,
+ max_verification_age_hours = request.timeout,
+ id_token_request = IdTokenRequest(
+ certificate_hash = request.idTokenRequest.idToken,
+ token_nonce = request.idTokenRequest.subscriberHash
+ ),
+ calling_package = callingPackage,
+ params = verificationParams
+ )
+
+ val verifications = imsiToInfoMap.map { (imsi, subscriptionInfo) ->
+ val subscriptionId = subscriptionInfo.subscriptionId
+ val slotIndex = subscriptionInfo.simSlotIndex
+ val phoneNumber = subscriptionInfo.number ?: ""
+ val iccid = subscriptionInfo.iccId ?: ""
+
+ Verification(
+ status = Verification.Status.STATUS_NONE,
+ association = VerificationAssociation(
+ sim = SIMAssociation(
+ sim_info = SIMAssociation.SIMInfo(
+ imsi = listOf(imsi),
+ sim_readable_number = phoneNumber,
+ telephony_phone_number = getTelephonyPhoneNumbers(context, subscriptionId),
+ iccid = iccid
+ ),
+ gaia_tokens = buildContext.gaiaTokens,
+ sim_slot = SIMSlotInfo(
+ slot_index = slotIndex,
+ subscription_id = subscriptionId
+ )
+ )
+ ),
+ telephony_info = TelephonyInfo(context, subscriptionId),
+ structured_api_params = structuredParams,
+ api_params = apiParamsList,
+ challenge_preference = ChallengePreference(
+ capabilities = request.verificationMethods,
+ metadata = ChallengePreferenceMetadata()
+ )
+ )
+ }
+
+ return SyncRequest(
+ verifications = verifications,
+ header_ = RequestHeader(
+ context,
+ sessionId,
+ buildContext,
+ "sync",
+ triggerType,
+ includeClientAuth,
+ ),
+ verification_tokens = ConstellationStateStore.loadVerificationTokens(context)
+ )
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/TelephonyInfoBuilder.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/TelephonyInfoBuilder.kt
new file mode 100644
index 0000000000..5bc26d6157
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/TelephonyInfoBuilder.kt
@@ -0,0 +1,232 @@
+@file:RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1)
+@file:SuppressLint("HardwareIds")
+@file:Suppress("DEPRECATION")
+
+package org.microg.gms.constellation.core.proto.builder
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.ConnectivityManager
+import android.os.Build
+import android.os.UserManager
+import android.telephony.ServiceState
+import android.telephony.SubscriptionManager
+import android.telephony.TelephonyManager
+import android.util.Base64
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import org.microg.gms.constellation.core.proto.NetworkSignal
+import org.microg.gms.constellation.core.proto.SimNetworkInfo
+import org.microg.gms.constellation.core.proto.SimOperatorInfo
+import org.microg.gms.constellation.core.proto.TelephonyInfo
+import java.security.MessageDigest
+
+private const val TAG = "TelephonyInfoBuilder"
+
+@SuppressLint("MissingPermission")
+operator fun TelephonyInfo.Companion.invoke(context: Context, subscriptionId: Int): TelephonyInfo {
+ val tm = context.getSystemService()
+ val subscriptionManager = context.getSystemService()
+ val targetTm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && subscriptionId >= 0) {
+ tm?.createForSubscriptionId(subscriptionId)
+ } else tm
+
+ val simState = when (targetTm?.simState) {
+ TelephonyManager.SIM_STATE_READY -> TelephonyInfo.SimState.SIM_STATE_READY
+ else -> TelephonyInfo.SimState.SIM_STATE_NOT_READY
+ }
+
+ val phoneType = when (targetTm?.phoneType) {
+ TelephonyManager.PHONE_TYPE_GSM -> TelephonyInfo.PhoneType.PHONE_TYPE_GSM
+ TelephonyManager.PHONE_TYPE_CDMA -> TelephonyInfo.PhoneType.PHONE_TYPE_CDMA
+ TelephonyManager.PHONE_TYPE_SIP -> TelephonyInfo.PhoneType.PHONE_TYPE_SIP
+ else -> TelephonyInfo.PhoneType.PHONE_TYPE_UNKNOWN
+ }
+
+ val networkRoaming = if (targetTm?.isNetworkRoaming == true) {
+ TelephonyInfo.RoamingState.ROAMING_ROAMING
+ } else {
+ TelephonyInfo.RoamingState.ROAMING_HOME
+ }
+
+ val cm = context.getSystemService()
+ val activeNetwork = cm?.activeNetworkInfo
+ val connectivityState = when {
+ activeNetwork == null -> TelephonyInfo.ConnectivityState.CONNECTIVITY_UNKNOWN
+ activeNetwork.isRoaming -> TelephonyInfo.ConnectivityState.CONNECTIVITY_ROAMING
+ else -> TelephonyInfo.ConnectivityState.CONNECTIVITY_HOME
+ }
+
+ val simInfo = SimNetworkInfo(
+ country_iso = targetTm?.simCountryIso?.lowercase() ?: "",
+ operator_ = targetTm?.simOperator ?: "",
+ operator_name = targetTm?.simOperatorName ?: ""
+ )
+
+ val networkInfo = SimNetworkInfo(
+ country_iso = targetTm?.networkCountryIso?.lowercase() ?: "",
+ operator_ = targetTm?.networkOperator ?: "",
+ operator_name = targetTm?.networkOperatorName ?: ""
+ )
+
+ val (activeSubCount, activeSubCountMax) = getActiveSubCounts(subscriptionManager)
+
+ return TelephonyInfo(
+ phone_type = phoneType,
+ group_id_level1 = targetTm?.groupIdLevel1 ?: "",
+ sim_info = simInfo,
+ network_info = networkInfo,
+ network_roaming = networkRoaming,
+ connectivity_state = connectivityState,
+ sms_capability = getSmsCapability(context, targetTm),
+ active_sub_count = activeSubCount,
+ active_sub_count_max = activeSubCountMax,
+ sim_state = simState,
+ service_state = getServiceState(targetTm),
+ sim_carrier_id = getSimCarrierId(targetTm),
+ is_embedded = false
+ )
+}
+
+private fun getSmsCapability(
+ context: Context,
+ telephonyManager: TelephonyManager?
+): TelephonyInfo.SmsCapability {
+ val hasReadSms =
+ ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
+ PackageManager.PERMISSION_GRANTED
+ val hasSendSms =
+ ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
+ PackageManager.PERMISSION_GRANTED
+
+ if (!hasReadSms || !hasSendSms) {
+ return TelephonyInfo.SmsCapability.SMS_DEFAULT_CAPABILITY
+ }
+
+ val isSmsRestricted =
+ context.getSystemService()?.userRestrictions?.getBoolean(UserManager.DISALLOW_SMS) ==
+ true
+ if (isSmsRestricted) {
+ return TelephonyInfo.SmsCapability.SMS_RESTRICTED
+ }
+
+ return if (telephonyManager?.isSmsCapable == true) {
+ TelephonyInfo.SmsCapability.SMS_CAPABLE
+ } else {
+ TelephonyInfo.SmsCapability.SMS_NOT_CAPABLE
+ }
+}
+
+private fun getActiveSubCounts(subscriptionManager: SubscriptionManager?): Pair {
+ if (subscriptionManager == null) {
+ return 0 to 0
+ }
+
+ return try {
+ subscriptionManager.activeSubscriptionInfoCount to
+ subscriptionManager.activeSubscriptionInfoCountMax
+ } catch (e: SecurityException) {
+ Log.w(TAG, "Could not retrieve active subscription counts", e)
+ 0 to 0
+ }
+}
+
+private fun getServiceState(telephonyManager: TelephonyManager?): TelephonyInfo.ServiceState {
+ val state = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ try {
+ telephonyManager?.serviceState?.state
+ } catch (e: SecurityException) {
+ Log.w(TAG, "Could not retrieve service state", e)
+ null
+ }
+ } else {
+ null
+ }
+
+ return when (state) {
+ ServiceState.STATE_IN_SERVICE -> TelephonyInfo.ServiceState.SERVICE_STATE_IN_SERVICE
+ ServiceState.STATE_OUT_OF_SERVICE -> TelephonyInfo.ServiceState.SERVICE_STATE_OUT_OF_SERVICE
+ ServiceState.STATE_EMERGENCY_ONLY -> TelephonyInfo.ServiceState.SERVICE_STATE_EMERGENCY_ONLY
+ ServiceState.STATE_POWER_OFF -> TelephonyInfo.ServiceState.SERVICE_STATE_POWER_OFF
+ else -> TelephonyInfo.ServiceState.SERVICE_STATE_UNKNOWN
+ }
+}
+
+private fun getSimCarrierId(telephonyManager: TelephonyManager?): Long {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ telephonyManager?.simCarrierId?.toLong() ?: -1L
+ } else {
+ -1L
+ }
+}
+
+fun NetworkSignal.Companion.getList(context: Context): List {
+ val connectivityInfos = mutableListOf()
+ try {
+ val cm = context.getSystemService()
+ cm?.activeNetworkInfo?.let { networkInfo ->
+ val type = when (networkInfo.type) {
+ ConnectivityManager.TYPE_WIFI -> NetworkSignal.Type.TYPE_WIFI
+ ConnectivityManager.TYPE_MOBILE -> NetworkSignal.Type.TYPE_MOBILE
+ else -> NetworkSignal.Type.TYPE_UNKNOWN
+ }
+ val state = when {
+ networkInfo.isConnected -> NetworkSignal.State.STATE_CONNECTED
+ networkInfo.isConnectedOrConnecting && !networkInfo.isConnected -> NetworkSignal.State.STATE_CONNECTING
+ else -> NetworkSignal.State.STATE_DISCONNECTED
+ }
+ val availability =
+ if (networkInfo.isAvailable) NetworkSignal.Availability.AVAILABLE else NetworkSignal.Availability.NOT_AVAILABLE
+
+ connectivityInfos.add(
+ NetworkSignal(
+ type = type,
+ availability = availability,
+ state = state
+ )
+ )
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not retrieve network info", e)
+ }
+ return connectivityInfos
+}
+
+fun SimOperatorInfo.Companion.getList(context: Context): List {
+ val infos = mutableListOf()
+ try {
+ val sm = context.getSystemService()
+ val tm = context.getSystemService()
+
+ sm?.activeSubscriptionInfoList?.forEach { info ->
+ val targetTM = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ tm?.createForSubscriptionId(info.subscriptionId)
+ } else tm
+
+ targetTM?.subscriberId?.let { imsi ->
+ val md = MessageDigest.getInstance("SHA-256")
+ val hash = Base64.encodeToString(
+ md.digest(imsi.toByteArray(Charsets.UTF_8)),
+ Base64.NO_WRAP
+ )
+
+ val simOperator = targetTM.simOperator ?: ""
+ infos.add(
+ SimOperatorInfo(
+ sim_operator = simOperator,
+ imsi_hash = hash
+ )
+ )
+ }
+ }
+ } catch (e: SecurityException) {
+ Log.e(TAG, "No permission to access SIM info for operator list", e)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error hashing IMSI", e)
+ }
+ return infos
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/CarrierIdVerifier.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/CarrierIdVerifier.kt
new file mode 100644
index 0000000000..42d73ac890
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/CarrierIdVerifier.kt
@@ -0,0 +1,109 @@
+@file:RequiresApi(Build.VERSION_CODES.N)
+
+package org.microg.gms.constellation.core.verification
+
+import android.content.Context
+import android.os.Build
+import android.telephony.TelephonyManager
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.content.getSystemService
+import org.microg.gms.constellation.core.proto.CarrierIdChallengeResponse
+import org.microg.gms.constellation.core.proto.CarrierIdError
+import org.microg.gms.constellation.core.proto.Challenge
+import org.microg.gms.constellation.core.proto.ChallengeResponse
+
+private const val TAG = "CarrierIdVerifier"
+
+internal data class CarrierIdSession(
+ val challengeId: String,
+ val subId: Int,
+ var attempts: Int = 0,
+) {
+ fun matches(challengeId: String, subId: Int): Boolean {
+ return this.challengeId == challengeId && this.subId == subId
+ }
+}
+
+fun Challenge.verifyCarrierId(context: Context, subId: Int): ChallengeResponse {
+ val carrierChallenge = carrier_id_challenge ?: return failure(
+ CarrierIdError.CARRIER_ID_ERROR_UNKNOWN_ERROR,
+ "Carrier challenge data missing"
+ )
+ val challengeData = carrierChallenge.isim_request.takeIf { it.isNotEmpty() }
+ ?: return failure(
+ CarrierIdError.CARRIER_ID_ERROR_UNKNOWN_ERROR,
+ "Carrier challenge data missing"
+ )
+ if (subId == -1) return failure(
+ CarrierIdError.CARRIER_ID_ERROR_NO_SIM,
+ "No active subscription for carrier auth"
+ )
+
+ val telephonyManager = context.getSystemService()
+ ?: return failure(
+ CarrierIdError.CARRIER_ID_ERROR_NOT_SUPPORTED,
+ "TelephonyManager unavailable"
+ )
+ val targetManager =
+ telephonyManager.createForSubscriptionId(subId)
+ if (challengeData.startsWith("[ts43]")) {
+ // Not supported for now, try to get the server to dispatch something different
+ return failure(
+ CarrierIdError.CARRIER_ID_ERROR_NOT_SUPPORTED,
+ "TS43-prefixed Carrier ID challenge not supported"
+ )
+ }
+
+ val appType = carrierChallenge.app_type.takeIf { it != 0 } ?: TelephonyManager.APPTYPE_USIM
+
+ return try {
+ val response =
+ targetManager.getIccAuthentication(appType, carrierChallenge.auth_type, challengeData)
+ if (response.isNullOrEmpty()) {
+ failure(CarrierIdError.CARRIER_ID_ERROR_NULL_RESPONSE, "Null ISIM response")
+ } else {
+ ChallengeResponse(
+ carrier_id_response = CarrierIdChallengeResponse(
+ isim_response = response,
+ carrier_id_error = CarrierIdError.CARRIER_ID_ERROR_NO_ERROR
+ )
+ )
+ }
+ } catch (e: SecurityException) {
+ Log.w(TAG, "Unable to read subscription for carrier auth", e)
+ failure(
+ CarrierIdError.CARRIER_ID_ERROR_UNABLE_TO_READ_SUBSCRIPTION,
+ e.message ?: "SecurityException"
+ )
+ } catch (e: UnsupportedOperationException) {
+ Log.w(TAG, "Carrier auth API unavailable", e)
+ failure(
+ CarrierIdError.CARRIER_ID_ERROR_NOT_SUPPORTED,
+ e.message ?: "UnsupportedOperationException"
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Carrier auth failed", e)
+ failure(
+ CarrierIdError.CARRIER_ID_ERROR_REFLECTION_ERROR,
+ e.message ?: "Reflection or platform error"
+ )
+ }
+}
+
+fun retryExceededCarrierId(): ChallengeResponse {
+ return failure(
+ CarrierIdError.CARRIER_ID_ERROR_RETRY_ATTEMPT_EXCEEDED,
+ "Carrier ID retry attempt exceeded"
+ )
+}
+
+private fun failure(status: CarrierIdError, message: String): ChallengeResponse {
+ Log.w(TAG, message)
+ return ChallengeResponse(
+ carrier_id_response = CarrierIdChallengeResponse(
+ isim_response = "",
+ carrier_id_error = status
+ )
+ )
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ChallengeProcessor.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ChallengeProcessor.kt
new file mode 100644
index 0000000000..e121a44dfe
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ChallengeProcessor.kt
@@ -0,0 +1,217 @@
+@file:RequiresApi(Build.VERSION_CODES.O)
+
+package org.microg.gms.constellation.core.verification
+
+import android.content.Context
+import android.os.Build
+import android.telephony.SubscriptionInfo
+import android.util.Log
+import androidx.annotation.RequiresApi
+import com.squareup.wire.GrpcException
+import com.squareup.wire.GrpcStatus
+import kotlinx.coroutines.delay
+import org.microg.gms.constellation.core.ConstellationStateStore
+import org.microg.gms.constellation.core.RpcClient
+import org.microg.gms.constellation.core.proto.ChallengeResponse
+import org.microg.gms.constellation.core.proto.ProceedRequest
+import org.microg.gms.constellation.core.proto.RequestHeader
+import org.microg.gms.constellation.core.proto.Verification
+import org.microg.gms.constellation.core.proto.VerificationMethod
+import org.microg.gms.constellation.core.proto.builder.RequestBuildContext
+import org.microg.gms.constellation.core.proto.builder.invoke
+import org.microg.gms.constellation.core.state
+
+object ChallengeProcessor {
+ private const val TAG = "ChallengeProcessor"
+ private const val MAX_PROCEED_ROUNDS = 16
+
+ private fun challengeTimeRemainingMillis(verification: Verification): Long? {
+ val expiry = verification.pending_verification_info?.challenge?.expiry_time ?: return null
+ val targetMillis = expiry.timestamp?.toEpochMilli() ?: return null
+ val referenceMillis = expiry.now?.toEpochMilli() ?: return null
+ return targetMillis - referenceMillis
+ }
+
+ suspend fun process(
+ context: Context,
+ sessionId: String,
+ imsiToInfoMap: Map,
+ buildContext: RequestBuildContext,
+ verification: Verification,
+ ): Verification {
+ var currentVerification = verification
+ var moSession: MoSmsSession? = null
+ var carrierIdSession: CarrierIdSession? = null
+
+ for (attempt in 1..MAX_PROCEED_ROUNDS) {
+ if (currentVerification.state != Verification.State.PENDING) {
+ Log.d(
+ TAG,
+ "Verification state: ${currentVerification.state}. Stopping sequential verification."
+ )
+ return currentVerification
+ }
+
+ val challenge = currentVerification.pending_verification_info?.challenge
+ if (challenge == null) {
+ Log.w(
+ TAG,
+ "Attempt $attempt: Pending verification but no challenge found. Stopping."
+ )
+ return currentVerification
+ }
+
+ val challengeId = challenge.challenge_id?.id ?: ""
+ val remainingMillis = challengeTimeRemainingMillis(currentVerification)
+ if (remainingMillis != null && remainingMillis <= 0L) {
+ if (challenge.type == VerificationMethod.MT_SMS) {
+ Log.w(
+ TAG,
+ "Attempt $attempt: Challenge $challengeId expired before MT wait. Proceeding with empty MT response."
+ )
+ } else {
+ Log.w(TAG, "Attempt $attempt: Challenge $challengeId expired before proceed")
+ return currentVerification
+ }
+ }
+
+ Log.d(
+ TAG,
+ "Attempt $attempt: Solving challenge ID: $challengeId, Type: ${challenge.type}"
+ )
+
+ val challengeImsi = currentVerification.association?.sim?.sim_info?.imsi?.firstOrNull()
+ val info = imsiToInfoMap[challengeImsi]
+ val subId = info?.subscriptionId ?: -1
+
+ if (challenge.type != VerificationMethod.MO_SMS) {
+ moSession = null
+ }
+ if (challenge.type != VerificationMethod.CARRIER_ID || challenge.ts43_challenge != null) {
+ carrierIdSession = null
+ }
+
+ val challengeResponse: ChallengeResponse? = when (challenge.type) {
+ VerificationMethod.TS43 -> challenge.ts43_challenge?.verify(context, subId)
+
+ VerificationMethod.CARRIER_ID -> {
+ if (challenge.ts43_challenge != null) {
+ challenge.ts43_challenge.verify(context, subId)
+ } else {
+ val activeSession =
+ carrierIdSession?.takeIf { it.matches(challengeId, subId) }
+ ?: CarrierIdSession(challengeId, subId).also {
+ carrierIdSession = it
+ }
+
+ activeSession.attempts += 1
+
+ if (activeSession.attempts >= 4) {
+ Log.w(
+ TAG,
+ "Attempt $attempt: Carrier ID challenge $challengeId is still pending after retry-exceeded response. Stopping."
+ )
+ return currentVerification
+ }
+
+ if (activeSession.attempts >= 3) {
+ Log.w(
+ TAG,
+ "Attempt $attempt: Carrier ID challenge $challengeId exceeded retry budget. Proceeding with retry-exceeded response."
+ )
+ retryExceededCarrierId()
+ } else {
+ challenge.verifyCarrierId(context, subId)
+ }
+ }
+ }
+
+ VerificationMethod.MT_SMS -> challenge.mt_challenge?.verify(subId, remainingMillis)
+
+ VerificationMethod.MO_SMS -> challenge.mo_challenge?.let { moChallenge ->
+ val activeSession = moSession?.takeIf { it.matches(challengeId, subId) }
+ if (activeSession != null) {
+ val delayMillis = activeSession.nextPollingDelayMillis(remainingMillis)
+ if (delayMillis > 0L) {
+ Log.d(
+ TAG,
+ "Attempt $attempt: Challenge $challengeId still pending. Waiting ${delayMillis}ms before next MO proceed ping."
+ )
+ delay(delayMillis)
+ }
+ activeSession.response
+ } else {
+ moChallenge.startSession(context, challengeId, subId).also {
+ moSession = it
+ }.response
+ }
+ }
+
+ VerificationMethod.REGISTERED_SMS -> challenge.registered_sms_challenge?.verify(
+ context,
+ subId
+ )
+
+ else -> {
+ moSession = null
+ Log.w(TAG, "Unsupported verification method: ${challenge.type}")
+ null
+ }
+ }
+
+ if (challengeResponse != null) {
+ Log.d(TAG, "Attempt $attempt: Challenge successfully solved. Proceeding...")
+ val proceedHeader = RequestHeader(
+ context,
+ sessionId,
+ buildContext,
+ "proceed",
+ includeClientAuth = true,
+ )
+ val proceedRequest = ProceedRequest(
+ verification = currentVerification,
+ challenge_response = challengeResponse,
+ header_ = proceedHeader
+ )
+ val proceedResponse = try {
+ RpcClient.phoneDeviceVerificationClient.Proceed().execute(proceedRequest)
+ } catch (e: GrpcException) {
+ if (e.grpcStatus == GrpcStatus.PERMISSION_DENIED ||
+ e.grpcStatus == GrpcStatus.UNAUTHENTICATED
+ ) {
+ Log.w(
+ TAG,
+ "Suspicious client status ${e.grpcStatus.name}. Clearing DroidGuard cache..."
+ )
+ ConstellationStateStore.clearDroidGuardToken(context)
+ }
+ throw e
+ }
+ ConstellationStateStore.storeProceedResponse(context, proceedResponse)
+ currentVerification = proceedResponse.verification ?: currentVerification
+
+ if (challenge.type == VerificationMethod.CARRIER_ID) {
+ val nextChallenge = currentVerification.pending_verification_info?.challenge
+ val sameCarrierIdChallenge =
+ nextChallenge?.type == VerificationMethod.CARRIER_ID &&
+ nextChallenge.challenge_id?.id == challengeId &&
+ nextChallenge.ts43_challenge == null
+
+ if (!sameCarrierIdChallenge) {
+ carrierIdSession = null
+ }
+ }
+ } else {
+ Log.w(
+ TAG,
+ "Attempt $attempt: Challenge verification failed or returned no response."
+ )
+ // GMS continues looping if IMSI doesn't match or other issues, but here we return to avoid infinite retries if verifier is broken
+ return currentVerification
+ }
+ }
+
+ Log.w(TAG, "Exhausted all $MAX_PROCEED_ROUNDS proceed rounds, record is still pending.")
+ return currentVerification
+ }
+}
\ No newline at end of file
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/MoSmsVerifier.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/MoSmsVerifier.kt
new file mode 100644
index 0000000000..458712c4cb
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/MoSmsVerifier.kt
@@ -0,0 +1,264 @@
+@file:RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1)
+
+package org.microg.gms.constellation.core.verification
+
+import android.Manifest
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
+import android.os.Build
+import android.telephony.SmsManager
+import android.telephony.SubscriptionManager
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withTimeoutOrNull
+import org.microg.gms.constellation.core.proto.ChallengeResponse
+import org.microg.gms.constellation.core.proto.MOChallengeResponseData
+import org.microg.gms.constellation.core.proto.MoChallenge
+import java.util.UUID
+import kotlin.coroutines.resume
+
+private const val TAG = "MoSmsVerifier"
+private const val ACTION_MO_SMS_SENT = "org.microg.gms.constellation.core.MO_SMS_SENT"
+private const val DEFAULT_MO_POLLING_INTERVALS_MILLIS =
+ "4000,1000,1000,3000,5000,5000,5000,5000,30000,30000,30000,240000,600000,300000"
+
+data class MoSmsSession(
+ val challengeId: String,
+ val subId: Int,
+ val response: ChallengeResponse,
+ private val pollingIntervalsMillis: List,
+ private var nextPollingIndex: Int = 0,
+) {
+ fun matches(challengeId: String, subId: Int): Boolean {
+ return this.challengeId == challengeId && this.subId == subId
+ }
+
+ fun nextPollingDelayMillis(remainingMillis: Long?): Long {
+ val configuredDelay = pollingIntervalsMillis.getOrNull(nextPollingIndex)
+ if (configuredDelay != null) {
+ nextPollingIndex += 1
+ }
+ val delayMillis = configuredDelay ?: remainingMillis ?: 0L
+ return if (remainingMillis != null) {
+ delayMillis.coerceAtMost(remainingMillis.coerceAtLeast(0L))
+ } else {
+ delayMillis.coerceAtLeast(0L)
+ }
+ }
+}
+
+suspend fun MoChallenge.startSession(
+ context: Context,
+ challengeId: String,
+ subId: Int
+): MoSmsSession {
+ return MoSmsSession(
+ challengeId = challengeId,
+ subId = subId,
+ response = sendOnce(context, subId),
+ pollingIntervalsMillis = pollingIntervalsMillis()
+ )
+}
+
+private suspend fun MoChallenge.sendOnce(context: Context, subId: Int): ChallengeResponse {
+ if (proxy_number.isEmpty() || sms.isEmpty()) {
+ return failedMoResponse()
+ }
+ if (context.checkCallingOrSelfPermission(Manifest.permission.SEND_SMS) != PackageManager.PERMISSION_GRANTED) {
+ Log.w(TAG, "SEND_SMS permission missing")
+ return failedMoResponse()
+ }
+
+ val smsManager = resolveSmsManager(context, subId) ?: return ChallengeResponse(
+ mo_response = MOChallengeResponseData(
+ status = if (subId != -1 && !isActiveSubscription(context, subId)) {
+ MOChallengeResponseData.Status.NO_ACTIVE_SUBSCRIPTION
+ } else {
+ MOChallengeResponseData.Status.NO_SMS_MANAGER
+ }
+ )
+ )
+
+ val port = data_sms_info?.destination_port ?: 0
+ val isBinarySms = port > 0
+ val action = ACTION_MO_SMS_SENT
+ val messageId = UUID.randomUUID().toString()
+ val sentIntent = Intent(action).apply {
+ `package` = context.packageName
+ putExtra("message_id", messageId)
+ }
+
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ messageId.hashCode(),
+ sentIntent,
+ PendingIntent.FLAG_ONE_SHOT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
+ )
+
+ Log.d(TAG, "Sending MO SMS to $proxy_number with messageId: $messageId")
+
+ return withTimeoutOrNull(30_000L) {
+ suspendCancellableCoroutine { continuation ->
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == action) {
+ val receivedId = intent.getStringExtra("message_id")
+ if (receivedId != messageId) return
+
+ val resultCode = resultCode
+ val errorCode = intent.getIntExtra("errorCode", -1)
+
+ Log.d(TAG, "MO SMS sent result: $resultCode, error: $errorCode")
+
+ val status = when (resultCode) {
+ -1 -> MOChallengeResponseData.Status.COMPLETED
+ else -> MOChallengeResponseData.Status.FAILED_TO_SEND_MO
+ }
+
+ try {
+ context.unregisterReceiver(this)
+ } catch (_: Exception) {
+ }
+
+ if (continuation.isActive) {
+ continuation.resume(
+ ChallengeResponse(
+ mo_response = MOChallengeResponseData(
+ status = status,
+ sms_result_code = resultCode.toLong(),
+ sms_error_code = errorCode.toLong()
+ )
+ )
+ )
+ }
+ }
+ }
+ }
+
+ ContextCompat.registerReceiver(
+ context,
+ receiver,
+ IntentFilter(action),
+ ContextCompat.RECEIVER_NOT_EXPORTED
+ )
+
+ continuation.invokeOnCancellation {
+ try {
+ context.unregisterReceiver(receiver)
+ } catch (_: Exception) {
+ }
+ }
+
+ try {
+ if (isBinarySms) {
+ smsManager.sendDataMessage(
+ proxy_number,
+ null,
+ port.toShort(),
+ sms.encodeToByteArray(),
+ pendingIntent,
+ null
+ )
+ } else {
+ smsManager.sendTextMessage(
+ proxy_number,
+ null,
+ sms,
+ pendingIntent,
+ null
+ )
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to initiate MO SMS send", e)
+ try {
+ context.unregisterReceiver(receiver)
+ } catch (_: Exception) {
+ }
+ if (continuation.isActive) {
+ continuation.resume(failedMoResponse())
+ }
+ }
+ }
+ } ?: failedMoResponse()
+}
+
+private fun MoChallenge.pollingIntervalsMillis(): List {
+ val configuredIntervals = polling_intervals.parsePollingIntervals()
+ return configuredIntervals.ifEmpty {
+ DEFAULT_MO_POLLING_INTERVALS_MILLIS.parsePollingIntervals()
+ }
+}
+
+private fun String.parsePollingIntervals(): List {
+ return split(',')
+ .mapNotNull { it.trim().toLongOrNull() }
+ .filter { it > 0L }
+}
+
+private fun failedMoResponse(): ChallengeResponse {
+ return ChallengeResponse(
+ mo_response = MOChallengeResponseData(
+ status = MOChallengeResponseData.Status.FAILED_TO_SEND_MO
+ )
+ )
+}
+
+private fun isActiveSubscription(context: Context, subId: Int): Boolean {
+ if (subId == -1) return true
+
+ if (ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_PHONE_STATE
+ ) == PackageManager.PERMISSION_DENIED
+ ) {
+ Log.e(TAG, "Permission not granted")
+ return false
+ }
+
+ return try {
+ val subManager = context.getSystemService() ?: return false
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ subManager.isActiveSubscriptionId(subId)
+ } else {
+ subManager.getActiveSubscriptionInfo(subId) != null
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to query active subscription for $subId", e)
+ false
+ }
+}
+
+@Suppress("DEPRECATION")
+private fun resolveSmsManager(context: Context, subId: Int): SmsManager? {
+ if (subId != -1 && !isActiveSubscription(context, subId)) {
+ return null
+ }
+
+ return try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val manager = context.getSystemService()
+ if (subId != -1) {
+ manager?.createForSubscriptionId(subId)
+ } else {
+ manager
+ }
+ } else {
+ if (subId != -1) {
+ SmsManager.getSmsManagerForSubscriptionId(subId)
+ } else {
+ SmsManager.getDefault()
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to resolve SmsManager for subId: $subId", e)
+ null
+ }
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/MtSmsVerifier.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/MtSmsVerifier.kt
new file mode 100644
index 0000000000..7a522c1e18
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/MtSmsVerifier.kt
@@ -0,0 +1,176 @@
+package org.microg.gms.constellation.core.verification
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.provider.Telephony
+import android.telephony.SmsMessage
+import android.util.Log
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withTimeoutOrNull
+import org.microg.gms.constellation.core.proto.ChallengeResponse
+import org.microg.gms.constellation.core.proto.MTChallenge
+import org.microg.gms.constellation.core.proto.MTChallengeResponseData
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
+import kotlin.coroutines.resume
+
+private const val TAG = "MtSmsVerifier"
+
+suspend fun MTChallenge.verify(
+ subId: Int,
+ timeoutMillis: Long? = null
+): ChallengeResponse? {
+ val expectedBody = sms.takeIf { it.isNotEmpty() } ?: return null
+ val inbox = MtSmsInboxRegistry.get(subId)
+ val effectiveTimeoutMillis = timeoutMillis?.coerceAtLeast(0L) ?: TimeUnit.MINUTES.toMillis(30)
+
+ Log.d(TAG, "Waiting for MT SMS containing challenge string")
+
+ val match = withTimeoutOrNull(effectiveTimeoutMillis) {
+ inbox.awaitMatch(expectedBody)
+ }
+
+ if (match == null) {
+ Log.w(TAG, "Timed out waiting for MT SMS, proceeding with empty response")
+ }
+
+ val response = match ?: ReceivedSms(body = "", sender = "")
+ return ChallengeResponse(
+ mt_response = MTChallengeResponseData(
+ sms = response.body,
+ sender = response.sender
+ )
+ )
+}
+
+internal object MtSmsInboxRegistry {
+ private val inboxes = ConcurrentHashMap()
+
+ fun prepare(context: Context, subIds: Iterable) {
+ dispose()
+
+ val effectiveSubIds = subIds.distinct().ifEmpty { listOf(-1) }
+ for (subId in effectiveSubIds) {
+ inboxes[subId] = MtSmsInbox(context.applicationContext, subId)
+ }
+ }
+
+ fun get(subId: Int): MtSmsInbox {
+ return inboxes[subId]
+ ?: error("MT SMS inbox for subId=$subId was not initialized")
+ }
+
+ fun dispose() {
+ val currentInboxes = inboxes.values.toList()
+ inboxes.clear()
+ for (inbox in currentInboxes) {
+ inbox.dispose()
+ }
+ }
+}
+
+internal data class ReceivedSms(
+ val body: String,
+ val sender: String
+)
+
+private data class PendingMatch(
+ val expectedBody: String,
+ val continuation: CancellableContinuation
+)
+
+internal class MtSmsInbox(
+ context: Context,
+ private val subId: Int
+) {
+ private val context = context.applicationContext
+ private val lock = Any()
+ private val bufferedMessages = mutableListOf()
+ private val pendingMatches = mutableListOf()
+
+ private val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
+
+ val receivedSubId = intent.getIntExtra(
+ "android.telephony.extra.SUBSCRIPTION_INDEX",
+ intent.getIntExtra("subscription", -1)
+ )
+ if (subId != -1 && receivedSubId != subId) return
+
+ onMessagesReceived(Telephony.Sms.Intents.getMessagesFromIntent(intent))
+ }
+ }
+
+ init {
+ val filter = IntentFilter(Telephony.Sms.Intents.SMS_RECEIVED_ACTION)
+ ContextCompat.registerReceiver(
+ context,
+ receiver,
+ filter,
+ ContextCompat.RECEIVER_NOT_EXPORTED
+ )
+ }
+
+ suspend fun awaitMatch(expectedBody: String): ReceivedSms? {
+ return suspendCancellableCoroutine { continuation ->
+ synchronized(lock) {
+ bufferedMessages.firstOrNull { it.body.contains(expectedBody) }?.let {
+ continuation.resume(it)
+ return@suspendCancellableCoroutine
+ }
+
+ val pendingMatch = PendingMatch(expectedBody, continuation)
+ pendingMatches += pendingMatch
+ continuation.invokeOnCancellation {
+ synchronized(lock) {
+ pendingMatches.remove(pendingMatch)
+ }
+ }
+ }
+ }
+ }
+
+ private fun onMessagesReceived(messages: Array) {
+ val receivedMessages = messages.mapNotNull { message ->
+ val body = message.messageBody ?: return@mapNotNull null
+ ReceivedSms(
+ body = body,
+ sender = message.originatingAddress ?: ""
+ )
+ }
+ if (receivedMessages.isEmpty()) return
+
+ synchronized(lock) {
+ bufferedMessages += receivedMessages
+ for (receivedMessage in receivedMessages) {
+ val iterator = pendingMatches.iterator()
+ while (iterator.hasNext()) {
+ val pendingMatch = iterator.next()
+ if (!receivedMessage.body.contains(pendingMatch.expectedBody)) continue
+
+ iterator.remove()
+ Log.d(TAG, "Matching MT SMS received from ${receivedMessage.sender}")
+ if (pendingMatch.continuation.isActive) {
+ pendingMatch.continuation.resume(receivedMessage)
+ }
+ }
+ }
+ }
+ }
+
+ fun dispose() {
+ synchronized(lock) {
+ pendingMatches.clear()
+ bufferedMessages.clear()
+ }
+ try {
+ context.unregisterReceiver(receiver)
+ } catch (_: IllegalArgumentException) {
+ }
+ }
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/RegisteredSmsVerifier.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/RegisteredSmsVerifier.kt
new file mode 100644
index 0000000000..b010277b41
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/RegisteredSmsVerifier.kt
@@ -0,0 +1,184 @@
+@file:RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1)
+
+package org.microg.gms.constellation.core.verification
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.provider.Telephony
+import android.telephony.SubscriptionManager
+import android.telephony.TelephonyManager
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import okio.ByteString.Companion.toByteString
+import org.microg.gms.constellation.core.VerificationSettingsPhenotypes
+import org.microg.gms.constellation.core.proto.ChallengeResponse
+import org.microg.gms.constellation.core.proto.RegisteredSmsChallenge
+import org.microg.gms.constellation.core.proto.RegisteredSmsChallengeResponse
+import org.microg.gms.constellation.core.proto.RegisteredSmsChallengeResponseItem
+import org.microg.gms.constellation.core.proto.RegisteredSmsChallengeResponsePayload
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
+import java.util.concurrent.TimeUnit
+
+private const val TAG = "RegisteredSmsVerifier"
+
+fun RegisteredSmsChallenge.verify(context: Context, subId: Int): ChallengeResponse {
+ val expectedPayloads = verified_senders
+ .map { it.phone_number_id.toByteArray() }
+ .filter { it.isNotEmpty() }
+ if (expectedPayloads.isEmpty()) return ChallengeResponse(
+ registered_sms_response = RegisteredSmsChallengeResponse(
+ items = emptyList()
+ )
+ )
+
+ val localNumbers = getLocalNumbers(context, subId)
+ if (localNumbers.isEmpty()) {
+ Log.w(TAG, "No local phone numbers available for registered SMS verification")
+ return ChallengeResponse(registered_sms_response = RegisteredSmsChallengeResponse(items = emptyList()))
+ }
+
+ val historyStart = System.currentTimeMillis() -
+ TimeUnit.HOURS.toMillis(VerificationSettingsPhenotypes.A2P_HISTORY_WINDOW_HOURS)
+ val bucketSizeMillis = (TimeUnit.HOURS.toMillis(
+ VerificationSettingsPhenotypes.A2P_SMS_SIGNAL_GRANULARITY_HRS
+ ) / 2).coerceAtLeast(1L)
+
+ val responseItems = mutableListOf()
+
+ try {
+ val cursor = context.contentResolver.query(
+ Telephony.Sms.Inbox.CONTENT_URI,
+ arrayOf("date", "address", "body", "sub_id"),
+ "date > ?",
+ arrayOf(historyStart.toString()),
+ "date DESC"
+ ) ?: return ChallengeResponse(
+ registered_sms_response = RegisteredSmsChallengeResponse(
+ items = emptyList()
+ )
+ )
+
+ cursor.use {
+ while (it.moveToNext()) {
+ val date = it.getLong(it.getColumnIndexOrThrow("date"))
+ val sender = it.getString(it.getColumnIndexOrThrow("address")) ?: continue
+ val body = it.getString(it.getColumnIndexOrThrow("body")) ?: continue
+ val messageSubId = runCatching {
+ it.getInt(it.getColumnIndexOrThrow("sub_id"))
+ }.getOrDefault(-1)
+
+ val candidateNumbers = if (subId != -1 && messageSubId == subId) {
+ localNumbers
+ } else if (messageSubId == -1) {
+ localNumbers
+ } else {
+ getLocalNumbers(context, messageSubId).ifEmpty { localNumbers }
+ }
+
+ val bucketStart = date - (date % bucketSizeMillis)
+ for (localNumber in candidateNumbers) {
+ val payload = computePayload(bucketStart, localNumber, sender, body)
+ if (expectedPayloads.any { expected -> expected.contentEquals(payload) }) {
+ responseItems += RegisteredSmsChallengeResponseItem(
+ payload = RegisteredSmsChallengeResponsePayload(
+ payload = payload.toByteString()
+ )
+ )
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Registered SMS verification failed", e)
+ return ChallengeResponse(registered_sms_response = RegisteredSmsChallengeResponse(items = emptyList()))
+ }
+
+ return ChallengeResponse(
+ registered_sms_response = RegisteredSmsChallengeResponse(items = responseItems.distinct())
+ )
+}
+
+@SuppressLint("HardwareIds")
+@Suppress("DEPRECATION")
+private fun getLocalNumbers(context: Context, targetSubId: Int): List {
+ val numbers = linkedSetOf()
+ val subscriptionManager =
+ context.getSystemService()
+ val telephonyManager = context.getSystemService()
+
+ val hasState = ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_PHONE_STATE
+ ) == PackageManager.PERMISSION_GRANTED
+ val isCarrier = telephonyManager?.hasCarrierPrivileges() == true
+ val hasNumbers = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_PHONE_NUMBERS
+ ) == PackageManager.PERMISSION_GRANTED
+ } else {
+ hasState
+ }
+
+ if (!isCarrier && (!hasState || !hasNumbers)) {
+ Log.e(TAG, "Permission not granted")
+ return emptyList()
+ }
+
+ try {
+ subscriptionManager?.activeSubscriptionInfoList.orEmpty().forEach { info ->
+ if (targetSubId != -1 && info.subscriptionId != targetSubId) return@forEach
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && subscriptionManager != null) {
+ numbers += subscriptionManager.getPhoneNumber(
+ info.subscriptionId,
+ SubscriptionManager.PHONE_NUMBER_SOURCE_CARRIER
+ )
+ numbers += subscriptionManager.getPhoneNumber(
+ info.subscriptionId,
+ SubscriptionManager.PHONE_NUMBER_SOURCE_UICC
+ )
+ numbers += subscriptionManager.getPhoneNumber(
+ info.subscriptionId,
+ SubscriptionManager.PHONE_NUMBER_SOURCE_IMS
+ )
+ }
+ val targetManager =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ telephonyManager?.createForSubscriptionId(info.subscriptionId)
+ } else {
+ telephonyManager
+ }
+ numbers += targetManager?.line1Number.orEmpty()
+ numbers += info.number.orEmpty()
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Unable to collect local phone numbers", e)
+ }
+
+ return numbers.filter { it.isNotBlank() }.distinct()
+}
+
+private fun computePayload(
+ bucketStart: Long,
+ localNumber: String,
+ sender: String,
+ body: String
+): ByteArray {
+ val digest = MessageDigest.getInstance("SHA-512")
+ digest.update(bucketStart.toString().toByteArray(StandardCharsets.UTF_8))
+ digest.update(hashUtf8(localNumber))
+ digest.update(hashUtf8(sender))
+ digest.update(body.toByteArray(StandardCharsets.UTF_8))
+ return digest.digest()
+}
+
+private fun hashUtf8(value: String): ByteArray {
+ return MessageDigest.getInstance("SHA-512")
+ .digest(value.toByteArray(StandardCharsets.UTF_8))
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/Ts43Verifier.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/Ts43Verifier.kt
new file mode 100644
index 0000000000..a43ef69f95
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/Ts43Verifier.kt
@@ -0,0 +1,417 @@
+@file:RequiresApi(Build.VERSION_CODES.O)
+@file:SuppressLint("HardwareIds")
+
+package org.microg.gms.constellation.core.verification
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Build
+import android.telephony.TelephonyManager
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.content.getSystemService
+import okhttp3.Cookie
+import okhttp3.CookieJar
+import okhttp3.HttpUrl
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import org.microg.gms.constellation.core.proto.ChallengeResponse
+import org.microg.gms.constellation.core.proto.ClientChallengeResponse
+import org.microg.gms.constellation.core.proto.OdsaOperation
+import org.microg.gms.constellation.core.proto.ServerChallengeResponse
+import org.microg.gms.constellation.core.proto.ServiceEntitlementRequest
+import org.microg.gms.constellation.core.proto.Ts43Challenge
+import org.microg.gms.constellation.core.proto.Ts43ChallengeResponse
+import org.microg.gms.constellation.core.proto.Ts43ChallengeResponseError
+import org.microg.gms.constellation.core.proto.Ts43ChallengeResponseStatus
+import org.microg.gms.constellation.core.verification.ts43.EapAkaService
+import org.microg.gms.constellation.core.verification.ts43.builder
+import org.microg.gms.constellation.core.verification.ts43.userAgent
+import org.xml.sax.InputSource
+import java.io.IOException
+import java.io.StringReader
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+import javax.xml.parsers.DocumentBuilderFactory
+
+private const val TAG = "Ts43Verifier"
+
+fun Ts43Challenge.verify(context: Context, subId: Int): ChallengeResponse {
+ val verifier = InternalTs43Verifier(context, subId)
+ return verifier.verify(this)
+}
+
+private class InternalTs43Verifier(private val context: Context, private val subId: Int) {
+ private val httpHistory = mutableListOf()
+
+ private val telephonyManager: TelephonyManager by lazy {
+ val tm = requireNotNull(context.getSystemService()) {
+ "TelephonyManager unavailable"
+ }
+ if (subId >= 0) {
+ tm.createForSubscriptionId(subId)
+ } else tm
+ }
+
+ private val eapAkaService by lazy { EapAkaService(telephonyManager) }
+
+ fun verify(challenge: Ts43Challenge): ChallengeResponse {
+ httpHistory.clear()
+
+ return try {
+ when {
+ challenge.client_challenge != null -> {
+ val op = challenge.client_challenge.operation
+ Log.d(TAG, "TS43: Client ODSA challenge route: ${op?.operation}")
+ if (op == null) {
+ buildFailureResponse(
+ challenge,
+ Ts43ChallengeResponseStatus.Code.TS43_STATUS_CHALLENGE_NOT_SET
+ )
+ } else {
+ val requestPayload = buildOdsaRequestPayload(
+ challenge.entitlement_url,
+ challenge.eap_aka_realm,
+ challenge.service_entitlement_request,
+ challenge.app_id
+ )
+ if (requestPayload == null) {
+ buildFailureResponse(
+ challenge,
+ Ts43ChallengeResponseStatus.Code.TS43_STATUS_INTERNAL_ERROR
+ )
+ } else {
+ val responseBody = performOdsaRequest(
+ challenge.entitlement_url,
+ op,
+ requestPayload,
+ challenge.app_id,
+ Ts43ChallengeResponseError.RequestType.TS43_REQUEST_TYPE_GET_PHONE_NUMBER_API
+ )
+ ChallengeResponse(
+ ts43_challenge_response = Ts43ChallengeResponse(
+ ts43_type = challenge.ts43_type,
+ client_challenge_response = ClientChallengeResponse(
+ get_phone_number_response = responseBody
+ ),
+ http_history = httpHistory.toList()
+ )
+ )
+ }
+ }
+ }
+
+ challenge.server_challenge != null -> {
+ val op = challenge.server_challenge.operation
+ Log.d(TAG, "TS43: Server ODSA challenge route: ${op?.operation}")
+ if (op == null) {
+ buildFailureResponse(
+ challenge,
+ Ts43ChallengeResponseStatus.Code.TS43_STATUS_CHALLENGE_NOT_SET
+ )
+ } else {
+ val requestPayload = buildOdsaRequestPayload(
+ challenge.entitlement_url,
+ challenge.eap_aka_realm,
+ challenge.service_entitlement_request,
+ challenge.app_id
+ )
+ if (requestPayload == null) {
+ buildFailureResponse(
+ challenge,
+ Ts43ChallengeResponseStatus.Code.TS43_STATUS_INTERNAL_ERROR
+ )
+ } else {
+ val responseBody = performOdsaRequest(
+ challenge.entitlement_url,
+ op,
+ requestPayload,
+ challenge.app_id,
+ Ts43ChallengeResponseError.RequestType.TS43_REQUEST_TYPE_ACQUIRE_TEMPORARY_TOKEN_API
+ )
+ ChallengeResponse(
+ ts43_challenge_response = Ts43ChallengeResponse(
+ ts43_type = challenge.ts43_type,
+ server_challenge_response = ServerChallengeResponse(
+ acquire_temporary_token_response = responseBody
+ ),
+ http_history = httpHistory.toList()
+ )
+ )
+ }
+ }
+ }
+
+ else -> buildFailureResponse(
+ challenge,
+ Ts43ChallengeResponseStatus.Code.TS43_STATUS_CHALLENGE_NOT_SET
+ )
+ }
+ } catch (e: Ts43ApiException) {
+ Log.w(TAG, "TS43 API failure requestType=${e.requestType} http=${e.httpStatus}", e)
+ ChallengeResponse(
+ ts43_challenge_response = Ts43ChallengeResponse(
+ ts43_type = challenge.ts43_type,
+ status = Ts43ChallengeResponseStatus(
+ error = Ts43ChallengeResponseError(
+ error_code = e.errorCode,
+ http_status = e.httpStatus,
+ request_type = e.requestType
+ )
+ ),
+ http_history = httpHistory.toList()
+ )
+ )
+ } catch (e: NullPointerException) {
+ Log.e(TAG, "TS43 null failure", e)
+ buildFailureResponse(
+ challenge,
+ Ts43ChallengeResponseStatus.Code.TS43_STATUS_INTERNAL_ERROR
+ )
+ } catch (e: RuntimeException) {
+ Log.e(TAG, "TS43 runtime failure", e)
+ buildFailureResponse(
+ challenge,
+ Ts43ChallengeResponseStatus.Code.TS43_STATUS_RUNTIME_ERROR
+ )
+ }
+ }
+
+ private fun performOdsaRequest(
+ entitlementUrl: String,
+ op: OdsaOperation,
+ req: ServiceEntitlementRequest?,
+ challengeAppId: String?,
+ requestType: Ts43ChallengeResponseError.RequestType
+ ): String {
+ val builder = op.builder(telephonyManager, req, currentAppIds(challengeAppId))
+ val url = builder.buildBaseUrl(entitlementUrl)
+ val userAgent = req?.userAgent(context) ?: "PRD-TS43 OS-Android/${Build.VERSION.RELEASE}"
+ val accept = req?.accept_content_type?.takeIf { it.isNotEmpty() } ?: "application/json"
+ val acceptLanguage = Locale.getDefault().toLanguageTag().ifEmpty { "en-US" }
+ httpHistory += "GET $url"
+
+ val request = Request.Builder()
+ .url(url)
+ .header("Accept", accept)
+ .header("User-Agent", userAgent)
+ .header("Accept-Language", acceptLanguage)
+ .build()
+
+ return try {
+ val response = okHttpClient.newCall(request).execute()
+ httpHistory += "RESP ${response.code} ${request.url}"
+ if (response.isSuccessful) {
+ response.body?.string()
+ ?: throw Ts43ApiException(
+ errorCode = errorCode(32),
+ httpStatus = response.code,
+ requestType = requestType
+ )
+ } else {
+ Log.w(TAG, "ODSA request failed: ${response.code} for ${op.operation}")
+ throw Ts43ApiException(
+ errorCode = errorCode(31),
+ httpStatus = response.code,
+ requestType = requestType
+ )
+ }
+ } catch (e: IOException) {
+ Log.e(TAG, " Network error in ODSA request", e)
+ throw Ts43ApiException(
+ errorCode = errorCode(30),
+ httpStatus = -1,
+ requestType = requestType,
+ cause = e
+ )
+ }
+ }
+
+ private fun buildOdsaRequestPayload(
+ entitlementUrl: String,
+ eapAkaRealm: String?,
+ req: ServiceEntitlementRequest?,
+ challengeAppId: String?
+ ): ServiceEntitlementRequest? {
+ if (req == null) return null
+ if (req.authentication_token.isNotEmpty() || req.temporary_token.isNotEmpty()) return req
+
+ val mccMnc = telephonyManager.simOperator ?: ""
+ val imsi = telephonyManager.subscriberId ?: ""
+ if (mccMnc.length < 5 || imsi.isEmpty()) return null
+
+ val eapId = eapAkaService.buildEapId(mccMnc, imsi, eapAkaRealm)
+ val builder = req.builder(telephonyManager, eapId, currentAppIds(challengeAppId))
+
+ val initialUrl = builder.buildBaseUrl(entitlementUrl)
+ val postUrl = entitlementUrl
+ val userAgent = req.userAgent(context)
+ val acceptLanguage = Locale.getDefault().toLanguageTag().ifEmpty { "en-US" }
+
+ // 1. Initial Identity Probe (GET)
+ var currentRequest = Request.Builder()
+ .url(initialUrl)
+ .header("Accept", "application/vnd.gsma.eap-relay.v1.0+json")
+ .header("User-Agent", userAgent)
+ .header("Accept-Language", acceptLanguage)
+ .build()
+ httpHistory += "GET $initialUrl"
+
+ // 2. EAP Rounds Loop
+ for (round in 1..3) {
+ val response = try {
+ okHttpClient.newCall(currentRequest).execute()
+ } catch (e: IOException) {
+ Log.e(TAG, "Network error in EAP round $round", e)
+ throw Ts43ApiException(
+ errorCode = errorCode(30),
+ httpStatus = -1,
+ requestType = Ts43ChallengeResponseError.RequestType.TS43_REQUEST_TYPE_AUTH_API,
+ cause = e
+ )
+ }
+
+ val body = response.body?.string() ?: return null
+ httpHistory += "RESP ${response.code} ${currentRequest.url}"
+ if (!response.isSuccessful) {
+ Log.w(TAG, "EAP round $round failed with code ${response.code}")
+ throw Ts43ApiException(
+ errorCode = errorCode(31),
+ httpStatus = response.code,
+ requestType = Ts43ChallengeResponseError.RequestType.TS43_REQUEST_TYPE_AUTH_API
+ )
+ }
+
+ val token = extractAuthToken(body)
+ if (token != null) {
+ return req.copy(authentication_token = token)
+ }
+
+ val eapRelayPacket = extractEapRelayPacket(body)
+ ?: throw Ts43ApiException(
+ errorCode = errorCode(32),
+ httpStatus = response.code,
+ requestType = Ts43ChallengeResponseError.RequestType.TS43_REQUEST_TYPE_AUTH_API
+ )
+
+ val akaResponse =
+ eapAkaService.performSimAkaAuth(eapRelayPacket, imsi, mccMnc) ?: return null
+
+ val postBody = JSONObject().put("eap-relay-packet", akaResponse).toString()
+ currentRequest = Request.Builder()
+ .url(postUrl)
+ .header(
+ "Accept",
+ "application/vnd.gsma.eap-relay.v1.0+json, text/vnd.wap.connectivity-xml"
+ )
+ .header("User-Agent", userAgent)
+ .header("Content-Type", "application/vnd.gsma.eap-relay.v1.0+json")
+ .header("Accept-Language", acceptLanguage)
+ .post(
+ postBody.toByteArray().toRequestBody(
+ "application/vnd.gsma.eap-relay.v1.0+json".toMediaType()
+ )
+ )
+ .build()
+ httpHistory += "POST $postUrl"
+ }
+
+ return null
+ }
+
+ private fun currentAppIds(challengeAppId: String? = null): List {
+ return listOfNotNull(
+ challengeAppId?.takeIf { it.isNotBlank() }
+ ).ifEmpty {
+ listOf("ap2014")
+ }
+ }
+
+ private fun extractEapRelayPacket(body: String): String? {
+ return runCatching { JSONObject(body).optString("eap-relay-packet") }
+ .getOrNull()
+ ?.takeIf { it.isNotEmpty() }
+ }
+
+ private fun extractAuthToken(body: String): String? {
+ runCatching {
+ JSONObject(body).optJSONObject("Token")?.optString("token")
+ }.getOrNull()?.takeIf { it.isNotEmpty() }?.let { return it }
+
+ return runCatching {
+ val normalized = body
+ .replace("&", "&")
+ .replace("&", "&")
+ .replace("\r\n", "\n")
+ val doc = DocumentBuilderFactory.newInstance()
+ .newDocumentBuilder()
+ .parse(InputSource(StringReader(normalized)))
+ val parms = doc.getElementsByTagName("parm")
+ for (i in 0 until parms.length) {
+ val node = parms.item(i)
+ val attrs = node.attributes ?: continue
+ val name = attrs.getNamedItem("name")?.nodeValue ?: continue
+ if (name == "token") {
+ return@runCatching attrs.getNamedItem("value")?.nodeValue
+ }
+ }
+ null
+ }.getOrNull()?.takeIf { it.isNotEmpty() }
+ }
+
+ private fun buildFailureResponse(
+ challenge: Ts43Challenge,
+ statusCode: Ts43ChallengeResponseStatus.Code
+ ): ChallengeResponse {
+ return ChallengeResponse(
+ ts43_challenge_response = Ts43ChallengeResponse(
+ ts43_type = challenge.ts43_type,
+ status = Ts43ChallengeResponseStatus(status_code = statusCode),
+ http_history = httpHistory.toList()
+ )
+ )
+ }
+}
+
+private class Ts43ApiException(
+ val errorCode: Ts43ChallengeResponseError.Code,
+ val httpStatus: Int,
+ val requestType: Ts43ChallengeResponseError.RequestType,
+ cause: Throwable? = null
+) : Exception(cause)
+
+private fun errorCode(rawCode: Int): Ts43ChallengeResponseError.Code =
+ Ts43ChallengeResponseError.Code.fromValue(rawCode)
+ ?: Ts43ChallengeResponseError.Code.TS43_ERROR_CODE_UNSPECIFIED
+
+private val okHttpClient by lazy {
+ OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .followRedirects(false)
+ .followSslRedirects(false)
+ .cookieJar(object : CookieJar {
+ private val cookieStore = mutableMapOf>()
+ override fun saveFromResponse(url: HttpUrl, cookies: List) {
+ val existing = cookieStore.getOrPut(url.host) { mutableListOf() }
+ for (cookie in cookies) {
+ existing.removeAll {
+ it.name == cookie.name &&
+ it.domain == cookie.domain &&
+ it.path == cookie.path
+ }
+ existing += cookie
+ }
+ }
+
+ override fun loadForRequest(url: HttpUrl): List {
+ return cookieStore[url.host]
+ ?.filter { cookie -> cookie.matches(url) }
+ .orEmpty()
+ }
+ })
+ .build()
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/EapAkaService.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/EapAkaService.kt
new file mode 100644
index 0000000000..47fae2cb31
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/EapAkaService.kt
@@ -0,0 +1,211 @@
+package org.microg.gms.constellation.core.verification.ts43
+
+import android.os.Build
+import android.telephony.TelephonyManager
+import android.util.Base64
+import android.util.Log
+import androidx.annotation.RequiresApi
+import java.nio.ByteBuffer
+import java.nio.charset.StandardCharsets
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+
+private const val TAG = "EapAkaService"
+
+class EapAkaService(private val telephonyManager: TelephonyManager) {
+
+ companion object {
+ private const val EAP_CODE_REQUEST = 1
+ private const val EAP_CODE_RESPONSE = 2
+
+ private const val EAP_TYPE_AKA = 23
+
+ private const val EAP_AKA_SUBTYPE_CHALLENGE = 1
+ private const val EAP_AKA_SUBTYPE_SYNC_FAILURE = 4
+
+ private const val AT_RAND = 1
+ private const val AT_AUTN = 2
+ private const val AT_RES = 3
+ private const val AT_AUTS = 4
+ private const val AT_MAC = 11
+
+ private const val SIM_RES_SUCCESS = 0xDB.toByte()
+ private const val SIM_RES_SYNC_FAIL = 0xDC.toByte()
+ }
+
+ @RequiresApi(Build.VERSION_CODES.N)
+ fun performSimAkaAuth(eapRelayBase64: String, imsi: String, mccMnc: String): String? {
+ val eapPacket = Base64.decode(eapRelayBase64, Base64.DEFAULT)
+ if (eapPacket.size < 12) return null
+
+ val code = eapPacket[0].toInt()
+ val eapId = eapPacket[1]
+ val type = eapPacket[4].toInt()
+ val subtype = eapPacket[5].toInt()
+
+ if (code != EAP_CODE_REQUEST || type != EAP_TYPE_AKA || subtype != EAP_AKA_SUBTYPE_CHALLENGE) {
+ Log.w(TAG, "Unexpected EAP packet: code=$code, type=$type, subtype=$subtype")
+ return null
+ }
+
+ // Parse attributes (starting at offset 8)
+ var rand: ByteArray? = null
+ var autn: ByteArray? = null
+
+ var offset = 8
+ while (offset + 2 <= eapPacket.size) {
+ val attrType = eapPacket[offset].toInt() and 0xFF
+ val attrLen = (eapPacket[offset + 1].toInt() and 0xFF) * 4
+ if (offset + attrLen > eapPacket.size || attrLen < 4) break
+
+ when (attrType) {
+ AT_RAND -> {
+ if (attrLen >= 20) {
+ rand = ByteArray(16)
+ System.arraycopy(eapPacket, offset + 4, rand, 0, 16)
+ }
+ }
+
+ AT_AUTN -> {
+ if (attrLen >= 20) {
+ autn = ByteArray(16)
+ System.arraycopy(eapPacket, offset + 4, autn, 0, 16)
+ }
+ }
+ }
+ offset += attrLen
+ if (rand != null && autn != null) break
+ }
+
+ if (rand == null || autn == null) {
+ Log.e(TAG, "Missing RAND or AUTN in EAP-AKA challenge")
+ return null
+ }
+
+ val challengeBytes = byteArrayOf(16) + rand + byteArrayOf(16) + autn
+ val challengeB64 = Base64.encodeToString(challengeBytes, Base64.NO_WRAP)
+
+ val iccAuthResult = telephonyManager.getIccAuthentication(
+ TelephonyManager.APPTYPE_USIM, TelephonyManager.AUTHTYPE_EAP_AKA, challengeB64
+ ) ?: run {
+ Log.e(TAG, "SIM returned null for AKA auth")
+ return null
+ }
+
+ val iccBytes = Base64.decode(iccAuthResult, Base64.DEFAULT)
+ if (iccBytes.isEmpty()) return null
+
+ return when (iccBytes[0]) {
+ SIM_RES_SUCCESS -> {
+ val res = extractTlv(1, iccBytes) ?: return null
+ val ck = extractTlv(1 + res.size + 1, iccBytes) ?: return null
+ val ik = extractTlv(1 + res.size + 1 + ck.size + 1, iccBytes) ?: return null
+
+ val identity = buildEapId(mccMnc, imsi)
+ val identityBytes = identity.toByteArray(StandardCharsets.UTF_8)
+ val keys = Fips186Prf.deriveKeys(identityBytes, ik, ck)
+
+ val kAut = keys["K_aut"] ?: run {
+ Log.e(TAG, "Failed to derive K_aut")
+ return null
+ }
+
+ val responsePacket = buildEapAkaResponse(eapId, res, kAut) ?: return null
+ Base64.encodeToString(responsePacket, Base64.NO_WRAP)
+ }
+
+ SIM_RES_SYNC_FAIL -> {
+ val auts = extractTlv(1, iccBytes) ?: return null
+ val responsePacket = buildEapAkaSyncFailure(eapId, auts)
+ Base64.encodeToString(responsePacket, Base64.NO_WRAP)
+ }
+
+ else -> {
+ Log.e(TAG, "Unknown SIM response tag: ${iccBytes[0]}")
+ null
+ }
+ }
+ }
+
+ fun buildEapId(mccMnc: String, imsi: String, realm: String? = null): String {
+ val mcc = mccMnc.substring(0, 3)
+ var mnc = mccMnc.substring(3)
+ if (mnc.length == 2) mnc = "0$mnc" // Zero-pad 2-digit MNCs
+ val defaultRealm = "nai.epc.mnc$mnc.mcc$mcc.3gppnetwork.org"
+ val resolvedRealm = when {
+ realm.isNullOrBlank() -> defaultRealm
+ realm == "nai.epc" -> defaultRealm
+ realm.contains(".mnc") && realm.contains(".mcc") && realm.contains("3gppnetwork.org") -> realm
+ else -> realm
+ }
+ return "0$imsi@$resolvedRealm"
+ }
+
+ private fun extractTlv(index: Int, data: ByteArray): ByteArray? {
+ if (index >= data.size) return null
+ val len = data[index].toInt() and 0xFF
+ if (index + 1 + len > data.size) return null
+ return data.copyOfRange(index + 1, index + 1 + len)
+ }
+
+ private fun buildEapAkaResponse(id: Byte, res: ByteArray, kAut: ByteArray): ByteArray? {
+ val resAttrLen = ((res.size + 4 + 3) / 4) * 4
+ val totalLen = 8 + resAttrLen + 20
+ val buffer = ByteBuffer.allocate(totalLen)
+
+ buffer.put(EAP_CODE_RESPONSE.toByte())
+ buffer.put(id)
+ buffer.putShort(totalLen.toShort())
+ buffer.put(EAP_TYPE_AKA.toByte())
+ buffer.put(EAP_AKA_SUBTYPE_CHALLENGE.toByte())
+ buffer.putShort(0)
+
+ buffer.put(AT_RES.toByte())
+ buffer.put((resAttrLen / 4).toByte())
+ buffer.putShort((res.size * 8).toShort())
+ buffer.put(res)
+
+ val padding = resAttrLen - 4 - res.size
+ if (padding > 0) buffer.put(ByteArray(padding))
+
+ buffer.position()
+ buffer.put(AT_MAC.toByte())
+ buffer.put(5)
+ buffer.putShort(0)
+ val macValueOffset = buffer.position()
+ buffer.put(ByteArray(16))
+
+ val packet = buffer.array()
+ val mac = hmacSha1(kAut, packet) ?: return null
+
+ System.arraycopy(mac, 0, packet, macValueOffset, 16)
+ return packet
+ }
+
+ private fun buildEapAkaSyncFailure(id: Byte, auts: ByteArray): ByteArray {
+ val attrLen = ((auts.size + 2 + 3) / 4) * 4
+ val totalLen = 8 + attrLen
+
+ return ByteBuffer.allocate(totalLen).apply {
+ put(EAP_CODE_RESPONSE.toByte())
+ put(id)
+ putShort(totalLen.toShort())
+ put(EAP_TYPE_AKA.toByte())
+ put(EAP_AKA_SUBTYPE_SYNC_FAILURE.toByte())
+ putShort(0)
+
+ put(AT_AUTS.toByte())
+ put((attrLen / 4).toByte())
+ put(auts)
+
+ val padding = attrLen - 2 - auts.size
+ if (padding > 0) put(ByteArray(padding))
+ }.array()
+ }
+
+ private fun hmacSha1(key: ByteArray, data: ByteArray): ByteArray? = try {
+ Mac.getInstance("HmacSHA1").apply { init(SecretKeySpec(key, "HmacSHA1")) }.doFinal(data)
+ } catch (_: Exception) {
+ null
+ }
+}
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/Fips186Prf.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/Fips186Prf.kt
new file mode 100644
index 0000000000..618cb719cb
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/Fips186Prf.kt
@@ -0,0 +1,108 @@
+package org.microg.gms.constellation.core.verification.ts43
+
+import java.security.MessageDigest
+import java.security.NoSuchAlgorithmException
+
+object Fips186Prf {
+ fun deriveKeys(
+ identityBytes: ByteArray,
+ ik: ByteArray,
+ ck: ByteArray
+ ): Map {
+ val xKey = try {
+ val md = MessageDigest.getInstance("SHA-1")
+ md.update(identityBytes)
+ md.update(ik)
+ md.update(ck)
+ md.digest()
+ } catch (_: NoSuchAlgorithmException) {
+ ByteArray(20)
+ }
+
+ if (xKey.size != 20) return emptyMap()
+
+ val result = ByteArray(160)
+ val xKeyWorking = xKey.copyOf()
+ var resultOffset = 0
+
+ repeat(8) {
+ val h = intArrayOf(
+ 0x67452301,
+ 0xEFCDAB89.toInt(),
+ 0x98BADCFE.toInt(),
+ 0x10325476,
+ 0xC3D2E1F0.toInt()
+ )
+ val w = IntArray(80)
+ for (k in 0 until 16) {
+ val wordIdx = k * 4
+ w[k] = if (wordIdx < 20) {
+ ((xKeyWorking.getOrElse(wordIdx) { 0 }.toInt() and 0xFF) shl 24) or
+ ((xKeyWorking.getOrElse(wordIdx + 1) { 0 }
+ .toInt() and 0xFF) shl 16) or
+ ((xKeyWorking.getOrElse(wordIdx + 2) { 0 }
+ .toInt() and 0xFF) shl 8) or
+ (xKeyWorking.getOrElse(wordIdx + 3) { 0 }.toInt() and 0xFF)
+ } else 0
+ }
+ for (k in 16 until 80) {
+ val temp = w[k - 3] xor w[k - 8] xor w[k - 14] xor w[k - 16]
+ w[k] = (temp shl 1) or (temp ushr 31)
+ }
+ var a = h[0]
+ var b = h[1]
+ var c = h[2]
+ var d = h[3]
+ var e = h[4]
+ for (t in 0 until 80) {
+ val f: Int
+ val k: Int
+ when {
+ t <= 19 -> {
+ f = (b and c) or (b.inv() and d); k = 0x5A827999
+ }
+
+ t <= 39 -> {
+ f = b xor c xor d; k = 0x6ED9EBA1
+ }
+
+ t <= 59 -> {
+ f = (b and c) or (b and d) or (c and d); k = 0x8F1BBCDC.toInt()
+ }
+
+ else -> {
+ f = b xor c xor d; k = 0xCA62C1D6.toInt()
+ }
+ }
+ val temp = ((a shl 5) or (a ushr 27)) + f + e + k + w[t]
+ e = d; d = c; c = (b shl 30) or (b ushr 2); b = a; a = temp
+ }
+ val block = IntArray(5)
+ block[0] = h[0] + a; block[1] = h[1] + b; block[2] = h[2] + c; block[3] =
+ h[3] + d; block[4] = h[4] + e
+ for (k in 0 until 5) {
+ val word = block[k]
+ result[resultOffset++] = (word shr 24).toByte()
+ result[resultOffset++] = (word shr 16).toByte()
+ result[resultOffset++] = (word shr 8).toByte()
+ result[resultOffset++] = word.toByte()
+ }
+ var carry = 1
+ for (k in 19 downTo 0) {
+ val resByte = result[resultOffset - 20 + k].toInt() and 0xFF
+ val keyByte = xKeyWorking[k].toInt() and 0xFF
+ val sum = carry + keyByte + resByte
+ xKeyWorking[k] = sum.toByte()
+ carry = sum shr 8
+ }
+ }
+
+ // RFC 4187 Section 7: PRF output slicing
+ return mapOf(
+ "K_encr" to result.copyOfRange(0, 16),
+ "K_aut" to result.copyOfRange(16, 32),
+ "MSK" to result.copyOfRange(32, 96),
+ "EMSK" to result.copyOfRange(96, 160)
+ )
+ }
+}
\ No newline at end of file
diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementExtension.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementExtension.kt
new file mode 100644
index 0000000000..f5966f6691
--- /dev/null
+++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementExtension.kt
@@ -0,0 +1,205 @@
+@file:RequiresApi(Build.VERSION_CODES.O)
+@file:SuppressLint("HardwareIds", "MissingPermission")
+
+package org.microg.gms.constellation.core.verification.ts43
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Build
+import android.telephony.TelephonyManager
+import androidx.annotation.RequiresApi
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import org.microg.gms.constellation.core.proto.OdsaOperation
+import org.microg.gms.constellation.core.proto.ServiceEntitlementRequest
+
+fun ServiceEntitlementRequest.builder(
+ telephonyManager: TelephonyManager,
+ eapId: String,
+ appIds: List = emptyList()
+) = ServiceEntitlementBuilder(
+ imsi = telephonyManager.subscriberId ?: "",
+ iccid = try {
+ telephonyManager.simSerialNumber
+ } catch (_: Exception) {
+ null
+ },
+ terminalId = telephonyManager.imei,
+ groupIdLevel1 = runCatching { telephonyManager.groupIdLevel1 }.getOrNull(),
+ eapId = eapId,
+ appIds = appIds,
+ req = this
+)
+
+fun ServiceEntitlementRequest.userAgent(context: Context): String {
+ val packageVersion =
+ context.packageManager.getPackageInfo(context.packageName, 0).versionName.orEmpty()
+ val vendor = terminal_vendor.take(4)
+ val model = terminal_model.take(10)
+ val swVersion = terminal_software_version.take(20)
+ return "PRD-TS43 term-$vendor/$model /$packageVersion OS-Android/$swVersion"
+}
+
+fun OdsaOperation.builder(
+ telephonyManager: TelephonyManager,
+ serviceEntitlementRequest: ServiceEntitlementRequest? = null,
+ appIds: List = emptyList()
+) = ServiceEntitlementBuilder(
+ imsi = telephonyManager.subscriberId ?: "",
+ iccid = try {
+ telephonyManager.simSerialNumber
+ } catch (_: Exception) {
+ null
+ },
+ terminalId = telephonyManager.imei,
+ groupIdLevel1 = runCatching { telephonyManager.groupIdLevel1 }.getOrNull(),
+ eapId = "", // Not needed for ODSA
+ appIds = appIds,
+ req = serviceEntitlementRequest ?: ServiceEntitlementRequest(),
+ odsa = this
+)
+
+class ServiceEntitlementBuilder(
+ private val imsi: String,
+ private val iccid: String?,
+ private val terminalId: String?,
+ private val groupIdLevel1: String?,
+ private val eapId: String,
+ private val appIds: List,
+ private val req: ServiceEntitlementRequest,
+ private val odsa: OdsaOperation? = null
+) {
+ fun buildBaseUrl(entitlementUrl: String): HttpUrl {
+ val baseUrl = entitlementUrl.toHttpUrl()
+
+ // GMS truncates these fields: vendor (4), model (10), sw_version (20)
+ val vendor = req.terminal_vendor.take(4)
+ val model = req.terminal_model.take(10)
+ val swVersion = req.terminal_software_version.take(20)
+
+ return baseUrl.newBuilder().apply {
+ when {
+ req.authentication_token.isNotEmpty() -> {
+ addQueryParameter("token", req.authentication_token)
+ if (imsi.isNotEmpty()) addQueryParameter("IMSI", imsi)
+ }
+
+ req.temporary_token.isNotEmpty() -> {
+ addQueryParameter("temporary_token", req.temporary_token)
+ }
+
+ eapId.isNotEmpty() -> {
+ addQueryParameter("EAP_ID", eapId)
+ }
+ }
+ addQueryParameter("terminal_id", terminalId ?: req.terminal_id)
+ if (req.gid1.isNotEmpty()) {
+ addQueryParameter("GID1", req.gid1)
+ } else if ((req.entitlement_version.toBigDecimalOrNull()?.toInt() ?: 0) >= 12) {
+ groupIdLevel1?.takeIf { it.isNotEmpty() }?.let { addQueryParameter("GID1", it) }
+ }
+ if (req.app_version.isNotEmpty()) addQueryParameter("app_version", req.app_version)
+ addQueryParameter("terminal_vendor", vendor)
+ addQueryParameter("terminal_model", model)
+ addQueryParameter("terminal_sw_version", swVersion)
+ addQueryParameter("app_name", req.app_name)
+ if (req.boost_type.isNotEmpty()) addQueryParameter("boost_type", req.boost_type)
+ if (appIds.isNotEmpty()) {
+ appIds.forEach { addQueryParameter("app", it) }
+ } else {
+ addQueryParameter("app", "ap2014")
+ }
+ addQueryParameter("vers", req.configuration_version.toString())
+ addQueryParameter("entitlement_version", req.entitlement_version)
+ if (req.notification_token.isNotEmpty()) {
+ addQueryParameter("notif_action", req.notification_action.toString())
+ addQueryParameter("notif_token", req.notification_token)
+ }
+
+ // Handle ODSA specific fields if present
+ odsa?.let {
+ addQueryParameter("operation", it.operation)
+ if (it.operation_type != -1) {
+ addQueryParameter("operation_type", it.operation_type.toString())
+ }
+ if (it.operation_targets.isNotEmpty()) {
+ addQueryParameter("operation_targets", it.operation_targets.joinToString(","))
+ }
+ if (it.terminal_iccid.isNotEmpty()) addQueryParameter(
+ "terminal_iccid",
+ it.terminal_iccid
+ )
+ else iccid?.let { i -> addQueryParameter("terminal_iccid", i) }
+
+ if (it.terminal_eid.isNotEmpty()) addQueryParameter("terminal_eid", it.terminal_eid)
+ if (it.target_terminal_id.isNotEmpty()) addQueryParameter(
+ "target_terminal_id",
+ it.target_terminal_id
+ )
+ if (it.target_terminal_iccid.isNotEmpty()) addQueryParameter(
+ "target_terminal_iccid",
+ it.target_terminal_iccid
+ )
+ if (it.target_terminal_eid.isNotEmpty()) addQueryParameter(
+ "target_terminal_eid",
+ it.target_terminal_eid
+ )
+ if (it.target_terminal_model.isNotEmpty()) addQueryParameter(
+ "target_terminal_model",
+ it.target_terminal_model
+ )
+ if (it.target_terminal_serial_number.isNotEmpty()) addQueryParameter(
+ "target_terminal_sn",
+ it.target_terminal_serial_number
+ )
+
+ it.target_terminal_ids.forEach { id ->
+ addQueryParameter("target_terminal_imeis", id)
+ }
+
+ if (it.old_terminal_id.isNotEmpty()) addQueryParameter(
+ "old_terminal_id",
+ it.old_terminal_id
+ )
+ if (it.old_terminal_iccid.isNotEmpty()) addQueryParameter(
+ "old_terminal_iccid",
+ it.old_terminal_iccid
+ )
+
+ // Companion fields
+ if (it.companion_terminal_id.isNotEmpty()) addQueryParameter(
+ "companion_terminal_id",
+ it.companion_terminal_id
+ )
+ if (it.companion_terminal_vendor.isNotEmpty()) addQueryParameter(
+ "companion_terminal_vendor",
+ it.companion_terminal_vendor
+ )
+ if (it.companion_terminal_model.isNotEmpty()) addQueryParameter(
+ "companion_terminal_model",
+ it.companion_terminal_model
+ )
+ if (it.companion_terminal_software_version.isNotEmpty()) addQueryParameter(
+ "companion_terminal_sw_version",
+ it.companion_terminal_software_version
+ )
+ if (it.companion_terminal_friendly_name.isNotEmpty()) addQueryParameter(
+ "companion_terminal_friendly_name",
+ it.companion_terminal_friendly_name
+ )
+ if (it.companion_terminal_service.isNotEmpty()) addQueryParameter(
+ "companion_terminal_service",
+ it.companion_terminal_service
+ )
+ if (it.companion_terminal_iccid.isNotEmpty()) addQueryParameter(
+ "companion_terminal_iccid",
+ it.companion_terminal_iccid
+ )
+ if (it.companion_terminal_eid.isNotEmpty()) addQueryParameter(
+ "companion_terminal_eid",
+ it.companion_terminal_eid
+ )
+ }
+ }.build()
+ }
+}
diff --git a/play-services-constellation/core/src/main/proto/constellation.proto b/play-services-constellation/core/src/main/proto/constellation.proto
new file mode 100644
index 0000000000..98297b63b2
--- /dev/null
+++ b/play-services-constellation/core/src/main/proto/constellation.proto
@@ -0,0 +1,1080 @@
+syntax = "proto3";
+option java_package = "org.microg.gms.constellation.core.proto";
+
+package google.internal.communications.phonedeviceverification.v1;
+
+import "google/protobuf/timestamp.proto";
+
+service PhoneDeviceVerification {
+ rpc GetConsent(GetConsentRequest) returns (GetConsentResponse);
+ rpc SetConsent(SetConsentRequest) returns (SetConsentResponse);
+ rpc Sync(SyncRequest) returns (SyncResponse);
+ rpc Proceed(ProceedRequest) returns (ProceedResponse);
+}
+
+service PhoneNumber {
+ rpc GetVerifiedPhoneNumbers(GetVerifiedPhoneNumbersRequest) returns (GetVerifiedPhoneNumbersResponse);
+}
+
+message RequestHeader {
+ ClientInfo client_info = 1;
+ ClientAuth client_auth = 2;
+ string session_id = 3;
+ RequestTrigger trigger = 4;
+}
+
+message ResponseHeader {
+ StatusProtoSimple status = 1;
+}
+
+message StatusProtoSimple {
+ int32 code = 1;
+}
+
+enum StatusCode {
+ STATUS_CODE_UNSPECIFIED = 0;
+ STATUS_CODE_OK = 1;
+ STATUS_CODE_ERROR = 2;
+}
+
+message ClientInfo {
+ DeviceID device_id = 1;
+ bytes client_public_key = 2;
+ string locale = 3;
+ int32 gmscore_version_number = 4;
+ string gmscore_version = 5;
+ int32 android_sdk_version = 6;
+ DroidGuardSignals droidguard_signals = 8;
+ repeated Experiment experiments = 9;
+ UserProfileType user_profile_type = 11;
+ repeated GaiaToken gaia_tokens = 12;
+ CountryInfo country_info = 13;
+ repeated NetworkSignal connectivity_infos = 14;
+ string model = 15;
+ string manufacturer = 16;
+ repeated SimOperatorInfo partial_sim_infos = 17;
+ DeviceType device_type = 18;
+ bool is_wearable_standalone = 19;
+ GaiaSignals gaia_signals = 20;
+ string device_fingerprint = 21;
+}
+
+enum UserProfileType {
+ UNKNOWN_PROFILE_TYPE = 0;
+ REGULAR_USER = 1;
+ MANAGED_PROFILE = 2;
+}
+
+enum DeviceType {
+ DEVICE_TYPE_UNKNOWN = 0;
+ DEVICE_TYPE_PHONE = 1;
+ DEVICE_TYPE_PHONE_GO = 2;
+ DEVICE_TYPE_TV = 3;
+ DEVICE_TYPE_WEARABLE = 4;
+ DEVICE_TYPE_AUTOMOTIVE = 5;
+ DEVICE_TYPE_BATTLESTAR = 6;
+ DEVICE_TYPE_CHROME_OS = 7;
+ DEVICE_TYPE_XR = 8;
+ DEVICE_TYPE_DESKTOP = 9;
+ DEVICE_TYPE_XR_PERIPHERAL = 10;
+}
+
+message ClientAuth {
+ DeviceID device_id = 1;
+ bytes signature = 2;
+ google.protobuf.Timestamp sign_timestamp = 3;
+}
+
+message CountryInfo {
+ repeated string sim_countries = 1;
+ repeated string network_countries = 2;
+}
+
+message DroidGuardSignals {
+ string droidguard_result = 1;
+ string droidguard_token = 2;
+}
+
+message DeviceID {
+ string iid_token = 1;
+ int64 primary_device_id = 2;
+ int64 user_serial = 3;
+ int64 gms_android_id = 4;
+}
+
+message RequestTrigger {
+ enum Type {
+ UNKNOWN = 0;
+ PERIODIC_CONSENT_CHECK = 1;
+ PERIODIC_REFRESH = 2;
+ SIM_STATE_CHANGED = 3;
+ GAIA_CHANGE_EVENT = 4;
+ USER_SETTINGS = 5;
+ DEBUG_SETTINGS = 6;
+ TRIGGER_API_CALL = 7;
+ REBOOT_CHECKER = 8;
+ SERVER_TRIGGER = 9;
+ FAILURE_RETRY = 10;
+ CONSENT_API_TRIGGER = 11;
+ PNVR_DEVICE_SETTINGS = 12;
+ }
+ Type type = 1;
+}
+
+message DroidGuardAttestation {
+ string droidguard_result = 1;
+ string droidguard_token = 2;
+}
+
+message Experiment {
+ string key = 1;
+ string value = 2;
+}
+
+message GaiaID {
+ string id = 1;
+}
+
+message GaiaToken {
+ string token = 1;
+}
+
+message GaiaSignalEntry {
+ string gaia_id = 1;
+ GaiaAccountSignalType signal_type = 2;
+ google.protobuf.Timestamp timestamp = 3;
+}
+
+enum GaiaAccountSignalType {
+ GAIA_ACCOUNT_SIGNAL_UNSPECIFIED = 0;
+ GAIA_ACCOUNT_SIGNAL_AUTHENTICATED = 1;
+ GAIA_ACCOUNT_SIGNAL_UNAUTHENTICATED = 2;
+ GAIA_ACCOUNT_SIGNAL_REMOVED_WITH_GMS_AUTH_BROADCAST = 3;
+}
+
+message GaiaSignals {
+ repeated GaiaSignalEntry gaia_signals = 1;
+}
+
+message Status {
+ int32 code = 1;
+}
+
+message VerificationPolicy {
+ string policy_id = 1;
+ int64 max_verification_age_hours = 2;
+ IdTokenRequest id_token_request = 3;
+ string calling_package = 4;
+ repeated VerificationParam params = 5;
+}
+
+message IdTokenRequest {
+ string certificate_hash = 1;
+ string token_nonce = 2;
+}
+
+message VerificationParam {
+ string key = 1;
+ string value = 2;
+}
+
+message SimOperatorInfo {
+ string imsi_hash = 1;
+
+ string sim_operator = 3;
+}
+
+message NetworkSignal {
+ enum Type {
+ TYPE_UNKNOWN = 0;
+ TYPE_WIFI = 1;
+ TYPE_MOBILE = 2;
+ }
+ enum Availability {
+ AVAILABILITY_UNKNOWN = 0;
+ AVAILABLE = 1;
+ NOT_AVAILABLE = 2;
+ }
+ enum State {
+ STATE_UNKNOWN = 0;
+ STATE_CONNECTING = 1;
+ STATE_CONNECTED = 2;
+ STATE_DISCONNECTING = 3;
+ STATE_DISCONNECTED = 4;
+ STATE_SUSPENDED = 5;
+ }
+ Type type = 1;
+ State state = 2;
+ Availability availability = 3;
+}
+
+message MobileOperatorInfo {
+ string country_code = 1;
+ uint64 nil_since_usec = 2;
+ string operator = 3;
+ uint32 nil_since_micros = 4;
+ string operator_name = 5;
+}
+
+message SyncRequest {
+ repeated Verification verifications = 3;
+ RequestHeader header = 4;
+ repeated VerificationToken verification_tokens = 5;
+}
+
+message SyncResponse {
+ repeated VerificationResponse responses = 1;
+ ServerTimestamp next_sync_time = 2;
+ ResponseHeader header = 3;
+ DroidguardToken droidguard_token = 4;
+ repeated VerificationToken verification_tokens = 5;
+}
+
+message ProceedRequest {
+ Verification verification = 2;
+ ChallengeResponse challenge_response = 3;
+ RequestHeader header = 4;
+}
+
+message ProceedResponse {
+ Verification verification = 1;
+ ResponseHeader header = 2;
+ ServerTimestamp next_sync_time = 3;
+ DroidguardToken droidguard_token = 4;
+}
+
+message ServerTimestamp {
+ google.protobuf.Timestamp timestamp = 1;
+ google.protobuf.Timestamp now = 2;
+}
+
+message VerificationToken {
+ bytes token = 1;
+ google.protobuf.Timestamp expiration_time = 2;
+}
+
+message Verification {
+ enum State {
+ UNKNOWN = 0;
+ NONE = 1;
+ PENDING = 2;
+ VERIFIED = 3;
+ }
+
+ enum Status {
+ STATUS_UNKNOWN = 0;
+ STATUS_NONE = 1;
+ STATUS_PENDING = 2;
+ STATUS_VERIFIED = 3;
+ STATUS_THROTTLED = 4;
+ STATUS_FAILED = 5;
+ STATUS_SKIPPED = 6;
+ STATUS_NOT_REQUIRED = 7;
+ STATUS_PHONE_NUMBER_ENTRY_REQUIRED = 8;
+ STATUS_INELIGIBLE = 9;
+ STATUS_DENIED = 10;
+ STATUS_NOT_IN_SERVICE = 11;
+ }
+
+ VerificationAssociation association = 1;
+ Status status = 2;
+ oneof info {
+ VerificationInfo verification_info = 3;
+ PendingVerificationInfo pending_verification_info = 4;
+ UnverifiedInfo unverified_info = 9;
+ }
+ TelephonyInfo telephony_info = 5;
+ repeated Param api_params = 6;
+ ChallengePreference challenge_preference = 7;
+ VerificationPolicy structured_api_params = 8;
+}
+
+message VerificationResponse {
+ Verification verification = 1;
+ StatusProto error = 2;
+}
+
+message StatusProto {
+ int32 code = 1;
+ string message = 3;
+}
+
+message SIMSlotInfo {
+ int32 slot_index = 1;
+ int32 subscription_id = 2;
+}
+
+message SIMAssociation {
+
+ message SIMInfo {
+ repeated string imsi = 1;
+ string sim_readable_number = 2;
+ repeated TelephonyPhoneNumber telephony_phone_number = 3;
+ string iccid = 4;
+ }
+
+ message TelephonyPhoneNumber {
+ string phone_number = 1;
+ TelephonyPhoneNumberType phone_number_type = 2;
+ }
+
+ SIMInfo sim_info = 1;
+ repeated GaiaToken gaia_tokens = 2;
+ SIMSlotInfo sim_slot = 4;
+}
+
+message GaiaAssociation {
+}
+
+message VerificationAssociation {
+ oneof association {
+ SIMAssociation sim = 1;
+ GaiaAssociation gaia = 2;
+ }
+}
+
+message VerificationInfo {
+ string phone_number = 1;
+ google.protobuf.Timestamp verification_time = 2;
+ VerificationMethod challenge_method = 6;
+}
+
+message PendingVerificationInfo {
+ Challenge challenge = 2;
+}
+
+message Challenge {
+ ChallengeID challenge_id = 1;
+ VerificationMethod type = 2;
+ MoChallenge mo_challenge = 3;
+ CarrierIdChallenge carrier_id_challenge = 4;
+ ServerTimestamp expiry_time = 5;
+ MTChallenge mt_challenge = 6;
+ RegisteredSmsChallenge registered_sms_challenge = 7;
+ FlashCallChallenge flash_call_challenge = 8;
+ Ts43Challenge ts43_challenge = 12;
+}
+
+message CarrierIdChallenge {
+ string isim_request = 3;
+ int32 auth_type = 5;
+ int32 app_type = 6;
+}
+
+message MTChallenge {
+ string sms = 1;
+}
+
+message ChallengeID {
+ string id = 1;
+}
+
+message Capabilities {
+ string droidguard_result = 1;
+ string droidguard_token = 2;
+}
+
+message MoChallenge {
+ string proxy_number = 1;
+ DataSmsInfo data_sms_info = 3;
+ string sms = 4;
+ string polling_intervals = 5;
+ string sms_without_persisting = 6;
+}
+
+message DataSmsInfo {
+ int32 destination_port = 1;
+}
+
+message RegisteredSmsChallenge {
+ repeated PhoneNumberID verified_senders = 1;
+}
+
+message PhoneNumberID {
+ bytes phone_number_id = 1;
+}
+
+message FlashCallChallenge {
+ repeated PhoneRange phone_ranges = 1;
+ FlashCallState state = 2;
+ repeated ChallengeID previous_challenge_ids = 3;
+ repeated ChallengeResponse previous_challenge_responses = 4;
+ int64 millis_between_interceptions = 5;
+}
+
+message PhoneRange {
+ string phone_number_prefix = 1;
+ string phone_number_suffix = 2;
+ string country_code = 3;
+ string carrier = 4;
+}
+
+enum FlashCallState {
+ FLASH_CALL_STATE_UNKNOWN = 0;
+ FLASH_CALL_STATE_PREPARING = 1;
+ FLASH_CALL_STATE_VERIFYING = 2;
+ FLASH_CALL_STATE_VERIFIED = 3;
+ FLASH_CALL_STATE_FAILED = 4;
+}
+
+message UnverifiedInfo {
+ enum Reason {
+ UNKNOWN_REASON = 0;
+ THROTTLED = 1;
+ FAILED = 2;
+ SKIPPED = 3;
+ NOT_REQUIRED = 4;
+ PHONE_NUMBER_ENTRY_REQUIRED = 5;
+ INELIGIBLE = 6;
+ DENIED = 7;
+ NOT_IN_SERVICE = 8;
+ }
+ Reason reason = 1;
+ google.protobuf.Timestamp retry_after_time = 2;
+ VerificationMethod challenge_method = 3;
+}
+
+message ChallengeResponse {
+ MTChallengeResponseData mt_response = 1;
+ CarrierIdChallengeResponse carrier_id_response = 2;
+ MOChallengeResponseData mo_response = 3;
+ RegisteredSmsChallengeResponse registered_sms_response = 6;
+ FlashCallChallengeResponse flash_call_response = 7;
+ Ts43ChallengeResponse ts43_challenge_response = 9;
+}
+
+message MTChallengeResponseData {
+ string sms = 1;
+ string sender = 2;
+}
+
+message CarrierIdChallengeResponse {
+ string isim_response = 3;
+ CarrierIdError carrier_id_error = 4;
+}
+
+enum CarrierIdError {
+ CARRIER_ID_ERROR_NO_ERROR = 0;
+ CARRIER_ID_ERROR_NOT_SUPPORTED = 1;
+ CARRIER_ID_ERROR_RETRY_ATTEMPT_EXCEEDED = 2;
+ CARRIER_ID_ERROR_NULL_RESPONSE = 3;
+ CARRIER_ID_ERROR_REFLECTION_ERROR = 4;
+ CARRIER_ID_ERROR_NO_SIM = 5;
+ CARRIER_ID_ERROR_UNABLE_TO_READ_SUBSCRIPTION = 6;
+ CARRIER_ID_ERROR_UNKNOWN_ERROR = 7;
+ CARRIER_ID_ERROR_ENTITLEMENT_SERVER_ERROR = 8;
+ CARRIER_ID_ERROR_JSON_PARSE_ERROR = 9;
+ CARRIER_ID_ERROR_INTERNAL_ERROR = 10;
+ CARRIER_ID_ERROR_INVALID_ARGUMENT = 11;
+}
+
+message MOChallengeResponseData {
+ enum Status {
+ UNKNOWN_STATUS = 0;
+ COMPLETED = 1;
+ FAILED_TO_SEND_MO = 2;
+ NO_ACTIVE_SUBSCRIPTION = 3;
+ NO_SMS_MANAGER = 4;
+ }
+ Status status = 1;
+ int64 sms_result_code = 2;
+ int64 sms_error_code = 3;
+}
+
+message RegisteredSmsChallengeResponse {
+ repeated RegisteredSmsChallengeResponseItem items = 1;
+}
+
+message RegisteredSmsChallengeResponseItem {
+ RegisteredSmsChallengeResponsePayload payload = 2;
+}
+
+message RegisteredSmsChallengeResponsePayload {
+ bytes payload = 1;
+}
+
+message Ts43ChallengeResponse {
+ Ts43Type ts43_type = 1;
+ ClientChallengeResponse client_challenge_response = 2;
+ ServerChallengeResponse server_challenge_response = 3;
+ Ts43ChallengeResponseStatus status = 4;
+ repeated string http_history = 5;
+}
+
+message Ts43ChallengeResponseStatus {
+ enum Code {
+ TS43_STATUS_UNSPECIFIED = 0;
+ TS43_STATUS_NOT_SUPPORTED = 1;
+ TS43_STATUS_CHALLENGE_NOT_SET = 2;
+ TS43_STATUS_INTERNAL_ERROR = 3;
+ TS43_STATUS_RUNTIME_ERROR = 4;
+ TS43_STATUS_JSON_PARSE_ERROR = 5;
+ }
+
+ oneof result {
+ Code status_code = 1;
+ Ts43ChallengeResponseError error = 2;
+ }
+}
+
+message Ts43ChallengeResponseError {
+ enum Code {
+ TS43_ERROR_CODE_UNSPECIFIED = 0;
+ TS43_ERROR_CODE_1 = 1;
+ TS43_ERROR_CODE_2 = 2;
+ TS43_ERROR_CODE_3 = 3;
+ TS43_ERROR_CODE_4 = 4;
+ TS43_ERROR_CODE_5 = 5;
+ TS43_ERROR_CODE_6 = 6;
+ TS43_ERROR_CODE_7 = 7;
+ TS43_ERROR_CODE_8 = 8;
+ TS43_ERROR_CODE_9 = 9;
+ TS43_ERROR_CODE_PHONE_UNAVAILABLE = 10;
+ TS43_ERROR_CODE_INVALID_IMSI_OR_MCCMNC = 11;
+ TS43_ERROR_CODE_EAP_AKA_CHALLENGE_FAILED = 20;
+ TS43_ERROR_CODE_EAP_AKA_SYNCHRONIZATION_FAILURE = 21;
+ TS43_ERROR_CODE_EAP_AKA_AUTHENTICATION_FAILED = 22;
+ TS43_ERROR_CODE_CONNECTIVITY_FAILURE = 30;
+ TS43_ERROR_CODE_HTTP_ERROR_STATUS = 31;
+ TS43_ERROR_CODE_HTTP_MALFORMED_RESPONSE = 32;
+ TS43_ERROR_CODE_TEMPORARY_TOKEN_UNAVAILABLE = 60;
+ }
+
+ enum RequestType {
+ TS43_REQUEST_TYPE_UNSPECIFIED = 0;
+ TS43_REQUEST_TYPE_AUTH_API = 1;
+ TS43_REQUEST_TYPE_GET_PHONE_NUMBER_API = 2;
+ TS43_REQUEST_TYPE_ACQUIRE_TEMPORARY_TOKEN_API = 3;
+ TS43_REQUEST_TYPE_SERVICE_ENTITLEMENT_API = 4;
+ }
+
+ Code error_code = 1;
+ int32 http_status = 2;
+ RequestType request_type = 3;
+}
+
+message ServerChallengeResponse {
+ string acquire_temporary_token_response = 2;
+}
+
+message ClientChallengeResponse {
+ string get_phone_number_response = 2;
+}
+
+message FlashCallChallengeResponse {
+ enum Error {
+ NO_ERROR = 0;
+ UNSPECIFIED = 1;
+ TIMED_OUT = 2;
+ NETWORK_NOT_AVAILABLE = 3;
+ TOO_MANY_CALLS = 4;
+ CONCURRENT_REQUESTS = 5;
+ IN_ECBM = 6;
+ IN_EMERGENCY_CALL = 7;
+ PRECONDITIONS_FAILED = 8;
+ API_NOT_AVAILABLE = 9;
+ ERROR_PREVIOUS_INCOMING_CALL = 10;
+ STATE_NOT_PREPARING = 11;
+ STATE_NOT_VERIFYING = 12;
+ ERROR_PENDING_VERIFICATION = 13;
+ PROCEED_FAILED = 14;
+ INTERCEPTION_FAILED = 15;
+ }
+ string caller = 1;
+ Error error = 2;
+}
+
+message Ts43Type {
+ enum Integrator {
+ TS43_INTEGRATOR_UNSPECIFIED = 0;
+ JIO = 1;
+ TELUS = 2;
+ ERICSSON = 3;
+ HPE = 4;
+ TMO = 5;
+ TMO_SERVER = 6;
+ TELENOR = 7;
+ RCS_CIS_PROXY = 8;
+ MOBI_US = 9;
+ SFR = 10;
+ SASKTEL_CANADA = 11;
+ DT = 13;
+ DT_SERVER = 14;
+ NETLYNC = 17;
+ ORANGE_FRANCE = 18;
+ AMDOCS = 19;
+ }
+ Integrator integrator = 1;
+}
+
+message CellularNetworkEvent {
+ google.protobuf.Timestamp timestamp = 1;
+ bool mobile_data_enabled = 2;
+ bool airplane_mode_enabled = 3;
+ bool data_roaming_enabled = 4;
+ bool mobile_data_always_on = 5;
+ repeated NetworkState networks = 6;
+}
+
+message NetworkState {
+ repeated int32 types = 1;
+ bool available = 2;
+}
+
+message ServiceStateEvent {
+ google.protobuf.Timestamp timestamp = 1;
+ bool airplane_mode_enabled = 2;
+ bool mobile_data_enabled = 3;
+ int32 voice_registration_state = 4;
+ int32 data_registration_state = 5;
+ int32 voice_network_type = 6;
+ int32 data_network_type = 7;
+ int32 signal_strength = 8;
+}
+
+message SMSEvent {
+ google.protobuf.Timestamp timestamp = 1;
+ EventDirection direction = 2;
+ EventPhoneNumberType number_type = 3;
+}
+
+message CallEvent {
+ google.protobuf.Timestamp timestamp = 1;
+ EventDirection direction = 2;
+}
+
+enum EventDirection {
+ UNKNOWN_DIRECTION = 0;
+ INCOMING = 1;
+ OUTGOING = 2;
+ MISSED = 3;
+}
+
+enum EventPhoneNumberType {
+ UNKNOWN_TYPE = 0;
+ LONG_NUMBER = 1;
+ SHORT_CODE = 2;
+}
+
+message TelephonyInfo {
+ enum PhoneType {
+ PHONE_TYPE_UNKNOWN = 0;
+ PHONE_TYPE_GSM = 1;
+ PHONE_TYPE_CDMA = 2;
+ PHONE_TYPE_SIP = 3;
+ }
+ enum SimState {
+ SIM_STATE_UNKNOWN = 0;
+ SIM_STATE_NOT_READY = 1;
+ SIM_STATE_READY = 2;
+ }
+ enum RoamingState {
+ ROAMING_UNKNOWN = 0;
+ ROAMING_HOME = 1;
+ ROAMING_ROAMING = 2;
+ }
+
+ enum ConnectivityState {
+ CONNECTIVITY_UNKNOWN = 0;
+ CONNECTIVITY_HOME = 1;
+ CONNECTIVITY_ROAMING = 2;
+ }
+ enum SmsCapability {
+ SMS_UNKNOWN = 0;
+ SMS_NOT_CAPABLE = 1;
+ SMS_CAPABLE = 2;
+ SMS_DEFAULT_CAPABILITY = 3;
+ SMS_RESTRICTED = 4;
+ }
+ enum ToggleState {
+ TOGGLE_UNKNOWN = 0;
+ TOGGLE_DISABLED = 1;
+ TOGGLE_ENABLED = 2;
+ }
+ enum ServiceState {
+ SERVICE_STATE_UNKNOWN = 0;
+ SERVICE_STATE_IN_SERVICE = 1;
+ SERVICE_STATE_OUT_OF_SERVICE = 2;
+ SERVICE_STATE_EMERGENCY_ONLY = 3;
+ SERVICE_STATE_POWER_OFF = 4;
+ }
+
+ PhoneType phone_type = 1;
+ string group_id_level1 = 2;
+ SimNetworkInfo sim_info = 3;
+ SimNetworkInfo network_info = 4;
+ RoamingState network_roaming = 5;
+ ConnectivityState connectivity_state = 6;
+ SmsCapability sms_capability = 7;
+ int32 active_sub_count = 8;
+ int32 active_sub_count_max = 9;
+ ToggleState vowifi_state = 11;
+ ToggleState sms_no_confirmation_state = 12;
+ SimState sim_state = 13;
+ string device_id = 15;
+ ServiceState service_state = 16;
+ repeated CellularNetworkEvent cellular_events = 17;
+ repeated CallEvent call_events = 18;
+ repeated SMSEvent sms_events = 19;
+ repeated ServiceStateEvent service_events = 20;
+ bool is_embedded = 21;
+ ToggleState carrier_id_capability = 23;
+ int64 sim_carrier_id = 25;
+}
+
+message SimNetworkInfo {
+ string country_iso = 1;
+ string operator = 2;
+ string operator_name = 3;
+ int32 inactive_time_diff_ms = 4;
+}
+
+message DroidguardToken {
+ string token = 1;
+ google.protobuf.Timestamp ttl = 2;
+}
+
+message GetConsentRequest {
+ DeviceID device_id = 1;
+ repeated GaiaToken gaia_tokens = 2;
+ RequestHeader header = 4;
+ repeated VerificationToken verification_tokens = 5;
+ AsterismClient asterism_client = 6;
+ VerificationPolicy structured_api_params = 7;
+ bool force_refresh = 8;
+ string session_id = 9;
+ bool unknown_flag = 10;
+}
+
+message GetConsentResponse {
+ RcsConsent rcs_consent = 1;
+ ServerTimestamp next_check_time = 5;
+ ConsentState device_consent = 6;
+ repeated GaiaConsent gaia_consents = 8;
+ DroidguardToken droidguard_token = 9;
+ PermissionState permission_state = 10;
+}
+
+enum ConsentVersion {
+ CONSENT_VERSION_UNSPECIFIED = 0;
+ RCS_CONSENT = 1;
+ RCS_DEFAULT_ON_LEGAL_FYI = 2;
+ RCS_DEFAULT_ON_OUT_OF_BOX = 3;
+ RCS_SAMSUNG_UNFREEZE = 4;
+ RCS_DEFAULT_ON_LEGAL_FYI_IN_SETTINGS = 5;
+}
+
+message SetConsentRequest {
+ RequestHeader header = 1;
+ RcsConsent rcs_consent = 3;
+ AsterismClient asterism_client = 4;
+ bytes audit_record = 6;
+ repeated Param api_params = 7;
+ OnDemandConsent on_demand_consent = 8;
+ ConsentVersion consent_version = 9;
+ oneof device_consent_oneof {
+ AsterismConsent device_consent = 10;
+ }
+}
+
+message SetConsentResponse {
+}
+
+enum Consent {
+ CONSENT_UNKNOWN = 0;
+ CONSENTED = 1;
+ NO_CONSENT = 2;
+ EXPIRED = 3;
+}
+
+message AsterismConsent {
+ Consent consent = 1;
+ ConsentSource consent_source = 2;
+ DeviceConsentVersion consent_version = 3;
+
+ enum DeviceConsentVersion {
+ UNKNOWN = 0;
+ PHONE_VERIFICATION_DEFAULT = 1;
+ PHONE_VERIFICATION_MESSAGES_CALLS_V1 = 2;
+ PHONE_VERIFICATION_INTL_SMS_CALLS = 3;
+ PHONE_VERIFICATION_REACHABILITY_INTL_SMS_CALLS = 4;
+ }
+}
+
+message ConsentState {
+ Consent state = 2;
+}
+
+message PermissionState {
+ int32 flags = 1;
+ PermissionType type = 2;
+}
+
+enum PermissionType {
+ PERMISSION_TYPE_UNSPECIFIED = 0;
+ LEGACY_DPNV = 1;
+ PNVR = 2;
+ NOT_ALLOWED = 3;
+}
+
+message RcsConsent {
+ Consent consent = 2;
+ ConsentVersion consent_version = 3;
+}
+
+message GaiaConsent {
+ AsterismClient asterism_client = 1;
+ Consent consent = 2;
+ ConsentVersion consent_version = 3;
+}
+
+message OnDemandConsent {
+ Consent consent = 1;
+ GaiaToken gaia_token = 2;
+ string consent_variant = 3;
+ string trigger = 4;
+}
+
+enum ConsentSource {
+ SOURCE_UNSPECIFIED = 0;
+ ANDROID_DEVICE_SETTINGS = 1;
+ GAIA_USERNAME_RECOVERY = 2;
+ AOB_SETUP_WIZARD = 3;
+ MINUTEMAID_JS_BRIDGE = 4;
+ GAIA_WEB_JS_BRIDGE = 5;
+ AM_PROFILES = 6;
+ MEET_ON_DEMAND_CONSENT = 7;
+ GPAY_ON_DEMAND_CONSENT = 8;
+}
+
+enum AsterismClient {
+ UNKNOWN_CLIENT = 0;
+ CONSTELLATION = 1;
+ RCS = 2;
+ ONE_TIME_VERIFICATION = 3;
+}
+
+message GetVerifiedPhoneNumbersRequest {
+ enum PhoneNumberSelection {
+ SELECTION_UNSPECIFIED = 0;
+ CONSTELLATION = 1;
+ RCS = 2;
+ }
+
+ string session_id = 1;
+ IIDTokenAuth iid_token_auth = 2;
+ repeated PhoneNumberSelection phone_number_selections = 3;
+ TokenOption token_option = 4;
+ string droidguard_result = 5;
+ ConsistencyOption consistency_option = 6;
+ RequestInfo request_info = 7;
+}
+
+message GetVerifiedPhoneNumbersResponse {
+ repeated VerifiedPhoneNumber phone_numbers = 2;
+}
+
+message IIDTokenAuth {
+ string iid_token = 1;
+ bytes client_sign = 2;
+ google.protobuf.Timestamp sign_timestamp = 3;
+}
+
+message TokenOption {
+ string certificate_hash = 1;
+ string token_nonce = 2;
+ string package_name = 3;
+}
+
+message VerifiedPhoneNumber {
+ string phone_number = 1;
+ google.protobuf.Timestamp verification_time = 2;
+ string id_token = 3;
+ RcsState rcs_state = 4;
+}
+
+enum RcsState {
+ STATE_UNSPECIFIED = 0;
+ ACTIVE = 1;
+}
+
+enum TelephonyPhoneNumberType {
+ PHONE_NUMBER_SOURCE_UNSPECIFIED = 0;
+ PHONE_NUMBER_SOURCE_CARRIER = 1;
+ PHONE_NUMBER_SOURCE_UICC = 2;
+ PHONE_NUMBER_SOURCE_IMS = 3;
+}
+
+message ConsistencyOption {
+ enum Consistency {
+ CONSISTENCY_UNSPECIFIED = 0;
+ STALE = 1;
+ STRONG = 2;
+ }
+ Consistency consistency = 1;
+}
+
+message RequestInfo {
+ string policy_id = 1;
+}
+
+message Param {
+ string key = 1;
+ string value = 2;
+}
+
+message ChallengePreference {
+ repeated VerificationMethod capabilities = 1;
+ ChallengePreferenceMetadata metadata = 2;
+}
+
+message ChallengePreferenceMetadata {
+ string sms_signature = 2;
+}
+
+enum VerificationMethod {
+ UNKNOWN = 0;
+ MO_SMS = 1;
+ MT_SMS = 2;
+ CARRIER_ID = 3;
+ IMSI_LOOKUP = 5;
+ REGISTERED_SMS = 7;
+ FLASH_CALL = 8;
+ TS43 = 11;
+}
+
+message AuditToken {
+ AuditTokenMetadata metadata = 2;
+}
+
+message AuditTokenMetadata {
+ AuditUuid uuid = 1;
+}
+
+message AuditUuid {
+ int64 uuid_msb = 1;
+ int64 uuid_lsb = 2;
+}
+
+message AuditPayload {
+ AuditDeviceInfo device_info = 1;
+ AuditDeviceId device_id = 2;
+ AuditEventMetadata event_metadata = 10;
+}
+
+message AuditDeviceInfo {
+ oneof device_identifier {
+ string android_id_hash = 1;
+ string instance_id = 2;
+ }
+}
+
+message AuditDeviceId {
+ string android_id_hash = 1;
+}
+
+message AuditEventMetadata {
+ AuditEventType event_type = 1;
+ string session_id = 2;
+ int64 event_timestamp = 3;
+ AuditConsentDetails consent_details = 4;
+ string package_name = 5;
+ string tos_url = 6;
+ string tos_version = 7;
+ string language = 8;
+ string country = 9;
+ string gaia_id = 10;
+ AuditComponentInfo component_info = 11;
+ string trigger = 12;
+}
+
+enum AuditEventType {
+ EVENT_TYPE_UNSPECIFIED = 0;
+ ASTERISM_CONSENT_CHANGE = 1;
+ RCS_CONSENT_CHANGE = 2;
+ VERIFICATION_COMPLETE = 3;
+}
+
+message AuditConsentDetails {
+ repeated int32 sim_slot_ids = 1;
+}
+
+message AuditComponentInfo {
+ AuditComponentType component_type = 1;
+}
+
+enum AuditComponentType {
+ COMPONENT_UNSPECIFIED = 0;
+ ASTERISM_CONSTELLATION = 119;
+ ASTERISM_RCS = 120;
+}
+
+message AuditConsentState {
+ Consent state = 1;
+}
+
+enum AuditEventTypeValue {
+ EVENT_VALUE_UNSPECIFIED = 0;
+ ASTERISM_CLIENT_CONSENT_CHANGE = 187;
+}
+
+message Ts43Challenge {
+ Ts43Type ts43_type = 1;
+ string entitlement_url = 2;
+ ServiceEntitlementRequest service_entitlement_request = 3;
+ ClientChallenge client_challenge = 4;
+ ServerChallenge server_challenge = 5;
+ string app_id = 6;
+ string eap_aka_realm = 7;
+}
+
+message ServerChallenge {
+ OdsaOperation operation = 1;
+}
+
+message ClientChallenge {
+ OdsaOperation operation = 1;
+}
+
+message OdsaOperation {
+ string operation = 1;
+ int32 operation_type = 2;
+ repeated string operation_targets = 3;
+ string companion_terminal_id = 4;
+ string companion_terminal_vendor = 5;
+ string companion_terminal_model = 6;
+ string companion_terminal_software_version = 7;
+ string companion_terminal_friendly_name = 8;
+ string companion_terminal_service = 9;
+ string companion_terminal_iccid = 10;
+ string companion_terminal_eid = 11;
+ string terminal_iccid = 12;
+ string terminal_eid = 13;
+ string target_terminal_id = 14;
+ repeated string target_terminal_ids = 15;
+ string target_terminal_iccid = 16;
+ string target_terminal_eid = 17;
+ string target_terminal_serial_number = 18;
+ string target_terminal_model = 19;
+ string old_terminal_id = 20;
+ string old_terminal_iccid = 21;
+}
+
+message ServiceEntitlementRequest {
+ int32 notification_action = 1;
+ string entitlement_version = 2;
+ string temporary_token = 3;
+ string authentication_token = 4;
+ string terminal_id = 5;
+ string terminal_vendor = 6;
+ string terminal_model = 7;
+ string terminal_software_version = 8;
+ string app_name = 9;
+ string app_version = 10;
+ string notification_token = 11;
+ int32 configuration_version = 12;
+ string accept_content_type = 13;
+ string boost_type = 14;
+ string gid1 = 15;
+}
+
diff --git a/play-services-constellation/src/main/AndroidManifest.xml b/play-services-constellation/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..670e962e03
--- /dev/null
+++ b/play-services-constellation/src/main/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetIidTokenRequest.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetIidTokenRequest.aidl
new file mode 100644
index 0000000000..4621cec67a
--- /dev/null
+++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetIidTokenRequest.aidl
@@ -0,0 +1,3 @@
+package com.google.android.gms.constellation;
+
+parcelable GetIidTokenRequest;
diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetIidTokenResponse.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetIidTokenResponse.aidl
new file mode 100644
index 0000000000..832407aa6e
--- /dev/null
+++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetIidTokenResponse.aidl
@@ -0,0 +1,3 @@
+package com.google.android.gms.constellation;
+
+parcelable GetIidTokenResponse;
diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.aidl
new file mode 100644
index 0000000000..ffd7ecad31
--- /dev/null
+++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.aidl
@@ -0,0 +1,3 @@
+package com.google.android.gms.constellation;
+
+parcelable GetPnvCapabilitiesRequest;
\ No newline at end of file
diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.aidl
new file mode 100644
index 0000000000..1b318c7ec7
--- /dev/null
+++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.aidl
@@ -0,0 +1,3 @@
+package com.google.android.gms.constellation;
+
+parcelable GetPnvCapabilitiesResponse;
\ No newline at end of file
diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/PhoneNumberInfo.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/PhoneNumberInfo.aidl
new file mode 100644
index 0000000000..b6889c7bd4
--- /dev/null
+++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/PhoneNumberInfo.aidl
@@ -0,0 +1,3 @@
+package com.google.android.gms.constellation;
+
+parcelable PhoneNumberInfo;
diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberRequest.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberRequest.aidl
new file mode 100644
index 0000000000..f5ca759758
--- /dev/null
+++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberRequest.aidl
@@ -0,0 +1,3 @@
+package com.google.android.gms.constellation;
+
+parcelable VerifyPhoneNumberRequest;
diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberResponse.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberResponse.aidl
new file mode 100644
index 0000000000..31eb20049c
--- /dev/null
+++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberResponse.aidl
@@ -0,0 +1,3 @@
+package com.google.android.gms.constellation;
+
+parcelable VerifyPhoneNumberResponse;
diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationApiService.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationApiService.aidl
new file mode 100644
index 0000000000..912acc7371
--- /dev/null
+++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationApiService.aidl
@@ -0,0 +1,39 @@
+package com.google.android.gms.constellation.internal;
+
+import com.google.android.gms.common.api.ApiMetadata;
+import com.google.android.gms.constellation.internal.IConstellationCallbacks;
+import com.google.android.gms.constellation.GetIidTokenRequest;
+import com.google.android.gms.constellation.GetPnvCapabilitiesRequest;
+import com.google.android.gms.constellation.VerifyPhoneNumberRequest;
+
+interface IConstellationApiService {
+ void verifyPhoneNumberV1(
+ IConstellationCallbacks cb,
+ in Bundle bundle,
+ in ApiMetadata apiMetadata
+ );
+
+ void verifyPhoneNumberSingleUse(
+ IConstellationCallbacks cb,
+ in Bundle bundle,
+ in ApiMetadata apiMetadata
+ );
+
+ void verifyPhoneNumber(
+ IConstellationCallbacks cb,
+ in VerifyPhoneNumberRequest request,
+ in ApiMetadata apiMetadata
+ );
+
+ void getIidToken(
+ IConstellationCallbacks cb,
+ in GetIidTokenRequest request,
+ in ApiMetadata apiMetadata
+ );
+
+ void getPnvCapabilities(
+ IConstellationCallbacks cb,
+ in GetPnvCapabilitiesRequest request,
+ in ApiMetadata apiMetadata
+ );
+}
diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationCallbacks.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationCallbacks.aidl
new file mode 100644
index 0000000000..74ce532b86
--- /dev/null
+++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationCallbacks.aidl
@@ -0,0 +1,15 @@
+package com.google.android.gms.constellation.internal;
+
+import com.google.android.gms.common.api.ApiMetadata;
+import com.google.android.gms.common.api.Status;
+import com.google.android.gms.constellation.GetIidTokenResponse;
+import com.google.android.gms.constellation.GetPnvCapabilitiesResponse;
+import com.google.android.gms.constellation.PhoneNumberInfo;
+import com.google.android.gms.constellation.VerifyPhoneNumberResponse;
+
+oneway interface IConstellationCallbacks {
+ void onPhoneNumberVerified(in Status status, in List phoneNumbers, in ApiMetadata apiMetadata);
+ void onPhoneNumberVerificationsCompleted(in Status status, in VerifyPhoneNumberResponse response, in ApiMetadata apiMetadata);
+ void onIidTokenGenerated(in Status status, in GetIidTokenResponse response, in ApiMetadata apiMetadata);
+ void onGetPnvCapabilitiesCompleted(in Status status, in GetPnvCapabilitiesResponse response, in ApiMetadata apiMetadata);
+}
diff --git a/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetIidTokenRequest.java b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetIidTokenRequest.java
new file mode 100644
index 0000000000..c24de63130
--- /dev/null
+++ b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetIidTokenRequest.java
@@ -0,0 +1,32 @@
+package com.google.android.gms.constellation;
+
+import android.os.Parcel;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable;
+import com.google.android.gms.common.internal.safeparcel.SafeParcelable;
+import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter;
+
+@SafeParcelable.Class
+public class GetIidTokenRequest extends AbstractSafeParcelable {
+ public static SafeParcelableCreatorAndWriter CREATOR =
+ findCreator(GetIidTokenRequest.class);
+
+ @Field(1)
+ @Nullable
+ public final Long projectNumber;
+
+ @Constructor
+ public GetIidTokenRequest(
+ @Param(1) @Nullable Long projectNumber
+ ) {
+ this.projectNumber = projectNumber;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ CREATOR.writeToParcel(this, dest, flags);
+ }
+}
\ No newline at end of file
diff --git a/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetIidTokenResponse.java b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetIidTokenResponse.java
new file mode 100644
index 0000000000..19b97ec9cc
--- /dev/null
+++ b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetIidTokenResponse.java
@@ -0,0 +1,47 @@
+package com.google.android.gms.constellation;
+
+import android.os.Parcel;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable;
+import com.google.android.gms.common.internal.safeparcel.SafeParcelable;
+import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter;
+
+@SafeParcelable.Class
+public class GetIidTokenResponse extends AbstractSafeParcelable {
+ public static SafeParcelableCreatorAndWriter CREATOR =
+ findCreator(GetIidTokenResponse.class);
+
+ @Field(1)
+ public final String iidToken;
+
+ @Field(2)
+ public final String fid;
+
+ @Field(3)
+ @Nullable
+ public final byte[] signature;
+
+ @Field(4)
+ public final long timestamp;
+
+ @Constructor
+ public GetIidTokenResponse(
+ @Param(1) String iidToken,
+ @Param(2) String fid,
+ @Param(3) @Nullable byte[] signature,
+ @Param(4) long timestamp
+ ) {
+ this.iidToken = iidToken;
+ this.fid = fid;
+ this.signature = signature;
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ CREATOR.writeToParcel(this, dest, flags);
+ }
+}
\ No newline at end of file
diff --git a/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.java b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.java
new file mode 100644
index 0000000000..a0c13b3306
--- /dev/null
+++ b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.java
@@ -0,0 +1,42 @@
+package com.google.android.gms.constellation;
+
+import android.os.Parcel;
+
+import androidx.annotation.NonNull;
+
+import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable;
+import com.google.android.gms.common.internal.safeparcel.SafeParcelable;
+import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter;
+
+import java.util.List;
+
+@SafeParcelable.Class
+public class GetPnvCapabilitiesRequest extends AbstractSafeParcelable {
+ public static SafeParcelableCreatorAndWriter CREATOR =
+ findCreator(GetPnvCapabilitiesRequest.class);
+
+ @Field(1)
+ public final String policyId;
+
+ @Field(2)
+ public final List verificationTypes;
+
+ @Field(3)
+ public final List simSlotIndices;
+
+ @Constructor
+ public GetPnvCapabilitiesRequest(
+ @Param(1) String policyId,
+ @Param(2) List verificationTypes,
+ @Param(3) List simSlotIndices
+ ) {
+ this.policyId = policyId;
+ this.verificationTypes = verificationTypes;
+ this.simSlotIndices = simSlotIndices;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ CREATOR.writeToParcel(this, dest, flags);
+ }
+}
\ No newline at end of file
diff --git a/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.java b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.java
new file mode 100644
index 0000000000..ee522b446e
--- /dev/null
+++ b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.java
@@ -0,0 +1,94 @@
+package com.google.android.gms.constellation;
+
+import android.os.Parcel;
+
+import androidx.annotation.NonNull;
+
+import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable;
+import com.google.android.gms.common.internal.safeparcel.SafeParcelable;
+import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter;
+
+import java.util.List;
+
+@SafeParcelable.Class
+public class GetPnvCapabilitiesResponse extends AbstractSafeParcelable {
+ public static SafeParcelableCreatorAndWriter CREATOR =
+ findCreator(GetPnvCapabilitiesResponse.class);
+
+ @Field(1)
+ public final List simCapabilities;
+
+ @Constructor
+ public GetPnvCapabilitiesResponse(
+ @Param(1) List simCapabilities
+ ) {
+ this.simCapabilities = simCapabilities;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ CREATOR.writeToParcel(this, dest, flags);
+ }
+
+ @SafeParcelable.Class
+ public static class SimCapability extends AbstractSafeParcelable {
+
+ public static SafeParcelableCreatorAndWriter CREATOR =
+ findCreator(SimCapability.class);
+ @Field(1)
+ public final int slotValue;
+ @Field(2)
+ public final String subscriberIdDigest;
+ @Field(3)
+ public final int carrierId;
+ @Field(4)
+ public final String operatorName;
+ @Field(5)
+ public final List verificationCapabilities;
+
+ @Constructor
+ public SimCapability(
+ @Param(1) int slotValue,
+ @Param(2) String subscriberIdDigest,
+ @Param(3) int carrierId,
+ @Param(4) String operatorName,
+ @Param(5) List verificationCapabilities
+ ) {
+ this.slotValue = slotValue;
+ this.subscriberIdDigest = subscriberIdDigest;
+ this.carrierId = carrierId;
+ this.operatorName = operatorName;
+ this.verificationCapabilities = verificationCapabilities;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ CREATOR.writeToParcel(this, dest, flags);
+ }
+ }
+
+ @SafeParcelable.Class
+ public static class VerificationCapability extends AbstractSafeParcelable {
+
+ public static SafeParcelableCreatorAndWriter CREATOR =
+ findCreator(VerificationCapability.class);
+ @Field(1)
+ public final int verificationMethod;
+ @Field(2)
+ public final int statusValue;
+
+ @Constructor
+ public VerificationCapability(
+ @Param(1) int verificationMethod,
+ @Param(2) int statusValue
+ ) {
+ this.verificationMethod = verificationMethod;
+ this.statusValue = statusValue;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ CREATOR.writeToParcel(this, dest, flags);
+ }
+ }
+}
\ No newline at end of file
diff --git a/play-services-constellation/src/main/java/com/google/android/gms/constellation/PhoneNumberInfo.java b/play-services-constellation/src/main/java/com/google/android/gms/constellation/PhoneNumberInfo.java
new file mode 100644
index 0000000000..77f4fd7753
--- /dev/null
+++ b/play-services-constellation/src/main/java/com/google/android/gms/constellation/PhoneNumberInfo.java
@@ -0,0 +1,49 @@
+package com.google.android.gms.constellation;
+
+import android.os.Bundle;
+import android.os.Parcel;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable;
+import com.google.android.gms.common.internal.safeparcel.SafeParcelable;
+import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter;
+
+@SafeParcelable.Class
+public class PhoneNumberInfo extends AbstractSafeParcelable {
+ public static SafeParcelableCreatorAndWriter CREATOR =
+ findCreator(PhoneNumberInfo.class);
+
+ @Field(1)
+ public final int version;
+
+ @Field(2)
+ @Nullable
+ public final String phoneNumber;
+
+ @Field(3)
+ public final long verificationTime;
+
+ @Field(4)
+ @Nullable
+ public final Bundle extras;
+
+ @Constructor
+ public PhoneNumberInfo(
+ @SafeParcelable.Param(1) int version,
+ @SafeParcelable.Param(2) @Nullable String phoneNumber,
+ @SafeParcelable.Param(3) long verificationTime,
+ @SafeParcelable.Param(4) @Nullable Bundle extras
+ ) {
+ this.version = version;
+ this.phoneNumber = phoneNumber;
+ this.verificationTime = verificationTime;
+ this.extras = extras;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ CREATOR.writeToParcel(this, dest, flags);
+ }
+}
\ No newline at end of file
diff --git a/play-services-constellation/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberRequest.java b/play-services-constellation/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberRequest.java
new file mode 100644
index 0000000000..a0ced6ac30
--- /dev/null
+++ b/play-services-constellation/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberRequest.java
@@ -0,0 +1,116 @@
+package com.google.android.gms.constellation;
+
+import android.os.Bundle;
+import android.os.Parcel;
+
+import androidx.annotation.NonNull;
+
+import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable;
+import com.google.android.gms.common.internal.safeparcel.SafeParcelable;
+import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter;
+
+import java.util.List;
+
+@SafeParcelable.Class
+public class VerifyPhoneNumberRequest extends AbstractSafeParcelable {
+ public static SafeParcelableCreatorAndWriter CREATOR =
+ findCreator(VerifyPhoneNumberRequest.class);
+
+ @Field(1)
+ public final String policyId;
+
+ @Field(2)
+ public final long timeout;
+
+ @Field(3)
+ public final IdTokenRequest idTokenRequest;
+
+ @Field(4)
+ public final Bundle extras;
+
+ @Field(5)
+ public final List targetedSims;
+
+ @Field(6)
+ public final boolean includeUnverified;
+
+ @Field(7)
+ public final int apiVersion;
+
+ @Field(8)
+ public final List verificationMethodsValues;
+
+ @Constructor
+ public VerifyPhoneNumberRequest(
+ @Param(1) String policyId,
+ @Param(2) long timeout,
+ @Param(3) IdTokenRequest idTokenRequest,
+ @Param(4) Bundle extras,
+ @Param(5) List targetedSims,
+ @Param(6) boolean includeUnverified,
+ @Param(7) int apiVersion,
+ @Param(8) List verificationMethodsValues
+ ) {
+ this.policyId = policyId;
+ this.timeout = timeout;
+ this.idTokenRequest = idTokenRequest;
+ this.extras = extras;
+ this.targetedSims = targetedSims;
+ this.includeUnverified = includeUnverified;
+ this.apiVersion = apiVersion;
+ this.verificationMethodsValues = verificationMethodsValues;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ CREATOR.writeToParcel(this, dest, flags);
+ }
+
+ @SafeParcelable.Class
+ public static class IdTokenRequest extends AbstractSafeParcelable {
+ public static SafeParcelableCreatorAndWriter CREATOR =
+ findCreator(IdTokenRequest.class);
+ @Field(1)
+ public final String idToken;
+ @Field(2)
+ public final String subscriberHash;
+
+ @Constructor
+ public IdTokenRequest(
+ @Param(1) String idToken,
+ @Param(2) String subscriberHash
+ ) {
+ this.idToken = idToken;
+ this.subscriberHash = subscriberHash;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ CREATOR.writeToParcel(this, dest, flags);
+ }
+ }
+
+ @SafeParcelable.Class
+ public static class ImsiRequest extends AbstractSafeParcelable {
+ public static SafeParcelableCreatorAndWriter CREATOR =
+ findCreator(ImsiRequest.class);
+ @Field(1)
+ public final String imsi;
+ @Field(2)
+ public final String phoneNumberHint;
+
+ @Constructor
+ public ImsiRequest(
+ @Param(1) String imsi,
+ @Param(2) String phoneNumberHint
+ ) {
+ this.imsi = imsi;
+ this.phoneNumberHint = phoneNumberHint;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ CREATOR.writeToParcel(this, dest, flags);
+ }
+ }
+}
\ No newline at end of file
diff --git a/play-services-constellation/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberResponse.java b/play-services-constellation/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberResponse.java
new file mode 100644
index 0000000000..263461debf
--- /dev/null
+++ b/play-services-constellation/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberResponse.java
@@ -0,0 +1,88 @@
+package com.google.android.gms.constellation;
+
+import android.os.Bundle;
+import android.os.Parcel;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable;
+import com.google.android.gms.common.internal.safeparcel.SafeParcelable;
+import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter;
+
+@SafeParcelable.Class
+public class VerifyPhoneNumberResponse extends AbstractSafeParcelable {
+ public static SafeParcelableCreatorAndWriter CREATOR =
+ findCreator(VerifyPhoneNumberResponse.class);
+
+ @Field(1)
+ public final PhoneNumberVerification[] verifications;
+
+ @Field(2)
+ public final Bundle extras;
+
+ @Constructor
+ public VerifyPhoneNumberResponse(
+ @Param(1) PhoneNumberVerification[] verifications,
+ @Param(2) Bundle extras
+ ) {
+ this.verifications = verifications;
+ this.extras = extras;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ CREATOR.writeToParcel(this, dest, flags);
+ }
+
+ @SafeParcelable.Class
+ public static class PhoneNumberVerification extends AbstractSafeParcelable {
+ public static SafeParcelableCreatorAndWriter CREATOR =
+ findCreator(PhoneNumberVerification.class);
+ @Field(1)
+ @Nullable
+ public final String phoneNumber;
+ @Field(2)
+ public final long timestampMillis;
+ @Field(3)
+ public final int verificationMethod;
+ @Field(4)
+ public final int simSlot;
+ @Field(5)
+ @Nullable
+ public final String verificationToken;
+ @Field(6)
+ @Nullable
+ public final Bundle extras;
+ @Field(7)
+ public final int verificationStatus;
+ @Field(8)
+ public final long retryAfterSeconds;
+
+ @Constructor
+ public PhoneNumberVerification(
+ @Param(1) @Nullable String phoneNumber,
+ @Param(2) long timestampMillis,
+ @Param(3) int verificationMethod,
+ @Param(4) int simSlot,
+ @Param(5) @Nullable String verificationToken,
+ @Param(6) @Nullable Bundle extras,
+ @Param(7) int verificationStatus,
+ @Param(8) long retryAfterSeconds
+ ) {
+ this.phoneNumber = phoneNumber;
+ this.timestampMillis = timestampMillis;
+ this.verificationMethod = verificationMethod;
+ this.simSlot = simSlot;
+ this.verificationToken = verificationToken;
+ this.extras = extras;
+ this.verificationStatus = verificationStatus;
+ this.retryAfterSeconds = retryAfterSeconds;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ CREATOR.writeToParcel(this, dest, flags);
+ }
+ }
+}
\ No newline at end of file
diff --git a/play-services-core/build.gradle b/play-services-core/build.gradle
index 8fc2896bbc..362e39c76a 100644
--- a/play-services-core/build.gradle
+++ b/play-services-core/build.gradle
@@ -32,6 +32,7 @@ dependencies {
implementation project(':play-services-cast-core')
implementation project(':play-services-cast-framework-core')
implementation project(':play-services-conscrypt-provider-core')
+ implementation project(':play-services-constellation-core')
implementation project(':play-services-cronet-core')
implementation project(':play-services-droidguard-core')
implementation project(':play-services-fido-core')
diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml
index 7efdc9bf29..a29fff819c 100644
--- a/play-services-core/src/main/AndroidManifest.xml
+++ b/play-services-core/src/main/AndroidManifest.xml
@@ -1223,7 +1223,6 @@
-
diff --git a/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java b/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java
index 8d809ca0a7..789db690b9 100644
--- a/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java
+++ b/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java
@@ -50,8 +50,11 @@
import static android.Manifest.permission.GET_ACCOUNTS;
import static android.Manifest.permission.POST_NOTIFICATIONS;
import static android.Manifest.permission.READ_EXTERNAL_STORAGE;
+import static android.Manifest.permission.READ_PHONE_NUMBERS;
import static android.Manifest.permission.READ_PHONE_STATE;
+import static android.Manifest.permission.READ_SMS;
import static android.Manifest.permission.RECEIVE_SMS;
+import static android.Manifest.permission.SEND_SMS;
import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE;
import static android.os.Build.VERSION.SDK_INT;
@@ -76,8 +79,13 @@ protected void prepareSelfCheckList(Context context, List checks
if (SDK_INT >= 33) {
permissions.add(POST_NOTIFICATIONS);
}
+ if (SDK_INT >= 26) {
+ permissions.add(READ_PHONE_NUMBERS);
+ }
permissions.add(READ_PHONE_STATE);
+ permissions.add(READ_SMS);
permissions.add(RECEIVE_SMS);
+ permissions.add(SEND_SMS);
checks.add(new PermissionCheckGroup(permissions.toArray(new String[0])) {
@Override
public void doChecks(Context context, ResultCollector collector) {
diff --git a/play-services-iid/src/main/java/com/google/android/gms/iid/InstanceID.java b/play-services-iid/src/main/java/com/google/android/gms/iid/InstanceID.java
index 327d8b1cc0..92df450a4f 100644
--- a/play-services-iid/src/main/java/com/google/android/gms/iid/InstanceID.java
+++ b/play-services-iid/src/main/java/com/google/android/gms/iid/InstanceID.java
@@ -246,8 +246,24 @@ public InstanceIdStore getStore() {
}
@PublicApi(exclude = true)
- public String requestToken(String authorizedEntity, String scope, Bundle extras) {
- throw new UnsupportedOperationException();
+ public String requestToken(String authorizedEntity, String scope, Bundle extras) throws IOException {
+ if (extras == null) extras = new Bundle();
+
+ extras.putString(EXTRA_SENDER, authorizedEntity);
+ if (scope != null) {
+ extras.putString(EXTRA_SCOPE, scope);
+ }
+
+ String actualSubtype = TextUtils.isEmpty(this.subtype) ? authorizedEntity : this.subtype;
+
+ if (!extras.containsKey("legacy.register")) {
+ extras.putString(EXTRA_SUBSCIPTION, authorizedEntity);
+ extras.putString(EXTRA_SUBTYPE, actualSubtype);
+ extras.putString("X-" + EXTRA_SUBSCIPTION, authorizedEntity);
+ extras.putString("X-" + EXTRA_SUBTYPE, actualSubtype);
+ }
+
+ return rpc.handleRegisterMessageResult(rpc.sendRegisterMessageBlocking(extras, getKeyPair()));
}
private synchronized KeyPair getKeyPair() {
diff --git a/settings.gradle b/settings.gradle
index 1ab99b9000..9fcbcea289 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -36,6 +36,7 @@ include ':play-services-basement'
include ':play-services-cast'
include ':play-services-cast-framework'
include ':play-services-clearcut'
+include ':play-services-constellation'
include ':play-services-drive'
include ':play-services-droidguard'
include ':play-services-fido'
@@ -92,6 +93,7 @@ sublude ':play-services-cast:core'
sublude ':play-services-cast-framework:core'
include ':play-services-chimera-core'
include ':play-services-conscrypt-provider-core'
+sublude ':play-services-constellation:core'
sublude ':play-services-cronet:core'
sublude ':play-services-droidguard:core'
sublude ':play-services-fido:core'