Merge remote-tracking branch 'openeuicc/master' into jmp
All checks were successful
/ build-debug (push) Successful in 4m30s

Conflicts:
	.forgejo/workflows/build-debug.yml
	app-unpriv/src/main/java/im/angry/openeuicc/di/UnprivilegedUiComponentFactory.kt
This commit is contained in:
Peter Cai 2024-12-22 08:56:31 -05:00
commit 0673cf370a
132 changed files with 4580 additions and 1430 deletions

View file

@ -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

29
.gitignore vendored
View file

@ -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
# Configuration files
/keystore.properties
/local.properties
# macOS
.DS_Store

14
.idea/.gitignore vendored
View file

@ -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

1
.idea/.name Normal file
View file

@ -0,0 +1 @@
OpenEUICC

View file

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

View file

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<compositeConfiguration>
<compositeBuild compositeDefinitionSource="SCRIPT">
<builds>
<build path="$PROJECT_DIR$/buildSrc" name="buildSrc">
<projects>
<project path="$PROJECT_DIR$/buildSrc" />
</projects>
</build>
</builds>
</compositeBuild>
</compositeConfiguration>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleHome" value="/usr/share/java/gradle" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/app-common" />
<option value="$PROJECT_DIR$/app-deps" />
<option value="$PROJECT_DIR$/app-unpriv" />
<option value="$PROJECT_DIR$/buildSrc" />
<option value="$PROJECT_DIR$/libs" />
<option value="$PROJECT_DIR$/libs/hidden-apis-shim" />
<option value="$PROJECT_DIR$/libs/hidden-apis-stub" />
<option value="$PROJECT_DIR$/libs/lpac-jni" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

View file

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

View file

View file

@ -20,14 +20,23 @@
android:label="@string/profile_notifications" />
<activity
android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
android:label="@string/profile_download"
android:theme="@style/Theme.AppCompat.Translucent" />
android:name="im.angry.openeuicc.ui.EuiccInfoActivity"
android:label="@string/euicc_info" />
<activity
android:name="im.angry.openeuicc.ui.LogsActivity"
android:label="@string/pref_advanced_logs" />
<activity
android:exported="true"
android:name="im.angry.openeuicc.ui.wizard.DownloadWizardActivity"
android:label="@string/download_wizard" />
<activity-alias
android:exported="true"
android:name="im.angry.openeuicc.ui.DirectProfileDownloadActivity"
android:targetActivity="im.angry.openeuicc.ui.wizard.DownloadWizardActivity" />
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"

View file

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

View file

