diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..6ea37a18 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +indent_size = 2 +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 120 + +[*.{kt,java}] +indent_size = 4 + +[*.gradle.kts] +indent_size = 4 + +[*.xml] +indent_size = 4 + +[.idea/**/*.xml] +indent_size = 2 diff --git a/.forgejo/workflows/build-debug.yml b/.forgejo/workflows/build-debug.yml index 51e802ca..9297f6fc 100644 --- a/.forgejo/workflows/build-debug.yml +++ b/.forgejo/workflows/build-debug.yml @@ -1,11 +1,15 @@ +name: Build Debug APKs + on: push: branches: - - 'master' + - '*' + tags: + - '*' jobs: build-debug: - runs-on: [docker, android-app-certs] + runs-on: [ docker, android-app-certs ] container: volumes: - android-app-keystore:/keystore @@ -33,14 +37,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/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index c8a72837..e4cc9e59 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -1,10 +1,12 @@ +name: Build Release + on: push: tags: '*' jobs: release: - runs-on: [docker, android-app-certs] + runs-on: [ docker, android-app-certs ] container: volumes: - android-app-keystore:/keystore diff --git a/.gitmodules b/.gitmodules index f888959b..5af1da8a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [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 +[submodule "libs/lpac-jni/src/main/jni/cJSON"] + path = libs/lpac-jni/src/main/jni/cjson/cjson + url = https://github.com/DaveGamble/cJSON 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 7643783a..8211a55c 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,6 +1,45 @@ + + + + diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml index 6e6eec11..79ee123c 100644 --- a/.idea/codeStyles/codeStyleConfig.xml +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -1,6 +1,5 @@ \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 1c5ab058..39844ecf 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -3,4 +3,4 @@ - \ No newline at end of file + diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml deleted file mode 100644 index e40be604..00000000 --- a/.idea/deploymentTargetSelector.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 148fdd24..ae4d11a8 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -3,4 +3,4 @@ - \ No newline at end of file + diff --git a/.idea/migrations.xml b/.idea/migrations.xml index f8051a6f..2850f35d 100644 --- a/.idea/migrations.xml +++ b/.idea/migrations.xml @@ -7,4 +7,4 @@ - \ No newline at end of file + diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 72eda03f..f51eca33 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -4,4 +4,4 @@ - \ No newline at end of file + diff --git a/README.md b/README.md index 28a616a8..0119a020 100644 --- a/README.md +++ b/README.md @@ -2,29 +2,53 @@ A fully free and open-source Local Profile Assistant implementation for Android devices. -There are two variants of this project, OpenEUICC and EasyEUICC: +There are two variants of this project, OpenEUICC and [EasyEUICC](https://easyeuicc.org): -| | OpenEUICC | EasyEUICC | -|:------------------------------|:-----------------------------------------------:|:-----------------:| -| Privileged | Must be installed as system app | No | -| Internal eSIM | Supported | Unsupported | -| External (Removable) eSIM | Supported | Supported | -| USB Readers | Yes | Yes | -| Requires allowlisting by eSIM | No | Yes -- except USB | -| System Integration | Partial (carrier partner API unimplemented yet) | No | +| | 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 | + +[^1]: Also known as "Removable eSIM" +[^2]: Carrier Partner API unimplemented yet Some side notes: -1. When privileged, OpenEUICC supports any eUICC chip that implements the SGP.22 standard, internal or external. However, there is __no guarantee__ that external (removable) eSIMs actually follow the standard. Please __DO NOT__ submit bug reports for non-functioning removable eSIMs. They are __NOT__ officially supported unless they also support / are supported by EasyEUICC, the unprivileged variant. -2. Both variants support accessing eUICC chips through USB CCID readers, regardless of whether the chip contains the correct ARA-M hash to allow for unprivileged access. However, only `T=0` readers that use the standard [USB CCID protocol](https://en.wikipedia.org/wiki/CCID_(protocol)) are supported. -3. Prebuilt release-mode EasyEUICC apks can be downloaded [here](https://gitea.angry.im/PeterCxy/OpenEUICC/releases). For OpenEUICC, no official release is currently provided and only debug mode APKs can be found in the CI page. -4. For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted, include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`. -__This project is Free Software licensed under GNU GPL v3, WITHOUT the "or later" clause.__ Any modification and derivative work __MUST__ be released under the SAME license, which means, at the very least, that the source code __MUST__ be available upon request. +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`. -__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.__ +[sgp.22]: https://www.gsma.com/solutions-and-impact/technologies/esim/gsma_resources/sgp-22-v2-2-2/ "SGP.22 v2.2.2" -Building (Gradle) -=== +[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 @@ -57,41 +81,50 @@ For EasyEUICC: ./gradlew :app-unpriv:assembleRelease ``` -Building (AOSP) -=== +# Building (AOSP) There are two ways to include OpenEUICC in your AOSP-based system image: -1. Include this project and its [dependencies](https://gitea.angry.im/PeterCxy/android_prebuilts_openeuicc-deps) inside the AOSP tree. - - If inclusion in `manifest.xml` is required, remember to set the `sync-s` option to clone submodules. - - The module name is `OpenEUICC`. You can include it in `PRODUCT_PACKAGES`, or simply build it standalone using `mm`. - - 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. +1. Include this project and its [dependencies](https://gitea.angry.im/PeterCxy/android_prebuilts_openeuicc-deps) inside + the AOSP tree. -FAQs -=== +- If inclusion in `manifest.xml` is required, remember to set the `sync-s` option to clone submodules. +- The module name is `OpenEUICC`. You can include it in `PRODUCT_PACKAGES`, or simply build it standalone using `mm`. +- 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. -- 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. +2. If compilation against AOSP source tree is not possible, consider [building with gradle](#building-gradle) and import + the apk as a prebuilt. -- 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. +- 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. -- 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. +[`privapp_whitelist_im.angry.openeuicc.xml`]: privapp_whitelist_im.angry.openeuicc.xml "OpenEUICC Privapp Whitelist" -- 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. +# FAQs -Copyright -=== +- 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: 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 Everything except `libs/lpac-jni` and `art/`: ``` -Copyright 2022-2024 OpenEUICC contributors +Copyright 2022-2026 OpenEUICC contributors This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License @@ -110,7 +143,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. `libs/lpac-jni`: ``` -Copyright (C) 2022-2024 OpenEUICC contributiors +Copyright (C) 2022-2026 OpenEUICC contributiors This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public @@ -126,4 +159,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/androidTest/java/im/angry/openeuicc/common/ExampleInstrumentedTest.kt b/app-common/src/androidTest/java/im/angry/openeuicc/common/ExampleInstrumentedTest.kt index b027bbee..ec104917 100644 --- a/app-common/src/androidTest/java/im/angry/openeuicc/common/ExampleInstrumentedTest.kt +++ b/app-common/src/androidTest/java/im/angry/openeuicc/common/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package im.angry.openeuicc.common -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -21,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("im.angry.openeuicc.common.test", appContext.packageName) } -} \ No newline at end of file +} diff --git a/app-common/src/main/AndroidManifest.xml b/app-common/src/main/AndroidManifest.xml index b0324dce..374dfaa0 100644 --- a/app-common/src/main/AndroidManifest.xml +++ b/app-common/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ - @@ -33,8 +33,8 @@ android:label="@string/isdr_aid_list" /> @@ -45,14 +45,17 @@ - - + + + + android:exported="false" + android:foregroundServiceType="shortService" /> 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 0de99b56..f650a8b5 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 @@ -7,7 +7,6 @@ import im.angry.openeuicc.common.R import im.angry.openeuicc.core.usb.UsbApduInterface 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 @@ -20,8 +19,9 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha override suspend fun tryOpenEuiccChannel( port: UiccPortInfoCompat, - isdrAid: ByteArray - ): EuiccChannel? { + isdrAid: ByteArray, + seId: EuiccChannel.SecureElementId, + ): EuiccChannel? = try { if (port.portIndex != 0) { Log.w( DefaultEuiccChannelManager.TAG, @@ -35,62 +35,57 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha DefaultEuiccChannelManager.TAG, "Trying OMAPI for physical slot ${port.card.physicalSlotIndex}" ) - try { - return EuiccChannelImpl( - context.getString(R.string.omapi), + EuiccChannelImpl( + context.getString(R.string.channel_type_omapi), + port, + OmapiApduInterface( + seService!!, port, - intrinsicChannelName = null, - OmapiApduInterface( - seService!!, - port, - context.preferenceRepository.verboseLoggingFlow - ), - isdrAid, - context.preferenceRepository.verboseLoggingFlow, - context.preferenceRepository.ignoreTLSCertificateFlow, - ).also { - Log.i(DefaultEuiccChannelManager.TAG, "Is OMAPI channel, setting MSS to 60") - it.lpa.setEs10xMss(60) - } - } catch (_: IllegalArgumentException) { - // Failed - Log.w( - DefaultEuiccChannelManager.TAG, - "OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex} with ISD-R AID: ${isdrAid.encodeHex()}." - ) - } - - return null + context.preferenceRepository.verboseLoggingFlow + ), + isdrAid, + seId, + 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 { - return EuiccChannelImpl( - context.getString(R.string.usb), - FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)), - intrinsicChannelName = ccidCtx.productName, - UsbApduInterface( - ccidCtx - ), - isdrAid, - context.preferenceRepository.verboseLoggingFlow, - context.preferenceRepository.ignoreTLSCertificateFlow, - ) - } catch (_: IllegalArgumentException) { - // Failed - Log.w( - DefaultEuiccChannelManager.TAG, - "USB APDU interface unavailable for ISD-R AID: ${isdrAid.encodeHex()}." - ) - } - return null + isdrAid: ByteArray, + seId: EuiccChannel.SecureElementId + ): EuiccChannel? = try { + EuiccChannelImpl( + context.getString(R.string.channel_type_usb), + FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)), + UsbApduInterface( + ccidCtx + ), + isdrAid, + seId, + 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() { seService?.shutdown() seService = null } -} \ No newline at end of file +} 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 6b336cda..21dd9e4a 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 @@ -6,8 +6,8 @@ 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.core.usb.smartCard import im.angry.openeuicc.di.AppContainer import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers @@ -32,7 +32,7 @@ open class DefaultEuiccChannelManager( private val channelCache = mutableListOf() - private var usbChannel: EuiccChannel? = null + private var usbChannels = mutableListOf() private val lock = Mutex() @@ -51,43 +51,94 @@ open class DefaultEuiccChannelManager( protected open val uiccCards: Collection get() = (0.. EuiccChannel?): EuiccChannel? { - val isdrAidList = + private suspend inline fun tryOpenChannelWithKnownAids( + openFn: (ByteArray, EuiccChannel.SecureElementId) -> EuiccChannel? + ): List { + var isdrAidList = parseIsdrAidList(appContainer.preferenceRepository.isdrAidListFlow.first()) + val ret = mutableListOf() + val openedAids = mutableListOf() + var hasReset = false + var vendorDecider: VendorAidDecider? = null + var seId = 0 - return isdrAidList.firstNotNullOfOrNull { - Log.i(TAG, "Opening channel, trying ISDR AID ${it.encodeHex()}") + outer@ while (true) { + for (aid in isdrAidList) { + if (vendorDecider != null && !vendorDecider.shouldOpenMore(openedAids, aid)) { + break@outer + } - openFn(it)?.let { channel -> - if (channel.valid) { - channel - } else { - channel.close() - null + val channel = + openFn(aid, EuiccChannel.SecureElementId.createFromInt(seId))?.let { channel -> + if (channel.valid) { + seId += 1 + channel + } else { + channel.close() + null + } + } + + if (!hasReset) { + val res = channel?.queryVendorAidListTransformation(isdrAidList) + if (res != null) { + // Reset the for loop since we needed to replace the AID list due to vendor-specific code + Log.i(TAG, "AID list replaced, resetting open attempt") + isdrAidList = res.first + vendorDecider = res.second + seId = 0 + ret.clear() + openedAids.clear() + channel.close() + hasReset = true // Don't let anything reset again + continue@outer + } + } + + if (channel != null) { + ret.add(channel) + openedAids.add(aid) + + // Don't try opening more than 1 channel unless there is a vendor + // implementation for deciding when we should stop opening more channels + if (vendorDecider == null) { + break@outer + } } } + + // If we get here we should exit, since the inner loop completed without resetting + break } + + // Set the hasMultipleSE field now since we only get to know that after we have iterated all AIDs + // This also flips a flag in EuiccChannelImpl and prevents the field from being set again + ret.forEach { it.hasMultipleSE = (seId > 1) } + + return ret } - private suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? { + private suspend fun tryOpenEuiccChannel( + port: UiccPortInfoCompat, + ): List? { lock.withLock { if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) { - return if (usbChannel != null && usbChannel!!.valid) { - usbChannel - } else { - usbChannel = null - null - } + return usbChannels } + // First get all channels for the requested port val existing = - channelCache.find { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex } - if (existing != null) { - if (existing.valid && port.logicalSlotIndex == existing.logicalSlotId) { + channelCache.filter { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex } + if (existing.isNotEmpty()) { + if (existing.all { it.valid && it.logicalSlotId == port.logicalSlotIndex }) { return existing } else { - existing.close() - channelCache.remove(existing) + // If any channel shouldn't be considered valid anymore, close all existing for the same slot / port + // and reopen + existing.forEach { + it.close() + channelCache.remove(it) + } } } @@ -96,12 +147,19 @@ open class DefaultEuiccChannelManager( return null } - val channel = - tryOpenChannelFirstValidAid { euiccChannelFactory.tryOpenEuiccChannel(port, it) } + // This function is not responsible for managing USB channels (see the initial check) + val channels = + tryOpenChannelWithKnownAids { isdrAid, seId -> + euiccChannelFactory.tryOpenEuiccChannel( + port, + isdrAid, + seId + ) + } - if (channel != null) { - channelCache.add(channel) - return channel + if (channels.isNotEmpty()) { + channelCache.addAll(channels) + return channels } else { Log.i( TAG, @@ -112,16 +170,19 @@ open class DefaultEuiccChannelManager( } } - protected suspend fun findEuiccChannelByLogicalSlot(logicalSlotId: Int): EuiccChannel? = + protected suspend fun findEuiccChannelByLogicalSlot( + logicalSlotId: Int, + seId: EuiccChannel.SecureElementId + ): EuiccChannel? = withContext(Dispatchers.IO) { if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { - return@withContext usbChannel + return@withContext usbChannels.find { it.seId == seId } } for (card in uiccCards) { for (port in card.ports) { if (port.logicalSlotIndex == logicalSlotId) { - return@withContext tryOpenEuiccChannel(port) + return@withContext tryOpenEuiccChannel(port)?.find { it.seId == seId } } } } @@ -129,23 +190,35 @@ open class DefaultEuiccChannelManager( null } + /** + * Find all EuiccChannels associated with a _physical_ slot, including all secure elements + * on cards with multiple of them. + */ private suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List? { if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { - return usbChannel?.let { listOf(it) } + return usbChannels.ifEmpty { null } } for (card in uiccCards) { if (card.physicalSlotIndex != physicalSlotId) continue return card.ports.mapNotNull { tryOpenEuiccChannel(it) } + .flatten() .ifEmpty { null } } return null } - private suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? = + /** + * Finds all EuiccChannels associated with a physical slot + port. Note that this + * may return multiple in case there are multiple SEs. + */ + private suspend fun findEuiccChannelsByPort( + physicalSlotId: Int, + portId: Int, + ): List? = withContext(Dispatchers.IO) { if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { - return@withContext usbChannel + return@withContext usbChannels.ifEmpty { null } } uiccCards.find { it.physicalSlotIndex == physicalSlotId }?.let { card -> @@ -168,15 +241,17 @@ open class DefaultEuiccChannelManager( return@withContext listOf(0) } - findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.map { it.portId } ?: listOf() + findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.map { it.portId }?.toSet()?.toList() + ?: listOf() } override suspend fun withEuiccChannel( physicalSlotId: Int, portId: Int, + seId: EuiccChannel.SecureElementId, fn: suspend (EuiccChannel) -> R ): R { - val channel = findEuiccChannelByPort(physicalSlotId, portId) + val channel = findEuiccChannelsByPort(physicalSlotId, portId)?.find { it.seId == seId } ?: throw EuiccChannelManager.EuiccChannelNotFoundException() val wrapper = EuiccChannelWrapper(channel) try { @@ -190,9 +265,10 @@ open class DefaultEuiccChannelManager( override suspend fun withEuiccChannel( logicalSlotId: Int, + seId: EuiccChannel.SecureElementId, fn: suspend (EuiccChannel) -> R ): R { - val channel = findEuiccChannelByLogicalSlot(logicalSlotId) + val channel = findEuiccChannelByLogicalSlot(logicalSlotId, seId) ?: throw EuiccChannelManager.EuiccChannelNotFoundException() val wrapper = EuiccChannelWrapper(channel) try { @@ -205,37 +281,48 @@ open class DefaultEuiccChannelManager( } override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) { - if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { - usbChannel?.close() - usbChannel = null + val numChannelsBefore = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { + usbChannels.size } else { - // If there is already a valid channel, we close it proactively - // Sometimes the current channel can linger on for a bit even after it should have become invalid - channelCache.find { it.slotId == physicalSlotId && it.portId == portId }?.apply { - if (valid) close() + // Don't use find* methods since they reopen channels if not found + channelCache.filter { it.slotId == physicalSlotId && it.portId == portId }.size + } + + val resetChannels = { + if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { + usbChannels.forEach { it.close() } + usbChannels.clear() + } else { + // If there is already a valid channel, we close it proactively + channelCache.filter { it.slotId == physicalSlotId && it.portId == portId }.forEach { it.close() } } } + resetChannels() + withTimeout(timeoutMillis) { while (true) { try { - val channel = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { + val channels = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { // tryOpenUsbEuiccChannel() will always try to reopen the channel, even if // a USB channel already exists tryOpenUsbEuiccChannel() - usbChannel!! + usbChannels } else { // tryOpenEuiccChannel() will automatically dispose of invalid channels // and recreate when needed - findEuiccChannelByPort(physicalSlotId, portId)!! + findEuiccChannelsByPort(physicalSlotId, portId)!! } - check(channel.valid) { "Invalid channel" } + check(channels.isNotEmpty()) { "No channel" } + check(channels.all { it.valid }) { "Invalid channel" } + check(numChannelsBefore > 0 && channels.size >= numChannelsBefore) { "Less channels than before" } break } catch (e: Exception) { Log.d( TAG, "Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms" ) + resetChannels() } delay(1000) } @@ -264,6 +351,15 @@ open class DefaultEuiccChannelManager( } }) + override fun flowEuiccSecureElements( + slotId: Int, + portId: Int + ): Flow = flow { + findEuiccChannelsByPort(slotId, portId)?.forEach { + emit(it.seId) + } + } + override suspend fun tryOpenUsbEuiccChannel(): Pair = withContext(Dispatchers.IO) { usbManager.deviceList.values.forEach { device -> @@ -277,15 +373,17 @@ open class DefaultEuiccChannelManager( "Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel" ) - val ccidCtx = UsbCcidContext.createFromUsbDevice(context, device, iface) ?: return@forEach + val ccidCtx = + UsbCcidContext.createFromUsbDevice(context, device, iface) ?: return@forEach try { - val channel = tryOpenChannelFirstValidAid { - euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, it) + val channels = tryOpenChannelWithKnownAids { isdrAid, seId -> + euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, isdrAid, seId) } - if (channel != null && channel.lpa.valid) { + if (channels.isNotEmpty() && channels[0].valid) { ccidCtx.allowDisconnect = true - usbChannel = channel + usbChannels.clear() + usbChannels.addAll(channels) return@withContext Pair(device, true) } } catch (e: Exception) { @@ -309,9 +407,9 @@ open class DefaultEuiccChannelManager( channel.close() } - usbChannel?.close() - usbChannel = null + usbChannels.forEach { it.close() } + usbChannels.clear() channelCache.clear() euiccChannelFactory.cleanup() } -} \ No newline at end of file +} 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 b20932fe..36c68e58 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt @@ -1,5 +1,7 @@ package im.angry.openeuicc.core +import android.os.Parcel +import android.os.Parcelable import im.angry.openeuicc.util.* import net.typeblog.lpac_jni.ApduInterface import net.typeblog.lpac_jni.LocalProfileAssistant @@ -13,6 +15,67 @@ interface EuiccChannel { val logicalSlotId: Int val portId: Int + /** + * A semi-obscure wrapper over the integer ID of a secure element on a card. + * + * Because the ID is arbitrary, this is intended to discourage the use of the + * integer value directly. Additionally, it prevents accidentally calling the + * wrong function in EuiccChannelManager with a ton of integer parameters. + */ + class SecureElementId private constructor(val id: Int) : Parcelable { + companion object { + val DEFAULT = SecureElementId(0) + + /** + * Create a SecureElementId from an integer ID. You should not call this directly + * unless you know what you're doing. + * + * This is currently only ever used in the download flow. + */ + fun createFromInt(id: Int): SecureElementId = + SecureElementId(id) + + @Suppress("unused") + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): SecureElementId = + createFromInt(parcel.readInt()) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } + + override fun hashCode(): Int = + id.hashCode() + + override fun equals(other: Any?): Boolean = + if (other is SecureElementId) { + this.id == other.id + } else { + super.equals(other) + } + + override fun describeContents(): Int = id + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeInt(id) + } + } + + /** + * Some chips support multiple SEs on one chip. The seId here is intended + * to distinguish channels opened from these different SEs. + */ + val seId: SecureElementId + + /** + * Does this channel belong to a chip that supports multiple SEs? + * Note that this is only made `var` to make initialization a bit less annoying -- + * this should never be set again after the channel is originally opened. + * Attempting to do so will yield an exception. + */ + var hasMultipleSE: Boolean + val lpa: LocalProfileAssistant val valid: Boolean @@ -22,13 +85,6 @@ interface EuiccChannel { */ val atr: ByteArray? - /** - * Intrinsic name of this channel. For device-internal SIM slots, - * this should be null; for USB readers, this should be the name of - * the reader device. - */ - val intrinsicChannelName: String? - /** * The underlying APDU interface for this channel */ @@ -40,4 +96,4 @@ interface EuiccChannel { 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 ba587a63..5ec8eabd 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 @@ -6,11 +6,16 @@ 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, isdrAid: ByteArray): EuiccChannel? + suspend fun tryOpenEuiccChannel( + port: UiccPortInfoCompat, + isdrAid: ByteArray, + seId: EuiccChannel.SecureElementId + ): EuiccChannel? fun tryOpenUsbEuiccChannel( ccidCtx: UsbCcidContext, - isdrAid: ByteArray + isdrAid: ByteArray, + seId: EuiccChannel.SecureElementId ): EuiccChannel? /** @@ -19,4 +24,4 @@ interface EuiccChannelFactory { * re-acquired when this happens */ fun cleanup() -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt index 2a33c206..6eb636b5 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 im.angry.openeuicc.util.* 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 @@ -11,11 +12,12 @@ import net.typeblog.lpac_jni.impl.LocalProfileAssistantImpl class EuiccChannelImpl( override val type: String, override val port: UiccPortInfoCompat, - override val intrinsicChannelName: String?, override val apduInterface: ApduInterface, override val isdrAid: ByteArray, + override val seId: EuiccChannel.SecureElementId, verboseLoggingFlow: Flow, - ignoreTLSCertificateFlow: Flow + ignoreTLSCertificateFlow: Flow, + es10xMssFlow: Flow, ) : EuiccChannel { override val slotId = port.card.physicalSlotIndex override val logicalSlotId = port.logicalSlotIndex @@ -25,8 +27,10 @@ class EuiccChannelImpl( LocalProfileAssistantImpl( isdrAid, apduInterface, - HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow) - ) + HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow), + ).also { + it.setEs10xMss(runBlocking { es10xMssFlow.first().toByte() }) + } override val atr: ByteArray? get() = (apduInterface as? ApduInterfaceAtrProvider)?.atr @@ -34,5 +38,16 @@ class EuiccChannelImpl( override val valid: Boolean get() = lpa.valid + private var hasMultipleSEInitialized = false + + override var hasMultipleSE: Boolean = false + set(value) { + if (hasMultipleSEInitialized) { + throw IllegalStateException("already initialized") + } + + field = value + } + override fun close() = lpa.close() } diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelManager.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelManager.kt index 17f3130c..74102321 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelManager.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelManager.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow * or when this instance is destroyed. * * To precisely control the lifecycle of this object itself (and thus its cached channels), - * all other compoents must access EuiccChannelManager objects through EuiccChannelManagerService. + * all other components must access EuiccChannelManager objects through EuiccChannelManagerService. * Holding references independent of EuiccChannelManagerService is unsupported. */ interface EuiccChannelManager { @@ -37,6 +37,14 @@ interface EuiccChannelManager { */ fun flowAllOpenEuiccPorts(): Flow> + /** + * Iterate over all the Secure Elements available on one eUICC. + * + * This is going to almost always return only 1 result, except in the case where + * a card has multiple SEs. + */ + fun flowEuiccSecureElements(slotId: Int, portId: Int): Flow + /** * Scan all possible USB devices for CCID readers that may contain eUICC cards. * If found, try to open it for access, and add it to the internal EuiccChannel cache @@ -67,7 +75,7 @@ interface EuiccChannelManager { */ suspend fun findAvailablePorts(physicalSlotId: Int): List - class EuiccChannelNotFoundException: Exception("EuiccChannel not found") + class EuiccChannelNotFoundException : Exception("EuiccChannel not found") /** * Find a EuiccChannel by its slot and port, then run a callback with a reference to it. @@ -78,19 +86,12 @@ interface EuiccChannelManager { * * If a channel for that slot / port is not found, EuiccChannelNotFoundException is thrown */ - suspend fun withEuiccChannel( - physicalSlotId: Int, - portId: Int, - fn: suspend (EuiccChannel) -> R - ): R + suspend fun withEuiccChannel(physicalSlotId: Int, portId: Int, seId: EuiccChannel.SecureElementId, fn: suspend (EuiccChannel) -> R): R /** - * Same as withEuiccChannel(Int, Int, (EuiccChannel) -> R) but instead uses logical slot ID + * Same as withEuiccChannel(Int, Int, SecureElementId, (EuiccChannel) -> R) but instead uses logical slot ID */ - suspend fun withEuiccChannel( - logicalSlotId: Int, - fn: suspend (EuiccChannel) -> R - ): R + suspend fun withEuiccChannel(logicalSlotId: Int, seId: EuiccChannel.SecureElementId, fn: suspend (EuiccChannel) -> R): R /** * Invalidate all EuiccChannels previously cached by this Manager @@ -105,4 +106,4 @@ interface EuiccChannelManager { suspend fun notifyEuiccProfilesChanged(logicalSlotId: Int) { // no-op by default } -} \ No newline at end of file +} 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 361a9438..e47d403b 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 @@ -26,20 +26,25 @@ class EuiccChannelWrapper(orig: EuiccChannel) : EuiccChannel { get() = channel.logicalSlotId override val portId: Int get() = channel.portId + override val seId: EuiccChannel.SecureElementId + get() = channel.seId private val lpaDelegate = lazy { LocalProfileAssistantWrapper(channel.lpa) } override val lpa: LocalProfileAssistant by lpaDelegate override val valid: Boolean get() = channel.valid - override val intrinsicChannelName: String? - get() = channel.intrinsicChannelName override val apduInterface: ApduInterface get() = channel.apduInterface override val atr: ByteArray? get() = channel.atr override val isdrAid: ByteArray get() = channel.isdrAid + override var hasMultipleSE: Boolean + get() = channel.hasMultipleSE + set(value) { + channel.hasMultipleSE = value + } override fun close() = channel.close() @@ -50,4 +55,4 @@ class EuiccChannelWrapper(orig: EuiccChannel) : EuiccChannel { (lpa as LocalProfileAssistantWrapper).invalidateWrapper() } } -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt b/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt index b715ca08..469a2bdc 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt @@ -1,5 +1,6 @@ package im.angry.openeuicc.core +import net.typeblog.lpac_jni.ProfileDownloadInput import net.typeblog.lpac_jni.EuiccInfo2 import net.typeblog.lpac_jni.LocalProfileAssistant import net.typeblog.lpac_jni.LocalProfileInfo @@ -40,13 +41,8 @@ class LocalProfileAssistantWrapper(orig: LocalProfileAssistant) : override fun deleteProfile(iccid: String): Boolean = lpa.deleteProfile(iccid) - override fun downloadProfile( - smdp: String, - matchingId: String?, - imei: String?, - confirmationCode: String?, - callback: ProfileDownloadCallback - ) = lpa.downloadProfile(smdp, matchingId, imei, confirmationCode, callback) + override fun downloadProfile(input: ProfileDownloadInput, callback: ProfileDownloadCallback) = + lpa.downloadProfile(input, callback) override fun deleteNotification(seqNumber: Long): Boolean = lpa.deleteNotification(seqNumber) @@ -63,4 +59,4 @@ class LocalProfileAssistantWrapper(orig: LocalProfileAssistant) : fun invalidateWrapper() { _inner = null } -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt b/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt index f918494a..3fb95588 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt @@ -15,7 +15,7 @@ class OmapiApduInterface( private val service: SEService, private val port: UiccPortInfoCompat, private val verboseLoggingFlow: Flow -): ApduInterface, ApduInterfaceAtrProvider { +) : ApduInterface, ApduInterfaceAtrProvider { companion object { const val TAG = "OmapiApduInterface" } @@ -83,4 +83,4 @@ class OmapiApduInterface( throw e } } -} \ No newline at end of file +} 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 4a4ccb98..6520e6f8 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 @@ -2,7 +2,8 @@ package im.angry.openeuicc.core.usb import android.util.Log import im.angry.openeuicc.core.ApduInterfaceAtrProvider -import im.angry.openeuicc.util.* +import im.angry.openeuicc.util.decodeHex +import im.angry.openeuicc.util.encodeHex import net.typeblog.lpac_jni.ApduInterface class UsbApduInterface( @@ -20,9 +21,70 @@ class UsbApduInterface( private var channels = mutableSetOf() + // ATR parser + // Specs: ISO/IEC 7816-3:2006 8.2 Answer-to-Reset + // See also: https://en.wikipedia.org/wiki/Answer_to_reset + class ParsedAtr private constructor(val ts: Byte?, val t0: Byte?, val ta1: Byte?, val tb1: Byte?, val tc1: Byte?, val td1: Byte?, val ta2: Byte?, val tb2: Byte?, val tc2: Byte?, val td2: Byte?) { + companion object { + fun parse(atr: ByteArray): ParsedAtr { + val ts = atr[0] + val t0 = atr[1] + val tx1 = arrayOf(null, null, null, null) + val tx2 = arrayOf(null, null, null, null) + var pointer = 2 + + for (i in 0..3) { + if (t0.toInt() and (0x10 shl i) != 0) { + tx1[i] = atr[pointer] + pointer++ + } + } + + val td1 = tx1[3] ?: 0 + + for (i in 0..3) { + if (td1.toInt() and (0x10 shl i) != 0) { + tx2[i] = atr[pointer] + pointer++ + } + } + + return ParsedAtr(ts=ts, t0=t0, ta1=tx1[0], tb1=tx1[1], tc1=tx1[2], td1=tx1[3], + ta2=tx2[0], tb2=tx2[1], tc2=tx2[2], td2=tx2[3], + ) + } + } + } + override fun connect() { ccidCtx.connect() + if (ccidCtx.transceiver.isTpdu) { + // Send parameter selection + // Specs: USB-CCID 3.2.1 TPDU level of exchange + val parsedAtr = ParsedAtr.parse(atr!!) + val ta1 = parsedAtr.ta1 ?: 0x11.toByte() + val pts1 = ta1 // TODO: Check that reader supports baud rate proposed by the card + val pps = byteArrayOf(0xff.toByte(), 0x10.toByte(), pts1, 0x00.toByte()) + Log.d(TAG, "PTS1=${pts1} PPS: ${pps.encodeHex()}") + ccidCtx.transceiver.sendXfrBlock(pps) + + // Send Set Parameters + // Specs: USB-CCID 6.1.7 PC_to_RDR_SetParameters + + val param = byteArrayOf( + pts1, + (if (parsedAtr.ts == 0x3F.toByte()) 0x02 else 0x00), + parsedAtr.tc1 ?: 0, + parsedAtr.tc2 ?: 0x0A, + 0x00 + ) + + Log.d(TAG, "Param: ${param.encodeHex()}") + + ccidCtx.transceiver.sendParamBlock(param) + } + // Send Terminal Capabilities // Specs: ETSI TS 102 221 v15.0.0 - 11.1.19 TERMINAL CAPABILITY val terminalCapabilities = buildCmd( @@ -53,6 +115,7 @@ class UsbApduInterface( val channelId = resp[0].toInt() Log.d(TAG, "channelId = $channelId") + channels.add(channelId) // Then, select AID val selectAid = selectByDfCmd(aid, channelId.toByte()) @@ -60,11 +123,11 @@ class UsbApduInterface( if (!isSuccessResponse(selectAidResp)) { Log.d(TAG, "Select DF failed : ${selectAidResp.encodeHex()}") + logicalChannelClose(channelId) + Log.d(TAG, "Closed logical channel $channelId due to select DF failure") return -1 } - channels.add(channelId) - return channelId } @@ -154,4 +217,4 @@ class UsbApduInterface( return resp } -} \ No newline at end of file +} 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 index caf69e7d..f1f75f6a 100644 --- 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 @@ -20,7 +20,6 @@ class UsbCcidContext private constructor( private val conn: UsbDeviceConnection, private val bulkIn: UsbEndpoint, private val bulkOut: UsbEndpoint, - val productName: String, val verboseLoggingFlow: Flow ) { companion object { @@ -38,7 +37,6 @@ class UsbCcidContext private constructor( conn, bulkIn, bulkOut, - usbDevice.productName ?: "USB", context.preferenceRepository.verboseLoggingFlow ) }.getOrNull() diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidDescription.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidDescription.kt index bc32fb6b..cb75a718 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidDescription.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidDescription.kt @@ -84,6 +84,8 @@ data class UsbCcidDescription( private fun hasFeature(feature: Int) = (dwFeatures and feature) != 0 + val isTpdu = hasFeature(0x10000) + val voltages: List get() { if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) return listOf(Voltage.AUTO) @@ -95,4 +97,4 @@ data class UsbCcidDescription( val hasT0Protocol: Boolean get() = (dwProtocols and MASK_T0_PROTO) != 0 -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidTransceiver.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidTransceiver.kt index 9155721f..7ed6b326 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidTransceiver.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidTransceiver.kt @@ -4,7 +4,7 @@ import android.hardware.usb.UsbDeviceConnection import android.hardware.usb.UsbEndpoint import android.os.SystemClock import android.util.Log -import im.angry.openeuicc.util.* +import im.angry.openeuicc.util.encodeHex import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -143,6 +143,8 @@ class UsbCcidTransceiver( val hasAutomaticPps = usbCcidDescription.hasAutomaticPps + val isTpdu = usbCcidDescription.isTpdu + private val inputBuffer = ByteArray(usbBulkIn.maxPacketSize) private var currentSequenceNumber: Byte = 0 @@ -158,6 +160,46 @@ class UsbCcidTransceiver( } } + private fun receiveParamBlock(expectedSequenceNumber: Byte): ByteArray { + var response: ByteArray? + do { + response = receiveParamBlockImmediate(expectedSequenceNumber) + } while (response!![7] == 0x80.toByte()) + return response + } + + private fun receiveParamBlockImmediate(expectedSequenceNumber: Byte): ByteArray { + /* + * Some USB CCID devices (notably NitroKey 3) may time-out and need a subsequent poke to + * carry on communications. No particular reason why the number 3 was chosen. If we get a + * zero-sized reply (or a time-out), we try again. Clamped retries prevent an infinite loop + * if things really turn sour. + */ + var attempts = 3 + Log.d(TAG, "Receive data block immediate seq=$expectedSequenceNumber") + var readBytes: Int + do { + readBytes = usbConnection.bulkTransfer( + usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS + ) + if (runBlocking { verboseLoggingFlow.first() }) { + Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}") + } + } while (readBytes <= 0 && attempts-- > 0) + if (inputBuffer[0] != 0x82.toByte()) { + throw UsbTransportException(buildString { + append("USB-CCID error - bad CCID header") + append(", type ") + append("%d (expected %d)".format(inputBuffer[0], MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK)) + if (expectedSequenceNumber != inputBuffer[6]) { + append(", sequence number ") + append("%d (expected %d)".format(inputBuffer[6], expectedSequenceNumber)) + } + }) + } + return inputBuffer + } + private fun receiveDataBlock(expectedSequenceNumber: Byte): CcidDataBlock { var response: CcidDataBlock? do { @@ -283,6 +325,38 @@ class UsbCcidTransceiver( return ccidDataBlock } + fun sendParamBlock( + payload: ByteArray + ): ByteArray { + val startTime = SystemClock.elapsedRealtime() + val l = payload.size + val sequenceNumber: Byte = currentSequenceNumber++ + val headerData = byteArrayOf( + 0x61.toByte(), + l.toByte(), + (l shr 8).toByte(), + (l shr 16).toByte(), + (l shr 24).toByte(), + SLOT_NUMBER.toByte(), + sequenceNumber, + 0x00.toByte(), + 0x00.toByte(), + 0x00.toByte() + ) + val data: ByteArray = headerData + payload + Log.d(TAG, "USB ParamBlock: ${data.encodeHex()}") + var sentBytes = 0 + while (sentBytes < data.size) { + val bytesToSend = usbBulkOut.maxPacketSize.coerceAtMost(data.size - sentBytes) + sendRaw(data, sentBytes, bytesToSend) + sentBytes += bytesToSend + } + val ccidDataBlock = receiveParamBlock(sequenceNumber) + val elapsedTime = SystemClock.elapsedRealtime() - startTime + Log.d(TAG, "USB ParamBlock call took ${elapsedTime}ms") + return ccidDataBlock + } + fun iccPowerOn(): CcidDataBlock { val startTime = SystemClock.elapsedRealtime() skipAvailableInput() @@ -342,4 +416,4 @@ class UsbCcidTransceiver( ) sendRaw(iccPowerCommand, 0, iccPowerCommand.size) } -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/di/AppContainer.kt b/app-common/src/main/java/im/angry/openeuicc/di/AppContainer.kt index cae7e2eb..eb77b91c 100644 --- a/app-common/src/main/java/im/angry/openeuicc/di/AppContainer.kt +++ b/app-common/src/main/java/im/angry/openeuicc/di/AppContainer.kt @@ -16,4 +16,4 @@ interface AppContainer { val uiComponentFactory: UiComponentFactory val euiccChannelFactory: EuiccChannelFactory val customizableTextProvider: CustomizableTextProvider -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/di/CustomizableTextProvider.kt b/app-common/src/main/java/im/angry/openeuicc/di/CustomizableTextProvider.kt index 2c86273a..1782b7d5 100644 --- a/app-common/src/main/java/im/angry/openeuicc/di/CustomizableTextProvider.kt +++ b/app-common/src/main/java/im/angry/openeuicc/di/CustomizableTextProvider.kt @@ -1,5 +1,8 @@ package im.angry.openeuicc.di +import android.net.Uri +import im.angry.openeuicc.core.EuiccChannel + interface CustomizableTextProvider { /** * Explanation string for when no eUICC is found on the device. @@ -13,8 +16,18 @@ interface CustomizableTextProvider { val profileSwitchingTimeoutMessage: String /** - * Format the name of a logical slot; internal only -- not intended for - * other channels such as USB. + * Display the website link in settings; null if not available. */ - fun formatInternalChannelName(logicalSlotId: Int): String -} \ No newline at end of file + val websiteUri: Uri? + + /** + * Format the name of a logical slot -- not for USB channels + */ + fun formatNonUsbChannelName(logicalSlotId: Int): String + + /** + * Format the name of a logical slot with a SE ID, in case of multi-SE chips; currently + * this is used in the download flow to distinguish between them on the same chip. + */ + fun formatNonUsbChannelNameWithSeId(logicalSlotId: Int, seId: EuiccChannel.SecureElementId): String +} diff --git a/app-common/src/main/java/im/angry/openeuicc/di/DefaultAppContainer.kt b/app-common/src/main/java/im/angry/openeuicc/di/DefaultAppContainer.kt index 9b70099c..8834614c 100644 --- a/app-common/src/main/java/im/angry/openeuicc/di/DefaultAppContainer.kt +++ b/app-common/src/main/java/im/angry/openeuicc/di/DefaultAppContainer.kt @@ -42,4 +42,4 @@ open class DefaultAppContainer(context: Context) : AppContainer { override val customizableTextProvider by lazy { DefaultCustomizableTextProvider(context) } -} \ No newline at end of file +} 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..8cdaa1ca 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 @@ -1,15 +1,23 @@ package im.angry.openeuicc.di import android.content.Context +import android.net.Uri import im.angry.openeuicc.common.R +import im.angry.openeuicc.core.EuiccChannel open class DefaultCustomizableTextProvider(private val context: Context) : CustomizableTextProvider { override val noEuiccExplanation: String 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 = + override val websiteUri: Uri? + get() = null + + override fun formatNonUsbChannelName(logicalSlotId: Int): String = context.getString(R.string.channel_name_format, logicalSlotId) -} \ No newline at end of file + + override fun formatNonUsbChannelNameWithSeId(logicalSlotId: Int, seId: EuiccChannel.SecureElementId): String = + context.getString(R.string.channel_name_format_se, logicalSlotId, seId.id) +} diff --git a/app-common/src/main/java/im/angry/openeuicc/di/DefaultUiComponentFactory.kt b/app-common/src/main/java/im/angry/openeuicc/di/DefaultUiComponentFactory.kt index 52a501a9..4107fa73 100644 --- a/app-common/src/main/java/im/angry/openeuicc/di/DefaultUiComponentFactory.kt +++ b/app-common/src/main/java/im/angry/openeuicc/di/DefaultUiComponentFactory.kt @@ -1,16 +1,20 @@ package im.angry.openeuicc.di import androidx.fragment.app.Fragment -import androidx.preference.PreferenceFragmentCompat +import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.ui.EuiccManagementFragment import im.angry.openeuicc.ui.NoEuiccPlaceholderFragment import im.angry.openeuicc.ui.SettingsFragment open class DefaultUiComponentFactory : UiComponentFactory { - override fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment = - EuiccManagementFragment.newInstance(slotId, portId) + override fun createEuiccManagementFragment( + slotId: Int, + portId: Int, + seId: EuiccChannel.SecureElementId + ): EuiccManagementFragment = + EuiccManagementFragment.newInstance(slotId, portId, seId) override fun createNoEuiccPlaceholderFragment(): Fragment = NoEuiccPlaceholderFragment() override fun createSettingsFragment(): Fragment = SettingsFragment() -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/di/UiComponentFactory.kt b/app-common/src/main/java/im/angry/openeuicc/di/UiComponentFactory.kt index 2c3c72b2..1dd2faae 100644 --- a/app-common/src/main/java/im/angry/openeuicc/di/UiComponentFactory.kt +++ b/app-common/src/main/java/im/angry/openeuicc/di/UiComponentFactory.kt @@ -1,11 +1,16 @@ package im.angry.openeuicc.di import androidx.fragment.app.Fragment -import androidx.preference.PreferenceFragmentCompat +import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.ui.EuiccManagementFragment interface UiComponentFactory { - fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment + fun createEuiccManagementFragment( + slotId: Int, + portId: Int, + seId: EuiccChannel.SecureElementId + ): EuiccManagementFragment + fun createNoEuiccPlaceholderFragment(): Fragment fun createSettingsFragment(): Fragment -} \ No newline at end of file +} 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 47443216..4934b989 100644 --- a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt +++ b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt @@ -12,6 +12,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import im.angry.openeuicc.common.R +import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers @@ -37,6 +38,9 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield import net.typeblog.lpac_jni.ProfileDownloadCallback +import net.typeblog.lpac_jni.ProfileDownloadInput +import net.typeblog.lpac_jni.ProfileDownloadState +import net.typeblog.lpac_jni.RemoteProfileInfo /** * An Android Service wrapper for EuiccChannelManager. @@ -378,32 +382,33 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { } fun launchProfileDownloadTask( - slotId: Int, - portId: Int, - smdp: String, - matchingId: String?, - confirmationCode: String?, - imei: String? + slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId, + input: ProfileDownloadInput ): ForegroundTaskSubscriberFlow = launchForegroundTask( getString(R.string.task_profile_download), getString(R.string.task_profile_download_failure), R.drawable.ic_task_sim_card_download ) { - euiccChannelManager.beginTrackedOperation(slotId, portId) { - euiccChannelManager.withEuiccChannel(slotId, portId) { channel -> - channel.lpa.downloadProfile( - smdp, - matchingId, - imei, - confirmationCode, - object : ProfileDownloadCallback { - override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) { - if (state.progress == 0) return - foregroundTaskState.value = - ForegroundTaskState.InProgress(state.progress) + euiccChannelManager.beginTrackedOperation(slotId, portId, seId) { + euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel -> + channel.lpa.downloadProfile(input, object : ProfileDownloadCallback { + override fun onStateUpdate(state: ProfileDownloadState) { + if (state.progress == 0) return + foregroundTaskState.value = ForegroundTaskState.InProgress(state.progress) + } + + override fun onConfirmMetadata(metadata: RemoteProfileInfo?): Boolean { + // TODO: Actually do something here and not just logging? + if (metadata != null) { + Log.i( + TAG, + "Downloading profile provider=${metadata.providerName} name=${metadata.name}" + ) } - }) + return true + } + }) } preferenceRepository.notificationDownloadFlow.first() @@ -413,6 +418,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { fun launchProfileRenameTask( slotId: Int, portId: Int, + seId: EuiccChannel.SecureElementId, iccid: String, name: String ): ForegroundTaskSubscriberFlow = @@ -421,7 +427,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { getString(R.string.task_profile_rename_failure), R.drawable.ic_task_rename ) { - euiccChannelManager.withEuiccChannel(slotId, portId) { channel -> + euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel -> channel.lpa.setNickname( iccid, name @@ -432,6 +438,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { fun launchProfileDeleteTask( slotId: Int, portId: Int, + seId: EuiccChannel.SecureElementId, iccid: String ): ForegroundTaskSubscriberFlow = launchForegroundTask( @@ -439,8 +446,8 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { getString(R.string.task_profile_delete_failure), R.drawable.ic_task_delete ) { - euiccChannelManager.beginTrackedOperation(slotId, portId) { - euiccChannelManager.withEuiccChannel(slotId, portId) { channel -> + euiccChannelManager.beginTrackedOperation(slotId, portId, seId) { + euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel -> channel.lpa.deleteProfile(iccid) } @@ -453,6 +460,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { fun launchProfileSwitchTask( slotId: Int, portId: Int, + seId: EuiccChannel.SecureElementId, iccid: String, enable: Boolean, // Enable or disable the profile indicated in iccid reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect @@ -462,9 +470,9 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { getString(R.string.task_profile_switch_failure), R.drawable.ic_task_switch ) { - euiccChannelManager.beginTrackedOperation(slotId, portId) { + euiccChannelManager.beginTrackedOperation(slotId, portId, seId) { val (response, refreshed) = - euiccChannelManager.withEuiccChannel(slotId, portId) { channel -> + euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel -> val refresh = preferenceRepository.refreshAfterSwitchFlow.first() val response = channel.lpa.switchProfile(iccid, enable, refresh) if (response || !refresh) { @@ -510,18 +518,22 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { } } - fun launchMemoryReset(slotId: Int, portId: Int): ForegroundTaskSubscriberFlow = + fun launchMemoryReset( + slotId: Int, + portId: Int, + seId: EuiccChannel.SecureElementId + ): ForegroundTaskSubscriberFlow = launchForegroundTask( getString(R.string.task_euicc_memory_reset), getString(R.string.task_euicc_memory_reset_failure), R.drawable.ic_euicc_memory_reset ) { - euiccChannelManager.beginTrackedOperation(slotId, portId) { - euiccChannelManager.withEuiccChannel(slotId, portId) { channel -> + euiccChannelManager.beginTrackedOperation(slotId, portId, seId) { + euiccChannelManager.withEuiccChannel(slotId, portId, seId) { 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/BaseEuiccAccessActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/BaseEuiccAccessActivity.kt index ae13962e..a58bfcc8 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/BaseEuiccAccessActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/BaseEuiccAccessActivity.kt @@ -1,7 +1,6 @@ package im.angry.openeuicc.ui import android.content.ComponentName -import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.os.Bundle @@ -36,7 +35,7 @@ abstract class BaseEuiccAccessActivity : AppCompatActivity() { bindService( Intent(this, EuiccChannelManagerService::class.java), euiccChannelManagerServiceConnection, - Context.BIND_AUTO_CREATE + BIND_AUTO_CREATE ) } @@ -49,4 +48,4 @@ abstract class BaseEuiccAccessActivity : AppCompatActivity() { * When called, euiccChannelManager is guaranteed to have been initialized */ abstract fun onInit() -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/BaseMaterialDialogFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/BaseMaterialDialogFragment.kt index 5c33bee6..ccf31379 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/BaseMaterialDialogFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/BaseMaterialDialogFragment.kt @@ -9,7 +9,7 @@ import androidx.fragment.app.DialogFragment import com.google.android.material.color.DynamicColors import im.angry.openeuicc.common.R -abstract class BaseMaterialDialogFragment: DialogFragment() { +abstract class BaseMaterialDialogFragment : DialogFragment() { override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { val inflater = super.onGetLayoutInflater(savedInstanceState) val wrappedContext = ContextThemeWrapper(requireContext(), R.style.Theme_OpenEUICC) @@ -23,4 +23,4 @@ abstract class BaseMaterialDialogFragment: DialogFragment() { it.window?.setBackgroundDrawableResource(R.drawable.dialog_background) } } -} \ 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 1d5f37ff..157ab42d 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt @@ -23,22 +23,32 @@ import im.angry.openeuicc.common.R import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.util.* +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext 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 private lateinit var infoList: RecyclerView private var logicalSlotId: Int = -1 + private var seId: EuiccChannel.SecureElementId = EuiccChannel.SecureElementId.DEFAULT data class Item( - @StringRes + @get:StringRes val titleResId: Int, val content: String?, val copiedToastResId: Int? = null, @@ -49,7 +59,6 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { super.onCreate(savedInstanceState) setContentView(R.layout.activity_euicc_info) setSupportActionBar(requireViewById(R.id.toolbar)) - setupToolbarInsets() supportActionBar!!.setDisplayHomeAsUpEnabled(true) swipeRefresh = requireViewById(R.id.swipe_refresh) @@ -60,18 +69,27 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { } logicalSlotId = intent.getIntExtra("logicalSlotId", 0) - - val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { - getString(R.string.usb) + seId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra("seId", EuiccChannel.SecureElementId::class.java) } else { - appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId) - } + @Suppress("DEPRECATION") + intent.getParcelableExtra("seId") + } ?: EuiccChannel.SecureElementId.DEFAULT - title = getString(R.string.euicc_info_activity_title, channelTitle) + setChannelTitle( + if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) + getString(R.string.channel_name_format_usb) else + appContainer.customizableTextProvider.formatNonUsbChannelName(logicalSlotId) + ) swipeRefresh.setOnRefreshListener { refresh() } - setupRootViewInsets(infoList) + setupRootViewSystemBarInsets( + window.decorView.rootView, arrayOf( + this::activityToolbarInsetHandler, + mainViewPaddingInsetHandler(infoList) + ) + ) } override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { @@ -83,6 +101,10 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { else -> super.onOptionsItemSelected(item) } + private fun setChannelTitle(title: CharSequence) { + super.setTitle(getString(R.string.euicc_info_activity_title, title)) + } + override fun onInit() { refresh() } @@ -91,8 +113,24 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { swipeRefresh.isRefreshing = true lifecycleScope.launch { - (infoList.adapter!! as EuiccInfoAdapter).euiccInfoItems = - euiccChannelManager.withEuiccChannel(logicalSlotId, ::buildEuiccInfoItems) + euiccChannelManager.withEuiccChannel(logicalSlotId, seId) { channel -> + if (channel.hasMultipleSE) { + withContext(Dispatchers.Main) { + val title = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { + getString(R.string.channel_name_format_usb_se, seId.id) + } else { + appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(logicalSlotId, seId) + } + setChannelTitle(title) + } + } + + val items = buildEuiccInfoItems(channel) + + withContext(Dispatchers.Main) { + (infoList.adapter!! as EuiccInfoAdapter).euiccInfoItems = items + } + } swipeRefresh.isRefreshing = false } @@ -102,20 +140,28 @@ 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())) + if (!channel.isdrAid.contentEquals(EUICC_DEFAULT_ISDR_AID.decodeHex())) { + // Only show if it's not the default ISD-R AID + add(Item(R.string.euicc_info_isdr_aid, channel.isdrAid.encodeHex())) + } channel.tryParseEuiccVendorInfo()?.let { vendorInfo -> + // @formatter:off vendorInfo.skuName?.let { add(Item(R.string.euicc_info_sku, it)) } vendorInfo.serialNumber?.let { add(Item(R.string.euicc_info_sn, it, copiedToastResId = R.string.toast_sn_copied)) } vendorInfo.firmwareVersion?.let { add(Item(R.string.euicc_info_fw_ver, it)) } - vendorInfo.bootloaderVersion?.let { add(Item(R.string.euicc_info_bl_ver, it)) } + // @formatter:on } - 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())) + 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) @@ -123,25 +169,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 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) @@ -168,7 +209,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) } } @@ -192,4 +233,4 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { holder.bind(euiccInfoItems[position]) } } -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt index 12995ff7..2ef479cc 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 @@ -19,8 +19,6 @@ import android.widget.PopupMenu import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment @@ -29,8 +27,8 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.floatingactionbutton.FloatingActionButton -import net.typeblog.lpac_jni.LocalProfileInfo import im.angry.openeuicc.common.R +import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.service.EuiccChannelManagerService import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone import im.angry.openeuicc.ui.wizard.DownloadWizardActivity @@ -38,19 +36,23 @@ import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import net.typeblog.lpac_jni.LocalProfileInfo +import net.typeblog.lpac_jni.ProfileClass open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, EuiccChannelFragmentMarker { companion object { const val TAG = "EuiccManagementFragment" - fun newInstance(slotId: Int, portId: Int): EuiccManagementFragment = - newInstanceEuicc(EuiccManagementFragment::class.java, slotId, portId) + fun newInstance( + slotId: Int, + portId: Int, + seId: EuiccChannel.SecureElementId + ): EuiccManagementFragment = + newInstanceEuicc(EuiccManagementFragment::class.java, slotId, portId, seId) } private lateinit var swipeRefresh: SwipeRefreshLayout @@ -58,6 +60,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, private lateinit var profileList: RecyclerView private var logicalSlotId: Int = -1 private lateinit var eid: String + private var enabledProfile: LocalProfileInfo? = null private val adapter = EuiccProfileAdapter() @@ -89,18 +92,22 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, val origFabMarginRight = (fab.layoutParams as ViewGroup.MarginLayoutParams).rightMargin val origFabMarginBottom = (fab.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin - ViewCompat.setOnApplyWindowInsetsListener(fab) { v, insets -> - val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.updateLayoutParams { - rightMargin = origFabMarginRight + bars.right - bottomMargin = origFabMarginBottom + bars.bottom + setupRootViewSystemBarInsets( + view, arrayOf( + mainViewPaddingInsetHandler(profileList), + { insets -> + fab.updateLayoutParams { + rightMargin = origFabMarginRight + insets.right + bottomMargin = origFabMarginBottom + insets.bottom + } } + )) - WindowInsetsCompat.CONSUMED - } - - setupRootViewInsets(profileList) + profileList.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(view: RecyclerView, newState: Int) = + if (newState == RecyclerView.SCROLL_STATE_IDLE) fab.show() else fab.hide() + }) return view } @@ -113,10 +120,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false) fab.setOnClickListener { - Intent(requireContext(), DownloadWizardActivity::class.java).apply { - putExtra("selectedLogicalSlot", logicalSlotId) - startActivity(this) - } + val intent = DownloadWizardActivity.newIntent(requireContext(), slotId, seId) + startActivity(intent) } } @@ -141,13 +146,14 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, menu.findItem(R.id.euicc_info).isVisible = logicalSlotId != -1 menu.findItem(R.id.euicc_memory_reset).isVisible = - runBlocking { preferenceRepository.euiccMemoryResetFlow.first() } + enabledProfile == null } override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { R.id.show_notifications -> { Intent(requireContext(), NotificationsActivity::class.java).apply { putExtra("logicalSlotId", logicalSlotId) + putExtra("seId", seId) startActivity(this) } true @@ -156,13 +162,14 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, R.id.euicc_info -> { Intent(requireContext(), EuiccInfoActivity::class.java).apply { putExtra("logicalSlotId", logicalSlotId) + putExtra("seId", seId) startActivity(this) } true } R.id.euicc_memory_reset -> { - EuiccMemoryResetFragment.newInstance(slotId, portId, eid) + EuiccMemoryResetFragment.newInstance(slotId, portId, seId, eid) .show(childFragmentManager, EuiccMemoryResetFragment.TAG) true } @@ -207,6 +214,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, val profiles = withEuiccChannel { channel -> logicalSlotId = channel.logicalSlotId eid = channel.lpa.eID + enabledProfile = channel.lpa.profiles.enabled euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId) if (unfilteredProfileListFlow.value) channel.lpa.profiles @@ -223,11 +231,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, } private suspend fun showSwitchFailureText() = withContext(Dispatchers.Main) { - Toast.makeText( - context, - R.string.toast_profile_enable_failed, - Toast.LENGTH_LONG - ).show() + val resId = R.string.toast_profile_enable_failed + Toast.makeText(context, resId, Toast.LENGTH_LONG).show() } private fun enableOrDisableProfile(iccid: String, enable: Boolean) { @@ -238,13 +243,12 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, ensureEuiccChannelManager() euiccChannelManagerService.waitForForegroundTask() - val err = euiccChannelManagerService.launchProfileSwitchTask( - slotId, - portId, - iccid, - enable, - reconnectTimeoutMillis = 30 * 1000 - ).waitDone() + val err = euiccChannelManagerService + .launchProfileSwitchTask( + slotId, portId, seId, iccid, enable, + reconnectTimeoutMillis = 30 * 1000 + ) + .waitDone() when (err) { null -> {} @@ -253,7 +257,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() @@ -294,19 +298,20 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, } } - protected open fun populatePopupWithProfileActions(popup: PopupMenu, profile: LocalProfileInfo) { + protected open fun populatePopupWithProfileActions( + popup: PopupMenu, + profile: LocalProfileInfo + ) { popup.inflate(R.menu.profile_options) - if (profile.isEnabled) { - popup.menu.findItem(R.id.enable).isVisible = false - popup.menu.findItem(R.id.delete).isVisible = false + if (!profile.isEnabled) return + popup.menu.findItem(R.id.enable).isVisible = false + popup.menu.findItem(R.id.delete).isVisible = false - // We hide the disable option by default to avoid "bricking" some cards that won't get - // recognized again by the phone's modem. However we don't have that worry if we are - // accessing it through a USB card reader, or when the user explicitly opted in - if (isUsb || disableSafeguardFlow.value) { - popup.menu.findItem(R.id.disable).isVisible = true - } - } + // We hide the disable option by default to avoid "bricking" some cards that won't get + // recognized again by the phone's modem. However, we don't have that worry if we are + // accessing it through a USB card reader, or when the user explicitly opted in + if (!isUsb && !disableSafeguardFlow.value) return + popup.menu.findItem(R.id.disable).isVisible = true } sealed class ViewHolder(root: View) : RecyclerView.ViewHolder(root) { @@ -321,7 +326,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, } } - inner class FooterViewHolder: ViewHolder(FrameLayout(requireContext())) { + inner class FooterViewHolder : ViewHolder(FrameLayout(requireContext())) { init { itemView.layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -347,6 +352,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,10 +372,13 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, true } - profileMenu.setOnClickListener { showOptionsMenu() } + profileMenu.setOnClickListener { + showOptionsMenu() + } } private lateinit var profile: LocalProfileInfo + private var canEnable: Boolean = false fun setProfile(profile: LocalProfileInfo) { this.profile = profile @@ -377,9 +386,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 @@ -387,15 +396,29 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, profileClass.isVisible = unfilteredProfileListFlow.value profileClass.setText( when (profile.profileClass) { - LocalProfileInfo.Clazz.Testing -> R.string.profile_class_testing - LocalProfileInfo.Clazz.Provisioning -> R.string.profile_class_provisioning - LocalProfileInfo.Clazz.Operational -> R.string.profile_class_operational + ProfileClass.Testing -> R.string.profile_class_testing + ProfileClass.Provisioning -> R.string.profile_class_provisioning + ProfileClass.Operational -> R.string.profile_class_operational } ) iccid.text = profile.iccid iccid.transformationMethod = PasswordTransformationMethod.getInstance() } + fun setProfileSequenceNumber(index: Int) { + profileSeqNumber.text = root.context.getString( + R.string.profile_sequence_number_format, + index, + ) + } + + fun setEnabledProfile(enabledProfile: LocalProfileInfo?) { + // cannot cross profile class enable profile + // e.g: testing -> operational or operational -> testing + canEnable = enabledProfile == null || + enabledProfile.profileClass == profile.profileClass + } + private fun showOptionsMenu() { // Prevent users from doing multiple things at once if (invalid || swipeRefresh.isRefreshing) return @@ -410,23 +433,45 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, private fun onMenuItemClicked(item: MenuItem): Boolean = when (item.itemId) { R.id.enable -> { - enableOrDisableProfile(profile.iccid, true) + if (canEnable) { + enableOrDisableProfile(profile.iccid, true) + } else { + val resId = R.string.toast_profile_enable_cross_class + Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG) + .show() + } true } + R.id.disable -> { enableOrDisableProfile(profile.iccid, false) true } + R.id.rename -> { - ProfileRenameFragment.newInstance(slotId, portId, profile.iccid, profile.displayName) + ProfileRenameFragment.newInstance( + slotId, + portId, + seId, + profile.iccid, + profile.displayName + ) .show(childFragmentManager, ProfileRenameFragment.TAG) true } + R.id.delete -> { - ProfileDeleteFragment.newInstance(slotId, portId, profile.iccid, profile.displayName) + ProfileDeleteFragment.newInstance( + slotId, + portId, + seId, + profile.iccid, + profile.displayName + ) .show(childFragmentManager, ProfileDeleteFragment.TAG) true } + else -> false } } @@ -438,9 +483,11 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = when (ViewHolder.Type.fromInt(viewType)) { ViewHolder.Type.PROFILE -> { - val view = LayoutInflater.from(parent.context).inflate(R.layout.euicc_profile, parent, false) + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.euicc_profile, parent, false) ProfileViewHolder(view) } + ViewHolder.Type.FOOTER -> { FooterViewHolder() } @@ -451,9 +498,11 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, position < profiles.size -> { ViewHolder.Type.PROFILE.value } + position >= profiles.size && position < profiles.size + footerViews.size -> { ViewHolder.Type.FOOTER.value } + else -> -1 } @@ -461,7 +510,10 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, when (holder) { is ProfileViewHolder -> { holder.setProfile(profiles[position]) + holder.setEnabledProfile(profiles.enabled) + holder.setProfileSequenceNumber(position + 1) } + is FooterViewHolder -> { holder.attach(footerViews[position - profiles.size]) } @@ -476,4 +528,4 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, override fun getItemCount(): Int = profiles.size + footerViews.size } -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccMemoryResetFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccMemoryResetFragment.kt index 086a849a..9a74db25 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccMemoryResetFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccMemoryResetFragment.kt @@ -8,18 +8,11 @@ import android.widget.EditText import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment -import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import im.angry.openeuicc.common.R +import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone -import im.angry.openeuicc.util.EuiccChannelFragmentMarker -import im.angry.openeuicc.util.EuiccProfilesChangedListener -import im.angry.openeuicc.util.ensureEuiccChannelManager -import im.angry.openeuicc.util.euiccChannelManagerService -import im.angry.openeuicc.util.newInstanceEuicc -import im.angry.openeuicc.util.notifyEuiccProfilesChanged -import im.angry.openeuicc.util.portId -import im.angry.openeuicc.util.slotId +import im.angry.openeuicc.util.* import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch @@ -29,8 +22,8 @@ class EuiccMemoryResetFragment : DialogFragment(), EuiccChannelFragmentMarker { private const val FIELD_EID = "eid" - fun newInstance(slotId: Int, portId: Int, eid: String) = - newInstanceEuicc(EuiccMemoryResetFragment::class.java, slotId, portId) { + fun newInstance(slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId, eid: String) = + newInstanceEuicc(EuiccMemoryResetFragment::class.java, slotId, portId, seId) { putString(FIELD_EID, eid) } } @@ -103,7 +96,7 @@ class EuiccMemoryResetFragment : DialogFragment(), EuiccChannelFragmentMarker { ensureEuiccChannelManager() euiccChannelManagerService.waitForForegroundTask() - euiccChannelManagerService.launchMemoryReset(slotId, portId) + euiccChannelManagerService.launchMemoryReset(slotId, portId, seId) .onStart { parentFragment?.notifyEuiccProfilesChanged() diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt index 022a391b..b6828741 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/IsdrAidListActivity.kt @@ -10,8 +10,7 @@ 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 im.angry.openeuicc.util.* import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -24,11 +23,17 @@ class IsdrAidListActivity : AppCompatActivity() { 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) + setupRootViewSystemBarInsets( + window.decorView.rootView, arrayOf( + this::activityToolbarInsetHandler, + mainViewPaddingInsetHandler(isdrAidListEditor) + ) + ) + lifecycleScope.launch { preferenceRepository.isdrAidListFlow.onEach { isdrAidListEditor.text = Editable.Factory.getInstance().newEditable(it) @@ -69,4 +74,4 @@ class IsdrAidListActivity : AppCompatActivity() { else -> super.onOptionsItemSelected(item) } -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/LogsActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/LogsActivity.kt index 599e9d3f..839b45cb 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/LogsActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/LogsActivity.kt @@ -51,14 +51,18 @@ class LogsActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_logs) setSupportActionBar(requireViewById(R.id.toolbar)) - setupToolbarInsets() supportActionBar!!.setDisplayHomeAsUpEnabled(true) swipeRefresh = requireViewById(R.id.swipe_refresh) scrollView = requireViewById(R.id.scroll_view) logText = requireViewById(R.id.log_text) - setupRootViewInsets(scrollView) + setupRootViewSystemBarInsets( + window.decorView.rootView, arrayOf( + this::activityToolbarInsetHandler, + mainViewPaddingInsetHandler(scrollView) + ) + ) swipeRefresh.setOnRefreshListener { lifecycleScope.launch { @@ -84,10 +88,12 @@ class LogsActivity : AppCompatActivity() { finish() true } + R.id.save -> { saveLogs() true } + else -> super.onOptionsItemSelected(item) } @@ -107,4 +113,4 @@ class LogsActivity : AppCompatActivity() { scrollView.fullScroll(View.FOCUS_DOWN) } } -} \ 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..6dd7de88 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 @@ -16,6 +16,9 @@ import android.view.MenuItem import android.view.View import android.widget.ProgressBar import androidx.activity.enableEdgeToEdge +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.viewpager2.adapter.FragmentStateAdapter @@ -23,7 +26,9 @@ import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import im.angry.openeuicc.common.R +import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannelManager +import im.angry.openeuicc.ui.wizard.DownloadWizardActivity import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collect @@ -47,18 +52,30 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { private var refreshing = false private data class Page( + val id: Long, val logicalSlotId: Int, val title: String, val createFragment: () -> Fragment ) private val pages: MutableList = mutableListOf() + private var nextPageId = 0L + + private fun newPage( + logicalSlotId: Int, + title: String, + createFragment: () -> Fragment + ): Page = Page(nextPageId++, logicalSlotId, title, createFragment) private val pagerAdapter by lazy { object : FragmentStateAdapter(this) { override fun getItemCount() = pages.size override fun createFragment(position: Int): Fragment = pages[position].createFragment() + + override fun getItemId(position: Int): Long = pages[position].id + + override fun containsItem(itemId: Long): Boolean = pages.any { it.id == itemId } } } @@ -78,7 +95,6 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) setSupportActionBar(requireViewById(R.id.toolbar)) - setupToolbarInsets() loadingProgress = requireViewById(R.id.loading) tabs = requireViewById(R.id.main_tabs) viewPager = requireViewById(R.id.view_pager) @@ -94,6 +110,12 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) }) + + setupRootViewSystemBarInsets( + window.decorView.rootView, arrayOf( + this::activityToolbarInsetHandler + ), consume = false + ) } override fun onDestroy() { @@ -112,10 +134,12 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { startActivity(Intent(this, SettingsActivity::class.java)) true } + R.id.reload -> { refresh() true } + else -> super.onOptionsItemSelected(item) } @@ -126,15 +150,10 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { } private fun ensureNotificationPermissions() { - val needsNotificationPerms = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU; - val notificationPermsGranted = - needsNotificationPerms && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED - if (needsNotificationPerms && !notificationPermsGranted) { - requestPermissions( - arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), - PERMISSION_REQUEST_CODE - ) - } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + val permissions = arrayOf(android.Manifest.permission.POST_NOTIFICATIONS) + if (permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) return + requestPermissions(permissions, PERMISSION_REQUEST_CODE) } private suspend fun init(fromUsbEvent: Boolean = false) { @@ -154,29 +173,40 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { euiccChannelManager.flowInternalEuiccPorts().onEach { (slotId, portId) -> Log.d(TAG, "slot $slotId port $portId") - euiccChannelManager.withEuiccChannel(slotId, portId) { channel -> - if (preferenceRepository.verboseLoggingFlow.first()) { - Log.d(TAG, channel.lpa.eID) - } - // Request the system to refresh the list of profiles every time we start - // Note that this is currently supposed to be no-op when unprivileged, - // but it could change in the future - euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId) + euiccChannelManager.flowEuiccSecureElements(slotId, portId).onEach { seId -> + euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel -> + if (preferenceRepository.verboseLoggingFlow.first()) { + Log.d(TAG, channel.lpa.eID) + } + // Request the system to refresh the list of profiles every time we start + // Note that this is currently supposed to be no-op when unprivileged, + // but it could change in the future + euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId) - val channelName = - appContainer.customizableTextProvider.formatInternalChannelName(channel.logicalSlotId) - newPages.add(Page(channel.logicalSlotId, channelName) { - appContainer.uiComponentFactory.createEuiccManagementFragment(slotId, portId) - }) - } + val channelName = if (channel.hasMultipleSE) { + appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId( + channel.logicalSlotId, + channel.seId + ) + } else { + appContainer.customizableTextProvider.formatNonUsbChannelName(channel.logicalSlotId) + } + newPages.add(newPage(channel.logicalSlotId, channelName) { + appContainer.uiComponentFactory.createEuiccManagementFragment( + slotId, + portId, + seId + ) + }) + } + }.collect() }.collect() // 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) - newPages.add(Page(EuiccChannelManager.USB_CHANNEL_ID, productName) { - UsbCcidReaderFragment() + newPages.add(newPage(EuiccChannelManager.USB_CHANNEL_ID, getString(R.string.channel_name_format_usb)) { + UsbCcidReaderPermissionFragment() }) } viewPager.visibility = View.VISIBLE @@ -184,7 +214,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { if (newPages.size > 1) { tabs.visibility = View.VISIBLE } else if (newPages.isEmpty()) { - newPages.add(Page(-1, "") { + newPages.add(newPage(-1, "") { appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment() }) } @@ -209,10 +239,12 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { viewPager.currentItem = 0 } - if (pages.size > 0) { + if (pages.isNotEmpty()) { ensureNotificationPermissions() } + ShortcutManagerCompat.setDynamicShortcuts(this, buildShortcuts().take(4)) + refreshing = false } @@ -231,4 +263,44 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { init(fromUsbEvent) // will set refreshing = false } } -} \ No newline at end of file + + protected open fun buildShortcuts(): List { + val downloadShortcut = ShortcutInfoCompat.Builder(this, "download") + .setShortLabel(getString(R.string.profile_download)) + .setIcon(IconCompat.createWithResource(this, R.drawable.ic_task_sim_card_download)) + .setIntent(DownloadWizardActivity.newIntent(this).apply { action = Intent.ACTION_VIEW }) + .build() + return listOf(downloadShortcut) + } + + fun instantiateUsbTabs(seIds: List) { + val existingUsbPageIndex = pages.indexOfFirst { it.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID } + if (existingUsbPageIndex == -1) return + + val usbPages = + seIds.map { seId -> + val name = if (seIds.size == 1) { + getString(R.string.channel_name_format_usb) + } else { + getString(R.string.channel_name_format_usb_se, seId.id) + } + newPage(EuiccChannelManager.USB_CHANNEL_ID, name) { + appContainer.uiComponentFactory.createEuiccManagementFragment( + EuiccChannelManager.USB_CHANNEL_ID, + 0, + seId + ) + } + } + + // Add before removing to avoid out-of-bounds problems + pages.addAll(existingUsbPageIndex, usbPages) + // Remove the old USB reader page + pages.removeAt(existingUsbPageIndex + usbPages.size) + + if (pages.size > 1) { + tabs.visibility = View.VISIBLE + } + pagerAdapter.notifyDataSetChanged() + } +} diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/NoEuiccPlaceholderFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/NoEuiccPlaceholderFragment.kt index 7e96af3a..aa32d744 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/NoEuiccPlaceholderFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/NoEuiccPlaceholderFragment.kt @@ -7,7 +7,7 @@ import android.view.ViewGroup import android.widget.TextView import androidx.fragment.app.Fragment import im.angry.openeuicc.common.R -import im.angry.openeuicc.util.* +import im.angry.openeuicc.util.OpenEuiccContextMarker class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker { override fun onCreateView( @@ -20,4 +20,4 @@ class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker { textView.text = appContainer.customizableTextProvider.noEuiccExplanation return view } -} \ No newline at end of file +} 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..c1ba17cd 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 @@ -1,6 +1,7 @@ package im.angry.openeuicc.ui import android.annotation.SuppressLint +import android.os.Build import android.os.Bundle import android.text.Html import android.view.ContextMenu @@ -20,6 +21,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import im.angry.openeuicc.common.R +import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers @@ -27,45 +29,54 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.typeblog.lpac_jni.LocalProfileNotification -class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker { +class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { private lateinit var swipeRefresh: SwipeRefreshLayout private lateinit var notificationList: RecyclerView private val notificationAdapter = NotificationAdapter() private var logicalSlotId = -1 + private var seId = EuiccChannel.SecureElementId.DEFAULT override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) setContentView(R.layout.activity_notifications) setSupportActionBar(requireViewById(R.id.toolbar)) - setupToolbarInsets() supportActionBar!!.setDisplayHomeAsUpEnabled(true) swipeRefresh = requireViewById(R.id.swipe_refresh) notificationList = requireViewById(R.id.recycler_view) - setupRootViewInsets(notificationList) + setupRootViewSystemBarInsets( + window.decorView.rootView, arrayOf( + this::activityToolbarInsetHandler, + mainViewPaddingInsetHandler(notificationList) + ) + ) } override fun onInit() { - notificationList.layoutManager = - LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) - notificationList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL)) - notificationList.adapter = notificationAdapter - registerForContextMenu(notificationList) - - logicalSlotId = intent.getIntExtra("logicalSlotId", 0) - - // 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) - } else { - appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId) + notificationList.apply { + val context = this@NotificationsActivity + adapter = notificationAdapter + layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) + addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL)) + registerForContextMenu(this) } - title = getString(R.string.profile_notifications_detailed_format, channelTitle) + logicalSlotId = intent.getIntExtra("logicalSlotId", 0) + seId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra("seId", EuiccChannel.SecureElementId::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra("seId") + } ?: EuiccChannel.SecureElementId.DEFAULT + + setChannelTitle( + if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) + getString(R.string.channel_name_format_usb) else + appContainer.customizableTextProvider.formatNonUsbChannelName(logicalSlotId) + ) swipeRefresh.setOnRefreshListener { refresh() @@ -86,6 +97,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker { finish() true } + R.id.help -> { AlertDialog.Builder(this, R.style.AlertDialogTheme).apply { setMessage(R.string.profile_notifications_help) @@ -96,9 +108,14 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker { } true } + else -> super.onOptionsItemSelected(item) } + private fun setChannelTitle(title: CharSequence) { + super.setTitle(getString(R.string.profile_notifications_detailed_format, title)) + } + private fun launchTask(task: suspend () -> Unit) { swipeRefresh.isRefreshing = true @@ -114,29 +131,39 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker { } private fun refresh() { - launchTask { - notificationAdapter.notifications = - euiccChannelManager.withEuiccChannel(logicalSlotId) { channel -> - val nameMap = buildMap { - for (profile in channel.lpa.profiles) { - put(profile.iccid, profile.displayName) - } - } + launchTask { + notificationAdapter.notifications = withEuiccChannel { channel -> + if (channel.hasMultipleSE) { + withContext(Dispatchers.Main) { + val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { + getString(R.string.channel_name_format_usb_se, seId.id) + } else { + appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(logicalSlotId, seId) + } + setChannelTitle(channelTitle) + } + } - channel.lpa.notifications.map { - LocalProfileNotificationWrapper(it, nameMap[it.iccid] ?: "???") - } - } - } + val nameMap = channel.lpa.profiles + .associate { Pair(it.iccid, it.displayName) } + + channel.lpa.notifications.map { + LocalProfileNotificationWrapper(it, nameMap[it.iccid] ?: "???") + } + } + } } + private suspend fun withEuiccChannel(fn: suspend (EuiccChannel) -> R) = + euiccChannelManager.withEuiccChannel(logicalSlotId, seId, fn) + data class LocalProfileNotificationWrapper( val inner: LocalProfileNotification, val profileName: String ) @SuppressLint("ClickableViewAccessibility") - inner class NotificationViewHolder(private val root: View): + inner class NotificationViewHolder(private val root: View) : RecyclerView.ViewHolder(root), View.OnCreateContextMenuListener, OnMenuItemClickListener { private val address: TextView = root.requireViewById(R.id.notification_address) private val sequenceNumber: TextView = @@ -170,7 +197,8 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker { LocalProfileNotification.Operation.Delete -> R.string.profile_notification_operation_delete LocalProfileNotification.Operation.Enable -> R.string.profile_notification_operation_enable LocalProfileNotification.Operation.Disable -> R.string.profile_notification_operation_disable - }) + } + ) fun updateNotification(value: LocalProfileNotificationWrapper) { notification = value @@ -181,10 +209,13 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker { value.inner.seqNumber ) profileName.text = Html.fromHtml( - root.context.getString(R.string.profile_notification_name_format, + root.context.getString( + R.string.profile_notification_name_format, operationToLocalizedText(value.inner.profileManagementOperation), - value.profileName, value.inner.iccid), - Html.FROM_HTML_MODE_COMPACT) + value.profileName, value.inner.iccid + ), + Html.FROM_HTML_MODE_COMPACT + ) } override fun onCreateContextMenu( @@ -204,7 +235,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker { R.id.notification_process -> { launchTask { withContext(Dispatchers.IO) { - euiccChannelManager.withEuiccChannel(logicalSlotId) { channel -> + withEuiccChannel { channel -> channel.lpa.handleNotification(notification.inner.seqNumber) } } @@ -213,10 +244,11 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker { } true } + R.id.notification_delete -> { launchTask { withContext(Dispatchers.IO) { - euiccChannelManager.withEuiccChannel(logicalSlotId) { channel -> + withEuiccChannel { channel -> channel.lpa.deleteNotification(notification.inner.seqNumber) } } @@ -225,11 +257,12 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker { } true } + else -> false } } - inner class NotificationAdapter: RecyclerView.Adapter() { + inner class NotificationAdapter : RecyclerView.Adapter() { var notifications: List = listOf() @SuppressLint("NotifyDataSetChanged") set(value) { @@ -249,4 +282,4 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker { holder.updateNotification(notifications[position]) } -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt index 38d1bc6f..41381db7 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt @@ -9,6 +9,7 @@ import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import im.angry.openeuicc.common.R +import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone import im.angry.openeuicc.util.* import kotlinx.coroutines.flow.onStart @@ -20,11 +21,11 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker { private const val FIELD_ICCID = "iccid" private const val FIELD_NAME = "name" - fun newInstance(slotId: Int, portId: Int, iccid: String, name: String) = - newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId) { + fun newInstance(slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId, iccid: String, name: String) = + newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId, seId) { putString(FIELD_ICCID, iccid) putString(FIELD_NAME, name) - } + } } private val iccid by lazy { @@ -88,7 +89,7 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker { requireParentFragment().lifecycleScope.launch { ensureEuiccChannelManager() euiccChannelManagerService.waitForForegroundTask() - euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, iccid) + euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, seId, iccid) .onStart { parentFragment?.notifyEuiccProfilesChanged() runCatching(::dismiss) @@ -96,4 +97,4 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker { .waitDone() } } -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileRenameFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileRenameFragment.kt index c588254c..46f5b664 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 @@ -12,6 +12,7 @@ import androidx.appcompat.widget.Toolbar import androidx.lifecycle.lifecycleScope import com.google.android.material.textfield.TextInputLayout import im.angry.openeuicc.common.R +import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone import im.angry.openeuicc.util.* import kotlinx.coroutines.launch @@ -24,11 +25,13 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment const val TAG = "ProfileRenameFragment" - fun newInstance(slotId: Int, portId: Int, iccid: String, currentName: String) = - newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId) { - putString(FIELD_ICCID, iccid) - putString(FIELD_CURRENT_NAME, currentName) - } + fun newInstance( + slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId, + iccid: String, currentName: String + ) = newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId, seId) { + putString(FIELD_ICCID, iccid) + putString(FIELD_CURRENT_NAME, currentName) + } } private lateinit var toolbar: Toolbar @@ -65,7 +68,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() } @@ -105,7 +108,7 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment ensureEuiccChannelManager() euiccChannelManagerService.waitForForegroundTask() val response = euiccChannelManagerService - .launchProfileRenameTask(slotId, portId, iccid, newName).waitDone() + .launchProfileRenameTask(slotId, portId, seId, iccid, newName).waitDone() when (response) { is LocalProfileAssistant.ProfileNameTooLongException -> { @@ -128,4 +131,4 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment } } } -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsActivity.kt index bb299a31..8c6097a6 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsActivity.kt @@ -8,7 +8,7 @@ import im.angry.openeuicc.OpenEuiccApplication import im.angry.openeuicc.common.R import im.angry.openeuicc.util.* -class SettingsActivity: AppCompatActivity() { +class SettingsActivity : AppCompatActivity() { private val appContainer get() = (application as OpenEuiccApplication).appContainer @@ -17,8 +17,14 @@ class SettingsActivity: AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_settings) setSupportActionBar(requireViewById(R.id.toolbar)) - setupToolbarInsets() supportActionBar!!.setDisplayHomeAsUpEnabled(true) + + setupRootViewSystemBarInsets( + window.decorView.rootView, arrayOf( + this::activityToolbarInsetHandler + ), consume = false + ) + val settingsFragment = appContainer.uiComponentFactory.createSettingsFragment() supportFragmentManager.beginTransaction() .replace(R.id.settings_container, settingsFragment) @@ -31,6 +37,7 @@ class SettingsActivity: AppCompatActivity() { finish() true } + else -> super.onOptionsItemSelected(item) } -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt index 65541428..2c11756c 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,9 +17,8 @@ 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() { +open class SettingsFragment : PreferenceFragmentCompat(), OpenEuiccContextMarker { private lateinit var developerPref: PreferenceCategory // Hidden developer options switch @@ -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() } @@ -47,10 +47,9 @@ open class SettingsFragment: PreferenceFragmentCompat() { requirePreference("pref_advanced_language").apply { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@apply - isVisible = true - intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { - data = Uri.fromParts("package", requireContext().packageName, null) - } + val uri = Uri.fromParts("package", requireContext().packageName, null) + intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS, uri) + isVisible = intent!!.resolveActivity(requireContext().packageManager) != null } requirePreference("pref_advanced_logs").apply { @@ -81,12 +80,19 @@ open class SettingsFragment: PreferenceFragmentCompat() { 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) } + + requirePreference("pref_info_website").apply { + val uri = appContainer.customizableTextProvider.websiteUri ?: return@apply + isVisible = true + summary = uri.buildUpon().clearQuery().build().toString() + intent = Intent(/* action = */ Intent.ACTION_VIEW, uri) + } } protected fun requirePreference(key: CharSequence) = @@ -94,57 +100,61 @@ open class SettingsFragment: PreferenceFragmentCompat() { override fun onStart() { super.onStart() - setupRootViewInsets(requireView().requireViewById(R.id.recycler_view)) + setupRootViewSystemBarInsets(requireView(), arrayOf( + mainViewPaddingInsetHandler(requireView().requireViewById(R.id.recycler_view)) + )) } @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) @@ -162,4 +172,4 @@ open class SettingsFragment: PreferenceFragmentCompat() { overlayCat.parent?.removePreference(overlayCat) } -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/UsbCcidReaderFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/UsbCcidReaderPermissionFragment.kt similarity index 85% rename from app-common/src/main/java/im/angry/openeuicc/ui/UsbCcidReaderFragment.kt rename to app-common/src/main/java/im/angry/openeuicc/ui/UsbCcidReaderPermissionFragment.kt index 7a52ca0d..c0e1718c 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/UsbCcidReaderFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/UsbCcidReaderPermissionFragment.kt @@ -14,23 +14,22 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Button -import android.widget.ProgressBar import android.widget.TextView import androidx.fragment.app.Fragment -import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope import im.angry.openeuicc.common.R import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** - * A wrapper fragment over EuiccManagementFragment where we handle - * logic specific to USB devices. This is mainly USB permission - * requests, and the fact that USB devices may or may not be - * available by the time the user selects it from MainActivity. + * A fragment to handle USB reader-specific permission flow. If/after + * permission is granted, this fragment simply calls back to MainActivity + * to instantiate the corresponding EuiccManagementFragment(s) for the USB + * reader. * * Having this fragment allows MainActivity to be (mostly) agnostic * of the underlying implementation of different types of channels. @@ -40,7 +39,7 @@ import kotlinx.coroutines.withContext * Note that for now we assume there will only be one USB card reader * device. This is also an implicit assumption in EuiccChannelManager. */ -class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker { +class UsbCcidReaderPermissionFragment : Fragment(), OpenEuiccContextMarker { companion object { const val ACTION_USB_PERMISSION = "im.angry.openeuicc.USB_PERMISSION" } @@ -69,7 +68,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker { private lateinit var text: TextView private lateinit var permissionButton: Button - private lateinit var loadingProgress: ProgressBar + private lateinit var loadingProgress: View private var usbDevice: UsbDevice? = null @@ -142,28 +141,23 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker { euiccChannelManager.tryOpenUsbEuiccChannel() } - loadingProgress.visibility = View.GONE - usbDevice = device if (device != null && !canOpen && !usbManager.hasPermission(device)) { + loadingProgress.visibility = View.GONE text.text = getString(R.string.usb_permission_needed) text.visibility = View.VISIBLE permissionButton.visibility = View.VISIBLE } else if (device != null && canOpen) { - childFragmentManager.commit { - replace( - R.id.child_container, - appContainer.uiComponentFactory.createEuiccManagementFragment( - slotId = EuiccChannelManager.USB_CHANNEL_ID, - portId = 0 - ) - ) + val seIds = withContext(Dispatchers.IO) { + euiccChannelManager.flowEuiccSecureElements(EuiccChannelManager.USB_CHANNEL_ID, 0).toList() } + (requireActivity() as MainActivity).instantiateUsbTabs(seIds) } else { + loadingProgress.visibility = View.GONE text.text = getString(R.string.usb_failed) text.visibility = View.VISIBLE permissionButton.visibility = View.GONE } } -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/preference/LongSummaryPreferenceCategory.kt b/app-common/src/main/java/im/angry/openeuicc/ui/preference/LongSummaryPreferenceCategory.kt index dc16e15f..15336cf3 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/preference/LongSummaryPreferenceCategory.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/preference/LongSummaryPreferenceCategory.kt @@ -7,10 +7,10 @@ import androidx.preference.PreferenceCategory import androidx.preference.PreferenceViewHolder @Suppress("unused") -class LongSummaryPreferenceCategory: PreferenceCategory { - constructor(ctx: Context): super(ctx) - constructor(ctx: Context, attrs: AttributeSet): super(ctx, attrs) - constructor(ctx: Context, attrs: AttributeSet, defStyle: Int): super(ctx, attrs, defStyle) +class LongSummaryPreferenceCategory : PreferenceCategory { + constructor(ctx: Context) : super(ctx) + constructor(ctx: Context, attrs: AttributeSet) : super(ctx, attrs) + constructor(ctx: Context, attrs: AttributeSet, defStyle: Int) : super(ctx, attrs, defStyle) override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/widget/DynamicModeTabLayout.kt b/app-common/src/main/java/im/angry/openeuicc/ui/widget/DynamicModeTabLayout.kt new file mode 100644 index 00000000..75becda1 --- /dev/null +++ b/app-common/src/main/java/im/angry/openeuicc/ui/widget/DynamicModeTabLayout.kt @@ -0,0 +1,51 @@ +package im.angry.openeuicc.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import com.google.android.material.R +import com.google.android.material.tabs.TabLayout + +/** + * A TabLayout that automatically switches to MODE_SCROLLABLE when + * child tabs overflow the full width of the layout. + */ +class DynamicModeTabLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = R.attr.tabStyle +) : TabLayout(context, attrs, defStyleAttr) { + init { + addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + updateModeIfNecessary() + } + } + + private fun updateModeIfNecessary() { + if (width <= 0 || tabCount == 0) return + + val tabStrip = getChildAt(0) as? ViewGroup ?: return + val totalTabWidth = (0 until tabStrip.childCount).sumOf { index -> + val tabView = tabStrip.getChildAt(index) + val layoutParams = tabView.layoutParams as? MarginLayoutParams + tabView.measure( + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + ) + tabView.measuredWidth + (layoutParams?.leftMargin ?: 0) + (layoutParams?.rightMargin ?: 0) + } + + val availableWidth = width - paddingLeft - paddingRight + val shouldScroll = totalTabWidth > availableWidth + val targetMode = if (shouldScroll) MODE_SCROLLABLE else MODE_FIXED + val targetGravity = if (shouldScroll) GRAVITY_START else GRAVITY_FILL + + if (tabMode != targetMode) { + tabMode = targetMode + } + + if (tabGravity != targetGravity) { + tabGravity = targetGravity + } + } +} 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 9e312d4e..ada7c01c 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt @@ -1,6 +1,8 @@ package im.angry.openeuicc.ui.wizard import android.app.assist.AssistContent +import android.content.Context +import android.content.Intent import android.os.Bundle import android.view.View import android.view.WindowManager @@ -17,6 +19,7 @@ import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import im.angry.openeuicc.common.R +import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.ui.BaseEuiccAccessActivity import im.angry.openeuicc.util.* @@ -24,10 +27,26 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import net.typeblog.lpac_jni.LocalProfileAssistant -class DownloadWizardActivity: BaseEuiccAccessActivity() { +class DownloadWizardActivity : BaseEuiccAccessActivity() { + companion object { + const val TAG = "DownloadWizardActivity" + + private const val FIELD_LOGICAL_SLOT_ID = "selectedLogicalSlot" + + fun newIntent( + context: Context, + logicalSlotId: Int = 0, + seId: EuiccChannel.SecureElementId = EuiccChannel.SecureElementId.DEFAULT + ) = Intent(context, DownloadWizardActivity::class.java).apply { + val selectedSyntheticSlotId = DownloadWizardSlotSelectFragment + .encodeSyntheticSlotId(logicalSlotId, seId) + putExtra(FIELD_LOGICAL_SLOT_ID, selectedSyntheticSlotId) + } + } + data class DownloadWizardState( var currentStepFragmentClassName: String?, - var selectedLogicalSlot: Int, + var selectedSyntheticSlotId: Int, var smdp: String, var matchingId: String?, var confirmationCode: String?, @@ -66,7 +85,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { state = DownloadWizardState( currentStepFragmentClassName = null, - selectedLogicalSlot = intent.getIntExtra("selectedLogicalSlot", 0), + selectedSyntheticSlotId = intent.getIntExtra(FIELD_LOGICAL_SLOT_ID, 0), smdp = "", matchingId = null, confirmationCode = null, @@ -94,27 +113,20 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { val navigation = requireViewById(R.id.download_wizard_navigation) val origHeight = navigation.layoutParams.height - - ViewCompat.setOnApplyWindowInsetsListener(navigation) { v, insets -> - val bars = insets.getInsets( - WindowInsetsCompat.Type.systemBars() - or WindowInsetsCompat.Type.displayCutout() - or WindowInsetsCompat.Type.ime() - ) - v.updatePadding(bars.left, 0, bars.right, bars.bottom) - val newParams = navigation.layoutParams - newParams.height = origHeight + bars.bottom - navigation.layoutParams = newParams - WindowInsetsCompat.CONSUMED - } - val fragmentRoot = requireViewById(R.id.step_fragment_container) - ViewCompat.setOnApplyWindowInsetsListener(fragmentRoot) { v, insets -> + + ViewCompat.setOnApplyWindowInsetsListener(window.decorView.rootView) { _, insets -> val bars = insets.getInsets( WindowInsetsCompat.Type.systemBars() - or WindowInsetsCompat.Type.displayCutout() + or WindowInsetsCompat.Type.displayCutout() + or WindowInsetsCompat.Type.ime() ) - v.updatePadding(bars.left, bars.top, bars.right, 0) + + fragmentRoot.updatePadding(bars.left, bars.top, bars.right, 0) + navigation.updatePadding(bars.left, 0, bars.right, bars.bottom) + val newNavParams = navigation.layoutParams + newNavParams.height = origHeight + bars.bottom + navigation.layoutParams = newNavParams WindowInsetsCompat.CONSUMED } } @@ -123,8 +135,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 @@ -151,7 +163,7 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString("currentStepFragmentClassName", state.currentStepFragmentClassName) - outState.putInt("selectedLogicalSlot", state.selectedLogicalSlot) + outState.putInt("selectedLogicalSlot", state.selectedSyntheticSlotId) outState.putString("smdp", state.smdp) outState.putString("matchingId", state.matchingId) outState.putString("confirmationCode", state.confirmationCode) @@ -167,16 +179,20 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { "currentStepFragmentClassName", state.currentStepFragmentClassName ) - state.selectedLogicalSlot = - savedInstanceState.getInt("selectedLogicalSlot", state.selectedLogicalSlot) + state.selectedSyntheticSlotId = + savedInstanceState.getInt("selectedSyntheticSlotId", state.selectedSyntheticSlotId) state.smdp = savedInstanceState.getString("smdp", state.smdp) state.matchingId = savedInstanceState.getString("matchingId", state.matchingId) state.imei = savedInstanceState.getString("imei", state.imei) state.downloadStarted = savedInstanceState.getBoolean("downloadStarted", state.downloadStarted) state.downloadTaskID = savedInstanceState.getLong("downloadTaskID", state.downloadTaskID) - state.confirmationCode = savedInstanceState.getString("confirmationCode", state.confirmationCode) - state.confirmationCodeRequired = savedInstanceState.getBoolean("confirmationCodeRequired", state.confirmationCodeRequired) + state.confirmationCode = + savedInstanceState.getString("confirmationCode", state.confirmationCode) + state.confirmationCodeRequired = savedInstanceState.getBoolean( + "confirmationCodeRequired", + state.confirmationCodeRequired + ) } private fun onPrevPressed() { @@ -200,10 +216,13 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { progressBar.isIndeterminate = true lifecycleScope.launch(Dispatchers.Main) { - if (state.selectedLogicalSlot >= 0) { + if (state.selectedSyntheticSlotId >= 0) { try { + val (slotId, seId) = DownloadWizardSlotSelectFragment.decodeSyntheticSlotId( + state.selectedSyntheticSlotId + ) // This is run on IO by default - euiccChannelManager.withEuiccChannel(state.selectedLogicalSlot) { channel -> + euiccChannelManager.withEuiccChannel(slotId, seId) { channel -> // Be _very_ sure that the channel we got is valid if (!channel.valid) throw EuiccChannelManager.EuiccChannelNotFoundException() } @@ -327,4 +346,4 @@ class DownloadWizardActivity: BaseEuiccAccessActivity() { open fun beforeNext() {} } -} \ No newline at end of file +} 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 1c69de58..2ea991e4 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 @@ -4,9 +4,11 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.EditText import androidx.core.widget.addTextChangedListener import com.google.android.material.textfield.TextInputLayout import im.angry.openeuicc.common.R +import im.angry.openeuicc.util.* class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepFragment() { private var inputComplete = false @@ -16,17 +18,25 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF override val hasPrev: Boolean get() = true - private lateinit var smdp: TextInputLayout - private lateinit var matchingId: TextInputLayout - private lateinit var confirmationCode: TextInputLayout - private lateinit var imei: TextInputLayout + private val address: EditText by lazy { + requireView().requireViewById(R.id.profile_download_server).editText!! + } + private val matchingId: EditText by lazy { + requireView().requireViewById(R.id.profile_download_code).editText!! + } + private val confirmationCode: EditText by lazy { + requireView().requireViewById(R.id.profile_download_confirmation_code).editText!! + } + private val imei: EditText by lazy { + requireView().requireViewById(R.id.profile_download_imei).editText!! + } private fun saveState() { - state.smdp = smdp.editText!!.text.toString().trim() + state.smdp = address.text.toString().trim() // Treat empty inputs as null -- this is important for the download step - state.matchingId = matchingId.editText!!.text.toString().trim().ifBlank { null } - state.confirmationCode = confirmationCode.editText!!.text.toString().trim().ifBlank { null } - state.imei = imei.editText!!.text.toString().ifBlank { null } + state.matchingId = matchingId.text.toString().trim().ifBlank { null } + state.confirmationCode = confirmationCode.text.toString().trim().ifBlank { null } + state.imei = imei.text.toString().ifBlank { null } } override fun beforeNext() = saveState() @@ -41,40 +51,30 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF DownloadWizardMethodSelectFragment() } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.fragment_download_details, container, false) - smdp = view.requireViewById(R.id.profile_download_server) - matchingId = view.requireViewById(R.id.profile_download_code) - confirmationCode = view.requireViewById(R.id.profile_download_confirmation_code) - imei = view.requireViewById(R.id.profile_download_imei) - smdp.editText!!.addTextChangedListener { - updateInputCompleteness() - } - confirmationCode.editText!!.addTextChangedListener { - updateInputCompleteness() - } - return view + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.fragment_download_details, container, /* attachToRoot = */ false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + address.addTextChangedListener(onTextChanged = ::handlePasteLPAString) + address.addTextChangedListener { updateInputCompleteness() } + matchingId.addTextChangedListener(onTextChanged = ::handlePasteLPAString) + confirmationCode.addTextChangedListener { updateInputCompleteness() } } override fun onStart() { super.onStart() - smdp.editText!!.setText(state.smdp) - matchingId.editText!!.setText(state.matchingId) - confirmationCode.editText!!.setText(state.confirmationCode) - imei.editText!!.setText(state.imei) + address.setText(state.smdp) + matchingId.setText(state.matchingId) + confirmationCode.setText(state.confirmationCode) + imei.setText(state.imei) updateInputCompleteness() if (state.confirmationCodeRequired) { - confirmationCode.editText!!.requestFocus() - confirmationCode.editText!!.hint = - getString(R.string.profile_download_confirmation_code_required) + confirmationCode.requestFocus() + confirmationCode.setHint(R.string.profile_download_confirmation_code_required) } else { - confirmationCode.editText!!.hint = - getString(R.string.profile_download_confirmation_code) + confirmationCode.setHint(R.string.profile_download_confirmation_code) } } @@ -83,10 +83,19 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF saveState() } + private fun handlePasteLPAString(text: CharSequence?, start: Int, before: Int, count: Int) { + if (start > 0 || before > 0) return // only handle insertions at the beginning + if (text == null || !text.startsWith("LPA:", ignoreCase = true)) return + val parsed = LPAString.parse(text) + address.setText(parsed.address) + matchingId.setText(parsed.matchingId) + if (parsed.confirmationCodeRequired) confirmationCode.requestFocus() + } + private fun updateInputCompleteness() { - inputComplete = isValidAddress(smdp.editText!!.text) + inputComplete = isValidAddress(address.text) if (state.confirmationCodeRequired) { - inputComplete = inputComplete && confirmationCode.editText!!.text.isNotEmpty() + inputComplete = inputComplete && confirmationCode.text.isNotEmpty() } refreshButtons() } @@ -98,11 +107,11 @@ private fun isValidAddress(input: CharSequence): Boolean { var port = 443 if (input.contains(':')) { val portIndex = input.lastIndexOf(':') - fqdn = input.substring(0, portIndex) + fqdn = input.take(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 + if (port !in 1..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('.')) { @@ -114,4 +123,4 @@ private fun isValidAddress(input: CharSequence): Boolean { } } 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..d20cdb6c 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 } @@ -136,4 +138,4 @@ class DownloadWizardDiagnosticsFragment : DownloadWizardActivity.DownloadWizardS ret.toString() } -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt index 4b02b7a1..e68e0c17 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt @@ -170,4 +170,4 @@ class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizard } } -} \ No newline at end of file +} 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 342a687f..a1cb091a 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 @@ -17,8 +18,9 @@ import im.angry.openeuicc.util.* import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import net.typeblog.lpac_jni.ProfileDownloadInput +import net.typeblog.lpac_jni.ProfileDownloadState import net.typeblog.lpac_jni.LocalProfileAssistant -import net.typeblog.lpac_jni.ProfileDownloadCallback class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStepFragment() { companion object { @@ -26,11 +28,11 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep * An array of LPA-side state types, mapping 1:1 to progressItems */ val LPA_PROGRESS_STATES = arrayOf( - ProfileDownloadCallback.DownloadState.Preparing, - ProfileDownloadCallback.DownloadState.Connecting, - ProfileDownloadCallback.DownloadState.Authenticating, - ProfileDownloadCallback.DownloadState.Downloading, - ProfileDownloadCallback.DownloadState.Finalizing, + ProfileDownloadState.Preparing, + ProfileDownloadState.Connecting, + ProfileDownloadState.Authenticating, + ProfileDownloadState.Downloading, + ProfileDownloadState.Finalizing, ) } @@ -42,19 +44,17 @@ 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() @@ -122,8 +122,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) @@ -133,9 +138,8 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep refreshButtons() } - is EuiccChannelManagerService.ForegroundTaskState.InProgress -> { + is EuiccChannelManagerService.ForegroundTaskState.InProgress -> updateProgress(it.progress) - } else -> {} } @@ -150,7 +154,12 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep } else { euiccChannelManagerService.waitForForegroundTask() - val (slotId, portId) = euiccChannelManager.withEuiccChannel(state.selectedLogicalSlot) { channel -> + val (logicalSlotId, seId) = DownloadWizardSlotSelectFragment.decodeSyntheticSlotId(state.selectedSyntheticSlotId) + + val (slotId, portId) = euiccChannelManager.withEuiccChannel( + logicalSlotId, + seId + ) { channel -> Pair(channel.slotId, channel.portId) } @@ -158,12 +167,8 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep state.downloadStarted = true val ret = euiccChannelManagerService.launchProfileDownloadTask( - slotId, - portId, - state.smdp, - state.matchingId, - state.confirmationCode, - state.imei + slotId, portId, seId, + ProfileDownloadInput(state.smdp, state.matchingId, state.imei, state.confirmationCode) ) state.downloadTaskID = ret.taskId @@ -174,7 +179,7 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep private fun updateProgress(progress: Int) { showProgressBar(progress) - val lpaState = ProfileDownloadCallback.lookupStateFromProgress(progress) + val lpaState = ProfileDownloadState.lookupStateFromProgress(progress) val stateIndex = LPA_PROGRESS_STATES.indexOf(lpaState) if (stateIndex > 0) { @@ -197,9 +202,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 -> { @@ -222,6 +233,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) + } } } } @@ -240,4 +260,4 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep holder.bind(progressItems[position]) } } -} \ No newline at end of file +} 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..ad3cece9 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 @@ -7,37 +7,45 @@ import android.view.View import android.view.ViewGroup import android.widget.CheckBox import android.widget.TextView -import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder import im.angry.openeuicc.common.R +import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.util.* +import kotlinx.coroutines.flow.flatMapConcat 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 { - const val LOW_NVRAM_THRESHOLD = - 30 * 1024 // < 30 KiB, alert about potential download failure + internal fun encodeSyntheticSlotId(logicalSlotId: Int, seId: EuiccChannel.SecureElementId): Int = + (logicalSlotId shl 16) + seId.id + + internal fun decodeSyntheticSlotId(id: Int): Pair = + Pair(id shr 16, EuiccChannel.SecureElementId.createFromInt(id and 0xFF)) } private data class SlotInfo( val logicalSlotId: Int, val isRemovable: Boolean, val hasMultiplePorts: Boolean, + val hasMultipleSEs: Boolean, val portId: Int, + val seId: EuiccChannel.SecureElementId, val eID: String, val freeSpace: Int, val imei: String, val enabledProfileName: String?, - val intrinsicChannelName: String?, - ) + ) { + // A synthetic slot ID used to uniquely identify this slot + SE chip in the download process + // We assume we don't have anywhere near 2^16 ports... + val syntheticSlotId: Int = (logicalSlotId shl 16) + seId.id + } private var loaded = false @@ -57,25 +65,6 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null - override fun beforeNext() { - super.beforeNext() - - if (adapter.selected.freeSpace < LOW_NVRAM_THRESHOLD) { - val activity = requireActivity() - - AlertDialog.Builder(requireContext()).apply { - setTitle(R.string.profile_download_low_nvram_title) - setMessage(R.string.profile_download_low_nvram_message) - setCancelable(true) - setPositiveButton(android.R.string.ok, null) - setNegativeButton(android.R.string.cancel) { _, _ -> - activity.finish() - } - show() - } - } - } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -86,7 +75,12 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt recyclerView.adapter = adapter recyclerView.layoutManager = LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false) - recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)) + recyclerView.addItemDecoration( + DividerItemDecoration( + requireContext(), + LinearLayoutManager.VERTICAL + ) + ) return view } @@ -98,37 +92,41 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt } @SuppressLint("NotifyDataSetChanged", "MissingPermission") + @OptIn(kotlinx.coroutines.FlowPreview::class) private suspend fun init() { ensureEuiccChannelManager() showProgressBar(-1) - val slots = euiccChannelManager.flowAllOpenEuiccPorts().map { (slotId, portId) -> - euiccChannelManager.withEuiccChannel(slotId, portId) { channel -> - SlotInfo( - channel.logicalSlotId, - channel.port.card.isRemovable, - channel.port.card.ports.size > 1, - channel.portId, - channel.lpa.eID, - channel.lpa.euiccInfo2?.freeNvram ?: 0, - try { - telephonyManager.getImei(channel.logicalSlotId) ?: "" - } catch (e: Exception) { - "" - }, - channel.lpa.profiles.enabled?.displayName, - channel.intrinsicChannelName, - ) + val slots = euiccChannelManager.flowAllOpenEuiccPorts().flatMapConcat { (slotId, portId) -> + euiccChannelManager.flowEuiccSecureElements(slotId, portId).map { seId -> + euiccChannelManager.withEuiccChannel(slotId, portId, seId) { channel -> + SlotInfo( + channel.logicalSlotId, + channel.port.card.isRemovable, + channel.port.card.ports.size > 1, + channel.hasMultipleSE, + channel.portId, + channel.seId, + channel.lpa.eID, + channel.lpa.euiccInfo2?.freeNvram ?: 0, + try { + telephonyManager.getImei(channel.logicalSlotId) ?: "" + } catch (e: Exception) { + "" + }, + channel.lpa.profiles.enabled?.displayName, + ) + } } - }.toList().sortedBy { it.logicalSlotId } + }.toList().sortedBy { it.syntheticSlotId } adapter.slots = slots // Ensure we always have a selected slot by default - val selectedIdx = slots.indexOfFirst { it.logicalSlotId == state.selectedLogicalSlot } + val selectedIdx = slots.indexOfFirst { it.syntheticSlotId == state.selectedSyntheticSlotId } adapter.currentSelectedIdx = if (selectedIdx > 0) { selectedIdx } else { if (slots.isNotEmpty()) { - state.selectedLogicalSlot = slots[0].logicalSlotId + state.selectedSyntheticSlotId = slots[0].syntheticSlotId } 0 } @@ -168,7 +166,8 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt adapter.notifyItemChanged(lastIdx) adapter.notifyItemChanged(curIdx) // Selected index isn't logical slot ID directly, needs a conversion - state.selectedLogicalSlot = adapter.slots[adapter.currentSelectedIdx].logicalSlotId + state.selectedSyntheticSlotId = + adapter.slots[adapter.currentSelectedIdx].syntheticSlotId state.imei = adapter.slots[adapter.currentSelectedIdx].imei } @@ -187,12 +186,22 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt } title.text = if (item.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { - item.intrinsicChannelName ?: root.context.getString(R.string.usb) + if (item.hasMultipleSEs) { + root.context.getString(R.string.channel_name_format_usb_se, item.seId.id) + } else { + root.context.getString(R.string.channel_name_format_usb) + } + } else if (item.hasMultipleSEs) { + appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId( + item.logicalSlotId, + item.seId + ) } else { - appContainer.customizableTextProvider.formatInternalChannelName(item.logicalSlotId) + appContainer.customizableTextProvider.formatNonUsbChannelName(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 } @@ -206,7 +215,8 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt get() = slots[currentSelectedIdx] override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SlotItemHolder { - val root = LayoutInflater.from(parent.context).inflate(R.layout.download_slot_item, parent, false) + val root = LayoutInflater.from(parent.context) + .inflate(R.layout.download_slot_item, parent, false) return SlotItemHolder(root) } @@ -216,4 +226,4 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt holder.bind(slots[position], position) } } -} \ No newline at end of file +} 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..d1f3ae3b --- /dev/null +++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/SimplifiedErrorMessages.kt @@ -0,0 +1,176 @@ +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 + ), + UnavailableProfile( + R.string.download_wizard_error_profile_unavailable, + R.string.download_wizard_error_suggest_contact_carrier + ), + 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 { + // @formatter:off + // Stage: InitiateAuthentication + put("8.8.1" to "3.8", UnknownHost) // Invalid SM-DP+ Address. + put("8.8.2" to "3.1", UnsupportedProfile) // None of the proposed Public Key Identifiers is supported by the SM-DP+. + put("8.8.3" to "3.1", UnsupportedProfile) // The SVN indicated by the eUICC is not supported by the SM-DP+. + put("8.8.4" to "3.7", UnsupportedProfile) // The SM-DP+ has no CERT.DPAuth.ECDSA signed by one of the GSMA CI Public Key supported by the eUICC. + + // Stage: AuthenticateClient + put("8.1" to "4.8", InsufficientMemory) // eUICC does not have sufficient space for this Profile. + put("8.1.1" to "2.1", EIDNotSupported) // eUICC does not support the EID. + put("8.1.1" to "3.8", EIDMismatch) // EID doesn't match the expected value. + put("8.1.2" to "6.1", UnsupportedProfile) // EUM Certificate is invalid. + put("8.1.2" to "6.3", UnsupportedProfile) // EUM Certificate has expired. + put("8.1.3" to "6.1", UnsupportedProfile) // eUICC Certificate is invalid. + put("8.1.3" to "6.3", UnsupportedProfile) // eUICC Certificate has expired. + put("8.2" to "1.2", UnreleasedProfile) // Profile has not yet been released. + put("8.2.5" to "4.3", UnavailableProfile) // No eligible Profile for this eUICC/Device. + put("8.2.6" to "3.8", MatchingIDRefused) // MatchingID (AC_Token or EventID) is refused. + put("8.8" to "4.2", EIDNotSupported) // eUICC is not supported by the SM-DP+. + put("8.8.5" to "6.4", ProfileRetriesExceeded) // The maximum number of retries for the Profile download order has been exceeded. + put("8.10.1" to "3.9", UnsupportedProfile) // The RSP session identified by the TransactionID is unknown. + put("8.11.1" to "3.9", UnsupportedProfile) // Unknown CI Public Key. + + // Stage: GetBoundProfilePackage + put("8.2" to "3.7", UnavailableProfile) // BPP is not available for a new binding. + put("8.2.7" to "2.2", ConfirmationCodeMissing) // Confirmation Code is missing. + put("8.2.7" to "3.8", ConfirmationCodeRefused) // Confirmation Code is refused. + put("8.2.7" to "6.4", ConfirmationCodeRetriesExceeded) // The maximum number of retries for the Confirmation Code has been exceeded. + + // Stage: AuthenticateClient, GetBoundProfilePackage + put("8.1" to "6.1", UnsupportedProfile) // eUICC Signature is invalid. + put("8.8.5" to "4.10", ProfileExpired) // The Download order has expired. + // @formatter:on + } + + 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/EuiccChannelFragmentUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt index b44bef89..444f5b18 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt @@ -1,5 +1,6 @@ package im.angry.openeuicc.util +import android.os.Build import android.os.Bundle import androidx.fragment.app.Fragment import im.angry.openeuicc.core.EuiccChannel @@ -9,6 +10,7 @@ import im.angry.openeuicc.ui.BaseEuiccAccessActivity private const val FIELD_SLOT_ID = "slotId" private const val FIELD_PORT_ID = "portId" +private const val FIELD_SE_ID = "seId" interface EuiccChannelFragmentMarker : OpenEuiccContextMarker @@ -17,12 +19,19 @@ private typealias BundleSetter = Bundle.() -> Unit // We must use extension functions because there is no way to add bounds to the type of "self" // in the definition of an interface, so the only way is to limit where the extension functions // can be applied. -fun newInstanceEuicc(clazz: Class, slotId: Int, portId: Int, addArguments: BundleSetter = {}): T - where T : Fragment, T : EuiccChannelFragmentMarker = +fun newInstanceEuicc( + clazz: Class, + slotId: Int, + portId: Int, + seId: EuiccChannel.SecureElementId, + addArguments: BundleSetter = {} +): T + where T : Fragment, T : EuiccChannelFragmentMarker = clazz.getDeclaredConstructor().newInstance().apply { arguments = Bundle() arguments!!.putInt(FIELD_SLOT_ID, slotId) arguments!!.putInt(FIELD_PORT_ID, portId) + arguments!!.putParcelable(FIELD_SE_ID, seId) arguments!!.addArguments() } @@ -30,31 +39,43 @@ fun newInstanceEuicc(clazz: Class, slotId: Int, portId: Int, addArguments // `channel` requires that the channel actually exists in EuiccChannelManager, which is // not always the case during operations such as switching val T.slotId: Int - where T : Fragment, T : EuiccChannelFragmentMarker + where T : Fragment, T : EuiccChannelFragmentMarker get() = requireArguments().getInt(FIELD_SLOT_ID) val T.portId: Int - where T : Fragment, T : EuiccChannelFragmentMarker + where T : Fragment, T : EuiccChannelFragmentMarker get() = requireArguments().getInt(FIELD_PORT_ID) +val T.seId: EuiccChannel.SecureElementId + where T : Fragment, T : EuiccChannelFragmentMarker + get() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requireArguments().getParcelable( + FIELD_SE_ID, + EuiccChannel.SecureElementId::class.java + )!! + } else { + @Suppress("DEPRECATION") + requireArguments().getParcelable(FIELD_SE_ID)!! + } val T.isUsb: Boolean - where T : Fragment, T : EuiccChannelFragmentMarker + where T : Fragment, T : EuiccChannelFragmentMarker get() = slotId == EuiccChannelManager.USB_CHANNEL_ID private fun T.requireEuiccActivity(): BaseEuiccAccessActivity - where T : Fragment, T : OpenEuiccContextMarker = + where T : Fragment, T : OpenEuiccContextMarker = requireActivity() as BaseEuiccAccessActivity val T.euiccChannelManager: EuiccChannelManager - where T : Fragment, T : OpenEuiccContextMarker + where T : Fragment, T : OpenEuiccContextMarker get() = requireEuiccActivity().euiccChannelManager val T.euiccChannelManagerService: EuiccChannelManagerService - where T : Fragment, T : OpenEuiccContextMarker + where T : Fragment, T : OpenEuiccContextMarker get() = requireEuiccActivity().euiccChannelManagerService suspend fun T.withEuiccChannel(fn: suspend (EuiccChannel) -> R): R - where T : Fragment, T : EuiccChannelFragmentMarker { + where T : Fragment, T : EuiccChannelFragmentMarker { ensureEuiccChannelManager() - return euiccChannelManager.withEuiccChannel(slotId, portId, fn) + return euiccChannelManager.withEuiccChannel(slotId, portId, seId, fn) } suspend fun T.ensureEuiccChannelManager() where T : Fragment, T : OpenEuiccContextMarker = @@ -69,4 +90,4 @@ fun T.notifyEuiccProfilesChanged() where T : Fragment { interface EuiccProfilesChangedListener { fun onEuiccProfilesChanged() -} \ No newline at end of file +} 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 63a81f19..91a6f729 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 @@ -7,7 +7,7 @@ data class LPAString( val confirmationCodeRequired: Boolean, ) { companion object { - fun parse(input: String): LPAString { + fun parse(input: CharSequence): LPAString { var token = input if (token.startsWith("LPA:", ignoreCase = true)) token = token.drop(4) val components = token.split('$').map { it.trim().ifBlank { null } } @@ -31,4 +31,4 @@ data class LPAString( ) return parts.joinToString("$").trimEnd('$') } -} \ No newline at end of file +} diff --git a/app-common/src/main/java/im/angry/openeuicc/util/LPAUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/LPAUtils.kt index 9f95412e..d2aa3fab 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/LPAUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/LPAUtils.kt @@ -5,6 +5,7 @@ import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannelManager import net.typeblog.lpac_jni.LocalProfileAssistant import net.typeblog.lpac_jni.LocalProfileInfo +import net.typeblog.lpac_jni.ProfileClass const val TAG = "LPAUtils" @@ -16,7 +17,7 @@ val LocalProfileInfo.isEnabled: Boolean get() = state == LocalProfileInfo.State.Enabled val List.operational: List - get() = filter { it.profileClass == LocalProfileInfo.Clazz.Operational } + get() = filter { it.profileClass == ProfileClass.Operational || it.isEnabled } val List.enabled: LocalProfileInfo? get() = find { it.isEnabled } @@ -79,9 +80,10 @@ fun LocalProfileAssistant.disableActiveProfileKeepIccId(refresh: Boolean): Strin suspend inline fun EuiccChannelManager.beginTrackedOperation( slotId: Int, portId: Int, + seId: EuiccChannel.SecureElementId, op: () -> Boolean ) { - val latestSeq = withEuiccChannel(slotId, portId) { channel -> + val latestSeq = withEuiccChannel(slotId, portId, seId) { channel -> channel.lpa.notifications.firstOrNull()?.seqNumber ?: 0 } @@ -91,7 +93,7 @@ suspend inline fun EuiccChannelManager.beginTrackedOperation( try { // Note that the exact instance of "channel" might have changed here if reconnected; // this is why we need to use two distinct calls to withEuiccChannel() - withEuiccChannel(slotId, portId) { channel -> + withEuiccChannel(slotId, portId, seId) { channel -> channel.lpa.notifications.filter { it.seqNumber > latestSeq }.forEach { Log.d(TAG, "Handling notification $it") channel.lpa.handleNotification(it.seqNumber) @@ -103,4 +105,4 @@ suspend inline fun EuiccChannelManager.beginTrackedOperation( } } Log.d(TAG, "Operation complete") -} \ No newline at end of file +} 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 5f4aec44..1b2a0747 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,6 +5,7 @@ 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 @@ -36,8 +37,8 @@ internal object PreferenceKeys { 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" @@ -48,11 +49,9 @@ internal object PreferenceConstants { # Refs: # eUICC standard + # Even if this AID is deleted here, it will still be attempted as the last resort. $EUICC_DEFAULT_ISDR_AID - # eSTK.me - A06573746B6D65FFFFFFFF4953442D52 - # eSIM.me A0000005591010000000008900000300 @@ -61,6 +60,20 @@ internal object PreferenceConstants { # Xesim A0000005591010FFFFFFFF8900000177 + + # LinksField + A000000559104C696E6B736669656C64 + + # ESTKme SE0 + # For multi-SE eSTK.me products, this will always be attempted even if removed from the list + ${ESTKme.ESTK_SE0_AID.encodeHex()} + + # ESTKme SE1 + # For multi-SE eSTK.me products, this will always be attempted even if removed from the list + ${ESTKme.ESTK_SE1_AID.encodeHex()} + + # ESTKme AUX (deprecated, use SE0 instead) + A06573746B6D65FFFFFFFF4953442D52 """.trimIndent() } @@ -80,12 +93,12 @@ open class PreferenceRepository(private val context: Context) { val developerOptionsEnabledFlow = bindFlow(PreferenceKeys.DEVELOPER_OPTIONS_ENABLED, false) val unfilteredProfileListFlow = bindFlow(PreferenceKeys.UNFILTERED_PROFILE_LIST, false) val ignoreTLSCertificateFlow = bindFlow(PreferenceKeys.IGNORE_TLS_CERTIFICATE, false) - val euiccMemoryResetFlow = bindFlow(PreferenceKeys.EUICC_MEMORY_RESET, false) 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, 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 079853eb..f1ca5598 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 @@ -31,83 +31,23 @@ fun formatFreeSpace(size: Int): String = /** * 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 + * If none is found, at least EUICC_DEFAULT_ISDR_AID is returned. + * If EUICC_DEFAULT_ISDR_AID is not contained in the list, it is always appended as the last + * element. */ -fun parseIsdrAidList(s: String): List = - s.split('\n') +fun parseIsdrAidList(s: String): List { + val ret = s.split('\n') + .asSequence() .map(String::trim) .filter { !it.startsWith('#') } .map(String::trim) .filter(String::isNotEmpty) .mapNotNull { runCatching(it::decodeHex).getOrNull() } - .ifEmpty { listOf(EUICC_DEFAULT_ISDR_AID.decodeHex()) } + .toList() -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') - } + return if (!ret.any { it.contentEquals(EUICC_DEFAULT_ISDR_AID.decodeHex()) }) { + ret + EUICC_DEFAULT_ISDR_AID.decodeHex() + } else { + ret } - - 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 +} diff --git a/app-common/src/main/java/im/angry/openeuicc/util/TelephonyCompat.kt b/app-common/src/main/java/im/angry/openeuicc/util/TelephonyCompat.kt index b831f012..c124b32e 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/TelephonyCompat.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/TelephonyCompat.kt @@ -1,16 +1,9 @@ package im.angry.openeuicc.util -import android.content.Context import android.os.Build import android.se.omapi.Reader import android.se.omapi.SEService import android.telephony.TelephonyManager -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine val TelephonyManager.activeModemCountCompat: Int get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -55,16 +48,11 @@ interface UiccPortInfoCompat { val logicalSlotIndex: Int } -data class FakeUiccCardInfoCompat( - override val physicalSlotIndex: Int, -): UiccCardInfoCompat { - override val ports: Collection = - listOf(FakeUiccPortInfoCompat(this)) +data class FakeUiccCardInfoCompat(override val physicalSlotIndex: Int) : UiccCardInfoCompat { + override val ports: Collection = listOf(FakeUiccPortInfoCompat(this)) } -data class FakeUiccPortInfoCompat( - override val card: UiccCardInfoCompat -): UiccPortInfoCompat { +data class FakeUiccPortInfoCompat(override val card: UiccCardInfoCompat) : UiccPortInfoCompat { override val portIndex: Int = 0 override val logicalSlotIndex: Int = card.physicalSlotIndex -} \ No newline at end of file +} 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..be36a554 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 @@ -11,6 +11,7 @@ import androidx.activity.result.ActivityResultCaller import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams @@ -35,46 +36,71 @@ fun DialogFragment.setWidthPercent(percentage: Int) { } /** - * Call this method (in onActivityCreated or later) - * to make the dialog near-full screen. + * A handler function for `setupRootViewSystemBarInsets`, which is intended to set up + * insets for the top toolbar, in the case where the activity contains a toolbar with the default + * ID `R.id.toolbar`, and a spacer `R.id.toolbar_spacer` for status bar background. */ -fun DialogFragment.setFullScreen() { - dialog?.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) -} - -fun AppCompatActivity.setupToolbarInsets() { - val spacer = requireViewById(R.id.toolbar_spacer) - ViewCompat.setOnApplyWindowInsetsListener(requireViewById(R.id.toolbar)) { v, insets -> - val bars = insets.getInsets( - WindowInsetsCompat.Type.systemBars() - or WindowInsetsCompat.Type.displayCutout() - ) - - v.updateLayoutParams { - topMargin = bars.top +fun AppCompatActivity.activityToolbarInsetHandler(insets: Insets) { + val toolbar = requireViewById(R.id.toolbar) + toolbar.apply { + updateLayoutParams { + topMargin = insets.top } - v.updatePadding(bars.left, v.paddingTop, bars.right, v.paddingBottom) + updatePadding(insets.left, paddingTop, insets.right, paddingBottom) + } - spacer.updateLayoutParams { - height = v.top - } - - WindowInsetsCompat.CONSUMED + requireViewById(R.id.toolbar_spacer).updateLayoutParams { + height = toolbar.top } } -fun setupRootViewInsets(view: ViewGroup) { +/** + * A handler function for `setupRootViewSystemBarInsets`, which is intended to set up + * left, right, and bottom padding for a "main view", usually a RecyclerView or a ScrollView. + * + * It ignores top paddings because that should be handled by the toolbar handler for the activity. + * See above. + */ +fun mainViewPaddingInsetHandler(v: View): (Insets) -> Unit = { insets -> // Disable clipToPadding to make sure content actually display - view.clipToPadding = false - ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> + if (v is ViewGroup) { + v.clipToPadding = false + } + v.updatePadding(insets.left, v.paddingTop, insets.right, insets.bottom) +} + +/** + * A wrapper for `ViewCompat.setOnApplyWindowInsetsListener`, which should only be called + * on a root view of a certain component. For activities, this should usually be `window.decorView.rootView`, + * and for Fragments this should be the outermost layer of view it inflated during creation. + * + * This function takes in an array of handler functions, and is expected to only ever be called + * on views belonging to the same hierarchy. All sibling views should be handled from the array of + * handler functions, rather than a separate call to this function OR `ViewCompat.setOnApplyWindowInsetsListener`. + * + * The reason this function exists is that on some versions of Android, the dispatch of window inset + * events is completely broken. If an inset event is handled by a view, it will never be seen by any of + * its siblings. By wrapping this function and restricting its use to only the "main" view hierarchy and + * handling all sibling views using our own handler functions, we work around that issue. + * + * Note that this function by default returns `WindowInsetCompat.CONSUME`, which will prevent the event from + * being dispatched further to child views. This may be a problem for activities that act as fragment hosts. + * In that case, please set `consume = false` in order for the event to propagate. + */ +fun setupRootViewSystemBarInsets(rootView: View, handlers: Array<(Insets) -> Unit>, consume: Boolean = true) { + ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets -> val bars = insets.getInsets( WindowInsetsCompat.Type.systemBars() - or WindowInsetsCompat.Type.displayCutout() + or WindowInsetsCompat.Type.displayCutout() ) - v.updatePadding(bars.left, v.paddingTop, bars.right, bars.bottom) + handlers.forEach { it(bars) } - WindowInsetsCompat.CONSUMED + if (consume) { + WindowInsetsCompat.CONSUMED + } else { + insets + } } } @@ -102,8 +128,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) @@ -121,4 +147,4 @@ fun T.setupLogSaving( lastFileName = getLogFileName() launchSaveIntent.launch(lastFileName) } -} \ No newline at end of file +} 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 046657f8..3f00303d 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 @@ -1,6 +1,7 @@ package im.angry.openeuicc.util import android.content.Context +import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.graphics.Bitmap import android.se.omapi.SEService @@ -17,19 +18,18 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlin.RuntimeException import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine +val Context.packageInfo: PackageInfo + get() = packageManager.getPackageInfo(packageName, /* flags = */ 0)!! + val Context.selfAppVersion: String - get() = - try { - val pInfo = packageManager.getPackageInfo(packageName, 0) - pInfo.versionName!! - } catch (e: PackageManager.NameNotFoundException) { - throw RuntimeException(e) - } + get() = packageInfo.versionName!! + +val Context.selfAppVersionCode: Long + get() = packageInfo.longVersionCode suspend fun readSelfLog(lines: Int = 2048): String = withContext(Dispatchers.IO) { try { @@ -96,13 +96,12 @@ inline fun Bitmap.use(f: (Bitmap) -> T): T = recycle() } -fun decodeQrFromBitmap(bmp: Bitmap): String? = - runCatching { - val pixels = IntArray(bmp.width * bmp.height) - bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height) +fun decodeQrFromBitmap(bmp: Bitmap): String? = runCatching { + val pixels = IntArray(bmp.width * bmp.height) + bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height) - val luminanceSource = RGBLuminanceSource(bmp.width, bmp.height, pixels) - val binaryBmp = BinaryBitmap(HybridBinarizer(luminanceSource)) + val luminanceSource = RGBLuminanceSource(bmp.width, bmp.height, pixels) + val binaryBmp = BinaryBitmap(HybridBinarizer(luminanceSource)) - QRCodeReader().decode(binaryBmp).text - }.getOrNull() + QRCodeReader().decode(binaryBmp).text +}.getOrNull() diff --git a/app-common/src/main/java/im/angry/openeuicc/util/Vendors.kt b/app-common/src/main/java/im/angry/openeuicc/util/Vendors.kt index 529f9ee8..1ad6f335 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/Vendors.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/Vendors.kt @@ -1,50 +1,59 @@ package im.angry.openeuicc.util import android.util.Log -import im.angry.openeuicc.core.ApduInterfaceAtrProvider import im.angry.openeuicc.core.EuiccChannel import net.typeblog.lpac_jni.Version data class EuiccVendorInfo( - val skuName: String?, - val serialNumber: String?, - val bootloaderVersion: String?, - val firmwareVersion: String?, + val skuName: String? = null, + val serialNumber: String? = null, + val firmwareVersion: String? = null, ) -private val EUICC_VENDORS: Array = arrayOf(EstkMe(), SimLink()) +private val EUICC_VENDORS: Array = arrayOf(ESTKme(), SIMLink()) -fun EuiccChannel.tryParseEuiccVendorInfo(): EuiccVendorInfo? { - EUICC_VENDORS.forEach { vendor -> - vendor.tryParseEuiccVendorInfo(this@tryParseEuiccVendorInfo)?.let { - return it - } - } +fun EuiccChannel.tryParseEuiccVendorInfo(): EuiccVendorInfo? = + EUICC_VENDORS.firstNotNullOfOrNull { it.tryParseEuiccVendorInfo(this) } - return null +fun EuiccChannel.queryVendorAidListTransformation(aidList: List): Pair, VendorAidDecider>? = + EUICC_VENDORS.firstNotNullOfOrNull { it.transformAidListIfNeeded(this, aidList) } + +fun interface VendorAidDecider { + /** + * Given a list of already opened AIDs, should we still attempt to open the next? + */ + fun shouldOpenMore(openedAids: List, nextAid: ByteArray): Boolean } interface EuiccVendor { fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo? + + /** + * Removable eSIM products from some vendors may prefer a vendor-specific list of AIDs or + * a specific ordering. For example, multi-SE products from eSTK.me might prefer us trying + * SE0 and SE1 AIDs first instead of the generic GSMA ISD-R AID. This method is intended + * to implement these vendor-specific cases. + * + * This method is called on an already opened `EuiccChannel`. If the method returns a non-null + * value, the channel will be closed and the process that attempts to open all channels will + * be restarted from the beginning. The method will not be called again for the same chip, + * but it should still ensure idempotency when called with an already-transformed input. + * + * The second return value of this method is used to decide when we should stop attempting more + * AIDs from the list. + */ + fun transformAidListIfNeeded( + referenceChannel: EuiccChannel, + aidList: List + ): Pair, VendorAidDecider>? = null } -private class EstkMe : EuiccVendor { +class ESTKme : EuiccVendor { companion object { private val PRODUCT_AID = "A06573746B6D65FFFFFFFFFFFF6D6774".decodeHex() - private val PRODUCT_ATR_FPR = "estk.me".encodeToByteArray() - } - private fun checkAtr(channel: EuiccChannel): Boolean { - val iface = channel.apduInterface - if (iface !is ApduInterfaceAtrProvider) return false - val atr = iface.atr ?: return false - for (index in atr.indices) { - if (atr.size - index < PRODUCT_ATR_FPR.size) break - if (atr.sliceArray(index until index + PRODUCT_ATR_FPR.size) - .contentEquals(PRODUCT_ATR_FPR) - ) return true - } - return false + val ESTK_SE0_AID = "A06573746B6D65FFFF4953442D522030".decodeHex() + val ESTK_SE1_AID = "A06573746B6D65FFFF4953442D522031".decodeHex() } private fun decodeAsn1String(b: ByteArray): String? { @@ -54,8 +63,6 @@ private class EstkMe : EuiccVendor { } override fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo? { - if (!checkAtr(channel)) return null - val iface = channel.apduInterface return try { iface.withLogicalChannel(PRODUCT_AID) { transmit -> @@ -64,8 +71,11 @@ private class EstkMe : EuiccVendor { EuiccVendorInfo( skuName = invoke(0x03), serialNumber = invoke(0x00), - bootloaderVersion = invoke(0x01), - firmwareVersion = invoke(0x02), + firmwareVersion = run { + val bl = invoke(0x01) // bootloader version + val fw = invoke(0x02) // firmware version + if (bl == null || fw == null) null else "$bl-$fw" + }, ) } } catch (e: Exception) { @@ -73,9 +83,38 @@ private class EstkMe : EuiccVendor { null } } + + override fun transformAidListIfNeeded( + referenceChannel: EuiccChannel, + aidList: List + ): Pair, VendorAidDecider>? { + try { + referenceChannel.apduInterface.withLogicalChannel(PRODUCT_AID) {} + } catch (_: Exception) { + // Not eSTK! + return null + } + + // If we get here, this is eSTK, and we need to rearrange aidList such that: + // 1. SE0 and SE1 AIDs are _always_ included in the list + // 2. SE0 and SE1 AIDs are always sorted at the beginning of the list + val expected = listOf(ESTK_SE0_AID, ESTK_SE1_AID, *aidList.filter { + !it.contentEquals(ESTK_SE0_AID) && !it.contentEquals(ESTK_SE1_AID) + }.toTypedArray()) + + return if (expected == aidList) { + null + } else { + Pair(expected, VendorAidDecider { openedAids, nextAid -> + // Don't open any more channels if we have reached the GSMA default AID and at least 1 + // eSTK AID has been opened (note that above we re-sorted them to the top of the list) + !(openedAids.isNotEmpty() && nextAid.contentEquals(EUICC_DEFAULT_ISDR_AID.decodeHex())) + }) + } + } } -private class SimLink : EuiccVendor { +class SIMLink : EuiccVendor { companion object { private val EID_PATTERN = Regex("^89044045(84|21)67274948") } @@ -86,6 +125,7 @@ private class SimLink : EuiccVendor { if (version == null || EID_PATTERN.find(eid, 0) == null) return null val versionName = when { // @formatter:off + version >= Version(37, 4, 3) -> "v3.2 (beta 1)" version >= Version(37, 1, 41) -> "v3.1 (beta 1)" version >= Version(36, 18, 5) -> "v3 (final)" version >= Version(36, 17, 39) -> "v3 (beta)" @@ -102,11 +142,6 @@ private class SimLink : EuiccVendor { "9eSIM $versionName" } - return EuiccVendorInfo( - skuName = skuName, - serialNumber = null, - bootloaderVersion = null, - firmwareVersion = null - ) + return EuiccVendorInfo(skuName = skuName) } -} \ No newline at end of file +} diff --git a/app-common/src/main/res/anim/slide_in_left.xml b/app-common/src/main/res/anim/slide_in_left.xml index 9078d1fb..202f040d 100644 --- a/app-common/src/main/res/anim/slide_in_left.xml +++ b/app-common/src/main/res/anim/slide_in_left.xml @@ -1,6 +1,6 @@ diff --git a/app-common/src/main/res/anim/slide_in_right.xml b/app-common/src/main/res/anim/slide_in_right.xml index 42aa3f52..df24f7d8 100644 --- a/app-common/src/main/res/anim/slide_in_right.xml +++ b/app-common/src/main/res/anim/slide_in_right.xml @@ -1,6 +1,6 @@ diff --git a/app-common/src/main/res/anim/slide_out_left.xml b/app-common/src/main/res/anim/slide_out_left.xml index 1a806a9b..c908fc61 100644 --- a/app-common/src/main/res/anim/slide_out_left.xml +++ b/app-common/src/main/res/anim/slide_out_left.xml @@ -1,6 +1,6 @@ diff --git a/app-common/src/main/res/anim/slide_out_right.xml b/app-common/src/main/res/anim/slide_out_right.xml index f209f384..929ae0fd 100644 --- a/app-common/src/main/res/anim/slide_out_right.xml +++ b/app-common/src/main/res/anim/slide_out_right.xml @@ -1,6 +1,6 @@ diff --git a/app-common/src/main/res/drawable/dialog_background.xml b/app-common/src/main/res/drawable/dialog_background.xml index cfad6482..39a77328 100644 --- a/app-common/src/main/res/drawable/dialog_background.xml +++ b/app-common/src/main/res/drawable/dialog_background.xml @@ -1,7 +1,5 @@ - - - \ No newline at end of file + + + diff --git a/app-common/src/main/res/drawable/ic_add.xml b/app-common/src/main/res/drawable/ic_add.xml index 70046c48..9eb39c35 100644 --- a/app-common/src/main/res/drawable/ic_add.xml +++ b/app-common/src/main/res/drawable/ic_add.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app-common/src/main/res/drawable/ic_check_black.xml b/app-common/src/main/res/drawable/ic_check_black.xml index 0432fa69..219e80e2 100644 --- a/app-common/src/main/res/drawable/ic_check_black.xml +++ b/app-common/src/main/res/drawable/ic_check_black.xml @@ -1,10 +1,10 @@ - + android:viewportHeight="24"> + diff --git a/app-common/src/main/res/drawable/ic_checkmark_outline.xml b/app-common/src/main/res/drawable/ic_checkmark_outline.xml index c23123a7..2b59a5b1 100644 --- a/app-common/src/main/res/drawable/ic_checkmark_outline.xml +++ b/app-common/src/main/res/drawable/ic_checkmark_outline.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app-common/src/main/res/drawable/ic_chevron_left.xml b/app-common/src/main/res/drawable/ic_chevron_left.xml index 1152da9f..b9e635c1 100644 --- a/app-common/src/main/res/drawable/ic_chevron_left.xml +++ b/app-common/src/main/res/drawable/ic_chevron_left.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app-common/src/main/res/drawable/ic_chevron_right.xml b/app-common/src/main/res/drawable/ic_chevron_right.xml index 1db5e680..f5be2563 100644 --- a/app-common/src/main/res/drawable/ic_chevron_right.xml +++ b/app-common/src/main/res/drawable/ic_chevron_right.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app-common/src/main/res/drawable/ic_edit.xml b/app-common/src/main/res/drawable/ic_edit.xml index 3c53db7e..24ac9bd4 100644 --- a/app-common/src/main/res/drawable/ic_edit.xml +++ b/app-common/src/main/res/drawable/ic_edit.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app-common/src/main/res/drawable/ic_error_outline.xml b/app-common/src/main/res/drawable/ic_error_outline.xml index d265d6df..d712eaad 100644 --- a/app-common/src/main/res/drawable/ic_error_outline.xml +++ b/app-common/src/main/res/drawable/ic_error_outline.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app-common/src/main/res/drawable/ic_gallery_black.xml b/app-common/src/main/res/drawable/ic_gallery_black.xml index 048f74a9..127e52dd 100644 --- a/app-common/src/main/res/drawable/ic_gallery_black.xml +++ b/app-common/src/main/res/drawable/ic_gallery_black.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app-common/src/main/res/drawable/ic_help_black.xml b/app-common/src/main/res/drawable/ic_help_black.xml index 58a2d2f5..7ae71340 100644 --- a/app-common/src/main/res/drawable/ic_help_black.xml +++ b/app-common/src/main/res/drawable/ic_help_black.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app-common/src/main/res/drawable/ic_menu_black.xml b/app-common/src/main/res/drawable/ic_menu_black.xml index 34b93ecd..14ff51ef 100644 --- a/app-common/src/main/res/drawable/ic_menu_black.xml +++ b/app-common/src/main/res/drawable/ic_menu_black.xml @@ -1,10 +1,10 @@ - + android:viewportHeight="24"> + diff --git a/app-common/src/main/res/drawable/ic_paste_go.xml b/app-common/src/main/res/drawable/ic_paste_go.xml index 7536fff3..36f4f35d 100644 --- a/app-common/src/main/res/drawable/ic_paste_go.xml +++ b/app-common/src/main/res/drawable/ic_paste_go.xml @@ -1,7 +1,16 @@ - - - - - - + + + + + + diff --git a/app-common/src/main/res/drawable/ic_refresh_black.xml b/app-common/src/main/res/drawable/ic_refresh_black.xml index ccaf34b7..0d768f68 100644 --- a/app-common/src/main/res/drawable/ic_refresh_black.xml +++ b/app-common/src/main/res/drawable/ic_refresh_black.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app-common/src/main/res/drawable/ic_save_as_black.xml b/app-common/src/main/res/drawable/ic_save_as_black.xml index aaee6785..dbc25d07 100644 --- a/app-common/src/main/res/drawable/ic_save_as_black.xml +++ b/app-common/src/main/res/drawable/ic_save_as_black.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app-common/src/main/res/drawable/ic_scan_black.xml b/app-common/src/main/res/drawable/ic_scan_black.xml index 597e8d7b..3d6820dc 100644 --- a/app-common/src/main/res/drawable/ic_scan_black.xml +++ b/app-common/src/main/res/drawable/ic_scan_black.xml @@ -1,10 +1,10 @@ - + android:viewportHeight="24"> + diff --git a/app-common/src/main/res/drawable/ic_task_delete.xml b/app-common/src/main/res/drawable/ic_task_delete.xml index 883bcaac..ebcea8e9 100644 --- a/app-common/src/main/res/drawable/ic_task_delete.xml +++ b/app-common/src/main/res/drawable/ic_task_delete.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app-common/src/main/res/drawable/ic_task_rename.xml b/app-common/src/main/res/drawable/ic_task_rename.xml index 3c53db7e..24ac9bd4 100644 --- a/app-common/src/main/res/drawable/ic_task_rename.xml +++ b/app-common/src/main/res/drawable/ic_task_rename.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app-common/src/main/res/drawable/ic_task_sim_card_download.xml b/app-common/src/main/res/drawable/ic_task_sim_card_download.xml index a2eadb23..bb4a0f1e 100644 --- a/app-common/src/main/res/drawable/ic_task_sim_card_download.xml +++ b/app-common/src/main/res/drawable/ic_task_sim_card_download.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app-common/src/main/res/drawable/ic_task_switch.xml b/app-common/src/main/res/drawable/ic_task_switch.xml index 86504d0e..1be9ff7e 100644 --- a/app-common/src/main/res/drawable/ic_task_switch.xml +++ b/app-common/src/main/res/drawable/ic_task_switch.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app-common/src/main/res/drawable/ic_x_black.xml b/app-common/src/main/res/drawable/ic_x_black.xml index f8ca0c64..8c8c40aa 100644 --- a/app-common/src/main/res/drawable/ic_x_black.xml +++ b/app-common/src/main/res/drawable/ic_x_black.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app-common/src/main/res/layout/activity_download_wizard.xml b/app-common/src/main/res/layout/activity_download_wizard.xml index 79513bb7..6f39fcf5 100644 --- a/app-common/src/main/res/layout/activity_download_wizard.xml +++ b/app-common/src/main/res/layout/activity_download_wizard.xml @@ -1,17 +1,17 @@ + android:layout_height="match_parent"> + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toBottomOf="@id/guideline" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent"> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/activity_euicc_info.xml b/app-common/src/main/res/layout/activity_euicc_info.xml index 8a8b0012..c1ce97d9 100644 --- a/app-common/src/main/res/layout/activity_euicc_info.xml +++ b/app-common/src/main/res/layout/activity_euicc_info.xml @@ -1,8 +1,8 @@ + android:layout_height="match_parent"> @@ -10,10 +10,10 @@ android:id="@+id/swipe_refresh" android:layout_width="0dp" android:layout_height="0dp" - app:layout_constraintTop_toBottomOf="@id/toolbar" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintBottom_toBottomOf="parent"> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/toolbar"> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/activity_isdr_aid_list.xml b/app-common/src/main/res/layout/activity_isdr_aid_list.xml index 48135fbe..fe9716a5 100644 --- a/app-common/src/main/res/layout/activity_isdr_aid_list.xml +++ b/app-common/src/main/res/layout/activity_isdr_aid_list.xml @@ -1,7 +1,7 @@ @@ -12,13 +12,13 @@ android:layout_width="0dp" android:layout_height="0dp" android:fontFamily="monospace" + android:gravity="top|start" android:importantForAutofill="no" android:inputType="textMultiLine" - android:gravity="top|start" - app:layout_constraintTop_toBottomOf="@id/toolbar" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/toolbar" tools:ignore="LabelFor" /> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/activity_logs.xml b/app-common/src/main/res/layout/activity_logs.xml index b37328d9..e0245375 100644 --- a/app-common/src/main/res/layout/activity_logs.xml +++ b/app-common/src/main/res/layout/activity_logs.xml @@ -1,9 +1,9 @@ + android:layout_height="match_parent"> @@ -11,10 +11,10 @@ android:id="@+id/swipe_refresh" android:layout_width="0dp" android:layout_height="0dp" - app:layout_constraintTop_toBottomOf="@id/toolbar" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintBottom_toBottomOf="parent"> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/toolbar"> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/activity_main.xml b/app-common/src/main/res/layout/activity_main.xml index 145fc0d6..8bfeee92 100644 --- a/app-common/src/main/res/layout/activity_main.xml +++ b/app-common/src/main/res/layout/activity_main.xml @@ -1,32 +1,31 @@ - + app:tabSelectedTextColor="?attr/colorOnSurfaceVariant" + app:tabTextColor="?attr/colorOnSurfaceVariant" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/main_tabs" /> + app:layout_constraintTop_toBottomOf="@id/main_tabs" /> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/activity_notifications.xml b/app-common/src/main/res/layout/activity_notifications.xml index 8a8b0012..c1ce97d9 100644 --- a/app-common/src/main/res/layout/activity_notifications.xml +++ b/app-common/src/main/res/layout/activity_notifications.xml @@ -1,8 +1,8 @@ + android:layout_height="match_parent"> @@ -10,10 +10,10 @@ android:id="@+id/swipe_refresh" android:layout_width="0dp" android:layout_height="0dp" - app:layout_constraintTop_toBottomOf="@id/toolbar" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintBottom_toBottomOf="parent"> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/toolbar"> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/activity_settings.xml b/app-common/src/main/res/layout/activity_settings.xml index 7e7c2b71..64f75fec 100644 --- a/app-common/src/main/res/layout/activity_settings.xml +++ b/app-common/src/main/res/layout/activity_settings.xml @@ -1,8 +1,8 @@ + android:layout_height="match_parent"> @@ -15,4 +15,4 @@ app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@id/toolbar" /> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/download_method_item.xml b/app-common/src/main/res/layout/download_method_item.xml index 5b2c2a8a..42237362 100644 --- a/app-common/src/main/res/layout/download_method_item.xml +++ b/app-common/src/main/res/layout/download_method_item.xml @@ -1,19 +1,19 @@ + android:background="?attr/selectableItemBackground" + android:padding="20dp"> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:tint="?attr/colorAccent" /> + app:layout_constraintStart_toEndOf="@id/download_method_icon" + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:tint="?attr/colorAccent" /> - \ 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..71c8a176 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/download_slot_item.xml b/app-common/src/main/res/layout/download_slot_item.xml index d0ca1764..c959d612 100644 --- a/app-common/src/main/res/layout/download_slot_item.xml +++ b/app-common/src/main/res/layout/download_slot_item.xml @@ -3,11 +3,11 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingBottom="20sp" - android:paddingTop="10sp" + android:background="?attr/selectableItemBackground" android:paddingStart="20sp" + android:paddingTop="10sp" android:paddingEnd="20sp" - android:background="?attr/selectableItemBackground"> + android:paddingBottom="20sp"> @@ -105,4 +105,4 @@ app:layout_constraintStart_toEndOf="@id/flow1" app:layout_constraintTop_toTopOf="parent" /> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/euicc_info_item.xml b/app-common/src/main/res/layout/euicc_info_item.xml index fa148fbb..2e770dda 100644 --- a/app-common/src/main/res/layout/euicc_info_item.xml +++ b/app-common/src/main/res/layout/euicc_info_item.xml @@ -1,9 +1,9 @@ + android:background="?android:attr/selectableItemBackground"> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/euicc_info_title" /> - \ 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..e27d7759 100644 --- a/app-common/src/main/res/layout/euicc_profile.xml +++ b/app-common/src/main/res/layout/euicc_profile.xml @@ -20,16 +20,16 @@ android:id="@+id/name" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:textStyle="bold" - android:textSize="16sp" - android:singleLine="true" android:ellipsize="marquee" - app:layout_constraintLeft_toLeftOf="parent" - app:layout_constraintRight_toLeftOf="@+id/profile_menu" - app:layout_constraintTop_toTopOf="parent" + android:singleLine="true" + android:textSize="16sp" + android:textStyle="bold" + app:layout_constrainedWidth="true" app:layout_constraintBottom_toTopOf="@+id/state" app:layout_constraintHorizontal_bias="0" - app:layout_constrainedWidth="true" /> + app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintRight_toLeftOf="@+id/profile_menu" + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintRight_toRightOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toBottomOf="@id/name" /> + app:layout_constraintTop_toBottomOf="@id/state" /> + app:layout_constraintTop_toBottomOf="@id/state" /> + app:layout_constraintTop_toBottomOf="@id/provider_label" /> + app:layout_constraintTop_toBottomOf="@id/provider" /> + app:layout_constraintTop_toBottomOf="@id/profile_class_label" /> + app:layout_constraintTop_toBottomOf="@id/profile_class" /> + + - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/footer_no_profile.xml b/app-common/src/main/res/layout/footer_no_profile.xml index b02c67c3..b579a5e0 100644 --- a/app-common/src/main/res/layout/footer_no_profile.xml +++ b/app-common/src/main/res/layout/footer_no_profile.xml @@ -8,14 +8,14 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="40dp" - android:layout_marginEnd="40dp" android:layout_marginTop="6dp" + android:layout_marginEnd="40dp" android:gravity="center" android:text="@string/no_profile" android:textStyle="italic" - app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" /> + app:layout_constraintTop_toTopOf="parent" /> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/fragment_download_details.xml b/app-common/src/main/res/layout/fragment_download_details.xml index 1a250756..423f258a 100644 --- a/app-common/src/main/res/layout/fragment_download_details.xml +++ b/app-common/src/main/res/layout/fragment_download_details.xml @@ -11,18 +11,18 @@ + android:layout_height="match_parent" + android:inputType="text" + android:maxLines="1" /> @@ -47,10 +47,10 @@ app:passwordToggleEnabled="true"> + android:inputType="textPassword" + android:maxLines="1" /> @@ -62,10 +62,10 @@ app:passwordToggleEnabled="true"> + android:inputType="textPassword" + android:maxLines="1" /> @@ -79,26 +79,26 @@ app:passwordToggleEnabled="true"> + android:inputType="numberPassword" + android:maxLines="1" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/download_wizard_details_title" /> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/fragment_download_diagnostics.xml b/app-common/src/main/res/layout/fragment_download_diagnostics.xml index 88b1ffba..81e8921b 100644 --- a/app-common/src/main/res/layout/fragment_download_diagnostics.xml +++ b/app-common/src/main/res/layout/fragment_download_diagnostics.xml @@ -1,9 +1,9 @@ + app:tint="?attr/colorAccent" /> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/fragment_download_method_select.xml b/app-common/src/main/res/layout/fragment_download_method_select.xml index 7fe22341..b39334c4 100644 --- a/app-common/src/main/res/layout/fragment_download_method_select.xml +++ b/app-common/src/main/res/layout/fragment_download_method_select.xml @@ -1,33 +1,33 @@ + android:layout_height="match_parent"> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/download_method_select_title" /> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/fragment_download_progress.xml b/app-common/src/main/res/layout/fragment_download_progress.xml index 82ebb258..ae804a2f 100644 --- a/app-common/src/main/res/layout/fragment_download_progress.xml +++ b/app-common/src/main/res/layout/fragment_download_progress.xml @@ -1,33 +1,33 @@ + android:layout_height="match_parent"> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/download_progress_title" /> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/fragment_download_slot_select.xml b/app-common/src/main/res/layout/fragment_download_slot_select.xml index 69b15353..a6a5f8e2 100644 --- a/app-common/src/main/res/layout/fragment_download_slot_select.xml +++ b/app-common/src/main/res/layout/fragment_download_slot_select.xml @@ -1,33 +1,33 @@ + android:layout_height="match_parent"> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/download_slot_select_title" /> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/fragment_euicc.xml b/app-common/src/main/res/layout/fragment_euicc.xml index 4ae7523a..fb4d4920 100644 --- a/app-common/src/main/res/layout/fragment_euicc.xml +++ b/app-common/src/main/res/layout/fragment_euicc.xml @@ -8,16 +8,16 @@ android:id="@+id/swipe_refresh" android:layout_width="wrap_content" android:layout_height="wrap_content" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" - app:layout_constraintTop_toTopOf="parent" app:layout_constraintRight_toRightOf="parent" - app:layout_constraintBottom_toBottomOf="parent"> + app:layout_constraintTop_toTopOf="parent"> + android:paddingTop="6dp" /> @@ -27,8 +27,10 @@ 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"/> + android:tooltipText="@string/profile_download" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintRight_toRightOf="parent" /> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/fragment_no_euicc_placeholder.xml b/app-common/src/main/res/layout/fragment_no_euicc_placeholder.xml index b040fda6..16bb3bc7 100644 --- a/app-common/src/main/res/layout/fragment_no_euicc_placeholder.xml +++ b/app-common/src/main/res/layout/fragment_no_euicc_placeholder.xml @@ -2,6 +2,7 @@ + - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/fragment_profile_rename.xml b/app-common/src/main/res/layout/fragment_profile_rename.xml index f0da20df..60aa63bb 100644 --- a/app-common/src/main/res/layout/fragment_profile_rename.xml +++ b/app-common/src/main/res/layout/fragment_profile_rename.xml @@ -8,8 +8,8 @@ android:id="@+id/toolbar" android:layout_width="0dp" android:layout_height="wrap_content" - app:layout_constraintTop_toTopOf="parent" app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintTop_toTopOf="parent" app:layout_constraintWidth_percent="1" app:navigationIcon="?homeAsUpIndicator" /> @@ -25,14 +25,14 @@ + app:layout_constraintTop_toBottomOf="@id/toolbar" /> - \ No newline at end of file + diff --git a/app-common/src/main/res/layout/fragment_usb_ccid_reader.xml b/app-common/src/main/res/layout/fragment_usb_ccid_reader.xml index 287e340f..083ae516 100644 --- a/app-common/src/main/res/layout/fragment_usb_ccid_reader.xml +++ b/app-common/src/main/res/layout/fragment_usb_ccid_reader.xml @@ -4,15 +4,21 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + app:layout_constraintTop_toTopOf="parent"> + + + app:layout_constraintTop_toTopOf="parent" />