Compare commits

..

No commits in common. "master" and "master" have entirely different histories.

190 changed files with 2073 additions and 7393 deletions

View file

@ -1,7 +1,7 @@
on: on:
push: push:
branches: branches:
- '*' - 'master'
jobs: jobs:
build-debug: build-debug:
@ -35,12 +35,11 @@ jobs:
- name: Build Debug APKs - name: Build Debug APKs
run: ./gradlew --no-daemon assembleDebug run: ./gradlew --no-daemon assembleDebug
- name: Copy Artifacts
run: find . -name 'app*-debug.apk' -exec cp {} . \;
- name: Upload Artifacts - name: Upload Artifacts
uses: https://gitea.angry.im/actions/upload-artifact@v3 uses: https://gitea.angry.im/actions/upload-artifact@v3
with: with:
name: Debug APKs name: Debug APKs
compression-level: 0 compression-level: 0
path: app*-debug.apk path: |
app-unpriv/build/outputs/apk/debug/app-unpriv-debug.apk
app/build/outputs/apk/debug/app-debug.apk

27
.gitignore vendored
View file

@ -1,11 +1,20 @@
/.gradle *.iml
/captures .gradle
# Configuration files
/keystore.properties
/local.properties /local.properties
/keystore.properties
# macOS /.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/deploymentTargetDropDown.xml
.DS_Store .DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/libs/**/build
/buildSrc/build
/app-deps/libs

15
.idea/.gitignore generated vendored
View file

@ -1,14 +1,3 @@
/shelf # Default ignored files
/caches /shelf/
/libraries
/assetWizardSettings.xml
/deploymentTargetDropDown.xml
/gradle.xml
/misc.xml
/modules.xml
/navEditor.xml
/runConfigurations.xml
/workspace.xml /workspace.xml
/AndroidProjectSystem.xml
**/*.iml

View file

@ -1,8 +1,5 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173"> <code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML"> <codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" /> <option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions> <indentOptions>
@ -116,8 +113,5 @@
</rules> </rules>
</arrangement> </arrangement>
</codeStyleSettings> </codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme> </code_scheme>
</component> </component>

View file

@ -1,6 +1,5 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<state> <state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" /> <option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state> </state>
</component> </component>

12
.idea/compiler.xml generated
View file

@ -1,6 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.7" /> <bytecodeTargetLevel target="1.7">
<module name="OpenEUICC.app" target="17" />
<module name="OpenEUICC.app-common" target="17" />
<module name="OpenEUICC.app-deps" target="17" />
<module name="OpenEUICC.app-unpriv" target="17" />
<module name="OpenEUICC.buildSrc" target="17" />
<module name="OpenEUICC.buildSrc.main" target="17" />
<module name="OpenEUICC.buildSrc.test" target="17" />
<module name="OpenEUICC.libs.hidden-apis-shim" target="17" />
<module name="OpenEUICC.libs.lpac-jni" target="17" />
</bytecodeTargetLevel>
</component> </component>
</project> </project>

View file

@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app-unpriv">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="app-unpriv.androidTest">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="app-unpriv.main">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="app-unpriv.unitTest">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="app.unitTest">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="app.androidTest">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="app.main">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="workspace.OpenEUICC.app-unpriv">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="workspace.OpenEUICC.app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

29
.idea/gradle.xml generated Normal file
View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleHome" value="/usr/share/java/gradle" />
<option name="gradleJvm" value="jbr-17" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/app-common" />
<option value="$PROJECT_DIR$/app-deps" />
<option value="$PROJECT_DIR$/app-unpriv" />
<option value="$PROJECT_DIR$/buildSrc" />
<option value="$PROJECT_DIR$/libs" />
<option value="$PROJECT_DIR$/libs/hidden-apis-shim" />
<option value="$PROJECT_DIR$/libs/hidden-apis-stub" />
<option value="$PROJECT_DIR$/libs/lpac-jni" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

2
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.24" /> <option name="version" value="1.9.20" />
</component> </component>
</project> </project>

10
.idea/migrations.xml generated
View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

25
.idea/misc.xml generated Normal file
View file

@ -0,0 +1,25 @@
<project version="4">
<component name="DesignSurface">
<option name="filePathToZoomLevelMap">
<map>
<entry key="app/src/main/res/drawable/ic_add.xml" value="0.2015" />
<entry key="app/src/main/res/layout/activity_main.xml" value="0.19375" />
<entry key="app/src/main/res/layout/euicc_profile.xml" value="0.19375" />
<entry key="app/src/main/res/layout/fragment_euicc.xml" value="0.19375" />
<entry key="app/src/main/res/layout/fragment_profile_download.xml" value="0.19375" />
<entry key="app/src/main/res/layout/fragment_profile_rename.xml" value="0.19375" />
<entry key="app/src/main/res/menu/activity_main.xml" value="0.19375" />
<entry key="app/src/main/res/menu/activity_main_slot_spinner.xml" value="0.19375" />
<entry key="app/src/main/res/menu/fragment_profile_download.xml" value="0.19375" />
<entry key="app/src/main/res/menu/fragment_profile_rename.xml" value="0.19375" />
<entry key="app/src/main/res/menu/profile_options.xml" value="0.19375" />
</map>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

0
Android.mk Normal file
View file

View file

@ -2,26 +2,17 @@
A fully free and open-source Local Profile Assistant implementation for Android devices. 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 | EasyEUICC | - OpenEUICC: The full-fledged privileged variant.
|:------------------------------|:-----------------------------------------------:|:-----------------:| - Due to its privilege requirement, OpenEUICC must be placed inside `/system/priv-app` and be signed with the platform certificate.
| Privileged | Must be installed as system app | No | - The preferred way to including OpenEUICC in a system image is to [build it along with AOSP](#building-aosp).
| Internal eSIM | Supported | Unsupported | - EasyEUICC: Unprivileged version that can run as a user app.
| External (Removable) eSIM | Supported | Supported | - This version supports two modes of operation:
| USB Readers | Supported | Supported | 1. Inserted, removable eSIMs: Due to obvious security requirements, EasyEUICC is only able to access eSIM chips whose [ARF/ARA](https://source.android.com/docs/core/connect/uicc#arf) contains the hash of EasyEUICC's signing certificate.
| Requires allowlisting by eSIM | No | Yes -- except USB | 2. USB CCID Card Readers: Only `T=0` readers that use the standard [USB CCID protocol](https://en.wikipedia.org/wiki/CCID_(protocol)) are supported. In this mode, EasyEUICC can access any eSIM chip loaded in the card reader regardless of their ARF/ARA, as long as they implement the [SGP.22 standard](https://www.gsma.com/solutions-and-impact/technologies/esim/wp-content/uploads/2021/07/SGP.22-v2.3.pdf).
| System Integration | Partial (carrier partner API unimplemented yet) | No | - Prebuilt release-mode EasyEUICC apks can be downloaded [here](https://gitea.angry.im/PeterCxy/OpenEUICC/releases)
- For removable eSIM chip vendors: to have your chip supported by official builds of EasyEUICC when inserted, include the ARA-M hash `2A2FA878BC7C3354C2CF82935A5945A3EDAE4AFA`
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.
__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) Building (Gradle)
=== ===

View file

@ -5,7 +5,7 @@ plugins {
android { android {
namespace = "im.angry.openeuicc.common" namespace = "im.angry.openeuicc.common"
compileSdk = 35 compileSdk = 34
defaultConfig { defaultConfig {
minSdk = 28 minSdk = 28

View file

@ -3,15 +3,10 @@
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
package="im.angry.openeuicc.common"> package="im.angry.openeuicc.common">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application>
android:enableOnBackInvokedCallback="true"
tools:targetApi="tiramisu">
<activity <activity
android:name="im.angry.openeuicc.ui.SettingsActivity" android:name="im.angry.openeuicc.ui.SettingsActivity"
android:label="@string/pref_settings" /> android:label="@string/pref_settings" />
@ -21,40 +16,14 @@
android:label="@string/profile_notifications" /> android:label="@string/profile_notifications" />
<activity <activity
android:name="im.angry.openeuicc.ui.EuiccInfoActivity" android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
android:label="@string/euicc_info" /> android:label="@string/profile_download"
android:theme="@style/Theme.AppCompat.Translucent" />
<activity <activity
android:name="im.angry.openeuicc.ui.LogsActivity" android:name="im.angry.openeuicc.ui.LogsActivity"
android:label="@string/pref_advanced_logs" /> android:label="@string/pref_advanced_logs" />
<activity
android:name="im.angry.openeuicc.ui.IsdrAidListActivity"
android:label="@string/isdr_aid_list" />
<activity
android:exported="true"
android:name="im.angry.openeuicc.ui.wizard.DownloadWizardActivity"
android:label="@string/download_wizard">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Accepts URIs that begin with "lpa:" -->
<!-- for example: "LPA:1$..." -->
<!-- refs: https://www.iana.org/assignments/uri-schemes/prov/lpa -->
<data android:scheme="lpa"/>
<data android:sspPrefix="1$"/>
</intent-filter>
</activity>
<activity-alias
android:exported="true"
android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
android:targetActivity="im.angry.openeuicc.ui.wizard.DownloadWizardActivity" />
<activity <activity
android:name="com.journeyapps.barcodescanner.CaptureActivity" android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor" android:screenOrientation="fullSensor"
@ -62,7 +31,6 @@
<service <service
android:name="im.angry.openeuicc.service.EuiccChannelManagerService" android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
android:foregroundServiceType="shortService"
android:exported="false" /> android:exported="false" />
</application> </application>
</manifest> </manifest>

View file

@ -1,5 +0,0 @@
package im.angry.openeuicc.core
interface ApduInterfaceAtrProvider {
val atr: ByteArray?
}

View file

@ -1,92 +1,59 @@
package im.angry.openeuicc.core package im.angry.openeuicc.core
import android.content.Context import android.content.Context
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.se.omapi.SEService import android.se.omapi.SEService
import android.util.Log import android.util.Log
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.usb.UsbApduInterface import im.angry.openeuicc.core.usb.UsbApduInterface
import im.angry.openeuicc.core.usb.UsbCcidContext import im.angry.openeuicc.core.usb.getIoEndpoints
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import java.lang.IllegalArgumentException import java.lang.IllegalArgumentException
open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccChannelFactory { open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccChannelFactory {
private var seService: SEService? = null private var seService: SEService? = null
private val usbManager by lazy {
context.getSystemService(Context.USB_SERVICE) as UsbManager
}
private suspend fun ensureSEService() { private suspend fun ensureSEService() {
if (seService == null || !seService!!.isConnected) { if (seService == null || !seService!!.isConnected) {
seService = connectSEService(context) seService = connectSEService(context)
} }
} }
override suspend fun tryOpenEuiccChannel( override suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
port: UiccPortInfoCompat,
isdrAid: ByteArray
): EuiccChannel? {
if (port.portIndex != 0) { if (port.portIndex != 0) {
Log.w( Log.w(DefaultEuiccChannelManager.TAG, "OMAPI channel attempted on non-zero portId, this may or may not work.")
DefaultEuiccChannelManager.TAG,
"OMAPI channel attempted on non-zero portId, this may or may not work."
)
} }
ensureSEService() ensureSEService()
Log.i( Log.i(DefaultEuiccChannelManager.TAG, "Trying OMAPI for physical slot ${port.card.physicalSlotIndex}")
DefaultEuiccChannelManager.TAG,
"Trying OMAPI for physical slot ${port.card.physicalSlotIndex}"
)
try { try {
return EuiccChannelImpl( return EuiccChannel(port, OmapiApduInterface(seService!!, port))
context.getString(R.string.omapi), } catch (e: IllegalArgumentException) {
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 // Failed
Log.w( Log.w(
DefaultEuiccChannelManager.TAG, DefaultEuiccChannelManager.TAG,
"OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex} with ISD-R AID: ${isdrAid.encodeHex()}." "OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex}."
) )
} }
return null return null
} }
override fun tryOpenUsbEuiccChannel( override fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel? {
ccidCtx: UsbCcidContext, val (bulkIn, bulkOut) = usbInterface.getIoEndpoints()
isdrAid: ByteArray if (bulkIn == null || bulkOut == null) return null
): EuiccChannel? { val conn = usbManager.openDevice(usbDevice) ?: return null
try { if (!conn.claimInterface(usbInterface, true)) return null
return EuiccChannelImpl( return EuiccChannel(
context.getString(R.string.usb),
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)), FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
intrinsicChannelName = ccidCtx.productName, UsbApduInterface(conn, bulkIn, bulkOut)
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
} }
override fun cleanup() { override fun cleanup() {

View file

@ -5,18 +5,12 @@ import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager import android.hardware.usb.UsbManager
import android.telephony.SubscriptionManager import android.telephony.SubscriptionManager
import android.util.Log import android.util.Log
import im.angry.openeuicc.core.usb.UsbCcidContext import im.angry.openeuicc.core.usb.getSmartCardInterface
import im.angry.openeuicc.core.usb.smartCard
import im.angry.openeuicc.core.usb.interfaces
import im.angry.openeuicc.di.AppContainer import im.angry.openeuicc.di.AppContainer
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -51,24 +45,6 @@ open class DefaultEuiccChannelManager(
protected open val uiccCards: Collection<UiccCardInfoCompat> protected open val uiccCards: Collection<UiccCardInfoCompat>
get() = (0..<tm.activeModemCountCompat).map { FakeUiccCardInfoCompat(it) } get() = (0..<tm.activeModemCountCompat).map { FakeUiccCardInfoCompat(it) }
private suspend inline fun tryOpenChannelFirstValidAid(openFn: (ByteArray) -> EuiccChannel?): EuiccChannel? {
val isdrAidList =
parseIsdrAidList(appContainer.preferenceRepository.isdrAidListFlow.first())
return isdrAidList.firstNotNullOfOrNull {
Log.i(TAG, "Opening channel, trying ISDR AID ${it.encodeHex()}")
openFn(it)?.let { channel ->
if (channel.valid) {
channel
} else {
channel.close()
null
}
}
}
}
private suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? { private suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
lock.withLock { lock.withLock {
if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) { if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) {
@ -96,10 +72,9 @@ open class DefaultEuiccChannelManager(
return null return null
} }
val channel = val channel = euiccChannelFactory.tryOpenEuiccChannel(port) ?: return null
tryOpenChannelFirstValidAid { euiccChannelFactory.tryOpenEuiccChannel(port, it) }
if (channel != null) { if (channel.valid) {
channelCache.add(channel) channelCache.add(channel)
return channel return channel
} else { } else {
@ -107,12 +82,14 @@ open class DefaultEuiccChannelManager(
TAG, TAG,
"Was able to open channel for logical slot ${port.logicalSlotIndex}, but the channel is invalid (cannot get eID or profiles without errors). This slot might be broken, aborting." "Was able to open channel for logical slot ${port.logicalSlotIndex}, but the channel is invalid (cannot get eID or profiles without errors). This slot might be broken, aborting."
) )
channel.close()
return null return null
} }
} }
} }
protected suspend fun findEuiccChannelByLogicalSlot(logicalSlotId: Int): EuiccChannel? = override fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel? =
runBlocking {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannel return@withContext usbChannel
@ -128,8 +105,27 @@ open class DefaultEuiccChannelManager(
null null
} }
}
private suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? { override fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel? =
runBlocking {
withContext(Dispatchers.IO) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannel
}
for (card in uiccCards) {
if (card.physicalSlotIndex != physicalSlotId) continue
for (port in card.ports) {
tryOpenEuiccChannel(port)?.let { return@withContext it }
}
}
null
}
}
override suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return usbChannel?.let { listOf(it) } return usbChannel?.let { listOf(it) }
} }
@ -142,7 +138,12 @@ open class DefaultEuiccChannelManager(
return null return null
} }
private suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? = override fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>? =
runBlocking {
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)
}
override suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannel return@withContext usbChannel
@ -153,155 +154,72 @@ open class DefaultEuiccChannelManager(
} }
} }
override suspend fun findFirstAvailablePort(physicalSlotId: Int): Int = override fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? =
withContext(Dispatchers.IO) { runBlocking {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { findEuiccChannelByPort(physicalSlotId, portId)
return@withContext 0
}
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.getOrNull(0)?.portId ?: -1
}
override suspend fun findAvailablePorts(physicalSlotId: Int): List<Int> =
withContext(Dispatchers.IO) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext listOf(0)
}
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.map { it.portId } ?: listOf()
}
override suspend fun <R> withEuiccChannel(
physicalSlotId: Int,
portId: Int,
fn: suspend (EuiccChannel) -> R
): R {
val channel = findEuiccChannelByPort(physicalSlotId, portId)
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
val wrapper = EuiccChannelWrapper(channel)
try {
return withContext(Dispatchers.IO) {
fn(wrapper)
}
} finally {
wrapper.invalidateWrapper()
}
}
override suspend fun <R> withEuiccChannel(
logicalSlotId: Int,
fn: suspend (EuiccChannel) -> R
): R {
val channel = findEuiccChannelByLogicalSlot(logicalSlotId)
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
val wrapper = EuiccChannelWrapper(channel)
try {
return withContext(Dispatchers.IO) {
fn(wrapper)
}
} finally {
wrapper.invalidateWrapper()
}
} }
override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) { override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) return
usbChannel?.close()
usbChannel = null
} else {
// If there is already a valid channel, we close it proactively // 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 // 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 { channelCache.find { it.slotId == physicalSlotId && it.portId == portId }?.apply {
if (valid) close() if (valid) close()
} }
}
withTimeout(timeoutMillis) { withTimeout(timeoutMillis) {
while (true) { while (true) {
try { try {
val channel = if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
// tryOpenUsbEuiccChannel() will always try to reopen the channel, even if
// a USB channel already exists
tryOpenUsbEuiccChannel()
usbChannel!!
} else {
// tryOpenEuiccChannel() will automatically dispose of invalid channels // tryOpenEuiccChannel() will automatically dispose of invalid channels
// and recreate when needed // and recreate when needed
findEuiccChannelByPort(physicalSlotId, portId)!! val channel = findEuiccChannelByPortBlocking(physicalSlotId, portId)!!
}
check(channel.valid) { "Invalid channel" } check(channel.valid) { "Invalid channel" }
break break
} catch (e: Exception) { } catch (e: Exception) {
Log.d( Log.d(TAG, "Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms")
TAG,
"Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms"
)
} }
delay(1000) delay(1000)
} }
} }
} }
override fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>> = flow { override suspend fun enumerateEuiccChannels(): List<EuiccChannel> =
uiccCards.forEach { info -> withContext(Dispatchers.IO) {
info.ports.forEach { port -> uiccCards.flatMap { info ->
info.ports.mapNotNull { port ->
tryOpenEuiccChannel(port)?.also { tryOpenEuiccChannel(port)?.also {
Log.d( Log.d(
TAG, TAG,
"Found eUICC on slot ${info.physicalSlotIndex} port ${port.portIndex}" "Found eUICC on slot ${info.physicalSlotIndex} port ${port.portIndex}"
) )
}
}
}
}
emit(Pair(info.physicalSlotIndex, port.portIndex)) override suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?> =
}
}
}
}.flowOn(Dispatchers.IO)
override fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>> =
merge(flowInternalEuiccPorts(), flow {
if (tryOpenUsbEuiccChannel().second) {
emit(Pair(EuiccChannelManager.USB_CHANNEL_ID, 0))
}
})
override suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
usbManager.deviceList.values.forEach { device -> usbManager.deviceList.values.forEach { device ->
Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}") Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}")
val iface = device.interfaces.smartCard ?: return@forEach val iface = device.getSmartCardInterface() ?: return@forEach
// If we don't have permission, tell UI code that we found a candidate device, but we // If we don't have permission, tell UI code that we found a candidate device, but we
// need permission to be able to do anything with it // need permission to be able to do anything with it
if (!usbManager.hasPermission(device)) return@withContext Pair(device, false) if (!usbManager.hasPermission(device)) return@withContext Pair(device, null)
Log.i( Log.i(TAG, "Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel")
TAG,
"Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel"
)
val ccidCtx = UsbCcidContext.createFromUsbDevice(context, device, iface) ?: return@forEach
try { try {
val channel = tryOpenChannelFirstValidAid { val channel = euiccChannelFactory.tryOpenUsbEuiccChannel(device, iface)
euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, it)
}
if (channel != null && channel.lpa.valid) { if (channel != null && channel.lpa.valid) {
ccidCtx.allowDisconnect = true
usbChannel = channel usbChannel = channel
return@withContext Pair(device, true) return@withContext Pair(device, channel)
} }
} catch (e: Exception) { } catch (e: Exception) {
// Ignored -- skip forward // Ignored -- skip forward
e.printStackTrace() e.printStackTrace()
} }
Log.i(TAG, "No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}")
ccidCtx.allowDisconnect = true
ccidCtx.disconnect()
Log.i(
TAG,
"No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}"
)
} }
return@withContext Pair(null, false) return@withContext Pair(null, null)
} }
override fun invalidate() { override fun invalidate() {

View file

@ -3,41 +3,21 @@ package im.angry.openeuicc.core
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import net.typeblog.lpac_jni.ApduInterface import net.typeblog.lpac_jni.ApduInterface
import net.typeblog.lpac_jni.LocalProfileAssistant import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.impl.HttpInterfaceImpl
import net.typeblog.lpac_jni.impl.LocalProfileAssistantImpl
interface EuiccChannel { class EuiccChannel(
val type: String val port: UiccPortInfoCompat,
apduInterface: ApduInterface,
) {
val slotId = port.card.physicalSlotIndex // PHYSICAL slot
val logicalSlotId = port.logicalSlotIndex
val portId = port.portIndex
val port: UiccPortInfoCompat val lpa: LocalProfileAssistant = LocalProfileAssistantImpl(apduInterface, HttpInterfaceImpl())
val slotId: Int // PHYSICAL slot
val logicalSlotId: Int
val portId: Int
val lpa: LocalProfileAssistant
val valid: Boolean val valid: Boolean
get() = lpa.valid
/** fun close() = lpa.close()
* Answer to Reset (ATR) value of the underlying interface, if any
*/
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
*/
val apduInterface: ApduInterface
/**
* The AID of the ISD-R channel currently in use
*/
val isdrAid: ByteArray
fun close()
} }

View file

