diff --git a/.forgejo/workflows/build-debug.yml b/.forgejo/workflows/build-debug.yml index 51e802ca..0818b8b5 100644 --- a/.forgejo/workflows/build-debug.yml +++ b/.forgejo/workflows/build-debug.yml @@ -1,7 +1,7 @@ on: push: branches: - - 'master' + - '*' jobs: build-debug: @@ -33,14 +33,23 @@ jobs: uses: https://gitea.angry.im/actions/setup-android@v3 - name: Build Debug APKs - run: ./gradlew --no-daemon assembleDebug + run: ./gradlew --no-daemon assembleDebug :app:assembleDebugMagiskModuleDir - name: Copy Artifacts - run: find . -name 'app*-debug.apk' -exec cp {} . \; + run: | + find . -name 'app*-debug.apk' -exec cp {} . \; + cp -r app/build/magisk/debug ./magisk-debug - - name: Upload Artifacts + - name: Upload APK Artifacts uses: https://gitea.angry.im/actions/upload-artifact@v3 with: name: Debug APKs compression-level: 0 path: app*-debug.apk + + - name: Upload Magisk Artifacts + uses: https://gitea.angry.im/actions/upload-artifact@v3 + with: + name: magisk-debug + compression-level: 0 + path: magisk-debug diff --git a/.gitmodules b/.gitmodules index f888959b..863f1850 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "libs/lpac-jni/src/main/jni/lpac"] path = libs/lpac-jni/src/main/jni/lpac - url = https://github.com/estkme/lpac + url = https://github.com/estkme-group/lpac.git diff --git a/.idea/.gitignore b/.idea/.gitignore index b7c2402a..d2293f69 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -1,14 +1,7 @@ -/shelf -/caches -/libraries -/assetWizardSettings.xml -/deploymentTargetDropDown.xml -/gradle.xml -/misc.xml -/modules.xml -/navEditor.xml -/runConfigurations.xml -/workspace.xml -/AndroidProjectSystem.xml - -**/*.iml \ No newline at end of file +* +!/codeStyles/Project.xml +!/codeStyles/codeStyleConfig.xml +!/vcs.xml +!/kotlinc.xml +!/compiler.xml +!/migrations.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 4bec4ea8..7643783a 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 a55e7a17..6e6eec11 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 deleted file mode 100644 index 8096d6c0..00000000 --- a/.idea/deploymentTargetSelector.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index e805548a..148fdd24 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/README.md b/README.md index f8019b2a..80ab3002 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,46 @@ 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 eSIM [^1] | Supported | Supported | +| USB Readers | Supported | Supported | +| Requires allowlisting by eSIM | No | Yes -- except USB | +| System Integration | Partial [^2] | No | +| Minimum Android Version | Android 11 or higher | Android 9 or higher | -__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. +[^1]: Also known as "Removable eSIM" +[^2]: Carrier Partner API unimplemented yet -__If you are releasing a modification of this app, you are kindly asked to make changes to at least the app name and package name.__ +Some side notes: -Building (Gradle) -=== +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][usb-ccid] are supported. +3. Prebuilt release-mode EasyEUICC apks can be downloaded [here][releases]. + For OpenEUICC, no official release is currently provided and only debug mode APKs and Magisk modules can be found in the [CI page][actions]. +4. For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted, + include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`. + +[sgp.22]: https://www.gsma.com/solutions-and-impact/technologies/esim/gsma_resources/sgp-22-v2-2-2/ "SGP.22 v2.2.2" +[usb-ccid]: https://en.wikipedia.org/wiki/CCID_%28protocol%29 "USB CCID Protocol" +[releases]: https://gitea.angry.im/PeterCxy/OpenEUICC/releases "EasyEUICC Releases" +[actions]: https://gitea.angry.im/PeterCxy/OpenEUICC/actions "OpenEUICC Actions" + +**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. + +**If you are releasing a modification of this app, you are kindly asked to make changes to at least the app name and package name.** + +# Building (Gradle) Make sure you have all submodules cloned and updated by running @@ -53,8 +74,7 @@ For EasyEUICC: ./gradlew :app-unpriv:assembleRelease ``` -Building (AOSP) -=== +# Building (AOSP) There are two ways to include OpenEUICC in your AOSP-based system image: @@ -64,25 +84,22 @@ There are two ways to include OpenEUICC in your AOSP-based system image: - Compilation of this project is **only** tested against the latest AOSP release version. The app itself should be compatible with older AOSP versions, but the source may not compile against an older AOSP source tree. 2. If compilation against AOSP source tree is not possible, consider [building with gradle](#building-gradle) and import the apk as a prebuilt. - No official `Android.bp` is provided for this case but it should be straightforward to write. - - You might want to include `privapp_whitelist_im.angry.openeuicc.xml` as well. + - You might want to include [`privapp_whitelist_im.angry.openeuicc.xml`] as well. -FAQs -=== +[`privapp_whitelist_im.angry.openeuicc.xml`]: privapp_whitelist_im.angry.openeuicc.xml "OpenEUICC Privapp Whitelist" -- Q: Do you provide prebuilt binaries for OpenEUICC? -- A: Debug-mode APKs are available continuously as an artifact of the [Actions](https://gitea.angry.im/PeterCxy/OpenEUICC/actions) CI used by this project. However, these debug-mode APKs are **not** intended for inclusion inside system images, nor are they supported by the developer in any sense. If you are a custom ROM developer, either include the entire OpenEUICC repository in your AOSP source tree, or generate an APK using `gradle` and import that as a prebuilt system app. Note that you might want `privapp_whitelist_im.angry.openeuicc.xml` as well. +# FAQs -- Q: AOSP's Settings app seems to be confused by OpenEUICC (for example, disabling / enabling profiles from the Networks page do not work properly) -- A: When your device has internal eSIM chip(s) __and__ you have inserted a removable eSIM chip, the Settings app can misbehave since it was never designed for this scenario. __Please prefer using OpenEUICC's own management interface whenever possible.__ In the future, there might be an option to exclude removable SIMs from being reported to the Android system. +- Q: Do you provide prebuilt binaries for OpenEUICC? \ + A: Debug-mode APKs and Magisk modules are available continuously as an artifact of the [Actions] CI used by this project. However, these debug-mode APKs are **not** intended for inclusion inside system images, nor are they supported by the developer in any sense. If you are a custom ROM developer, either include the entire OpenEUICC repository in your AOSP source tree, or generate an APK using `gradle` and import that as a prebuilt system app. Note that you might want [`privapp_whitelist_im.angry.openeuicc.xml`] as well. -- Q: Can EasyEUICC manage my phone's internal eSIM? -- A: No. For EasyEUICC to work, the eSIM chip MUST proactively grant access via its ARA-M field. +- Q: Can EasyEUICC manage my phone's internal eSIM? \ + A: No. For EasyEUICC to work, the eSIM chip MUST proactively grant access via its ARA-M field. -- Q: Removable eSIMs? Are they a joke? -- A: No, even though the name "removable embedded SIM" can sound like an oxymoron. In fact, there can be many advantages to these chips compared to fully embedded ones. For example, the ability to transfer eSIM profiles without carrier support or approval, or the ability to use eSIM on devices that do not and may never get the support, such as Wi-Fi hotspots. +- Q: Removable eSIMs? Are they a joke? \ + A: No, even though the name "removable embedded SIM" can sound like an oxymoron. In fact, there can be many advantages to these chips compared to fully embedded ones. For example, the ability to transfer eSIM profiles without carrier support or approval, or the ability to use eSIM on devices that do not and may never get the support, such as Wi-Fi hotspots. -Copyright -=== +# Copyright Everything except `libs/lpac-jni` and `art/`: @@ -122,4 +139,4 @@ License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA ``` -`art/`: Courtesy of [Aikoyori](https://github.com/Aikoyori), CC NC-SA 4.0. \ No newline at end of file +`art/`: Courtesy of [Aikoyori](https://github.com/Aikoyori), CC NC-SA 4.0. diff --git a/app-common/src/main/AndroidManifest.xml b/app-common/src/main/AndroidManifest.xml index 6edaaf13..44c82c01 100644 --- a/app-common/src/main/AndroidManifest.xml +++ b/app-common/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + + + - - + + + 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 ea0bd602..78a8c3f3 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,84 +1,86 @@ 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) } } - override suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? { + override suspend fun tryOpenEuiccChannel( + port: UiccPortInfoCompat, + isdrAid: ByteArray + ): EuiccChannel? = try { if (port.portIndex != 0) { - Log.w(DefaultEuiccChannelManager.TAG, "OMAPI channel attempted on non-zero portId, this may or may not work.") + Log.w( + DefaultEuiccChannelManager.TAG, + "OMAPI channel attempted on non-zero portId, this may or may not work." + ) } ensureSEService() - Log.i(DefaultEuiccChannelManager.TAG, "Trying OMAPI for physical slot ${port.card.physicalSlotIndex}") - try { - return EuiccChannelImpl( - context.getString(R.string.omapi), + Log.i( + DefaultEuiccChannelManager.TAG, + "Trying OMAPI for physical slot ${port.card.physicalSlotIndex}" + ) + EuiccChannelImpl( + context.getString(R.string.channel_type_omapi), + port, + intrinsicChannelName = null, + OmapiApduInterface( + seService!!, port, - intrinsicChannelName = null, - OmapiApduInterface( - seService!!, - port, - context.preferenceRepository.verboseLoggingFlow - ), - context.preferenceRepository.verboseLoggingFlow, - context.preferenceRepository.ignoreTLSCertificateFlow, - ).also { - Log.i(DefaultEuiccChannelManager.TAG, "Is OMAPI channel, setting MSS to 60") - it.lpa.setEs10xMss(60) - } - } catch (e: IllegalArgumentException) { - // Failed - Log.w( - DefaultEuiccChannelManager.TAG, - "OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex}." - ) - } - - return null - } - - override fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): 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 - 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, + context.preferenceRepository.es10xMssFlow, ) + } catch (_: IllegalArgumentException) { + // Failed + Log.w( + DefaultEuiccChannelManager.TAG, + "OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex} with ISD-R AID: ${isdrAid.encodeHex()}." + ) + null + } + + override fun tryOpenUsbEuiccChannel( + ccidCtx: UsbCcidContext, + isdrAid: ByteArray + ): EuiccChannel? = try { + EuiccChannelImpl( + context.getString(R.string.channel_type_usb), + FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)), + intrinsicChannelName = ccidCtx.productName, + UsbApduInterface( + ccidCtx + ), + isdrAid, + context.preferenceRepository.verboseLoggingFlow, + context.preferenceRepository.ignoreTLSCertificateFlow, + context.preferenceRepository.es10xMssFlow, + ) + } catch (_: IllegalArgumentException) { + // Failed + Log.w( + DefaultEuiccChannelManager.TAG, + "USB APDU interface unavailable for ISD-R AID: ${isdrAid.encodeHex()}." + ) + 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 dd57eab2..6b336cda 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 @@ -12,6 +13,7 @@ import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.merge @@ -49,6 +51,24 @@ open class DefaultEuiccChannelManager( protected open val uiccCards: Collection 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 +96,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 +107,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 +232,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,10 +272,19 @@ 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" + ) + + val ccidCtx = UsbCcidContext.createFromUsbDevice(context, device, iface) ?: return@forEach + try { - val channel = euiccChannelFactory.tryOpenUsbEuiccChannel(device, iface) + val channel = tryOpenChannelFirstValidAid { + euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, it) + } if (channel != null && channel.lpa.valid) { + ccidCtx.allowDisconnect = true usbChannel = channel return@withContext Pair(device, true) } @@ -260,7 +292,14 @@ open class DefaultEuiccChannelManager( // Ignored -- skip forward e.printStackTrace() } - Log.i(TAG, "No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}") + + ccidCtx.allowDisconnect = true + ccidCtx.disconnect() + + 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/EuiccChannel.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt index 597a70d2..b20932fe 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/EuiccChannelFactory.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelFactory.kt index fb5d95d8..ba587a63 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,15 +1,17 @@ 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 // "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( + ccidCtx: UsbCcidContext, + 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 a56b1ccb..eaec5227 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,8 +1,9 @@ package im.angry.openeuicc.core import im.angry.openeuicc.util.UiccPortInfoCompat -import im.angry.openeuicc.util.decodeHex import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import net.typeblog.lpac_jni.ApduInterface import net.typeblog.lpac_jni.LocalProfileAssistant import net.typeblog.lpac_jni.impl.HttpInterfaceImpl @@ -13,24 +14,23 @@ class EuiccChannelImpl( override val port: UiccPortInfoCompat, override val intrinsicChannelName: String?, override val apduInterface: ApduInterface, + override val isdrAid: ByteArray, verboseLoggingFlow: Flow, - ignoreTLSCertificateFlow: Flow + ignoreTLSCertificateFlow: Flow, + es10xMssFlow: 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) - ) + HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow), + ).also { + it.setEs10xMss(runBlocking { es10xMssFlow.first().toByte() }) + } override val atr: ByteArray? get() = (apduInterface as? ApduInterfaceAtrProvider)?.atr 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 09004d35..361a9438 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/core/usb/UsbApduInterface.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt index f9e764b5..4a4ccb98 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,29 +21,19 @@ class UsbApduInterface( private var channels = mutableSetOf() override fun connect() { - ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!! + ccidCtx.connect() - 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 - } + // 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() { - conn.close() - - atr = null - } + override fun disconnect() = ccidCtx.disconnect() override fun logicalChannelOpen(aid: ByteArray): Int { // OPEN LOGICAL CHANNEL @@ -140,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)!") @@ -151,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 @@ -161,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 00000000..caf69e7d --- /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 + } + } +} diff --git a/app-common/src/main/java/im/angry/openeuicc/di/DefaultCustomizableTextProvider.kt b/app-common/src/main/java/im/angry/openeuicc/di/DefaultCustomizableTextProvider.kt index b4936112..76227fd3 100644 --- a/app-common/src/main/java/im/angry/openeuicc/di/DefaultCustomizableTextProvider.kt +++ b/app-common/src/main/java/im/angry/openeuicc/di/DefaultCustomizableTextProvider.kt @@ -8,7 +8,7 @@ open class DefaultCustomizableTextProvider(private val context: Context) : Custo get() = context.getString(R.string.no_euicc) override val profileSwitchingTimeoutMessage: String - get() = context.getString(R.string.enable_disable_timeout) + get() = context.getString(R.string.profile_switch_timeout) override fun formatInternalChannelName(logicalSlotId: Int): String = context.getString(R.string.channel_name_format, logicalSlotId) 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 9957f30d..47443216 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 @@ -4,6 +4,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.os.Binder import android.os.IBinder +import android.os.PowerManager import android.util.Log import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat @@ -91,6 +92,12 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { } val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate + private val wakeLock: PowerManager.WakeLock by lazy { + (getSystemService(POWER_SERVICE) as PowerManager).run { + newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this::class.simpleName) + } + } + /** * The state of a "foreground" task (named so due to the need to startForeground()) */ @@ -275,6 +282,8 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { updateForegroundNotification(title, iconRes) + wakeLock.acquire(10 * 60 * 1000L /*10 minutes*/) + try { withContext(Dispatchers.IO + NonCancellable) { // Any LPA-related task must always complete this@EuiccChannelManagerService.task() @@ -290,6 +299,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { postForegroundTaskFailureNotification(failureTitle) } } finally { + wakeLock.release() if (isActive) { stopSelf() } @@ -446,30 +456,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") } @@ -502,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 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 4e499dc2..248afaf5 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,9 +27,16 @@ 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) + private val YES_NO = Pair(R.string.euicc_info_yes, R.string.euicc_info_no) } private lateinit var swipeRefresh: SwipeRefreshLayout @@ -62,7 +69,7 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { logicalSlotId = intent.getIntExtra("logicalSlotId", 0) val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { - getString(R.string.usb) + getString(R.string.channel_type_usb) } else { appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId) } @@ -102,19 +109,27 @@ 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)) } + 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)) } } - 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_gp_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())) } + + val nvramText = buildString { + append(formatFreeSpace(info.freeNvram)) + append(' ') + append(getString(R.string.euicc_info_free_nvram_hint)) + } + add(Item(R.string.euicc_info_free_nvram, nvramText)) } channel.lpa.euiccInfo2?.euiccCiPKIdListForSigning.orEmpty().let { signers -> // SGP.28 v1.0, eSIM CI Registration Criteria (Page 5 of 9, 2019-10-24) @@ -122,25 +137,20 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { // FS.27 v2.0, Security Guidelines for UICC Profiles (Page 25 of 27, 2024-01-30) // https://www.gsma.com/solutions-and-impact/technologies/security/wp-content/uploads/2024/01/FS.27-Security-Guidelines-for-UICC-Credentials-v2.0-FINAL-23-July.pdf#page=25 val resId = when { - signers.isEmpty() -> R.string.unknown // the case is not mp, but it's is not common + signers.isEmpty() -> R.string.euicc_info_unknown // the case is not mp, but it's is not common PKID_GSMA_LIVE_CI.any(signers::contains) -> R.string.euicc_info_ci_gsma_live PKID_GSMA_TEST_CI.any(signers::contains) -> R.string.euicc_info_ci_gsma_test else -> R.string.euicc_info_ci_unknown } 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.euicc_info_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) @@ -167,7 +177,7 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { fun bind(item: Item) { copiedToastResId = item.copiedToastResId title.setText(item.titleResId) - content.text = item.content ?: getString(R.string.unknown) + content.text = item.content ?: getString(R.string.euicc_info_unknown) } } 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 12995ff7..016e96f8 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 @@ -253,7 +253,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, if (!isUsb) { withContext(Dispatchers.Main) { AlertDialog.Builder(requireContext()).apply { - setMessage(R.string.switch_did_not_refresh) + setMessage(R.string.profile_switch_did_not_refresh) setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() requireActivity().finish() @@ -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 @@ -377,9 +380,9 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, state.setText( if (profile.isEnabled) { - R.string.enabled + R.string.profile_state_enabled } else { - R.string.disabled + R.string.profile_state_disabled } ) provider.text = profile.providerName @@ -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/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt new file mode 100644 index 00000000..022a391b --- /dev/null +++ b/app-common/src/main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt @@ -0,0 +1,72 @@ +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 + } + + android.R.id.home -> { + finish() + true + } + + else -> super.onOptionsItemSelected(item) + } +} \ No newline at end of file diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/MainActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/MainActivity.kt index 01d0ab2b..b42f4cf1 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/MainActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/MainActivity.kt @@ -174,7 +174,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { // If USB readers exist, add them at the very last // We use a wrapper fragment to handle logic specific to USB readers usbDevice?.let { - val productName = it.productName ?: getString(R.string.usb) + val productName = it.productName ?: getString(R.string.channel_type_usb) newPages.add(Page(EuiccChannelManager.USB_CHANNEL_ID, productName) { UsbCcidReaderFragment() }) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/NotificationsActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/NotificationsActivity.kt index 21a2d405..07d5f132 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/NotificationsActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/NotificationsActivity.kt @@ -60,7 +60,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker { // This is slightly different from the MainActivity logic // due to the length (we don't want to display the full USB product name) val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { - getString(R.string.usb) + getString(R.string.channel_type_usb) } else { appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId) } 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 c588254c..281e6253 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 @@ -65,7 +65,7 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment super.onViewCreated(view, savedInstanceState) profileRenameNewName.editText!!.setText(currentName) toolbar.apply { - setTitle(R.string.rename) + setTitle(R.string.profile_rename) setNavigationOnClickListener { if (!renaming) dismiss() } 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 b085286f..7a717ac1 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 @@ -8,6 +8,7 @@ import android.provider.Settings import android.widget.Toast import androidx.lifecycle.lifecycleScope import androidx.preference.CheckBoxPreference +import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat @@ -16,7 +17,6 @@ import im.angry.openeuicc.util.* import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking open class SettingsFragment: PreferenceFragmentCompat() { private lateinit var developerPref: PreferenceCategory @@ -34,7 +34,7 @@ open class SettingsFragment: PreferenceFragmentCompat() { // Show / hide developer preference based on whether it is enabled lifecycleScope.launch { preferenceRepository.developerOptionsEnabledFlow - .onEach { developerPref.isVisible = it } + .onEach(developerPref::setVisible) .collect() } @@ -78,8 +78,18 @@ 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) + + requirePreference("pref_developer_es10x_mss") + .bindIntFlow(preferenceRepository.es10xMssFlow, 63) + + requirePreference("pref_developer_isdr_aid_list").apply { + intent = Intent(requireContext(), IsdrAidListActivity::class.java) + } } protected fun requirePreference(key: CharSequence) = @@ -93,51 +103,53 @@ open class SettingsFragment: PreferenceFragmentCompat() { @Suppress("UNUSED_PARAMETER") private fun onAppVersionClicked(pref: Preference): Boolean { if (developerPref.isVisible) return false + val now = System.currentTimeMillis() - if (now - lastClickTimestamp >= 1000) { - numClicks = 1 - } else { - numClicks++ - } + numClicks = if (now - lastClickTimestamp >= 1000) 1 else numClicks + 1 lastClickTimestamp = now - if (numClicks == 7) { - lifecycleScope.launch { - preferenceRepository.developerOptionsEnabledFlow.updatePreference(true) - - lastToast?.cancel() - Toast.makeText( - requireContext(), - R.string.developer_options_enabled, - Toast.LENGTH_SHORT - ).show() - } - } else if (numClicks > 1) { - lastToast?.cancel() - lastToast = Toast.makeText( - requireContext(), - getString(R.string.developer_options_steps, 7 - numClicks), - Toast.LENGTH_SHORT - ) - lastToast!!.show() + lifecycleScope.launch { + preferenceRepository.developerOptionsEnabledFlow.updatePreference(numClicks >= 7) } + val toastText = when { + numClicks == 7 -> getString(R.string.developer_options_enabled) + numClicks > 1 -> getString(R.string.developer_options_steps, 7 - numClicks) + else -> return true + } + + lastToast?.cancel() + lastToast = Toast.makeText(requireContext(), toastText, Toast.LENGTH_SHORT) + lastToast!!.show() return true } protected fun CheckBoxPreference.bindBooleanFlow(flow: PreferenceFlowWrapper) { lifecycleScope.launch { - flow.collect { isChecked = it } + flow.collect(::setChecked) } setOnPreferenceChangeListener { _, newValue -> - runBlocking { + lifecycleScope.launch { flow.updatePreference(newValue as Boolean) } true } } + private fun ListPreference.bindIntFlow(flow: PreferenceFlowWrapper, defaultValue: Int) { + lifecycleScope.launch { + flow.collect { value = it.toString() } + } + + setOnPreferenceChangeListener { _, newValue -> + lifecycleScope.launch { + flow.updatePreference((newValue as String).toIntOrNull() ?: defaultValue) + } + true + } + } + protected fun mergePreferenceOverlay(overlayKey: String, targetKey: String) { val overlayCat = requirePreference(overlayKey) val targetCat = requirePreference(targetKey) 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 a9f868f0..6574645a 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 @@ -122,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 @@ -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/DownloadWizardDetailsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt index 402e7a52..1c69de58 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 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 e282196a..38418684 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/ui/wizard/DownloadWizardProgressFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt index 1b816d48..0048190b 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 @@ -7,6 +7,7 @@ import android.view.ViewGroup import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView +import androidx.annotation.StringRes import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager @@ -42,23 +43,24 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep } private data class ProgressItem( - val titleRes: Int, - var state: ProgressState + @StringRes val titleRes: Int, + var state: ProgressState = ProgressState.NotStarted, + var errorMessage: SimplifiedErrorMessages? = null, ) private val progressItems = arrayOf( - ProgressItem(R.string.download_wizard_progress_step_preparing, ProgressState.NotStarted), - ProgressItem(R.string.download_wizard_progress_step_connecting, ProgressState.NotStarted), - ProgressItem( - R.string.download_wizard_progress_step_authenticating, - ProgressState.NotStarted - ), - ProgressItem(R.string.download_wizard_progress_step_downloading, ProgressState.NotStarted), - ProgressItem(R.string.download_wizard_progress_step_finalizing, ProgressState.NotStarted) + ProgressItem(R.string.download_wizard_progress_step_preparing), + ProgressItem(R.string.download_wizard_progress_step_connecting), + ProgressItem(R.string.download_wizard_progress_step_authenticating), + ProgressItem(R.string.download_wizard_progress_step_downloading), + ProgressItem(R.string.download_wizard_progress_step_finalizing) ) 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 @@ -119,8 +121,13 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep // Change the state of the last InProgress item to success (or error) progressItems.forEachIndexed { index, progressItem -> if (progressItem.state == ProgressState.InProgress) { - progressItem.state = - if (state.downloadError == null) ProgressState.Done else ProgressState.Error + if (state.downloadError == null) { + progressItem.state = ProgressState.Done + } else { + progressItem.state = ProgressState.Error + progressItem.errorMessage = + SimplifiedErrorMessages.fromDownloadError(state.downloadError!!) + } } adapter.notifyItemChanged(index) @@ -130,9 +137,8 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep refreshButtons() } - is EuiccChannelManagerService.ForegroundTaskState.InProgress -> { + is EuiccChannelManagerService.ForegroundTaskState.InProgress -> updateProgress(it.progress) - } else -> {} } @@ -194,9 +200,15 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep private val progressBar = root.requireViewById(R.id.download_progress_icon_progress) private val icon = root.requireViewById(R.id.download_progress_icon) + private val errorTitle = + root.requireViewById(R.id.download_progress_item_error_title) + private val errorSuggestion = + root.requireViewById(R.id.download_progress_item_error_suggestion) fun bind(item: ProgressItem) { title.text = getString(item.titleRes) + errorTitle.visibility = View.GONE + errorSuggestion.visibility = View.GONE when (item.state) { ProgressState.NotStarted -> { @@ -219,6 +231,15 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep progressBar.visibility = View.GONE icon.setImageResource(R.drawable.ic_error_outline) icon.visibility = View.VISIBLE + + item.errorMessage?.titleResId?.let { + errorTitle.visibility = View.VISIBLE + errorTitle.text = getString(it) + } + item.errorMessage?.suggestResId?.let { + errorSuggestion.visibility = View.VISIBLE + errorSuggestion.text = getString(it) + } } } } diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardSlotSelectFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardSlotSelectFragment.kt index 28bc9f00..8097058d 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardSlotSelectFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardSlotSelectFragment.kt @@ -19,7 +19,6 @@ import im.angry.openeuicc.util.* import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch -import net.typeblog.lpac_jni.LocalProfileInfo class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardStepFragment() { companion object { @@ -187,12 +186,12 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt } title.text = if (item.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { - item.intrinsicChannelName ?: root.context.getString(R.string.usb) + item.intrinsicChannelName ?: root.context.getString(R.string.channel_type_usb) } else { appContainer.customizableTextProvider.formatInternalChannelName(item.logicalSlotId) } eID.text = item.eID - activeProfile.text = item.enabledProfileName ?: root.context.getString(R.string.unknown) + activeProfile.text = item.enabledProfileName ?: root.context.getString(R.string.profile_no_enabled_profile) freeSpace.text = formatFreeSpace(item.freeSpace) checkBox.isChecked = adapter.currentSelectedIdx == idx } diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/SimplifiedErrorMessages.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/SimplifiedErrorMessages.kt new file mode 100644 index 00000000..8ce5740b --- /dev/null +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/SimplifiedErrorMessages.kt @@ -0,0 +1,154 @@ +package im.angry.openeuicc.ui.wizard + +import androidx.annotation.StringRes +import im.angry.openeuicc.common.R +import net.typeblog.lpac_jni.LocalProfileAssistant +import org.json.JSONObject +import java.net.NoRouteToHostException +import java.net.PortUnreachableException +import java.net.SocketException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import javax.net.ssl.SSLException + +enum class SimplifiedErrorMessages( + @StringRes val titleResId: Int, + @StringRes val suggestResId: Int? +) { + ICCIDAlreadyInUse( + R.string.download_wizard_error_iccid_already, + R.string.download_wizard_error_suggest_profile_installed + ), + InsufficientMemory( + R.string.download_wizard_error_insufficient_memory, + R.string.download_wizard_error_suggest_insufficient_memory + ), + UnsupportedProfile( + R.string.download_wizard_error_unsupported_profile, + null + ), + CardInternalError( + R.string.download_wizard_error_card_internal_error, + null + ), + EIDNotSupported( + R.string.download_wizard_error_eid_not_supported, + R.string.download_wizard_error_suggest_contact_carrier + ), + EIDMismatch( + R.string.download_wizard_error_eid_mismatch, + R.string.download_wizard_error_suggest_contact_reissue + ), + UnreleasedProfile( + R.string.download_wizard_error_profile_unreleased, + R.string.download_wizard_error_suggest_contact_reissue + ), + MatchingIDRefused( + R.string.download_wizard_error_matching_id_refused, + R.string.download_wizard_error_suggest_contact_carrier + ), + ProfileRetriesExceeded( + R.string.download_wizard_error_profile_retries_exceeded, + R.string.download_wizard_error_suggest_contact_carrier + ), + ConfirmationCodeMissing( + R.string.download_wizard_error_confirmation_code_missing, + R.string.download_wizard_error_suggest_contact_carrier + ), + ConfirmationCodeRefused( + R.string.download_wizard_error_confirmation_code_refused, + R.string.download_wizard_error_suggest_contact_carrier + ), + ConfirmationCodeRetriesExceeded( + R.string.download_wizard_error_confirmation_code_retries_exceeded, + R.string.download_wizard_error_suggest_contact_carrier + ), + ProfileExpired( + R.string.download_wizard_error_profile_expired, + R.string.download_wizard_error_suggest_contact_carrier + ), + UnknownHost( + R.string.download_wizard_error_unknown_hostname, + null + ), + NetworkUnreachable( + R.string.download_wizard_error_network_unreachable, + R.string.download_wizard_error_suggest_network_unreachable + ), + TLSError( + R.string.download_wizard_error_tls_certificate, + null + ); + + companion object { + private val httpErrors = buildMap { + // Stage: AuthenticateClient + put("8.1" to "4.8", InsufficientMemory) + put("8.1.1" to "2.1", EIDNotSupported) + put("8.1.1" to "3.8", EIDMismatch) + put("8.2" to "1.2", UnreleasedProfile) + put("8.2.6" to "3.8", MatchingIDRefused) + put("8.8.5" to "6.4", ProfileRetriesExceeded) + + // Stage: GetBoundProfilePackage + put("8.2.7" to "2.2", ConfirmationCodeMissing) + put("8.2.7" to "3.8", ConfirmationCodeRefused) + put("8.2.7" to "6.4", ConfirmationCodeRetriesExceeded) + + // Stage: AuthenticateClient, GetBoundProfilePackage + put("8.8.5" to "4.10", ProfileExpired) + } + + fun fromDownloadError(exc: LocalProfileAssistant.ProfileDownloadException) = when { + exc.lpaErrorReason != "ES10B_ERROR_REASON_UNDEFINED" -> fromLPAErrorReason(exc.lpaErrorReason) + exc.lastHttpResponse?.rcode == 200 -> fromHTTPResponse(exc.lastHttpResponse!!) + exc.lastHttpException != null -> fromHTTPException(exc.lastHttpException!!) + exc.lastApduResponse != null -> fromAPDUResponse(exc.lastApduResponse!!) + else -> null + } + + private fun fromLPAErrorReason(reason: String) = when (reason) { + "ES10B_ERROR_REASON_UNSUPPORTED_CRT_VALUES" -> UnsupportedProfile + "ES10B_ERROR_REASON_UNSUPPORTED_REMOTE_OPERATION_TYPE" -> UnsupportedProfile + "ES10B_ERROR_REASON_UNSUPPORTED_PROFILE_CLASS" -> UnsupportedProfile + "ES10B_ERROR_REASON_INSTALL_FAILED_DUE_TO_ICCID_ALREADY_EXISTS_ON_EUICC" -> ICCIDAlreadyInUse + "ES10B_ERROR_REASON_INSTALL_FAILED_DUE_TO_INSUFFICIENT_MEMORY_FOR_PROFILE" -> InsufficientMemory + "ES10B_ERROR_REASON_INSTALL_FAILED_DUE_TO_INTERRUPTION" -> CardInternalError + "ES10B_ERROR_REASON_INSTALL_FAILED_DUE_TO_PE_PROCESSING_ERROR" -> CardInternalError + else -> null + } + + private fun fromHTTPResponse(httpResponse: net.typeblog.lpac_jni.HttpInterface.HttpResponse): SimplifiedErrorMessages? { + if (httpResponse.data.first().toInt() != '{'.code) return null + val response = JSONObject(httpResponse.data.decodeToString()) + val statusCodeData = response.optJSONObject("header") + ?.optJSONObject("functionExecutionStatus") + ?.optJSONObject("statusCodeData") + ?: return null + val subjectCode = statusCodeData.optString("subjectCode") + val reasonCode = statusCodeData.optString("reasonCode") + return httpErrors[subjectCode to reasonCode] + } + + private fun fromHTTPException(exc: Exception) = when (exc) { + is SSLException -> TLSError + is UnknownHostException -> UnknownHost + is NoRouteToHostException -> NetworkUnreachable + is PortUnreachableException -> NetworkUnreachable + is SocketTimeoutException -> NetworkUnreachable + is SocketException -> exc.message + ?.contains("Connection reset", ignoreCase = true) + ?.let { if (it) NetworkUnreachable else null } + + else -> null + } + + private fun fromAPDUResponse(resp: ByteArray): SimplifiedErrorMessages? { + val isSuccess = resp.size >= 2 && + resp[resp.size - 2] == 0x90.toByte() && + resp[resp.size - 1] == 0x00.toByte() + if (isSuccess) return null + return CardInternalError + } + } +} 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 20956fb5..63a81f19 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" ) } } 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 34d1cfd9..2fef3db2 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,14 @@ 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.intPreferencesKey +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") @@ -31,9 +34,39 @@ 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") + val ISDR_AID_LIST = stringPreferencesKey("isdr_aid_list") + val ES10X_MSS = intPreferencesKey("es10x_mss") +} + +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 + + # ESTKme AUX (deprecated, use SE0 instead) + A06573746B6D65FFFFFFFF4953442D52 + + # ESTKme SE0 + A06573746B6D65FFFF4953442D522030 + + # eSIM.me + A0000005591010000000008900000300 + + # 5ber.eSIM + A0000005591010FFFFFFFF8900050500 + + # Xesim + A0000005591010FFFFFFFF8900000177 + """.trimIndent() } open class PreferenceRepository(private val context: Context) { @@ -48,27 +81,51 @@ 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) 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() }) + val es10xMssFlow = bindFlow(PreferenceKeys.ES10X_MSS, 63) - 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 + inner: Flow, + private val encoder: (T) -> T, ) : Flow by inner { - internal constructor(context: Context, key: Preferences.Key, defaultValue: T) : this( + internal constructor( + context: Context, + key: Preferences.Key, + defaultValue: T, + encoder: (T) -> T, + decoder: (T) -> T + ) : this( context, key, - context.dataStore.data.map { it[key] ?: defaultValue } + 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) } } -} \ No newline at end of file + + 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 8d724620..57d150b3 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,72 +29,15 @@ fun formatFreeSpace(size: Int): String = "$size B" } -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 +/** + * 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) + .filter(String::isNotEmpty) + .mapNotNull { runCatching(it::decodeHex).getOrNull() } + .ifEmpty { listOf(EUICC_DEFAULT_ISDR_AID.decodeHex()) } diff --git a/app-common/src/main/java/im/angry/openeuicc/util/UiUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/UiUtils.kt index a73d7fe0..c7c859d9 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/UiUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/UiUtils.kt @@ -102,8 +102,8 @@ fun T.setupLogSaving( AlertDialog.Builder(context).apply { setMessage(R.string.logs_saved_message) - setNegativeButton(R.string.no) { _, _ -> } - setPositiveButton(R.string.yes) { _, _ -> + setNegativeButton(android.R.string.cancel) { _, _ -> } + setPositiveButton(android.R.string.ok) { _, _ -> val intent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" clipData = ClipData.newUri(context.contentResolver, lastFileName, uri) 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 5a559f97..046657f8 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-unpriv/src/main/res/layout/activity_compatibility_check.xml b/app-common/src/main/res/layout/activity_isdr_aid_list.xml similarity index 63% rename from app-unpriv/src/main/res/layout/activity_compatibility_check.xml rename to app-common/src/main/res/layout/activity_isdr_aid_list.xml index 4d622e39..48135fbe 100644 --- a/app-unpriv/src/main/res/layout/activity_compatibility_check.xml +++ b/app-common/src/main/res/layout/activity_isdr_aid_list.xml @@ -1,18 +1,24 @@ - + app:layout_constraintBottom_toBottomOf="parent" + tools:ignore="LabelFor" /> \ No newline at end of file diff --git a/app-common/src/main/res/layout/download_progress_item.xml b/app-common/src/main/res/layout/download_progress_item.xml index f1d0852e..c59673b0 100644 --- a/app-common/src/main/res/layout/download_progress_item.xml +++ b/app-common/src/main/res/layout/download_progress_item.xml @@ -1,30 +1,32 @@ + android:layout_height="wrap_content"> + app:layout_constraintBottom_toBottomOf="@id/download_progress_icon_container" + app:layout_constraintEnd_toStartOf="@id/download_progress_icon_container" + app:layout_constraintHorizontal_bias="0.0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="@id/download_progress_icon_container" + app:layout_constraintVertical_bias="0.5" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintVertical_bias="0.0"> + + + + \ No newline at end of file diff --git a/app-common/src/main/res/layout/euicc_profile.xml b/app-common/src/main/res/layout/euicc_profile.xml index 58d55ab1..021c53bd 100644 --- a/app-common/src/main/res/layout/euicc_profile.xml +++ b/app-common/src/main/res/layout/euicc_profile.xml @@ -54,7 +54,7 @@ + + diff --git a/app-common/src/main/res/layout/fragment_euicc.xml b/app-common/src/main/res/layout/fragment_euicc.xml index 4ae7523a..c5fde7bc 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/menu/activity_isdr_aid_list.xml b/app-common/src/main/res/menu/activity_isdr_aid_list.xml new file mode 100644 index 00000000..99492d64 --- /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/menu/activity_main.xml b/app-common/src/main/res/menu/activity_main.xml index 0e002928..c15663f8 100644 --- a/app-common/src/main/res/menu/activity_main.xml +++ b/app-common/src/main/res/menu/activity_main.xml @@ -3,7 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> diff --git a/app-common/src/main/res/menu/activity_notifications.xml b/app-common/src/main/res/menu/activity_notifications.xml index 87f96a66..b80e06e7 100644 --- a/app-common/src/main/res/menu/activity_notifications.xml +++ b/app-common/src/main/res/menu/activity_notifications.xml @@ -4,6 +4,6 @@ \ No newline at end of file diff --git a/app-common/src/main/res/menu/fragment_profile_rename.xml b/app-common/src/main/res/menu/fragment_profile_rename.xml index bde850f1..f55c56c0 100644 --- a/app-common/src/main/res/menu/fragment_profile_rename.xml +++ b/app-common/src/main/res/menu/fragment_profile_rename.xml @@ -4,6 +4,6 @@ \ No newline at end of file diff --git a/app-common/src/main/res/menu/profile_options.xml b/app-common/src/main/res/menu/profile_options.xml index 6add53d5..60722d61 100644 --- a/app-common/src/main/res/menu/profile_options.xml +++ b/app-common/src/main/res/menu/profile_options.xml @@ -2,18 +2,18 @@ + android:title="@string/profile_enable"/> + android:title="@string/profile_disable"/> + android:title="@string/profile_rename"/> + android:title="@string/profile_delete"/> \ 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 1fd361c8..b1719727 100644 --- a/app-common/src/main/res/values-ja/strings.xml +++ b/app-common/src/main/res/values-ja/strings.xml @@ -2,26 +2,28 @@ このアプリでアクセスできるリムーバブル eUICC カードがデバイス上で検出されていません。互換性のあるカード挿入または USB リーダーを接続してください。 この eSIM にはプロファイルがありません。 - 不明 - 情報がありません - ヘルプ - スロットを再読み込み + ヘルプ + スロットを再読み込み + 不明 論理スロット %d - 有効済み - 無効済み - プロバイダー: + + 有効化済み + 無効化済み + プロバイダー: クラス: テスト中 プロビジョニング 稼働中 - 有効化 - 無効化 - 削除 - 名前を変更 - eSIM チップがプロファイルの切り替えの待機中にタイムアウトしました。これはデバイスのモデムファームウェアのバグの可能性があります。機内モードに切り替えるかアプリを再起動、デバイスを再起動してください。 - 操作は成功しましたが、デバイスのモデムが更新を拒否しました。新しいプロファイルを使用するには機内モードに切り替えるか、再起動する必要があります。 + 有効化 + 無効化 + 削除 + 名前を変更 + eSIM チップがプロファイルの切り替えの待機中にタイムアウトしました。これはデバイスのモデムファームウェアのバグの可能性があります。機内モードに切り替えるかアプリを再起動、デバイスを再起動してください。 + 操作は成功しましたが、デバイスのモデムが更新を拒否しました。新しいプロファイルを使用するには機内モードに切り替えるか、再起動する必要があります。 新しい eSIM プロファイルに切り替えることができません。 確認文字列が一致しません + 確認文字列が一致しません + このチップは消去されました ICCID をクリップボードにコピーしました シリアル番号をクリップボードにコピーしました EID をクリップボードにコピーしました @@ -38,14 +40,16 @@ eSIM プロファイルの削除に失敗しました eSIM プロファイルを切り替え中 eSIM プロファイルの切り替えに失敗しました + eSIM チップを消去中 + eSIM チップの消去に失敗しました 新しい eSIM サーバー (RSP / SM-DP+) アクティベーションコード 確認コード (オプション) 確認コード (必須) IMEI (オプション) - 残り容量が少ない - 残り容量が少ないため、ダウンロードに失敗する可能性があります。 + 残りの容量が少量です + 残り容量が少ないため、このプロファイルのダウンロードに失敗する可能性があります。 クリップボードに LPA コードがありません 解析できません QR コードまたはクリップボードの内容を LPA コードとして解析できませんでした。 @@ -83,6 +87,27 @@ 最終の APDU 例外: 保存 「%s」での診断 + この eSIM プロファイルはすでに eSIM チップに存在します。 + eSIM チップにはプロファイルをダウンロードするのに必要なメモリが残っていません。 + この eSIM プロファイルは、eSIM チップではサポートされていません。 + eSIM チップでエラーが発生しました。 + 使用しているデバイスまたは eSIM チップの EID は、通信事業者によってサポートされていません。 + この eSIM プロファイルは、別のデバイスにダウンロードされています。 + この eSIM プロファイルは取り消されました。 + アクティベーションコードが無効です。 + eSIM プロファイルのダウンロード試行回数の上限を超えました。 + このプロファイルをダウンロードするには確認コードが必要です。 + 入力した確認コードは無効です。 + この eSIM プロファイルは有効期限が切れています。 + 確認コードのダウンロード試行回数の上限を超えました。 + 不明な SM-DP+ アドレス + ネットワークにアクセスできません + TLS 証明書エラー、この eSIM プロファイルはサポートされていません + ダウンロード済みの eSIM プロファイルを再インストールしようとしています + 未使用の eSIM プロファイルをいくつか削除して、再度お試しください + サポートについては、通信事業者にお問い合わせください。 + この eSIM プロファイルを再発行するには、通信事業者にお問い合わせください。 + 別のネットワークに接続後 (例: Wi-Fi とデータを切り替え) を行った後に再度お試しください。 ログは共有したパスに保存されました。別のアプリで共有しますか? 新しいニックネーム ニックネームを UTF-8 にエンコードできませんでした @@ -96,8 +121,8 @@ eSIM プロファイルはダウンロードや削除、有効化や無効化されたときに通信事業者に通知を送信できます。送信されるこれらの通知のキューはここにリストされます。\n\n設定では、各タイプの通知を自動的に送信するかどうかを指定できます。通知が送信された場合でもキューのスペースが不足していない限り、記録から自動的に削除されることはありません。\n\nここでは保留中の各通知を手動で送信または削除できます。 ダウンロードしました 削除しました - 有効化しました - 無効化しました + 有効化済み + 無効化済み 処理 削除 eUICC 情報 @@ -110,20 +135,33 @@ 製品ファームウェアバージョン SGP.22 バージョン eUICC OS バージョン - グローバルプラットフォームのバージョン + グローバルプラットフォームのバージョン SAS 認定番号 保護されたプロファイルのバージョン NVRAM の空き容量 (eSIM プロファイルストレージ) + (参照用) 証明書発行者 (CI) GSMA ライブ CI GSMA テスト CI 不明な eSIM CI - はい - いいえ + eUICC を消去 + eUICC を消去 + このチップ上のすべてのプロファイルを削除することを確認してください。この操作は元に戻せないことを理解してください。\n\nEID: %1$s\n\n%2$s + 確認のために「%s」を入力してください + EID が %s で終わるチップを消去することを確認し、この操作は元に戻せないことを理解してください + 消去 + + はい + いいえ + 不明 + 情報がありません 保存 %s のログ 開発者になるまであと %d ステップです。 あなたは開発者になりました! + ISD-R AID リスト + カスタム ISD-R AID リストを保存しました。 + リセット 設定 通知 eSIM のプロファイル操作により、通信事業者に通知が送信されます。必要に応じてこの動作を微調整できます。 @@ -135,7 +173,7 @@ プロファイルを切り替え中の通知を送信します\nこのタイプの通知は信頼できないことに注意してください。 高度な設定 プロファイルの無効化と削除を許可 - デフォルトでは、このアプリでデバイスに挿入された取り外し可能な eSIM の有効なプロファイルを無効化することを防いでいます。なぜなのかというと時々アクセスができなくなるからです。\nこのチェックボックスを ON にすることで、この保護機能を解除します。 + デフォルトでは、このアプリでデバイスに挿入されたリムーバブル eSIM の有効なプロファイルを無効化することを防いでいます。なぜなのかというと時々アクセスができなくなるからです。\nこのチェックボックスを ON にすることで、この保護機能を解除します。 詳細ログ 詳細ログを有効化します。これには個人的な情報が含まれている可能性があります。この機能を ON にした後は、信頼できるユーザーとのみログを共有してください。 言語 @@ -143,23 +181,22 @@ ログ アプリの最新デバッグログを表示します 開発者オプション + モデムに更新コマンドを送信 + プロファイルを切り替えた後にモデムに更新コマンドを送信するかどうかを設定します。クラッシュが発生する場合は、この機能を無効化してください。 フィルタリングされていないプロファイル一覧を表示 非運用のプロファイルも含めます SM-DP+ TLS 証明書を無視する RSP サーバーで使用される TLS 証明書を受け入れます + eUICC の消去を許可 + これは危険な操作であり、デフォルトでは非表示になっています。代わりとしてすべてのプロファイルを手動で削除することもできます。 + グローバル ES10x MSS + + 高速 + 互換モード + + ISD-R AID リストをカスタマイズ + 一部ブランドのリムーバブル eUICC は独自の非標準な ISD-R AID を使用しているため、サードパーティー製アプリからアクセスできない場合があります。このリストに追加された非標準な AID の使用を試みますが、動作の保証はできません。 情報 アプリバージョン ソースコード - 確認文字列が一致しません - このチップは消去されました - 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 8c360912..7303d2d5 100644 --- a/app-common/src/main/res/values-zh-rCN/strings.xml +++ b/app-common/src/main/res/values-zh-rCN/strings.xml @@ -2,20 +2,21 @@ 在此设备上未检测到此应用程序可访问的可插拔 eUICC 卡。请插入兼容卡或 USB 读卡器。 此 eSIM 上还没有配置文件 - 未知 - 帮助 - 重新加载卡槽 + 未知 + 帮助 + 重新加载卡槽 + 未知 逻辑卡槽 %d - 已启用 - 已禁用 - 提供商: + 已启用 + 已禁用 + 提供商: 类型: - 启用 - 禁用 - 删除 - 重命名 - 等待 eSIM 芯片切换配置文件时超时。这可能是您手机基带固件中的一个错误。请尝试切换飞行模式、重新启动应用程序或重新启动手机 - 操作成功, 但是您手机的基带拒绝刷新。您可能需要切换飞行模式或重新启动,以便使用新的配置文件。 + 启用 + 禁用 + 删除 + 重命名 + 等待 eSIM 芯片切换配置文件时超时。这可能是您手机基带固件中的一个错误。请尝试切换飞行模式、重新启动应用程序或重新启动手机 + 操作成功, 但是您手机的基带拒绝刷新。您可能需要切换飞行模式或重新启动,以便使用新的配置文件。 无法切换到新的 eSIM 配置文件。 输入的确认文本不匹配 已复制 ICCID 到剪贴板 @@ -46,6 +47,7 @@ IMEI (可选) 剩余空间不足 当前芯片的剩余空间不足,可能导致配置下载失败。\n是否继续下载? + 请连接到其他网络(例如在 Wi-Fi 和数据之间切换)后重试。 日志已保存到指定路径。需要通过其他 App 分享吗? 新昵称 无法将昵称编码为 UTF-8 @@ -65,6 +67,7 @@ 删除 保存日志 %s 的日志 + 自定义 ISD-R AID 列表已保存 设置 通知 操作 eSIM 配置文件会向运营商发送通知。根据需要在此处微调此行为。 @@ -81,6 +84,12 @@ 详细日志中包含敏感信息,开启此功能后请仅与你信任的人共享你的日志。 日志 查看应用程序的最新调试日志 + 某些品牌的可移除 eUICC 可能会使用自己的非标准 ISD-R AID,导致第三方应用无法访问。此 App 可以尝试使用此列表中添加的非标准 AID,但不能保证它们一定有效。 + 全局 ES10x MSS + + 最佳效率 + 最佳兼容性 + 信息 App 版本 源码 @@ -130,36 +139,62 @@ 可插拔 SGP.22 版本 eUICC OS 版本 - GlobalPlatform 版本 + GlobalPlatform 版本 SAS 认证号码 Protected Profile 版本 NVRAM 剩余空间 (eSIM 存储容量) + (仅供参考) 证书签发者 (CI) GSMA 生产环境 CI GSMA 测试 CI 未知 eSIM CI - - + + 还有 %d 步成为开发者 你现在是开发者了! 语言 选择 App 语言 开发者选项 + 切换配置文件后是否向基带发送刷新命令。如果发现崩溃,请尝试禁用此功能。 显示未经过滤的配置文件列表 在配置文件列表中包括非生产环境的配置文件 无视 SM-DP+ 的 TLS 证书 允许 RSP 服务器使用任意证书 - 无信息 + 无信息 输入的确认文本不匹配 此芯片已被擦除 正在擦除 eSIM 芯片 eSIM 芯片擦除失败 擦除 eSIM 芯片 擦除 eSIM 芯片 - 请确认删除此芯片上的所有配置文件,并了解此操作不可逆。\n\nEID: %s\n\n%s + 请确认删除此芯片上的所有配置文件,并了解此操作不可逆。\n\nEID: %1$s\n\n%2$s 请在此处输入「%s」以确认 我确认擦除 EID 以 %s 结尾的芯片,并了解此操作不可逆 擦除 允许擦除 eUICC 此操作是默认隐藏的危险操作。作为替代方案,您可以手动删除所有配置文件。 + 向基带发送刷新命令 + 自定义 ISD-R AID 列表 + 重置 + ISD-R AID 列表 + 此 eSIM 配置文件已存在于您的 eSIM 芯片上。 + 您的 eSIM 芯片没有足够的空间来下载配置文件。 + 您的 eSIM 芯片不支持此 eSIM 配置文件。 + eSIM 芯片错误。 + 您的设备或 eSIM 芯片的 EID 不受您的运营商支持。 + 此 eSIM 配置文件已被下载到另一台设备上。 + 此 eSIM 配置文件已被撤销。 + 激活码无效。 + 已超出 eSIM 配置文件的最大下载尝试次数。 + 下载此配置文件需要确认码。 + 您输入的确认码无效。 + 此 eSIM 配置文件已过期。 + 已超出确认码的最大下载尝试次数。 + 未知的 SM-DP+ 地址 + 网络不可达 + TLS 证书错误,不支持此 eSIM 配置文件 + 您正在尝试重新安装已下载的 eSIM 配置文件 + 请删除一些未使用的 eSIM 配置文件,然后重试 + 请联系您的运营商寻求帮助。 + 请联系您的运营商重新签发此 eSIM 配置文件。 \ 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 3d8270dd..ef6c842b 100644 --- a/app-common/src/main/res/values-zh-rTW/strings.xml +++ b/app-common/src/main/res/values-zh-rTW/strings.xml @@ -2,20 +2,21 @@ 在此裝置上未檢測到此應用程式可訪問的可插拔 eUICC 卡。請插入相容卡或 USB 晶片讀卡機。 此 eSIM 上還沒有設定檔 - 未知 - 幫助 - 重新載入卡槽 + 未知 + 幫助 + 重新載入卡槽 + 未知 虛擬卡槽 %d - 已啟用 - 已停用 - 電信業者: + 已啟用 + 已停用 + 電信業者: 類型: - 啟用 - 停用 - 刪除 - 重新命名 - 等待 eSIM 切換設定檔時逾時。這可能是您手機基頻處理器韌體中的一個錯誤。請嘗試切換飛航模式、重新啟動應用程式或重新啟動手機 - 操作成功, 但是您手機的基頻處理器沒有重新整理。您可能需要切換飛航模式或重新啟動,以便使用新的設定檔。 + 啟用 + 停用 + 刪除 + 重新命名 + 等待 eSIM 切換設定檔時逾時。這可能是您手機基頻處理器韌體中的一個錯誤。請嘗試切換飛航模式、重新啟動應用程式或重新啟動手機 + 操作成功, 但是您手機的基頻處理器沒有重新整理。您可能需要切換飛航模式或重新啟動,以便使用新的設定檔。 無法切換到新的 eSIM 設定檔。 輸入的確認文字不匹配 已複製 ICCID 到剪貼簿 @@ -46,6 +47,7 @@ IMEI (可選) 剩餘空間不足 目前晶片的剩餘空間不足,可能導致配置下載失敗。\n是否繼續下載? + 請連接到其他網路(例如在 Wi-Fi 和資料之間切換)後重試。 日誌已儲存到指定路徑。需要透過其他 App 分享嗎? 新名稱 無法將名稱編碼為 UTF-8 @@ -65,6 +67,7 @@ 刪除 儲存日誌 %s 的日誌 + 自訂 ISD-R AID 列表已儲存 設定 通知 變更 eSIM 設定檔會向電信業者傳送通知。根據需要在此處微調此行為。 @@ -81,6 +84,8 @@ 進階 允許 停用/刪除 已啟用的設定檔 預設情況下,此應用程式會阻止您停用可插拔 eSIM 中已啟用的設定檔。\n因為這樣做 有時 會導致無法存取。\n勾選此框以 移除 此保護措施。 + 某些品牌的可移除 eUICC 可能會使用自己的非標準 ISD-R AID,導致第三方應用程式無法存取。此 App 可以嘗試使用此清單中新增的非標準 AID,但不能保證它們一定有效。 + 全局 ES10x MSS 資訊 App 版本 原始碼 @@ -130,36 +135,62 @@ 可插拔 SGP.22 版本 eUICC OS 版本 - GlobalPlatform 版本 + GlobalPlatform 版本 SAS 認證號碼 Protected Profile 版本 NVRAM 剩餘空間 (eSIM 儲存容量) + (僅供參考) 證書簽發者 (CI) GSMA 生產環境 CI GSMA 測試 CI 未知 eSIM CI - - + + 還有 %d 步成為開發者 您現在是開發者了! 語言 選擇 App 語言 開發人員選項 + 切換設定檔後是否向基帶發送刷新命令。如果發現崩潰,請嘗試停用此功能。 顯示未經過濾的設定檔列表 在設定檔列表中包括非生產環境的設定檔 忽略 SM-DP+ 的 TLS 證書 允許 RSP 伺服器使用任意證書 - 無資訊 + 無資訊 輸入的確認文字不匹配 此晶片已被擦除 正在擦除 eSIM 晶片 eSIM 晶片擦除失敗 擦除 eSIM 晶片 擦除 eSIM 晶片 - 請確認刪除此晶片上的所有配置文件,並了解此操作不可逆。\n\nEID: %s\n\n%s + 請確認刪除此晶片上的所有配置文件,並了解此操作不可逆。\n\nEID: %1$s\n\n%2$s 請在此輸入「%s」以確認 我確認擦除 EID 以 %s 結尾的晶片,並了解此操作不可逆 擦除 允許擦除 eUICC 此操作是預設隱藏的危險操作。作為替代方案,您可以手動刪除所有設定檔。 + 向基帶發送刷新命令 + 自訂 ISD-R AID 列表 + 重置 + ISD-R AID 列表 + 此 eSIM 設定檔已存在於您的 eSIM 晶片上。 + 您的 eSIM 晶片沒有足夠的空間來下載設定檔。 + 您的 eSIM 晶片不支援此 eSIM 設定檔。 + eSIM 晶片錯誤。 + 您的裝置或 eSIM 晶片的 EID 不受您的電信業者支援。 + 此 eSIM 設定檔已被下載到另一台裝置上。 + 此 eSIM 設定檔已被撤銷。 + 啟用碼無效。 + 已超出 eSIM 設定檔的最大下載嘗試次數。 + 下載此設定檔需要確認碼。 + 您輸入的確認碼無效。 + 此 eSIM 設定檔已過期。 + 已超出確認碼的最大下載嘗試次數。 + 未知的 SM-DP+ 位址 + 網路不可達 + TLS 憑證錯誤,不支援此 eSIM 設定檔 + 您正在嘗試重新安裝已下載的 eSIM 設定文件 + 請刪除一些未使用的 eSIM 設定文件,然後重試 + 請聯絡您的電信業者尋求協助。 + 請聯絡您的電信業者重新簽發此 eSIM 設定檔。 \ 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 cc84381d..39a762fe 100644 --- a/app-common/src/main/res/values/strings.xml +++ b/app-common/src/main/res/values/strings.xml @@ -2,31 +2,34 @@ No removable eUICC card accessible by this app is detected on this device. Insert a compatible card or a USB reader. No profiles (yet) on this eSIM. - Unknown - Information Unavailable - Help - Reload Slots + + Help + + Reload Slots + Unknown Logical Slot %d - USB - OpenMobile API (OMAPI) + USB + OpenMobile API (OMAPI) - Enabled - Disabled - Provider: + + Enabled + Disabled + Provider: Class: Testing Provisioning Operational - ICCID: + ICCID: + #%d - Enable - Disable - Delete - Rename + Enable + Disable + Delete + Rename - Timed out waiting for the eSIM chip to switch profiles. This may be a bug in your phone\'s modem firmware. Try toggling airplane mode, restarting the application, or rebooting the phone. - The operation was successful, but your phone\'s modem refused to refresh. You might need to toggle airplane mode or reboot in order to use the new profile. + Timed out waiting for the eSIM chip to switch profiles. This may be a bug in your phone\'s modem firmware. Try toggling airplane mode, restarting the application, or rebooting the phone. + The operation was successful, but your phone\'s modem refused to refresh. You might need to toggle airplane mode or reboot in order to use the new profile. Cannot switch to new eSIM profile. Confirmation string mismatch @@ -101,6 +104,27 @@ Last APDU exception: Save Diagnostics at %s + This eSIM profile is already present on your eSIM chip. + Your eSIM chip does not have sufficient memory left to download the profile. + This eSIM profile is unsupported by your eSIM chip. + An error occurred in your eSIM chip. + The EID of your device or eSIM chip is unsupported by your carrier. + This eSIM profile has been downloaded on another device. + This eSIM profile has been revoked. + The activation code is invalid. + The maximum number of download attempts for the eSIM profile has been exceeded. + Confirmation code is required to download this profile. + The confirmation code you entered is invalid. + This eSIM profile has expired. + The maximum number of download attempts for the confirmation code has been exceeded. + Unknown SM-DP+ address + Network is unreachable + TLS certificate error, this eSIM profile is not supported + You are trying to reinstall an already downloaded eSIM profile + Please delete some unused eSIM profiles and try again + Please contact your carrier for assistance. + Please contact your carrier to reissue this eSIM profile. + Please try again after connecting to a different network (e.g. switching between Wi-Fi and data). Logs have been saved to the selected path. Would you like to share the log through another app? @@ -134,12 +158,14 @@ Product Bootloader Version Product Firmware Version EID + ISD-R AID SGP.22 Version eUICC OS Version - GlobalPlatform Version + GlobalPlatform Version SAS Accreditation Number Protected Profile Version Free NVRAM (eSIM profile storage) + (for reference only) Certificate Issuer (CI) GSMA Live CI GSMA Test CI @@ -148,13 +174,16 @@ 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 - Yes - No + + Yes + No + Unknown + Information Unavailable Save Logs at %s @@ -162,6 +191,10 @@ You are %d steps away from being a developer. You are now a developer! + ISD-R AID List + Saved custom ISD-R AID list. + Reset + Settings Notifications eSIM profile operations send notifications to the carrier. Fine-tune this behavior as needed here. @@ -181,12 +214,26 @@ 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 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. + ES10x MSS + Global ES10x MSS + + High Efficiency + Most Compatible + + + 255 + 63 + + 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 7d25118d..831b04db 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"> + + + + + + + android:label="@string/quick_compatibility" /> by lazy { getCompatibilityChecks(this) } - private val adapter = CompatibilityChecksAdapter() - - override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_compatibility_check) - setSupportActionBar(requireViewById(im.angry.openeuicc.common.R.id.toolbar)) - setupToolbarInsets() - supportActionBar!!.setDisplayHomeAsUpEnabled(true) - - compatibilityCheckList = requireViewById(R.id.recycler_view).also { - it.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) - it.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL)) - it.adapter = adapter - } - - setupRootViewInsets(compatibilityCheckList) - } - - @SuppressLint("NotifyDataSetChanged") - override fun onStart() { - super.onStart() - lifecycleScope.launch { - compatibilityChecks.executeAll { adapter.notifyDataSetChanged() } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - android.R.id.home -> { - finish() - true - } - else -> super.onOptionsItemSelected(item) - } - - inner class ViewHolder(private val root: View): RecyclerView.ViewHolder(root) { - private val titleView: TextView = root.requireViewById(R.id.compatibility_check_title) - private val descView: TextView = root.requireViewById(R.id.compatibility_check_desc) - private val statusContainer: ViewGroup = root.requireViewById(R.id.compatibility_check_status_container) - - fun bindItem(item: CompatibilityCheck) { - titleView.text = item.title - descView.text = Html.fromHtml(item.description, Html.FROM_HTML_MODE_COMPACT) - - statusContainer.children.forEach { - it.isVisible = false - } - - val viewId = when (item.state) { - CompatibilityCheck.State.SUCCESS -> R.id.compatibility_check_checkmark - CompatibilityCheck.State.FAILURE -> R.id.compatibility_check_error - CompatibilityCheck.State.FAILURE_UNKNOWN -> R.id.compatibility_check_unknown - else -> R.id.compatibility_check_progress_bar - } - root.requireViewById(viewId).isVisible = true - } - } - - inner class CompatibilityChecksAdapter: RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = - ViewHolder(layoutInflater.inflate(R.layout.compatibility_check_item, parent, false)) - - override fun getItemCount(): Int = - compatibilityChecks.indexOfLast { it.state != CompatibilityCheck.State.NOT_STARTED } + 1 - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bindItem(compatibilityChecks[position]) - } - } -} \ No newline at end of file diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/ui/QuickCompatibilityActivity.kt b/app-unpriv/src/main/java/im/angry/openeuicc/ui/QuickCompatibilityActivity.kt new file mode 100644 index 00000000..d5e599fd --- /dev/null +++ b/app-unpriv/src/main/java/im/angry/openeuicc/ui/QuickCompatibilityActivity.kt @@ -0,0 +1,24 @@ +package im.angry.openeuicc.ui + +import android.os.Bundle +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import im.angry.easyeuicc.R +import im.angry.openeuicc.di.UnprivilegedUiComponentFactory +import im.angry.openeuicc.util.OpenEuiccContextMarker + +class QuickCompatibilityActivity : AppCompatActivity(), OpenEuiccContextMarker { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContentView(R.layout.activity_quick_compatibility) + + val quickCompatibilityFragment = + (appContainer.uiComponentFactory as UnprivilegedUiComponentFactory) + .createQuickCompatibilityFragment() + + supportFragmentManager.beginTransaction() + .replace(R.id.quick_compatibility_container, quickCompatibilityFragment) + .commit() + } +} diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/ui/QuickCompatibilityFragment.kt b/app-unpriv/src/main/java/im/angry/openeuicc/ui/QuickCompatibilityFragment.kt new file mode 100644 index 00000000..9b417306 --- /dev/null +++ b/app-unpriv/src/main/java/im/angry/openeuicc/ui/QuickCompatibilityFragment.kt @@ -0,0 +1,186 @@ +package im.angry.openeuicc.ui + +import android.content.pm.PackageManager +import android.icu.text.ListFormatter +import android.os.Build +import android.os.Bundle +import android.se.omapi.Reader +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.CheckBox +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import im.angry.easyeuicc.R +import im.angry.openeuicc.util.EUICC_DEFAULT_ISDR_AID +import im.angry.openeuicc.util.UnprivilegedEuiccContextMarker +import im.angry.openeuicc.util.connectSEService +import im.angry.openeuicc.util.decodeHex +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +open class QuickCompatibilityFragment : Fragment(), UnprivilegedEuiccContextMarker { + companion object { + enum class Compatibility { + COMPATIBLE, + NOT_COMPATIBLE, + } + + data class CompatibilityResult( + val compatibility: Compatibility, + val slotsOmapi: List = emptyList(), + val slotsIsdr: List = emptyList() + ) + } + + private val conclusion: TextView by lazy { + requireView().requireViewById(R.id.quick_compatibility_conclusion) + } + + private val resultSlots: TextView by lazy { + requireView().requireViewById(R.id.quick_compatibility_result_slots) + } + + private val resultSlotsIsdr: TextView by lazy { + requireView().requireViewById(R.id.quick_compatibility_result_slots_isdr) + } + + private val resultNotes: TextView by lazy { + requireView().requireViewById(R.id.quick_compatibility_result_notes) + } + + private val skipCheckBox: CheckBox by lazy { + requireView().requireViewById(R.id.quick_compatibility_skip) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = inflater.inflate(R.layout.fragment_quick_compatibility, container, false).apply { + requireViewById(R.id.quick_compatibility_device_information) + .text = formatDeviceInformation() + requireViewById