diff --git a/build.gradle b/build.gradle index 296bd87097..13fe28848a 100644 --- a/build.gradle +++ b/build.gradle @@ -73,7 +73,7 @@ def execResult(... args) { } def ignoreGit = providers.environmentVariable('GRADLE_MICROG_VERSION_WITHOUT_GIT').getOrElse('0') == '1' -def gmsVersion = "25.09.32" +def gmsVersion = "25.19.31" def gmsVersionCode = Integer.parseInt(gmsVersion.replaceAll('\\.', '')) def vendingVersion = "40.2.26" def vendingVersionCode = Integer.parseInt(vendingVersion.replaceAll('\\.', '')) diff --git a/gradle.properties b/gradle.properties index 1c50760c87..c5f2244f31 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,6 @@ android.useAndroidX=true org.gradle.configuration-cache=true org.gradle.caching=true -org.gradle.jvmargs=-Xmx4096m -XX:+UseParallelGC --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED +org.gradle.jvmargs=-Xmx6144m -XX:+UseParallelGC -XX:MaxMetaspaceSize=1024m --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED +kotlin.daemon.jvmargs=-Xmx3072m +android.suppressUnsupportedCompileSdk=35 diff --git a/play-services-api/src/main/aidl/com/google/android/gms/asterism/GetAsterismConsentRequest.aidl b/play-services-api/src/main/aidl/com/google/android/gms/asterism/GetAsterismConsentRequest.aidl new file mode 100644 index 0000000000..5eaf47dbab --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/asterism/GetAsterismConsentRequest.aidl @@ -0,0 +1,2 @@ +package com.google.android.gms.asterism; +parcelable GetAsterismConsentRequest; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/asterism/GetAsterismConsentResponse.aidl b/play-services-api/src/main/aidl/com/google/android/gms/asterism/GetAsterismConsentResponse.aidl new file mode 100644 index 0000000000..619f036646 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/asterism/GetAsterismConsentResponse.aidl @@ -0,0 +1,2 @@ +package com.google.android.gms.asterism; +parcelable GetAsterismConsentResponse; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/asterism/SetAsterismConsentRequest.aidl b/play-services-api/src/main/aidl/com/google/android/gms/asterism/SetAsterismConsentRequest.aidl new file mode 100644 index 0000000000..ea5ab9c4b5 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/asterism/SetAsterismConsentRequest.aidl @@ -0,0 +1,2 @@ +package com.google.android.gms.asterism; +parcelable SetAsterismConsentRequest; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/asterism/SetAsterismConsentResponse.aidl b/play-services-api/src/main/aidl/com/google/android/gms/asterism/SetAsterismConsentResponse.aidl new file mode 100644 index 0000000000..2249636a94 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/asterism/SetAsterismConsentResponse.aidl @@ -0,0 +1,2 @@ +package com.google.android.gms.asterism; +parcelable SetAsterismConsentResponse; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/asterism/internal/IAsterismApiService.aidl b/play-services-api/src/main/aidl/com/google/android/gms/asterism/internal/IAsterismApiService.aidl new file mode 100644 index 0000000000..bddf751f9f --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/asterism/internal/IAsterismApiService.aidl @@ -0,0 +1,12 @@ +package com.google.android.gms.asterism.internal; + +import com.google.android.gms.asterism.internal.IAsterismCallbacks; +import com.google.android.gms.asterism.GetAsterismConsentRequest; +import com.google.android.gms.asterism.SetAsterismConsentRequest; +import com.google.android.gms.common.api.ApiMetadata; + +interface IAsterismApiService { + void getAsterismConsent(IAsterismCallbacks callbacks, in GetAsterismConsentRequest request, in ApiMetadata metadata) = 0; + void setAsterismConsent(IAsterismCallbacks callbacks, in SetAsterismConsentRequest request, in ApiMetadata metadata) = 1; + void getIsPnvrConstellationDevice(IAsterismCallbacks callbacks, in ApiMetadata metadata) = 2; +} diff --git a/play-services-api/src/main/aidl/com/google/android/gms/asterism/internal/IAsterismCallbacks.aidl b/play-services-api/src/main/aidl/com/google/android/gms/asterism/internal/IAsterismCallbacks.aidl new file mode 100644 index 0000000000..b4fe1a9aad --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/asterism/internal/IAsterismCallbacks.aidl @@ -0,0 +1,12 @@ +package com.google.android.gms.asterism.internal; + +import com.google.android.gms.asterism.GetAsterismConsentResponse; +import com.google.android.gms.asterism.SetAsterismConsentResponse; +import com.google.android.gms.common.api.ApiMetadata; +import com.google.android.gms.common.api.Status; + +oneway interface IAsterismCallbacks { + void onConsentFetched(in Status status, in GetAsterismConsentResponse response, in ApiMetadata metadata) = 0; + void onConsentRegistered(in Status status, in SetAsterismConsentResponse response, in ApiMetadata metadata) = 1; + void onIsPnvrConstellationDevice(in Status status, boolean result, in ApiMetadata metadata) = 2; +} diff --git a/play-services-api/src/main/aidl/com/google/android/gms/constellation/GetIidTokenRequest.aidl b/play-services-api/src/main/aidl/com/google/android/gms/constellation/GetIidTokenRequest.aidl new file mode 100644 index 0000000000..4c89fd1ba9 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/constellation/GetIidTokenRequest.aidl @@ -0,0 +1,2 @@ +package com.google.android.gms.constellation; +parcelable GetIidTokenRequest; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/constellation/GetIidTokenResponse.aidl b/play-services-api/src/main/aidl/com/google/android/gms/constellation/GetIidTokenResponse.aidl new file mode 100644 index 0000000000..0801bd19f1 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/constellation/GetIidTokenResponse.aidl @@ -0,0 +1,2 @@ +package com.google.android.gms.constellation; +parcelable GetIidTokenResponse; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.aidl b/play-services-api/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.aidl new file mode 100644 index 0000000000..52e18850c5 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.aidl @@ -0,0 +1,2 @@ +package com.google.android.gms.constellation; +parcelable GetPnvCapabilitiesRequest; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.aidl b/play-services-api/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.aidl new file mode 100644 index 0000000000..677989f7f7 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.aidl @@ -0,0 +1,2 @@ +package com.google.android.gms.constellation; +parcelable GetPnvCapabilitiesResponse; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/constellation/IdTokenRequest.aidl b/play-services-api/src/main/aidl/com/google/android/gms/constellation/IdTokenRequest.aidl new file mode 100644 index 0000000000..6c3182de53 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/constellation/IdTokenRequest.aidl @@ -0,0 +1,3 @@ +// AIDL parcelable declaration +package com.google.android.gms.constellation; +parcelable IdTokenRequest; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/constellation/PhoneNumberInfo.aidl b/play-services-api/src/main/aidl/com/google/android/gms/constellation/PhoneNumberInfo.aidl new file mode 100644 index 0000000000..38f14e10ca --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/constellation/PhoneNumberInfo.aidl @@ -0,0 +1,2 @@ +package com.google.android.gms.constellation; +parcelable PhoneNumberInfo; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/constellation/PhoneNumberVerification.aidl b/play-services-api/src/main/aidl/com/google/android/gms/constellation/PhoneNumberVerification.aidl new file mode 100644 index 0000000000..33aa05ba86 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/constellation/PhoneNumberVerification.aidl @@ -0,0 +1,3 @@ +// AIDL parcelable declaration +package com.google.android.gms.constellation; +parcelable PhoneNumberVerification; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/constellation/SimCapability.aidl b/play-services-api/src/main/aidl/com/google/android/gms/constellation/SimCapability.aidl new file mode 100644 index 0000000000..0e3d0740b4 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/constellation/SimCapability.aidl @@ -0,0 +1,3 @@ +// AIDL parcelable declaration +package com.google.android.gms.constellation; +parcelable SimCapability; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/constellation/VerificationCapability.aidl b/play-services-api/src/main/aidl/com/google/android/gms/constellation/VerificationCapability.aidl new file mode 100644 index 0000000000..4e3148ee0f --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/constellation/VerificationCapability.aidl @@ -0,0 +1,3 @@ +// AIDL parcelable declaration +package com.google.android.gms.constellation; +parcelable VerificationCapability; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberRequest.aidl b/play-services-api/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberRequest.aidl new file mode 100644 index 0000000000..2fdf4b1184 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberRequest.aidl @@ -0,0 +1,2 @@ +package com.google.android.gms.constellation; +parcelable VerifyPhoneNumberRequest; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberResponse.aidl b/play-services-api/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberResponse.aidl new file mode 100644 index 0000000000..57fc150dcd --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberResponse.aidl @@ -0,0 +1,2 @@ +package com.google.android.gms.constellation; +parcelable VerifyPhoneNumberResponse; diff --git a/play-services-api/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationApiService.aidl b/play-services-api/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationApiService.aidl new file mode 100644 index 0000000000..40a28979eb --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationApiService.aidl @@ -0,0 +1,19 @@ +package com.google.android.gms.constellation.internal; + +import android.os.Bundle; +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; + +/** + * Constellation API service for phone number verification. + */ +interface IConstellationApiService { + void verifyPhoneNumberV1(IConstellationCallbacks callbacks, in Bundle bundle, in ApiMetadata metadata) = 0; + void verifyPhoneNumberSingleUse(IConstellationCallbacks callbacks, in Bundle bundle, in ApiMetadata metadata) = 1; + void verifyPhoneNumber(IConstellationCallbacks callbacks, in VerifyPhoneNumberRequest request, in ApiMetadata metadata) = 2; + void getIidToken(IConstellationCallbacks callbacks, in GetIidTokenRequest request, in ApiMetadata metadata) = 3; + void getPnvCapabilities(IConstellationCallbacks callbacks, in GetPnvCapabilitiesRequest request, in ApiMetadata metadata) = 4; +} diff --git a/play-services-api/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationCallbacks.aidl b/play-services-api/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationCallbacks.aidl new file mode 100644 index 0000000000..11e5224d28 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationCallbacks.aidl @@ -0,0 +1,18 @@ +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.PhoneNumberInfo; +import com.google.android.gms.constellation.GetIidTokenResponse; +import com.google.android.gms.constellation.GetPnvCapabilitiesResponse; +import com.google.android.gms.constellation.VerifyPhoneNumberResponse; + +/** + * Constellation callbacks. + */ +oneway interface IConstellationCallbacks { + void onPhoneNumberVerified(in Status status, in List phoneNumbers, in ApiMetadata metadata) = 0; + void onPhoneNumberVerificationsCompleted(in Status status, in VerifyPhoneNumberResponse response, in ApiMetadata metadata) = 1; + void onIidTokenGenerated(in Status status, in GetIidTokenResponse response, in ApiMetadata metadata) = 2; + void onGetPnvCapabilitiesCompleted(in Status status, in GetPnvCapabilitiesResponse response, in ApiMetadata metadata) = 3; +} diff --git a/play-services-api/src/main/aidl/com/google/android/gms/phenotype/internal/IGetStorageInfoCallbacks.aidl b/play-services-api/src/main/aidl/com/google/android/gms/phenotype/internal/IGetStorageInfoCallbacks.aidl new file mode 100644 index 0000000000..9f94215b1c --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/phenotype/internal/IGetStorageInfoCallbacks.aidl @@ -0,0 +1,7 @@ +package com.google.android.gms.phenotype.internal; + +import com.google.android.gms.common.api.Status; + +interface IGetStorageInfoCallbacks { + oneway void onStorageInfo(in Status status, in byte[] data) = 1; +} diff --git a/play-services-api/src/main/aidl/com/google/android/gms/phenotype/internal/IPhenotypeService.aidl b/play-services-api/src/main/aidl/com/google/android/gms/phenotype/internal/IPhenotypeService.aidl index eb6113d9ed..8eadff6a81 100644 --- a/play-services-api/src/main/aidl/com/google/android/gms/phenotype/internal/IPhenotypeService.aidl +++ b/play-services-api/src/main/aidl/com/google/android/gms/phenotype/internal/IPhenotypeService.aidl @@ -1,6 +1,7 @@ package com.google.android.gms.phenotype.internal; import com.google.android.gms.common.api.internal.IStatusCallback; +import com.google.android.gms.phenotype.internal.IGetStorageInfoCallbacks; import com.google.android.gms.phenotype.internal.IPhenotypeCallbacks; import com.google.android.gms.phenotype.Flag; import com.google.android.gms.phenotype.RegistrationInfo; @@ -32,4 +33,7 @@ interface IPhenotypeService { oneway void syncAllAfterOperation(IPhenotypeCallbacks callbacks, long p1) = 23; // returns via callbacks.onSyncFinished() oneway void setRuntimeProperties(IStatusCallback callbacks, String p1, in byte[] p2) = 24; // oneway void setExternalExperiments(IStatusCallback callbacks, String p1, in List p2) = 25; + oneway void getStorageInfo(IGetStorageInfoCallbacks callbacks) = 26; + oneway void commitToConfigurationV2(IPhenotypeCallbacks callbacks, in byte[] data) = 30; } + diff --git a/play-services-api/src/main/java/com/google/android/gms/asterism/GetAsterismConsentRequest.java b/play-services-api/src/main/java/com/google/android/gms/asterism/GetAsterismConsentRequest.java new file mode 100644 index 0000000000..736a0296da --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/asterism/GetAsterismConsentRequest.java @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.asterism; + +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; + +@SafeParcelable.Class +public class GetAsterismConsentRequest extends AbstractSafeParcelable { + @Field(1) + public int requestCode; + @Field(2) + public int asterClientType; + + @Constructor + public GetAsterismConsentRequest(@Param(1) int requestCode, @Param(2) int asterClientType) { + this.requestCode = requestCode; + this.asterClientType = asterClientType; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(GetAsterismConsentRequest.class); +} diff --git a/play-services-api/src/main/java/com/google/android/gms/asterism/GetAsterismConsentResponse.java b/play-services-api/src/main/java/com/google/android/gms/asterism/GetAsterismConsentResponse.java new file mode 100644 index 0000000000..f16d8a2f04 --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/asterism/GetAsterismConsentResponse.java @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.asterism; + +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; + +@SafeParcelable.Class +public class GetAsterismConsentResponse extends AbstractSafeParcelable { + @Field(1) + public int requestCode; + @Field(2) + public int consentState; + @Field(3) + public String iidToken; + @Field(4) + public String gaiaToken; + @Field(5) + public int consentVersion; + + @Constructor + public GetAsterismConsentResponse(@Param(1) int requestCode, @Param(2) int consentState, + @Param(3) String iidToken, @Param(4) String gaiaToken, @Param(5) int consentVersion) { + this.requestCode = requestCode; + this.consentState = consentState; + this.iidToken = iidToken; + this.gaiaToken = gaiaToken; + this.consentVersion = consentVersion; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(GetAsterismConsentResponse.class); +} diff --git a/play-services-api/src/main/java/com/google/android/gms/asterism/SetAsterismConsentRequest.java b/play-services-api/src/main/java/com/google/android/gms/asterism/SetAsterismConsentRequest.java new file mode 100644 index 0000000000..fc6eee1ddf --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/asterism/SetAsterismConsentRequest.java @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.asterism; + +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; + +@SafeParcelable.Class +public class SetAsterismConsentRequest extends AbstractSafeParcelable { + @Field(1) + public int requestCode; + @Field(2) + public int asterClientType; + @Field(4) + public long timestamp; + @Field(5) + public int consentSource; + @Field(6) + public Bundle extras; + @Field(7) + public int consentVariant; + + @Constructor + public SetAsterismConsentRequest(@Param(1) int requestCode, @Param(2) int asterClientType, + @Param(4) long timestamp, @Param(5) int consentSource, @Param(6) Bundle extras, + @Param(7) int consentVariant) { + this.requestCode = requestCode; + this.asterClientType = asterClientType; + this.timestamp = timestamp; + this.consentSource = consentSource; + this.extras = extras; + this.consentVariant = consentVariant; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(SetAsterismConsentRequest.class); +} diff --git a/play-services-api/src/main/java/com/google/android/gms/asterism/SetAsterismConsentResponse.java b/play-services-api/src/main/java/com/google/android/gms/asterism/SetAsterismConsentResponse.java new file mode 100644 index 0000000000..4fd24741b8 --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/asterism/SetAsterismConsentResponse.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.asterism; + +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; + +@SafeParcelable.Class +public class SetAsterismConsentResponse extends AbstractSafeParcelable { + @Field(1) + public int requestCode; + @Field(2) + public String iidToken; + @Field(3) + public String gaiaToken; + + @Constructor + public SetAsterismConsentResponse(@Param(1) int requestCode, @Param(2) String iidToken, @Param(3) String gaiaToken) { + this.requestCode = requestCode; + this.iidToken = iidToken; + this.gaiaToken = gaiaToken; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(SetAsterismConsentResponse.class); +} diff --git a/play-services-api/src/main/java/com/google/android/gms/constellation/GetIidTokenRequest.java b/play-services-api/src/main/java/com/google/android/gms/constellation/GetIidTokenRequest.java new file mode 100644 index 0000000000..6ec37a027b --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/constellation/GetIidTokenRequest.java @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +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; + +/** + * Request to get an Instance ID token. + */ +@SafeParcelable.Class +public class GetIidTokenRequest extends AbstractSafeParcelable { + @Field(1) + public Long subscriptionId; + + @Constructor + public GetIidTokenRequest(@Param(1) Long subscriptionId) { + this.subscriptionId = subscriptionId; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(GetIidTokenRequest.class); +} diff --git a/play-services-api/src/main/java/com/google/android/gms/constellation/GetIidTokenResponse.java b/play-services-api/src/main/java/com/google/android/gms/constellation/GetIidTokenResponse.java new file mode 100644 index 0000000000..41aaf7cda2 --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/constellation/GetIidTokenResponse.java @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +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; + +/** + * Response containing Instance ID token. + * + * Fields: iidToken, fid (Firebase Installation ID), signature bytes, signature timestamp. + */ +@SafeParcelable.Class +public class GetIidTokenResponse extends AbstractSafeParcelable { + @Field(1) + public String token; + @Field(2) + public String fid; + @Field(3) + public byte[] signature; + @Field(4) + public long signatureTimestampMillis; + + @Constructor + public GetIidTokenResponse( + @Param(1) String token, + @Param(2) String fid, + @Param(3) byte[] signature, + @Param(4) long signatureTimestampMillis) { + this.token = token; + this.fid = fid; + this.signature = signature; + this.signatureTimestampMillis = signatureTimestampMillis; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(GetIidTokenResponse.class); +} diff --git a/play-services-api/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.java b/play-services-api/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.java new file mode 100644 index 0000000000..c0bca02603 --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +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; + +/** + * Request to get phone number verification (PNV) capabilities. + * + * Fields: + * - field 1: policyId - UPI policy string + * - field 2: verificationMethods - List of method IDs to check + * - field 3: subscriptionIds - List of subscription IDs + */ +@SafeParcelable.Class +public class GetPnvCapabilitiesRequest extends AbstractSafeParcelable { + @Field(1) + public String policyId; + @Field(2) + public List verificationMethods; + @Field(3) + public List subscriptionIds; + + @Constructor + public GetPnvCapabilitiesRequest( + @Param(1) String policyId, + @Param(2) List verificationMethods, + @Param(3) List subscriptionIds) { + this.policyId = policyId; + this.verificationMethods = verificationMethods; + this.subscriptionIds = subscriptionIds; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(GetPnvCapabilitiesRequest.class); +} diff --git a/play-services-api/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.java b/play-services-api/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.java new file mode 100644 index 0000000000..f28de18d17 --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.java @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +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; + +/** + * Response containing PNV capabilities. + * + * Contains a list of SimCapability objects describing what verification + * methods are available for each SIM. + */ +@SafeParcelable.Class +public class GetPnvCapabilitiesResponse extends AbstractSafeParcelable { + @Field(1) + public List capabilities; + + @Constructor + public GetPnvCapabilitiesResponse(@Param(1) List capabilities) { + this.capabilities = capabilities; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(GetPnvCapabilitiesResponse.class); +} diff --git a/play-services-api/src/main/java/com/google/android/gms/constellation/IdTokenRequest.java b/play-services-api/src/main/java/com/google/android/gms/constellation/IdTokenRequest.java new file mode 100644 index 0000000000..b21a1c82f1 --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/constellation/IdTokenRequest.java @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +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; + +@SafeParcelable.Class +public class IdTokenRequest extends AbstractSafeParcelable { + @Field(1) + public String audience; + @Field(2) + public String nonce; + + @Constructor + public IdTokenRequest(@Param(1) String audience, @Param(2) String nonce) { + this.audience = audience; + this.nonce = nonce; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(IdTokenRequest.class); +} diff --git a/play-services-api/src/main/java/com/google/android/gms/constellation/ImsiRequest.java b/play-services-api/src/main/java/com/google/android/gms/constellation/ImsiRequest.java new file mode 100644 index 0000000000..20ea875642 --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/constellation/ImsiRequest.java @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +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; + +/** + * Request containing IMSI and associated metadata. + * + * Fields: + * - imsi: IMSI string + * - msisdn: MSISDN/phone number (E.164 string) + */ +@SafeParcelable.Class +public class ImsiRequest extends AbstractSafeParcelable { + @Field(1) + public String imsi; + @Field(2) + public String msisdn; + + @Constructor + public ImsiRequest(@Param(1) String imsi, @Param(2) String msisdn) { + this.imsi = imsi; + this.msisdn = msisdn; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(ImsiRequest.class); +} diff --git a/play-services-api/src/main/java/com/google/android/gms/constellation/PhoneNumberInfo.java b/play-services-api/src/main/java/com/google/android/gms/constellation/PhoneNumberInfo.java new file mode 100644 index 0000000000..008fbe2516 --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/constellation/PhoneNumberInfo.java @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +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; + +@SafeParcelable.Class +public class PhoneNumberInfo extends AbstractSafeParcelable { + @Field(1) + public int version; + @Field(2) + public String phoneNumber; + @Field(3) + public Long timestamp; + @Field(4) + public Bundle extras; + + public PhoneNumberInfo(String phoneNumber, Long timestamp, Bundle extras) { + this.version = 1; + this.phoneNumber = phoneNumber; + this.timestamp = timestamp; + this.extras = extras; + } + + @Constructor + public PhoneNumberInfo(@Param(1) int version, @Param(2) String phoneNumber, @Param(3) Long timestamp, @Param(4) Bundle extras) { + this.version = version; + this.phoneNumber = phoneNumber; + this.timestamp = timestamp; + this.extras = extras; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(PhoneNumberInfo.class); +} diff --git a/play-services-api/src/main/java/com/google/android/gms/constellation/PhoneNumberVerification.java b/play-services-api/src/main/java/com/google/android/gms/constellation/PhoneNumberVerification.java new file mode 100644 index 0000000000..8908da75d1 --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/constellation/PhoneNumberVerification.java @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +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; + +/** + * Represents a phone number verification result from Constellation. + * + * Fields: phoneNumber, timestamp, verificationMethod, errorCode, token, extras, + * verificationStatus, retryAfterSeconds. + * + * AIDL verificationStatus values differ from proto VerificationState: + * Proto: UNKNOWN=0, NONE=1, PENDING=2, VERIFIED=3 + * AIDL: 1=VERIFIED + */ +@SafeParcelable.Class +public class PhoneNumberVerification extends AbstractSafeParcelable { + @Field(1) + public String phoneNumber; + @Field(2) + public long timestamp; + @Field(3) + public int verificationMethod; // Was incorrectly named verificationState + @Field(4) + public int errorCode; // Was incorrectly at field 7 + @Field(5) + public String token; + @Field(6) + public Bundle extras; + @Field(7) + public int verificationStatus; // Was incorrectly named errorCode + @Field(8) + public long retryAfterSeconds; + + @Constructor + public PhoneNumberVerification( + @Param(1) String phoneNumber, + @Param(2) long timestamp, + @Param(3) int verificationMethod, + @Param(4) int errorCode, + @Param(5) String token, + @Param(6) Bundle extras, + @Param(7) int verificationStatus, + @Param(8) long retryAfterSeconds) { + this.phoneNumber = phoneNumber; + this.timestamp = timestamp; + this.verificationMethod = verificationMethod; + this.errorCode = errorCode; + this.token = token; + this.extras = extras; + this.verificationStatus = verificationStatus; + this.retryAfterSeconds = retryAfterSeconds; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(PhoneNumberVerification.class); + + // Verification status (field 7) - AIDL values (NOT proto values!) + // Evidence: bazj.java:287+314, bazk.java:296+387 all set g=1 for verified numbers + // bayl.java:22-28 requires phone number when g==1 ("Invalid verifiedPhoneNumber") + public static final int STATUS_VERIFIED = 1; // AIDL verified (proto would be 3) + public static final int STATUS_UNVERIFIED = 0; // NOT_VERIFIED + public static final int STATUS_SMS_VERIFICATION_FAILED = 4; + // Messages uses status 7 as a non-retryable failure that triggers manual MSISDN fallback + public static final int STATUS_NON_RETRYABLE_FAILURE = 7; + // Messages uses status 8 to mark UPI ineligible and still sends a token + public static final int STATUS_INELIGIBLE = 8; + // Other status values are less certain, but validation allows 0-10 + + // Verification method (field 3) - AIDL values differ from proto enum values + public static final int METHOD_UNKNOWN = 0; + public static final int METHOD_MO_SMS = 1; + public static final int METHOD_MT_SMS = 2; + public static final int METHOD_CARRIER_ID = 3; + public static final int METHOD_IMSI_LOOKUP = 5; + public static final int METHOD_REGISTERED_SMS = 7; + public static final int METHOD_FLASH_CALL = 8; + public static final int METHOD_TS43_AIDL = 9; // TS43 in AIDL (proto gbqb.TS43 = 11, but AIDL uses 9) + public static final int METHOD_TS43 = 11; // gbqb.TS43 proto value - DO NOT use in PhoneNumberVerification + + // Error code (field 4) - defaults to -1, valid range: >= 0 or == -1 + public static final int ERROR_NONE = -1; +} diff --git a/play-services-api/src/main/java/com/google/android/gms/constellation/SimCapability.java b/play-services-api/src/main/java/com/google/android/gms/constellation/SimCapability.java new file mode 100644 index 0000000000..a350d8b18a --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/constellation/SimCapability.java @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +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; + +/** + * SIM capability information for phone verification. + * + * Fields: + * - subscriptionId: Android subscription ID for the SIM + * - phoneNumber: Phone number associated with SIM (E.164) + * - slotIndex: Physical SIM slot index + * - carrierId: Carrier identifier string + * - verificationCapabilities: List of supported verification methods + */ +@SafeParcelable.Class +public class SimCapability extends AbstractSafeParcelable { + @Field(1) + public int subscriptionId; + @Field(2) + public String phoneNumber; + @Field(3) + public int slotIndex; + @Field(4) + public String carrierId; + @Field(5) + public List verificationCapabilities; + + @Constructor + public SimCapability( + @Param(1) int subscriptionId, + @Param(2) String phoneNumber, + @Param(3) int slotIndex, + @Param(4) String carrierId, + @Param(5) List verificationCapabilities) { + this.subscriptionId = subscriptionId; + this.phoneNumber = phoneNumber; + this.slotIndex = slotIndex; + this.carrierId = carrierId; + this.verificationCapabilities = verificationCapabilities; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(SimCapability.class); +} diff --git a/play-services-api/src/main/java/com/google/android/gms/constellation/VerificationCapability.java b/play-services-api/src/main/java/com/google/android/gms/constellation/VerificationCapability.java new file mode 100644 index 0000000000..aa889b0839 --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/constellation/VerificationCapability.java @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +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; + +/** + * Represents a verification capability/method. + * + * Fields: + * - verificationType: Type of verification (SMS OTP, EAP-AKA, etc.) + * - priority: Priority order for this verification method + * + * Verification types (inferred from constellation.proto): + * - 0: Unknown + * - 1: SMS OTP + * - 2: EAP-AKA (TS.43) + * - 3: Silent verification + * - 4: Device attestation + */ +@SafeParcelable.Class +public class VerificationCapability extends AbstractSafeParcelable { + @Field(1) + public int verificationType; + @Field(2) + public int priority; + + @Constructor + public VerificationCapability( + @Param(1) int verificationType, + @Param(2) int priority) { + this.verificationType = verificationType; + this.priority = priority; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(VerificationCapability.class); + + // Verification types (inferred) + public static final int TYPE_UNKNOWN = 0; + public static final int TYPE_SMS_OTP = 1; + public static final int TYPE_EAP_AKA = 2; // TS.43 + public static final int TYPE_SILENT = 3; + public static final int TYPE_ATTESTATION = 4; +} diff --git a/play-services-api/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberRequest.java b/play-services-api/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberRequest.java new file mode 100644 index 0000000000..f4ff309916 --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberRequest.java @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +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; + +/** + * Request to verify a phone number via Constellation. + * + * - field 1: policyId - UPI policy string (e.g., "upi-carrier-id-mt-priority"), NOT phone number + * - field 2: timeout - always 0L from Messages + * - field 3: idTokenRequest - audience + nonce for JWT + * - field 4: extras - Bundle with session_id, consent_type, force_provisioning, etc. + * - field 5: imsiRequests - List of IMSI/MSISDN pairs per SIM + * - field 6: allowFallback - whether fallback verification methods are allowed + * - field 7: verificationType - preferred verification type + * - field 8: verificationCapabilities - supported verification methods (List) + */ +@SafeParcelable.Class +public class VerifyPhoneNumberRequest extends AbstractSafeParcelable { + @Field(1) + public String policyId; + @Field(2) + public long timeout; + @Field(3) + public IdTokenRequest idTokenRequest; + @Field(4) + public Bundle extras; + @Field(5) + public List imsiRequests; + @Field(6) + public boolean allowFallback; + @Field(7) + public int verificationType; + @Field(8) + public List verificationCapabilities; + + @Constructor + public VerifyPhoneNumberRequest( + @Param(1) String policyId, + @Param(2) long timeout, + @Param(3) IdTokenRequest idTokenRequest, + @Param(4) Bundle extras, + @Param(5) List imsiRequests, + @Param(6) boolean allowFallback, + @Param(7) int verificationType, + @Param(8) List verificationCapabilities) { + this.policyId = policyId; + this.timeout = timeout; + this.idTokenRequest = idTokenRequest; + this.extras = extras; + this.imsiRequests = imsiRequests; + this.allowFallback = allowFallback; + this.verificationType = verificationType; + this.verificationCapabilities = verificationCapabilities; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(VerifyPhoneNumberRequest.class); +} diff --git a/play-services-api/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberResponse.java b/play-services-api/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberResponse.java new file mode 100644 index 0000000000..393e4dea48 --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberResponse.java @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +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; + +/** + * Response from phone number verification. + * + * Contains an array of PhoneNumberVerification objects - one for each + * phone number that was verified or attempted. + */ +@SafeParcelable.Class +public class VerifyPhoneNumberResponse extends AbstractSafeParcelable { + @Field(1) + public PhoneNumberVerification[] verifications; + @Field(2) + public 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); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(VerifyPhoneNumberResponse.class); +} diff --git a/play-services-conscrypt-provider-core/src/main/java/com/google/android/gms/providerinstaller/ProviderInstallerImpl.java b/play-services-conscrypt-provider-core/src/main/java/com/google/android/gms/providerinstaller/ProviderInstallerImpl.java index 175efc6046..7c02ad5862 100644 --- a/play-services-conscrypt-provider-core/src/main/java/com/google/android/gms/providerinstaller/ProviderInstallerImpl.java +++ b/play-services-conscrypt-provider-core/src/main/java/com/google/android/gms/providerinstaller/ProviderInstallerImpl.java @@ -83,8 +83,10 @@ public static void insertProvider(Context context) { int res = Security.insertProviderAt(provider, 1); if (res == 1) { - Security.setProperty("ssl.SocketFactory.provider", "com.google.android.gms.org.conscrypt.OpenSSLSocketFactoryImpl"); - Security.setProperty("ssl.ServerSocketFactory.provider", "com.google.android.gms.org.conscrypt.OpenSSLServerSocketFactoryImpl"); + if (SDK_INT < 29) { + Security.setProperty("ssl.SocketFactory.provider", "com.google.android.gms.org.conscrypt.OpenSSLSocketFactoryImpl"); + Security.setProperty("ssl.ServerSocketFactory.provider", "com.google.android.gms.org.conscrypt.OpenSSLServerSocketFactoryImpl"); + } SSLContext sslContext = SSLContext.getInstance("Default"); SSLContext.setDefault(sslContext); @@ -109,6 +111,24 @@ public static void reportRequestStats(Context context, long elapsedRealtimeBefor private static void initProvider(Context context, String packageName) { Log.d(TAG, "Initializing provider for " + packageName); + // Android 10+ ships Conscrypt in /apex/com.android.conscrypt/ which provides + // modern TLS. Don't load microG's bundled libconscrypt_gmscore_jni.so -- + // it ABORTs (SIGABRT in JNI_OnLoad) on Samsung Android 12. + // Instead, wrap the system Conscrypt provider under the GmsCore_OpenSSL name + // so callers that look for it by name (e.g., Messages) still find it. + if (SDK_INT >= 29) { + Provider systemConscrypt = Security.getProvider("AndroidOpenSSL"); + if (systemConscrypt != null) { + provider = new Provider(PROVIDER_NAME, systemConscrypt.getVersion(), + "GmsCore Conscrypt (system " + SDK_INT + ")") {}; + provider.putAll(systemConscrypt); + Log.d(TAG, "Android " + SDK_INT + " -- wrapped system Conscrypt as " + PROVIDER_NAME); + } else { + Log.w(TAG, "Android " + SDK_INT + " but no AndroidOpenSSL provider found"); + } + return; + } + try { provider = Conscrypt.newProviderBuilder().setName(PROVIDER_NAME).defaultTlsProtocol("TLSv1.2").build(); } catch (UnsatisfiedLinkError e) { @@ -132,14 +152,23 @@ private static void loadConscryptDirect(Context context, String packageName) thr String path = "lib/" + primaryCpuAbi + "/libconscrypt_gmscore_jni.so"; File cacheFile = new File(context.createPackageContext(packageName, 0).getCacheDir().getAbsolutePath() + "/.gmscore/" + path); cacheFile.getParentFile().mkdirs(); - File apkFile = new File(context.getPackageCodePath()); + + // FIX: Get GmsCore's APK path, not the calling app's APK + // context.getPackageCodePath() returns the calling app (e.g., Messages), but + // libconscrypt_gmscore_jni.so is in GmsCore's APK + ApplicationInfo gmsInfo = context.getPackageManager().getApplicationInfo("com.google.android.gms", 0); + File apkFile = new File(gmsInfo.sourceDir); + Log.d(TAG, "Loading conscrypt from GmsCore APK: " + apkFile.getPath()); + if (!cacheFile.exists() || cacheFile.lastModified() < apkFile.lastModified()) { - ZipFile zipFile = new ZipFile(apkFile); - ZipEntry entry = zipFile.getEntry(path); - if (entry != null) { - copyInputStream(zipFile.getInputStream(entry), new FileOutputStream(cacheFile)); - } else { - Log.d(TAG, "Can't load native library: " + path + " does not exist in " + apkFile); + try (ZipFile zipFile = new ZipFile(apkFile)) { + ZipEntry entry = zipFile.getEntry(path); + if (entry != null) { + copyInputStream(zipFile.getInputStream(entry), new FileOutputStream(cacheFile)); + Log.d(TAG, "Extracted native library to: " + cacheFile.getPath()); + } else { + Log.d(TAG, "Can't load native library: " + path + " does not exist in " + apkFile); + } } } Log.d(TAG, "Loading conscrypt_gmscore_jni from " + cacheFile.getPath()); diff --git a/play-services-core/build.gradle b/play-services-core/build.gradle index a348337ce1..4f0199319a 100644 --- a/play-services-core/build.gradle +++ b/play-services-core/build.gradle @@ -5,6 +5,7 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' +apply plugin: 'com.squareup.wire' configurations { mapboxRuntimeOnly @@ -15,6 +16,7 @@ configurations { dependencies { implementation "com.squareup.wire:wire-runtime:$wireVersion" + implementation "com.squareup.wire:wire-grpc-client:$wireVersion" implementation "de.hdodenhof:circleimageview:1.3.0" implementation project(':fake-signature') @@ -105,6 +107,8 @@ dependencies { implementation "androidx.credentials:credentials:$credentialsVersion" implementation "androidx.work:work-runtime-ktx:$workVersion" + + testImplementation 'junit:junit:4.13.2' } android { @@ -219,3 +223,14 @@ android.applicationVariants.all { variant -> output.outputFileName = variant.applicationId + "-" + variant.versionCode + variant.versionName.substring(version.length()) + ".apk" } } + +// Enable deprecation and unchecked warnings for Java compilation +tasks.withType(JavaCompile).configureEach { + options.compilerArgs += ["-Xlint:deprecation", "-Xlint:unchecked"] +} + +wire { + kotlin { + javaInterop = true + } +} diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index a17d7f03ef..428a5de0d0 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -142,6 +142,13 @@ + + + + + + + @@ -437,6 +444,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -997,6 +1024,17 @@ + + + + + + + + + - @@ -1359,7 +1396,6 @@ - @@ -1412,7 +1448,6 @@ - diff --git a/play-services-core/src/main/java/org/microg/gms/asterism/AsterismService.java b/play-services-core/src/main/java/org/microg/gms/asterism/AsterismService.java new file mode 100644 index 0000000000..01bfc9378a --- /dev/null +++ b/play-services-core/src/main/java/org/microg/gms/asterism/AsterismService.java @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.asterism; + +import android.os.RemoteException; +import android.util.Log; + +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 org.microg.gms.BaseService; +import org.microg.gms.common.GmsService; +import org.microg.gms.constellation.RcsFeatures; +import org.microg.gms.rcs.RcsCallerPolicy; + +/** + * Asterism Service - Consent Management for RCS. + * + * Handles Google Terms of Service consent. Messages calls setConsent() to record + * ToS agreement before provisioning. + * + * Service ID: 199 (ASTERISM) + * Action: com.google.android.gms.asterism.service.START + */ +public class AsterismService extends BaseService { + private static final String TAG = "GmsAsterismSvc"; + + private AsterismServiceImpl impl; + + public AsterismService() { + super(TAG, GmsService.ASTERISM); + } + + @Override + public void handleServiceRequest(IGmsCallbacks callback, GetServiceRequest request, GmsService service) throws RemoteException { + String callingPackage = RcsCallerPolicy.checkAsterismCaller(this, request); + String callingVersion = RcsCallerPolicy.getPackageVersionSummary(this, callingPackage); + Log.d(TAG, "handleServiceRequest: supportsConnectionInfo=" + request.supportsConnectionInfo); + Log.d(TAG, "handleServiceRequest from: " + callingPackage); + Log.i("MicroGRcs", "svc199 bind caller=" + callingPackage + " version=" + callingVersion + " supportsConnectionInfo=" + request.supportsConnectionInfo); + + if (impl == null) { + impl = new AsterismServiceImpl(this); + } + + if (request.supportsConnectionInfo) { + ConnectionInfo info = new ConnectionInfo(); + info.features = RcsFeatures.SUPPORTED; + Log.d(TAG, "Returning ConnectionInfo with " + RcsFeatures.SUPPORTED.length + " features"); + callback.onPostInitCompleteWithConnectionInfo(0, impl.asBinder(), info); + } else { + callback.onPostInitComplete(0, impl.asBinder(), null); + } + } +} diff --git a/play-services-core/src/main/java/org/microg/gms/asterism/AsterismServiceImpl.java b/play-services-core/src/main/java/org/microg/gms/asterism/AsterismServiceImpl.java new file mode 100644 index 0000000000..a4faacfac5 --- /dev/null +++ b/play-services-core/src/main/java/org/microg/gms/asterism/AsterismServiceImpl.java @@ -0,0 +1,293 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.asterism; + +import android.content.Context; +import android.os.Binder; +import android.os.Bundle; +import android.os.IBinder; +import android.os.Parcel; +import android.os.RemoteException; +import android.util.Log; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.microg.gms.constellation.ConstellationConstants; +import org.microg.gms.constellation.GoogleConstellationClient; + +/** + * Binder shim for the Asterism consent APIs used by Messages. + */ +public class AsterismServiceImpl extends Binder { + private static final String TAG = "GmsAsterismSvcImpl"; + private static final String DESCRIPTOR = "com.google.android.gms.asterism.internal.IAsterismApiService"; + private static final String CB_DESCRIPTOR = "com.google.android.gms.asterism.internal.IAsterismCallbacks"; + + private static final int TX_GET_CONSENT = 1; + private static final int TX_SET_CONSENT = 2; + private static final int TX_IS_PNVR_DEVICE = 3; + + private static final int CB_ON_CONSENT_FETCHED = 1; + private static final int CB_ON_CONSENT_REGISTERED = 2; + private static final int CB_ON_IS_PNVR_DEVICE = 3; + + private final Context context; + private final ExecutorService worker = Executors.newSingleThreadExecutor(); + + public AsterismServiceImpl(Context context) { + this.context = context; + Log.i(TAG, "AsterismServiceImpl created"); + } + + @Override + protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) throws RemoteException { + if (code == INTERFACE_TRANSACTION) { + reply.writeString(DESCRIPTOR); + return true; + } + data.enforceInterface(DESCRIPTOR); + + switch (code) { + case TX_GET_CONSENT: + return handleGetConsent(data, reply); + case TX_SET_CONSENT: + return handleSetConsent(data, reply); + case TX_IS_PNVR_DEVICE: + return handleIsPnvrDevice(data, reply); + default: + Log.w(TAG, "Unknown tx code: " + code); + return handleGenericSuccess(data, reply); + } + } + + /** + * Return CONSENTED plus the Messages IID token used for ACS requests. + * IID registration (RSA keygen + FCM HTTP) runs off the binder thread + * to avoid ANR -- the callback is already oneway. + */ + private boolean handleGetConsent(Parcel data, Parcel reply) { + IBinder callback = data.readStrongBinder(); + data.readInt(); // request SafeParcel header or null marker + Log.i(TAG, "getAsterismConsent called"); + + reply.writeNoException(); + + if (callback != null) { + worker.execute(() -> { + String iidToken = null; + try { + kotlin.Pair result = GoogleConstellationClient.getOrRegisterIidToken( + context, context.getPackageName(), ConstellationConstants.SENDER_MESSAGES_IID); + iidToken = result.getFirst(); + } catch (Exception e) { + Log.w(TAG, "Failed to get IID token: " + e.getMessage()); + } + try { + Parcel cb = Parcel.obtain(); + try { + cb.writeInterfaceToken(CB_DESCRIPTOR); + writeStatus(cb, 0); + writeGetConsentResponse(cb, 1 /* CONSENTED */, iidToken); + writeDefaultApiMetadata(cb); + callback.transact(CB_ON_CONSENT_FETCHED, cb, null, FLAG_ONEWAY); + } finally { + cb.recycle(); + } + } catch (Exception e) { + Log.e(TAG, "Error sending getConsent callback", e); + } + }); + } + return true; + } + + /** + * Acknowledge consent registration and return the Messages IID token. + * Same off-thread pattern as handleGetConsent. + */ + private boolean handleSetConsent(Parcel data, Parcel reply) { + IBinder callback = data.readStrongBinder(); + Log.i(TAG, "setAsterismConsent called"); + + reply.writeNoException(); + + if (callback != null) { + worker.execute(() -> { + String iidToken = null; + try { + kotlin.Pair result = GoogleConstellationClient.getOrRegisterIidToken( + context, context.getPackageName(), ConstellationConstants.SENDER_MESSAGES_IID); + iidToken = result.getFirst(); + } catch (Exception e) { + Log.w(TAG, "Failed to get IID token for setConsent: " + e.getMessage()); + } + try { + Parcel cb = Parcel.obtain(); + try { + cb.writeInterfaceToken(CB_DESCRIPTOR); + writeStatus(cb, 0); + writeSetConsentResponse(cb, iidToken); + writeDefaultApiMetadata(cb); + callback.transact(CB_ON_CONSENT_REGISTERED, cb, null, FLAG_ONEWAY); + } finally { + cb.recycle(); + } + } catch (Exception e) { + Log.e(TAG, "Error sending setConsent callback", e); + } + }); + } + return true; + } + + /** + * Report that this device supports the PNVR Constellation path. + */ + private boolean handleIsPnvrDevice(Parcel data, Parcel reply) { + try { + IBinder callback = data.readStrongBinder(); + Log.i(TAG, "getIsPnvrConstellationDevice called"); + + reply.writeNoException(); + + if (callback != null) { + Parcel cb = Parcel.obtain(); + try { + cb.writeInterfaceToken(CB_DESCRIPTOR); + writeStatus(cb, 0); + cb.writeInt(1); // true + writeDefaultApiMetadata(cb); + callback.transact(CB_ON_IS_PNVR_DEVICE, cb, null, FLAG_ONEWAY); + } finally { + cb.recycle(); + } + } + return true; + } catch (Exception e) { + Log.e(TAG, "Error in getIsPnvrConstellationDevice", e); + reply.writeNoException(); + return true; + } + } + + /** + * Write a minimal SUCCESS Status parcelable. + */ + private void writeStatus(Parcel dest, int statusCode) { + int startPos = beginSafeParcelable(dest); + writeSafeParcelField(dest, 1000, 1); // versionCode = 1 + writeSafeParcelField(dest, 1, statusCode); // statusCode + endSafeParcelable(dest, startPos); + } + + /** + * Write GetAsterismConsentResponse. + */ + private void writeGetConsentResponse(Parcel dest, int consentState, String iidToken) { + dest.writeInt(1); + int startPos = beginSafeParcelable(dest); + writeSafeParcelField(dest, 1, 0); // requestCode = 0 + writeSafeParcelField(dest, 2, consentState); // 1 = CONSENTED + if (iidToken != null) { + writeSafeParcelStringField(dest, 3, iidToken); // iidToken + } + // field 4: gaiaToken - omit (null) + writeSafeParcelField(dest, 5, 1); // consentVersion = 1 + endSafeParcelable(dest, startPos); + } + + /** + * Write SetAsterismConsentResponse SafeParcelable. + * Fields: 1=requestCode(int), 2=iidToken(String), 3=gaiaToken(String) + */ + private void writeSetConsentResponse(Parcel dest, String iidToken) { + dest.writeInt(1); // non-null marker + int startPos = beginSafeParcelable(dest); + writeSafeParcelField(dest, 1, 0); // requestCode = 0 + if (iidToken != null) { + writeSafeParcelStringField(dest, 2, iidToken); // iidToken + } + endSafeParcelable(dest, startPos); + } + + /** + * Write default ApiMetadata SafeParcelable (minimal). + */ + private void writeDefaultApiMetadata(Parcel dest) { + dest.writeInt(1); // non-null marker + int startPos = beginSafeParcelable(dest); + // ApiMetadata minimal: just versionCode + writeSafeParcelField(dest, 1000, 1); + endSafeParcelable(dest, startPos); + } + + // === SafeParcel encoding helpers === + // SafeParcel format: [total_length:4] [field_header:4 field_data:N]... [end_marker:0] + // field_header = (fieldId << 16) | dataSize, or (fieldId << 16) | 0xFFFF for variable-length + + private int beginSafeParcelable(Parcel dest) { + // Write placeholder for total length + int startPos = dest.dataPosition(); + dest.writeInt(0); // placeholder + return startPos; + } + + private void endSafeParcelable(Parcel dest, int startPos) { + int endPos = dest.dataPosition(); + dest.setDataPosition(startPos); + dest.writeInt(endPos - startPos - 4); // total length (excluding the length field itself) + dest.setDataPosition(endPos); + } + + private void writeSafeParcelField(Parcel dest, int fieldId, int value) { + // Int field: header = (fieldId << 16) | 4 (size of int) + dest.writeInt((fieldId << 16) | 4); + dest.writeInt(value); + } + + private void writeSafeParcelStringField(Parcel dest, int fieldId, String value) { + // String: variable length, header = (fieldId << 16) | 0xFFFF + dest.writeInt((fieldId << 16) | 0xFFFF); + int lenPos = dest.dataPosition(); + dest.writeInt(0); // placeholder for data length + int dataStart = dest.dataPosition(); + dest.writeString(value); + int dataEnd = dest.dataPosition(); + dest.setDataPosition(lenPos); + dest.writeInt(dataEnd - dataStart); + dest.setDataPosition(dataEnd); + } + + private boolean handleGenericSuccess(Parcel data, Parcel reply) { + try { + IBinder callback = null; + try { callback = data.readStrongBinder(); } catch (Exception e) {} + + if (callback != null) { + Parcel cb = Parcel.obtain(); + try { + cb.writeInterfaceToken(CB_DESCRIPTOR); + writeStatus(cb, 0); + callback.transact(CB_ON_CONSENT_FETCHED, cb, null, FLAG_ONEWAY); + } finally { + cb.recycle(); + } + } + + reply.writeNoException(); + return true; + } catch (Exception e) { + Log.w(TAG, "Error in generic handler", e); + reply.writeNoException(); + return true; + } + } + + public IBinder asBinder() { + return this; + } +} diff --git a/play-services-core/src/main/java/org/microg/gms/constellation/ConstellationConstants.java b/play-services-core/src/main/java/org/microg/gms/constellation/ConstellationConstants.java new file mode 100644 index 0000000000..0045a43a71 --- /dev/null +++ b/play-services-core/src/main/java/org/microg/gms/constellation/ConstellationConstants.java @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation; + +/** + * Shared constants for Constellation (svc 155) and Asterism (svc 199) services. + */ +public final class ConstellationConstants { + /** SharedPreferences file holding DG token cache, EC key pair, verification state. */ + public static final String PREFS_CONSTELLATION = "constellation_prefs"; + + /** SharedPreferences file holding per-sender IID tokens. */ + public static final String PREFS_CONSTELLATION_IID = "constellation_iid"; + + /** Broadcast action emitted by our silent-SMS PendingIntent (carries SMS PDU extras). */ + public static final String ACTION_SILENT_SMS_RECEIVED = + "com.google.android.gms.constellation.SILENT_SMS_RECEIVED"; + + /** FCM sender ID Messages passes via clqs.java:40 (getIidToken request.a). */ + public static final String SENDER_MESSAGES_IID = "466216207879"; + + /** Constellation default sender (GMS Phenotype IidToken__default_project_number). */ + public static final String SENDER_CONSTELLATION = "496232013492"; + + /** Read-only PhoneNumber API sender (GMS Phenotype IidToken__read_only_project_number). */ + public static final String SENDER_READ_ONLY = "745476177629"; + + private ConstellationConstants() {} +} diff --git a/play-services-core/src/main/java/org/microg/gms/constellation/ConstellationService.java b/play-services-core/src/main/java/org/microg/gms/constellation/ConstellationService.java new file mode 100644 index 0000000000..e009afcc54 --- /dev/null +++ b/play-services-core/src/main/java/org/microg/gms/constellation/ConstellationService.java @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation; + +import android.os.RemoteException; +import android.util.Log; + +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 org.microg.gms.BaseService; +import org.microg.gms.common.GmsService; +import org.microg.gms.rcs.RcsCallerPolicy; + +import java.util.HashMap; +import java.util.Map; + +/** + * Constellation Service - Phone Number Verification for RCS. + * + * Called by Google Messages to verify phone numbers for RCS activation. + * + * Service ID: 155 (CONSTELLATION) + * Action: com.google.android.gms.constellation.service.START + */ +public class ConstellationService extends BaseService { + private static final String TAG = "GmsConstellationSvc"; + + private final Map implByPackage = new HashMap<>(); + + public ConstellationService() { + super(TAG, GmsService.CONSTELLATION); + } + + @Override + public void handleServiceRequest(IGmsCallbacks callback, GetServiceRequest request, GmsService service) throws RemoteException { + String callingPackage = RcsCallerPolicy.checkConstellationCaller(this, request); + String callingVersion = RcsCallerPolicy.getPackageVersionSummary(this, callingPackage); + Log.d(TAG, "handleServiceRequest: supportsConnectionInfo=" + request.supportsConnectionInfo); + Log.d(TAG, "handleServiceRequest from: " + callingPackage); + Log.i("MicroGRcs", "svc155 bind caller=" + callingPackage + " version=" + callingVersion + " supportsConnectionInfo=" + request.supportsConnectionInfo); + + ConstellationServiceImpl impl; + synchronized (implByPackage) { + impl = implByPackage.get(callingPackage); + if (impl == null) { + impl = new ConstellationServiceImpl(this, callingPackage); + implByPackage.put(callingPackage, impl); + } + } + + if (request.supportsConnectionInfo) { + // Return ConnectionInfo with supported features + ConnectionInfo info = new ConnectionInfo(); + info.features = RcsFeatures.SUPPORTED; + Log.d(TAG, "Returning ConnectionInfo with " + RcsFeatures.SUPPORTED.length + " features"); + callback.onPostInitCompleteWithConnectionInfo(0, impl.asBinder(), info); + } else { + callback.onPostInitComplete(0, impl.asBinder(), null); + } + } +} diff --git a/play-services-core/src/main/java/org/microg/gms/constellation/ConstellationServiceImpl.java b/play-services-core/src/main/java/org/microg/gms/constellation/ConstellationServiceImpl.java new file mode 100644 index 0000000000..d521e138d8 --- /dev/null +++ b/play-services-core/src/main/java/org/microg/gms/constellation/ConstellationServiceImpl.java @@ -0,0 +1,660 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation; + +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.os.Parcel; +import android.os.RemoteException; +import android.telephony.SubscriptionInfo; +import android.telephony.SubscriptionManager; +import android.telephony.TelephonyManager; +import android.util.Log; + +import com.google.android.gms.common.api.ApiMetadata; +import com.google.android.gms.common.api.CommonStatusCodes; +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.GetPnvCapabilitiesRequest; +import com.google.android.gms.constellation.GetPnvCapabilitiesResponse; +import com.google.android.gms.constellation.ImsiRequest; +import com.google.android.gms.constellation.PhoneNumberVerification; +import com.google.android.gms.constellation.SimCapability; +import com.google.android.gms.constellation.VerificationCapability; +import com.google.android.gms.constellation.VerifyPhoneNumberRequest; +import com.google.android.gms.constellation.VerifyPhoneNumberResponse; +import com.google.android.gms.constellation.internal.IConstellationApiService; +import com.google.android.gms.constellation.internal.IConstellationCallbacks; + +import com.squareup.wire.GrpcException; + +import org.microg.gms.rcs.RcsCallerPolicy; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Invariant: only STATUS_VERIFIED responses may carry a non-empty token. + * All non-verified statuses must return token="" to avoid poisoning Messages state. + */ +public class ConstellationServiceImpl extends IConstellationApiService.Stub { + private static final String TAG = "GmsConstellationSvcImpl"; + + private static final int BINDER_INTERFACE_TRANSACTION = IBinder.INTERFACE_TRANSACTION; + + private final Context context; + private final Ts43Client ts43Client; + private final ExecutorService worker = Executors.newSingleThreadExecutor(); + private final String boundPackageName; + + static final class VerificationDecision { + final int status; + final String token; + + VerificationDecision(int status, String token) { + this.status = status; + this.token = token; + } + } + + static VerificationDecision decideVerificationOutcome(String token, boolean upiIneligible) { + if (token != null && !token.isEmpty() && !upiIneligible) { + return new VerificationDecision(PhoneNumberVerification.STATUS_VERIFIED, token); + } + if (upiIneligible) { + return new VerificationDecision(PhoneNumberVerification.STATUS_INELIGIBLE, ""); + } + return new VerificationDecision(PhoneNumberVerification.STATUS_NON_RETRYABLE_FAILURE, ""); + } + + /** + * Extract the verification method claim from a JWT when available. + */ + static int extractVerificationMethodFromJwt(String token) { + if (token == null || token.isEmpty() || !token.contains(".")) { + return PhoneNumberVerification.METHOD_TS43_AIDL; + } + try { + String[] parts = token.split("\\."); + if (parts.length < 2) return PhoneNumberVerification.METHOD_TS43_AIDL; + byte[] decoded = java.util.Base64.getUrlDecoder().decode(parts[1]); + String payload = new String(decoded, java.nio.charset.StandardCharsets.UTF_8); + int idx = payload.indexOf("\"phone_number_verification_method\""); + if (idx < 0) return PhoneNumberVerification.METHOD_TS43_AIDL; + int colonIdx = payload.indexOf(':', idx); + if (colonIdx < 0) return PhoneNumberVerification.METHOD_TS43_AIDL; + int startQuote = payload.indexOf('"', colonIdx); + if (startQuote < 0) return PhoneNumberVerification.METHOD_TS43_AIDL; + int endQuote = payload.indexOf('"', startQuote + 1); + if (endQuote < 0) return PhoneNumberVerification.METHOD_TS43_AIDL; + return mapVerificationMethodString(payload.substring(startQuote + 1, endQuote)); + } catch (Exception e) { + return PhoneNumberVerification.METHOD_TS43_AIDL; + } + } + + static int mapVerificationMethodString(String method) { + switch (method) { + case "VERIFICATION_METHOD_MT_SMS": return PhoneNumberVerification.METHOD_MT_SMS; + case "VERIFICATION_METHOD_MO_SMS": return PhoneNumberVerification.METHOD_MO_SMS; + case "VERIFICATION_METHOD_CARRIER_ID": return PhoneNumberVerification.METHOD_CARRIER_ID; + case "VERIFICATION_METHOD_IMSI_LOOKUP": return PhoneNumberVerification.METHOD_IMSI_LOOKUP; + case "VERIFICATION_METHOD_REGISTERED_SMS": return PhoneNumberVerification.METHOD_REGISTERED_SMS; + case "VERIFICATION_METHOD_FLASH_CALL": return PhoneNumberVerification.METHOD_FLASH_CALL; + case "VERIFICATION_METHOD_TS43": return PhoneNumberVerification.METHOD_TS43_AIDL; + default: return PhoneNumberVerification.METHOD_TS43_AIDL; + } + } + + static List buildVerificationCapabilities(List requestedMethods) { + List capabilities = new ArrayList<>(); + Set seenTypes = new HashSet<>(); + + if (requestedMethods == null || requestedMethods.isEmpty()) { + addCapability(capabilities, seenTypes, VerificationCapability.TYPE_EAP_AKA); + addCapability(capabilities, seenTypes, VerificationCapability.TYPE_SMS_OTP); + addCapability(capabilities, seenTypes, VerificationCapability.TYPE_SILENT); + return capabilities; + } + + for (Integer method : requestedMethods) { + if (method == null) continue; + int capabilityType = mapVerificationMethodToCapabilityType(method); + if (capabilityType != VerificationCapability.TYPE_UNKNOWN) { + addCapability(capabilities, seenTypes, capabilityType); + } + } + + if (capabilities.isEmpty()) { + addCapability(capabilities, seenTypes, VerificationCapability.TYPE_EAP_AKA); + } + return capabilities; + } + + private static void addCapability(List capabilities, Set seenTypes, int type) { + if (seenTypes.add(type)) { + capabilities.add(new VerificationCapability(type, capabilities.size())); + } + } + + private static int mapVerificationMethodToCapabilityType(int method) { + switch (method) { + case PhoneNumberVerification.METHOD_TS43: + case PhoneNumberVerification.METHOD_TS43_AIDL: + case PhoneNumberVerification.METHOD_IMSI_LOOKUP: + case PhoneNumberVerification.METHOD_CARRIER_ID: + return VerificationCapability.TYPE_EAP_AKA; + case PhoneNumberVerification.METHOD_MT_SMS: + case PhoneNumberVerification.METHOD_MO_SMS: + return VerificationCapability.TYPE_SMS_OTP; + case PhoneNumberVerification.METHOD_REGISTERED_SMS: + case PhoneNumberVerification.METHOD_FLASH_CALL: + return VerificationCapability.TYPE_SILENT; + default: + return VerificationCapability.TYPE_UNKNOWN; + } + } + + /** + * Map verification failures onto the top-level Status codes Messages expects. + */ + static int mapExceptionToStatusCode(Throwable e) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (t instanceof GrpcException) { + int grpcCode = ((GrpcException) t).getGrpcStatus().getCode(); + switch (grpcCode) { + case 8: // RESOURCE_EXHAUSTED + return 5008; + case 4: // DEADLINE_EXCEEDED + case 10: // ABORTED + case 14: // UNAVAILABLE + return 5007; + case 7: // PERMISSION_DENIED + return 5009; + default: + return 5002; + } + } + } + return 8; + } + + public ConstellationServiceImpl(Context context) { + this(context, null); + } + + public ConstellationServiceImpl(Context context, String boundPackageName) { + this.context = context; + this.boundPackageName = boundPackageName; + this.ts43Client = new Ts43Client(context); + Log.i(TAG, "ConstellationServiceImpl created - RCS phone verification service"); + } + + private String checkCaller(int callingUid) { + return RcsCallerPolicy.checkConstellationCaller(context, callingUid, boundPackageName); + } + + + /** + * Legacy V1 phone number verification (Bundle-based). + */ + @Override + public void verifyPhoneNumberV1(IConstellationCallbacks callbacks, Bundle bundle, ApiMetadata metadata) throws RemoteException { + Log.d(TAG, "verifyPhoneNumberV1()"); + + String phoneNumber = null; + int subId = -1; + if (bundle != null) { + phoneNumber = bundle.getString("phone_number"); + if (phoneNumber == null) phoneNumber = bundle.getString("phoneNumber"); + if (phoneNumber == null) phoneNumber = bundle.getString("msisdn"); + if (phoneNumber == null) phoneNumber = bundle.getString("number"); + + subId = bundle.getInt("subscription_id", bundle.getInt("subscriptionId", bundle.getInt("sub_id", -1))); + if (subId == -1) { + subId = (int) bundle.getLong("subscription_id", bundle.getLong("subscriptionId", bundle.getLong("sub_id", -1L))); + } + } + + if (phoneNumber == null || phoneNumber.isEmpty()) { + phoneNumber = getPhoneNumberFromSim(subId); + } + + VerifyPhoneNumberRequest request = new VerifyPhoneNumberRequest( + phoneNumber, + subId, + null, // idTokenRequest + bundle != null ? bundle : new Bundle(), + null, // imsiRequests + true, // allowFallback + PhoneNumberVerification.METHOD_TS43, + null // verificationCapabilities + ); + verifyPhoneNumber(callbacks, request, metadata); + } + + /** + * Get the phone number for a specific subscription. + */ + private String getPhoneNumberFromSim(int subId) { + try { + SubscriptionManager sm = (SubscriptionManager) context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (sm != null && subId > 0) { + String number = sm.getPhoneNumber(subId); + if (number != null && !number.isEmpty()) { + return number; + } + } + } + + TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (tm != null && subId > 0) { + TelephonyManager tmSub = tm.createForSubscriptionId(subId); + @SuppressWarnings("deprecation") + String number = tmSub.getLine1Number(); + if (number != null && !number.isEmpty()) { + return number; + } + } + + if (sm != null && subId > 0) { + List subs = sm.getActiveSubscriptionInfoList(); + if (subs != null) { + for (SubscriptionInfo sub : subs) { + if (sub.getSubscriptionId() == subId) { + @SuppressWarnings("deprecation") + String subNumber = sub.getNumber(); + if (subNumber != null && !subNumber.isEmpty()) { + return subNumber; + } + break; + } + } + } + } + + Log.d(TAG, "No phone number found for subId=" + subId + " (SIM has no stored number)"); + } catch (SecurityException e) { + Log.w(TAG, "No permission to read phone number: " + e.getMessage()); + } catch (Exception e) { + Log.w(TAG, "Failed to get phone number from SIM: " + e.getMessage()); + } + return null; + } + + /** + * Single-use phone number verification (Bundle-based). + */ + @Override + public void verifyPhoneNumberSingleUse(IConstellationCallbacks callbacks, Bundle bundle, ApiMetadata metadata) throws RemoteException { + Log.d(TAG, "verifyPhoneNumberSingleUse()"); + verifyPhoneNumberV1(callbacks, bundle, metadata); + } + + /** + * Verify a phone number. + * + * This is the main method for phone verification (current API). + * Google Messages calls this to verify the SIM's phone number. + */ + @Override + public void verifyPhoneNumber(IConstellationCallbacks callbacks, VerifyPhoneNumberRequest request, ApiMetadata metadata) throws RemoteException { + Log.d(TAG, "verifyPhoneNumber() called"); + int callingUid = android.os.Binder.getCallingUid(); + String callingPackage = checkCaller(callingUid); + worker.execute(() -> { + try { + String phoneNumber = request != null ? request.policyId : null; + int subId = request != null ? (int) request.timeout : -1; + + String imsi = null; + String msisdn = null; + if (request != null && request.imsiRequests != null && !request.imsiRequests.isEmpty()) { + ImsiRequest imsiRequest = request.imsiRequests.get(0); + imsi = imsiRequest != null ? imsiRequest.imsi : null; + msisdn = imsiRequest != null ? imsiRequest.msisdn : null; + } + + if (phoneNumber == null || phoneNumber.isEmpty() || !phoneNumber.startsWith("+")) { + if (msisdn != null && msisdn.startsWith("+")) { + phoneNumber = msisdn; + } else { + phoneNumber = getPhoneNumberFromSim(subId); + } + } + + Ts43Client.EntitlementResult entitlement = ts43Client.performEntitlementCheckResult(subId, phoneNumber, imsi, msisdn); + + if (entitlement.ineligible) { + Log.i(TAG, "TS.43 ineligible, trying Google Constellation..."); + GoogleConstellationClient googleClient = new GoogleConstellationClient(context); + entitlement = googleClient.verifyPhoneNumber(request, callingPackage, imsi, phoneNumber); + } + + String token = entitlement.token; + boolean upiIneligible = entitlement.ineligible; + String reason = entitlement.reason; + + if (entitlement.needsManualMsisdn) { + Log.i(TAG, "Server requests manual phone number entry - returning verificationStatus=7"); + PhoneNumberVerification verification = new PhoneNumberVerification( + phoneNumber, + System.currentTimeMillis(), + PhoneNumberVerification.METHOD_TS43_AIDL, + PhoneNumberVerification.ERROR_NONE, + "", // no token yet + new Bundle(), + PhoneNumberVerification.STATUS_NON_RETRYABLE_FAILURE, // status 7 + -1L + ); + PhoneNumberVerification[] verifications = new PhoneNumberVerification[] { verification }; + VerifyPhoneNumberResponse response = new VerifyPhoneNumberResponse(verifications, new Bundle()); + callbacks.onPhoneNumberVerificationsCompleted( + Status.SUCCESS, + response, + ApiMetadata.DEFAULT + ); + Log.i(TAG, "verifyPhoneNumber() completed - status=7 (manual MSISDN required) for: " + phoneNumber); + return; + } + + if (entitlement.isError()) { + int statusCode = entitlement.cause != null + ? mapExceptionToStatusCode(entitlement.cause) + : (reason != null && reason.contains("5002:")) ? 5002 // retryable (THROTTLED/FAILED) + : (reason != null && reason.contains("5001:")) ? 5001 + : 8; // INTERNAL_ERROR (stock default for non-gRPC errors) + Log.i(TAG, "Returning Status(" + statusCode + ") with null response for error (reason=" + reason + ")"); + callbacks.onPhoneNumberVerificationsCompleted( + new Status(statusCode, reason), + null, + ApiMetadata.DEFAULT + ); + Log.i(TAG, "verifyPhoneNumber() completed - Status(" + statusCode + ") reason=" + reason + " for: " + phoneNumber); + return; + } + + VerificationDecision decision = decideVerificationOutcome(token, upiIneligible); + int verificationStatus = decision.status; + token = decision.token; + + if (verificationStatus == PhoneNumberVerification.STATUS_VERIFIED) { + Log.i(TAG, "Returning verificationStatus=1 (VERIFIED) with real token (reason=" + reason + ")"); + } else if (upiIneligible) { + Log.i(TAG, "Returning verificationStatus=8 (INELIGIBLE) with empty token (reason=" + reason + ")"); + } + + long nowMillis = System.currentTimeMillis(); + + PhoneNumberVerification verification = new PhoneNumberVerification( + phoneNumber, + nowMillis, + extractVerificationMethodFromJwt(token), + PhoneNumberVerification.ERROR_NONE, + token, + new Bundle(), + verificationStatus, + -1L // retryAfterSeconds: -1 = default (no retry) + ); + + PhoneNumberVerification[] verifications = new PhoneNumberVerification[] { verification }; + VerifyPhoneNumberResponse response = new VerifyPhoneNumberResponse(verifications, new Bundle()); + + callbacks.onPhoneNumberVerificationsCompleted( + Status.SUCCESS, + response, + ApiMetadata.DEFAULT + ); + Log.i(TAG, "verifyPhoneNumber() completed - status=" + verificationStatus + " reason=" + reason + " for: " + phoneNumber); + Log.i("MicroGRcs", "svc155 status=" + verificationStatus + " hasToken=" + (token != null && !token.isEmpty())); + } catch (Exception e) { + Log.e(TAG, "verifyPhoneNumber() failed", e); + int statusCode = mapExceptionToStatusCode(e); + Log.w(TAG, "verifyPhoneNumber() mapped exception to Status(" + statusCode + ")"); + try { + callbacks.onPhoneNumberVerificationsCompleted( + new Status(statusCode, e.getMessage()), + null, + ApiMetadata.DEFAULT + ); + } catch (RemoteException re) { + Log.e(TAG, "Failed to send error callback", re); + } + } + }); // worker.execute + } + + /** + * Compute the Firebase installation ID from the stored EC key. + */ + private String computeFidFromEcKey() { + try { + android.content.SharedPreferences keyPrefs = context.getSharedPreferences(ConstellationConstants.PREFS_CONSTELLATION, Context.MODE_PRIVATE); + String pubKeyB64 = keyPrefs.getString("public_key", null); + if (pubKeyB64 == null) return null; + byte[] pubKeyBytes = android.util.Base64.decode(pubKeyB64, android.util.Base64.DEFAULT); + byte[] sha1 = java.security.MessageDigest.getInstance("SHA1").digest(pubKeyBytes); + sha1[0] = (byte) ((sha1[0] & 0x0F) + 0x70); + return android.util.Base64.encodeToString(sha1, 0, 8, + android.util.Base64.NO_WRAP | android.util.Base64.NO_PADDING | android.util.Base64.URL_SAFE); + } catch (Exception e) { + Log.w(TAG, "computeFidFromEcKey failed", e); + return null; + } + } + + private String redact(String value) { + if (value == null || value.length() < 6) return ""; + return value.substring(0, 3) + "..." + value.substring(value.length() - 3); + } + + private String[] getPackagesForUidSafe(int uid) { + try { + String[] packages = context.getPackageManager().getPackagesForUid(uid); + return packages != null ? packages : new String[0]; + } catch (Exception e) { + Log.w(TAG, "Failed to resolve packages for uid=" + uid, e); + return new String[0]; + } + } + + private String transactionName(int code) { + switch (code) { + case 1: + return "verifyPhoneNumberV1"; + case 2: + return "verifyPhoneNumberSingleUse"; + case 3: + return "verifyPhoneNumber"; + case 4: + return "getIidToken"; + case 5: + return "getPnvCapabilities"; + case 1598968902: + return "INTERFACE_TRANSACTION"; + default: + return "unknown"; + } + } + + /** + * Get Instance ID token (transaction code 4). + * Messages uses this to get a token for the gmscore_instance_id_token ACS header. + */ + @Override + public void getIidToken(IConstellationCallbacks callbacks, GetIidTokenRequest request, ApiMetadata metadata) throws RemoteException { + checkCaller(android.os.Binder.getCallingUid()); + String senderId; + if (request != null && request.subscriptionId != null && request.subscriptionId != 0L) { + senderId = Long.toString(request.subscriptionId); + } else { + senderId = ConstellationConstants.SENDER_CONSTELLATION; + } + Log.d(TAG, "getIidToken(sender=" + senderId + ")"); + + worker.execute(() -> { + try { + String packageName = context.getPackageName(); + kotlin.Pair result = GoogleConstellationClient.getOrRegisterIidToken(context, packageName, senderId); + String iidToken = result.getFirst(); + + android.content.SharedPreferences iidPrefs = context.getSharedPreferences(ConstellationConstants.PREFS_CONSTELLATION_IID, Context.MODE_PRIVATE); + String fid = iidPrefs.getString("instance_id", ""); + if (fid.isEmpty()) { + fid = computeFidFromEcKey(); + if (fid == null) { + fid = android.provider.Settings.Secure.getString(context.getContentResolver(), android.provider.Settings.Secure.ANDROID_ID); + } + } + + GetIidTokenResponse response = new GetIidTokenResponse( + iidToken, fid, null, 0L + ); + callbacks.onIidTokenGenerated(Status.SUCCESS, response, ApiMetadata.DEFAULT); + Log.d(TAG, "getIidToken() completed"); + } catch (Exception e) { + Log.e(TAG, "getIidToken() failed", e); + try { + callbacks.onIidTokenGenerated(new Status(5004, e.getMessage()), null, ApiMetadata.DEFAULT); + } catch (Exception callbackEx) { + Log.e(TAG, "getIidToken() callback failed", callbackEx); + } + } + }); + } + + /** + * Get phone number verification capabilities. + * Transaction code 5. + * + * Returns what verification methods are available for the given phone numbers/SIMs. + */ + @Override + public void getPnvCapabilities(IConstellationCallbacks callbacks, GetPnvCapabilitiesRequest request, ApiMetadata metadata) throws RemoteException { + checkCaller(android.os.Binder.getCallingUid()); + Log.i(TAG, "getPnvCapabilities() called"); + if (request != null) { + Log.d(TAG, " policyId: " + request.policyId); + Log.d(TAG, " verificationMethods: " + request.verificationMethods); + Log.d(TAG, " subscriptionIds: " + request.subscriptionIds); + } + Log.d(TAG, " metadata: " + metadata); + + try { + GetPnvCapabilitiesResponse response = new GetPnvCapabilitiesResponse(buildSimCapabilities(request)); + + callbacks.onGetPnvCapabilitiesCompleted( + Status.SUCCESS, + response, + ApiMetadata.DEFAULT + ); + Log.i(TAG, "getPnvCapabilities() completed"); + } catch (Exception e) { + Log.e(TAG, "getPnvCapabilities() failed", e); + callbacks.onGetPnvCapabilitiesCompleted( + new Status(CommonStatusCodes.INTERNAL_ERROR, e.getMessage()), + null, + ApiMetadata.DEFAULT + ); + } + } + + private List buildSimCapabilities(GetPnvCapabilitiesRequest request) { + List verificationCapabilities = + buildVerificationCapabilities(request != null ? request.verificationMethods : null); + List capabilities = new ArrayList<>(); + + SubscriptionManager sm = (SubscriptionManager) context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE); + List activeSubscriptions = null; + try { + activeSubscriptions = sm != null ? sm.getActiveSubscriptionInfoList() : null; + } catch (SecurityException e) { + Log.w(TAG, "No permission to read active subscriptions: " + e.getMessage()); + } catch (Exception e) { + Log.w(TAG, "Failed to read active subscriptions: " + e.getMessage()); + } + + if (request != null && request.subscriptionIds != null && !request.subscriptionIds.isEmpty()) { + for (Integer subId : request.subscriptionIds) { + if (subId == null) continue; + capabilities.add(buildSimCapability(subId, findSubscriptionInfo(activeSubscriptions, subId), verificationCapabilities)); + } + } else if (activeSubscriptions != null) { + for (SubscriptionInfo sub : activeSubscriptions) { + if (sub != null) { + capabilities.add(buildSimCapability(sub.getSubscriptionId(), sub, verificationCapabilities)); + } + } + } + + return capabilities; + } + + private SimCapability buildSimCapability(int subId, SubscriptionInfo sub, List verificationCapabilities) { + return new SimCapability( + subId, + getPhoneNumberFromSim(subId), + sub != null ? sub.getSimSlotIndex() : -1, + getCarrierId(sub), + new ArrayList<>(verificationCapabilities) + ); + } + + private SubscriptionInfo findSubscriptionInfo(List subscriptions, int subId) { + if (subscriptions == null) return null; + for (SubscriptionInfo sub : subscriptions) { + if (sub != null && sub.getSubscriptionId() == subId) return sub; + } + return null; + } + + private String getCarrierId(SubscriptionInfo sub) { + if (sub == null) return ""; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + int carrierId = sub.getCarrierId(); + if (carrierId > 0) return Integer.toString(carrierId); + } + CharSequence carrierName = sub.getCarrierName(); + return carrierName != null ? carrierName.toString() : ""; + } + + /** + * Handle unknown transaction codes for forward compatibility. + */ + @Override + public boolean onTransact(int code, Parcel data, Parcel reply, int flags) throws RemoteException { + Log.d(TAG, "onTransact code=" + code + " hex=0x" + Integer.toHexString(code) + " (" + transactionName(code) + ") uid=" + android.os.Binder.getCallingUid() + " pid=" + android.os.Binder.getCallingPid()); + if (super.onTransact(code, data, reply, flags)) { + return true; + } + Log.w(TAG, "onTransact: unknown code " + code); + return false; + } + + /** + * Helper to convert Bundle to string for logging. + */ + @SuppressWarnings("deprecation") + private String bundleToString(Bundle bundle) { + if (bundle == null) return "null"; + StringBuilder sb = new StringBuilder("{"); + for (String key : bundle.keySet()) { + if (sb.length() > 1) sb.append(", "); + sb.append(key).append("=").append(bundle.get(key)); + } + sb.append("}"); + return sb.toString(); + } +} diff --git a/play-services-core/src/main/java/org/microg/gms/constellation/RcsFeatures.java b/play-services-core/src/main/java/org/microg/gms/constellation/RcsFeatures.java new file mode 100644 index 0000000000..da6db42f19 --- /dev/null +++ b/play-services-core/src/main/java/org/microg/gms/constellation/RcsFeatures.java @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation; + +import com.google.android.gms.common.Feature; + +/** + * Features advertised by Constellation (svc 155) and Asterism (svc 199). + * Versions must match or exceed what Messages requests. + */ +public final class RcsFeatures { + public static final Feature[] SUPPORTED = new Feature[] { + new Feature("asterism_consent", 3), + new Feature("one_time_verification", 1), + new Feature("carrier_auth", 1), + new Feature("verify_phone_number", 2), + new Feature("get_iid_token", 1), + new Feature("get_pnv_capabilities", 1), + new Feature("ts43", 1), + new Feature("verify_phone_number_local_read", 1) + }; + + private RcsFeatures() {} +} diff --git a/play-services-core/src/main/java/org/microg/gms/constellation/Ts43Client.java b/play-services-core/src/main/java/org/microg/gms/constellation/Ts43Client.java new file mode 100644 index 0000000000..c18e774157 --- /dev/null +++ b/play-services-core/src/main/java/org/microg/gms/constellation/Ts43Client.java @@ -0,0 +1,1111 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.LinkProperties; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.telephony.CarrierConfigManager; +import android.telephony.TelephonyManager; +import android.util.Base64; +import android.util.Log; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Client for GSMA TS.43 Service Entitlement Configuration. + * + * Implements the EAP-AKA authentication flow to retrieve RCS provisioning tokens + * from the carrier's entitlement server. + */ +public class Ts43Client { + private static final String TAG = "GmsTs43Client"; + private final Context context; + private final SimAuthProvider simAuthProvider; + private final Base64Decoder base64Decoder; + private final Logger logger; + + private static final int EAP_AKA_TYPE = 23; + private static final int EAP_AKA_SUBTYPE_CHALLENGE = 1; + private static final int AT_RAND = 1; + private static final int AT_AUTN = 2; + private static final int AT_RES = 3; + private static final int AT_MAC = 11; + + private static final String EAP_RELAY_ACCEPT = "application/vnd.gsma.eap-relay.v1.0+json"; + private static final String ENTITLEMENT_URL_KEY = "gps.entitlement_url"; + + private static final int NETWORK_BIND_NONE = 0; + private static final int NETWORK_BIND_WIFI = 1; + private static final int NETWORK_BIND_CELLULAR = 2; + private static final int TS43_TIMEOUT_MS = 15000; + + interface Logger { + void d(String tag, String msg); + void i(String tag, String msg); + void w(String tag, String msg); + void w(String tag, String msg, Throwable tr); + void e(String tag, String msg); + void e(String tag, String msg, Throwable tr); + } + + interface Base64Decoder { + byte[] decode(String str); + String encodeToString(byte[] input); + } + + interface SimAuthProvider { + String getNetworkOperator(); + String getSimOperator(); + String getSubscriberId(); + String getIccAuthentication(int appType, int authType, String data); + } + + private static final class Ts43SimInfo { + private final String imsi; + private final String simOperator; + + private Ts43SimInfo(String imsi, String simOperator) { + this.imsi = imsi; + this.simOperator = simOperator; + } + } + + public static final class EntitlementResult { + public final String token; + public final boolean ineligible; + public final String reason; + /** Original exception for gRPC error code mapping. null for success/ineligible. */ + public final Throwable cause; + /** When true, server returned PHONE_NUMBER_ENTRY_REQUIRED (reason=5). + * Maps to verificationStatus=7 → Messages shows phone number input UI. */ + public final boolean needsManualMsisdn; + + private EntitlementResult(String token, boolean ineligible, String reason, Throwable cause, boolean needsManualMsisdn) { + this.token = token; + this.ineligible = ineligible; + this.reason = reason; + this.cause = cause; + this.needsManualMsisdn = needsManualMsisdn; + } + + public static EntitlementResult success(String token) { + return new EntitlementResult(token, false, "success", null, false); + } + + public static EntitlementResult ineligible(String token, String reason) { + return new EntitlementResult(token, true, reason, null, false); + } + + public static EntitlementResult error(String reason) { + return new EntitlementResult(null, false, reason, null, false); + } + + public static EntitlementResult error(String reason, Throwable cause) { + return new EntitlementResult(null, false, reason, cause, false); + } + + /** Server says user must manually enter phone number (UnverifiedReason=5). + * Maps to verificationStatus=7 → Messages shows phone input UI → re-calls verifyPhoneNumber. */ + public static EntitlementResult phoneNumberEntryRequired(String reason) { + return new EntitlementResult(null, false, reason, null, true); + } + + /** True when this result represents an error (no token, not ineligible, not manual MSISDN). */ + public boolean isError() { + return !ineligible && !needsManualMsisdn && (token == null || token.isEmpty()); + } + } + + private static final class EntitlementEndpoint { + final String url; + final boolean fromCarrierConfig; + + private EntitlementEndpoint(String url, boolean fromCarrierConfig) { + this.url = url; + this.fromCarrierConfig = fromCarrierConfig; + } + } + + public Ts43Client(Context context) { + this.context = context; + TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + this.simAuthProvider = new SimAuthProvider() { + @Override + public String getNetworkOperator() { + return tm.getNetworkOperator(); + } + + @Override + public String getSimOperator() { + return tm.getSimOperator(); + } + + @Override + public String getSubscriberId() { + try { + return tm.getSubscriberId(); + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for getSubscriberId", e); + return null; + } + } + + @Override + public String getIccAuthentication(int appType, int authType, String data) { + return tm.getIccAuthentication(appType, authType, data); + } + }; + this.base64Decoder = new Base64Decoder() { + @Override + public byte[] decode(String str) { + return Base64.decode(str, Base64.NO_WRAP); + } + + @Override + public String encodeToString(byte[] input) { + return Base64.encodeToString(input, Base64.NO_WRAP); + } + }; + this.logger = new Logger() { + @Override public void d(String tag, String msg) { Log.d(tag, msg); } + @Override public void i(String tag, String msg) { Log.i(tag, msg); } + @Override public void w(String tag, String msg) { Log.w(tag, msg); } + @Override public void w(String tag, String msg, Throwable tr) { Log.w(tag, msg, tr); } + @Override public void e(String tag, String msg) { Log.e(tag, msg); } + @Override public void e(String tag, String msg, Throwable tr) { Log.e(tag, msg, tr); } + }; + } + + Ts43Client(Context context, SimAuthProvider simAuthProvider, Base64Decoder base64Decoder, Logger logger) { + this.context = context; + this.simAuthProvider = simAuthProvider; + this.base64Decoder = base64Decoder; + this.logger = logger; + } + + + /** + * Performs TS.43 entitlement check using JSON EAP relay (GSMA TS.43 v5.0+). + * + * Two-phase flow: + * Phase 1 - EAP-AKA auth: GET with EAP_ID → server returns {"eap-relay-packet": "base64"} → + * SIM auth → POST {"eap-relay-packet": "response"} → server returns {"Token": {"token": "..."}} + * Phase 2 - ODSA request: GET with token param → server returns phone number or temp token + * + * @param entitlementUrl URL from Ts43Challenge.entitlement_url (server-provided, NOT CarrierConfig) + * @param eapAkaRealm Optional realm from Ts43Challenge.eap_aka_realm + */ + public EntitlementResult performEntitlementCheckResult(int subId, String phoneNumber, + String requestImsi, String requestMsisdn, + String entitlementUrl, String eapAkaRealm, + google.internal.communications.phonedeviceverification.v1.ServiceEntitlementRequest serviceReq, + google.internal.communications.phonedeviceverification.v1.OdsaOperation odsaOp, + String appId) { + logger.i(TAG, "Starting TS.43 entitlement check for subId=" + subId + " url=" + entitlementUrl); + + if (entitlementUrl == null || entitlementUrl.isEmpty()) { + // No entitlement URL from server - Jibe carrier without TS.43 + logger.w(TAG, "No entitlement URL provided; returning INELIGIBLE"); + return EntitlementResult.ineligible("", "jibe-no-ts43"); + } + + Ts43SimInfo simInfo = resolveSimInfo(subId, requestImsi, requestMsisdn); + if (simInfo == null) { + logger.e(TAG, "Unable to resolve SIM info for entitlement check"); + return EntitlementResult.error("sim-info"); + } + + String simOperator = simInfo.simOperator; + if (simOperator == null || simOperator.length() < 5) { + logger.e(TAG, "Invalid SIM operator: " + simOperator); + return EntitlementResult.error("sim-operator"); + } + String mcc = simOperator.substring(0, 3); + String mnc = simOperator.substring(3); + String imsi = simInfo.imsi; + if (imsi == null || imsi.isEmpty()) { + logger.e(TAG, "IMSI missing from SIM info"); + return EntitlementResult.error("imsi-missing"); + } + + // Build EAP-AKA identity (NAI) + String eapId = getNai(imsi, mcc, mnc, eapAkaRealm); + + try { + // Phase 1: EAP-AKA authentication via JSON relay + String authToken = performEapAkaAuth(subId, entitlementUrl, eapId, imsi, simOperator, eapAkaRealm); + if (authToken == null) { + logger.w(TAG, "EAP-AKA auth failed - no token obtained"); + return EntitlementResult.error("eap-aka-auth-failed"); + } + logger.i(TAG, "EAP-AKA auth succeeded, auth_token length=" + authToken.length()); + + // Phase 2: ODSA request with auth token to get phone number / temp token + java.util.Map cookies = new java.util.HashMap<>(); + String odsaResponse = performOdsaRequest(subId, entitlementUrl, authToken, + serviceReq, odsaOp, appId, cookies); + if (odsaResponse != null) { + logger.i(TAG, "ODSA response received, length=" + odsaResponse.length()); + return EntitlementResult.success(odsaResponse); + } + // If ODSA fails, fall back to returning the auth token itself + logger.w(TAG, "ODSA request failed, returning auth token as fallback"); + return EntitlementResult.success(authToken); + + } catch (java.net.UnknownHostException e) { + logger.w(TAG, "TS.43 DNS error: " + e.getMessage()); + return EntitlementResult.error("unknown-host"); + } catch (java.net.ConnectException e) { + logger.w(TAG, "TS.43 Connection Refused: " + e.getMessage()); + return EntitlementResult.error("connection-refused"); + } catch (java.net.SocketTimeoutException e) { + logger.e(TAG, "TS.43 Timeout: " + e.getMessage()); + return EntitlementResult.error("timeout"); + } catch (IOException e) { + logger.e(TAG, "TS.43 IO error: " + e.getMessage(), e); + return EntitlementResult.error("io-error"); + } catch (Exception e) { + logger.e(TAG, "TS.43 unexpected error", e); + return EntitlementResult.error("unexpected"); + } + } + + /** Legacy overload for callers without server-provided URL/realm. */ + public EntitlementResult performEntitlementCheckResult(int subId, String phoneNumber, String requestImsi, String requestMsisdn) { + EntitlementEndpoint endpoint = resolveEntitlementUrl(subId, null); + String url = (endpoint != null && endpoint.fromCarrierConfig) ? endpoint.url : null; + return performEntitlementCheckResult(subId, phoneNumber, requestImsi, requestMsisdn, url, null, null, null, null); + } + + /** Overload without ODSA proto data (backward compat). */ + public EntitlementResult performEntitlementCheckResult(int subId, String phoneNumber, + String requestImsi, String requestMsisdn, + String entitlementUrl, String eapAkaRealm) { + return performEntitlementCheckResult(subId, phoneNumber, requestImsi, requestMsisdn, + entitlementUrl, eapAkaRealm, null, null, null); + } + + /** + * Phase 1: EAP-AKA authentication via JSON body relay. + * Up to 3 rounds of challenge-response with the entitlement server. + * Returns the authentication token on success, null on failure. + */ + private String performEapAkaAuth(int subId, String entitlementUrl, String eapId, + String imsi, String simOperator, String eapAkaRealm) throws IOException { + // Build initial GET URL with EAP_ID parameter + String separator = entitlementUrl.contains("?") ? "&" : "?"; + String initialUrl = entitlementUrl + separator + "EAP_ID=" + java.net.URLEncoder.encode(eapId, "UTF-8"); + + // Cookie store for session continuity + java.util.Map cookies = new java.util.LinkedHashMap<>(); + + // Round 1: Initial GET - server returns EAP challenge in JSON body + HttpURLConnection conn = openNetworkConnection(initialUrl, subId); + conn.setConnectTimeout(TS43_TIMEOUT_MS); + conn.setReadTimeout(TS43_TIMEOUT_MS); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", EAP_RELAY_ACCEPT); + logger.d(TAG, "EAP round 1: GET " + initialUrl); + + // EAP relay loop: read response, process challenge, POST back, repeat. + // Max 3 challenge-response exchanges before giving up. + int postsRemaining = 3; + while (true) { + // Apply cookies to current connection + applyCookies(conn, cookies); + + int responseCode = conn.getResponseCode(); + logger.d(TAG, "EAP: HTTP " + responseCode + " (postsRemaining=" + postsRemaining + ")"); + + // Collect cookies from response + collectCookies(conn, cookies); + + if (responseCode != 200) { + logger.w(TAG, "EAP: unexpected HTTP " + responseCode); + conn.disconnect(); + return null; + } + + String body = readStream(conn.getInputStream()); + conn.disconnect(); + logger.d(TAG, "EAP: body length=" + body.length()); + + // Check if response contains auth token (authentication complete) + String token = extractToken(body); + if (token != null) { + logger.i(TAG, "EAP: auth token received"); + return token; + } + + if (postsRemaining <= 0) { + logger.w(TAG, "EAP-AKA auth: exceeded max rounds without completing"); + return null; + } + + // Extract EAP relay packet for SIM auth + String eapRelayPacket = extractEapRelayPacket(body); + if (eapRelayPacket == null) { + logger.w(TAG, "EAP: no eap-relay-packet or token in response"); + return null; + } + + // Process EAP-AKA challenge with SIM + String eapResponse = processEapPacket(subId, eapRelayPacket, imsi, simOperator, eapAkaRealm); + if (eapResponse == null) { + logger.e(TAG, "EAP: SIM auth failed"); + return null; + } + + // POST the EAP response back as JSON + conn = openNetworkConnection(entitlementUrl, subId); + conn.setConnectTimeout(TS43_TIMEOUT_MS); + conn.setReadTimeout(TS43_TIMEOUT_MS); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Accept", EAP_RELAY_ACCEPT + ", text/vnd.wap.connectivity-xml"); + conn.setRequestProperty("Content-Type", EAP_RELAY_ACCEPT); + conn.setDoOutput(true); + applyCookies(conn, cookies); + + String postBody = "{\"eap-relay-packet\":\"" + eapResponse + "\"}"; + logger.d(TAG, "EAP: POST eap-relay-packet (" + eapResponse.length() + " chars)"); + conn.getOutputStream().write(postBody.getBytes(StandardCharsets.UTF_8)); + postsRemaining--; + // Loop back to read POST response at top + } + } + + /** + * Phase 2: ODSA request with auth token and full GSMA TS.43 query parameters. + * GET entitlementUrl?token=...&terminal_id=...&terminal_vendor=... + * Returns the raw response body for inclusion in ChallengeResponse proto. + */ + private String performOdsaRequest(int subId, String entitlementUrl, String authToken, + google.internal.communications.phonedeviceverification.v1.ServiceEntitlementRequest req, + google.internal.communications.phonedeviceverification.v1.OdsaOperation odsaOp, + String appId, java.util.Map cookies) { + try { + StringBuilder urlBuilder = new StringBuilder(entitlementUrl); + urlBuilder.append(entitlementUrl.contains("?") ? "&" : "?"); + urlBuilder.append("token=").append(java.net.URLEncoder.encode(authToken, "UTF-8")); + + if (req != null) { + appendParam(urlBuilder, "terminal_id", req.terminal_id); + appendParam(urlBuilder, "terminal_vendor", truncate(req.terminal_vendor, 4)); + appendParam(urlBuilder, "terminal_model", truncate(req.terminal_model, 10)); + appendParam(urlBuilder, "terminal_sw_version", truncate(req.terminal_software_version, 20)); + appendParam(urlBuilder, "vers", String.valueOf(req.configuration_version)); + appendParam(urlBuilder, "entitlement_version", req.entitlement_version); + if (req.gid1 != null && !req.gid1.isEmpty()) { + appendParam(urlBuilder, "GID1", req.gid1); + } + if (req.notification_token != null && !req.notification_token.isEmpty()) { + appendParam(urlBuilder, "notif_action", String.valueOf(req.notification_action)); + appendParam(urlBuilder, "notif_token", req.notification_token); + } + } + + if (appId != null && !appId.isEmpty()) { + appendParam(urlBuilder, "app", appId); + } else { + appendParam(urlBuilder, "app", "ap2014"); + } + + if (odsaOp != null) { + appendParam(urlBuilder, "operation", odsaOp.operation); + if (odsaOp.operation_type != 0 && odsaOp.operation_type != -1) { + appendParam(urlBuilder, "operation_type", String.valueOf(odsaOp.operation_type)); + } + if (odsaOp.operation_targets != null && !odsaOp.operation_targets.isEmpty()) { + StringBuilder targets = new StringBuilder(); + for (int i = 0; i < odsaOp.operation_targets.size(); i++) { + if (i > 0) targets.append(","); + targets.append(odsaOp.operation_targets.get(i)); + } + appendParam(urlBuilder, "operation_targets", targets.toString()); + } + if (odsaOp.target_terminal_iccid != null && !odsaOp.target_terminal_iccid.isEmpty()) { + appendParam(urlBuilder, "target_terminal_iccid", odsaOp.target_terminal_iccid); + } + } + + String odsaUrl = urlBuilder.toString(); + logger.d(TAG, "ODSA request: GET " + entitlementUrl + "?token=<" + authToken.length() + " chars> + " + + (req != null ? "full params" : "minimal")); + + HttpURLConnection conn = openNetworkConnection(odsaUrl, subId); + conn.setConnectTimeout(TS43_TIMEOUT_MS); + conn.setReadTimeout(TS43_TIMEOUT_MS); + conn.setRequestMethod("GET"); + String accept = (req != null && req.accept_content_type != null && !req.accept_content_type.isEmpty()) + ? req.accept_content_type : "application/json"; + conn.setRequestProperty("Accept", accept); + conn.setRequestProperty("User-Agent", buildTs43UserAgent(req)); + conn.setRequestProperty("Accept-Language", java.util.Locale.getDefault().toLanguageTag()); + conn.setInstanceFollowRedirects(false); + applyCookies(conn, cookies); + + int responseCode = conn.getResponseCode(); + collectCookies(conn, cookies); + logger.d(TAG, "ODSA response: HTTP " + responseCode); + + if (responseCode == 200) { + String body = readStream(conn.getInputStream()); + conn.disconnect(); + logger.d(TAG, "ODSA body length=" + body.length()); + return body; + } + + logger.w(TAG, "ODSA request failed: HTTP " + responseCode); + conn.disconnect(); + return null; + } catch (Exception e) { + logger.e(TAG, "ODSA request exception: " + e.getMessage(), e); + return null; + } + } + + private static void appendParam(StringBuilder urlBuilder, String key, String value) { + if (value != null && !value.isEmpty()) { + try { + urlBuilder.append("&").append(key).append("=") + .append(java.net.URLEncoder.encode(value, "UTF-8")); + } catch (java.io.UnsupportedEncodingException ignored) {} + } + } + + private static String truncate(String value, int maxLen) { + if (value == null) return ""; + return value.length() <= maxLen ? value : value.substring(0, maxLen); + } + + private String buildTs43UserAgent( + google.internal.communications.phonedeviceverification.v1.ServiceEntitlementRequest req) { + if (req == null) return "PRD-TS43 OS-Android/" + android.os.Build.VERSION.RELEASE; + String vendor = truncate(req.terminal_vendor, 4); + String model = truncate(req.terminal_model, 10); + String swVersion = truncate(req.terminal_software_version, 20); + return "PRD-TS43 term-" + vendor + "/" + model + " OS-Android/" + swVersion; + } + + private void applyCookies(HttpURLConnection conn, java.util.Map cookies) { + if (!cookies.isEmpty()) { + StringBuilder cookieHeader = new StringBuilder(); + for (java.util.Map.Entry entry : cookies.entrySet()) { + if (cookieHeader.length() > 0) cookieHeader.append("; "); + cookieHeader.append(entry.getKey()).append("=").append(entry.getValue()); + } + conn.setRequestProperty("Cookie", cookieHeader.toString()); + } + } + + private void collectCookies(HttpURLConnection conn, java.util.Map cookies) { + java.util.List setCookies = conn.getHeaderFields().get("Set-Cookie"); + if (setCookies != null) { + for (String sc : setCookies) { + String[] parts = sc.split(";")[0].split("=", 2); + if (parts.length == 2) cookies.put(parts[0].trim(), parts[1].trim()); + } + } + } + + /** Extract eap-relay-packet from JSON body. */ + private String extractEapRelayPacket(String body) { + try { + org.json.JSONObject json = new org.json.JSONObject(body); + String packet = json.optString("eap-relay-packet", null); + if (packet != null && !packet.isEmpty()) return packet; + } catch (Exception e) { + logger.d(TAG, "Response is not JSON EAP relay: " + e.getMessage()); + } + return null; + } + + /** + * Process an EAP-AKA packet from JSON relay: decode base64, extract RAND/AUTN, + * authenticate with SIM, build response packet, return as base64. + */ + String processEapPacket(int subId, String eapRelayBase64, String imsi, String simOperator, String eapAkaRealm) { + byte[] eapPayload = base64Decoder.decode(eapRelayBase64); + if (eapPayload == null || eapPayload.length < 8) { + logger.e(TAG, "Invalid EAP packet: too short"); + return null; + } + + if (eapPayload[0] != 1 || eapPayload[4] != EAP_AKA_TYPE) { + logger.e(TAG, "Not an EAP-AKA Request: code=" + (eapPayload[0] & 0xFF) + " type=" + (eapPayload[4] & 0xFF)); + return null; + } + + int id = eapPayload[1] & 0xFF; + int subtype = eapPayload[5] & 0xFF; + if (subtype != EAP_AKA_SUBTYPE_CHALLENGE) { + logger.e(TAG, "Unexpected EAP-AKA subtype: " + subtype); + return null; + } + + // Extract RAND and AUTN attributes + byte[] rand = null, autn = null; + int offset = 8; + while (offset + 1 < eapPayload.length) { + int attrType = eapPayload[offset] & 0xFF; + int attrLen = (eapPayload[offset + 1] & 0xFF) * 4; + if (attrLen < 4 || offset + attrLen > eapPayload.length) break; + + if (attrType == AT_RAND) { + rand = new byte[16]; + System.arraycopy(eapPayload, offset + 4, rand, 0, 16); + } else if (attrType == AT_AUTN) { + autn = new byte[16]; + System.arraycopy(eapPayload, offset + 4, autn, 0, 16); + } + offset += attrLen; + } + + if (rand == null || autn == null) { + logger.e(TAG, "Missing RAND or AUTN in EAP-AKA challenge"); + return null; + } + + return generateEapAkaResponse(subId, id, rand, autn, imsi, simOperator, eapAkaRealm); + } + + public String performEntitlementCheck(int subId, String phoneNumber, String requestImsi, String requestMsisdn) { + return performEntitlementCheckResult(subId, phoneNumber, requestImsi, requestMsisdn).token; + } + + private static class SimAuthResult { + byte[] res; + byte[] ck; + byte[] ik; + byte[] auts; + } + + /** + * Parse SIM EAP-AKA authentication response (3GPP TS 31.102 Section 7.1.2). + * + * Success format: 0xDB [len_RES] [RES] [len_CK] [CK] [len_IK] [IK] + * Sync failure: 0xDC [len_AUTS] [AUTS] + * + * Note: No inner tags - just sequential length-value pairs after the status byte. + */ + private SimAuthResult parseSimResponse(String responseBase64) { + if (responseBase64 == null) return null; + byte[] data = base64Decoder.decode(responseBase64); + if (data == null || data.length < 2) return null; + + SimAuthResult result = new SimAuthResult(); + int tag = data[0] & 0xFF; + int offset = 1; + + if (tag == 0xDB) { + // Success: sequential LV for RES, CK, IK + result.res = extractLv(data, offset); + if (result.res == null) { + logger.w(TAG, "Failed to extract RES from SIM response"); + return null; + } + offset += 1 + result.res.length; + + result.ck = extractLv(data, offset); + if (result.ck == null) { + logger.w(TAG, "Failed to extract CK from SIM response"); + return null; + } + offset += 1 + result.ck.length; + + result.ik = extractLv(data, offset); + if (result.ik == null) { + logger.w(TAG, "Failed to extract IK from SIM response"); + return null; + } + + logger.d(TAG, "SIM auth success: RES=" + result.res.length + "B, CK=" + result.ck.length + "B, IK=" + result.ik.length + "B"); + } else if (tag == 0xDC) { + // Sync failure: LV for AUTS + result.auts = extractLv(data, offset); + if (result.auts == null) { + logger.w(TAG, "Failed to extract AUTS from SIM response"); + return null; + } + logger.d(TAG, "SIM auth sync failure: AUTS=" + result.auts.length + "B"); + } else { + logger.w(TAG, "Unknown SIM response tag: 0x" + Integer.toHexString(tag)); + return null; + } + + return result; + } + + /** Extract a length-value pair at the given offset. Returns the value bytes, or null on error. */ + private byte[] extractLv(byte[] data, int offset) { + if (offset >= data.length) return null; + int len = data[offset] & 0xFF; + int valueStart = offset + 1; + int valueEnd = valueStart + len; + if (valueEnd > data.length) return null; + return Arrays.copyOfRange(data, valueStart, valueEnd); + } + + private byte[] calculateMac(byte[] k_aut, byte[] packet) { + try { + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(k_aut, "HmacSHA1")); + return mac.doFinal(packet); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + logger.e(TAG, "Failed to calculate MAC", e); + return null; + } + } + + /** + * FIPS 186-2 Change Notice 1 PRF (Appendix 3.1). + * Used by EAP-AKA (RFC 4187 Section 7) to derive key material from MK. + * Produces 160 bytes: K_encr(16) + K_aut(16) + MSK(64) + EMSK(64). + */ + private byte[] fips186Prf(byte[] xKey) { + final int[] H = {0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0}; + + byte[] result = new byte[160]; // 8 iterations * 20 bytes each + byte[] xKeyPadded = new byte[64]; + System.arraycopy(xKey, 0, xKeyPadded, 0, Math.min(xKey.length, 64)); + + for (int iter = 0; iter < 8; iter++) { + int[] w = new int[80]; + for (int i = 0; i < 16; i++) { + w[i] = ((xKeyPadded[i * 4] & 0xFF) << 24) + | ((xKeyPadded[i * 4 + 1] & 0xFF) << 16) + | ((xKeyPadded[i * 4 + 2] & 0xFF) << 8) + | (xKeyPadded[i * 4 + 3] & 0xFF); + } + for (int i = 16; i < 80; i++) { + w[i] = Integer.rotateLeft(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); + } + + int a = H[0], b = H[1], c = H[2], d = H[3], e = H[4]; + for (int i = 0; i < 80; i++) { + int f, k; + if (i < 20) { f = (b & c) | (~b & d); k = 0x5A827999; } + else if (i < 40) { f = b ^ c ^ d; k = 0x6ED9EBA1; } + else if (i < 60) { f = (b & c) | (b & d) | (c & d); k = 0x8F1BBCDC; } + else { f = b ^ c ^ d; k = 0xCA62C1D6; } + int temp = Integer.rotateLeft(a, 5) + f + e + k + w[i]; + e = d; d = c; c = Integer.rotateLeft(b, 30); b = a; a = temp; + } + + int[] out = {H[0] + a, H[1] + b, H[2] + c, H[3] + d, H[4] + e}; + for (int i = 0; i < 5; i++) { + int off = iter * 20 + i * 4; + result[off] = (byte) (out[i] >> 24); + result[off + 1] = (byte) (out[i] >> 16); + result[off + 2] = (byte) (out[i] >> 8); + result[off + 3] = (byte) out[i]; + } + + long carry = 1; + int outStart = iter * 20; + for (int i = 19; i >= 0; i--) { + carry += (xKeyPadded[i] & 0xFFL) + (result[outStart + i] & 0xFFL); + xKeyPadded[i] = (byte) carry; + carry >>= 8; + } + } + + return result; + } + + /** + * Derive EAP-AKA keys from MK using FIPS 186-2 PRF (RFC 4187 Section 7). + * Returns: K_encr(16) + K_aut(16) + MSK(64) + EMSK(64) = 160 bytes. + */ + private byte[] deriveKeys(byte[] mk) { + return fips186Prf(mk); + } + + private String getNai(String imsi, String mcc, String mnc, String realm) { + // MNC must be 3 digits in the domain + String mnc3 = mnc.length() == 2 ? "0" + mnc : mnc; + String effectiveRealm; + if (realm != null && !realm.isEmpty() && !realm.equals("nai.epc") + && realm.contains(".mnc") && realm.contains(".mcc") && realm.contains("3gppnetwork.org")) { + effectiveRealm = realm; + } else if (realm != null && !realm.isEmpty() && !realm.equals("nai.epc")) { + effectiveRealm = realm; + } else { + effectiveRealm = String.format("nai.epc.mnc%s.mcc%s.3gppnetwork.org", mnc3, mcc); + } + String nai = "0" + imsi + "@" + effectiveRealm; + logger.d(TAG, "Derived NAI: " + nai + (realm != null ? " (server realm=" + realm + ")" : " (default realm)")); + return nai; + } + + private Ts43SimInfo resolveSimInfo(int subId, String requestImsi, String requestMsisdn) { + String simOperator = null; + String networkOperator = null; + String imsi = requestImsi; + + if (subId > 0) { + try { + TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (tm != null) { + TelephonyManager tmSub = tm.createForSubscriptionId(subId); + simOperator = tmSub.getSimOperator(); + networkOperator = tmSub.getNetworkOperator(); + if (imsi == null || imsi.isEmpty()) { + try { + imsi = tmSub.getSubscriberId(); + } catch (SecurityException e) { + logger.w(TAG, "Permission denied for subscriber ID (subId)", e); + } + } + } + } catch (Exception e) { + logger.w(TAG, "Failed to read SIM info for subId", e); + } + } + + if (simOperator == null || simOperator.length() < 5) { + String tmOperator = simAuthProvider.getSimOperator(); + if (tmOperator != null && tmOperator.length() >= 5) { + simOperator = tmOperator; + logger.d(TAG, "Using default TelephonyManager simOperator: " + simOperator); + } else if (imsi != null && imsi.length() >= 6) { + String candidate5 = imsi.substring(0, 5); + simOperator = candidate5; + logger.w(TAG, "TelephonyManager unavailable, falling back to IMSI-derived operator: " + simOperator); + } else if (imsi != null && imsi.length() >= 5) { + simOperator = imsi.substring(0, 5); + logger.w(TAG, "TelephonyManager unavailable, short IMSI fallback: " + simOperator); + } + } + + if (simOperator == null || simOperator.length() < 5) { + simOperator = simAuthProvider.getSimOperator(); + logger.d(TAG, "Fallback to default simOperator: " + simOperator); + } + + if (networkOperator == null || networkOperator.length() < 5) { + networkOperator = simAuthProvider.getNetworkOperator(); + } + + if (imsi == null || imsi.isEmpty()) { + imsi = simAuthProvider.getSubscriberId(); + } + + if (simOperator == null || simOperator.length() < 5) { + logger.e(TAG, "SIM operator missing or invalid"); + return null; + } + + if (imsi == null || imsi.isEmpty()) { + logger.e(TAG, "IMSI missing from SIM info"); + return null; + } + + if (requestMsisdn != null && requestMsisdn.startsWith("+")) { + logger.d(TAG, "Request MSISDN present: " + requestMsisdn); + } + + logger.d(TAG, "Resolved SIM info: imsi=" + redact(imsi) + + ", simOperator=" + simOperator + + ", networkOperator=" + networkOperator + + ", subId=" + subId); + return new Ts43SimInfo(imsi, simOperator); + } + + private EntitlementEndpoint resolveEntitlementUrl(int subId, String fallbackUrl) { + try { + CarrierConfigManager manager = (CarrierConfigManager) context.getSystemService(Context.CARRIER_CONFIG_SERVICE); + if (manager != null) { + android.os.PersistableBundle bundle = getConfigForSubIdCompat(manager, subId); + if (bundle != null) { + String entitlementUrl = bundle.getString(ENTITLEMENT_URL_KEY); + if (entitlementUrl != null && !entitlementUrl.isEmpty()) { + logger.d(TAG, "CarrierConfig entitlement URL: " + entitlementUrl + " (subId=" + subId + ")"); + return new EntitlementEndpoint(entitlementUrl, true); + } + logger.d(TAG, "CarrierConfig entitlement URL empty (subId=" + subId + ")"); + } else { + logger.d(TAG, "CarrierConfig bundle null (subId=" + subId + ")"); + } + } else { + logger.d(TAG, "CarrierConfig manager null"); + } + } catch (Exception e) { + logger.w(TAG, "Failed to read carrier config entitlement URL", e); + } + + logger.d(TAG, "CarrierConfig entitlement URL empty, using fallback: " + fallbackUrl); + return new EntitlementEndpoint(fallbackUrl, false); + } + + private HttpURLConnection openNetworkConnection(String urlString, int subId) throws IOException { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + int mode = getNetworkBindMode(); + if (cm != null && mode != NETWORK_BIND_NONE) { + Network network = findNetworkForMode(cm, mode); + if (network != null) { + NetworkCapabilities caps = cm.getNetworkCapabilities(network); + LinkProperties linkProps = cm.getLinkProperties(network); + logger.d(TAG, "Binding TS.43 request to network mode=" + mode + + ", transports=" + caps + + ", ifaces=" + (linkProps != null ? linkProps.getInterfaceName() : "null") + + ", subId=" + subId); + return (HttpURLConnection) network.openConnection(new URL(urlString)); + } + logger.w(TAG, "Requested network mode not available, falling back to default network (mode=" + mode + ")"); + } + + logActiveNetwork(cm, subId); + return (HttpURLConnection) new URL(urlString).openConnection(); + } + + private void logActiveNetwork(ConnectivityManager cm, int subId) { + if (cm == null) { + logger.w(TAG, "ConnectivityManager unavailable (subId=" + subId + ")"); + return; + } + Network network = cm.getActiveNetwork(); + NetworkCapabilities caps = cm.getNetworkCapabilities(network); + LinkProperties linkProps = cm.getLinkProperties(network); + logger.d(TAG, "Active network: " + network + + ", transports=" + caps + + ", ifaces=" + (linkProps != null ? linkProps.getInterfaceName() : "null") + + ", subId=" + subId); + } + + private int getNetworkBindMode() { + return NETWORK_BIND_NONE; + } + + private Network findNetworkForMode(ConnectivityManager cm, int mode) { + Network[] networks = getAllNetworksCompat(cm); + if (networks == null) return null; + + for (Network network : networks) { + NetworkCapabilities caps = cm.getNetworkCapabilities(network); + if (caps == null) continue; + if (mode == NETWORK_BIND_WIFI && caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + return network; + } + if (mode == NETWORK_BIND_CELLULAR && caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { + return network; + } + } + return null; + } + + @SuppressWarnings("deprecation") + private android.os.PersistableBundle getConfigForSubIdCompat(CarrierConfigManager manager, int subId) { + return manager.getConfigForSubId(subId); + } + + @SuppressWarnings("deprecation") + private Network[] getAllNetworksCompat(ConnectivityManager cm) { + return cm.getAllNetworks(); + } + + /** + * Generates the EAP-AKA response packet (Base64 encoded). + */ + private String generateEapAkaResponse(int subId, int id, byte[] rand, byte[] autn, String imsi, String simOperator, String eapAkaRealm) { + byte[] authData = new byte[1 + rand.length + 1 + autn.length]; + authData[0] = (byte) rand.length; + System.arraycopy(rand, 0, authData, 1, rand.length); + authData[1 + rand.length] = (byte) autn.length; + System.arraycopy(autn, 0, authData, 1 + rand.length + 1, autn.length); + + String authDataStr = base64Decoder.encodeToString(authData); + logger.d(TAG, "Calling getIccAuthentication with: " + authDataStr); + + String simResponseBase64; + try { + simResponseBase64 = simAuthProvider.getIccAuthentication( + TelephonyManager.APPTYPE_USIM, + TelephonyManager.AUTHTYPE_EAP_AKA, + authDataStr + ); + } catch (SecurityException e) { + logger.e(TAG, "Permission denied for getIccAuthentication", e); + return null; + } + + if (simResponseBase64 == null) { + logger.e(TAG, "getIccAuthentication returned null"); + return null; + } + + SimAuthResult authResult = parseSimResponse(simResponseBase64); + if (authResult == null) { + logger.e(TAG, "Failed to parse SIM response"); + return null; + } + + if (authResult.auts != null) { + return constructSyncFailure(id, authResult.auts); + } + + if (authResult.res == null || authResult.ck == null || authResult.ik == null) { + logger.e(TAG, "SIM response missing RES, CK, or IK"); + return null; + } + + if (imsi == null) { + logger.e(TAG, "Missing IMSI for key derivation"); + return null; + } + if (simOperator == null || simOperator.length() < 5) { + logger.e(TAG, "Invalid SIM operator for key derivation"); + return null; + } + String mcc = simOperator.substring(0, 3); + String mnc = simOperator.substring(3); + String identity = getNai(imsi, mcc, mnc, eapAkaRealm); + + byte[] identityBytes = identity.getBytes(StandardCharsets.UTF_8); + byte[] mkInput = new byte[identityBytes.length + authResult.ik.length + authResult.ck.length]; + int pos = 0; + System.arraycopy(identityBytes, 0, mkInput, pos, identityBytes.length); pos += identityBytes.length; + System.arraycopy(authResult.ik, 0, mkInput, pos, authResult.ik.length); pos += authResult.ik.length; + System.arraycopy(authResult.ck, 0, mkInput, pos, authResult.ck.length); + + byte[] mk; + try { + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + mk = sha1.digest(mkInput); + } catch (NoSuchAlgorithmException e) { + logger.e(TAG, "SHA-1 not supported", e); + return null; + } + + byte[] keyMaterial = deriveKeys(mk); + if (keyMaterial == null) return null; + byte[] k_aut = Arrays.copyOfRange(keyMaterial, 16, 32); + + int resLen = authResult.res.length; + int resPad = (4 - (resLen % 4)) % 4; + int atResLen = 4 + resLen + resPad; + int atMacLen = 20; + int totalLen = 8 + atResLen + atMacLen; + + byte[] packet = new byte[totalLen]; + pos = 0; + + packet[pos++] = 0x02; + packet[pos++] = (byte) id; + packet[pos++] = (byte) (totalLen >> 8); + packet[pos++] = (byte) totalLen; + packet[pos++] = (byte) EAP_AKA_TYPE; + packet[pos++] = 0x01; + packet[pos++] = 0x00; + packet[pos++] = 0x00; + + packet[pos++] = (byte) AT_RES; + packet[pos++] = (byte) (atResLen / 4); + int resBits = resLen * 8; + packet[pos++] = (byte) (resBits >> 8); + packet[pos++] = (byte) resBits; + System.arraycopy(authResult.res, 0, packet, pos, resLen); + pos += resLen; + pos += resPad; + + int macOffset = pos; + packet[pos++] = (byte) AT_MAC; + packet[pos++] = 0x05; + packet[pos++] = 0x00; + packet[pos++] = 0x00; + pos += 16; + + byte[] mac = calculateMac(k_aut, packet); + if (mac == null) return null; + + System.arraycopy(mac, 0, packet, macOffset + 4, 16); + + return base64Decoder.encodeToString(packet); + } + + private String constructSyncFailure(int id, byte[] auts) { + int atAutsLen = 16; + int totalLen = 8 + atAutsLen; + + byte[] packet = new byte[totalLen]; + int pos = 0; + + packet[pos++] = 0x02; + packet[pos++] = (byte) id; + packet[pos++] = (byte) (totalLen >> 8); + packet[pos++] = (byte) totalLen; + packet[pos++] = (byte) EAP_AKA_TYPE; + packet[pos++] = 0x04; + packet[pos++] = 0x00; + packet[pos++] = 0x00; + + packet[pos++] = 0x04; + packet[pos++] = 0x04; + System.arraycopy(auts, 0, packet, pos, auts.length); + + return base64Decoder.encodeToString(packet); + } + + private String extractToken(String responseBody) { + try { + org.json.JSONObject json = new org.json.JSONObject(responseBody); + org.json.JSONObject tokenObj = json.optJSONObject("Token"); + if (tokenObj != null) { + String token = tokenObj.optString("token", null); + if (token != null && !token.isEmpty()) return token; + } + } catch (Exception ignored) {} + + try { + Pattern p = Pattern.compile("]+name=\"token\"[^>]+value=\"([^\"]+)\""); + Matcher m = p.matcher(responseBody); + if (m.find()) return m.group(1); + } catch (Exception ignored) {} + + try { + Pattern p = Pattern.compile("\"token\"\\s*:\\s*\"([^\"]+)\""); + Matcher m = p.matcher(responseBody); + if (m.find()) return m.group(1); + } catch (Exception ignored) {} + + return null; + } + + private String readStream(InputStream in) throws IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } + + private String redact(String value) { + if (value == null) return "null"; + if (value.length() <= 6) return "[redacted]"; + return value.substring(0, 6) + "..." + value.substring(value.length() - 3); + } +} diff --git a/play-services-core/src/main/java/org/microg/gms/phenotype/ConfigurationProvider.java b/play-services-core/src/main/java/org/microg/gms/phenotype/ConfigurationProvider.java index 19ab1207d0..730ef7c2c6 100644 --- a/play-services-core/src/main/java/org/microg/gms/phenotype/ConfigurationProvider.java +++ b/play-services-core/src/main/java/org/microg/gms/phenotype/ConfigurationProvider.java @@ -28,18 +28,49 @@ public class ConfigurationProvider extends ContentProvider { private static final String TAG = "GmsPhenotypeCfgProvider"; + private static final String FLAG_ALLOW_MANUAL_MSISDN = "RcsFlags__allow_manual_phone_number_input"; + private static final String FLAG_UPI_NO_ACS_FALLBACK = "RcsProvisioning__min_gmscore_version_for_upi_without_acs_fallback_met"; + private static final String FLAG_ENABLE_UPI = "RcsProvisioning__enable_upi"; + private static final String FLAG_ENABLE_UPI_MVP = "RcsProvisioning__enable_upi_mvp"; + private static final String FLAG_ACS_URL = "RcsFlags__acs_url"; + // Carrier-generic Jibe URL template (%s = MCC) + private static final String FLAG_MCC_URL_FORMAT = "RcsFlags__mcc_url_format"; + private static final String JIBE_MCC_URL_FORMAT = "rcs-acs-mcc%s.jibe.google.com"; + private static final String FLAG_ALLOW_OVERRIDES = "RcsFlags__allow_overrides"; + private static final String FLAG_TRUE = "true"; + private static final String FLAG_FALSE = "false"; + @Override public boolean onCreate() { - Log.d(TAG, "unimplemented Method: onCreate"); - return false; + Log.d(TAG, "ConfigurationProvider created"); + return true; } @Nullable @Override public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) { - selection = Uri.decode(uri.getLastPathSegment()); - if (selection == null) return null; - return new MatrixCursor(new String[]{"key", "value"}); + String packageName = Uri.decode(uri.getLastPathSegment()); + if (packageName == null) return null; + + MatrixCursor cursor = new MatrixCursor(new String[]{"key", "value"}); + // Serve RCS flags for both IMS library and Messages app + if (packageName.startsWith("com.google.android.ims.library") || + packageName.startsWith("com.google.android.apps.messaging")) { + cursor.addRow(new Object[]{FLAG_ALLOW_MANUAL_MSISDN, FLAG_TRUE}); + cursor.addRow(new Object[]{FLAG_UPI_NO_ACS_FALLBACK, FLAG_TRUE}); + // ENABLED: UPI path so Messages calls our Constellation service + // Our service then calls Google's real API to trigger OTP SMS + // and get a properly signed JWT token + cursor.addRow(new Object[]{FLAG_ENABLE_UPI, FLAG_TRUE}); + cursor.addRow(new Object[]{FLAG_ENABLE_UPI_MVP, FLAG_TRUE}); + cursor.addRow(new Object[]{FLAG_ACS_URL, ""}); // URL resolved via mcc_url_format + cursor.addRow(new Object[]{FLAG_MCC_URL_FORMAT, JIBE_MCC_URL_FORMAT}); + cursor.addRow(new Object[]{"RcsProvisioning__enable_client_attestation_check", FLAG_FALSE}); + cursor.addRow(new Object[]{"RcsProvisioning__enable_client_attestation_check_v2", FLAG_FALSE}); + cursor.addRow(new Object[]{FLAG_ALLOW_OVERRIDES, FLAG_TRUE}); + Log.d(TAG, "Serving RCS phenotype flags (UPI ENABLED, mcc_url_format) for " + packageName); + } + return cursor; } @Nullable diff --git a/play-services-core/src/main/java/org/microg/gms/rcs/IRcsService.java b/play-services-core/src/main/java/org/microg/gms/rcs/IRcsService.java new file mode 100644 index 0000000000..ad8d9337f4 --- /dev/null +++ b/play-services-core/src/main/java/org/microg/gms/rcs/IRcsService.java @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.rcs; + +import android.os.Binder; +import android.os.IBinder; +import android.os.Parcel; +import android.os.RemoteException; +import android.util.Log; + +/** RCS Service binder stub (svc 189). */ +public abstract class IRcsService extends Binder { + private static final String TAG = "GmsRcsServiceBinder"; + + public static abstract class Stub extends IRcsService { + @Override + protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) throws RemoteException { + Log.d(TAG, "onTransact: code=" + code + ", flags=" + flags); + return super.onTransact(code, data, reply, flags); + } + + public IBinder asBinder() { + return this; + } + } +} diff --git a/play-services-core/src/main/java/org/microg/gms/rcs/RcsCallerPolicy.java b/play-services-core/src/main/java/org/microg/gms/rcs/RcsCallerPolicy.java new file mode 100644 index 0000000000..5774069dc4 --- /dev/null +++ b/play-services-core/src/main/java/org/microg/gms/rcs/RcsCallerPolicy.java @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.rcs; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.os.Build; +import android.util.Log; + +import com.google.android.gms.common.internal.GetServiceRequest; + +import org.microg.gms.common.PackageUtils; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Stock-parity caller allowlists for the internal RCS verification surfaces. + * Matches stock GMS enforcement; relaxing this for third-party RCS clients + * is out of scope for the bounty and can be revisited separately. + */ +public final class RcsCallerPolicy { + private static final String TAG = "GmsRcsCallerPolicy"; + + private static final Set CONSTELLATION_ALLOWED_PACKAGES = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList( + "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" + ))); + + private static final Set ASTERISM_ALLOWED_PACKAGES = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList( + "com.google.android.apps.messaging", + "com.google.android.apps.tachyon", + "com.google.android.ims", + "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" + ))); + + private RcsCallerPolicy() {} + + public static boolean isConstellationPackageAllowed(String packageName) { + return CONSTELLATION_ALLOWED_PACKAGES.contains(packageName); + } + + public static boolean isAsterismPackageAllowed(String packageName) { + return ASTERISM_ALLOWED_PACKAGES.contains(packageName); + } + + public static String checkConstellationCaller(Context context, GetServiceRequest request) { + return checkAllowedCaller(context, request, CONSTELLATION_ALLOWED_PACKAGES, "Constellation"); + } + + public static String checkConstellationCaller(Context context, int callingUid, String suggestedPackageName) { + return checkAllowedCaller(context, callingUid, suggestedPackageName, CONSTELLATION_ALLOWED_PACKAGES, "Constellation"); + } + + public static String checkAsterismCaller(Context context, GetServiceRequest request) { + return checkAllowedCaller(context, request, ASTERISM_ALLOWED_PACKAGES, "Asterism"); + } + + public static String getPackageVersionSummary(Context context, String packageName) { + if (packageName == null || packageName.isEmpty()) return "unknown"; + try { + PackageInfo info = context.getPackageManager().getPackageInfo(packageName, 0); + long versionCode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ? info.getLongVersionCode() : info.versionCode; + String versionName = info.versionName != null ? info.versionName : "unknown"; + return versionName + "(" + versionCode + ")"; + } catch (Exception e) { + Log.w(TAG, "Failed to get version for package=" + packageName, e); + return "unknown"; + } + } + + private static String checkAllowedCaller(Context context, GetServiceRequest request, Set allowedPackages, String serviceName) { + if (request == null) { + throw new IllegalArgumentException(serviceName + " request missing"); + } + String callingPackage = PackageUtils.getAndCheckCallingPackage(context, request.packageName); + if (callingPackage == null) { + throw new SecurityException(serviceName + " caller package missing or invalid"); + } + if (!allowedPackages.contains(callingPackage)) { + Log.w(TAG, serviceName + " rejecting caller: " + callingPackage); + throw new SecurityException(serviceName + " caller not allowed: " + callingPackage); + } + return callingPackage; + } + + private static String checkAllowedCaller(Context context, int callingUid, String suggestedPackageName, Set allowedPackages, String serviceName) { + String callingPackage = PackageUtils.getAndCheckPackage(context, suggestedPackageName, callingUid); + if (callingPackage == null) { + throw new SecurityException(serviceName + " caller package missing or invalid for uid=" + callingUid); + } + if (!allowedPackages.contains(callingPackage)) { + Log.w(TAG, serviceName + " rejecting caller: " + callingPackage + " uid=" + callingUid); + throw new SecurityException(serviceName + " caller not allowed: " + callingPackage); + } + return callingPackage; + } +} diff --git a/play-services-core/src/main/java/org/microg/gms/rcs/RcsService.java b/play-services-core/src/main/java/org/microg/gms/rcs/RcsService.java new file mode 100644 index 0000000000..65ab1bbe52 --- /dev/null +++ b/play-services-core/src/main/java/org/microg/gms/rcs/RcsService.java @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.rcs; + +import android.os.RemoteException; +import android.util.Log; + +import com.google.android.gms.common.api.CommonStatusCodes; +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 org.microg.gms.BaseService; +import org.microg.gms.common.GmsService; +import org.microg.gms.common.PackageUtils; + +/** + * RCS Service implementation. + * + * Handles the "com.google.android.gms.rcs.START" intent. + * Returns a success code to allow Google Messages to proceed with RCS setup. + */ +public class RcsService extends BaseService { + private static final String TAG = "GmsRcsService"; + + public RcsService() { + super(TAG, GmsService.RCS); + } + + @Override + public void handleServiceRequest(IGmsCallbacks callback, GetServiceRequest request, GmsService service) throws RemoteException { + String packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName); + if (packageName == null) { + Log.w(TAG, "Missing or invalid calling package"); + return; + } + String callingVersion = RcsCallerPolicy.getPackageVersionSummary(this, packageName); + + Log.d(TAG, "handleServiceRequest from: " + packageName); + Log.i("MicroGRcs", "svc189 bind caller=" + packageName + " version=" + callingVersion + " supportsConnectionInfo=" + request.supportsConnectionInfo); + if (request.extras != null) { + Log.d(TAG, "Request extras: " + request.extras); + } + + // Return SUCCESS and our binder stub + callback.onPostInitCompleteWithConnectionInfo( + CommonStatusCodes.SUCCESS, + new RcsServiceImpl(packageName).asBinder(), + new ConnectionInfo() + ); + } + + private static class RcsServiceImpl extends IRcsService.Stub { + private final String packageName; + + public RcsServiceImpl(String packageName) { + this.packageName = packageName; + } + } +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/auth/appcert/AppCertManager.kt b/play-services-core/src/main/kotlin/org/microg/gms/auth/appcert/AppCertManager.kt index 96658b4849..2c4b43b110 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/auth/appcert/AppCertManager.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/auth/appcert/AppCertManager.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 microG Project Team + * SPDX-FileCopyrightText: 2026 microG Project Team * SPDX-License-Identifier: Apache-2.0 */ @@ -22,11 +22,6 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.ByteString.Companion.of import org.microg.gms.checkin.LastCheckinInfo -import org.microg.gms.common.Constants -import org.microg.gms.gcm.GcmConstants -import org.microg.gms.gcm.GcmDatabase -import org.microg.gms.gcm.RegisterRequest -import org.microg.gms.gcm.completeRegisterRequest import org.microg.gms.profile.Build import org.microg.gms.profile.ProfileManager import org.microg.gms.settings.SettingsContract.CheckIn @@ -70,26 +65,19 @@ class AppCertManager(private val context: Context) { val androidId = lastCheckinInfo.androidId val sessionId = Random.nextLong() val data = hashMapOf( - "dg_androidId" to androidId.toString(16), - "dg_session" to sessionId.toString(16), + "dg_androidId" to java.lang.Long.toHexString(androidId), + "dg_session" to java.lang.Long.toHexString(sessionId), "dg_gmsCoreVersion" to BuildConfig.VERSION_CODE.toString(), "dg_sdkVersion" to Build.VERSION.SDK_INT.toString() ) val droidGuardResult = try { DroidGuardClient.getResults(context, "devicekey", data).await() } catch (e: Exception) { + Log.w(TAG, "DG devicekey failed: ${e.message}") null } - val token = completeRegisterRequest(context, GcmDatabase(context), RegisterRequest().build(context) - .checkin(lastCheckinInfo) - .app("com.google.android.gms", Constants.GMS_PACKAGE_SIGNATURE_SHA1, BuildConfig.VERSION_CODE) - .sender(REGISTER_SENDER) - .extraParam("subscription", REGISTER_SUBSCRIPTION) - .extraParam("X-subscription", REGISTER_SUBSCRIPTION) - .extraParam("subtype", REGISTER_SUBTYPE) - .extraParam("X-subtype", REGISTER_SUBTYPE) - .extraParam("scope", REGISTER_SCOPE)) - .getString(GcmConstants.EXTRA_REGISTRATION_ID) + Log.i(TAG, "DG devicekey result: ${if (droidGuardResult != null) "${droidGuardResult.length} chars" else "null"}, androidId=${java.lang.Long.toHexString(androidId)}") + val token = DEVICE_KEY_TOKEN_PLACEHOLDER val request = DeviceKeyRequest( droidGuardResult = droidGuardResult, androidId = lastCheckinInfo.androidId, @@ -102,12 +90,14 @@ class AppCertManager(private val context: Context) { queue.add(object : Request(Method.POST, "https://android.googleapis.com/auth/devicekey", null) { override fun getBody(): ByteArray = request.encode() - override fun getBodyContentType(): String = "application/octet-stream" + override fun getBodyContentType(): String = "application/x-protobuf" override fun parseNetworkResponse(response: NetworkResponse): Response { + Log.i(TAG, "devicekey HTTP ${response.statusCode}, ${response.data?.size ?: 0} bytes") return if (response.statusCode == 200) { Response.success(response.data, null) } else { + Log.w(TAG, "devicekey HTTP ${response.statusCode} body: ${String(response.data ?: ByteArray(0)).take(200)}") Response.success(null, null) } } @@ -128,14 +118,19 @@ class AppCertManager(private val context: Context) { override fun getHeaders(): Map { return mapOf( - "User-Agent" to "GoogleAuth/1.4 (${Build.DEVICE} ${Build.ID}); gzip", - "content-type" to "application/octet-stream", - "app" to "com.google.android.gms", - "device" to androidId.toString(16) + "app" to java.util.UUID.randomUUID().toString(), + "device" to java.lang.Long.toHexString(androidId), + "gmsversion" to BuildConfig.VERSION_CODE.toString(), + "gmscoreFlow" to "3" ) } }) - val deviceKeyBytes = deferredResponse.await() ?: return false + val deviceKeyBytes = deferredResponse.await() + if (deviceKeyBytes == null) { + Log.w(TAG, "devicekey fetch returned null (HTTP error)") + return false + } + Log.i(TAG, "devicekey SUCCESS: ${deviceKeyBytes.size} bytes") context.openFileOutput("device_key", Context.MODE_PRIVATE).use { it.write(deviceKeyBytes) } @@ -150,7 +145,10 @@ class AppCertManager(private val context: Context) { } suspend fun getSpatulaHeader(packageName: String): String? { - val deviceKey = deviceKey ?: if (fetchDeviceKey()) deviceKey else null + // Try fetch/refresh; even if fetchDeviceKey() returns false (e.g. HTTP 400), + // readDeviceKey() inside it may have loaded a valid key from disk. + if (deviceKey == null) fetchDeviceKey() + val deviceKey = deviceKey val packageCertificateHash = context.packageManager.getCertificates(packageName).firstOrNull()?.digest("SHA1")?.toBase64(Base64.NO_WRAP) val proto = if (deviceKey != null) { val macSecret = deviceKey.macSecret?.toByteArray() @@ -175,7 +173,6 @@ class AppCertManager(private val context: Context) { packageInfo = SpatulaHeaderProto.PackageInfo(packageName, packageCertificateHash), deviceId = androidId ) - return null // TODO } Log.d(TAG, "Spatula Header: $proto") return Base64.encodeToString(proto.encode(), Base64.NO_WRAP) @@ -184,10 +181,9 @@ class AppCertManager(private val context: Context) { companion object { private const val TAG = "AppCertManager" private const val DEVICE_KEY_TIMEOUT = 60 * 60 * 1000L - private const val REGISTER_SENDER = "745476177629" - private const val REGISTER_SUBTYPE = "745476177629" - private const val REGISTER_SUBSCRIPTION = "745476177629" - private const val REGISTER_SCOPE = "DeviceKeyRequest" + // Stock GMS sends a real GCM token here; microG uses a placeholder since it lacks + // the proprietary GCM registration for this endpoint. Server accepts it for fresh androidIds. + private const val DEVICE_KEY_TOKEN_PLACEHOLDER = "not_available" private val deviceKeyLock = Mutex() private var deviceKey: DeviceKey? = null private var deviceKeyCacheTime = 0L diff --git a/play-services-core/src/main/kotlin/org/microg/gms/constellation/ChallengeProcessor.kt b/play-services-core/src/main/kotlin/org/microg/gms/constellation/ChallengeProcessor.kt new file mode 100644 index 0000000000..bcdeb4c6bc --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/constellation/ChallengeProcessor.kt @@ -0,0 +1,584 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.telephony.SmsMessage +import android.util.Log +import google.internal.communications.phonedeviceverification.v1.* +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.CompletableDeferred +import okio.ByteString.Companion.toByteString +import kotlin.coroutines.resume + +private const val TAG = "GmsConstellationChallenge" + +/** OTP SMS data: full body + sender address. */ +data class OtpSmsResult(val messageBody: String, val originatingAddress: String) + +/** + * SMS inbox that buffers messages arriving before PENDING is detected. + * Pre-register receivers BEFORE Sync so SMS arriving during the + * Sync RPC isn't missed. + * + * Lifecycle: prepare() before Sync → awaitMatch() after PENDING → dispose() in finally. + * Uses both silent (createAppSpecificSmsToken PendingIntent) and noisy (SMS_RECEIVED) paths. + */ +object SmsInbox { + private val lock = Any() + private val bufferedMessages = mutableListOf() + private var pendingDeferred: CompletableDeferred? = null + private var silentReceiver: BroadcastReceiver? = null + private var noisyReceiver: BroadcastReceiver? = null + + fun prepare(context: Context) { + dispose(context) + Log.d(TAG, "SmsInbox: preparing receivers") + + silentReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + Log.d(TAG, "SILENT_SMS_RECEIVED") + extractSmsFromIntent(intent)?.let { onSmsReceived(it, "silent") } + } + } + if (Build.VERSION.SDK_INT >= 33) { + context.registerReceiver(silentReceiver!!, + IntentFilter(ConstellationConstants.ACTION_SILENT_SMS_RECEIVED), + Context.RECEIVER_NOT_EXPORTED) + } else { + context.registerReceiver(silentReceiver!!, + IntentFilter(ConstellationConstants.ACTION_SILENT_SMS_RECEIVED)) + } + + val hasReceiveSms = context.checkSelfPermission(android.Manifest.permission.RECEIVE_SMS) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (hasReceiveSms) { + noisyReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + if (intent.action == "android.provider.Telephony.SMS_RECEIVED") { + extractSmsFromIntent(intent)?.let { onSmsReceived(it, "noisy") } + } + } + } + if (Build.VERSION.SDK_INT >= 33) { + context.registerReceiver(noisyReceiver!!, + IntentFilter("android.provider.Telephony.SMS_RECEIVED").apply { priority = 1000 }, + Context.RECEIVER_EXPORTED) + } else { + context.registerReceiver(noisyReceiver!!, + IntentFilter("android.provider.Telephony.SMS_RECEIVED").apply { priority = 1000 }) + } + Log.d(TAG, "SmsInbox: receivers registered") + } else { + Log.d(TAG, "SmsInbox: silent receiver only (RECEIVE_SMS not granted)") + } + } + + private fun extractSmsFromIntent(intent: Intent): OtpSmsResult? { + @Suppress("DEPRECATION") + val pdus = intent.extras?.get("pdus") as? Array<*> + if (pdus != null) { + for (pdu in pdus) { + val msg = if (Build.VERSION.SDK_INT >= 23) { + SmsMessage.createFromPdu(pdu as ByteArray, intent.extras?.getString("format")) + } else { + @Suppress("DEPRECATION") + SmsMessage.createFromPdu(pdu as ByteArray) + } + val body = msg?.messageBody ?: continue + val sender = msg.originatingAddress ?: "" + return OtpSmsResult(body, sender) + } + } + // Fallback for silent path without PDUs + val body = intent.getStringExtra("message_body") + ?: intent.getStringExtra("body") + ?: intent.getStringExtra("sms_body") + val sender = intent.getStringExtra("originating_address") + ?: intent.getStringExtra("address") + ?: "" + if (body != null) return OtpSmsResult(body, sender) + // Last resort: token matched by Android (PendingIntent fired = correct SMS) + return OtpSmsResult("TOKEN_MATCHED_SMS", sender) + } + + private fun onSmsReceived(sms: OtpSmsResult, source: String) { + Log.d(TAG, "SmsInbox: SMS received via $source") + synchronized(lock) { + bufferedMessages.add(sms) + pendingDeferred?.let { deferred -> + if (deferred.isActive) { + deferred.complete(sms) + } + } + } + } + + /** + * Suspend until OTP SMS arrives or timeout. Checks buffer first (SMS may have + * arrived during Sync RPC), then suspends without blocking any thread. + */ + suspend fun awaitMatch(timeoutSeconds: Long = 120): OtpSmsResult? { + synchronized(lock) { + if (bufferedMessages.isNotEmpty()) { + Log.d(TAG, "SmsInbox: using buffered SMS") + return bufferedMessages.first() + } + pendingDeferred = CompletableDeferred() + } + Log.d(TAG, "SmsInbox: waiting ${timeoutSeconds}s for OTP") + return try { + kotlinx.coroutines.withTimeoutOrNull(timeoutSeconds * 1000L) { + pendingDeferred!!.await() + }?.also { + Log.i(TAG, "SmsInbox: OTP received") + } ?: run { + Log.w(TAG, "SmsInbox: OTP timeout after ${timeoutSeconds}s") + null + } + } catch (e: Exception) { + Log.e(TAG, "SmsInbox: error waiting for SMS: ${e.message}") + null + } + } + + fun dispose(context: Context) { + synchronized(lock) { + bufferedMessages.clear() + pendingDeferred?.cancel() + pendingDeferred = null + } + silentReceiver?.let { r -> try { context.unregisterReceiver(r) } catch (_: Exception) {} } + noisyReceiver?.let { r -> try { context.unregisterReceiver(r) } catch (_: Exception) {} } + silentReceiver = null + noisyReceiver = null + } +} + +// ======== CHALLENGE VERIFIERS ======== + +object ChallengeProcessor { + + private fun moFailedToSend() = ChallengeResponse( + mo_challenge_response = MOChallengeResponse( + status = MOChallengeStatus.MO_STATUS_FAILED_TO_SEND + ) + ) + + private fun ts43InternalError(ts43Type: Ts43Type?) = ChallengeResponse( + ts43_challenge_response = Ts43ChallengeResponse( + ts43_type = ts43Type, + error = Error(error_type = ErrorType.ERROR_TYPE_INTERNAL_ERROR) + ) + ) + + /** + * Send MO SMS to proxy_number from server challenge. Returns ChallengeResponse. + * Sends SMS and waits for delivery confirmation. + */ + suspend fun sendMoSms( + context: Context, + moChallenge: MOChallenge, + subId: Int + ): ChallengeResponse { + val proxyNumber = moChallenge.proxy_number + val smsText = moChallenge.sms + if (proxyNumber.isEmpty() || smsText.isEmpty()) { + Log.w(TAG, "MO SMS: empty proxy_number or sms text") + return moFailedToSend() + } + if (context.checkCallingOrSelfPermission(android.Manifest.permission.SEND_SMS) + != android.content.pm.PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "MO SMS: SEND_SMS permission not granted") + return moFailedToSend() + } + + val smsManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(android.telephony.SmsManager::class.java) + ?.let { if (subId > 0) it.createForSubscriptionId(subId) else it } + } else { + @Suppress("DEPRECATION") + if (subId > 0) android.telephony.SmsManager.getSmsManagerForSubscriptionId(subId) + else android.telephony.SmsManager.getDefault() + } + if (smsManager == null) { + Log.e(TAG, "MO SMS: cannot resolve SmsManager for subId=$subId") + return ChallengeResponse( + mo_challenge_response = MOChallengeResponse( + status = MOChallengeStatus.MO_STATUS_NO_SMS_MANAGER + ) + ) + } + + val messageId = java.util.UUID.randomUUID().toString() + val action = "org.microg.gms.constellation.MO_SMS_SENT" + val sentIntent = android.content.Intent(action).apply { + `package` = context.packageName + putExtra("message_id", messageId) + } + val pendingIntent = android.app.PendingIntent.getBroadcast( + context, messageId.hashCode(), sentIntent, + android.app.PendingIntent.FLAG_ONE_SHOT or android.app.PendingIntent.FLAG_IMMUTABLE + ) + + Log.d(TAG, "MO SMS: sending verification SMS") + + val port = moChallenge.data_sms_info?.port ?: 0 + return kotlinx.coroutines.withTimeoutOrNull(30_000L) { + kotlinx.coroutines.suspendCancellableCoroutine { continuation -> + val receiver = object : android.content.BroadcastReceiver() { + override fun onReceive(ctx: android.content.Context, intent: android.content.Intent) { + if (intent.getStringExtra("message_id") != messageId) return + val resultCode = resultCode + val errorCode = intent.getIntExtra("errorCode", -1) + try { ctx.unregisterReceiver(this) } catch (_: Exception) {} + if (continuation.isActive) { + val status = if (resultCode == -1) // Activity.RESULT_OK + MOChallengeStatus.MO_STATUS_COMPLETED + else + MOChallengeStatus.MO_STATUS_FAILED_TO_SEND + continuation.resume(ChallengeResponse( + mo_challenge_response = MOChallengeResponse( + status = status, + sms_result_code = resultCode.toLong(), + sms_error_code = errorCode.toLong() + ) + )) + } + } + } + androidx.core.content.ContextCompat.registerReceiver( + context, receiver, android.content.IntentFilter(action), + androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED + ) + continuation.invokeOnCancellation { + try { context.unregisterReceiver(receiver) } catch (_: Exception) {} + } + try { + if (port > 0) { + smsManager.sendDataMessage(proxyNumber, null, port.toShort(), smsText.toByteArray(), pendingIntent, null) + } else { + smsManager.sendTextMessage(proxyNumber, null, smsText, pendingIntent, null) + } + } catch (e: Exception) { + Log.e(TAG, "MO SMS send failed: ${e.message}") + try { context.unregisterReceiver(receiver) } catch (_: Exception) {} + if (continuation.isActive) { + continuation.resume(moFailedToSend()) + } + } + } + } ?: moFailedToSend() + } + + /** + * Verify via Carrier ID: send SIM ISIM challenge to TelephonyManager.getIccAuthentication(). + * Pure SIM-level crypto, no DG needed. + */ + fun verifyCarrierId( + context: Context, + challenge: CarrierIDChallenge, + subId: Int + ): ChallengeResponse { + val challengeData = challenge.isim_request.takeIf { it.isNotEmpty() } + if (challengeData == null) { + Log.w(TAG, "CarrierID: empty isim_request") + return carrierIdError(CarrierIdError.CARRIER_ID_ERROR_UNKNOWN_ERROR) + } + if (challengeData.startsWith("[ts43]")) { + Log.d(TAG, "ChallengeProcessor: [ts43] prefix, returning NOT_SUPPORTED") + return carrierIdError(CarrierIdError.CARRIER_ID_ERROR_NOT_SUPPORTED) + } + if (subId <= 0) { + Log.w(TAG, "CarrierID: invalid subId=$subId") + return carrierIdError(CarrierIdError.CARRIER_ID_ERROR_NO_SIM) + } + val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? android.telephony.TelephonyManager + ?: return carrierIdError(CarrierIdError.CARRIER_ID_ERROR_NOT_SUPPORTED) + val targetTm = tm.createForSubscriptionId(subId) + val appType = challenge.app_type.takeIf { it != 0 } ?: android.telephony.TelephonyManager.APPTYPE_USIM + + return try { + val response = targetTm.getIccAuthentication(appType, challenge.auth_type, challengeData) + if (response.isNullOrEmpty()) { + Log.w(TAG, "CarrierID: null/empty ISIM response") + carrierIdError(CarrierIdError.CARRIER_ID_ERROR_NULL_RESPONSE) + } else { + Log.i(TAG, "CarrierID: verification succeeded") + ChallengeResponse( + carrier_id_challenge_response = CarrierIDChallengeResponse( + isim_response = response, + carrier_id_error = CarrierIdError.CARRIER_ID_ERROR_NO_ERROR + ) + ) + } + } catch (e: SecurityException) { + Log.w(TAG, "CarrierID: SecurityException - ${e.message}") + carrierIdError(CarrierIdError.CARRIER_ID_ERROR_UNABLE_TO_READ_SUBSCRIPTION) + } catch (e: Exception) { + Log.e(TAG, "CarrierID: failed - ${e.message}") + carrierIdError(CarrierIdError.CARRIER_ID_ERROR_REFLECTION_ERROR) + } + } + + private fun carrierIdError(error: CarrierIdError): ChallengeResponse { + return ChallengeResponse( + carrier_id_challenge_response = CarrierIDChallengeResponse( + isim_response = "", + carrier_id_error = error + ) + ) + } + + /** + * Verify via RegisteredSMS: hash SMS inbox entries and compare against server-provided payloads. + * Algorithm: SHA-512(timeBucket + SHA-512(localNumber) + SHA-512(sender) + body) + */ + fun verifyRegisteredSms( + context: Context, + challenge: RegisteredSMSChallenge, + subId: Int + ): ChallengeResponse { + val expectedPayloads = challenge.verified_senders + .map { it.phone_number_id.toByteArray() } + .filter { it.isNotEmpty() } + if (expectedPayloads.isEmpty()) { + Log.w(TAG, "RegisteredSMS: no verified_senders in challenge") + return ChallengeResponse(registered_sms_challenge_response = RegisteredSMSChallengeResponse(items = emptyList())) + } + + if (context.checkCallingOrSelfPermission(android.Manifest.permission.READ_SMS) + != android.content.pm.PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "RegisteredSMS: READ_SMS permission not granted") + return ChallengeResponse(registered_sms_challenge_response = RegisteredSMSChallengeResponse(items = emptyList())) + } + + val localNumber = getLocalNumber(context, subId) + if (localNumber.isEmpty()) { + Log.w(TAG, "RegisteredSMS: no local phone number available") + return ChallengeResponse(registered_sms_challenge_response = RegisteredSMSChallengeResponse(items = emptyList())) + } + + val historyWindowMs = 168L * 3600_000L // 7 days + val granularityMs = 3600_000L / 2 // 30 min buckets + val historyStart = System.currentTimeMillis() - historyWindowMs + + val matchedItems = mutableListOf() + try { + val cursor = context.contentResolver.query( + android.provider.Telephony.Sms.Inbox.CONTENT_URI, + arrayOf("date", "address", "body"), + "date > ?", + arrayOf(historyStart.toString()), + "date DESC" + ) + cursor?.use { + while (it.moveToNext()) { + val date = it.getLong(0) + val sender = it.getString(1) ?: continue + val body = it.getString(2) ?: continue + val bucketStart = date - (date % granularityMs) + val payload = computeRegisteredSmsPayload(bucketStart, localNumber, sender, body) + if (expectedPayloads.any { expected -> expected.contentEquals(payload) }) { + matchedItems += RegisteredSmsPayload(payload = payload.toByteString()) + } + } + } + } catch (e: Exception) { + Log.e(TAG, "RegisteredSMS: inbox query failed: ${e.message}") + } + + Log.d(TAG, "RegisteredSMS: ${matchedItems.size} matches found") + return ChallengeResponse(registered_sms_challenge_response = RegisteredSMSChallengeResponse(items = matchedItems.distinct())) + } + + private fun computeRegisteredSmsPayload(bucketStart: Long, localNumber: String, sender: String, body: String): ByteArray { + val digest = java.security.MessageDigest.getInstance("SHA-512") + digest.update(bucketStart.toString().toByteArray(Charsets.UTF_8)) + digest.update(java.security.MessageDigest.getInstance("SHA-512").digest(localNumber.toByteArray(Charsets.UTF_8))) + digest.update(java.security.MessageDigest.getInstance("SHA-512").digest(sender.toByteArray(Charsets.UTF_8))) + digest.update(body.toByteArray(Charsets.UTF_8)) + return digest.digest() + } + + @Suppress("DEPRECATION") + private fun getLocalNumber(context: Context, subId: Int): String { + try { + val sm = context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) as? android.telephony.SubscriptionManager + val info = sm?.activeSubscriptionInfoList?.find { it.subscriptionId == subId } + val number = info?.number + if (!number.isNullOrEmpty()) return number + val tm = (context.getSystemService(Context.TELEPHONY_SERVICE) as? android.telephony.TelephonyManager) + ?.createForSubscriptionId(subId) + return tm?.line1Number ?: "" + } catch (e: Exception) { + Log.w(TAG, "RegisteredSMS: cannot get local number: ${e.message}") + return "" + } + } + + /** + * Verify via FlashCall: wait for incoming call from a number within server-provided phone_ranges. + * API <31: PhoneStateListener provides incoming number directly. + * API 31+: incoming number stripped by platform; fall back to CallLog query on RINGING. + */ + @Suppress("DEPRECATION") + suspend fun verifyFlashCall( + context: Context, + challenge: FlashCallChallenge, + timeoutMs: Long + ): ChallengeResponse? { + val ranges = challenge.phone_ranges + if (ranges.isEmpty()) { + Log.w(TAG, "FlashCall: no phone_ranges in challenge") + return null + } + + val waitMs = (timeoutMs.takeIf { it > 0 } ?: 30_000L).coerceIn(10_000, 120_000) + Log.d(TAG, "FlashCall: waiting ${waitMs}ms for call from ${ranges.size} range(s)") + + val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? android.telephony.TelephonyManager + if (tm == null) { + Log.w(TAG, "FlashCall: no TelephonyManager") + return null + } + + val result = withTimeoutOrNull(waitMs) { + suspendCancellableCoroutine { continuation -> + val listener = object : android.telephony.PhoneStateListener() { + override fun onCallStateChanged(state: Int, incomingNumber: String?) { + if (state != android.telephony.TelephonyManager.CALL_STATE_RINGING) return + + val number = if (!incomingNumber.isNullOrEmpty()) { + incomingNumber + } else if (Build.VERSION.SDK_INT >= 31) { + queryLatestIncomingCall(context) + } else null + + if (number != null) { + val normalized = number.replace(Regex("[^0-9+]"), "") + if (matchesPhoneRanges(normalized, ranges)) { + Log.i(TAG, "FlashCall: matched incoming call") + tm.listen(this, android.telephony.PhoneStateListener.LISTEN_NONE) + if (continuation.isActive) { + continuation.resume(ChallengeResponse( + flash_call_challenge_response = FlashCallChallengeResponse(caller = normalized) + )) + } + } + } + } + } + tm.listen(listener, android.telephony.PhoneStateListener.LISTEN_CALL_STATE) + continuation.invokeOnCancellation { + tm.listen(listener, android.telephony.PhoneStateListener.LISTEN_NONE) + } + } + } + + if (result == null) { + Log.w(TAG, "FlashCall: timeout after ${waitMs}ms") + } + return result + } + + private fun queryLatestIncomingCall(context: Context): String? { + if (context.checkCallingOrSelfPermission(android.Manifest.permission.READ_CALL_LOG) + != android.content.pm.PackageManager.PERMISSION_GRANTED) return null + return try { + context.contentResolver.query( + android.provider.CallLog.Calls.CONTENT_URI, + arrayOf(android.provider.CallLog.Calls.NUMBER), + "${android.provider.CallLog.Calls.TYPE} = ?", + arrayOf(android.provider.CallLog.Calls.INCOMING_TYPE.toString()), + "${android.provider.CallLog.Calls.DATE} DESC" + )?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + } catch (e: Exception) { + Log.w(TAG, "FlashCall: CallLog query failed: ${e.message}") + null + } + } + + private fun matchesPhoneRanges(number: String, ranges: List): Boolean { + val digits = number.removePrefix("+") + return ranges.any { range -> + val prefix = (range.country_code ?: "") + (range.prefix ?: "") + if (!digits.startsWith(prefix)) return@any false + val suffix = digits.removePrefix(prefix) + val lower = range.lower_bound ?: return@any true + val upper = range.upper_bound ?: lower + suffix >= lower && suffix <= upper + } + } + + /** + * Handle TS.43 challenge from server. Server provides entitlement_url + eap_aka_realm. + * Delegates to Ts43Client for EAP-AKA authentication with the SIM. + * Returns Ts43ChallengeResponse with auth token or error. + */ + fun handleTs43Challenge( + context: Context, + ts43Challenge: Ts43Challenge, + subId: Int, + phoneNumber: String? + ): ChallengeResponse? { + try { + val entitlementUrl = ts43Challenge.entitlement_url + if (entitlementUrl.isNullOrEmpty()) { + Log.w(TAG, "TS43: empty entitlement_url") + return ts43InternalError(ts43Challenge.ts43_type) + } + + val ts43Client = Ts43Client(context) + val odsaOp = ts43Challenge.client_challenge?.get_phone_number_operation + ?: ts43Challenge.server_challenge?.acquire_temporary_token_operation + val result = ts43Client.performEntitlementCheckResult( + subId, phoneNumber ?: "", "", "", + entitlementUrl, ts43Challenge.eap_aka_realm, + ts43Challenge.service_entitlement_request, + odsaOp, + ts43Challenge.app_id + ) + Log.i(TAG, "TS43: result ineligible=${result.ineligible} error=${result.isError}") + + if (result.isError) { + return ts43InternalError(ts43Challenge.ts43_type) + } + + // Route result to correct proto field based on challenge type + val responseToken = result.token ?: "" + val ts43Response = if (ts43Challenge.server_challenge != null) { + // Server challenge: return in server_challenge_response + Ts43ChallengeResponse( + ts43_type = ts43Challenge.ts43_type, + server_challenge_response = ServerChallengeResponse( + acquire_temporary_token_response = responseToken + ) + ) + } else { + // Client challenge (default): return in client_challenge_response + Ts43ChallengeResponse( + ts43_type = ts43Challenge.ts43_type, + client_challenge_response = ClientChallengeResponse( + get_phone_number_response = responseToken + ) + ) + } + return ChallengeResponse( + ts43_challenge_response = ts43Response + ) + } catch (e: Exception) { + Log.e(TAG, "TS43: ${e.javaClass.simpleName}: ${e.message}") + return ts43InternalError(ts43Challenge.ts43_type) + } + } +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationConsentFlow.kt b/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationConsentFlow.kt new file mode 100644 index 0000000000..55284fdc95 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationConsentFlow.kt @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation + +import android.util.Log +import google.internal.communications.phonedeviceverification.v1.ConsentValue +import google.internal.communications.phonedeviceverification.v1.Param +import google.internal.communications.phonedeviceverification.v1.StringId + +private const val TAG = "GmsConstellationClient" + +internal data class ConsentRequestContext( + val sessionId: String, + val protoCtx: RequestProtoContext, + val registeredAppIds: List, + val params: List, + val iidToken: String, +) + +internal data class ConsentFlowOutcome( + val consented: Boolean, + val setConsentAttempted: Boolean, + val setConsentSucceeded: Boolean, + val arfbCached: Boolean, +) + +internal suspend fun runConsentFlow( + rpc: ConstellationRpcClient, + requestContext: ConsentRequestContext, +): ConsentFlowOutcome { + Log.d(TAG, "Calling GetConsent") + + val getConsentToken = rpc.getDroidGuardToken("getConsent", requestContext.iidToken) + if (getConsentToken == null) { + Log.w(TAG, "DroidGuard token for GetConsent failed, proceeding without DG") + } + + val consentRequest = buildGetConsentRequest( + sessionId = requestContext.sessionId, + ctx = requestContext.protoCtx, + getConsentToken = getConsentToken, + registeredAppIds = requestContext.registeredAppIds, + params = requestContext.params, + ) + + try { + val consentResponse = rpc.getConsent(consentRequest) + Log.i(TAG, "GetConsent succeeded, consent=${consentResponse.device_consent?.consent}") + + val initialArfbCached = cacheConsentTokenResponse(rpc, requestContext.iidToken, consentResponse.droidguard_token_response) + + if (consentResponse.device_consent?.consent == ConsentValue.CONSENT_VALUE_CONSENTED) { + Log.i(TAG, "Consent already established") + return ConsentFlowOutcome( + consented = true, + setConsentAttempted = false, + setConsentSucceeded = false, + arfbCached = initialArfbCached, + ) + } + + Log.w(TAG, "Consent not established, calling SetConsent") + return trySetConsent(rpc, requestContext, initialArfbCached) + } catch (e: Exception) { + if (e is com.squareup.wire.GrpcException) { + Log.e(TAG, "GetConsent gRPC error: code=${e.grpcStatus.code} message=${e.grpcMessage}") + if (e.grpcStatus.code == 7 || e.grpcStatus.code == 16) { + rpc.clearDroidGuardTokenCache(rpc.resolveDroidGuardFlow("getConsent"), "Auth error (grpc-status=${e.grpcStatus.code})") + } + } else { + Log.e(TAG, "GetConsent failed: ${e.javaClass.simpleName}: ${e.message}") + } + Log.w(TAG, "GetConsent failed, continuing to Sync") + return ConsentFlowOutcome( + consented = false, + setConsentAttempted = false, + setConsentSucceeded = false, + arfbCached = false, + ) + } +} + +private suspend fun trySetConsent( + rpc: ConstellationRpcClient, + requestContext: ConsentRequestContext, + initialArfbCached: Boolean, +): ConsentFlowOutcome { + try { + var setConsentSucceeded = false + Log.d(TAG, "SetConsent without DG") + try { + val noDgRequest = buildSetConsentRequest(requestContext.sessionId, requestContext.protoCtx, null) + rpc.setConsent(noDgRequest) + Log.i(TAG, "SetConsent succeeded (no DG)") + setConsentSucceeded = true + } catch (e1: Exception) { + val code1 = if (e1 is com.squareup.wire.GrpcException) e1.grpcStatus.code else -1 + Log.w(TAG, "SetConsent without DG failed (grpc-status=$code1)") + if (code1 == 7) { + Log.d(TAG, "Retrying SetConsent with DG") + val setConsentToken = rpc.getDroidGuardToken("setConsent", requestContext.iidToken) + if (setConsentToken != null) { + try { + val dgRequest = buildSetConsentRequest(requestContext.sessionId, requestContext.protoCtx, setConsentToken) + rpc.setConsent(dgRequest) + Log.i(TAG, "SetConsent succeeded (with DG)") + setConsentSucceeded = true + } catch (e2: Exception) { + Log.e(TAG, "SetConsent with DG also failed: ${e2.message}") + } + } else { + Log.e(TAG, "Failed to get DroidGuard token for SetConsent retry") + } + } else { + Log.e(TAG, "SetConsent failed with non-PERMISSION_DENIED error, not retrying") + } + } + + if (setConsentSucceeded) { + val retryArfbCached = retryGetConsentAfterSetConsent(rpc, requestContext) + return ConsentFlowOutcome( + consented = true, + setConsentAttempted = true, + setConsentSucceeded = true, + arfbCached = initialArfbCached || retryArfbCached, + ) + } else { + Log.w(TAG, "SetConsent failed, continuing to Sync") + return ConsentFlowOutcome( + consented = false, + setConsentAttempted = true, + setConsentSucceeded = false, + arfbCached = initialArfbCached, + ) + } + } catch (e: Exception) { + Log.e(TAG, "SetConsent failed: ${e.javaClass.simpleName}: ${e.message}") + Log.w(TAG, "Continuing to Sync despite SetConsent failure") + return ConsentFlowOutcome( + consented = false, + setConsentAttempted = true, + setConsentSucceeded = false, + arfbCached = initialArfbCached, + ) + } +} + +private suspend fun retryGetConsentAfterSetConsent( + rpc: ConstellationRpcClient, + requestContext: ConsentRequestContext, +): Boolean { + Log.d(TAG, "Retrying GetConsent after SetConsent") + val retryToken = rpc.getDroidGuardToken("getConsent", requestContext.iidToken) + if (retryToken == null) { + Log.w(TAG, "Retry GetConsent skipped: no DG token available") + return false + } + + val retryRequest = buildGetConsentRequest( + sessionId = requestContext.sessionId, + ctx = requestContext.protoCtx, + getConsentToken = retryToken, + registeredAppIds = requestContext.registeredAppIds, + params = requestContext.params, + ) + val retryResponse = rpc.getConsent(retryRequest) + Log.i(TAG, "GetConsent retry: consent=${retryResponse.device_consent?.consent}") + + val retryCached = cacheConsentTokenResponse(rpc, requestContext.iidToken, retryResponse.droidguard_token_response) + if (retryCached) { + Log.i(TAG, "ARfb cached from retry") + } + return retryCached +} + +private fun cacheConsentTokenResponse( + rpc: ConstellationRpcClient, + iidToken: String, + dgTokenResponse: google.internal.communications.phonedeviceverification.v1.DroidGuardTokenResponse?, +): Boolean { + if (dgTokenResponse != null) { + val serverToken = dgTokenResponse.droidguard_token + if (!serverToken.isNullOrEmpty()) { + val expiryMillis = try { dgTokenResponse.droidguard_token_ttl?.toEpochMilli() ?: 0L } catch (_: Exception) { 0L } + rpc.cacheDroidGuardToken(rpc.resolveDroidGuardFlow("getConsent"), serverToken, expiryMillis, iidToken) + Log.i(TAG, "Cached ARfb from GetConsent response") + return true + } else { + Log.w(TAG, "GetConsent DG token response present but empty") + } + } else { + Log.w(TAG, "No DG token in GetConsent response - Sync will use raw DG") + } + return false +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationGpnvFlow.kt b/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationGpnvFlow.kt new file mode 100644 index 0000000000..d6f470b104 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationGpnvFlow.kt @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation + +import android.util.Log +import com.google.android.gms.constellation.VerifyPhoneNumberRequest as AidlVerifyPhoneNumberRequest +import google.internal.communications.phonedeviceverification.v1.ClientCredentialsProto +import google.internal.communications.phonedeviceverification.v1.GetVerifiedPhoneNumbersRequest +import google.internal.communications.phonedeviceverification.v1.IdTokenRequestProto +import google.internal.communications.phonedeviceverification.v1.VerifiedPhoneNumber +import java.security.MessageDigest +import java.time.Instant +import java.util.Base64 +import okio.ByteString +import org.json.JSONObject + +private const val TAG = "GmsConstellationClient" + +internal data class GpnvRequestContext( + val sessionId: String, + val privateKey: java.security.PrivateKey?, + val readOnlyIidToken: String, + val idTokenCertificateHash: String, + val idTokenCallingPackage: String, + val idTokenNonce: String, +) + +internal data class GpnvLookupResult( + val jwt: String, + val phoneNumber: String?, +) + +internal suspend fun fetchVerifiedPhoneToken( + rpc: ConstellationRpcClient, + requestContext: GpnvRequestContext, + targetPhone: String?, + marker: String, +): GpnvLookupResult? { + val response = rpc.getVerifiedPhoneNumbers(buildGpnvRequest(requestContext)) + Log.d(TAG, "GPNV returned ${response.verified_phone_numbers.size} numbers") + + val matchingNumber = findMatchingVerifiedNumber(response.verified_phone_numbers, targetPhone) + val jwt = matchingNumber?.token + if (matchingNumber != null && !jwt.isNullOrEmpty()) { + logJwtSummary(marker, jwt, matchingNumber.phone_number) + return GpnvLookupResult(jwt = jwt, phoneNumber = matchingNumber.phone_number) + } + + return null +} + +internal fun extractRequestedPhoneNumber( + request: AidlVerifyPhoneNumberRequest?, + msisdnOverride: String?, +): String? { + val requestMsisdn = request?.imsiRequests?.firstOrNull()?.msisdn?.takeIf { it.isNotEmpty() } + val e164PolicyId = request?.policyId?.takeIf { it.startsWith("+") } + return requestMsisdn ?: msisdnOverride ?: e164PolicyId +} + +internal fun findMatchingVerifiedNumber( + numbers: List, + targetPhone: String?, +): VerifiedPhoneNumber? { + if (numbers.isEmpty()) return null + if (!targetPhone.isNullOrEmpty()) { + val match = numbers.firstOrNull { it.phone_number == targetPhone } + if (match != null) return match + Log.w(TAG, "No exact phone match in GPNV response, using first") + } + return numbers.firstOrNull() +} + +private fun createIidTokenAuth( + privateKey: java.security.PrivateKey?, + iidTokenForSig: String, +): ClientCredentialsProto { + if (privateKey == null) { + Log.w(TAG, "GPNV: private key missing, sending without signature") + return ClientCredentialsProto( + iid_token = iidTokenForSig, + client_signature = ByteString.EMPTY, + signature_timestamp = null, + ) + } + + val nowMillis = System.currentTimeMillis() + val seconds = nowMillis / 1000 + val nanos = ((nowMillis % 1000) * 1_000_000).toInt() + val signingString = "$iidTokenForSig:$seconds:$nanos" + + return try { + val signature = java.security.Signature.getInstance("SHA256withECDSA") + signature.initSign(privateKey) + signature.update(signingString.toByteArray(Charsets.UTF_8)) + val signatureBytes = signature.sign() + val ts = Instant.ofEpochSecond(seconds, nanos.toLong()) + + ClientCredentialsProto( + iid_token = iidTokenForSig, + client_signature = ByteString.of(*signatureBytes), + signature_timestamp = ts, + ) + } catch (e: Exception) { + Log.w(TAG, "GPNV: failed to generate signature: ${e.message}") + ClientCredentialsProto( + iid_token = iidTokenForSig, + client_signature = ByteString.EMPTY, + signature_timestamp = null, + ) + } +} + +private fun buildGpnvRequest(requestContext: GpnvRequestContext): GetVerifiedPhoneNumbersRequest { + return GetVerifiedPhoneNumbersRequest( + session_id = requestContext.sessionId, + client_credentials = createIidTokenAuth(requestContext.privateKey, requestContext.readOnlyIidToken), + selection_types = listOf(1), + id_token_request = IdTokenRequestProto( + certificate_hash = requestContext.idTokenCertificateHash, + calling_package = requestContext.idTokenCallingPackage, + token_nonce = requestContext.idTokenNonce, + ), + droidguard_result = "", + ) +} + +private fun decodeJwtPayloadJson(jwt: String): JSONObject? { + val parts = jwt.split('.') + if (parts.size < 2) return null + val payloadB64 = parts[1] + val padLen = (4 - (payloadB64.length % 4)) % 4 + val padded = payloadB64 + "=".repeat(padLen) + return try { + val decoded = Base64.getUrlDecoder().decode(padded) + JSONObject(String(decoded, Charsets.UTF_8)) + } catch (_: Exception) { + null + } +} + +internal fun jwtSha256HexPrefix(jwt: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(jwt.toByteArray(Charsets.UTF_8)) + val sb = StringBuilder(8) + for (b in digest.take(4)) { + sb.append(String.format("%02x", b)) + } + return sb.toString() +} + +internal fun logJwtSummary(marker: String, jwt: String, phoneFromResponse: String?) { + val payload = decodeJwtPayloadJson(jwt) + val phoneSuffix = payload?.optString("phone_number")?.takeLast(4) + Log.i(TAG, "JWT received (${jwt.length} chars)") + Log.i("MicroGRcs", "constellation JWT len=${jwt.length} phone=***${phoneSuffix ?: "?"}") +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationProceedFlow.kt b/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationProceedFlow.kt new file mode 100644 index 0000000000..f158f04798 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationProceedFlow.kt @@ -0,0 +1,313 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation + +import android.content.Context +import android.util.Log +import google.internal.communications.phonedeviceverification.v1.* +import kotlinx.coroutines.delay + +private const val TAG = "GmsConstellationClient" + +internal data class ProceedRequestContext( + val context: Context, + val rpc: ConstellationRpcClient, + val protoCtx: RequestProtoContext, + val gpnvRequestContext: GpnvRequestContext, + val sessionId: String, + val iidToken: String, + val subId: Int, + val phoneNumber: String, + val deviceAndroidId: Long, + val userAndroidId: Long, + val proceedClientCredentials: ClientCredentials?, + val initialVerification: Verification, +) + +internal sealed class ProceedFlowOutcome { + data class Verified(val jwt: String) : ProceedFlowOutcome() + data class Error(val reason: String, val cause: Exception? = null) : ProceedFlowOutcome() + data object Incomplete : ProceedFlowOutcome() +} + +internal suspend fun runProceedFlow(requestContext: ProceedRequestContext): ProceedFlowOutcome { + Log.d(TAG, "Entering challenge dispatch loop") + + var currentVerification = requestContext.initialVerification + val carrierIdAttempts = mutableMapOf() + var moSmsSent = false + + for (round in 1..16) { + val challenge = currentVerification.pending_challenge?.challenge + if (challenge == null) { + Log.w(TAG, "Round $round: no challenge in pending verification") + break + } + val challengeType = challenge.type + val challengeId = challenge.challenge_id?.id ?: "unknown" + Log.d(TAG, "Challenge round $round: type=$challengeType") + + val expiryTimeMs = challenge.expiry_time?.let { pair -> + val serverNowMs = pair.start_time?.let { it.epochSecond * 1000L + it.nano / 1_000_000L } ?: 0L + val serverExpiryMs = pair.end_time?.let { it.epochSecond * 1000L + it.nano / 1_000_000L } ?: 0L + if (serverNowMs > 0 && serverExpiryMs > serverNowMs) serverExpiryMs - serverNowMs else null + } + + val challengeResponse = buildChallengeResponse( + requestContext = requestContext, + challenge = challenge, + challengeType = challengeType, + challengeId = challengeId, + round = round, + expiryTimeMs = expiryTimeMs, + carrierIdAttempts = carrierIdAttempts, + moSmsSent = moSmsSent, + ) + + if (challengeResponse == null) { + Log.w(TAG, "Round $round: verifier returned null, exiting loop") + break + } + + if (challengeType == ChallengeType.CHALLENGE_TYPE_MO_SMS && challenge.mo_challenge != null && !moSmsSent) { + moSmsSent = true + } + + when (val proceedOutcome = executeProceed( + requestContext = requestContext, + currentVerification = currentVerification, + challengeResponse = challengeResponse, + challengeId = challengeId, + round = round, + )) { + is ProceedExecutionOutcome.Verified -> { + Log.i(TAG, "VERIFIED after challenge round $round") + var proceedGpnvCtx = requestContext.gpnvRequestContext + for (gpnvAttempt in 1..2) { + try { + val verifiedToken = fetchVerifiedPhoneToken( + rpc = requestContext.rpc, + requestContext = proceedGpnvCtx, + targetPhone = requestContext.phoneNumber, + marker = "GPNV_POST_PROCEED", + ) + return if (verifiedToken != null) { + ProceedFlowOutcome.Verified(verifiedToken.jwt) + } else { + Log.e(TAG, "VERIFIED but GPNV returned empty token") + ProceedFlowOutcome.Error("proceed-no-token") + } + } catch (e: Exception) { + val msg = e.message ?: "" + if (gpnvAttempt == 1 && msg.contains("could not verify iid_token")) { + Log.w(TAG, "Post-Proceed GPNV iid_token rejected, re-registering") + Log.i("MicroGRcs", "GPNV iid_token rejected, retrying with fresh token") + GoogleConstellationClient.invalidateIidToken(requestContext.context, ConstellationConstants.SENDER_READ_ONLY) + val (freshToken, freshSource) = GoogleConstellationClient.getOrRegisterIidToken( + requestContext.context, "com.google.android.gms", ConstellationConstants.SENDER_READ_ONLY + ) + Log.i("MicroGRcs", "GPNV retry iid=$freshSource") + proceedGpnvCtx = proceedGpnvCtx.copy(readOnlyIidToken = freshToken) + } else { + Log.e(TAG, "Post-Proceed GPNV failed: $msg") + Log.i("MicroGRcs", "GPNV failed: $msg") + return ProceedFlowOutcome.Error("proceed-gpnv-failed") + } + } + } + return ProceedFlowOutcome.Error("proceed-gpnv-exhausted") + } + is ProceedExecutionOutcome.Pending -> { + Log.d(TAG, "Still PENDING after round $round") + currentVerification = proceedOutcome.verification + } + is ProceedExecutionOutcome.Error -> { + return ProceedFlowOutcome.Error(proceedOutcome.reason, proceedOutcome.cause) + } + is ProceedExecutionOutcome.OtherState -> { + Log.w(TAG, "Unexpected post-Proceed state: ${proceedOutcome.state} (round $round)") + break + } + } + } + + Log.w(TAG, "Challenge loop exhausted (16 rounds) or exited early") + return ProceedFlowOutcome.Incomplete +} + +private suspend fun buildChallengeResponse( + requestContext: ProceedRequestContext, + challenge: Challenge, + challengeType: ChallengeType?, + challengeId: String, + round: Int, + expiryTimeMs: Long?, + carrierIdAttempts: MutableMap, + moSmsSent: Boolean, +): ChallengeResponse? { + return when (challengeType) { + ChallengeType.CHALLENGE_TYPE_MT_SMS -> { + val timeoutSec = (expiryTimeMs?.div(1000) ?: 120L).coerceIn(10, 300) + Log.d(TAG, "MT_SMS: waiting ${timeoutSec}s for OTP") + val otpSms = SmsInbox.awaitMatch(timeoutSeconds = timeoutSec) + if (otpSms != null) { + Log.i(TAG, "MT_SMS OTP received") + ChallengeResponse( + mt_challenge_response = MTChallengeResponse( + sms_body = otpSms.messageBody, + originating_address = otpSms.originatingAddress, + ) + ) + } else { + Log.w(TAG, "MT_SMS: OTP timeout after ${timeoutSec}s") + ChallengeResponse( + mt_challenge_response = MTChallengeResponse( + sms_body = "", + originating_address = "", + ) + ) + } + } + ChallengeType.CHALLENGE_TYPE_MO_SMS -> { + val moChallenge = challenge.mo_challenge + if (moChallenge == null) { + Log.w(TAG, " MO_SMS: no mo_challenge data") + null + } else if (!moSmsSent) { + Log.d(TAG, "MO_SMS: sending verification SMS") + ChallengeProcessor.sendMoSms(requestContext.context, moChallenge, requestContext.subId) + } else { + val pollDelays = moChallenge.polling_intervals.split(",").mapNotNull { it.trim().toLongOrNull() } + val pollIndex = (round - 2).coerceAtLeast(0) + val pollDelay = pollDelays.getOrElse(pollIndex) { pollDelays.lastOrNull() ?: 5000L } + Log.d(TAG, "MO_SMS: polling (${pollDelay}ms)") + delay(pollDelay.coerceIn(1000, 30000)) + ChallengeResponse( + mo_challenge_response = MOChallengeResponse( + status = MOChallengeStatus.MO_STATUS_COMPLETED, + ) + ) + } + } + ChallengeType.CHALLENGE_TYPE_CARRIER_ID -> { + val carrierChallenge = challenge.carrier_id_challenge + if (carrierChallenge == null) { + Log.w(TAG, " CARRIER_ID: no carrier_id_challenge data") + null + } else { + val attempts = carrierIdAttempts.getOrDefault(challengeId, 0) + 1 + carrierIdAttempts[challengeId] = attempts + if (attempts > 3) { + Log.w(TAG, " CARRIER_ID: retry exceeded ($attempts) for $challengeId") + ChallengeResponse( + carrier_id_challenge_response = CarrierIDChallengeResponse( + carrier_id_error = CarrierIdError.CARRIER_ID_ERROR_RETRY_ATTEMPT_EXCEEDED, + ) + ) + } else { + Log.d(TAG, "CARRIER_ID: attempt $attempts/3") + ChallengeProcessor.verifyCarrierId(requestContext.context, carrierChallenge, requestContext.subId) + } + } + } + ChallengeType.CHALLENGE_TYPE_REGISTERED_SMS -> { + val regChallenge = challenge.registered_sms_challenge + if (regChallenge == null) { + Log.w(TAG, " REGISTERED_SMS: no challenge data") + null + } else { + Log.d(TAG, "REGISTERED_SMS: verifying against SMS inbox") + ChallengeProcessor.verifyRegisteredSms(requestContext.context, regChallenge, requestContext.subId) + } + } + ChallengeType.CHALLENGE_TYPE_FLASH_CALL -> { + val flashChallenge = challenge.flash_call_challenge + if (flashChallenge == null) { + Log.w(TAG, " FLASH_CALL: no flash_call_challenge data") + null + } else { + val waitMs = expiryTimeMs ?: (flashChallenge.millis_between_interceptions.takeIf { it > 0 } ?: 30_000L) + Log.d(TAG, "FLASH_CALL: waiting for incoming call") + ChallengeProcessor.verifyFlashCall(requestContext.context, flashChallenge, waitMs) + } + } + ChallengeType.CHALLENGE_TYPE_TS43 -> { + val ts43Challenge = challenge.ts43_challenge + if (ts43Challenge == null) { + Log.w(TAG, " TS43: no ts43_challenge data") + null + } else { + Log.d(TAG, "TS43: processing entitlement challenge") + ChallengeProcessor.handleTs43Challenge(requestContext.context, ts43Challenge, requestContext.subId, requestContext.phoneNumber) + } + } + else -> { + Log.w(TAG, " Unsupported challenge type: $challengeType") + null + } + } +} + +private sealed class ProceedExecutionOutcome { + data class Verified(val verification: Verification) : ProceedExecutionOutcome() + data class Pending(val verification: Verification) : ProceedExecutionOutcome() + data class OtherState(val state: VerificationState?) : ProceedExecutionOutcome() + data class Error(val reason: String, val cause: Exception? = null) : ProceedExecutionOutcome() +} + +private suspend fun executeProceed( + requestContext: ProceedRequestContext, + currentVerification: Verification, + challengeResponse: ChallengeResponse, + challengeId: String, + round: Int, +): ProceedExecutionOutcome { + val proceedDgToken = requestContext.rpc.getDroidGuardToken("proceed", requestContext.iidToken) + val proceedClientInfo = buildClientInfo( + ctx = requestContext.protoCtx, + droidGuardToken = proceedDgToken, + ) + val proceedHeader = buildRequestHeader( + sessionId = requestContext.sessionId, + clientInfo = proceedClientInfo, + clientCredentials = requestContext.proceedClientCredentials, + ) + val proceedRequest = ProceedRequest( + verification = currentVerification, + challenge_response = challengeResponse, + header_ = proceedHeader, + ) + + try { + val proceedResponse = requestContext.rpc.proceed(proceedRequest) + val nextVerification = proceedResponse.verification + if (nextVerification == null) { + Log.w(TAG, "Round $round: proceed response verification missing") + return ProceedExecutionOutcome.Error("proceed-verification-missing") + } + val nextState = nextVerification.state + Log.i(TAG, "Round $round: proceed response received. state=$nextState") + + return when (nextState) { + VerificationState.VERIFICATION_STATE_VERIFIED -> ProceedExecutionOutcome.Verified(nextVerification) + VerificationState.VERIFICATION_STATE_PENDING -> ProceedExecutionOutcome.Pending(nextVerification) + else -> ProceedExecutionOutcome.OtherState(nextState) + } + } catch (e: Exception) { + if (e is com.squareup.wire.GrpcException) { + Log.e(TAG, "Round $round: proceed gRPC error: code=${e.grpcStatus.code} message=${e.grpcMessage}") + if (e.grpcStatus.code == 7 || e.grpcStatus.code == 16) { + requestContext.rpc.clearDroidGuardTokenCache( + requestContext.rpc.resolveDroidGuardFlow("proceed"), + "Auth error (grpc-status=${e.grpcStatus.code})" + ) + } + } else { + Log.e(TAG, "Round $round: proceed request failed: ${e.javaClass.simpleName}: ${e.message}") + } + return ProceedExecutionOutcome.Error("proceed-failed", e) + } +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationProtoBuilder.kt b/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationProtoBuilder.kt new file mode 100644 index 0000000000..c8075c04f7 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationProtoBuilder.kt @@ -0,0 +1,594 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.os.Bundle +import android.telephony.ServiceState +import android.telephony.SubscriptionInfo +import android.telephony.SubscriptionManager +import android.telephony.TelephonyManager +import android.util.Log +import google.internal.communications.phonedeviceverification.v1.* +import okio.ByteString + +private const val TAG = "GmsConstellationProto" + +@Suppress("DEPRECATION") +private fun ConnectivityManager.allNetworksCompat(): Array = allNetworks + +@Suppress("DEPRECATION") +private fun SubscriptionInfo.mccCompat(): Int = mcc + +@Suppress("DEPRECATION") +private fun SubscriptionInfo.mncCompat(): Int = mnc + +data class TelephonyData( + val simCountry: String, + val networkCountry: String, + val simOperator: String, + val networkOperator: String, + val groupIdLevel1: String, + val imei: String, + val iccId: String, + val phoneTypeInt: Int, + val dataRoamingInt: Int, + val networkRoamingInt: Int, + val smsCapabilityInt: Int, + val activeSubCount: Int, + val maxSubCount: Int, + val simSlotIndex: Int, + val subId: Int, + val carrierIdCapabilityInt: Int, + val smsNoConfirmInt: Int, + val simStateEnum: Int, + val serviceStateEnum: Int, + val isEmbedded: Boolean, + val carrierId: Long, + val simOperatorName: String, + val networkOperatorName: String, + val subscriptionInfo: SubscriptionInfo?, + val telephonyManagerSub: TelephonyManager?, +) + +data class RequestProtoContext( + val iidToken: String, + val deviceAndroidId: Long, + val userAndroidId: Long, + val publicKeyBytes: ByteString?, + val localeStr: String, + val gmscoreVersionNumber: Int, + val gmscoreVersion: String, + val registeredAppIds: List, + val countryInfo: CountryInfo, + val connectivityInfos: List, + val telephonyInfoContainer: TelephonyInfoContainer?, +) + +fun gatherConnectivityInfos(context: Context): List { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + val result = mutableListOf() + try { + val bestByType = linkedMapOf() + connectivityManager?.allNetworksCompat()?.forEach { network -> + val caps = connectivityManager.getNetworkCapabilities(network) ?: return@forEach + val connType = when { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> ConnectivityType.CONNECTIVITY_TYPE_WIFI + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> ConnectivityType.CONNECTIVITY_TYPE_MOBILE + else -> ConnectivityType.CONNECTIVITY_TYPE_UNKNOWN + } + if (connType == ConnectivityType.CONNECTIVITY_TYPE_UNKNOWN) return@forEach + + val connState = when { + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) -> ConnectivityState.CONNECTIVITY_STATE_CONNECTED + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -> ConnectivityState.CONNECTIVITY_STATE_CONNECTING + else -> ConnectivityState.CONNECTIVITY_STATE_UNKNOWN + } + val connAvail = if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { + ConnectivityAvailability.CONNECTIVITY_AVAILABLE + } else { + ConnectivityAvailability.CONNECTIVITY_NOT_AVAILABLE + } + val newInfo = ConnectivityInfo( + type = connType, + state = connState, + availability = connAvail + ) + val oldInfo = bestByType[connType] + if (oldInfo == null || newInfo.state.value > oldInfo.state.value) { + bestByType[connType] = newInfo + } + } + result.addAll(bestByType.values) + } catch (e: SecurityException) { + Log.w(TAG, "Could not get connectivity info", e) + } + return result +} + +fun gatherTelephonyData( + context: Context, + targetImsi: String?, + targetMsisdn: String? +): TelephonyData { + val telephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager + val subscriptionManager = context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) as? SubscriptionManager + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + + val allSubs = subscriptionManager?.activeSubscriptionInfoList ?: emptyList() + val subscriptionInfo = if (targetImsi != null && allSubs.size > 1) { + allSubs.find { sub -> + val subMcc = if (Build.VERSION.SDK_INT >= 29) sub.mccString?.toIntOrNull() else sub.mccCompat() + val subMnc = if (Build.VERSION.SDK_INT >= 29) sub.mncString?.toIntOrNull() else sub.mncCompat() + val subMccMnc = "${subMcc ?: -1}${String.format("%02d", subMnc ?: -1)}" + targetImsi.startsWith(subMccMnc) + } ?: allSubs.find { sub -> + @Suppress("DEPRECATION") + targetMsisdn != null && sub.number != null && sub.number.isNotEmpty() && + (targetMsisdn.endsWith(sub.number.takeLast(8)) || sub.number.endsWith(targetMsisdn.takeLast(8))) + } ?: allSubs.firstOrNull().also { + Log.w(TAG, "Could not match IMSI to any subscription, using first") + } + } else { + allSubs.firstOrNull() + } + + val subId = subscriptionInfo?.subscriptionId ?: SubscriptionManager.INVALID_SUBSCRIPTION_ID + val matchedMcc = if (Build.VERSION.SDK_INT >= 29) subscriptionInfo?.mccString else subscriptionInfo?.mccCompat()?.toString() + val matchedMnc = if (Build.VERSION.SDK_INT >= 29) subscriptionInfo?.mncString else subscriptionInfo?.mncCompat()?.toString() + + val telephonyManagerSub = if (subId != SubscriptionManager.INVALID_SUBSCRIPTION_ID) { + try { + telephonyManager?.createForSubscriptionId(subId) ?: telephonyManager + } catch (e: Exception) { + telephonyManager + } + } else { + telephonyManager + } + + val simCountry = telephonyManager?.simCountryIso?.lowercase(java.util.Locale.ROOT) ?: "" + val networkCountry = telephonyManager?.networkCountryIso?.lowercase(java.util.Locale.ROOT) ?: "" + val simOperatorStr = telephonyManagerSub?.simOperator ?: "" + val networkOperatorStr = telephonyManagerSub?.networkOperator ?: "" + val groupIdLevel1 = try { + telephonyManagerSub?.groupIdLevel1 ?: "" + } catch (e: SecurityException) { + Log.w(TAG, "No permission for GroupIdLevel1") + "" + } + val imei = try { + @Suppress("DEPRECATION") + telephonyManagerSub?.deviceId ?: "" + } catch (e: SecurityException) { + Log.w(TAG, "No permission for IMEI") + "" + } + val iccId = try { + subscriptionInfo?.iccId + ?: run { + @Suppress("DEPRECATION") + telephonyManagerSub?.simSerialNumber + } + ?: "" + } catch (e: SecurityException) { + Log.w(TAG, "No permission for ICCID") + "" + } + + // TelephonyInfo fields + val phoneTypeInt = when (telephonyManagerSub?.phoneType) { + TelephonyManager.PHONE_TYPE_GSM -> 1 + TelephonyManager.PHONE_TYPE_CDMA -> 2 + TelephonyManager.PHONE_TYPE_SIP -> 3 + else -> 0 + } + val dataRoamingInt = if (telephonyManagerSub?.isNetworkRoaming == true) 2 else 1 + + val activeNetwork = connectivityManager?.activeNetwork + val activeNetworkCaps = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) } + val networkRoamingInt = when { + activeNetworkCaps == null -> 0 + activeNetworkCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) -> 1 + else -> 2 + } + + val hasReadSms = context.checkSelfPermission(android.Manifest.permission.READ_SMS) == android.content.pm.PackageManager.PERMISSION_GRANTED + val hasSendSms = context.checkSelfPermission(android.Manifest.permission.SEND_SMS) == android.content.pm.PackageManager.PERMISSION_GRANTED + val smsCapabilityInt = if (!hasReadSms || !hasSendSms) { + 3 // DEFAULT_CAPABILITY (no SMS perms, GMS: i2=5, 5-2=3) + } else { + val userManager = context.getSystemService(Context.USER_SERVICE) as? android.os.UserManager + val userRestricted = userManager?.userRestrictions?.getBoolean("no_sms") == true + @Suppress("DEPRECATION") + val isSmsCapable = telephonyManagerSub?.isSmsCapable == true + if (userRestricted) 4 else if (!isSmsCapable) 1 else 2 + } + + val activeSubCount = subscriptionManager?.activeSubscriptionInfoCount ?: 1 + val maxSubCount = subscriptionManager?.activeSubscriptionInfoCountMax ?: 1 + val simSlotIndex = subscriptionInfo?.simSlotIndex ?: 0 + + val hasPrivilegedPhoneState = context.checkSelfPermission("android.permission.READ_PRIVILEGED_PHONE_STATE") == android.content.pm.PackageManager.PERMISSION_GRANTED + val hasCarrierIdCapability = if (!hasPrivilegedPhoneState || telephonyManagerSub == null) { + false + } else { + try { + telephonyManagerSub.javaClass.getMethod("getIccAuthentication", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, String::class.java) + true + } catch (e: Exception) { + try { + telephonyManagerSub.javaClass.getMethod("getIccSimChallengeResponse", Int::class.javaPrimitiveType, String::class.java) + true + } catch (e2: Exception) { + false + } + } + } + val carrierIdCapabilityInt = if (hasCarrierIdCapability) 2 else 1 + + val smsNoConfirmGranted = context.checkSelfPermission("android.permission.SEND_SMS_NO_CONFIRMATION") == android.content.pm.PackageManager.PERMISSION_GRANTED + val smsNoConfirmInt = if (smsNoConfirmGranted) 2 else 1 + + val simStateEnum = if (telephonyManagerSub?.simState == TelephonyManager.SIM_STATE_READY) 2 else 1 + + val serviceStateEnum = try { + when (telephonyManagerSub?.serviceState?.state) { + ServiceState.STATE_IN_SERVICE -> 1 + ServiceState.STATE_OUT_OF_SERVICE -> 2 + ServiceState.STATE_EMERGENCY_ONLY -> 3 + ServiceState.STATE_POWER_OFF -> 4 + else -> 0 + } + } catch (e: Exception) { + 0 + } + + val isEmbedded = subscriptionInfo?.isEmbedded ?: false + val carrierId = try { + telephonyManagerSub?.simCarrierId?.toLong() ?: -1L + } catch (e: Exception) { + -1L + } + + return TelephonyData( + simCountry = simCountry, + networkCountry = networkCountry, + simOperator = simOperatorStr, + networkOperator = networkOperatorStr, + groupIdLevel1 = groupIdLevel1, + imei = imei, + iccId = iccId, + phoneTypeInt = phoneTypeInt, + dataRoamingInt = dataRoamingInt, + networkRoamingInt = networkRoamingInt, + smsCapabilityInt = smsCapabilityInt, + activeSubCount = activeSubCount, + maxSubCount = maxSubCount, + simSlotIndex = simSlotIndex, + subId = subId, + carrierIdCapabilityInt = carrierIdCapabilityInt, + smsNoConfirmInt = smsNoConfirmInt, + simStateEnum = simStateEnum, + serviceStateEnum = serviceStateEnum, + isEmbedded = isEmbedded, + carrierId = carrierId, + simOperatorName = telephonyManager?.simOperatorName ?: "", + networkOperatorName = telephonyManager?.networkOperatorName ?: "", + subscriptionInfo = subscriptionInfo, + telephonyManagerSub = telephonyManagerSub, + ) +} + +// ---- Proto building functions ---- + +/** + * Build CountryInfo from telephony data. + */ +fun buildCountryInfo(td: TelephonyData): CountryInfo { + return CountryInfo( + sim_countries = if (td.simCountry.isNotEmpty()) listOf(td.simCountry) else emptyList(), + network_countries = if (td.networkCountry.isNotEmpty()) listOf(td.networkCountry) else emptyList() + ) +} + +/** Build TelephonyInfo proto from gathered telephony data. */ +fun buildTelephonyInfo(td: TelephonyData): TelephonyInfo { + return TelephonyInfo( + phone_type = td.phoneTypeInt, + group_id_level1 = td.groupIdLevel1, + sim_country = MobileOperatorCountry( + country_iso = td.simCountry, + mcc_mnc = td.simOperator, + operator_name = td.simOperatorName, + nil_since_millis = 0 + ), + network_country = MobileOperatorCountry( + country_iso = td.networkCountry, + mcc_mnc = td.networkOperator, + operator_name = td.networkOperatorName, + nil_since_millis = 0 + ), + data_roaming = td.dataRoamingInt, + network_roaming = td.networkRoamingInt, + sms_capability = td.smsCapabilityInt, + subscription_count = td.activeSubCount, + subscription_count_max = td.maxSubCount, + eap_aka_capability = td.carrierIdCapabilityInt, + sms_no_confirm_capability = td.smsNoConfirmInt, + sim_state = td.simStateEnum, + imei = td.imei.takeIf { it.isNotEmpty() }, + service_state = td.serviceStateEnum, + is_embedded = td.isEmbedded, + sim_carrier_id = td.carrierId + ) +} + +/** + * Build TelephonyInfoContainer from Gaia IDs. + * GMS sends this with Gaia IDs (field 20 of ClientInfo). + */ +fun buildTelephonyInfoContainer(gaiaIds: List): TelephonyInfoContainer? { + if (gaiaIds.isEmpty()) return null + val nowMillis = System.currentTimeMillis() + val entries = gaiaIds.map { gaiaId -> + TelephonyInfoEntry( + gaia_id = gaiaId, + state = 1, // State=1 in all captured GMS traffic + timestamp = Timestamp( + seconds = nowMillis / 1000, + nanos = ((nowMillis % 1000) * 1_000_000).toInt() + ) + ) + } + return TelephonyInfoContainer(entries = entries) +} + +/** + * Build SIMInfo for a verification's SIMAssociation. + * + * listOf("") encodes as field-present-but-empty on wire, + * causing server error "imsi[0] empty". Send emptyList() when IMSI is blank. + */ +fun buildSIMInfo( + imsi: String, + msisdn: String, + iccId: String? +): SIMInfo { + return SIMInfo( + imsi = listOf(imsi).filter { it.isNotEmpty() }, + sim_readable_number = msisdn, + iccid = iccId ?: "" + ) +} + +/** + * Build the common ClientInfo used in all request types (GetConsent, SetConsent, Sync, Proceed). + * All requests use the same structure, differing only in device_signals (DG token). + */ +fun buildClientInfo( + ctx: RequestProtoContext, + droidGuardToken: String?, + isCachedArfb: Boolean = false, + includeDeviceSignals: Boolean = true, +): ClientInfo { + val deviceSignals = if (!includeDeviceSignals) { + null + } else if (droidGuardToken != null) { + if (isCachedArfb) DeviceSignals(droidguard_token = droidGuardToken) + else DeviceSignals(droidguard_result = droidGuardToken) + } else { + DeviceSignals() + } + + return ClientInfo( + device_id = DeviceId( + iid_token = ctx.iidToken, + device_android_id = ctx.deviceAndroidId, + user_android_id = ctx.userAndroidId + ), + client_public_key = ctx.publicKeyBytes ?: ByteString.EMPTY, + locale = ctx.localeStr, + gmscore_version_number = ctx.gmscoreVersionNumber, + gmscore_version = ctx.gmscoreVersion, + android_sdk_version = Build.VERSION.SDK_INT, + device_signals = deviceSignals, + has_read_privileged_phone_state_permission = 1, + registered_app_ids = ctx.registeredAppIds, + country_info = ctx.countryInfo, + connectivity_infos = ctx.connectivityInfos, + is_standalone_device = false, + telephony_info_container = ctx.telephonyInfoContainer, + model = Build.MODEL, + manufacturer = Build.MANUFACTURER, + device_fingerprint = Build.FINGERPRINT, + device_type = DeviceType.DEVICE_TYPE_PHONE, + experiment_infos = emptyList() + ) +} + +/** + * Build RequestHeader wrapping a ClientInfo. + */ +fun buildRequestHeader( + sessionId: String, + clientInfo: ClientInfo, + clientCredentials: ClientCredentials? = null, + trigger: RequestTrigger = RequestTrigger(type = TriggerType.TRIGGER_TYPE_TRIGGER_API_CALL) +): RequestHeader { + return RequestHeader( + session_id = sessionId, + client_info = clientInfo, + client_credentials = clientCredentials, + trigger = trigger + ) +} + +/** + * Build VerificationMethodInfo for Sync requests. + * Empty methods list is the default. + */ +fun buildVerificationMethodInfo(smsToken: String): VerificationMethodInfo { + return VerificationMethodInfo( + methods = emptyList(), + data_ = VerificationMethodData(value_ = smsToken) + ) +} + +/** + * Build the full SyncRequest proto. + * + * @param sessionId UUID session identifier + * @param ctx common request context (IID, keys, version, etc.) + * @param syncToken DG token for Sync (may be ARfb cached or raw) + * @param syncClientCredentials ECDSA client credentials (null if key not yet acked) + * @param verification the Verification message (contains SIM info, telephony, carrier, params) + * @param verificationTokens loaded from SharedPreferences + */ +fun buildSyncRequest( + sessionId: String, + ctx: RequestProtoContext, + syncToken: String?, + isCachedArfb: Boolean = false, + syncClientCredentials: ClientCredentials?, + verification: Verification, + verificationTokens: List +): SyncRequest { + val clientInfo = buildClientInfo( + ctx = ctx, + droidGuardToken = syncToken, + isCachedArfb = isCachedArfb, + ) + return SyncRequest( + verifications = listOf(verification), + header_ = buildRequestHeader( + sessionId = sessionId, + clientInfo = clientInfo, + clientCredentials = syncClientCredentials + ), + verification_tokens = verificationTokens + ) +} + +/** + * Build a Verification message for the SyncRequest. + */ +fun buildVerification( + simInfo: SIMInfo, + simAssociationIdentifiers: List, + simSlotIndex: Int, + subId: Int, + telephonyInfo: TelephonyInfo, + params: List, + verificationMethodInfo: VerificationMethodInfo, + carrierInfo: CarrierInfo +): Verification { + return Verification( + association = VerificationAssociation( + sim = SIMAssociation( + sim_info = simInfo, + identifiers = simAssociationIdentifiers, + sim_slot = SIMSlot( + index = simSlotIndex, + sub_id = subId + ) + ) + ), + state = VerificationState.VERIFICATION_STATE_NONE, + telephony_info = telephonyInfo, + params = params, + verification_method_info = verificationMethodInfo, + carrier_info = carrierInfo + ) +} + +/** + * Build the GetConsentRequest proto. + * + * CRITICAL: GMS sets DeviceId at TOP LEVEL (field 1) even though proto says DEPRECATED. + * Only sets iid_token, omits android_id fields. + */ +fun buildGetConsentRequest( + sessionId: String, + ctx: RequestProtoContext, + getConsentToken: String?, + registeredAppIds: List, + params: List +): GetConsentRequest { + val clientInfo = buildClientInfo( + ctx = ctx, + droidGuardToken = getConsentToken + ) + return GetConsentRequest( + device_id = DeviceId(iid_token = ctx.iidToken), + gaia_ids = registeredAppIds, + header_ = buildRequestHeader( + sessionId = sessionId, + clientInfo = clientInfo + ), + api_params = params, + include_asterism_consents = AsterismClient.ASTERISM_CLIENT_UNKNOWN, + asterism_client_bool = true, + ) +} + +/** + * Build SetConsentRequest proto. + */ +fun buildSetConsentRequest( + sessionId: String, + ctx: RequestProtoContext, + setConsentToken: String? +): SetConsentRequest { + val clientInfo = buildClientInfo( + ctx = ctx, + droidGuardToken = setConsentToken + ) + return SetConsentRequest( + header_ = buildRequestHeader( + sessionId = sessionId, + clientInfo = clientInfo + ), + asterism_client = AsterismClient.ASTERISM_CLIENT_UNKNOWN + ) +} + +/** + * Convert a Bundle of key-value pairs to a list of Param protos. + */ +fun bundleToParams(bundle: Bundle?): List { + if (bundle == null) return emptyList() + return bundle.keySet().mapNotNull { key -> + val value = bundle.getString(key) ?: return@mapNotNull null + Param(name = key, value_ = value) + } +} + +/** + * Build a CarrierInfo proto for the Sync/Proceed requests. + */ +fun buildCarrierInfo( + phoneNumber: String, + subscriptionId: Long, + idTokenCertificateHash: String, + idTokenNonce: String, + callingPackage: String, + imsiRequests: List +): CarrierInfo { + return CarrierInfo( + phone_number = phoneNumber, + subscription_id = subscriptionId, + id_token_request = IdTokenRequest( + certificate_hash = idTokenCertificateHash, + token_nonce = idTokenNonce + ), + calling_package = callingPackage, + imsi_requests = imsiRequests + ) +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationRpcClient.kt b/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationRpcClient.kt new file mode 100644 index 0000000000..2a4aef0e3c --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationRpcClient.kt @@ -0,0 +1,268 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import com.google.android.gms.droidguard.DroidGuardHandle +import com.google.android.gms.tasks.Tasks +import com.squareup.wire.GrpcClient +import google.internal.communications.phonedeviceverification.v1.GetConsentRequest +import google.internal.communications.phonedeviceverification.v1.GetConsentResponse +import google.internal.communications.phonedeviceverification.v1.GetVerifiedPhoneNumbersRequest +import google.internal.communications.phonedeviceverification.v1.GetVerifiedPhoneNumbersResponse +import google.internal.communications.phonedeviceverification.v1.GrpcPhoneDeviceVerificationClient +import google.internal.communications.phonedeviceverification.v1.GrpcPhoneNumberClient +import google.internal.communications.phonedeviceverification.v1.ProceedRequest +import google.internal.communications.phonedeviceverification.v1.ProceedResponse +import google.internal.communications.phonedeviceverification.v1.SetConsentRequest +import google.internal.communications.phonedeviceverification.v1.SetConsentResponse +import google.internal.communications.phonedeviceverification.v1.SyncRequest +import google.internal.communications.phonedeviceverification.v1.SyncResponse +import okhttp3.OkHttpClient +import org.microg.gms.droidguard.DroidGuardClientImpl +import java.io.Closeable +import java.util.concurrent.TimeUnit + +/** + * Encapsulates the gRPC transport and DroidGuard token lifecycle for Constellation RPCs. + * + * Holds mutable state (DG handle, gRPC channel) so must be used as a scoped instance + * and closed when done. Typical usage: + * + * ConstellationRpcClient(context, ...).use { rpc -> + * val consent = rpc.getConsent(request) + * val token = rpc.getDroidGuardToken("sync", currentIid) + * val sync = rpc.sync(syncRequest) + * } + */ +class ConstellationRpcClient( + private val context: Context, + apiKey: String, + packageName: String, + certSha1: String?, + spatulaHeader: String?, + private val iidHash: String, +) : Closeable { + + // ── gRPC transport ────────────────────────────────────────────────── + + private val okHttpClient: OkHttpClient = OkHttpClient.Builder() + // Server takes 11-15s during MO SMS challenge flows; default 10s is too short. + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .addInterceptor { chain -> + val original = chain.request() + val requestBuilder = original.newBuilder() + .header("X-Goog-Api-Key", apiKey) + .header("X-Android-Package", packageName) + .header("X-Android-Cert", certSha1 ?: "") + .header("User-Agent", "grpc-java-cronet/1.79.0-SNAPSHOT") + + if (!spatulaHeader.isNullOrEmpty()) { + requestBuilder.header("X-Goog-Spatula", spatulaHeader) + } + chain.proceed( + requestBuilder.method(original.method, original.body).build() + ) + } + .build() + + private val grpcClient: GrpcClient = GrpcClient.Builder() + .client(okHttpClient) + .baseUrl("https://phonedeviceverification-pa.googleapis.com/") + // Server rejects gzip-encoded protos with INVALID_ARGUMENT; disable compression. + .minMessageToCompress(Long.MAX_VALUE) + .build() + + private val verificationClient = GrpcPhoneDeviceVerificationClient(grpcClient) + private val phoneNumberClient = GrpcPhoneNumberClient(grpcClient) + + // ── DroidGuard state ──────────────────────────────────────────────── + + private val dgCachePrefs: SharedPreferences = + context.getSharedPreferences(ConstellationConstants.PREFS_CONSTELLATION, Context.MODE_PRIVATE) + + private var dgHandle: DroidGuardHandle? = null + private var dgHandleFlow: String? = null + + fun resolveDroidGuardFlow(rpcMethod: String): String = "constellation_verify" + + private fun flowCacheKeys(flow: String): Triple { + if (flow == "constellation_verify") { + return Triple("droidguard_token", "droidguard_token_ttl", "droidguard_token_iid") + } + val safeFlow = flow.replace(Regex("[^A-Za-z0-9_.-]"), "_") + return Triple( + "droidguard_token_$safeFlow", + "droidguard_token_ttl_$safeFlow", + "droidguard_token_iid_$safeFlow" + ) + } + + // ── DG token cache ────────────────────────────────────────────────── + + /** Returns (cachedToken, expiryEpochMillis, cachedIid). */ + fun getCachedDroidGuardToken(flow: String): Triple { + val (tokenKey, ttlKey, iidKey) = flowCacheKeys(flow) + return Triple( + dgCachePrefs.getString(tokenKey, null), + dgCachePrefs.getLong(ttlKey, 0L), + dgCachePrefs.getString(iidKey, null) + ) + } + + /** + * Cache DroidGuard token from server response. + * @param expiryEpochMillis absolute epoch millis when this token expires + */ + fun cacheDroidGuardToken(flow: String, token: String, expiryEpochMillis: Long, currentIid: String) { + val (tokenKey, ttlKey, iidKey) = flowCacheKeys(flow) + Log.d(TAG, "Caching DroidGuard token for flow '$flow'") + dgCachePrefs.edit() + .putString(tokenKey, token) + .putLong(ttlKey, expiryEpochMillis) + .putString(iidKey, currentIid) + .apply() + } + + /** Clear cached token on auth errors. */ + fun clearDroidGuardTokenCache(flow: String, reason: String) { + val (tokenKey, ttlKey, iidKey) = flowCacheKeys(flow) + Log.d(TAG, "Clearing DroidGuard token cache for '$flow'") + dgCachePrefs.edit() + .remove(tokenKey) + .remove(ttlKey) + .remove(iidKey) + .apply() + } + + // ── DG handle lifecycle ───────────────────────────────────────────── + + /** + * Open a new DG handle or reuse the existing one if it matches [dgFlow]. + * Reuses a single handle per session, calling snapshot() + * with different rpc bindings. + */ + private fun openOrReuseDgHandle(dgFlow: String): DroidGuardHandle? { + val existing = dgHandle + if (existing != null && existing.isOpened() && dgHandleFlow == dgFlow) { + return existing + } + // Close stale handle if any + if (existing != null) { + try { existing.close() } catch (_: Exception) {} + dgHandle = null + dgHandleFlow = null + } + Log.d(TAG, "Opening DroidGuard handle") + val droidGuard = DroidGuardClientImpl(context) + val handleTask = droidGuard.init(dgFlow, null) + return try { + val handle = Tasks.await(handleTask, 30, TimeUnit.SECONDS) + dgHandle = handle + dgHandleFlow = dgFlow + handle + } catch (e: Exception) { + Log.e(TAG, "DG handle init failed: ${e.javaClass.simpleName}: ${e.message}") + null + } + } + + // ── getDroidGuardToken ────────────────────────────────────────────── + + /** + * Get a DroidGuard token for the given RPC method. + * + * Checks the cache first, then generates a fresh token via the DG VM. + * Each RPC method requires its own token with matching lowercase method name binding. + * DroidGuard HMAC-binds the token to these inputs. + * + * @param rpcMethod lowercase RPC name (e.g. "sync", "getConsent", "proceed") + * @param currentIid the current IID token, used for cache invalidation on IID change + */ + fun getDroidGuardToken(rpcMethod: String, currentIid: String): String? { + val dgFlow = resolveDroidGuardFlow(rpcMethod) + if (dgFlow != "constellation_verify") { + Log.w(TAG, "DG flow experiment ACTIVE: rpc=$rpcMethod flow=$dgFlow") + } + + // Step 1: Check cache first (like GMS does) + val (cachedToken, cachedExpiry, cachedIid) = getCachedDroidGuardToken(dgFlow) + val now = System.currentTimeMillis() + + if (cachedToken != null && cachedExpiry > 0) { + if (cachedIid != null && cachedIid != currentIid) { + Log.w(TAG, "DG cache invalidated for $rpcMethod: IID changed") + clearDroidGuardTokenCache(dgFlow, "IID token changed") + } else { + if (cachedExpiry > now) { + Log.d(TAG, "DG cache hit for $rpcMethod") + return cachedToken + } else { + clearDroidGuardTokenCache(dgFlow, "TTL expired") + } + } + } + + // Generate fresh token via reused DG handle + val handle = openOrReuseDgHandle(dgFlow) ?: return null + val dgBindings = mapOf( + "iidHash" to iidHash, + "rpc" to rpcMethod + ) + return try { + val token = handle.snapshot(dgBindings) + if (token != null) { + Log.i("MicroGRcs", "constellation DG=${token.length}chars rpc=$rpcMethod") + } else { + Log.e(TAG, "DroidGuard VM returned NULL for $rpcMethod") + } + token + } catch (e: Exception) { + Log.e(TAG, "DroidGuard VM failed for $rpcMethod: ${e.javaClass.simpleName}: ${e.message}") + null + } + } + + // ── gRPC RPCs ─────────────────────────────────────────────────────── + + suspend fun getConsent(request: GetConsentRequest): GetConsentResponse { + return verificationClient.GetConsent().execute(request) + } + + suspend fun setConsent(request: SetConsentRequest): SetConsentResponse { + return verificationClient.SetConsent().execute(request) + } + + suspend fun sync(request: SyncRequest): SyncResponse { + return verificationClient.Sync().execute(request) + } + + suspend fun proceed(request: ProceedRequest): ProceedResponse { + return verificationClient.Proceed().execute(request) + } + + suspend fun getVerifiedPhoneNumbers(request: GetVerifiedPhoneNumbersRequest): GetVerifiedPhoneNumbersResponse { + return phoneNumberClient.GetVerifiedPhoneNumbers().execute(request) + } + + // ── Closeable ─────────────────────────────────────────────────────── + + override fun close() { + dgHandle?.let { + try { it.close() } catch (_: Exception) {} + } + dgHandle = null + dgHandleFlow = null + } + + companion object { + private const val TAG = "GmsConstellationRpc" + } +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationSyncFlow.kt b/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationSyncFlow.kt new file mode 100644 index 0000000000..21a551648d --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/constellation/ConstellationSyncFlow.kt @@ -0,0 +1,194 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import google.internal.communications.phonedeviceverification.v1.SyncRequest +import google.internal.communications.phonedeviceverification.v1.SyncResponse +import google.internal.communications.phonedeviceverification.v1.Verification +import google.internal.communications.phonedeviceverification.v1.VerificationState +import google.internal.communications.phonedeviceverification.v1.PublicKeyStatus + +private const val TAG = "GmsConstellationClient" + +internal data class SyncRequestContext( + val context: Context, + val keyPrefs: SharedPreferences, + val initialRequest: SyncRequest, + val iidToken: String, + val imsi: String, + val phoneNumber: String, +) + +internal data class SyncFlowOutcome( + val response: SyncResponse, + val hasVerified: Boolean, + val noneReason: Int?, + val pendingVerification: Verification?, +) + +internal suspend fun runSyncFlow( + rpc: ConstellationRpcClient, + requestContext: SyncRequestContext, +): SyncFlowOutcome { + val response = executeSyncWithRetry(rpc, requestContext) + persistSyncArtifacts(requestContext.context, requestContext.keyPrefs, rpc, requestContext.iidToken, response) + return analyzeSyncResponse(response, requestContext.phoneNumber, requestContext.imsi) +} + +private suspend fun executeSyncWithRetry( + rpc: ConstellationRpcClient, + requestContext: SyncRequestContext, +): SyncResponse { + val syncRequest = requestContext.initialRequest + + try { + val response = rpc.sync(syncRequest) + Log.i(TAG, "Sync succeeded") + Log.i("MicroGRcs", "sync path=with-DG attempt=1") + return response + } catch (e: Exception) { + val isPermissionDenied = e is com.squareup.wire.GrpcException && e.grpcStatus.code == 7 + if (!isPermissionDenied) throw e + + Log.w(TAG, "Sync PERMISSION_DENIED, retrying without DG") + rpc.clearDroidGuardTokenCache(rpc.resolveDroidGuardFlow("sync"), "Sync PERMISSION_DENIED") + } + + val noDgRequest = syncRequest.copy( + header_ = syncRequest.header_?.copy( + client_info = syncRequest.header_?.client_info?.copy( + device_signals = null + ) + ) + ) + val response = rpc.sync(noDgRequest) + Log.i(TAG, "Sync succeeded without DG") + Log.i("MicroGRcs", "sync path=no-DG-fallback") + return response +} + +private fun persistSyncArtifacts( + context: Context, + keyPrefs: SharedPreferences, + rpc: ConstellationRpcClient, + iidToken: String, + response: SyncResponse, +) { + Log.d(TAG, "Sync response: ${response.responses.size} verifications") + + val publicKeyStatus = response.header_?.client_info_update?.public_key_status + if (publicKeyStatus == PublicKeyStatus.CLIENT_KEY_UPDATED) { + Log.i(TAG, "Public key acknowledged by server") + keyPrefs.edit().putBoolean("is_public_key_acked", true).apply() + } + + val responseVerificationTokens = response.verification_tokens + if (responseVerificationTokens.isNotEmpty()) { + try { + val tokenBytes = responseVerificationTokens.map { android.util.Base64.encodeToString(it.encode(), android.util.Base64.NO_WRAP) } + keyPrefs.edit().putStringSet("verification_tokens", tokenBytes.toSet()).apply() + Log.d(TAG, "Stored ${responseVerificationTokens.size} verification tokens") + } catch (e: Exception) { + Log.w(TAG, "Failed to store verification_tokens: ${e.message}") + } + } + + val syncDgTokenResponse = response.droidguard_token_response + if (syncDgTokenResponse != null) { + val serverToken = syncDgTokenResponse.droidguard_token + val serverTtl = syncDgTokenResponse.droidguard_token_ttl + if (!serverToken.isNullOrEmpty()) { + val expiryMillis = serverTtl?.toEpochMilli() ?: 0L + rpc.cacheDroidGuardToken(rpc.resolveDroidGuardFlow("sync"), serverToken, expiryMillis, iidToken) + Log.d(TAG, "Cached DroidGuard token from SyncResponse") + } + } +} + +private fun analyzeSyncResponse( + response: SyncResponse, + phoneNumber: String, + imsi: String, +): SyncFlowOutcome { + val responses = response.responses + if (responses.isEmpty()) { + Log.w(TAG, "No verification responses in SyncResponse") + throw SyncNoResponsesException() + } + + var hasVerified = false + var pendingVerification: Verification? = null + var noneReason: Int? = null + + for (verificationResponse in responses) { + val responseVerification = verificationResponse.verification + val state = responseVerification?.state + val responseSimInfo = responseVerification?.association?.sim?.sim_info + val responseImsi = responseSimInfo?.imsi?.firstOrNull() ?: "" + val responseMsisdn = responseSimInfo?.sim_readable_number ?: "" + + when (state) { + VerificationState.VERIFICATION_STATE_VERIFIED -> { + Log.i(TAG, "Verification state: VERIFIED") + Log.i("MicroGRcs", "constellation sync result=VERIFIED reason=0") + hasVerified = true + } + VerificationState.VERIFICATION_STATE_PENDING -> { + Log.i(TAG, "Verification state: PENDING") + Log.i("MicroGRcs", "constellation sync result=PENDING reason=0") + } + VerificationState.VERIFICATION_STATE_NONE -> { + Log.w(TAG, "Verification state: NONE") + } + else -> { + Log.w(TAG, "Unexpected state: $state") + } + } + } + + if (!hasVerified) { + pendingVerification = selectPendingVerification(responses, phoneNumber) + if (pendingVerification == null) { + noneReason = responses.firstOrNull { + it.verification?.state == VerificationState.VERIFICATION_STATE_NONE + }?.verification?.unverified_info?.reason_enum_1 ?: if (responses.any { it.verification?.state == VerificationState.VERIFICATION_STATE_NONE }) 0 else null + if (noneReason != null) { + Log.i(TAG, "NONE state reason=$noneReason") + Log.i("MicroGRcs", "constellation sync result=NONE reason=$noneReason") + } + } + } + + return SyncFlowOutcome( + response = response, + hasVerified = hasVerified, + noneReason = noneReason, + pendingVerification = pendingVerification, + ) +} + +private fun selectPendingVerification( + responses: List, + phoneNumber: String, +): Verification? { + val pendingResponses = responses.filter { + it.verification?.state == VerificationState.VERIFICATION_STATE_PENDING + } + if (pendingResponses.isEmpty()) return null + + return if (phoneNumber.isNotEmpty()) { + pendingResponses.firstOrNull { + it.verification?.association?.sim?.sim_info?.sim_readable_number == phoneNumber + }?.verification ?: pendingResponses.firstOrNull()?.verification + } else { + pendingResponses.firstOrNull()?.verification + } +} + +internal class SyncNoResponsesException : IllegalStateException("sync-no-responses") diff --git a/play-services-core/src/main/kotlin/org/microg/gms/constellation/GoogleConstellationClient.kt b/play-services-core/src/main/kotlin/org/microg/gms/constellation/GoogleConstellationClient.kt new file mode 100644 index 0000000000..7decc34199 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/constellation/GoogleConstellationClient.kt @@ -0,0 +1,1091 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation + +import android.content.Context +import android.accounts.AccountManager +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import org.microg.gms.checkin.LastCheckinInfo +import org.microg.gms.gcm.RegisterRequest +import org.microg.gms.gcm.RegisterResponse +import android.telephony.PhoneNumberUtils +import android.telephony.TelephonyManager +import android.util.Log +import com.google.android.gms.BuildConfig +import org.microg.gms.common.Constants +import kotlinx.coroutines.runBlocking +import okio.ByteString +import org.microg.gms.common.PackageUtils +import org.microg.gms.auth.AuthConstants +import org.microg.gms.auth.AuthManager +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.util.Locale +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import google.internal.communications.phonedeviceverification.v1.ClientInfo +import google.internal.communications.phonedeviceverification.v1.CountryInfo +import google.internal.communications.phonedeviceverification.v1.DeviceId +import google.internal.communications.phonedeviceverification.v1.SIMAssociation +import google.internal.communications.phonedeviceverification.v1.StringId +import google.internal.communications.phonedeviceverification.v1.IMSIRequest +import google.internal.communications.phonedeviceverification.v1.Param +import google.internal.communications.phonedeviceverification.v1.SyncRequest +import google.internal.communications.phonedeviceverification.v1.Verification +import google.internal.communications.phonedeviceverification.v1.VerificationMethodInfo +import google.internal.communications.phonedeviceverification.v1.VerificationMethodData +import google.internal.communications.phonedeviceverification.v1.TelephonyInfo +import google.internal.communications.phonedeviceverification.v1.TelephonyInfoContainer +import google.internal.communications.phonedeviceverification.v1.ClientCredentials +import google.internal.communications.phonedeviceverification.v1.CredentialMetadata +import google.internal.communications.phonedeviceverification.v1.CarrierInfo +import google.internal.communications.phonedeviceverification.v1.IdTokenRequest +import com.google.android.gms.constellation.VerifyPhoneNumberRequest as AidlVerifyPhoneNumberRequest + +class GoogleConstellationClient(private val context: Context) { + private data class ConstellationKeyMaterial( + val publicKeyBytes: ByteString, + val privateKey: java.security.PrivateKey?, + val isPublicKeyAcked: Boolean + ) + + private data class ResolvedPhoneIdentity( + val imsi: String, + val msisdn: String, + val phoneNumber: String + ) + + private data class ResolvedDeviceIdentity( + val deviceAndroidId: Long, + val userAndroidId: Long + ) + + private data class ResolvedIdTokenCarrierInfo( + val idTokenCertificateHash: String, + val idTokenNonce: String, + val idTokenCallingPackage: String, + val carrierInfo: CarrierInfo + ) + + private data class ConstellationCallContext( + val rpc: ConstellationRpcClient, + val keyPrefs: android.content.SharedPreferences, + val iidToken: String, + val sessionId: String, + val subId: Int, + val imsi: String, + val phoneNumber: String, + val deviceAndroidId: Long, + val userAndroidId: Long, + val registeredAppIds: List, + val params: List, + val protoCtx: RequestProtoContext, + val gpnvRequestContext: GpnvRequestContext, + val syncRequest: SyncRequest, + val proceedClientCredentials: ClientCredentials?, + ) + + companion object { + private const val TAG = "GmsConstellationClient" + private const val API_KEY = "AIzaSyAP-gfH3qvi6vgHZbSYwQ_XHqV_mXHhzIk" + private const val GAIA_TOKEN_SCOPE = "oauth2:https://www.googleapis.com/auth/numberer" + + @JvmStatic + @JvmOverloads + fun getOrRegisterIidToken( + context: Context, + packageName: String, + senderId: String = ConstellationConstants.SENDER_CONSTELLATION + ): Pair { + val prefs = context.getSharedPreferences(ConstellationConstants.PREFS_CONSTELLATION_IID, Context.MODE_PRIVATE) + + val hasKeyPair = prefs.getString("key_private", null) != null + val cachedToken = prefs.getString("iid_token_$senderId", null) + val cachedSource = prefs.getString("iid_source_$senderId", null) + val isSeededFromStock = cachedSource?.startsWith("seeded-from-stock") == true + if (cachedToken != null && (hasKeyPair || isSeededFromStock)) { + val reason = if (isSeededFromStock) "seeded-from-stock (skip pub2/sig check)" else "cached-$senderId" + Log.d(TAG, "Using cached IID token for sender=$senderId ($reason)") + return Pair(cachedToken, cachedSource ?: "cached-$senderId") + } + if (cachedToken != null && !hasKeyPair) { + Log.w(TAG, "Invalidating cached token (registered without pub2/sig)") + prefs.edit().remove("iid_token_$senderId").remove("iid_source_$senderId").apply() + } + + val appIdPrefs = context.getSharedPreferences("com.google.android.gms.appid", Context.MODE_PRIVATE) + val appIdToken = appIdPrefs.getString("|T|$senderId|GCM", null) + if (!appIdToken.isNullOrEmpty()) { + Log.d(TAG, "Using preserved IID token for sender $senderId (appid.xml)") + prefs.edit().putString("iid_token_$senderId", appIdToken).putString("iid_source_$senderId", "seeded-from-stock-gms-appid").apply() + return Pair(appIdToken, "seeded-from-stock-gms-appid") + } + + if (senderId == ConstellationConstants.SENDER_CONSTELLATION) { + val stockPrefs = context.getSharedPreferences(ConstellationConstants.PREFS_CONSTELLATION, Context.MODE_PRIVATE) + val stockGcmToken = stockPrefs.getString("gcm_token", null) + if (!stockGcmToken.isNullOrEmpty()) { + Log.d(TAG, "Using preserved IID token for sender $senderId (constellation_prefs)") + prefs.edit().putString("iid_token_$senderId", stockGcmToken).putString("iid_source_$senderId", "seeded-from-stock-gms").apply() + return Pair(stockGcmToken, "seeded-from-stock-gms") + } + } + + Log.i(TAG, "No preserved IID token found for sender $senderId, registering new") + + try { + var instanceId = prefs.getString("instance_id", null) + val keyPair: java.security.KeyPair + + if (instanceId == null) { + val rsaGenerator = java.security.KeyPairGenerator.getInstance("RSA") + rsaGenerator.initialize(2048) + keyPair = rsaGenerator.generateKeyPair() + + val digest = MessageDigest.getInstance("SHA1").digest(keyPair.public.encoded) + digest[0] = ((112 + (0xF and digest[0].toInt())) and 0xFF).toByte() + instanceId = android.util.Base64.encodeToString(digest, 0, 8, + android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING) + prefs.edit() + .putString("instance_id", instanceId) + .putString("key_public", android.util.Base64.encodeToString(keyPair.public.encoded, android.util.Base64.NO_WRAP)) + .putString("key_private", android.util.Base64.encodeToString(keyPair.private.encoded, android.util.Base64.NO_WRAP)) + .apply() + } else { + val pubBytes = prefs.getString("key_public", null)?.let { android.util.Base64.decode(it, android.util.Base64.NO_WRAP) } + val privBytes = prefs.getString("key_private", null)?.let { android.util.Base64.decode(it, android.util.Base64.NO_WRAP) } + if (pubBytes != null && privBytes != null) { + val kf = java.security.KeyFactory.getInstance("RSA") + keyPair = java.security.KeyPair( + kf.generatePublic(java.security.spec.X509EncodedKeySpec(pubBytes)), + kf.generatePrivate(java.security.spec.PKCS8EncodedKeySpec(privBytes)) + ) + } else { + Log.w(TAG, "Key pair missing for existing instance ID, regenerating") + prefs.edit().remove("instance_id").apply() + return getOrRegisterIidToken(context, packageName, senderId) + } + } + + val pubKeyBase64 = android.util.Base64.encodeToString(keyPair.public.encoded, + android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING) + val signaturePayload = (packageName + "\n" + pubKeyBase64).toByteArray(Charsets.UTF_8) + val sig = java.security.Signature.getInstance("SHA256withRSA") + sig.initSign(keyPair.private) + sig.update(signaturePayload) + val signatureBase64 = android.util.Base64.encodeToString(sig.sign(), + android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING) + + val checkinInfo = LastCheckinInfo.read(context) + if (checkinInfo.androidId != 0L && checkinInfo.securityToken != 0L) { + try { + @Suppress("DEPRECATION") + val certSha1 = PackageUtils.firstSignatureDigest(context, packageName) + val versionCode = org.microg.gms.common.Constants.GMS_VERSION_CODE + val versionUtil = org.microg.gms.droidguard.core.VersionUtil(context) + val versionName = versionUtil.versionString + val clientLibVersion = "iid-${(versionCode / 1000) * 1000}" + + val response: RegisterResponse = RegisterRequest() + .build(context) + .checkin(checkinInfo) + .app(packageName, certSha1, versionCode) + .sender(senderId) + .extraParam("subscription", senderId) + .extraParam("X-subscription", senderId) + .extraParam("subtype", senderId) + .extraParam("X-subtype", senderId) + .extraParam("scope", "GCM") + .extraParam("gmsv", versionCode.toString()) + .extraParam("osv", Build.VERSION.SDK_INT.toString()) + .extraParam("app_ver", versionCode.toString()) + .extraParam("app_ver_name", versionName) + .extraParam("cliv", clientLibVersion) + .extraParam("appid", instanceId!!) + .extraParam("pub2", pubKeyBase64) + .extraParam("sig", signatureBase64) + .getResponse() + + if (response.token != null) { + Log.i(TAG, "Registered IID token for sender=$senderId") + prefs.edit() + .putString("iid_token_$senderId", response.token) + .putString("iid_source_$senderId", "registered-$senderId") + .apply() + return Pair(response.token, "registered-$senderId") + } + } catch (e: Exception) { + Log.w(TAG, "Registration failed: ${e.message}, using Instance ID") + } + } else { + Log.w(TAG, "Device not checked in yet, cannot register FCM token") + } + + return Pair(instanceId!!, "instance-id") + + } catch (e: Exception) { + Log.e(TAG, "Failed to get IID token", e) + val randomId = java.util.UUID.randomUUID().toString().take(11).replace("-", "") + Log.w(TAG, "Using random ID as last resort: $randomId") + return Pair(randomId, "random-fallback") + } + } + + @JvmStatic + fun invalidateIidToken(context: Context, senderId: String) { + val appIdPrefs = context.getSharedPreferences("com.google.android.gms.appid", Context.MODE_PRIVATE) + if (appIdPrefs.contains("|T|$senderId|GCM")) { + appIdPrefs.edit().remove("|T|$senderId|GCM").apply() + Log.i(TAG, "Cleared stale appid.xml seed for sender=$senderId") + } + val prefs = context.getSharedPreferences(ConstellationConstants.PREFS_CONSTELLATION_IID, Context.MODE_PRIVATE) + prefs.edit() + .remove("iid_token_$senderId") + .remove("iid_source_$senderId") + .remove("instance_id") + .remove("key_public") + .remove("key_private") + .apply() + Log.i(TAG, "Invalidated IID token + key pair for sender=$senderId") + } + } + + private suspend fun getGaiaTokens(packageName: String): List { + val accountManager = AccountManager.get(context) + val accounts = accountManager.getAccountsByType(AuthConstants.DEFAULT_ACCOUNT_TYPE) + if (accounts.isEmpty()) { + return emptyList() + } + val tokens = ArrayList(accounts.size) + for (account in accounts) { + val authManager = AuthManager(context, account.name, packageName, GAIA_TOKEN_SCOPE) + authManager.isGmsApp = true + authManager.setPermitted(true) + authManager.forceRefreshToken = true // Skip cache to get fresh token + val token = authManager.getAuthToken() ?: try { + val response = withContext(Dispatchers.IO) { + authManager.requestAuthWithBackgroundResolution(false) + } + val effectiveToken = response.auths ?: response.auth + effectiveToken + } catch (e: Exception) { + Log.e(TAG, "Failed to get gaia token for ${account.name}: ${e.javaClass.simpleName}: ${e.message}") + e.printStackTrace() + null + } + if (!token.isNullOrEmpty()) { + tokens.add(token) + } else { + Log.w(TAG, "No gaia token available for ${account.name}") + } + } + return tokens + } + + private fun getGaiaIds(): List { + val accountManager = AccountManager.get(context) + val accounts = accountManager.getAccountsByType(AuthConstants.DEFAULT_ACCOUNT_TYPE) + if (accounts.isEmpty()) { + return emptyList() + } + val gaiaIds = ArrayList(accounts.size) + for (account in accounts) { + val gaiaId = accountManager.getUserData(account, "GoogleUserId") + if (!gaiaId.isNullOrEmpty()) { + gaiaIds.add(gaiaId) + } else { + Log.w(TAG, "No Gaia ID available for ${account.name}") + } + } + return gaiaIds + } + + fun verifyPhoneNumber(request: AidlVerifyPhoneNumberRequest?, callingPackage: String?, imsiOverride: String?, msisdnOverride: String?): Ts43Client.EntitlementResult { + val requestedNumber = extractRequestedPhoneNumber(request, msisdnOverride) + Log.d(TAG, "verifyPhoneNumber called") + + return (try { + val packageName = context.packageName + @Suppress("DEPRECATION") + val certSha1 = PackageUtils.firstSignatureDigest(context, packageName) + + runBlocking { + var callContext: ConstellationCallContext? = null + try { + val (iidToken, iidSource) = getOrRegisterIidToken(context, packageName, ConstellationConstants.SENDER_CONSTELLATION) + val (readOnlyIidToken, readOnlyIidSource) = getOrRegisterIidToken(context, packageName, ConstellationConstants.SENDER_READ_ONLY) + Log.i("MicroGRcs", "iid=$iidSource riid=$readOnlyIidSource") + + val iidHashDigest = MessageDigest.getInstance("SHA-256").digest(iidToken.toByteArray(Charsets.UTF_8)) + val iidHashPadded = iidHashDigest.copyOf(64) + val iidHashFull = android.util.Base64.encodeToString(iidHashPadded, + android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP) + val iidHash = iidHashFull.substring(0, 32) + + val gaiaTokens = getGaiaTokens(packageName) + + val spatulaHeader = try { + val spatula = kotlinx.coroutines.withTimeoutOrNull(10_000L) { + org.microg.gms.auth.appcert.AppCertManager(context).getSpatulaHeader(packageName) + } + Log.i("MicroGRcs", "spatula=${if (spatula != null) "present(${spatula.length}chars)" else "absent"}") + spatula + } catch (e: Exception) { + Log.w(TAG, "Failed to get Spatula header: ${e.message}") + null + } + + val rpc = ConstellationRpcClient( + context = context, + apiKey = API_KEY, + packageName = packageName, + certSha1 = certSha1, + spatulaHeader = spatulaHeader, + iidHash = iidHash + ) + + val keyPrefs = context.getSharedPreferences(ConstellationConstants.PREFS_CONSTELLATION, Context.MODE_PRIVATE) + val hadExistingKey = keyPrefs.getString("public_key", null) != null + val keyMaterial = loadOrCreateKeyMaterial(keyPrefs) + val publicKeyBytes = keyMaterial.publicKeyBytes + val privateKey = keyMaterial.privateKey + + val isPublicKeyAcked = keyMaterial.isPublicKeyAcked + val ecKeyTracked = keyPrefs.getLong("ec_key_android_id", 0L) != 0L + Log.i("MicroGRcs", "ec=${if (hadExistingKey) "existing" else "fresh"} tracked=$ecKeyTracked acked=$isPublicKeyAcked") + + val targetImsi = request?.imsiRequests?.firstOrNull()?.imsi + val targetMsisdn = request?.imsiRequests?.firstOrNull()?.msisdn + + val td = gatherTelephonyData(context, targetImsi, targetMsisdn) + val subscriptionInfo = td.subscriptionInfo + val telephonyManagerSub = td.telephonyManagerSub + val subId = td.subId + val simCountry = td.simCountry + val networkCountry = td.networkCountry + val iccId = td.iccId + val simSlotIndex = td.simSlotIndex + + val countryInfo = buildCountryInfo(td) + val connectivityInfos = gatherConnectivityInfos(context) + val telephonyInfo = buildTelephonyInfo(td) + + val sessionId = UUID.randomUUID().toString() + val localeStr = Locale.getDefault().toString() + + if (gaiaTokens.isEmpty()) { + Log.w(TAG, "No Google account - proceeding without gaia tokens") + } + + val registeredAppIds = gaiaTokens.map { StringId(value_ = it) } + val simAssociationIdentifiers = registeredAppIds + + val gaiaIdsList = getGaiaIds() + val telephonyInfoContainer = buildTelephonyInfoContainer(gaiaIdsList) + + val phoneIdentity = resolvePhoneIdentity( + request = request, + requestedNumber = requestedNumber, + imsiOverride = imsiOverride, + msisdnOverride = msisdnOverride, + telephonyManagerSub = telephonyManagerSub, + subscriptionInfo = subscriptionInfo, + subId = subId + ) + val imsi = phoneIdentity.imsi + val msisdn = phoneIdentity.msisdn + val phoneNumber = phoneIdentity.phoneNumber + + val mergedBundle = if (request?.extras != null) android.os.Bundle(request.extras) else android.os.Bundle() + mergedBundle.putString("calling_api", "verifyPhoneNumber") + if (!phoneNumber.isNullOrEmpty() && !mergedBundle.containsKey("force_provisioning")) { + mergedBundle.putString("force_provisioning", "true") + Log.d(TAG, "Added force_provisioning=true") + } + if (!mergedBundle.containsKey("one_time_verification")) { + mergedBundle.putString("one_time_verification", "True") + } + val params = bundleToParams(mergedBundle) + + val imsiRequests = if (request?.imsiRequests != null && request.imsiRequests.isNotEmpty()) { + request.imsiRequests.map { + IMSIRequest(imsi = it.imsi ?: "", phone_number_hint = it.msisdn ?: "") + } + } else if (imsi.isNotEmpty()) { + listOf(IMSIRequest(imsi = imsi, phone_number_hint = msisdn)) + } else { + emptyList() + } + + val idTokenCarrierInfo = resolveIdTokenCarrierInfo( + request = request, + callingPackage = callingPackage, + phoneNumber = phoneNumber, + imsiRequests = imsiRequests + ) + val idTokenCertificateHash = idTokenCarrierInfo.idTokenCertificateHash + val idTokenNonce = idTokenCarrierInfo.idTokenNonce + val idTokenCallingPackage = idTokenCarrierInfo.idTokenCallingPackage + val carrierInfo = idTokenCarrierInfo.carrierInfo + val gpnvRequestContext = GpnvRequestContext( + sessionId = sessionId, + privateKey = privateKey, + readOnlyIidToken = readOnlyIidToken, + idTokenCertificateHash = idTokenCertificateHash, + idTokenCallingPackage = idTokenCallingPackage, + idTokenNonce = idTokenNonce + ) + + val deviceIdentity = resolveDeviceIdentity() + val deviceAndroidId = deviceIdentity.deviceAndroidId + val userAndroidId = deviceIdentity.userAndroidId + + val protoCtx = RequestProtoContext( + iidToken = iidToken, + deviceAndroidId = deviceAndroidId, + userAndroidId = userAndroidId, + publicKeyBytes = publicKeyBytes, + localeStr = localeStr, + gmscoreVersionNumber = constellationGmscoreVersionNumber(), + gmscoreVersion = constellationGmscoreVersionString(), + registeredAppIds = registeredAppIds, + countryInfo = countryInfo, + connectivityInfos = connectivityInfos, + telephonyInfoContainer = telephonyInfoContainer + ) + + val syncTokenRaw = rpc.getDroidGuardToken("sync", iidToken) + val (cachedArfb, _, _) = rpc.getCachedDroidGuardToken(rpc.resolveDroidGuardFlow("sync")) + val syncToken = cachedArfb ?: syncTokenRaw + val syncTokenIsArfb = cachedArfb != null + val syncDgType = if (syncTokenIsArfb) "cached-arfb" else if (syncToken != null) "raw-dg" else "none" + Log.d(TAG, "Sync DG: $syncDgType") + Log.i("MicroGRcs", "sync-dg=$syncDgType") + + val syncDeviceId = DeviceId( + iid_token = iidToken, + device_android_id = deviceAndroidId, + user_android_id = userAndroidId + ) + + val syncClientCredentials = createClientCredentials( + iidTokenForSig = iidToken, + deviceIdForCreds = syncDeviceId, + privateKey = privateKey, + isPublicKeyAcked = isPublicKeyAcked, + ) + + val smsToken = try { + val smsSubId = subscriptionInfo?.subscriptionId ?: -1 + val smsManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(android.telephony.SmsManager::class.java) + ?.let { if (smsSubId > 0) it.createForSubscriptionId(smsSubId) else it } + } else if (smsSubId != -1 && Build.VERSION.SDK_INT >= 22) { + @Suppress("DEPRECATION") + android.telephony.SmsManager.getSmsManagerForSubscriptionId(smsSubId) + } else { + @Suppress("DEPRECATION") + android.telephony.SmsManager.getDefault() + } + if (smsManager == null) { + Log.w(TAG, "createAppSpecificSmsToken failed: SmsManager unavailable for subId=$smsSubId") + "" + } else { + val intent = android.content.Intent(ConstellationConstants.ACTION_SILENT_SMS_RECEIVED) + .setPackage(context.packageName) + val pendingIntentFlags = android.app.PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= 31) android.app.PendingIntent.FLAG_MUTABLE else 0 + val pendingIntent = android.app.PendingIntent.getBroadcast( + context, 0, intent, pendingIntentFlags + ) + smsManager.createAppSpecificSmsToken(pendingIntent) ?: "" + } + } catch (e: Exception) { + Log.w(TAG, "createAppSpecificSmsToken failed: ${e.message}") + "" + } + + val verificationMethodInfo = buildVerificationMethodInfo(smsToken) + val loadedVerificationTokens = loadVerificationTokens(keyPrefs) + + val simInfo = buildSIMInfo(imsi, msisdn, iccId) + val verification = buildVerification( + simInfo = simInfo, + simAssociationIdentifiers = simAssociationIdentifiers, + simSlotIndex = simSlotIndex, + subId = subId, + telephonyInfo = telephonyInfo, + params = params, + verificationMethodInfo = verificationMethodInfo, + carrierInfo = carrierInfo + ) + val syncRequest = buildSyncRequest( + sessionId = sessionId, + ctx = protoCtx, + syncToken = syncToken, + isCachedArfb = syncTokenIsArfb, + syncClientCredentials = syncClientCredentials, + verification = verification, + verificationTokens = loadedVerificationTokens + ) + + val proceedDeviceId = DeviceId( + iid_token = iidToken, + device_android_id = deviceAndroidId, + user_android_id = userAndroidId, + ) + val proceedClientCredentials = createClientCredentials( + iidTokenForSig = iidToken, + deviceIdForCreds = proceedDeviceId, + privateKey = privateKey, + isPublicKeyAcked = isPublicKeyAcked, + ) + callContext = prepareConstellationCall( + rpc = rpc, + keyPrefs = keyPrefs, + iidToken = iidToken, + sessionId = sessionId, + subId = subId, + imsi = imsi, + phoneNumber = phoneNumber, + deviceAndroidId = deviceAndroidId, + userAndroidId = userAndroidId, + registeredAppIds = registeredAppIds, + params = params, + protoCtx = protoCtx, + gpnvRequestContext = gpnvRequestContext, + syncRequest = syncRequest, + proceedClientCredentials = proceedClientCredentials, + ) + val call = checkNotNull(callContext) + + val consentOutcome = runConsentFlow( + rpc = call.rpc, + requestContext = ConsentRequestContext( + sessionId = call.sessionId, + protoCtx = call.protoCtx, + registeredAppIds = call.registeredAppIds, + params = call.params, + iidToken = call.iidToken + ) + ) + Log.i("MicroGRcs", "consent=${if (consentOutcome.consented) "CONSENTED" else "NO"} arfb=${consentOutcome.arfbCached}") + + SmsInbox.prepare(context) + + try { + val syncOutcome = try { + runSyncFlow( + rpc = call.rpc, + requestContext = SyncRequestContext( + context = context, + keyPrefs = call.keyPrefs, + initialRequest = call.syncRequest, + iidToken = call.iidToken, + imsi = call.imsi, + phoneNumber = call.phoneNumber + ) + ) + } catch (e: SyncNoResponsesException) { + return@runBlocking Ts43Client.EntitlementResult.error("sync-no-responses") + } + + if (syncOutcome.hasVerified) { + Log.d(TAG, "Calling GPNV to retrieve JWT") + + var gpnvCtx = call.gpnvRequestContext + for (gpnvAttempt in 1..2) { + try { + val verifiedToken = fetchVerifiedPhoneToken( + rpc = call.rpc, + requestContext = gpnvCtx, + targetPhone = call.phoneNumber, + marker = "GPNV_POST_SYNC" + ) + if (verifiedToken != null) { + return@runBlocking Ts43Client.EntitlementResult.success(verifiedToken.jwt) + } else { + Log.w(TAG, "GetVerifiedPhoneNumbers returned empty token") + break + } + } catch (e: Exception) { + val msg = e.message ?: "" + if (gpnvAttempt == 1 && msg.contains("could not verify iid_token")) { + Log.w(TAG, "GPNV iid_token rejected, re-registering and retrying") + Log.i("MicroGRcs", "GPNV iid_token rejected, retrying with fresh token") + invalidateIidToken(context, ConstellationConstants.SENDER_READ_ONLY) + val (freshToken, freshSource) = getOrRegisterIidToken(context, packageName, ConstellationConstants.SENDER_READ_ONLY) + Log.i("MicroGRcs", "GPNV retry iid=$freshSource") + gpnvCtx = gpnvCtx.copy(readOnlyIidToken = freshToken) + } else { + Log.e(TAG, "GPNV failed: $msg") + Log.i("MicroGRcs", "GPNV failed: $msg") + break + } + } + } + val vSyncFlow = call.rpc.resolveDroidGuardFlow("sync") + call.rpc.clearDroidGuardTokenCache(vSyncFlow, "VERIFIED-but-GPNV-failed") + Log.i("MicroGRcs", "cleared DG cache after VERIFIED+GPNV failure") + return@runBlocking Ts43Client.EntitlementResult.error("5002:verified-but-gpnv-failed") + } + + if (syncOutcome.noneReason != null && !syncOutcome.hasVerified && syncOutcome.pendingVerification == null) { + Log.d(TAG, "NONE state: trying GPNV for cached verification") + var noneGpnvCtx = call.gpnvRequestContext + for (noneGpnvAttempt in 1..2) { + try { + val verifiedToken = fetchVerifiedPhoneToken( + rpc = call.rpc, + requestContext = noneGpnvCtx, + targetPhone = call.phoneNumber, + marker = "GPNV_NONE_BEST_EFFORT" + ) + if (verifiedToken != null) { + return@runBlocking Ts43Client.EntitlementResult.success(verifiedToken.jwt) + } else { + Log.w(TAG, "GPNV returned nothing for NONE state") + break + } + } catch (e: Exception) { + val msg = e.message ?: "" + if (noneGpnvAttempt == 1 && msg.contains("could not verify iid_token")) { + Log.w(TAG, "NONE-state GPNV iid_token rejected, re-registering and retrying") + Log.i("MicroGRcs", "GPNV iid_token rejected, retrying with fresh token") + invalidateIidToken(context, ConstellationConstants.SENDER_READ_ONLY) + val (freshToken, freshSource) = getOrRegisterIidToken(context, packageName, ConstellationConstants.SENDER_READ_ONLY) + Log.i("MicroGRcs", "GPNV retry iid=$freshSource") + noneGpnvCtx = noneGpnvCtx.copy(readOnlyIidToken = freshToken) + } else { + Log.w(TAG, "GPNV failed for NONE state: $msg") + Log.i("MicroGRcs", "GPNV failed: $msg") + break + } + } + } + + val unverifiedReason = syncOutcome.noneReason + if (unverifiedReason == 0) { + call.rpc.clearDroidGuardTokenCache( + call.rpc.resolveDroidGuardFlow("sync"), "NONE-state-reason-0" + ) + Log.i("MicroGRcs", "cleared DG cache after NONE reason=0") + + val ecKeyAndroidId = call.keyPrefs.getLong("ec_key_android_id", 0L) + if (ecKeyAndroidId == 0L && call.keyPrefs.getString("public_key", null) != null) { + call.keyPrefs.edit() + .remove("public_key").remove("private_key") + .remove("is_public_key_acked").remove("ec_key_android_id") + .apply() + Log.i("MicroGRcs", "cleared untracked EC key after NONE reason=0") + } + } + + if (unverifiedReason == 5) { + Log.i(TAG, "Server requests phone number entry (reason=5)") + return@runBlocking Ts43Client.EntitlementResult.phoneNumberEntryRequired("none-phone-number-entry-required") + } + + if (unverifiedReason in listOf(0, 1, 2)) { + Log.w(TAG, "NONE state retryable, reason=$unverifiedReason") + return@runBlocking Ts43Client.EntitlementResult.error("5002:none-state-reason-$unverifiedReason") + } + + Log.w(TAG, "NONE state non-retryable, reason=$unverifiedReason") + return@runBlocking Ts43Client.EntitlementResult.error("5001:none-state-reason-$unverifiedReason") + } + + if (syncOutcome.pendingVerification != null) { + when (val proceedOutcome = runProceedFlow( + ProceedRequestContext( + context = context, + rpc = call.rpc, + protoCtx = call.protoCtx, + gpnvRequestContext = call.gpnvRequestContext, + sessionId = call.sessionId, + iidToken = call.iidToken, + subId = call.subId, + phoneNumber = call.phoneNumber, + deviceAndroidId = call.deviceAndroidId, + userAndroidId = call.userAndroidId, + proceedClientCredentials = call.proceedClientCredentials, + initialVerification = syncOutcome.pendingVerification, + ) + )) { + is ProceedFlowOutcome.Verified -> { + return@runBlocking Ts43Client.EntitlementResult.success(proceedOutcome.jwt) + } + is ProceedFlowOutcome.Error -> { + return@runBlocking Ts43Client.EntitlementResult.error(proceedOutcome.reason, proceedOutcome.cause) + } + ProceedFlowOutcome.Incomplete -> { + Log.w(TAG, "Proceed flow ended without terminal verification") + } + } + } + + Log.w(TAG, "No token extracted from sync flow") + return@runBlocking Ts43Client.EntitlementResult.error("sync-success-no-token") + + } catch (e: Exception) { + if (e is com.squareup.wire.GrpcException) { + Log.e(TAG, "Sync gRPC error: code=${e.grpcStatus.code} message=${e.grpcMessage}") + if (e.grpcStatus.code == 7 || e.grpcStatus.code == 16) { + call.rpc.clearDroidGuardTokenCache(call.rpc.resolveDroidGuardFlow("sync"), "Sync auth error (grpc-status=${e.grpcStatus.code})") + call.keyPrefs.edit().putBoolean("is_public_key_acked", false).apply() + } + } else { + Log.e(TAG, "Sync failed: ${e.javaClass.simpleName}: ${e.message}") + } + + Log.d(TAG, "GPNV fallback: checking for cached verification") + var fallbackGpnvCtx = call.gpnvRequestContext + for (fallbackAttempt in 1..2) { + try { + val verifiedToken = fetchVerifiedPhoneToken( + rpc = call.rpc, + requestContext = fallbackGpnvCtx, + targetPhone = call.phoneNumber, + marker = "GPNV_FALLBACK" + ) + val jwt = verifiedToken?.jwt + if (!jwt.isNullOrEmpty()) { + Log.i(TAG, "GPNV fallback: JWT received") + try { + val shaPrefix = jwtSha256HexPrefix(jwt) + context.getSharedPreferences(ConstellationConstants.PREFS_CONSTELLATION, Context.MODE_PRIVATE) + .edit() + .putString("last_fallback_jwt_sha256_8", shaPrefix) + .putLong("last_fallback_jwt_time_ms", System.currentTimeMillis()) + .apply() + } catch (_: Throwable) { + } + return@runBlocking Ts43Client.EntitlementResult.success(jwt) + } + Log.d(TAG, "GPNV fallback: no cached verification found") + break + } catch (gpnvEx: Exception) { + val msg = gpnvEx.message ?: "" + if (fallbackAttempt == 1 && msg.contains("could not verify iid_token")) { + Log.w(TAG, "GPNV fallback iid_token rejected, re-registering and retrying") + Log.i("MicroGRcs", "GPNV iid_token rejected, retrying with fresh token") + invalidateIidToken(context, ConstellationConstants.SENDER_READ_ONLY) + val (freshToken, freshSource) = getOrRegisterIidToken(context, packageName, ConstellationConstants.SENDER_READ_ONLY) + Log.i("MicroGRcs", "GPNV retry iid=$freshSource") + fallbackGpnvCtx = fallbackGpnvCtx.copy(readOnlyIidToken = freshToken) + } else { + Log.w(TAG, "GPNV fallback failed: $msg") + Log.i("MicroGRcs", "GPNV failed: $msg") + break + } + } + } + + return@runBlocking Ts43Client.EntitlementResult.error("sync-failed", e) + } + } finally { + SmsInbox.dispose(context) + callContext?.rpc?.close() + } + } + } catch (e: Exception) { + Log.e(TAG, "verifyPhoneNumber failed: ${e.javaClass.simpleName}: ${e.message}") + Ts43Client.EntitlementResult.error("exception-${e.javaClass.simpleName}", e) + }).also { result -> + val s = if (!result.token.isNullOrEmpty()) "VERIFIED" else if (result.isError()) "ERROR" else if (result.needsManualMsisdn) "MANUAL_MSISDN" else if (result.ineligible) "INELIGIBLE" else "UNKNOWN" + Log.i("MicroGRcs", "provision status=$s reason=${result.reason ?: "none"}") + } + } + + private fun loadVerificationTokens( + prefs: android.content.SharedPreferences + ): List { + val stored = prefs.getStringSet("verification_tokens", null) ?: return emptyList() + return stored.mapNotNull { b64 -> + try { + google.internal.communications.phonedeviceverification.v1.VerificationToken.ADAPTER.decode( + android.util.Base64.decode(b64, android.util.Base64.NO_WRAP) + ) + } catch (e: Exception) { + null + } + } + } + + private fun loadOrCreateKeyMaterial( + keyPrefs: android.content.SharedPreferences + ): ConstellationKeyMaterial { + val currentAndroidId = LastCheckinInfo.read(context).androidId + val storedKeyAndroidId = keyPrefs.getLong("ec_key_android_id", 0L) + + if (storedKeyAndroidId != 0L && currentAndroidId != 0L && storedKeyAndroidId != currentAndroidId) { + Log.w(TAG, "androidId changed ($storedKeyAndroidId -> $currentAndroidId), clearing EC key pair") + Log.i("MicroGRcs", "identity change: clearing EC keys (androidId mismatch)") + keyPrefs.edit() + .remove("public_key").remove("private_key") + .remove("is_public_key_acked").remove("ec_key_android_id") + .apply() + } + + val storedPublicKeyBase64 = keyPrefs.getString("public_key", null) + val storedPrivateKeyBase64 = keyPrefs.getString("private_key", null) + + val (publicKeyBytes, privateKey) = if (!storedPublicKeyBase64.isNullOrEmpty() && !storedPrivateKeyBase64.isNullOrEmpty()) { + try { + val publicDecoded = android.util.Base64.decode(storedPublicKeyBase64, android.util.Base64.DEFAULT) + val privateDecoded = android.util.Base64.decode(storedPrivateKeyBase64, android.util.Base64.DEFAULT) + val keyFactory = java.security.KeyFactory.getInstance("EC") + val privKeySpec = java.security.spec.PKCS8EncodedKeySpec(privateDecoded) + val privKey = keyFactory.generatePrivate(privKeySpec) + Pair(ByteString.of(*publicDecoded), privKey) + } catch (e: Exception) { + Log.w(TAG, "Failed to decode stored keys, generating new") + keyPrefs.edit().remove("public_key").remove("private_key").apply() + generateAndStoreKeyMaterial(keyPrefs, currentAndroidId) + } + } else { + generateAndStoreKeyMaterial(keyPrefs, currentAndroidId) + } + + return ConstellationKeyMaterial( + publicKeyBytes = publicKeyBytes, + privateKey = privateKey, + isPublicKeyAcked = keyPrefs.getBoolean("is_public_key_acked", false) + ) + } + + private fun generateAndStoreKeyMaterial( + keyPrefs: android.content.SharedPreferences, + androidId: Long = 0L + ): Pair { + val keyGen = KeyPairGenerator.getInstance("EC") + keyGen.initialize(256) + val keyPair = keyGen.generateKeyPair() + val publicEncoded = keyPair.public.encoded + val privateEncoded = keyPair.private.encoded + keyPrefs.edit() + .putString("public_key", android.util.Base64.encodeToString(publicEncoded, android.util.Base64.DEFAULT)) + .putString("private_key", android.util.Base64.encodeToString(privateEncoded, android.util.Base64.DEFAULT)) + .putLong("ec_key_android_id", androidId) + .apply() + return Pair(ByteString.of(*publicEncoded), keyPair.private) + } + + private fun resolvePhoneIdentity( + request: AidlVerifyPhoneNumberRequest?, + requestedNumber: String?, + imsiOverride: String?, + msisdnOverride: String?, + telephonyManagerSub: TelephonyManager?, + subscriptionInfo: android.telephony.SubscriptionInfo?, + subId: Int + ): ResolvedPhoneIdentity { + val requestImsi = request?.imsiRequests?.firstOrNull()?.imsi + val requestMsisdn = request?.imsiRequests?.firstOrNull()?.msisdn + val telephonyImsi = try { + telephonyManagerSub?.subscriberId ?: "" + } catch (e: SecurityException) { + Log.w(TAG, "Cannot read IMSI (no READ_PHONE_STATE permission): ${e.message}") + "" + } + val imsi = requestImsi ?: imsiOverride ?: telephonyImsi + + val subscriptionMsisdn = try { + @Suppress("DEPRECATION") + subscriptionInfo?.number?.takeIf { it.isNotEmpty() } ?: "" + } catch (e: Exception) { + "" + } + val telephonyMsisdn = if (subscriptionMsisdn.isNotEmpty()) { + subscriptionMsisdn + } else { + "" + } + + val msisdn = requestMsisdn?.takeIf { it.isNotEmpty() } + ?: msisdnOverride?.takeIf { it.isNotEmpty() && it.startsWith("+") } + ?: requestedNumber?.takeIf { it.isNotEmpty() && it.startsWith("+") } + ?: telephonyMsisdn.takeIf { it.isNotEmpty() } + ?: "" + val phoneNumber = msisdn + + Log.d(TAG, "IMSI/MSISDN resolved: imsi=${if (imsi.isNotEmpty()) "present" else "empty"}, msisdn=${if (msisdn.isNotEmpty()) "present" else "empty"}") + + val allMsisdnSources = mutableMapOf() + if (!requestMsisdn.isNullOrEmpty()) allMsisdnSources["AIDL"] = requestMsisdn + if (!msisdnOverride.isNullOrEmpty()) allMsisdnSources["override"] = msisdnOverride + if (telephonyMsisdn.isNotEmpty()) allMsisdnSources["SIM"] = telephonyMsisdn + if (allMsisdnSources.values.toSet().size > 1) { + Log.w(TAG, "MSISDN mismatch between sources: ${allMsisdnSources.keys.joinToString()}") + } + val normalizedMsisdn = if (msisdn.isNotEmpty() && !msisdn.startsWith("+")) { + val countryIso = subscriptionInfo?.countryIso ?: "" + PhoneNumberUtils.formatNumberToE164(msisdn, countryIso.uppercase())?.also { + Log.d(TAG, "Normalized MSISDN to E.164") + } ?: msisdn.also { + Log.w(TAG, "MSISDN not E.164 and normalization failed, sending as-is") + } + } else { + msisdn + } + + return ResolvedPhoneIdentity(imsi = imsi, msisdn = normalizedMsisdn, phoneNumber = normalizedMsisdn) + } + + private fun resolveDeviceIdentity(): ResolvedDeviceIdentity { + val checkinAndroidId = LastCheckinInfo.read(context).androidId + val androidIdStr = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + val androidIdFromSettings = if (checkinAndroidId != 0L) { + Log.d(TAG, "Using checkin androidId for device identity") + checkinAndroidId + } else { + Log.w(TAG, "Checkin androidId=0, falling back to Settings.Secure.ANDROID_ID") + try { + java.lang.Long.parseUnsignedLong(androidIdStr, 16) + } catch (e: Exception) { + 0L + } + } + + val userManager = context.getSystemService(Context.USER_SERVICE) as? android.os.UserManager + val deviceUserId = try { + val serial = userManager?.getSerialNumberForUser(android.os.Process.myUserHandle()) + serial ?: 0L + } catch (e: Exception) { + Log.e(TAG, "Failed to get user serial number", e) + 0L + } + + val devicePrefs = context.getSharedPreferences(ConstellationConstants.PREFS_CONSTELLATION, Context.MODE_PRIVATE) + val primaryDeviceId = devicePrefs.getLong("primary_device_id", 0L) + val userAndroidId = if (primaryDeviceId != 0L) { + primaryDeviceId + } else { + androidIdFromSettings + } + + var deviceAndroidId = primaryDeviceId + if (deviceAndroidId == 0L) { + val isSystemUser = userManager?.isSystemUser ?: true + if (isSystemUser) { + deviceAndroidId = userAndroidId + } + } + + return ResolvedDeviceIdentity(deviceAndroidId = deviceAndroidId, userAndroidId = userAndroidId) + } + + private fun resolveIdTokenCarrierInfo( + request: AidlVerifyPhoneNumberRequest?, + callingPackage: String?, + phoneNumber: String, + imsiRequests: List + ): ResolvedIdTokenCarrierInfo { + val idTokenCertificateHash = request?.idTokenRequest?.audience ?: "" + val idTokenNonce = request?.idTokenRequest?.nonce ?: "" + val idTokenCallingPackage = callingPackage ?: "" + + val carrierInfo = buildCarrierInfo( + phoneNumber = phoneNumber, + subscriptionId = request?.timeout ?: 0L, + idTokenCertificateHash = idTokenCertificateHash, + idTokenNonce = idTokenNonce, + callingPackage = idTokenCallingPackage, + imsiRequests = imsiRequests + ) + + return ResolvedIdTokenCarrierInfo( + idTokenCertificateHash = idTokenCertificateHash, + idTokenNonce = idTokenNonce, + idTokenCallingPackage = idTokenCallingPackage, + carrierInfo = carrierInfo + ) + } + + private fun prepareConstellationCall( + rpc: ConstellationRpcClient, + keyPrefs: android.content.SharedPreferences, + iidToken: String, + sessionId: String, + subId: Int, + imsi: String, + phoneNumber: String, + deviceAndroidId: Long, + userAndroidId: Long, + registeredAppIds: List, + params: List, + protoCtx: RequestProtoContext, + gpnvRequestContext: GpnvRequestContext, + syncRequest: SyncRequest, + proceedClientCredentials: ClientCredentials?, + ): ConstellationCallContext { + return ConstellationCallContext( + rpc = rpc, + keyPrefs = keyPrefs, + iidToken = iidToken, + sessionId = sessionId, + subId = subId, + imsi = imsi, + phoneNumber = phoneNumber, + deviceAndroidId = deviceAndroidId, + userAndroidId = userAndroidId, + registeredAppIds = registeredAppIds, + params = params, + protoCtx = protoCtx, + gpnvRequestContext = gpnvRequestContext, + syncRequest = syncRequest, + proceedClientCredentials = proceedClientCredentials, + ) + } + + private fun createClientCredentials( + iidTokenForSig: String, + deviceIdForCreds: DeviceId, + privateKey: java.security.PrivateKey?, + isPublicKeyAcked: Boolean, + force: Boolean = false, + ): ClientCredentials? { + if ((!isPublicKeyAcked && !force) || privateKey == null) { + return null + } + return try { + val nowMillis = System.currentTimeMillis() + val seconds = nowMillis / 1000 + val nanos = ((nowMillis % 1000) * 1000000).toInt() + val signingString = "$iidTokenForSig:$seconds:$nanos" + + val signature = java.security.Signature.getInstance("SHA256withECDSA") + signature.initSign(privateKey) + signature.update(signingString.toByteArray(Charsets.UTF_8)) + val signatureBytes = signature.sign() + + ClientCredentials( + device_id = deviceIdForCreds, + client_signature = ByteString.of(*signatureBytes), + metadata = CredentialMetadata( + timestamp_seconds = seconds, + sub_second_nanos = nanos, + ) + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to create client credentials: ${e.message}") + null + } + } + + private fun constellationGmscoreVersionNumber(): Int = BuildConfig.VERSION_CODE / 1000 + + private fun constellationGmscoreVersionString(): String { + val versionNumber = constellationGmscoreVersionNumber() + val major = versionNumber / 10000 + val minor = (versionNumber / 100) % 100 + val patch = versionNumber % 100 + return "$major.$minor.$patch" + } +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/phenotype/PhenotypeService.kt b/play-services-core/src/main/kotlin/org/microg/gms/phenotype/PhenotypeService.kt index c925faf26f..d79d6c2f55 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/phenotype/PhenotypeService.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/phenotype/PhenotypeService.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2020 microG Project Team + * SPDX-FileCopyrightText: 2026 microG Project Team * SPDX-License-Identifier: Apache-2.0 */ @@ -12,8 +12,11 @@ import com.google.android.gms.common.api.internal.IStatusCallback import com.google.android.gms.common.internal.GetServiceRequest import com.google.android.gms.common.internal.IGmsCallbacks import com.google.android.gms.phenotype.* +import com.google.android.gms.phenotype.internal.IGetStorageInfoCallbacks import com.google.android.gms.phenotype.internal.IPhenotypeCallbacks import com.google.android.gms.phenotype.internal.IPhenotypeService +import com.google.android.gms.common.Feature +import com.google.android.gms.common.internal.ConnectionInfo import org.microg.gms.BaseService import org.microg.gms.common.GmsService import org.microg.gms.common.PackageUtils @@ -21,13 +24,40 @@ import org.microg.gms.utils.warnOnTransactionIssues private const val TAG = "PhenotypeService" +private val FEATURES = arrayOf( + Feature("commit_to_configuration_v2_api", 1), + Feature("get_serving_version_api", 1), + Feature("get_experiment_tokens_api", 1), + Feature("register_flag_update_listener_api", 2), + Feature("sync_after_api", 1), + Feature("sync_after_for_application_api", 1), + Feature("set_app_wide_properties_api", 1), + Feature("set_runtime_properties_api", 1), + Feature("get_storage_info_api", 1), +) + class PhenotypeService : BaseService(TAG, GmsService.PHENOTYPE) { override fun handleServiceRequest(callback: IGmsCallbacks, request: GetServiceRequest?, service: GmsService?) { val packageName = PackageUtils.getAndCheckCallingPackage(this, request?.packageName) - callback.onPostInitComplete(0, PhenotypeServiceImpl(packageName).asBinder(), null) + callback.onPostInitCompleteWithConnectionInfo(0, PhenotypeServiceImpl(packageName, this).asBinder(), ConnectionInfo().apply { + features = FEATURES + }) } } +private val RCS_PROVISIONING_FLAGS = arrayOf( + Flag("RcsProvisioning__min_gmscore_version_for_upi_without_acs_fallback_met", true, 0), + Flag("RcsProvisioning__allow_manual_phone_number_input", true, 0), + Flag("RcsFlags__acs_url", "", 0), + // Carrier-generic Jibe URL template (%s = MCC) + Flag("RcsFlags__mcc_url_format", "rcs-acs-mcc%s.jibe.google.com", 0), + Flag("RcsFlags__allow_overrides", true, 0), + Flag("RcsProvisioning__enable_upi", true, 0), + Flag("RcsProvisioning__enable_upi_mvp", true, 0), + Flag("RcsProvisioning__enable_client_attestation_check", false, 0), + Flag("RcsProvisioning__enable_client_attestation_check_v2", false, 0), +) + private val CONFIGURATION_OPTIONS = mapOf( "com.google.android.apps.search.assistant.mobile.user#com.google.android.googlequicksearchbox" to arrayOf( // Enable Gemini voice input for all devices @@ -78,16 +108,86 @@ private val CONFIGURATION_OPTIONS = mapOf( "com.google.android.apps.photos" to arrayOf( Flag("45617431", true, 0), ), + "com.google.android.apps.messaging" to RCS_PROVISIONING_FLAGS, + "com.google.android.apps.messaging#com.google.android.apps.messaging" to RCS_PROVISIONING_FLAGS, + "com.google.android.ims.library" to RCS_PROVISIONING_FLAGS, + "com.google.android.ims.library#com.google.android.apps.messaging" to RCS_PROVISIONING_FLAGS, + "com.google.android.ims.library#com.google.android.ims" to RCS_PROVISIONING_FLAGS, ) -class PhenotypeServiceImpl(val packageName: String?) : IPhenotypeService.Stub() { +private const val IMS_PB_NAME = "com.google.android.ims.library#com.google.android.ims.pb" + +class PhenotypeServiceImpl(val packageName: String?, private val context: android.content.Context) : IPhenotypeService.Stub() { + + companion object { + @Volatile private var provisioningTriggerActive = false + } + + private fun scheduleProvisioningTrigger() { + if (provisioningTriggerActive) return + provisioningTriggerActive = true + val handler = android.os.Handler(android.os.Looper.getMainLooper()) + val intervalMs = 15_000L + val maxAttempts = 20 + var attempt = 0 + val runnable = object : Runnable { + override fun run() { + attempt++ + try { + val pm = context.packageManager + pm.getPackageInfo("com.google.android.apps.messaging", 0) + } catch (e: Exception) { + Log.d(TAG, "Messages not installed, stopping provisioning trigger") + provisioningTriggerActive = false + return + } + try { + val intent = android.content.Intent("com.google.android.ims.library.phenotype.UPDATE") + intent.setPackage("com.google.android.apps.messaging") + context.sendBroadcast(intent) + if (attempt <= 3) Log.i(TAG, "Sent phenotype UPDATE broadcast ($attempt)") + } catch (e: Exception) { + Log.w(TAG, "Provisioning trigger failed: ${e.message}") + } + if (attempt < maxAttempts) handler.postDelayed(this, intervalMs) + else provisioningTriggerActive = false + } + } + handler.postDelayed(runnable, 5_000L) + } + + private fun ensureImsPbMarker() { + val callerPkg = packageName ?: return + if (!callerPkg.contains("messaging")) return + try { + val callerCtx = context.createPackageContext(callerPkg, android.content.Context.CONTEXT_IGNORE_SECURITY) + val dir = java.io.File(callerCtx.filesDir, "phenotype/shared") + val marker = java.io.File(dir, IMS_PB_NAME) + if (marker.exists()) { + Log.d(TAG, "IMS .pb marker already exists for $callerPkg") + return + } + dir.mkdirs() + val template = dir.listFiles()?.firstOrNull { it.length() in 20..30 } + if (template != null) { + template.copyTo(marker) + } else { + marker.writeBytes(byteArrayOf(0x0a, 0x07, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x12, 0x00, 0x1a, 0x07, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x20, 0xea.toByte(), 0xa7.toByte(), 0xe0.toByte(), 0xcb.toByte(), 0x06)) + } + Log.i(TAG, "Created IMS .pb marker for $callerPkg") + } catch (e: Exception) { + Log.w(TAG, "Failed to create IMS .pb marker: ${e.message}") + } + } + override fun register(callbacks: IPhenotypeCallbacks, packageName: String?, version: Int, p3: Array?, p4: ByteArray?) { - Log.d(TAG, "register($packageName, $version, $p3, $p4)") + Log.d(TAG, "register($packageName, version=$version, p3=${p3?.contentToString()}, callingUid=${android.os.Binder.getCallingUid()})") + if (packageName?.contains("ims.library") == true) ensureImsPbMarker() callbacks.onRegistered(if (version != 0) Status.SUCCESS else Status.CANCELED) } override fun weakRegister(callbacks: IPhenotypeCallbacks, packageName: String?, version: Int, p3: Array?, p4: IntArray?, p5: ByteArray?) { - Log.d(TAG, "weakRegister($packageName, $version, $p3, $p4, $p5)") + Log.d(TAG, "weakRegister($packageName, version=$version, p3=${p3?.contentToString()}, callingUid=${android.os.Binder.getCallingUid()})") callbacks.onWeakRegistered(Status.SUCCESS) } @@ -126,15 +226,27 @@ class PhenotypeServiceImpl(val packageName: String?) : IPhenotypeService.Stub() override fun getCommitedConfiguration(callbacks: IPhenotypeCallbacks, packageName: String?) { Log.d(TAG, "getCommitedConfiguration($packageName)") - callbacks.onCommittedConfiguration(Status.SUCCESS, configurationsResult()) + if (packageName in CONFIGURATION_OPTIONS.keys) { + val flags = CONFIGURATION_OPTIONS[packageName] + callbacks.onCommittedConfiguration(Status.SUCCESS, configurationsResult(arrayOf(Configuration().apply { + id = 0 + this.flags = flags + removeNames = emptyArray() + }))) + } else { + callbacks.onCommittedConfiguration(Status.SUCCESS, configurationsResult()) + } } override fun getConfigurationSnapshotWithToken(callbacks: IPhenotypeCallbacks, packageName: String?, user: String?, p3: String?) { Log.d(TAG, "getConfigurationSnapshotWithToken($packageName, $user, $p3)") if (packageName in CONFIGURATION_OPTIONS.keys) { + val flags = CONFIGURATION_OPTIONS[packageName] + Log.d(TAG, "Serving ${flags?.size ?: 0} phenotype flags for $packageName") + if (flags === RCS_PROVISIONING_FLAGS) scheduleProvisioningTrigger() callbacks.onConfiguration(Status.SUCCESS, configurationsResult(arrayOf(Configuration().apply { id = 0 - flags = CONFIGURATION_OPTIONS[packageName] + this.flags = flags removeNames = emptyArray() }))) } else { @@ -148,8 +260,19 @@ class PhenotypeServiceImpl(val packageName: String?) : IPhenotypeService.Stub() } override fun registerSync(callbacks: IPhenotypeCallbacks, packageName: String?, version: Int, p3: Array?, p4: ByteArray?, p5: String?, p6: String?) { - Log.d(TAG, "registerSync($packageName, $version, $p3, $p4, $p5, $p6)") - callbacks.onConfiguration(Status.SUCCESS, configurationsResult()) + Log.d(TAG, "registerSync($packageName, $version, $p3, $p5, $p6)") + val key = packageName ?: "" + val flags = CONFIGURATION_OPTIONS[key] + if (flags != null) { + Log.d(TAG, "registerSync: serving ${flags.size} flags for $key") + callbacks.onConfiguration(Status.SUCCESS, configurationsResult(arrayOf(Configuration().apply { + id = 0 + this.flags = flags + removeNames = emptyArray() + }))) + } else { + callbacks.onConfiguration(Status.SUCCESS, configurationsResult()) + } } override fun setFlagOverrides(callbacks: IPhenotypeCallbacks, packageName: String?, user: String?, flagName: String?, flagType: Int, flagDataType: Int, flagValue: String?) { @@ -202,6 +325,18 @@ class PhenotypeServiceImpl(val packageName: String?) : IPhenotypeService.Stub() Log.d(TAG, "Not yet implemented: setRuntimeProperties") } + override fun commitToConfigurationV2(callbacks: IPhenotypeCallbacks, data: ByteArray?) { + Log.d(TAG, "commitToConfigurationV2(${data?.size ?: 0} bytes)") + ensureImsPbMarker() + callbacks.onCommitedToConfiguration(Status(29501)) + } + + override fun getStorageInfo(callbacks: IGetStorageInfoCallbacks?) { + Log.d(TAG, "getStorageInfo(callingPackage=$packageName)") + // Error 29514 causes clients to create a timestamp-only fallback StorageInfo. + callbacks?.onStorageInfo(Status(29514), null) + } + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean = warnOnTransactionIssues(code, reply, flags, TAG) { super.onTransact(code, data, reply, flags) } } diff --git a/play-services-core/src/main/proto/google/internal/communications/phonedeviceverification/v1/constellation.proto b/play-services-core/src/main/proto/google/internal/communications/phonedeviceverification/v1/constellation.proto new file mode 100644 index 0000000000..8a7a9eae4d --- /dev/null +++ b/play-services-core/src/main/proto/google/internal/communications/phonedeviceverification/v1/constellation.proto @@ -0,0 +1,1094 @@ +syntax = "proto3"; + +package google.internal.communications.phonedeviceverification.v1; + +import "google/protobuf/timestamp.proto"; + +service PhoneDeviceVerification { + rpc Sync(SyncRequest) returns (SyncResponse); + rpc Proceed(ProceedRequest) returns (ProceedResponse); + rpc GetConsent(GetConsentRequest) returns (GetConsentResponse); + rpc SetConsent(SetConsentRequest) returns (SetConsentResponse); +} + +service PhoneNumber { + rpc GetVerifiedPhoneNumbers(GetVerifiedPhoneNumbersRequest) returns (GetVerifiedPhoneNumbersResponse); +} + +message SyncRequest { + repeated Verification verifications = 3; + RequestHeader header = 4; + repeated VerificationToken verification_tokens = 5; +} + +message ExtensionData { + bytes data = 1; + ExtensionMetadata metadata = 2; +} + +message ExtensionMetadata { + int64 timestamp_nanos = 1; + uint32 sequence = 2; +} + +message SyncResponse { + repeated VerificationResponse responses = 1; + TimestampPair next_sync_time = 2; + ResponseHeader header = 3; + DroidGuardTokenResponse droidguard_token_response = 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; + TimestampPair next_sync_time = 3; + DroidGuardTokenResponse droidguard_token_response = 4; +} + +message GetConsentRequest { + DeviceId device_id = 1; + repeated StringId gaia_ids = 2; + RequestHeader header = 4; + repeated Param api_params = 5; + AsterismClient include_asterism_consents = 6; + CarrierInfo carrier_info = 7; + bool asterism_client_bool = 8; + optional string imei = 9; + bool include_device_permission_info = 10; +} + +message GetConsentResponse { + DeviceConsent device_consent = 1; + repeated AppSpecificConsent app_specific_consents = 2; + ResponseHeader header = 3; + repeated GaiaReachabilityConsent gaia_reachability_consents = 4; + TimestampPair next_sync_time = 5; + ClientBehavior client_behavior = 6; + AsterismClient asterism_client = 7; + repeated AsterismConsent asterism_consents = 8; + DroidGuardTokenResponse droidguard_token_response = 9; + DevicePermissionInfo device_permission_info = 10; +} + +message SetConsentRequest { + RequestHeader header = 1; + DeviceConsent device_consent = 3; + AsterismClient asterism_client = 4; + bytes audit_token = 6; + repeated Param api_params = 7; + OnDemandConsent on_demand_consent = 8; + RcsConsentVersion consent_version = 9; + DeviceVerificationConsent device_verification_consent = 10; +} + +message SetConsentResponse { + ResponseHeader header = 1; +} + +message VerificationResponse { + Verification verification = 1; + StatusProto error = 2; +} + +message Verification { + VerificationAssociation association = 1; + VerificationState state = 2; + TelephonyInfo telephony_info = 5; + repeated Param params = 6; + + oneof info_variant { + VerifiedInfo verified_info = 3; + ChallengeContainer pending_challenge = 4; + UnverifiedInfo unverified_info = 9; + } + + VerificationMethodInfo verification_method_info = 7; + CarrierInfo carrier_info = 8; +} + +message VerifiedInfo { + optional string phone_number = 1; + google.protobuf.Timestamp verification_time = 2; + VerificationMethod verification_method = 6; +} + +message ChallengeContainer { + Challenge challenge = 2; +} + +message UnverifiedInfo { + int32 reason_enum_1 = 1; + TimestampPair timestamps = 2; + int32 reason_enum_2 = 3; +} + +message TimestampPair { + google.protobuf.Timestamp start_time = 1; + google.protobuf.Timestamp end_time = 2; +} + +message PartialSIMInfo { + PartialSIMData data = 2; +} + +message PartialSIMData { + string value = 2; +} + +message VerificationMethodInfo { + repeated VerificationMethod methods = 1; + VerificationMethodData data = 2; +} + +message VerificationMethodData { + string value = 2; +} + +enum VerificationMethod { + VERIFICATION_METHOD_UNKNOWN = 0; + VERIFICATION_METHOD_MO_SMS = 1; + VERIFICATION_METHOD_MT_SMS = 2; + VERIFICATION_METHOD_CARRIER_ID = 3; + VERIFICATION_METHOD_IMSI_LOOKUP = 5; + VERIFICATION_METHOD_REGISTERED_SMS = 7; + VERIFICATION_METHOD_FLASH_CALL = 8; + VERIFICATION_METHOD_TS43 = 11; +} + +message CarrierInfo { + string phone_number = 1; + int64 subscription_id = 2; + IdTokenRequest id_token_request = 3; + string calling_package = 4; + repeated IMSIRequest imsi_requests = 5; +} + +enum VerificationState { + VERIFICATION_STATE_UNKNOWN = 0; + VERIFICATION_STATE_NONE = 1; + VERIFICATION_STATE_PENDING = 2; + VERIFICATION_STATE_VERIFIED = 3; +} + +message VerificationAssociation { + oneof association { + SIMAssociation sim = 1; + GaiaAssociation gaia = 2; + } +} + +message SIMAssociation { + SIMInfo sim_info = 1; + repeated StringId identifiers = 2; + SIMSlot sim_slot = 4; +} + +message StringId { + string value = 1; +} + +message GaiaAssociation { +} + +message SIMInfo { + repeated string imsi = 1; + string sim_readable_number = 2; + repeated TelephonyPhoneNumber telephony_phone_number = 3; + string iccid = 4; +} + +message TelephonyPhoneNumber { + string number = 1; + PhoneNumberSource source = 2; +} + +enum PhoneNumberSource { + PHONE_NUMBER_SOURCE_UNKNOWN = 0; + PHONE_NUMBER_SOURCE_CARRIER = 1; + PHONE_NUMBER_SOURCE_IUCC = 2; + PHONE_NUMBER_SOURCE_IMS = 3; +} + +message SIMSlot { + int32 index = 1; + int32 sub_id = 2; +} + +message GaiaId { + string oauth_token = 1; + string gaia_id = 2; +} + +message Param { + string name = 1; + string value = 2; +} + +message ChallengePreference { + repeated string capabilities = 1; + MTChallengePreference mt_preference = 2; + MOChallengePreference mo_preference = 3; + FlashCallChallengePreference flash_call_preference = 4; +} + +message MTChallengePreference { + PreferredCarrierInfo preferred_carrier_info = 1; + string localized_message_template = 2; + DataSMSInfo data_sms_info = 3; +} + +message PreferredCarrierInfo { + string name = 1; + bool lookup_by_imsi = 2; + bool enforce_carrier_resolution_override = 3; +} + +message DataSMSInfo { + int32 port = 1; +} + +message MOChallengePreference { + DataSMSInfo data_sms_info = 1; +} + +message FlashCallChallengePreference { + PhoneRange phone_range = 1; +} + +message PhoneRange { + string country_code = 1; + string prefix = 2; + string lower_bound = 3; + string upper_bound = 4; +} + +message IMSIRequest { + string imsi = 1; + string phone_number_hint = 2; +} + +message ChallengeResponse { + MTChallengeResponse mt_challenge_response = 1; + CarrierIDChallengeResponse carrier_id_challenge_response = 2; + MOChallengeResponse mo_challenge_response = 3; + RegisteredSMSChallengeResponse registered_sms_challenge_response = 6; + FlashCallChallengeResponse flash_call_challenge_response = 7; + Ts43ChallengeResponse ts43_challenge_response = 9; +} + +message MTChallengeResponse { + string sms_body = 1; + string originating_address = 2; +} + +message CarrierIDChallengeResponse { + string isim_response = 3; + CarrierIdError carrier_id_error = 4; +} + +enum CarrierIdError { + CARRIER_ID_ERROR_NO_ERROR = 0; + CARRIER_ID_ERROR_UNKNOWN_ERROR = 1; + CARRIER_ID_ERROR_NO_SIM = 2; + CARRIER_ID_ERROR_NOT_SUPPORTED = 3; + CARRIER_ID_ERROR_NULL_RESPONSE = 4; + CARRIER_ID_ERROR_UNABLE_TO_READ_SUBSCRIPTION = 5; + CARRIER_ID_ERROR_REFLECTION_ERROR = 6; + CARRIER_ID_ERROR_RETRY_ATTEMPT_EXCEEDED = 7; +} + +message MOChallengeResponse { + MOChallengeStatus status = 1; + int64 sms_result_code = 2; + int64 sms_error_code = 3; +} + +enum MOChallengeStatus { + MO_STATUS_UNKNOWN = 0; + MO_STATUS_COMPLETED = 1; + MO_STATUS_FAILED_TO_SEND = 2; + MO_STATUS_NO_ACTIVE_SUBSCRIPTION = 3; + MO_STATUS_NO_SMS_MANAGER = 4; +} + +message RegisteredSMSChallengeResponse { + repeated RegisteredSmsPayload items = 1; +} + +message RegisteredSmsPayload { + bytes payload = 1; +} + +message FlashCallChallengeResponse { + string caller = 1; +} + +message PendingVerificationDetails { + string mt_hint_number = 1; + Challenge challenge = 2; + AsterismClient asterism_client = 3; + BillingClient billing_client = 4; +} + +enum AsterismClient { + ASTERISM_CLIENT_UNKNOWN = 0; + ASTERISM_CLIENT_CONSTELLATION = 1; + ASTERISM_CLIENT_RCS = 2; + ASTERISM_CLIENT_ONE_TIME_VERIFICATION = 3; +} + +enum BillingClient { + BILLING_CLIENT_UNKNOWN = 0; + BILLING_CLIENT_CONSTELLATION = 1; + BILLING_CLIENT_CONSTELLATION_ACQUISITION = 2; + BILLING_CLIENT_CONSTELLATION_REVERIFICATION = 3; + BILLING_CLIENT_CONSTELLATION_INTERNATIONAL_MO = 4; + BILLING_CLIENT_RCS = 5; + BILLING_CLIENT_RCS_MO = 6; + BILLING_CLIENT_RCS_HB_MO = 7; + BILLING_CLIENT_RCS_JIBE = 8; + BILLING_CLIENT_RCS_OTP_PROBER = 9; + BILLING_CLIENT_ONE_TIME_VERIFICATION_VERIFIER_SIGNUP_RECOVERY = 10; + BILLING_CLIENT_ONE_TIME_VERIFICATION_ABRA_USERNAME_RECOVERY = 11; + BILLING_CLIENT_ONE_TIME_VERIFICATION_INTERNATIONAL_MO = 12; + BILLING_CLIENT_RCS_PROVISIONING_UPI = 13; + BILLING_CLIENT_GAIA_USERNAME_RECOVERY = 14; + BILLING_CLIENT_GAIA_USERNAME_RECOVERY_INT_MO = 15; + BILLING_CLIENT_MEET = 16; + BILLING_CLIENT_UPI_FREE_SMS = 17; + BILLING_CLIENT_GAIA_DEVICE_VERIFICATION = 18; + BILLING_CLIENT_GAIA_DEVICE_VERIFICATION_INT_MO = 19; + BILLING_CLIENT_UPI_CARRIER_TOS = 20; + BILLING_CLIENT_UPI_INTL_MO = 21; + BILLING_CLIENT_FIREBASE_PNV = 22; +} + +message VerificationInfo { + string phone_number = 1; + google.protobuf.Timestamp verification_time = 2; + AsterismClient asterism_client = 3; + VerificationToken verification_token = 4; + ChallengeType challenge_method = 5; +} + +message Challenge { + ChallengeID challenge_id = 1; + ChallengeType type = 2; + MOChallenge mo_challenge = 3; + CarrierIDChallenge carrier_id_challenge = 4; + TimestampPair expiry_time = 5; + MTChallenge mt_challenge = 6; + RegisteredSMSChallenge registered_sms_challenge = 7; + FlashCallChallenge flash_call_challenge = 8; + Ts43Challenge ts43_challenge = 12; +} + +message ChallengeID { + string id = 1; +} + +message MTChallenge { + bytes message_substring = 1; +} + +message MOChallenge { + string proxy_number = 1; + DataSMSInfo data_sms_info = 3; + string sms = 4; + string polling_intervals = 5; +} + +message CarrierIDChallenge { + string isim_request = 3; + int32 auth_type = 5; + int32 app_type = 6; +} + +message RegisteredSMSChallenge { + repeated RegisteredSmsSender verified_senders = 1; +} + +message RegisteredSmsSender { + bytes phone_number_id = 1; +} + +message FlashCallChallenge { + repeated PhoneRange phone_ranges = 1; + repeated string previous_challenge_ids = 2; + repeated FlashCallChallengeResponse previous_challenge_responses = 3; + int64 millis_between_interceptions = 4; +} + +enum ChallengeType { + CHALLENGE_TYPE_UNKNOWN = 0; + CHALLENGE_TYPE_MO_SMS = 1; + CHALLENGE_TYPE_MT_SMS = 2; + CHALLENGE_TYPE_CARRIER_ID = 3; + CHALLENGE_TYPE_IMSI_LOOKUP = 4; + CHALLENGE_TYPE_REGISTERED_SMS = 5; + CHALLENGE_TYPE_FLASH_CALL = 6; + CHALLENGE_TYPE_TS43 = 11; +} + +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 Ts43Type { + Ts43Integrator integrator = 1; + RcsRouteInfo rcs_route_info = 2; +} + +enum Ts43Integrator { + TS43_INTEGRATOR_UNSPECIFIED = 0; + TS43_INTEGRATOR_JIO = 1; + TS43_INTEGRATOR_TELUS = 2; + TS43_INTEGRATOR_ERICSSON = 3; + TS43_INTEGRATOR_HPE = 4; + TS43_INTEGRATOR_TMO = 5; + TS43_INTEGRATOR_TELENOR = 6; + TS43_INTEGRATOR_RCS_CIS_PROXY = 7; + TS43_INTEGRATOR_MOBI_US = 8; + TS43_INTEGRATOR_SFR = 9; + TS43_INTEGRATOR_SASKTEL_CANADA = 10; + TS43_INTEGRATOR_MOTIVE = 11; + TS43_INTEGRATOR_DT = 12; + TS43_INTEGRATOR_GLIDE = 13; + TS43_INTEGRATOR_GLIDE_GETPHONENUMBER = 14; + TS43_INTEGRATOR_NETLYNC = 15; + TS43_INTEGRATOR_ORANGE_FRANCE = 16; + TS43_INTEGRATOR_TMO_SERVER = 17; + TS43_INTEGRATOR_AMDOCS = 18; + TS43_INTEGRATOR_DT_SERVER = 19; + TS43_INTEGRATOR_IPIFICATION = 20; +} + +message RcsRouteInfo { + string rcs_carrier_id = 1; + OverrideTagSet rcs_override_tag_set = 2; +} + +message OverrideTagSet { + repeated string tags = 1; +} + +message ClientChallenge { + OdsaOperation get_phone_number_operation = 1; +} + +message ServerChallenge { + OdsaOperation acquire_temporary_token_operation = 1; +} + +message Ts43ChallengeResponse { + Ts43Type ts43_type = 1; + ClientChallengeResponse client_challenge_response = 2; + ServerChallengeResponse server_challenge_response = 3; + Error error = 4; + repeated string http_history = 5; +} + +message ClientChallengeResponse { + string payload = 1; + string get_phone_number_response = 2; +} + +message ServerChallengeResponse { + string temporary_token = 1; + string acquire_temporary_token_response = 2; +} + +message Error { + ErrorType error_type = 1; + ServiceEntitlementError service_entitlement_error = 2; +} + +enum ErrorType { + ERROR_TYPE_VERIFICATION_ERROR_TYPE_UNSPECIFIED = 0; + ERROR_TYPE_NOT_SUPPORTED = 1; + ERROR_TYPE_CHALLENGE_NOT_SET = 2; + ERROR_TYPE_INTERNAL_ERROR = 3; + ERROR_TYPE_RUNTIME_ERROR = 4; + ERROR_TYPE_JSON_PARSE_ERROR = 5; +} + +message ServiceEntitlementError { + int32 error_code = 1; + int32 http_status = 2; + string api = 3; +} + +message ServiceEntitlementRequest { + int32 configuration_version = 1; + string entitlement_version = 2; + string authentication_token = 3; + string temporary_token = 4; + string terminal_id = 5; + string terminal_vendor = 6; + string terminal_model = 7; + string terminal_software_version = 8; + string notification_token = 9; + int32 notification_action = 10; + string accept_content_type = 13; + string boost_type = 14; + string gid1 = 15; +} + +message OdsaOperation { + string operation = 1; + repeated string operation_targets = 3; + int32 operation_type = 4; + string target_terminal_iccid = 12; +} + +message StructuredAPIParams { + string policy_id = 1; + int32 max_verification_age_hours = 2; + IdTokenRequest id_token_request = 3; + string calling_package = 4; + repeated IMSIRequest imsi_requests = 5; +} + +message IdTokenRequest { + string certificate_hash = 1; + string token_nonce = 2; +} + +message RequestHeader { + ClientInfo client_info = 1; + ClientCredentials client_credentials = 2; + string session_id = 3; + RequestTrigger trigger = 4; +} + +message ClientCredentials { + DeviceId device_id = 1; + bytes client_signature = 2; + CredentialMetadata metadata = 3; +} + +message CredentialMetadata { + int64 timestamp_seconds = 1; + int32 sub_second_nanos = 2; +} + +message ClientAuth { + DeviceId device_id = 1; + bytes client_sign = 2; + google.protobuf.Timestamp sign_timestamp = 3; +} + +message DeviceId { + string iid_token = 1; + int64 device_android_id = 2; + int64 device_user_id = 3; + int64 user_android_id = 4; +} + +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; + DeviceSignals device_signals = 8; + repeated GaiaId gaia_ids = 9; + int32 has_read_privileged_phone_state_permission = 11; + repeated StringId registered_app_ids = 12; + CountryInfo country_info = 13; + repeated ConnectivityInfo connectivity_infos = 14; + string model = 15; + string manufacturer = 16; + repeated ExperimentInfo experiment_infos = 17; + DeviceType device_type = 18; + bool is_standalone_device = 19; + TelephonyInfoContainer telephony_info_container = 20; + string device_fingerprint = 21; +} + +message ExperimentInfo { + string experiment_id = 1; + string experiment_value = 3; +} + +message MobileOperatorCountry { + string country_iso = 1; + string mcc_mnc = 2; + string operator_name = 3; + uint32 nil_since_millis = 4; +} + +enum VoiceCapability { + VOICE_CAPABILITY_UNKNOWN = 0; + VOICE_CAPABILITY_CAPABLE = 1; +} + +enum NetworkType { + NETWORK_TYPE_UNKNOWN = 0; + NETWORK_TYPE_LTE = 1; +} + +message TelephonyInfoContainer { + repeated TelephonyInfoEntry entries = 1; +} + +message TelephonyInfoEntry { + string gaia_id = 1; + int32 state = 2; + Timestamp timestamp = 3; +} + +message Timestamp { + int64 seconds = 1; + int32 nanos = 2; +} + +message DeviceSignals { + string droidguard_result = 1; + string droidguard_token = 2; +} + +message CountryInfo { + repeated string sim_countries = 1; + repeated string network_countries = 2; +} + +message ConnectivityInfo { + ConnectivityType type = 1; + ConnectivityState state = 2; + ConnectivityAvailability availability = 3; +} + +enum ConnectivityType { + CONNECTIVITY_TYPE_UNKNOWN = 0; + CONNECTIVITY_TYPE_WIFI = 1; + CONNECTIVITY_TYPE_MOBILE = 2; +} + +enum ConnectivityState { + CONNECTIVITY_STATE_UNKNOWN = 0; + CONNECTIVITY_STATE_CONNECTING = 1; + CONNECTIVITY_STATE_CONNECTED = 2; + CONNECTIVITY_STATE_DISCONNECTING = 3; + CONNECTIVITY_STATE_DISCONNECTED = 4; + CONNECTIVITY_STATE_SUSPENDED = 5; +} + +enum ConnectivityAvailability { + CONNECTIVITY_AVAILABILITY_UNKNOWN = 0; + CONNECTIVITY_AVAILABLE = 1; + CONNECTIVITY_NOT_AVAILABLE = 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; +} + +enum UserProfileType { + UNKNOWN_PROFILE_TYPE = 0; + REGULAR_USER = 1; + MANAGED_PROFILE = 2; +} + +message VerificationToken { + bytes token = 1; + google.protobuf.Timestamp expiration_time = 2; +} + +message GetVerifiedPhoneNumbersRequest { + string session_id = 1; + ClientCredentialsProto client_credentials = 2; + repeated int32 selection_types = 3; + IdTokenRequestProto id_token_request = 4; + string droidguard_result = 5; +} + +message GetVerifiedPhoneNumbersResponse { + repeated VerifiedPhoneNumber verified_phone_numbers = 2; +} + +message VerifiedPhoneNumber { + string phone_number = 1; + google.protobuf.Timestamp timestamp = 2; + string token = 3; + int32 rcs_state = 4; +} + +message ClientCredentialsProto { + string iid_token = 1; + bytes client_signature = 2; + google.protobuf.Timestamp signature_timestamp = 3; +} + +message IdTokenRequestProto { + string certificate_hash = 1; + string calling_package = 2; + string token_nonce = 3; +} + +message ResponseHeader { + ClientInfoUpdate client_info_update = 1; + string session_id = 2; + string server_build_label = 3; +} + +message ClientInfoUpdate { + PublicKeyStatus public_key_status = 1; +} + +enum PublicKeyStatus { + PUBLIC_KEY_STATUS_NO_STATUS = 0; + CLIENT_KEY_UPDATED = 1; +} + +message ServerTimestamp { + google.protobuf.Timestamp timestamp = 1; + google.protobuf.Timestamp now = 2; +} + +message DroidGuardTokenResponse { + string droidguard_token = 1; + google.protobuf.Timestamp droidguard_token_ttl = 2; +} + +message StatusProto { + int32 code = 1; + string space = 2; + string message = 3; + bytes message_set = 5; + int32 canonical_code = 6; +} + +message TelephonyInfo { + int32 phone_type = 1; + string group_id_level1 = 2; + MobileOperatorCountry sim_country = 3; + MobileOperatorCountry network_country = 4; + int32 data_roaming = 5; + int32 network_roaming = 6; + int32 sms_capability = 7; + int32 subscription_count = 8; + int32 subscription_count_max = 9; + int32 eap_aka_capability = 11; + int32 sms_no_confirm_capability = 12; + int32 sim_state = 13; + optional string imei = 15; + int32 service_state = 16; + repeated TelephonyCapabilityInfo capability_infos = 17; + repeated TelephonyStateInfo state_infos = 18; + repeated TelephonyPermissionInfo permission_infos = 19; + repeated TelephonyDetailedInfo detailed_infos = 20; + bool is_embedded = 21; + int32 unknown_field_23 = 23; + int64 sim_carrier_id = 25; +} + +message TelephonyCapabilityInfo { + CredentialMetadata metadata = 1; + bool capability_1 = 2; + bool capability_2 = 3; + bool capability_3 = 4; + bool capability_4 = 5; + repeated TelephonyCapabilityEntry entries = 6; +} + +message TelephonyCapabilityEntry { + repeated int32 values = 1; + bool flag = 2; +} + +message TelephonyStateInfo { + CredentialMetadata metadata = 1; + int32 state = 2; +} + +message TelephonyPermissionInfo { + CredentialMetadata metadata = 1; + int32 permission_1 = 2; + int32 permission_2 = 3; +} + +message TelephonyDetailedInfo { + CredentialMetadata metadata = 1; + bool flag_1 = 2; + bool flag_2 = 3; + int32 value_1 = 4; + int32 value_2 = 5; + int32 value_3 = 6; + int32 value_4 = 7; + int32 value_5 = 8; +} + +message MobileOperatorInfo { + string country_code = 1; + string mcc_mnc = 2; + string operator_name = 3; + uint32 nil_since_micros = 4; + uint64 nil_since_usec = 5; +} + +message CarrierIdChallengePreference { + Integrator integrator = 1; + GtafVerificationMethod gtaf_verification_method = 2; +} + +message CellularNetworkEvent { + google.protobuf.Timestamp event_timestamp = 1; + bool mobile_data_enabled = 2; + bool airplane_mode_enabled = 3; + bool mobile_data_always_on_enabled = 4; + bool connected_to_wifi = 5; + repeated CellularNetwork data_networks = 6; +} + +message CellularNetwork { + repeated int32 network_capabilities = 1; + bool can_reach_google = 2; +} + +message CallEvent { + google.protobuf.Timestamp event_timestamp = 1; + CommunicationDirection event_direction = 2; + PhoneNumberType number_type = 3; +} + +message SMSEvent { + google.protobuf.Timestamp event_timestamp = 1; + CommunicationDirection event_direction = 2; + PhoneNumberType number_type = 3; +} + +message ServiceStateEvent { + google.protobuf.Timestamp event_timestamp = 1; + bool mobile_data_enabled = 2; + bool airplane_mode_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; +} + +enum SIMState { + SIM_STATE_UNKNOWN = 0; + SIM_NOT_READY = 1; + SIM_READY = 2; +} + +enum PhoneType { + PHONE_TYPE_UNKNOWN = 0; + PHONE_TYPE_GSM = 1; + PHONE_TYPE_CDMA = 2; + PHONE_TYPE_SIP = 3; +} + +enum RoamingState { + ROAMING_STATE_UNKNOWN = 0; + ROAMING_STATE_NOT_ROAMING = 1; + ROAMING_STATE_ROAMING = 2; +} + +enum SMSCapability { + SMS_CAPABILITY_UNKNOWN = 0; + SMS_CAPABILITY_INCAPABLE = 1; + SMS_CAPABILITY_APP_RESTRICTED = 2; + SMS_CAPABILITY_USER_RESTRICTED = 3; + SMS_CAPABILITY_CAPABLE = 4; +} + +enum CarrierIdCapability { + CARRIER_ID_CAPABILITY_UNKNOWN = 0; + CARRIER_ID_INCAPABLE = 1; + CARRIER_ID_CAPABLE = 2; +} + +enum PremiumSmsPermission { + PREMIUM_SMS_PERMISSION_UNKNOWN = 0; + PREMIUM_SMS_PROMPT_REQUIRED = 1; + PREMIUM_SMS_PERMISSION_GRANTED = 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; +} + +enum GtafVerificationMethod { + METHOD_UNKNOWN = 0; + METHOD_CARRIER_ID_TS43 = 1; + METHOD_CARRIER_ID_LEGACY = 2; + METHOD_CARRIER_ID_TS43_UPI = 3; +} + +enum Integrator { + INTEGRATOR_UNSPECIFIED = 0; + INTEGRATOR_TATA_GT1 = 1; + INTEGRATOR_TATA_GT2 = 2; +} + +enum CommunicationDirection { + COMMUNICATION_DIRECTION_UNKNOWN = 0; + COMMUNICATION_DIRECTION_INCOMING = 1; + COMMUNICATION_DIRECTION_OUTGOING = 2; + COMMUNICATION_DIRECTION_MISSED = 3; +} + +enum PhoneNumberType { + PHONE_NUMBER_TYPE_UNKNOWN = 0; + PHONE_NUMBER_TYPE_LONG_NUMBER = 1; + PHONE_NUMBER_TYPE_SHORT_CODE = 2; +} + +message RequestTrigger { + TriggerType type = 1; +} + +enum TriggerType { + TRIGGER_TYPE_UNKNOWN = 0; + TRIGGER_TYPE_PERIODIC_CONSENT_CHECK = 1; + TRIGGER_TYPE_PERIODIC_REFRESH = 2; + TRIGGER_TYPE_SIM_STATE_CHANGED = 3; + TRIGGER_TYPE_GAIA_CHANGE_EVENT = 4; + TRIGGER_TYPE_USER_SETTINGS = 5; + TRIGGER_TYPE_DEBUG_SETTINGS = 6; + TRIGGER_TYPE_TRIGGER_API_CALL = 7; + TRIGGER_TYPE_REBOOT_CHECKER = 8; + TRIGGER_TYPE_SERVER_TRIGGER = 9; + TRIGGER_TYPE_FAILURE_RETRY = 10; + TRIGGER_TYPE_CONSENT_API_TRIGGER = 11; + TRIGGER_TYPE_PNVR_DEVICE_SETTINGS = 12; +} + +message DeviceConsent { + ConsentValue consent = 2; + ConsentCostSetting cost_setting = 3; +} + +message AppSpecificConsent { + ConsentValue consent = 1; + AppIdentifier app = 2; +} + +message GaiaReachabilityConsent { + GaiaId gaia_id = 1; + ConsentValue reachability_consent = 2; +} + +message OnDemandConsent { + ConsentValue consent = 1; + GaiaId gaia_id = 2; + string consent_variant = 3; + string trigger = 4; +} + +message DeviceVerificationConsent { + ConsentValue consent_value = 1; + DeviceVerificationConsentSource consent_source = 2; + DeviceVerificationConsentVersion consent_version = 3; +} + +message ClientBehavior { + DeviceConsent current_consent = 1; + CheckerState checkers_state = 2; +} + +message AsterismConsent { + AsterismClient consumer = 1; + ConsentValue consent = 2; + RcsConsentVersion consent_version = 3; + bool are_all_rcs_users_unmonitored = 4; +} + +message DevicePermissionInfo { + DevicePermissionState permission_state = 1; + DevicePermissionMode permission_mode = 2; +} + +enum ConsentValue { + CONSENT_VALUE_UNKNOWN = 0; + CONSENT_VALUE_CONSENTED = 1; + CONSENT_VALUE_NO_CONSENT = 2; + CONSENT_VALUE_EXPIRED = 3; +} + +enum ConsentCostSetting { + CONSENT_COST_SETTING_NONE = 0; + CONSENT_COST_SETTING_AUTOMATIC = 1; + CONSENT_COST_SETTING_MANUAL = 2; +} + +enum CheckerState { + CHECKERS_UNKNOWN_STATE = 0; + CHECKERS_INACTIVE = 1; + CHECKERS_ACTIVE = 2; +} + +enum DeviceVerificationConsentSource { + DEVICE_VERIFICATION_CONSENT_SOURCE_UNSPECIFIED = 0; + DEVICE_VERIFICATION_CONSENT_SOURCE_ANDROID_DEVICE_SETTINGS = 1; + DEVICE_VERIFICATION_CONSENT_SOURCE_GAIA_USERNAME_RECOVERY = 2; + DEVICE_VERIFICATION_CONSENT_SOURCE_AOB_SETUP_WIZARD = 3; + DEVICE_VERIFICATION_CONSENT_SOURCE_MINUTEMAID_JS_BRIDGE = 4; + DEVICE_VERIFICATION_CONSENT_SOURCE_GAIA_WEB_JS_BRIDGE = 5; + DEVICE_VERIFICATION_CONSENT_SOURCE_AM_PROFILES = 6; +} + +enum DeviceVerificationConsentVersion { + DEVICE_VERIFICATION_CONSENT_VERSION_UNKNOWN = 0; + DEVICE_VERIFICATION_CONSENT_VERSION_PHONE_VERIFICATION_DEFAULT = 1; + DEVICE_VERIFICATION_CONSENT_VERSION_PHONE_VERIFICATION_MESSAGES_CALLS_V1 = 2; + DEVICE_VERIFICATION_CONSENT_VERSION_PHONE_VERIFICATION_INTL_SMS_CALLS = 3; + DEVICE_VERIFICATION_CONSENT_VERSION_PHONE_VERIFICATION_REACHABILITY_INTL_SMS_CALLS = 4; +} + +enum RcsConsentVersion { + RCS_CONSENT_VERSION_UNSPECIFIED = 0; + RCS_CONSENT_VERSION_RCS_CONSENT = 1; + RCS_CONSENT_VERSION_RCS_DEFAULT_ON_LEGAL_FYI = 2; + RCS_CONSENT_VERSION_RCS_DEFAULT_ON_OUT_OF_BOX = 3; + RCS_CONSENT_VERSION_RCS_SAMSUNG_UNFREEZE = 4; + RCS_CONSENT_VERSION_RCS_DEFAULT_ON_LEGAL_FYI_IN_SETTINGS = 5; +} + +enum AppIdentifier { + APP_IDENTIFIER_UNKNOWN_APP = 0; + APP_IDENTIFIER_RCS = 1; +} + +enum DevicePermissionState { + DEVICE_PERMISSION_STATE_UNSPECIFIED = 0; + DEVICE_PERMISSION_STATE_GRANTED = 1; + DEVICE_PERMISSION_STATE_DENIED = 2; +} + +enum DevicePermissionMode { + DEVICE_PERMISSION_MODE_UNSPECIFIED = 0; + DEVICE_PERMISSION_MODE_LEGACY_DPNV = 1; + DEVICE_PERMISSION_MODE_PNVR = 2; + DEVICE_PERMISSION_MODE_NOT_ALLOWED = 3; +} diff --git a/play-services-core/src/test/java/org/microg/gms/constellation/ConstellationServiceImplTest.java b/play-services-core/src/test/java/org/microg/gms/constellation/ConstellationServiceImplTest.java new file mode 100644 index 0000000000..7b2e8521d6 --- /dev/null +++ b/play-services-core/src/test/java/org/microg/gms/constellation/ConstellationServiceImplTest.java @@ -0,0 +1,253 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation; + +import static org.junit.Assert.assertEquals; + +import com.google.android.gms.constellation.PhoneNumberVerification; +import com.google.android.gms.constellation.VerificationCapability; +import com.squareup.wire.GrpcException; +import com.squareup.wire.GrpcStatus; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +public class ConstellationServiceImplTest { + + // --- mapExceptionToStatusCode tests --- + + @Test + public void mapException_resourceExhausted_returns5008() { + GrpcException e = new GrpcException(GrpcStatus.RESOURCE_EXHAUSTED, "quota", null); + assertEquals(5008, ConstellationServiceImpl.mapExceptionToStatusCode(e)); + } + + @Test + public void mapException_deadlineExceeded_returns5007() { + GrpcException e = new GrpcException(GrpcStatus.DEADLINE_EXCEEDED, "timeout", null); + assertEquals(5007, ConstellationServiceImpl.mapExceptionToStatusCode(e)); + } + + @Test + public void mapException_aborted_returns5007() { + GrpcException e = new GrpcException(GrpcStatus.ABORTED, "aborted", null); + assertEquals(5007, ConstellationServiceImpl.mapExceptionToStatusCode(e)); + } + + @Test + public void mapException_unavailable_returns5007() { + GrpcException e = new GrpcException(GrpcStatus.UNAVAILABLE, "unavailable", null); + assertEquals(5007, ConstellationServiceImpl.mapExceptionToStatusCode(e)); + } + + @Test + public void mapException_permissionDenied_returns5009() { + GrpcException e = new GrpcException(GrpcStatus.PERMISSION_DENIED, "denied", null); + assertEquals(5009, ConstellationServiceImpl.mapExceptionToStatusCode(e)); + } + + @Test + public void mapException_invalidArgument_returns5002() { + GrpcException e = new GrpcException(GrpcStatus.INVALID_ARGUMENT, "bad arg", null); + assertEquals(5002, ConstellationServiceImpl.mapExceptionToStatusCode(e)); + } + + @Test + public void mapException_unauthenticated_returns5002() { + GrpcException e = new GrpcException(GrpcStatus.UNAUTHENTICATED, "no auth", null); + assertEquals(5002, ConstellationServiceImpl.mapExceptionToStatusCode(e)); + } + + @Test + public void mapException_wrappedGrpcException_unwraps() { + GrpcException inner = new GrpcException(GrpcStatus.RESOURCE_EXHAUSTED, "quota", null); + RuntimeException wrapper = new RuntimeException("wrapped", inner); + assertEquals(5008, ConstellationServiceImpl.mapExceptionToStatusCode(wrapper)); + } + + @Test + public void mapException_nonGrpc_returns8() { + assertEquals(8, ConstellationServiceImpl.mapExceptionToStatusCode(new NullPointerException("npe"))); + } + + @Test + public void mapException_plainIOException_returns8() { + assertEquals(8, ConstellationServiceImpl.mapExceptionToStatusCode(new java.io.IOException("network"))); + } + + // --- decideVerificationOutcome tests --- + + @Test + public void decideVerificationOutcome_verifiedKeepsToken() { + ConstellationServiceImpl.VerificationDecision decision = + ConstellationServiceImpl.decideVerificationOutcome("real-jwt", false); + + assertEquals(PhoneNumberVerification.STATUS_VERIFIED, decision.status); + assertEquals("real-jwt", decision.token); + } + + @Test + public void decideVerificationOutcome_ineligibleForcesEmptyToken() { + ConstellationServiceImpl.VerificationDecision decision = + ConstellationServiceImpl.decideVerificationOutcome("some-token", true); + + assertEquals(PhoneNumberVerification.STATUS_INELIGIBLE, decision.status); + assertEquals("", decision.token); + } + + @Test + public void decideVerificationOutcome_nullTokenBecomesFailureWithEmptyToken() { + ConstellationServiceImpl.VerificationDecision decision = + ConstellationServiceImpl.decideVerificationOutcome(null, false); + + assertEquals(PhoneNumberVerification.STATUS_NON_RETRYABLE_FAILURE, decision.status); + assertEquals("", decision.token); + } + + @Test + public void decideVerificationOutcome_emptyTokenBecomesFailureWithEmptyToken() { + ConstellationServiceImpl.VerificationDecision decision = + ConstellationServiceImpl.decideVerificationOutcome("", false); + + assertEquals(PhoneNumberVerification.STATUS_NON_RETRYABLE_FAILURE, decision.status); + assertEquals("", decision.token); + } + + // --- extractVerificationMethodFromJwt tests --- + + private static String fakeJwt(String payloadJson) { + String header = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"alg\":\"RS256\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String payload = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(payloadJson.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return header + "." + payload + ".fakesig"; + } + + @Test + public void extractMethod_mtSms() { + String jwt = fakeJwt("{\"google\":{\"phone_number_verification_method\":\"VERIFICATION_METHOD_MT_SMS\"}}"); + assertEquals(PhoneNumberVerification.METHOD_MT_SMS, ConstellationServiceImpl.extractVerificationMethodFromJwt(jwt)); + } + + @Test + public void extractMethod_moSms() { + String jwt = fakeJwt("{\"google\":{\"phone_number_verification_method\":\"VERIFICATION_METHOD_MO_SMS\"}}"); + assertEquals(PhoneNumberVerification.METHOD_MO_SMS, ConstellationServiceImpl.extractVerificationMethodFromJwt(jwt)); + } + + @Test + public void extractMethod_ts43() { + String jwt = fakeJwt("{\"google\":{\"phone_number_verification_method\":\"VERIFICATION_METHOD_TS43\"}}"); + assertEquals(PhoneNumberVerification.METHOD_TS43_AIDL, ConstellationServiceImpl.extractVerificationMethodFromJwt(jwt)); + } + + @Test + public void extractMethod_noClaim_defaultsToTs43Aidl() { + String jwt = fakeJwt("{\"iss\":\"accounts.google.com\"}"); + assertEquals(PhoneNumberVerification.METHOD_TS43_AIDL, ConstellationServiceImpl.extractVerificationMethodFromJwt(jwt)); + } + + @Test + public void extractMethod_nullToken_defaultsToTs43Aidl() { + assertEquals(PhoneNumberVerification.METHOD_TS43_AIDL, ConstellationServiceImpl.extractVerificationMethodFromJwt(null)); + } + + @Test + public void extractMethod_emptyToken_defaultsToTs43Aidl() { + assertEquals(PhoneNumberVerification.METHOD_TS43_AIDL, ConstellationServiceImpl.extractVerificationMethodFromJwt("")); + } + + @Test + public void extractMethod_nonJwtString_defaultsToTs43Aidl() { + assertEquals(PhoneNumberVerification.METHOD_TS43_AIDL, ConstellationServiceImpl.extractVerificationMethodFromJwt("not-a-jwt")); + } + + // --- mapVerificationMethodString tests --- + + @Test + public void mapMethodString_allKnownMethods() { + assertEquals(PhoneNumberVerification.METHOD_MT_SMS, ConstellationServiceImpl.mapVerificationMethodString("VERIFICATION_METHOD_MT_SMS")); + assertEquals(PhoneNumberVerification.METHOD_MO_SMS, ConstellationServiceImpl.mapVerificationMethodString("VERIFICATION_METHOD_MO_SMS")); + assertEquals(PhoneNumberVerification.METHOD_CARRIER_ID, ConstellationServiceImpl.mapVerificationMethodString("VERIFICATION_METHOD_CARRIER_ID")); + assertEquals(PhoneNumberVerification.METHOD_IMSI_LOOKUP, ConstellationServiceImpl.mapVerificationMethodString("VERIFICATION_METHOD_IMSI_LOOKUP")); + assertEquals(PhoneNumberVerification.METHOD_REGISTERED_SMS, ConstellationServiceImpl.mapVerificationMethodString("VERIFICATION_METHOD_REGISTERED_SMS")); + assertEquals(PhoneNumberVerification.METHOD_FLASH_CALL, ConstellationServiceImpl.mapVerificationMethodString("VERIFICATION_METHOD_FLASH_CALL")); + assertEquals(PhoneNumberVerification.METHOD_TS43_AIDL, ConstellationServiceImpl.mapVerificationMethodString("VERIFICATION_METHOD_TS43")); + } + + @Test + public void mapMethodString_unknown_defaultsToTs43Aidl() { + assertEquals(PhoneNumberVerification.METHOD_TS43_AIDL, ConstellationServiceImpl.mapVerificationMethodString("SOMETHING_NEW")); + } + + // --- buildVerificationCapabilities tests --- + + @Test + public void buildVerificationCapabilities_defaultAdvertisesSupportedPaths() { + List capabilities = ConstellationServiceImpl.buildVerificationCapabilities(null); + + assertEquals(3, capabilities.size()); + assertEquals(VerificationCapability.TYPE_EAP_AKA, capabilities.get(0).verificationType); + assertEquals(0, capabilities.get(0).priority); + assertEquals(VerificationCapability.TYPE_SMS_OTP, capabilities.get(1).verificationType); + assertEquals(1, capabilities.get(1).priority); + assertEquals(VerificationCapability.TYPE_SILENT, capabilities.get(2).verificationType); + assertEquals(2, capabilities.get(2).priority); + } + + @Test + public void buildVerificationCapabilities_mapsRequestedMethodsInOrderWithoutDuplicates() { + List capabilities = ConstellationServiceImpl.buildVerificationCapabilities(Arrays.asList( + PhoneNumberVerification.METHOD_MT_SMS, + PhoneNumberVerification.METHOD_TS43_AIDL, + PhoneNumberVerification.METHOD_TS43, + PhoneNumberVerification.METHOD_FLASH_CALL + )); + + assertEquals(3, capabilities.size()); + assertEquals(VerificationCapability.TYPE_SMS_OTP, capabilities.get(0).verificationType); + assertEquals(0, capabilities.get(0).priority); + assertEquals(VerificationCapability.TYPE_EAP_AKA, capabilities.get(1).verificationType); + assertEquals(1, capabilities.get(1).priority); + assertEquals(VerificationCapability.TYPE_SILENT, capabilities.get(2).verificationType); + assertEquals(2, capabilities.get(2).priority); + } + + @Test + public void buildVerificationCapabilities_unknownOnlyFallsBackToTs43() { + List capabilities = ConstellationServiceImpl.buildVerificationCapabilities(Arrays.asList(0, 999)); + + assertEquals(1, capabilities.size()); + assertEquals(VerificationCapability.TYPE_EAP_AKA, capabilities.get(0).verificationType); + assertEquals(0, capabilities.get(0).priority); + } + + // --- EntitlementResult.needsManualMsisdn tests --- + + @Test + public void entitlementResult_phoneNumberEntryRequired_isNotError() { + Ts43Client.EntitlementResult result = Ts43Client.EntitlementResult.phoneNumberEntryRequired("test-reason"); + assertEquals(false, result.isError()); + assertEquals(true, result.needsManualMsisdn); + assertEquals(null, result.token); + } + + @Test + public void entitlementResult_error_isError() { + Ts43Client.EntitlementResult result = Ts43Client.EntitlementResult.error("test-error"); + assertEquals(true, result.isError()); + assertEquals(false, result.needsManualMsisdn); + } + + @Test + public void entitlementResult_success_isNotError() { + Ts43Client.EntitlementResult result = Ts43Client.EntitlementResult.success("jwt-token"); + assertEquals(false, result.isError()); + assertEquals(false, result.needsManualMsisdn); + } +} diff --git a/play-services-core/src/test/java/org/microg/gms/constellation/Ts43ClientTest.java b/play-services-core/src/test/java/org/microg/gms/constellation/Ts43ClientTest.java new file mode 100644 index 0000000000..5800cf859c --- /dev/null +++ b/play-services-core/src/test/java/org/microg/gms/constellation/Ts43ClientTest.java @@ -0,0 +1,256 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation; + +import android.content.Context; + +import org.junit.Test; +import static org.junit.Assert.*; + +import java.util.Arrays; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +public class Ts43ClientTest { + + // Mock Base64Decoder using java.util.Base64 + private final Ts43Client.Base64Decoder base64Decoder = new Ts43Client.Base64Decoder() { + @Override + public byte[] decode(String str) { + return java.util.Base64.getDecoder().decode(str); + } + + @Override + public String encodeToString(byte[] input) { + return java.util.Base64.getEncoder().encodeToString(input); + } + }; + + // Mock Logger + private final Ts43Client.Logger logger = new Ts43Client.Logger() { + @Override public void d(String tag, String msg) { System.out.println("D/" + tag + ": " + msg); } + @Override public void i(String tag, String msg) { System.out.println("I/" + tag + ": " + msg); } + @Override public void w(String tag, String msg) { System.out.println("W/" + tag + ": " + msg); } + @Override public void w(String tag, String msg, Throwable tr) { System.out.println("W/" + tag + ": " + msg); tr.printStackTrace(); } + @Override public void e(String tag, String msg) { System.out.println("E/" + tag + ": " + msg); } + @Override public void e(String tag, String msg, Throwable tr) { System.out.println("E/" + tag + ": " + msg); tr.printStackTrace(); } + }; + + /** + * Build a SIM EAP-AKA success response in 3GPP TS 31.102 format: + * 0xDB [len_RES] [RES] [len_CK] [CK] [len_IK] [IK] + */ + private byte[] buildSimResponse(byte[] res, byte[] ck, byte[] ik) { + byte[] response = new byte[1 + 1 + res.length + 1 + ck.length + 1 + ik.length]; + int pos = 0; + response[pos++] = (byte) 0xDB; + response[pos++] = (byte) res.length; + System.arraycopy(res, 0, response, pos, res.length); pos += res.length; + response[pos++] = (byte) ck.length; + System.arraycopy(ck, 0, response, pos, ck.length); pos += ck.length; + response[pos++] = (byte) ik.length; + System.arraycopy(ik, 0, response, pos, ik.length); + return response; + } + + /** + * Build an EAP-AKA challenge packet (Code=Request, Type=23, Subtype=Challenge). + */ + private byte[] buildEapAkaChallenge(int id, byte[] rand, byte[] autn) { + // AT_RAND: Type=1, Length=5 words (20 bytes), Reserved(2) + RAND(16) + // AT_AUTN: Type=2, Length=5 words (20 bytes), Reserved(2) + AUTN(16) + int totalLen = 8 + 20 + 20; // Header + AT_RAND + AT_AUTN + byte[] packet = new byte[totalLen]; + int pos = 0; + packet[pos++] = 0x01; // Code: Request + packet[pos++] = (byte) id; + packet[pos++] = (byte) (totalLen >> 8); + packet[pos++] = (byte) totalLen; + packet[pos++] = 0x17; // Type: EAP-AKA (23) + packet[pos++] = 0x01; // Subtype: Challenge + packet[pos++] = 0x00; // Reserved + packet[pos++] = 0x00; // Reserved + // AT_RAND + packet[pos++] = 0x01; // Type: AT_RAND + packet[pos++] = 0x05; // Length: 5 words + packet[pos++] = 0x00; // Reserved + packet[pos++] = 0x00; // Reserved + System.arraycopy(rand, 0, packet, pos, 16); pos += 16; + // AT_AUTN + packet[pos++] = 0x02; // Type: AT_AUTN + packet[pos++] = 0x05; // Length: 5 words + packet[pos++] = 0x00; // Reserved + packet[pos++] = 0x00; // Reserved + System.arraycopy(autn, 0, packet, pos, 16); + return packet; + } + + @Test + public void testProcessEapPacket_producesValidResponse() { + byte[] res = new byte[8]; Arrays.fill(res, (byte) 0x11); + byte[] ck = new byte[16]; Arrays.fill(ck, (byte) 0x22); + byte[] ik = new byte[16]; Arrays.fill(ik, (byte) 0x33); + + Ts43Client.SimAuthProvider simAuthProvider = new Ts43Client.SimAuthProvider() { + @Override public String getNetworkOperator() { return "12345"; } + @Override public String getSimOperator() { return "12345"; } + @Override public String getSubscriberId() { return "123456789012345"; } + @Override + public String getIccAuthentication(int appType, int authType, String data) { + byte[] decoded = java.util.Base64.getDecoder().decode(data); + assertEquals(34, decoded.length); // [16] + RAND(16) + [16] + AUTN(16) + return java.util.Base64.getEncoder().encodeToString( + buildSimResponse(res, ck, ik) + ); + } + }; + + Ts43Client client = new Ts43Client(null, simAuthProvider, base64Decoder, logger); + + byte[] rand = new byte[16]; Arrays.fill(rand, (byte) 0xAA); + byte[] autn = new byte[16]; Arrays.fill(autn, (byte) 0xBB); + byte[] challengePacket = buildEapAkaChallenge(42, rand, autn); + String challengeBase64 = java.util.Base64.getEncoder().encodeToString(challengePacket); + + // processEapPacket is package-private, call via reflection or test the full chain + // For now, test the EAP response format via the public generateEapAkaResponse + // by calling processEapPacket through the test constructor + String response = client.processEapPacket(1, challengeBase64, "123456789012345", "12345", null); + + assertNotNull("processEapPacket should return non-null response", response); + + byte[] responseBytes = java.util.Base64.getDecoder().decode(response); + + // Verify EAP Response header + assertEquals(0x02, responseBytes[0]); // Code: Response + assertEquals(42, responseBytes[1] & 0xFF); // ID matches challenge + assertEquals(23, responseBytes[4] & 0xFF); // Type: EAP-AKA + assertEquals(1, responseBytes[5] & 0xFF); // Subtype: Challenge + + // Verify AT_RES at offset 8 + int pos = 8; + assertEquals(3, responseBytes[pos] & 0xFF); // Type: AT_RES + // AT_RES length = (4 + 8 + 0) / 4 = 3 words + assertEquals(3, responseBytes[pos + 1] & 0xFF); + // RES bit count: 8 bytes * 8 = 64 bits = 0x0040 (big-endian) + assertEquals(0x00, responseBytes[pos + 2] & 0xFF); + assertEquals(0x40, responseBytes[pos + 3] & 0xFF); + + // Verify AT_MAC at offset 8 + 12 = 20 + pos = 20; + assertEquals(11, responseBytes[pos] & 0xFF); // Type: AT_MAC + assertEquals(5, responseBytes[pos + 1] & 0xFF); // Length: 5 words + + // MAC should be non-zero (HMAC-SHA1 computed) + boolean macNonZero = false; + for (int i = 0; i < 16; i++) { + if (responseBytes[pos + 4 + i] != 0) macNonZero = true; + } + assertTrue("AT_MAC should be non-zero", macNonZero); + } + + @Test + public void testSimResponseParsing_success() { + byte[] res = new byte[]{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, (byte)0x88}; + byte[] ck = new byte[16]; Arrays.fill(ck, (byte) 0xAA); + byte[] ik = new byte[16]; Arrays.fill(ik, (byte) 0xBB); + byte[] simResp = buildSimResponse(res, ck, ik); + String b64 = java.util.Base64.getEncoder().encodeToString(simResp); + + Ts43Client.SimAuthProvider simAuthProvider = new Ts43Client.SimAuthProvider() { + @Override public String getNetworkOperator() { return "12345"; } + @Override public String getSimOperator() { return "12345"; } + @Override public String getSubscriberId() { return "123456789012345"; } + @Override public String getIccAuthentication(int a, int b, String d) { return null; } + }; + + Ts43Client client = new Ts43Client(null, simAuthProvider, base64Decoder, logger); + + // parseSimResponse is private, but we can test indirectly via processEapPacket + // which calls it. For direct testing, use reflection or make package-private. + // This test verifies the format is correctly built by buildSimResponse. + assertEquals(0xDB, simResp[0] & 0xFF); + assertEquals(8, simResp[1] & 0xFF); // RES length + assertEquals(0x11, simResp[2] & 0xFF); // First RES byte + assertEquals(16, simResp[10] & 0xFF); // CK length + assertEquals(16, simResp[27] & 0xFF); // IK length + } + + @Test + public void testSimResponseParsing_syncFailure() { + byte[] auts = new byte[14]; Arrays.fill(auts, (byte) 0xCC); + byte[] simResp = new byte[1 + 1 + 14]; + simResp[0] = (byte) 0xDC; // Sync failure tag + simResp[1] = 14; // AUTS length + System.arraycopy(auts, 0, simResp, 2, 14); + + assertEquals(0xDC, simResp[0] & 0xFF); + assertEquals(14, simResp[1] & 0xFF); + } + + @Test + public void testFips186Prf_deterministicOutput() { + // The PRF should produce deterministic output for the same input + Ts43Client.SimAuthProvider simAuthProvider = new Ts43Client.SimAuthProvider() { + @Override public String getNetworkOperator() { return "12345"; } + @Override public String getSimOperator() { return "12345"; } + @Override public String getSubscriberId() { return "123456789012345"; } + @Override public String getIccAuthentication(int a, int b, String d) { return null; } + }; + + Ts43Client client = new Ts43Client(null, simAuthProvider, base64Decoder, logger); + + // fips186Prf is private, but we can verify via the full chain: + // Two calls with identical SIM responses should produce identical EAP responses + byte[] res = new byte[8]; Arrays.fill(res, (byte) 0x11); + byte[] ck = new byte[16]; Arrays.fill(ck, (byte) 0x22); + byte[] ik = new byte[16]; Arrays.fill(ik, (byte) 0x33); + byte[] rand = new byte[16]; Arrays.fill(rand, (byte) 0xAA); + byte[] autn = new byte[16]; Arrays.fill(autn, (byte) 0xBB); + + Ts43Client.SimAuthProvider authProvider = new Ts43Client.SimAuthProvider() { + @Override public String getNetworkOperator() { return "12345"; } + @Override public String getSimOperator() { return "12345"; } + @Override public String getSubscriberId() { return "123456789012345"; } + @Override + public String getIccAuthentication(int appType, int authType, String data) { + return java.util.Base64.getEncoder().encodeToString(buildSimResponse(res, ck, ik)); + } + }; + + Ts43Client c = new Ts43Client(null, authProvider, base64Decoder, logger); + byte[] challenge = buildEapAkaChallenge(1, rand, autn); + String b64 = java.util.Base64.getEncoder().encodeToString(challenge); + + String resp1 = c.processEapPacket(1, b64, "123456789012345", "12345", null); + String resp2 = c.processEapPacket(1, b64, "123456789012345", "12345", null); + + assertNotNull(resp1); + assertNotNull(resp2); + assertEquals("Same inputs should produce identical EAP responses", resp1, resp2); + } + + @Test + public void testEntitlementResult_types() { + Ts43Client.EntitlementResult success = Ts43Client.EntitlementResult.success("token"); + assertFalse(success.isError()); + assertFalse(success.needsManualMsisdn); + assertEquals("token", success.token); + + Ts43Client.EntitlementResult error = Ts43Client.EntitlementResult.error("fail"); + assertTrue(error.isError()); + assertFalse(error.needsManualMsisdn); + assertNull(error.token); + + Ts43Client.EntitlementResult ineligible = Ts43Client.EntitlementResult.ineligible("", "reason"); + assertFalse(ineligible.isError()); + assertTrue(ineligible.ineligible); + + Ts43Client.EntitlementResult manual = Ts43Client.EntitlementResult.phoneNumberEntryRequired("reason"); + assertFalse(manual.isError()); + assertTrue(manual.needsManualMsisdn); + } +} diff --git a/play-services-core/src/test/java/org/microg/gms/rcs/RcsCallerPolicyTest.java b/play-services-core/src/test/java/org/microg/gms/rcs/RcsCallerPolicyTest.java new file mode 100644 index 0000000000..48fff7188d --- /dev/null +++ b/play-services-core/src/test/java/org/microg/gms/rcs/RcsCallerPolicyTest.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.rcs; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class RcsCallerPolicyTest { + @Test + public void constellationAllowsMessagesAndGmsSurfaces() { + assertTrue(RcsCallerPolicy.isConstellationPackageAllowed("com.google.android.apps.messaging")); + assertTrue(RcsCallerPolicy.isConstellationPackageAllowed("com.google.android.gms")); + assertTrue(RcsCallerPolicy.isConstellationPackageAllowed("com.google.android.ims")); + assertTrue(RcsCallerPolicy.isConstellationPackageAllowed("com.google.firebase.pnv")); + } + + @Test + public void constellationRejectsUnrelatedPackages() { + assertFalse(RcsCallerPolicy.isConstellationPackageAllowed("com.example.unrelated")); + assertFalse(RcsCallerPolicy.isConstellationPackageAllowed("")); + assertFalse(RcsCallerPolicy.isConstellationPackageAllowed(null)); + } + + @Test + public void asterismAllowsOnlyConsentCallers() { + assertTrue(RcsCallerPolicy.isAsterismPackageAllowed("com.google.android.apps.messaging")); + assertTrue(RcsCallerPolicy.isAsterismPackageAllowed("com.google.android.ims")); + assertFalse(RcsCallerPolicy.isAsterismPackageAllowed("com.google.android.gms")); + assertFalse(RcsCallerPolicy.isAsterismPackageAllowed("com.example.unrelated")); + } +} diff --git a/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/VersionUtil.kt b/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/VersionUtil.kt index bba3157324..dd0b9d2be4 100644 --- a/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/VersionUtil.kt +++ b/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/VersionUtil.kt @@ -44,7 +44,7 @@ class VersionUtil(private val context: Context) { return type // Use unknown build type } val versionString: String - get() = "${BuildConfig.VERSION_NAME} ($buildType-{{cl}})" + get() = "${BuildConfig.VERSION_NAME} ($buildType-${BuildConfig.VERSION_CODE})" val versionCode: Int get() = BuildConfig.VERSION_CODE + (getVersionOffset(buildType) ?: 0) diff --git a/play-services-droidguard/src/main/java/com/google/android/gms/droidguard/internal/DroidGuardInitReply.java b/play-services-droidguard/src/main/java/com/google/android/gms/droidguard/internal/DroidGuardInitReply.java index 0294a5c0e8..ea4e538c72 100644 --- a/play-services-droidguard/src/main/java/com/google/android/gms/droidguard/internal/DroidGuardInitReply.java +++ b/play-services-droidguard/src/main/java/com/google/android/gms/droidguard/internal/DroidGuardInitReply.java @@ -36,10 +36,7 @@ public void writeToParcel(Parcel dest, int flags) { public DroidGuardInitReply createFromParcel(Parcel source) { ParcelFileDescriptor pfd = source.readParcelable(ParcelFileDescriptor.class.getClassLoader()); Parcelable object = source.readParcelable(getClass().getClassLoader()); - if (pfd != null && object != null) { - return new DroidGuardInitReply(pfd, object); - } - return null; + return new DroidGuardInitReply(pfd, object); } @Override