@ -1,17 +1,15 @@
package im.angry.openeuicc.core package im.angry.openeuicc.core
import im.angry.openeuicc.core.usb.UsbCcidContext import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbInterface
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
// This class is here instead of inside DI because it contains a bit more logic than just // This class is here instead of inside DI because it contains a bit more logic than just
// "dumb" dependency injection. // "dumb" dependency injection.
interface EuiccChannelFactory { interface EuiccChannelFactory {
suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat, isdrAid: ByteArray): EuiccChannel? suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel?
fun tryOpenUsbEuiccChannel( fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel?
ccidCtx: UsbCcidContext,
isdrAid: ByteArray
): EuiccChannel?
/** /**
* Release all resources used by this EuiccChannelFactory * Release all resources used by this EuiccChannelFactory

View file

@ -1,38 +0,0 @@
package im.angry.openeuicc.core
import im.angry.openeuicc.util.UiccPortInfoCompat
import im.angry.openeuicc.util.decodeHex
import kotlinx.coroutines.flow.Flow
import net.typeblog.lpac_jni.ApduInterface
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.impl.HttpInterfaceImpl
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,
verboseLoggingFlow: Flow<Boolean>,
ignoreTLSCertificateFlow: Flow<Boolean>
) : EuiccChannel {
override val slotId = port.card.physicalSlotIndex
override val logicalSlotId = port.logicalSlotIndex
override val portId = port.portIndex
override val lpa: LocalProfileAssistant =
LocalProfileAssistantImpl(
isdrAid,
apduInterface,
HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow)
)
override val atr: ByteArray?
get() = (apduInterface as? ApduInterfaceAtrProvider)?.atr
override val valid: Boolean
get() = lpa.valid
override fun close() = lpa.close()
}

View file

@ -1,7 +1,6 @@
package im.angry.openeuicc.core package im.angry.openeuicc.core
import android.hardware.usb.UsbDevice import android.hardware.usb.UsbDevice
import kotlinx.coroutines.flow.Flow
/** /**
* EuiccChannelManager holds references to, and manages the lifecycles of, individual * EuiccChannelManager holds references to, and manages the lifecycles of, individual
@ -19,35 +18,19 @@ interface EuiccChannelManager {
} }
/** /**
* Scan all possible _device internal_ sources for EuiccChannels, as a flow, return their physical * Scan all possible _device internal_ sources for EuiccChannels, return them and have all
* (slotId, portId) and have all scanned channels cached; these channels will remain open * scanned channels cached; these channels will remain open for the entire lifetime of
* for the entire lifetime of this EuiccChannelManager object, unless disconnected externally * this EuiccChannelManager object, unless disconnected externally or invalidate()'d
* or invalidate()'d.
*
* To obtain a temporary reference to a EuiccChannel, use `withEuiccChannel()`.
*/ */
fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>> suspend fun enumerateEuiccChannels(): List<EuiccChannel>
/**
* Same as flowInternalEuiccPorts(), except that this includes non-device internal eUICC chips
* as well. Namely, this includes the USB reader.
*
* Non-internal readers will only be included if they have been opened properly, i.e. with permissions
* granted by the user.
*/
fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>>
/** /**
* Scan all possible USB devices for CCID readers that may contain eUICC cards. * 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 * If found, try to open it for access, and add it to the internal EuiccChannel cache
* as a "port" with id 99. When user interaction is required to obtain permission * as a "port" with id 99. When user interaction is required to obtain permission
* to interact with the device, the second return value will be false. * to interact with the device, the second return value (EuiccChannel) will be null.
*
* Returns (usbDevice, canOpen). canOpen is false if either (1) no usb reader is found;
* or (2) usb reader is found, but user interaction is required for access;
* or (3) usb reader is found, but we are unable to open ISD-R.
*/ */
suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean> suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?>
/** /**
* Wait for a slot + port to reconnect (i.e. become valid again) * Wait for a slot + port to reconnect (i.e. become valid again)
@ -57,40 +40,29 @@ interface EuiccChannelManager {
suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long = 1000) suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long = 1000)
/** /**
* Returns the first mapped & available port ID for a physical slot, or -1 if * Returns the EuiccChannel corresponding to a **logical** slot
* not found.
*/ */
suspend fun findFirstAvailablePort(physicalSlotId: Int): Int fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel?
/** /**
* Returns all mapped & available port IDs for a physical slot. * Returns the first EuiccChannel corresponding to a **physical** slot
* If the physical slot supports MEP and has multiple ports, it is undefined
* which of the two channels will be returned.
*/ */
suspend fun findAvailablePorts(physicalSlotId: Int): List<Int> fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel?
class EuiccChannelNotFoundException: Exception("EuiccChannel not found")
/** /**
* Find a EuiccChannel by its slot and port, then run a callback with a reference to it. * Returns all EuiccChannels corresponding to a **physical** slot
* The reference is not supposed to be held outside of the callback. This is enforced via * Multiple channels are possible in the case of MEP
* a wrapper object.
*
* The callback is run on Dispatchers.IO by default.
*
* If a channel for that slot / port is not found, EuiccChannelNotFoundException is thrown
*/ */
suspend fun <R> withEuiccChannel( suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>?
physicalSlotId: Int, fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>?
portId: Int,
fn: suspend (EuiccChannel) -> R
): R
/** /**
* Same as withEuiccChannel(Int, Int, (EuiccChannel) -> R) but instead uses logical slot ID * Returns the EuiccChannel corresponding to a **physical** slot and a port ID
*/ */
suspend fun <R> withEuiccChannel( suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel?
logicalSlotId: Int, fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel?
fn: suspend (EuiccChannel) -> R
): R
/** /**
* Invalidate all EuiccChannels previously cached by this Manager * Invalidate all EuiccChannels previously cached by this Manager
@ -102,7 +74,7 @@ interface EuiccChannelManager {
* This is only expected to be implemented when the application is privileged * This is only expected to be implemented when the application is privileged
* TODO: Remove this from the common interface * TODO: Remove this from the common interface
*/ */
suspend fun notifyEuiccProfilesChanged(logicalSlotId: Int) { fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
// no-op by default // no-op by default
} }
} }

View file

@ -1,53 +0,0 @@
package im.angry.openeuicc.core
import im.angry.openeuicc.util.*
import net.typeblog.lpac_jni.ApduInterface
import net.typeblog.lpac_jni.LocalProfileAssistant
class EuiccChannelWrapper(orig: EuiccChannel) : EuiccChannel {
private var _inner: EuiccChannel? = orig
private val channel: EuiccChannel
get() {
if (_inner == null) {
throw IllegalStateException("This wrapper has been invalidated")
}
return _inner!!
}
override val type: String
get() = channel.type
override val port: UiccPortInfoCompat
get() = channel.port
override val slotId: Int
get() = channel.slotId
override val logicalSlotId: Int
get() = channel.logicalSlotId
override val portId: Int
get() = channel.portId
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 fun close() = channel.close()
fun invalidateWrapper() {
_inner = null
if (lpaDelegate.isInitialized()) {
(lpa as LocalProfileAssistantWrapper).invalidateWrapper()
}
}
}

View file

@ -1,66 +0,0 @@
package im.angry.openeuicc.core
import net.typeblog.lpac_jni.EuiccInfo2
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.LocalProfileInfo
import net.typeblog.lpac_jni.LocalProfileNotification
import net.typeblog.lpac_jni.ProfileDownloadCallback
class LocalProfileAssistantWrapper(orig: LocalProfileAssistant) :
LocalProfileAssistant {
private var _inner: LocalProfileAssistant? = orig
private val lpa: LocalProfileAssistant
get() {
if (_inner == null) {
throw IllegalStateException("This wrapper has been invalidated")
}
return _inner!!
}
override val valid: Boolean
get() = lpa.valid
override val profiles: List<LocalProfileInfo>
get() = lpa.profiles
override val notifications: List<LocalProfileNotification>
get() = lpa.notifications
override val eID: String
get() = lpa.eID
override val euiccInfo2: EuiccInfo2?
get() = lpa.euiccInfo2
override fun setEs10xMss(mss: Byte) = lpa.setEs10xMss(mss)
override fun enableProfile(iccid: String, refresh: Boolean): Boolean =
lpa.enableProfile(iccid, refresh)
override fun disableProfile(iccid: String, refresh: Boolean): Boolean =
lpa.disableProfile(iccid, refresh)
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 deleteNotification(seqNumber: Long): Boolean = lpa.deleteNotification(seqNumber)
override fun handleNotification(seqNumber: Long): Boolean = lpa.handleNotification(seqNumber)
override fun euiccMemoryReset() = lpa.euiccMemoryReset()
override fun setNickname(iccid: String, nickname: String) {
lpa.setNickname(iccid, nickname)
}
override fun close() = lpa.close()
fun invalidateWrapper() {
_inner = null
}
}

View file

@ -3,33 +3,19 @@ package im.angry.openeuicc.core
import android.se.omapi.Channel import android.se.omapi.Channel
import android.se.omapi.SEService import android.se.omapi.SEService
import android.se.omapi.Session import android.se.omapi.Session
import android.util.Log
import im.angry.openeuicc.util.* 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.ApduInterface
import java.util.concurrent.atomic.AtomicInteger
class OmapiApduInterface( class OmapiApduInterface(
private val service: SEService, private val service: SEService,
private val port: UiccPortInfoCompat, private val port: UiccPortInfoCompat
private val verboseLoggingFlow: Flow<Boolean> ): ApduInterface {
): ApduInterface, ApduInterfaceAtrProvider {
companion object {
const val TAG = "OmapiApduInterface"
}
private lateinit var session: Session private lateinit var session: Session
private val index = AtomicInteger(0) private lateinit var lastChannel: Channel
private val channels = mutableMapOf<Int, Channel>()
override val valid: Boolean override val valid: Boolean
get() = service.isConnected && (this::session.isInitialized && !session.isClosed) get() = service.isConnected && (this::session.isInitialized && !session.isClosed)
override val atr: ByteArray?
get() = session.atr
override fun connect() { override fun connect() {
session = service.getUiccReaderCompat(port.logicalSlotIndex + 1).openSession() session = service.getUiccReaderCompat(port.logicalSlotIndex + 1).openSession()
} }
@ -39,48 +25,26 @@ class OmapiApduInterface(
} }
override fun logicalChannelOpen(aid: ByteArray): Int { override fun logicalChannelOpen(aid: ByteArray): Int {
val channel = session.openLogicalChannel(aid) check(!this::lastChannel.isInitialized) {
check(channel != null) { "Failed to open logical channel (${aid.encodeHex()})" } "Can only open one channel"
val handle = index.incrementAndGet() }
synchronized(channels) { channels[handle] = channel } lastChannel = session.openLogicalChannel(aid)!!;
return handle return 1;
} }
override fun logicalChannelClose(handle: Int) { override fun logicalChannelClose(handle: Int) {
val channel = channels[handle] check(handle == 1 && !this::lastChannel.isInitialized) {
check(channel != null) { "Invalid logical channel handle $handle" } "Unknown channel"
if (channel.isOpen) channel.close() }
synchronized(channels) { channels.remove(handle) } lastChannel.close()
} }
override fun transmit(handle: Int, tx: ByteArray): ByteArray { override fun transmit(tx: ByteArray): ByteArray {
val channel = channels[handle] check(this::lastChannel.isInitialized) {
check(channel != null) { "Invalid logical channel handle $handle" } "Unknown channel"
if (runBlocking { verboseLoggingFlow.first() }) {
Log.d(TAG, "OMAPI APDU: ${tx.encodeHex()}")
} }
try { return lastChannel.transmit(tx)
for (i in 0..10) {
val res = channel.transmit(tx)
if (runBlocking { verboseLoggingFlow.first() }) {
Log.d(TAG, "OMAPI APDU response: ${res.encodeHex()}")
} }
if (res.size == 2 && res[0] == 0x66.toByte() && res[1] == 0x01.toByte()) {
Log.d(TAG, "Received checksum error 0x6601, retrying (count = $i)")
continue
}
return res
}
throw RuntimeException("Retransmit attempts exhausted; this was likely caused by checksum errors")
} catch (e: Exception) {
Log.e(TAG, "OMAPI APDU exception")
e.printStackTrace()
throw e
}
}
} }

View file

@ -1,41 +1,49 @@
package im.angry.openeuicc.core.usb package im.angry.openeuicc.core.usb
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.util.Log import android.util.Log
import im.angry.openeuicc.core.ApduInterfaceAtrProvider
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import net.typeblog.lpac_jni.ApduInterface import net.typeblog.lpac_jni.ApduInterface
class UsbApduInterface( class UsbApduInterface(
private val ccidCtx: UsbCcidContext private val conn: UsbDeviceConnection,
) : ApduInterface, ApduInterfaceAtrProvider { private val bulkIn: UsbEndpoint,
private val bulkOut: UsbEndpoint
): ApduInterface {
companion object { companion object {
private const val TAG = "UsbApduInterface" private const val TAG = "UsbApduInterface"
} }
override val atr: ByteArray? private lateinit var ccidDescription: UsbCcidDescription
get() = ccidCtx.atr private lateinit var transceiver: UsbCcidTransceiver
override val valid: Boolean private var channelId = -1
get() = channels.isNotEmpty()
private var channels = mutableSetOf<Int>()
override fun connect() { override fun connect() {
ccidCtx.connect() ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!!
// Send Terminal Capabilities if (!ccidDescription.hasT0Protocol) {
// Specs: ETSI TS 102 221 v15.0.0 - 11.1.19 TERMINAL CAPABILITY throw IllegalArgumentException("Unsupported card reader; T=0 support is required")
val terminalCapabilities = buildCmd(
0x80.toByte(), 0xaa.toByte(), 0x00, 0x00,
"A9088100820101830107".decodeHex(),
le = null,
)
transmitApduByChannel(terminalCapabilities, 0)
} }
override fun disconnect() = ccidCtx.disconnect() transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription)
try {
transceiver.iccPowerOn()
} catch (e: Exception) {
e.printStackTrace()
throw e
}
}
override fun disconnect() {
conn.close()
}
override fun logicalChannelOpen(aid: ByteArray): Int { override fun logicalChannelOpen(aid: ByteArray): Int {
check(channelId == -1) { "Logical channel already opened" }
// OPEN LOGICAL CHANNEL // OPEN LOGICAL CHANNEL
val req = manageChannelCmd(true, 0) val req = manageChannelCmd(true, 0)
@ -51,7 +59,7 @@ class UsbApduInterface(
return -1 return -1
} }
val channelId = resp[0].toInt() channelId = resp[0].toInt()
Log.d(TAG, "channelId = $channelId") Log.d(TAG, "channelId = $channelId")
// Then, select AID // Then, select AID
@ -63,32 +71,32 @@ class UsbApduInterface(
return -1 return -1
} }
channels.add(channelId)
return channelId return channelId
} }
override fun logicalChannelClose(handle: Int) { override fun logicalChannelClose(handle: Int) {
check(channels.contains(handle)) { check(handle == channelId) { "Logical channel ID mismatch" }
"Invalid logical channel handle $handle" check(channelId != -1) { "Logical channel is not opened" }
}
// CLOSE LOGICAL CHANNEL // CLOSE LOGICAL CHANNEL
val req = manageChannelCmd(false, handle.toByte()) val req = manageChannelCmd(false, channelId.toByte())
val resp = transmitApduByChannel(req, handle.toByte()) val resp = transmitApduByChannel(req, channelId.toByte())
if (!isSuccessResponse(resp)) { if (!isSuccessResponse(resp)) {
Log.d(TAG, "CLOSE LOGICAL CHANNEL failed: ${resp.encodeHex()}") Log.d(TAG, "CLOSE LOGICAL CHANNEL failed: ${resp.encodeHex()}")
} }
channels.remove(handle)
channelId = -1
} }
override fun transmit(handle: Int, tx: ByteArray): ByteArray { override fun transmit(tx: ByteArray): ByteArray {
check(channels.contains(handle)) { check(channelId != -1) { "Logical channel is not opened" }
"Invalid logical channel handle $handle" return transmitApduByChannel(tx, channelId.toByte())
}
return transmitApduByChannel(tx, handle.toByte())
} }
override val valid: Boolean
get() = channelId != -1
private fun isSuccessResponse(resp: ByteArray): Boolean = private fun isSuccessResponse(resp: ByteArray): Boolean =
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte() resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
@ -122,7 +130,7 @@ class UsbApduInterface(
// OR the channel mask into the CLA byte // OR the channel mask into the CLA byte
realTx[0] = ((realTx[0].toInt() and 0xFC) or channel.toInt()).toByte() realTx[0] = ((realTx[0].toInt() and 0xFC) or channel.toInt()).toByte()
var resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!! var resp = transceiver.sendXfrBlock(realTx).data!!
if (resp.size < 2) throw RuntimeException("APDU response smaller than 2 (sw1 + sw2)!") if (resp.size < 2) throw RuntimeException("APDU response smaller than 2 (sw1 + sw2)!")
@ -133,7 +141,7 @@ class UsbApduInterface(
// 0x6C = wrong le // 0x6C = wrong le
// so we fix the le field here // so we fix the le field here
realTx[realTx.size - 1] = resp[resp.size - 1] realTx[realTx.size - 1] = resp[resp.size - 1]
resp = ccidCtx.transceiver.sendXfrBlock(realTx).data!! resp = transceiver.sendXfrBlock(realTx).data!!
} else if (sw1 == 0x61) { } else if (sw1 == 0x61) {
// 0x61 = X bytes available // 0x61 = X bytes available
// continue reading by GET RESPONSE // continue reading by GET RESPONSE
@ -143,7 +151,7 @@ class UsbApduInterface(
realTx[0], 0xC0.toByte(), 0x00, 0x00, sw2.toByte() realTx[0], 0xC0.toByte(), 0x00, 0x00, sw2.toByte()
) )
val tmp = ccidCtx.transceiver.sendXfrBlock(getResponseCmd).data!! val tmp = transceiver.sendXfrBlock(getResponseCmd).data!!
resp = resp.sliceArray(0 until (resp.size - 2)) + tmp resp = resp.sliceArray(0 until (resp.size - 2)) + tmp

View file

@ -1,87 +0,0 @@
package im.angry.openeuicc.core.usb
import android.content.Context
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import im.angry.openeuicc.util.preferenceRepository
import kotlinx.coroutines.flow.Flow
/**
* A wrapper over an usb device + interface, manages the lifecycle independent
* of the APDU interface exposed to lpac-jni.
*
* This allows us to try multiple AIDs on each interface without opening / closing
* the USB connection numerous times.
*/
class UsbCcidContext private constructor(
private val conn: UsbDeviceConnection,
private val bulkIn: UsbEndpoint,
private val bulkOut: UsbEndpoint,
val productName: String,
val verboseLoggingFlow: Flow<Boolean>
) {
companion object {
fun createFromUsbDevice(
context: Context,
usbDevice: UsbDevice,
usbInterface: UsbInterface
): UsbCcidContext? = runCatching {
val (bulkIn, bulkOut) = usbInterface.endpoints.bulkPair
if (bulkIn == null || bulkOut == null) return@runCatching null
val conn = context.getSystemService(UsbManager::class.java).openDevice(usbDevice)
?: return@runCatching null
if (!conn.claimInterface(usbInterface, true)) return@runCatching null
UsbCcidContext(
conn,
bulkIn,
bulkOut,
usbDevice.productName ?: "USB",
context.preferenceRepository.verboseLoggingFlow
)
}.getOrNull()
}
/**
* When set to false (the default), the disconnect() method does nothing.
* This allows the separation of device disconnection from lpac-jni's APDU interface.
*/
var allowDisconnect = false
private var initialized = false
lateinit var transceiver: UsbCcidTransceiver
var atr: ByteArray? = null
fun connect() {
if (initialized) {
return
}
val ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!!
if (!ccidDescription.hasT0Protocol) {
throw IllegalArgumentException("Unsupported card reader; T=0 support is required")
}
transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription, verboseLoggingFlow)
try {
// 6.1.1.1 PC_to_RDR_IccPowerOn (Page 20 of 40)
// https://www.usb.org/sites/default/files/DWG_Smart-Card_USB-ICC_ICCD_rev10.pdf
atr = transceiver.iccPowerOn().data
} catch (e: Exception) {
e.printStackTrace()
throw e
}
initialized = true
}
fun disconnect() {
if (initialized && allowDisconnect) {
conn.close()
atr = null
}
}
}

View file

@ -20,12 +20,12 @@ data class UsbCcidDescription(
private const val FEATURE_EXCHANGE_LEVEL_TPDU = 0x10000 private const val FEATURE_EXCHANGE_LEVEL_TPDU = 0x10000
private const val FEATURE_EXCHANGE_LEVEL_SHORT_APDU = 0x20000 private const val FEATURE_EXCHANGE_LEVEL_SHORT_APDU = 0x20000
private const val FEATURE_EXCHANGE_LEVEL_EXTENDED_APDU = 0x40000 private const val FEATURE_EXCHAGE_LEVEL_EXTENDED_APDU = 0x40000
// bVoltageSupport Masks // bVoltageSupport Masks
private const val VOLTAGE_5V0: Byte = 1 private const val VOLTAGE_5V: Byte = 1
private const val VOLTAGE_3V0: Byte = 2 private const val VOLTAGE_3V: Byte = 2
private const val VOLTAGE_1V8: Byte = 4 private const val VOLTAGE_1_8V: Byte = 4
private const val SLOT_OFFSET = 4 private const val SLOT_OFFSET = 4
private const val FEATURES_OFFSET = 40 private const val FEATURES_OFFSET = 40
@ -71,23 +71,30 @@ data class UsbCcidDescription(
} }
enum class Voltage(powerOnValue: Int, mask: Int) { enum class Voltage(powerOnValue: Int, mask: Int) {
// @formatter:off AUTO(0, 0), _5V(1, VOLTAGE_5V.toInt()), _3V(2, VOLTAGE_3V.toInt()), _1_8V(
AUTO(0, 0), 3,
V50(1, VOLTAGE_5V0.toInt()), VOLTAGE_1_8V.toInt()
V30(2, VOLTAGE_3V0.toInt()), );
V18(3, VOLTAGE_1V8.toInt());
// @formatter:on
val mask = powerOnValue.toByte() val mask = powerOnValue.toByte()
val powerOnValue = mask.toByte() val powerOnValue = mask.toByte()
} }
private fun hasFeature(feature: Int) = (dwFeatures and feature) != 0 private fun hasFeature(feature: Int): Boolean =
(dwFeatures and feature) != 0
val voltages: List<Voltage> val voltages: Array<Voltage>
get() { get() =
if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) return listOf(Voltage.AUTO) if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) {
return Voltage.entries.filter { (it.mask.toInt() and bVoltageSupport.toInt()) != 0 } arrayOf(Voltage.AUTO)
} else {
Voltage.values().mapNotNull {
if ((it.mask.toInt() and bVoltageSupport.toInt()) != 0) {
it
} else {
null
}
}.toTypedArray()
} }
val hasAutomaticPps: Boolean val hasAutomaticPps: Boolean

View file

