Skip to content

Commit d2a6b75

Browse files
committed
fix(rcs): Spatula header, Asterism consent fallback, and TS.43 identity
Bind AppCertService for X-Goog-Spatula from constellation-core, use rcs_consent when Gaia consent is missing, and keep TS.43 AKA identity aligned with EAP_ID.
1 parent c23a34c commit d2a6b75

18 files changed

Lines changed: 544 additions & 37 deletions

File tree

‎play-services-asterism/core/build.gradle‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ android {
2222

2323
sourceSets {
2424
main.java.srcDirs += 'src/main/kotlin'
25+
test.java.srcDirs += 'src/test/kotlin'
2526
}
2627

2728
compileOptions {
@@ -32,6 +33,10 @@ android {
3233
kotlinOptions {
3334
jvmTarget = 1.8
3435
}
36+
37+
testOptions {
38+
unitTests.returnDefaultValues = true
39+
}
3540
}
3641

3742
apply from: '../../gradle/publish-android.gradle'
@@ -43,4 +48,6 @@ dependencies {
4348

4449
implementation project(':play-services-base-core')
4550
implementation project(':play-services-constellation-core')
51+
52+
testImplementation 'junit:junit:4.13.2'
4653
}

‎play-services-asterism/core/src/main/kotlin/org/microg/gms/asterism/core/AsterismApiService.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,18 @@ import kotlinx.coroutines.launch
1919
import org.microg.gms.BaseService
2020
import org.microg.gms.common.GmsService
2121
import org.microg.gms.common.PackageUtils
22+
import org.microg.gms.constellation.core.RpcClient
2223

2324
private const val TAG = "AsterismApiService"
2425

2526
class AsterismApiService : BaseService(TAG, GmsService.ASTERISM) {
2627
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
2728

29+
override fun onCreate() {
30+
super.onCreate()
31+
RpcClient.initialize(this)
32+
}
33+
2834
override fun handleServiceRequest(
2935
callback: IGmsCallbacks?,
3036
request: GetServiceRequest?,

‎play-services-asterism/core/src/main/kotlin/org/microg/gms/asterism/core/GetAsterismConsentHandler.kt‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ import kotlinx.coroutines.withContext
1616
import org.microg.gms.constellation.core.ConstellationStateStore
1717
import org.microg.gms.constellation.core.RpcClient
1818
import org.microg.gms.constellation.core.authManager
19+
import org.microg.gms.constellation.core.proto.AsterismClient
1920
import org.microg.gms.constellation.core.proto.Consent
2021
import org.microg.gms.constellation.core.proto.ConsentVersion
2122
import org.microg.gms.constellation.core.proto.DeviceID
2223
import org.microg.gms.constellation.core.proto.GetConsentRequest
24+
import org.microg.gms.constellation.core.proto.GetConsentResponse
2325
import org.microg.gms.constellation.core.proto.RequestHeader
2426
import org.microg.gms.constellation.core.proto.RequestTrigger
2527
import org.microg.gms.constellation.core.proto.builder.buildRequestContext
@@ -51,14 +53,10 @@ suspend fun handleGetAsterismConsent(
5153
)
5254
)
5355

54-
val gaiaConsent = response.gaia_consents.find {
55-
it.asterism_client == request.asterismClient
56-
}
57-
val (consentValue, consentVersion) = if (gaiaConsent != null) {
58-
gaiaConsent.consent to gaiaConsent.consent_version
59-
} else {
60-
Consent.NO_CONSENT to ConsentVersion.CONSENT_VERSION_UNSPECIFIED
61-
}
56+
val (consentValue, consentVersion) = resolveAsterismConsent(
57+
response,
58+
request.asterismClient
59+
)
6260

6361
callbacks.onConsentFetched(
6462
Status.SUCCESS,
@@ -85,6 +83,25 @@ suspend fun handleGetAsterismConsent(
8583
}
8684
}
8785

86+
internal fun resolveAsterismConsent(
87+
response: GetConsentResponse,
88+
asterismClient: AsterismClient
89+
): Pair<Consent, ConsentVersion> {
90+
response.gaia_consents.firstOrNull {
91+
it.asterism_client == asterismClient
92+
}?.let {
93+
return it.consent to it.consent_version
94+
}
95+
96+
if (asterismClient == AsterismClient.RCS) {
97+
response.rcs_consent?.takeIf { it.consent != Consent.CONSENT_UNKNOWN }?.let {
98+
return it.consent to it.consent_version
99+
}
100+
}
101+
102+
return Consent.NO_CONSENT to ConsentVersion.CONSENT_VERSION_UNSPECIFIED
103+
}
104+
88105
suspend fun handleGetIsPnvrConstellationDevice(
89106
context: Context,
90107
callbacks: IAsterismCallbacks
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.microg.gms.asterism.core
7+
8+
import org.junit.Assert.assertEquals
9+
import org.junit.Test
10+
import org.microg.gms.constellation.core.proto.AsterismClient
11+
import org.microg.gms.constellation.core.proto.Consent
12+
import org.microg.gms.constellation.core.proto.ConsentVersion
13+
import org.microg.gms.constellation.core.proto.GaiaConsent
14+
import org.microg.gms.constellation.core.proto.GetConsentResponse
15+
import org.microg.gms.constellation.core.proto.RcsConsent
16+
17+
class AsterismConsentResolverTest {
18+
19+
@Test
20+
fun matchingGaiaConsentTakesPrecedence() {
21+
val response = GetConsentResponse(
22+
rcs_consent = RcsConsent(
23+
consent = Consent.CONSENTED,
24+
consent_version = ConsentVersion.RCS_DEFAULT_ON_OUT_OF_BOX
25+
),
26+
gaia_consents = listOf(
27+
GaiaConsent(
28+
asterism_client = AsterismClient.RCS,
29+
consent = Consent.NO_CONSENT,
30+
consent_version = ConsentVersion.RCS_CONSENT
31+
)
32+
)
33+
)
34+
35+
assertEquals(
36+
Consent.NO_CONSENT to ConsentVersion.RCS_CONSENT,
37+
resolveAsterismConsent(response, AsterismClient.RCS)
38+
)
39+
}
40+
41+
@Test
42+
fun rcsConsentIsUsedWhenMatchingGaiaConsentIsAbsent() {
43+
val response = GetConsentResponse(
44+
rcs_consent = RcsConsent(
45+
consent = Consent.CONSENTED,
46+
consent_version = ConsentVersion.RCS_DEFAULT_ON_OUT_OF_BOX
47+
)
48+
)
49+
50+
assertEquals(
51+
Consent.CONSENTED to ConsentVersion.RCS_DEFAULT_ON_OUT_OF_BOX,
52+
resolveAsterismConsent(response, AsterismClient.RCS)
53+
)
54+
}
55+
56+
@Test
57+
fun unrelatedGaiaConsentDoesNotMaskRcsConsent() {
58+
val response = GetConsentResponse(
59+
rcs_consent = RcsConsent(
60+
consent = Consent.CONSENTED,
61+
consent_version = ConsentVersion.RCS_CONSENT
62+
),
63+
gaia_consents = listOf(
64+
GaiaConsent(
65+
asterism_client = AsterismClient.CONSTELLATION,
66+
consent = Consent.NO_CONSENT,
67+
consent_version = ConsentVersion.CONSENT_VERSION_UNSPECIFIED
68+
)
69+
)
70+
)
71+
72+
assertEquals(
73+
Consent.CONSENTED to ConsentVersion.RCS_CONSENT,
74+
resolveAsterismConsent(response, AsterismClient.RCS)
75+
)
76+
}
77+
78+
@Test
79+
fun rcsConsentIsNotAppliedToNonRcsClients() {
80+
val response = GetConsentResponse(
81+
rcs_consent = RcsConsent(
82+
consent = Consent.CONSENTED,
83+
consent_version = ConsentVersion.RCS_CONSENT
84+
)
85+
)
86+
87+
assertEquals(
88+
Consent.NO_CONSENT to ConsentVersion.CONSENT_VERSION_UNSPECIFIED,
89+
resolveAsterismConsent(response, AsterismClient.CONSTELLATION)
90+
)
91+
}
92+
93+
@Test
94+
fun noConsentDataFallsBackToNoConsent() {
95+
assertEquals(
96+
Consent.NO_CONSENT to ConsentVersion.CONSENT_VERSION_UNSPECIFIED,
97+
resolveAsterismConsent(GetConsentResponse(), AsterismClient.RCS)
98+
)
99+
}
100+
101+
@Test
102+
fun unknownRcsConsentIsTreatedAsMissing() {
103+
val response = GetConsentResponse(
104+
rcs_consent = RcsConsent(
105+
consent = Consent.CONSENT_UNKNOWN,
106+
consent_version = ConsentVersion.CONSENT_VERSION_UNSPECIFIED
107+
)
108+
)
109+
110+
assertEquals(
111+
Consent.NO_CONSENT to ConsentVersion.CONSENT_VERSION_UNSPECIFIED,
112+
resolveAsterismConsent(response, AsterismClient.RCS)
113+
)
114+
}
115+
}

‎play-services-constellation/core/build.gradle‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ dependencies {
5757
api project(':play-services-constellation')
5858

5959
implementation project(':play-services-base-core')
60+
implementation project(':play-services-api')
6061
implementation project(':play-services-iid')
6162
implementation project(':play-services-auth-base')
6263

‎play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationApiService.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ private const val TAG = "C11NApiService"
2828
class ConstellationApiService : BaseService(TAG, GmsService.CONSTELLATION) {
2929
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
3030

31+
override fun onCreate() {
32+
super.onCreate()
33+
RpcClient.initialize(this)
34+
}
35+
3136
override fun handleServiceRequest(
3237
callback: IGmsCallbacks?,
3338
request: GetServiceRequest?,

‎play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/RpcClient.kt‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,35 @@
11
package org.microg.gms.constellation.core
22

3+
import android.content.Context
4+
import android.util.Log
35
import com.squareup.wire.GrpcClient
46
import okhttp3.OkHttpClient
7+
import okhttp3.Request
58
import org.microg.gms.common.Constants
69
import org.microg.gms.constellation.core.proto.PhoneDeviceVerificationClient
710
import org.microg.gms.constellation.core.proto.PhoneNumberClient
811
import java.util.concurrent.TimeUnit
912

13+
private const val TAG = "ConstellationRpcClient"
14+
15+
internal fun addSpatulaHeader(request: Request, spatulaHeader: String?): Request {
16+
if (spatulaHeader.isNullOrBlank()) return request
17+
return request.newBuilder().header("X-Goog-Spatula", spatulaHeader).build()
18+
}
19+
1020
object RpcClient {
21+
@Volatile
22+
private var spatulaHeaderProvider: SpatulaHeaderProvider? = null
23+
24+
fun initialize(context: Context) {
25+
if (spatulaHeaderProvider != null) return
26+
synchronized(this) {
27+
if (spatulaHeaderProvider == null) {
28+
spatulaHeaderProvider = AppCertSpatulaHeaderProvider(context.applicationContext)
29+
}
30+
}
31+
}
32+
1133
private val client: OkHttpClient = OkHttpClient.Builder()
1234
.readTimeout(60, TimeUnit.SECONDS)
1335
.addInterceptor { chain ->
@@ -16,7 +38,13 @@ object RpcClient {
1638
.header("X-Goog-Api-Key", "AIzaSyAP-gfH3qvi6vgHZbSYwQ_XHqV_mXHhzIk")
1739
.header("X-Android-Package", Constants.GMS_PACKAGE_NAME)
1840
.header("X-Android-Cert", Constants.GMS_PACKAGE_SIGNATURE_SHA1.uppercase())
19-
chain.proceed(builder.build())
41+
val spatulaHeader = try {
42+
spatulaHeaderProvider?.getSpatulaHeader(Constants.GMS_PACKAGE_NAME)
43+
} catch (e: Exception) {
44+
Log.w(TAG, "Unable to obtain X-Goog-Spatula", e)
45+
null
46+
}
47+
chain.proceed(addSpatulaHeader(builder.build(), spatulaHeader))
2048
}
2149
.build()
2250

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.microg.gms.constellation.core
7+
8+
import android.content.ComponentName
9+
import android.content.Context
10+
import android.content.Intent
11+
import android.content.ServiceConnection
12+
import android.os.IBinder
13+
import android.os.SystemClock
14+
import android.util.Log
15+
import com.google.android.gms.auth.appcert.IAppCertService
16+
import java.util.concurrent.LinkedBlockingQueue
17+
import java.util.concurrent.TimeUnit
18+
19+
internal const val APP_CERT_SERVICE_ACTION = "com.google.android.gms.auth.be.appcert.AppCertService"
20+
private const val TAG = "SpatulaHeaderProvider"
21+
private const val BIND_TIMEOUT_SECONDS = 60L
22+
private const val CACHE_TTL_MS = 30L * 60L * 1000L
23+
24+
internal interface SpatulaHeaderProvider {
25+
fun getSpatulaHeader(packageName: String): String?
26+
}
27+
28+
// Bind AppCertService instead of depending on play-services-core.
29+
internal class AppCertSpatulaHeaderProvider(
30+
private val context: Context
31+
) : SpatulaHeaderProvider {
32+
@Volatile
33+
private var cachedHeader: String? = null
34+
35+
@Volatile
36+
private var cachedAtElapsedMs: Long = 0L
37+
38+
private val lock = Any()
39+
40+
override fun getSpatulaHeader(packageName: String): String? {
41+
val cached = cachedHeader
42+
if (!cached.isNullOrBlank() && !isCacheExpired()) {
43+
return cached
44+
}
45+
synchronized(lock) {
46+
val lockedCache = cachedHeader
47+
if (!lockedCache.isNullOrBlank() && !isCacheExpired()) {
48+
return lockedCache
49+
}
50+
val header = fetchFromAppCertService(packageName)
51+
if (!header.isNullOrBlank()) {
52+
cachedHeader = header
53+
cachedAtElapsedMs = SystemClock.elapsedRealtime()
54+
}
55+
return header
56+
}
57+
}
58+
59+
private fun isCacheExpired(): Boolean {
60+
return SystemClock.elapsedRealtime() - cachedAtElapsedMs >= CACHE_TTL_MS
61+
}
62+
63+
private fun fetchFromAppCertService(packageName: String): String? {
64+
val serviceQueue = LinkedBlockingQueue<IAppCertService>(1)
65+
val connection = object : ServiceConnection {
66+
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
67+
service?.let { serviceQueue.offer(IAppCertService.Stub.asInterface(it)) }
68+
}
69+
70+
override fun onServiceDisconnected(name: ComponentName?) = Unit
71+
}
72+
val intent = Intent(APP_CERT_SERVICE_ACTION).setPackage(context.packageName)
73+
val bound = try {
74+
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
75+
} catch (e: Exception) {
76+
Log.w(TAG, "Unable to bind AppCertService", e)
77+
false
78+
}
79+
if (!bound) return null
80+
81+
return try {
82+
val service = serviceQueue.poll(BIND_TIMEOUT_SECONDS, TimeUnit.SECONDS) ?: return null
83+
service.getSpatulaHeader(packageName)
84+
} catch (e: Exception) {
85+
Log.w(TAG, "AppCertService.getSpatulaHeader failed", e)
86+
null
87+
} finally {
88+
runCatching { context.unbindService(connection) }
89+
}
90+
}
91+
}

‎play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/Ts43Verifier.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ private fun buildOdsaRequestPayload(
298298
requestType = Ts43ChallengeResponseError.RequestType.TS43_REQUEST_TYPE_AUTH_API
299299
)
300300

301-
val akaResponse = eapAkaService.performSimAkaAuth(eapRelayPacket, imsi, mccMnc)
301+
val akaResponse = eapAkaService.performSimAkaAuth(eapRelayPacket, eapId)
302302
?: return null
303303

304304
val postBody = JSONObject().put("eap-relay-packet", akaResponse).toString()

0 commit comments

Comments
 (0)