diff --git a/.forgejo/workflows/build-debug.yml b/.forgejo/workflows/build-debug.yml
index 4fc7a6a0..19a22ce2 100644
--- a/.forgejo/workflows/build-debug.yml
+++ b/.forgejo/workflows/build-debug.yml
@@ -38,11 +38,12 @@ jobs:
- name: Build Debug Bundle
run: ./gradlew --no-daemon :app-unpriv:bundleJmpDebug
+ - name: Copy Artifacts
+ run: find . -name 'app*-debug.apk' -exec cp {} . \;
+
- name: Upload Artifacts
uses: https://gitea.angry.im/actions/upload-artifact@v3
with:
name: Debug APKs
compression-level: 0
- path: |
- app-unpriv/build/outputs/apk/jmp/debug/app-unpriv-jmp-debug.apk
- app-unpriv/build/outputs/bundle/jmpDebug/app-unpriv-jmp-debug.aab
+ path: app*-debug.apk
diff --git a/.gitignore b/.gitignore
index 2b15a47e..1aa6f8a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,20 +1,11 @@
-*.iml
-.gradle
-/local.properties
-/keystore.properties
-/.idea/caches
-/.idea/libraries
-/.idea/modules.xml
-/.idea/workspace.xml
-/.idea/navEditor.xml
-/.idea/assetWizardSettings.xml
-/.idea/deploymentTargetDropDown.xml
-.DS_Store
-/build
+/.gradle
/captures
-.externalNativeBuild
-.cxx
-local.properties
-/libs/**/build
-/buildSrc/build
-/app-deps/libs
\ No newline at end of file
+
+# Configuration files
+
+/keystore.properties
+/local.properties
+
+# macOS
+
+.DS_Store
diff --git a/.idea/.gitignore b/.idea/.gitignore
index 26d33521..0d51aca1 100644
--- a/.idea/.gitignore
+++ b/.idea/.gitignore
@@ -1,3 +1,13 @@
-# Default ignored files
-/shelf/
+/shelf
+/caches
+/libraries
+/assetWizardSettings.xml
+/deploymentTargetDropDown.xml
+/gradle.xml
+/misc.xml
+/modules.xml
+/navEditor.xml
+/runConfigurations.xml
/workspace.xml
+
+**/*.iml
\ No newline at end of file
diff --git a/.idea/.name b/.idea/.name
new file mode 100644
index 00000000..7547d568
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+OpenEUICC
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index 61f4db45..1c5ab058 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -1,16 +1,6 @@
-
-
-
-
-
-
-
-
-
-
-
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index 589fc6ca..00000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index a1f9d9f9..00000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Android.mk b/Android.mk
deleted file mode 100644
index e69de29b..00000000
diff --git a/LAST_RELEASE_VERCODE b/LAST_RELEASE_VERCODE
index 209ac45b..26f42e64 100644
--- a/LAST_RELEASE_VERCODE
+++ b/LAST_RELEASE_VERCODE
@@ -1 +1 @@
-287
+294
diff --git a/app-common/src/main/AndroidManifest.xml b/app-common/src/main/AndroidManifest.xml
index aa301cda..f53e6ffc 100644
--- a/app-common/src/main/AndroidManifest.xml
+++ b/app-common/src/main/AndroidManifest.xml
@@ -20,14 +20,23 @@
android:label="@string/profile_notifications" />
+ android:name="im.angry.openeuicc.ui.EuiccInfoActivity"
+ android:label="@string/euicc_info" />
+
+
+
+
? {
+ private suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List? {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return usbChannel?.let { listOf(it) }
}
@@ -138,12 +121,7 @@ open class DefaultEuiccChannelManager(
return null
}
- override fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List? =
- runBlocking {
- findAllEuiccChannelsByPhysicalSlot(physicalSlotId)
- }
-
- override suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
+ private suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
withContext(Dispatchers.IO) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannel
@@ -154,26 +132,82 @@ open class DefaultEuiccChannelManager(
}
}
- override fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? =
- runBlocking {
- findEuiccChannelByPort(physicalSlotId, portId)
+ override suspend fun findFirstAvailablePort(physicalSlotId: Int): Int =
+ withContext(Dispatchers.IO) {
+ if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
+ return@withContext 0
+ }
+
+ findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.getOrNull(0)?.portId ?: -1
}
- override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) {
- if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) return
+ override suspend fun findAvailablePorts(physicalSlotId: Int): List =
+ withContext(Dispatchers.IO) {
+ if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
+ return@withContext listOf(0)
+ }
- // If there is already a valid channel, we close it proactively
- // Sometimes the current channel can linger on for a bit even after it should have become invalid
- channelCache.find { it.slotId == physicalSlotId && it.portId == portId }?.apply {
- if (valid) close()
+ findAllEuiccChannelsByPhysicalSlot(physicalSlotId)?.map { it.portId } ?: listOf()
+ }
+
+ override suspend fun 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 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) {
+ if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
+ usbChannel?.close()
+ usbChannel = null
+ } else {
+ // If there is already a valid channel, we close it proactively
+ // Sometimes the current channel can linger on for a bit even after it should have become invalid
+ channelCache.find { it.slotId == physicalSlotId && it.portId == portId }?.apply {
+ if (valid) close()
+ }
}
withTimeout(timeoutMillis) {
while (true) {
try {
- // tryOpenEuiccChannel() will automatically dispose of invalid channels
- // and recreate when needed
- val channel = findEuiccChannelByPort(physicalSlotId, portId)!!
+ 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
+ // and recreate when needed
+ findEuiccChannelByPort(physicalSlotId, portId)!!
+ }
check(channel.valid) { "Invalid channel" }
break
} catch (e: Exception) {
@@ -184,34 +218,42 @@ open class DefaultEuiccChannelManager(
}
}
- override suspend fun enumerateEuiccChannels(): List =
- withContext(Dispatchers.IO) {
- uiccCards.flatMap { info ->
- info.ports.mapNotNull { port ->
- tryOpenEuiccChannel(port)?.also {
- Log.d(
- TAG,
- "Found eUICC on slot ${info.physicalSlotIndex} port ${port.portIndex}"
- )
- }
+ override fun flowInternalEuiccPorts(): Flow> = flow {
+ uiccCards.forEach { info ->
+ info.ports.forEach { port ->
+ tryOpenEuiccChannel(port)?.also {
+ Log.d(
+ TAG,
+ "Found eUICC on slot ${info.physicalSlotIndex} port ${port.portIndex}"
+ )
+
+ emit(Pair(info.physicalSlotIndex, port.portIndex))
}
}
}
+ }.flowOn(Dispatchers.IO)
- override suspend fun enumerateUsbEuiccChannel(): Pair =
+ override fun flowAllOpenEuiccPorts(): Flow> =
+ merge(flowInternalEuiccPorts(), flow {
+ if (tryOpenUsbEuiccChannel().second) {
+ emit(Pair(EuiccChannelManager.USB_CHANNEL_ID, 0))
+ }
+ })
+
+ override suspend fun tryOpenUsbEuiccChannel(): Pair =
withContext(Dispatchers.IO) {
usbManager.deviceList.values.forEach { device ->
Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}")
val iface = device.getSmartCardInterface() ?: return@forEach
// If we don't have permission, tell UI code that we found a candidate device, but we
// need permission to be able to do anything with it
- if (!usbManager.hasPermission(device)) return@withContext Pair(device, null)
+ if (!usbManager.hasPermission(device)) return@withContext Pair(device, false)
Log.i(TAG, "Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel")
try {
val channel = euiccChannelFactory.tryOpenUsbEuiccChannel(device, iface)
if (channel != null && channel.lpa.valid) {
usbChannel = channel
- return@withContext Pair(device, channel)
+ return@withContext Pair(device, true)
}
} catch (e: Exception) {
// Ignored -- skip forward
@@ -219,7 +261,7 @@ open class DefaultEuiccChannelManager(
}
Log.i(TAG, "No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}")
}
- return@withContext Pair(null, null)
+ return@withContext Pair(null, false)
}
override fun invalidate() {
diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt
index e1065350..5f399ea3 100644
--- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannel.kt
@@ -1,26 +1,32 @@
package im.angry.openeuicc.core
import im.angry.openeuicc.util.*
-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 EuiccChannel(
- val port: UiccPortInfoCompat,
- apduInterface: ApduInterface,
- verboseLoggingFlow: Flow
-) {
- val slotId = port.card.physicalSlotIndex // PHYSICAL slot
- val logicalSlotId = port.logicalSlotIndex
- val portId = port.portIndex
+interface EuiccChannel {
+ val type: String
- val lpa: LocalProfileAssistant =
- LocalProfileAssistantImpl(apduInterface, HttpInterfaceImpl(verboseLoggingFlow))
+ val port: UiccPortInfoCompat
+
+ val slotId: Int // PHYSICAL slot
+ val logicalSlotId: Int
+ val portId: Int
+
+ val lpa: LocalProfileAssistant
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?
+
+ fun close()
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt
new file mode 100644
index 00000000..a82cb970
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelImpl.kt
@@ -0,0 +1,32 @@
+package im.angry.openeuicc.core
+
+import im.angry.openeuicc.util.*
+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?,
+ private val apduInterface: ApduInterface,
+ verboseLoggingFlow: Flow,
+ ignoreTLSCertificateFlow: Flow
+) : EuiccChannel {
+ override val slotId = port.card.physicalSlotIndex
+ override val logicalSlotId = port.logicalSlotIndex
+ override val portId = port.portIndex
+
+ override val lpa: LocalProfileAssistant =
+ LocalProfileAssistantImpl(apduInterface, HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow))
+
+ override val atr: ByteArray?
+ get() = (apduInterface as? ApduInterfaceAtrProvider)?.atr
+
+ override val valid: Boolean
+ get() = lpa.valid
+
+ override fun close() = lpa.close()
+}
diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelManager.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelManager.kt
index b21ccf61..17f3130c 100644
--- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelManager.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelManager.kt
@@ -1,6 +1,7 @@
package im.angry.openeuicc.core
import android.hardware.usb.UsbDevice
+import kotlinx.coroutines.flow.Flow
/**
* EuiccChannelManager holds references to, and manages the lifecycles of, individual
@@ -18,19 +19,35 @@ interface EuiccChannelManager {
}
/**
- * Scan all possible _device internal_ sources for EuiccChannels, return them and have all
- * scanned channels cached; these channels will remain open for the entire lifetime of
- * this EuiccChannelManager object, unless disconnected externally or invalidate()'d
+ * Scan all possible _device internal_ sources for EuiccChannels, as a flow, return their physical
+ * (slotId, portId) and have all scanned channels cached; these channels will remain open
+ * for the entire lifetime of this EuiccChannelManager object, unless disconnected externally
+ * or invalidate()'d.
+ *
+ * To obtain a temporary reference to a EuiccChannel, use `withEuiccChannel()`.
*/
- suspend fun enumerateEuiccChannels(): List
+ fun flowInternalEuiccPorts(): Flow>
+
+ /**
+ * 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>
/**
* 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
* as a "port" with id 99. When user interaction is required to obtain permission
- * to interact with the device, the second return value (EuiccChannel) will be null.
+ * to interact with the device, the second return value will be false.
+ *
+ * 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 enumerateUsbEuiccChannel(): Pair
+ suspend fun tryOpenUsbEuiccChannel(): Pair
/**
* Wait for a slot + port to reconnect (i.e. become valid again)
@@ -40,29 +57,40 @@ interface EuiccChannelManager {
suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long = 1000)
/**
- * Returns the EuiccChannel corresponding to a **logical** slot
+ * Returns the first mapped & available port ID for a physical slot, or -1 if
+ * not found.
*/
- fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel?
+ suspend fun findFirstAvailablePort(physicalSlotId: Int): Int
/**
- * 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.
+ * Returns all mapped & available port IDs for a physical slot.
*/
- fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel?
+ suspend fun findAvailablePorts(physicalSlotId: Int): List
+
+ class EuiccChannelNotFoundException: Exception("EuiccChannel not found")
/**
- * Returns all EuiccChannels corresponding to a **physical** slot
- * Multiple channels are possible in the case of MEP
+ * Find a EuiccChannel by its slot and port, then run a callback with a reference to it.
+ * The reference is not supposed to be held outside of the callback. This is enforced via
+ * 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 findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List?
- fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List?
+ suspend fun withEuiccChannel(
+ physicalSlotId: Int,
+ portId: Int,
+ fn: suspend (EuiccChannel) -> R
+ ): R
/**
- * Returns the EuiccChannel corresponding to a **physical** slot and a port ID
+ * Same as withEuiccChannel(Int, Int, (EuiccChannel) -> R) but instead uses logical slot ID
*/
- suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel?
- fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel?
+ suspend fun withEuiccChannel(
+ logicalSlotId: Int,
+ fn: suspend (EuiccChannel) -> R
+ ): R
/**
* Invalidate all EuiccChannels previously cached by this Manager
@@ -74,7 +102,7 @@ interface EuiccChannelManager {
* This is only expected to be implemented when the application is privileged
* TODO: Remove this from the common interface
*/
- fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
+ suspend fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
// no-op by default
}
}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelWrapper.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelWrapper.kt
new file mode 100644
index 00000000..4204e826
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelWrapper.kt
@@ -0,0 +1,48 @@
+package im.angry.openeuicc.core
+
+import im.angry.openeuicc.util.*
+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 atr: ByteArray?
+ get() = channel.atr
+
+ override fun close() = channel.close()
+
+ fun invalidateWrapper() {
+ _inner = null
+
+ if (lpaDelegate.isInitialized()) {
+ (lpa as LocalProfileAssistantWrapper).invalidateWrapper()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt b/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt
new file mode 100644
index 00000000..b715ca08
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/core/LocalProfileAssistantWrapper.kt
@@ -0,0 +1,66 @@
+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
+ get() = lpa.profiles
+ override val notifications: List
+ 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
+ }
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt b/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt
index b63f343d..c70669d4 100644
--- a/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/core/OmapiApduInterface.kt
@@ -15,7 +15,7 @@ class OmapiApduInterface(
private val service: SEService,
private val port: UiccPortInfoCompat,
private val verboseLoggingFlow: Flow
-): ApduInterface {
+): ApduInterface, ApduInterfaceAtrProvider {
companion object {
const val TAG = "OmapiApduInterface"
}
@@ -26,6 +26,9 @@ class OmapiApduInterface(
override val valid: Boolean
get() = service.isConnected && (this::session.isInitialized && !session.isClosed)
+ override val atr: ByteArray?
+ get() = session.atr
+
override fun connect() {
session = service.getUiccReaderCompat(port.logicalSlotIndex + 1).openSession()
}
@@ -38,8 +41,8 @@ class OmapiApduInterface(
check(!this::lastChannel.isInitialized) {
"Can only open one channel"
}
- lastChannel = session.openLogicalChannel(aid)!!;
- return 1;
+ lastChannel = session.openLogicalChannel(aid)!!
+ return 1
}
override fun logicalChannelClose(handle: Int) {
diff --git a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt
index 9894343d..624ef894 100644
--- a/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt
@@ -3,6 +3,7 @@ package im.angry.openeuicc.core.usb
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.util.Log
+import im.angry.openeuicc.core.ApduInterfaceAtrProvider
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.Flow
import net.typeblog.lpac_jni.ApduInterface
@@ -12,7 +13,7 @@ class UsbApduInterface(
private val bulkIn: UsbEndpoint,
private val bulkOut: UsbEndpoint,
private val verboseLoggingFlow: Flow
-): ApduInterface {
+) : ApduInterface, ApduInterfaceAtrProvider {
companion object {
private const val TAG = "UsbApduInterface"
}
@@ -22,6 +23,8 @@ class UsbApduInterface(
private var channelId = -1
+ override var atr: ByteArray? = null
+
override fun connect() {
ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!!
@@ -32,7 +35,9 @@ class UsbApduInterface(
transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription, verboseLoggingFlow)
try {
- transceiver.iccPowerOn()
+ // 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
diff --git a/app-common/src/main/java/im/angry/openeuicc/di/AppContainer.kt b/app-common/src/main/java/im/angry/openeuicc/di/AppContainer.kt
index 4b3c3cda..cae7e2eb 100644
--- a/app-common/src/main/java/im/angry/openeuicc/di/AppContainer.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/di/AppContainer.kt
@@ -15,4 +15,5 @@ interface AppContainer {
val preferenceRepository: PreferenceRepository
val uiComponentFactory: UiComponentFactory
val euiccChannelFactory: EuiccChannelFactory
+ val customizableTextProvider: CustomizableTextProvider
}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/di/CustomizableTextProvider.kt b/app-common/src/main/java/im/angry/openeuicc/di/CustomizableTextProvider.kt
new file mode 100644
index 00000000..2c86273a
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/di/CustomizableTextProvider.kt
@@ -0,0 +1,20 @@
+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
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/di/DefaultAppContainer.kt b/app-common/src/main/java/im/angry/openeuicc/di/DefaultAppContainer.kt
index 93fd8b85..9b70099c 100644
--- a/app-common/src/main/java/im/angry/openeuicc/di/DefaultAppContainer.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/di/DefaultAppContainer.kt
@@ -38,4 +38,8 @@ open class DefaultAppContainer(context: Context) : AppContainer {
override val euiccChannelFactory by lazy {
DefaultEuiccChannelFactory(context)
}
+
+ override val customizableTextProvider by lazy {
+ DefaultCustomizableTextProvider(context)
+ }
}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/di/DefaultCustomizableTextProvider.kt b/app-common/src/main/java/im/angry/openeuicc/di/DefaultCustomizableTextProvider.kt
new file mode 100644
index 00000000..b4936112
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/di/DefaultCustomizableTextProvider.kt
@@ -0,0 +1,15 @@
+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)
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/di/DefaultUiComponentFactory.kt b/app-common/src/main/java/im/angry/openeuicc/di/DefaultUiComponentFactory.kt
index 32550d63..52a501a9 100644
--- a/app-common/src/main/java/im/angry/openeuicc/di/DefaultUiComponentFactory.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/di/DefaultUiComponentFactory.kt
@@ -1,13 +1,16 @@
package im.angry.openeuicc.di
import androidx.fragment.app.Fragment
-import im.angry.openeuicc.core.EuiccChannel
+import androidx.preference.PreferenceFragmentCompat
import im.angry.openeuicc.ui.EuiccManagementFragment
import im.angry.openeuicc.ui.NoEuiccPlaceholderFragment
+import im.angry.openeuicc.ui.SettingsFragment
open class DefaultUiComponentFactory : UiComponentFactory {
- override fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment =
- EuiccManagementFragment.newInstance(channel.slotId, channel.portId)
+ override fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment =
+ EuiccManagementFragment.newInstance(slotId, portId)
override fun createNoEuiccPlaceholderFragment(): Fragment = NoEuiccPlaceholderFragment()
+
+ override fun createSettingsFragment(): Fragment = SettingsFragment()
}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/di/UiComponentFactory.kt b/app-common/src/main/java/im/angry/openeuicc/di/UiComponentFactory.kt
index 4e09a709..2c3c72b2 100644
--- a/app-common/src/main/java/im/angry/openeuicc/di/UiComponentFactory.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/di/UiComponentFactory.kt
@@ -1,10 +1,11 @@
package im.angry.openeuicc.di
import androidx.fragment.app.Fragment
-import im.angry.openeuicc.core.EuiccChannel
+import androidx.preference.PreferenceFragmentCompat
import im.angry.openeuicc.ui.EuiccManagementFragment
interface UiComponentFactory {
- fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment
+ fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment
fun createNoEuiccPlaceholderFragment(): Fragment
+ fun createSettingsFragment(): Fragment
}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt
index 8db3bbe5..760f1af8 100644
--- a/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/service/EuiccChannelManagerService.kt
@@ -15,14 +15,19 @@ import im.angry.openeuicc.core.EuiccChannelManager
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.onStart
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.isActive
@@ -55,7 +60,26 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
private const val TAG = "EuiccChannelManagerService"
private const val CHANNEL_ID = "tasks"
private const val FOREGROUND_ID = 1000
- private const val TASK_FAILURE_ID = 1001
+ 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.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.applyCompletionTransform() =
+ transformWhile {
+ emit(it)
+ it !is ForegroundTaskState.Done
+ }
}
inner class LocalBinder : Binder() {
@@ -89,6 +113,25 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
private val foregroundTaskState: MutableStateFlow =
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) :
+ Flow 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> =
+ mutableMapOf()
+
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return LocalBinder()
@@ -166,12 +209,26 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
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 MUST be started in order for the
- * foreground task to run.
+ * 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.
@@ -185,7 +242,9 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
failureTitle: String,
iconRes: Int,
task: suspend EuiccChannelManagerService.() -> Unit
- ): Flow? {
+ ): 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(
@@ -193,7 +252,9 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
ForegroundTaskState.InProgress(0)
)
) {
- return null
+ return ForegroundTaskSubscriberFlow(
+ taskID,
+ flow { emit(ForegroundTaskState.Done(IllegalStateException("There are tasks currently running"))) })
}
lifecycleScope.launch(Dispatchers.Main) {
@@ -235,38 +296,71 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
}
}
+ // 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(
+ 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.
- return foregroundTaskState.transformWhile {
- // 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) {
- withContext(Dispatchers.Main) {
- updateForegroundNotification(title, iconRes)
- }
- }
- emit(it)
- it !is ForegroundTaskState.Done
- }.onStart {
- // When this Flow is started, we unblock the coroutine launched above by
- // self-starting as a foreground service.
- withContext(Dispatchers.Main) {
- startForegroundService(
- Intent(
- this@EuiccChannelManagerService,
- this@EuiccChannelManagerService::class.java
- )
- )
- }
- }.onCompletion { foregroundTaskState.value = ForegroundTaskState.Idle }
- }
+ 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)
+ }
- val isForegroundTaskRunning: Boolean
- get() = foregroundTaskState.value != ForegroundTaskState.Idle
+ 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 }
@@ -280,30 +374,26 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
matchingId: String?,
confirmationCode: String?,
imei: String?
- ): Flow? =
+ ): 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) {
- val channel = euiccChannelManager.findEuiccChannelByPort(slotId, portId)
- val res = 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)
- }
- })
-
- if (!res) {
- // TODO: Provide more details on the error
- throw RuntimeException("Failed to download profile; this is typically caused by another error happened before.")
+ 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()
@@ -315,19 +405,17 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
portId: Int,
iccid: String,
name: String
- ): Flow? =
+ ): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_rename),
getString(R.string.task_profile_rename_failure),
R.drawable.ic_task_rename
) {
- val res = euiccChannelManager.findEuiccChannelByPort(slotId, portId)!!.lpa.setNickname(
- iccid,
- name
- )
-
- if (!res) {
- throw RuntimeException("Profile not renamed")
+ euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
+ channel.lpa.setNickname(
+ iccid,
+ name
+ )
}
}
@@ -335,17 +423,16 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
slotId: Int,
portId: Int,
iccid: String
- ): Flow? =
+ ): 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.findEuiccChannelByPort(
- slotId,
- portId
- )!!.lpa.deleteProfile(iccid)
+ euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
+ channel.lpa.deleteProfile(iccid)
+ }
preferenceRepository.notificationDeleteFlow.first()
}
@@ -358,16 +445,18 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
portId: Int,
iccid: String,
enable: Boolean, // Enable or disable the profile indicated in iccid
- reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect, useful for USB readers
- ): Flow? =
+ reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect
+ ): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_switch),
getString(R.string.task_profile_switch_failure),
R.drawable.ic_task_switch
) {
euiccChannelManager.beginTrackedOperation(slotId, portId) {
- val channel = euiccChannelManager.findEuiccChannelByPort(slotId, portId)!!
- val (res, refreshed) =
+ val (res, refreshed) = euiccChannelManager.withEuiccChannel(
+ slotId,
+ portId
+ ) { channel ->
if (!channel.lpa.switchProfile(iccid, enable, refresh = true)) {
// Sometimes, we *can* enable or disable the profile, but we cannot
// send the refresh command to the modem because the profile somehow
@@ -378,13 +467,15 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
} else {
Pair(true, true)
}
+ }
if (!res) {
throw RuntimeException("Could not switch profile")
}
- if (!refreshed) {
+ 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()
}
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/DirectProfileDownloadActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/DirectProfileDownloadActivity.kt
deleted file mode 100644
index 9e79de6a..00000000
--- a/app-common/src/main/java/im/angry/openeuicc/ui/DirectProfileDownloadActivity.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-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()
-}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt
new file mode 100644
index 00000000..e88ad012
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccInfoActivity.kt
@@ -0,0 +1,204 @@
+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
+
+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(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
+ )
+ )
+ channel.lpa.euiccInfo2.let { info ->
+ add(Item(R.string.euicc_info_sgp22_version, info?.sgp22Version))
+ add(Item(R.string.euicc_info_firmware_version, info?.euiccFirmwareVersion))
+ add(Item(R.string.euicc_info_globalplatform_version, info?.globalPlatformVersion))
+ add(Item(R.string.euicc_info_pp_version, info?.ppVersion))
+ add(Item(R.string.euicc_info_sas_accreditation_number, info?.sasAccreditationNumber))
+ 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)))
+ }
+ add(
+ Item(
+ R.string.euicc_info_atr,
+ channel.atr?.encodeHex() ?: getString(R.string.information_unavailable),
+ copiedToastResId = R.string.toast_atr_copied,
+ )
+ )
+ }
+
+ private fun formatByBoolean(b: Boolean, res: Pair): 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() {
+ var euiccInfoItems: List- = 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])
+ }
+ }
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt
index da291230..842f4ec7 100644
--- a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt
@@ -4,9 +4,9 @@ import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Intent
+import android.os.Build
import android.os.Bundle
import android.text.method.PasswordTransformationMethod
-import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
@@ -21,6 +21,7 @@ import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
@@ -31,11 +32,12 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton
import net.typeblog.lpac_jni.LocalProfileInfo
import im.angry.openeuicc.common.R
import im.angry.openeuicc.service.EuiccChannelManagerService
+import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
+import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -52,6 +54,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var fab: FloatingActionButton
private lateinit var profileList: RecyclerView
+ private var logicalSlotId: Int = -1
private val adapter = EuiccProfileAdapter()
@@ -63,6 +66,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
// This gives us access to the "latest" state without having to launch coroutines
private lateinit var disableSafeguardFlow: StateFlow
+ private lateinit var unfilteredProfileListFlow: StateFlow
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
@@ -105,8 +110,10 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
fab.setOnClickListener {
- ProfileDownloadFragment.newInstance(slotId, portId)
- .show(childFragmentManager, ProfileDownloadFragment.TAG)
+ Intent(requireContext(), DownloadWizardActivity::class.java).apply {
+ putExtra("selectedLogicalSlot", logicalSlotId)
+ startActivity(this)
+ }
}
}
@@ -127,9 +134,21 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
R.id.show_notifications -> {
- Intent(requireContext(), NotificationsActivity::class.java).apply {
- putExtra("logicalSlotId", channel.logicalSlotId)
- startActivity(this)
+ if (logicalSlotId != -1) {
+ Intent(requireContext(), NotificationsActivity::class.java).apply {
+ putExtra("logicalSlotId", logicalSlotId)
+ startActivity(this)
+ }
+ }
+ true
+ }
+
+ R.id.euicc_info -> {
+ if (logicalSlotId != -1) {
+ Intent(requireContext(), EuiccInfoActivity::class.java).apply {
+ putExtra("logicalSlotId", logicalSlotId)
+ startActivity(this)
+ }
}
true
}
@@ -148,31 +167,43 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
listOf()
}
- @SuppressLint("NotifyDataSetChanged")
private fun refresh() {
if (invalid) return
swipeRefresh.isRefreshing = true
lifecycleScope.launch {
- ensureEuiccChannelManager()
- euiccChannelManagerService.waitForForegroundTask()
+ doRefresh()
+ }
+ }
- if (!this@EuiccManagementFragment::disableSafeguardFlow.isInitialized) {
- disableSafeguardFlow =
- preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope)
- }
+ @SuppressLint("NotifyDataSetChanged")
+ protected open suspend fun doRefresh() {
+ ensureEuiccChannelManager()
+ euiccChannelManagerService.waitForForegroundTask()
- val profiles = withContext(Dispatchers.IO) {
- euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
+ if (!::disableSafeguardFlow.isInitialized) {
+ disableSafeguardFlow =
+ preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope)
+ }
+ if (!::unfilteredProfileListFlow.isInitialized) {
+ unfilteredProfileListFlow =
+ preferenceRepository.unfilteredProfileListFlow.stateIn(lifecycleScope)
+ }
+
+ val profiles = withEuiccChannel { channel ->
+ logicalSlotId = channel.logicalSlotId
+ euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
+ if (unfilteredProfileListFlow.value)
+ channel.lpa.profiles
+ else
channel.lpa.profiles.operational
- }
+ }
- withContext(Dispatchers.Main) {
- adapter.profiles = profiles
- adapter.footerViews = onCreateFooterViews(profileList, profiles)
- adapter.notifyDataSetChanged()
- swipeRefresh.isRefreshing = false
- }
+ withContext(Dispatchers.Main) {
+ adapter.profiles = profiles
+ adapter.footerViews = onCreateFooterViews(profileList, profiles)
+ adapter.notifyDataSetChanged()
+ swipeRefresh.isRefreshing = false
}
}
@@ -192,24 +223,15 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
- val res = euiccChannelManagerService.launchProfileSwitchTask(
+ val err = euiccChannelManagerService.launchProfileSwitchTask(
slotId,
portId,
iccid,
enable,
- reconnectTimeoutMillis = if (isUsb) {
- 0
- } else {
- 30 * 1000
- }
- )?.last() as? EuiccChannelManagerService.ForegroundTaskState.Done
+ reconnectTimeoutMillis = 30 * 1000
+ ).waitDone()
- if (res == null) {
- showSwitchFailureText()
- return@launch
- }
-
- when (res.error) {
+ when (err) {
null -> {}
is EuiccChannelManagerService.SwitchingProfilesRefreshException -> {
// This is only really fatal for internal eSIMs
@@ -236,7 +258,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
invalid = true
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
AlertDialog.Builder(requireContext()).apply {
- setMessage(R.string.enable_disable_timeout)
+ setMessage(appContainer.customizableTextProvider.profileSwitchingTimeoutMessage)
setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
@@ -279,7 +301,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
companion object {
fun fromInt(value: Int) =
- Type.values().first { it.value == value }
+ entries.first { it.value == value }
}
}
}
@@ -307,6 +329,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
private val name: TextView = root.requireViewById(R.id.name)
private val state: TextView = root.requireViewById(R.id.state)
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)
init {
@@ -321,7 +345,8 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
iccid.setOnLongClickListener {
requireContext().getSystemService(ClipboardManager::class.java)!!
.setPrimaryClip(ClipData.newPlainText("iccid", iccid.text))
- Toast.makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast
+ .makeText(requireContext(), R.string.toast_iccid_copied, Toast.LENGTH_SHORT)
.show()
true
}
@@ -343,6 +368,15 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
}
)
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.transformationMethod = PasswordTransformationMethod.getInstance()
}
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/LogsActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/LogsActivity.kt
index 49bfa0f3..599e9d3f 100644
--- a/app-common/src/main/java/im/angry/openeuicc/ui/LogsActivity.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/LogsActivity.kt
@@ -1,6 +1,7 @@
package im.angry.openeuicc.ui
import android.icu.text.SimpleDateFormat
+import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
@@ -8,7 +9,6 @@ import android.view.View
import android.widget.ScrollView
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
-import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -17,7 +17,6 @@ import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import java.io.FileOutputStream
import java.util.Date
class LogsActivity : AppCompatActivity() {
@@ -27,15 +26,25 @@ class LogsActivity : AppCompatActivity() {
private lateinit var logStr: String
private val saveLogs =
- registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
- if (uri == null) return@registerForActivityResult
- if (!this::logStr.isInitialized) return@registerForActivityResult
- contentResolver.openFileDescriptor(uri, "w")?.use {
- FileOutputStream(it.fileDescriptor).use { os ->
- os.write(logStr.encodeToByteArray())
- }
- }
- }
+ setupLogSaving(
+ getLogFileName = {
+ getString(
+ R.string.logs_filename_template,
+ SimpleDateFormat.getDateTimeInstance().format(Date())
+ )
+ },
+ 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?) {
enableEdgeToEdge()
@@ -76,9 +85,7 @@ class LogsActivity : AppCompatActivity() {
true
}
R.id.save -> {
- saveLogs.launch(getString(R.string.logs_filename_template,
- SimpleDateFormat.getDateTimeInstance().format(Date())
- ))
+ saveLogs()
true
}
else -> super.onOptionsItemSelected(item)
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/MainActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/MainActivity.kt
index e432f6ce..01d0ab2b 100644
--- a/app-common/src/main/java/im/angry/openeuicc/ui/MainActivity.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/MainActivity.kt
@@ -23,9 +23,12 @@ import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import im.angry.openeuicc.common.R
+import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
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.withContext
@@ -44,6 +47,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private var refreshing = false
private data class Page(
+ val logicalSlotId: Int,
val title: String,
val createFragment: () -> Fragment
)
@@ -105,7 +109,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
R.id.settings -> {
- startActivity(Intent(this, SettingsActivity::class.java));
+ startActivity(Intent(this, SettingsActivity::class.java))
true
}
R.id.reload -> {
@@ -122,7 +126,10 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
}
private fun ensureNotificationPermissions() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ 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
@@ -138,65 +145,75 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
// Prevent concurrent access with any running foreground task
euiccChannelManagerService.waitForForegroundTask()
- val knownChannels = withContext(Dispatchers.IO) {
- euiccChannelManager.enumerateEuiccChannels().onEach {
- Log.d(TAG, "slot ${it.slotId} port ${it.portId}")
+ val (usbDevice, _) = withContext(Dispatchers.IO) {
+ euiccChannelManager.tryOpenUsbEuiccChannel()
+ }
+
+ val newPages: MutableList = 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, it.lpa.eID)
+ Log.d(TAG, channel.lpa.eID)
}
// Request the system to refresh the list of profiles every time we start
// Note that this is currently supposed to be no-op when unprivileged,
// but it could change in the future
- euiccChannelManager.notifyEuiccProfilesChanged(it.logicalSlotId)
+ euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
+
+ val channelName =
+ appContainer.customizableTextProvider.formatInternalChannelName(channel.logicalSlotId)
+ newPages.add(Page(channel.logicalSlotId, channelName) {
+ appContainer.uiComponentFactory.createEuiccManagementFragment(slotId, portId)
+ })
}
+ }.collect()
+
+ // If USB readers exist, add them at the very last
+ // We use a wrapper fragment to handle logic specific to USB readers
+ usbDevice?.let {
+ val productName = it.productName ?: getString(R.string.usb)
+ newPages.add(Page(EuiccChannelManager.USB_CHANNEL_ID, productName) {
+ UsbCcidReaderFragment()
+ })
+ }
+ viewPager.visibility = View.VISIBLE
+
+ if (newPages.size > 1) {
+ tabs.visibility = View.VISIBLE
+ } else if (newPages.isEmpty()) {
+ newPages.add(Page(-1, "") {
+ appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment()
+ })
}
- val (usbDevice, _) = withContext(Dispatchers.IO) {
- euiccChannelManager.enumerateUsbEuiccChannel()
+ newPages.sortBy { it.logicalSlotId }
+
+ pages.clear()
+ pages.addAll(newPages)
+
+ loadingProgress.visibility = View.GONE
+ pagerAdapter.notifyDataSetChanged()
+ // Reset the adapter so that the current view actually gets cleared
+ // notifyDataSetChanged() doesn't cause the current view to be removed.
+ viewPager.adapter = pagerAdapter
+
+ if (fromUsbEvent && usbDevice != null) {
+ // If this refresh was triggered by a USB insertion while active, scroll to that page
+ viewPager.post {
+ viewPager.setCurrentItem(pages.size - 1, true)
+ }
+ } else {
+ viewPager.currentItem = 0
}
- 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) })
- }
-
- // If USB readers exist, add them at the very last
- // We use a wrapper fragment to handle logic specific to USB readers
- usbDevice?.let {
- pages.add(Page(it.productName ?: getString(R.string.usb)) { UsbCcidReaderFragment() })
- }
- viewPager.visibility = View.VISIBLE
-
- if (pages.size > 1) {
- tabs.visibility = View.VISIBLE
- } else if (pages.isEmpty()) {
- pages.add(Page("") { appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment() })
- }
-
- pagerAdapter.notifyDataSetChanged()
- // Reset the adapter so that the current view actually gets cleared
- // notifyDataSetChanged() doesn't cause the current view to be removed.
- viewPager.adapter = pagerAdapter
-
- if (fromUsbEvent && usbDevice != null) {
- // If this refresh was triggered by a USB insertion while active, scroll to that page
- viewPager.post {
- viewPager.setCurrentItem(pages.size - 1, true)
- }
- } else {
- viewPager.currentItem = 0
- }
-
- if (pages.size > 0) {
- ensureNotificationPermissions()
- }
-
- refreshing = false
+ if (pages.size > 0) {
+ ensureNotificationPermissions()
}
+
+ refreshing = false
}
private fun refresh(fromUsbEvent: Boolean = false) {
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/NoEuiccPlaceholderFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/NoEuiccPlaceholderFragment.kt
index e9e44b11..7e96af3a 100644
--- a/app-common/src/main/java/im/angry/openeuicc/ui/NoEuiccPlaceholderFragment.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/NoEuiccPlaceholderFragment.kt
@@ -4,15 +4,20 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.TextView
import androidx.fragment.app.Fragment
import im.angry.openeuicc.common.R
+import im.angry.openeuicc.util.*
-class NoEuiccPlaceholderFragment : Fragment() {
+class NoEuiccPlaceholderFragment : Fragment(), OpenEuiccContextMarker {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
- return inflater.inflate(R.layout.fragment_no_euicc_placeholder, container, false)
+ val view = inflater.inflate(R.layout.fragment_no_euicc_placeholder, container, false)
+ val textView = view.requireViewById(R.id.no_euicc_placeholder)
+ textView.text = appContainer.customizableTextProvider.noEuiccExplanation
+ return view
}
}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/NotificationsActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/NotificationsActivity.kt
index 884e2237..21a2d405 100644
--- a/app-common/src/main/java/im/angry/openeuicc/ui/NotificationsActivity.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/NotificationsActivity.kt
@@ -20,7 +20,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import im.angry.openeuicc.common.R
-import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
@@ -33,7 +32,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private lateinit var notificationList: RecyclerView
private val notificationAdapter = NotificationAdapter()
- private lateinit var euiccChannel: EuiccChannel
+ private var logicalSlotId = -1
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
@@ -56,14 +55,14 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
notificationList.adapter = notificationAdapter
registerForContextMenu(notificationList)
- val logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
+ logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
// This is slightly different from the MainActivity logic
// due to the length (we don't want to display the full USB product name)
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.usb)
} else {
- getString(R.string.channel_name_format, logicalSlotId)
+ appContainer.customizableTextProvider.formatInternalChannelName(logicalSlotId)
}
title = getString(R.string.profile_notifications_detailed_format, channelTitle)
@@ -104,16 +103,8 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
swipeRefresh.isRefreshing = true
lifecycleScope.launch {
- if (!this@NotificationsActivity::euiccChannel.isInitialized) {
- withContext(Dispatchers.IO) {
- euiccChannelManagerLoaded.await()
- euiccChannel = euiccChannelManager.findEuiccChannelBySlotBlocking(
- intent.getIntExtra(
- "logicalSlotId",
- 0
- )
- )!!
- }
+ withContext(Dispatchers.IO) {
+ euiccChannelManagerLoaded.await()
}
task()
@@ -124,15 +115,16 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private fun refresh() {
launchTask {
- val profiles = withContext(Dispatchers.IO) {
- euiccChannel.lpa.profiles
- }
-
notificationAdapter.notifications =
- withContext(Dispatchers.IO) {
- euiccChannel.lpa.notifications.map {
- val profile = profiles.find { p -> p.iccid == it.iccid }
- LocalProfileNotificationWrapper(it, profile?.displayName ?: "???")
+ euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
+ val nameMap = buildMap {
+ for (profile in channel.lpa.profiles) {
+ put(profile.iccid, profile.displayName)
+ }
+ }
+
+ channel.lpa.notifications.map {
+ LocalProfileNotificationWrapper(it, nameMap[it.iccid] ?: "???")
}
}
}
@@ -147,6 +139,8 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
inner class NotificationViewHolder(private val root: View):
RecyclerView.ViewHolder(root), View.OnCreateContextMenuListener, OnMenuItemClickListener {
private val address: TextView = root.requireViewById(R.id.notification_address)
+ private val sequenceNumber: TextView =
+ root.requireViewById(R.id.notification_sequence_number)
private val profileName: TextView = root.requireViewById(R.id.notification_profile_name)
private lateinit var notification: LocalProfileNotificationWrapper
@@ -168,6 +162,7 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
}
}
+
private fun operationToLocalizedText(operation: LocalProfileNotification.Operation) =
root.context.getText(
when (operation) {
@@ -181,6 +176,10 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
notification = value
address.text = value.inner.notificationAddress
+ sequenceNumber.text = root.context.getString(
+ R.string.profile_notification_sequence_number_format,
+ value.inner.seqNumber
+ )
profileName.text = Html.fromHtml(
root.context.getString(R.string.profile_notification_name_format,
operationToLocalizedText(value.inner.profileManagementOperation),
@@ -205,7 +204,9 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
R.id.notification_process -> {
launchTask {
withContext(Dispatchers.IO) {
- euiccChannel.lpa.handleNotification(notification.inner.seqNumber)
+ euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
+ channel.lpa.handleNotification(notification.inner.seqNumber)
+ }
}
refresh()
@@ -215,7 +216,9 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
R.id.notification_delete -> {
launchTask {
withContext(Dispatchers.IO) {
- euiccChannel.lpa.deleteNotification(notification.inner.seqNumber)
+ euiccChannelManager.withEuiccChannel(logicalSlotId) { channel ->
+ channel.lpa.deleteNotification(notification.inner.seqNumber)
+ }
}
refresh()
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt
index 901f263a..7f82f22a 100644
--- a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt
@@ -4,54 +4,69 @@ import android.app.Dialog
import android.os.Bundle
import android.text.Editable
import android.widget.EditText
+import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
+import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.*
-import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
companion object {
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): ProfileDeleteFragment {
val instance = newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId)
instance.requireArguments().apply {
- putString("iccid", iccid)
- putString("name", name)
+ putString(FIELD_ICCID, iccid)
+ putString(FIELD_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 {
EditText(requireContext()).apply {
- hint = Editable.Factory.getInstance().newEditable(
- getString(R.string.profile_delete_confirm_input, requireArguments().getString("name")!!)
- )
+ hint = Editable.Factory.getInstance()
+ .newEditable(getString(R.string.profile_delete_confirm_input, name))
}
}
+
private val inputMatchesName: Boolean
- get() = editText.text.toString() == requireArguments().getString("name")!!
+ get() = editText.text.toString() == name
+
+ private var toast: Toast? = null
+
private var deleting = false
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- return AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme).apply {
- setMessage(getString(R.string.profile_delete_confirm, requireArguments().getString("name")))
+ private val alertDialog: AlertDialog
+ get() = requireDialog() as AlertDialog
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
+ AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme).apply {
+ setMessage(getString(R.string.profile_delete_confirm, name))
setView(editText)
setPositiveButton(android.R.string.ok, null) // Set listener to null to prevent auto closing
setNegativeButton(android.R.string.cancel, null)
}.create()
- }
override fun onResume() {
super.onResume()
- val alertDialog = dialog!! as AlertDialog
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
- if (!deleting && inputMatchesName) delete()
+ if (!deleting) delete()
}
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
if (!deleting) dismiss()
@@ -59,8 +74,15 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
}
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
- val alertDialog = dialog!! as AlertDialog
alertDialog.setCanceledOnTouchOutside(false)
alertDialog.setCancelable(false)
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
@@ -69,12 +91,7 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
requireParentFragment().lifecycleScope.launch {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
-
- euiccChannelManagerService.launchProfileDeleteTask(
- slotId,
- portId,
- requireArguments().getString("iccid")!!
- )!!.onStart {
+ euiccChannelManagerService.launchProfileDeleteTask(slotId, portId, iccid).onStart {
if (parentFragment is EuiccProfilesChangedListener) {
// Trigger a refresh in the parent fragment -- it should wait until
// any foreground task is completed before actually doing a refresh
@@ -86,7 +103,7 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
} catch (e: IllegalStateException) {
// Ignored
}
- }.collect()
+ }.waitDone()
}
}
}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDownloadFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDownloadFragment.kt
deleted file mode 100644
index 843c9e5f..00000000
--- a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDownloadFragment.kt
+++ /dev/null
@@ -1,282 +0,0 @@
-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.service.EuiccChannelManagerService
-import im.angry.openeuicc.util.*
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.last
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-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 ->
- 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()
-
- lifecycleScope.launch(Dispatchers.IO) {
- ensureEuiccChannelManager()
- if (euiccChannelManagerService.isForegroundTaskRunning) {
- withContext(Dispatchers.Main) {
- dismiss()
- }
- return@launch
- }
-
- val imei = try {
- telephonyManager.getImei(channel.logicalSlotId) ?: ""
- } catch (e: Exception) {
- ""
- }
-
- // 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))
- profileDownloadIMEI.editText!!.text =
- Editable.Factory.getInstance().newEditable(imei)
- }
- }
- }
-
- 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 {
- ensureEuiccChannelManager()
- euiccChannelManagerService.waitForForegroundTask()
- val res = doDownloadProfile(server, code, confirmationCode, imei)
-
- if (res == null || res.error != null) {
- Log.d(TAG, "Error downloading profile")
-
- if (res?.error != null) {
- Log.d(TAG, Log.getStackTraceString(res.error))
- }
-
- Toast.makeText(requireContext(), R.string.profile_download_failed, Toast.LENGTH_LONG).show()
- }
-
- if (parentFragment is EuiccProfilesChangedListener) {
- (parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
- }
-
- try {
- dismiss()
- } catch (e: IllegalStateException) {
- // Ignored
- }
- }
- }
-
- private suspend fun doDownloadProfile(
- server: String,
- code: String?,
- confirmationCode: String?,
- imei: String?
- ) = withContext(Dispatchers.Main) {
- // The service is responsible for launching the actual blocking part on the IO context
- val res = euiccChannelManagerService.launchProfileDownloadTask(
- slotId,
- portId,
- server,
- code,
- confirmationCode,
- imei
- )!!.onEach {
- if (it is EuiccChannelManagerService.ForegroundTaskState.InProgress) {
- progress.progress = it.progress
- progress.isIndeterminate = it.progress == 0
- } else {
- progress.progress = 100
- progress.isIndeterminate = false
- }
- }.last()
-
- res as? EuiccChannelManagerService.ForegroundTaskState.Done
- }
-
- override fun onDismiss(dialog: DialogInterface) {
- super.onDismiss(dialog)
- if (finishWhenDone) {
- activity?.finish()
- }
- }
-
- override fun onCancel(dialog: DialogInterface) {
- super.onCancel(dialog)
- if (finishWhenDone) {
- activity?.finish()
- }
- }
-}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileRenameFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileRenameFragment.kt
index 278ea437..25c5273e 100644
--- a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileRenameFragment.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileRenameFragment.kt
@@ -11,9 +11,10 @@ import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.lifecycleScope
import com.google.android.material.textfield.TextInputLayout
import im.angry.openeuicc.common.R
+import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.*
-import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
+import net.typeblog.lpac_jni.LocalProfileAssistant
class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragmentMarker {
companion object {
@@ -53,6 +54,7 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+ profileRenameNewName.editText!!.setText(requireArguments().getString("currentName"))
toolbar.apply {
setTitle(R.string.rename)
setNavigationOnClickListener {
@@ -65,11 +67,6 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
}
}
- override fun onStart() {
- super.onStart()
- profileRenameNewName.editText!!.setText(requireArguments().getString("currentName"))
- }
-
override fun onResume() {
super.onResume()
setWidthPercent(95)
@@ -81,13 +78,18 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
}
}
- private fun rename() {
- val name = profileRenameNewName.editText!!.text.toString().trim()
- if (name.length >= 64) {
- Toast.makeText(context, R.string.toast_profile_name_too_long, Toast.LENGTH_LONG).show()
- return
- }
+ private fun showErrorAndCancel(errorStrRes: Int) {
+ Toast.makeText(
+ requireContext(),
+ errorStrRes,
+ Toast.LENGTH_LONG
+ ).show()
+ renaming = false
+ progress.visibility = View.GONE
+ }
+
+ private fun rename() {
renaming = true
progress.isIndeterminate = true
progress.visibility = View.VISIBLE
@@ -95,21 +97,37 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
lifecycleScope.launch {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
- euiccChannelManagerService.launchProfileRenameTask(
+ val res = euiccChannelManagerService.launchProfileRenameTask(
slotId,
portId,
requireArguments().getString("iccid")!!,
- name
- )?.collect()
+ profileRenameNewName.editText!!.text.toString().trim()
+ ).waitDone()
- if (parentFragment is EuiccProfilesChangedListener) {
- (parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
- }
+ when (res) {
+ is LocalProfileAssistant.ProfileNameTooLongException -> {
+ showErrorAndCancel(R.string.profile_rename_too_long)
+ }
- try {
- dismiss()
- } catch (e: IllegalStateException) {
- // Ignored
+ is LocalProfileAssistant.ProfileNameIsInvalidUTF8Exception -> {
+ showErrorAndCancel(R.string.profile_rename_encoding_error)
+ }
+
+ is Throwable -> {
+ showErrorAndCancel(R.string.profile_rename_failure)
+ }
+
+ else -> {
+ if (parentFragment is EuiccProfilesChangedListener) {
+ (parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
+ }
+
+ try {
+ dismiss()
+ } catch (e: IllegalStateException) {
+ // Ignored
+ }
+ }
}
}
}
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsActivity.kt
index 52e32727..bb299a31 100644
--- a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsActivity.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsActivity.kt
@@ -4,10 +4,14 @@ import android.os.Bundle
import android.view.MenuItem
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
+import im.angry.openeuicc.OpenEuiccApplication
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
class SettingsActivity: AppCompatActivity() {
+ private val appContainer
+ get() = (application as OpenEuiccApplication).appContainer
+
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -15,8 +19,9 @@ class SettingsActivity: AppCompatActivity() {
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
+ val settingsFragment = appContainer.uiComponentFactory.createSettingsFragment()
supportFragmentManager.beginTransaction()
- .replace(R.id.settings_container, SettingsFragment())
+ .replace(R.id.settings_container, settingsFragment)
.commit()
}
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt
index 5ed43480..fab680f2 100644
--- a/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/SettingsFragment.kt
@@ -2,71 +2,154 @@ package im.angry.openeuicc.ui
import android.content.Intent
import android.net.Uri
+import android.os.Build
import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.datastore.preferences.core.Preferences
+import android.provider.Settings
+import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import androidx.preference.CheckBoxPreference
import androidx.preference.Preference
+import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
-import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
-class SettingsFragment: PreferenceFragmentCompat() {
+open 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?) {
setPreferencesFromResource(R.xml.pref_settings, rootKey)
- findPreference("pref_info_app_version")
- ?.summary = requireContext().selfAppVersion
+ developerPref = requirePreference("pref_developer")
- findPreference("pref_info_source_code")
- ?.setOnPreferenceClickListener {
- startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.summary.toString())))
- true
+ // Show / hide developer preference based on whether it is enabled
+ lifecycleScope.launch {
+ preferenceRepository.developerOptionsEnabledFlow
+ .onEach { developerPref.isVisible = it }
+ .collect()
+ }
+
+ requirePreference("pref_info_app_version").apply {
+ summary = requireContext().selfAppVersion
+
+ // Enable developer options when this is clicked for 7 times
+ setOnPreferenceClickListener(::onAppVersionClicked)
+ }
+
+ requirePreference("pref_advanced_language").apply {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@apply
+ isVisible = true
+ intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply {
+ data = Uri.fromParts("package", requireContext().packageName, null)
}
+ }
- findPreference("pref_advanced_logs")
- ?.setOnPreferenceClickListener {
- startActivity(Intent(requireContext(), LogsActivity::class.java))
- true
- }
+ requirePreference("pref_advanced_logs").apply {
+ intent = Intent(requireContext(), LogsActivity::class.java)
+ }
- findPreference("pref_notifications_download")
- ?.bindBooleanFlow(preferenceRepository.notificationDownloadFlow, PreferenceKeys.NOTIFICATION_DOWNLOAD)
+ requirePreference("pref_notifications_download")
+ .bindBooleanFlow(preferenceRepository.notificationDownloadFlow)
- findPreference("pref_notifications_delete")
- ?.bindBooleanFlow(preferenceRepository.notificationDeleteFlow, PreferenceKeys.NOTIFICATION_DELETE)
+ requirePreference("pref_notifications_delete")
+ .bindBooleanFlow(preferenceRepository.notificationDeleteFlow)
- findPreference("pref_notifications_switch")
- ?.bindBooleanFlow(preferenceRepository.notificationSwitchFlow, PreferenceKeys.NOTIFICATION_SWITCH)
+ requirePreference("pref_notifications_switch")
+ .bindBooleanFlow(preferenceRepository.notificationSwitchFlow)
- findPreference("pref_advanced_disable_safeguard_removable_esim")
- ?.bindBooleanFlow(preferenceRepository.disableSafeguardFlow, PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM)
+ requirePreference("pref_advanced_disable_safeguard_removable_esim")
+ .bindBooleanFlow(preferenceRepository.disableSafeguardFlow)
- findPreference("pref_advanced_verbose_logging")
- ?.bindBooleanFlow(preferenceRepository.verboseLoggingFlow, PreferenceKeys.VERBOSE_LOGGING)
+ requirePreference("pref_advanced_verbose_logging")
+ .bindBooleanFlow(preferenceRepository.verboseLoggingFlow)
+
+ requirePreference("pref_developer_unfiltered_profile_list")
+ .bindBooleanFlow(preferenceRepository.unfilteredProfileListFlow)
+
+ requirePreference("pref_developer_ignore_tls_certificate")
+ .bindBooleanFlow(preferenceRepository.ignoreTLSCertificateFlow)
}
+ protected fun requirePreference(key: CharSequence) =
+ findPreference(key)!!
+
override fun onStart() {
super.onStart()
- setupRootViewInsets(requireView().requireViewById(androidx.preference.R.id.recycler_view))
+ setupRootViewInsets(requireView().requireViewById(R.id.recycler_view))
}
- private fun CheckBoxPreference.bindBooleanFlow(flow: Flow, key: Preferences.Key) {
+ @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
+ }
+
+ private fun CheckBoxPreference.bindBooleanFlow(flow: PreferenceFlowWrapper) {
lifecycleScope.launch {
flow.collect { isChecked = it }
}
setOnPreferenceChangeListener { _, newValue ->
runBlocking {
- preferenceRepository.updatePreference(key, newValue as Boolean)
+ flow.updatePreference(newValue as Boolean)
}
true
}
}
+
+ protected fun mergePreferenceOverlay(overlayKey: String, targetKey: String) {
+ val overlayCat = requirePreference(overlayKey)
+ val targetCat = requirePreference(targetKey)
+
+ val prefs = buildList {
+ for (i in 0..): 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
- 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(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()
- }
-}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/UsbCcidReaderFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/UsbCcidReaderFragment.kt
index 3988b09a..7a52ca0d 100644
--- a/app-common/src/main/java/im/angry/openeuicc/ui/UsbCcidReaderFragment.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/UsbCcidReaderFragment.kt
@@ -20,7 +20,6 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
-import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
@@ -73,7 +72,6 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
private lateinit var loadingProgress: ProgressBar
private var usbDevice: UsbDevice? = null
- private var usbChannel: EuiccChannel? = null
override fun onCreateView(
inflater: LayoutInflater,
@@ -122,7 +120,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
try {
requireContext().unregisterReceiver(usbPermissionReceiver)
} catch (_: Exception) {
-
+ // ignore
}
}
@@ -131,7 +129,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
try {
requireContext().unregisterReceiver(usbPermissionReceiver)
} catch (_: Exception) {
-
+ // ignore
}
}
@@ -140,24 +138,26 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
permissionButton.visibility = View.GONE
loadingProgress.visibility = View.VISIBLE
- val (device, channel) = withContext(Dispatchers.IO) {
- euiccChannelManager.enumerateUsbEuiccChannel()
+ val (device, canOpen) = withContext(Dispatchers.IO) {
+ euiccChannelManager.tryOpenUsbEuiccChannel()
}
loadingProgress.visibility = View.GONE
usbDevice = device
- usbChannel = channel
- if (device != null && channel == null && !usbManager.hasPermission(device)) {
+ if (device != null && !canOpen && !usbManager.hasPermission(device)) {
text.text = getString(R.string.usb_permission_needed)
text.visibility = View.VISIBLE
permissionButton.visibility = View.VISIBLE
- } else if (device != null && channel != null) {
+ } else if (device != null && canOpen) {
childFragmentManager.commit {
replace(
R.id.child_container,
- appContainer.uiComponentFactory.createEuiccManagementFragment(channel)
+ appContainer.uiComponentFactory.createEuiccManagementFragment(
+ slotId = EuiccChannelManager.USB_CHANNEL_ID,
+ portId = 0
+ )
)
}
} else {
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt
new file mode 100644
index 00000000..e342dee6
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardActivity.kt
@@ -0,0 +1,280 @@
+package im.angry.openeuicc.ui.wizard
+
+import android.os.Bundle
+import android.view.View
+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.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 kotlinx.coroutines.withContext
+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?,
+ )
+
+ 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(
+ null,
+ intent.getIntExtra("selectedLogicalSlot", 0),
+ "",
+ null,
+ null,
+ null,
+ false,
+ -1,
+ null
+ )
+
+ 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(R.id.download_wizard_navigation)
+ val origHeight = navigation.layoutParams.height
+
+ ViewCompat.setOnApplyWindowInsetsListener(navigation) { v, insets ->
+ val bars = insets.getInsets(
+ WindowInsetsCompat.Type.systemBars()
+ or WindowInsetsCompat.Type.displayCutout()
+ or WindowInsetsCompat.Type.ime()
+ )
+ v.updatePadding(bars.left, 0, bars.right, bars.bottom)
+ val newParams = navigation.layoutParams
+ newParams.height = origHeight + bars.bottom
+ navigation.layoutParams = newParams
+ WindowInsetsCompat.CONSUMED
+ }
+
+ val fragmentRoot = requireViewById(R.id.step_fragment_container)
+ ViewCompat.setOnApplyWindowInsetsListener(fragmentRoot) { v, insets ->
+ val bars = insets.getInsets(
+ WindowInsetsCompat.Type.systemBars()
+ or WindowInsetsCompat.Type.displayCutout()
+ )
+ v.updatePadding(bars.left, bars.top, bars.right, 0)
+ WindowInsetsCompat.CONSUMED
+ }
+ }
+
+ 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)
+ }
+
+ 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)
+ }
+
+ 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()
+ 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
+
+ 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() {}
+ }
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt
new file mode 100644
index 00000000..5fa80022
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDetailsFragment.kt
@@ -0,0 +1,75 @@
+package im.angry.openeuicc.ui.wizard
+
+import android.os.Bundle
+import android.util.Patterns
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import 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 =
+ 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()
+ }
+ 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()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ saveState()
+ }
+
+ private fun updateInputCompleteness() {
+ inputComplete = Patterns.DOMAIN_NAME.matcher(smdp.editText!!.text).matches()
+ refreshButtons()
+ }
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDiagnosticsFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDiagnosticsFragment.kt
new file mode 100644
index 00000000..e282196a
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardDiagnosticsFragment.kt
@@ -0,0 +1,139 @@
+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(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()
+ }
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt
new file mode 100644
index 00000000..6203364e
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardMethodSelectFragment.kt
@@ -0,0 +1,172 @@
+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(Dispatchers.IO) {
+ runCatching {
+ requireContext().contentResolver.openInputStream(result)?.let { input ->
+ val bmp = BitmapFactory.decodeStream(input)
+ input.close()
+
+ decodeQrFromBitmap(bmp)?.let {
+ withContext(Dispatchers.Main) {
+ processLpaString(it)
+ }
+ }
+
+ bmp.recycle()
+ }
+ }
+ }
+ }
+
+ 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(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(s: String) {
+ val components = s.split("$")
+ if (components.size < 3 || components[0] != "LPA:1") {
+ 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()
+ }
+ return
+ }
+ state.smdp = components[1]
+ state.matchingId = components[2]
+ gotoNextFragment(DownloadWizardDetailsFragment())
+ }
+
+ private class DownloadMethodViewHolder(private val root: View) : ViewHolder(root) {
+ private val icon = root.requireViewById(R.id.download_method_icon)
+ private val title = root.requireViewById(R.id.download_method_title)
+
+ fun bind(item: DownloadMethod) {
+ icon.setImageResource(item.iconRes)
+ title.setText(item.titleRes)
+ root.setOnClickListener { item.onClick() }
+ }
+ }
+
+ private inner class DownloadMethodAdapter : RecyclerView.Adapter() {
+ 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])
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt
new file mode 100644
index 00000000..1b816d48
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardProgressFragment.kt
@@ -0,0 +1,240 @@
+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()
+
+ 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(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..(R.id.download_progress_item_title)
+ private val progressBar =
+ root.requireViewById(R.id.download_progress_icon_progress)
+ private val icon = root.requireViewById(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() {
+ 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])
+ }
+ }
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardSlotSelectFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardSlotSelectFragment.kt
new file mode 100644
index 00000000..5510fb05
--- /dev/null
+++ b/app-common/src/main/java/im/angry/openeuicc/ui/wizard/DownloadWizardSlotSelectFragment.kt
@@ -0,0 +1,215 @@
+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 =
+ 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(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(R.id.slot_item_title)
+ private val type = root.requireViewById(R.id.slot_item_type)
+ private val eID = root.requireViewById(R.id.slot_item_eid)
+ private val activeProfile = root.requireViewById(R.id.slot_item_active_profile)
+ private val freeSpace = root.requireViewById(R.id.slot_item_free_space)
+ private val checkBox = root.requireViewById(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() {
+ var slots: List = 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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt
index e92be408..3f3c4ee5 100644
--- a/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt
@@ -32,15 +32,17 @@ val T.portId: Int where T: Fragment, T: EuiccChannelFragmentMarker
val T.isUsb: Boolean where T: Fragment, T: EuiccChannelFragmentMarker
get() = requireArguments().getInt("slotId") == EuiccChannelManager.USB_CHANNEL_ID
-val T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: EuiccChannelFragmentMarker
+val T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: OpenEuiccContextMarker
get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManager
-val T.euiccChannelManagerService: EuiccChannelManagerService where T: Fragment, T: EuiccChannelFragmentMarker
+val T.euiccChannelManagerService: EuiccChannelManagerService where T: Fragment, T: OpenEuiccContextMarker
get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManagerService
-val T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker
- get() =
- euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!!
-suspend fun T.ensureEuiccChannelManager() where T: Fragment, T: EuiccChannelFragmentMarker =
+suspend fun T.withEuiccChannel(fn: suspend (EuiccChannel) -> R): R where T : Fragment, T : EuiccChannelFragmentMarker {
+ ensureEuiccChannelManager()
+ return euiccChannelManager.withEuiccChannel(slotId, portId, fn)
+}
+
+suspend fun T.ensureEuiccChannelManager() where T: Fragment, T: OpenEuiccContextMarker =
(requireActivity() as BaseEuiccAccessActivity).euiccChannelManagerLoaded.await()
interface EuiccProfilesChangedListener {
diff --git a/app-common/src/main/java/im/angry/openeuicc/util/LPAUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/LPAUtils.kt
index e7a3322d..9f95412e 100644
--- a/app-common/src/main/java/im/angry/openeuicc/util/LPAUtils.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/util/LPAUtils.kt
@@ -3,9 +3,6 @@ package im.angry.openeuicc.util
import android.util.Log
import im.angry.openeuicc.core.EuiccChannel
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.LocalProfileInfo
@@ -19,9 +16,10 @@ val LocalProfileInfo.isEnabled: Boolean
get() = state == LocalProfileInfo.State.Enabled
val List.operational: List
- get() = filter {
- it.profileClass == LocalProfileInfo.Clazz.Operational
- }
+ get() = filter { it.profileClass == LocalProfileInfo.Clazz.Operational }
+
+val List.enabled: LocalProfileInfo?
+ get() = find { it.isEnabled }
val List.hasMultipleChips: Boolean
get() = distinctBy { it.slotId }.size > 1
@@ -42,22 +40,27 @@ fun LocalProfileAssistant.switchProfile(
* See EuiccManager.waitForReconnect()
*/
fun LocalProfileAssistant.disableActiveProfile(refresh: Boolean): Boolean =
- profiles.find { it.isEnabled }?.let {
+ profiles.enabled?.let {
Log.i(TAG, "Disabling active profile ${it.iccid}")
disableProfile(it.iccid, refresh)
} ?: true
/**
- * 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.
+ * Disable the current active profile if any. If refresh is true, also cause a refresh command.
* See EuiccManager.waitForReconnect()
+ *
+ * Return the iccid of the profile being disabled, or null if no active profile found or failed to
+ * disable.
*/
-fun LocalProfileAssistant.disableActiveProfileWithUndo(refreshOnDisable: Boolean): () -> Unit =
- profiles.find { it.isEnabled }?.let {
- disableProfile(it.iccid, refreshOnDisable)
- return { enableProfile(it.iccid) }
- } ?: { }
+fun LocalProfileAssistant.disableActiveProfileKeepIccId(refresh: Boolean): String? =
+ profiles.enabled?.let {
+ Log.i(TAG, "Disabling active profile ${it.iccid}")
+ if (disableProfile(it.iccid, refresh)) {
+ it.iccid
+ } else {
+ null
+ }
+ }
/**
* Begin a "tracked" operation where notifications may be generated by the eSIM
@@ -78,60 +81,21 @@ suspend inline fun EuiccChannelManager.beginTrackedOperation(
portId: Int,
op: () -> Boolean
) {
- val latestSeq =
- findEuiccChannelByPort(slotId, portId)!!.lpa.notifications.firstOrNull()?.seqNumber
+ val latestSeq = withEuiccChannel(slotId, portId) { channel ->
+ channel.lpa.notifications.firstOrNull()?.seqNumber
?: 0
- Log.d(TAG, "Latest notification is $latestSeq before operation")
- if (op()) {
- Log.d(TAG, "Operation has requested notification handling")
- try {
- // Note that the exact instance of "channel" might have changed here if reconnected;
- // so we MUST use the automatic getter for "channel"
- findEuiccChannelByPort(
- slotId,
- portId
- )?.lpa?.notifications?.filter { it.seqNumber > latestSeq }?.forEach {
- Log.d(TAG, "Handling notification $it")
- findEuiccChannelByPort(
- slotId,
- portId
- )?.lpa?.handleNotification(it.seqNumber)
- }
- } catch (e: Exception) {
- // Ignore any error during notification handling
- e.printStackTrace()
- }
}
- Log.d(TAG, "Operation complete")
-}
-
-/**
- * Same as beginTrackedOperation but uses blocking primitives.
- * TODO: This function needs to be phased out of use.
- */
-inline fun EuiccChannelManager.beginTrackedOperationBlocking(
- slotId: Int,
- portId: Int,
- op: () -> Boolean
-) {
- val latestSeq =
- findEuiccChannelByPortBlocking(slotId, portId)!!.lpa.notifications.firstOrNull()?.seqNumber
- ?: 0
Log.d(TAG, "Latest notification is $latestSeq before operation")
if (op()) {
Log.d(TAG, "Operation has requested notification handling")
try {
// Note that the exact instance of "channel" might have changed here if reconnected;
- // so we MUST use the automatic getter for "channel"
- findEuiccChannelByPortBlocking(
- slotId,
- portId
- )?.lpa?.notifications?.filter { it.seqNumber > latestSeq }?.forEach {
- Log.d(TAG, "Handling notification $it")
- findEuiccChannelByPortBlocking(
- slotId,
- portId
- )?.lpa?.handleNotification(it.seqNumber)
+ // this is why we need to use two distinct calls to withEuiccChannel()
+ withEuiccChannel(slotId, portId) { channel ->
+ channel.lpa.notifications.filter { it.seqNumber > latestSeq }.forEach {
+ Log.d(TAG, "Handling notification $it")
+ channel.lpa.handleNotification(it.seqNumber)
+ }
}
} catch (e: Exception) {
// Ignore any error during notification handling
diff --git a/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt
index 262482ad..f5e3ca2c 100644
--- a/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/util/PreferenceUtils.kt
@@ -19,38 +19,54 @@ val Context.preferenceRepository: PreferenceRepository
val Fragment.preferenceRepository: PreferenceRepository
get() = requireContext().preferenceRepository
-object PreferenceKeys {
+internal object PreferenceKeys {
+ // ---- Profile Notifications ----
val NOTIFICATION_DOWNLOAD = booleanPreferencesKey("notification_download")
val NOTIFICATION_DELETE = booleanPreferencesKey("notification_delete")
val NOTIFICATION_SWITCH = booleanPreferencesKey("notification_switch")
- val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim")
- val VERBOSE_LOGGING = booleanPreferencesKey("verbose_logging")
-}
-
-class PreferenceRepository(context: Context) {
- private val dataStore = context.dataStore
-
- // Expose flows so that we can also handle default values
- // ---- Profile Notifications ----
- val notificationDownloadFlow: Flow =
- dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DOWNLOAD] ?: true }
-
- val notificationDeleteFlow: Flow =
- dataStore.data.map { it[PreferenceKeys.NOTIFICATION_DELETE] ?: true }
-
- val notificationSwitchFlow: Flow =
- dataStore.data.map { it[PreferenceKeys.NOTIFICATION_SWITCH] ?: false }
// ---- Advanced ----
- val disableSafeguardFlow: Flow =
- dataStore.data.map { it[PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM] ?: false }
+ val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim")
+ val VERBOSE_LOGGING = booleanPreferencesKey("verbose_logging")
- val verboseLoggingFlow: Flow =
- dataStore.data.map { it[PreferenceKeys.VERBOSE_LOGGING] ?: false }
+ // ---- Developer Options ----
+ val DEVELOPER_OPTIONS_ENABLED = booleanPreferencesKey("developer_options_enabled")
+ val UNFILTERED_PROFILE_LIST = booleanPreferencesKey("unfiltered_profile_list")
+ val IGNORE_TLS_CERTIFICATE = booleanPreferencesKey("ignore_tls_certificate")
+}
- suspend fun updatePreference(key: Preferences.Key, value: T) {
- dataStore.edit {
- it[key] = value
- }
+class PreferenceRepository(private val context: Context) {
+ // Expose flows so that we can also handle default values
+ // ---- Profile Notifications ----
+ val notificationDownloadFlow = bindFlow(PreferenceKeys.NOTIFICATION_DOWNLOAD, true)
+ val notificationDeleteFlow = bindFlow(PreferenceKeys.NOTIFICATION_DELETE, true)
+ val notificationSwitchFlow = bindFlow(PreferenceKeys.NOTIFICATION_SWITCH, false)
+
+ // ---- Advanced ----
+ val disableSafeguardFlow = bindFlow(PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM, false)
+ val verboseLoggingFlow = bindFlow(PreferenceKeys.VERBOSE_LOGGING, false)
+
+ // ---- Developer Options ----
+ val developerOptionsEnabledFlow = bindFlow(PreferenceKeys.DEVELOPER_OPTIONS_ENABLED, false)
+ val unfilteredProfileListFlow = bindFlow(PreferenceKeys.UNFILTERED_PROFILE_LIST, false)
+ val ignoreTLSCertificateFlow = bindFlow(PreferenceKeys.IGNORE_TLS_CERTIFICATE, false)
+
+ private fun bindFlow(key: Preferences.Key, defaultValue: T): PreferenceFlowWrapper =
+ PreferenceFlowWrapper(context, key, defaultValue)
+}
+
+class PreferenceFlowWrapper private constructor(
+ private val context: Context,
+ private val key: Preferences.Key,
+ inner: Flow
+) : Flow by inner {
+ internal constructor(context: Context, key: Preferences.Key, defaultValue: T) : this(
+ context,
+ key,
+ context.dataStore.data.map { it[key] ?: defaultValue }
+ )
+
+ suspend fun updatePreference(value: T) {
+ context.dataStore.edit { it[key] = value }
}
}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt
index ebf87298..8d724620 100644
--- a/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/util/StringUtils.kt
@@ -27,4 +27,74 @@ fun formatFreeSpace(size: Int): String =
"%.2f KiB".format(size.toDouble() / 1024)
} else {
"$size B"
- }
\ No newline at end of file
+ }
+
+fun String.prettyPrintJson(): String {
+ val ret = StringBuilder()
+ var inQuotes = false
+ var escaped = false
+ val indentSymbolStack = ArrayDeque()
+
+ val addNewLine = {
+ ret.append('\n')
+ repeat(indentSymbolStack.size) {
+ ret.append('\t')
+ }
+ }
+
+ var lastChar = ' '
+
+ for (c in this) {
+ when {
+ !inQuotes && (c == '{' || c == '[') -> {
+ ret.append(c)
+ indentSymbolStack.addLast(c)
+ addNewLine()
+ }
+
+ !inQuotes && (c == '}' || c == ']') -> {
+ indentSymbolStack.removeLast()
+ if (lastChar != ',') {
+ addNewLine()
+ }
+ ret.append(c)
+ }
+
+ !inQuotes && c == ',' -> {
+ ret.append(c)
+ addNewLine()
+ }
+
+ !inQuotes && c == ':' -> {
+ ret.append(c)
+ ret.append(' ')
+ }
+
+ inQuotes && c == '\\' -> {
+ ret.append(c)
+ escaped = true
+ continue
+ }
+
+ !escaped && c == '"' -> {
+ ret.append(c)
+ inQuotes = !inQuotes
+ }
+
+ !inQuotes && c == ' ' -> {
+ // Do nothing -- we ignore spaces outside of quotes by default
+ // This is to ensure predictable formatting
+ }
+
+ else -> ret.append(c)
+ }
+
+ if (escaped) {
+ escaped = false
+ }
+
+ lastChar = c
+ }
+
+ return ret.toString()
+}
\ No newline at end of file
diff --git a/app-common/src/main/java/im/angry/openeuicc/util/TelephonyCompat.kt b/app-common/src/main/java/im/angry/openeuicc/util/TelephonyCompat.kt
index 5c7d2173..b831f012 100644
--- a/app-common/src/main/java/im/angry/openeuicc/util/TelephonyCompat.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/util/TelephonyCompat.kt
@@ -45,6 +45,8 @@ fun SEService.getUiccReaderCompat(slotNumber: Int): Reader {
interface UiccCardInfoCompat {
val physicalSlotIndex: Int
val ports: Collection
+ val isRemovable: Boolean
+ get() = true // This defaults to removable unless overridden
}
interface UiccPortInfoCompat {
diff --git a/app-common/src/main/java/im/angry/openeuicc/util/UiUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/UiUtils.kt
index fbede878..a73d7fe0 100644
--- a/app-common/src/main/java/im/angry/openeuicc/util/UiUtils.kt
+++ b/app-common/src/main/java/im/angry/openeuicc/util/UiUtils.kt
@@ -1,17 +1,24 @@
package im.angry.openeuicc.util
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
import android.content.res.Resources
import android.graphics.Rect
import android.view.View
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.appcompat.widget.Toolbar
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.Fragment
import im.angry.openeuicc.common.R
+import java.io.FileOutputStream
// Source:
/**
@@ -69,4 +76,49 @@ fun setupRootViewInsets(view: ViewGroup) {
WindowInsetsCompat.CONSUMED
}
+}
+
+fun 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)
+ }
}
\ No newline at end of file
diff --git a/app-common/src/main/res/anim/slide_in_left.xml b/app-common/src/main/res/anim/slide_in_left.xml
new file mode 100644
index 00000000..9078d1fb
--- /dev/null
+++ b/app-common/src/main/res/anim/slide_in_left.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app-common/src/main/res/anim/slide_in_right.xml b/app-common/src/main/res/anim/slide_in_right.xml
new file mode 100644
index 00000000..42aa3f52
--- /dev/null
+++ b/app-common/src/main/res/anim/slide_in_right.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app-common/src/main/res/anim/slide_out_left.xml b/app-common/src/main/res/anim/slide_out_left.xml
new file mode 100644
index 00000000..1a806a9b
--- /dev/null
+++ b/app-common/src/main/res/anim/slide_out_left.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app-common/src/main/res/anim/slide_out_right.xml b/app-common/src/main/res/anim/slide_out_right.xml
new file mode 100644
index 00000000..f209f384
--- /dev/null
+++ b/app-common/src/main/res/anim/slide_out_right.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app-unpriv/src/main/res/drawable/ic_checkmark_outline.xml b/app-common/src/main/res/drawable/ic_checkmark_outline.xml
similarity index 100%
rename from app-unpriv/src/main/res/drawable/ic_checkmark_outline.xml
rename to app-common/src/main/res/drawable/ic_checkmark_outline.xml
diff --git a/app-common/src/main/res/drawable/ic_chevron_left.xml b/app-common/src/main/res/drawable/ic_chevron_left.xml
new file mode 100644
index 00000000..1152da9f
--- /dev/null
+++ b/app-common/src/main/res/drawable/ic_chevron_left.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app-common/src/main/res/drawable/ic_chevron_right.xml b/app-common/src/main/res/drawable/ic_chevron_right.xml
new file mode 100644
index 00000000..1db5e680
--- /dev/null
+++ b/app-common/src/main/res/drawable/ic_chevron_right.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app-common/src/main/res/drawable/ic_edit.xml b/app-common/src/main/res/drawable/ic_edit.xml
new file mode 100644
index 00000000..3c53db7e
--- /dev/null
+++ b/app-common/src/main/res/drawable/ic_edit.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app-unpriv/src/main/res/drawable/ic_error_outline.xml b/app-common/src/main/res/drawable/ic_error_outline.xml
similarity index 100%
rename from app-unpriv/src/main/res/drawable/ic_error_outline.xml
rename to app-common/src/main/res/drawable/ic_error_outline.xml
diff --git a/app-common/src/main/res/drawable/ic_paste_go.xml b/app-common/src/main/res/drawable/ic_paste_go.xml
new file mode 100644
index 00000000..7536fff3
--- /dev/null
+++ b/app-common/src/main/res/drawable/ic_paste_go.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app-common/src/main/res/layout/activity_download_wizard.xml b/app-common/src/main/res/layout/activity_download_wizard.xml
new file mode 100644
index 00000000..79513bb7
--- /dev/null
+++ b/app-common/src/main/res/layout/activity_download_wizard.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/activity_euicc_info.xml b/app-common/src/main/res/layout/activity_euicc_info.xml
new file mode 100644
index 00000000..8a8b0012
--- /dev/null
+++ b/app-common/src/main/res/layout/activity_euicc_info.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/download_method_item.xml b/app-common/src/main/res/layout/download_method_item.xml
new file mode 100644
index 00000000..5b2c2a8a
--- /dev/null
+++ b/app-common/src/main/res/layout/download_method_item.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/download_progress_item.xml b/app-common/src/main/res/layout/download_progress_item.xml
new file mode 100644
index 00000000..f1d0852e
--- /dev/null
+++ b/app-common/src/main/res/layout/download_progress_item.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/download_slot_item.xml b/app-common/src/main/res/layout/download_slot_item.xml
new file mode 100644
index 00000000..d0ca1764
--- /dev/null
+++ b/app-common/src/main/res/layout/download_slot_item.xml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/euicc_info_item.xml b/app-common/src/main/res/layout/euicc_info_item.xml
new file mode 100644
index 00000000..fa148fbb
--- /dev/null
+++ b/app-common/src/main/res/layout/euicc_info_item.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/euicc_profile.xml b/app-common/src/main/res/layout/euicc_profile.xml
index 06aa2b84..58d55ab1 100644
--- a/app-common/src/main/res/layout/euicc_profile.xml
+++ b/app-common/src/main/res/layout/euicc_profile.xml
@@ -28,7 +28,8 @@
app:layout_constraintRight_toLeftOf="@+id/profile_menu"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/state"
- app:layout_constraintHorizontal_bias="0" />
+ app:layout_constraintHorizontal_bias="0"
+ app:layout_constrainedWidth="true" />
+ app:layout_constraintBottom_toTopOf="@+id/profile_class_label"/>
+
+
+
+
diff --git a/app-common/src/main/res/layout/fragment_download_details.xml b/app-common/src/main/res/layout/fragment_download_details.xml
new file mode 100644
index 00000000..1a250756
--- /dev/null
+++ b/app-common/src/main/res/layout/fragment_download_details.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/fragment_download_diagnostics.xml b/app-common/src/main/res/layout/fragment_download_diagnostics.xml
new file mode 100644
index 00000000..88b1ffba
--- /dev/null
+++ b/app-common/src/main/res/layout/fragment_download_diagnostics.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/fragment_download_method_select.xml b/app-common/src/main/res/layout/fragment_download_method_select.xml
new file mode 100644
index 00000000..7fe22341
--- /dev/null
+++ b/app-common/src/main/res/layout/fragment_download_method_select.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/fragment_download_progress.xml b/app-common/src/main/res/layout/fragment_download_progress.xml
new file mode 100644
index 00000000..82ebb258
--- /dev/null
+++ b/app-common/src/main/res/layout/fragment_download_progress.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/fragment_download_slot_select.xml b/app-common/src/main/res/layout/fragment_download_slot_select.xml
new file mode 100644
index 00000000..69b15353
--- /dev/null
+++ b/app-common/src/main/res/layout/fragment_download_slot_select.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/fragment_profile_download.xml b/app-common/src/main/res/layout/fragment_profile_download.xml
deleted file mode 100644
index 78274dc9..00000000
--- a/app-common/src/main/res/layout/fragment_profile_download.xml
+++ /dev/null
@@ -1,126 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/fragment_slot_select.xml b/app-common/src/main/res/layout/fragment_slot_select.xml
deleted file mode 100644
index b818b808..00000000
--- a/app-common/src/main/res/layout/fragment_slot_select.xml
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app-common/src/main/res/layout/notification_item.xml b/app-common/src/main/res/layout/notification_item.xml
index 6b7253c7..5d56b3ae 100644
--- a/app-common/src/main/res/layout/notification_item.xml
+++ b/app-common/src/main/res/layout/notification_item.xml
@@ -15,6 +15,15 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/menu/fragment_profile_download.xml b/app-common/src/main/res/menu/fragment_profile_download.xml
deleted file mode 100644
index f93ae8d8..00000000
--- a/app-common/src/main/res/menu/fragment_profile_download.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
\ No newline at end of file
diff --git a/app-common/src/main/res/values-ja/strings.xml b/app-common/src/main/res/values-ja/strings.xml
new file mode 100644
index 00000000..b592ec3a
--- /dev/null
+++ b/app-common/src/main/res/values-ja/strings.xml
@@ -0,0 +1,147 @@
+
+
+ このアプリでアクセスできるリムーバブル eUICC カードがデバイス上で検出されていません。互換性のあるカード挿入または USB リーダーを接続してください。
+ この eSIM にはプロファイルがありません。
+ 不明
+ 情報なし
+ ヘルプ
+ スロットを再読み込み
+ 論理スロット %d
+ 有効済み
+ 無効済み
+ プロバイダー:
+ 有効化
+ 無効化
+ 削除
+ 名前を変更
+ eSIM チップがプロファイルの切り替えの待機中にタイムアウトしました。これはデバイスのモデムファームウェアのバグの可能性があります。機内モードに切り替えるかアプリを再起動、デバイスを再起動してください。
+ 操作は成功しましたが、デバイスのモデムが更新を拒否しました。新しいプロファイルを使用するには機内モードに切り替えるか、再起動する必要があります。
+ 新しい eSIM プロファイルに切り替えることができません。
+ 入力した確認用テキストは一致していません
+ ICCID をクリップボードにコピーしました
+ EID をクリップボードにコピーしました
+ ATR をクリップボードにコピーしました
+ USB の権限を許可
+ USB スマートカードリーダーにアクセスするには許可が必要です。
+ USB スマートカードリーダー経由で eSIM に接続できません。
+ 長時間実行されるタスク
+ eSIM プロファイルをダウンロード中です
+ eSIM プロファイルのダウンロードに失敗しました
+ eSIM プロファイルの名前を変更中です
+ eSIM プロファイルの名前の変更に失敗しました
+ eSIM プロファイルを削除中です
+ eSIM プロファイルの削除に失敗しました
+ eSIM プロファイルを切り替え中
+ eSIM プロファイルの切り替えに失敗しました
+ 新しい eSIM
+ サーバー (RSP / SM-DP+)
+ アクティベーションコード
+ 確認コード (オプション)
+ IMEI (オプション)
+ ダウンロードに失敗する可能性があります
+ 残り容量が少ないため、ダウンロードに失敗する可能性があります。
+ クリップボードに LPA コードが見つかりません
+ LPA コードを解析できません
+ クリップボードまたは QR コードの内容を LPA コードとして解析できません
+ ダウンロードウィザード
+ 戻る
+ 次へ
+ 選択された SIM が取り外されました
+ ダウンロードする eSIM を選択または確認:
+ タイプ:
+ リムーバブル
+ 内部
+ 内部 - ポート: %d
+ 有効なプロファイル:
+ 空き容量:
+ eSIM プロファイルをどの方法でダウンロードしますか?
+ カメラで QR コードをスキャン
+ ギャラリーから QR コードをスキャン
+ クリップボードから読み込む
+ 手動で入力
+ eSIM をダウンロードするための詳細情報を入力または確認:
+ eSIM をダウンロード中です…
+ 準備中
+ サーバーへの接続を確立しています
+ サーバーでデバイスを認証中です
+ eSIM プロファイルをダウンロード中です
+ eSIM プロファイルをストレージに読み込み中です
+ エラー診断
+ エラーコード: %s
+ 最終の HTTP ステータス (サーバー): %d
+ 最終の HTTP レスポンス (サーバー):
+ 最終の HTTP 例外:
+ 最終の APDU レスポンス (SIM): %s
+ 最終の APDU レスポンス (SIM) は成功しました
+ 最終の APDU レスポンス (SIM) は失敗しました
+ 最終の APDU 例外:
+ 保存
+ %s のエラー診断
+ ログは指定されたパスに保存しました。他のアプリにシェアしますか?
+ 新しいニックネーム
+ ニックネームを UTF-8 にエンコードできません
+ ニックネームは 64 文字以内にしてください
+ ニックネームの変更で予期せぬエラーが発生しました
+ %s のプロファイルを削除してもよろしいですか?この操作は元に戻せません。
+ 削除を確認するには「%s」を入力してください
+ 通知
+ 通知 (%s)
+ 通知の管理
+ eSIM プロファイルはダウンロードや削除、有効化や無効化されたときに通信事業者に通知を送信できます。送信されるこれらの通知のキューはここにリストされます。\n\n設定では、各タイプの通知を自動的に送信するかどうかを指定できます。通知が送信された場合でもキューのスペースが不足していない限り、記録から自動的に削除されることはありません。\n\nここでは保留中の各通知を手動で送信または削除できます。
+ ダウンロードしました
+ 削除しました
+ 有効化しました
+ 無効化しました
+ 処理
+ 削除
+ eUICC 情報
+ eUICC 情報 (%s)
+ アクセスモード
+ リムーバブル
+ SGP.22 バージョン
+ eUICC OS のバージョン
+ グローバルプラットフォームのバージョン
+ SAS 認定番号
+ Protected Profileのバージョン
+ NVRAM の空き容量 (eSIM プロファイルストレージ)
+ 証明書の発行者 (CI)
+ GSMA プロダクション CI
+ GSMA テスト CI
+ 未知の eSIM CI
+ はい
+ いいえ
+ 保存
+ %s のログ
+ 開発者になるまであと %d ステップです。
+ あなたは開発者になりました!
+ 設定
+ 通知
+ eSIM のプロファイル操作により、通信事業者に通知が送信されます。ここでは、どのタイプの通知を送信するのかを微調整できます。
+ ダウンロード
+ プロファイルのダウンロード済みの通知を送信します
+ 削除
+ プロファイルの削除済みの通知を送信します
+ 切り替え
+ プロファイルの切り替え済みの通知を送信します\nこのタイプの通知は有効化しても必ず送信するとは限らないことに注意してください。
+ 高度な設定
+ 有効なプロファイルの無効化と削除を許可する
+ デフォルトでは、このアプリでデバイスに挿入された取り外し可能な eSIM の有効なプロファイルを無効化することを防いでいます。なぜなのかというと時々アクセスができなくなるからです。\nこのチェックボックスを ON にすることで、この保護機能を解除します。
+ 詳細ログ
+ 詳細ログを有効化します。これには個人的な情報が含まれている可能性があります。この機能を ON にした後は、信頼できるユーザーとのみログを共有してください。
+ ログ
+ アプリの最新デバッグログを表示します
+ 開発者オプション
+ SM-DP+ TLS 証明書を無視する
+ SM-DP+ TLS 証明書を無視して任意の RSP を許可します
+ 情報
+ アプリバージョン
+ ソースコード
+ 言語
+ アプリの言語を選択
+ すべてのプロファイルを表示
+ プロダクション以外のプロファイルも表示する
+ タイプ:
+ テスティング
+ 準備中
+ 動作中
+
diff --git a/app-common/src/main/res/values-zh-rCN/strings.xml b/app-common/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 00000000..cf517346
--- /dev/null
+++ b/app-common/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,147 @@
+
+
+ 在此设备上未检测到此应用程序可访问的可插拔 eUICC 卡。请插入兼容卡或 USB 读卡器。
+ 此 eSIM 上还没有配置文件
+ 未知
+ 帮助
+ 重新加载卡槽
+ 逻辑卡槽 %d
+ 已启用
+ 已禁用
+ 提供商:
+ 类型:
+ 启用
+ 禁用
+ 删除
+ 重命名
+ 等待 eSIM 芯片切换配置文件时超时。这可能是您手机基带固件中的一个错误。请尝试切换飞行模式、重新启动应用程序或重新启动手机
+ 操作成功, 但是您手机的基带拒绝刷新。您可能需要切换飞行模式或重新启动,以便使用新的配置文件。
+ 无法切换到新的 eSIM 配置文件。
+ 输入的确认文本不匹配
+ 已复制 ICCID 到剪贴板
+ 已复制 EID 到剪贴板
+ 已复制 ATR 到剪贴板
+ 授予 USB 权限
+ 需要获得访问 USB 智能卡读卡器的权限。
+ 无法通过 USB 智能卡读卡器连接到 eSIM。
+ 长时间运行的后台任务
+ 正在下载 eSIM 配置文件
+ 无法下载 eSIM 配置文件
+ 正在重命名 eSIM 配置文件
+ 无法重命名 eSIM 配置文件
+ 正在删除 eSIM 配置文件
+ 无法删除 eSIM 配置文件
+ 正在切换 eSIM 配置文件
+ 无法切换 eSIM 配置文件
+ 添加新 eSIM
+ 服务器 (RSP / SM-DP+)
+ 激活码
+ 确认码 (可选)
+ IMEI (可选)
+ 本次下载可能会失败
+ 当前芯片的剩余空间不足,可能导致配置下载失败。\n是否继续下载?
+ 日志已保存到指定路径。需要通过其他 App 分享吗?
+ 新昵称
+ 无法将昵称编码为 UTF-8
+ 昵称长于 64 字符
+ 重命名配置文件时发生了未知错误
+ 您确定要删除 %s 吗?此操作是不可逆的。
+ 请输入\'%s\'以确认删除
+ 通知列表
+ 通知列表 (%s)
+ 管理通知
+ eSIM 配置文件可以在下载、删除、启用或禁用时向运营商发送通知。此处列出了要发送的这些通知的队列。\n\n在\"设置\"中,您可以指定是否自动发送每种类型的通知。请注意,即使通知已发送,也不会自动从记录中删除,除非队列空间不足。\n\n在这里,您可以手动发送或删除每个待处理的通知。
+ 已下载
+ 已删除
+ 已启用
+ 已禁用
+ 处理
+ 删除
+ 保存日志
+ %s 的日志
+ 设置
+ 通知
+ 操作 eSIM 配置文件会向运营商发送通知。根据需要在此处微调此行为。
+ 下载
+ 发送 下载 配置文件的通知
+ 删除
+ 发送 删除 配置文件的通知
+ 切换
+ 发送 切换 配置文件的通知\n注意,这种类型的通知是不可靠的。
+ 高级
+ 允许 禁用/删除 已启用的配置文件
+ 默认情况下,此应用程序会阻止您禁用可插拔 eSIM 中已启用的配置文件。\n因为这样做 有时 会使其无法访问。\n勾选此框以 移除 此保护措施。
+ 记录详细日志
+ 详细日志中包含敏感信息,开启此功能后请仅与你信任的人共享你的日志。
+ 日志
+ 查看应用程序的最新调试日志
+ 信息
+ App 版本
+ 源码
+ 测试
+ 准备中
+ 可用
+ 未在剪贴板上发现 LPA 码
+ LPA 码解析错误
+ 无法将二维码或剪贴板内容解析为 LPA 码
+ 下载向导
+ 返回
+ 下一步
+ 您选择的 SIM 已被移除
+ 请选择或确认下载目标 eSIM 卡槽:
+ 类型:
+ 可插拔
+ 内置
+ 内置, 端口 %d
+ 当前配置文件:
+ 剩余空间:
+ 您想要如何下载 eSIM 配置文件?
+ 用相机扫描二维码
+ 从图库选择二维码
+ 从剪贴板读取
+ 手动输入
+ 请输入或确认下载 eSIM 的详细信息:
+ 正在下载您的 eSIM...
+ 准备中
+ 正在连接服务器
+ 正在向服务器认证您的设备
+ 正在下载 eSIM 配置文件
+ 正在写入 eSIM 配置文件
+ 错误诊断
+ 错误代码: %s
+ 上次 HTTP 状态码 (来自服务器): %d
+ 上次 HTTP 应答 (来自服务器):
+ 上次 HTTP 错误:
+ 上次 APDU 应答 (来自 SIM): %s
+ 上次 APDU 应答 (来自 SIM) 是成功的
+ 上次 APDU 应答 (来自 SIM) 是失败的
+ 上次 APDU 错误:
+ 保存
+ %s 的错误诊断
+ eUICC 详情
+ eUICC 详情 (%s)
+ 访问方式
+ 可插拔
+ SGP.22 版本
+ eUICC OS 版本
+ GlobalPlatform 版本
+ SAS 认证号码
+ Protected Profile 版本
+ NVRAM 剩余空间 (eSIM 存储容量)
+ 证书签发者 (CI)
+ GSMA 生产环境 CI
+ GSMA 测试 CI
+ 未知 eSIM CI
+ 是
+ 否
+ 还有 %d 步成为开发者
+ 你现在是开发者了!
+ 语言
+ 选择 App 语言
+ 开发者选项
+ 显示未经过滤的配置文件列表
+ 在配置文件列表中包括非生产环境的配置文件
+ 无视 SM-DP+ 的 TLS 证书
+ 允许 RSP 服务器使用任意证书
+ 无信息
+
\ No newline at end of file
diff --git a/app-common/src/main/res/values/strings.xml b/app-common/src/main/res/values/strings.xml
index 0278ffae..d3bce002 100644
--- a/app-common/src/main/res/values/strings.xml
+++ b/app-common/src/main/res/values/strings.xml
@@ -3,16 +3,22 @@
No removable eUICC card accessible by this app is detected on this device. Insert a compatible card or a USB reader.
No profiles (yet) on this eSIM.
Unknown
+ Information Unavailable
Help
Reload Slots
Logical Slot %d
- USB
+ USB
+ OpenMobile API (OMAPI)
Enabled
Disabled
Provider:
- ICCID:
+ Class:
+ Testing
+ Provisioning
+ Operational
+ ICCID:
Enable
Disable
@@ -23,11 +29,10 @@
The operation was successful, but your phone\'s modem refused to refresh. You might need to toggle airplane mode or reboot in order to use the new profile.
Cannot switch to new eSIM profile.
- Nickname cannot be longer than 64 characters
+ Confirmation string mismatch
ICCID copied to clipboard
-
- Select Slot
- Select
+ EID copied to clipboard
+ ATR copied to clipboard
Grant USB permission
Permission is needed to access the USB smart card reader.
@@ -48,13 +53,55 @@
Activation Code
Confirmation Code (Optional)
IMEI (Optional)
- Space remaining: %s
- Scan QR Code
- Scan QR Code from Gallery
- Download
- Failed to download eSIM. Check your activation / QR code.
+
+ This download may fail
+ This download may fail due to low remaining capacity.
+ No LPA code found in clipboard
+ Unable to parse
+ Could not parse QR code or clipboard content as a LPA code.
+
+ Download Wizard
+ Back
+ Next
+ Selected SIM has been removed
+ Select or confirm the eSIM you would like to download to:
+ Type:
+ Removable
+ Internal
+ Internal, port %d
+ eID:
+ Active Profile:
+ Free Space:
+ How would you like to download the eSIM profile?
+ Scan a QR code with camera
+ Load a QR code from gallery
+ Load from Clipboard
+ Enter manually
+ Input or confirm details for downloading your eSIM:
+ Downloading your eSIM…
+ Preparing
+ Establishing connection to server
+ Authenticating your device with server
+ Downloading eSIM profile
+ Loading eSIM profile into storage
+ Error diagnostics
+ Error code: %s
+ Last HTTP status (from server): %d
+ Last HTTP response (from server):
+ Last HTTP exception:
+ Last APDU response (from SIM): %s
+ Last APDU response (from SIM) is successful
+ Last APDU response (from SIM) is a failure
+ Last APDU exception:
+ Save
+ Diagnostics at %s
+
+ Logs have been saved to the selected path. Would you like to share the log through another app?
New nickname
+ Failed to encode nickname as UTF-8
+ Nickname is longer than 64 characters
+ Unknown failure when renaming profile
Are you sure you want to delete the profile %s? This operation is irreversible.
Type \'%s\' here to confirm deletion
@@ -67,13 +114,37 @@
Deleted
Enabled
Disabled
- <b>%1$s</b> %2$s (%3$s)
+ <b>%1$s</b> %2$s (%3$s)
+ #%d
Process
Delete
+ eUICC Info
+ eUICC Info (%s)
+ Access Mode
+ Removable
+ EID
+ SGP.22 Version
+ eUICC OS Version
+ GlobalPlatform Version
+ SAS Accreditation Number
+ Protected Profile Version
+ Free NVRAM (eSIM profile storage)
+ Certificate Issuer (CI)
+ GSMA Live CI
+ GSMA Test CI
+ Unknown eSIM CI
+ Answer To Reset (ATR)
+
+ Yes
+ No
+
Save
Logs at %s
+ You are %d steps away from being a developer.
+ You are now a developer!
+
Settings
Notifications
eSIM profile operations send notifications to the carrier. Fine-tune this behavior as needed here.
@@ -88,8 +159,15 @@
By default, this app prevents you from disabling the active profile on a removable eSIM inserted in the device, because doing so may sometimes render it inaccessible.\nCheck this box to remove this safeguard.
Verbose Logging
Enable verbose logs, which may contain sensitive information. Only share your logs with someone you trust after turning this on.
+ Language
+ Select app language
Logs
View recent debug logs of the application
+ Developer Options
+ Show unfiltered profile list
+ Include non-production profiles in the list
+ Ignore SM-DP+ TLS certificate
+ Accept any TLS certificate used by the RSP server
Info
App Version
Source Code
diff --git a/app-common/src/main/res/xml/locale_config.xml b/app-common/src/main/res/xml/locale_config.xml
new file mode 100644
index 00000000..e1a13f82
--- /dev/null
+++ b/app-common/src/main/res/xml/locale_config.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-common/src/main/res/xml/pref_settings.xml b/app-common/src/main/res/xml/pref_settings.xml
index 53395ed8..bb5bd505 100644
--- a/app-common/src/main/res/xml/pref_settings.xml
+++ b/app-common/src/main/res/xml/pref_settings.xml
@@ -1,5 +1,6 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+ app:key="pref_info_source_code">
+
+
\ No newline at end of file
diff --git a/app-deps/.gitignore b/app-deps/.gitignore
index 42afabfd..c23e5a2d 100644
--- a/app-deps/.gitignore
+++ b/app-deps/.gitignore
@@ -1 +1,2 @@
-/build
\ No newline at end of file
+/build
+/libs
\ No newline at end of file
diff --git a/app-unpriv/build.gradle.kts b/app-unpriv/build.gradle.kts
index 0714529d..5eaa4df2 100644
--- a/app-unpriv/build.gradle.kts
+++ b/app-unpriv/build.gradle.kts
@@ -49,6 +49,10 @@ android {
kotlinOptions {
jvmTarget = "1.8"
}
+ dependenciesInfo {
+ // Disable dependency metadata -- breaks compatibility with F-Droid
+ includeInApk = false
+ }
}
dependencies {
diff --git a/app-unpriv/src/jmp/AndroidManifest.xml b/app-unpriv/src/jmp/AndroidManifest.xml
index d9058f08..4ab370fa 100644
--- a/app-unpriv/src/jmp/AndroidManifest.xml
+++ b/app-unpriv/src/jmp/AndroidManifest.xml
@@ -9,5 +9,6 @@
android:roundIcon="@mipmap/ic_launcher_jmp"
android:label="@string/app_name"
android:supportsRtl="true"
+ android:localeConfig="@xml/locale_config"
android:theme="@style/Theme.OpenEUICC" />
\ No newline at end of file
diff --git a/app-unpriv/src/jmp/java/im/angry/openeuicc/di/JmpAppContainer.kt b/app-unpriv/src/jmp/java/im/angry/openeuicc/di/JmpAppContainer.kt
index 9306a6af..aae1bb72 100644
--- a/app-unpriv/src/jmp/java/im/angry/openeuicc/di/JmpAppContainer.kt
+++ b/app-unpriv/src/jmp/java/im/angry/openeuicc/di/JmpAppContainer.kt
@@ -6,4 +6,8 @@ class JmpAppContainer(context: Context) : UnprivilegedAppContainer(context) {
override val uiComponentFactory by lazy {
JmpUiComponentFactory()
}
+
+ override val customizableTextProvider by lazy {
+ JmpCustomizableTextProvider(context)
+ }
}
\ No newline at end of file
diff --git a/app-unpriv/src/jmp/java/im/angry/openeuicc/di/JmpCustomizableTextProvider.kt b/app-unpriv/src/jmp/java/im/angry/openeuicc/di/JmpCustomizableTextProvider.kt
new file mode 100644
index 00000000..cc46cb31
--- /dev/null
+++ b/app-unpriv/src/jmp/java/im/angry/openeuicc/di/JmpCustomizableTextProvider.kt
@@ -0,0 +1,12 @@
+package im.angry.openeuicc.di
+
+import android.content.Context
+import im.angry.easyeuicc.R
+
+class JmpCustomizableTextProvider(private val context: Context) :
+ UnprivilegedCustomizableTextProvider(context) {
+ override val noEuiccExplanation: String
+ get() = context.getString(R.string.no_euicc_jmp)
+ override val profileSwitchingTimeoutMessage: String
+ get() = context.getString(R.string.enable_disable_timeout_jmp)
+}
\ No newline at end of file
diff --git a/app-unpriv/src/jmp/res/layout/fragment_no_euicc_placeholder_jmp.xml b/app-unpriv/src/jmp/res/layout/fragment_no_euicc_placeholder_jmp.xml
index 43bd343f..6253eb20 100644
--- a/app-unpriv/src/jmp/res/layout/fragment_no_euicc_placeholder_jmp.xml
+++ b/app-unpriv/src/jmp/res/layout/fragment_no_euicc_placeholder_jmp.xml
@@ -11,7 +11,7 @@
android:layout_marginStart="40dp"
android:layout_marginEnd="40dp"
android:gravity="center"
- android:text="@string/no_euicc"
+ android:text="@string/no_euicc_jmp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
diff --git a/app-unpriv/src/jmp/res/values-ja/strings.xml b/app-unpriv/src/jmp/res/values-ja/strings.xml
new file mode 100644
index 00000000..fdbd39d9
--- /dev/null
+++ b/app-unpriv/src/jmp/res/values-ja/strings.xml
@@ -0,0 +1,6 @@
+
+
+ このデバイスで JMP eSIM Adapter を見つかりません。JMP eSIM Adapter をデバイスに挿入、または USB リーダーに経由し接続してください。
+ JMP eSIM Adapter を購入
+ eSIM チップがプロファイルの切り替えの待機中にタイムアウトしました。 SIM ツールキットの Tools -> Reboot を選択し、eSIM Adapter をリフレッシュしてください。
+
\ No newline at end of file
diff --git a/app-unpriv/src/jmp/res/values-zh-rCN/strings.xml b/app-unpriv/src/jmp/res/values-zh-rCN/strings.xml
new file mode 100644
index 00000000..f74a5ced
--- /dev/null
+++ b/app-unpriv/src/jmp/res/values-zh-rCN/strings.xml
@@ -0,0 +1,6 @@
+
+
+ 没有在此设备上发现 JMP eSIM Adapter。请将其插入本设备或 USB 读卡器。
+ 购入 JMP eSIM Adapter
+ 等待 eSIM 芯片切换配置文件超时。请使用 SIM Toolkit 中的 Tools -> Reboot 手动刷新 eSIM Adapter。
+
\ No newline at end of file
diff --git a/app-unpriv/src/jmp/res/values/strings.xml b/app-unpriv/src/jmp/res/values/strings.xml
index 45b28aa1..985b9d9b 100644
--- a/app-unpriv/src/jmp/res/values/strings.xml
+++ b/app-unpriv/src/jmp/res/values/strings.xml
@@ -1,10 +1,10 @@
JMP SIM Manager
- No JMP eSIM Adapter found on this device. Insert one into the device or through a USB card reader.
+ No JMP eSIM Adapter found on this device. Insert one into the device or through a USB card reader.
Buy JMP eSIM Adapter
https://jmp.chat/esim-adapter
https://gitea.angry.im/jmp-sim/jmp-sim-manager
- Timed out waiting for the eSIM chip to switch profiles. Please manually refresh the eSIM adapter by going to SIM Toolkit, and select Tools -> Reboot.
+ Timed out waiting for the eSIM chip to switch profiles. Please manually refresh the eSIM adapter by going to SIM Toolkit, and select Tools -> Reboot.
\ No newline at end of file
diff --git a/app-unpriv/src/main/AndroidManifest.xml b/app-unpriv/src/main/AndroidManifest.xml
index e72b1124..ce985cdc 100644
--- a/app-unpriv/src/main/AndroidManifest.xml
+++ b/app-unpriv/src/main/AndroidManifest.xml
@@ -1,5 +1,6 @@
-
+
+ android:localeConfig="@xml/locale_config"
+ android:theme="@style/Theme.OpenEUICC"
+ tools:targetApi="tiramisu">
+ android:exported="false"
+ android:label="@string/compatibility_check" />
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedAppContainer.kt b/app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedAppContainer.kt
index e424b1fd..cfd9e43e 100644
--- a/app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedAppContainer.kt
+++ b/app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedAppContainer.kt
@@ -6,4 +6,8 @@ open class UnprivilegedAppContainer(context: Context) : DefaultAppContainer(cont
override val uiComponentFactory by lazy {
UnprivilegedUiComponentFactory()
}
+
+ override val customizableTextProvider by lazy {
+ UnprivilegedCustomizableTextProvider(context)
+ }
}
\ No newline at end of file
diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedCustomizableTextProvider.kt b/app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedCustomizableTextProvider.kt
new file mode 100644
index 00000000..807f66d4
--- /dev/null
+++ b/app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedCustomizableTextProvider.kt
@@ -0,0 +1,10 @@
+package im.angry.openeuicc.di
+
+import android.content.Context
+import im.angry.easyeuicc.R
+
+open class UnprivilegedCustomizableTextProvider(private val context: Context) :
+ DefaultCustomizableTextProvider(context) {
+ override fun formatInternalChannelName(logicalSlotId: Int): String =
+ context.getString(R.string.channel_name_format_unpriv, logicalSlotId)
+}
\ No newline at end of file
diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedUiComponentFactory.kt b/app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedUiComponentFactory.kt
index b62231c7..820e796c 100644
--- a/app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedUiComponentFactory.kt
+++ b/app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedUiComponentFactory.kt
@@ -1,9 +1,19 @@
package im.angry.openeuicc.di
import androidx.fragment.app.Fragment
+import im.angry.openeuicc.ui.EuiccManagementFragment
+import im.angry.openeuicc.ui.SettingsFragment
+import im.angry.openeuicc.ui.UnprivilegedEuiccManagementFragment
import im.angry.openeuicc.ui.UnprivilegedNoEuiccPlaceholderFragment
+import im.angry.openeuicc.ui.UnprivilegedSettingsFragment
open class UnprivilegedUiComponentFactory : DefaultUiComponentFactory() {
+ override fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment =
+ UnprivilegedEuiccManagementFragment.newInstance(slotId, portId)
+
override fun createNoEuiccPlaceholderFragment(): Fragment =
UnprivilegedNoEuiccPlaceholderFragment()
+
+ override fun createSettingsFragment(): Fragment =
+ UnprivilegedSettingsFragment()
}
\ No newline at end of file
diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/ui/CompatibilityCheckActivity.kt b/app-unpriv/src/main/java/im/angry/openeuicc/ui/CompatibilityCheckActivity.kt
index 06b46df5..39a360f9 100644
--- a/app-unpriv/src/main/java/im/angry/openeuicc/ui/CompatibilityCheckActivity.kt
+++ b/app-unpriv/src/main/java/im/angry/openeuicc/ui/CompatibilityCheckActivity.kt
@@ -1,7 +1,8 @@
package im.angry.openeuicc.ui
+import android.annotation.SuppressLint
import android.os.Bundle
-import android.util.Log
+import android.text.Html
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
@@ -9,6 +10,7 @@ import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.children
+import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@@ -30,15 +32,16 @@ class CompatibilityCheckActivity: AppCompatActivity() {
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
- compatibilityCheckList = requireViewById(R.id.recycler_view)
- compatibilityCheckList.layoutManager =
- LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
- compatibilityCheckList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
- compatibilityCheckList.adapter = adapter
+ compatibilityCheckList = requireViewById(R.id.recycler_view).also {
+ it.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
+ it.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
+ it.adapter = adapter
+ }
setupRootViewInsets(compatibilityCheckList)
}
+ @SuppressLint("NotifyDataSetChanged")
override fun onStart() {
super.onStart()
lifecycleScope.launch {
@@ -62,26 +65,19 @@ class CompatibilityCheckActivity: AppCompatActivity() {
fun bindItem(item: CompatibilityCheck) {
titleView.text = item.title
- descView.text = item.description
+ descView.text = Html.fromHtml(item.description, Html.FROM_HTML_MODE_COMPACT)
statusContainer.children.forEach {
- it.visibility = View.GONE
+ it.isVisible = false
}
- when (item.state) {
- CompatibilityCheck.State.SUCCESS -> {
- root.requireViewById(R.id.compatibility_check_checkmark).visibility = View.VISIBLE
- }
- CompatibilityCheck.State.FAILURE -> {
- root.requireViewById(R.id.compatibility_check_error).visibility = View.VISIBLE
- }
- CompatibilityCheck.State.FAILURE_UNKNOWN -> {
- root.requireViewById(R.id.compatibility_check_unknown).visibility = View.VISIBLE
- }
- else -> {
- root.requireViewById(R.id.compatibility_check_progress_bar).visibility = View.VISIBLE
- }
+ val viewId = when (item.state) {
+ CompatibilityCheck.State.SUCCESS -> R.id.compatibility_check_checkmark
+ CompatibilityCheck.State.FAILURE -> R.id.compatibility_check_error
+ CompatibilityCheck.State.FAILURE_UNKNOWN -> R.id.compatibility_check_unknown
+ else -> R.id.compatibility_check_progress_bar
}
+ root.requireViewById(viewId).isVisible = true
}
}
diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedEuiccManagementFragment.kt b/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedEuiccManagementFragment.kt
new file mode 100644
index 00000000..fad03fdf
--- /dev/null
+++ b/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedEuiccManagementFragment.kt
@@ -0,0 +1,31 @@
+package im.angry.openeuicc.ui
+
+import android.view.Menu
+import android.view.MenuInflater
+import im.angry.easyeuicc.R
+import im.angry.openeuicc.util.SIMToolkit
+import im.angry.openeuicc.util.newInstanceEuicc
+import im.angry.openeuicc.util.slotId
+
+
+class UnprivilegedEuiccManagementFragment : EuiccManagementFragment() {
+ companion object {
+ const val TAG = "UnprivilegedEuiccManagementFragment"
+
+ fun newInstance(slotId: Int, portId: Int): EuiccManagementFragment =
+ newInstanceEuicc(UnprivilegedEuiccManagementFragment::class.java, slotId, portId)
+ }
+
+ private val stk by lazy {
+ SIMToolkit(requireContext())
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+ super.onCreateOptionsMenu(menu, inflater)
+ inflater.inflate(R.menu.fragment_sim_toolkit, menu)
+ menu.findItem(R.id.open_sim_toolkit).apply {
+ isVisible = stk.isAvailable(slotId)
+ intent = stk.intent(slotId)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedSettingsFragment.kt b/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedSettingsFragment.kt
new file mode 100644
index 00000000..23589a6a
--- /dev/null
+++ b/app-unpriv/src/main/java/im/angry/openeuicc/ui/UnprivilegedSettingsFragment.kt
@@ -0,0 +1,46 @@
+package im.angry.openeuicc.ui
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.widget.Toast
+import androidx.preference.Preference
+import im.angry.easyeuicc.R
+import im.angry.openeuicc.util.encodeHex
+import java.security.MessageDigest
+
+class UnprivilegedSettingsFragment : SettingsFragment() {
+ private val firstSigner by lazy {
+ val packageInfo = requireContext().let {
+ it.packageManager.getPackageInfo(
+ it.packageName,
+ PackageManager.GET_SIGNING_CERTIFICATES,
+ )
+ }
+ packageInfo.signingInfo!!.apkContentsSigners.first().let {
+ MessageDigest.getInstance("SHA-1")
+ .apply { update(it.toByteArray()) }
+ .digest()
+ }
+ }
+
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ super.onCreatePreferences(savedInstanceState, rootKey)
+ addPreferencesFromResource(R.xml.pref_unprivileged_settings)
+ mergePreferenceOverlay("pref_info_overlay", "pref_info")
+
+ requirePreference("pref_info_ara_m").apply {
+ summary = firstSigner.encodeHex()
+ setOnPreferenceClickListener {
+ requireContext().getSystemService(ClipboardManager::class.java)!!
+ .setPrimaryClip(ClipData.newPlainText("ara-m", summary))
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast
+ .makeText(requireContext(), R.string.toast_ara_m_copied, Toast.LENGTH_SHORT)
+ .show()
+ true
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/util/SIMToolkit.kt b/app-unpriv/src/main/java/im/angry/openeuicc/util/SIMToolkit.kt
new file mode 100644
index 00000000..ced813a1
--- /dev/null
+++ b/app-unpriv/src/main/java/im/angry/openeuicc/util/SIMToolkit.kt
@@ -0,0 +1,67 @@
+package im.angry.openeuicc.util
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import androidx.annotation.ArrayRes
+import im.angry.easyeuicc.R
+import im.angry.openeuicc.core.EuiccChannelManager
+
+class SIMToolkit(private val context: Context) {
+ private val slotSelection = getComponentNames(R.array.sim_toolkit_slot_selection)
+
+ private val slots = buildMap {
+ put(0, getComponentNames(R.array.sim_toolkit_slot_1))
+ put(1, getComponentNames(R.array.sim_toolkit_slot_2))
+ }
+
+ private val packageNames = buildSet {
+ addAll(slotSelection.map { it.packageName })
+ addAll(slots.values.flatten().map { it.packageName })
+ }
+
+ private val activities = packageNames.flatMap(::getActivities).toSet()
+
+ private val launchIntent by lazy {
+ packageNames.firstNotNullOfOrNull(::getLaunchIntent)
+ }
+
+ private fun getLaunchIntent(packageName: String) = try {
+ val pm = context.packageManager
+ pm.getLaunchIntentForPackage(packageName)
+ } catch (_: PackageManager.NameNotFoundException) {
+ null
+ }
+
+ private fun getActivities(packageName: String): List {
+ return try {
+ val pm = context.packageManager
+ val packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES)
+ val activities = packageInfo.activities
+ if (activities.isNullOrEmpty()) return emptyList()
+ activities.filter { it.exported }.map { ComponentName(it.packageName, it.name) }
+ } catch (_: PackageManager.NameNotFoundException) {
+ emptyList()
+ }
+ }
+
+ private fun getComponentNames(@ArrayRes id: Int) =
+ context.resources.getStringArray(id).mapNotNull(ComponentName::unflattenFromString)
+
+ fun isAvailable(slotId: Int) = when (slotId) {
+ -1 -> false
+ EuiccChannelManager.USB_CHANNEL_ID -> false
+ else -> intent(slotId) != null
+ }
+
+ fun intent(slotId: Int): Intent? {
+ val components = slots.getOrDefault(slotId, emptySet()) + slotSelection
+ val intent = Intent(Intent.ACTION_MAIN, null).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ component = components.find(activities::contains)
+ addCategory(Intent.CATEGORY_LAUNCHER)
+ }
+ return if (intent.component != null) intent else launchIntent
+ }
+}
diff --git a/app-common/src/main/res/menu/fragment_slot_select.xml b/app-unpriv/src/main/res/menu/fragment_sim_toolkit.xml
similarity index 52%
rename from app-common/src/main/res/menu/fragment_slot_select.xml
rename to app-unpriv/src/main/res/menu/fragment_sim_toolkit.xml
index e129008b..610b3a12 100644
--- a/app-common/src/main/res/menu/fragment_slot_select.xml
+++ b/app-unpriv/src/main/res/menu/fragment_sim_toolkit.xml
@@ -2,8 +2,8 @@
\ No newline at end of file
diff --git a/app-unpriv/src/main/res/values-ja/strings.xml b/app-unpriv/src/main/res/values-ja/strings.xml
new file mode 100644
index 00000000..053a8d12
--- /dev/null
+++ b/app-unpriv/src/main/res/values-ja/strings.xml
@@ -0,0 +1,34 @@
+
+
+ 互換性のチェック
+ SIM ツールキットを開く
+
+ システムの機能
+ デバイスにリムーバブル eUICC カードの管理に必要なすべての機能が備わっているかどうか。例えば基本的な電話機能や OMAPI のサポートなど。
+ 使用しているデバイスには電話機能がありません。
+ 使用しているデバイスまたはシステムには OMAPI のサポートを宣言していません。これは、ハードウェアからのサポートが不足していることが原因の可能性があります。または、フラグが不足していることが原因の可能性もあります。OMAPI が実際にサポートされているかどうかを判断するには次の 2 つのチェック項目を参照してください。
+ OMAPI の接続
+ 使用しているデバイスは、OMAPI 経由で SIM カード上のセキュアエレメントへのアクセスを許可していますか?
+ OMAPI 経由で SIM カードのセキュアエレメントリーダーを検出できません。このデバイスに SIM を挿入していない場合は、SIM を挿入後にこのチェックを再試行してください。
+ セキュアエレメントアクセスが正常に検出されましたが、次の SIM スロットでのみ有効です: SIM%s.
+ ISD-R チャネルアクセス
+ 使用しているデバイスは、OMAPI 経由で eSIM への ISD-R (管理) チャネルを開くことをサポートしていますか?
+ OMAPI 経由での ISD-R アクセスがサポートされているかどうかを確認できません。まだ SIM カードが挿入されていない場合は、挿入した状態で再試行してください (どの SIM カードでも構いません)。
+ ISD-R への OMAPI アクセスは、次のスロットでのみ可能です: SIM%s.
+ 既知の破損リストに掲載されていない
+ 取り外し可能な eSIM に関連するバグがデバイスに存在しないかを確認します。
+ おっと…使用しているデバイスには、取り外し可能な eSIM へのアクセス時にバグが存在します。これは必ずしも全く機能しないことを意味するわけではありませんが、注意して進める必要があります。
+ USB カードリーダーのサポート
+ 使用しているデバイスは、USB カードリーダー経由の eSIM の管理をサポートしていますか?
+ このデバイスの標準 USB CCID リーダーを介して eSIM を管理できます (ここで他のチェック項目に失敗した場合でも)。カードリーダーを挿入し、このアプリを開いてこの方法で eSIM を管理できます。
+ 使用しているデバイスは USB ホストとしての機能をサポートしていません。
+ 判定 (USB 以外)
+ これまでのすべてのチェック項目に基づいて、デバイスに挿入された取り外し可能な eSIM の管理と互換性がある可能性はどのくらいありますか?
+ このデバイスに挿入された取り外し可能な eSIM の使用および管理が使用できる可能性があります。
+ 挿入された取り外し可能な eSIM にアクセスするとデバイスにバグが発生することが知られています。\n%s
+ 挿入された取り外し可能な eSIM が使用しているデバイスで管理できるかはわかりません。ただし、このデバイスは OMAPI のサポートを宣言しているため、動作する可能性はわずかに高くなります。\n%s
+ 挿入された取り外し可能な eSIM がデバイス上で管理できるかどうかは判断できません。デバイスが OMAPI のサポートを宣言していないため、このデバイス上で取り外し可能な eSIM を管理することはサポートされていない可能性があります。\n%s
+ 挿入された取り外し可能な eSIM がデバイス上で管理できるかどうかを確認できません。\n%s
+ ただし、eSIM プロファイルがすでに読み込まれている場合、有効化されたプロファイル自体は引き続き機能します。また、プロファイルが管理できない場合は、このデバイスで USB カードリーダーを介してプロファイルを管理できる可能性があります。
+ ARA-M SHA-1 をクリップボードにコピーしました
+
diff --git a/app-unpriv/src/main/res/values-zh-rCN/strings.xml b/app-unpriv/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 00000000..8d3060d6
--- /dev/null
+++ b/app-unpriv/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,32 @@
+
+ 兼容性检查
+ 打开 SIM 卡应用程序
+ 系统功能
+ 您的设备是否具有管理可插拔 eUICC 卡所需的所有功能。例如,基本的电话功能和 OMAPI 支持。
+ 您的设备没有电话功能。
+ 您的设备/系统未声明支持 OMAPI。这可能是由于缺少硬件支持,或者可能仅仅是由于缺少标志。请参阅以下两项检查以确定 OMAPI 是否确实受支持。
+ OMAPI 连接
+ 您的设备是否允许通过 OMAPI 访问 SIM 卡上的安全元件?
+ 无法通过 OMAPI 检测到 SIM 卡的 Secure Element。如果您尚未在此设备中插入 SIM 卡,请尝试插入一张 SIM 卡并重试此检查。
+ 已成功检测到可访问 Secure Element 的卡槽,但仅限于以下 SIM 卡槽:SIM%s。
+ ISD-R 通道访问
+ 您的设备是否支持通过 OMAPI 打开 eSIM 的 ISD-R (管理) 通道?
+ 无法确定是否支持通过 OMAPI 进行 ISD-R 访问。如果尚未插入,您可能需要插入 SIM 卡 (任何 SIM 卡都可以) 重试。
+ OMAPI 只能在以下 SIM 插槽上访问 ISD-R:SIM%s。
+ 不在已知的 BUG 名单中
+ 确保您的设备不存在与可插拔 eSIM 相关的错误。
+ 糟糕,您的设备在访问可插拔 eSIM 时存在错误。这并不表示完全无法使用,但我们不保证该应用在您设备上的行为。
+ USB 读卡器支持
+ 您的设备是否支持通过 USB 读卡器管理 eSIM?
+ 您可以通过此设备上的标准 USB CCID 读取器管理 eSIM (即使您在这里有任何其他检查项失败)。请插入读卡器,然后打开此应用程序以这种方式管理 eSIM。
+ 您的设备不支持 USB 读卡器。
+ 结论 (USB 读卡器以外)
+ 根据之前的所有检查,您的设备与可插拔 eSIM 卡兼容的可能性有多大?
+ 您可以使用和管理插入此设备的可插拔 eSIM 卡。
+ 已知您的设备在访问可插拔 eSIM 卡时存在问题。\n%s
+ 我们无法确定是否可以在您的设备上管理可插拔 eSIM 卡。不过,您的设备确实声明支持 OMAPI,因此它工作的可能性略高。\n%s
+ 我们无法确定是否可以在您的设备上管理可插拔 eSIM 卡。由于您的设备未声明支持OMAPI,因此更有可能不支持在此设备上管理可插拔 eSIM。\n%s
+ 我们无法确定是否可以在您的设备上管理可插拔 eSIM 卡。\n%s
+ 然而,已经加载了eSIM配置文件的可插拔 eSIM 卡仍然可以工作; 即使无法在装置上直接管理可插拔 eSIM 卡中的配置文件,您仍然可以使用 USB 卡读卡器来管理配置文件。
+ ARA-M SHA-1 已拷贝到剪贴板
+
\ No newline at end of file
diff --git a/app-unpriv/src/main/res/values/sim_toolkit.xml b/app-unpriv/src/main/res/values/sim_toolkit.xml
new file mode 100644
index 00000000..1f162715
--- /dev/null
+++ b/app-unpriv/src/main/res/values/sim_toolkit.xml
@@ -0,0 +1,32 @@
+
+
+
+ - com.android.stk/.StkMain
+ - com.android.stk/.StkMainHide
+ - com.android.stk/.StkListActivity
+ - com.android.stk/.StkLauncherListActivity
+
+
+ - com.android.stk/.StkMain1
+ - com.android.stk/.PrimaryStkMain
+ - com.android.stk/.StkLauncherActivity
+ - com.android.stk/.StkLauncherActivity_Chn
+ - com.android.stk/.StkLauncherActivityI
+ - com.android.stk/.OppoStkLauncherActivity1
+ - com.android.stk/.OplusStkLauncherActivity1
+ - com.android.stk/.mtk.StkLauncherActivityI
+
+
+ - com.android.stk/.StkMain2
+ - com.android.stk/.SecondaryStkMain
+ - com.android.stk/.StkLauncherActivity2
+ - com.android.stk/.StkLauncherActivityII
+ - com.android.stk/.OppoStkLauncherActivity2
+ - com.android.stk/.OplusStkLauncherActivity2
+ - com.android.stk/.mtk.StkLauncherActivityII
+ - com.android.stk1/.StkLauncherActivity
+ - com.android.stk2/.StkLauncherActivity
+ - com.android.stk2/.StkLauncherActivity_Chn
+ - com.android.stk2/.StkLauncherActivity2
+
+
\ No newline at end of file
diff --git a/app-unpriv/src/main/res/values/strings.xml b/app-unpriv/src/main/res/values/strings.xml
index 124bedf4..afed295b 100644
--- a/app-unpriv/src/main/res/values/strings.xml
+++ b/app-unpriv/src/main/res/values/strings.xml
@@ -1,7 +1,14 @@
EasyEUICC
- SIM %d
+ SIM %d
Compatibility Check
+ Open SIM Toolkit
+
+
+ ARA-M SHA-1
+
+
+ ARA-M SHA-1 copied to clipboard
System Features
@@ -11,11 +18,11 @@
OMAPI Connectivity
Does your device allow access to Secure Elements on SIM cards via OMAPI?
Unable to detect Secure Element readers for SIM cards via OMAPI. If you have not inserted a SIM in this device, try inserting one and retry this check.
- Successfully detected Secure Element access, but only for the following SIM slots: %s.
+ Successfully detected Secure Element access, but only for the following SIM slots: <b>SIM%s</b>.
ISD-R Channel Access
Does your device support opening an ISD-R (management) channel to eSIMs via OMAPI?
Cannot determine whether ISD-R access through OMAPI is supported. You might want to retry with SIM cards inserted (any SIM card will do) if not already.
- OMAPI access to ISD-R is only possible on the following SIM slots: %s.
+ OMAPI access to ISD-R is only possible on the following SIM slots: <b>SIM%s</b>.
Not on the Known Broken List
Making sure your device is not known to have bugs associated with removable eSIMs.
Oops, your device is known to have bugs when accessing removable eSIMs. This does not necessarily mean that it will not work at all, but you will have to proceed with caution.
diff --git a/app-unpriv/src/main/res/xml/pref_unprivileged_settings.xml b/app-unpriv/src/main/res/xml/pref_unprivileged_settings.xml
new file mode 100644
index 00000000..3281cafe
--- /dev/null
+++ b/app-unpriv/src/main/res/xml/pref_unprivileged_settings.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cd306200..cae19d3b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,7 +5,8 @@
package="im.angry.openeuicc">
+ android:required="true"
+ tools:ignore="UnnecessaryRequiredFeature" />
@@ -19,7 +20,9 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
- android:theme="@style/Theme.OpenEUICC">
+ android:localeConfig="@xml/locale_config"
+ android:theme="@style/Theme.OpenEUICC"
+ tools:targetApi="tiramisu">
@@ -49,6 +52,15 @@
+
+
+
+
diff --git a/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt b/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt
index ce57fb80..b690c794 100644
--- a/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt
+++ b/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelFactory.kt
@@ -3,6 +3,7 @@ package im.angry.openeuicc.core
import android.content.Context
import android.util.Log
import im.angry.openeuicc.OpenEuiccApplication
+import im.angry.openeuicc.R
import im.angry.openeuicc.util.*
import java.lang.IllegalArgumentException
@@ -26,14 +27,17 @@ class PrivilegedEuiccChannelFactory(context: Context) : DefaultEuiccChannelFacto
"Trying TelephonyManager for slot ${port.card.physicalSlotIndex} port ${port.portIndex}"
)
try {
- return EuiccChannel(
+ return EuiccChannelImpl(
+ context.getString(R.string.telephony_manager),
port,
+ intrinsicChannelName = null,
TelephonyManagerApduInterface(
port,
tm,
context.preferenceRepository.verboseLoggingFlow
),
- context.preferenceRepository.verboseLoggingFlow
+ context.preferenceRepository.verboseLoggingFlow,
+ context.preferenceRepository.ignoreTLSCertificateFlow,
)
} catch (e: IllegalArgumentException) {
// Failed
diff --git a/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelManager.kt b/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelManager.kt
index 923bbab8..aaf54902 100644
--- a/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelManager.kt
+++ b/app/src/main/java/im/angry/openeuicc/core/PrivilegedEuiccChannelManager.kt
@@ -28,9 +28,9 @@ class PrivilegedEuiccChannelManager(
}
}
- override fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
+ override suspend fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
appContainer.subscriptionManager.apply {
- findEuiccChannelBySlotBlocking(logicalSlotId)?.let {
+ findEuiccChannelByLogicalSlot(logicalSlotId)?.let {
tryRefreshCachedEuiccInfo(it.cardId)
}
}
diff --git a/app/src/main/java/im/angry/openeuicc/core/TelephonyManagerApduInterface.kt b/app/src/main/java/im/angry/openeuicc/core/TelephonyManagerApduInterface.kt
index ef877b48..6b093680 100644
--- a/app/src/main/java/im/angry/openeuicc/core/TelephonyManagerApduInterface.kt
+++ b/app/src/main/java/im/angry/openeuicc/core/TelephonyManagerApduInterface.kt
@@ -39,7 +39,7 @@ class TelephonyManagerApduInterface(
val hex = aid.encodeHex()
val channel = tm.iccOpenLogicalChannelByPortCompat(port.card.physicalSlotIndex, port.portIndex, hex, 0)
if (channel.status != IccOpenLogicalChannelResponse.STATUS_NO_ERROR || channel.channel == IccOpenLogicalChannelResponse.INVALID_CHANNEL) {
- throw IllegalArgumentException("Cannot open logical channel $hex via TelephonManager on slot ${port.card.physicalSlotIndex} port ${port.portIndex}");
+ throw IllegalArgumentException("Cannot open logical channel $hex via TelephonManager on slot ${port.card.physicalSlotIndex} port ${port.portIndex}")
}
lastChannel = channel.channel
return lastChannel
diff --git a/app/src/main/java/im/angry/openeuicc/di/PrivilegedAppContainer.kt b/app/src/main/java/im/angry/openeuicc/di/PrivilegedAppContainer.kt
index c5896f27..d821e684 100644
--- a/app/src/main/java/im/angry/openeuicc/di/PrivilegedAppContainer.kt
+++ b/app/src/main/java/im/angry/openeuicc/di/PrivilegedAppContainer.kt
@@ -23,4 +23,8 @@ class PrivilegedAppContainer(context: Context) : DefaultAppContainer(context) {
override val euiccChannelFactory by lazy {
PrivilegedEuiccChannelFactory(context)
}
+
+ override val customizableTextProvider by lazy {
+ PrivilegedCustomizableTextProvider(context)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/im/angry/openeuicc/di/PrivilegedCustomizableTextProvider.kt b/app/src/main/java/im/angry/openeuicc/di/PrivilegedCustomizableTextProvider.kt
new file mode 100644
index 00000000..e53832fa
--- /dev/null
+++ b/app/src/main/java/im/angry/openeuicc/di/PrivilegedCustomizableTextProvider.kt
@@ -0,0 +1,10 @@
+package im.angry.openeuicc.di
+
+import android.content.Context
+import im.angry.openeuicc.R
+
+class PrivilegedCustomizableTextProvider(private val context: Context) :
+ DefaultCustomizableTextProvider(context) {
+ override val noEuiccExplanation: String
+ get() = context.getString(R.string.no_euicc_priv)
+}
\ No newline at end of file
diff --git a/app/src/main/java/im/angry/openeuicc/di/PrivilegedUiComponentFactory.kt b/app/src/main/java/im/angry/openeuicc/di/PrivilegedUiComponentFactory.kt
index d3c5cdbd..e5b747ab 100644
--- a/app/src/main/java/im/angry/openeuicc/di/PrivilegedUiComponentFactory.kt
+++ b/app/src/main/java/im/angry/openeuicc/di/PrivilegedUiComponentFactory.kt
@@ -1,10 +1,14 @@
package im.angry.openeuicc.di
-import im.angry.openeuicc.core.EuiccChannel
+import androidx.fragment.app.Fragment
import im.angry.openeuicc.ui.EuiccManagementFragment
import im.angry.openeuicc.ui.PrivilegedEuiccManagementFragment
+import im.angry.openeuicc.ui.PrivilegedSettingsFragment
class PrivilegedUiComponentFactory : DefaultUiComponentFactory() {
- override fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment =
- PrivilegedEuiccManagementFragment.newInstance(channel.slotId, channel.portId)
+ override fun createEuiccManagementFragment(slotId: Int, portId: Int): EuiccManagementFragment =
+ PrivilegedEuiccManagementFragment.newInstance(slotId, portId)
+
+ override fun createSettingsFragment(): Fragment =
+ PrivilegedSettingsFragment()
}
\ No newline at end of file
diff --git a/app/src/main/java/im/angry/openeuicc/service/OpenEuiccService.kt b/app/src/main/java/im/angry/openeuicc/service/OpenEuiccService.kt
index 572b02bb..02b3baf3 100644
--- a/app/src/main/java/im/angry/openeuicc/service/OpenEuiccService.kt
+++ b/app/src/main/java/im/angry/openeuicc/service/OpenEuiccService.kt
@@ -9,12 +9,13 @@ import android.telephony.euicc.DownloadableSubscription
import android.telephony.euicc.EuiccInfo
import android.util.Log
import net.typeblog.lpac_jni.LocalProfileInfo
-import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
+import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.*
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
-import java.lang.IllegalStateException
+import kotlin.IllegalStateException
class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
companion object {
@@ -37,16 +38,10 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
}
private data class EuiccChannelManagerContext(
- val euiccChannelManager: EuiccChannelManager
+ val euiccChannelManagerService: EuiccChannelManagerService
) {
- fun findChannel(physicalSlotId: Int): EuiccChannel? =
- euiccChannelManager.findEuiccChannelByPhysicalSlotBlocking(physicalSlotId)
-
- fun findChannel(slotId: Int, portId: Int): EuiccChannel? =
- euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)
-
- fun findAllChannels(physicalSlotId: Int): List? =
- euiccChannelManager.findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId)
+ val euiccChannelManager
+ get() = euiccChannelManagerService.euiccChannelManager
}
/**
@@ -59,7 +54,7 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
*
* This function cannot be inline because non-local returns may bypass the unbind
*/
- private fun withEuiccChannelManager(fn: EuiccChannelManagerContext.() -> T): T {
+ private fun withEuiccChannelManager(fn: suspend EuiccChannelManagerContext.() -> T): T {
val (binder, unbind) = runBlocking {
bindServiceSuspended(
Intent(
@@ -73,23 +68,24 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
throw RuntimeException("Unable to bind to EuiccChannelManagerService; aborting")
}
- val ret =
- EuiccChannelManagerContext((binder as EuiccChannelManagerService.LocalBinder).service.euiccChannelManager).fn()
+ val localBinder = binder as EuiccChannelManagerService.LocalBinder
+
+ val ret = runBlocking {
+ EuiccChannelManagerContext(localBinder.service).fn()
+ }
unbind()
return ret
}
override fun onGetEid(slotId: Int): String? = withEuiccChannelManager {
- findChannel(slotId)?.lpa?.eID
+ val portId = euiccChannelManager.findFirstAvailablePort(slotId)
+ if (portId < 0) return@withEuiccChannelManager null
+ euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
+ channel.lpa.eID
+ }
}
- // When two eSIM cards are present on one device, the Android settings UI
- // gets confused and sets the incorrect slotId for profiles from one of
- // the cards. This function helps Detect this case and abort early.
- private fun EuiccChannel.profileExists(iccid: String?) =
- lpa.profiles.any { it.iccid == iccid }
-
private fun ensurePortIsMapped(slotId: Int, portId: Int) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return
@@ -114,14 +110,18 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
telephonyManager.simSlotMapping = mappings
return
} catch (_: Exception) {
-
+ // ignore
}
// Sometimes hardware supports one ordering but not the reverse
telephonyManager.simSlotMapping = mappings.reversed()
}
- private fun retryWithTimeout(timeoutMillis: Int, backoff: Int = 1000, f: () -> T?): T? {
+ private suspend fun retryWithTimeout(
+ timeoutMillis: Int,
+ backoff: Int = 1000,
+ f: suspend () -> T?
+ ): T? {
val startTimeMillis = System.currentTimeMillis()
do {
try {
@@ -129,7 +129,7 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
} catch (_: Exception) {
// Ignore
} finally {
- Thread.sleep(backoff.toLong())
+ delay(backoff.toLong())
}
} while (System.currentTimeMillis() - startTimeMillis < timeoutMillis)
return null
@@ -177,38 +177,56 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
}
// TODO: Temporarily enable the slot to access its profiles if it is currently unmapped
- val channel =
- findChannel(slotId) ?: return@withEuiccChannelManager GetEuiccProfileInfoListResult(
+ val port = euiccChannelManager.findFirstAvailablePort(slotId)
+ if (port == -1) {
+ return@withEuiccChannelManager GetEuiccProfileInfoListResult(
RESULT_FIRST_USER,
arrayOf(),
true
)
- val profiles = channel.lpa.profiles.operational.map {
- EuiccProfileInfo.Builder(it.iccid).apply {
- setProfileName(it.name)
- setNickname(it.displayName)
- setServiceProviderName(it.providerName)
- setState(
- when (it.state) {
- LocalProfileInfo.State.Enabled -> EuiccProfileInfo.PROFILE_STATE_ENABLED
- LocalProfileInfo.State.Disabled -> EuiccProfileInfo.PROFILE_STATE_DISABLED
- }
- )
- setProfileClass(
- when (it.profileClass) {
- LocalProfileInfo.Clazz.Testing -> EuiccProfileInfo.PROFILE_CLASS_TESTING
- LocalProfileInfo.Clazz.Provisioning -> EuiccProfileInfo.PROFILE_CLASS_PROVISIONING
- LocalProfileInfo.Clazz.Operational -> EuiccProfileInfo.PROFILE_CLASS_OPERATIONAL
- }
- )
- }.build()
}
- return@withEuiccChannelManager GetEuiccProfileInfoListResult(
- RESULT_OK,
- profiles.toTypedArray(),
- channel.removable
- )
+ return@withEuiccChannelManager try {
+ euiccChannelManager.withEuiccChannel(slotId, port) { channel ->
+ val filteredProfiles =
+ if (preferenceRepository.unfilteredProfileListFlow.first())
+ channel.lpa.profiles
+ else
+ channel.lpa.profiles.operational
+ val profiles = filteredProfiles.map {
+ EuiccProfileInfo.Builder(it.iccid).apply {
+ setProfileName(it.name)
+ setNickname(it.displayName)
+ setServiceProviderName(it.providerName)
+ setState(
+ when (it.state) {
+ LocalProfileInfo.State.Enabled -> EuiccProfileInfo.PROFILE_STATE_ENABLED
+ LocalProfileInfo.State.Disabled -> EuiccProfileInfo.PROFILE_STATE_DISABLED
+ }
+ )
+ setProfileClass(
+ when (it.profileClass) {
+ LocalProfileInfo.Clazz.Testing -> EuiccProfileInfo.PROFILE_CLASS_TESTING
+ LocalProfileInfo.Clazz.Provisioning -> EuiccProfileInfo.PROFILE_CLASS_PROVISIONING
+ LocalProfileInfo.Clazz.Operational -> EuiccProfileInfo.PROFILE_CLASS_OPERATIONAL
+ }
+ )
+ }.build()
+ }
+
+ GetEuiccProfileInfoListResult(
+ RESULT_OK,
+ profiles.toTypedArray(),
+ channel.port.card.isRemovable
+ )
+ }
+ } catch (e: EuiccChannelManager.EuiccChannelNotFoundException) {
+ GetEuiccProfileInfoListResult(
+ RESULT_FIRST_USER,
+ arrayOf(),
+ true
+ )
+ }
}
override fun onGetEuiccInfo(slotId: Int): EuiccInfo {
@@ -219,39 +237,26 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
Log.i(TAG, "onDeleteSubscription slotId=$slotId iccid=$iccid")
if (shouldIgnoreSlot(slotId)) return@withEuiccChannelManager RESULT_FIRST_USER
- try {
- val channels =
- findAllChannels(slotId) ?: return@withEuiccChannelManager RESULT_FIRST_USER
+ val ports = euiccChannelManager.findAvailablePorts(slotId)
+ if (ports.isEmpty()) return@withEuiccChannelManager RESULT_FIRST_USER
- if (!channels[0].profileExists(iccid)) {
- return@withEuiccChannelManager RESULT_FIRST_USER
+ // Check that the profile has been disabled on all slots
+ val enabledAnywhere = ports.any { port ->
+ euiccChannelManager.withEuiccChannel(slotId, port) { channel ->
+ channel.lpa.profiles.enabled?.iccid == iccid
}
+ }
- // If the profile is enabled by ANY channel (port), we cannot delete it
- channels.forEach { channel ->
- val profile = channel.lpa.profiles.find {
- it.iccid == iccid
- } ?: return@withEuiccChannelManager RESULT_FIRST_USER
+ if (enabledAnywhere) return@withEuiccChannelManager RESULT_FIRST_USER
- if (profile.state == LocalProfileInfo.State.Enabled) {
- // Must disable the profile first
- return@withEuiccChannelManager RESULT_FIRST_USER
- }
- }
+ euiccChannelManagerService.waitForForegroundTask()
+ val success = euiccChannelManagerService.launchProfileDeleteTask(slotId, ports[0], iccid)
+ .waitDone() == null
- euiccChannelManager.beginTrackedOperationBlocking(channels[0].slotId, channels[0].portId) {
- if (channels[0].lpa.deleteProfile(iccid)) {
- return@withEuiccChannelManager RESULT_OK
- }
-
- runBlocking {
- preferenceRepository.notificationDeleteFlow.first()
- }
- }
-
- return@withEuiccChannelManager RESULT_FIRST_USER
- } catch (e: Exception) {
- return@withEuiccChannelManager RESULT_FIRST_USER
+ return@withEuiccChannelManager if (success) {
+ RESULT_OK
+ } else {
+ RESULT_FIRST_USER
}
}
@@ -274,51 +279,90 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
if (shouldIgnoreSlot(slotId)) return@withEuiccChannelManager RESULT_FIRST_USER
try {
+ // First, try to find a pair of slotId and portId we can use for the switching operation
// retryWithTimeout is needed here because this function may be called just after
// AOSP has switched slot mappings, in which case the slots may not be ready yet.
- val channel = if (portIndex == -1) {
- retryWithTimeout(5000) { findChannel(slotId) }
- } else {
- retryWithTimeout(5000) { findChannel(slotId, portIndex) }
+ val (foundSlotId, foundPortId) = retryWithTimeout(5000) {
+ if (portIndex == -1) {
+ // If port is not indicated, we can use any port
+ val port = euiccChannelManager.findFirstAvailablePort(slotId).let {
+ if (it < 0) {
+ throw IllegalStateException("No mapped port available; may need to try again")
+ }
+
+ it
+ }
+
+ Pair(slotId, port)
+ } else {
+ // Else, check until the indicated port is available
+ euiccChannelManager.withEuiccChannel(slotId, portIndex) { channel ->
+ if (!channel.valid) {
+ throw IllegalStateException("Indicated slot / port combination is unavailable; may need to try again")
+ }
+ }
+
+ Pair(slotId, portIndex)
+ }
} ?: run {
+ // Failure case: mapped slots / ports aren't usable per constraints
+ // If we can't find a usable slot / port already mapped, and we aren't allowed to
+ // deactivate a SIM, we can only abort
if (!forceDeactivateSim) {
- // The user must select which SIM to deactivate
return@withEuiccChannelManager RESULT_MUST_DEACTIVATE_SIM
+ }
+
+ // If port ID is not indicated, we just try to map port 0
+ // This is because in order to get here, we have to have failed findFirstAvailablePort(),
+ // which means no eUICC port is mapped or connected properly whatsoever.
+ val foundPortId = if (portIndex == -1) {
+ 0
} else {
- try {
- // If we are allowed to deactivate any SIM we like, try mapping the indicated port first
- ensurePortIsMapped(slotId, portIndex)
- retryWithTimeout(5000) { findChannel(slotId, portIndex) }
- } catch (e: Exception) {
- // We cannot map the port (or it is already mapped)
- // but we can also use any port available on the card
- retryWithTimeout(5000) { findChannel(slotId) }
+ portIndex
+ }
+
+ // Now we can try to map an unused port
+ try {
+ ensurePortIsMapped(slotId, foundPortId)
+ } catch (_: Exception) {
+ return@withEuiccChannelManager RESULT_FIRST_USER
+ }
+
+ // Wait for availability again
+ retryWithTimeout(5000) {
+ euiccChannelManager.withEuiccChannel(slotId, foundPortId) { channel ->
+ if (!channel.valid) {
+ throw IllegalStateException("Indicated slot / port combination is unavailable; may need to try again")
+ }
+ }
+ } ?: return@withEuiccChannelManager RESULT_FIRST_USER
+
+ Pair(slotId, foundPortId)
+ }
+
+ Log.i(TAG, "Found slotId=$foundSlotId, portId=$foundPortId for switching")
+
+ // Now, figure out what they want us to do: disabling a profile, or enabling a new one?
+ val (foundIccid, enable) = if (iccid == null) {
+ // iccid == null means disabling
+ val foundIccid =
+ euiccChannelManager.withEuiccChannel(foundSlotId, foundPortId) { channel ->
+ channel.lpa.profiles.enabled?.iccid
} ?: return@withEuiccChannelManager RESULT_FIRST_USER
- }
+ Pair(foundIccid, false)
+ } else {
+ Pair(iccid, true)
}
- if (iccid != null && !channel.profileExists(iccid)) {
- Log.i(TAG, "onSwitchToSubscriptionWithPort iccid=$iccid not found")
- return@withEuiccChannelManager RESULT_FIRST_USER
- }
+ val res = euiccChannelManagerService.launchProfileSwitchTask(
+ foundSlotId,
+ foundPortId,
+ foundIccid,
+ enable,
+ 30 * 1000
+ ).waitDone()
- euiccChannelManager.beginTrackedOperationBlocking(channel.slotId, channel.portId) {
- if (iccid != null) {
- // Disable any active profile first if present
- channel.lpa.disableActiveProfile(false)
- if (!channel.lpa.enableProfile(iccid)) {
- return@withEuiccChannelManager RESULT_FIRST_USER
- }
- } else {
- if (!channel.lpa.disableActiveProfile(true)) {
- return@withEuiccChannelManager RESULT_FIRST_USER
- }
- }
-
- runBlocking {
- preferenceRepository.notificationSwitchFlow.first()
- }
- }
+ if (res != null) return@withEuiccChannelManager RESULT_FIRST_USER
return@withEuiccChannelManager RESULT_OK
} catch (e: Exception) {
@@ -335,13 +379,19 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
"onUpdateSubscriptionNickname slotId=$slotId iccid=$iccid nickname=$nickname"
)
if (shouldIgnoreSlot(slotId)) return@withEuiccChannelManager RESULT_FIRST_USER
- val channel = findChannel(slotId) ?: return@withEuiccChannelManager RESULT_FIRST_USER
- if (!channel.profileExists(iccid)) {
+ val port = euiccChannelManager.findFirstAvailablePort(slotId)
+ if (port < 0) {
return@withEuiccChannelManager RESULT_FIRST_USER
}
- val success = channel.lpa
- .setNickname(iccid, nickname!!)
- appContainer.subscriptionManager.tryRefreshCachedEuiccInfo(channel.cardId)
+
+ euiccChannelManagerService.waitForForegroundTask()
+ val success =
+ (euiccChannelManagerService.launchProfileRenameTask(slotId, port, iccid, nickname!!)
+ .waitDone()) == null
+
+ euiccChannelManager.withEuiccChannel(slotId, port) { channel ->
+ appContainer.subscriptionManager.tryRefreshCachedEuiccInfo(channel.cardId)
+ }
return@withEuiccChannelManager if (success) {
RESULT_OK
} else {
diff --git a/app/src/main/java/im/angry/openeuicc/ui/LuiActivity.kt b/app/src/main/java/im/angry/openeuicc/ui/LuiActivity.kt
index d7ac213b..de2ca247 100644
--- a/app/src/main/java/im/angry/openeuicc/ui/LuiActivity.kt
+++ b/app/src/main/java/im/angry/openeuicc/ui/LuiActivity.kt
@@ -8,6 +8,7 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import im.angry.openeuicc.R
+import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
class LuiActivity : AppCompatActivity() {
override fun onStart() {
@@ -25,10 +26,11 @@ class LuiActivity : AppCompatActivity() {
}
requireViewById(R.id.lui_skip).setOnClickListener { finish() }
- // TODO: Deactivate LuiActivity if there is no eSIM found.
+ // TODO: Deactivate DownloadWizardActivity if there is no eSIM found.
// TODO: Support pre-filled download info (from carrier apps); UX
requireViewById(R.id.lui_download).setOnClickListener {
- startActivity(Intent(this, DirectProfileDownloadActivity::class.java))
+ startActivity(Intent(this, DownloadWizardActivity::class.java))
+ finish()
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/im/angry/openeuicc/ui/PrivilegedEuiccManagementFragment.kt b/app/src/main/java/im/angry/openeuicc/ui/PrivilegedEuiccManagementFragment.kt
index 7d055f47..12b60bda 100644
--- a/app/src/main/java/im/angry/openeuicc/ui/PrivilegedEuiccManagementFragment.kt
+++ b/app/src/main/java/im/angry/openeuicc/ui/PrivilegedEuiccManagementFragment.kt
@@ -6,8 +6,6 @@ import android.widget.Button
import android.widget.PopupMenu
import im.angry.openeuicc.R
import im.angry.openeuicc.util.*
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.LocalProfileInfo
class PrivilegedEuiccManagementFragment: EuiccManagementFragment() {
@@ -16,14 +14,20 @@ class PrivilegedEuiccManagementFragment: EuiccManagementFragment() {
newInstanceEuicc(PrivilegedEuiccManagementFragment::class.java, slotId, portId)
}
+ private var isMEP = false
+ private var isRemovable = false
+
override suspend fun onCreateFooterViews(
parent: ViewGroup,
profiles: List
): List =
super.onCreateFooterViews(parent, profiles).let { footers ->
- // isMEP can map to a slow operation (UiccCardInfo.isMultipleEnabledProfilesSupported())
- // so let's do it in the IO context
- if (withContext(Dispatchers.IO) { channel.isMEP }) {
+ withEuiccChannel { channel ->
+ isMEP = channel.isMEP
+ isRemovable = channel.port.card.isRemovable
+ }
+
+ if (isMEP) {
val view = layoutInflater.inflate(R.layout.footer_mep, parent, false)
view.requireViewById