@ -5,9 +5,6 @@ import android.hardware.usb.UsbEndpoint
import android.os.SystemClock import android.os.SystemClock
import android.util.Log import android.util.Log
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
@ -21,8 +18,7 @@ class UsbCcidTransceiver(
private val usbConnection: UsbDeviceConnection, private val usbConnection: UsbDeviceConnection,
private val usbBulkIn: UsbEndpoint, private val usbBulkIn: UsbEndpoint,
private val usbBulkOut: UsbEndpoint, private val usbBulkOut: UsbEndpoint,
private val usbCcidDescription: UsbCcidDescription, private val usbCcidDescription: UsbCcidDescription
private val verboseLoggingFlow: Flow<Boolean>
) { ) {
companion object { companion object {
private const val TAG = "UsbCcidTransceiver" private const val TAG = "UsbCcidTransceiver"
@ -95,7 +91,6 @@ class UsbCcidTransceiver(
data class UsbCcidErrorException(val msg: String, val errorResponse: CcidDataBlock) : data class UsbCcidErrorException(val msg: String, val errorResponse: CcidDataBlock) :
Exception(msg) Exception(msg)
@Suppress("ArrayInDataClass")
data class CcidDataBlock( data class CcidDataBlock(
val dwLength: Int, val dwLength: Int,
val bSlot: Byte, val bSlot: Byte,
@ -183,27 +178,30 @@ class UsbCcidTransceiver(
readBytes = usbConnection.bulkTransfer( readBytes = usbConnection.bulkTransfer(
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
) )
if (runBlocking { verboseLoggingFlow.first() }) { Log.d(TAG, "Received " + readBytes + " bytes: " + inputBuffer.encodeHex())
Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}")
}
} while (readBytes <= 0 && attempts-- > 0) } while (readBytes <= 0 && attempts-- > 0)
if (readBytes < CCID_HEADER_LENGTH) { if (readBytes < CCID_HEADER_LENGTH) {
throw UsbTransportException("USB-CCID error - failed to receive CCID header") throw UsbTransportException("USB-CCID error - failed to receive CCID header")
} }
if (inputBuffer[0] != MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.toByte()) { if (inputBuffer[0] != MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK.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]) { if (expectedSequenceNumber != inputBuffer[6]) {
append(", sequence number ") throw UsbTransportException(
append("%d (expected %d)".format(inputBuffer[6], expectedSequenceNumber)) ((("USB-CCID error - bad CCID header, type " + inputBuffer[0]) + " (expected " +
MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK) + "), sequence number " + inputBuffer[6]
) + " (expected " +
expectedSequenceNumber + ")"
)
} }
}) throw UsbTransportException(
"USB-CCID error - bad CCID header type " + inputBuffer[0]
)
} }
var result = CcidDataBlock.parseHeaderFromBytes(inputBuffer) var result = CcidDataBlock.parseHeaderFromBytes(inputBuffer)
if (expectedSequenceNumber != result.bSeq) { if (expectedSequenceNumber != result.bSeq) {
throw UsbTransportException("USB-CCID error - expected sequence number $expectedSequenceNumber, got $result") throw UsbTransportException(
("USB-CCID error - expected sequence number " +
expectedSequenceNumber + ", got " + result)
)
} }
val dataBuffer = ByteArray(result.dwLength) val dataBuffer = ByteArray(result.dwLength)
@ -214,7 +212,9 @@ class UsbCcidTransceiver(
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
) )
if (readBytes < 0) { if (readBytes < 0) {
throw UsbTransportException("USB error - failed reading response data! Header: $result") throw UsbTransportException(
"USB error - failed reading response data! Header: $result"
)
} }
System.arraycopy(inputBuffer, 0, dataBuffer, bufferedBytes, readBytes) System.arraycopy(inputBuffer, 0, dataBuffer, bufferedBytes, readBytes)
bufferedBytes += readBytes bufferedBytes += readBytes
@ -279,7 +279,7 @@ class UsbCcidTransceiver(
} }
val ccidDataBlock = receiveDataBlock(sequenceNumber) val ccidDataBlock = receiveDataBlock(sequenceNumber)
val elapsedTime = SystemClock.elapsedRealtime() - startTime val elapsedTime = SystemClock.elapsedRealtime() - startTime
Log.d(TAG, "USB XferBlock call took ${elapsedTime}ms") Log.d(TAG, "USB XferBlock call took " + elapsedTime + "ms")
return ccidDataBlock return ccidDataBlock
} }
@ -287,13 +287,13 @@ class UsbCcidTransceiver(
val startTime = SystemClock.elapsedRealtime() val startTime = SystemClock.elapsedRealtime()
skipAvailableInput() skipAvailableInput()
var response: CcidDataBlock? = null var response: CcidDataBlock? = null
for (voltage in usbCcidDescription.voltages) { for (v in usbCcidDescription.voltages) {
Log.v(TAG, "CCID: attempting to power on with voltage $voltage") Log.v(TAG, "CCID: attempting to power on with voltage $v")
response = try { response = try {
iccPowerOnVoltage(voltage.powerOnValue) iccPowerOnVoltage(v.powerOnValue)
} catch (e: UsbCcidErrorException) { } catch (e: UsbCcidErrorException) {
if (e.errorResponse.bError.toInt() == 7) { // Power select error if (e.errorResponse.bError.toInt() == 7) { // Power select error
Log.v(TAG, "CCID: failed to power on with voltage $voltage") Log.v(TAG, "CCID: failed to power on with voltage $v")
iccPowerOff() iccPowerOff()
Log.v(TAG, "CCID: powered off") Log.v(TAG, "CCID: powered off")
continue continue
@ -308,11 +308,8 @@ class UsbCcidTransceiver(
val elapsedTime = SystemClock.elapsedRealtime() - startTime val elapsedTime = SystemClock.elapsedRealtime() - startTime
Log.d( Log.d(
TAG, TAG,
buildString { "Usb transport connected, took " + elapsedTime + "ms, ATR=" +
append("Usb transport connected") response.data?.encodeHex()
append(", took ", elapsedTime, "ms")
append(", ATR=", response.data?.encodeHex())
}
) )
return response return response
} }

View file

@ -6,22 +6,31 @@ import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbEndpoint import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface import android.hardware.usb.UsbInterface
class UsbTransportException(message: String) : Exception(message) class UsbTransportException(msg: String) : Exception(msg)
val UsbDevice.interfaces: Iterable<UsbInterface> fun UsbInterface.getIoEndpoints(): Pair<UsbEndpoint?, UsbEndpoint?> {
get() = (0 until interfaceCount).map(::getInterface) var bulkIn: UsbEndpoint? = null
var bulkOut: UsbEndpoint? = null
val Iterable<UsbInterface>.smartCard: UsbInterface? for (i in 0 until endpointCount) {
get() = find { it.interfaceClass == UsbConstants.USB_CLASS_CSCID } val endpoint = getEndpoint(i)
if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
val UsbInterface.endpoints: Iterable<UsbEndpoint> continue
get() = (0 until endpointCount).map(::getEndpoint) }
if (endpoint.direction == UsbConstants.USB_DIR_IN) {
val Iterable<UsbEndpoint>.bulkPair: Pair<UsbEndpoint?, UsbEndpoint?> bulkIn = endpoint
get() { } else if (endpoint.direction == UsbConstants.USB_DIR_OUT) {
val endpoints = filter { it.type == UsbConstants.USB_ENDPOINT_XFER_BULK } bulkOut = endpoint
return Pair( }
endpoints.find { it.direction == UsbConstants.USB_DIR_IN }, }
endpoints.find { it.direction == UsbConstants.USB_DIR_OUT }, return Pair(bulkIn, bulkOut)
) }
fun UsbDevice.getSmartCardInterface(): UsbInterface? {
for (i in 0 until interfaceCount) {
val anInterface = getInterface(i)
if (anInterface.interfaceClass == UsbConstants.USB_CLASS_CSCID) {
return anInterface
}
}
return null
} }

View file

@ -15,5 +15,4 @@ interface AppContainer {
val preferenceRepository: PreferenceRepository val preferenceRepository: PreferenceRepository
val uiComponentFactory: UiComponentFactory val uiComponentFactory: UiComponentFactory
val euiccChannelFactory: EuiccChannelFactory val euiccChannelFactory: EuiccChannelFactory
val customizableTextProvider: CustomizableTextProvider
} }

View file

@ -1,20 +0,0 @@
package im.angry.openeuicc.di
interface CustomizableTextProvider {
/**
* Explanation string for when no eUICC is found on the device.
* This could be different depending on whether the app is privileged or not.
*/
val noEuiccExplanation: String
/**
* Shown when we timed out switching between profiles.
*/
val profileSwitchingTimeoutMessage: String
/**
* Format the name of a logical slot; internal only -- not intended for
* other channels such as USB.
*/
fun formatInternalChannelName(logicalSlotId: Int): String
}

View file

@ -38,8 +38,4 @@ open class DefaultAppContainer(context: Context) : AppContainer {
override val euiccChannelFactory by lazy { override val euiccChannelFactory by lazy {
DefaultEuiccChannelFactory(context) DefaultEuiccChannelFactory(context)
} }
override val customizableTextProvider by lazy {
DefaultCustomizableTextProvider(context)
}
} }

View file

@ -1,15 +0,0 @@
package im.angry.openeuicc.di
import android.content.Context
import im.angry.openeuicc.common.R
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)
override fun formatInternalChannelName(logicalSlotId: Int): String =
context.getString(R.string.channel_name_format, logicalSlotId)
}

View file

@ -1,16 +1,13 @@
package im.angry.openeuicc.di package im.angry.openeuicc.di
import androidx.fragment.app.Fragment 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.EuiccManagementFragment
import im.angry.openeuicc.ui.NoEuiccPlaceholderFragment import im.angry.openeuicc.ui.NoEuiccPlaceholderFragment
import im.angry.openeuicc.ui.SettingsFragment
open class DefaultUiComponentFactory : UiComponentFactory { open class DefaultUiComponentFactory : UiComponentFactory {
override fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment = override fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment =
EuiccManagementFragment.newInstance(slotId, portId) EuiccManagementFragment.newInstance(channel.slotId, channel.portId)
override fun createNoEuiccPlaceholderFragment(): Fragment = NoEuiccPlaceholderFragment() override fun createNoEuiccPlaceholderFragment(): Fragment = NoEuiccPlaceholderFragment()
override fun createSettingsFragment(): Fragment = SettingsFragment()
} }

View file

@ -1,11 +1,10 @@
package im.angry.openeuicc.di package im.angry.openeuicc.di
import androidx.fragment.app.Fragment 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.EuiccManagementFragment
interface UiComponentFactory { interface UiComponentFactory {
fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment
fun createNoEuiccPlaceholderFragment(): Fragment fun createNoEuiccPlaceholderFragment(): Fragment
fun createSettingsFragment(): Fragment
} }

View file

@ -1,42 +1,11 @@
package im.angry.openeuicc.service package im.angry.openeuicc.service
import android.app.Service
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Binder import android.os.Binder
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.yield
import net.typeblog.lpac_jni.ProfileDownloadCallback
/** /**
* An Android Service wrapper for EuiccChannelManager. * An Android Service wrapper for EuiccChannelManager.
@ -48,41 +17,8 @@ import net.typeblog.lpac_jni.ProfileDownloadCallback
* instance of EuiccChannelManager. UI components can keep being bound to this service for * instance of EuiccChannelManager. UI components can keep being bound to this service for
* their entire lifecycles, since the whole purpose of them is to expose the current state * their entire lifecycles, since the whole purpose of them is to expose the current state
* to the user. * to the user.
*
* Additionally, this service is also responsible for long-running "foreground" tasks that
* are not suitable to be managed by UI components. This includes profile downloading, etc.
* When a UI component needs to run one of these tasks, they have to bind to this service
* and call one of the `launch*` methods, which will run the task inside this service's
* lifecycle context and return a Flow instance for the UI component to subscribe to its
* progress.
*/ */
class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker { class EuiccChannelManagerService : Service(), OpenEuiccContextMarker {
companion object {
private const val TAG = "EuiccChannelManagerService"
private const val CHANNEL_ID = "tasks"
private const val FOREGROUND_ID = 1000
private const val TASK_FAILURE_ID = 1000
/**
* Utility function to wait for a foreground task to be done, return its
* error if any, or null on success.
*/
suspend fun Flow<ForegroundTaskState>.waitDone(): Throwable? =
(this.last() as ForegroundTaskState.Done).error
/**
* Apply transform to a ForegroundTaskState flow so that it completes when a Done is seen.
*
* This must be applied each time a flow is returned for subscription purposes. If applied
* beforehand, we lose the ability to subscribe multiple times.
*/
private fun Flow<ForegroundTaskState>.applyCompletionTransform() =
transformWhile {
emit(it)
it !is ForegroundTaskState.Done
}
}
inner class LocalBinder : Binder() { inner class LocalBinder : Binder() {
val service = this@EuiccChannelManagerService val service = this@EuiccChannelManagerService
} }
@ -92,436 +28,14 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
} }
val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate
private val wakeLock: PowerManager.WakeLock by lazy { override fun onBind(intent: Intent?): IBinder = LocalBinder()
(getSystemService(POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this::class.simpleName)
}
}
/**
* The state of a "foreground" task (named so due to the need to startForeground())
*/
sealed interface ForegroundTaskState {
data object Idle : ForegroundTaskState
data class InProgress(val progress: Int) : ForegroundTaskState
data class Done(val error: Throwable?) : ForegroundTaskState
}
/**
* This flow emits whenever the service has had a start command, from startService()
* The service self-starts when foreground is required, because other components
* only bind to this service and do not start it per-se.
*/
private val foregroundStarted: MutableSharedFlow<Unit> = MutableSharedFlow()
/**
* This flow is used to emit progress updates when a foreground task is running.
*/
private val foregroundTaskState: MutableStateFlow<ForegroundTaskState> =
MutableStateFlow(ForegroundTaskState.Idle)
/**
* A simple wrapper over a flow with taskId added.
*
* taskID is the exact millisecond-precision timestamp when the task is launched.
*/
class ForegroundTaskSubscriberFlow(val taskId: Long, inner: Flow<ForegroundTaskState>) :
Flow<ForegroundTaskState> by inner
/**
* A cache of subscribers to 5 recently-launched foreground tasks, identified by ID
*
* Only one can be run at the same time, but those that are done will be kept in this
* map for a little while -- because UI components may be stopped and recreated while
* tasks are running. Having this buffer allows the components to re-subscribe even if
* the task completes while they are being recreated.
*/
private val foregroundTaskSubscribers: MutableMap<Long, SharedFlow<ForegroundTaskState>> =
mutableMapOf()
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return LocalBinder()
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
// This is the whole reason of the existence of this service:
// we can clean up opened channels when no one is using them
if (euiccChannelManagerDelegate.isInitialized()) { if (euiccChannelManagerDelegate.isInitialized()) {
euiccChannelManager.invalidate() euiccChannelManager.invalidate()
} }
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return super.onStartCommand(intent, flags, startId).also {
lifecycleScope.launch {
foregroundStarted.emit(Unit)
}
}
}
private fun ensureForegroundTaskNotificationChannel() {
val nm = NotificationManagerCompat.from(this)
if (nm.getNotificationChannelCompat(CHANNEL_ID) == null) {
val channel =
NotificationChannelCompat.Builder(
CHANNEL_ID,
NotificationManagerCompat.IMPORTANCE_LOW
)
.setName(getString(R.string.task_notification))
.setVibrationEnabled(false)
.build()
nm.createNotificationChannel(channel)
}
}
private suspend fun updateForegroundNotification(title: String, iconRes: Int) {
ensureForegroundTaskNotificationChannel()
val nm = NotificationManagerCompat.from(this)
val state = foregroundTaskState.value
if (state is ForegroundTaskState.InProgress) {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setProgress(100, state.progress, state.progress == 0)
.setSmallIcon(iconRes)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
if (state.progress == 0) {
startForeground(FOREGROUND_ID, notification)
} else if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
nm.notify(FOREGROUND_ID, notification)
}
// Yield out so that the main looper can handle the notification event
// Without this yield, the notification sent above will not be shown in time.
yield()
} else {
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
private fun postForegroundTaskFailureNotification(title: String) {
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setSmallIcon(R.drawable.ic_x_black)
.build()
NotificationManagerCompat.from(this).notify(TASK_FAILURE_ID, notification)
}
/**
* Recover the subscriber to a foreground task that is recently launched.
*
* null if the task doesn't exist, or was launched too long ago.
*/
fun recoverForegroundTaskSubscriber(taskId: Long): ForegroundTaskSubscriberFlow? =
foregroundTaskSubscribers[taskId]?.let {
ForegroundTaskSubscriberFlow(taskId, it.applyCompletionTransform())
}
/**
* Launch a potentially blocking foreground task in this service's lifecycle context.
* This function does not block, but returns a Flow that emits ForegroundTaskState
* updates associated with this task. The last update the returned flow will emit is
* always ForegroundTaskState.Done.
*
* The returned flow can only be subscribed to once even though the underlying implementation
* is a SharedFlow. This is due to the need to apply transformations so that the stream
* actually completes. In order to subscribe multiple times, use `recoverForegroundTaskSubscriber`
* to acquire another instance.
*
* The task closure is expected to update foregroundTaskState whenever appropriate.
* If a foreground task is already running, this function returns null.
*
* To wait for foreground tasks to be available, use waitForForegroundTask().
*
* The function will set the state back to Idle once it sees ForegroundTaskState.Done.
*/
private fun launchForegroundTask(
title: String,
failureTitle: String,
iconRes: Int,
task: suspend EuiccChannelManagerService.() -> Unit
): ForegroundTaskSubscriberFlow {
val taskID = System.currentTimeMillis()
// Atomically set the state to InProgress. If this returns true, we are
// the only task currently in progress.
if (!foregroundTaskState.compareAndSet(
ForegroundTaskState.Idle,
ForegroundTaskState.InProgress(0)
)
) {
return ForegroundTaskSubscriberFlow(
taskID,
flow { emit(ForegroundTaskState.Done(IllegalStateException("There are tasks currently running"))) })
}
lifecycleScope.launch(Dispatchers.Main) {
// Wait until our self-start command has succeeded.
// We can only call startForeground() after that
val res = withTimeoutOrNull(30 * 1000) {
foregroundStarted.first()
}
if (res == null) {
// The only case where the wait above could time out is if the subscriber
// to the flow is stuck. Or we failed to start foreground.
// In that case, we should just set our state back to Idle -- setting it
// to Done wouldn't help much because nothing is going to then set it Idle.
foregroundTaskState.value = ForegroundTaskState.Idle
return@launch
}
updateForegroundNotification(title, iconRes)
wakeLock.acquire(10 * 60 * 1000L /*10 minutes*/)
try {
withContext(Dispatchers.IO + NonCancellable) { // Any LPA-related task must always complete
this@EuiccChannelManagerService.task()
}
// This update will be sent by the subscriber (as shown below)
foregroundTaskState.value = ForegroundTaskState.Done(null)
} catch (t: Throwable) {
Log.e(TAG, "Foreground task encountered an error")
Log.e(TAG, Log.getStackTraceString(t))
foregroundTaskState.value = ForegroundTaskState.Done(t)
if (isActive) {
postForegroundTaskFailureNotification(failureTitle)
}
} finally {
wakeLock.release()
if (isActive) {
stopSelf()
}
}
}
// This is the flow we are going to return. We allow multiple subscribers by
// re-emitting state updates into this flow from another coroutine.
// replay = 2 ensures that we at least have 1 previous state whenever subscribed to.
// This is helpful when the task completed and is then re-subscribed to due to a
// UI recreation event -- this way, the UI will know at least one last progress event
// before completion / failure
val subscriberFlow = MutableSharedFlow<ForegroundTaskState>(
replay = 2,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
// We should be the only task running, so we can subscribe to foregroundTaskState
// until we encounter ForegroundTaskState.Done.
// Then, we complete the returned flow, but we also set the state back to Idle.
// The state update back to Idle won't show up in the returned stream, because
// it has been completed by that point.
lifecycleScope.launch(Dispatchers.Main) {
foregroundTaskState
.applyCompletionTransform()
.onEach {
// Also update our notification when we see an update
// But ignore the first progress = 0 update -- that is the current value.
// we need that to be handled by the main coroutine after it finishes.
if (it !is ForegroundTaskState.InProgress || it.progress != 0) {
updateForegroundNotification(title, iconRes)
}
subscriberFlow.emit(it)
}
.onCompletion {
// Reset state back to Idle when we are done.
// We do it here because otherwise Idle and Done might become conflated
// when emitted by the main coroutine in quick succession.
// Doing it here ensures we've seen Done. This Idle event won't be
// emitted to the consumer because the subscription has completed here.
foregroundTaskState.value = ForegroundTaskState.Idle
}
.collect()
}
foregroundTaskSubscribers[taskID] = subscriberFlow.asSharedFlow()
if (foregroundTaskSubscribers.size > 5) {
// Remove enough elements so that the size is kept at 5
for (key in foregroundTaskSubscribers.keys.sorted()
.take(foregroundTaskSubscribers.size - 5)) {
foregroundTaskSubscribers.remove(key)
}
}
// Before we return, and after we have set everything up,
// self-start with foreground permission.
// This is going to unblock the main coroutine handling the task.
startForegroundService(
Intent(
this@EuiccChannelManagerService,
this@EuiccChannelManagerService::class.java
)
)
return ForegroundTaskSubscriberFlow(
taskID,
subscriberFlow.asSharedFlow().applyCompletionTransform()
)
}
suspend fun waitForForegroundTask() {
foregroundTaskState.takeWhile { it != ForegroundTaskState.Idle }
.collect()
}
fun launchProfileDownloadTask(
slotId: Int,
portId: Int,
smdp: String,
matchingId: String?,
confirmationCode: String?,
imei: String?
): 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)
}
})
}
preferenceRepository.notificationDownloadFlow.first()
}
}
fun launchProfileRenameTask(
slotId: Int,
portId: Int,
iccid: String,
name: String
): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_rename),
getString(R.string.task_profile_rename_failure),
R.drawable.ic_task_rename
) {
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
channel.lpa.setNickname(
iccid,
name
)
}
}
fun launchProfileDeleteTask(
slotId: Int,
portId: Int,
iccid: String
): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_delete),
getString(R.string.task_profile_delete_failure),
R.drawable.ic_task_delete
) {
euiccChannelManager.beginTrackedOperation(slotId, portId) {
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
channel.lpa.deleteProfile(iccid)
}
preferenceRepository.notificationDeleteFlow.first()
}
}
class SwitchingProfilesRefreshException : Exception()
fun launchProfileSwitchTask(
slotId: Int,
portId: Int,
iccid: String,
enable: Boolean, // Enable or disable the profile indicated in iccid
reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect
) =
launchForegroundTask(
getString(R.string.task_profile_switch),
getString(R.string.task_profile_switch_failure),
R.drawable.ic_task_switch
) {
euiccChannelManager.beginTrackedOperation(slotId, portId) {
val (response, refreshed) =
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
val refresh = preferenceRepository.refreshAfterSwitchFlow.first()
val response = channel.lpa.switchProfile(iccid, enable, refresh)
if (response || !refresh) {
Pair(response, refresh)
} else {
// refresh failed, but refresh was requested
// Sometimes, we *can* enable or disable the profile, but we cannot
// send the refresh command to the modem because the profile somehow
// makes the modem "busy". In this case, we can still switch by setting
// refresh to false, but then the switch cannot take effect until the
// user resets the modem manually by toggling airplane mode or rebooting.
Pair(
channel.lpa.switchProfile(iccid, enable, refresh = false),
false
)
}
}
if (!response) {
throw RuntimeException("Could not switch profile")
}
if (!refreshed && slotId != EuiccChannelManager.USB_CHANNEL_ID) {
// We may have switched the profile, but we could not refresh. Tell the caller about this
// but only if we are talking to a modem and not a USB reader
throw SwitchingProfilesRefreshException()
}
if (reconnectTimeoutMillis > 0) {
// Add an unconditional delay first to account for any race condition between
// the card sending the refresh command and the modem actually refreshing
delay(reconnectTimeoutMillis / 10)
// This throws TimeoutCancellationException if timed out
euiccChannelManager.waitForReconnect(
slotId,
portId,
reconnectTimeoutMillis / 10 * 9
)
}
preferenceRepository.notificationSwitchFlow.first()
}
}
fun launchMemoryReset(slotId: Int, portId: Int): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_euicc_memory_reset),
getString(R.string.task_euicc_memory_reset_failure),
R.drawable.ic_euicc_memory_reset
) {
euiccChannelManager.beginTrackedOperation(slotId, portId) {
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
channel.lpa.euiccMemoryReset()
}
preferenceRepository.notificationDeleteFlow.first()
}
}
} }

View file

