Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
571a5be347
feat: Add support for profile downloads via carrier apps
Test: Download eSIM via Google Fi
Change-Id: I4c8e5560b71d16a280a666ca6b06d257b9d70e05
2025-01-26 15:21:24 -05:00
15 changed files with 414 additions and 13 deletions

View file

@ -45,8 +45,9 @@ class LocalProfileAssistantWrapper(orig: LocalProfileAssistant) :
matchingId: String?,
imei: String?,
confirmationCode: String?,
preview: Boolean,
callback: ProfileDownloadCallback
) = lpa.downloadProfile(smdp, matchingId, imei, confirmationCode, callback)
) = lpa.downloadProfile(smdp, matchingId, imei, confirmationCode, preview, callback)
override fun deleteNotification(seqNumber: Long): Boolean = lpa.deleteNotification(seqNumber)

View file

@ -373,6 +373,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
smdp: String,
matchingId: String?,
confirmationCode: String?,
preview: Boolean,
imei: String?
): ForegroundTaskSubscriberFlow =
launchForegroundTask(
@ -387,7 +388,10 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
matchingId,
imei,
confirmationCode,
preview,
object : ProfileDownloadCallback {
override fun onMetadataReceived(profileName: String, iccid: String, appCerts: String) {
}
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
if (state.progress == 0) return
foregroundTaskState.value =

View file

@ -29,6 +29,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
var smdp: String,
var matchingId: String?,
var confirmationCode: String?,
var preview: Boolean,
var imei: String?,
var downloadStarted: Boolean,
var downloadTaskID: Long,
@ -66,6 +67,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() {
"",
null,
null,
false,
null,
false,
-1,

View file

@ -160,6 +160,7 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
state.smdp,
state.matchingId,
state.confirmationCode,
state.preview,
state.imei
)

View file

@ -53,6 +53,18 @@
</intent-filter>
</activity>
<activity android:name=".ui.UserConsentActivity"
android:exported="true"
android:permission="android.permission.BIND_EUICC_SERVICE"
android:theme="@style/Theme.UserConsentDialog">
<intent-filter android:priority="100">
<action android:name="android.service.euicc.action.RESOLVE_DEACTIVATE_SIM" />
<action android:name="android.service.euicc.action.RESOLVE_NO_PRIVILEGES" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.service.euicc.category.EUICC_UI" />
</intent-filter>
</activity>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"

View file

@ -3,7 +3,9 @@ package im.angry.openeuicc.service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.service.euicc.*
import android.telephony.UiccAccessRule
import android.telephony.UiccSlotMapping
import android.telephony.euicc.DownloadableSubscription
import android.telephony.euicc.EuiccInfo
@ -16,6 +18,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlin.IllegalStateException
import net.typeblog.lpac_jni.ProfileDownloadCallback
class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
companion object {
@ -147,14 +150,186 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
// Not implemented
}
override fun onDownloadSubscription(
slotIndex: Int,
subscription: DownloadableSubscription,
switchAfterDownload: Boolean,
forceDeactivateSim: Boolean,
resolvedBundle: Bundle?
): DownloadSubscriptionResult {
Log.i(TAG, "onDownloadSubscription slotIndex=$slotIndex switchAfterDownload=$switchAfterDownload forceDeactivateSim=$forceDeactivateSim resolvedBundle=$resolvedBundle")
Log.i(TAG, "onDownloadSubscription getEncodedActivationCode()=${subscription.getEncodedActivationCode()}")
val activationParts = subscription.getEncodedActivationCode()?.split("$")
val smdpAddress = activationParts?.getOrNull(1)
val matchingId = activationParts?.getOrNull(2)
if (smdpAddress.isNullOrBlank() || matchingId.isNullOrBlank()) {
Log.e(TAG, "SMDP address or activation code is not valid.")
return DownloadSubscriptionResult(
RESULT_FIRST_USER,
0,
-1
)
}
Log.i(TAG, "onDownloadSubscription smdpAddress=$smdpAddress")
Log.i(TAG, "onDownloadSubscription matchingId=$matchingId")
var profileIccid: String? = null
return try {
withEuiccChannelManager {
val portId = euiccChannelManager.findFirstAvailablePort(slotIndex)
if (portId < 0) {
Log.e(TAG, "No available port for slotIndex=$slotIndex")
return@withEuiccChannelManager DownloadSubscriptionResult(
RESULT_FIRST_USER,
0,
-1
)
}
euiccChannelManager.withEuiccChannel(slotIndex, portId) { channel ->
channel.lpa.downloadProfile(
smdp = smdpAddress,
matchingId = matchingId,
imei = telephonyManager.imei,
confirmationCode = subscription.confirmationCode,
preview = false,
callback = object : ProfileDownloadCallback {
override fun onMetadataReceived(profileName: String, iccid: String, appCerts: String) {
Log.i(TAG, "Profile metadata received: profileName=$profileName iccid=$iccid")
profileIccid = iccid
}
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
Log.i(TAG, "Profile download state update: progress=${state.progress}")
}
}
)
if (switchAfterDownload) {
onSwitchToSubscriptionWithPort(slotIndex, portId, profileIccid, false)
}
DownloadSubscriptionResult(
RESULT_OK,
0,
channel.cardId
)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error during profile download: ${e.message}", e)
DownloadSubscriptionResult(
RESULT_FIRST_USER,
0,
-1
)
}
}
override fun onGetDownloadableSubscriptionMetadata(
slotId: Int,
subscription: DownloadableSubscription?,
forceDeactivateSim: Boolean
): GetDownloadableSubscriptionMetadataResult {
// Stub: return as-is and do not fetch anything
// This is incompatible with carrier eSIM apps; should we make it compatible?
return GetDownloadableSubscriptionMetadataResult(RESULT_OK, subscription)
Log.i(TAG, "onGetDownloadableSubscriptionMetadata slotId=$slotId")
if (subscription == null) {
return GetDownloadableSubscriptionMetadataResult(
RESULT_FIRST_USER,
null
)
}
Log.i(TAG, "onGetDownloadableSubscriptionMetadata getEncodedActivationCode()=${subscription.getEncodedActivationCode()}")
// Extract SMDP address and matching ID
val activationParts = subscription.getEncodedActivationCode()?.split("$")
val smdpAddress = activationParts?.getOrNull(1)
val matchingId = activationParts?.getOrNull(2)
if (smdpAddress.isNullOrBlank() || matchingId.isNullOrBlank()) {
Log.e(TAG, "SMDP address or activation code is not valid.")
return GetDownloadableSubscriptionMetadataResult(
RESULT_FIRST_USER,
null
)
}
Log.i(TAG, "onGetDownloadableSubscriptionMetadata smdpAddress=$smdpAddress")
Log.i(TAG, "onGetDownloadableSubscriptionMetadata matchingId=$matchingId")
val portId = withEuiccChannelManager {
euiccChannelManager.findFirstAvailablePort(slotId)
}
if (portId < 0) {
Log.e(TAG, "No available port for slotId=$slotId")
return GetDownloadableSubscriptionMetadataResult(
RESULT_FIRST_USER,
null
)
}
var updatedSubscription: DownloadableSubscription? = null
return try {
withEuiccChannelManager {
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
channel.lpa.downloadProfile(
smdp = smdpAddress,
matchingId = matchingId,
imei = telephonyManager.imei,
confirmationCode = subscription.confirmationCode,
preview = true,
callback = object : ProfileDownloadCallback {
override fun onMetadataReceived(profileName: String, iccid: String, appCerts: String) {
Log.i(TAG, "App certificates: appCerts=$appCerts")
Log.i(TAG, "Profile metadata received: profileName=$profileName")
val accessRules = appCerts.split(",").mapNotNull { certHex ->
try {
val certBytes = certHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
UiccAccessRule(certBytes, null, 0L)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse certificate hash: $certHex", e)
null
}
}
updatedSubscription = DownloadableSubscription.Builder(subscription)
.setCarrierName(profileName)
.setAccessRules(accessRules)
.build()
}
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
Log.i(TAG, "Profile download state update: progress=${state.progress}")
}
}
)
}
}
if (updatedSubscription == null) {
throw IllegalStateException("Metadata not received from callback")
}
GetDownloadableSubscriptionMetadataResult(
RESULT_OK,
updatedSubscription
)
} catch (e: Exception) {
Log.e(TAG, "Error during profile download: ${e.message}", e)
GetDownloadableSubscriptionMetadataResult(
RESULT_FIRST_USER,
null
)
}
}
override fun onGetDefaultDownloadableSubscriptionList(

View file

@ -0,0 +1,94 @@
package im.angry.openeuicc.ui
import android.app.Activity
import android.app.AlertDialog
import android.content.pm.PackageManager
import android.os.Bundle
import android.telephony.SubscriptionManager
import android.telephony.TelephonyManager
import android.telephony.euicc.EuiccManager
import android.text.TextUtils
import android.util.Log
import im.angry.openeuicc.R
class UserConsentActivity : Activity() {
private lateinit var euiccManager: EuiccManager
private lateinit var subscriptionManager: SubscriptionManager
private lateinit var telephonyManager: TelephonyManager
private var mCardId: Int = -1
private var mUsePortIndex: Boolean = false
private var mPortIndex: Int = -1
private var mSubscriptionId: Int = -1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
euiccManager = getSystemService(EuiccManager::class.java) as EuiccManager
subscriptionManager = getSystemService(SubscriptionManager::class.java) as SubscriptionManager
telephonyManager = getSystemService(TelephonyManager::class.java) as TelephonyManager
val callingPackage = intent.getStringExtra("android.service.euicc.extra.RESOLUTION_CALLING_PACKAGE")
if (TextUtils.isEmpty(callingPackage)) {
Log.e("UserConsentActivity", "Unknown calling package in resolution activity")
sendResultAndFinish(false)
return
}
val extras = intent.extras
if (extras != null) {
mCardId = extras.getInt("android.service.euicc.extra.RESOLUTION_CARD_ID", -1)
mUsePortIndex = extras.getBoolean("android.service.euicc.extra.RESOLUTION_USE_PORT_INDEX", false)
mPortIndex = extras.getInt("android.service.euicc.extra.RESOLUTION_PORT_INDEX", -1)
mSubscriptionId = extras.getInt("android.service.euicc.extra.RESOLUTION_SUBSCRIPTION_ID", -1)
}
try {
val packageManager = packageManager
val appLabel = packageManager.getApplicationLabel(packageManager.getApplicationInfo(callingPackage!!, 0))
if (TextUtils.isEmpty(appLabel)) {
Log.e("UserConsentActivity", "Calling app doesn't have a label")
sendResultAndFinish(false)
return
}
} catch (e: PackageManager.NameNotFoundException) {
Log.e("UserConsentActivity", "Calling package doesn't exist", e)
sendResultAndFinish(false)
return
}
showUserConsentDialog()
}
fun sendResultAndFinish(consent: Boolean) {
val bundle = Bundle()
bundle.putBoolean("android.service.euicc.extra.RESOLUTION_CONSENT", consent)
if (mUsePortIndex) {
bundle.putInt("android.service.euicc.extra.RESOLUTION_PORT_INDEX", mPortIndex);
}
euiccManager.createForCardId(mCardId)
euiccManager.continueOperation(intent, bundle)
setResult(if (consent) Activity.RESULT_OK else Activity.RESULT_CANCELED)
finish()
}
fun showUserConsentDialog() {
AlertDialog.Builder(this)
.setTitle(getString(R.string.user_consent_title))
.setMessage(getString(R.string.user_consent_message))
.setPositiveButton(getString(R.string.user_consent_positive)) { _, _ ->
sendResultAndFinish(true)
}
.setNegativeButton(getString(R.string.user_consent_negative)) { _, _ ->
sendResultAndFinish(false)
}
.setOnCancelListener {
sendResultAndFinish(false)
}
.show()
}
}

View file

@ -22,4 +22,9 @@
<string name="lui_desc">Your device supports eSIMs. To connect to mobile network, download your eSIM issued by a carrier, or insert a physical SIM.</string>
<string name="lui_skip">Skip</string>
<string name="lui_download">Download eSIM</string>
<string name="user_consent_title">Allow your carrier to download SIM?</string>
<string name="user_consent_message">Your SIM will be used immediately after download</string>
<string name="user_consent_negative">No thanks</string>
<string name="user_consent_positive">Yes</string>
</resources>

View file

@ -0,0 +1,13 @@
<resources>
<style name="Theme.UserConsentDialog" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:backgroundDimEnabled">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View file

@ -305,12 +305,45 @@ public abstract class EuiccService extends Service {
* defined in {@code RESOLVABLE_ERROR_}. A subclass should override this method. Otherwise,
* this method does nothing and returns null by default.
* @see android.telephony.euicc.EuiccManager#downloadSubscription
* @deprecated prefer {@link #onDownloadSubscription(int, int,
* DownloadableSubscription, boolean, boolean, Bundle)}
*/
@Deprecated
public DownloadSubscriptionResult onDownloadSubscription(int slotId,
DownloadableSubscription subscription, boolean switchAfterDownload,
boolean forceDeactivateSim, Bundle resolvedBundle) {
@NonNull DownloadableSubscription subscription, boolean switchAfterDownload,
boolean forceDeactivateSim, @Nullable Bundle resolvedBundle) {
return null;
}
/**
* Download the given subscription.
*
* @param slotIndex Index of the SIM slot to use for the operation.
* @param portIndex Index of the port from the slot. portIndex is used when
* switchAfterDownload is set to {@code true}, otherwise download is port agnostic.
* @param subscription The subscription to download.
* @param switchAfterDownload If true, the subscription should be enabled upon successful
* download.
* @param forceDeactivateSim If true, and if an active SIM must be deactivated to access the
* eUICC, perform this action automatically. Otherwise, {@link #RESULT_MUST_DEACTIVATE_SIM}
* should be returned to allow the user to consent to this operation first.
* @param resolvedBundle The bundle containing information on resolved errors. It can contain
* a string of confirmation code for the key {@link #EXTRA_RESOLUTION_CONFIRMATION_CODE},
* and a boolean for key {@link #EXTRA_RESOLUTION_ALLOW_POLICY_RULES} indicating whether
* the user allows profile policy rules or not.
* @return a DownloadSubscriptionResult instance including a result code, a resolvable errors
* bit map, and original the card Id. The result code may be one of the predefined
* {@code RESULT_} constants or any implementation-specific code starting with
* {@link #RESULT_FIRST_USER}. The resolvable error bit map can be either 0 or values
* defined in {@code RESOLVABLE_ERROR_}.
* @see android.telephony.euicc.EuiccManager#downloadSubscription
*/
@NonNull
public DownloadSubscriptionResult onDownloadSubscription(int slotIndex, int portIndex,
@NonNull DownloadableSubscription subscription, boolean switchAfterDownload,
boolean forceDeactivateSim, @NonNull Bundle resolvedBundle) {
// stub implementation, LPA needs to implement this
throw new UnsupportedOperationException("LPA must override onDownloadSubscription");
}
/**
* Download the given subscription.
*
@ -329,8 +362,8 @@ public abstract class EuiccService extends Service {
* {@link #onDownloadSubscription(int, DownloadableSubscription, boolean, boolean, Bundle)}. The
* default return value for this one is Integer.MIN_VALUE.
*/
@Deprecated public int onDownloadSubscription(int slotId,
DownloadableSubscription subscription, boolean switchAfterDownload,
@Deprecated public @Result int onDownloadSubscription(int slotId,
@NonNull DownloadableSubscription subscription, boolean switchAfterDownload,
boolean forceDeactivateSim) {
return Integer.MIN_VALUE;
}

View file

@ -37,7 +37,7 @@ interface LocalProfileAssistant {
fun deleteProfile(iccid: String): Boolean
fun downloadProfile(smdp: String, matchingId: String?, imei: String?,
confirmationCode: String?, callback: ProfileDownloadCallback)
confirmationCode: String?, preview: Boolean, callback: ProfileDownloadCallback)
fun deleteNotification(seqNumber: Long): Boolean
fun handleNotification(seqNumber: Long): Boolean

View file

@ -28,7 +28,7 @@ internal object LpacJni {
// es9p + es10b
// We do not expose all of the functions because of tediousness :)
external fun downloadProfile(handle: Long, smdp: String, matchingId: String?, imei: String?,
confirmationCode: String?, callback: ProfileDownloadCallback): Int
confirmationCode: String?, preview: Boolean, callback: ProfileDownloadCallback): Int
external fun downloadErrCodeToString(code: Int): String
external fun handleNotification(handle: Long, seqNumber: Long): Int
// Cancel any ongoing es9p and/or es10b sessions

View file

@ -21,5 +21,6 @@ interface ProfileDownloadCallback {
Finalizing(80), // load bpp
}
fun onMetadataReceived(profileName: String, iccid: String, appCerts: String)
fun onStateUpdate(state: DownloadState)
}

View file

@ -202,13 +202,14 @@ class LocalProfileAssistantImpl(
@Synchronized
override fun downloadProfile(smdp: String, matchingId: String?, imei: String?,
confirmationCode: String?, callback: ProfileDownloadCallback) {
confirmationCode: String?, preview: Boolean, callback: ProfileDownloadCallback) {
val res = LpacJni.downloadProfile(
contextHandle,
smdp,
matchingId,
imei,
confirmationCode,
preview,
callback
)

View file

@ -1,5 +1,7 @@
#include <euicc/es8p.h>
#include <euicc/es9p.h>
#include <euicc/es10b.h>
#include <euicc/hexutil.h>
#include <stdlib.h>
#include <string.h>
#include <syslog.h>
@ -60,7 +62,7 @@ JNIEXPORT jint JNICALL
Java_net_typeblog_lpac_1jni_LpacJni_downloadProfile(JNIEnv *env, jobject thiz, jlong handle,
jstring smdp, jstring matching_id,
jstring imei, jstring confirmation_code,
jobject callback) {
jboolean preview, jobject callback) {
struct euicc_ctx *ctx = (struct euicc_ctx *) handle;
struct es10b_load_bound_profile_package_result es10b_load_bound_profile_package_result;
const char *_confirmation_code = NULL;
@ -109,6 +111,63 @@ Java_net_typeblog_lpac_1jni_LpacJni_downloadProfile(JNIEnv *env, jobject thiz, j
goto out;
}
if (ctx->http._internal.prepare_download_param->b64_profileMetadata) {
struct es8p_metadata *profile_metadata = NULL;
ret = es8p_metadata_parse(&profile_metadata, ctx->http._internal.prepare_download_param->b64_profileMetadata);
if (ret == 0) {
const char *profile_name = profile_metadata->serviceProviderName ? profile_metadata->serviceProviderName : "Unknown";
const char *iccid = (profile_metadata->iccid[0] != '\0') ? profile_metadata->iccid : "Unknown";
size_t totalLength = 0;
for (int i = 0; i < profile_metadata->numRefArDo; i++) {
if (profile_metadata->refArDoList[i].deviceAppIdRefDo) {
totalLength += (profile_metadata->refArDoList[i].deviceAppIdLength * 2) + 1;
}
}
char *certSignatures = malloc(totalLength + 1);
if (certSignatures) {
certSignatures[0] = '\0';
for (int i = 0; i < profile_metadata->numRefArDo; i++) {
if (profile_metadata->refArDoList[i].deviceAppIdRefDo) {
char deviceAppIdHex[profile_metadata->refArDoList[i].deviceAppIdLength * 2 + 1];
euicc_hexutil_bin2hex(deviceAppIdHex, sizeof(deviceAppIdHex),
profile_metadata->refArDoList[i].deviceAppIdRefDo,
profile_metadata->refArDoList[i].deviceAppIdLength);
strcat(certSignatures, deviceAppIdHex);
strcat(certSignatures, ",");
}
}
if (strlen(certSignatures) > 0 && certSignatures[strlen(certSignatures) - 1] == ',') {
certSignatures[strlen(certSignatures) - 1] = '\0';
}
}
jclass callbackClass = (*env)->GetObjectClass(env, callback);
jmethodID onMetadataReceived = (*env)->GetMethodID(env, callbackClass, "onMetadataReceived",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V");
if (onMetadataReceived) {
jstring jProfileName = (*env)->NewStringUTF(env, profile_name);
jstring jIccid = (*env)->NewStringUTF(env, iccid);
jstring jCertSignatures = (*env)->NewStringUTF(env, certSignatures ? certSignatures : "");
(*env)->CallVoidMethod(env, callback, onMetadataReceived, jProfileName, jIccid, jCertSignatures);
(*env)->DeleteLocalRef(env, jProfileName);
(*env)->DeleteLocalRef(env, jIccid);
(*env)->DeleteLocalRef(env, jCertSignatures);
}
es8p_metadata_free(&profile_metadata);
}
if (preview) {
goto out;
}
}
(*env)->CallVoidMethod(env, callback, on_state_update, download_state_downloading);
ret = es10b_prepare_download(ctx, _confirmation_code);
syslog(LOG_INFO, "es10b_prepare_download %d", ret);
@ -178,4 +237,4 @@ Java_net_typeblog_lpac_1jni_LpacJni_downloadErrCodeToString(JNIEnv *env, jobject
default:
return toJString(env, "ES10B_ERROR_REASON_UNDEFINED");
}
}
}