From 571a5be347df88ea4a8f82adc3865875d55db87a Mon Sep 17 00:00:00 2001 From: Alexander Koskovich Date: Sun, 26 Jan 2025 15:21:24 -0500 Subject: [PATCH 01/99] feat: Add support for profile downloads via carrier apps Test: Download eSIM via Google Fi Change-Id: I4c8e5560b71d16a280a666ca6b06d257b9d70e05 --- .../core/LocalProfileAssistantWrapper.kt | 3 +- .../service/EuiccChannelManagerService.kt | 4 + .../ui/wizard/DownloadWizardActivity.kt | 2 + .../wizard/DownloadWizardProgressFragment.kt | 1 + app/src/main/AndroidManifest.xml | 12 ++ .../openeuicc/service/OpenEuiccService.kt | 181 +++++++++++++++++- .../angry/openeuicc/ui/UserConsentActivity.kt | 94 +++++++++ app/src/main/res/values/strings.xml | 5 + app/src/main/res/values/themes.xml | 13 ++ .../android/service/euicc/EuiccService.java | 41 +++- .../lpac_jni/LocalProfileAssistant.kt | 2 +- .../java/net/typeblog/lpac_jni/LpacJni.kt | 2 +- .../lpac_jni/ProfileDownloadCallback.kt | 1 + .../impl/LocalProfileAssistantImpl.kt | 3 +- .../src/main/jni/lpac-jni/lpac-download.c | 63 +++++- 15 files changed, 414 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/im/angry/openeuicc/ui/UserConsentActivity.kt create mode 100644 app/src/main/res/values/themes.xml diff --git a/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt b/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt index b715ca08f..cd62fca42 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt @@ -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) diff --git a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt index 760f1af86..07c8bd4ec 100644 --- a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt +++ b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt @@ -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 = diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt index e342dee6f..11503ac57 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt @@ -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, diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt index 1b816d48f..9fd7c2168 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt @@ -160,6 +160,7 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep state.smdp, state.matchingId, state.confirmationCode, + state.preview, state.imei ) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cae19d3b1..4f5083e62 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -53,6 +53,18 @@ + + + + + + + + + + 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( diff --git a/app/src/main/java/im/angry/openeuicc/ui/UserConsentActivity.kt b/app/src/main/java/im/angry/openeuicc/ui/UserConsentActivity.kt new file mode 100644 index 000000000..d425a3be8 --- /dev/null +++ b/app/src/main/java/im/angry/openeuicc/ui/UserConsentActivity.kt @@ -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() + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 47c88bdd0..041bf0abf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,4 +22,9 @@ Your device supports eSIMs. To connect to mobile network, download your eSIM issued by a carrier, or insert a physical SIM. Skip Download eSIM + + Allow your carrier to download SIM? + Your SIM will be used immediately after download + No thanks + Yes \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..54cdad2bb --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,13 @@ + + + diff --git a/libs/hidden-apis-stub/src/main/java/android/service/euicc/EuiccService.java b/libs/hidden-apis-stub/src/main/java/android/service/euicc/EuiccService.java index a1cd43bba..629b8d80b 100644 --- a/libs/hidden-apis-stub/src/main/java/android/service/euicc/EuiccService.java +++ b/libs/hidden-apis-stub/src/main/java/android/service/euicc/EuiccService.java @@ -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; } diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LocalProfileAssistant.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LocalProfileAssistant.kt index 48ab1c54a..5056da3b1 100644 --- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LocalProfileAssistant.kt +++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LocalProfileAssistant.kt @@ -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 diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LpacJni.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LpacJni.kt index 370fcabc4..956736e6f 100644 --- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LpacJni.kt +++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LpacJni.kt @@ -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 diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/ProfileDownloadCallback.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/ProfileDownloadCallback.kt index 289ddf6b0..aa37540ed 100644 --- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/ProfileDownloadCallback.kt +++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/ProfileDownloadCallback.kt @@ -21,5 +21,6 @@ interface ProfileDownloadCallback { Finalizing(80), // load bpp } + fun onMetadataReceived(profileName: String, iccid: String, appCerts: String) fun onStateUpdate(state: DownloadState) } \ No newline at end of file diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt index 7310acd73..ff1a9b730 100644 --- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt +++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt @@ -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 ) diff --git a/libs/lpac-jni/src/main/jni/lpac-jni/lpac-download.c b/libs/lpac-jni/src/main/jni/lpac-jni/lpac-download.c index 028e30d10..04bc331f4 100644 --- a/libs/lpac-jni/src/main/jni/lpac-jni/lpac-download.c +++ b/libs/lpac-jni/src/main/jni/lpac-jni/lpac-download.c @@ -1,5 +1,7 @@ +#include #include #include +#include #include #include #include @@ -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"); } -} \ No newline at end of file +} From f5074acae2abf0f9c5f2efd3a8ced5fce9c43d2a Mon Sep 17 00:00:00 2001 From: septs Date: Tue, 4 Feb 2025 22:02:58 -0500 Subject: [PATCH 02/99] feat: alert when confirmation code is required by QR code Closes #136 Co-authored-by: septs --- .../DownloadWizardMethodSelectFragment.kt | 23 +++++++++++++------ .../im/angry/openeuicc/util/ActivationCode.kt | 23 +++++++++++++++++++ app-common/src/main/res/values-ja/strings.xml | 2 ++ .../src/main/res/values-zh-rCN/strings.xml | 2 ++ app-common/src/main/res/values/strings.xml | 2 ++ 5 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt index 6203364ea..2846fd776 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt @@ -124,9 +124,22 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard processLpaString(text.toString()) } - private fun processLpaString(s: String) { - val components = s.split("$") - if (components.size < 3 || components[0] != "LPA:1") { + private fun processLpaString(input: String) { + try { + val parsed = ActivationCode.fromString(input) + state.smdp = parsed.address + state.matchingId = parsed.matchingId + if (parsed.confirmationCodeRequired) { + AlertDialog.Builder(requireContext()).apply { + setTitle(R.string.profile_download_required_confirmation_code) + setMessage(R.string.profile_download_required_confirmation_code_message) + setCancelable(true) + setPositiveButton(android.R.string.ok, null) + show() + } + } + gotoNextFragment(DownloadWizardDetailsFragment()) + } catch (e: IllegalArgumentException) { AlertDialog.Builder(requireContext()).apply { setTitle(R.string.profile_download_incorrect_lpa_string) setMessage(R.string.profile_download_incorrect_lpa_string_message) @@ -134,11 +147,7 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard setNegativeButton(android.R.string.cancel, null) show() } - return } - state.smdp = components[1] - state.matchingId = components[2] - gotoNextFragment(DownloadWizardDetailsFragment()) } private class DownloadMethodViewHolder(private val root: View) : ViewHolder(root) { diff --git a/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt b/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt new file mode 100644 index 000000000..3aca0d63c --- /dev/null +++ b/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt @@ -0,0 +1,23 @@ +package im.angry.openeuicc.util + +data class ActivationCode( + val address: String, + val matchingId: String? = null, + val oid: String? = null, + val confirmationCodeRequired: Boolean = false, +) { + companion object { + fun fromString(input: String): ActivationCode { + val components = input.removePrefix("LPA:").split('$') + if (components.size < 2 || components[0] != "1") { + throw IllegalArgumentException("Invalid activation code format") + } + return ActivationCode( + address = components[1].trim(), + matchingId = components.getOrNull(2)?.trim()?.ifBlank { null }, + oid = components.getOrNull(3)?.trim()?.ifBlank { null }, + confirmationCodeRequired = components.getOrNull(4)?.trim() == "1" + ) + } + } +} \ No newline at end of file diff --git a/app-common/src/main/res/values-ja/strings.xml b/app-common/src/main/res/values-ja/strings.xml index b592ec3ad..1710a7ddf 100644 --- a/app-common/src/main/res/values-ja/strings.xml +++ b/app-common/src/main/res/values-ja/strings.xml @@ -144,4 +144,6 @@ テスティング 準備中 動作中 + 要確認コード + スキャンされた QR コード、又はクリップボードからペーストされた LPA コードには、確認コードの必要が表示されています。 diff --git a/app-common/src/main/res/values-zh-rCN/strings.xml b/app-common/src/main/res/values-zh-rCN/strings.xml index cf517346f..2cadc03db 100644 --- a/app-common/src/main/res/values-zh-rCN/strings.xml +++ b/app-common/src/main/res/values-zh-rCN/strings.xml @@ -144,4 +144,6 @@ 无视 SM-DP+ 的 TLS 证书 允许 RSP 服务器使用任意证书 无信息 + 需要确认码 + 您扫描的二维码或粘贴的 LPA 码需要一个额外的确认码 \ No newline at end of file diff --git a/app-common/src/main/res/values/strings.xml b/app-common/src/main/res/values/strings.xml index d3bce0025..71e2418ad 100644 --- a/app-common/src/main/res/values/strings.xml +++ b/app-common/src/main/res/values/strings.xml @@ -57,6 +57,8 @@ This download may fail This download may fail due to low remaining capacity. No LPA code found in clipboard + Confirmation Code Required + Please provide a confirmation code as required by the scanned QR code or LPA code from clipboard. Unable to parse Could not parse QR code or clipboard content as a LPA code. From 9517f53712eb125cead4139a69e897c612649388 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 15 Feb 2025 14:26:45 -0500 Subject: [PATCH 03/99] chore: Update lpac dependency --- libs/lpac-jni/src/main/jni/lpac | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/lpac-jni/src/main/jni/lpac b/libs/lpac-jni/src/main/jni/lpac index a5a0516f0..90f710484 160000 --- a/libs/lpac-jni/src/main/jni/lpac +++ b/libs/lpac-jni/src/main/jni/lpac @@ -1 +1 @@ -Subproject commit a5a0516f084936e7e87cf7420fb99283fa3052ef +Subproject commit 90f7104847d4bb392b275746da20a55177a67573 From 03bfdf373c59e973d78f7549646ba2f0c91fd937 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Mon, 17 Feb 2025 17:07:11 -0500 Subject: [PATCH 04/99] lpac-jni: Expose customizable ISDR AIDs from lpac ...so that we could expose an option to the user going forward. --- .../im/angry/openeuicc/core/EuiccChannelImpl.kt | 11 ++++++++++- .../src/main/java/net/typeblog/lpac_jni/LpacJni.kt | 6 +++++- .../lpac_jni/impl/LocalProfileAssistantImpl.kt | 3 ++- libs/lpac-jni/src/main/jni/lpac-jni/lpac-jni.c | 14 ++++++++++++++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt index a82cb970c..3da829a10 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt @@ -15,12 +15,21 @@ class EuiccChannelImpl( verboseLoggingFlow: Flow, ignoreTLSCertificateFlow: Flow ) : EuiccChannel { + companion object { + // TODO: This needs to go somewhere else. + val ISDR_AID = "A0000005591010FFFFFFFF8900000100".decodeHex() + } + override val slotId = port.card.physicalSlotIndex override val logicalSlotId = port.logicalSlotIndex override val portId = port.portIndex override val lpa: LocalProfileAssistant = - LocalProfileAssistantImpl(apduInterface, HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow)) + LocalProfileAssistantImpl( + ISDR_AID, + apduInterface, + HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow) + ) override val atr: ByteArray? get() = (apduInterface as? ApduInterfaceAtrProvider)?.atr diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LpacJni.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LpacJni.kt index 370fcabc4..fa9474fe3 100644 --- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LpacJni.kt +++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LpacJni.kt @@ -5,7 +5,11 @@ internal object LpacJni { System.loadLibrary("lpac-jni") } - external fun createContext(apduInterface: ApduInterface, httpInterface: HttpInterface): Long + external fun createContext( + isdrAid: ByteArray, + apduInterface: ApduInterface, + httpInterface: HttpInterface + ): Long external fun destroyContext(handle: Long) external fun euiccInit(handle: Long): Int diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt index 7310acd73..8aafe94cd 100644 --- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt +++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt @@ -12,6 +12,7 @@ import net.typeblog.lpac_jni.LocalProfileNotification import net.typeblog.lpac_jni.ProfileDownloadCallback class LocalProfileAssistantImpl( + isdrAid: ByteArray, rawApduInterface: ApduInterface, rawHttpInterface: HttpInterface ): LocalProfileAssistant { @@ -76,7 +77,7 @@ class LocalProfileAssistantImpl( private val httpInterface = HttpInterfaceWrapper(rawHttpInterface) private var finalized = false - private var contextHandle: Long = LpacJni.createContext(apduInterface, httpInterface) + private var contextHandle: Long = LpacJni.createContext(isdrAid, apduInterface, httpInterface) init { if (LpacJni.euiccInit(contextHandle) < 0) { diff --git a/libs/lpac-jni/src/main/jni/lpac-jni/lpac-jni.c b/libs/lpac-jni/src/main/jni/lpac-jni/lpac-jni.c index 38d4f3a34..6ea9d3e5e 100644 --- a/libs/lpac-jni/src/main/jni/lpac-jni/lpac-jni.c +++ b/libs/lpac-jni/src/main/jni/lpac-jni/lpac-jni.c @@ -37,17 +37,30 @@ jint JNI_OnLoad(JavaVM *vm, void *reserved) { JNIEXPORT jlong JNICALL Java_net_typeblog_lpac_1jni_LpacJni_createContext(JNIEnv *env, jobject thiz, + jbyteArray isdr_aid, jobject apdu_interface, jobject http_interface) { struct lpac_jni_ctx *jni_ctx = NULL; struct euicc_ctx *ctx = NULL; + jbyte *isdr_java = NULL; + uint32_t isdr_len = 0; + uint8_t *isdr_c = NULL; ctx = calloc(1, sizeof(struct euicc_ctx)); jni_ctx = calloc(1, sizeof(struct lpac_jni_ctx)); + + isdr_java = (*env)->GetByteArrayElements(env, isdr_aid, JNI_FALSE); + isdr_len = (*env)->GetArrayLength(env, isdr_aid); + isdr_c = calloc(isdr_len, sizeof(uint8_t)); + memcpy(isdr_c, isdr_java, isdr_len); + (*env)->ReleaseByteArrayElements(env, isdr_aid, isdr_java, JNI_ABORT); + ctx->apdu.interface = &lpac_jni_apdu_interface; ctx->http.interface = &lpac_jni_http_interface; jni_ctx->apdu_interface = (*env)->NewGlobalRef(env, apdu_interface); jni_ctx->http_interface = (*env)->NewGlobalRef(env, http_interface); + ctx->aid = (const uint8_t *) isdr_c; + ctx->aid_len = isdr_len; ctx->userdata = (void *) jni_ctx; return (jlong) ctx; } @@ -60,6 +73,7 @@ Java_net_typeblog_lpac_1jni_LpacJni_destroyContext(JNIEnv *env, jobject thiz, jl (*env)->DeleteGlobalRef(env, jni_ctx->apdu_interface); (*env)->DeleteGlobalRef(env, jni_ctx->http_interface); free(jni_ctx); + free((void *) ctx->aid); free(ctx); } From c8ecdee09546191550db0b1f292ed26e9894b39b Mon Sep 17 00:00:00 2001 From: septs Date: Wed, 26 Feb 2025 14:33:18 +0100 Subject: [PATCH 05/99] feat: supports for multi logical channel (#148) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/148 Co-authored-by: septs Co-committed-by: septs --- .../im/angry/openeuicc/core/EuiccChannel.kt | 6 ++ .../angry/openeuicc/core/EuiccChannelImpl.kt | 5 +- .../openeuicc/core/EuiccChannelWrapper.kt | 3 + .../openeuicc/core/OmapiApduInterface.kt | 36 ++++++------ .../openeuicc/core/usb/UsbApduInterface.kt | 39 +++++++------ .../core/TelephonyManagerApduInterface.kt | 57 +++++++------------ .../util/PrivilegedTelephonyCompat.kt | 23 ++++++-- .../net/typeblog/lpac_jni/ApduInterface.kt | 13 ++++- .../impl/LocalProfileAssistantImpl.kt | 4 +- .../src/main/jni/lpac-jni/interface-wrapper.c | 16 ++++-- .../lpac-jni/src/main/jni/lpac-jni/lpac-jni.c | 2 +- .../lpac-jni/src/main/jni/lpac-jni/lpac-jni.h | 1 + 12 files changed, 117 insertions(+), 88 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt index 5f399ea3a..597a70d26 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt @@ -1,6 +1,7 @@ package im.angry.openeuicc.core import im.angry.openeuicc.util.* +import net.typeblog.lpac_jni.ApduInterface import net.typeblog.lpac_jni.LocalProfileAssistant interface EuiccChannel { @@ -28,5 +29,10 @@ interface EuiccChannel { */ val intrinsicChannelName: String? + /** + * The underlying APDU interface for this channel + */ + val apduInterface: ApduInterface + fun close() } \ No newline at end of file diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt index 3da829a10..a56b1ccb9 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt @@ -1,6 +1,7 @@ package im.angry.openeuicc.core -import im.angry.openeuicc.util.* +import im.angry.openeuicc.util.UiccPortInfoCompat +import im.angry.openeuicc.util.decodeHex import kotlinx.coroutines.flow.Flow import net.typeblog.lpac_jni.ApduInterface import net.typeblog.lpac_jni.LocalProfileAssistant @@ -11,7 +12,7 @@ class EuiccChannelImpl( override val type: String, override val port: UiccPortInfoCompat, override val intrinsicChannelName: String?, - private val apduInterface: ApduInterface, + override val apduInterface: ApduInterface, verboseLoggingFlow: Flow, ignoreTLSCertificateFlow: Flow ) : EuiccChannel { diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelWrapper.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelWrapper.kt index 4204e8266..09004d358 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelWrapper.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelWrapper.kt @@ -1,6 +1,7 @@ package im.angry.openeuicc.core import im.angry.openeuicc.util.* +import net.typeblog.lpac_jni.ApduInterface import net.typeblog.lpac_jni.LocalProfileAssistant class EuiccChannelWrapper(orig: EuiccChannel) : EuiccChannel { @@ -33,6 +34,8 @@ class EuiccChannelWrapper(orig: EuiccChannel) : EuiccChannel { get() = channel.valid override val intrinsicChannelName: String? get() = channel.intrinsicChannelName + override val apduInterface: ApduInterface + get() = channel.apduInterface override val atr: ByteArray? get() = channel.atr diff --git a/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt b/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt index c70669d49..b3f42b526 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt @@ -7,7 +7,6 @@ import android.util.Log import im.angry.openeuicc.util.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.single import kotlinx.coroutines.runBlocking import net.typeblog.lpac_jni.ApduInterface @@ -21,7 +20,12 @@ class OmapiApduInterface( } private lateinit var session: Session - private lateinit var lastChannel: Channel + private val channels = arrayOf( + null, + null, + null, + null, + ) override val valid: Boolean get() = service.isConnected && (this::session.isInitialized && !session.isClosed) @@ -38,24 +42,24 @@ class OmapiApduInterface( } override fun logicalChannelOpen(aid: ByteArray): Int { - check(!this::lastChannel.isInitialized) { - "Can only open one channel" - } - lastChannel = session.openLogicalChannel(aid)!! - return 1 + val channel = session.openLogicalChannel(aid) + check(channel != null) { "Failed to open logical channel (${aid.encodeHex()})" } + val index = channels.indexOf(null) + check(index != -1) { "No free logical channel slots" } + synchronized(channels) { channels[index] = channel } + return index } override fun logicalChannelClose(handle: Int) { - check(handle == 1 && !this::lastChannel.isInitialized) { - "Unknown channel" - } - lastChannel.close() + val channel = channels.getOrNull(handle) + check(channel != null) { "Invalid logical channel handle $handle" } + if (channel.isOpen) channel.close() + synchronized(channels) { channels[handle] = null } } - override fun transmit(tx: ByteArray): ByteArray { - check(this::lastChannel.isInitialized) { - "Unknown channel" - } + override fun transmit(handle: Int, tx: ByteArray): ByteArray { + val channel = channels.getOrNull(handle) + check(channel != null) { "Invalid logical channel handle $handle" } if (runBlocking { verboseLoggingFlow.first() }) { Log.d(TAG, "OMAPI APDU: ${tx.encodeHex()}") @@ -63,7 +67,7 @@ class OmapiApduInterface( try { for (i in 0..10) { - val res = lastChannel.transmit(tx) + val res = channel.transmit(tx) if (runBlocking { verboseLoggingFlow.first() }) { Log.d(TAG, "OMAPI APDU response: ${res.encodeHex()}") } diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt index 624ef894b..f9e764b50 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt @@ -21,10 +21,13 @@ class UsbApduInterface( private lateinit var ccidDescription: UsbCcidDescription private lateinit var transceiver: UsbCcidTransceiver - private var channelId = -1 - override var atr: ByteArray? = null + override val valid: Boolean + get() = channels.isNotEmpty() + + private var channels = mutableSetOf() + override fun connect() { ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!! @@ -46,11 +49,11 @@ class UsbApduInterface( override fun disconnect() { conn.close() + + atr = null } override fun logicalChannelOpen(aid: ByteArray): Int { - check(channelId == -1) { "Logical channel already opened" } - // OPEN LOGICAL CHANNEL val req = manageChannelCmd(true, 0) @@ -66,7 +69,7 @@ class UsbApduInterface( return -1 } - channelId = resp[0].toInt() + val channelId = resp[0].toInt() Log.d(TAG, "channelId = $channelId") // Then, select AID @@ -78,32 +81,32 @@ class UsbApduInterface( return -1 } + channels.add(channelId) + return channelId } override fun logicalChannelClose(handle: Int) { - check(handle == channelId) { "Logical channel ID mismatch" } - check(channelId != -1) { "Logical channel is not opened" } - + check(channels.contains(handle)) { + "Invalid logical channel handle $handle" + } // CLOSE LOGICAL CHANNEL - val req = manageChannelCmd(false, channelId.toByte()) - val resp = transmitApduByChannel(req, channelId.toByte()) + val req = manageChannelCmd(false, handle.toByte()) + val resp = transmitApduByChannel(req, handle.toByte()) if (!isSuccessResponse(resp)) { Log.d(TAG, "CLOSE LOGICAL CHANNEL failed: ${resp.encodeHex()}") } - - channelId = -1 + channels.remove(handle) } - override fun transmit(tx: ByteArray): ByteArray { - check(channelId != -1) { "Logical channel is not opened" } - return transmitApduByChannel(tx, channelId.toByte()) + override fun transmit(handle: Int, tx: ByteArray): ByteArray { + check(channels.contains(handle)) { + "Invalid logical channel handle $handle" + } + return transmitApduByChannel(tx, handle.toByte()) } - override val valid: Boolean - get() = channelId != -1 - private fun isSuccessResponse(resp: ByteArray): Boolean = resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte() diff --git a/app/src/main/java/im/angry/openeuicc/core/TelephonyManagerApduInterface.kt b/app/src/main/java/im/angry/openeuicc/core/TelephonyManagerApduInterface.kt index 6b093680a..f0b190968 100644 --- a/app/src/main/java/im/angry/openeuicc/core/TelephonyManagerApduInterface.kt +++ b/app/src/main/java/im/angry/openeuicc/core/TelephonyManagerApduInterface.kt @@ -18,12 +18,10 @@ class TelephonyManagerApduInterface( const val TAG = "TelephonyManagerApduInterface" } - private var lastChannel: Int = -1 - override val valid: Boolean - // TelephonyManager channels will never become truly "invalid", - // just that transactions might return errors or nonsense - get() = lastChannel != -1 + get() = channels.isNotEmpty() + + private var channels = mutableSetOf() override fun connect() { // Do nothing @@ -31,52 +29,39 @@ class TelephonyManagerApduInterface( override fun disconnect() { // Do nothing - lastChannel = -1 } override fun logicalChannelOpen(aid: ByteArray): Int { - check(lastChannel == -1) { "Already initialized" } val hex = aid.encodeHex() val channel = tm.iccOpenLogicalChannelByPortCompat(port.card.physicalSlotIndex, port.portIndex, hex, 0) if (channel.status != IccOpenLogicalChannelResponse.STATUS_NO_ERROR || channel.channel == IccOpenLogicalChannelResponse.INVALID_CHANNEL) { - throw IllegalArgumentException("Cannot open logical channel $hex via TelephonManager on slot ${port.card.physicalSlotIndex} port ${port.portIndex}") + throw IllegalArgumentException("Cannot open logical channel $hex via TelephonyManager on slot ${port.card.physicalSlotIndex} port ${port.portIndex}") } - lastChannel = channel.channel - return lastChannel + channels.add(channel.channel) + return channel.channel } override fun logicalChannelClose(handle: Int) { - check(handle == lastChannel) { "Invalid channel handle " } + check(channels.contains(handle)) { + "Invalid logical channel handle $handle" + } tm.iccCloseLogicalChannelByPortCompat(port.card.physicalSlotIndex, port.portIndex, handle) - lastChannel = -1 + channels.remove(handle) } - override fun transmit(tx: ByteArray): ByteArray { - check(lastChannel != -1) { "Uninitialized" } - + override fun transmit(handle: Int, tx: ByteArray): ByteArray { + check(channels.contains(handle)) { + "Invalid logical channel handle $handle" + } if (runBlocking { verboseLoggingFlow.first() }) { Log.d(TAG, "TelephonyManager APDU: ${tx.encodeHex()}") } - - val cla = tx[0].toUByte().toInt() - val instruction = tx[1].toUByte().toInt() - val p1 = tx[2].toUByte().toInt() - val p2 = tx[3].toUByte().toInt() - val p3 = tx[4].toUByte().toInt() - val p4 = tx.drop(5).toByteArray().encodeHex() - - return tm.iccTransmitApduLogicalChannelByPortCompat(port.card.physicalSlotIndex, port.portIndex, lastChannel, - cla, - instruction, - p1, - p2, - p3, - p4 - ).also { - if (runBlocking { verboseLoggingFlow.first() }) { - Log.d(TAG, "TelephonyManager APDU response: $it") - } - }?.decodeHex() ?: byteArrayOf() + val result = tm.iccTransmitApduLogicalChannelByPortCompat( + port.card.physicalSlotIndex, port.portIndex, handle, + tx, + ) + if (runBlocking { verboseLoggingFlow.first() }) + Log.d(TAG, "TelephonyManager APDU response: $result") + return result?.decodeHex() ?: byteArrayOf() } - } \ No newline at end of file diff --git a/app/src/main/java/im/angry/openeuicc/util/PrivilegedTelephonyCompat.kt b/app/src/main/java/im/angry/openeuicc/util/PrivilegedTelephonyCompat.kt index dbd39f20b..a9df18eb7 100644 --- a/app/src/main/java/im/angry/openeuicc/util/PrivilegedTelephonyCompat.kt +++ b/app/src/main/java/im/angry/openeuicc/util/PrivilegedTelephonyCompat.kt @@ -111,15 +111,26 @@ fun TelephonyManager.iccCloseLogicalChannelByPortCompat( } fun TelephonyManager.iccTransmitApduLogicalChannelByPortCompat( - slotIndex: Int, portIndex: Int, channel: Int, - cla: Int, inst: Int, p1: Int, p2: Int, p3: Int, data: String? -): String? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + slotIndex: Int, + portIndex: Int, + channel: Int, + tx: ByteArray +): String? { + val cla = tx[0].toUByte().toInt() + val ins = tx[1].toUByte().toInt() + val p1 = tx[2].toUByte().toInt() + val p2 = tx[3].toUByte().toInt() + val p3 = tx[4].toUByte().toInt() + val p4 = tx.drop(5).toByteArray().encodeHex() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { iccTransmitApduLogicalChannelByPort( - slotIndex, portIndex, channel, cla, inst, p1, p2, p3, data + slotIndex, portIndex, channel, + cla, ins, p1, p2, p3, p4 ) } else { iccTransmitApduLogicalChannelBySlot( - slotIndex, channel, cla, inst, p1, p2, p3, data + slotIndex, channel, + cla, ins, p1, p2, p3, p4 ) } +} diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/ApduInterface.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/ApduInterface.kt index dfa92dfa7..75a690543 100644 --- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/ApduInterface.kt +++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/ApduInterface.kt @@ -8,7 +8,7 @@ interface ApduInterface { fun disconnect() fun logicalChannelOpen(aid: ByteArray): Int fun logicalChannelClose(handle: Int) - fun transmit(tx: ByteArray): ByteArray + fun transmit(handle: Int, tx: ByteArray): ByteArray /** * Is this APDU connection still valid? @@ -16,4 +16,13 @@ interface ApduInterface { * callers should further check with the LPA to fully determine the validity of a channel */ val valid: Boolean -} \ No newline at end of file + + fun withLogicalChannel(aid: ByteArray, cb: ((ByteArray) -> ByteArray) -> T): T { + val handle = logicalChannelOpen(aid) + return try { + cb { transmit(handle, it) } + } finally { + logicalChannelClose(handle) + } + } +} diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt index 8aafe94cd..faf531262 100644 --- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt +++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt @@ -28,9 +28,9 @@ class LocalProfileAssistantImpl( var lastApduResponse: ByteArray? = null var lastApduException: Exception? = null - override fun transmit(tx: ByteArray): ByteArray = + override fun transmit(handle: Int, tx: ByteArray): ByteArray = try { - apduInterface.transmit(tx).also { + apduInterface.transmit(handle, tx).also { lastApduException = null lastApduResponse = it } diff --git a/libs/lpac-jni/src/main/jni/lpac-jni/interface-wrapper.c b/libs/lpac-jni/src/main/jni/lpac-jni/interface-wrapper.c index 9059171a4..a61fc9672 100644 --- a/libs/lpac-jni/src/main/jni/lpac-jni/interface-wrapper.c +++ b/libs/lpac-jni/src/main/jni/lpac-jni/interface-wrapper.c @@ -22,7 +22,7 @@ void interface_wrapper_init() { "([B)I"); method_apdu_logical_channel_close = (*env)->GetMethodID(env, apdu_class, "logicalChannelClose", "(I)V"); - method_apdu_transmit = (*env)->GetMethodID(env, apdu_class, "transmit", "([B)[B"); + method_apdu_transmit = (*env)->GetMethodID(env, apdu_class, "transmit", "(I[B)[B"); jclass http_class = (*env)->FindClass(env, "net/typeblog/lpac_jni/HttpInterface"); method_http_transmit = (*env)->GetMethodID(env, http_class, "transmit", @@ -53,24 +53,30 @@ apdu_interface_logical_channel_open(struct euicc_ctx *ctx, const uint8_t *aid, u jint ret = (*env)->CallIntMethod(env, LPAC_JNI_CTX(ctx)->apdu_interface, method_apdu_logical_channel_open, jbarr); LPAC_JNI_EXCEPTION_RETURN; + LPAC_JNI_CTX(ctx)->logical_channel_id = ret; return ret; } -static void apdu_interface_logical_channel_close(struct euicc_ctx *ctx, uint8_t channel) { +static void apdu_interface_logical_channel_close(struct euicc_ctx *ctx, + __attribute__((unused)) uint8_t channel) { LPAC_JNI_SETUP_ENV; + jint logical_channel_id = LPAC_JNI_CTX(ctx)->logical_channel_id; (*env)->CallVoidMethod(env, LPAC_JNI_CTX(ctx)->apdu_interface, - method_apdu_logical_channel_close, channel); + method_apdu_logical_channel_close, logical_channel_id); (*env)->ExceptionClear(env); } static int apdu_interface_transmit(struct euicc_ctx *ctx, uint8_t **rx, uint32_t *rx_len, const uint8_t *tx, uint32_t tx_len) { + const int logic_channel = LPAC_JNI_CTX(ctx)->logical_channel_id; LPAC_JNI_SETUP_ENV; jbyteArray txArr = (*env)->NewByteArray(env, tx_len); (*env)->SetByteArrayRegion(env, txArr, 0, tx_len, (const jbyte *) tx); - jbyteArray ret = (jbyteArray) (*env)->CallObjectMethod(env, LPAC_JNI_CTX(ctx)->apdu_interface, - method_apdu_transmit, txArr); + jbyteArray ret = (jbyteArray) (*env)->CallObjectMethod( + env, LPAC_JNI_CTX(ctx)->apdu_interface, + method_apdu_transmit, logic_channel, txArr + ); LPAC_JNI_EXCEPTION_RETURN; *rx_len = (*env)->GetArrayLength(env, ret); *rx = calloc(*rx_len, sizeof(uint8_t)); diff --git a/libs/lpac-jni/src/main/jni/lpac-jni/lpac-jni.c b/libs/lpac-jni/src/main/jni/lpac-jni/lpac-jni.c index 6ea9d3e5e..ca319db27 100644 --- a/libs/lpac-jni/src/main/jni/lpac-jni/lpac-jni.c +++ b/libs/lpac-jni/src/main/jni/lpac-jni/lpac-jni.c @@ -28,7 +28,7 @@ jint JNI_OnLoad(JavaVM *vm, void *reserved) { string_constructor = (*env)->GetMethodID(env, string_class, "", "([BLjava/lang/String;)V"); - const char _unused[1]; + const jchar _unused[1]; empty_string = (*env)->NewString(env, _unused, 0); empty_string = (*env)->NewGlobalRef(env, empty_string); diff --git a/libs/lpac-jni/src/main/jni/lpac-jni/lpac-jni.h b/libs/lpac-jni/src/main/jni/lpac-jni/lpac-jni.h index a5d6262d6..c2300beb2 100644 --- a/libs/lpac-jni/src/main/jni/lpac-jni/lpac-jni.h +++ b/libs/lpac-jni/src/main/jni/lpac-jni/lpac-jni.h @@ -8,6 +8,7 @@ _Static_assert(sizeof(void *) <= sizeof(jlong), "jlong must be big enough to hold a platform raw pointer"); struct lpac_jni_ctx { + jint logical_channel_id; jobject apdu_interface; jobject http_interface; }; From 1d67fa5cfa6d22dd1bbed4342916c8493d60dfc1 Mon Sep 17 00:00:00 2001 From: septs Date: Tue, 4 Mar 2025 03:12:40 +0100 Subject: [PATCH 06/99] feat: detect used product (#147) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/147 Co-authored-by: septs Co-committed-by: septs --- .idea/.gitignore | 1 + .../angry/openeuicc/ui/EuiccInfoActivity.kt | 43 +++++++--------- .../im/angry/openeuicc/vendored/estkme.kt | 49 +++++++++++++++++++ .../im/angry/openeuicc/vendored/simlink.kt | 20 ++++++++ app-common/src/main/res/values/strings.xml | 5 ++ .../java/net/typeblog/lpac_jni/EuiccInfo2.kt | 33 ++++++++++--- .../impl/LocalProfileAssistantImpl.kt | 45 +++++++++-------- .../lpac_jni/impl/RootCertificates.kt | 4 +- 8 files changed, 143 insertions(+), 57 deletions(-) create mode 100644 app-common/src/main/java/im/angry/openeuicc/vendored/estkme.kt create mode 100644 app-common/src/main/java/im/angry/openeuicc/vendored/simlink.kt diff --git a/.idea/.gitignore b/.idea/.gitignore index 0d51aca15..b7c2402ad 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -9,5 +9,6 @@ /navEditor.xml /runConfigurations.xml /workspace.xml +/AndroidProjectSystem.xml **/*.iml \ No newline at end of file diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt index e88ad0123..528b2324d 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt @@ -23,6 +23,8 @@ import im.angry.openeuicc.common.R import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.util.* +import im.angry.openeuicc.vendored.getESTKmeInfo +import im.angry.openeuicc.vendored.getSIMLinkVersion import kotlinx.coroutines.launch import net.typeblog.lpac_jni.impl.PKID_GSMA_LIVE_CI import net.typeblog.lpac_jni.impl.PKID_GSMA_TEST_CI @@ -100,24 +102,22 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { private fun buildEuiccInfoItems(channel: EuiccChannel) = buildList { add(Item(R.string.euicc_info_access_mode, channel.type)) - add( - Item( - R.string.euicc_info_removable, - formatByBoolean(channel.port.card.isRemovable, YES_NO) - ) - ) - add( - Item( - R.string.euicc_info_eid, - channel.lpa.eID, - copiedToastResId = R.string.toast_eid_copied - ) - ) + add(Item(R.string.euicc_info_removable, formatByBoolean(channel.port.card.isRemovable, YES_NO))) + add(Item(R.string.euicc_info_eid, channel.lpa.eID, copiedToastResId = R.string.toast_eid_copied)) + getESTKmeInfo(channel.apduInterface)?.let { + add(Item(R.string.euicc_info_sku, it.skuName)) + add(Item(R.string.euicc_info_sn, it.serialNumber, copiedToastResId = R.string.toast_sn_copied)) + add(Item(R.string.euicc_info_bl_ver, it.bootloaderVersion)) + add(Item(R.string.euicc_info_fw_ver, it.firmwareVersion)) + } + getSIMLinkVersion(channel.lpa.eID, channel.lpa.euiccInfo2?.euiccFirmwareVersion)?.let { + add(Item(R.string.euicc_info_sku, "9eSIM $it")) + } channel.lpa.euiccInfo2.let { info -> - add(Item(R.string.euicc_info_sgp22_version, info?.sgp22Version)) - add(Item(R.string.euicc_info_firmware_version, info?.euiccFirmwareVersion)) - add(Item(R.string.euicc_info_globalplatform_version, info?.globalPlatformVersion)) - add(Item(R.string.euicc_info_pp_version, info?.ppVersion)) + add(Item(R.string.euicc_info_sgp22_version, info?.sgp22Version.toString())) + add(Item(R.string.euicc_info_firmware_version, info?.euiccFirmwareVersion.toString())) + add(Item(R.string.euicc_info_globalplatform_version, info?.globalPlatformVersion.toString())) + add(Item(R.string.euicc_info_pp_version, info?.ppVersion.toString())) add(Item(R.string.euicc_info_sas_accreditation_number, info?.sasAccreditationNumber)) add(Item(R.string.euicc_info_free_nvram, info?.freeNvram?.let(::formatFreeSpace))) } @@ -134,13 +134,8 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { } add(Item(R.string.euicc_info_ci_type, getString(resId))) } - add( - Item( - R.string.euicc_info_atr, - channel.atr?.encodeHex() ?: getString(R.string.information_unavailable), - copiedToastResId = R.string.toast_atr_copied, - ) - ) + val atr = channel.atr?.encodeHex() ?: getString(R.string.information_unavailable) + add(Item(R.string.euicc_info_atr, atr, copiedToastResId = R.string.toast_atr_copied)) } private fun formatByBoolean(b: Boolean, res: Pair): String = diff --git a/app-common/src/main/java/im/angry/openeuicc/vendored/estkme.kt b/app-common/src/main/java/im/angry/openeuicc/vendored/estkme.kt new file mode 100644 index 000000000..228292149 --- /dev/null +++ b/app-common/src/main/java/im/angry/openeuicc/vendored/estkme.kt @@ -0,0 +1,49 @@ +package im.angry.openeuicc.vendored + +import android.util.Log +import im.angry.openeuicc.core.ApduInterfaceAtrProvider +import im.angry.openeuicc.util.TAG +import im.angry.openeuicc.util.decodeHex +import net.typeblog.lpac_jni.ApduInterface + +data class ESTKmeInfo( + val serialNumber: String?, + val bootloaderVersion: String?, + val firmwareVersion: String?, + val skuName: String?, +) + +fun isESTKmeATR(iface: ApduInterface): Boolean { + if (iface !is ApduInterfaceAtrProvider) return false + val atr = iface.atr ?: return false + val fpr = "estk.me".encodeToByteArray() + for (index in atr.indices) { + if (atr.size - index < fpr.size) break + if (atr.sliceArray(index until index + fpr.size).contentEquals(fpr)) return true + } + return false +} + +fun getESTKmeInfo(iface: ApduInterface): ESTKmeInfo? { + if (!isESTKmeATR(iface)) return null + fun decode(b: ByteArray): String? { + if (b.size < 2) return null + if (b[b.size - 2] != 0x90.toByte() || b[b.size - 1] != 0x00.toByte()) return null + return b.sliceArray(0 until b.size - 2).decodeToString() + } + return try { + iface.withLogicalChannel("A06573746B6D65FFFFFFFFFFFF6D6774".decodeHex()) { transmit -> + fun invoke(p1: Byte) = decode(transmit(byteArrayOf(0x00, 0x00, p1, 0x00, 0x00))) + ESTKmeInfo( + invoke(0x00), // serial number + invoke(0x01), // bootloader version + invoke(0x02), // firmware version + invoke(0x03), // sku name + ) + } + } catch (e: Exception) { + Log.d(TAG, "Failed to get ESTKmeInfo", e) + null + } +} + diff --git a/app-common/src/main/java/im/angry/openeuicc/vendored/simlink.kt b/app-common/src/main/java/im/angry/openeuicc/vendored/simlink.kt new file mode 100644 index 000000000..506f16c61 --- /dev/null +++ b/app-common/src/main/java/im/angry/openeuicc/vendored/simlink.kt @@ -0,0 +1,20 @@ +package im.angry.openeuicc.vendored + +import net.typeblog.lpac_jni.Version + +private val prefix = Regex("^89044045(84|21)67274948") // SIMLink EID prefix + +fun getSIMLinkVersion(eid: String, version: Version?): String? { + if (version == null || prefix.find(eid, 0) == null) return null + return when { + // @formatter:off + version >= Version(37, 1, 41) -> "v3.1 (beta 1)" + version >= Version(36, 18, 5) -> "v3 (final)" + version >= Version(36, 17, 39) -> "v3 (beta)" + version >= Version(36, 17, 4) -> "v2s" + version >= Version(36, 9, 3) -> "v2.1" + version >= Version(36, 7, 2) -> "v2" + // @formatter:on + else -> null + } +} diff --git a/app-common/src/main/res/values/strings.xml b/app-common/src/main/res/values/strings.xml index 71e2418ad..a45ce1f6c 100644 --- a/app-common/src/main/res/values/strings.xml +++ b/app-common/src/main/res/values/strings.xml @@ -31,6 +31,7 @@ Cannot switch to new eSIM profile. Confirmation string mismatch ICCID copied to clipboard + Serial Number copied to clipboard EID copied to clipboard ATR copied to clipboard @@ -125,6 +126,10 @@ eUICC Info (%s) Access Mode Removable + Product Name + Product Serial Number + Product Bootloader Version + Product Firmware Version EID SGP.22 Version eUICC OS Version diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/EuiccInfo2.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/EuiccInfo2.kt index 6c7305199..072004970 100644 --- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/EuiccInfo2.kt +++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/EuiccInfo2.kt @@ -2,14 +2,31 @@ package net.typeblog.lpac_jni /* Corresponds to EuiccInfo2 in SGP.22 */ data class EuiccInfo2( - val sgp22Version: String, - val profileVersion: String, - val euiccFirmwareVersion: String, - val globalPlatformVersion: String, + val sgp22Version: Version, + val profileVersion: Version, + val euiccFirmwareVersion: Version, + val globalPlatformVersion: Version, val sasAccreditationNumber: String, - val ppVersion: String, + val ppVersion: Version, val freeNvram: Int, val freeRam: Int, - val euiccCiPKIdListForSigning: Array, - val euiccCiPKIdListForVerification: Array, -) \ No newline at end of file + val euiccCiPKIdListForSigning: Set, + val euiccCiPKIdListForVerification: Set, +) + +data class Version( + val major: Int, + val minor: Int, + val patch: Int, +) { + constructor(version: String) : this(version.split('.').map(String::toInt)) + private constructor(parts: List) : this(parts[0], parts[1], parts[2]) + + operator fun compareTo(other: Version): Int { + if (major != other.major) return major - other.major + if (minor != other.minor) return minor - other.minor + return patch - other.patch + } + + override fun toString() = "$major.$minor.$patch" +} diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt index faf531262..3674f4f19 100644 --- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt +++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt @@ -10,6 +10,7 @@ import net.typeblog.lpac_jni.LocalProfileAssistant import net.typeblog.lpac_jni.LocalProfileInfo import net.typeblog.lpac_jni.LocalProfileNotification import net.typeblog.lpac_jni.ProfileDownloadCallback +import net.typeblog.lpac_jni.Version class LocalProfileAssistantImpl( isdrAid: ByteArray, @@ -84,8 +85,8 @@ class LocalProfileAssistantImpl( throw IllegalArgumentException("Failed to initialize LPA") } - val pkids = euiccInfo2?.euiccCiPKIdListForVerification ?: arrayOf() - httpInterface.usePublicKeyIds(pkids) + val pkids = euiccInfo2?.euiccCiPKIdListForVerification ?: setOf() + httpInterface.usePublicKeyIds(pkids.toTypedArray()) } override fun setEs10xMss(mss: Byte) { @@ -157,31 +158,29 @@ class LocalProfileAssistantImpl( val cInfo = LpacJni.es10cexGetEuiccInfo2(contextHandle) if (cInfo == 0L) return null - val euiccCiPKIdListForSigning = mutableListOf() - var curr = LpacJni.euiccInfo2GetEuiccCiPKIdListForSigning(cInfo) - while (curr != 0L) { - euiccCiPKIdListForSigning.add(LpacJni.stringDeref(curr)) - curr = LpacJni.stringArrNext(curr) - } - - val euiccCiPKIdListForVerification = mutableListOf() - curr = LpacJni.euiccInfo2GetEuiccCiPKIdListForVerification(cInfo) - while (curr != 0L) { - euiccCiPKIdListForVerification.add(LpacJni.stringDeref(curr)) - curr = LpacJni.stringArrNext(curr) - } - val ret = EuiccInfo2( - LpacJni.euiccInfo2GetSGP22Version(cInfo), - LpacJni.euiccInfo2GetProfileVersion(cInfo), - LpacJni.euiccInfo2GetEuiccFirmwareVersion(cInfo), - LpacJni.euiccInfo2GetGlobalPlatformVersion(cInfo), + Version(LpacJni.euiccInfo2GetSGP22Version(cInfo)), + Version(LpacJni.euiccInfo2GetProfileVersion(cInfo)), + Version(LpacJni.euiccInfo2GetEuiccFirmwareVersion(cInfo)), + Version(LpacJni.euiccInfo2GetGlobalPlatformVersion(cInfo)), LpacJni.euiccInfo2GetSasAcreditationNumber(cInfo), - LpacJni.euiccInfo2GetPpVersion(cInfo), + Version(LpacJni.euiccInfo2GetPpVersion(cInfo)), LpacJni.euiccInfo2GetFreeNonVolatileMemory(cInfo).toInt(), LpacJni.euiccInfo2GetFreeVolatileMemory(cInfo).toInt(), - euiccCiPKIdListForSigning.toTypedArray(), - euiccCiPKIdListForVerification.toTypedArray() + buildSet { + var cursor = LpacJni.euiccInfo2GetEuiccCiPKIdListForSigning(cInfo) + while (cursor != 0L) { + add(LpacJni.stringDeref(cursor)) + cursor = LpacJni.stringArrNext(cursor) + } + }, + buildSet { + var cursor = LpacJni.euiccInfo2GetEuiccCiPKIdListForVerification(cInfo) + while (cursor != 0L) { + add(LpacJni.stringDeref(cursor)) + cursor = LpacJni.stringArrNext(cursor) + } + }, ) LpacJni.euiccInfo2Free(cInfo) diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/RootCertificates.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/RootCertificates.kt index cfd5779f5..295a91158 100644 --- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/RootCertificates.kt +++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/RootCertificates.kt @@ -14,7 +14,7 @@ const val DEFAULT_PKID_GSMA_RSP2_ROOT_CI1 = "81370f5125d0b1d408d4c3b232e6d25e795 // List of GSMA Live CIs // https://www.gsma.com/solutions-and-impact/technologies/esim/gsma-root-ci/ -val PKID_GSMA_LIVE_CI = arrayOf( +val PKID_GSMA_LIVE_CI = setOf( // GSMA RSP2 Root CI1 (SGP.22 v2+v3, CA: DigiCert) // https://euicc-manual.osmocom.org/docs/pki/ci/files/81370f.txt DEFAULT_PKID_GSMA_RSP2_ROOT_CI1, @@ -25,7 +25,7 @@ val PKID_GSMA_LIVE_CI = arrayOf( // SGP.26 v3.0, 2023-12-01 // https://www.gsma.com/solutions-and-impact/technologies/esim/wp-content/uploads/2023/12/SGP.26-v3.0.pdf -val PKID_GSMA_TEST_CI = arrayOf( +val PKID_GSMA_TEST_CI = setOf( // Test CI (SGP.26, NIST P256) // https://euicc-manual.osmocom.org/docs/pki/ci/files/34eecf.txt "34eecf13156518d48d30bdf06853404d115f955d", From ef295c9d128de48cd9e22968dd6f81337bc6029b Mon Sep 17 00:00:00 2001 From: h0353914 Date: Tue, 4 Mar 2025 03:13:39 +0100 Subject: [PATCH 07/99] Add Traditional Chinese (zh-TW) translation (#142) Converted from Simplified Chinese Co-authored-by: h0353914 <45159732+h0353914@users.noreply.github.com> Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/142 Co-authored-by: h0353914 Co-committed-by: h0353914 --- .../src/main/res/values-zh-rTW/strings.xml | 147 ++++++++++++++++++ .../src/main/res/values-zh-rTW/strings.xml | 32 ++++ app/src/main/res/values-zh-rTW/strings.xml | 20 +++ 3 files changed, 199 insertions(+) create mode 100644 app-common/src/main/res/values-zh-rTW/strings.xml create mode 100644 app-unpriv/src/main/res/values-zh-rTW/strings.xml create mode 100644 app/src/main/res/values-zh-rTW/strings.xml diff --git a/app-common/src/main/res/values-zh-rTW/strings.xml b/app-common/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 000000000..28691a0cc --- /dev/null +++ b/app-common/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,147 @@ + + + 在此裝置上未檢測到此應用程式可訪問的可插拔 eUICC 卡。請插入相容卡或 USB 晶片讀卡機。 + 此 eSIM 上還沒有設定檔 + 未知 + 幫助 + 重新載入卡槽 + 虛擬卡槽 %d + 已啟用 + 已停用 + 電信業者: + 類型: + 啟用 + 停用 + 刪除 + 重新命名 + 等待 eSIM 切換設定檔時逾時。這可能是您手機基頻處理器韌體中的一個錯誤。請嘗試切換飛航模式、重新啟動應用程式或重新啟動手機 + 操作成功, 但是您手機的基頻處理器沒有重新整理。您可能需要切換飛航模式或重新啟動,以便使用新的設定檔。 + 無法切換到新的 eSIM 設定檔。 + 輸入的確認文字不匹配 + 已複製 ICCID 到剪貼簿 + 已複製 EID 到剪貼簿 + 已複製 ATR 到剪貼簿 + 授予 USB 權限 + 需要獲得訪問 USB 晶片讀卡機的權限。 + 無法透過 USB 晶片讀卡機連線到 eSIM。 + 長時間運行的背景作業 + 正在下載 eSIM 設定檔 + 無法下載 eSIM 設定檔 + 正在重新命名 eSIM 設定檔 + 無法重新命名 eSIM 設定檔 + 正在刪除 eSIM 設定檔 + 無法刪除 eSIM 設定檔 + 正在切換 eSIM 設定檔 + 無法切換 eSIM 設定檔 + 新增新 eSIM + 伺服器 (RSP / SM-DP+) + 啟用碼 + 確認碼 (可選) + IMEI (可選) + 本次下載可能會失敗 + 目前晶片的剩餘空間不足,可能導致配置下載失敗。\n是否繼續下載? + 日誌已儲存到指定路徑。需要透過其他 App 分享嗎? + 新名稱 + 無法將名稱編碼為 UTF-8 + 名稱長於 64 字元 + 重新命名設定檔時發生了未知錯誤 + 您確定要刪除 %s 嗎?此動作無法還原。 + 請輸入\'%s\'以確認刪除 + 通知列表 + 通知列表 (%s) + 管理通知 + eSIM 設定檔可以在下載、刪除、啟用或停用時向電信業者傳送通知。此處列出了要傳送的這些通知的佇列。\n\n在\"設定\"中,您可以指定是否自動傳送每種型別的通知。請注意,即使通知已傳送,也不會自動從記錄中刪除,除非佇列空間不足。\n\n在這裡,您可以手動傳送或刪除每個待處理的通知。 + 已下載 + 已刪除 + 已啟用 + 已停用 + 處理 + 刪除 + 儲存日誌 + %s 的日誌 + 設定 + 通知 + 變更 eSIM 設定檔會向電信業者傳送通知。根據需要在此處微調此行為。 + 下載 + 傳送 下載 設定檔的通知 + 刪除 + 傳送 刪除 設定檔的通知 + 切換 + 記錄詳細日誌 + 詳細日誌中包含敏感資訊,開啟此功能後請僅與你信任的人共享你的日誌。 + 日誌 + 檢視應用程式的最新除錯日誌 + 傳送 切換 設定檔的通知\n注意,這種型別的通知是不可靠的。 + 進階 + 允許 停用/刪除 已啟用的設定檔 + 預設情況下,此應用程式會阻止您停用可插拔 eSIM 中已啟用的設定檔。\n因為這樣做 有時 會導致無法存取。\n勾選此框以 移除 此保護措施。 + 資訊 + App 版本 + 原始碼 + 測試 + 準備中 + 可用 + 未在剪貼簿上發現 LPA 碼 + LPA 碼解析錯誤 + 無法將二維碼或剪貼簿內容解析為 LPA 碼 + 下載精靈 + 返回 + 下一步 + 您選擇的 SIM 卡已被移除 + 請選擇或確認下載目標 eSIM 卡槽: + 型別: + 可插拔 + 內建 + 內建, 埠 %d + 目前設定檔: + 剩餘空間: + 您想要如何下載 eSIM 設定檔? + 用相機掃描二維碼 + 從相簿選擇二維碼 + 從剪貼簿讀取 + 手動輸入 + 請輸入或確認下載 eSIM 的詳細資訊: + 正在下載您的 eSIM... + 準備中 + 正在連線到伺服器 + 正在向伺服器驗證您的裝置 + 正在下載 eSIM 設定檔 + 正在寫入 eSIM 設定檔 + 錯誤診斷 + 錯誤代碼: %s + 上次 HTTP 狀態碼 (來自伺服器): %d + 上次 HTTP 應答 (來自伺服器): + 上次 HTTP 錯誤: + 上次 APDU 應答 (來自 SIM): %s + 上次 APDU 應答 (來自 SIM) 是成功的 + 上次 APDU 應答 (來自 SIM) 是失敗的 + 上次 APDU 錯誤: + 儲存 + %s 的錯誤診斷 + eUICC 詳情 + eUICC 詳情 (%s) + 訪問方式 + 可插拔 + SGP.22 版本 + eUICC OS 版本 + GlobalPlatform 版本 + SAS 認證號碼 + Protected Profile 版本 + NVRAM 剩餘空間 (eSIM 儲存容量) + 證書簽發者 (CI) + GSMA 生產環境 CI + GSMA 測試 CI + 未知 eSIM CI + + + 還有 %d 步成為開發者 + 您現在是開發者了! + 語言 + 選擇 App 語言 + 開發人員選項 + 顯示未經過濾的設定檔列表 + 在設定檔列表中包括非生產環境的設定檔 + 忽略 SM-DP+ 的 TLS 證書 + 允許 RSP 伺服器使用任意證書 + 無資訊 + \ No newline at end of file diff --git a/app-unpriv/src/main/res/values-zh-rTW/strings.xml b/app-unpriv/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 000000000..b8d0eb83d --- /dev/null +++ b/app-unpriv/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,32 @@ + + 相容性檢查 + 啟動 SIM 卡應用程式 + 系統功能 + 您的裝置是否具有管理可插拔 eUICC 卡所需的所有功能。例如,基本的電話功能和 OMAPI 支援。 + 您的裝置沒有電話功能。 + 您的裝置/系統未宣告支援 OMAPI。這可能是由於缺少硬體支援,或者可能僅僅是由於缺少標誌。請參閱以下兩項檢查以確定 OMAPI 是否確實受支援。 + OMAPI 連線 + 您的裝置是否允許透過 OMAPI 存取 SIM 卡上的安全元件? + 無法透過 OMAPI 偵測到 SIM 卡的 Secure Element。如果您尚未在此裝置中插入 SIM 卡,請嘗試插入一張 SIM 卡並重試此檢查。 + 已成功檢測到可存取 Secure Element 的卡槽,但僅限於以下 SIM 卡槽:SIM%s + ISD-R 通道存取 + 您的裝置是否支援透過 OMAPI 開啟 eSIM 的 ISD-R (管理) 通道? + 無法確定是否支援透過 OMAPI 進行 ISD-R 的存取。如果尚未插入,您可能需要插入 SIM 卡 (任何 SIM 卡都可以) 重試。 + OMAPI 只能在以下 SIM 插槽上存取 ISD-R:SIM%s + 不在已知錯誤清單中 + 確保您的裝置不存在與可插拔 eSIM 相關的錯誤。 + 很抱歉,您的裝置在存取可插拔 eSIM 時存在錯誤。這並不表示完全無法使用,但我們不保證該應用在您裝置上的行為。 + USB 晶片讀卡機支援 + 您的裝置是否支援透過 USB 晶片讀卡機管理 eSIM? + 您可以透過此裝置上的標準 USB CCID 讀卡機管理 eSIM (即使您在這裡有任何其他檢查項失敗)。請插入讀卡機,然後開啟此應用程式以這種方式管理 eSIM。 + 您的裝置不支援 USB 晶片讀卡機。 + 結論 (USB 晶片讀卡機以外) + 根據之前的所有檢查,您的裝置與可插拔 eSIM 卡相容的可能性有多大? + 您可以使用和管理插入此裝置的可插拔 eSIM 卡。 + 已知您的裝置在存取可插拔 eSIM 卡時存在問題。\n%s + 我們無法確定是否可以在您的裝置上管理可插拔 eSIM 卡。不過,您的裝置確實宣告支援 OMAPI,因此它工作的可能性略高。\n%s + 我們無法確定是否可以在您的裝置上管理可插拔 eSIM 卡。由於您的裝置未宣告支援OMAPI,因此更有可能不支援在此裝置上管理可插拔 eSIM。\n%s + 我們無法確定是否可以在您的裝置上管理可插拔 eSIM 卡。\n%s + 然而,已經載入了eSIM設定檔的可插拔 eSIM 卡仍然可以工作; 即使無法在裝置上直接管理可插拔 eSIM 卡中的設定檔,您仍然可以使用 USB 卡讀卡機來管理設定檔。 + ARA-M SHA-1 已複製到剪貼簿 + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 000000000..368efbc60 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,20 @@ + + + 在此裝置上找不到 eUICC 晶片。\n在某些裝置上,您可能需要先在此應用的選單中啟用雙卡支援。 + 雙卡 + 雙卡支援狀態已切換。請等待基頻處理器重新啟動。 + 此卡槽支援多個啟用設定檔 (MEP)。要啟用或停用此功能,請使用\"卡槽對映\"工具。 + 卡槽對映 + 虛擬卡槽 %d: + 卡槽 %1$d 端口 %2$d + 您的手機有 %1$d 個虛擬 SIM 卡槽和 %2$d 個實體 SIM 卡槽。%3$s\n\n選擇您希望每個虛擬卡槽對應的實體卡槽 和/或 \"端口\"。請注意,並非所有對映模式都受硬體支援。 + \n\n實體卡槽 %1$d 支援多個啟用的設定檔 (MEP)。要使用此功能,請將其 %2$d 個虛擬\"端口\"分配給上面顯示的不同虛擬卡槽。\n\n啟用 MEP 後,\"端口\"會在 OpenEUICC 中顯示為共享 eSIM 設定檔的獨立的 eSIM 卡槽。 + \n支援雙卡模式,但已停用。如果您的裝置帶有內建 eSIM 晶片,則預設情況下可能不會啟用。更改上面的對映或啟用雙卡以訪問您的 eSIM。 + 您的新卡槽對映已設定完畢。請等待基頻處理器重新整理卡槽。 + 指定的對映可能無效或硬體不支援您指定的對映。 + 透過下載 eSIM 連線到行動網路 + 您的裝置支援 eSIM。要連線到行動網路,請下載電信業者釋出的 eSIM,或插入實體 SIM 卡。 + 跳過 + 下載 eSIM + TelephonyManager (特權) + \ No newline at end of file From d5aefcaec740c7fd646912612eefbb49cee2ad12 Mon Sep 17 00:00:00 2001 From: septs Date: Tue, 4 Mar 2025 03:14:01 +0100 Subject: [PATCH 08/99] refactor: usb ccid driver (#149) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/149 Co-authored-by: septs Co-committed-by: septs --- .../core/DefaultEuiccChannelFactory.kt | 5 +- .../core/DefaultEuiccChannelManager.kt | 5 +- .../openeuicc/core/usb/UsbCcidDescription.kt | 39 ++++++--------- .../openeuicc/core/usb/UsbCcidTransceiver.kt | 49 +++++++++---------- .../angry/openeuicc/core/usb/UsbCcidUtils.kt | 41 ++++++---------- 5 files changed, 61 insertions(+), 78 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt index 5e87564c3..ea0bd6023 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt @@ -8,7 +8,8 @@ import android.se.omapi.SEService import android.util.Log import im.angry.openeuicc.common.R import im.angry.openeuicc.core.usb.UsbApduInterface -import im.angry.openeuicc.core.usb.getIoEndpoints +import im.angry.openeuicc.core.usb.bulkPair +import im.angry.openeuicc.core.usb.endpoints import im.angry.openeuicc.util.* import java.lang.IllegalArgumentException @@ -61,7 +62,7 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha } override fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel? { - val (bulkIn, bulkOut) = usbInterface.getIoEndpoints() + val (bulkIn, bulkOut) = usbInterface.endpoints.bulkPair if (bulkIn == null || bulkOut == null) return null val conn = usbManager.openDevice(usbDevice) ?: return null if (!conn.claimInterface(usbInterface, true)) return null diff --git a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt index 293042c03..dd57eab2d 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt @@ -5,7 +5,8 @@ import android.hardware.usb.UsbDevice import android.hardware.usb.UsbManager import android.telephony.SubscriptionManager import android.util.Log -import im.angry.openeuicc.core.usb.getSmartCardInterface +import im.angry.openeuicc.core.usb.smartCard +import im.angry.openeuicc.core.usb.interfaces import im.angry.openeuicc.di.AppContainer import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers @@ -244,7 +245,7 @@ open class DefaultEuiccChannelManager( withContext(Dispatchers.IO) { usbManager.deviceList.values.forEach { device -> Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}") - val iface = device.getSmartCardInterface() ?: return@forEach + val iface = device.interfaces.smartCard ?: return@forEach // If we don't have permission, tell UI code that we found a candidate device, but we // need permission to be able to do anything with it if (!usbManager.hasPermission(device)) return@withContext Pair(device, false) diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidDescription.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidDescription.kt index 5123d530a..bc32fb6b5 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidDescription.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidDescription.kt @@ -20,12 +20,12 @@ data class UsbCcidDescription( private const val FEATURE_EXCHANGE_LEVEL_TPDU = 0x10000 private const val FEATURE_EXCHANGE_LEVEL_SHORT_APDU = 0x20000 - private const val FEATURE_EXCHAGE_LEVEL_EXTENDED_APDU = 0x40000 + private const val FEATURE_EXCHANGE_LEVEL_EXTENDED_APDU = 0x40000 // bVoltageSupport Masks - private const val VOLTAGE_5V: Byte = 1 - private const val VOLTAGE_3V: Byte = 2 - private const val VOLTAGE_1_8V: Byte = 4 + private const val VOLTAGE_5V0: Byte = 1 + private const val VOLTAGE_3V0: Byte = 2 + private const val VOLTAGE_1V8: Byte = 4 private const val SLOT_OFFSET = 4 private const val FEATURES_OFFSET = 40 @@ -71,31 +71,24 @@ data class UsbCcidDescription( } enum class Voltage(powerOnValue: Int, mask: Int) { - AUTO(0, 0), _5V(1, VOLTAGE_5V.toInt()), _3V(2, VOLTAGE_3V.toInt()), _1_8V( - 3, - VOLTAGE_1_8V.toInt() - ); + // @formatter:off + AUTO(0, 0), + V50(1, VOLTAGE_5V0.toInt()), + V30(2, VOLTAGE_3V0.toInt()), + V18(3, VOLTAGE_1V8.toInt()); + // @formatter:on val mask = powerOnValue.toByte() val powerOnValue = mask.toByte() } - private fun hasFeature(feature: Int): Boolean = - (dwFeatures and feature) != 0 + private fun hasFeature(feature: Int) = (dwFeatures and feature) != 0 - val voltages: Array - get() = - if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) { - arrayOf(Voltage.AUTO) - } else { - Voltage.values().mapNotNull { - if ((it.mask.toInt() and bVoltageSupport.toInt()) != 0) { - it - } else { - null - } - }.toTypedArray() - } + val voltages: List + get() { + if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) return listOf(Voltage.AUTO) + return Voltage.entries.filter { (it.mask.toInt() and bVoltageSupport.toInt()) != 0 } + } val hasAutomaticPps: Boolean get() = hasFeature(FEATURE_AUTOMATIC_PPS) diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidTransceiver.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidTransceiver.kt index 5ef35afa6..9155721f3 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidTransceiver.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidTransceiver.kt @@ -95,6 +95,7 @@ class UsbCcidTransceiver( data class UsbCcidErrorException(val msg: String, val errorResponse: CcidDataBlock) : Exception(msg) + @Suppress("ArrayInDataClass") data class CcidDataBlock( val dwLength: Int, val bSlot: Byte, @@ -183,31 +184,26 @@ class UsbCcidTransceiver( usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS ) if (runBlocking { verboseLoggingFlow.first() }) { - Log.d(TAG, "Received " + readBytes + " bytes: " + inputBuffer.encodeHex()) + Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}") } } while (readBytes <= 0 && attempts-- > 0) if (readBytes < CCID_HEADER_LENGTH) { throw UsbTransportException("USB-CCID error - failed to receive CCID header") } if (inputBuffer[0] != MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.toByte()) { - if (expectedSequenceNumber != inputBuffer[6]) { - throw UsbTransportException( - ((("USB-CCID error - bad CCID header, type " + inputBuffer[0]) + " (expected " + - MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK) + "), sequence number " + inputBuffer[6] - ) + " (expected " + - expectedSequenceNumber + ")" - ) - } - throw UsbTransportException( - "USB-CCID error - bad CCID header type " + inputBuffer[0] - ) + throw UsbTransportException(buildString { + append("USB-CCID error - bad CCID header") + append(", type ") + append("%d (expected %d)".format(inputBuffer[0], MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK)) + if (expectedSequenceNumber != inputBuffer[6]) { + append(", sequence number ") + append("%d (expected %d)".format(inputBuffer[6], expectedSequenceNumber)) + } + }) } var result = CcidDataBlock.parseHeaderFromBytes(inputBuffer) if (expectedSequenceNumber != result.bSeq) { - throw UsbTransportException( - ("USB-CCID error - expected sequence number " + - expectedSequenceNumber + ", got " + result) - ) + throw UsbTransportException("USB-CCID error - expected sequence number $expectedSequenceNumber, got $result") } val dataBuffer = ByteArray(result.dwLength) @@ -218,9 +214,7 @@ class UsbCcidTransceiver( usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS ) if (readBytes < 0) { - throw UsbTransportException( - "USB error - failed reading response data! Header: $result" - ) + throw UsbTransportException("USB error - failed reading response data! Header: $result") } System.arraycopy(inputBuffer, 0, dataBuffer, bufferedBytes, readBytes) bufferedBytes += readBytes @@ -285,7 +279,7 @@ class UsbCcidTransceiver( } val ccidDataBlock = receiveDataBlock(sequenceNumber) val elapsedTime = SystemClock.elapsedRealtime() - startTime - Log.d(TAG, "USB XferBlock call took " + elapsedTime + "ms") + Log.d(TAG, "USB XferBlock call took ${elapsedTime}ms") return ccidDataBlock } @@ -293,13 +287,13 @@ class UsbCcidTransceiver( val startTime = SystemClock.elapsedRealtime() skipAvailableInput() var response: CcidDataBlock? = null - for (v in usbCcidDescription.voltages) { - Log.v(TAG, "CCID: attempting to power on with voltage $v") + for (voltage in usbCcidDescription.voltages) { + Log.v(TAG, "CCID: attempting to power on with voltage $voltage") response = try { - iccPowerOnVoltage(v.powerOnValue) + iccPowerOnVoltage(voltage.powerOnValue) } catch (e: UsbCcidErrorException) { if (e.errorResponse.bError.toInt() == 7) { // Power select error - Log.v(TAG, "CCID: failed to power on with voltage $v") + Log.v(TAG, "CCID: failed to power on with voltage $voltage") iccPowerOff() Log.v(TAG, "CCID: powered off") continue @@ -314,8 +308,11 @@ class UsbCcidTransceiver( val elapsedTime = SystemClock.elapsedRealtime() - startTime Log.d( TAG, - "Usb transport connected, took " + elapsedTime + "ms, ATR=" + - response.data?.encodeHex() + buildString { + append("Usb transport connected") + append(", took ", elapsedTime, "ms") + append(", ATR=", response.data?.encodeHex()) + } ) return response } diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidUtils.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidUtils.kt index edca7a03a..877c7fd20 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidUtils.kt @@ -6,31 +6,22 @@ import android.hardware.usb.UsbDevice import android.hardware.usb.UsbEndpoint import android.hardware.usb.UsbInterface -class UsbTransportException(msg: String) : Exception(msg) +class UsbTransportException(message: String) : Exception(message) -fun UsbInterface.getIoEndpoints(): Pair { - var bulkIn: UsbEndpoint? = null - var bulkOut: UsbEndpoint? = null - for (i in 0 until endpointCount) { - val endpoint = getEndpoint(i) - if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) { - continue - } - if (endpoint.direction == UsbConstants.USB_DIR_IN) { - bulkIn = endpoint - } else if (endpoint.direction == UsbConstants.USB_DIR_OUT) { - bulkOut = endpoint - } - } - return Pair(bulkIn, bulkOut) -} +val UsbDevice.interfaces: Iterable + get() = (0 until interfaceCount).map(::getInterface) -fun UsbDevice.getSmartCardInterface(): UsbInterface? { - for (i in 0 until interfaceCount) { - val anInterface = getInterface(i) - if (anInterface.interfaceClass == UsbConstants.USB_CLASS_CSCID) { - return anInterface - } +val Iterable.smartCard: UsbInterface? + get() = find { it.interfaceClass == UsbConstants.USB_CLASS_CSCID } + +val UsbInterface.endpoints: Iterable + get() = (0 until endpointCount).map(::getEndpoint) + +val Iterable.bulkPair: Pair + get() { + val endpoints = filter { it.type == UsbConstants.USB_ENDPOINT_XFER_BULK } + return Pair( + endpoints.find { it.direction == UsbConstants.USB_DIR_IN }, + endpoints.find { it.direction == UsbConstants.USB_DIR_OUT }, + ) } - return null -} \ No newline at end of file From 6c9063a7612009e13fd6989aa064f6d18d3a7dc2 Mon Sep 17 00:00:00 2001 From: septs Date: Wed, 5 Mar 2025 14:18:03 +0100 Subject: [PATCH 09/99] chore: add zh-TW to locale-config (#155) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/155 Co-authored-by: septs Co-committed-by: septs --- app-common/src/main/res/xml/locale_config.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app-common/src/main/res/xml/locale_config.xml b/app-common/src/main/res/xml/locale_config.xml index e1a13f82b..6d7f0764b 100644 --- a/app-common/src/main/res/xml/locale_config.xml +++ b/app-common/src/main/res/xml/locale_config.xml @@ -3,4 +3,5 @@ + \ No newline at end of file From 65c7f8de83dacc3ed538a4210e38d47677c1f018 Mon Sep 17 00:00:00 2001 From: septs Date: Wed, 5 Mar 2025 14:18:56 +0100 Subject: [PATCH 10/99] feat: prompt to enable disabled sim toolkit app (#153) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/153 Co-authored-by: septs Co-committed-by: septs --- .../ui/UnprivilegedEuiccManagementFragment.kt | 23 +++- .../im/angry/openeuicc/util/SIMToolkit.kt | 104 +++++++++++------- app-unpriv/src/main/res/values/strings.xml | 1 + 3 files changed, 84 insertions(+), 44 deletions(-) diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedEuiccManagementFragment.kt b/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedEuiccManagementFragment.kt index fad03fdfe..617cbec55 100644 --- a/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedEuiccManagementFragment.kt +++ b/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedEuiccManagementFragment.kt @@ -1,7 +1,10 @@ package im.angry.openeuicc.ui +import android.content.pm.PackageManager +import android.provider.Settings import android.view.Menu import android.view.MenuInflater +import android.widget.Toast import im.angry.easyeuicc.R import im.angry.openeuicc.util.SIMToolkit import im.angry.openeuicc.util.newInstanceEuicc @@ -24,8 +27,22 @@ class UnprivilegedEuiccManagementFragment : EuiccManagementFragment() { super.onCreateOptionsMenu(menu, inflater) inflater.inflate(R.menu.fragment_sim_toolkit, menu) menu.findItem(R.id.open_sim_toolkit).apply { - isVisible = stk.isAvailable(slotId) - intent = stk.intent(slotId) + val slot = stk[slotId] ?: return@apply + isVisible = slot.intent != null + setOnMenuItemClickListener { + val intent = slot.intent ?: return@setOnMenuItemClickListener false + if (intent.action == Settings.ACTION_APPLICATION_DETAILS_SETTINGS) { + val packageName = intent.data!!.schemeSpecificPart + val label = requireContext().packageManager.getApplicationLabel(packageName) + val message = requireContext().getString(R.string.toast_prompt_to_enable_sim_toolkit, label) + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + startActivity(intent) + true + } } } -} \ No newline at end of file +} + +private fun PackageManager.getApplicationLabel(packageName: String): CharSequence = + getApplicationLabel(getApplicationInfo(packageName, 0)) diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/util/SIMToolkit.kt b/app-unpriv/src/main/java/im/angry/openeuicc/util/SIMToolkit.kt index ced813a18..99824ffe4 100644 --- a/app-unpriv/src/main/java/im/angry/openeuicc/util/SIMToolkit.kt +++ b/app-unpriv/src/main/java/im/angry/openeuicc/util/SIMToolkit.kt @@ -3,65 +3,87 @@ package im.angry.openeuicc.util import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.pm.ActivityInfo import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import android.widget.Toast import androidx.annotation.ArrayRes import im.angry.easyeuicc.R import im.angry.openeuicc.core.EuiccChannelManager class SIMToolkit(private val context: Context) { - private val slotSelection = getComponentNames(R.array.sim_toolkit_slot_selection) - private val slots = buildMap { + fun getComponentNames(@ArrayRes id: Int) = context.resources + .getStringArray(id).mapNotNull(ComponentName::unflattenFromString) + put(-1, getComponentNames(R.array.sim_toolkit_slot_selection)) put(0, getComponentNames(R.array.sim_toolkit_slot_1)) put(1, getComponentNames(R.array.sim_toolkit_slot_2)) } - private val packageNames = buildSet { - addAll(slotSelection.map { it.packageName }) - addAll(slots.values.flatten().map { it.packageName }) + operator fun get(slotId: Int): Slot? = when (slotId) { + -1, EuiccChannelManager.USB_CHANNEL_ID -> null + else -> Slot(context.packageManager, buildSet { + addAll(slots.getOrDefault(slotId, emptySet())) + addAll(slots.getOrDefault(-1, emptySet())) + }) } - private val activities = packageNames.flatMap(::getActivities).toSet() + data class Slot(private val packageManager: PackageManager, private val components: Set) { + private val packageNames: Iterable + get() = components.map(ComponentName::getPackageName).toSet() - private val launchIntent by lazy { - packageNames.firstNotNullOfOrNull(::getLaunchIntent) - } + private val launchIntent: Intent? + get() = packageNames.firstNotNullOfOrNull(packageManager::getLaunchIntent) - private fun getLaunchIntent(packageName: String) = try { - val pm = context.packageManager - pm.getLaunchIntentForPackage(packageName) - } catch (_: PackageManager.NameNotFoundException) { - null - } + private val activities: Iterable + get() = packageNames.flatMap(packageManager::getActivities) + .filter(ActivityInfo::exported).map { ComponentName(it.packageName, it.name) } - private fun getActivities(packageName: String): List { - return try { - val pm = context.packageManager - val packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES) - val activities = packageInfo.activities - if (activities.isNullOrEmpty()) return emptyList() - activities.filter { it.exported }.map { ComponentName(it.packageName, it.name) } - } catch (_: PackageManager.NameNotFoundException) { - emptyList() + private fun getActivityIntent(): Intent? { + for (activity in activities) { + if (!components.contains(activity)) continue + if (isDisabledState(packageManager.getComponentEnabledSetting(activity))) continue + return Intent.makeMainActivity(activity) + } + return launchIntent } - } - private fun getComponentNames(@ArrayRes id: Int) = - context.resources.getStringArray(id).mapNotNull(ComponentName::unflattenFromString) - - fun isAvailable(slotId: Int) = when (slotId) { - -1 -> false - EuiccChannelManager.USB_CHANNEL_ID -> false - else -> intent(slotId) != null - } - - fun intent(slotId: Int): Intent? { - val components = slots.getOrDefault(slotId, emptySet()) + slotSelection - val intent = Intent(Intent.ACTION_MAIN, null).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK - component = components.find(activities::contains) - addCategory(Intent.CATEGORY_LAUNCHER) + private fun getDisabledPackageIntent(): Intent? { + val disabledPackageName = packageNames.find { + try { + isDisabledState(packageManager.getApplicationEnabledSetting(it)) + } catch (_: IllegalArgumentException) { + false + } + } + if (disabledPackageName == null) return null + return Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", disabledPackageName, null) + ) } - return if (intent.component != null) intent else launchIntent + + val intent: Intent? + get() = getActivityIntent() ?: getDisabledPackageIntent() } } + +private fun isDisabledState(state: Int) = when (state) { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED -> true + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER -> true + else -> false +} + +private fun PackageManager.getLaunchIntent(packageName: String) = try { + getLaunchIntentForPackage(packageName) +} catch (_: PackageManager.NameNotFoundException) { + null +} + +private fun PackageManager.getActivities(packageName: String) = try { + getPackageInfo(packageName, PackageManager.GET_ACTIVITIES) + .activities?.toList() ?: emptyList() +} catch (_: PackageManager.NameNotFoundException) { + emptyList() +} diff --git a/app-unpriv/src/main/res/values/strings.xml b/app-unpriv/src/main/res/values/strings.xml index afed295b8..43bf44fa5 100644 --- a/app-unpriv/src/main/res/values/strings.xml +++ b/app-unpriv/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ ARA-M SHA-1 copied to clipboard + Please ENABLE your \"%s\" application System Features From 99d9200c28f73afed57a227074e09c7bfa4629be Mon Sep 17 00:00:00 2001 From: septs Date: Wed, 5 Mar 2025 14:19:16 +0100 Subject: [PATCH 11/99] fix: omapi apdu interface (#152) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/152 Co-authored-by: septs Co-committed-by: septs --- .../openeuicc/core/OmapiApduInterface.kt | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt b/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt index b3f42b526..f918494ae 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import net.typeblog.lpac_jni.ApduInterface +import java.util.concurrent.atomic.AtomicInteger class OmapiApduInterface( private val service: SEService, @@ -20,12 +21,8 @@ class OmapiApduInterface( } private lateinit var session: Session - private val channels = arrayOf( - null, - null, - null, - null, - ) + private val index = AtomicInteger(0) + private val channels = mutableMapOf() override val valid: Boolean get() = service.isConnected && (this::session.isInitialized && !session.isClosed) @@ -44,21 +41,20 @@ class OmapiApduInterface( override fun logicalChannelOpen(aid: ByteArray): Int { val channel = session.openLogicalChannel(aid) check(channel != null) { "Failed to open logical channel (${aid.encodeHex()})" } - val index = channels.indexOf(null) - check(index != -1) { "No free logical channel slots" } - synchronized(channels) { channels[index] = channel } - return index + val handle = index.incrementAndGet() + synchronized(channels) { channels[handle] = channel } + return handle } override fun logicalChannelClose(handle: Int) { - val channel = channels.getOrNull(handle) + val channel = channels[handle] check(channel != null) { "Invalid logical channel handle $handle" } if (channel.isOpen) channel.close() - synchronized(channels) { channels[handle] = null } + synchronized(channels) { channels.remove(handle) } } override fun transmit(handle: Int, tx: ByteArray): ByteArray { - val channel = channels.getOrNull(handle) + val channel = channels[handle] check(channel != null) { "Invalid logical channel handle $handle" } if (runBlocking { verboseLoggingFlow.first() }) { From 1313bfd24e41ec0a9e6a0e0e28c59b063aac0868 Mon Sep 17 00:00:00 2001 From: septs Date: Sat, 8 Mar 2025 16:46:46 +0100 Subject: [PATCH 12/99] fix: iccCloseLogicalChannelByPort method signature (#157) fixes #154 Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/157 Co-authored-by: septs Co-committed-by: septs --- .../angry/openeuicc/util/TelephonyManagerHiddenApi.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/libs/hidden-apis-shim/src/main/java/im/angry/openeuicc/util/TelephonyManagerHiddenApi.kt b/libs/hidden-apis-shim/src/main/java/im/angry/openeuicc/util/TelephonyManagerHiddenApi.kt index 4203feaef..0d4235469 100644 --- a/libs/hidden-apis-shim/src/main/java/im/angry/openeuicc/util/TelephonyManagerHiddenApi.kt +++ b/libs/hidden-apis-shim/src/main/java/im/angry/openeuicc/util/TelephonyManagerHiddenApi.kt @@ -69,11 +69,13 @@ fun TelephonyManager.iccOpenLogicalChannelByPort( ): IccOpenLogicalChannelResponse = iccOpenLogicalChannelByPort.invoke(this, slotId, portId, appletId, p2) as IccOpenLogicalChannelResponse -fun TelephonyManager.iccCloseLogicalChannelBySlot(slotId: Int, channel: Int): Boolean = - iccCloseLogicalChannelBySlot.invoke(this, slotId, channel) as Boolean +fun TelephonyManager.iccCloseLogicalChannelBySlot(slotId: Int, channel: Int) { + iccCloseLogicalChannelBySlot.invoke(this, slotId, channel) +} -fun TelephonyManager.iccCloseLogicalChannelByPort(slotId: Int, portId: Int, channel: Int): Boolean = - iccCloseLogicalChannelByPort.invoke(this, slotId, portId, channel) as Boolean +fun TelephonyManager.iccCloseLogicalChannelByPort(slotId: Int, portId: Int, channel: Int) { + iccCloseLogicalChannelByPort.invoke(this, slotId, portId, channel) +} fun TelephonyManager.iccTransmitApduLogicalChannelBySlot( slotId: Int, channel: Int, cla: Int, instruction: Int, From 6a5d4b92888507758c27eb1431724b6388698679 Mon Sep 17 00:00:00 2001 From: reindex Date: Sat, 8 Mar 2025 16:47:43 +0100 Subject: [PATCH 13/99] Update Japanese (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crowdinで英語のXMLを同期、Stringsを比較しやすいように再成形済み。 Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/159 Co-authored-by: reindex Co-committed-by: reindex --- app-common/src/main/res/values-ja/strings.xml | 71 ++++++++++--------- app-unpriv/src/main/res/values-ja/strings.xml | 21 +++--- 2 files changed, 50 insertions(+), 42 deletions(-) diff --git a/app-common/src/main/res/values-ja/strings.xml b/app-common/src/main/res/values-ja/strings.xml index 1710a7ddf..e4969c1f7 100644 --- a/app-common/src/main/res/values-ja/strings.xml +++ b/app-common/src/main/res/values-ja/strings.xml @@ -3,13 +3,17 @@ このアプリでアクセスできるリムーバブル eUICC カードがデバイス上で検出されていません。互換性のあるカード挿入または USB リーダーを接続してください。 この eSIM にはプロファイルがありません。 不明 - 情報なし + 情報がありません ヘルプ スロットを再読み込み 論理スロット %d 有効済み 無効済み プロバイダー: + クラス: + テスト中 + プロビジョニング + 稼働中 有効化 無効化 削除 @@ -17,8 +21,9 @@ eSIM チップがプロファイルの切り替えの待機中にタイムアウトしました。これはデバイスのモデムファームウェアのバグの可能性があります。機内モードに切り替えるかアプリを再起動、デバイスを再起動してください。 操作は成功しましたが、デバイスのモデムが更新を拒否しました。新しいプロファイルを使用するには機内モードに切り替えるか、再起動する必要があります。 新しい eSIM プロファイルに切り替えることができません。 - 入力した確認用テキストは一致していません + 確認文字列が一致しません ICCID をクリップボードにコピーしました + シリアル番号をクリップボードにコピーしました EID をクリップボードにコピーしました ATR をクリップボードにコピーしました USB の権限を許可 @@ -40,13 +45,15 @@ IMEI (オプション) ダウンロードに失敗する可能性があります 残り容量が少ないため、ダウンロードに失敗する可能性があります。 - クリップボードに LPA コードが見つかりません - LPA コードを解析できません - クリップボードまたは QR コードの内容を LPA コードとして解析できません + クリップボードに LPA コードがありません + 確認コードが必要です + クリップボードからスキャンした QR コードまたは LPA コードに必要な確認コードを入力してください。 + 解析できません + QR コードまたはクリップボードの内容を LPA コードとして解析できませんでした。 ダウンロードウィザード 戻る 次へ - 選択された SIM が取り外されました + 選択した SIM が削除されました ダウンロードする eSIM を選択または確認: タイプ: リムーバブル @@ -76,12 +83,12 @@ 最終の APDU レスポンス (SIM) は失敗しました 最終の APDU 例外: 保存 - %s のエラー診断 - ログは指定されたパスに保存しました。他のアプリにシェアしますか? + 「%s」での診断 + ログは共有したパスに保存されました。別のアプリで共有しますか? 新しいニックネーム - ニックネームを UTF-8 にエンコードできません - ニックネームは 64 文字以内にしてください - ニックネームの変更で予期せぬエラーが発生しました + ニックネームを UTF-8 にエンコードできませんでした + ニックネームが 64 文字を超えています + プロファイルの名前変更時に不明なエラーが発生しました %s のプロファイルを削除してもよろしいですか?この操作は元に戻せません。 削除を確認するには「%s」を入力してください 通知 @@ -98,16 +105,20 @@ eUICC 情報 (%s) アクセスモード リムーバブル + 製品名 + 製品シリアル番号 + 製品ブートローダーバージョン + 製品ファームウェアバージョン SGP.22 バージョン - eUICC OS のバージョン + eUICC OS バージョン グローバルプラットフォームのバージョン SAS 認定番号 - Protected Profileのバージョン + 保護されたプロファイルのバージョン NVRAM の空き容量 (eSIM プロファイルストレージ) - 証明書の発行者 (CI) - GSMA プロダクション CI + 証明書発行者 (CI) + GSMA ライブ CI GSMA テスト CI - 未知の eSIM CI + 不明な eSIM CI はい いいえ 保存 @@ -116,34 +127,28 @@ あなたは開発者になりました! 設定 通知 - eSIM のプロファイル操作により、通信事業者に通知が送信されます。ここでは、どのタイプの通知を送信するのかを微調整できます。 + eSIM のプロファイル操作により、通信事業者に通知が送信されます。必要に応じてこの動作を微調整できます。 ダウンロード - プロファイルのダウンロード済みの通知を送信します + プロファイルをダウンロード中の通知を送信します 削除 - プロファイルの削除済みの通知を送信します - 切り替え - プロファイルの切り替え済みの通知を送信します\nこのタイプの通知は有効化しても必ず送信するとは限らないことに注意してください。 + プロファイルを削除中の通知を送信します + 切り替え中 + プロファイルを切り替え中の通知を送信します\nこのタイプの通知は信頼できないことに注意してください。 高度な設定 - 有効なプロファイルの無効化と削除を許可する + プロファイルの無効化と削除を許可 デフォルトでは、このアプリでデバイスに挿入された取り外し可能な eSIM の有効なプロファイルを無効化することを防いでいます。なぜなのかというと時々アクセスができなくなるからです。\nこのチェックボックスを ON にすることで、この保護機能を解除します。 詳細ログ 詳細ログを有効化します。これには個人的な情報が含まれている可能性があります。この機能を ON にした後は、信頼できるユーザーとのみログを共有してください。 + 言語 + アプリの言語 ログ アプリの最新デバッグログを表示します 開発者オプション + フィルタリングされていないプロファイル一覧を表示 + 非運用のプロファイルも含めます SM-DP+ TLS 証明書を無視する - SM-DP+ TLS 証明書を無視して任意の RSP を許可します + RSP サーバーで使用される TLS 証明書を受け入れます 情報 アプリバージョン ソースコード - 言語 - アプリの言語を選択 - すべてのプロファイルを表示 - プロダクション以外のプロファイルも表示する - タイプ: - テスティング - 準備中 - 動作中 - 要確認コード - スキャンされた QR コード、又はクリップボードからペーストされた LPA コードには、確認コードの必要が表示されています。 diff --git a/app-unpriv/src/main/res/values-ja/strings.xml b/app-unpriv/src/main/res/values-ja/strings.xml index 053a8d12d..3a6851a9f 100644 --- a/app-unpriv/src/main/res/values-ja/strings.xml +++ b/app-unpriv/src/main/res/values-ja/strings.xml @@ -2,33 +2,36 @@ 互換性のチェック SIM ツールキットを開く + + + ARA-M SHA-1 をクリップボードにコピーしました + 「%s」アプリを有効化してください システムの機能 デバイスにリムーバブル eUICC カードの管理に必要なすべての機能が備わっているかどうか。例えば基本的な電話機能や OMAPI のサポートなど。 使用しているデバイスには電話機能がありません。 使用しているデバイスまたはシステムには OMAPI のサポートを宣言していません。これは、ハードウェアからのサポートが不足していることが原因の可能性があります。または、フラグが不足していることが原因の可能性もあります。OMAPI が実際にサポートされているかどうかを判断するには次の 2 つのチェック項目を参照してください。 OMAPI の接続 - 使用しているデバイスは、OMAPI 経由で SIM カード上のセキュアエレメントへのアクセスを許可していますか? + 使用しているデバイスは、OMAPI 経由で SIM カード上のセキュアエレメントへのアクセスを許可しているか否や。 OMAPI 経由で SIM カードのセキュアエレメントリーダーを検出できません。このデバイスに SIM を挿入していない場合は、SIM を挿入後にこのチェックを再試行してください。 - セキュアエレメントアクセスが正常に検出されましたが、次の SIM スロットでのみ有効です: SIM%s. + セキュアエレメントのアクセスが正常に検出されましたが、次の SIM スロットでのみ有効です: <b>SIM%s</b> ISD-R チャネルアクセス - 使用しているデバイスは、OMAPI 経由で eSIM への ISD-R (管理) チャネルを開くことをサポートしていますか? + 使用しているデバイスは、OMAPI 経由で eSIM への ISD-R (管理) チャネルを開くことをサポートしているか否や。 OMAPI 経由での ISD-R アクセスがサポートされているかどうかを確認できません。まだ SIM カードが挿入されていない場合は、挿入した状態で再試行してください (どの SIM カードでも構いません)。 - ISD-R への OMAPI アクセスは、次のスロットでのみ可能です: SIM%s. - 既知の破損リストに掲載されていない + ISD-R への OMAPI アクセスは、次のスロットでのみ可能です: <b>SIM%s</b> + 既知の破損リストの記載されていない 取り外し可能な eSIM に関連するバグがデバイスに存在しないかを確認します。 - おっと…使用しているデバイスには、取り外し可能な eSIM へのアクセス時にバグが存在します。これは必ずしも全く機能しないことを意味するわけではありませんが、注意して進める必要があります。 + おっと...使用しているデバイスには、取り外し可能な eSIM へのアクセス時にバグが存在します。これは必ずしも全く機能しないことを意味するわけではありませんが、注意して進める必要があります。 USB カードリーダーのサポート - 使用しているデバイスは、USB カードリーダー経由の eSIM の管理をサポートしていますか? + 使用しているデバイスは、USB カードリーダー経由の eSIM の管理をサポートしているか否や。 このデバイスの標準 USB CCID リーダーを介して eSIM を管理できます (ここで他のチェック項目に失敗した場合でも)。カードリーダーを挿入し、このアプリを開いてこの方法で eSIM を管理できます。 使用しているデバイスは USB ホストとしての機能をサポートしていません。 判定 (USB 以外) - これまでのすべてのチェック項目に基づいて、デバイスに挿入された取り外し可能な eSIM の管理と互換性がある可能性はどのくらいありますか? + これまでのすべてのチェック項目に基づいて、デバイスに挿入された取り外し可能な eSIM の管理と互換性がある可能性はどの程度かについて このデバイスに挿入された取り外し可能な eSIM の使用および管理が使用できる可能性があります。 挿入された取り外し可能な eSIM にアクセスするとデバイスにバグが発生することが知られています。\n%s 挿入された取り外し可能な eSIM が使用しているデバイスで管理できるかはわかりません。ただし、このデバイスは OMAPI のサポートを宣言しているため、動作する可能性はわずかに高くなります。\n%s 挿入された取り外し可能な eSIM がデバイス上で管理できるかどうかは判断できません。デバイスが OMAPI のサポートを宣言していないため、このデバイス上で取り外し可能な eSIM を管理することはサポートされていない可能性があります。\n%s 挿入された取り外し可能な eSIM がデバイス上で管理できるかどうかを確認できません。\n%s ただし、eSIM プロファイルがすでに読み込まれている場合、有効化されたプロファイル自体は引き続き機能します。また、プロファイルが管理できない場合は、このデバイスで USB カードリーダーを介してプロファイルを管理できる可能性があります。 - ARA-M SHA-1 をクリップボードにコピーしました From d068261ff92c198c7c0b002f44ae521314e928a1 Mon Sep 17 00:00:00 2001 From: septs Date: Sat, 8 Mar 2025 16:48:32 +0100 Subject: [PATCH 14/99] improve stk menu handling (#162) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/162 Co-authored-by: septs Co-committed-by: septs --- .../ui/UnprivilegedEuiccManagementFragment.kt | 33 ++++++++------ .../im/angry/openeuicc/util/SIMToolkit.kt | 43 +++++++++---------- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedEuiccManagementFragment.kt b/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedEuiccManagementFragment.kt index 617cbec55..7cf300cf9 100644 --- a/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedEuiccManagementFragment.kt +++ b/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedEuiccManagementFragment.kt @@ -4,6 +4,7 @@ import android.content.pm.PackageManager import android.provider.Settings import android.view.Menu import android.view.MenuInflater +import android.view.MenuItem import android.widget.Toast import im.angry.easyeuicc.R import im.angry.openeuicc.util.SIMToolkit @@ -26,22 +27,28 @@ class UnprivilegedEuiccManagementFragment : EuiccManagementFragment() { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) inflater.inflate(R.menu.fragment_sim_toolkit, menu) + } + + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) menu.findItem(R.id.open_sim_toolkit).apply { - val slot = stk[slotId] ?: return@apply - isVisible = slot.intent != null - setOnMenuItemClickListener { - val intent = slot.intent ?: return@setOnMenuItemClickListener false - if (intent.action == Settings.ACTION_APPLICATION_DETAILS_SETTINGS) { - val packageName = intent.data!!.schemeSpecificPart - val label = requireContext().packageManager.getApplicationLabel(packageName) - val message = requireContext().getString(R.string.toast_prompt_to_enable_sim_toolkit, label) - Toast.makeText(context, message, Toast.LENGTH_LONG).show() - } - startActivity(intent) - true - } + intent = stk[slotId]?.intent + isVisible = intent != null } } + + override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { + R.id.open_sim_toolkit -> { + SIMToolkit.getDisabledPackageName(item.intent)?.also { packageName -> + val label = requireContext().packageManager.getApplicationLabel(packageName) + val message = getString(R.string.toast_prompt_to_enable_sim_toolkit, label) + Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() + } + super.onOptionsItemSelected(item) // handling intent + } + + else -> super.onOptionsItemSelected(item) + } } private fun PackageManager.getApplicationLabel(packageName: String): CharSequence = diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/util/SIMToolkit.kt b/app-unpriv/src/main/java/im/angry/openeuicc/util/SIMToolkit.kt index 99824ffe4..f58f76af4 100644 --- a/app-unpriv/src/main/java/im/angry/openeuicc/util/SIMToolkit.kt +++ b/app-unpriv/src/main/java/im/angry/openeuicc/util/SIMToolkit.kt @@ -7,7 +7,6 @@ import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.net.Uri import android.provider.Settings -import android.widget.Toast import androidx.annotation.ArrayRes import im.angry.easyeuicc.R import im.angry.openeuicc.core.EuiccChannelManager @@ -32,9 +31,10 @@ class SIMToolkit(private val context: Context) { data class Slot(private val packageManager: PackageManager, private val components: Set) { private val packageNames: Iterable get() = components.map(ComponentName::getPackageName).toSet() + .filter(packageManager::isInstalledApp) private val launchIntent: Intent? - get() = packageNames.firstNotNullOfOrNull(packageManager::getLaunchIntent) + get() = packageNames.firstNotNullOfOrNull(packageManager::getLaunchIntentForPackage) private val activities: Iterable get() = packageNames.flatMap(packageManager::getActivities) @@ -50,23 +50,23 @@ class SIMToolkit(private val context: Context) { } private fun getDisabledPackageIntent(): Intent? { - val disabledPackageName = packageNames.find { - try { - isDisabledState(packageManager.getApplicationEnabledSetting(it)) - } catch (_: IllegalArgumentException) { - false - } - } - if (disabledPackageName == null) return null - return Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", disabledPackageName, null) - ) + val disabledPackageName = packageNames + .find { isDisabledState(packageManager.getApplicationEnabledSetting(it)) } + ?: return null + val uri = Uri.fromParts("package", disabledPackageName, null) + return Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri) } val intent: Intent? get() = getActivityIntent() ?: getDisabledPackageIntent() } + + companion object { + fun getDisabledPackageName(intent: Intent?): String? { + if (intent?.action != Settings.ACTION_APPLICATION_DETAILS_SETTINGS) return null + return intent.data?.schemeSpecificPart + } + } } private fun isDisabledState(state: Int) = when (state) { @@ -75,15 +75,12 @@ private fun isDisabledState(state: Int) = when (state) { else -> false } -private fun PackageManager.getLaunchIntent(packageName: String) = try { - getLaunchIntentForPackage(packageName) +private fun PackageManager.isInstalledApp(packageName: String) = try { + getPackageInfo(packageName, 0) + true } catch (_: PackageManager.NameNotFoundException) { - null + false } -private fun PackageManager.getActivities(packageName: String) = try { - getPackageInfo(packageName, PackageManager.GET_ACTIVITIES) - .activities?.toList() ?: emptyList() -} catch (_: PackageManager.NameNotFoundException) { - emptyList() -} +private fun PackageManager.getActivities(packageName: String) = + getPackageInfo(packageName, PackageManager.GET_ACTIVITIES).activities?.toList() ?: emptyList() From 2eabf719d024e286ba488ba97b9eb4db3c2b81f1 Mon Sep 17 00:00:00 2001 From: septs Date: Sat, 8 Mar 2025 16:52:19 +0100 Subject: [PATCH 15/99] refactor: EuiccChannelFragmentUtils (#164) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/164 Co-authored-by: septs Co-committed-by: septs --- .../openeuicc/ui/ProfileDeleteFragment.kt | 24 ++----- .../openeuicc/ui/ProfileRenameFragment.kt | 55 ++++++++-------- .../util/EuiccChannelFragmentUtils.kt | 66 ++++++++++++------- 3 files changed, 77 insertions(+), 68 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt index 7f82f22a8..38d1bc6f1 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt @@ -20,13 +20,10 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker { private const val FIELD_ICCID = "iccid" private const val FIELD_NAME = "name" - fun newInstance(slotId: Int, portId: Int, iccid: String, name: String): ProfileDeleteFragment { - val instance = newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId) - instance.requireArguments().apply { + fun newInstance(slotId: Int, portId: Int, iccid: String, name: String) = + newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId) { putString(FIELD_ICCID, iccid) putString(FIELD_NAME, name) - } - return instance } } @@ -91,19 +88,12 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker { requireParentFragment().lifecycleScope.launch { ensureEuiccChannelManager() euiccChannelManagerService.waitForForegroundTask() - euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, iccid).onStart { - if (parentFragment is EuiccProfilesChangedListener) { - // Trigger a refresh in the parent fragment -- it should wait until - // any foreground task is completed before actually doing a refresh - (parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged() + euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, iccid) + .onStart { + parentFragment?.notifyEuiccProfilesChanged() + runCatching(::dismiss) } - - try { - dismiss() - } catch (e: IllegalStateException) { - // Ignored - } - }.waitDone() + .waitDone() } } } \ No newline at end of file diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileRenameFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileRenameFragment.kt index 25c5273e4..c588254c1 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileRenameFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileRenameFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ProgressBar import android.widget.Toast +import androidx.annotation.StringRes import androidx.appcompat.widget.Toolbar import androidx.lifecycle.lifecycleScope import com.google.android.material.textfield.TextInputLayout @@ -18,16 +19,16 @@ import net.typeblog.lpac_jni.LocalProfileAssistant class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragmentMarker { companion object { + private const val FIELD_ICCID = "iccid" + private const val FIELD_CURRENT_NAME = "currentName" + const val TAG = "ProfileRenameFragment" - fun newInstance(slotId: Int, portId: Int, iccid: String, currentName: String): ProfileRenameFragment { - val instance = newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId) - instance.requireArguments().apply { - putString("iccid", iccid) - putString("currentName", currentName) + fun newInstance(slotId: Int, portId: Int, iccid: String, currentName: String) = + newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId) { + putString(FIELD_ICCID, iccid) + putString(FIELD_CURRENT_NAME, currentName) } - return instance - } } private lateinit var toolbar: Toolbar @@ -36,6 +37,14 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment private var renaming = false + private val iccid: String by lazy { + requireArguments().getString(FIELD_ICCID)!! + } + + private val currentName: String by lazy { + requireArguments().getString(FIELD_CURRENT_NAME)!! + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -54,7 +63,7 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - profileRenameNewName.editText!!.setText(requireArguments().getString("currentName")) + profileRenameNewName.editText!!.setText(currentName) toolbar.apply { setTitle(R.string.rename) setNavigationOnClickListener { @@ -78,12 +87,8 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment } } - private fun showErrorAndCancel(errorStrRes: Int) { - Toast.makeText( - requireContext(), - errorStrRes, - Toast.LENGTH_LONG - ).show() + private fun showErrorAndCancel(@StringRes resId: Int) { + Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG).show() renaming = false progress.visibility = View.GONE @@ -94,17 +99,15 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment progress.isIndeterminate = true progress.visibility = View.VISIBLE + val newName = profileRenameNewName.editText!!.text.toString().trim() + lifecycleScope.launch { ensureEuiccChannelManager() euiccChannelManagerService.waitForForegroundTask() - val res = euiccChannelManagerService.launchProfileRenameTask( - slotId, - portId, - requireArguments().getString("iccid")!!, - profileRenameNewName.editText!!.text.toString().trim() - ).waitDone() + val response = euiccChannelManagerService + .launchProfileRenameTask(slotId, portId, iccid, newName).waitDone() - when (res) { + when (response) { is LocalProfileAssistant.ProfileNameTooLongException -> { showErrorAndCancel(R.string.profile_rename_too_long) } @@ -118,15 +121,9 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment } else -> { - if (parentFragment is EuiccProfilesChangedListener) { - (parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged() - } + parentFragment?.notifyEuiccProfilesChanged() - try { - dismiss() - } catch (e: IllegalStateException) { - // Ignored - } + runCatching(::dismiss) } } } diff --git a/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt index 3f3c4ee53..b44bef896 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt @@ -7,43 +7,65 @@ import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.service.EuiccChannelManagerService import im.angry.openeuicc.ui.BaseEuiccAccessActivity -interface EuiccChannelFragmentMarker: OpenEuiccContextMarker +private const val FIELD_SLOT_ID = "slotId" +private const val FIELD_PORT_ID = "portId" + +interface EuiccChannelFragmentMarker : OpenEuiccContextMarker + +private typealias BundleSetter = Bundle.() -> Unit // We must use extension functions because there is no way to add bounds to the type of "self" // in the definition of an interface, so the only way is to limit where the extension functions // can be applied. -fun newInstanceEuicc(clazz: Class, slotId: Int, portId: Int, addArguments: Bundle.() -> Unit = {}): T where T: Fragment, T: EuiccChannelFragmentMarker { - val instance = clazz.newInstance() - instance.arguments = Bundle().apply { - putInt("slotId", slotId) - putInt("portId", portId) - addArguments() +fun newInstanceEuicc(clazz: Class, slotId: Int, portId: Int, addArguments: BundleSetter = {}): T + where T : Fragment, T : EuiccChannelFragmentMarker = + clazz.getDeclaredConstructor().newInstance().apply { + arguments = Bundle() + arguments!!.putInt(FIELD_SLOT_ID, slotId) + arguments!!.putInt(FIELD_PORT_ID, portId) + arguments!!.addArguments() } - return instance -} // Convenient methods to avoid using `channel` for these // `channel` requires that the channel actually exists in EuiccChannelManager, which is // not always the case during operations such as switching -val T.slotId: Int where T: Fragment, T: EuiccChannelFragmentMarker - get() = requireArguments().getInt("slotId") -val T.portId: Int where T: Fragment, T: EuiccChannelFragmentMarker - get() = requireArguments().getInt("portId") -val T.isUsb: Boolean where T: Fragment, T: EuiccChannelFragmentMarker - get() = requireArguments().getInt("slotId") == EuiccChannelManager.USB_CHANNEL_ID +val T.slotId: Int + where T : Fragment, T : EuiccChannelFragmentMarker + get() = requireArguments().getInt(FIELD_SLOT_ID) +val T.portId: Int + where T : Fragment, T : EuiccChannelFragmentMarker + get() = requireArguments().getInt(FIELD_PORT_ID) +val T.isUsb: Boolean + where T : Fragment, T : EuiccChannelFragmentMarker + get() = slotId == EuiccChannelManager.USB_CHANNEL_ID -val T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: OpenEuiccContextMarker - get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManager -val T.euiccChannelManagerService: EuiccChannelManagerService where T: Fragment, T: OpenEuiccContextMarker - get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManagerService +private fun T.requireEuiccActivity(): BaseEuiccAccessActivity + where T : Fragment, T : OpenEuiccContextMarker = + requireActivity() as BaseEuiccAccessActivity -suspend fun T.withEuiccChannel(fn: suspend (EuiccChannel) -> R): R where T : Fragment, T : EuiccChannelFragmentMarker { +val T.euiccChannelManager: EuiccChannelManager + where T : Fragment, T : OpenEuiccContextMarker + get() = requireEuiccActivity().euiccChannelManager + +val T.euiccChannelManagerService: EuiccChannelManagerService + where T : Fragment, T : OpenEuiccContextMarker + get() = requireEuiccActivity().euiccChannelManagerService + +suspend fun T.withEuiccChannel(fn: suspend (EuiccChannel) -> R): R + where T : Fragment, T : EuiccChannelFragmentMarker { ensureEuiccChannelManager() return euiccChannelManager.withEuiccChannel(slotId, portId, fn) } -suspend fun T.ensureEuiccChannelManager() where T: Fragment, T: OpenEuiccContextMarker = - (requireActivity() as BaseEuiccAccessActivity).euiccChannelManagerLoaded.await() +suspend fun T.ensureEuiccChannelManager() where T : Fragment, T : OpenEuiccContextMarker = + requireEuiccActivity().euiccChannelManagerLoaded.await() + +fun T.notifyEuiccProfilesChanged() where T : Fragment { + if (this !is EuiccProfilesChangedListener) return + // Trigger a refresh in the parent fragment -- it should wait until + // any foreground task is completed before actually doing a refresh + this.onEuiccProfilesChanged() +} interface EuiccProfilesChangedListener { fun onEuiccProfilesChanged() From 8243914588a41df0d45cb67a1bf33bd0fc308985 Mon Sep 17 00:00:00 2001 From: septs Date: Sat, 8 Mar 2025 16:54:53 +0100 Subject: [PATCH 16/99] feat: recent url sharing (#160) ![Screenshot_20250306-135020.png](/attachments/c2932882-72bd-4fb4-8955-e538d2dcd59c) see https://developer.android.com/guide/components/activities/recents#url-sharing Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/160 Co-authored-by: septs Co-committed-by: septs --- .../ui/wizard/DownloadWizardActivity.kt | 17 +++++++++++++++++ .../im/angry/openeuicc/util/ActivationCode.kt | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt index e342dee6f..8cab18b97 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt @@ -1,5 +1,6 @@ package im.angry.openeuicc.ui.wizard +import android.app.assist.AssistContent import android.os.Bundle import android.view.View import android.view.inputmethod.InputMethodManager @@ -8,6 +9,7 @@ import android.widget.ProgressBar import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.enableEdgeToEdge +import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding @@ -111,6 +113,21 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { } } + override fun onProvideAssistContent(outContent: AssistContent?) { + super.onProvideAssistContent(outContent) + outContent?.webUri = try { + val activationCode = ActivationCode( + state.smdp, + state.matchingId, + null, + state.confirmationCode != null, + ) + "LPA:$activationCode".toUri() + } catch (_: Exception) { + null + } + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString("currentStepFragmentClassName", state.currentStepFragmentClassName) diff --git a/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt b/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt index 3aca0d63c..c21e837ee 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt @@ -20,4 +20,15 @@ data class ActivationCode( ) } } + + override fun toString(): String { + val parts = arrayOf( + "1", + address, + matchingId ?: "", + oid ?: "", + if (confirmationCodeRequired) "1" else "" + ) + return parts.joinToString("$").trimEnd('$') + } } \ No newline at end of file From 889b08767ce25cb42cf16ccf5189f70a5086d432 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 8 Mar 2025 11:34:26 -0500 Subject: [PATCH 17/99] Move eUICC vendor handling to an interface in util ...instead of ad-hoc functions --- .../angry/openeuicc/ui/EuiccInfoActivity.kt | 15 +-- .../java/im/angry/openeuicc/util/Vendors.kt | 112 ++++++++++++++++++ .../im/angry/openeuicc/vendored/estkme.kt | 49 -------- .../im/angry/openeuicc/vendored/simlink.kt | 20 ---- 4 files changed, 117 insertions(+), 79 deletions(-) create mode 100644 app-common/src/main/java/im/angry/openeuicc/util/Vendors.kt delete mode 100644 app-common/src/main/java/im/angry/openeuicc/vendored/estkme.kt delete mode 100644 app-common/src/main/java/im/angry/openeuicc/vendored/simlink.kt diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt index 528b2324d..4e499dc24 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt @@ -23,8 +23,6 @@ import im.angry.openeuicc.common.R import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.util.* -import im.angry.openeuicc.vendored.getESTKmeInfo -import im.angry.openeuicc.vendored.getSIMLinkVersion import kotlinx.coroutines.launch import net.typeblog.lpac_jni.impl.PKID_GSMA_LIVE_CI import net.typeblog.lpac_jni.impl.PKID_GSMA_TEST_CI @@ -104,14 +102,11 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { add(Item(R.string.euicc_info_access_mode, channel.type)) add(Item(R.string.euicc_info_removable, formatByBoolean(channel.port.card.isRemovable, YES_NO))) add(Item(R.string.euicc_info_eid, channel.lpa.eID, copiedToastResId = R.string.toast_eid_copied)) - getESTKmeInfo(channel.apduInterface)?.let { - add(Item(R.string.euicc_info_sku, it.skuName)) - add(Item(R.string.euicc_info_sn, it.serialNumber, copiedToastResId = R.string.toast_sn_copied)) - add(Item(R.string.euicc_info_bl_ver, it.bootloaderVersion)) - add(Item(R.string.euicc_info_fw_ver, it.firmwareVersion)) - } - getSIMLinkVersion(channel.lpa.eID, channel.lpa.euiccInfo2?.euiccFirmwareVersion)?.let { - add(Item(R.string.euicc_info_sku, "9eSIM $it")) + channel.tryParseEuiccVendorInfo()?.let { vendorInfo -> + vendorInfo.skuName?.let { add(Item(R.string.euicc_info_sku, it)) } + vendorInfo.serialNumber?.let { add(Item(R.string.euicc_info_sn, it)) } + vendorInfo.firmwareVersion?.let { add(Item(R.string.euicc_info_fw_ver, it)) } + vendorInfo.bootloaderVersion?.let { add(Item(R.string.euicc_info_bl_ver, it)) } } channel.lpa.euiccInfo2.let { info -> add(Item(R.string.euicc_info_sgp22_version, info?.sgp22Version.toString())) diff --git a/app-common/src/main/java/im/angry/openeuicc/util/Vendors.kt b/app-common/src/main/java/im/angry/openeuicc/util/Vendors.kt new file mode 100644 index 000000000..529f9ee84 --- /dev/null +++ b/app-common/src/main/java/im/angry/openeuicc/util/Vendors.kt @@ -0,0 +1,112 @@ +package im.angry.openeuicc.util + +import android.util.Log +import im.angry.openeuicc.core.ApduInterfaceAtrProvider +import im.angry.openeuicc.core.EuiccChannel +import net.typeblog.lpac_jni.Version + +data class EuiccVendorInfo( + val skuName: String?, + val serialNumber: String?, + val bootloaderVersion: String?, + val firmwareVersion: String?, +) + +private val EUICC_VENDORS: Array = arrayOf(EstkMe(), SimLink()) + +fun EuiccChannel.tryParseEuiccVendorInfo(): EuiccVendorInfo? { + EUICC_VENDORS.forEach { vendor -> + vendor.tryParseEuiccVendorInfo(this@tryParseEuiccVendorInfo)?.let { + return it + } + } + + return null +} + +interface EuiccVendor { + fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo? +} + +private class EstkMe : EuiccVendor { + companion object { + private val PRODUCT_AID = "A06573746B6D65FFFFFFFFFFFF6D6774".decodeHex() + private val PRODUCT_ATR_FPR = "estk.me".encodeToByteArray() + } + + private fun checkAtr(channel: EuiccChannel): Boolean { + val iface = channel.apduInterface + if (iface !is ApduInterfaceAtrProvider) return false + val atr = iface.atr ?: return false + for (index in atr.indices) { + if (atr.size - index < PRODUCT_ATR_FPR.size) break + if (atr.sliceArray(index until index + PRODUCT_ATR_FPR.size) + .contentEquals(PRODUCT_ATR_FPR) + ) return true + } + return false + } + + private fun decodeAsn1String(b: ByteArray): String? { + if (b.size < 2) return null + if (b[b.size - 2] != 0x90.toByte() || b[b.size - 1] != 0x00.toByte()) return null + return b.sliceArray(0 until b.size - 2).decodeToString() + } + + override fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo? { + if (!checkAtr(channel)) return null + + val iface = channel.apduInterface + return try { + iface.withLogicalChannel(PRODUCT_AID) { transmit -> + fun invoke(p1: Byte) = + decodeAsn1String(transmit(byteArrayOf(0x00, 0x00, p1, 0x00, 0x00))) + EuiccVendorInfo( + skuName = invoke(0x03), + serialNumber = invoke(0x00), + bootloaderVersion = invoke(0x01), + firmwareVersion = invoke(0x02), + ) + } + } catch (e: Exception) { + Log.d(TAG, "Failed to get ESTKmeInfo", e) + null + } + } +} + +private class SimLink : EuiccVendor { + companion object { + private val EID_PATTERN = Regex("^89044045(84|21)67274948") + } + + override fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo? { + val eid = channel.lpa.eID + val version = channel.lpa.euiccInfo2?.euiccFirmwareVersion + if (version == null || EID_PATTERN.find(eid, 0) == null) return null + val versionName = when { + // @formatter:off + version >= Version(37, 1, 41) -> "v3.1 (beta 1)" + version >= Version(36, 18, 5) -> "v3 (final)" + version >= Version(36, 17, 39) -> "v3 (beta)" + version >= Version(36, 17, 4) -> "v2s" + version >= Version(36, 9, 3) -> "v2.1" + version >= Version(36, 7, 2) -> "v2" + // @formatter:on + else -> null + } + + val skuName = if (versionName == null) { + "9eSIM" + } else { + "9eSIM $versionName" + } + + return EuiccVendorInfo( + skuName = skuName, + serialNumber = null, + bootloaderVersion = null, + firmwareVersion = null + ) + } +} \ No newline at end of file diff --git a/app-common/src/main/java/im/angry/openeuicc/vendored/estkme.kt b/app-common/src/main/java/im/angry/openeuicc/vendored/estkme.kt deleted file mode 100644 index 228292149..000000000 --- a/app-common/src/main/java/im/angry/openeuicc/vendored/estkme.kt +++ /dev/null @@ -1,49 +0,0 @@ -package im.angry.openeuicc.vendored - -import android.util.Log -import im.angry.openeuicc.core.ApduInterfaceAtrProvider -import im.angry.openeuicc.util.TAG -import im.angry.openeuicc.util.decodeHex -import net.typeblog.lpac_jni.ApduInterface - -data class ESTKmeInfo( - val serialNumber: String?, - val bootloaderVersion: String?, - val firmwareVersion: String?, - val skuName: String?, -) - -fun isESTKmeATR(iface: ApduInterface): Boolean { - if (iface !is ApduInterfaceAtrProvider) return false - val atr = iface.atr ?: return false - val fpr = "estk.me".encodeToByteArray() - for (index in atr.indices) { - if (atr.size - index < fpr.size) break - if (atr.sliceArray(index until index + fpr.size).contentEquals(fpr)) return true - } - return false -} - -fun getESTKmeInfo(iface: ApduInterface): ESTKmeInfo? { - if (!isESTKmeATR(iface)) return null - fun decode(b: ByteArray): String? { - if (b.size < 2) return null - if (b[b.size - 2] != 0x90.toByte() || b[b.size - 1] != 0x00.toByte()) return null - return b.sliceArray(0 until b.size - 2).decodeToString() - } - return try { - iface.withLogicalChannel("A06573746B6D65FFFFFFFFFFFF6D6774".decodeHex()) { transmit -> - fun invoke(p1: Byte) = decode(transmit(byteArrayOf(0x00, 0x00, p1, 0x00, 0x00))) - ESTKmeInfo( - invoke(0x00), // serial number - invoke(0x01), // bootloader version - invoke(0x02), // firmware version - invoke(0x03), // sku name - ) - } - } catch (e: Exception) { - Log.d(TAG, "Failed to get ESTKmeInfo", e) - null - } -} - diff --git a/app-common/src/main/java/im/angry/openeuicc/vendored/simlink.kt b/app-common/src/main/java/im/angry/openeuicc/vendored/simlink.kt deleted file mode 100644 index 506f16c61..000000000 --- a/app-common/src/main/java/im/angry/openeuicc/vendored/simlink.kt +++ /dev/null @@ -1,20 +0,0 @@ -package im.angry.openeuicc.vendored - -import net.typeblog.lpac_jni.Version - -private val prefix = Regex("^89044045(84|21)67274948") // SIMLink EID prefix - -fun getSIMLinkVersion(eid: String, version: Version?): String? { - if (version == null || prefix.find(eid, 0) == null) return null - return when { - // @formatter:off - version >= Version(37, 1, 41) -> "v3.1 (beta 1)" - version >= Version(36, 18, 5) -> "v3 (final)" - version >= Version(36, 17, 39) -> "v3 (beta)" - version >= Version(36, 17, 4) -> "v2s" - version >= Version(36, 9, 3) -> "v2.1" - version >= Version(36, 7, 2) -> "v2" - // @formatter:on - else -> null - } -} From c528962f29cf8087838550ed46565d664807dba4 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 8 Mar 2025 15:40:30 -0500 Subject: [PATCH 18/99] wizard: Accept deep-links with the LPA: schema Co-authored-by: septs --- app-common/src/main/AndroidManifest.xml | 15 +++++++- .../ui/wizard/DownloadWizardActivity.kt | 35 ++++++++++++++----- .../wizard/DownloadWizardDetailsFragment.kt | 6 +++- .../DownloadWizardSlotSelectFragment.kt | 6 +++- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/app-common/src/main/AndroidManifest.xml b/app-common/src/main/AndroidManifest.xml index f53e6ffc3..6edaaf13a 100644 --- a/app-common/src/main/AndroidManifest.xml +++ b/app-common/src/main/AndroidManifest.xml @@ -30,7 +30,20 @@ + android:label="@string/download_wizard"> + + + + + + + + + + + + + Date: Sat, 8 Mar 2025 15:45:58 -0500 Subject: [PATCH 19/99] ActivationCode::fromString -> ActivationCode::parse Un-confusion :D --- .../angry/openeuicc/ui/wizard/DownloadWizardActivity.kt | 3 +-- .../ui/wizard/DownloadWizardMethodSelectFragment.kt | 2 +- .../main/java/im/angry/openeuicc/util/ActivationCode.kt | 8 ++++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt index 01a4d7c28..44b74790c 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt @@ -21,7 +21,6 @@ import im.angry.openeuicc.ui.BaseEuiccAccessActivity import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import net.typeblog.lpac_jni.LocalProfileAssistant class DownloadWizardActivity: BaseEuiccAccessActivity() { @@ -123,7 +122,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { // but that _is_ the desired behavior. val uri = intent.data if (uri?.scheme == "lpa") { - val parsed = ActivationCode.fromString(uri.schemeSpecificPart) + val parsed = ActivationCode.parse(uri.schemeSpecificPart) state.smdp = parsed.address state.matchingId = parsed.matchingId state.skipMethodSelect = true diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt index 2846fd776..452f1e187 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt @@ -126,7 +126,7 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard private fun processLpaString(input: String) { try { - val parsed = ActivationCode.fromString(input) + val parsed = ActivationCode.parse(input) state.smdp = parsed.address state.matchingId = parsed.matchingId if (parsed.confirmationCodeRequired) { diff --git a/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt b/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt index c21e837ee..2399b5a24 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt @@ -2,12 +2,12 @@ package im.angry.openeuicc.util data class ActivationCode( val address: String, - val matchingId: String? = null, - val oid: String? = null, - val confirmationCodeRequired: Boolean = false, + val matchingId: String?, + val oid: String?, + val confirmationCodeRequired: Boolean, ) { companion object { - fun fromString(input: String): ActivationCode { + fun parse(input: String): ActivationCode { val components = input.removePrefix("LPA:").split('$') if (components.size < 2 || components[0] != "1") { throw IllegalArgumentException("Invalid activation code format") From 7edde1ffa438a70c1910126a8551ce6338dc81af Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 8 Mar 2025 16:05:00 -0500 Subject: [PATCH 20/99] wizard: Rework handling for confirmation code from LPA strings There are more than one way to acquire an LPA string here. Let's just store whether we need confirmation code as a boolean in state and then use that to decide whether it is actually required in the step for inputting details. --- .../ui/wizard/DownloadWizardActivity.kt | 8 +++++++- .../wizard/DownloadWizardDetailsFragment.kt | 16 ++++++++++++++++ .../DownloadWizardMethodSelectFragment.kt | 19 ++++++++----------- app-common/src/main/res/values-ja/strings.xml | 3 +-- .../src/main/res/values-zh-rCN/strings.xml | 3 +-- .../src/main/res/values-zh-rTW/strings.xml | 1 + app-common/src/main/res/values/strings.xml | 3 +-- 7 files changed, 35 insertions(+), 18 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt index 44b74790c..dfbe17da4 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt @@ -35,6 +35,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { var downloadTaskID: Long, var downloadError: LocalProfileAssistant.ProfileDownloadException?, var skipMethodSelect: Boolean, + var confirmationCodeRequired: Boolean, ) private lateinit var state: DownloadWizardState @@ -72,7 +73,8 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { downloadStarted = false, downloadTaskID = -1, downloadError = null, - skipMethodSelect = false + skipMethodSelect = false, + confirmationCodeRequired = false, ) handleDeepLink() @@ -125,6 +127,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { val parsed = ActivationCode.parse(uri.schemeSpecificPart) state.smdp = parsed.address state.matchingId = parsed.matchingId + state.confirmationCodeRequired = parsed.confirmationCodeRequired state.skipMethodSelect = true } } @@ -154,6 +157,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { outState.putString("imei", state.imei) outState.putBoolean("downloadStarted", state.downloadStarted) outState.putLong("downloadTaskID", state.downloadTaskID) + outState.putBoolean("confirmationCodeRequired", state.confirmationCodeRequired) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { @@ -170,6 +174,8 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { state.downloadStarted = savedInstanceState.getBoolean("downloadStarted", state.downloadStarted) state.downloadTaskID = savedInstanceState.getLong("downloadTaskID", state.downloadTaskID) + state.confirmationCode = savedInstanceState.getString("confirmationCode", state.confirmationCode) + state.confirmationCodeRequired = savedInstanceState.getBoolean("confirmationCodeRequired", state.confirmationCodeRequired) } private fun onPrevPressed() { diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt index 4a852ecce..402e7a523 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt @@ -5,6 +5,7 @@ import android.util.Patterns import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.core.widget.addTextChangedListener import com.google.android.material.textfield.TextInputLayout import im.angry.openeuicc.common.R @@ -55,6 +56,9 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF smdp.editText!!.addTextChangedListener { updateInputCompleteness() } + confirmationCode.editText!!.addTextChangedListener { + updateInputCompleteness() + } return view } @@ -65,6 +69,15 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF confirmationCode.editText!!.setText(state.confirmationCode) imei.editText!!.setText(state.imei) updateInputCompleteness() + + if (state.confirmationCodeRequired) { + confirmationCode.editText!!.requestFocus() + confirmationCode.editText!!.hint = + getString(R.string.profile_download_confirmation_code_required) + } else { + confirmationCode.editText!!.hint = + getString(R.string.profile_download_confirmation_code) + } } override fun onPause() { @@ -74,6 +87,9 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF private fun updateInputCompleteness() { inputComplete = Patterns.DOMAIN_NAME.matcher(smdp.editText!!.text).matches() + if (state.confirmationCodeRequired) { + inputComplete = inputComplete && confirmationCode.editText!!.text.isNotEmpty() + } refreshButtons() } } \ No newline at end of file diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt index 452f1e187..85d75b33a 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt @@ -129,15 +129,7 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard val parsed = ActivationCode.parse(input) state.smdp = parsed.address state.matchingId = parsed.matchingId - if (parsed.confirmationCodeRequired) { - AlertDialog.Builder(requireContext()).apply { - setTitle(R.string.profile_download_required_confirmation_code) - setMessage(R.string.profile_download_required_confirmation_code_message) - setCancelable(true) - setPositiveButton(android.R.string.ok, null) - show() - } - } + state.confirmationCodeRequired = parsed.confirmationCodeRequired gotoNextFragment(DownloadWizardDetailsFragment()) } catch (e: IllegalArgumentException) { AlertDialog.Builder(requireContext()).apply { @@ -150,14 +142,19 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard } } - private class DownloadMethodViewHolder(private val root: View) : ViewHolder(root) { + private inner class DownloadMethodViewHolder(private val root: View) : ViewHolder(root) { private val icon = root.requireViewById(R.id.download_method_icon) private val title = root.requireViewById(R.id.download_method_title) fun bind(item: DownloadMethod) { icon.setImageResource(item.iconRes) title.setText(item.titleRes) - root.setOnClickListener { item.onClick() } + root.setOnClickListener { + // If the user elected to use another download method, reset the confirmation code flag + // too + state.confirmationCodeRequired = false + item.onClick() + } } } diff --git a/app-common/src/main/res/values-ja/strings.xml b/app-common/src/main/res/values-ja/strings.xml index e4969c1f7..df7267484 100644 --- a/app-common/src/main/res/values-ja/strings.xml +++ b/app-common/src/main/res/values-ja/strings.xml @@ -42,12 +42,11 @@ サーバー (RSP / SM-DP+) アクティベーションコード 確認コード (オプション) + 確認コード (必須) IMEI (オプション) ダウンロードに失敗する可能性があります 残り容量が少ないため、ダウンロードに失敗する可能性があります。 クリップボードに LPA コードがありません - 確認コードが必要です - クリップボードからスキャンした QR コードまたは LPA コードに必要な確認コードを入力してください。 解析できません QR コードまたはクリップボードの内容を LPA コードとして解析できませんでした。 ダウンロードウィザード diff --git a/app-common/src/main/res/values-zh-rCN/strings.xml b/app-common/src/main/res/values-zh-rCN/strings.xml index 2cadc03db..de020460f 100644 --- a/app-common/src/main/res/values-zh-rCN/strings.xml +++ b/app-common/src/main/res/values-zh-rCN/strings.xml @@ -37,6 +37,7 @@ 服务器 (RSP / SM-DP+) 激活码 确认码 (可选) + 确认码 (必需) IMEI (可选) 本次下载可能会失败 当前芯片的剩余空间不足,可能导致配置下载失败。\n是否继续下载? @@ -144,6 +145,4 @@ 无视 SM-DP+ 的 TLS 证书 允许 RSP 服务器使用任意证书 无信息 - 需要确认码 - 您扫描的二维码或粘贴的 LPA 码需要一个额外的确认码 \ No newline at end of file diff --git a/app-common/src/main/res/values-zh-rTW/strings.xml b/app-common/src/main/res/values-zh-rTW/strings.xml index 28691a0cc..d133d2b66 100644 --- a/app-common/src/main/res/values-zh-rTW/strings.xml +++ b/app-common/src/main/res/values-zh-rTW/strings.xml @@ -37,6 +37,7 @@ 伺服器 (RSP / SM-DP+) 啟用碼 確認碼 (可選) + 確認碼 (必需) IMEI (可選) 本次下載可能會失敗 目前晶片的剩餘空間不足,可能導致配置下載失敗。\n是否繼續下載? diff --git a/app-common/src/main/res/values/strings.xml b/app-common/src/main/res/values/strings.xml index a45ce1f6c..990e62930 100644 --- a/app-common/src/main/res/values/strings.xml +++ b/app-common/src/main/res/values/strings.xml @@ -53,13 +53,12 @@ Server (RSP / SM-DP+) Activation Code Confirmation Code (Optional) + Confirmation Code (Required) IMEI (Optional) This download may fail This download may fail due to low remaining capacity. No LPA code found in clipboard - Confirmation Code Required - Please provide a confirmation code as required by the scanned QR code or LPA code from clipboard. Unable to parse Could not parse QR code or clipboard content as a LPA code. From 2b86d719ddcc34ee1e3597e3992a3d522891fb71 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 8 Mar 2025 16:07:41 -0500 Subject: [PATCH 21/99] ActivationCode -> LPAString Un-confuse myself.... The term "ActivationCode" is too overloaded even within OpenEUICC itself. --- .../im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt | 4 ++-- .../ui/wizard/DownloadWizardMethodSelectFragment.kt | 2 +- .../openeuicc/util/{ActivationCode.kt => LPAString.kt} | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) rename app-common/src/main/java/im/angry/openeuicc/util/{ActivationCode.kt => LPAString.kt} (89%) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt index dfbe17da4..a9f868f02 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt @@ -124,7 +124,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { // but that _is_ the desired behavior. val uri = intent.data if (uri?.scheme == "lpa") { - val parsed = ActivationCode.parse(uri.schemeSpecificPart) + val parsed = LPAString.parse(uri.schemeSpecificPart) state.smdp = parsed.address state.matchingId = parsed.matchingId state.confirmationCodeRequired = parsed.confirmationCodeRequired @@ -135,7 +135,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { override fun onProvideAssistContent(outContent: AssistContent?) { super.onProvideAssistContent(outContent) outContent?.webUri = try { - val activationCode = ActivationCode( + val activationCode = LPAString( state.smdp, state.matchingId, null, diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt index 85d75b33a..90fdb3306 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt @@ -126,7 +126,7 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard private fun processLpaString(input: String) { try { - val parsed = ActivationCode.parse(input) + val parsed = LPAString.parse(input) state.smdp = parsed.address state.matchingId = parsed.matchingId state.confirmationCodeRequired = parsed.confirmationCodeRequired diff --git a/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt b/app-common/src/main/java/im/angry/openeuicc/util/LPAString.kt similarity index 89% rename from app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt rename to app-common/src/main/java/im/angry/openeuicc/util/LPAString.kt index 2399b5a24..20956fb52 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/ActivationCode.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/LPAString.kt @@ -1,18 +1,18 @@ package im.angry.openeuicc.util -data class ActivationCode( +data class LPAString( val address: String, val matchingId: String?, val oid: String?, val confirmationCodeRequired: Boolean, ) { companion object { - fun parse(input: String): ActivationCode { + fun parse(input: String): LPAString { val components = input.removePrefix("LPA:").split('$') if (components.size < 2 || components[0] != "1") { throw IllegalArgumentException("Invalid activation code format") } - return ActivationCode( + return LPAString( address = components[1].trim(), matchingId = components.getOrNull(2)?.trim()?.ifBlank { null }, oid = components.getOrNull(3)?.trim()?.ifBlank { null }, From 6557ce45a749ed99f9e78f0412e88b492412aafd Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 8 Mar 2025 16:09:47 -0500 Subject: [PATCH 22/99] i18n: Update alert for low NVRAM --- app-common/src/main/res/values-ja/strings.xml | 2 +- app-common/src/main/res/values-zh-rCN/strings.xml | 2 +- app-common/src/main/res/values-zh-rTW/strings.xml | 2 +- app-common/src/main/res/values/strings.xml | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app-common/src/main/res/values-ja/strings.xml b/app-common/src/main/res/values-ja/strings.xml index df7267484..22b8a4c22 100644 --- a/app-common/src/main/res/values-ja/strings.xml +++ b/app-common/src/main/res/values-ja/strings.xml @@ -44,7 +44,7 @@ 確認コード (オプション) 確認コード (必須) IMEI (オプション) - ダウンロードに失敗する可能性があります + 残り容量が少ない 残り容量が少ないため、ダウンロードに失敗する可能性があります。 クリップボードに LPA コードがありません 解析できません diff --git a/app-common/src/main/res/values-zh-rCN/strings.xml b/app-common/src/main/res/values-zh-rCN/strings.xml index de020460f..9fa27f409 100644 --- a/app-common/src/main/res/values-zh-rCN/strings.xml +++ b/app-common/src/main/res/values-zh-rCN/strings.xml @@ -39,7 +39,7 @@ 确认码 (可选) 确认码 (必需) IMEI (可选) - 本次下载可能会失败 + 剩余空间不足 当前芯片的剩余空间不足,可能导致配置下载失败。\n是否继续下载? 日志已保存到指定路径。需要通过其他 App 分享吗? 新昵称 diff --git a/app-common/src/main/res/values-zh-rTW/strings.xml b/app-common/src/main/res/values-zh-rTW/strings.xml index d133d2b66..1b076b254 100644 --- a/app-common/src/main/res/values-zh-rTW/strings.xml +++ b/app-common/src/main/res/values-zh-rTW/strings.xml @@ -39,7 +39,7 @@ 確認碼 (可選) 確認碼 (必需) IMEI (可選) - 本次下載可能會失敗 + 剩餘空間不足 目前晶片的剩餘空間不足,可能導致配置下載失敗。\n是否繼續下載? 日誌已儲存到指定路徑。需要透過其他 App 分享嗎? 新名稱 diff --git a/app-common/src/main/res/values/strings.xml b/app-common/src/main/res/values/strings.xml index 990e62930..d6e1781dd 100644 --- a/app-common/src/main/res/values/strings.xml +++ b/app-common/src/main/res/values/strings.xml @@ -56,8 +56,8 @@ Confirmation Code (Required) IMEI (Optional) - This download may fail - This download may fail due to low remaining capacity. + Low remaining capacity + This profile may fail to download due to low remaining capacity. No LPA code found in clipboard Unable to parse Could not parse QR code or clipboard content as a LPA code. From 53f9459aed7ba760b1c19ac0dec345c0815aa4cc Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 8 Mar 2025 16:14:37 -0500 Subject: [PATCH 23/99] i18n: Update missing translations --- app-common/src/main/res/values-zh-rCN/strings.xml | 5 +++++ app-common/src/main/res/values-zh-rTW/strings.xml | 5 +++++ app-common/src/main/res/values/strings.xml | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app-common/src/main/res/values-zh-rCN/strings.xml b/app-common/src/main/res/values-zh-rCN/strings.xml index 9fa27f409..e255d9a8b 100644 --- a/app-common/src/main/res/values-zh-rCN/strings.xml +++ b/app-common/src/main/res/values-zh-rCN/strings.xml @@ -37,6 +37,11 @@ 服务器 (RSP / SM-DP+) 激活码 确认码 (可选) + 已复制序列号到剪贴板 + 产品名称 + 产品序列号 + 产品 Bootloader 版本 + 产品固件版本 确认码 (必需) IMEI (可选) 剩余空间不足 diff --git a/app-common/src/main/res/values-zh-rTW/strings.xml b/app-common/src/main/res/values-zh-rTW/strings.xml index 1b076b254..b8c94b5e8 100644 --- a/app-common/src/main/res/values-zh-rTW/strings.xml +++ b/app-common/src/main/res/values-zh-rTW/strings.xml @@ -37,6 +37,11 @@ 伺服器 (RSP / SM-DP+) 啟用碼 確認碼 (可選) + 已複製序號到剪貼簿 + 產品名稱 + 產品序號 + 產品引導程式版本 + 產品韌體版本 確認碼 (必需) IMEI (可選) 剩餘空間不足 diff --git a/app-common/src/main/res/values/strings.xml b/app-common/src/main/res/values/strings.xml index d6e1781dd..8a0ac24f2 100644 --- a/app-common/src/main/res/values/strings.xml +++ b/app-common/src/main/res/values/strings.xml @@ -31,7 +31,7 @@ Cannot switch to new eSIM profile. Confirmation string mismatch ICCID copied to clipboard - Serial Number copied to clipboard + Serial number copied to clipboard EID copied to clipboard ATR copied to clipboard From d3df70501a9e99d28a830558cdb7f9234861965d Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 8 Mar 2025 16:27:36 -0500 Subject: [PATCH 24/99] wizard: Make sure bitmaps are recycled properly Co-authored-by: septs --- .../wizard/DownloadWizardMethodSelectFragment.kt | 15 ++++++--------- .../main/java/im/angry/openeuicc/util/Utils.kt | 7 +++++++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt index 90fdb3306..0ae330e98 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt @@ -44,17 +44,14 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard lifecycleScope.launch(Dispatchers.IO) { runCatching { - requireContext().contentResolver.openInputStream(result)?.let { input -> - val bmp = BitmapFactory.decodeStream(input) - input.close() - - decodeQrFromBitmap(bmp)?.let { - withContext(Dispatchers.Main) { - processLpaString(it) + requireContext().contentResolver.openInputStream(result)?.use { input -> + BitmapFactory.decodeStream(input).use { bmp -> + decodeQrFromBitmap(bmp)?.let { + withContext(Dispatchers.Main) { + processLpaString(it) + } } } - - bmp.recycle() } } } diff --git a/app-common/src/main/java/im/angry/openeuicc/util/Utils.kt b/app-common/src/main/java/im/angry/openeuicc/util/Utils.kt index 444c17692..5a559f976 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/Utils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/Utils.kt @@ -86,6 +86,13 @@ suspend fun connectSEService(context: Context): SEService = suspendCoroutine { c } } +inline fun Bitmap.use(f: (Bitmap) -> T): T = + try { + f(this) + } finally { + recycle() + } + fun decodeQrFromBitmap(bmp: Bitmap): String? = runCatching { val pixels = IntArray(bmp.width * bmp.height) From db8063cd5fd0347767ba77e388b0c29b53198d8a Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 8 Mar 2025 16:42:48 -0500 Subject: [PATCH 25/99] wizard: Reduce nested closures --- .../wizard/DownloadWizardMethodSelectFragment.kt | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt index 0ae330e98..4b02b7a19 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt @@ -42,18 +42,16 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard registerForActivityResult(ActivityResultContracts.GetContent()) { result -> if (result == null) return@registerForActivityResult - lifecycleScope.launch(Dispatchers.IO) { - runCatching { - requireContext().contentResolver.openInputStream(result)?.use { input -> - BitmapFactory.decodeStream(input).use { bmp -> - decodeQrFromBitmap(bmp)?.let { - withContext(Dispatchers.Main) { - processLpaString(it) - } - } + lifecycleScope.launch { + val decoded = withContext(Dispatchers.IO) { + runCatching { + requireContext().contentResolver.openInputStream(result)?.use { input -> + BitmapFactory.decodeStream(input).use(::decodeQrFromBitmap) } } } + + decoded.getOrNull()?.let { processLpaString(it) } } } From ece231f17ba851f4dfec2d2770944ff3ce1303d0 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 8 Mar 2025 17:05:52 -0500 Subject: [PATCH 26/99] lpac-jni: Run `euicc_http_cleanup()` on success --- libs/lpac-jni/src/main/jni/lpac-jni/lpac-download.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/lpac-jni/src/main/jni/lpac-jni/lpac-download.c b/libs/lpac-jni/src/main/jni/lpac-jni/lpac-download.c index 028e30d10..bae2ee8a7 100644 --- a/libs/lpac-jni/src/main/jni/lpac-jni/lpac-download.c +++ b/libs/lpac-jni/src/main/jni/lpac-jni/lpac-download.c @@ -126,8 +126,11 @@ Java_net_typeblog_lpac_1jni_LpacJni_downloadProfile(JNIEnv *env, jobject thiz, j syslog(LOG_INFO, "es10b_load_bound_profile_package %d, reason %d", ret, es10b_load_bound_profile_package_result.errorReason); if (ret < 0) { ret = - (int) es10b_load_bound_profile_package_result.errorReason; + goto out; } + euicc_http_cleanup(ctx); + out: // We expect Java side to call cancelSessions after any error -- thus, `euicc_http_cleanup` is done there // This is so that Java side can access the last HTTP and/or APDU errors when we return. From 17102be7cbda1c05a3e6543ad7a03677b7175b27 Mon Sep 17 00:00:00 2001 From: septs Date: Sat, 8 Mar 2025 17:25:26 -0500 Subject: [PATCH 27/99] feat: Allow forcing the use of TelephonyManager everywhere Manual merge of #139, but removed all reference to "TMAPI" because such a term does not exist. Also reworked PreferenceRepository to allow extensibility from the privileged app. --- .../im/angry/openeuicc/ui/SettingsFragment.kt | 2 +- .../im/angry/openeuicc/util/PreferenceUtils.kt | 4 ++-- app-common/src/main/res/xml/pref_settings.xml | 4 ++-- .../core/PrivilegedEuiccChannelFactory.kt | 3 ++- .../angry/openeuicc/di/PrivilegedAppContainer.kt | 5 +++++ .../openeuicc/ui/PrivilegedSettingsFragment.kt | 10 ++++++++++ .../util/PrivilegedPreferenceRepository.kt | 15 +++++++++++++++ app/src/main/res/values-ja/strings.xml | 2 ++ app/src/main/res/values-zh-rCN/strings.xml | 2 ++ app/src/main/res/values-zh-rTW/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 4 ++++ app/src/main/res/xml/pref_privileged_settings.xml | 12 ++++++++++++ 12 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/im/angry/openeuicc/util/PrivilegedPreferenceRepository.kt create mode 100644 app/src/main/res/xml/pref_privileged_settings.xml diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt index fab680f2a..c54d6a185 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt @@ -122,7 +122,7 @@ open class SettingsFragment: PreferenceFragmentCompat() { return true } - private fun CheckBoxPreference.bindBooleanFlow(flow: PreferenceFlowWrapper) { + protected fun CheckBoxPreference.bindBooleanFlow(flow: PreferenceFlowWrapper) { lifecycleScope.launch { flow.collect { isChecked = it } } diff --git a/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt index f5e3ca2c1..3c7bbf4f5 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt @@ -35,7 +35,7 @@ internal object PreferenceKeys { val IGNORE_TLS_CERTIFICATE = booleanPreferencesKey("ignore_tls_certificate") } -class PreferenceRepository(private val context: Context) { +open class PreferenceRepository(private val context: Context) { // Expose flows so that we can also handle default values // ---- Profile Notifications ---- val notificationDownloadFlow = bindFlow(PreferenceKeys.NOTIFICATION_DOWNLOAD, true) @@ -51,7 +51,7 @@ class PreferenceRepository(private val context: Context) { val unfilteredProfileListFlow = bindFlow(PreferenceKeys.UNFILTERED_PROFILE_LIST, false) val ignoreTLSCertificateFlow = bindFlow(PreferenceKeys.IGNORE_TLS_CERTIFICATE, false) - private fun bindFlow(key: Preferences.Key, defaultValue: T): PreferenceFlowWrapper = + protected fun bindFlow(key: Preferences.Key, defaultValue: T): PreferenceFlowWrapper = PreferenceFlowWrapper(context, key, defaultValue) } diff --git a/app-common/src/main/res/xml/pref_settings.xml b/app-common/src/main/res/xml/pref_settings.xml index bb5bd5050..944bd27b9 100644 --- a/app-common/src/main/res/xml/pref_settings.xml +++ b/app-common/src/main/res/xml/pref_settings.xml @@ -52,7 +52,7 @@ - @@ -69,7 +69,7 @@ app:summary="@string/pref_developer_ignore_tls_certificate_desc" app:title="@string/pref_developer_ignore_tls_certificate" /> - + @@ -13,5 +19,9 @@ class PrivilegedSettingsFragment : SettingsFragment() { // eventually work for platform-signed apps. Or, at some point we might introduce our own // locale picker, which hopefully works whether privileged or not. requirePreference("pref_advanced_language").isVisible = false + + // Force use TelephonyManager API + requirePreference("pref_developer_tmapi_removable") + .bindBooleanFlow((preferenceRepository as PrivilegedPreferenceRepository).removableTelephonyManagerFlow) } } \ No newline at end of file diff --git a/app/src/main/java/im/angry/openeuicc/util/PrivilegedPreferenceRepository.kt b/app/src/main/java/im/angry/openeuicc/util/PrivilegedPreferenceRepository.kt new file mode 100644 index 000000000..9e1ffcc94 --- /dev/null +++ b/app/src/main/java/im/angry/openeuicc/util/PrivilegedPreferenceRepository.kt @@ -0,0 +1,15 @@ +package im.angry.openeuicc.util + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey + +internal object PrivilegedPreferenceKeys { + // ---- Developer Options ---- + val REMOVABLE_TELEPHONY_MANAGER = booleanPreferencesKey("removable_telephony_manager") +} + +class PrivilegedPreferenceRepository(context: Context) : PreferenceRepository(context) { + // ---- Developer Options ---- + val removableTelephonyManagerFlow = + bindFlow(PrivilegedPreferenceKeys.REMOVABLE_TELEPHONY_MANAGER, false) +} \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 29f3ef6dd..acc1728fe 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -17,4 +17,6 @@ 使用しているデバイスは eSIM をサポートしています。モバイルネットワークに接続するには通信事業者が発行した eSIM をダウンロードするか、物理 SIM を挿入してください。 スキップ eSIM をダウンロード + TelephonyManagerをどこでも使用 + デフォルトでは、非特権モード (EasyEUICC) と一致するように、取り外し可能な eUICC に対して OMAPI のみが試行されます。これは、一部のデバイスではうまく機能しない可能性があります。このオプションを選択する場合、取り外し可能な eUICC でも TelephonyManager を使用することになります。 diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 18497b267..d6befc2d2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -17,4 +17,6 @@ 跳过 下载 eSIM TelephonyManager (特权) + 全局使用 TelephonyManager + 在默认情况下,可移除 eUICC 将仅使用 OMAPI。这与非特权模式 (EasyEUICC) 一致。在某些设备上 OMAPI 可能存在问题 -- 选择此选项以强制使用 TelephonyManager。 \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 368efbc60..10285d3cd 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -17,4 +17,6 @@ 跳過 下載 eSIM TelephonyManager (特權) + 全域使用 TelephonyManager + 在預設情況下,可移除 eUICC 將僅使用 OMAPI。這與非特權模式 (EasyEUICC) 一致。在某些裝置上 OMAPI 可能有問題 -- 選擇此選項以強制使用 TelephonyManager。 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 47c88bdd0..ddf17e40e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,4 +22,8 @@ Your device supports eSIMs. To connect to mobile network, download your eSIM issued by a carrier, or insert a physical SIM. Skip Download eSIM + + + Use TelephonyManager everywhere + By default, only OMAPI is attempted for removable eUICCs to match what is done in unprivileged mode (i.e. EasyEUICC). This may not work well on some devices. Select this option to force the use of TelephonyManager even for removable eUICCs. \ No newline at end of file diff --git a/app/src/main/res/xml/pref_privileged_settings.xml b/app/src/main/res/xml/pref_privileged_settings.xml new file mode 100644 index 000000000..339233bc4 --- /dev/null +++ b/app/src/main/res/xml/pref_privileged_settings.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file From 5dd9eed4feebf18f0e49f155456d63f0bcbfd115 Mon Sep 17 00:00:00 2001 From: septs Date: Sat, 8 Mar 2025 20:26:30 -0500 Subject: [PATCH 28/99] feat: euicc memory reset peter: Adjusted strings and i18n translation. Also removed the arbitrary limit on USB channels -- this is a developer option anyway. --- .../service/EuiccChannelManagerService.kt | 15 +++ .../openeuicc/ui/EuiccManagementFragment.kt | 57 +++++--- .../openeuicc/ui/EuiccMemoryResetFragment.kt | 126 ++++++++++++++++++ .../im/angry/openeuicc/ui/SettingsFragment.kt | 6 + .../angry/openeuicc/util/PreferenceUtils.kt | 2 + .../res/drawable/ic_euicc_memory_reset.xml | 18 +++ .../src/main/res/menu/fragment_euicc.xml | 5 + app-common/src/main/res/values-ja/strings.xml | 12 ++ .../src/main/res/values-zh-rCN/strings.xml | 12 ++ .../src/main/res/values-zh-rTW/strings.xml | 12 ++ app-common/src/main/res/values/strings.xml | 13 ++ app-common/src/main/res/xml/pref_settings.xml | 7 + 12 files changed, 264 insertions(+), 21 deletions(-) create mode 100644 app-common/src/main/java/im/angry/openeuicc/ui/EuiccMemoryResetFragment.kt create mode 100644 app-common/src/main/res/drawable/ic_euicc_memory_reset.xml diff --git a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt index 760f1af86..52943d87e 100644 --- a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt +++ b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt @@ -495,4 +495,19 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { preferenceRepository.notificationSwitchFlow.first() } } + + fun launchMemoryReset(slotId: Int, portId: Int): ForegroundTaskSubscriberFlow = + launchForegroundTask( + getString(R.string.task_euicc_memory_reset), + getString(R.string.task_euicc_memory_reset_failure), + R.drawable.ic_euicc_memory_reset + ) { + euiccChannelManager.beginTrackedOperation(slotId, portId) { + euiccChannelManager.withEuiccChannel(slotId, portId) { channel -> + channel.lpa.euiccMemoryReset() + } + + preferenceRepository.euiccMemoryResetFlow.first() + } + } } \ No newline at end of file diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt index 842f4ec7d..12995ff70 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt @@ -38,8 +38,10 @@ import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, @@ -55,6 +57,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, private lateinit var fab: FloatingActionButton private lateinit var profileList: RecyclerView private var logicalSlotId: Int = -1 + private lateinit var eid: String private val adapter = EuiccProfileAdapter() @@ -131,31 +134,42 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, inflater.inflate(R.menu.fragment_euicc, menu) } - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - R.id.show_notifications -> { - if (logicalSlotId != -1) { - Intent(requireContext(), NotificationsActivity::class.java).apply { - putExtra("logicalSlotId", logicalSlotId) - startActivity(this) - } - } - true - } + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + menu.findItem(R.id.show_notifications).isVisible = + logicalSlotId != -1 + menu.findItem(R.id.euicc_info).isVisible = + logicalSlotId != -1 + menu.findItem(R.id.euicc_memory_reset).isVisible = + runBlocking { preferenceRepository.euiccMemoryResetFlow.first() } + } - R.id.euicc_info -> { - if (logicalSlotId != -1) { - Intent(requireContext(), EuiccInfoActivity::class.java).apply { - putExtra("logicalSlotId", logicalSlotId) - startActivity(this) - } - } - true + override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { + R.id.show_notifications -> { + Intent(requireContext(), NotificationsActivity::class.java).apply { + putExtra("logicalSlotId", logicalSlotId) + startActivity(this) } - - else -> super.onOptionsItemSelected(item) + true } + R.id.euicc_info -> { + Intent(requireContext(), EuiccInfoActivity::class.java).apply { + putExtra("logicalSlotId", logicalSlotId) + startActivity(this) + } + true + } + + R.id.euicc_memory_reset -> { + EuiccMemoryResetFragment.newInstance(slotId, portId, eid) + .show(childFragmentManager, EuiccMemoryResetFragment.TAG) + true + } + + else -> super.onOptionsItemSelected(item) + } + protected open suspend fun onCreateFooterViews( parent: ViewGroup, profiles: List @@ -192,6 +206,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, val profiles = withEuiccChannel { channel -> logicalSlotId = channel.logicalSlotId + eid = channel.lpa.eID euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId) if (unfilteredProfileListFlow.value) channel.lpa.profiles diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccMemoryResetFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccMemoryResetFragment.kt new file mode 100644 index 000000000..086a849a9 --- /dev/null +++ b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccMemoryResetFragment.kt @@ -0,0 +1,126 @@ +package im.angry.openeuicc.ui + +import android.graphics.Typeface +import android.os.Bundle +import android.text.Editable +import android.util.Log +import android.widget.EditText +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import im.angry.openeuicc.common.R +import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone +import im.angry.openeuicc.util.EuiccChannelFragmentMarker +import im.angry.openeuicc.util.EuiccProfilesChangedListener +import im.angry.openeuicc.util.ensureEuiccChannelManager +import im.angry.openeuicc.util.euiccChannelManagerService +import im.angry.openeuicc.util.newInstanceEuicc +import im.angry.openeuicc.util.notifyEuiccProfilesChanged +import im.angry.openeuicc.util.portId +import im.angry.openeuicc.util.slotId +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +class EuiccMemoryResetFragment : DialogFragment(), EuiccChannelFragmentMarker { + companion object { + const val TAG = "EuiccMemoryResetFragment" + + private const val FIELD_EID = "eid" + + fun newInstance(slotId: Int, portId: Int, eid: String) = + newInstanceEuicc(EuiccMemoryResetFragment::class.java, slotId, portId) { + putString(FIELD_EID, eid) + } + } + + private val eid: String by lazy { requireArguments().getString(FIELD_EID)!! } + + private val confirmText: String by lazy { + getString(R.string.euicc_memory_reset_confirm_text, eid.takeLast(8)) + } + + private inline val isMatched: Boolean + get() = editText.text.toString() == confirmText + + private var confirmed = false + + private var toast: Toast? = null + set(value) { + toast?.cancel() + field = value + value?.show() + } + + private val editText by lazy { + EditText(requireContext()).apply { + isLongClickable = false + typeface = Typeface.MONOSPACE + hint = Editable.Factory.getInstance() + .newEditable(getString(R.string.euicc_memory_reset_hint_text, confirmText)) + } + } + + private inline val alertDialog: AlertDialog + get() = requireDialog() as AlertDialog + + override fun onCreateDialog(savedInstanceState: Bundle?) = + AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme) + .setTitle(R.string.euicc_memory_reset_title) + .setMessage(getString(R.string.euicc_memory_reset_message, eid, confirmText)) + .setView(editText) + // Set listener to null to prevent auto closing + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.euicc_memory_reset_invoke_button, null) + .create() + + override fun onResume() { + super.onResume() + alertDialog.setCanceledOnTouchOutside(false) + alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) + .setOnClickListener { if (!confirmed) confirmation() } + alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE) + .setOnClickListener { if (!confirmed) dismiss() } + } + + private fun confirmation() { + toast?.cancel() + if (!isMatched) { + Log.d(TAG, buildString { + appendLine("User input is mismatch:") + appendLine(editText.text) + appendLine(confirmText) + }) + val resId = R.string.toast_euicc_memory_reset_confirm_text_mismatched + toast = Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG) + return + } + confirmed = true + preventUserAction() + + requireParentFragment().lifecycleScope.launch { + ensureEuiccChannelManager() + euiccChannelManagerService.waitForForegroundTask() + + euiccChannelManagerService.launchMemoryReset(slotId, portId) + .onStart { + parentFragment?.notifyEuiccProfilesChanged() + + val resId = R.string.toast_euicc_memory_reset_finitshed + toast = Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG) + + runCatching(::dismiss) + } + .waitDone() + } + } + + private fun preventUserAction() { + editText.isEnabled = false + alertDialog.setCancelable(false) + alertDialog.setCanceledOnTouchOutside(false) + alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).isEnabled = false + } +} diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt index c54d6a185..cdb58f143 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt @@ -1,6 +1,7 @@ package im.angry.openeuicc.ui import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle @@ -77,6 +78,11 @@ open class SettingsFragment: PreferenceFragmentCompat() { requirePreference("pref_developer_ignore_tls_certificate") .bindBooleanFlow(preferenceRepository.ignoreTLSCertificateFlow) + + requirePreference("pref_developer_euicc_memory_reset").apply { + isVisible = context.packageManager.hasSystemFeature(PackageManager.FEATURE_USB_HOST) + bindBooleanFlow(preferenceRepository.euiccMemoryResetFlow) + } } protected fun requirePreference(key: CharSequence) = diff --git a/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt index 3c7bbf4f5..34d1cfd96 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt @@ -33,6 +33,7 @@ internal object PreferenceKeys { val DEVELOPER_OPTIONS_ENABLED = booleanPreferencesKey("developer_options_enabled") val UNFILTERED_PROFILE_LIST = booleanPreferencesKey("unfiltered_profile_list") val IGNORE_TLS_CERTIFICATE = booleanPreferencesKey("ignore_tls_certificate") + val EUICC_MEMORY_RESET = booleanPreferencesKey("euicc_memory_reset") } open class PreferenceRepository(private val context: Context) { @@ -50,6 +51,7 @@ open class PreferenceRepository(private val context: Context) { val developerOptionsEnabledFlow = bindFlow(PreferenceKeys.DEVELOPER_OPTIONS_ENABLED, false) val unfilteredProfileListFlow = bindFlow(PreferenceKeys.UNFILTERED_PROFILE_LIST, false) val ignoreTLSCertificateFlow = bindFlow(PreferenceKeys.IGNORE_TLS_CERTIFICATE, false) + val euiccMemoryResetFlow = bindFlow(PreferenceKeys.EUICC_MEMORY_RESET, false) protected fun bindFlow(key: Preferences.Key, defaultValue: T): PreferenceFlowWrapper = PreferenceFlowWrapper(context, key, defaultValue) diff --git a/app-common/src/main/res/drawable/ic_euicc_memory_reset.xml b/app-common/src/main/res/drawable/ic_euicc_memory_reset.xml new file mode 100644 index 000000000..f1ca8c164 --- /dev/null +++ b/app-common/src/main/res/drawable/ic_euicc_memory_reset.xml @@ -0,0 +1,18 @@ + + + + diff --git a/app-common/src/main/res/menu/fragment_euicc.xml b/app-common/src/main/res/menu/fragment_euicc.xml index b54eaf17e..6e2dfbe87 100644 --- a/app-common/src/main/res/menu/fragment_euicc.xml +++ b/app-common/src/main/res/menu/fragment_euicc.xml @@ -10,4 +10,9 @@ android:id="@+id/euicc_info" android:title="@string/euicc_info" app:showAsAction="never" /> + + \ No newline at end of file diff --git a/app-common/src/main/res/values-ja/strings.xml b/app-common/src/main/res/values-ja/strings.xml index 22b8a4c22..1fd361c8b 100644 --- a/app-common/src/main/res/values-ja/strings.xml +++ b/app-common/src/main/res/values-ja/strings.xml @@ -150,4 +150,16 @@ 情報 アプリバージョン ソースコード + 確認文字列が一致しません + このチップは消去されました + eSIM チップを消去しています + eSIM チップの消去は失敗しました + eSIM を消去する + eSIM を消去する + このチップ内のすべてのプロファイルを削除することをご確認してください。この操作は元に戻せないことをご理解してください。\n\nEID: %s\n\n%s + 確認のため、ここに「%s」を入力してください + EID が %s で終わるチップを消去することに同意します。これは元に戻せないことを理解しています。 + 消去する + eUICC の消去を可能にする + この操作は、デフォルトでは非表示になっている危険な操作です。代わりに、すべての構成ファイルを手動で削除することもできます。 diff --git a/app-common/src/main/res/values-zh-rCN/strings.xml b/app-common/src/main/res/values-zh-rCN/strings.xml index e255d9a8b..8c360912c 100644 --- a/app-common/src/main/res/values-zh-rCN/strings.xml +++ b/app-common/src/main/res/values-zh-rCN/strings.xml @@ -150,4 +150,16 @@ 无视 SM-DP+ 的 TLS 证书 允许 RSP 服务器使用任意证书 无信息 + 输入的确认文本不匹配 + 此芯片已被擦除 + 正在擦除 eSIM 芯片 + eSIM 芯片擦除失败 + 擦除 eSIM 芯片 + 擦除 eSIM 芯片 + 请确认删除此芯片上的所有配置文件,并了解此操作不可逆。\n\nEID: %s\n\n%s + 请在此处输入「%s」以确认 + 我确认擦除 EID 以 %s 结尾的芯片,并了解此操作不可逆 + 擦除 + 允许擦除 eUICC + 此操作是默认隐藏的危险操作。作为替代方案,您可以手动删除所有配置文件。 \ No newline at end of file diff --git a/app-common/src/main/res/values-zh-rTW/strings.xml b/app-common/src/main/res/values-zh-rTW/strings.xml index b8c94b5e8..3d8270ddc 100644 --- a/app-common/src/main/res/values-zh-rTW/strings.xml +++ b/app-common/src/main/res/values-zh-rTW/strings.xml @@ -150,4 +150,16 @@ 忽略 SM-DP+ 的 TLS 證書 允許 RSP 伺服器使用任意證書 無資訊 + 輸入的確認文字不匹配 + 此晶片已被擦除 + 正在擦除 eSIM 晶片 + eSIM 晶片擦除失敗 + 擦除 eSIM 晶片 + 擦除 eSIM 晶片 + 請確認刪除此晶片上的所有配置文件,並了解此操作不可逆。\n\nEID: %s\n\n%s + 請在此輸入「%s」以確認 + 我確認擦除 EID 以 %s 結尾的晶片,並了解此操作不可逆 + 擦除 + 允許擦除 eUICC + 此操作是預設隱藏的危險操作。作為替代方案,您可以手動刪除所有設定檔。 \ No newline at end of file diff --git a/app-common/src/main/res/values/strings.xml b/app-common/src/main/res/values/strings.xml index 8a0ac24f2..cc84381d6 100644 --- a/app-common/src/main/res/values/strings.xml +++ b/app-common/src/main/res/values/strings.xml @@ -30,6 +30,8 @@ Cannot switch to new eSIM profile. Confirmation string mismatch + Confirmation string mismatch + This chip has been erased ICCID copied to clipboard Serial number copied to clipboard EID copied to clipboard @@ -48,6 +50,8 @@ Failed to delete eSIM profile Switching eSIM profile Failed to switch eSIM profile + Erasing eSIM chip + Failed to erase eSIM chip New eSIM Server (RSP / SM-DP+) @@ -142,6 +146,13 @@ Unknown eSIM CI Answer To Reset (ATR) + Erase eUICC + Erase eUICC + Please confirm to delete all profiles on this chip and understand that this operation is irreversible.\n\nEID: %s\n\n%s + Type \'%s\' here to confirm + I CONFIRM TO ERASE THE CHIP WHOSE EID ENDS WITH %s AND UNDERSTAND THAT THIS IS IRREVERSIBLE + Erase + Yes No @@ -174,6 +185,8 @@ Include non-production profiles in the list Ignore SM-DP+ TLS certificate Accept any TLS certificate used by the RSP server + Allow erasing eUICC + This is a dangerous operation and hidden by default. As an alternative, you can delete all profiles manually. Info App Version Source Code diff --git a/app-common/src/main/res/xml/pref_settings.xml b/app-common/src/main/res/xml/pref_settings.xml index 944bd27b9..008f9aeb4 100644 --- a/app-common/src/main/res/xml/pref_settings.xml +++ b/app-common/src/main/res/xml/pref_settings.xml @@ -69,6 +69,13 @@ app:summary="@string/pref_developer_ignore_tls_certificate_desc" app:title="@string/pref_developer_ignore_tls_certificate" /> + + Date: Sat, 8 Mar 2025 20:28:19 -0500 Subject: [PATCH 29/99] fix: Don't use beginTrackedOperation for erasure. It's wrong. --- .../angry/openeuicc/service/EuiccChannelManagerService.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt index 52943d87e..9957f30d8 100644 --- a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt +++ b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt @@ -502,12 +502,8 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { getString(R.string.task_euicc_memory_reset_failure), R.drawable.ic_euicc_memory_reset ) { - euiccChannelManager.beginTrackedOperation(slotId, portId) { - euiccChannelManager.withEuiccChannel(slotId, portId) { channel -> - channel.lpa.euiccMemoryReset() - } - - preferenceRepository.euiccMemoryResetFlow.first() + euiccChannelManager.withEuiccChannel(slotId, portId) { channel -> + channel.lpa.euiccMemoryReset() } } } \ No newline at end of file From f6c50490b86bc912e1cb52ca9f08e49138adeef0 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 8 Mar 2025 20:35:00 -0500 Subject: [PATCH 30/99] fix: No USB_HOST feature requirement for eUICC erasure --- .../main/java/im/angry/openeuicc/ui/SettingsFragment.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt index cdb58f143..b085286fb 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt @@ -1,7 +1,6 @@ package im.angry.openeuicc.ui import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle @@ -79,10 +78,8 @@ open class SettingsFragment: PreferenceFragmentCompat() { requirePreference("pref_developer_ignore_tls_certificate") .bindBooleanFlow(preferenceRepository.ignoreTLSCertificateFlow) - requirePreference("pref_developer_euicc_memory_reset").apply { - isVisible = context.packageManager.hasSystemFeature(PackageManager.FEATURE_USB_HOST) - bindBooleanFlow(preferenceRepository.euiccMemoryResetFlow) - } + requirePreference("pref_developer_euicc_memory_reset") + .bindBooleanFlow(preferenceRepository.euiccMemoryResetFlow) } protected fun requirePreference(key: CharSequence) = From 74cc08ce8e7d678de49f40a9f5926a1d5ebb77da Mon Sep 17 00:00:00 2001 From: septs Date: Sun, 9 Mar 2025 04:22:07 +0100 Subject: [PATCH 31/99] chore: remove visibility attribute from euicc memory reset preference (#166) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/166 Co-authored-by: septs Co-committed-by: septs --- app-common/src/main/res/xml/pref_settings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app-common/src/main/res/xml/pref_settings.xml b/app-common/src/main/res/xml/pref_settings.xml index 008f9aeb4..7d25118d1 100644 --- a/app-common/src/main/res/xml/pref_settings.xml +++ b/app-common/src/main/res/xml/pref_settings.xml @@ -71,7 +71,6 @@ From 88eb1ce0e22efff875e35eaa5025dec0c1a2eae2 Mon Sep 17 00:00:00 2001 From: septs Date: Sun, 9 Mar 2025 22:47:02 +0100 Subject: [PATCH 32/99] feat: update TelephonyManager preference key and implement context marker interface (#167) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/167 Co-authored-by: septs Co-committed-by: septs --- .../openeuicc/core/PrivilegedEuiccChannelFactory.kt | 5 +++-- .../openeuicc/ui/PrivilegedSettingsFragment.kt | 6 +++--- .../java/im/angry/openeuicc/util/PrivilegedUtils.kt | 13 +++++++++++++ app/src/main/res/xml/pref_privileged_settings.xml | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt b/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt index 2044c02ee..6dccda948 100644 --- a/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt +++ b/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt @@ -8,7 +8,8 @@ import im.angry.openeuicc.util.* import kotlinx.coroutines.flow.first import java.lang.IllegalArgumentException -class PrivilegedEuiccChannelFactory(context: Context) : DefaultEuiccChannelFactory(context) { +class PrivilegedEuiccChannelFactory(context: Context) : DefaultEuiccChannelFactory(context), + PrivilegedEuiccContextMarker { private val tm by lazy { (context.applicationContext as OpenEuiccApplication).appContainer.telephonyManager } @@ -22,7 +23,7 @@ class PrivilegedEuiccChannelFactory(context: Context) : DefaultEuiccChannelFacto super.tryOpenEuiccChannel(port)?.let { return it } } - if (port.card.isEuicc || (context.preferenceRepository as PrivilegedPreferenceRepository).removableTelephonyManagerFlow.first()) { + if (port.card.isEuicc || preferenceRepository.removableTelephonyManagerFlow.first()) { Log.i( DefaultEuiccChannelManager.TAG, "Trying TelephonyManager for slot ${port.card.physicalSlotIndex} port ${port.portIndex}" diff --git a/app/src/main/java/im/angry/openeuicc/ui/PrivilegedSettingsFragment.kt b/app/src/main/java/im/angry/openeuicc/ui/PrivilegedSettingsFragment.kt index 7229688b3..c67d88047 100644 --- a/app/src/main/java/im/angry/openeuicc/ui/PrivilegedSettingsFragment.kt +++ b/app/src/main/java/im/angry/openeuicc/ui/PrivilegedSettingsFragment.kt @@ -6,7 +6,7 @@ import androidx.preference.Preference import im.angry.openeuicc.R import im.angry.openeuicc.util.* -class PrivilegedSettingsFragment : SettingsFragment() { +class PrivilegedSettingsFragment : SettingsFragment(), PrivilegedEuiccContextMarker { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { super.onCreatePreferences(savedInstanceState, rootKey) addPreferencesFromResource(R.xml.pref_privileged_settings) @@ -21,7 +21,7 @@ class PrivilegedSettingsFragment : SettingsFragment() { requirePreference("pref_advanced_language").isVisible = false // Force use TelephonyManager API - requirePreference("pref_developer_tmapi_removable") - .bindBooleanFlow((preferenceRepository as PrivilegedPreferenceRepository).removableTelephonyManagerFlow) + requirePreference("pref_developer_removable_telephony_manager") + .bindBooleanFlow(preferenceRepository.removableTelephonyManagerFlow) } } \ No newline at end of file diff --git a/app/src/main/java/im/angry/openeuicc/util/PrivilegedUtils.kt b/app/src/main/java/im/angry/openeuicc/util/PrivilegedUtils.kt index e295f2694..21c8002f0 100644 --- a/app/src/main/java/im/angry/openeuicc/util/PrivilegedUtils.kt +++ b/app/src/main/java/im/angry/openeuicc/util/PrivilegedUtils.kt @@ -5,10 +5,23 @@ import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.os.IBinder +import androidx.fragment.app.Fragment import java.util.concurrent.Executors import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +interface PrivilegedEuiccContextMarker { + val privilegedEuiccMarkerContext: Context + get() = when (this) { + is Context -> this + is Fragment -> requireContext() + else -> throw RuntimeException("PrivilegedEuiccContextMarker shall only be used on Fragments or UI types that derive from Context") + } + + val preferenceRepository: PrivilegedPreferenceRepository + get() = privilegedEuiccMarkerContext.preferenceRepository as PrivilegedPreferenceRepository +} + suspend fun Context.bindServiceSuspended(intent: Intent, flags: Int): Pair Unit> = suspendCoroutine { cont -> var binder: IBinder? diff --git a/app/src/main/res/xml/pref_privileged_settings.xml b/app/src/main/res/xml/pref_privileged_settings.xml index 339233bc4..5279126af 100644 --- a/app/src/main/res/xml/pref_privileged_settings.xml +++ b/app/src/main/res/xml/pref_privileged_settings.xml @@ -5,7 +5,7 @@ app:key="pref_developer_overlay"> From b9849afe182ef747ddf7819732d27f97d03cce6e Mon Sep 17 00:00:00 2001 From: LuK1337 Date: Wed, 12 Mar 2025 01:00:10 +0100 Subject: [PATCH 33/99] fix: Address multiple substitutions in string format error (#175) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/175 Co-authored-by: LuK1337 Co-committed-by: LuK1337 --- app-common/src/main/res/values-ja/strings.xml | 2 +- app-common/src/main/res/values-zh-rCN/strings.xml | 2 +- app-common/src/main/res/values-zh-rTW/strings.xml | 2 +- app-common/src/main/res/values/strings.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app-common/src/main/res/values-ja/strings.xml b/app-common/src/main/res/values-ja/strings.xml index 1fd361c8b..644e56458 100644 --- a/app-common/src/main/res/values-ja/strings.xml +++ b/app-common/src/main/res/values-ja/strings.xml @@ -156,7 +156,7 @@ eSIM チップの消去は失敗しました eSIM を消去する eSIM を消去する - このチップ内のすべてのプロファイルを削除することをご確認してください。この操作は元に戻せないことをご理解してください。\n\nEID: %s\n\n%s + このチップ内のすべてのプロファイルを削除することをご確認してください。この操作は元に戻せないことをご理解してください。\n\nEID: %1$s\n\n%2$s 確認のため、ここに「%s」を入力してください EID が %s で終わるチップを消去することに同意します。これは元に戻せないことを理解しています。 消去する diff --git a/app-common/src/main/res/values-zh-rCN/strings.xml b/app-common/src/main/res/values-zh-rCN/strings.xml index 8c360912c..789b53200 100644 --- a/app-common/src/main/res/values-zh-rCN/strings.xml +++ b/app-common/src/main/res/values-zh-rCN/strings.xml @@ -156,7 +156,7 @@ eSIM 芯片擦除失败 擦除 eSIM 芯片 擦除 eSIM 芯片 - 请确认删除此芯片上的所有配置文件,并了解此操作不可逆。\n\nEID: %s\n\n%s + 请确认删除此芯片上的所有配置文件,并了解此操作不可逆。\n\nEID: %1$s\n\n%2$s 请在此处输入「%s」以确认 我确认擦除 EID 以 %s 结尾的芯片,并了解此操作不可逆 擦除 diff --git a/app-common/src/main/res/values-zh-rTW/strings.xml b/app-common/src/main/res/values-zh-rTW/strings.xml index 3d8270ddc..53be5715d 100644 --- a/app-common/src/main/res/values-zh-rTW/strings.xml +++ b/app-common/src/main/res/values-zh-rTW/strings.xml @@ -156,7 +156,7 @@ eSIM 晶片擦除失敗 擦除 eSIM 晶片 擦除 eSIM 晶片 - 請確認刪除此晶片上的所有配置文件,並了解此操作不可逆。\n\nEID: %s\n\n%s + 請確認刪除此晶片上的所有配置文件,並了解此操作不可逆。\n\nEID: %1$s\n\n%2$s 請在此輸入「%s」以確認 我確認擦除 EID 以 %s 結尾的晶片,並了解此操作不可逆 擦除 diff --git a/app-common/src/main/res/values/strings.xml b/app-common/src/main/res/values/strings.xml index cc84381d6..8146f5472 100644 --- a/app-common/src/main/res/values/strings.xml +++ b/app-common/src/main/res/values/strings.xml @@ -148,7 +148,7 @@ Erase eUICC Erase eUICC - Please confirm to delete all profiles on this chip and understand that this operation is irreversible.\n\nEID: %s\n\n%s + Please confirm to delete all profiles on this chip and understand that this operation is irreversible.\n\nEID: %1$s\n\n%2$s Type \'%s\' here to confirm I CONFIRM TO ERASE THE CHIP WHOSE EID ENDS WITH %s AND UNDERSTAND THAT THIS IS IRREVERSIBLE Erase From 360760b78fbc2298c62521921f8960d82ad41242 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 15 Mar 2025 15:38:26 -0400 Subject: [PATCH 34/99] chore: Upgrade Android Studio and Gradle --- .idea/codeStyles/Project.xml | 6 + .idea/codeStyles/codeStyleConfig.xml | 1 + .idea/deploymentTargetSelector.xml | 24 ++ .idea/kotlinc.xml | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 +- gradlew | 297 ++++++++++++++--------- gradlew.bat | 37 +-- 8 files changed, 239 insertions(+), 135 deletions(-) diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 4bec4ea8a..7643783a8 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,5 +1,8 @@ + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml index a55e7a179..6e6eec114 100644 --- a/.idea/codeStyles/codeStyleConfig.xml +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -1,5 +1,6 @@ + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 8096d6c0d..e40be604b 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -8,6 +8,30 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index e805548aa..148fdd246 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..a4b76b9530d66f5e68d973ea569d8e19de379189 100644 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index dab3589f2..9355b4155 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Wed Jun 08 13:28:20 EDT 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c8..f5feea6d6 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,69 +15,104 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +122,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +133,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f93..9b42019c7 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -75,13 +78,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal From a6286ed09765fbc2cd80a5f38c70050cf9cacc50 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sun, 16 Mar 2025 17:32:09 -0400 Subject: [PATCH 35/99] feat: Acquire partial wake lock for all foreground tasks All of our foreground tasks require the CPU to be at least awake to make any progress. We could keep the screen on but we really only need the partial wake lock to make sure progress is made. --- app-common/src/main/AndroidManifest.xml | 1 + .../openeuicc/service/EuiccChannelManagerService.kt | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/app-common/src/main/AndroidManifest.xml b/app-common/src/main/AndroidManifest.xml index 6edaaf13a..464ae0f5f 100644 --- a/app-common/src/main/AndroidManifest.xml +++ b/app-common/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + Date: Sun, 16 Mar 2025 17:35:46 -0400 Subject: [PATCH 36/99] We don't need a public wake lock --- .../im/angry/openeuicc/service/EuiccChannelManagerService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt index a812a47db..88c805796 100644 --- a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt +++ b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt @@ -92,7 +92,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { } val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate - val wakeLock: PowerManager.WakeLock by lazy { + private val wakeLock: PowerManager.WakeLock by lazy { (getSystemService(POWER_SERVICE) as PowerManager).run { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this::class.simpleName) } From 33d383a3ce8dbe405791c17c6d4e34b67d7c47c8 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sun, 16 Mar 2025 17:54:54 -0400 Subject: [PATCH 37/99] ui: wizard: Keep screen on during the download process --- .../openeuicc/ui/wizard/DownloadWizardActivity.kt | 11 +++++++++++ .../ui/wizard/DownloadWizardProgressFragment.kt | 3 +++ 2 files changed, 14 insertions(+) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt index a9f868f02..9e312d4e8 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt @@ -3,6 +3,7 @@ package im.angry.openeuicc.ui.wizard import android.app.assist.AssistContent import android.os.Bundle import android.view.View +import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.Button import android.widget.ProgressBar @@ -251,6 +252,14 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { supportFragmentManager.beginTransaction().setCustomAnimations(enterAnim, exitAnim) .replace(R.id.step_fragment_container, nextFrag) .commit() + + // Sync screen on state + if (nextFrag.keepScreenOn) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + refreshButtons() } @@ -280,6 +289,8 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { protected val state: DownloadWizardState get() = (requireActivity() as DownloadWizardActivity).state + open val keepScreenOn = false + abstract val hasNext: Boolean abstract val hasPrev: Boolean abstract fun createNextFragment(): DownloadWizardStepFragment? diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt index 1b816d48f..342a687f8 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt @@ -59,6 +59,9 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep private val adapter = ProgressItemAdapter() + // We don't want to turn off the screen during a download + override val keepScreenOn = true + private var isDone = false override val hasNext: Boolean From 6b169c505d623c186d10283a3ec94501b5f4e5f3 Mon Sep 17 00:00:00 2001 From: septs Date: Mon, 17 Mar 2025 00:01:06 +0100 Subject: [PATCH 38/99] fix: crash (priv) (#177) resolves #178 ``` 1741836445.585 10331 13748 13748 E AndroidRuntime: FATAL EXCEPTION: main 1741836445.585 10331 13748 13748 E AndroidRuntime: Process: im.angry.openeuicc, PID: 13748 1741836445.585 10331 13748 13748 E AndroidRuntime: java.lang.RuntimeException: PrivilegedEuiccContextMarker shall only be used on Fragments or UI types that derive from Context 1741836445.585 10331 13748 13748 E AndroidRuntime: at im.angry.openeuicc.util.PrivilegedEuiccContextMarker$DefaultImpls.getPrivilegedEuiccMarkerContext(PrivilegedUtils.kt:18) ``` Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/177 Co-authored-by: septs Co-committed-by: septs --- .../src/main/java/im/angry/openeuicc/util/Utils.kt | 3 +++ .../openeuicc/core/PrivilegedEuiccChannelFactory.kt | 7 +++---- .../java/im/angry/openeuicc/util/PrivilegedUtils.kt | 13 +++---------- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/util/Utils.kt b/app-common/src/main/java/im/angry/openeuicc/util/Utils.kt index 5a559f976..046657f8d 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/Utils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/Utils.kt @@ -54,6 +54,9 @@ interface OpenEuiccContextMarker { val appContainer: AppContainer get() = openEuiccApplication.appContainer + val preferenceRepository: PreferenceRepository + get() = appContainer.preferenceRepository + val telephonyManager: TelephonyManager get() = appContainer.telephonyManager } diff --git a/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt b/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt index 6dccda948..d9bceda3e 100644 --- a/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt +++ b/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt @@ -10,9 +10,8 @@ import java.lang.IllegalArgumentException class PrivilegedEuiccChannelFactory(context: Context) : DefaultEuiccChannelFactory(context), PrivilegedEuiccContextMarker { - private val tm by lazy { - (context.applicationContext as OpenEuiccApplication).appContainer.telephonyManager - } + override val openEuiccMarkerContext: Context + get() = context @Suppress("NAME_SHADOWING") override suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? { @@ -35,7 +34,7 @@ class PrivilegedEuiccChannelFactory(context: Context) : DefaultEuiccChannelFacto intrinsicChannelName = null, TelephonyManagerApduInterface( port, - tm, + telephonyManager, context.preferenceRepository.verboseLoggingFlow ), context.preferenceRepository.verboseLoggingFlow, diff --git a/app/src/main/java/im/angry/openeuicc/util/PrivilegedUtils.kt b/app/src/main/java/im/angry/openeuicc/util/PrivilegedUtils.kt index 21c8002f0..641858101 100644 --- a/app/src/main/java/im/angry/openeuicc/util/PrivilegedUtils.kt +++ b/app/src/main/java/im/angry/openeuicc/util/PrivilegedUtils.kt @@ -10,16 +10,9 @@ import java.util.concurrent.Executors import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -interface PrivilegedEuiccContextMarker { - val privilegedEuiccMarkerContext: Context - get() = when (this) { - is Context -> this - is Fragment -> requireContext() - else -> throw RuntimeException("PrivilegedEuiccContextMarker shall only be used on Fragments or UI types that derive from Context") - } - - val preferenceRepository: PrivilegedPreferenceRepository - get() = privilegedEuiccMarkerContext.preferenceRepository as PrivilegedPreferenceRepository +interface PrivilegedEuiccContextMarker : OpenEuiccContextMarker { + override val preferenceRepository: PrivilegedPreferenceRepository + get() = appContainer.preferenceRepository as PrivilegedPreferenceRepository } suspend fun Context.bindServiceSuspended(intent: Intent, flags: Int): Pair Unit> = From e08f8beb45007d6b70fa3a649815f16e760c1a6d Mon Sep 17 00:00:00 2001 From: septs Date: Mon, 17 Mar 2025 00:01:22 +0100 Subject: [PATCH 39/99] feat: add iQOO stk launch support (#179) ![image](/attachments/b2aac119-c488-41e6-a39f-eab8559cd63b) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/179 Co-authored-by: septs Co-committed-by: septs --- app-unpriv/src/main/res/values/sim_toolkit.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app-unpriv/src/main/res/values/sim_toolkit.xml b/app-unpriv/src/main/res/values/sim_toolkit.xml index 1f162715e..8db99550b 100644 --- a/app-unpriv/src/main/res/values/sim_toolkit.xml +++ b/app-unpriv/src/main/res/values/sim_toolkit.xml @@ -5,12 +5,14 @@ com.android.stk/.StkMainHide com.android.stk/.StkListActivity com.android.stk/.StkLauncherListActivity + com.android.stk/.StkSelectionActivity com.android.stk/.StkMain1 com.android.stk/.PrimaryStkMain com.android.stk/.StkLauncherActivity com.android.stk/.StkLauncherActivity_Chn + com.android.stk/.StkLauncherActivity1 com.android.stk/.StkLauncherActivityI com.android.stk/.OppoStkLauncherActivity1 com.android.stk/.OplusStkLauncherActivity1 From dc6b3a4810bd05b25bd5433993cece12a79c5945 Mon Sep 17 00:00:00 2001 From: septs Date: Sun, 16 Mar 2025 21:04:28 -0400 Subject: [PATCH 40/99] feat: support disabling refresh after switch in settings peter: Reworked strings and i18 translations. Also removed the ad-hoc function in favor of a lambda. --- .../service/EuiccChannelManagerService.kt | 37 +++++++++++-------- .../im/angry/openeuicc/ui/SettingsFragment.kt | 3 ++ .../angry/openeuicc/util/PreferenceUtils.kt | 13 +++---- app-common/src/main/res/values-ja/strings.xml | 2 + .../src/main/res/values-zh-rCN/strings.xml | 2 + .../src/main/res/values-zh-rTW/strings.xml | 2 + app-common/src/main/res/values/strings.xml | 2 + app-common/src/main/res/xml/pref_settings.xml | 6 +++ 8 files changed, 44 insertions(+), 23 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt index 88c805796..2a2448889 100644 --- a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt +++ b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt @@ -12,6 +12,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import im.angry.openeuicc.common.R +import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers @@ -456,30 +457,34 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { iccid: String, enable: Boolean, // Enable or disable the profile indicated in iccid reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect - ): ForegroundTaskSubscriberFlow = + ) = launchForegroundTask( getString(R.string.task_profile_switch), getString(R.string.task_profile_switch_failure), R.drawable.ic_task_switch ) { euiccChannelManager.beginTrackedOperation(slotId, portId) { - val (res, refreshed) = euiccChannelManager.withEuiccChannel( - slotId, - portId - ) { channel -> - if (!channel.lpa.switchProfile(iccid, enable, refresh = true)) { - // Sometimes, we *can* enable or disable the profile, but we cannot - // send the refresh command to the modem because the profile somehow - // makes the modem "busy". In this case, we can still switch by setting - // refresh to false, but then the switch cannot take effect until the - // user resets the modem manually by toggling airplane mode or rebooting. - Pair(channel.lpa.switchProfile(iccid, enable, refresh = false), false) - } else { - Pair(true, true) + val (response, refreshed) = + euiccChannelManager.withEuiccChannel(slotId, portId) { channel -> + val refresh = preferenceRepository.refreshAfterSwitchFlow.first() + val response = channel.lpa.switchProfile(iccid, enable, refresh) + if (response || !refresh) { + Pair(response, refresh) + } else { + // refresh failed, but refresh was requested + // Sometimes, we *can* enable or disable the profile, but we cannot + // send the refresh command to the modem because the profile somehow + // makes the modem "busy". In this case, we can still switch by setting + // refresh to false, but then the switch cannot take effect until the + // user resets the modem manually by toggling airplane mode or rebooting. + Pair( + channel.lpa.switchProfile(iccid, enable, refresh = false), + false + ) + } } - } - if (!res) { + if (!response) { throw RuntimeException("Could not switch profile") } diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt index b085286fb..d137e9099 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt @@ -78,6 +78,9 @@ open class SettingsFragment: PreferenceFragmentCompat() { requirePreference("pref_developer_ignore_tls_certificate") .bindBooleanFlow(preferenceRepository.ignoreTLSCertificateFlow) + requirePreference("pref_developer_refresh_after_switch") + .bindBooleanFlow(preferenceRepository.refreshAfterSwitchFlow) + requirePreference("pref_developer_euicc_memory_reset") .bindBooleanFlow(preferenceRepository.euiccMemoryResetFlow) } diff --git a/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt index 34d1cfd96..c69c5e43c 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt @@ -31,6 +31,7 @@ internal object PreferenceKeys { // ---- Developer Options ---- val DEVELOPER_OPTIONS_ENABLED = booleanPreferencesKey("developer_options_enabled") + val REFRESH_AFTER_SWITCH = booleanPreferencesKey("refresh_after_switch") val UNFILTERED_PROFILE_LIST = booleanPreferencesKey("unfiltered_profile_list") val IGNORE_TLS_CERTIFICATE = booleanPreferencesKey("ignore_tls_certificate") val EUICC_MEMORY_RESET = booleanPreferencesKey("euicc_memory_reset") @@ -48,6 +49,7 @@ open class PreferenceRepository(private val context: Context) { val verboseLoggingFlow = bindFlow(PreferenceKeys.VERBOSE_LOGGING, false) // ---- Developer Options ---- + val refreshAfterSwitchFlow = bindFlow(PreferenceKeys.REFRESH_AFTER_SWITCH, true) val developerOptionsEnabledFlow = bindFlow(PreferenceKeys.DEVELOPER_OPTIONS_ENABLED, false) val unfilteredProfileListFlow = bindFlow(PreferenceKeys.UNFILTERED_PROFILE_LIST, false) val ignoreTLSCertificateFlow = bindFlow(PreferenceKeys.IGNORE_TLS_CERTIFICATE, false) @@ -60,15 +62,12 @@ open class PreferenceRepository(private val context: Context) { class PreferenceFlowWrapper private constructor( private val context: Context, private val key: Preferences.Key, - inner: Flow + inner: Flow, ) : Flow by inner { - internal constructor(context: Context, key: Preferences.Key, defaultValue: T) : this( - context, - key, - context.dataStore.data.map { it[key] ?: defaultValue } - ) + internal constructor(context: Context, key: Preferences.Key, defaultValue: T) : + this(context, key, context.dataStore.data.map { it[key] ?: defaultValue }) suspend fun updatePreference(value: T) { context.dataStore.edit { it[key] = value } } -} \ No newline at end of file +} diff --git a/app-common/src/main/res/values-ja/strings.xml b/app-common/src/main/res/values-ja/strings.xml index 644e56458..35fd40014 100644 --- a/app-common/src/main/res/values-ja/strings.xml +++ b/app-common/src/main/res/values-ja/strings.xml @@ -143,6 +143,7 @@ ログ アプリの最新デバッグログを表示します 開発者オプション + プロファイルを切り替えた後にモデムに更新コマンドを送信するかどうか。クラッシュが発生する場合は、これを無効にしてみてください。 フィルタリングされていないプロファイル一覧を表示 非運用のプロファイルも含めます SM-DP+ TLS 証明書を無視する @@ -162,4 +163,5 @@ 消去する eUICC の消去を可能にする この操作は、デフォルトでは非表示になっている危険な操作です。代わりに、すべての構成ファイルを手動で削除することもできます。 + モデムに更新コマンドを送信 diff --git a/app-common/src/main/res/values-zh-rCN/strings.xml b/app-common/src/main/res/values-zh-rCN/strings.xml index 789b53200..2e069bd63 100644 --- a/app-common/src/main/res/values-zh-rCN/strings.xml +++ b/app-common/src/main/res/values-zh-rCN/strings.xml @@ -145,6 +145,7 @@ 语言 选择 App 语言 开发者选项 + 切换配置文件后是否向基带发送刷新命令。如果发现崩溃,请尝试禁用此功能。 显示未经过滤的配置文件列表 在配置文件列表中包括非生产环境的配置文件 无视 SM-DP+ 的 TLS 证书 @@ -162,4 +163,5 @@ 擦除 允许擦除 eUICC 此操作是默认隐藏的危险操作。作为替代方案,您可以手动删除所有配置文件。 + 向基带发送刷新命令 \ No newline at end of file diff --git a/app-common/src/main/res/values-zh-rTW/strings.xml b/app-common/src/main/res/values-zh-rTW/strings.xml index 53be5715d..729893ca4 100644 --- a/app-common/src/main/res/values-zh-rTW/strings.xml +++ b/app-common/src/main/res/values-zh-rTW/strings.xml @@ -145,6 +145,7 @@ 語言 選擇 App 語言 開發人員選項 + 切換設定檔後是否向基帶發送刷新命令。如果發現崩潰,請嘗試停用此功能。 顯示未經過濾的設定檔列表 在設定檔列表中包括非生產環境的設定檔 忽略 SM-DP+ 的 TLS 證書 @@ -162,4 +163,5 @@ 擦除 允許擦除 eUICC 此操作是預設隱藏的危險操作。作為替代方案,您可以手動刪除所有設定檔。 + 向基帶發送刷新命令 \ No newline at end of file diff --git a/app-common/src/main/res/values/strings.xml b/app-common/src/main/res/values/strings.xml index 8146f5472..a366b88e5 100644 --- a/app-common/src/main/res/values/strings.xml +++ b/app-common/src/main/res/values/strings.xml @@ -181,6 +181,8 @@ Logs View recent debug logs of the application Developer Options + Send refresh command to modem + Whether to send a refresh command to the modem after switching profiles. Try disabling this if you see crashes. Show unfiltered profile list Include non-production profiles in the list Ignore SM-DP+ TLS certificate diff --git a/app-common/src/main/res/xml/pref_settings.xml b/app-common/src/main/res/xml/pref_settings.xml index 7d25118d1..ce700f5eb 100644 --- a/app-common/src/main/res/xml/pref_settings.xml +++ b/app-common/src/main/res/xml/pref_settings.xml @@ -57,6 +57,12 @@ app:title="@string/pref_developer" app:iconSpaceReserved="false"> + + Date: Sun, 23 Mar 2025 10:35:07 -0400 Subject: [PATCH 41/99] feat: Customizable ISD-R AID list This is stored base64-encoded in shared preferences (to avoid XML encoding issues). By default we have the standard AID plus the 5ber one. We may add more going forward. --- app-common/src/main/AndroidManifest.xml | 4 ++ .../core/DefaultEuiccChannelFactory.kt | 23 +++++-- .../core/DefaultEuiccChannelManager.kt | 48 +++++++++++-- .../openeuicc/core/EuiccChannelFactory.kt | 8 ++- .../angry/openeuicc/core/EuiccChannelImpl.kt | 8 +-- .../angry/openeuicc/ui/IsdrAidListActivity.kt | 67 +++++++++++++++++++ .../im/angry/openeuicc/ui/SettingsFragment.kt | 4 ++ .../angry/openeuicc/util/PreferenceUtils.kt | 52 ++++++++++++-- .../im/angry/openeuicc/util/StringUtils.kt | 18 ++++- .../res/layout/activity_isdr_aid_list.xml | 23 +++++++ .../main/res/menu/activity_isdr_aid_list.xml | 15 +++++ app-common/src/main/res/values-ja/strings.xml | 5 ++ .../src/main/res/values-zh-rCN/strings.xml | 5 ++ .../src/main/res/values-zh-rTW/strings.xml | 5 ++ app-common/src/main/res/values/strings.xml | 7 ++ app-common/src/main/res/xml/pref_settings.xml | 6 ++ .../core/PrivilegedEuiccChannelFactory.kt | 10 ++- 17 files changed, 280 insertions(+), 28 deletions(-) create mode 100644 app-common/src/main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt create mode 100644 app-common/src/main/res/layout/activity_isdr_aid_list.xml create mode 100644 app-common/src/main/res/menu/activity_isdr_aid_list.xml diff --git a/app-common/src/main/AndroidManifest.xml b/app-common/src/main/AndroidManifest.xml index 464ae0f5f..b0324dce4 100644 --- a/app-common/src/main/AndroidManifest.xml +++ b/app-common/src/main/AndroidManifest.xml @@ -28,6 +28,10 @@ android:name="im.angry.openeuicc.ui.LogsActivity" android:label="@string/pref_advanced_logs" /> + + get() = (0.. EuiccChannel?): EuiccChannel? { + val isdrAidList = + parseIsdrAidList(appContainer.preferenceRepository.isdrAidListFlow.first()) + + return isdrAidList.firstNotNullOfOrNull { + Log.i(TAG, "Opening channel, trying ISDR AID ${it.encodeHex()}") + + openFn(it)?.let { channel -> + if (channel.valid) { + channel + } else { + channel.close() + null + } + } + } + } + private suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? { lock.withLock { if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) { @@ -76,9 +95,10 @@ open class DefaultEuiccChannelManager( return null } - val channel = euiccChannelFactory.tryOpenEuiccChannel(port) ?: return null + val channel = + tryOpenChannelFirstValidAid { euiccChannelFactory.tryOpenEuiccChannel(port, it) } - if (channel.valid) { + if (channel != null) { channelCache.add(channel) return channel } else { @@ -86,7 +106,6 @@ open class DefaultEuiccChannelManager( TAG, "Was able to open channel for logical slot ${port.logicalSlotIndex}, but the channel is invalid (cannot get eID or profiles without errors). This slot might be broken, aborting." ) - channel.close() return null } } @@ -212,7 +231,10 @@ open class DefaultEuiccChannelManager( check(channel.valid) { "Invalid channel" } break } catch (e: Exception) { - Log.d(TAG, "Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms") + Log.d( + TAG, + "Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms" + ) } delay(1000) } @@ -249,9 +271,18 @@ open class DefaultEuiccChannelManager( // If we don't have permission, tell UI code that we found a candidate device, but we // need permission to be able to do anything with it if (!usbManager.hasPermission(device)) return@withContext Pair(device, false) - Log.i(TAG, "Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel") + Log.i( + TAG, + "Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel" + ) try { - val channel = euiccChannelFactory.tryOpenUsbEuiccChannel(device, iface) + val channel = tryOpenChannelFirstValidAid { + euiccChannelFactory.tryOpenUsbEuiccChannel( + device, + iface, + it + ) + } if (channel != null && channel.lpa.valid) { usbChannel = channel return@withContext Pair(device, true) @@ -260,7 +291,10 @@ open class DefaultEuiccChannelManager( // Ignored -- skip forward e.printStackTrace() } - Log.i(TAG, "No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}") + Log.i( + TAG, + "No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}" + ) } return@withContext Pair(null, false) } diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelFactory.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelFactory.kt index fb5d95d8b..87f588504 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelFactory.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelFactory.kt @@ -7,9 +7,13 @@ import im.angry.openeuicc.util.* // This class is here instead of inside DI because it contains a bit more logic than just // "dumb" dependency injection. interface EuiccChannelFactory { - suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? + suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat, isdrAid: ByteArray): EuiccChannel? - fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel? + fun tryOpenUsbEuiccChannel( + usbDevice: UsbDevice, + usbInterface: UsbInterface, + isdrAid: ByteArray + ): EuiccChannel? /** * Release all resources used by this EuiccChannelFactory diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt index a56b1ccb9..ed8797a33 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt @@ -13,21 +13,17 @@ class EuiccChannelImpl( override val port: UiccPortInfoCompat, override val intrinsicChannelName: String?, override val apduInterface: ApduInterface, + isdrAid: ByteArray, verboseLoggingFlow: Flow, ignoreTLSCertificateFlow: Flow ) : EuiccChannel { - companion object { - // TODO: This needs to go somewhere else. - val ISDR_AID = "A0000005591010FFFFFFFF8900000100".decodeHex() - } - override val slotId = port.card.physicalSlotIndex override val logicalSlotId = port.logicalSlotIndex override val portId = port.portIndex override val lpa: LocalProfileAssistant = LocalProfileAssistantImpl( - ISDR_AID, + isdrAid, apduInterface, HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow) ) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt new file mode 100644 index 000000000..655342181 --- /dev/null +++ b/app-common/src/main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt @@ -0,0 +1,67 @@ +package im.angry.openeuicc.ui + +import android.os.Bundle +import android.text.Editable +import android.view.Menu +import android.view.MenuItem +import android.widget.EditText +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import im.angry.openeuicc.common.R +import im.angry.openeuicc.util.preferenceRepository +import im.angry.openeuicc.util.setupToolbarInsets +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class IsdrAidListActivity : AppCompatActivity() { + private lateinit var isdrAidListEditor: EditText + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_isdr_aid_list) + setSupportActionBar(requireViewById(R.id.toolbar)) + setupToolbarInsets() + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + + isdrAidListEditor = requireViewById(R.id.isdr_aid_list_editor) + + lifecycleScope.launch { + preferenceRepository.isdrAidListFlow.onEach { + isdrAidListEditor.text = Editable.Factory.getInstance().newEditable(it) + }.collect() + } + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.activity_isdr_aid_list, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = + when (item.itemId) { + R.id.save -> { + lifecycleScope.launch { + preferenceRepository.isdrAidListFlow.updatePreference(isdrAidListEditor.text.toString()) + Toast.makeText( + this@IsdrAidListActivity, + R.string.isdr_aid_list_saved, + Toast.LENGTH_SHORT + ).show() + } + true + } + + R.id.reset -> { + lifecycleScope.launch { + preferenceRepository.isdrAidListFlow.removePreference() + } + true + } + + else -> super.onOptionsItemSelected(item) + } +} \ No newline at end of file diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt index d137e9099..65541428e 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt @@ -83,6 +83,10 @@ open class SettingsFragment: PreferenceFragmentCompat() { requirePreference("pref_developer_euicc_memory_reset") .bindBooleanFlow(preferenceRepository.euiccMemoryResetFlow) + + requirePreference("pref_developer_isdr_aid_list").apply { + intent = Intent(requireContext(), IsdrAidListActivity::class.java) + } } protected fun requirePreference(key: CharSequence) = diff --git a/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt index c69c5e43c..928079fb7 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt @@ -5,11 +5,13 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import androidx.fragment.app.Fragment import im.angry.openeuicc.OpenEuiccApplication import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import java.util.Base64 private val Context.dataStore: DataStore by preferencesDataStore(name = "prefs") @@ -35,6 +37,20 @@ internal object PreferenceKeys { val UNFILTERED_PROFILE_LIST = booleanPreferencesKey("unfiltered_profile_list") val IGNORE_TLS_CERTIFICATE = booleanPreferencesKey("ignore_tls_certificate") val EUICC_MEMORY_RESET = booleanPreferencesKey("euicc_memory_reset") + val ISDR_AID_LIST = stringPreferencesKey("isdr_aid_list") +} + +const val EUICC_DEFAULT_ISDR_AID = "A0000005591010FFFFFFFF8900000100" + +internal object PreferenceConstants { + val DEFAULT_AID_LIST = """ + # One AID per line. Comment lines start with #. + # eUICC standard + $EUICC_DEFAULT_ISDR_AID + + # 5ber + A0000005591010FFFFFFFF8900050500 + """.trimIndent() } open class PreferenceRepository(private val context: Context) { @@ -54,20 +70,46 @@ open class PreferenceRepository(private val context: Context) { val unfilteredProfileListFlow = bindFlow(PreferenceKeys.UNFILTERED_PROFILE_LIST, false) val ignoreTLSCertificateFlow = bindFlow(PreferenceKeys.IGNORE_TLS_CERTIFICATE, false) val euiccMemoryResetFlow = bindFlow(PreferenceKeys.EUICC_MEMORY_RESET, false) + val isdrAidListFlow = bindFlow( + PreferenceKeys.ISDR_AID_LIST, + PreferenceConstants.DEFAULT_AID_LIST, + { Base64.getEncoder().encodeToString(it.encodeToByteArray()) }, + { Base64.getDecoder().decode(it).decodeToString() }) - protected fun bindFlow(key: Preferences.Key, defaultValue: T): PreferenceFlowWrapper = - PreferenceFlowWrapper(context, key, defaultValue) + protected fun bindFlow( + key: Preferences.Key, + defaultValue: T, + encoder: (T) -> T = { it }, + decoder: (T) -> T = { it } + ): PreferenceFlowWrapper = + PreferenceFlowWrapper(context, key, defaultValue, encoder, decoder) } class PreferenceFlowWrapper private constructor( private val context: Context, private val key: Preferences.Key, inner: Flow, + private val encoder: (T) -> T, ) : Flow by inner { - internal constructor(context: Context, key: Preferences.Key, defaultValue: T) : - this(context, key, context.dataStore.data.map { it[key] ?: defaultValue }) + internal constructor( + context: Context, + key: Preferences.Key, + defaultValue: T, + encoder: (T) -> T, + decoder: (T) -> T + ) : + this( + context, + key, + context.dataStore.data.map { it[key]?.let(decoder) ?: defaultValue }, + encoder + ) suspend fun updatePreference(value: T) { - context.dataStore.edit { it[key] = value } + context.dataStore.edit { it[key] = encoder(value) } + } + + suspend fun removePreference() { + context.dataStore.edit { it.remove(key) } } } diff --git a/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt index 8d724620f..9f993a318 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt @@ -1,7 +1,7 @@ package im.angry.openeuicc.util fun String.decodeHex(): ByteArray { - check(length % 2 == 0) { "Must have an even length" } + require(length % 2 == 0) { "Must have an even length" } val decodedLength = length / 2 val out = ByteArray(decodedLength) @@ -29,6 +29,22 @@ fun formatFreeSpace(size: Int): String = "$size B" } +/** + * Decode a list of potential ISDR AIDs, one per line. Lines starting with '#' are ignored. + * If none is found, at least EUICC_DEFAULT_ISDR_AID is returned + */ +fun parseIsdrAidList(s: String): List = + s.split('\n').map(String::trim).filter { !it.startsWith('#') } + .map(String::trim) + .mapNotNull { + try { + it.decodeHex() + } catch (_: IllegalArgumentException) { + null + } + } + .ifEmpty { listOf(EUICC_DEFAULT_ISDR_AID.decodeHex()) } + fun String.prettyPrintJson(): String { val ret = StringBuilder() var inQuotes = false diff --git a/app-common/src/main/res/layout/activity_isdr_aid_list.xml b/app-common/src/main/res/layout/activity_isdr_aid_list.xml new file mode 100644 index 000000000..06a75a19e --- /dev/null +++ b/app-common/src/main/res/layout/activity_isdr_aid_list.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/app-common/src/main/res/menu/activity_isdr_aid_list.xml b/app-common/src/main/res/menu/activity_isdr_aid_list.xml new file mode 100644 index 000000000..32f178a99 --- /dev/null +++ b/app-common/src/main/res/menu/activity_isdr_aid_list.xml @@ -0,0 +1,15 @@ + +

+ + + + \ No newline at end of file diff --git a/app-common/src/main/res/values-ja/strings.xml b/app-common/src/main/res/values-ja/strings.xml index 35fd40014..d51e2c79d 100644 --- a/app-common/src/main/res/values-ja/strings.xml +++ b/app-common/src/main/res/values-ja/strings.xml @@ -124,6 +124,7 @@ %s のログ 開発者になるまであと %d ステップです。 あなたは開発者になりました! + カスタム ISD-R AID リストが保存されました 設定 通知 eSIM のプロファイル操作により、通信事業者に通知が送信されます。必要に応じてこの動作を微調整できます。 @@ -148,6 +149,7 @@ 非運用のプロファイルも含めます SM-DP+ TLS 証明書を無視する RSP サーバーで使用される TLS 証明書を受け入れます + 一部のブランドの取り外し可能な eUICC では、独自の非標準 ISD-R AID が使用されている場合があり、サードパーティ アプリからアクセスできなくなります。アプリはこのリストに追加された非標準の AID の使用を試みる可能性がありますが、動作することは保証されません。 情報 アプリバージョン ソースコード @@ -164,4 +166,7 @@ eUICC の消去を可能にする この操作は、デフォルトでは非表示になっている危険な操作です。代わりに、すべての構成ファイルを手動で削除することもできます。 モデムに更新コマンドを送信 + ISD-R AID リストのカスタマイズ + リセット + ISD-R AID リスト diff --git a/app-common/src/main/res/values-zh-rCN/strings.xml b/app-common/src/main/res/values-zh-rCN/strings.xml index 2e069bd63..32ced907c 100644 --- a/app-common/src/main/res/values-zh-rCN/strings.xml +++ b/app-common/src/main/res/values-zh-rCN/strings.xml @@ -65,6 +65,7 @@ 删除 保存日志 %s 的日志 + 自定义 ISD-R AID 列表已保存 设置 通知 操作 eSIM 配置文件会向运营商发送通知。根据需要在此处微调此行为。 @@ -81,6 +82,7 @@ 详细日志中包含敏感信息,开启此功能后请仅与你信任的人共享你的日志。 日志 查看应用程序的最新调试日志 + 某些品牌的可移除 eUICC 可能会使用自己的非标准 ISD-R AID,导致第三方应用无法访问。此 App 可以尝试使用此列表中添加的非标准 AID,但不能保证它们一定有效。 信息 App 版本 源码 @@ -164,4 +166,7 @@ 允许擦除 eUICC 此操作是默认隐藏的危险操作。作为替代方案,您可以手动删除所有配置文件。 向基带发送刷新命令 + 自定义 ISD-R AID 列表 + 重置 + ISD-R AID 列表 \ No newline at end of file diff --git a/app-common/src/main/res/values-zh-rTW/strings.xml b/app-common/src/main/res/values-zh-rTW/strings.xml index 729893ca4..5136bf739 100644 --- a/app-common/src/main/res/values-zh-rTW/strings.xml +++ b/app-common/src/main/res/values-zh-rTW/strings.xml @@ -65,6 +65,7 @@ 刪除 儲存日誌 %s 的日誌 + 自訂 ISD-R AID 列表已儲存 設定 通知 變更 eSIM 設定檔會向電信業者傳送通知。根據需要在此處微調此行為。 @@ -81,6 +82,7 @@ 進階 允許 停用/刪除 已啟用的設定檔 預設情況下,此應用程式會阻止您停用可插拔 eSIM 中已啟用的設定檔。\n因為這樣做 有時 會導致無法存取。\n勾選此框以 移除 此保護措施。 + 某些品牌的可移除 eUICC 可能會使用自己的非標準 ISD-R AID,導致第三方應用程式無法存取。此 App 可以嘗試使用此清單中新增的非標準 AID,但不能保證它們一定有效。 資訊 App 版本 原始碼 @@ -164,4 +166,7 @@ 允許擦除 eUICC 此操作是預設隱藏的危險操作。作為替代方案,您可以手動刪除所有設定檔。 向基帶發送刷新命令 + 自訂 ISD-R AID 列表 + 重置 + ISD-R AID 列表 \ No newline at end of file diff --git a/app-common/src/main/res/values/strings.xml b/app-common/src/main/res/values/strings.xml index a366b88e5..5e4e4da03 100644 --- a/app-common/src/main/res/values/strings.xml +++ b/app-common/src/main/res/values/strings.xml @@ -162,6 +162,11 @@ You are %d steps away from being a developer. You are now a developer! + Reset + + ISD-R AID List + Saved custom ISD-R AID list. + Settings Notifications eSIM profile operations send notifications to the carrier. Fine-tune this behavior as needed here. @@ -189,6 +194,8 @@ Accept any TLS certificate used by the RSP server Allow erasing eUICC This is a dangerous operation and hidden by default. As an alternative, you can delete all profiles manually. + Customize ISD-R AID list + Some brands of removable eUICCs may use their own non-standard ISD-R AID, rendering them inaccessible to third-party apps. We can attempt to use non-standard AIDs added in this list, but there is no guarantee that they will work. Info App Version Source Code diff --git a/app-common/src/main/res/xml/pref_settings.xml b/app-common/src/main/res/xml/pref_settings.xml index ce700f5eb..690a12013 100644 --- a/app-common/src/main/res/xml/pref_settings.xml +++ b/app-common/src/main/res/xml/pref_settings.xml @@ -81,6 +81,12 @@ app:summary="@string/pref_developer_euicc_memory_reset_desc" app:title="@string/pref_developer_euicc_memory_reset" /> + + Date: Sun, 23 Mar 2025 11:00:28 -0400 Subject: [PATCH 42/99] CompatibilityCheck: use the shared default AID constant --- .../java/im/angry/openeuicc/util/CompatibilityCheck.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/util/CompatibilityCheck.kt b/app-unpriv/src/main/java/im/angry/openeuicc/util/CompatibilityCheck.kt index 8424c5507..94ee9d6c8 100644 --- a/app-unpriv/src/main/java/im/angry/openeuicc/util/CompatibilityCheck.kt +++ b/app-unpriv/src/main/java/im/angry/openeuicc/util/CompatibilityCheck.kt @@ -128,10 +128,6 @@ internal class OmapiConnCheck(private val context: Context): CompatibilityCheck( } internal class IsdrChannelAccessCheck(private val context: Context): CompatibilityCheck(context) { - companion object { - val ISDR_AID = "A0000005591010FFFFFFFF8900000100".decodeHex() - } - override val title: String get() = context.getString(R.string.compatibility_check_isdr_channel) override val defaultDescription: String @@ -147,7 +143,10 @@ internal class IsdrChannelAccessCheck(private val context: Context): Compatibili val (validSlotIds, result) = readers.map { try { - it.openSession().openLogicalChannel(ISDR_AID)?.close() + // Note: we ONLY check the default ISD-R AID, because this test is for the _device_, + // NOT the eUICC. We don't care what AID a potential eUICC might use, all we need to + // check is we can open _some_ AID. + it.openSession().openLogicalChannel(EUICC_DEFAULT_ISDR_AID.decodeHex())?.close() Pair(it.slotIndex, State.SUCCESS) } catch (_: SecurityException) { // Ignore; this is expected when everything works From 92fbfc52296dcc9819bf5c4974d6b78cd3fb6742 Mon Sep 17 00:00:00 2001 From: septs Date: Tue, 1 Apr 2025 03:18:02 +0200 Subject: [PATCH 43/99] chore: add more isd-r aids (#184) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/184 Co-authored-by: septs Co-committed-by: septs --- .../angry/openeuicc/util/PreferenceUtils.kt | 28 +++++++++++++------ .../res/layout/activity_isdr_aid_list.xml | 1 + 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt index 928079fb7..5f4aec442 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt @@ -45,11 +45,22 @@ const val EUICC_DEFAULT_ISDR_AID = "A0000005591010FFFFFFFF8900000100" internal object PreferenceConstants { val DEFAULT_AID_LIST = """ # One AID per line. Comment lines start with #. + # Refs: + # eUICC standard $EUICC_DEFAULT_ISDR_AID - - # 5ber + + # eSTK.me + A06573746B6D65FFFFFFFF4953442D52 + + # eSIM.me + A0000005591010000000008900000300 + + # 5ber.eSIM A0000005591010FFFFFFFF8900050500 + + # Xesim + A0000005591010FFFFFFFF8900000177 """.trimIndent() } @@ -97,13 +108,12 @@ class PreferenceFlowWrapper private constructor( defaultValue: T, encoder: (T) -> T, decoder: (T) -> T - ) : - this( - context, - key, - context.dataStore.data.map { it[key]?.let(decoder) ?: defaultValue }, - encoder - ) + ) : this( + context, + key, + context.dataStore.data.map { it[key]?.let(decoder) ?: defaultValue }, + encoder + ) suspend fun updatePreference(value: T) { context.dataStore.edit { it[key] = encoder(value) } diff --git a/app-common/src/main/res/layout/activity_isdr_aid_list.xml b/app-common/src/main/res/layout/activity_isdr_aid_list.xml index 06a75a19e..48135fbe8 100644 --- a/app-common/src/main/res/layout/activity_isdr_aid_list.xml +++ b/app-common/src/main/res/layout/activity_isdr_aid_list.xml @@ -11,6 +11,7 @@ android:id="@+id/isdr_aid_list_editor" android:layout_width="0dp" android:layout_height="0dp" + android:fontFamily="monospace" android:importantForAutofill="no" android:inputType="textMultiLine" android:gravity="top|start" From 05abed117a71796648b0ab6ac51a60ad63b57e8c Mon Sep 17 00:00:00 2001 From: septs Date: Tue, 1 Apr 2025 03:18:22 +0200 Subject: [PATCH 44/99] fix: click sn copy (#186) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/186 Co-authored-by: septs Co-committed-by: septs --- .../src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt index 4e499dc24..aa922be7b 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt @@ -104,7 +104,7 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { add(Item(R.string.euicc_info_eid, channel.lpa.eID, copiedToastResId = R.string.toast_eid_copied)) channel.tryParseEuiccVendorInfo()?.let { vendorInfo -> vendorInfo.skuName?.let { add(Item(R.string.euicc_info_sku, it)) } - vendorInfo.serialNumber?.let { add(Item(R.string.euicc_info_sn, it)) } + vendorInfo.serialNumber?.let { add(Item(R.string.euicc_info_sn, it, copiedToastResId = R.string.toast_sn_copied)) } vendorInfo.firmwareVersion?.let { add(Item(R.string.euicc_info_fw_ver, it)) } vendorInfo.bootloaderVersion?.let { add(Item(R.string.euicc_info_bl_ver, it)) } } From 3662f93760b6c418e89d585fa024e15f522f2ed0 Mon Sep 17 00:00:00 2001 From: septs Date: Tue, 1 Apr 2025 03:18:45 +0200 Subject: [PATCH 45/99] fix: send terminal capabilities (#187) fix 9eSIM v1 (G+D) on USB Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/187 Co-authored-by: septs Co-committed-by: septs --- .../java/im/angry/openeuicc/core/usb/UsbApduInterface.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt index f9e764b50..31ba333da 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt @@ -45,6 +45,15 @@ class UsbApduInterface( e.printStackTrace() throw e } + + // Send Terminal Capabilities + // Specs: ETSI TS 102 221 v15.0.0 - 11.1.19 TERMINAL CAPABILITY + val terminalCapabilities = buildCmd( + 0x80.toByte(), 0xaa.toByte(), 0x00, 0x00, + "A9088100820101830107".decodeHex(), + le = null, + ) + transmitApduByChannel(terminalCapabilities, 0,) } override fun disconnect() { From 00ddf092874bf3aa863bed925eb2c6fddcc4eb0b Mon Sep 17 00:00:00 2001 From: septs Date: Tue, 1 Apr 2025 03:19:21 +0200 Subject: [PATCH 46/99] fix: improve lpa string parsing (#181) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/181 Co-authored-by: septs Co-committed-by: septs --- .../java/im/angry/openeuicc/util/LPAString.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/util/LPAString.kt b/app-common/src/main/java/im/angry/openeuicc/util/LPAString.kt index 20956fb52..63a81f19a 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/LPAString.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/LPAString.kt @@ -8,15 +8,15 @@ data class LPAString( ) { companion object { fun parse(input: String): LPAString { - val components = input.removePrefix("LPA:").split('$') - if (components.size < 2 || components[0] != "1") { - throw IllegalArgumentException("Invalid activation code format") - } + var token = input + if (token.startsWith("LPA:", ignoreCase = true)) token = token.drop(4) + val components = token.split('$').map { it.trim().ifBlank { null } } + require(components.getOrNull(0) == "1") { "Invalid AC_Format" } return LPAString( - address = components[1].trim(), - matchingId = components.getOrNull(2)?.trim()?.ifBlank { null }, - oid = components.getOrNull(3)?.trim()?.ifBlank { null }, - confirmationCodeRequired = components.getOrNull(4)?.trim() == "1" + requireNotNull(components.getOrNull(1)) { "SM-DP+ is required" }, + components.getOrNull(2), + components.getOrNull(3), + components.getOrNull(4) == "1" ) } } From 6c774450ec65924b6058338444379447b3d0d74f Mon Sep 17 00:00:00 2001 From: septs Date: Tue, 1 Apr 2025 23:13:57 +0200 Subject: [PATCH 47/99] fix: usb isd-r aid fallback (#188) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/188 Co-authored-by: septs Co-committed-by: septs --- .../core/DefaultEuiccChannelFactory.kt | 41 +++++++++++-------- .../core/DefaultEuiccChannelManager.kt | 6 +-- .../openeuicc/core/usb/UsbApduInterface.kt | 2 +- .../im/angry/openeuicc/util/StringUtils.kt | 13 +++--- .../core/PrivilegedEuiccChannelFactory.kt | 4 +- 5 files changed, 34 insertions(+), 32 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt index b24967594..870baae56 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt @@ -60,11 +60,11 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha Log.i(DefaultEuiccChannelManager.TAG, "Is OMAPI channel, setting MSS to 60") it.lpa.setEs10xMss(60) } - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Failed Log.w( DefaultEuiccChannelManager.TAG, - "OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex}." + "OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex} with ISD-R AID: ${isdrAid.encodeHex()}." ) } @@ -80,20 +80,29 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha if (bulkIn == null || bulkOut == null) return null val conn = usbManager.openDevice(usbDevice) ?: return null if (!conn.claimInterface(usbInterface, true)) return null - return EuiccChannelImpl( - context.getString(R.string.usb), - FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)), - intrinsicChannelName = usbDevice.productName, - UsbApduInterface( - conn, - bulkIn, - bulkOut, - context.preferenceRepository.verboseLoggingFlow - ), - isdrAid, - context.preferenceRepository.verboseLoggingFlow, - context.preferenceRepository.ignoreTLSCertificateFlow, - ) + try { + return EuiccChannelImpl( + context.getString(R.string.usb), + FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)), + intrinsicChannelName = usbDevice.productName, + UsbApduInterface( + conn, + bulkIn, + bulkOut, + context.preferenceRepository.verboseLoggingFlow + ), + isdrAid, + context.preferenceRepository.verboseLoggingFlow, + context.preferenceRepository.ignoreTLSCertificateFlow, + ) + } catch (_: IllegalArgumentException) { + // Failed + Log.w( + DefaultEuiccChannelManager.TAG, + "USB APDU interface unavailable for ISD-R AID: ${isdrAid.encodeHex()}." + ) + } + return null } override fun cleanup() { diff --git a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt index 74ec285c0..ac9ba0876 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt @@ -277,11 +277,7 @@ open class DefaultEuiccChannelManager( ) try { val channel = tryOpenChannelFirstValidAid { - euiccChannelFactory.tryOpenUsbEuiccChannel( - device, - iface, - it - ) + euiccChannelFactory.tryOpenUsbEuiccChannel(device, iface, it) } if (channel != null && channel.lpa.valid) { usbChannel = channel diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt index 31ba333da..107395feb 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt @@ -53,7 +53,7 @@ class UsbApduInterface( "A9088100820101830107".decodeHex(), le = null, ) - transmitApduByChannel(terminalCapabilities, 0,) + transmitApduByChannel(terminalCapabilities, 0) } override fun disconnect() { diff --git a/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt index 9f993a318..079853ebd 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt @@ -34,15 +34,12 @@ fun formatFreeSpace(size: Int): String = * If none is found, at least EUICC_DEFAULT_ISDR_AID is returned */ fun parseIsdrAidList(s: String): List = - s.split('\n').map(String::trim).filter { !it.startsWith('#') } + s.split('\n') .map(String::trim) - .mapNotNull { - try { - it.decodeHex() - } catch (_: IllegalArgumentException) { - null - } - } + .filter { !it.startsWith('#') } + .map(String::trim) + .filter(String::isNotEmpty) + .mapNotNull { runCatching(it::decodeHex).getOrNull() } .ifEmpty { listOf(EUICC_DEFAULT_ISDR_AID.decodeHex()) } fun String.prettyPrintJson(): String { diff --git a/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt b/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt index 5489bd5bd..68eddefd1 100644 --- a/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt +++ b/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt @@ -44,11 +44,11 @@ class PrivilegedEuiccChannelFactory(context: Context) : DefaultEuiccChannelFacto context.preferenceRepository.verboseLoggingFlow, context.preferenceRepository.ignoreTLSCertificateFlow, ) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Failed Log.w( DefaultEuiccChannelManager.TAG, - "TelephonyManager APDU interface unavailable for slot ${port.card.physicalSlotIndex} port ${port.portIndex}, falling back" + "TelephonyManager APDU interface unavailable for slot ${port.card.physicalSlotIndex} port ${port.portIndex} with ISD-R AID: ${isdrAid.encodeHex()}." ) } } From 994324acb6d7887b703ab2d1ee5479100eab1e56 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 5 Apr 2025 17:57:47 -0400 Subject: [PATCH 48/99] Fix up back button for IsdrAidListActivity --- .../main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt index 655342181..022a391bb 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt @@ -62,6 +62,11 @@ class IsdrAidListActivity : AppCompatActivity() { true } + android.R.id.home -> { + finish() + true + } + else -> super.onOptionsItemSelected(item) } } \ No newline at end of file From 1fda1204591b10215bcdcd858e2b68b2e1f6387c Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 5 Apr 2025 20:44:15 -0400 Subject: [PATCH 49/99] Avoid reconnecting to USB iface repeatedly while trying different AIDs --- .../core/DefaultEuiccChannelFactory.kt | 24 +---- .../core/DefaultEuiccChannelManager.kt | 11 ++- .../openeuicc/core/EuiccChannelFactory.kt | 6 +- .../openeuicc/core/usb/UsbApduInterface.kt | 43 ++------- .../openeuicc/core/usb/UsbCcidContext.kt | 87 +++++++++++++++++++ 5 files changed, 111 insertions(+), 60 deletions(-) create mode 100644 app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidContext.kt diff --git a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt index 870baae56..0de99b563 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt @@ -1,25 +1,17 @@ package im.angry.openeuicc.core import android.content.Context -import android.hardware.usb.UsbDevice -import android.hardware.usb.UsbInterface -import android.hardware.usb.UsbManager import android.se.omapi.SEService import android.util.Log import im.angry.openeuicc.common.R import im.angry.openeuicc.core.usb.UsbApduInterface -import im.angry.openeuicc.core.usb.bulkPair -import im.angry.openeuicc.core.usb.endpoints +import im.angry.openeuicc.core.usb.UsbCcidContext import im.angry.openeuicc.util.* import java.lang.IllegalArgumentException open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccChannelFactory { private var seService: SEService? = null - private val usbManager by lazy { - context.getSystemService(Context.USB_SERVICE) as UsbManager - } - private suspend fun ensureSEService() { if (seService == null || !seService!!.isConnected) { seService = connectSEService(context) @@ -72,24 +64,16 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha } override fun tryOpenUsbEuiccChannel( - usbDevice: UsbDevice, - usbInterface: UsbInterface, + ccidCtx: UsbCcidContext, isdrAid: ByteArray ): EuiccChannel? { - val (bulkIn, bulkOut) = usbInterface.endpoints.bulkPair - if (bulkIn == null || bulkOut == null) return null - val conn = usbManager.openDevice(usbDevice) ?: return null - if (!conn.claimInterface(usbInterface, true)) return null try { return EuiccChannelImpl( context.getString(R.string.usb), FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)), - intrinsicChannelName = usbDevice.productName, + intrinsicChannelName = ccidCtx.productName, UsbApduInterface( - conn, - bulkIn, - bulkOut, - context.preferenceRepository.verboseLoggingFlow + ccidCtx ), isdrAid, context.preferenceRepository.verboseLoggingFlow, diff --git a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt index ac9ba0876..6b336cdae 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt @@ -5,6 +5,7 @@ import android.hardware.usb.UsbDevice import android.hardware.usb.UsbManager import android.telephony.SubscriptionManager import android.util.Log +import im.angry.openeuicc.core.usb.UsbCcidContext import im.angry.openeuicc.core.usb.smartCard import im.angry.openeuicc.core.usb.interfaces import im.angry.openeuicc.di.AppContainer @@ -275,11 +276,15 @@ open class DefaultEuiccChannelManager( TAG, "Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel" ) + + val ccidCtx = UsbCcidContext.createFromUsbDevice(context, device, iface) ?: return@forEach + try { val channel = tryOpenChannelFirstValidAid { - euiccChannelFactory.tryOpenUsbEuiccChannel(device, iface, it) + euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, it) } if (channel != null && channel.lpa.valid) { + ccidCtx.allowDisconnect = true usbChannel = channel return@withContext Pair(device, true) } @@ -287,6 +292,10 @@ open class DefaultEuiccChannelManager( // Ignored -- skip forward e.printStackTrace() } + + ccidCtx.allowDisconnect = true + ccidCtx.disconnect() + Log.i( TAG, "No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}" diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelFactory.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelFactory.kt index 87f588504..ba587a638 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelFactory.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelFactory.kt @@ -1,7 +1,6 @@ package im.angry.openeuicc.core -import android.hardware.usb.UsbDevice -import android.hardware.usb.UsbInterface +import im.angry.openeuicc.core.usb.UsbCcidContext import im.angry.openeuicc.util.* // This class is here instead of inside DI because it contains a bit more logic than just @@ -10,8 +9,7 @@ interface EuiccChannelFactory { suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat, isdrAid: ByteArray): EuiccChannel? fun tryOpenUsbEuiccChannel( - usbDevice: UsbDevice, - usbInterface: UsbInterface, + ccidCtx: UsbCcidContext, isdrAid: ByteArray ): EuiccChannel? diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt index 107395feb..4a4ccb98c 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt @@ -1,27 +1,19 @@ package im.angry.openeuicc.core.usb -import android.hardware.usb.UsbDeviceConnection -import android.hardware.usb.UsbEndpoint import android.util.Log import im.angry.openeuicc.core.ApduInterfaceAtrProvider import im.angry.openeuicc.util.* -import kotlinx.coroutines.flow.Flow import net.typeblog.lpac_jni.ApduInterface class UsbApduInterface( - private val conn: UsbDeviceConnection, - private val bulkIn: UsbEndpoint, - private val bulkOut: UsbEndpoint, - private val verboseLoggingFlow: Flow + private val ccidCtx: UsbCcidContext ) : ApduInterface, ApduInterfaceAtrProvider { companion object { private const val TAG = "UsbApduInterface" } - private lateinit var ccidDescription: UsbCcidDescription - private lateinit var transceiver: UsbCcidTransceiver - - override var atr: ByteArray? = null + override val atr: ByteArray? + get() = ccidCtx.atr override val valid: Boolean get() = channels.isNotEmpty() @@ -29,22 +21,7 @@ class UsbApduInterface( private var channels = mutableSetOf() override fun connect() { - ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!! - - if (!ccidDescription.hasT0Protocol) { - throw IllegalArgumentException("Unsupported card reader; T=0 support is required") - } - - transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription, verboseLoggingFlow) - - try { - // 6.1.1.1 PC_to_RDR_IccPowerOn (Page 20 of 40) - // https://www.usb.org/sites/default/files/DWG_Smart-Card_USB-ICC_ICCD_rev10.pdf - atr = transceiver.iccPowerOn().data - } catch (e: Exception) { - e.printStackTrace() - throw e - } + ccidCtx.connect() // Send Terminal Capabilities // Specs: ETSI TS 102 221 v15.0.0 - 11.1.19 TERMINAL CAPABILITY @@ -56,11 +33,7 @@ class UsbApduInterface( transmitApduByChannel(terminalCapabilities, 0) } - override fun disconnect() { - conn.close() - - atr = null - } + override fun disconnect() = ccidCtx.disconnect() override fun logicalChannelOpen(aid: ByteArray): Int { // OPEN LOGICAL CHANNEL @@ -149,7 +122,7 @@ class UsbApduInterface( // OR the channel mask into the CLA byte realTx[0] = ((realTx[0].toInt() and 0xFC) or channel.toInt()).toByte() - var resp = transceiver.sendXfrBlock(realTx).data!! + var resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!! if (resp.size < 2) throw RuntimeException("APDU response smaller than 2 (sw1 + sw2)!") @@ -160,7 +133,7 @@ class UsbApduInterface( // 0x6C = wrong le // so we fix the le field here realTx[realTx.size - 1] = resp[resp.size - 1] - resp = transceiver.sendXfrBlock(realTx).data!! + resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!! } else if (sw1 == 0x61) { // 0x61 = X bytes available // continue reading by GET RESPONSE @@ -170,7 +143,7 @@ class UsbApduInterface( realTx[0], 0xC0.toByte(), 0x00, 0x00, sw2.toByte() ) - val tmp = transceiver.sendXfrBlock(getResponseCmd).data!! + val tmp = ccidCtx.transceiver.sendXfrBlock(getResponseCmd).data!! resp = resp.sliceArray(0 until (resp.size - 2)) + tmp diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidContext.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidContext.kt new file mode 100644 index 000000000..caf69e7de --- /dev/null +++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidContext.kt @@ -0,0 +1,87 @@ +package im.angry.openeuicc.core.usb + +import android.content.Context +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import im.angry.openeuicc.util.preferenceRepository +import kotlinx.coroutines.flow.Flow + +/** + * A wrapper over an usb device + interface, manages the lifecycle independent + * of the APDU interface exposed to lpac-jni. + * + * This allows us to try multiple AIDs on each interface without opening / closing + * the USB connection numerous times. + */ +class UsbCcidContext private constructor( + private val conn: UsbDeviceConnection, + private val bulkIn: UsbEndpoint, + private val bulkOut: UsbEndpoint, + val productName: String, + val verboseLoggingFlow: Flow +) { + companion object { + fun createFromUsbDevice( + context: Context, + usbDevice: UsbDevice, + usbInterface: UsbInterface + ): UsbCcidContext? = runCatching { + val (bulkIn, bulkOut) = usbInterface.endpoints.bulkPair + if (bulkIn == null || bulkOut == null) return@runCatching null + val conn = context.getSystemService(UsbManager::class.java).openDevice(usbDevice) + ?: return@runCatching null + if (!conn.claimInterface(usbInterface, true)) return@runCatching null + UsbCcidContext( + conn, + bulkIn, + bulkOut, + usbDevice.productName ?: "USB", + context.preferenceRepository.verboseLoggingFlow + ) + }.getOrNull() + } + + /** + * When set to false (the default), the disconnect() method does nothing. + * This allows the separation of device disconnection from lpac-jni's APDU interface. + */ + var allowDisconnect = false + private var initialized = false + lateinit var transceiver: UsbCcidTransceiver + var atr: ByteArray? = null + + fun connect() { + if (initialized) { + return + } + + val ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!! + + if (!ccidDescription.hasT0Protocol) { + throw IllegalArgumentException("Unsupported card reader; T=0 support is required") + } + + transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription, verboseLoggingFlow) + + try { + // 6.1.1.1 PC_to_RDR_IccPowerOn (Page 20 of 40) + // https://www.usb.org/sites/default/files/DWG_Smart-Card_USB-ICC_ICCD_rev10.pdf + atr = transceiver.iccPowerOn().data + } catch (e: Exception) { + e.printStackTrace() + throw e + } + + initialized = true + } + + fun disconnect() { + if (initialized && allowDisconnect) { + conn.close() + atr = null + } + } +} From 68114fa863623c2b6068ed47501df1ae611288c6 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sat, 5 Apr 2025 20:50:10 -0400 Subject: [PATCH 50/99] Expose the current ISD-R AID in use --- .../src/main/java/im/angry/openeuicc/core/EuiccChannel.kt | 5 +++++ .../main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt | 2 +- .../main/java/im/angry/openeuicc/core/EuiccChannelWrapper.kt | 2 ++ .../src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt | 1 + app-common/src/main/res/values/strings.xml | 1 + 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt index 597a70d26..b20932fe1 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt @@ -34,5 +34,10 @@ interface EuiccChannel { */ val apduInterface: ApduInterface + /** + * The AID of the ISD-R channel currently in use + */ + val isdrAid: ByteArray + fun close() } \ No newline at end of file diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt index ed8797a33..2a33c2061 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt @@ -13,7 +13,7 @@ class EuiccChannelImpl( override val port: UiccPortInfoCompat, override val intrinsicChannelName: String?, override val apduInterface: ApduInterface, - isdrAid: ByteArray, + override val isdrAid: ByteArray, verboseLoggingFlow: Flow, ignoreTLSCertificateFlow: Flow ) : EuiccChannel { diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelWrapper.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelWrapper.kt index 09004d358..361a9438e 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelWrapper.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelWrapper.kt @@ -38,6 +38,8 @@ class EuiccChannelWrapper(orig: EuiccChannel) : EuiccChannel { get() = channel.apduInterface override val atr: ByteArray? get() = channel.atr + override val isdrAid: ByteArray + get() = channel.isdrAid override fun close() = channel.close() diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt index aa922be7b..1d5f37ffb 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt @@ -102,6 +102,7 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { add(Item(R.string.euicc_info_access_mode, channel.type)) add(Item(R.string.euicc_info_removable, formatByBoolean(channel.port.card.isRemovable, YES_NO))) add(Item(R.string.euicc_info_eid, channel.lpa.eID, copiedToastResId = R.string.toast_eid_copied)) + add(Item(R.string.euicc_info_isdr_aid, channel.isdrAid.encodeHex())) channel.tryParseEuiccVendorInfo()?.let { vendorInfo -> vendorInfo.skuName?.let { add(Item(R.string.euicc_info_sku, it)) } vendorInfo.serialNumber?.let { add(Item(R.string.euicc_info_sn, it, copiedToastResId = R.string.toast_sn_copied)) } diff --git a/app-common/src/main/res/values/strings.xml b/app-common/src/main/res/values/strings.xml index 5e4e4da03..05ca15a1a 100644 --- a/app-common/src/main/res/values/strings.xml +++ b/app-common/src/main/res/values/strings.xml @@ -134,6 +134,7 @@ Product Bootloader Version Product Firmware Version EID + ISD-R AID SGP.22 Version eUICC OS Version GlobalPlatform Version From 756c621d5e0f23be814a33cdb745993933cc1f75 Mon Sep 17 00:00:00 2001 From: septs Date: Mon, 14 Apr 2025 03:36:14 +0200 Subject: [PATCH 51/99] fix: stricted sm-dp+ address checking (#190) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/190 Co-authored-by: septs Co-committed-by: septs --- .../wizard/DownloadWizardDetailsFragment.kt | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt index 402e7a523..1c69de582 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt @@ -1,11 +1,9 @@ package im.angry.openeuicc.ui.wizard import android.os.Bundle -import android.util.Patterns import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.core.widget.addTextChangedListener import com.google.android.material.textfield.TextInputLayout import im.angry.openeuicc.common.R @@ -86,10 +84,34 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF } private fun updateInputCompleteness() { - inputComplete = Patterns.DOMAIN_NAME.matcher(smdp.editText!!.text).matches() + inputComplete = isValidAddress(smdp.editText!!.text) if (state.confirmationCodeRequired) { inputComplete = inputComplete && confirmationCode.editText!!.text.isNotEmpty() } refreshButtons() } +} + +private fun isValidAddress(input: CharSequence): Boolean { + if (!input.contains('.')) return false + var fqdn = input + var port = 443 + if (input.contains(':')) { + val portIndex = input.lastIndexOf(':') + fqdn = input.substring(0, portIndex) + port = input.substring(portIndex + 1, input.length).toIntOrNull(10) ?: 0 + } + // see https://en.wikipedia.org/wiki/Port_(computer_networking) + if (port < 1 || port > 0xffff) return false + // see https://en.wikipedia.org/wiki/Fully_qualified_domain_name + if (fqdn.isEmpty() || fqdn.length > 255) return false + for (part in fqdn.split('.')) { + if (part.isEmpty() || part.length > 64) return false + if (part.first() == '-' || part.last() == '-') return false + for (c in part) { + if (c.isLetterOrDigit() || c == '-') continue + return false + } + } + return true } \ No newline at end of file From a601ab7d72b55fd23f898926923e805acfa1280c Mon Sep 17 00:00:00 2001 From: septs Date: Mon, 14 Apr 2025 03:36:38 +0200 Subject: [PATCH 52/99] fix: send euicc memory reset notification (#189) ```console $ ./lpac profile list | jq { "type": "lpa", "payload": { "code": 0, "message": "success", "data": [ { "iccid": "8944476500001320600", "isdpAid": "a0000005591010ffffffff8900001100", "profileState": "enabled", "profileNickname": null, "serviceProviderName": "BetterRoaming", "profileName": "BetterRoaming", "iconType": null, "icon": null, "profileClass": "operational" }, { "iccid": "89861234567891232113", "isdpAid": "a0000005591010ffffffff8900001200", "profileState": "disabled", "profileNickname": null, "serviceProviderName": "rspmanager_tester2", "profileName": "20230625_yysx", "iconType": null, "icon": null, "profileClass": "operational" } ] } } $ ./lpac notification list | jq { "type": "lpa", "payload": { "code": 0, "message": "success", "data": [ { "seqNumber": 48, "profileManagementOperation": "install", "notificationAddress": "smdp.io", "iccid": "8944476500001320600" }, { "seqNumber": 49, "profileManagementOperation": "enable", "notificationAddress": "rsp.truphone.com", "iccid": "8944476500001320600" }, { "seqNumber": 50, "profileManagementOperation": "install", "notificationAddress": "secsmsminiapp.eastcompeace.com", "iccid": "89861234567891232113" }, { "seqNumber": 51, "profileManagementOperation": "install", "notificationAddress": "secsmsminiapp.eastcompeace.com", "iccid": "89861234567891232113" } ] } } $ ./lpac chip purge yes {"type":"lpa","payload":{"code":0,"message":"success","data":null}} $ ./lpac notification list | jq { "type": "lpa", "payload": { "code": 0, "message": "success", "data": [ { "seqNumber": 48, "profileManagementOperation": "install", "notificationAddress": "smdp.io", "iccid": "8944476500001320600" }, { "seqNumber": 49, "profileManagementOperation": "enable", "notificationAddress": "rsp.truphone.com", "iccid": "8944476500001320600" }, { "seqNumber": 50, "profileManagementOperation": "install", "notificationAddress": "secsmsminiapp.eastcompeace.com", "iccid": "89861234567891232113" }, { "seqNumber": 51, "profileManagementOperation": "install", "notificationAddress": "secsmsminiapp.eastcompeace.com", "iccid": "89861234567891232113" }, { "seqNumber": 52, "profileManagementOperation": "delete", "notificationAddress": "rsp.truphone.com", "iccid": "8944476500001320600" }, { "seqNumber": 53, "profileManagementOperation": "delete", "notificationAddress": "secsmsminiapp.eastcompeace.com", "iccid": "89861234567891232113" } ] } } ``` Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/189 Co-authored-by: septs Co-committed-by: septs --- .../openeuicc/service/EuiccChannelManagerService.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt index 2a2448889..47443216e 100644 --- a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt +++ b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt @@ -12,7 +12,6 @@ import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import im.angry.openeuicc.common.R -import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers @@ -517,8 +516,12 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { getString(R.string.task_euicc_memory_reset_failure), R.drawable.ic_euicc_memory_reset ) { - euiccChannelManager.withEuiccChannel(slotId, portId) { channel -> - channel.lpa.euiccMemoryReset() + euiccChannelManager.beginTrackedOperation(slotId, portId) { + euiccChannelManager.withEuiccChannel(slotId, portId) { channel -> + channel.lpa.euiccMemoryReset() + } + + preferenceRepository.notificationDeleteFlow.first() } } } \ No newline at end of file From 023f6ded28faec991f9472a0af4fef980abb2417 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sun, 4 May 2025 21:10:32 -0400 Subject: [PATCH 53/99] Update README.md to add a comparison matrix for Easy/OpenEUICC --- README.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f8019b2ac..28a616a81 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,22 @@ A fully free and open-source Local Profile Assistant implementation for Android devices. -There are two variants of this project: +There are two variants of this project, OpenEUICC and EasyEUICC: -- OpenEUICC: The full-fledged privileged variant. - - Due to its privilege requirement, OpenEUICC must be placed inside `/system/priv-app` and be signed with the platform certificate. - - The preferred way to including OpenEUICC in a system image is to [build it along with AOSP](#building-aosp). - - __Note__: When privileged, OpenEUICC supports any eUICC chip that implements the SGP.22 standard, internal or external. However, there is __no guarantee__ that external (removable) eSIMs actually follow the standard. Please __DO NOT__ submit bug reports for non-functioning removable eSIMs. They are __NOT__ officially supported unless they also support / are supported by EasyEUICC, the unprivileged variant. -- EasyEUICC: Unprivileged version that can run as a user app. - - This version supports two modes of operation: - 1. Inserted, removable eSIMs: Due to obvious security requirements, EasyEUICC is only able to access eSIM chips whose [ARF/ARA](https://source.android.com/docs/core/connect/uicc#arf) contains the hash of EasyEUICC's signing certificate. - 2. USB CCID Card Readers: Only `T=0` readers that use the standard [USB CCID protocol](https://en.wikipedia.org/wiki/CCID_(protocol)) are supported. In this mode, EasyEUICC can access any eSIM chip loaded in the card reader regardless of their ARF/ARA, as long as they implement the [SGP.22 standard](https://www.gsma.com/solutions-and-impact/technologies/esim/wp-content/uploads/2021/07/SGP.22-v2.3.pdf). - - Prebuilt release-mode EasyEUICC apks can be downloaded [here](https://gitea.angry.im/PeterCxy/OpenEUICC/releases) - - For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted, include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA` +| | OpenEUICC | EasyEUICC | +|:------------------------------|:-----------------------------------------------:|:-----------------:| +| Privileged | Must be installed as system app | No | +| Internal eSIM | Supported | Unsupported | +| External (Removable) eSIM | Supported | Supported | +| USB Readers | Yes | Yes | +| Requires allowlisting by eSIM | No | Yes -- except USB | +| System Integration | Partial (carrier partner API unimplemented yet) | No | + +Some side notes: +1. When privileged, OpenEUICC supports any eUICC chip that implements the SGP.22 standard, internal or external. However, there is __no guarantee__ that external (removable) eSIMs actually follow the standard. Please __DO NOT__ submit bug reports for non-functioning removable eSIMs. They are __NOT__ officially supported unless they also support / are supported by EasyEUICC, the unprivileged variant. +2. Both variants support accessing eUICC chips through USB CCID readers, regardless of whether the chip contains the correct ARA-M hash to allow for unprivileged access. However, only `T=0` readers that use the standard [USB CCID protocol](https://en.wikipedia.org/wiki/CCID_(protocol)) are supported. +3. Prebuilt release-mode EasyEUICC apks can be downloaded [here](https://gitea.angry.im/PeterCxy/OpenEUICC/releases). For OpenEUICC, no official release is currently provided and only debug mode APKs can be found in the CI page. +4. For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted, include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`. __This project is Free Software licensed under GNU GPL v3, WITHOUT the "or later" clause.__ Any modification and derivative work __MUST__ be released under the SAME license, which means, at the very least, that the source code __MUST__ be available upon request. From eaef00b88af4ea3e52579e8a662bc3304f1ab689 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sun, 18 May 2025 11:17:43 -0400 Subject: [PATCH 54/99] Fixup README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 28a616a81..f953f9ea5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ There are two variants of this project, OpenEUICC and EasyEUICC: | Privileged | Must be installed as system app | No | | Internal eSIM | Supported | Unsupported | | External (Removable) eSIM | Supported | Supported | -| USB Readers | Yes | Yes | +| USB Readers | Supported | Supported | | Requires allowlisting by eSIM | No | Yes -- except USB | | System Integration | Partial (carrier partner API unimplemented yet) | No | From 149a19ca1c90696ddf1b31d0f562a82f06fb2086 Mon Sep 17 00:00:00 2001 From: xqdoo00o Date: Mon, 16 Jun 2025 03:54:02 +0200 Subject: [PATCH 55/99] fix: build warning (#194) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/194 Co-authored-by: xqdoo00o Co-committed-by: xqdoo00o --- libs/lpac-jni/src/main/jni/lpac-jni/interface-wrapper.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/lpac-jni/src/main/jni/lpac-jni/interface-wrapper.c b/libs/lpac-jni/src/main/jni/lpac-jni/interface-wrapper.c index a61fc9672..007e80dea 100644 --- a/libs/lpac-jni/src/main/jni/lpac-jni/interface-wrapper.c +++ b/libs/lpac-jni/src/main/jni/lpac-jni/interface-wrapper.c @@ -80,7 +80,7 @@ apdu_interface_transmit(struct euicc_ctx *ctx, uint8_t **rx, uint32_t *rx_len, c LPAC_JNI_EXCEPTION_RETURN; *rx_len = (*env)->GetArrayLength(env, ret); *rx = calloc(*rx_len, sizeof(uint8_t)); - (*env)->GetByteArrayRegion(env, ret, 0, *rx_len, *rx); + (*env)->GetByteArrayRegion(env, ret, 0, *rx_len, (jbyte *) *rx); (*env)->DeleteLocalRef(env, txArr); (*env)->DeleteLocalRef(env, ret); return 0; @@ -113,7 +113,7 @@ http_interface_transmit(struct euicc_ctx *ctx, const char *url, uint32_t *rcode, jbyteArray rxArr = (jbyteArray) (*env)->GetObjectField(env, ret, field_resp_data); *rx_len = (*env)->GetArrayLength(env, rxArr); *rx = calloc(*rx_len, sizeof(uint8_t)); - (*env)->GetByteArrayRegion(env, rxArr, 0, *rx_len, *rx); + (*env)->GetByteArrayRegion(env, rxArr, 0, *rx_len, (jbyte *) *rx); (*env)->DeleteLocalRef(env, txArr); (*env)->DeleteLocalRef(env, rxArr); (*env)->DeleteLocalRef(env, headersArr); From db4645b17fa7087cfdf23deedfe6a79e2e689133 Mon Sep 17 00:00:00 2001 From: septs Date: Mon, 16 Jun 2025 03:54:32 +0200 Subject: [PATCH 56/99] feat: sas accreditation number format check (#193) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/193 Co-authored-by: septs Co-committed-by: septs --- .../angry/openeuicc/ui/EuiccInfoActivity.kt | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt index 1d5f37ffb..4a34edd56 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt @@ -27,6 +27,13 @@ import kotlinx.coroutines.launch import net.typeblog.lpac_jni.impl.PKID_GSMA_LIVE_CI import net.typeblog.lpac_jni.impl.PKID_GSMA_TEST_CI +// https://euicc-manual.osmocom.org/docs/pki/eum/accredited.json +// ref: +private val RE_SAS = Regex( + """^[A-Z]{2}-[A-Z]{2}(?:-UP)?-\d{4}T?(?:-\d+)?T?$""", + setOf(RegexOption.IGNORE_CASE), +) + class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { companion object { private val YES_NO = Pair(R.string.yes, R.string.no) @@ -109,13 +116,14 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { vendorInfo.firmwareVersion?.let { add(Item(R.string.euicc_info_fw_ver, it)) } vendorInfo.bootloaderVersion?.let { add(Item(R.string.euicc_info_bl_ver, it)) } } - channel.lpa.euiccInfo2.let { info -> - add(Item(R.string.euicc_info_sgp22_version, info?.sgp22Version.toString())) - add(Item(R.string.euicc_info_firmware_version, info?.euiccFirmwareVersion.toString())) - add(Item(R.string.euicc_info_globalplatform_version, info?.globalPlatformVersion.toString())) - add(Item(R.string.euicc_info_pp_version, info?.ppVersion.toString())) - add(Item(R.string.euicc_info_sas_accreditation_number, info?.sasAccreditationNumber)) - add(Item(R.string.euicc_info_free_nvram, info?.freeNvram?.let(::formatFreeSpace))) + channel.lpa.euiccInfo2?.let { info -> + add(Item(R.string.euicc_info_sgp22_version, info.sgp22Version.toString())) + add(Item(R.string.euicc_info_firmware_version, info.euiccFirmwareVersion.toString())) + add(Item(R.string.euicc_info_globalplatform_version, info.globalPlatformVersion.toString())) + add(Item(R.string.euicc_info_pp_version, info.ppVersion.toString())) + info.sasAccreditationNumber.trim().takeIf(RE_SAS::matches) + ?.let { add(Item(R.string.euicc_info_sas_accreditation_number, it.uppercase())) } + add(Item(R.string.euicc_info_free_nvram, info.freeNvram.let(::formatFreeSpace))) } channel.lpa.euiccInfo2?.euiccCiPKIdListForSigning.orEmpty().let { signers -> // SGP.28 v1.0, eSIM CI Registration Criteria (Page 5 of 9, 2019-10-24) @@ -130,18 +138,13 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { } add(Item(R.string.euicc_info_ci_type, getString(resId))) } - val atr = channel.atr?.encodeHex() ?: getString(R.string.information_unavailable) + val atr = channel.atr?.encodeHex() ?: getString(R.string.information_unavailable) add(Item(R.string.euicc_info_atr, atr, copiedToastResId = R.string.toast_atr_copied)) } + @Suppress("SameParameterValue") private fun formatByBoolean(b: Boolean, res: Pair): String = - getString( - if (b) { - res.first - } else { - res.second - } - ) + getString(if (b) res.first else res.second) inner class EuiccInfoViewHolder(root: View) : ViewHolder(root) { private val title: TextView = root.requireViewById(R.id.euicc_info_title) From 21c04ed1795b6415f8a8953c50b4d5eb63c8bf36 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Tue, 17 Jun 2025 07:57:19 -0400 Subject: [PATCH 57/99] Allow Actions to build from any branch --- .forgejo/workflows/build-debug.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/build-debug.yml b/.forgejo/workflows/build-debug.yml index 51e802ca2..660dabcb7 100644 --- a/.forgejo/workflows/build-debug.yml +++ b/.forgejo/workflows/build-debug.yml @@ -1,7 +1,7 @@ on: push: branches: - - 'master' + - '*' jobs: build-debug: From c0dc8ac19d38dcc899553ee66446ba3cf2dc7ffe Mon Sep 17 00:00:00 2001 From: septs Date: Tue, 8 Jul 2025 15:51:36 +0800 Subject: [PATCH 58/99] feat: profile sequence number --- .../angry/openeuicc/ui/EuiccManagementFragment.kt | 13 ++++++++++++- app-common/src/main/res/layout/euicc_profile.xml | 8 ++++++++ app-common/src/main/res/layout/fragment_euicc.xml | 1 + app-common/src/main/res/values/strings.xml | 1 + 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt index 12995ff70..3c94c6cda 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt @@ -347,6 +347,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, private val profileClassLabel: TextView = root.requireViewById(R.id.profile_class_label) private val profileClass: TextView = root.requireViewById(R.id.profile_class) private val profileMenu: ImageButton = root.requireViewById(R.id.profile_menu) + private val profileSeqNumber: TextView = root.requireViewById(R.id.profile_sequence_number) init { iccid.setOnClickListener { @@ -366,7 +367,9 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, true } - profileMenu.setOnClickListener { showOptionsMenu() } + profileMenu.setOnClickListener { + showOptionsMenu() + } } private lateinit var profile: LocalProfileInfo @@ -396,6 +399,13 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, iccid.transformationMethod = PasswordTransformationMethod.getInstance() } + fun setProfileSequenceNumber(index: Int) { + profileSeqNumber.text = root.context.getString( + R.string.profile_sequence_number_format, + index, + ) + } + private fun showOptionsMenu() { // Prevent users from doing multiple things at once if (invalid || swipeRefresh.isRefreshing) return @@ -461,6 +471,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, when (holder) { is ProfileViewHolder -> { holder.setProfile(profiles[position]) + holder.setProfileSequenceNumber(position + 1) } is FooterViewHolder -> { holder.attach(footerViews[position - profiles.size]) diff --git a/app-common/src/main/res/layout/euicc_profile.xml b/app-common/src/main/res/layout/euicc_profile.xml index 58d55ab1e..74c1d7a0c 100644 --- a/app-common/src/main/res/layout/euicc_profile.xml +++ b/app-common/src/main/res/layout/euicc_profile.xml @@ -129,6 +129,14 @@ app:layout_constraintTop_toBottomOf="@id/profile_class" app:layout_constraintBottom_toBottomOf="parent"/> + + diff --git a/app-common/src/main/res/layout/fragment_euicc.xml b/app-common/src/main/res/layout/fragment_euicc.xml index 4ae7523a8..c5fde7bc5 100644 --- a/app-common/src/main/res/layout/fragment_euicc.xml +++ b/app-common/src/main/res/layout/fragment_euicc.xml @@ -27,6 +27,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:layout_marginBottom="16dp" + android:contentDescription="@string/profile_download" android:src="@drawable/ic_add" app:layout_constraintRight_toRightOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> diff --git a/app-common/src/main/res/values/strings.xml b/app-common/src/main/res/values/strings.xml index 05ca15a1a..38bb97662 100644 --- a/app-common/src/main/res/values/strings.xml +++ b/app-common/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ Provisioning Operational ICCID: + #%d Enable Disable From 4ac0820bbfd5d467b4646fe5ff2d5c204b06ae23 Mon Sep 17 00:00:00 2001 From: septs Date: Thu, 10 Jul 2025 02:54:25 +0200 Subject: [PATCH 59/99] fix: improve deep-link compatibility (#198) Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/198 Co-authored-by: septs Co-committed-by: septs --- app-common/src/main/AndroidManifest.xml | 5 +++-- .../im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app-common/src/main/AndroidManifest.xml b/app-common/src/main/AndroidManifest.xml index b0324dce4..44c82c015 100644 --- a/app-common/src/main/AndroidManifest.xml +++ b/app-common/src/main/AndroidManifest.xml @@ -45,8 +45,9 @@ - - + + + diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt index 9e312d4e8..6574645ad 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt @@ -123,8 +123,8 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { // If we get an LPA string from deep-link intents, extract from there. // Note that `onRestoreInstanceState` could override this with user input, // but that _is_ the desired behavior. - val uri = intent.data - if (uri?.scheme == "lpa") { + val uri = intent.data ?: return + if (uri.scheme.contentEquals("lpa", ignoreCase = true)) { val parsed = LPAString.parse(uri.schemeSpecificPart) state.smdp = parsed.address state.matchingId = parsed.matchingId From 6d43a9207cbfb1c03ccf304a2c3cd117c578df34 Mon Sep 17 00:00:00 2001 From: septs Date: Wed, 16 Jul 2025 14:24:05 +0200 Subject: [PATCH 60/99] chore: simplify pretty print json string (#201) https://developer.android.com/reference/org/json/JSONObject Reviewed-on: https://gitea.angry.im/PeterCxy/OpenEUICC/pulls/201 Co-authored-by: septs Co-committed-by: septs --- .../DownloadWizardDiagnosticsFragment.kt | 4 +- .../im/angry/openeuicc/util/StringUtils.kt | 70 ------------------- 2 files changed, 3 insertions(+), 71 deletions(-) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDiagnosticsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDiagnosticsFragment.kt index e282196a4..38418684f 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDiagnosticsFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDiagnosticsFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import android.widget.TextView import im.angry.openeuicc.common.R import im.angry.openeuicc.util.* +import org.json.JSONObject import java.util.Date class DownloadWizardDiagnosticsFragment : DownloadWizardActivity.DownloadWizardStepFragment() { @@ -86,9 +87,10 @@ class DownloadWizardDiagnosticsFragment : DownloadWizardActivity.DownloadWizardS ret.appendLine() val str = resp.data.decodeToString(throwOnInvalidSequence = false) + ret.appendLine( if (str.startsWith('{')) { - str.prettyPrintJson() + JSONObject(str).toString(2) } else { str } diff --git a/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt index 079853ebd..57d150b31 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt @@ -41,73 +41,3 @@ fun parseIsdrAidList(s: String): List = .filter(String::isNotEmpty) .mapNotNull { runCatching(it::decodeHex).getOrNull() } .ifEmpty { listOf(EUICC_DEFAULT_ISDR_AID.decodeHex()) } - -fun String.prettyPrintJson(): String { - val ret = StringBuilder() - var inQuotes = false - var escaped = false - val indentSymbolStack = ArrayDeque() - - val addNewLine = { - ret.append('\n') - repeat(indentSymbolStack.size) { - ret.append('\t') - } - } - - var lastChar = ' ' - - for (c in this) { - when { - !inQuotes && (c == '{' || c == '[') -> { - ret.append(c) - indentSymbolStack.addLast(c) - addNewLine() - } - - !inQuotes && (c == '}' || c == ']') -> { - indentSymbolStack.removeLast() - if (lastChar != ',') { - addNewLine() - } - ret.append(c) - } - - !inQuotes && c == ',' -> { - ret.append(c) - addNewLine() - } - - !inQuotes && c == ':' -> { - ret.append(c) - ret.append(' ') - } - - inQuotes && c == '\\' -> { - ret.append(c) - escaped = true - continue - } - - !escaped && c == '"' -> { - ret.append(c) - inQuotes = !inQuotes - } - - !inQuotes && c == ' ' -> { - // Do nothing -- we ignore spaces outside of quotes by default - // This is to ensure predictable formatting - } - - else -> ret.append(c) - } - - if (escaped) { - escaped = false - } - - lastChar = c - } - - return ret.toString() -} \ No newline at end of file From 677b69cedfcae37557ca5626bb1741987ffb0a10 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Sun, 20 Jul 2025 10:29:27 -0400 Subject: [PATCH 61/99] feat: quick compatibility check Co-authored-by: septs --- app-unpriv/src/main/AndroidManifest.xml | 5 + .../openeuicc/di/UnprivilegedAppContainer.kt | 5 + .../di/UnprivilegedUiComponentFactory.kt | 7 +- .../ui/QuickCompatibilityActivity.kt | 25 ++++ .../ui/QuickCompatibilityFragment.kt | 140 ++++++++++++++++++ .../openeuicc/ui/UnprivilegedMainActivity.kt | 13 +- .../openeuicc/util/CompatibilityCheck.kt | 6 +- .../util/UnprivilegedPreferenceRepository.kt | 14 ++ .../angry/openeuicc/util/UnprivilegedUtils.kt | 6 + .../layout/activity_quick_compatibility.xml | 16 ++ .../layout/fragment_quick_compatibility.xml | 53 +++++++ app-unpriv/src/main/res/values/strings.xml | 10 ++ 12 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 app-unpriv/src/main/java/im/angry/openeuicc/ui/QuickCompatibilityActivity.kt create mode 100644 app-unpriv/src/main/java/im/angry/openeuicc/ui/QuickCompatibilityFragment.kt create mode 100644 app-unpriv/src/main/java/im/angry/openeuicc/util/UnprivilegedPreferenceRepository.kt create mode 100644 app-unpriv/src/main/java/im/angry/openeuicc/util/UnprivilegedUtils.kt create mode 100644 app-unpriv/src/main/res/layout/activity_quick_compatibility.xml create mode 100644 app-unpriv/src/main/res/layout/fragment_quick_compatibility.xml diff --git a/app-unpriv/src/main/AndroidManifest.xml b/app-unpriv/src/main/AndroidManifest.xml index ce985cdc7..ba3707997 100644 --- a/app-unpriv/src/main/AndroidManifest.xml +++ b/app-unpriv/src/main/AndroidManifest.xml @@ -23,6 +23,11 @@ + + = emptyList() + ) + } + + private val conclusion: TextView by lazy { + requireView().requireViewById(R.id.quick_availability_conclusion) + } + + private val resultSlots: TextView by lazy { + requireView().requireViewById(R.id.quick_availability_result_slots) + } + + private val resultNotes: TextView by lazy { + requireView().requireViewById(R.id.quick_availability_result_notes) + } + + private val hidden: CheckBox by lazy { + requireView().requireViewById(R.id.quick_availability_hidden) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = inflater.inflate(R.layout.fragment_quick_compatibility, container, false).apply { + requireViewById(R.id.quick_availability_device_information) + .text = formatDeviceInformation() + requireViewById