@ -9,18 +9,14 @@ import android.os.IBinder
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.service.EuiccChannelManagerService import im.angry.openeuicc.service.EuiccChannelManagerService
import kotlinx.coroutines.CompletableDeferred
abstract class BaseEuiccAccessActivity : AppCompatActivity() { abstract class BaseEuiccAccessActivity : AppCompatActivity() {
val euiccChannelManagerLoaded = CompletableDeferred<Unit>()
lateinit var euiccChannelManager: EuiccChannelManager lateinit var euiccChannelManager: EuiccChannelManager
lateinit var euiccChannelManagerService: EuiccChannelManagerService
private val euiccChannelManagerServiceConnection = object : ServiceConnection { private val euiccChannelManagerServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
euiccChannelManagerService = (service!! as EuiccChannelManagerService.LocalBinder).service euiccChannelManager =
euiccChannelManager = euiccChannelManagerService.euiccChannelManager (service!! as EuiccChannelManagerService.LocalBinder).service.euiccChannelManager
euiccChannelManagerLoaded.complete(Unit)
onInit() onInit()
} }

View file

@ -0,0 +1,40 @@
package im.angry.openeuicc.ui
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DirectProfileDownloadActivity : BaseEuiccAccessActivity(), SlotSelectFragment.SlotSelectedListener, OpenEuiccContextMarker {
override fun onInit() {
lifecycleScope.launch {
val knownChannels = withContext(Dispatchers.IO) {
euiccChannelManager.enumerateEuiccChannels()
}
when {
knownChannels.isEmpty() -> {
finish()
}
knownChannels.hasMultipleChips -> {
SlotSelectFragment.newInstance(knownChannels.sortedBy { it.logicalSlotId })
.show(supportFragmentManager, SlotSelectFragment.TAG)
}
else -> {
// If the device has only one eSIM "chip" (but may be mapped to multiple slots),
// we can skip the slot selection dialog since there is only one chip to save to.
onSlotSelected(knownChannels[0].slotId,
knownChannels[0].portId)
}
}
}
}
override fun onSlotSelected(slotId: Int, portId: Int) {
ProfileDownloadFragment.newInstance(slotId, portId, finishWhenDone = true)
.show(supportFragmentManager, ProfileDownloadFragment.TAG)
}
override fun onSlotSelectCancelled() = finish()
}

View file

@ -1,198 +0,0 @@
package im.angry.openeuicc.ui
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.annotation.StringRes
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 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.launch
import net.typeblog.lpac_jni.impl.PKID_GSMA_LIVE_CI
import net.typeblog.lpac_jni.impl.PKID_GSMA_TEST_CI
// https://euicc-manual.osmocom.org/docs/pki/eum/accredited.json
// ref: <https://regex101.com/r/5FFz8u>
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 lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var infoList: RecyclerView
private var logicalSlotId: Int = -1
data class Item(
@StringRes
val titleResId: Int,
val content: String?,
val copiedToastResId: Int? = null,
)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_euicc_info)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
swipeRefresh = requireViewById(R.id.swipe_refresh)
infoList = requireViewById<RecyclerView>(R.id.recycler_view).also {
it.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
it.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
it.adapter = EuiccInfoAdapter()
}
logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.usb)
} else {
appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId)
}
title = getString(R.string.euicc_info_activity_title, channelTitle)
swipeRefresh.setOnRefreshListener { refresh() }
setupRootViewInsets(infoList)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
finish()
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onInit() {
refresh()
}
private fun refresh() {
swipeRefresh.isRefreshing = true
lifecycleScope.launch {
(infoList.adapter!! as EuiccInfoAdapter).euiccInfoItems =
euiccChannelManager.withEuiccChannel(logicalSlotId, ::buildEuiccInfoItems)
swipeRefresh.isRefreshing = false
}
}
private fun buildEuiccInfoItems(channel: EuiccChannel) = buildList {
add(Item(R.string.euicc_info_access_mode, channel.type))
add(Item(R.string.euicc_info_removable, formatByBoolean(channel.port.card.isRemovable, YES_NO)))
add(Item(R.string.euicc_info_eid, channel.lpa.eID, copiedToastResId = R.string.toast_eid_copied))
add(Item(R.string.euicc_info_isdr_aid, channel.isdrAid.encodeHex()))
channel.tryParseEuiccVendorInfo()?.let { vendorInfo ->
vendorInfo.skuName?.let { add(Item(R.string.euicc_info_sku, it)) }
vendorInfo.serialNumber?.let { add(Item(R.string.euicc_info_sn, it, copiedToastResId = R.string.toast_sn_copied)) }
vendorInfo.firmwareVersion?.let { add(Item(R.string.euicc_info_fw_ver, it)) }
vendorInfo.bootloaderVersion?.let { add(Item(R.string.euicc_info_bl_ver, it)) }
}
channel.lpa.euiccInfo2?.let { info ->
add(Item(R.string.euicc_info_sgp22_version, info.sgp22Version.toString()))
add(Item(R.string.euicc_info_firmware_version, info.euiccFirmwareVersion.toString()))
add(Item(R.string.euicc_info_globalplatform_version, info.globalPlatformVersion.toString()))
add(Item(R.string.euicc_info_pp_version, info.ppVersion.toString()))
info.sasAccreditationNumber.trim().takeIf(RE_SAS::matches)
?.let { add(Item(R.string.euicc_info_sas_accreditation_number, it.uppercase())) }
add(Item(R.string.euicc_info_free_nvram, info.freeNvram.let(::formatFreeSpace)))
}
channel.lpa.euiccInfo2?.euiccCiPKIdListForSigning.orEmpty().let { signers ->
// SGP.28 v1.0, eSIM CI Registration Criteria (Page 5 of 9, 2019-10-24)
// https://www.gsma.com/newsroom/wp-content/uploads/SGP.28-v1.0.pdf#page=5
// 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
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)
add(Item(R.string.euicc_info_atr, atr, copiedToastResId = R.string.toast_atr_copied))
}
@Suppress("SameParameterValue")
private fun formatByBoolean(b: Boolean, res: Pair<Int, Int>): String =
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)
private val content: TextView = root.requireViewById(R.id.euicc_info_content)
private var copiedToastResId: Int? = null
init {
root.setOnClickListener {
if (copiedToastResId != null) {
val label = title.text.toString()
getSystemService(ClipboardManager::class.java)!!
.setPrimaryClip(ClipData.newPlainText(label, content.text))
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(
this@EuiccInfoActivity,
copiedToastResId!!,
Toast.LENGTH_SHORT
).show()
}
}
}
}
fun bind(item: Item) {
copiedToastResId = item.copiedToastResId
title.setText(item.titleResId)
content.text = item.content ?: getString(R.string.unknown)
}
}
inner class EuiccInfoAdapter : RecyclerView.Adapter<EuiccInfoViewHolder>() {
var euiccInfoItems: List<Item> = listOf()
@SuppressLint("NotifyDataSetChanged")
set(newVal) {
field = newVal
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EuiccInfoViewHolder {
val root = LayoutInflater.from(parent.context)
.inflate(R.layout.euicc_info_item, parent, false)
return EuiccInfoViewHolder(root)
}
override fun getItemCount(): Int = euiccInfoItems.size
override fun onBindViewHolder(holder: EuiccInfoViewHolder, position: Int) {
holder.bind(euiccInfoItems[position])
}
}
}

View file

@ -4,9 +4,9 @@ import android.annotation.SuppressLint
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.method.PasswordTransformationMethod import android.text.method.PasswordTransformationMethod
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
@ -19,10 +19,6 @@ import android.widget.PopupMenu
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog 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 import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -31,9 +27,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import net.typeblog.lpac_jni.LocalProfileInfo import net.typeblog.lpac_jni.LocalProfileInfo
import im.angry.openeuicc.common.R import im.angry.openeuicc.common.R
import im.angry.openeuicc.service.EuiccChannelManagerService import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
@ -41,7 +35,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
@ -56,8 +49,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
private lateinit var swipeRefresh: SwipeRefreshLayout private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var fab: FloatingActionButton private lateinit var fab: FloatingActionButton
private lateinit var profileList: RecyclerView private lateinit var profileList: RecyclerView
private var logicalSlotId: Int = -1
private lateinit var eid: String
private val adapter = EuiccProfileAdapter() private val adapter = EuiccProfileAdapter()
@ -69,8 +60,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
// This gives us access to the "latest" state without having to launch coroutines // This gives us access to the "latest" state without having to launch coroutines
private lateinit var disableSafeguardFlow: StateFlow<Boolean> private lateinit var disableSafeguardFlow: StateFlow<Boolean>
private lateinit var unfilteredProfileListFlow: StateFlow<Boolean>
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
@ -87,21 +76,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
fab = view.requireViewById(R.id.fab) fab = view.requireViewById(R.id.fab)
profileList = view.requireViewById(R.id.profile_list) profileList = view.requireViewById(R.id.profile_list)
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<ViewGroup.MarginLayoutParams> {
rightMargin = origFabMarginRight + bars.right
bottomMargin = origFabMarginBottom + bars.bottom
}
WindowInsetsCompat.CONSUMED
}
setupRootViewInsets(profileList)
return view return view
} }
@ -113,15 +87,10 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false) LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
fab.setOnClickListener { fab.setOnClickListener {
Intent(requireContext(), DownloadWizardActivity::class.java).apply { ProfileDownloadFragment.newInstance(slotId, portId)
putExtra("selectedLogicalSlot", logicalSlotId) .show(childFragmentManager, ProfileDownloadFragment.TAG)
startActivity(this)
}
}
} }
override fun onStart() {
super.onStart()
refresh() refresh()
} }
@ -134,39 +103,16 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
inflater.inflate(R.menu.fragment_euicc, menu) inflater.inflate(R.menu.fragment_euicc, menu)
} }
override fun onPrepareOptionsMenu(menu: Menu) { override fun onOptionsItemSelected(item: MenuItem): Boolean =
super.onPrepareOptionsMenu(menu) when (item.itemId) {
menu.findItem(R.id.show_notifications).isVisible =
logicalSlotId != -1
menu.findItem(R.id.euicc_info).isVisible =
logicalSlotId != -1
menu.findItem(R.id.euicc_memory_reset).isVisible =
runBlocking { preferenceRepository.euiccMemoryResetFlow.first() }
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.show_notifications -> { R.id.show_notifications -> {
Intent(requireContext(), NotificationsActivity::class.java).apply { Intent(requireContext(), NotificationsActivity::class.java).apply {
putExtra("logicalSlotId", logicalSlotId) putExtra("logicalSlotId", channel.logicalSlotId)
startActivity(this) startActivity(this)
} }
true true
} }
R.id.euicc_info -> {
Intent(requireContext(), EuiccInfoActivity::class.java).apply {
putExtra("logicalSlotId", logicalSlotId)
startActivity(this)
}
true
}
R.id.euicc_memory_reset -> {
EuiccMemoryResetFragment.newInstance(slotId, portId, eid)
.show(childFragmentManager, EuiccMemoryResetFragment.TAG)
true
}
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
@ -181,36 +127,19 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
listOf() listOf()
} }
@SuppressLint("NotifyDataSetChanged")
private fun refresh() { private fun refresh() {
if (invalid) return if (invalid) return
swipeRefresh.isRefreshing = true swipeRefresh.isRefreshing = true
lifecycleScope.launch { lifecycleScope.launch {
doRefresh() if (!this@EuiccManagementFragment::disableSafeguardFlow.isInitialized) {
}
}
@SuppressLint("NotifyDataSetChanged")
protected open suspend fun doRefresh() {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
if (!::disableSafeguardFlow.isInitialized) {
disableSafeguardFlow = disableSafeguardFlow =
preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope) preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope)
} }
if (!::unfilteredProfileListFlow.isInitialized) {
unfilteredProfileListFlow =
preferenceRepository.unfilteredProfileListFlow.stateIn(lifecycleScope)
}
val profiles = withEuiccChannel { channel -> val profiles = withContext(Dispatchers.IO) {
logicalSlotId = channel.logicalSlotId
eid = channel.lpa.eID
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId) euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
if (unfilteredProfileListFlow.value)
channel.lpa.profiles
else
channel.lpa.profiles.operational channel.lpa.profiles.operational
} }
@ -221,13 +150,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
swipeRefresh.isRefreshing = false swipeRefresh.isRefreshing = false
} }
} }
private suspend fun showSwitchFailureText() = withContext(Dispatchers.Main) {
Toast.makeText(
context,
R.string.toast_profile_enable_failed,
Toast.LENGTH_LONG
).show()
} }
private fun enableOrDisableProfile(iccid: String, enable: Boolean) { private fun enableOrDisableProfile(iccid: String, enable: Boolean) {
@ -235,22 +157,32 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
fab.isEnabled = false fab.isEnabled = false
lifecycleScope.launch { lifecycleScope.launch {
ensureEuiccChannelManager() beginTrackedOperation {
euiccChannelManagerService.waitForForegroundTask() val (res, refreshed) =
if (!channel.lpa.switchProfile(iccid, enable, refresh = true)) {
// Sometimes, we *can* enable or disable the profile, but we cannot
// send the refresh command to the modem because the profile somehow
// makes the modem "busy". In this case, we can still switch by setting
// refresh to false, but then the switch cannot take effect until the
// user resets the modem manually by toggling airplane mode or rebooting.
Pair(channel.lpa.switchProfile(iccid, enable, refresh = false), false)
} else {
Pair(true, true)
}
val err = euiccChannelManagerService.launchProfileSwitchTask( if (!res) {
slotId, Log.d(TAG, "Failed to enable / disable profile $iccid")
portId, withContext(Dispatchers.Main) {
iccid, Toast.makeText(
enable, context,
reconnectTimeoutMillis = 30 * 1000 R.string.toast_profile_enable_failed,
).waitDone() Toast.LENGTH_LONG
).show()
}
return@beginTrackedOperation false
}
when (err) { if (!refreshed && !isUsb) {
null -> {}
is EuiccChannelManagerService.SwitchingProfilesRefreshException -> {
// This is only really fatal for internal eSIMs
if (!isUsb) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
AlertDialog.Builder(requireContext()).apply { AlertDialog.Builder(requireContext()).apply {
setMessage(R.string.switch_did_not_refresh) setMessage(R.string.switch_did_not_refresh)
@ -264,16 +196,23 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
show() show()
} }
} }
} return@beginTrackedOperation true
} }
is TimeoutCancellationException -> { if (!isUsb) {
try {
euiccChannelManager.waitForReconnect(
slotId,
portId,
timeoutMillis = 30 * 1000
)
} catch (e: TimeoutCancellationException) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// Prevent this Fragment from being used again // Prevent this Fragment from being used again
invalid = true invalid = true
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid // Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
AlertDialog.Builder(requireContext()).apply { AlertDialog.Builder(requireContext()).apply {
setMessage(appContainer.customizableTextProvider.profileSwitchingTimeoutMessage) setMessage(R.string.enable_disable_timeout)
setPositiveButton(android.R.string.ok) { dialog, _ -> setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss() dialog.dismiss()
requireActivity().finish() requireActivity().finish()
@ -284,11 +223,12 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
show() show()
} }
} }
return@beginTrackedOperation false
}
} }
else -> showSwitchFailureText() preferenceRepository.notificationSwitchFlow.first()
} }
refresh() refresh()
fab.isEnabled = true fab.isEnabled = true
} }
@ -316,7 +256,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
companion object { companion object {
fun fromInt(value: Int) = fun fromInt(value: Int) =
entries.first { it.value == value } Type.values().first { it.value == value }
} }
} }
} }
@ -344,8 +284,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
private val name: TextView = root.requireViewById(R.id.name) private val name: TextView = root.requireViewById(R.id.name)
private val state: TextView = root.requireViewById(R.id.state) private val state: TextView = root.requireViewById(R.id.state)
private val provider: TextView = root.requireViewById(R.id.provider) private val provider: TextView = root.requireViewById(R.id.provider)
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 profileMenu: ImageButton = root.requireViewById(R.id.profile_menu)
init { init {
@ -358,10 +296,9 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
} }
iccid.setOnLongClickListener { iccid.setOnLongClickListener {
requireContext().getSystemService(ClipboardManager::class.java)!! requireContext().getSystemService(ClipboardManager::class.java)
.setPrimaryClip(ClipData.newPlainText("iccid", iccid.text)) .setPrimaryClip(ClipData.newPlainText("iccid", iccid.text))
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast Toast.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
.show() .show()
true true
} }
@ -383,15 +320,6 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
} }
) )
provider.text = profile.providerName provider.text = profile.providerName
profileClassLabel.isVisible = unfilteredProfileListFlow.value
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
}
)
iccid.text = profile.iccid iccid.text = profile.iccid
iccid.transformationMethod = PasswordTransformationMethod.getInstance() iccid.transformationMethod = PasswordTransformationMethod.getInstance()
} }

View file

@ -1,126 +0,0 @@
package im.angry.openeuicc.ui
import android.graphics.Typeface
import android.os.Bundle
import android.text.Editable
import android.util.Log
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.EuiccChannelFragmentMarker
import im.angry.openeuicc.util.EuiccProfilesChangedListener
import im.angry.openeuicc.util.ensureEuiccChannelManager
import im.angry.openeuicc.util.euiccChannelManagerService
import im.angry.openeuicc.util.newInstanceEuicc
import im.angry.openeuicc.util.notifyEuiccProfilesChanged
import im.angry.openeuicc.util.portId
import im.angry.openeuicc.util.slotId
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
class EuiccMemoryResetFragment : DialogFragment(), EuiccChannelFragmentMarker {
companion object {
const val TAG = "EuiccMemoryResetFragment"
private const val FIELD_EID = "eid"
fun newInstance(slotId: Int, portId: Int, eid: String) =
newInstanceEuicc(EuiccMemoryResetFragment::class.java, slotId, portId) {
putString(FIELD_EID, eid)
}
}
private val eid: String by lazy { requireArguments().getString(FIELD_EID)!! }
private val confirmText: String by lazy {
getString(R.string.euicc_memory_reset_confirm_text, eid.takeLast(8))
}
private inline val isMatched: Boolean
get() = editText.text.toString() == confirmText
private var confirmed = false
private var toast: Toast? = null
set(value) {
toast?.cancel()
field = value
value?.show()
}
private val editText by lazy {
EditText(requireContext()).apply {
isLongClickable = false
typeface = Typeface.MONOSPACE
hint = Editable.Factory.getInstance()
.newEditable(getString(R.string.euicc_memory_reset_hint_text, confirmText))
}
}
private inline val alertDialog: AlertDialog
get() = requireDialog() as AlertDialog
override fun onCreateDialog(savedInstanceState: Bundle?) =
AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme)
.setTitle(R.string.euicc_memory_reset_title)
.setMessage(getString(R.string.euicc_memory_reset_message, eid, confirmText))
.setView(editText)
// Set listener to null to prevent auto closing
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.euicc_memory_reset_invoke_button, null)
.create()
override fun onResume() {
super.onResume()
alertDialog.setCanceledOnTouchOutside(false)
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE)
.setOnClickListener { if (!confirmed) confirmation() }
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE)
.setOnClickListener { if (!confirmed) dismiss() }
}
private fun confirmation() {
toast?.cancel()
if (!isMatched) {
Log.d(TAG, buildString {
appendLine("User input is mismatch:")
appendLine(editText.text)
appendLine(confirmText)
})
val resId = R.string.toast_euicc_memory_reset_confirm_text_mismatched
toast = Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG)
return
}
confirmed = true
preventUserAction()
requireParentFragment().lifecycleScope.launch {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
euiccChannelManagerService.launchMemoryReset(slotId, portId)
.onStart {
parentFragment?.notifyEuiccProfilesChanged()
val resId = R.string.toast_euicc_memory_reset_finitshed
toast = Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG)
runCatching(::dismiss)
}
.waitDone()
}
}
private fun preventUserAction() {
editText.isEnabled = false
alertDialog.setCancelable(false)
alertDialog.setCanceledOnTouchOutside(false)
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).isEnabled = false
}
}

View file

@ -1,72 +0,0 @@
package im.angry.openeuicc.ui
import android.os.Bundle
import android.text.Editable
import android.view.Menu
import android.view.MenuItem
import android.widget.EditText
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.preferenceRepository
import im.angry.openeuicc.util.setupToolbarInsets
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class IsdrAidListActivity : AppCompatActivity() {
private lateinit var isdrAidListEditor: EditText
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_isdr_aid_list)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
isdrAidListEditor = requireViewById(R.id.isdr_aid_list_editor)
lifecycleScope.launch {
preferenceRepository.isdrAidListFlow.onEach {
isdrAidListEditor.text = Editable.Factory.getInstance().newEditable(it)
}.collect()
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.activity_isdr_aid_list, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
R.id.save -> {
lifecycleScope.launch {
preferenceRepository.isdrAidListFlow.updatePreference(isdrAidListEditor.text.toString())
Toast.makeText(
this@IsdrAidListActivity,
R.string.isdr_aid_list_saved,
Toast.LENGTH_SHORT
).show()
}
true
}
R.id.reset -> {
lifecycleScope.launch {
preferenceRepository.isdrAidListFlow.removePreference()
}
true
}
android.R.id.home -> {
finish()
true
}
else -> super.onOptionsItemSelected(item)
}
}

View file

