Skip to content

Commit db4f7cb

Browse files
committed
Add RCS unit tests and extract testable DroidGuard helpers.
Introduce shared phone-number and VM cache utilities, wire them into Constellation and DroidGuard, and add offline JUnit coverage for provisioning and tachyon-related logic.
1 parent 4983bce commit db4f7cb

12 files changed

Lines changed: 313 additions & 9 deletions

File tree

play-services-constellation/core/build.gradle

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

2424
sourceSets {
2525
main.java.srcDirs += 'src/main/kotlin'
26+
test.java.srcDirs += 'src/test/kotlin'
27+
}
28+
29+
testOptions {
30+
unitTests.returnDefaultValues = true
2631
}
2732

2833
compileOptions {
@@ -59,4 +64,6 @@ dependencies {
5964
implementation "com.squareup.okhttp3:okhttp:$okHttpVersion"
6065
api "com.squareup.wire:wire-runtime:$wireVersion"
6166
api "com.squareup.wire:wire-grpc-client:$wireVersion"
67+
68+
testImplementation 'junit:junit:4.13.2'
6269
}

play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/SyncRequestBuilder.kt

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,14 @@ suspend operator fun SyncRequest.Companion.invoke(
164164
val phoneNumberHint = request.targetedSims
165165
.firstOrNull { it.imsi == imsi }
166166
?.phoneNumberHint
167-
?.takeIf { it.isNotBlank() }
168-
val phoneNumber = PhoneNumberUtils.formatNumberToE164(
169-
subscriptionInfo.number,
170-
subscriptionInfo.countryIso
171-
) ?: phoneNumberHint ?: subscriptionInfo.number.orEmpty()
167+
val phoneNumber = resolveSimReadableNumber(
168+
formattedE164 = PhoneNumberUtils.formatNumberToE164(
169+
subscriptionInfo.number,
170+
subscriptionInfo.countryIso
171+
),
172+
phoneNumberHint = phoneNumberHint,
173+
rawSubscriptionNumber = subscriptionInfo.number
174+
)
172175
val iccid = subscriptionInfo.iccId ?: ""
173176

174177
Verification(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.microg.gms.constellation.core.proto.builder
7+
8+
/**
9+
* Resolves the SIM-readable phone number sent in Constellation sync requests.
10+
*
11+
* Google Messages may supply a phoneNumberHint when E.164 formatting from the
12+
* subscription record is unavailable (common on dual-SIM or incomplete SIM profiles).
13+
*/
14+
internal fun resolveSimReadableNumber(
15+
formattedE164: String?,
16+
phoneNumberHint: String?,
17+
rawSubscriptionNumber: String?
18+
): String {
19+
if (!formattedE164.isNullOrBlank()) {
20+
return formattedE164
21+
}
22+
if (!phoneNumberHint.isNullOrBlank()) {
23+
return phoneNumberHint
24+
}
25+
return rawSubscriptionNumber.orEmpty()
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.microg.gms.constellation.core.proto.builder
7+
8+
import org.junit.Assert.assertEquals
9+
import org.junit.Test
10+
11+
class SyncRequestPhoneNumberResolverTest {
12+
13+
@Test
14+
fun prefersFormattedE164() {
15+
assertEquals(
16+
"+15551234567",
17+
resolveSimReadableNumber(
18+
formattedE164 = "+15551234567",
19+
phoneNumberHint = "+15559876543",
20+
rawSubscriptionNumber = "5551234567"
21+
)
22+
)
23+
}
24+
25+
@Test
26+
fun fallsBackToPhoneNumberHintWhenE164Missing() {
27+
assertEquals(
28+
"+15559876543",
29+
resolveSimReadableNumber(
30+
formattedE164 = null,
31+
phoneNumberHint = "+15559876543",
32+
rawSubscriptionNumber = "5551234567"
33+
)
34+
)
35+
}
36+
37+
@Test
38+
fun fallsBackToRawNumberWhenHintBlank() {
39+
assertEquals(
40+
"5551234567",
41+
resolveSimReadableNumber(
42+
formattedE164 = "",
43+
phoneNumberHint = " ",
44+
rawSubscriptionNumber = "5551234567"
45+
)
46+
)
47+
}
48+
49+
@Test
50+
fun returnsEmptyWhenAllInputsMissing() {
51+
assertEquals(
52+
"",
53+
resolveSimReadableNumber(
54+
formattedE164 = null,
55+
phoneNumberHint = null,
56+
rawSubscriptionNumber = null
57+
)
58+
)
59+
}
60+
61+
@Test
62+
fun skipsBlankE164ForHint() {
63+
assertEquals(
64+
"+491701234567",
65+
resolveSimReadableNumber(
66+
formattedE164 = " ",
67+
phoneNumberHint = "+491701234567",
68+
rawSubscriptionNumber = null
69+
)
70+
)
71+
}
72+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.microg.gms.constellation.core.verification.ts43
7+
8+
import org.junit.Assert.assertArrayEquals
9+
import org.junit.Assert.assertEquals
10+
import org.junit.Test
11+
12+
class Fips186PrfTest {
13+
14+
@Test
15+
fun deriveKeys_returnsExpectedKeyLengths() {
16+
val keys = Fips186Prf.deriveKeys(
17+
identityBytes = byteArrayOf(0x01, 0x02, 0x03),
18+
ik = ByteArray(16) { it.toByte() },
19+
ck = ByteArray(16) { (it + 16).toByte() }
20+
)
21+
22+
assertEquals(16, keys["K_encr"]!!.size)
23+
assertEquals(16, keys["K_aut"]!!.size)
24+
assertEquals(64, keys["MSK"]!!.size)
25+
assertEquals(64, keys["EMSK"]!!.size)
26+
}
27+
28+
@Test
29+
fun deriveKeys_isDeterministic() {
30+
val identity = "310260123456789".toByteArray(Charsets.UTF_8)
31+
val ik = ByteArray(16) { 0x11 }
32+
val ck = ByteArray(16) { 0x22 }
33+
34+
val first = Fips186Prf.deriveKeys(identity, ik, ck)
35+
val second = Fips186Prf.deriveKeys(identity, ik, ck)
36+
37+
assertArrayEquals(first["K_encr"], second["K_encr"])
38+
assertArrayEquals(first["K_aut"], second["K_aut"])
39+
assertArrayEquals(first["MSK"], second["MSK"])
40+
assertArrayEquals(first["EMSK"], second["EMSK"])
41+
}
42+
43+
@Test
44+
fun deriveKeys_changesWhenIdentityChanges() {
45+
val ik = ByteArray(16) { 0x11 }
46+
val ck = ByteArray(16) { 0x22 }
47+
48+
val a = Fips186Prf.deriveKeys("imsi-a".toByteArray(), ik, ck)
49+
val b = Fips186Prf.deriveKeys("imsi-b".toByteArray(), ik, ck)
50+
51+
assertEquals(false, a["MSK"]!!.contentEquals(b["MSK"]))
52+
}
53+
}

play-services-droidguard/build.gradle

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

2727
sourceSets {
2828
main.java.srcDirs += 'src/main/kotlin'
29+
test.java.srcDirs += 'src/test/kotlin'
30+
}
31+
32+
testOptions {
33+
unitTests.returnDefaultValues = true
2934
}
3035

3136
compileOptions {
@@ -46,4 +51,6 @@ dependencies {
4651
api project(':play-services-base')
4752

4853
implementation "androidx.annotation:annotation:$annotationVersion"
54+
55+
testImplementation 'junit:junit:4.13.2'
4956
}

play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/NetworkHandleProxyFactory.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ class NetworkHandleProxyFactory(private val context: Context) : HandleProxyFacto
135135
})
136136
val signed: SignedResponse = future.get()
137137
val response = signed.unpack()
138-
val vmKey = response.vmChecksum!!.hex().uppercase(Locale.US)
138+
val vmKey = formatVmCacheKey(response.vmChecksum!!.hex())
139139
if (!isValidCache(vmKey)) {
140140
val temp = File(getCacheDir(), "${UUID.randomUUID()}.apk")
141141
temp.parentFile!!.mkdirs()
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.microg.gms.droidguard
7+
8+
import java.util.Locale
9+
10+
/** Matches stock GMS: context.getDir("dg_cache", MODE_PRIVATE) → app_dg_cache/ */
11+
const val DG_CACHE_FOLDER_NAME = "dg_cache"
12+
13+
/**
14+
* Stock GMS uses uppercase hex for VM cache subdirectory names.
15+
* okio ByteString.hex() returns lowercase, which mismatches /proc/self/maps paths.
16+
*/
17+
fun formatVmCacheKey(vmChecksumHex: String): String = vmChecksumHex.uppercase(Locale.US)

play-services-droidguard/src/main/kotlin/org/microg/gms/droidguard/HandleProxyFactory.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import android.os.Bundle
1010
import android.os.ParcelFileDescriptor
1111
import android.os.Parcelable
1212
import androidx.annotation.GuardedBy
13-
import org.microg.gms.droidguard.DgVmClassLoader
1413
import java.io.File
1514
import java.io.IOException
1615
import java.security.MessageDigest
@@ -49,7 +48,7 @@ open class HandleProxyFactory(private val context: Context) {
4948
}
5049

5150
fun getTheApkFile(vmKey: String) = File(getCacheDir(vmKey), "the.apk")
52-
protected fun getCacheDir() = context.getDir(CACHE_FOLDER_NAME, Context.MODE_PRIVATE)
51+
protected fun getCacheDir() = context.getDir(DG_CACHE_FOLDER_NAME, Context.MODE_PRIVATE)
5352
protected fun getCacheDir(vmKey: String) = File(getCacheDir(), vmKey)
5453
protected fun getOptDir(vmKey: String) = File(getCacheDir(vmKey), "opt")
5554
protected fun isValidCache(vmKey: String) = getTheApkFile(vmKey).isFile && getOptDir(vmKey).isDirectory
@@ -98,7 +97,8 @@ open class HandleProxyFactory(private val context: Context) {
9897

9998
companion object {
10099
const val CLASS_NAME = "com.google.ccc.abuse.droidguard.DroidGuard"
101-
const val CACHE_FOLDER_NAME = "dg_cache"
100+
@Deprecated("Use DG_CACHE_FOLDER_NAME", ReplaceWith("DG_CACHE_FOLDER_NAME"))
101+
const val CACHE_FOLDER_NAME = DG_CACHE_FOLDER_NAME
102102
private val CLASS_MAP = hashMapOf<String, Class<*>>()
103103
val PROD_CERT_HASH = byteArrayOf(61, 122, 18, 35, 1, -102, -93, -99, -98, -96, -29, 67, 106, -73, -64, -119, 107, -5, 79, -74, 121, -12, -34, 95, -25, -62, 63, 50, 108, -113, -103, 74)
104104
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package com.google.android.gms.droidguard.internal;
7+
8+
import android.os.Bundle;
9+
import android.os.Parcel;
10+
import android.os.Parcelable;
11+
12+
import org.junit.Test;
13+
14+
import static org.junit.Assert.assertNotNull;
15+
import static org.junit.Assert.assertNull;
16+
import static org.junit.Assert.assertSame;
17+
18+
public class DroidGuardInitReplyTest {
19+
20+
@Test
21+
public void createFromParcel_returnsNullWhenBothFieldsMissing() {
22+
Parcel parcel = Parcel.obtain();
23+
parcel.writeParcelable(null, 0);
24+
parcel.writeParcelable(null, 0);
25+
parcel.setDataPosition(0);
26+
27+
assertNull(DroidGuardInitReply.CREATOR.createFromParcel(parcel));
28+
parcel.recycle();
29+
}
30+
31+
@Test
32+
public void createFromParcel_preservesObjectWhenPfdMissing() {
33+
Bundle extras = new Bundle();
34+
extras.putString("flow", "tachyon_registration");
35+
36+
Parcel parcel = Parcel.obtain();
37+
parcel.writeParcelable(null, 0);
38+
parcel.writeParcelable(extras, 0);
39+
parcel.setDataPosition(0);
40+
41+
DroidGuardInitReply reply = DroidGuardInitReply.CREATOR.createFromParcel(parcel);
42+
parcel.recycle();
43+
44+
assertNotNull(reply);
45+
assertNull(reply.pfd);
46+
assertSame(extras, reply.object);
47+
}
48+
49+
@Test
50+
public void roundTrip_preservesParcelableExtras() {
51+
Bundle extras = new Bundle();
52+
extras.putString("clientVersion", "252432031");
53+
54+
DroidGuardInitReply original = new DroidGuardInitReply(null, extras);
55+
Parcel parcel = Parcel.obtain();
56+
original.writeToParcel(parcel, 0);
57+
parcel.setDataPosition(0);
58+
59+
DroidGuardInitReply restored = DroidGuardInitReply.CREATOR.createFromParcel(parcel);
60+
parcel.recycle();
61+
62+
assertNotNull(restored);
63+
Parcelable restoredExtras = restored.object;
64+
assertNotNull(restoredExtras);
65+
assertSame(extras, restoredExtras);
66+
}
67+
}

0 commit comments

Comments
 (0)