@ -6,6 +6,7 @@ import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.se.omapi.SEService
import android.util.Log
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.usb.UsbApduInterface
import im.angry.openeuicc.core.usb.getIoEndpoints
import im.angry.openeuicc.util.*
@ -33,14 +34,17 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
Log.i(DefaultEuiccChannelManager.TAG, "Trying OMAPI for physical slot ${port.card.physicalSlotIndex}")
try {
return EuiccChannel(
return EuiccChannelImpl(
context.getString(R.string.omapi),
port,
intrinsicChannelName = null,
OmapiApduInterface(
seService!!,
port,
context.preferenceRepository.verboseLoggingFlow
),
context.preferenceRepository.verboseLoggingFlow
context.preferenceRepository.verboseLoggingFlow,
context.preferenceRepository.ignoreTLSCertificateFlow,
).also {
Log.i(DefaultEuiccChannelManager.TAG, "Is OMAPI channel, setting MSS to 60")
it.lpa.setEs10xMss(60)
@ -61,15 +65,18 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
if (bulkIn == null || bulkOut == null) return null
val conn = usbManager.openDevice(usbDevice) ?: return null
if (!conn.claimInterface(usbInterface, true)) return null
return EuiccChannel(
return EuiccChannelImpl(
context.getString(R.string.usb),
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
intrinsicChannelName = usbDevice.productName,
UsbApduInterface(
conn,
bulkIn,
bulkOut,
context.preferenceRepository.verboseLoggingFlow
),
context.preferenceRepository.verboseLoggingFlow
context.preferenceRepository.verboseLoggingFlow,
context.preferenceRepository.ignoreTLSCertificateFlow,
)
}

View file

@ -10,7 +10,10 @@ import im.angry.openeuicc.di.AppContainer
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@ -88,44 +91,24 @@ open class DefaultEuiccChannelManager(
}
}
override fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel? =
runBlocking {
withContext(Dispatchers.IO) {
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannel
}
protected suspend fun findEuiccChannelByLogicalSlot(logicalSlotId: Int): EuiccChannel? =
withContext(Dispatchers.IO) {
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannel
}
for (card in uiccCards) {
for (port in card.ports) {
if (port.logicalSlotIndex == logicalSlotId) {
return@withContext tryOpenEuiccChannel(port)
}
for (card in uiccCards) {
for (port in card.ports) {
if (port.logicalSlotIndex == logicalSlotId) {
return@withContext tryOpenEuiccChannel(port)
}
}
null
}
null
}
override fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel? =
runBlocking {
withContext(Dispatchers.IO) {
if (physicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
return@withContext usbChannel
}
for (card in uiccCards) {
if (card.physicalSlotIndex != physicalSlotId) continue
for (port in card.ports) {
tryOpenEuiccChannel(port)?.let { return@withContext it }
}
}
null
}
}
override suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
private suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
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<EuiccChannel>? =
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<Int> =
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 <R> withEuiccChannel(
physicalSlotId: Int,
portId: Int,
fn: suspend (EuiccChannel) -> R
): R {
val channel = findEuiccChannelByPort(physicalSlotId, portId)
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
val wrapper = EuiccChannelWrapper(channel)
try {
return withContext(Dispatchers.IO) {
fn(wrapper)
}
} finally {
wrapper.invalidateWrapper()
}
}
override suspend fun <R> withEuiccChannel(
logicalSlotId: Int,
fn: suspend (EuiccChannel) -> R
): R {
val channel = findEuiccChannelByLogicalSlot(logicalSlotId)
?: throw EuiccChannelManager.EuiccChannelNotFoundException()
val wrapper = EuiccChannelWrapper(channel)
try {
return withContext(Dispatchers.IO) {
fn(wrapper)
}
} finally {
wrapper.invalidateWrapper()
}
}
override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) {
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<EuiccChannel> =
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<Pair<Int, Int>> = 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<UsbDevice?, EuiccChannel?> =
override fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>> =
merge(flowInternalEuiccPorts(), flow {
if (tryOpenUsbEuiccChannel().second) {
emit(Pair(EuiccChannelManager.USB_CHANNEL_ID, 0))
}
})
override suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean> =
withContext(Dispatchers.IO) {
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() {

View file

@ -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<Boolean>
) {
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()
}

View file

@ -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<Boolean>,
ignoreTLSCertificateFlow: Flow<Boolean>
) : EuiccChannel {
override val slotId = port.card.physicalSlotIndex
override val logicalSlotId = port.logicalSlotIndex
override val portId = port.portIndex
override val lpa: LocalProfileAssistant =
LocalProfileAssistantImpl(apduInterface, HttpInterfaceImpl(verboseLoggingFlow, ignoreTLSCertificateFlow))
override val atr: ByteArray?
get() = (apduInterface as? ApduInterfaceAtrProvider)?.atr
override val valid: Boolean
get() = lpa.valid
override fun close() = lpa.close()
}

View file

@ -1,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<EuiccChannel>
fun flowInternalEuiccPorts(): Flow<Pair<Int, Int>>
/**
* Same as flowInternalEuiccPorts(), except that this includes non-device internal eUICC chips
* as well. Namely, this includes the USB reader.
*
* Non-internal readers will only be included if they have been opened properly, i.e. with permissions
* granted by the user.
*/
fun flowAllOpenEuiccPorts(): Flow<Pair<Int, Int>>
/**
* Scan all possible USB devices for CCID readers that may contain eUICC cards.
* 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<UsbDevice?, EuiccChannel?>
suspend fun tryOpenUsbEuiccChannel(): Pair<UsbDevice?, Boolean>
/**
* 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<Int>
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<EuiccChannel>?
fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>?
suspend fun <R> 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 <R> 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
}
}

View file

@ -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()
}
}
}

View file

@ -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<LocalProfileInfo>
get() = lpa.profiles
override val notifications: List<LocalProfileNotification>
get() = lpa.notifications
override val eID: String
get() = lpa.eID
override val euiccInfo2: EuiccInfo2?
get() = lpa.euiccInfo2
override fun setEs10xMss(mss: Byte) = lpa.setEs10xMss(mss)
override fun enableProfile(iccid: String, refresh: Boolean): Boolean =
lpa.enableProfile(iccid, refresh)
override fun disableProfile(iccid: String, refresh: Boolean): Boolean =
lpa.disableProfile(iccid, refresh)
override fun deleteProfile(iccid: String): Boolean = lpa.deleteProfile(iccid)
override fun downloadProfile(
smdp: String,
matchingId: String?,
imei: String?,
confirmationCode: String?,
callback: ProfileDownloadCallback
) = lpa.downloadProfile(smdp, matchingId, imei, confirmationCode, callback)
override fun deleteNotification(seqNumber: Long): Boolean = lpa.deleteNotification(seqNumber)
override fun handleNotification(seqNumber: Long): Boolean = lpa.handleNotification(seqNumber)
override fun euiccMemoryReset() = lpa.euiccMemoryReset()
override fun setNickname(iccid: String, nickname: String) {
lpa.setNickname(iccid, nickname)
}
override fun close() = lpa.close()
fun invalidateWrapper() {
_inner = null
}
}

View file

@ -15,7 +15,7 @@ class OmapiApduInterface(
private val service: SEService,
private val port: UiccPortInfoCompat,
private val verboseLoggingFlow: Flow<Boolean>
): 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) {

View file

@ -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<Boolean>
): 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

View file

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

View file

@ -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
}

View file

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

View file

@ -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)
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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<ForegroundTaskState>.waitDone(): Throwable? =
(this.last() as ForegroundTaskState.Done).error
/**
* Apply transform to a ForegroundTaskState flow so that it completes when a Done is seen.
*
* This must be applied each time a flow is returned for subscription purposes. If applied
* beforehand, we lose the ability to subscribe multiple times.
*/
private fun Flow<ForegroundTaskState>.applyCompletionTransform() =
transformWhile {
emit(it)
it !is ForegroundTaskState.Done
}
}
inner class LocalBinder : Binder() {
@ -89,6 +113,25 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
private val foregroundTaskState: MutableStateFlow<ForegroundTaskState> =
MutableStateFlow(ForegroundTaskState.Idle)
/**
* A simple wrapper over a flow with taskId added.
*
* taskID is the exact millisecond-precision timestamp when the task is launched.
*/
class ForegroundTaskSubscriberFlow(val taskId: Long, inner: Flow<ForegroundTaskState>) :
Flow<ForegroundTaskState> by inner
/**
* A cache of subscribers to 5 recently-launched foreground tasks, identified by ID
*
* Only one can be run at the same time, but those that are done will be kept in this
* map for a little while -- because UI components may be stopped and recreated while
* tasks are running. Having this buffer allows the components to re-subscribe even if
* the task completes while they are being recreated.
*/
private val foregroundTaskSubscribers: MutableMap<Long, SharedFlow<ForegroundTaskState>> =
mutableMapOf()
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return LocalBinder()
@ -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<ForegroundTaskState>? {
): 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<ForegroundTaskState>(
replay = 2,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
// We should be the only task running, so we can subscribe to foregroundTaskState
// until we encounter ForegroundTaskState.Done.
// Then, we complete the returned flow, but we also set the state back to Idle.
// The state update back to Idle won't show up in the returned stream, because
// it has been completed by that point.
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<ForegroundTaskState>? =
): 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<ForegroundTaskState>? =
): 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<ForegroundTaskState>? =
): 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<ForegroundTaskState>? =
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)
}