@ -1,14 +1,13 @@
package im.angry.openeuicc.ui package im.angry.openeuicc.ui
import android.icu.text.SimpleDateFormat import android.icu.text.SimpleDateFormat
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.ScrollView import android.widget.ScrollView
import android.widget.TextView import android.widget.TextView
import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@ -17,6 +16,7 @@ import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.FileOutputStream
import java.util.Date import java.util.Date
class LogsActivity : AppCompatActivity() { class LogsActivity : AppCompatActivity() {
@ -26,40 +26,26 @@ class LogsActivity : AppCompatActivity() {
private lateinit var logStr: String private lateinit var logStr: String
private val saveLogs = private val saveLogs =
setupLogSaving( registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
getLogFileName = { if (uri == null) return@registerForActivityResult
getString( if (!this::logStr.isInitialized) return@registerForActivityResult
R.string.logs_filename_template, contentResolver.openFileDescriptor(uri, "w")?.use {
SimpleDateFormat.getDateTimeInstance().format(Date()) FileOutputStream(it.fileDescriptor).use { os ->
) os.write(logStr.encodeToByteArray())
}, }
getLogText = ::buildLogText }
)
private fun buildLogText() = buildString {
appendLine("Manufacturer: ${Build.MANUFACTURER}")
appendLine("Brand: ${Build.BRAND}")
appendLine("Model: ${Build.MODEL}")
appendLine("SDK Version: ${Build.VERSION.SDK_INT}")
appendLine("App Version: $selfAppVersion")
appendLine("-".repeat(10))
appendLine(logStr)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_logs) setContentView(R.layout.activity_logs)
setSupportActionBar(requireViewById(R.id.toolbar)) setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true) supportActionBar!!.setDisplayHomeAsUpEnabled(true)
swipeRefresh = requireViewById(R.id.swipe_refresh) swipeRefresh = requireViewById(R.id.swipe_refresh)
scrollView = requireViewById(R.id.scroll_view) scrollView = requireViewById(R.id.scroll_view)
logText = requireViewById(R.id.log_text) logText = requireViewById(R.id.log_text)
setupRootViewInsets(scrollView)
swipeRefresh.setOnRefreshListener { swipeRefresh.setOnRefreshListener {
lifecycleScope.launch { lifecycleScope.launch {
reload() reload()
@ -80,12 +66,10 @@ class LogsActivity : AppCompatActivity() {
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
finish()
true
}
R.id.save -> { R.id.save -> {
saveLogs() saveLogs.launch(getString(R.string.logs_filename_template,
SimpleDateFormat.getDateTimeInstance().format(Date())
))
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)

View file

@ -5,9 +5,7 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager
import android.hardware.usb.UsbManager import android.hardware.usb.UsbManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.telephony.TelephonyManager import android.telephony.TelephonyManager
import android.util.Log import android.util.Log
@ -15,7 +13,6 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.ProgressBar import android.widget.ProgressBar
import androidx.activity.enableEdgeToEdge
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
@ -23,12 +20,8 @@ import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import im.angry.openeuicc.common.R import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -36,8 +29,6 @@ import kotlinx.coroutines.withContext
open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker { open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
companion object { companion object {
const val TAG = "MainActivity" const val TAG = "MainActivity"
const val PERMISSION_REQUEST_CODE = 1000
} }
private lateinit var loadingProgress: ProgressBar private lateinit var loadingProgress: ProgressBar
@ -47,7 +38,6 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private var refreshing = false private var refreshing = false
private data class Page( private data class Page(
val logicalSlotId: Int,
val title: String, val title: String,
val createFragment: () -> Fragment val createFragment: () -> Fragment
) )
@ -74,11 +64,9 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
@SuppressLint("WrongConstant", "UnspecifiedRegisterReceiverFlag") @SuppressLint("WrongConstant", "UnspecifiedRegisterReceiverFlag")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
setSupportActionBar(requireViewById(R.id.toolbar)) setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
loadingProgress = requireViewById(R.id.loading) loadingProgress = requireViewById(R.id.loading)
tabs = requireViewById(R.id.main_tabs) tabs = requireViewById(R.id.main_tabs)
viewPager = requireViewById(R.id.view_pager) viewPager = requireViewById(R.id.view_pager)
@ -109,7 +97,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
override fun onOptionsItemSelected(item: MenuItem): Boolean = override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) { when (item.itemId) {
R.id.settings -> { R.id.settings -> {
startActivity(Intent(this, SettingsActivity::class.java)) startActivity(Intent(this, SettingsActivity::class.java));
true true
} }
R.id.reload -> { R.id.reload -> {
@ -125,76 +113,49 @@ 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
)
}
}
private suspend fun init(fromUsbEvent: Boolean = false) { private suspend fun init(fromUsbEvent: Boolean = false) {
refreshing = true // We don't check this here -- the check happens in refresh() refreshing = true // We don't check this here -- the check happens in refresh()
loadingProgress.visibility = View.VISIBLE loadingProgress.visibility = View.VISIBLE
viewPager.visibility = View.GONE viewPager.visibility = View.GONE
tabs.visibility = View.GONE tabs.visibility = View.GONE
// Prevent concurrent access with any running foreground task
euiccChannelManagerService.waitForForegroundTask()
val (usbDevice, _) = withContext(Dispatchers.IO) { val knownChannels = withContext(Dispatchers.IO) {
euiccChannelManager.tryOpenUsbEuiccChannel() euiccChannelManager.enumerateEuiccChannels().onEach {
} Log.d(TAG, "slot ${it.slotId} port ${it.portId}")
Log.d(TAG, it.lpa.eID)
val newPages: MutableList<Page> = mutableListOf()
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 // 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, // Note that this is currently supposed to be no-op when unprivileged,
// but it could change in the future // but it could change in the future
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId) euiccChannelManager.notifyEuiccProfilesChanged(it.logicalSlotId)
}
val channelName = }
appContainer.customizableTextProvider.formatInternalChannelName(channel.logicalSlotId)
newPages.add(Page(channel.logicalSlotId, channelName) { val (usbDevice, _) = withContext(Dispatchers.IO) {
appContainer.uiComponentFactory.createEuiccManagementFragment(slotId, portId) euiccChannelManager.enumerateUsbEuiccChannel()
}) }
withContext(Dispatchers.Main) {
loadingProgress.visibility = View.GONE
knownChannels.sortedBy { it.logicalSlotId }.forEach { channel ->
pages.add(Page(
getString(R.string.channel_name_format, channel.logicalSlotId)
) { appContainer.uiComponentFactory.createEuiccManagementFragment(channel) })
} }
}.collect()
// If USB readers exist, add them at the very last // If USB readers exist, add them at the very last
// We use a wrapper fragment to handle logic specific to USB readers // We use a wrapper fragment to handle logic specific to USB readers
usbDevice?.let { usbDevice?.let {
val productName = it.productName ?: getString(R.string.usb) pages.add(Page(it.productName ?: getString(R.string.usb)) { UsbCcidReaderFragment() })
newPages.add(Page(EuiccChannelManager.USB_CHANNEL_ID, productName) {
UsbCcidReaderFragment()
})
} }
viewPager.visibility = View.VISIBLE viewPager.visibility = View.VISIBLE
if (newPages.size > 1) { if (pages.size > 1) {
tabs.visibility = View.VISIBLE tabs.visibility = View.VISIBLE
} else if (newPages.isEmpty()) { } else if (pages.isEmpty()) {
newPages.add(Page(-1, "") { pages.add(Page("") { appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment() })
appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment()
})
} }
newPages.sortBy { it.logicalSlotId }
pages.clear()
pages.addAll(newPages)
loadingProgress.visibility = View.GONE
pagerAdapter.notifyDataSetChanged() pagerAdapter.notifyDataSetChanged()
// Reset the adapter so that the current view actually gets cleared // Reset the adapter so that the current view actually gets cleared
// notifyDataSetChanged() doesn't cause the current view to be removed. // notifyDataSetChanged() doesn't cause the current view to be removed.
@ -209,12 +170,9 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
viewPager.currentItem = 0 viewPager.currentItem = 0
} }
if (pages.size > 0) {
ensureNotificationPermissions()
}
refreshing = false refreshing = false
} }
}
private fun refresh(fromUsbEvent: Boolean = false) { private fun refresh(fromUsbEvent: Boolean = false) {
if (refreshing) return if (refreshing) return

View file

@ -4,20 +4,15 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import im.angry.openeuicc.common.R import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker { class NoEuiccPlaceholderFragment : Fragment() {
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View? {
val view = inflater.inflate(R.layout.fragment_no_euicc_placeholder, container, false) return inflater.inflate(R.layout.fragment_no_euicc_placeholder, container, false)
val textView = view.requireViewById<TextView>(R.id.no_euicc_placeholder)
textView.text = appContainer.customizableTextProvider.noEuiccExplanation
return view
} }
} }

View file

@ -11,7 +11,6 @@ import android.view.MenuItem.OnMenuItemClickListener
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.forEach import androidx.core.view.forEach
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -20,6 +19,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import im.angry.openeuicc.common.R import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -32,37 +32,34 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private lateinit var notificationList: RecyclerView private lateinit var notificationList: RecyclerView
private val notificationAdapter = NotificationAdapter() private val notificationAdapter = NotificationAdapter()
private var logicalSlotId = -1 private lateinit var euiccChannel: EuiccChannel
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_notifications) setContentView(R.layout.activity_notifications)
setSupportActionBar(requireViewById(R.id.toolbar)) setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true) supportActionBar!!.setDisplayHomeAsUpEnabled(true)
}
override fun onInit() {
euiccChannel = euiccChannelManager
.findEuiccChannelBySlotBlocking(intent.getIntExtra("logicalSlotId", 0))!!
swipeRefresh = requireViewById(R.id.swipe_refresh) swipeRefresh = requireViewById(R.id.swipe_refresh)
notificationList = requireViewById(R.id.recycler_view) notificationList = requireViewById(R.id.recycler_view)
setupRootViewInsets(notificationList)
}
override fun onInit() {
notificationList.layoutManager = notificationList.layoutManager =
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
notificationList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL)) notificationList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
notificationList.adapter = notificationAdapter notificationList.adapter = notificationAdapter
registerForContextMenu(notificationList) registerForContextMenu(notificationList)
logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
// This is slightly different from the MainActivity logic // This is slightly different from the MainActivity logic
// due to the length (we don't want to display the full USB product name) // due to the length (we don't want to display the full USB product name)
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) { val channelTitle = if (euiccChannel.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.usb) getString(R.string.usb)
} else { } else {
appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId) getString(R.string.channel_name_format, euiccChannel.logicalSlotId)
} }
title = getString(R.string.profile_notifications_detailed_format, channelTitle) title = getString(R.string.profile_notifications_detailed_format, channelTitle)
@ -103,10 +100,6 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
swipeRefresh.isRefreshing = true swipeRefresh.isRefreshing = true
lifecycleScope.launch { lifecycleScope.launch {
withContext(Dispatchers.IO) {
euiccChannelManagerLoaded.await()
}
task() task()
swipeRefresh.isRefreshing = false swipeRefresh.isRefreshing = false
@ -115,16 +108,15 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private fun refresh() { private fun refresh() {
launchTask { launchTask {
notificationAdapter.notifications = val profiles = withContext(Dispatchers.IO) {
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel -> euiccChannel.lpa.profiles
val nameMap = buildMap {
for (profile in channel.lpa.profiles) {
put(profile.iccid, profile.displayName)
}
} }
channel.lpa.notifications.map { notificationAdapter.notifications =
LocalProfileNotificationWrapper(it, nameMap[it.iccid] ?: "???") withContext(Dispatchers.IO) {
euiccChannel.lpa.notifications.map {
val profile = profiles.find { p -> p.iccid == it.iccid }
LocalProfileNotificationWrapper(it, profile?.displayName ?: "???")
} }
} }
} }
@ -139,8 +131,6 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
inner class NotificationViewHolder(private val root: View): inner class NotificationViewHolder(private val root: View):
RecyclerView.ViewHolder(root), View.OnCreateContextMenuListener, OnMenuItemClickListener { RecyclerView.ViewHolder(root), View.OnCreateContextMenuListener, OnMenuItemClickListener {
private val address: TextView = root.requireViewById(R.id.notification_address) private val address: TextView = root.requireViewById(R.id.notification_address)
private val sequenceNumber: TextView =
root.requireViewById(R.id.notification_sequence_number)
private val profileName: TextView = root.requireViewById(R.id.notification_profile_name) private val profileName: TextView = root.requireViewById(R.id.notification_profile_name)
private lateinit var notification: LocalProfileNotificationWrapper private lateinit var notification: LocalProfileNotificationWrapper
@ -162,7 +152,6 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
} }
} }
private fun operationToLocalizedText(operation: LocalProfileNotification.Operation) = private fun operationToLocalizedText(operation: LocalProfileNotification.Operation) =
root.context.getText( root.context.getText(
when (operation) { when (operation) {
@ -176,10 +165,6 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
notification = value notification = value
address.text = value.inner.notificationAddress address.text = value.inner.notificationAddress
sequenceNumber.text = root.context.getString(
R.string.profile_notification_sequence_number_format,
value.inner.seqNumber
)
profileName.text = Html.fromHtml( 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), operationToLocalizedText(value.inner.profileManagementOperation),
@ -204,9 +189,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
R.id.notification_process -> { R.id.notification_process -> {
launchTask { launchTask {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel -> euiccChannel.lpa.handleNotification(notification.inner.seqNumber)
channel.lpa.handleNotification(notification.inner.seqNumber)
}
} }
refresh() refresh()
@ -216,9 +199,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
R.id.notification_delete -> { R.id.notification_delete -> {
launchTask { launchTask {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
euiccChannelManager.withEuiccChannel(logicalSlotId) { channel -> euiccChannel.lpa.deleteNotification(notification.inner.seqNumber)
channel.lpa.deleteNotification(notification.inner.seqNumber)
}
} }
refresh() refresh()

View file

@ -3,67 +3,56 @@ package im.angry.openeuicc.ui
import android.app.Dialog import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.util.Log
import android.widget.EditText import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R import im.angry.openeuicc.common.R
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.lang.Exception
class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker { class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
companion object { companion object {
const val TAG = "ProfileDeleteFragment" const val TAG = "ProfileDeleteFragment"
private const val FIELD_ICCID = "iccid"
private const val FIELD_NAME = "name"
fun newInstance(slotId: Int, portId: Int, iccid: String, name: String) = fun newInstance(slotId: Int, portId: Int, iccid: String, name: String): ProfileDeleteFragment {
newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId) { val instance = newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId)
putString(FIELD_ICCID, iccid) instance.requireArguments().apply {
putString(FIELD_NAME, name) putString("iccid", iccid)
putString("name", name)
} }
return instance
} }
private val iccid by lazy {
requireArguments().getString(FIELD_ICCID)!!
}
private val name by lazy {
requireArguments().getString(FIELD_NAME)!!
} }
private val editText by lazy { private val editText by lazy {
EditText(requireContext()).apply { EditText(requireContext()).apply {
hint = Editable.Factory.getInstance() hint = Editable.Factory.getInstance().newEditable(
.newEditable(getString(R.string.profile_delete_confirm_input, name)) getString(R.string.profile_delete_confirm_input, requireArguments().getString("name")!!)
)
} }
} }
private val inputMatchesName: Boolean private val inputMatchesName: Boolean
get() = editText.text.toString() == name get() = editText.text.toString() == requireArguments().getString("name")!!
private var toast: Toast? = null
private var deleting = false private var deleting = false
private val alertDialog: AlertDialog override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
get() = requireDialog() as AlertDialog return AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme).apply {
setMessage(getString(R.string.profile_delete_confirm, requireArguments().getString("name")))
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme).apply {
setMessage(getString(R.string.profile_delete_confirm, name))
setView(editText) setView(editText)
setPositiveButton(android.R.string.ok, null) // Set listener to null to prevent auto closing setPositiveButton(android.R.string.ok, null) // Set listener to null to prevent auto closing
setNegativeButton(android.R.string.cancel, null) setNegativeButton(android.R.string.cancel, null)
}.create() }.create()
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
val alertDialog = dialog!! as AlertDialog
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
if (!deleting) delete() if (!deleting && inputMatchesName) delete()
} }
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
if (!deleting) dismiss() if (!deleting) dismiss()
@ -71,29 +60,30 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
} }
private fun delete() { private fun delete() {
toast?.cancel()
if (!inputMatchesName) {
val resId = R.string.toast_profile_delete_confirm_text_mismatched
toast = Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG).also {
it.show()
}
return
}
deleting = true deleting = true
val alertDialog = dialog!! as AlertDialog
alertDialog.setCanceledOnTouchOutside(false) alertDialog.setCanceledOnTouchOutside(false)
alertDialog.setCancelable(false) alertDialog.setCancelable(false)
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).isEnabled = false alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).isEnabled = false
requireParentFragment().lifecycleScope.launch { lifecycleScope.launch {
ensureEuiccChannelManager() try {
euiccChannelManagerService.waitForForegroundTask() doDelete()
euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, iccid) } catch (e: Exception) {
.onStart { Log.d(ProfileDownloadFragment.TAG, "Error deleting profile")
parentFragment?.notifyEuiccProfilesChanged() Log.d(ProfileDownloadFragment.TAG, Log.getStackTraceString(e))
runCatching(::dismiss) } finally {
if (parentFragment is EuiccProfilesChangedListener) {
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
} }
.waitDone() dismiss()
} }
} }
} }
private suspend fun doDelete() = beginTrackedOperation {
channel.lpa.deleteProfile(requireArguments().getString("iccid")!!)
preferenceRepository.notificationDeleteFlow.first()
}
}

View file

@ -0,0 +1,266 @@
package im.angry.openeuicc.ui
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.DialogInterface
import android.graphics.BitmapFactory
import android.os.Bundle
import android.text.Editable
import android.util.Log
import android.view.*
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.lifecycleScope
import com.google.android.material.textfield.TextInputLayout
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.ProfileDownloadCallback
import kotlin.Exception
class ProfileDownloadFragment : BaseMaterialDialogFragment(),
Toolbar.OnMenuItemClickListener, EuiccChannelFragmentMarker {
companion object {
const val TAG = "ProfileDownloadFragment"
fun newInstance(slotId: Int, portId: Int, finishWhenDone: Boolean = false): ProfileDownloadFragment =
newInstanceEuicc(ProfileDownloadFragment::class.java, slotId, portId) {
putBoolean("finishWhenDone", finishWhenDone)
}
}
private lateinit var toolbar: Toolbar
private lateinit var profileDownloadServer: TextInputLayout
private lateinit var profileDownloadCode: TextInputLayout
private lateinit var profileDownloadConfirmationCode: TextInputLayout
private lateinit var profileDownloadIMEI: TextInputLayout
private lateinit var profileDownloadFreeSpace: TextView
private lateinit var progress: ProgressBar
private var freeNvram: Int = -1
private var downloading = false
private val finishWhenDone by lazy {
requireArguments().getBoolean("finishWhenDone", false)
}
private val barcodeScannerLauncher = registerForActivityResult(ScanContract()) { result ->
result.contents?.let { content ->
Log.d(TAG, content)
onScanResult(content)
}
}
private val gallerySelectorLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { result ->
if (result == null) return@registerForActivityResult
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
requireContext().contentResolver.openInputStream(result)?.let { input ->
val bmp = BitmapFactory.decodeStream(input)
input.close()
decodeQrFromBitmap(bmp)?.let {
withContext(Dispatchers.Main) {
onScanResult(it)
}
}
bmp.recycle()
}
}
}
}
private fun onScanResult(result: String) {
val components = result.split("$")
if (components.size < 3 || components[0] != "LPA:1") return
profileDownloadServer.editText?.setText(components[1])
profileDownloadCode.editText?.setText(components[2])
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = inflater.inflate(R.layout.fragment_profile_download, container, false)
toolbar = view.requireViewById(R.id.toolbar)
profileDownloadServer = view.requireViewById(R.id.profile_download_server)
profileDownloadCode = view.requireViewById(R.id.profile_download_code)
profileDownloadConfirmationCode = view.requireViewById(R.id.profile_download_confirmation_code)
profileDownloadIMEI = view.requireViewById(R.id.profile_download_imei)
profileDownloadFreeSpace = view.requireViewById(R.id.profile_download_free_space)
progress = view.requireViewById(R.id.progress)
toolbar.inflateMenu(R.menu.fragment_profile_download)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
toolbar.apply {
setTitle(R.string.profile_download)
setNavigationOnClickListener {
if (!downloading) {
dismiss()
}
}
setOnMenuItemClickListener(this@ProfileDownloadFragment)
}
}
override fun onMenuItemClick(item: MenuItem): Boolean = downloading ||
when (item.itemId) {
R.id.scan -> {
barcodeScannerLauncher.launch(ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setOrientationLocked(false)
})
true
}
R.id.scan_from_gallery -> {
gallerySelectorLauncher.launch("image/*")
true
}
R.id.ok -> {
startDownloadProfile()
true
}
else -> false
}
override fun onResume() {
super.onResume()
setWidthPercent(95)
}
@SuppressLint("MissingPermission")
override fun onStart() {
super.onStart()
profileDownloadIMEI.editText!!.text = Editable.Factory.getInstance().newEditable(
try {
telephonyManager.getImei(channel.logicalSlotId) ?: ""
} catch (e: Exception) {
""
}
)
lifecycleScope.launch(Dispatchers.IO) {
// Fetch remaining NVRAM
val str = channel.lpa.euiccInfo2?.freeNvram?.also {
freeNvram = it
}?.let { formatFreeSpace(it) }
withContext(Dispatchers.Main) {
profileDownloadFreeSpace.text = getString(R.string.profile_download_free_space,
str ?: getText(R.string.unknown))
}
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return super.onCreateDialog(savedInstanceState).also {
it.setCanceledOnTouchOutside(false)
}
}
private fun startDownloadProfile() {
val server = profileDownloadServer.editText!!.let {
it.text.toString().trim().apply {
if (isEmpty()) {
it.requestFocus()
return@startDownloadProfile
}
}
}
val code = profileDownloadCode.editText!!.text.toString().trim()
.ifBlank { null }
val confirmationCode = profileDownloadConfirmationCode.editText!!.text.toString().trim()
.ifBlank { null }
val imei = profileDownloadIMEI.editText!!.text.toString().trim()
.ifBlank { null }
downloading = true
profileDownloadServer.editText!!.isEnabled = false
profileDownloadCode.editText!!.isEnabled = false
profileDownloadConfirmationCode.editText!!.isEnabled = false
profileDownloadIMEI.editText!!.isEnabled = false
progress.isIndeterminate = true
progress.visibility = View.VISIBLE
lifecycleScope.launch {
try {
doDownloadProfile(server, code, confirmationCode, imei)
} catch (e: Exception) {
Log.d(TAG, "Error downloading profile")
Log.d(TAG, Log.getStackTraceString(e))
Toast.makeText(context, R.string.profile_download_failed, Toast.LENGTH_LONG).show()
} finally {
if (parentFragment is EuiccProfilesChangedListener) {
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
}
dismiss()
}
}
}
private suspend fun doDownloadProfile(
server: String,
code: String?,
confirmationCode: String?,
imei: String?
) = beginTrackedOperation {
val res = channel.lpa.downloadProfile(
server,
code,
imei,
confirmationCode,
object : ProfileDownloadCallback {
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
lifecycleScope.launch(Dispatchers.Main) {
progress.isIndeterminate = false
progress.progress = state.progress
}
}
})
if (!res) {
// TODO: Provide more details on the error
throw RuntimeException("Failed to download profile; this is typically caused by another error happened before.")
}
// If we get here, we are successful
// This function is wrapped in beginTrackedOperation, so by returning the settings value,
// We only send notifications if the user allowed us to
preferenceRepository.notificationDownloadFlow.first()
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (finishWhenDone) {
activity?.finish()
}
}
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
if (finishWhenDone) {
activity?.finish()
}
}
}

View file

@ -2,32 +2,34 @@ package im.angry.openeuicc.ui
import android.app.Dialog import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.Toast import android.widget.Toast
import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
import im.angry.openeuicc.common.R import im.angry.openeuicc.common.R
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.typeblog.lpac_jni.LocalProfileAssistant import kotlinx.coroutines.withContext
import java.lang.Exception
import java.lang.RuntimeException
class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragmentMarker { class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragmentMarker {
companion object { companion object {
private const val FIELD_ICCID = "iccid"
private const val FIELD_CURRENT_NAME = "currentName"
const val TAG = "ProfileRenameFragment" const val TAG = "ProfileRenameFragment"
fun newInstance(slotId: Int, portId: Int, iccid: String, currentName: String) = fun newInstance(slotId: Int, portId: Int, iccid: String, currentName: String): ProfileRenameFragment {
newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId) { val instance = newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId)
putString(FIELD_ICCID, iccid) instance.requireArguments().apply {
putString(FIELD_CURRENT_NAME, currentName) putString("iccid", iccid)
putString("currentName", currentName)
}
return instance
} }
} }
@ -37,14 +39,6 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
private var renaming = false private var renaming = false
private val iccid: String by lazy {
requireArguments().getString(FIELD_ICCID)!!
}
private val currentName: String by lazy {
requireArguments().getString(FIELD_CURRENT_NAME)!!
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -63,7 +57,6 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
profileRenameNewName.editText!!.setText(currentName)
toolbar.apply { toolbar.apply {
setTitle(R.string.rename) setTitle(R.string.rename)
setNavigationOnClickListener { setNavigationOnClickListener {
@ -76,6 +69,11 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
} }
} }
override fun onStart() {
super.onStart()
profileRenameNewName.editText!!.setText(requireArguments().getString("currentName"))
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
setWidthPercent(95) setWidthPercent(95)
@ -87,45 +85,35 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
} }
} }
private fun showErrorAndCancel(@StringRes resId: Int) { private fun rename() {
Toast.makeText(requireContext(), resId, Toast.LENGTH_LONG).show() val name = profileRenameNewName.editText!!.text.toString().trim()
if (name.length >= 64) {
renaming = false Toast.makeText(context, R.string.toast_profile_name_too_long, Toast.LENGTH_LONG).show()
progress.visibility = View.GONE return
} }
private fun rename() {
renaming = true renaming = true
progress.isIndeterminate = true progress.isIndeterminate = true
progress.visibility = View.VISIBLE progress.visibility = View.VISIBLE
val newName = profileRenameNewName.editText!!.text.toString().trim()
lifecycleScope.launch { lifecycleScope.launch {
ensureEuiccChannelManager() try {
euiccChannelManagerService.waitForForegroundTask() doRename(name)
val response = euiccChannelManagerService } catch (e: Exception) {
.launchProfileRenameTask(slotId, portId, iccid, newName).waitDone() Log.d(TAG, "Failed to rename profile")
Log.d(TAG, Log.getStackTraceString(e))
when (response) { } finally {
is LocalProfileAssistant.ProfileNameTooLongException -> { if (parentFragment is EuiccProfilesChangedListener) {
showErrorAndCancel(R.string.profile_rename_too_long) (parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
}
dismiss()
}
}
} }
is LocalProfileAssistant.ProfileNameIsInvalidUTF8Exception -> { private suspend fun doRename(name: String) = withContext(Dispatchers.IO) {
showErrorAndCancel(R.string.profile_rename_encoding_error) if (!channel.lpa.setNickname(requireArguments().getString("iccid")!!, name)) {
} throw RuntimeException("Profile nickname not changed")
is Throwable -> {
showErrorAndCancel(R.string.profile_rename_failure)
}
else -> {
parentFragment?.notifyEuiccProfilesChanged()
runCatching(::dismiss)
}
}
} }
} }
} }

View file

@ -2,26 +2,17 @@ package im.angry.openeuicc.ui
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import im.angry.openeuicc.OpenEuiccApplication
import im.angry.openeuicc.common.R 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
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings) setContentView(R.layout.activity_settings)
setSupportActionBar(requireViewById(R.id.toolbar)) setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true) supportActionBar!!.setDisplayHomeAsUpEnabled(true)
val settingsFragment = appContainer.uiComponentFactory.createSettingsFragment()
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(R.id.settings_container, settingsFragment) .replace(R.id.settings_container, SettingsFragment())
.commit() .commit()
} }

View file

@ -2,164 +2,60 @@ package im.angry.openeuicc.ui
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import androidx.datastore.preferences.core.Preferences
import android.widget.Toast
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.CheckBoxPreference import androidx.preference.CheckBoxPreference
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import im.angry.openeuicc.common.R import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
open class SettingsFragment: PreferenceFragmentCompat() { class SettingsFragment: PreferenceFragmentCompat() {
private lateinit var developerPref: PreferenceCategory
// Hidden developer options switch
private var numClicks = 0
private var lastClickTimestamp = -1L
private var lastToast: Toast? = null
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.pref_settings, rootKey) setPreferencesFromResource(R.xml.pref_settings, rootKey)
developerPref = requirePreference("pref_developer") findPreference<Preference>("pref_info_app_version")
?.summary = requireContext().selfAppVersion
// Show / hide developer preference based on whether it is enabled findPreference<Preference>("pref_info_source_code")
lifecycleScope.launch { ?.setOnPreferenceClickListener {
preferenceRepository.developerOptionsEnabledFlow startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.summary.toString())))
.onEach { developerPref.isVisible = it } true
.collect()
} }
requirePreference<Preference>("pref_info_app_version").apply { findPreference<Preference>("pref_advanced_logs")
summary = requireContext().selfAppVersion ?.setOnPreferenceClickListener {
startActivity(Intent(requireContext(), LogsActivity::class.java))
// Enable developer options when this is clicked for 7 times true
setOnPreferenceClickListener(::onAppVersionClicked)
} }
requirePreference<Preference>("pref_advanced_language").apply { findPreference<CheckBoxPreference>("pref_notifications_download")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@apply ?.bindBooleanFlow(preferenceRepository.notificationDownloadFlow, PreferenceKeys.NOTIFICATION_DOWNLOAD)
isVisible = true
intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { findPreference<CheckBoxPreference>("pref_notifications_delete")
data = Uri.fromParts("package", requireContext().packageName, null) ?.bindBooleanFlow(preferenceRepository.notificationDeleteFlow, PreferenceKeys.NOTIFICATION_DELETE)
}
findPreference<CheckBoxPreference>("pref_notifications_switch")
?.bindBooleanFlow(preferenceRepository.notificationSwitchFlow, PreferenceKeys.NOTIFICATION_SWITCH)
findPreference<CheckBoxPreference>("pref_advanced_disable_safeguard_removable_esim")
?.bindBooleanFlow(preferenceRepository.disableSafeguardFlow, PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM)
} }
requirePreference<Preference>("pref_advanced_logs").apply { private fun CheckBoxPreference.bindBooleanFlow(flow: Flow<Boolean>, key: Preferences.Key<Boolean>) {
intent = Intent(requireContext(), LogsActivity::class.java)
}
requirePreference<CheckBoxPreference>("pref_notifications_download")
.bindBooleanFlow(preferenceRepository.notificationDownloadFlow)
requirePreference<CheckBoxPreference>("pref_notifications_delete")
.bindBooleanFlow(preferenceRepository.notificationDeleteFlow)
requirePreference<CheckBoxPreference>("pref_notifications_switch")
.bindBooleanFlow(preferenceRepository.notificationSwitchFlow)
requirePreference<CheckBoxPreference>("pref_advanced_disable_safeguard_removable_esim")
.bindBooleanFlow(preferenceRepository.disableSafeguardFlow)
requirePreference<CheckBoxPreference>("pref_advanced_verbose_logging")
.bindBooleanFlow(preferenceRepository.verboseLoggingFlow)
requirePreference<CheckBoxPreference>("pref_developer_unfiltered_profile_list")
.bindBooleanFlow(preferenceRepository.unfilteredProfileListFlow)
requirePreference<CheckBoxPreference>("pref_developer_ignore_tls_certificate")
.bindBooleanFlow(preferenceRepository.ignoreTLSCertificateFlow)
requirePreference<CheckBoxPreference>("pref_developer_refresh_after_switch")
.bindBooleanFlow(preferenceRepository.refreshAfterSwitchFlow)
requirePreference<CheckBoxPreference>("pref_developer_euicc_memory_reset")
.bindBooleanFlow(preferenceRepository.euiccMemoryResetFlow)
requirePreference<Preference>("pref_developer_isdr_aid_list").apply {
intent = Intent(requireContext(), IsdrAidListActivity::class.java)
}
}
protected fun <T : Preference> requirePreference(key: CharSequence) =
findPreference<T>(key)!!
override fun onStart() {
super.onStart()
setupRootViewInsets(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++
}
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()
}
return true
}
protected fun CheckBoxPreference.bindBooleanFlow(flow: PreferenceFlowWrapper<Boolean>) {
lifecycleScope.launch { lifecycleScope.launch {
flow.collect { isChecked = it } flow.collect { isChecked = it }
} }
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
runBlocking { runBlocking {
flow.updatePreference(newValue as Boolean) preferenceRepository.updatePreference(key, newValue as Boolean)
} }
true true
} }
} }
protected fun mergePreferenceOverlay(overlayKey: String, targetKey: String) {
val overlayCat = requirePreference<PreferenceCategory>(overlayKey)
val targetCat = requirePreference<PreferenceCategory>(targetKey)
val prefs = buildList {
for (i in 0..<overlayCat.preferenceCount) {
add(overlayCat.getPreference(i))
}
}
prefs.forEach {
overlayCat.removePreference(it)
targetCat.addPreference(it)
}
overlayCat.parent?.removePreference(overlayCat)
}
} }

View file

@ -0,0 +1,93 @@
package im.angry.openeuicc.ui
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Spinner
import androidx.appcompat.widget.Toolbar
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.util.*
class SlotSelectFragment : BaseMaterialDialogFragment(), OpenEuiccContextMarker {
companion object {
const val TAG = "SlotSelectFragment"
fun newInstance(knownChannels: List<EuiccChannel>): SlotSelectFragment {
return SlotSelectFragment().apply {
arguments = Bundle().apply {
putIntArray("slotIds", knownChannels.map { it.slotId }.toIntArray())
putIntArray("logicalSlotIds", knownChannels.map { it.logicalSlotId }.toIntArray())
putIntArray("portIds", knownChannels.map { it.portId }.toIntArray())
}
}
}
}
interface SlotSelectedListener {
fun onSlotSelected(slotId: Int, portId: Int)
fun onSlotSelectCancelled()
}
private lateinit var toolbar: Toolbar
private lateinit var spinner: Spinner
private lateinit var adapter: ArrayAdapter<String>
private lateinit var slotIds: IntArray
private lateinit var logicalSlotIds: IntArray
private lateinit var portIds: IntArray
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_slot_select, container, false)
toolbar = view.requireViewById(R.id.toolbar)
toolbar.setTitle(R.string.slot_select)
toolbar.inflateMenu(R.menu.fragment_slot_select)
adapter = ArrayAdapter<String>(inflater.context, R.layout.spinner_item)
spinner = view.requireViewById(R.id.spinner)
spinner.adapter = adapter
return view
}
override fun onStart() {
super.onStart()
slotIds = requireArguments().getIntArray("slotIds")!!
logicalSlotIds = requireArguments().getIntArray("logicalSlotIds")!!
portIds = requireArguments().getIntArray("portIds")!!
logicalSlotIds.forEach { id ->
adapter.add(getString(R.string.channel_name_format, id))
}
toolbar.setNavigationOnClickListener {
(requireActivity() as SlotSelectedListener).onSlotSelectCancelled()
}
toolbar.setOnMenuItemClickListener {
val slotId = slotIds[spinner.selectedItemPosition]
val portId = portIds[spinner.selectedItemPosition]
(requireActivity() as SlotSelectedListener).onSlotSelected(slotId, portId)
dismiss()
true
}
}
override fun onResume() {
super.onResume()
setWidthPercent(75)
}
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
(requireActivity() as SlotSelectedListener).onSlotSelectCancelled()
}
}

View file

@ -20,6 +20,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -72,6 +73,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
private lateinit var loadingProgress: ProgressBar private lateinit var loadingProgress: ProgressBar
private var usbDevice: UsbDevice? = null private var usbDevice: UsbDevice? = null
private var usbChannel: EuiccChannel? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -120,7 +122,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
try { try {
requireContext().unregisterReceiver(usbPermissionReceiver) requireContext().unregisterReceiver(usbPermissionReceiver)
} catch (_: Exception) { } catch (_: Exception) {
// ignore
} }
} }
@ -129,7 +131,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
try { try {
requireContext().unregisterReceiver(usbPermissionReceiver) requireContext().unregisterReceiver(usbPermissionReceiver)
} catch (_: Exception) { } catch (_: Exception) {
// ignore
} }
} }
@ -138,26 +140,24 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
permissionButton.visibility = View.GONE permissionButton.visibility = View.GONE
loadingProgress.visibility = View.VISIBLE loadingProgress.visibility = View.VISIBLE
val (device, canOpen) = withContext(Dispatchers.IO) { val (device, channel) = withContext(Dispatchers.IO) {
euiccChannelManager.tryOpenUsbEuiccChannel() euiccChannelManager.enumerateUsbEuiccChannel()
} }
loadingProgress.visibility = View.GONE loadingProgress.visibility = View.GONE
usbDevice = device usbDevice = device
usbChannel = channel
if (device != null && !canOpen && !usbManager.hasPermission(device)) { if (device != null && channel == null && !usbManager.hasPermission(device)) {
text.text = getString(R.string.usb_permission_needed) text.text = getString(R.string.usb_permission_needed)
text.visibility = View.VISIBLE text.visibility = View.VISIBLE
permissionButton.visibility = View.VISIBLE permissionButton.visibility = View.VISIBLE
} else if (device != null && canOpen) { } else if (device != null && channel != null) {
childFragmentManager.commit { childFragmentManager.commit {
replace( replace(
R.id.child_container, R.id.child_container,
appContainer.uiComponentFactory.createEuiccManagementFragment( appContainer.uiComponentFactory.createEuiccManagementFragment(channel)
slotId = EuiccChannelManager.USB_CHANNEL_ID,
portId = 0
)
) )
} }
} else { } else {

View file

@ -1,330 +0,0 @@
package im.angry.openeuicc.ui.wizard
import android.app.assist.AssistContent
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.ProgressBar
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.typeblog.lpac_jni.LocalProfileAssistant
class DownloadWizardActivity: BaseEuiccAccessActivity() {
data class DownloadWizardState(
var currentStepFragmentClassName: String?,
var selectedLogicalSlot: Int,
var smdp: String,
var matchingId: String?,
var confirmationCode: String?,
var imei: String?,
var downloadStarted: Boolean,
var downloadTaskID: Long,
var downloadError: LocalProfileAssistant.ProfileDownloadException?,
var skipMethodSelect: Boolean,
var confirmationCodeRequired: Boolean,
)
private lateinit var state: DownloadWizardState
private lateinit var progressBar: ProgressBar
private lateinit var nextButton: Button
private lateinit var prevButton: Button
private var currentFragment: DownloadWizardStepFragment? = null
set(value) {
if (this::state.isInitialized) {
state.currentStepFragmentClassName = value?.javaClass?.name
}
field = value
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_download_wizard)
onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// Make back == prev
onPrevPressed()
}
})
state = DownloadWizardState(
currentStepFragmentClassName = null,
selectedLogicalSlot = intent.getIntExtra("selectedLogicalSlot", 0),
smdp = "",
matchingId = null,
confirmationCode = null,
imei = null,
downloadStarted = false,
downloadTaskID = -1,
downloadError = null,
skipMethodSelect = false,
confirmationCodeRequired = false,
)
handleDeepLink()
progressBar = requireViewById(R.id.progress)
nextButton = requireViewById(R.id.download_wizard_next)
prevButton = requireViewById(R.id.download_wizard_back)
nextButton.setOnClickListener {
onNextPressed()
}
prevButton.setOnClickListener {
onPrevPressed()
}
val navigation = requireViewById<View>(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<View>(R.id.step_fragment_container)
ViewCompat.setOnApplyWindowInsetsListener(fragmentRoot) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)
v.updatePadding(bars.left, bars.top, bars.right, 0)
WindowInsetsCompat.CONSUMED
}
}
private fun handleDeepLink() {
// 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 parsed = LPAString.parse(uri.schemeSpecificPart)
state.smdp = parsed.address
state.matchingId = parsed.matchingId
state.confirmationCodeRequired = parsed.confirmationCodeRequired
state.skipMethodSelect = true
}
}
override fun onProvideAssistContent(outContent: AssistContent?) {
super.onProvideAssistContent(outContent)
outContent?.webUri = try {
val activationCode = LPAString(
state.smdp,
state.matchingId,
null,
state.confirmationCode != null,
)
"LPA:$activationCode".toUri()
} catch (_: Exception) {
null
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString("currentStepFragmentClassName", state.currentStepFragmentClassName)
outState.putInt("selectedLogicalSlot", state.selectedLogicalSlot)
outState.putString("smdp", state.smdp)
outState.putString("matchingId", state.matchingId)
outState.putString("confirmationCode", state.confirmationCode)
outState.putString("imei", state.imei)
outState.putBoolean("downloadStarted", state.downloadStarted)
outState.putLong("downloadTaskID", state.downloadTaskID)
outState.putBoolean("confirmationCodeRequired", state.confirmationCodeRequired)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
state.currentStepFragmentClassName = savedInstanceState.getString(
"currentStepFragmentClassName",
state.currentStepFragmentClassName
)
state.selectedLogicalSlot =
savedInstanceState.getInt("selectedLogicalSlot", state.selectedLogicalSlot)
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)
}
private fun onPrevPressed() {
hideIme()
if (currentFragment?.hasPrev == true) {
val prevFrag = currentFragment?.createPrevFragment()
if (prevFrag == null) {
finish()
} else {
showFragment(prevFrag, R.anim.slide_in_left, R.anim.slide_out_right)
}
}
}
private fun onNextPressed() {
hideIme()
nextButton.isEnabled = false
progressBar.visibility = View.VISIBLE
progressBar.isIndeterminate = true
lifecycleScope.launch(Dispatchers.Main) {
if (state.selectedLogicalSlot >= 0) {
try {
// This is run on IO by default
euiccChannelManager.withEuiccChannel(state.selectedLogicalSlot) { channel ->
// Be _very_ sure that the channel we got is valid
if (!channel.valid) throw EuiccChannelManager.EuiccChannelNotFoundException()
}
} catch (e: EuiccChannelManager.EuiccChannelNotFoundException) {
Toast.makeText(
this@DownloadWizardActivity,
R.string.download_wizard_slot_removed,
Toast.LENGTH_LONG
).show()
finish()
}
}
progressBar.visibility = View.GONE
nextButton.isEnabled = true
if (currentFragment?.hasNext == true) {
currentFragment?.beforeNext()
val nextFrag = currentFragment?.createNextFragment()
if (nextFrag == null) {
finish()
} else {
showFragment(nextFrag, R.anim.slide_in_right, R.anim.slide_out_left)
}
}
}
}
override fun onInit() {
progressBar.visibility = View.GONE
if (state.currentStepFragmentClassName != null) {
val clazz = Class.forName(state.currentStepFragmentClassName!!)
showFragment(clazz.getDeclaredConstructor().newInstance() as DownloadWizardStepFragment)
} else {
showFragment(DownloadWizardSlotSelectFragment())
}
}
private fun showFragment(
nextFrag: DownloadWizardStepFragment,
enterAnim: Int = 0,
exitAnim: Int = 0
) {
currentFragment = nextFrag
supportFragmentManager.beginTransaction().setCustomAnimations(enterAnim, exitAnim)
.replace(R.id.step_fragment_container, nextFrag)
.commit()
// Sync screen on state
if (nextFrag.keepScreenOn) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
refreshButtons()
}
private fun refreshButtons() {
currentFragment?.let {
nextButton.visibility = if (it.hasNext) {
View.VISIBLE
} else {
View.GONE
}
prevButton.visibility = if (it.hasPrev) {
View.VISIBLE
} else {
View.GONE
}
}
}
private fun hideIme() {
currentFocus?.let {
val imm = getSystemService(InputMethodManager::class.java)
imm.hideSoftInputFromWindow(it.windowToken, 0)
}
}
abstract class DownloadWizardStepFragment : Fragment(), OpenEuiccContextMarker {
protected val state: DownloadWizardState
get() = (requireActivity() as DownloadWizardActivity).state
open val keepScreenOn = false
abstract val hasNext: Boolean
abstract val hasPrev: Boolean
abstract fun createNextFragment(): DownloadWizardStepFragment?
abstract fun createPrevFragment(): DownloadWizardStepFragment?
protected fun gotoNextFragment(next: DownloadWizardStepFragment? = null) {
val realNext = next ?: createNextFragment()
(requireActivity() as DownloadWizardActivity).showFragment(
realNext!!,
R.anim.slide_in_right,
R.anim.slide_out_left
)
}
protected fun hideProgressBar() {
(requireActivity() as DownloadWizardActivity).progressBar.visibility = View.GONE
}
protected fun showProgressBar(progressValue: Int) {
(requireActivity() as DownloadWizardActivity).progressBar.apply {
visibility = View.VISIBLE
if (progressValue >= 0) {
isIndeterminate = false
progress = progressValue
} else {
isIndeterminate = true
}
}
}
protected fun refreshButtons() {
(requireActivity() as DownloadWizardActivity).refreshButtons()
}
open fun beforeNext() {}
}
}

View file

@ -1,117 +0,0 @@
package im.angry.openeuicc.ui.wizard
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.widget.addTextChangedListener
import com.google.android.material.textfield.TextInputLayout
import im.angry.openeuicc.common.R
class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
private var inputComplete = false
override val hasNext: Boolean
get() = inputComplete
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 fun saveState() {
state.smdp = smdp.editText!!.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 }
}
override fun beforeNext() = saveState()
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
DownloadWizardProgressFragment()
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
if (state.skipMethodSelect) {
DownloadWizardSlotSelectFragment()
} else {
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 onStart() {
super.onStart()
smdp.editText!!.setText(state.smdp)
matchingId.editText!!.setText(state.matchingId)
confirmationCode.editText!!.setText(state.confirmationCode)
imei.editText!!.setText(state.imei)
updateInputCompleteness()
if (state.confirmationCodeRequired) {
confirmationCode.editText!!.requestFocus()
confirmationCode.editText!!.hint =
getString(R.string.profile_download_confirmation_code_required)
} else {
confirmationCode.editText!!.hint =
getString(R.string.profile_download_confirmation_code)
}
}
override fun onPause() {
super.onPause()
saveState()
}
private fun updateInputCompleteness() {
inputComplete = isValidAddress(smdp.editText!!.text)
if (state.confirmationCodeRequired) {
inputComplete = inputComplete && confirmationCode.editText!!.text.isNotEmpty()
}
refreshButtons()
}
}
private fun isValidAddress(input: CharSequence): Boolean {
if (!input.contains('.')) return false
var fqdn = input
var port = 443
if (input.contains(':')) {
val portIndex = input.lastIndexOf(':')
fqdn = input.substring(0, portIndex)
port = input.substring(portIndex + 1, input.length).toIntOrNull(10) ?: 0
}
// see https://en.wikipedia.org/wiki/Port_(computer_networking)
if (port < 1 || port > 0xffff) return false
// see https://en.wikipedia.org/wiki/Fully_qualified_domain_name
if (fqdn.isEmpty() || fqdn.length > 255) return false
for (part in fqdn.split('.')) {
if (part.isEmpty() || part.length > 64) return false
if (part.first() == '-' || part.last() == '-') return false
for (c in part) {
if (c.isLetterOrDigit() || c == '-') continue
return false
}
}
return true
}

View file

@ -1,139 +0,0 @@
package im.angry.openeuicc.ui.wizard
import android.icu.text.SimpleDateFormat
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
import java.util.Date
class DownloadWizardDiagnosticsFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
override val hasNext: Boolean
get() = true
override val hasPrev: Boolean
get() = false
private lateinit var diagnosticTextView: TextView
private val saveDiagnostics =
setupLogSaving(
getLogFileName = {
getString(
R.string.download_wizard_diagnostics_file_template,
SimpleDateFormat.getDateTimeInstance().format(Date())
)
},
getLogText = { diagnosticTextView.text.toString() }
)
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_download_diagnostics, container, false)
view.requireViewById<View>(R.id.download_wizard_diagnostics_save).setOnClickListener {
saveDiagnostics()
}
diagnosticTextView = view.requireViewById(R.id.download_wizard_diagnostics_text)
return view
}
override fun onStart() {
super.onStart()
val str = buildDiagnosticsText()
if (str == null) {
requireActivity().finish()
return
}
diagnosticTextView.text = str
}
private fun buildDiagnosticsText(): String? = state.downloadError?.let { err ->
val ret = StringBuilder()
ret.appendLine(
getString(
R.string.download_wizard_diagnostics_error_code,
err.lpaErrorReason
)
)
ret.appendLine()
err.lastHttpResponse?.let { resp ->
if (resp.rcode != 200) {
// Only show the status if it's not 200
// Because we can have errors even if the rcode is 200 due to SM-DP+ servers being dumb
// and showing 200 might mislead users
ret.appendLine(
getString(
R.string.download_wizard_diagnostics_last_http_status,
resp.rcode
)
)
ret.appendLine()
}
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_http_response))
ret.appendLine()
val str = resp.data.decodeToString(throwOnInvalidSequence = false)
ret.appendLine(
if (str.startsWith('{')) {
str.prettyPrintJson()
} else {
str
}
)
ret.appendLine()
}
err.lastHttpException?.let { e ->
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_http_exception))
ret.appendLine()
ret.appendLine("${e.javaClass.name}: ${e.message}")
ret.appendLine(e.stackTrace.joinToString("\n"))
ret.appendLine()
}
err.lastApduResponse?.let { resp ->
val isSuccess =
resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
if (isSuccess) {
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_response_success))
} else {
// Only show the full APDU response when it's a failure
// Otherwise it's going to get very crammed
ret.appendLine(
getString(
R.string.download_wizard_diagnostics_last_apdu_response,
resp.encodeHex()
)
)
ret.appendLine()
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_response_fail))
}
}
err.lastApduException?.let { e ->
ret.appendLine(getString(R.string.download_wizard_diagnostics_last_apdu_exception))
ret.appendLine()
ret.appendLine("${e.javaClass.name}: ${e.message}")
ret.appendLine(e.stackTrace.joinToString("\n"))
ret.appendLine()
}
ret.toString()
}
}

View file

@ -1,173 +0,0 @@
package im.angry.openeuicc.ui.wizard
import android.app.AlertDialog
import android.content.ClipboardManager
import android.graphics.BitmapFactory
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
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 com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DownloadWizardMethodSelectFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
data class DownloadMethod(
val iconRes: Int,
val titleRes: Int,
val onClick: () -> Unit
)
// TODO: Maybe we should find a better barcode scanner (or an external one?)
private val barcodeScannerLauncher = registerForActivityResult(ScanContract()) { result ->
result.contents?.let { content ->
processLpaString(content)
}
}
private val gallerySelectorLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { result ->
if (result == null) return@registerForActivityResult
lifecycleScope.launch {
val decoded = withContext(Dispatchers.IO) {
runCatching {
requireContext().contentResolver.openInputStream(result)?.use { input ->
BitmapFactory.decodeStream(input).use(::decodeQrFromBitmap)
}
}
}
decoded.getOrNull()?.let { processLpaString(it) }
}
}
val downloadMethods = arrayOf(
DownloadMethod(R.drawable.ic_scan_black, R.string.download_wizard_method_qr_code) {
barcodeScannerLauncher.launch(ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setOrientationLocked(false)
})
},
DownloadMethod(R.drawable.ic_gallery_black, R.string.download_wizard_method_gallery) {
gallerySelectorLauncher.launch("image/*")
},
DownloadMethod(R.drawable.ic_paste_go, R.string.download_wizard_method_clipboard) {
handleLoadFromClipboard()
},
DownloadMethod(R.drawable.ic_edit, R.string.download_wizard_method_manual) {
gotoNextFragment(DownloadWizardDetailsFragment())
}
)
override val hasNext: Boolean
get() = false
override val hasPrev: Boolean
get() = true
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? =
null
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
DownloadWizardSlotSelectFragment()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_download_method_select, container, false)
val recyclerView = view.requireViewById<RecyclerView>(R.id.download_method_list)
recyclerView.adapter = DownloadMethodAdapter()
recyclerView.layoutManager =
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
recyclerView.addItemDecoration(
DividerItemDecoration(
requireContext(),
LinearLayoutManager.VERTICAL
)
)
return view
}
private fun handleLoadFromClipboard() {
val clipboard = requireContext().getSystemService(ClipboardManager::class.java)
val text = clipboard.primaryClip?.getItemAt(0)?.text
if (text == null) {
Toast.makeText(
requireContext(),
R.string.profile_download_no_lpa_string,
Toast.LENGTH_SHORT
).show()
return
}
processLpaString(text.toString())
}
private fun processLpaString(input: String) {
try {
val parsed = LPAString.parse(input)
state.smdp = parsed.address
state.matchingId = parsed.matchingId
state.confirmationCodeRequired = parsed.confirmationCodeRequired
gotoNextFragment(DownloadWizardDetailsFragment())
} catch (e: IllegalArgumentException) {
AlertDialog.Builder(requireContext()).apply {
setTitle(R.string.profile_download_incorrect_lpa_string)
setMessage(R.string.profile_download_incorrect_lpa_string_message)
setCancelable(true)
setNegativeButton(android.R.string.cancel, null)
show()
}
}
}
private inner class DownloadMethodViewHolder(private val root: View) : ViewHolder(root) {
private val icon = root.requireViewById<ImageView>(R.id.download_method_icon)
private val title = root.requireViewById<TextView>(R.id.download_method_title)
fun bind(item: DownloadMethod) {
icon.setImageResource(item.iconRes)
title.setText(item.titleRes)
root.setOnClickListener {
// If the user elected to use another download method, reset the confirmation code flag
// too
state.confirmationCodeRequired = false
item.onClick()
}
}
}
private inner class DownloadMethodAdapter : RecyclerView.Adapter<DownloadMethodViewHolder>() {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): DownloadMethodViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.download_method_item, parent, false)
return DownloadMethodViewHolder(view)
}
override fun getItemCount(): Int = downloadMethods.size
override fun onBindViewHolder(holder: DownloadMethodViewHolder, position: Int) {
holder.bind(downloadMethods[position])
}
}
}

View file

@ -1,243 +0,0 @@
package im.angry.openeuicc.ui.wizard
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import im.angry.openeuicc.common.R
import im.angry.openeuicc.service.EuiccChannelManagerService
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.ProfileDownloadCallback
class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
companion object {
/**
* 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,
)
}
private enum class ProgressState {
NotStarted,
InProgress,
Done,
Error
}
private data class ProgressItem(
val titleRes: Int,
var state: ProgressState
)
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)
)
private val adapter = ProgressItemAdapter()
// We don't want to turn off the screen during a download
override val keepScreenOn = true
private var isDone = false
override val hasNext: Boolean
get() = isDone
override val hasPrev: Boolean
get() = false
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment? =
if (state.downloadError != null) {
DownloadWizardDiagnosticsFragment()
} else {
null
}
override fun createPrevFragment(): DownloadWizardActivity.DownloadWizardStepFragment? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_download_progress, container, false)
val recyclerView = view.requireViewById<RecyclerView>(R.id.download_progress_list)
recyclerView.adapter = adapter
recyclerView.layoutManager =
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
recyclerView.addItemDecoration(
DividerItemDecoration(
requireContext(),
LinearLayoutManager.VERTICAL
)
)
return view
}
override fun onStart() {
super.onStart()
lifecycleScope.launch {
showProgressBar(-1) // set indeterminate first
ensureEuiccChannelManager()
val subscriber = startDownloadOrSubscribe()
if (subscriber == null) {
requireActivity().finish()
return@launch
}
subscriber.onEach {
when (it) {
is EuiccChannelManagerService.ForegroundTaskState.Done -> {
hideProgressBar()
state.downloadError =
it.error as? LocalProfileAssistant.ProfileDownloadException
// 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
}
adapter.notifyItemChanged(index)
}
isDone = true
refreshButtons()
}
is EuiccChannelManagerService.ForegroundTaskState.InProgress -> {
updateProgress(it.progress)
}
else -> {}
}
}.collect()
}
}
private suspend fun startDownloadOrSubscribe(): EuiccChannelManagerService.ForegroundTaskSubscriberFlow? =
if (state.downloadStarted) {
// This will also return null if task ID is -1 (uninitialized), too
euiccChannelManagerService.recoverForegroundTaskSubscriber(state.downloadTaskID)
} else {
euiccChannelManagerService.waitForForegroundTask()
val (slotId, portId) = euiccChannelManager.withEuiccChannel(state.selectedLogicalSlot) { channel ->
Pair(channel.slotId, channel.portId)
}
// Set started to true even before we start -- in case we get killed in the middle
state.downloadStarted = true
val ret = euiccChannelManagerService.launchProfileDownloadTask(
slotId,
portId,
state.smdp,
state.matchingId,
state.confirmationCode,
state.imei
)
state.downloadTaskID = ret.taskId
ret
}
private fun updateProgress(progress: Int) {
showProgressBar(progress)
val lpaState = ProfileDownloadCallback.lookupStateFromProgress(progress)
val stateIndex = LPA_PROGRESS_STATES.indexOf(lpaState)
if (stateIndex > 0) {
for (i in (0..<stateIndex)) {
if (progressItems[i].state != ProgressState.Done) {
progressItems[i].state = ProgressState.Done
adapter.notifyItemChanged(i)
}
}
}
if (progressItems[stateIndex].state != ProgressState.InProgress) {
progressItems[stateIndex].state = ProgressState.InProgress
adapter.notifyItemChanged(stateIndex)
}
}
private inner class ProgressItemHolder(val root: View) : RecyclerView.ViewHolder(root) {
private val title = root.requireViewById<TextView>(R.id.download_progress_item_title)
private val progressBar =
root.requireViewById<ProgressBar>(R.id.download_progress_icon_progress)
private val icon = root.requireViewById<ImageView>(R.id.download_progress_icon)
fun bind(item: ProgressItem) {
title.text = getString(item.titleRes)
when (item.state) {
ProgressState.NotStarted -> {
progressBar.visibility = View.GONE
icon.visibility = View.GONE
}
ProgressState.InProgress -> {
progressBar.visibility = View.VISIBLE
icon.visibility = View.GONE
}
ProgressState.Done -> {
progressBar.visibility = View.GONE
icon.setImageResource(R.drawable.ic_checkmark_outline)
icon.visibility = View.VISIBLE
}
ProgressState.Error -> {
progressBar.visibility = View.GONE
icon.setImageResource(R.drawable.ic_error_outline)
icon.visibility = View.VISIBLE
}
}
}
}
private inner class ProgressItemAdapter : RecyclerView.Adapter<ProgressItemHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProgressItemHolder {
val root = LayoutInflater.from(parent.context)
.inflate(R.layout.download_progress_item, parent, false)
return ProgressItemHolder(root)
}
override fun getItemCount(): Int = progressItems.size
override fun onBindViewHolder(holder: ProgressItemHolder, position: Int) {
holder.bind(progressItems[position])
}
}
}

View file

@ -1,219 +0,0 @@
package im.angry.openeuicc.ui.wizard
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
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.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import net.typeblog.lpac_jni.LocalProfileInfo
class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
companion object {
const val LOW_NVRAM_THRESHOLD =
30 * 1024 // < 30 KiB, alert about potential download failure
}
private data class SlotInfo(
val logicalSlotId: Int,
val isRemovable: Boolean,
val hasMultiplePorts: Boolean,
val portId: Int,
val eID: String,
val freeSpace: Int,
val imei: String,
val enabledProfileName: String?,
val intrinsicChannelName: String?,
)
private var loaded = false
private val adapter = SlotInfoAdapter()
override val hasNext: Boolean
get() = loaded && adapter.slots.isNotEmpty()
override val hasPrev: Boolean
get() = true
override fun createNextFragment(): DownloadWizardActivity.DownloadWizardStepFragment =
if (state.skipMethodSelect) {
DownloadWizardDetailsFragment()
} else {
DownloadWizardMethodSelectFragment()
}
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?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_download_slot_select, container, false)
val recyclerView = view.requireViewById<RecyclerView>(R.id.download_slot_list)
recyclerView.adapter = adapter
recyclerView.layoutManager =
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
return view
}
override fun onStart() {
super.onStart()
if (!loaded) {
lifecycleScope.launch { init() }
}
}
@SuppressLint("NotifyDataSetChanged", "MissingPermission")
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,
)
}
}.toList().sortedBy { it.logicalSlotId }
adapter.slots = slots
// Ensure we always have a selected slot by default
val selectedIdx = slots.indexOfFirst { it.logicalSlotId == state.selectedLogicalSlot }
adapter.currentSelectedIdx = if (selectedIdx > 0) {
selectedIdx
} else {
if (slots.isNotEmpty()) {
state.selectedLogicalSlot = slots[0].logicalSlotId
}
0
}
if (slots.isNotEmpty()) {
state.imei = slots[adapter.currentSelectedIdx].imei
}
adapter.notifyDataSetChanged()
hideProgressBar()
loaded = true
refreshButtons()
}
private inner class SlotItemHolder(val root: View) : ViewHolder(root) {
private val title = root.requireViewById<TextView>(R.id.slot_item_title)
private val type = root.requireViewById<TextView>(R.id.slot_item_type)
private val eID = root.requireViewById<TextView>(R.id.slot_item_eid)
private val activeProfile = root.requireViewById<TextView>(R.id.slot_item_active_profile)
private val freeSpace = root.requireViewById<TextView>(R.id.slot_item_free_space)
private val checkBox = root.requireViewById<CheckBox>(R.id.slot_checkbox)
private var curIdx = -1
init {
root.setOnClickListener(this::onSelect)
checkBox.setOnClickListener(this::onSelect)
}
@Suppress("UNUSED_PARAMETER")
fun onSelect(view: View) {
if (curIdx < 0) return
checkBox.isChecked = true
if (adapter.currentSelectedIdx == curIdx) return
val lastIdx = adapter.currentSelectedIdx
adapter.currentSelectedIdx = curIdx
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.imei = adapter.slots[adapter.currentSelectedIdx].imei
}
fun bind(item: SlotInfo, idx: Int) {
curIdx = idx
type.text = if (item.isRemovable) {
root.context.getString(R.string.download_wizard_slot_type_removable)
} else if (!item.hasMultiplePorts) {
root.context.getString(R.string.download_wizard_slot_type_internal)
} else {
root.context.getString(
R.string.download_wizard_slot_type_internal_port,
item.portId
)
}
title.text = if (item.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
item.intrinsicChannelName ?: root.context.getString(R.string.usb)
} else {
appContainer.customizableTextProvider.formatInternalChannelName(item.logicalSlotId)
}
eID.text = item.eID
activeProfile.text = item.enabledProfileName ?: root.context.getString(R.string.unknown)
freeSpace.text = formatFreeSpace(item.freeSpace)
checkBox.isChecked = adapter.currentSelectedIdx == idx
}
}
private inner class SlotInfoAdapter : RecyclerView.Adapter<SlotItemHolder>() {
var slots: List<SlotInfo> = listOf()
var currentSelectedIdx = -1
val selected: SlotInfo
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)
return SlotItemHolder(root)
}
override fun getItemCount(): Int = slots.size
override fun onBindViewHolder(holder: SlotItemHolder, position: Int) {
holder.bind(slots[position], position)
}
}
}

View file

@ -4,69 +4,49 @@ import android.os.Bundle
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.service.EuiccChannelManagerService
import im.angry.openeuicc.ui.BaseEuiccAccessActivity import im.angry.openeuicc.ui.BaseEuiccAccessActivity
import kotlinx.coroutines.Dispatchers
private const val FIELD_SLOT_ID = "slotId" import kotlinx.coroutines.withContext
private const val FIELD_PORT_ID = "portId"
interface EuiccChannelFragmentMarker: OpenEuiccContextMarker interface EuiccChannelFragmentMarker: OpenEuiccContextMarker
private typealias BundleSetter = Bundle.() -> Unit
// We must use extension functions because there is no way to add bounds to the type of "self" // 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 // in the definition of an interface, so the only way is to limit where the extension functions
// can be applied. // can be applied.
fun <T> newInstanceEuicc(clazz: Class<T>, slotId: Int, portId: Int, addArguments: BundleSetter = {}): T fun <T> newInstanceEuicc(clazz: Class<T>, slotId: Int, portId: Int, addArguments: Bundle.() -> Unit = {}): T where T: Fragment, T: EuiccChannelFragmentMarker {
where T : Fragment, T : EuiccChannelFragmentMarker = val instance = clazz.newInstance()
clazz.getDeclaredConstructor().newInstance().apply { instance.arguments = Bundle().apply {
arguments = Bundle() putInt("slotId", slotId)
arguments!!.putInt(FIELD_SLOT_ID, slotId) putInt("portId", portId)
arguments!!.putInt(FIELD_PORT_ID, portId) addArguments()
arguments!!.addArguments() }
return instance
} }
// Convenient methods to avoid using `channel` for these // Convenient methods to avoid using `channel` for these
// `channel` requires that the channel actually exists in EuiccChannelManager, which is // `channel` requires that the channel actually exists in EuiccChannelManager, which is
// not always the case during operations such as switching // not always the case during operations such as switching
val <T> T.slotId: Int val <T> T.slotId: Int where T: Fragment, T: EuiccChannelFragmentMarker
where T : Fragment, T : EuiccChannelFragmentMarker get() = requireArguments().getInt("slotId")
get() = requireArguments().getInt(FIELD_SLOT_ID) val <T> T.portId: Int where T: Fragment, T: EuiccChannelFragmentMarker
val <T> T.portId: Int get() = requireArguments().getInt("portId")
where T : Fragment, T : EuiccChannelFragmentMarker val <T> T.isUsb: Boolean where T: Fragment, T: EuiccChannelFragmentMarker
get() = requireArguments().getInt(FIELD_PORT_ID) get() = requireArguments().getInt("slotId") == EuiccChannelManager.USB_CHANNEL_ID
val <T> T.isUsb: Boolean
where T : Fragment, T : EuiccChannelFragmentMarker
get() = slotId == EuiccChannelManager.USB_CHANNEL_ID
private fun <T> T.requireEuiccActivity(): BaseEuiccAccessActivity val <T> T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: EuiccChannelFragmentMarker
where T : Fragment, T : OpenEuiccContextMarker = get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManager
requireActivity() as BaseEuiccAccessActivity val <T> T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker
get() =
val <T> T.euiccChannelManager: EuiccChannelManager euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!!
where T : Fragment, T : OpenEuiccContextMarker
get() = requireEuiccActivity().euiccChannelManager
val <T> T.euiccChannelManagerService: EuiccChannelManagerService
where T : Fragment, T : OpenEuiccContextMarker
get() = requireEuiccActivity().euiccChannelManagerService
suspend fun <T, R> T.withEuiccChannel(fn: suspend (EuiccChannel) -> R): R
where T : Fragment, T : EuiccChannelFragmentMarker {
ensureEuiccChannelManager()
return euiccChannelManager.withEuiccChannel(slotId, portId, fn)
}
suspend fun <T> T.ensureEuiccChannelManager() where T : Fragment, T : OpenEuiccContextMarker =
requireEuiccActivity().euiccChannelManagerLoaded.await()
fun <T> T.notifyEuiccProfilesChanged() where T : Fragment {
if (this !is EuiccProfilesChangedListener) return
// Trigger a refresh in the parent fragment -- it should wait until
// any foreground task is completed before actually doing a refresh
this.onEuiccProfilesChanged()
}
interface EuiccProfilesChangedListener { interface EuiccProfilesChangedListener {
fun onEuiccProfilesChanged() fun onEuiccProfilesChanged()
} }
suspend fun <T> T.beginTrackedOperation(op: suspend () -> Boolean) where T: Fragment, T: EuiccChannelFragmentMarker {
withContext(Dispatchers.IO) {
euiccChannelManager.beginTrackedOperationBlocking(slotId, portId) {
op()
}
}
}

View file

@ -1,34 +0,0 @@
package im.angry.openeuicc.util
data class LPAString(
val address: String,
val matchingId: String?,
val oid: String?,
val confirmationCodeRequired: Boolean,
) {
companion object {
fun parse(input: String): LPAString {
var token = input
if (token.startsWith("LPA:", ignoreCase = true)) token = token.drop(4)
val components = token.split('$').map { it.trim().ifBlank { null } }
require(components.getOrNull(0) == "1") { "Invalid AC_Format" }
return LPAString(
requireNotNull(components.getOrNull(1)) { "SM-DP+ is required" },
components.getOrNull(2),
components.getOrNull(3),
components.getOrNull(4) == "1"
)
}
}
override fun toString(): String {
val parts = arrayOf(
"1",
address,
matchingId ?: "",
oid ?: "",
if (confirmationCodeRequired) "1" else ""
)
return parts.joinToString("$").trimEnd('$')
}
}

View file

@ -3,6 +3,9 @@ package im.angry.openeuicc.util
import android.util.Log import android.util.Log
import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager import im.angry.openeuicc.core.EuiccChannelManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.LocalProfileAssistant import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.LocalProfileInfo import net.typeblog.lpac_jni.LocalProfileInfo
@ -16,10 +19,9 @@ val LocalProfileInfo.isEnabled: Boolean
get() = state == LocalProfileInfo.State.Enabled get() = state == LocalProfileInfo.State.Enabled
val List<LocalProfileInfo>.operational: List<LocalProfileInfo> val List<LocalProfileInfo>.operational: List<LocalProfileInfo>
get() = filter { it.profileClass == LocalProfileInfo.Clazz.Operational } get() = filter {
it.profileClass == LocalProfileInfo.Clazz.Operational
val List<LocalProfileInfo>.enabled: LocalProfileInfo? }
get() = find { it.isEnabled }
val List<EuiccChannel>.hasMultipleChips: Boolean val List<EuiccChannel>.hasMultipleChips: Boolean
get() = distinctBy { it.slotId }.size > 1 get() = distinctBy { it.slotId }.size > 1
@ -40,27 +42,22 @@ fun LocalProfileAssistant.switchProfile(
* See EuiccManager.waitForReconnect() * See EuiccManager.waitForReconnect()
*/ */
fun LocalProfileAssistant.disableActiveProfile(refresh: Boolean): Boolean = fun LocalProfileAssistant.disableActiveProfile(refresh: Boolean): Boolean =
profiles.enabled?.let { profiles.find { it.isEnabled }?.let {
Log.i(TAG, "Disabling active profile ${it.iccid}") Log.i(TAG, "Disabling active profile ${it.iccid}")
disableProfile(it.iccid, refresh) disableProfile(it.iccid, refresh)
} ?: true } ?: true
/** /**
* Disable the current active profile if any. If refresh is true, also cause a refresh command. * Disable the active profile, return a lambda that reverts this action when called.
* If refreshOnDisable is true, also cause a eUICC refresh command. Note that refreshing
* will disconnect the eUICC and might need some time before being operational again.
* See EuiccManager.waitForReconnect() * See EuiccManager.waitForReconnect()
*
* Return the iccid of the profile being disabled, or null if no active profile found or failed to
* disable.
*/ */
fun LocalProfileAssistant.disableActiveProfileKeepIccId(refresh: Boolean): String? = fun LocalProfileAssistant.disableActiveProfileWithUndo(refreshOnDisable: Boolean): () -> Unit =
profiles.enabled?.let { profiles.find { it.isEnabled }?.let {
Log.i(TAG, "Disabling active profile ${it.iccid}") disableProfile(it.iccid, refreshOnDisable)
if (disableProfile(it.iccid, refresh)) { return { enableProfile(it.iccid) }
it.iccid } ?: { }
} else {
null
}
}
/** /**
* Begin a "tracked" operation where notifications may be generated by the eSIM * Begin a "tracked" operation where notifications may be generated by the eSIM
@ -76,26 +73,29 @@ fun LocalProfileAssistant.disableActiveProfileKeepIccId(refresh: Boolean): Strin
* should be the concern of op() itself, and this function assumes that when * should be the concern of op() itself, and this function assumes that when
* op() returns, the slotId and portId will correspond to a valid channel again. * op() returns, the slotId and portId will correspond to a valid channel again.
*/ */
suspend inline fun EuiccChannelManager.beginTrackedOperation( inline fun EuiccChannelManager.beginTrackedOperationBlocking(
slotId: Int, slotId: Int,
portId: Int, portId: Int,
op: () -> Boolean op: () -> Boolean
) { ) {
val latestSeq = withEuiccChannel(slotId, portId) { channel -> val latestSeq =
channel.lpa.notifications.firstOrNull()?.seqNumber findEuiccChannelByPortBlocking(slotId, portId)!!.lpa.notifications.firstOrNull()?.seqNumber
?: 0 ?: 0
}
Log.d(TAG, "Latest notification is $latestSeq before operation") Log.d(TAG, "Latest notification is $latestSeq before operation")
if (op()) { if (op()) {
Log.d(TAG, "Operation has requested notification handling") Log.d(TAG, "Operation has requested notification handling")
try { try {
// Note that the exact instance of "channel" might have changed here if reconnected; // 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() // so we MUST use the automatic getter for "channel"
withEuiccChannel(slotId, portId) { channel -> findEuiccChannelByPortBlocking(
channel.lpa.notifications.filter { it.seqNumber > latestSeq }.forEach { slotId,
portId
)?.lpa?.notifications?.filter { it.seqNumber > latestSeq }?.forEach {
Log.d(TAG, "Handling notification $it") Log.d(TAG, "Handling notification $it")
channel.lpa.handleNotification(it.seqNumber) findEuiccChannelByPortBlocking(
} slotId,
portId
)?.lpa?.handleNotification(it.seqNumber)
} }
} catch (e: Exception) { } catch (e: Exception) {
// Ignore any error during notification handling // Ignore any error during notification handling

View file

@ -5,13 +5,11 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import im.angry.openeuicc.OpenEuiccApplication import im.angry.openeuicc.OpenEuiccApplication
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.util.Base64
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "prefs") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "prefs")
@ -21,105 +19,34 @@ val Context.preferenceRepository: PreferenceRepository
val Fragment.preferenceRepository: PreferenceRepository val Fragment.preferenceRepository: PreferenceRepository
get() = requireContext().preferenceRepository get() = requireContext().preferenceRepository
internal object PreferenceKeys { object PreferenceKeys {
// ---- Profile Notifications ----
val NOTIFICATION_DOWNLOAD = booleanPreferencesKey("notification_download") val NOTIFICATION_DOWNLOAD = booleanPreferencesKey("notification_download")
val NOTIFICATION_DELETE = booleanPreferencesKey("notification_delete") val NOTIFICATION_DELETE = booleanPreferencesKey("notification_delete")
val NOTIFICATION_SWITCH = booleanPreferencesKey("notification_switch") val NOTIFICATION_SWITCH = booleanPreferencesKey("notification_switch")
// ---- Advanced ----
val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim") val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim")
val VERBOSE_LOGGING = booleanPreferencesKey("verbose_logging")
// ---- Developer Options ----
val DEVELOPER_OPTIONS_ENABLED = booleanPreferencesKey("developer_options_enabled")
val REFRESH_AFTER_SWITCH = booleanPreferencesKey("refresh_after_switch")
val UNFILTERED_PROFILE_LIST = booleanPreferencesKey("unfiltered_profile_list")
val IGNORE_TLS_CERTIFICATE = booleanPreferencesKey("ignore_tls_certificate")
val EUICC_MEMORY_RESET = booleanPreferencesKey("euicc_memory_reset")
val ISDR_AID_LIST = stringPreferencesKey("isdr_aid_list")
} }
const val EUICC_DEFAULT_ISDR_AID = "A0000005591010FFFFFFFF8900000100" class PreferenceRepository(context: Context) {
private val dataStore = context.dataStore
internal object PreferenceConstants {
val DEFAULT_AID_LIST = """
# One AID per line. Comment lines start with #.
# Refs: <https://euicc-manual.osmocom.org/docs/lpa/applet-id-oem/>
# eUICC standard
$EUICC_DEFAULT_ISDR_AID
# eSTK.me
A06573746B6D65FFFFFFFF4953442D52
# eSIM.me
A0000005591010000000008900000300
# 5ber.eSIM
A0000005591010FFFFFFFF8900050500
# Xesim
A0000005591010FFFFFFFF8900000177
""".trimIndent()
}
open class PreferenceRepository(private val context: Context) {
// Expose flows so that we can also handle default values // Expose flows so that we can also handle default values
// ---- Profile Notifications ---- // ---- Profile Notifications ----
val notificationDownloadFlow = bindFlow(PreferenceKeys.NOTIFICATION_DOWNLOAD, true) val notificationDownloadFlow: Flow<Boolean> =
val notificationDeleteFlow = bindFlow(PreferenceKeys.NOTIFICATION_DELETE, true) dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DOWNLOAD] ?: true }
val notificationSwitchFlow = bindFlow(PreferenceKeys.NOTIFICATION_SWITCH, false)
val notificationDeleteFlow: Flow<Boolean> =
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DELETE] ?: true }
val notificationSwitchFlow: Flow<Boolean> =
dataStore.data.map { it[PreferenceKeys.NOTIFICATION_SWITCH] ?: false }
// ---- Advanced ---- // ---- Advanced ----
val disableSafeguardFlow = bindFlow(PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM, false) val disableSafeguardFlow: Flow<Boolean> =
val verboseLoggingFlow = bindFlow(PreferenceKeys.VERBOSE_LOGGING, false) dataStore.data.map { it[PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM] ?: false }
// ---- Developer Options ---- suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
val refreshAfterSwitchFlow = bindFlow(PreferenceKeys.REFRESH_AFTER_SWITCH, true) dataStore.edit {
val developerOptionsEnabledFlow = bindFlow(PreferenceKeys.DEVELOPER_OPTIONS_ENABLED, false) it[key] = value
val unfilteredProfileListFlow = bindFlow(PreferenceKeys.UNFILTERED_PROFILE_LIST, false) }
val ignoreTLSCertificateFlow = bindFlow(PreferenceKeys.IGNORE_TLS_CERTIFICATE, false)
val euiccMemoryResetFlow = bindFlow(PreferenceKeys.EUICC_MEMORY_RESET, false)
val isdrAidListFlow = bindFlow(
PreferenceKeys.ISDR_AID_LIST,
PreferenceConstants.DEFAULT_AID_LIST,
{ Base64.getEncoder().encodeToString(it.encodeToByteArray()) },
{ Base64.getDecoder().decode(it).decodeToString() })
protected fun <T> bindFlow(
key: Preferences.Key<T>,
defaultValue: T,
encoder: (T) -> T = { it },
decoder: (T) -> T = { it }
): PreferenceFlowWrapper<T> =
PreferenceFlowWrapper(context, key, defaultValue, encoder, decoder)
}
class PreferenceFlowWrapper<T> private constructor(
private val context: Context,
private val key: Preferences.Key<T>,
inner: Flow<T>,
private val encoder: (T) -> T,
) : Flow<T> by inner {
internal constructor(
context: Context,
key: Preferences.Key<T>,
defaultValue: T,
encoder: (T) -> T,
decoder: (T) -> T
) : this(
context,
key,
context.dataStore.data.map { it[key]?.let(decoder) ?: defaultValue },
encoder
)
suspend fun updatePreference(value: T) {
context.dataStore.edit { it[key] = encoder(value) }
}
suspend fun removePreference() {
context.dataStore.edit { it.remove(key) }
} }
} }

View file

@ -1,7 +1,7 @@
package im.angry.openeuicc.util package im.angry.openeuicc.util
fun String.decodeHex(): ByteArray { fun String.decodeHex(): ByteArray {
require(length % 2 == 0) { "Must have an even length" } check(length % 2 == 0) { "Must have an even length" }
val decodedLength = length / 2 val decodedLength = length / 2
val out = ByteArray(decodedLength) val out = ByteArray(decodedLength)
@ -28,86 +28,3 @@ fun formatFreeSpace(size: Int): String =
} else { } else {
"$size B" "$size B"
} }
/**
* Decode a list of potential ISDR AIDs, one per line. Lines starting with '#' are ignored.
* If none is found, at least EUICC_DEFAULT_ISDR_AID is returned
*/
fun parseIsdrAidList(s: String): List<ByteArray> =
s.split('\n')
.map(String::trim)
.filter { !it.startsWith('#') }
.map(String::trim)
.filter(String::isNotEmpty)
.mapNotNull { runCatching(it::decodeHex).getOrNull() }
.ifEmpty { listOf(EUICC_DEFAULT_ISDR_AID.decodeHex()) }
fun String.prettyPrintJson(): String {
val ret = StringBuilder()
var inQuotes = false
var escaped = false
val indentSymbolStack = ArrayDeque<Char>()
val addNewLine = {
ret.append('\n')
repeat(indentSymbolStack.size) {
ret.append('\t')
}
}
var lastChar = ' '
for (c in this) {
when {
!inQuotes && (c == '{' || c == '[') -> {
ret.append(c)
indentSymbolStack.addLast(c)
addNewLine()
}
!inQuotes && (c == '}' || c == ']') -> {
indentSymbolStack.removeLast()
if (lastChar != ',') {
addNewLine()
}
ret.append(c)
}
!inQuotes && c == ',' -> {
ret.append(c)
addNewLine()
}
!inQuotes && c == ':' -> {
ret.append(c)
ret.append(' ')
}
inQuotes && c == '\\' -> {
ret.append(c)
escaped = true
continue
}
!escaped && c == '"' -> {
ret.append(c)
inQuotes = !inQuotes
}
!inQuotes && c == ' ' -> {
// Do nothing -- we ignore spaces outside of quotes by default
// This is to ensure predictable formatting
}
else -> ret.append(c)
}
if (escaped) {
escaped = false
}
lastChar = c
}
return ret.toString()
}

View file

@ -45,8 +45,6 @@ fun SEService.getUiccReaderCompat(slotNumber: Int): Reader {
interface UiccCardInfoCompat { interface UiccCardInfoCompat {
val physicalSlotIndex: Int val physicalSlotIndex: Int
val ports: Collection<UiccPortInfoCompat> val ports: Collection<UiccPortInfoCompat>
val isRemovable: Boolean
get() = true // This defaults to removable unless overridden
} }
interface UiccPortInfoCompat { interface UiccPortInfoCompat {

View file

@ -1,24 +1,9 @@
package im.angry.openeuicc.util package im.angry.openeuicc.util
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.content.res.Resources import android.content.res.Resources
import android.graphics.Rect import android.graphics.Rect
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.activity.result.ActivityResultCaller
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import im.angry.openeuicc.common.R
import java.io.FileOutputStream
// Source: <https://stackoverflow.com/questions/12478520/how-to-set-dialogfragments-width-and-height> // Source: <https://stackoverflow.com/questions/12478520/how-to-set-dialogfragments-width-and-height>
/** /**
@ -41,84 +26,3 @@ fun DialogFragment.setWidthPercent(percentage: Int) {
fun DialogFragment.setFullScreen() { fun DialogFragment.setFullScreen() {
dialog?.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) dialog?.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
} }
fun AppCompatActivity.setupToolbarInsets() {
val spacer = requireViewById<View>(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<ViewGroup.MarginLayoutParams> {
topMargin = bars.top
}
v.updatePadding(bars.left, v.paddingTop, bars.right, v.paddingBottom)
spacer.updateLayoutParams {
height = v.top
}
WindowInsetsCompat.CONSUMED
}
}
fun setupRootViewInsets(view: ViewGroup) {
// Disable clipToPadding to make sure content actually display
view.clipToPadding = false
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)
v.updatePadding(bars.left, v.paddingTop, bars.right, bars.bottom)
WindowInsetsCompat.CONSUMED
}
}
fun <T : ActivityResultCaller> T.setupLogSaving(
getLogFileName: () -> String,
getLogText: () -> String
): () -> Unit {
var lastFileName = "untitled"
val launchSaveIntent =
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
if (uri == null) return@registerForActivityResult
val context = when (this@setupLogSaving) {
is Context -> this@setupLogSaving
is Fragment -> requireContext()
else -> throw IllegalArgumentException("Must be either Context or Fragment!")
}
context.contentResolver.openFileDescriptor(uri, "w")?.use {
FileOutputStream(it.fileDescriptor).use { os ->
os.write(getLogText().encodeToByteArray())
}
}
AlertDialog.Builder(context).apply {
setMessage(R.string.logs_saved_message)
setNegativeButton(R.string.no) { _, _ -> }
setPositiveButton(R.string.yes) { _, _ ->
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
clipData = ClipData.newUri(context.contentResolver, lastFileName, uri)
putExtra(Intent.EXTRA_TITLE, lastFileName)
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, null))
}
}.show()
}
return {
lastFileName = getLogFileName()
launchSaveIntent.launch(lastFileName)
}
}

View file

@ -54,9 +54,6 @@ interface OpenEuiccContextMarker {
val appContainer: AppContainer val appContainer: AppContainer
get() = openEuiccApplication.appContainer get() = openEuiccApplication.appContainer
val preferenceRepository: PreferenceRepository
get() = appContainer.preferenceRepository
val telephonyManager: TelephonyManager val telephonyManager: TelephonyManager
get() = appContainer.telephonyManager get() = appContainer.telephonyManager
} }
@ -89,13 +86,6 @@ suspend fun connectSEService(context: Context): SEService = suspendCoroutine { c
} }
} }
inline fun <T> Bitmap.use(f: (Bitmap) -> T): T =
try {
f(this)
} finally {
recycle()
}
fun decodeQrFromBitmap(bmp: Bitmap): String? = fun decodeQrFromBitmap(bmp: Bitmap): String? =
runCatching { runCatching {
val pixels = IntArray(bmp.width * bmp.height) val pixels = IntArray(bmp.width * bmp.height)

View file

@ -1,112 +0,0 @@
package im.angry.openeuicc.util
import android.util.Log
import im.angry.openeuicc.core.ApduInterfaceAtrProvider
import im.angry.openeuicc.core.EuiccChannel
import net.typeblog.lpac_jni.Version
data class EuiccVendorInfo(
val skuName: String?,
val serialNumber: String?,
val bootloaderVersion: String?,
val firmwareVersion: String?,
)
private val EUICC_VENDORS: Array<EuiccVendor> = arrayOf(EstkMe(), SimLink())
fun EuiccChannel.tryParseEuiccVendorInfo(): EuiccVendorInfo? {
EUICC_VENDORS.forEach { vendor ->
vendor.tryParseEuiccVendorInfo(this@tryParseEuiccVendorInfo)?.let {
return it
}
}
return null
}
interface EuiccVendor {
fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo?
}
private class EstkMe : EuiccVendor {
companion object {
private val PRODUCT_AID = "A06573746B6D65FFFFFFFFFFFF6D6774".decodeHex()
private val PRODUCT_ATR_FPR = "estk.me".encodeToByteArray()
}
private fun checkAtr(channel: EuiccChannel): Boolean {
val iface = channel.apduInterface
if (iface !is ApduInterfaceAtrProvider) return false
val atr = iface.atr ?: return false
for (index in atr.indices) {
if (atr.size - index < PRODUCT_ATR_FPR.size) break
if (atr.sliceArray(index until index + PRODUCT_ATR_FPR.size)
.contentEquals(PRODUCT_ATR_FPR)
) return true
}
return false
}
private fun decodeAsn1String(b: ByteArray): String? {
if (b.size < 2) return null
if (b[b.size - 2] != 0x90.toByte() || b[b.size - 1] != 0x00.toByte()) return null
return b.sliceArray(0 until b.size - 2).decodeToString()
}
override fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo? {
if (!checkAtr(channel)) return null
val iface = channel.apduInterface
return try {
iface.withLogicalChannel(PRODUCT_AID) { transmit ->
fun invoke(p1: Byte) =
decodeAsn1String(transmit(byteArrayOf(0x00, 0x00, p1, 0x00, 0x00)))
EuiccVendorInfo(
skuName = invoke(0x03),
serialNumber = invoke(0x00),
bootloaderVersion = invoke(0x01),
firmwareVersion = invoke(0x02),
)
}
} catch (e: Exception) {
Log.d(TAG, "Failed to get ESTKmeInfo", e)
null
}
}
}
private class SimLink : EuiccVendor {
companion object {
private val EID_PATTERN = Regex("^89044045(84|21)67274948")
}
override fun tryParseEuiccVendorInfo(channel: EuiccChannel): EuiccVendorInfo? {
val eid = channel.lpa.eID
val version = channel.lpa.euiccInfo2?.euiccFirmwareVersion
if (version == null || EID_PATTERN.find(eid, 0) == null) return null
val versionName = when {
// @formatter:off
version >= Version(37, 1, 41) -> "v3.1 (beta 1)"
version >= Version(36, 18, 5) -> "v3 (final)"
version >= Version(36, 17, 39) -> "v3 (beta)"
version >= Version(36, 17, 4) -> "v2s"
version >= Version(36, 9, 3) -> "v2.1"
version >= Version(36, 7, 2) -> "v2"
// @formatter:on
else -> null
}
val skuName = if (versionName == null) {
"9eSIM"
} else {
"9eSIM $versionName"
}
return EuiccVendorInfo(
skuName = skuName,
serialNumber = null,
bootloaderVersion = null,
firmwareVersion = null
)
}
}

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@android:integer/config_shortAnimTime"
android:interpolator="@android:anim/decelerate_interpolator"
android:fromXDelta="-100%"
android:toXDelta="0%" />

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@android:integer/config_shortAnimTime"
android:interpolator="@android:anim/decelerate_interpolator"
android:fromXDelta="100%"
android:toXDelta="0%" />

View file

@ -1,6 +0,0 @@
<!-- res/anim/slide_out.xml -->
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@android:integer/config_shortAnimTime"
android:interpolator="@android:anim/decelerate_interpolator"
android:fromXDelta="0%"
android:toXDelta="-100%" />

View file

@ -1,6 +0,0 @@
<!-- res/anim/slide_out.xml -->
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@android:integer/config_shortAnimTime"
android:interpolator="@android:anim/decelerate_interpolator"
android:fromXDelta="0%"
android:toXDelta="100%" />

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M15.41,7.41L14,6l-6,6 6,6 1.41,-1.41L10.83,12z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
</vector>

View file

@ -1,18 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="21"
android:viewportHeight="21">
<path
android:pathData="m3.578,6.487c1.385,-2.384 3.966,-3.987 6.922,-3.987 4.418,0 8,3.582 8,8s-3.582,8 -8,8 -8,-3.582 -8,-8"
android:strokeWidth="1"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:pathData="m7.5,6.5l-4,0l-0,-4"
android:strokeWidth="1"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

View file

@ -1,7 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M5,5h2v3h10V5h2v6h2V5c0,-1.1 -0.9,-2 -2,-2h-4.18C14.4,1.84 13.3,1 12,1S9.6,1.84 9.18,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h5v-2H5V5zM12,3c0.55,0 1,0.45 1,1s-0.45,1 -1,1s-1,-0.45 -1,-1S11.45,3 12,3z"/>
<path android:fillColor="@android:color/white" android:pathData="M18.01,13l-1.42,1.41l1.58,1.58l-6.17,0l0,2l6.17,0l-1.58,1.59l1.42,1.41l3.99,-4z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M18,2h-8L4,8v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4C20,2.9 19.1,2 18,2zM12,17l-4,-4h3V9.02L13,9v4h3L12,17z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>

View file

@ -1,74 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<FrameLayout
android:id="@+id/step_fragment_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/download_wizard_navigation"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<View
android:id="@+id/guideline"
android:layout_width="0dp"
android:layout_height="0dp"
android:orientation="vertical"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@id/download_wizard_navigation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ProgressBar
android:id="@+id/progress"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/guideline"
app:layout_constraintBottom_toTopOf="@id/download_wizard_navigation"
style="@style/Widget.AppCompat.ProgressBar.Horizontal" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/download_wizard_navigation"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="?attr/colorSurfaceContainer"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.button.MaterialButton
android:id="@+id/download_wizard_back"
android:text="@string/download_wizard_back"
android:background="?attr/selectableItemBackground"
android:textColor="?attr/colorPrimary"
android:layout_width="wrap_content"
android:layout_height="48dp"
app:icon="@drawable/ic_chevron_left"
app:iconGravity="start"
app:iconTint="?attr/colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/download_wizard_next"
android:text="@string/download_wizard_next"
android:background="?attr/selectableItemBackground"
android:textColor="?attr/colorPrimary"
android:layout_width="wrap_content"
android:layout_height="48dp"
app:icon="@drawable/ic_chevron_right"
app:iconGravity="end"
app:iconTint="?attr/colorPrimary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<include layout="@layout/toolbar_activity" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
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_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/toolbar_activity" />
<EditText
android:id="@+id/isdr_aid_list_editor"
android:layout_width="0dp"
android:layout_height="0dp"
android:fontFamily="monospace"
android:importantForAutofill="no"
android:inputType="textMultiLine"
android:gravity="top|start"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
tools:ignore="LabelFor" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -5,7 +5,13 @@
android:layout_height="match_parent" android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<include layout="@layout/toolbar_activity" /> <com.google.android.material.appbar.MaterialToolbar
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_constraintWidth_percent="1" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh" android:id="@+id/swipe_refresh"

View file

@ -5,7 +5,13 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<include layout="@layout/toolbar_activity" /> <com.google.android.material.appbar.MaterialToolbar
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_constraintWidth_percent="1" />
<com.google.android.material.tabs.TabLayout <com.google.android.material.tabs.TabLayout
android:id="@+id/main_tabs" android:id="@+id/main_tabs"

View file

@ -4,7 +4,13 @@
android:layout_height="match_parent" android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<include layout="@layout/toolbar_activity" /> <com.google.android.material.appbar.MaterialToolbar
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_constraintWidth_percent="1" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh" android:id="@+id/swipe_refresh"

View file

@ -4,7 +4,13 @@
android:layout_height="match_parent" android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<include layout="@layout/toolbar_activity" /> <com.google.android.material.appbar.MaterialToolbar
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_constraintWidth_percent="1" />
<FrameLayout <FrameLayout
android:id="@+id/settings_container" android:id="@+id/settings_container"

View file

@ -1,44 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:padding="20dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:id="@+id/download_method_icon"
android:layout_width="30dp"
android:layout_height="30dp"
app:tint="?attr/colorAccent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/download_method_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:textSize="15sp"
android:maxLines="1"
android:ellipsize="marquee"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/download_method_icon"
app:layout_constraintEnd_toStartOf="@id/download_method_chevron"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constrainedWidth="true" />
<ImageView
android:id="@+id/download_method_chevron"
android:src="@drawable/ic_chevron_right"
android:layout_width="30dp"
android:layout_height="30dp"
app:tint="?attr/colorAccent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,45 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
android:id="@+id/download_progress_item_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="20dp"
android:textSize="14sp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/download_progress_icon_container"
app:layout_constrainedWidth="true"
app:layout_constraintHorizontal_bias="0.0" />
<FrameLayout
android:id="@+id/download_progress_icon_container"
android:layout_margin="20dp"
android:layout_width="30dp"
android:layout_height="30dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<ProgressBar
android:id="@+id/download_progress_icon_progress"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:indeterminate="true"
android:visibility="gone" />
<ImageView
android:id="@+id/download_progress_icon"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
app:tint="?attr/colorPrimary" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,108 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
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:paddingStart="20sp"
android:paddingEnd="20sp"
android:background="?attr/selectableItemBackground">
<TextView
android:id="@+id/slot_item_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="10sp"
android:textSize="18sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/slot_item_type_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="100dp"
android:text="@string/download_wizard_slot_type"
android:textSize="14sp" />
<TextView
android:id="@+id/slot_item_type"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textSize="14sp" />
<TextView
android:id="@+id/slot_item_eid_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="100dp"
android:text="@string/download_wizard_slot_eid"
android:textSize="14sp" />
<TextView
android:id="@+id/slot_item_eid"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textSize="14sp" />
<TextView
android:id="@+id/slot_item_active_profile_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="100dp"
android:text="@string/download_wizard_slot_active_profile"
android:textSize="14sp" />
<TextView
android:id="@+id/slot_item_active_profile"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textSize="14sp" />
<TextView
android:id="@+id/slot_item_free_space_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="100dp"
android:text="@string/download_wizard_slot_free_space"
android:textSize="14sp" />
<TextView
android:id="@+id/slot_item_free_space"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textSize="14sp" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/flow1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10sp"
android:layout_marginTop="20sp"
android:layout_marginEnd="10sp"
app:constraint_referenced_ids="slot_item_type_label,slot_item_type,slot_item_eid_label,slot_item_eid,slot_item_active_profile_label,slot_item_active_profile,slot_item_free_space_label,slot_item_free_space"
app:flow_wrapMode="aligned"
app:flow_horizontalAlign="start"
app:flow_horizontalBias="1"
app:flow_horizontalGap="10sp"
app:flow_horizontalStyle="packed"
app:flow_maxElementsWrap="2"
app:flow_verticalBias="0"
app:flow_verticalGap="16sp"
app:flow_verticalStyle="packed"
app:layout_constraintEnd_toStartOf="@id/slot_checkbox"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/slot_item_title" />
<CheckBox
android:id="@+id/slot_checkbox"
android:layout_width="48dp"
android:layout_height="48dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/flow1"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
android:id="@+id/euicc_info_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginVertical="12dp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/euicc_info_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginVertical="12dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/euicc_info_title"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -28,8 +28,7 @@
app:layout_constraintRight_toLeftOf="@+id/profile_menu" app:layout_constraintRight_toLeftOf="@+id/profile_menu"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/state" app:layout_constraintBottom_toTopOf="@+id/state"
app:layout_constraintHorizontal_bias="0" app:layout_constraintHorizontal_bias="0" />
app:layout_constrainedWidth="true" />
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/profile_menu" android:id="@+id/profile_menu"
@ -63,45 +62,18 @@
android:singleLine="true" android:singleLine="true"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/state" app:layout_constraintTop_toBottomOf="@id/state"
app:layout_constraintBottom_toTopOf="@+id/profile_class_label"/> app:layout_constraintBottom_toTopOf="@+id/iccid_label"/>
<TextView <TextView
android:id="@+id/provider" android:id="@+id/provider"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:layout_marginStart="7dp" android:layout_marginLeft="7dp"
android:textSize="14sp" android:textSize="14sp"
android:singleLine="true" android:singleLine="true"
app:layout_constraintLeft_toRightOf="@id/provider_label" app:layout_constraintLeft_toRightOf="@id/provider_label"
app:layout_constraintTop_toBottomOf="@id/state" app:layout_constraintTop_toBottomOf="@id/state"
app:layout_constraintBottom_toTopOf="@+id/profile_class"/>
<TextView
android:id="@+id/profile_class_label"
android:text="@string/profile_class"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textSize="14sp"
android:textStyle="bold"
android:singleLine="true"
android:visibility="gone"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/provider_label"
app:layout_constraintBottom_toTopOf="@+id/iccid_label"/>
<TextView
android:id="@+id/profile_class"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginStart="7dp"
android:textSize="14sp"
android:singleLine="true"
android:visibility="gone"
app:layout_constraintLeft_toRightOf="@id/profile_class_label"
app:layout_constraintTop_toBottomOf="@id/provider"
app:layout_constraintBottom_toTopOf="@+id/iccid"/> app:layout_constraintBottom_toTopOf="@+id/iccid"/>
<TextView <TextView
@ -114,7 +86,7 @@
android:textStyle="bold" android:textStyle="bold"
android:singleLine="true" android:singleLine="true"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/profile_class_label" app:layout_constraintTop_toBottomOf="@id/provider_label"
app:layout_constraintBottom_toBottomOf="parent"/> app:layout_constraintBottom_toBottomOf="parent"/>
<TextView <TextView
@ -122,11 +94,11 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:layout_marginStart="7dp" android:layout_marginLeft="7dp"
android:textSize="14sp" android:textSize="14sp"
android:singleLine="true" android:singleLine="true"
app:layout_constraintLeft_toRightOf="@id/iccid_label" app:layout_constraintLeft_toRightOf="@id/iccid_label"
app:layout_constraintTop_toBottomOf="@id/profile_class" app:layout_constraintTop_toBottomOf="@id/provider"
app:layout_constraintBottom_toBottomOf="parent"/> app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,104 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/download_wizard_details_title"
android:text="@string/download_wizard_details"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:textSize="20sp"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp"
android:layout_marginStart="60dp"
android:layout_marginEnd="60dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constrainedWidth="true"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/profile_download_server"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/profile_download_server">
<com.google.android.material.textfield.TextInputEditText
android:maxLines="1"
android:inputType="text"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/profile_download_code"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/profile_download_code"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:maxLines="1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/profile_download_confirmation_code"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/profile_download_confirmation_code"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:maxLines="1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/profile_download_imei"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:layout_marginBottom="6dp"
android:hint="@string/profile_download_imei"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:maxLines="1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:inputType="numberPassword" />
</com.google.android.material.textfield.TextInputLayout>
<androidx.constraintlayout.helper.widget.Flow
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginHorizontal="20dp"
app:constraint_referenced_ids="profile_download_server,profile_download_code,profile_download_confirmation_code,profile_download_imei"
app:flow_verticalGap="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/download_wizard_details_title"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constrainedWidth="true" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -1,59 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:fillViewport="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/download_wizard_diagnostics_title"
android:text="@string/download_wizard_diagnostics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:textSize="20sp"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp"
android:layout_marginStart="60dp"
android:layout_marginEnd="60dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constrainedWidth="true"
app:layout_constraintTop_toTopOf="parent" />
<ImageButton
android:id="@+id/download_wizard_diagnostics_save"
android:src="@drawable/ic_save_as_black"
android:layout_margin="20dp"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/download_wizard_diagnostics_save"
app:tint="?attr/colorAccent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/download_wizard_diagnostics_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp"
android:textIsSelectable="true"
android:focusable="true"
android:textSize="10sp"
android:fontFamily="monospace"
android:lineSpacingMultiplier="1.1"
android:longClickable="true"
app:layout_constraintTop_toBottomOf="@id/download_wizard_diagnostics_title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
tools:ignore="SmallSp" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
android:id="@+id/download_method_select_title"
android:text="@string/download_wizard_method_select"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:textSize="20sp"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp"
android:layout_marginStart="60dp"
android:layout_marginEnd="60dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constrainedWidth="true"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/download_method_list"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/download_method_select_title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constrainedHeight="true" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
android:id="@+id/download_progress_title"
android:text="@string/download_wizard_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:textSize="20sp"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp"
android:layout_marginStart="60dp"
android:layout_marginEnd="60dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constrainedWidth="true"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/download_progress_list"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/download_progress_title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constrainedHeight="true" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
android:id="@+id/download_slot_select_title"
android:text="@string/download_wizard_slot_select"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:textSize="20sp"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp"
android:layout_marginStart="60dp"
android:layout_marginEnd="60dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constrainedWidth="true"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/download_slot_list"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/download_slot_select_title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constrainedHeight="true" />
</androidx.constraintlayout.widget.ConstraintLayout>

Some files were not shown because too many files have changed in this diff Show more