Compare commits

...

23 commits

Author SHA1 Message Date
3926108ea6 i18n: More fixes
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m16s
2026-03-15 11:42:27 -04:00
aeb837b50c i18n: Update STK menu shortcut translation
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m20s
2026-03-15 11:34:03 -04:00
297880fa53 feat: Optional confirmation signal for profileDownloadTask
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m38s
This is to prepare for confirmation UI and EuiccService preview impl.
2026-03-14 21:00:21 -04:00
0f369b3b12 fix: Pass logicalSlotId to DownloadWizardActivity, not slotId
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m28s
This is confusing and we should probably fix this eventually.
2026-03-14 14:48:46 -04:00
ba45a7eb7f refactor: Pass RemoteProfileInfo into ProfileDownloadState
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m26s
...instead of using a different callback. This will help us hook this up
into EuiccService properly later (we still need a way for EuiccService
to send events back to EuiccChannelManagerService)
2026-03-14 14:38:12 -04:00
6837aba211 feat: language switcher with platform signed on privileged
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m59s
2026-03-09 20:43:51 -04:00
eec2a105ff Trigger new actions run
All checks were successful
Build Debug APKs / build-debug (push) Successful in 7m33s
2026-03-09 09:47:49 -04:00
d4855f130c lpac-jni: Export profile metadata during download and allow cancellation at that point
All checks were successful
Build Debug APKs / build-debug (push) Successful in 9m15s
(Shoutout to Mark Gallagher <mark@fts.scot> who in #313 introduced
a similar feature. Note that we still have not exposed profile
installation result here, but what's really useful would only be the
sequence number for us)

This leaves a TODO to actually hook up the UI to allow cancellation
after metadata is displayed. But most of this is intended to support
EuiccService APIs to allow apps to download profiles directly, which
requires the use of profilePolicyRules (not done yet; lpac pending) in
the preview metadata.
2026-03-08 19:31:39 -04:00
2108696646 lpac-jni: Upgrade lpac (#315)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 6m58s
Upstream no longer bundles cjson so we pull it in ourselves. Android.bp is also updated for AOSP builds.

Reviewed-on: #315
2026-03-08 22:20:34 +01:00
6210c81201 ui: Switch to MODE_SCROLLABLE when tab width is about to overflow
All checks were successful
Build Debug APKs / build-debug (push) Successful in 5m30s
...to prevent tabs from inserting line breaks.
2026-03-01 22:29:26 -05:00
0a353a3df6 fix: version name suffix in debug (#290)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 6m24s
Reviewed-on: #290
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-28 23:52:27 +01:00
713ffec26d feat: multi-SE support for USB channels (#309)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 6m6s
All channels now support multi-SE. UsbCcidReaderFragment now no longer wraps inner EuiccManagementFragment's but instead calls MainActivity to instantiate tabs for USB channels. Also, we now no longer use the USB product name as channel titles but instead use "USB Reader" and "USB Reader, SE x" for all of them.

Reviewed-on: #309
Reviewed-by: septs <github@septs.pw>
2026-02-28 23:43:03 +01:00
c676c27338 fix: channel title (#292)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 9m26s
Reviewed-on: #292
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-28 22:35:39 +01:00
81810b22fb Revert "Switch to gradle daemon mode"
All checks were successful
Build Debug APKs / build-debug (push) Successful in 5m50s
This reverts commit fdde41329b.
2026-02-27 20:44:12 -05:00
fdde41329b Switch to gradle daemon mode
Some checks failed
Build Debug APKs / build-debug (push) Failing after 1m3s
2026-02-27 18:48:52 -05:00
2cf2d9490a Support TPDU-based CCID readers (#295)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 5m33s
resolves #37

For TPDU based readers like USB 2.0-CRW 2 additional commands are needed in initialisation. Add them

Reviewed-on: #295
Reviewed-by: septs <github@septs.pw>
Co-authored-by: Vladimir Serbinenko <phcoder@gmail.com>
Co-committed-by: Vladimir Serbinenko <phcoder@gmail.com>
2026-02-26 02:22:11 +01:00
b9863e2e54 refactor: profile download callback (#284)
Some checks failed
Build Debug APKs / build-debug (push) Has been cancelled
Reviewed-on: #284
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-26 02:20:04 +01:00
56fbd34616 feat: paste lpa string into smdp address input box (#291)
Some checks failed
Build Debug APKs / build-debug (push) Has been cancelled
Reviewed-on: #291
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-26 02:19:42 +01:00
419db21b12 refactor: simplify sim toolkit design (#296)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 6m24s
Reviewed-on: #296
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-24 00:26:20 +01:00
9496370135 fix: improve language settings (#300)
Some checks failed
Build Debug APKs / build-debug (push) Has been cancelled
resolves #299 (may be)

Reviewed-on: #300
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-24 00:23:53 +01:00
84f57a4ef7 feat: detect network lock in quick compatibility (#303)
Some checks failed
Build Debug APKs / build-debug (push) Has been cancelled
Reviewed-on: #303
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-24 00:22:50 +01:00
a75478dd31 feat: assets statements (#304)
Some checks failed
Build Debug APKs / build-debug (push) Has been cancelled
see https://developer.chrome.com/docs/capabilities/get-installed-related-apps?hl=en

Reviewed-on: #304
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2026-02-24 00:22:23 +01:00
f17e171372 feat: make debug and release version name different (#288)
All checks were successful
Build Debug APKs / build-debug (push) Successful in 5m25s
Reviewed-on: #288
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2025-12-22 00:55:49 +01:00
59 changed files with 905 additions and 394 deletions

3
.gitmodules vendored
View file

@ -1,3 +1,6 @@
[submodule "libs/lpac-jni/src/main/jni/lpac"]
path = libs/lpac-jni/src/main/jni/lpac
url = https://github.com/estkme-group/lpac.git
[submodule "libs/lpac-jni/src/main/jni/cJSON"]
path = libs/lpac-jni/src/main/jni/cjson/cjson
url = https://github.com/DaveGamble/cJSON

View file

@ -1,5 +1,39 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="android" withSubpackages="true" static="true" />
<package name="androidx" withSubpackages="true" static="true" />
<package name="com" withSubpackages="true" static="true" />
<package name="junit" withSubpackages="true" static="true" />
<package name="net" withSubpackages="true" static="true" />
<package name="org" withSubpackages="true" static="true" />
<package name="java" withSubpackages="true" static="true" />
<package name="javax" withSubpackages="true" static="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>

3
.idea/vcs.xml generated
View file

@ -2,6 +2,7 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libs/lpac-jni/src/main/jni/cjson/cjson" vcs="Git" />
<mapping directory="$PROJECT_DIR$/libs/lpac-jni/src/main/jni/lpac" vcs="Git" />
</component>
</project>
</project>

View file

@ -38,7 +38,6 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
EuiccChannelImpl(
context.getString(R.string.channel_type_omapi),
port,
intrinsicChannelName = null,
OmapiApduInterface(
seService!!,
port,
@ -67,7 +66,6 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
EuiccChannelImpl(
context.getString(R.string.channel_type_usb),
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
intrinsicChannelName = ccidCtx.productName,
UsbApduInterface(
ccidCtx
),

View file

@ -52,7 +52,6 @@ open class DefaultEuiccChannelManager(
get() = (0..<tm.activeModemCountCompat).map { FakeUiccCardInfoCompat(it) }
private suspend inline fun tryOpenChannelWithKnownAids(
supportsMultiSE: Boolean,
openFn: (ByteArray, EuiccChannel.SecureElementId) -> EuiccChannel?
): List<EuiccChannel> {
var isdrAidList =
@ -100,10 +99,9 @@ open class DefaultEuiccChannelManager(
ret.add(channel)
openedAids.add(aid)
// Don't try opening more than 1 channel unless we support multi SE or
// there is a vendor implementation for deciding when we should stop
// opening more channels
if (!supportsMultiSE || vendorDecider == null) {
// Don't try opening more than 1 channel unless there is a vendor
// implementation for deciding when we should stop opening more channels
if (vendorDecider == null) {
break@outer
}
}
@ -149,9 +147,9 @@ open class DefaultEuiccChannelManager(
return null
}
// This function is not responsible for managing USB channels (see the initial check), so supportsMultiSE is true.
// This function is not responsible for managing USB channels (see the initial check)
val channels =
tryOpenChannelWithKnownAids(supportsMultiSE = true) { isdrAid, seId ->
tryOpenChannelWithKnownAids { isdrAid, seId ->
euiccChannelFactory.tryOpenEuiccChannel(
port,
isdrAid,
@ -379,8 +377,7 @@ open class DefaultEuiccChannelManager(
UsbCcidContext.createFromUsbDevice(context, device, iface) ?: return@forEach
try {
// TODO: We should also support multiple SEs over USB readers (the code here already does, UI doesn't yet)
val channels = tryOpenChannelWithKnownAids(supportsMultiSE = false) { isdrAid, seId ->
val channels = tryOpenChannelWithKnownAids { isdrAid, seId ->
euiccChannelFactory.tryOpenUsbEuiccChannel(ccidCtx, isdrAid, seId)
}
if (channels.isNotEmpty() && channels[0].valid) {

View file

@ -85,13 +85,6 @@ interface EuiccChannel {
*/
val atr: ByteArray?
/**
* Intrinsic name of this channel. For device-internal SIM slots,
* this should be null; for USB readers, this should be the name of
* the reader device.
*/
val intrinsicChannelName: String?
/**
* The underlying APDU interface for this channel
*/

View file

@ -12,7 +12,6 @@ import net.typeblog.lpac_jni.impl.LocalProfileAssistantImpl
class EuiccChannelImpl(
override val type: String,
override val port: UiccPortInfoCompat,
override val intrinsicChannelName: String?,
override val apduInterface: ApduInterface,
override val isdrAid: ByteArray,
override val seId: EuiccChannel.SecureElementId,

View file

@ -34,8 +34,6 @@ class EuiccChannelWrapper(orig: EuiccChannel) : EuiccChannel {
override val lpa: LocalProfileAssistant by lpaDelegate
override val valid: Boolean
get() = channel.valid
override val intrinsicChannelName: String?
get() = channel.intrinsicChannelName
override val apduInterface: ApduInterface
get() = channel.apduInterface
override val atr: ByteArray?

View file

@ -1,5 +1,6 @@
package im.angry.openeuicc.core
import net.typeblog.lpac_jni.ProfileDownloadInput
import net.typeblog.lpac_jni.EuiccInfo2
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.LocalProfileInfo
@ -40,13 +41,8 @@ class LocalProfileAssistantWrapper(orig: LocalProfileAssistant) :
override fun deleteProfile(iccid: String): Boolean = lpa.deleteProfile(iccid)
override fun downloadProfile(
smdp: String,
matchingId: String?,
imei: String?,
confirmationCode: String?,
callback: ProfileDownloadCallback
) = lpa.downloadProfile(smdp, matchingId, imei, confirmationCode, callback)
override fun downloadProfile(input: ProfileDownloadInput, callback: ProfileDownloadCallback) =
lpa.downloadProfile(input, callback)
override fun deleteNotification(seqNumber: Long): Boolean = lpa.deleteNotification(seqNumber)
@ -63,4 +59,4 @@ class LocalProfileAssistantWrapper(orig: LocalProfileAssistant) :
fun invalidateWrapper() {
_inner = null
}
}
}

View file

@ -21,9 +21,70 @@ class UsbApduInterface(
private var channels = mutableSetOf<Int>()
// ATR parser
// Specs: ISO/IEC 7816-3:2006 8.2 Answer-to-Reset
// See also: https://en.wikipedia.org/wiki/Answer_to_reset
class ParsedAtr private constructor(val ts: Byte?, val t0: Byte?, val ta1: Byte?, val tb1: Byte?, val tc1: Byte?, val td1: Byte?, val ta2: Byte?, val tb2: Byte?, val tc2: Byte?, val td2: Byte?) {
companion object {
fun parse(atr: ByteArray): ParsedAtr {
val ts = atr[0]
val t0 = atr[1]
val tx1 = arrayOf<Byte?>(null, null, null, null)
val tx2 = arrayOf<Byte?>(null, null, null, null)
var pointer = 2
for (i in 0..3) {
if (t0.toInt() and (0x10 shl i) != 0) {
tx1[i] = atr[pointer]
pointer++
}
}
val td1 = tx1[3] ?: 0
for (i in 0..3) {
if (td1.toInt() and (0x10 shl i) != 0) {
tx2[i] = atr[pointer]
pointer++
}
}
return ParsedAtr(ts=ts, t0=t0, ta1=tx1[0], tb1=tx1[1], tc1=tx1[2], td1=tx1[3],
ta2=tx2[0], tb2=tx2[1], tc2=tx2[2], td2=tx2[3],
)
}
}
}
override fun connect() {
ccidCtx.connect()
if (ccidCtx.transceiver.isTpdu) {
// Send parameter selection
// Specs: USB-CCID 3.2.1 TPDU level of exchange
val parsedAtr = ParsedAtr.parse(atr!!)
val ta1 = parsedAtr.ta1 ?: 0x11.toByte()
val pts1 = ta1 // TODO: Check that reader supports baud rate proposed by the card
val pps = byteArrayOf(0xff.toByte(), 0x10.toByte(), pts1, 0x00.toByte())
Log.d(TAG, "PTS1=${pts1} PPS: ${pps.encodeHex()}")
ccidCtx.transceiver.sendXfrBlock(pps)
// Send Set Parameters
// Specs: USB-CCID 6.1.7 PC_to_RDR_SetParameters
val param = byteArrayOf(
pts1,
(if (parsedAtr.ts == 0x3F.toByte()) 0x02 else 0x00),
parsedAtr.tc1 ?: 0,
parsedAtr.tc2 ?: 0x0A,
0x00
)
Log.d(TAG, "Param: ${param.encodeHex()}")
ccidCtx.transceiver.sendParamBlock(param)
}
// Send Terminal Capabilities
// Specs: ETSI TS 102 221 v15.0.0 - 11.1.19 TERMINAL CAPABILITY
val terminalCapabilities = buildCmd(

View file

@ -20,7 +20,6 @@ class UsbCcidContext private constructor(
private val conn: UsbDeviceConnection,
private val bulkIn: UsbEndpoint,
private val bulkOut: UsbEndpoint,
val productName: String,
val verboseLoggingFlow: Flow<Boolean>
) {
companion object {
@ -38,7 +37,6 @@ class UsbCcidContext private constructor(
conn,
bulkIn,
bulkOut,
usbDevice.productName ?: "USB",
context.preferenceRepository.verboseLoggingFlow
)
}.getOrNull()

View file

@ -84,6 +84,8 @@ data class UsbCcidDescription(
private fun hasFeature(feature: Int) = (dwFeatures and feature) != 0
val isTpdu = hasFeature(0x10000)
val voltages: List<Voltage>
get() {
if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) return listOf(Voltage.AUTO)
@ -95,4 +97,4 @@ data class UsbCcidDescription(
val hasT0Protocol: Boolean
get() = (dwProtocols and MASK_T0_PROTO) != 0
}
}

View file

@ -143,6 +143,8 @@ class UsbCcidTransceiver(
val hasAutomaticPps = usbCcidDescription.hasAutomaticPps
val isTpdu = usbCcidDescription.isTpdu
private val inputBuffer = ByteArray(usbBulkIn.maxPacketSize)
private var currentSequenceNumber: Byte = 0
@ -158,6 +160,46 @@ class UsbCcidTransceiver(
}
}
private fun receiveParamBlock(expectedSequenceNumber: Byte): ByteArray {
var response: ByteArray?
do {
response = receiveParamBlockImmediate(expectedSequenceNumber)
} while (response!![7] == 0x80.toByte())
return response
}
private fun receiveParamBlockImmediate(expectedSequenceNumber: Byte): ByteArray {
/*
* Some USB CCID devices (notably NitroKey 3) may time-out and need a subsequent poke to
* carry on communications. No particular reason why the number 3 was chosen. If we get a
* zero-sized reply (or a time-out), we try again. Clamped retries prevent an infinite loop
* if things really turn sour.
*/
var attempts = 3
Log.d(TAG, "Receive data block immediate seq=$expectedSequenceNumber")
var readBytes: Int
do {
readBytes = usbConnection.bulkTransfer(
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
)
if (runBlocking { verboseLoggingFlow.first() }) {
Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}")
}
} while (readBytes <= 0 && attempts-- > 0)
if (inputBuffer[0] != 0x82.toByte()) {
throw UsbTransportException(buildString {
append("USB-CCID error - bad CCID header")
append(", type ")
append("%d (expected %d)".format(inputBuffer[0], MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK))
if (expectedSequenceNumber != inputBuffer[6]) {
append(", sequence number ")
append("%d (expected %d)".format(inputBuffer[6], expectedSequenceNumber))
}
})
}
return inputBuffer
}
private fun receiveDataBlock(expectedSequenceNumber: Byte): CcidDataBlock {
var response: CcidDataBlock?
do {
@ -283,6 +325,38 @@ class UsbCcidTransceiver(
return ccidDataBlock
}
fun sendParamBlock(
payload: ByteArray
): ByteArray {
val startTime = SystemClock.elapsedRealtime()
val l = payload.size
val sequenceNumber: Byte = currentSequenceNumber++
val headerData = byteArrayOf(
0x61.toByte(),
l.toByte(),
(l shr 8).toByte(),
(l shr 16).toByte(),
(l shr 24).toByte(),
SLOT_NUMBER.toByte(),
sequenceNumber,
0x00.toByte(),
0x00.toByte(),
0x00.toByte()
)
val data: ByteArray = headerData + payload
Log.d(TAG, "USB ParamBlock: ${data.encodeHex()}")
var sentBytes = 0
while (sentBytes < data.size) {
val bytesToSend = usbBulkOut.maxPacketSize.coerceAtMost(data.size - sentBytes)
sendRaw(data, sentBytes, bytesToSend)
sentBytes += bytesToSend
}
val ccidDataBlock = receiveParamBlock(sequenceNumber)
val elapsedTime = SystemClock.elapsedRealtime() - startTime
Log.d(TAG, "USB ParamBlock call took ${elapsedTime}ms")
return ccidDataBlock
}
fun iccPowerOn(): CcidDataBlock {
val startTime = SystemClock.elapsedRealtime()
skipAvailableInput()

View file

@ -17,7 +17,10 @@ import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@ -34,10 +37,13 @@ import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.yield
import net.typeblog.lpac_jni.ProfileDownloadCallback
import net.typeblog.lpac_jni.ProfileDownloadInput
import net.typeblog.lpac_jni.ProfileDownloadState
/**
* An Android Service wrapper for EuiccChannelManager.
@ -104,7 +110,7 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
*/
sealed interface ForegroundTaskState {
data object Idle : ForegroundTaskState
data class InProgress(val progress: Int) : ForegroundTaskState
data class InProgress(val progress: Int, val context: Any? = null) : ForegroundTaskState
data class Done(val error: Throwable?) : ForegroundTaskState
}
@ -379,13 +385,14 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
}
fun launchProfileDownloadTask(
slotId: Int,
portId: Int,
seId: EuiccChannel.SecureElementId,
smdp: String,
matchingId: String?,
confirmationCode: String?,
imei: String?
slotId: Int, portId: Int, seId: EuiccChannel.SecureElementId,
input: ProfileDownloadInput,
// Optionally, a Channel to send confirmation signal when metadata preview is received.
// When we emit a ForegroundTaskState.InProgress with ProfileDownloadState.ConfirmingMetadata,
// the caller can send a true/false value into this channel to either continue immediately or cancel the download.
// Note that there is a timeout of 1 minute, after which we default to cancelling.
// When absent, the default value is just a buffered channel with 1 true value in it, so effectively no-op.
confirmationSignal: Channel<Boolean> = Channel<Boolean>(1).apply { trySendBlocking(true) }
): ForegroundTaskSubscriberFlow =
launchForegroundTask(
getString(R.string.task_profile_download),
@ -394,18 +401,40 @@ class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
) {
euiccChannelManager.beginTrackedOperation(slotId, portId, seId) {
euiccChannelManager.withEuiccChannel(slotId, portId, seId) { 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)
channel.lpa.downloadProfile(input) { state ->
val progress = state.downloadProgress
foregroundTaskState.value = ForegroundTaskState.InProgress(
progress,
state
)
if (state is ProfileDownloadState.ConfirmingDownload) {
state.metadata?.let { metadata ->
// TODO: Actually do something here and not just logging?
Log.i(
TAG,
"Downloading profile provider=${metadata.providerName} name=${metadata.name}"
)
}
})
// Try to receive a signal for confirmation while blocking this thread
// This of course assumes we're NOT on the main thread here. We aren't,
// because we don't run download on the main thread; see withEuiccChannel.
return@downloadProfile runBlocking {
try {
// We can't wait indefinitely; just time out after 1 minute.
withTimeout(60 * 1000) {
confirmationSignal.receive()
}
} catch (_: TimeoutCancellationException) {
// Default to cancelling / aborting here if we didn't receive a confirmation signal
false
}
}
}
true
}
}
preferenceRepository.notificationDownloadFlow.first()

View file

@ -76,13 +76,11 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
intent.getParcelableExtra("seId")
} ?: EuiccChannel.SecureElementId.DEFAULT
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.channel_type_usb)
} else {
appContainer.customizableTextProvider.formatNonUsbChannelName(logicalSlotId)
}
title = getString(R.string.euicc_info_activity_title, channelTitle)
setChannelTitle(
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID)
getString(R.string.channel_name_format_usb) else
appContainer.customizableTextProvider.formatNonUsbChannelName(logicalSlotId)
)
swipeRefresh.setOnRefreshListener { refresh() }
@ -103,6 +101,10 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
else -> super.onOptionsItemSelected(item)
}
private fun setChannelTitle(title: CharSequence) {
super.setTitle(getString(R.string.euicc_info_activity_title, title))
}
override fun onInit() {
refresh()
}
@ -112,13 +114,14 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
lifecycleScope.launch {
euiccChannelManager.withEuiccChannel(logicalSlotId, seId) { channel ->
// When the chip multi-SE, we need to include seId in the title (because we don't have access
// to hasMultipleSE in the onCreate() function, we need to do it here).
// TODO: Move channel formatting to somewhere centralized and remove this hack. (And also, of course, add support for USB)
if (channel.hasMultipleSE && logicalSlotId != EuiccChannelManager.USB_CHANNEL_ID) {
if (channel.hasMultipleSE) {
withContext(Dispatchers.Main) {
title =
val title = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.channel_name_format_usb_se, seId.id)
} else {
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(logicalSlotId, seId)
}
setChannelTitle(title)
}
}
@ -166,7 +169,7 @@ class EuiccInfoActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
// FS.27 v2.0, Security Guidelines for UICC Profiles (Page 25 of 27, 2024-01-30)
// https://www.gsma.com/solutions-and-impact/technologies/security/wp-content/uploads/2024/01/FS.27-Security-Guidelines-for-UICC-Credentials-v2.0-FINAL-23-July.pdf#page=25
val resId = when {
signers.isEmpty() -> R.string.euicc_info_unknown // the case is not mp, but it's is not common
signers.isEmpty() -> R.string.euicc_info_unknown // the case is not mp, but it is not common
PKID_GSMA_LIVE_CI.any(signers::contains) -> R.string.euicc_info_ci_gsma_live
PKID_GSMA_TEST_CI.any(signers::contains) -> R.string.euicc_info_ci_gsma_test
else -> R.string.euicc_info_ci_unknown

View file

@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.LocalProfileInfo
import net.typeblog.lpac_jni.ProfileClass
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
EuiccChannelFragmentMarker {
@ -119,7 +120,7 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
fab.setOnClickListener {
val intent = DownloadWizardActivity.newIntent(requireContext(), slotId, seId)
val intent = DownloadWizardActivity.newIntent(requireContext(), logicalSlotId, seId)
startActivity(intent)
}
}
@ -395,9 +396,9 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
profileClass.isVisible = unfilteredProfileListFlow.value
profileClass.setText(
when (profile.profileClass) {
LocalProfileInfo.Clazz.Testing -> R.string.profile_class_testing
LocalProfileInfo.Clazz.Provisioning -> R.string.profile_class_provisioning
LocalProfileInfo.Clazz.Operational -> R.string.profile_class_operational
ProfileClass.Testing -> R.string.profile_class_testing
ProfileClass.Provisioning -> R.string.profile_class_provisioning
ProfileClass.Operational -> R.string.profile_class_operational
}
)
iccid.text = profile.iccid

View file

@ -26,6 +26,7 @@ import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.ui.wizard.DownloadWizardActivity
import im.angry.openeuicc.util.*
@ -51,18 +52,30 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private var refreshing = false
private data class Page(
val id: Long,
val logicalSlotId: Int,
val title: String,
val createFragment: () -> Fragment
)
private val pages: MutableList<Page> = mutableListOf()
private var nextPageId = 0L
private fun newPage(
logicalSlotId: Int,
title: String,
createFragment: () -> Fragment
): Page = Page(nextPageId++, logicalSlotId, title, createFragment)
private val pagerAdapter by lazy {
object : FragmentStateAdapter(this) {
override fun getItemCount() = pages.size
override fun createFragment(position: Int): Fragment = pages[position].createFragment()
override fun getItemId(position: Int): Long = pages[position].id
override fun containsItem(itemId: Long): Boolean = pages.any { it.id == itemId }
}
}
@ -178,7 +191,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
} else {
appContainer.customizableTextProvider.formatNonUsbChannelName(channel.logicalSlotId)
}
newPages.add(Page(channel.logicalSlotId, channelName) {
newPages.add(newPage(channel.logicalSlotId, channelName) {
appContainer.uiComponentFactory.createEuiccManagementFragment(
slotId,
portId,
@ -192,9 +205,8 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
// If USB readers exist, add them at the very last
// We use a wrapper fragment to handle logic specific to USB readers
usbDevice?.let {
val productName = it.productName ?: getString(R.string.channel_type_usb)
newPages.add(Page(EuiccChannelManager.USB_CHANNEL_ID, productName) {
UsbCcidReaderFragment()
newPages.add(newPage(EuiccChannelManager.USB_CHANNEL_ID, getString(R.string.channel_name_format_usb)) {
UsbCcidReaderPermissionFragment()
})
}
viewPager.visibility = View.VISIBLE
@ -202,7 +214,7 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
if (newPages.size > 1) {
tabs.visibility = View.VISIBLE
} else if (newPages.isEmpty()) {
newPages.add(Page(-1, "") {
newPages.add(newPage(-1, "") {
appContainer.uiComponentFactory.createNoEuiccPlaceholderFragment()
})
}
@ -260,4 +272,35 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
.build()
return listOf(downloadShortcut)
}
fun instantiateUsbTabs(seIds: List<EuiccChannel.SecureElementId>) {
val existingUsbPageIndex = pages.indexOfFirst { it.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID }
if (existingUsbPageIndex == -1) return
val usbPages =
seIds.map { seId ->
val name = if (seIds.size == 1) {
getString(R.string.channel_name_format_usb)
} else {
getString(R.string.channel_name_format_usb_se, seId.id)
}
newPage(EuiccChannelManager.USB_CHANNEL_ID, name) {
appContainer.uiComponentFactory.createEuiccManagementFragment(
EuiccChannelManager.USB_CHANNEL_ID,
0,
seId
)
}
}
// Add before removing to avoid out-of-bounds problems
pages.addAll(existingUsbPageIndex, usbPages)
// Remove the old USB reader page
pages.removeAt(existingUsbPageIndex + usbPages.size)
if (pages.size > 1) {
tabs.visibility = View.VISIBLE
}
pagerAdapter.notifyDataSetChanged()
}
}

View file

@ -72,15 +72,11 @@ class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker
intent.getParcelableExtra("seId")
} ?: EuiccChannel.SecureElementId.DEFAULT
// 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.channel_type_usb)
} else {
appContainer.customizableTextProvider.formatNonUsbChannelName(logicalSlotId)
}
title = getString(R.string.profile_notifications_detailed_format, channelTitle)
setChannelTitle(
if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID)
getString(R.string.channel_name_format_usb) else
appContainer.customizableTextProvider.formatNonUsbChannelName(logicalSlotId)
)
swipeRefresh.setOnRefreshListener {
refresh()
@ -116,6 +112,10 @@ class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker
else -> super.onOptionsItemSelected(item)
}
private fun setChannelTitle(title: CharSequence) {
super.setTitle(getString(R.string.profile_notifications_detailed_format, title))
}
private fun launchTask(task: suspend () -> Unit) {
swipeRefresh.isRefreshing = true
@ -133,10 +133,14 @@ class NotificationsActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker
private fun refresh() {
launchTask {
notificationAdapter.notifications = withEuiccChannel { channel ->
if (channel.hasMultipleSE && logicalSlotId != EuiccChannelManager.USB_CHANNEL_ID) {
if (channel.hasMultipleSE) {
withContext(Dispatchers.Main) {
title =
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.channel_name_format_usb_se, seId.id)
} else {
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(logicalSlotId, seId)
}
setChannelTitle(channelTitle)
}
}

View file

@ -47,10 +47,9 @@ open class SettingsFragment : PreferenceFragmentCompat(), OpenEuiccContextMarker
requirePreference<Preference>("pref_advanced_language").apply {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@apply
isVisible = true
intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply {
data = Uri.fromParts("package", requireContext().packageName, null)
}
val uri = Uri.fromParts("package", requireContext().packageName, null)
intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS, uri)
isVisible = intent!!.resolveActivity(requireContext().packageManager) != null
}
requirePreference<Preference>("pref_advanced_logs").apply {

View file

@ -14,24 +14,22 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* A wrapper fragment over EuiccManagementFragment where we handle
* logic specific to USB devices. This is mainly USB permission
* requests, and the fact that USB devices may or may not be
* available by the time the user selects it from MainActivity.
* A fragment to handle USB reader-specific permission flow. If/after
* permission is granted, this fragment simply calls back to MainActivity
* to instantiate the corresponding EuiccManagementFragment(s) for the USB
* reader.
*
* Having this fragment allows MainActivity to be (mostly) agnostic
* of the underlying implementation of different types of channels.
@ -41,7 +39,7 @@ import kotlinx.coroutines.withContext
* Note that for now we assume there will only be one USB card reader
* device. This is also an implicit assumption in EuiccChannelManager.
*/
class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
class UsbCcidReaderPermissionFragment : Fragment(), OpenEuiccContextMarker {
companion object {
const val ACTION_USB_PERMISSION = "im.angry.openeuicc.USB_PERMISSION"
}
@ -70,7 +68,7 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
private lateinit var text: TextView
private lateinit var permissionButton: Button
private lateinit var loadingProgress: ProgressBar
private lateinit var loadingProgress: View
private var usbDevice: UsbDevice? = null
@ -143,27 +141,20 @@ class UsbCcidReaderFragment : Fragment(), OpenEuiccContextMarker {
euiccChannelManager.tryOpenUsbEuiccChannel()
}
loadingProgress.visibility = View.GONE
usbDevice = device
if (device != null && !canOpen && !usbManager.hasPermission(device)) {
loadingProgress.visibility = View.GONE
text.text = getString(R.string.usb_permission_needed)
text.visibility = View.VISIBLE
permissionButton.visibility = View.VISIBLE
} else if (device != null && canOpen) {
childFragmentManager.commit {
replace(
R.id.child_container,
appContainer.uiComponentFactory.createEuiccManagementFragment(
slotId = EuiccChannelManager.USB_CHANNEL_ID,
portId = 0,
// TODO: What if a USB card has multiple SEs?
seId = EuiccChannel.SecureElementId.DEFAULT
)
)
val seIds = withContext(Dispatchers.IO) {
euiccChannelManager.flowEuiccSecureElements(EuiccChannelManager.USB_CHANNEL_ID, 0).toList()
}
(requireActivity() as MainActivity).instantiateUsbTabs(seIds)
} else {
loadingProgress.visibility = View.GONE
text.text = getString(R.string.usb_failed)
text.visibility = View.VISIBLE
permissionButton.visibility = View.GONE

View file

@ -0,0 +1,51 @@
package im.angry.openeuicc.ui.widget
import android.content.Context
import android.util.AttributeSet
import android.view.ViewGroup
import com.google.android.material.R
import com.google.android.material.tabs.TabLayout
/**
* A TabLayout that automatically switches to MODE_SCROLLABLE when
* child tabs overflow the full width of the layout.
*/
class DynamicModeTabLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.tabStyle
) : TabLayout(context, attrs, defStyleAttr) {
init {
addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateModeIfNecessary()
}
}
private fun updateModeIfNecessary() {
if (width <= 0 || tabCount == 0) return
val tabStrip = getChildAt(0) as? ViewGroup ?: return
val totalTabWidth = (0 until tabStrip.childCount).sumOf { index ->
val tabView = tabStrip.getChildAt(index)
val layoutParams = tabView.layoutParams as? MarginLayoutParams
tabView.measure(
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
)
tabView.measuredWidth + (layoutParams?.leftMargin ?: 0) + (layoutParams?.rightMargin ?: 0)
}
val availableWidth = width - paddingLeft - paddingRight
val shouldScroll = totalTabWidth > availableWidth
val targetMode = if (shouldScroll) MODE_SCROLLABLE else MODE_FIXED
val targetGravity = if (shouldScroll) GRAVITY_START else GRAVITY_FILL
if (tabMode != targetMode) {
tabMode = targetMode
}
if (tabGravity != targetGravity) {
tabGravity = targetGravity
}
}
}

View file

@ -4,9 +4,11 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import androidx.core.widget.addTextChangedListener
import com.google.android.material.textfield.TextInputLayout
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
private var inputComplete = false
@ -16,17 +18,25 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF
override val hasPrev: Boolean
get() = true
private lateinit var smdp: TextInputLayout
private lateinit var matchingId: TextInputLayout
private lateinit var confirmationCode: TextInputLayout
private lateinit var imei: TextInputLayout
private val address: EditText by lazy {
requireView().requireViewById<TextInputLayout>(R.id.profile_download_server).editText!!
}
private val matchingId: EditText by lazy {
requireView().requireViewById<TextInputLayout>(R.id.profile_download_code).editText!!
}
private val confirmationCode: EditText by lazy {
requireView().requireViewById<TextInputLayout>(R.id.profile_download_confirmation_code).editText!!
}
private val imei: EditText by lazy {
requireView().requireViewById<TextInputLayout>(R.id.profile_download_imei).editText!!
}
private fun saveState() {
state.smdp = smdp.editText!!.text.toString().trim()
state.smdp = address.text.toString().trim()
// Treat empty inputs as null -- this is important for the download step
state.matchingId = matchingId.editText!!.text.toString().trim().ifBlank { null }
state.confirmationCode = confirmationCode.editText!!.text.toString().trim().ifBlank { null }
state.imei = imei.editText!!.text.toString().ifBlank { null }
state.matchingId = matchingId.text.toString().trim().ifBlank { null }
state.confirmationCode = confirmationCode.text.toString().trim().ifBlank { null }
state.imei = imei.text.toString().ifBlank { null }
}
override fun beforeNext() = saveState()
@ -41,40 +51,30 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF
DownloadWizardMethodSelectFragment()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_download_details, container, false)
smdp = view.requireViewById(R.id.profile_download_server)
matchingId = view.requireViewById(R.id.profile_download_code)
confirmationCode = view.requireViewById(R.id.profile_download_confirmation_code)
imei = view.requireViewById(R.id.profile_download_imei)
smdp.editText!!.addTextChangedListener {
updateInputCompleteness()
}
confirmationCode.editText!!.addTextChangedListener {
updateInputCompleteness()
}
return view
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
inflater.inflate(R.layout.fragment_download_details, container, /* attachToRoot = */ false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
address.addTextChangedListener(onTextChanged = ::handlePasteLPAString)
address.addTextChangedListener { updateInputCompleteness() }
matchingId.addTextChangedListener(onTextChanged = ::handlePasteLPAString)
confirmationCode.addTextChangedListener { updateInputCompleteness() }
}
override fun onStart() {
super.onStart()
smdp.editText!!.setText(state.smdp)
matchingId.editText!!.setText(state.matchingId)
confirmationCode.editText!!.setText(state.confirmationCode)
imei.editText!!.setText(state.imei)
address.setText(state.smdp)
matchingId.setText(state.matchingId)
confirmationCode.setText(state.confirmationCode)
imei.setText(state.imei)
updateInputCompleteness()
if (state.confirmationCodeRequired) {
confirmationCode.editText!!.requestFocus()
confirmationCode.editText!!.hint =
getString(R.string.profile_download_confirmation_code_required)
confirmationCode.requestFocus()
confirmationCode.setHint(R.string.profile_download_confirmation_code_required)
} else {
confirmationCode.editText!!.hint =
getString(R.string.profile_download_confirmation_code)
confirmationCode.setHint(R.string.profile_download_confirmation_code)
}
}
@ -83,10 +83,19 @@ class DownloadWizardDetailsFragment : DownloadWizardActivity.DownloadWizardStepF
saveState()
}
private fun handlePasteLPAString(text: CharSequence?, start: Int, before: Int, count: Int) {
if (start > 0 || before > 0) return // only handle insertions at the beginning
if (text == null || !text.startsWith("LPA:", ignoreCase = true)) return
val parsed = LPAString.parse(text)
address.setText(parsed.address)
matchingId.setText(parsed.matchingId)
if (parsed.confirmationCodeRequired) confirmationCode.requestFocus()
}
private fun updateInputCompleteness() {
inputComplete = isValidAddress(smdp.editText!!.text)
inputComplete = isValidAddress(address.text)
if (state.confirmationCodeRequired) {
inputComplete = inputComplete && confirmationCode.editText!!.text.isNotEmpty()
inputComplete = inputComplete && confirmationCode.text.isNotEmpty()
}
refreshButtons()
}
@ -98,11 +107,11 @@ private fun isValidAddress(input: CharSequence): Boolean {
var port = 443
if (input.contains(':')) {
val portIndex = input.lastIndexOf(':')
fqdn = input.substring(0, portIndex)
fqdn = input.take(portIndex)
port = input.substring(portIndex + 1, input.length).toIntOrNull(10) ?: 0
}
// see https://en.wikipedia.org/wiki/Port_(computer_networking)
if (port < 1 || port > 0xffff) return false
if (port !in 1..0xffff) return false
// see https://en.wikipedia.org/wiki/Fully_qualified_domain_name
if (fqdn.isEmpty() || fqdn.length > 255) return false
for (part in fqdn.split('.')) {
@ -114,4 +123,4 @@ private fun isValidAddress(input: CharSequence): Boolean {
}
}
return true
}
}

View file

@ -18,23 +18,11 @@ import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import net.typeblog.lpac_jni.ProfileDownloadInput
import net.typeblog.lpac_jni.ProfileDownloadState
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.ProfileDownloadCallback
class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStepFragment() {
companion object {
/**
* 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,
@ -138,7 +126,7 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
}
is EuiccChannelManagerService.ForegroundTaskState.InProgress ->
updateProgress(it.progress)
updateProgress(it.context as? ProfileDownloadState ?: return@onEach)
else -> {}
}
@ -166,13 +154,8 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
state.downloadStarted = true
val ret = euiccChannelManagerService.launchProfileDownloadTask(
slotId,
portId,
seId,
state.smdp,
state.matchingId,
state.confirmationCode,
state.imei
slotId, portId, seId,
ProfileDownloadInput(state.smdp, state.matchingId, state.imei, state.confirmationCode)
)
state.downloadTaskID = ret.taskId
@ -180,11 +163,19 @@ class DownloadWizardProgressFragment : DownloadWizardActivity.DownloadWizardStep
ret
}
private fun updateProgress(progress: Int) {
private fun updateProgress(state: ProfileDownloadState) {
val progress = state.downloadProgress
showProgressBar(progress)
val lpaState = ProfileDownloadCallback.lookupStateFromProgress(progress)
val stateIndex = LPA_PROGRESS_STATES.indexOf(lpaState)
val stateIndex = when (state) {
is ProfileDownloadState.Preparing -> 0
is ProfileDownloadState.Connecting -> 1
is ProfileDownloadState.Authenticating -> 2
// TODO: Actually implement metadata confirmation (a dialog or something else)
is ProfileDownloadState.ConfirmingDownload -> 2
is ProfileDownloadState.Downloading -> 3
is ProfileDownloadState.Finalizing -> 4
}
if (stateIndex > 0) {
for (i in (0..<stateIndex)) {

View file

@ -41,7 +41,6 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
val freeSpace: Int,
val imei: String,
val enabledProfileName: String?,
val intrinsicChannelName: String?,
) {
// A synthetic slot ID used to uniquely identify this slot + SE chip in the download process
// We assume we don't have anywhere near 2^16 ports...
@ -115,7 +114,6 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
""
},
channel.lpa.profiles.enabled?.displayName,
channel.intrinsicChannelName,
)
}
}
@ -188,7 +186,11 @@ class DownloadWizardSlotSelectFragment : DownloadWizardActivity.DownloadWizardSt
}
title.text = if (item.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
item.intrinsicChannelName ?: root.context.getString(R.string.channel_type_usb)
if (item.hasMultipleSEs) {
root.context.getString(R.string.channel_name_format_usb_se, item.seId.id)
} else {
root.context.getString(R.string.channel_name_format_usb)
}
} else if (item.hasMultipleSEs) {
appContainer.customizableTextProvider.formatNonUsbChannelNameWithSeId(
item.logicalSlotId,

View file

@ -7,7 +7,7 @@ data class LPAString(
val confirmationCodeRequired: Boolean,
) {
companion object {
fun parse(input: String): LPAString {
fun parse(input: CharSequence): LPAString {
var token = input
if (token.startsWith("LPA:", ignoreCase = true)) token = token.drop(4)
val components = token.split('$').map { it.trim().ifBlank { null } }
@ -31,4 +31,4 @@ data class LPAString(
)
return parts.joinToString("$").trimEnd('$')
}
}
}

View file

@ -5,6 +5,8 @@ import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.LocalProfileInfo
import net.typeblog.lpac_jni.ProfileClass
import net.typeblog.lpac_jni.ProfileDownloadState
const val TAG = "LPAUtils"
@ -16,11 +18,21 @@ val LocalProfileInfo.isEnabled: Boolean
get() = state == LocalProfileInfo.State.Enabled
val List<LocalProfileInfo>.operational: List<LocalProfileInfo>
get() = filter { it.profileClass == LocalProfileInfo.Clazz.Operational || it.isEnabled }
get() = filter { it.profileClass == ProfileClass.Operational || it.isEnabled }
val List<LocalProfileInfo>.enabled: LocalProfileInfo?
get() = find { it.isEnabled }
val ProfileDownloadState.downloadProgress: Int
get() = when (this) {
is ProfileDownloadState.Preparing -> 0
is ProfileDownloadState.Connecting -> 20
is ProfileDownloadState.Authenticating -> 40
is ProfileDownloadState.ConfirmingDownload -> 50
is ProfileDownloadState.Downloading -> 60
is ProfileDownloadState.Finalizing -> 80
}
val List<EuiccChannel>.hasMultipleChips: Boolean
get() = distinctBy { it.slotId }.size > 1
@ -104,4 +116,4 @@ suspend inline fun EuiccChannelManager.beginTrackedOperation(
}
}
Log.d(TAG, "Operation complete")
}
}

View file

@ -6,7 +6,7 @@
<include layout="@layout/toolbar_activity" />
<com.google.android.material.tabs.TabLayout
<im.angry.openeuicc.ui.widget.DynamicModeTabLayout
android:id="@+id/main_tabs"
android:layout_width="match_parent"
android:layout_height="wrap_content"

View file

@ -4,15 +4,21 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toTopOf="parent">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/usb_reader_text"
@ -37,11 +43,4 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/usb_reader_text" />
<FrameLayout
android:id="@+id/child_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -195,4 +195,6 @@
<string name="pref_info_website">公式サイト</string>
<string name="pref_info_source_code">ソースコード</string>
<string name="channel_name_format_se">論理スロット %1$d, SE %2$d</string>
<string name="channel_name_format_usb">USB リーダー</string>
<string name="channel_name_format_usb_se">USB リーダー, SE %d</string>
</resources>

View file

@ -193,4 +193,6 @@
<string name="download_wizard_error_suggest_contact_carrier">请联系您的运营商寻求帮助。</string>
<string name="download_wizard_error_suggest_contact_reissue">请联系您的运营商重新签发此 eSIM 配置文件。</string>
<string name="channel_name_format_se">逻辑卡槽 %1$d, SE %2$d</string>
<string name="channel_name_format_usb">USB 读卡器</string>
<string name="channel_name_format_usb_se">USB 读卡器, SE %d</string>
</resources>

View file

@ -189,4 +189,6 @@
<string name="download_wizard_error_suggest_contact_carrier">請聯絡您的電信業者尋求協助。</string>
<string name="download_wizard_error_suggest_contact_reissue">請聯絡您的電信業者重新簽發此 eSIM 設定檔。</string>
<string name="channel_name_format_se">虛擬卡槽 %1$d, SE %2$d</string>
<string name="channel_name_format_usb">USB 讀卡機</string>
<string name="channel_name_format_usb_se">USB 讀卡機, SE %d</string>
</resources>

View file

@ -11,6 +11,8 @@
<string name="channel_name_format">Logical Slot %d</string>
<string name="channel_name_format_se">Logical Slot %1$d, SE %2$d</string>
<string name="channel_type_usb" translatable="false">USB</string>
<string name="channel_name_format_usb">USB Reader</string>
<string name="channel_name_format_usb_se">USB Reader, SE %d</string>
<string name="channel_type_omapi" translatable="false">OpenMobile API (OMAPI)</string>
<!-- Profile -->

View file

@ -24,6 +24,8 @@ android {
applicationId = "im.angry.easyeuicc"
minSdk = 28
targetSdk = 35
emitAssetStatements("https://easyeuicc.org", "https://preview.easyeuicc.org")
}
buildTypes {
@ -46,4 +48,4 @@ android {
dependencies {
implementation(project(":app-common"))
}
}

View file

@ -13,6 +13,11 @@
android:theme="@style/Theme.OpenEUICC"
tools:targetApi="tiramisu">
<!-- https://web.dev/get-installed-related-apps/?hl=en -->
<meta-data
android:name="asset_statements"
android:resource="@string/asset_statements" />
<activity
android:name="im.angry.openeuicc.ui.UnprivilegedMainActivity"
android:exported="true">

View file

@ -173,6 +173,8 @@ open class QuickCompatibilityFragment : Fragment(), UnprivilegedEuiccContextMark
appendLine("MODEL: ${Build.MODEL}")
appendLine("VERSION.RELEASE: ${Build.VERSION.RELEASE}")
appendLine("VERSION.SDK_INT: ${Build.VERSION.SDK_INT}")
val carrier = getSystemProperty("ro.carrier")
if (carrier != "unknown") appendLine("CARRIER: $carrier")
}
}
@ -181,3 +183,8 @@ private inline val Reader.isSIM: Boolean
private inline val Reader.slotIndex: Int
get() = (name.replace("SIM", "").toIntOrNull() ?: 1) - 1 // 0-based index
fun getSystemProperty(name: String): String =
Runtime.getRuntime().exec(arrayOf("getprop", name))
.inputStream.bufferedReader()
.use { it.readLine() }.ifEmpty { "unknown" }

View file

@ -34,7 +34,7 @@ class UnprivilegedEuiccManagementFragment : EuiccManagementFragment() {
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.open_sim_toolkit).apply {
intent = stk[slotId]?.intent
intent = stk[slotId]
isVisible = intent != null
}
}

View file

@ -3,7 +3,6 @@ package im.angry.openeuicc.util
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
@ -13,84 +12,52 @@ import im.angry.openeuicc.core.EuiccChannelManager
class SIMToolkit(private val context: Context) {
private val slots = buildMap {
fun getComponentNames(@ArrayRes id: Int) = context.resources
.getStringArray(id).mapNotNull(ComponentName::unflattenFromString).toSet()
put(-1, getComponentNames(R.array.sim_toolkit_slot_selection))
put(0, getComponentNames(R.array.sim_toolkit_slot_1))
put(1, getComponentNames(R.array.sim_toolkit_slot_2))
fun getIntents(@ArrayRes id: Int) = context.resources.getStringArray(id)
.mapNotNull(ComponentName::unflattenFromString)
.map(Intent::makeMainActivity)
put(-1, getIntents(R.array.sim_toolkit_slot_selection))
put(0, getIntents(R.array.sim_toolkit_slot_1))
put(1, getIntents(R.array.sim_toolkit_slot_2))
}
val intents: Iterable<Intent?>
get() = listOf(get(0)?.intent, get(1)?.intent)
get() = listOf(get(0), get(1))
operator fun get(slotId: Int): Slot? = when (slotId) {
-1, EuiccChannelManager.USB_CHANNEL_ID -> null
else -> Slot(context.packageManager, buildSet {
addAll(slots.getOrDefault(slotId, emptySet()))
addAll(slots.getOrDefault(-1, emptySet()))
})
operator fun get(slotId: Int): Intent? {
if (slotId == -1 || slotId == EuiccChannelManager.USB_CHANNEL_ID) return null
val intents = (slots[slotId] ?: emptyList()) + slots[-1]!!
val packageNames = intents.mapNotNull(Intent::getPackage).toSet()
return getIntent(context.packageManager, intents) // try to find an exported activity first
?: getLaunchIntent(context.packageManager, packageNames) // fallback to launch intent
?: getDisabledPackageIntent(context.packageManager, packageNames) // app settings if disabled
}
data class Slot(private val packageManager: PackageManager, private val components: Set<ComponentName>) {
private val packageNames: Iterable<String>
get() = components.map(ComponentName::getPackageName).toSet()
.filter(packageManager::isInstalledApp)
private val launchIntent: Intent?
get() = packageNames.firstNotNullOfOrNull(packageManager::getLaunchIntentForPackage)
private val activities: Iterable<ComponentName>
get() = packageNames.flatMap(packageManager::getActivities)
.filter(ActivityInfo::exported).map { ComponentName(it.packageName, it.name) }
private fun getActivityIntent(): Intent? {
for (activity in activities) {
if (!components.contains(activity)) continue
if (isDisabledState(packageManager.getComponentEnabledSetting(activity))) continue
return Intent.makeMainActivity(activity)
}
return launchIntent
}
private fun getDisabledPackageIntent(): Intent? {
val disabledPackageName = packageNames
.find { isDisabledState(packageManager.getApplicationEnabledSetting(it)) }
?: return null
val uri = Uri.fromParts("package", disabledPackageName, null)
return Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri)
}
val intent: Intent?
get() {
val intent = getActivityIntent() ?: getDisabledPackageIntent() ?: return null
if (intent.resolveActivity(packageManager) == null) return null
return intent
}
}
fun isSelection(intent: Intent) =
slots.getOrDefault(-1, emptySet()).contains(intent.component)
fun isSelection(intent: Intent) = intent in slots[-1]!!
companion object {
fun getDisabledPackageName(intent: Intent?): String? {
if (intent?.action != Settings.ACTION_APPLICATION_DETAILS_SETTINGS) return null
return intent.data?.schemeSpecificPart
return intent.data!!.schemeSpecificPart
}
}
}
private fun isDisabledState(state: Int) = when (state) {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED -> true
PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER -> true
else -> false
private fun getIntent(packageManager: PackageManager, intents: Iterable<Intent>) =
intents.firstOrNull { it.resolveActivityInfo(packageManager, 0)?.exported ?: false }
private fun getLaunchIntent(packageManager: PackageManager, packageNames: Iterable<String>) =
packageNames.firstNotNullOfOrNull(packageManager::getLaunchIntentForPackage)
private fun getDisabledPackageIntent(packageManager: PackageManager, packageNames: Iterable<String>): Intent? {
val packageName = packageNames.firstOrNull(packageManager::isDisabledState) ?: return null
val uri = Uri.fromParts("package", packageName, null)
return Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri)
}
private fun PackageManager.isInstalledApp(packageName: String) = try {
getPackageInfo(packageName, 0)
true
} catch (_: PackageManager.NameNotFoundException) {
false
}
private fun PackageManager.getActivities(packageName: String) =
getPackageInfo(packageName, PackageManager.GET_ACTIVITIES).activities?.toList() ?: emptyList()
private fun PackageManager.isDisabledState(packageName: String) =
when (getApplicationEnabledSetting(packageName)) {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED -> true
PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER -> true
else -> false
}

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="compatibility_check">互換性の確認</string>
<string name="open_sim_toolkit">SIM ツールキットを開く</string>
<string name="open_sim_toolkit">STK メニューを開く</string>
<!-- Settings -->
<!-- Toast -->
<string name="toast_ara_m_copied">ARA-M SHA-1 をクリップボードにコピーしました</string>
@ -18,4 +18,6 @@
<string name="quick_compatibility_button_continue">続行</string>
<string name="quick_compatibility_skip">このメッセージを再度表示しない</string>
<string name="quick_compatibility_unknown">不明</string>
<string name="shortcut_sim_toolkit">STK メニュー</string>
<string name="shortcut_sim_toolkit_with_slot">STK メニュー #%d</string>
</resources>

View file

@ -1,8 +1,8 @@
<resources>
<string name="compatibility_check">兼容性检查</string>
<string name="open_sim_toolkit">打开 SIM 卡应用程序</string>
<string name="open_sim_toolkit">打开 STK 菜单</string>
<string name="toast_ara_m_copied">ARA-M SHA-1 已拷贝到剪贴板</string>
<string name="toast_prompt_to_enable_sim_toolkit">请启用您的“%s”应用程序</string>
<string name="toast_prompt_to_enable_sim_toolkit">请启用您的 “%s” 应用程序</string>
<string name="quick_compatibility">简易兼容性检测</string>
<string name="quick_compatibility_compatible">您的手机可以管理兼容 %s 的卡片</string>
<string name="quick_compatibility_not_compatible">您的手机与 %s 不兼容</string>
@ -14,4 +14,6 @@
<string name="quick_compatibility_button_continue">继续</string>
<string name="quick_compatibility_skip">不再显示此消息</string>
<string name="quick_compatibility_unknown">未知</string>
</resources>
<string name="shortcut_sim_toolkit">STK 菜单</string>
<string name="shortcut_sim_toolkit_with_slot">STK 菜单 #%d</string>
</resources>

View file

@ -1,8 +1,8 @@
<resources>
<string name="compatibility_check">相容性檢查</string>
<string name="open_sim_toolkit">啟動 SIM 卡應用程式</string>
<string name="open_sim_toolkit">啟動 STK 選單</string>
<string name="toast_ara_m_copied">ARA-M SHA-1 已複製到剪貼簿</string>
<string name="toast_prompt_to_enable_sim_toolkit">請啟用您的“%s”應用程式</string>
<string name="toast_prompt_to_enable_sim_toolkit">請啟用您的「%s」應用程式</string>
<string name="quick_compatibility">簡易相容性檢測</string>
<string name="quick_compatibility_compatible">您的手機可以管理相容 %s 的卡片</string>
<string name="quick_compatibility_not_compatible">您的手機與 %s 不相容</string>
@ -14,4 +14,6 @@
<string name="quick_compatibility_button_continue">繼續</string>
<string name="quick_compatibility_skip">不再顯示此訊息</string>
<string name="quick_compatibility_unknown">未知</string>
</resources>
<string name="shortcut_sim_toolkit">STK 選單</string>
<string name="shortcut_sim_toolkit_with_slot">STK 選單 #%d</string>
</resources>

View file

@ -33,7 +33,6 @@ class PrivilegedEuiccChannelFactory(context: Context) : DefaultEuiccChannelFacto
return EuiccChannelImpl(
context.getString(R.string.channel_type_telephony_manager),
port,
intrinsicChannelName = null,
TelephonyManagerApduInterface(
port,
telephonyManager,

View file

@ -19,6 +19,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import net.typeblog.lpac_jni.LocalProfileInfo
import net.typeblog.lpac_jni.ProfileClass
class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
companion object {
@ -210,9 +211,9 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
)
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
ProfileClass.Testing -> EuiccProfileInfo.PROFILE_CLASS_TESTING
ProfileClass.Provisioning -> EuiccProfileInfo.PROFILE_CLASS_PROVISIONING
ProfileClass.Operational -> EuiccProfileInfo.PROFILE_CLASS_OPERATIONAL
}
)
}.build()

View file

@ -1,5 +1,6 @@
package im.angry.openeuicc.ui
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.preference.CheckBoxPreference
import androidx.preference.Preference
@ -7,6 +8,13 @@ import im.angry.openeuicc.R
import im.angry.openeuicc.util.*
class PrivilegedSettingsFragment : SettingsFragment(), PrivilegedEuiccContextMarker {
private val isSignedWithPlatformKey by lazy {
val info = with(requireContext()) {
packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
}
info.javaClass.getMethod("isSignedWithPlatformKey").invoke(info) as Boolean
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
super.onCreatePreferences(savedInstanceState, rootKey)
addPreferencesFromResource(R.xml.pref_privileged_settings)
@ -18,7 +26,9 @@ class PrivilegedSettingsFragment : SettingsFragment(), PrivilegedEuiccContextMar
// This is disabled here, not moved to unprivileged, because I have hope that this will
// eventually work for platform-signed apps. Or, at some point we might introduce our own
// locale picker, which hopefully works whether privileged or not.
requirePreference<Preference>("pref_advanced_language").isVisible = false
requirePreference<Preference>("pref_advanced_language").apply {
isVisible = isVisible && !isSignedWithPlatformKey
}
// Force use TelephonyManager API
requirePreference<CheckBoxPreference>("pref_developer_removable_telephony_manager")

View file

@ -0,0 +1,18 @@
package im.angry.openeuicc.build
import com.android.build.api.dsl.VariantDimension
import groovy.json.JsonOutput
import groovy.json.StringEscapeUtils
/**
* https://web.dev/get-installed-related-apps/?hl=en
*/
fun VariantDimension.emitAssetStatements(vararg sites: String) {
val relation = listOf("delegate_permission/common.handle_all_urls")
val statements = sites.map {
val target = mapOf("namespace" to "web", "site" to it)
mapOf("relation" to relation, "target" to target)
}
val output = JsonOutput.toJson(statements)
resValue(type = "string", name = "asset_statements", value = StringEscapeUtils.escapeJava(output))
}

View file

@ -20,32 +20,39 @@ val Project.gitVersionCode: Int
0
}
val Project.gitVersionName: String
get() =
try {
val stdout = ByteArrayOutputStream()
exec {
commandLine("git", "describe", "--always", "--tags", "--dirty")
standardOutput = stdout
}
stdout.toString("utf-8").trim('\n')
} catch (_: Exception) {
"Unknown"
fun Project.getGitVersionName(vararg args: String): String =
try {
val stdout = ByteArrayOutputStream()
exec {
commandLine("git", "describe", "--always", "--tags", "--dirty", *args)
standardOutput = stdout
}
stdout.toString("utf-8").trim('\n').removePrefix("unpriv-")
} catch (_: Exception) {
"Unknown"
}
class MyVersioningPlugin : Plugin<Project> {
override fun apply(target: Project) {
target.configure<BaseAppModuleExtension> {
defaultConfig {
versionCode = target.gitVersionCode
versionName = target.gitVersionName.removePrefix("unpriv-")
// format: <tag>[-<commits>-g<hash>][-dirty][-suffix]
versionName = target.getGitVersionName()
}
applicationVariants.all {
if (name == "debug") {
val versionCode = (System.currentTimeMillis() / 1000).toInt()
// format: <tag>-<commits>-g<hash>[-dirty][-suffix]
val versionName = target.getGitVersionName("--long")
val versionNameSuffix = mergedFlavor.versionNameSuffix
outputs.forEach {
(it as ApkVariantOutputImpl).versionCodeOverride =
(System.currentTimeMillis() / 1000).toInt()
with(it as ApkVariantOutputImpl) {
versionCodeOverride = versionCode
versionNameOverride = versionName + versionNameSuffix
}
}
}
}

View file

@ -37,10 +37,7 @@ interface LocalProfileAssistant {
fun disableProfile(iccid: String, refresh: Boolean = true): Boolean
fun deleteProfile(iccid: String): Boolean
fun downloadProfile(
smdp: String, matchingId: String?, imei: String?,
confirmationCode: String?, callback: ProfileDownloadCallback
)
fun downloadProfile(input: ProfileDownloadInput, callback: ProfileDownloadCallback)
fun deleteNotification(seqNumber: Long): Boolean
fun handleNotification(seqNumber: Long): Boolean

View file

@ -7,7 +7,7 @@ data class LocalProfileInfo(
val nickName: String,
val providerName: String,
val isdpAID: String,
val profileClass: Clazz
val profileClass: ProfileClass
) {
enum class State {
Enabled,
@ -24,20 +24,4 @@ data class LocalProfileInfo(
}
}
enum class Clazz {
Testing,
Provisioning,
Operational;
companion object {
@JvmStatic
fun fromString(str: String?) =
when (str?.lowercase()) {
"test" -> Testing
"provisioning" -> Provisioning
"operational" -> Operational
else -> Operational
}
}
}
}
}

View file

@ -31,9 +31,9 @@ internal object LpacJni {
external fun es10bDeleteNotification(handle: Long, seqNumber: Long): Int
// es9p + es10b
// We do not expose all of the functions because of tediousness :)
// We do not expose all the functions because of tediousness :)
external fun downloadProfile(
handle: Long, smdp: String, matchingId: String?, imei: String?,
handle: Long, address: String, matchingId: String?, imei: String?,
confirmationCode: String?, callback: ProfileDownloadCallback
): Int

View file

@ -0,0 +1,18 @@
package net.typeblog.lpac_jni
enum class ProfileClass {
Testing,
Provisioning,
Operational;
companion object {
@JvmStatic
fun fromString(str: String?) =
when (str?.lowercase()) {
"test" -> Testing
"provisioning" -> Provisioning
"operational" -> Operational
else -> Operational
}
}
}

View file

@ -1,25 +1,5 @@
package net.typeblog.lpac_jni
interface ProfileDownloadCallback {
companion object {
fun lookupStateFromProgress(progress: Int): DownloadState =
when (progress) {
0 -> DownloadState.Preparing
20 -> DownloadState.Connecting
40 -> DownloadState.Authenticating
60 -> DownloadState.Downloading
80 -> DownloadState.Finalizing
else -> throw IllegalArgumentException("Unknown state")
}
}
enum class DownloadState(val progress: Int) {
Preparing(0),
Connecting(20), // Before {server,client} authentication
Authenticating(40), // {server,client} authentication
Downloading(60), // prepare download, get bpp from es9p
Finalizing(80), // load bpp
}
fun onStateUpdate(state: DownloadState)
}
fun interface ProfileDownloadCallback {
fun onStatusUpdate(state: ProfileDownloadState): Boolean
}

View file

@ -0,0 +1,8 @@
package net.typeblog.lpac_jni
data class ProfileDownloadInput(
val address: String,
val matchingId: String?,
val imei: String?,
val confirmationCode: String?
)

View file

@ -0,0 +1,10 @@
package net.typeblog.lpac_jni
sealed class ProfileDownloadState {
class Preparing : ProfileDownloadState()
class Connecting : ProfileDownloadState()
class Authenticating : ProfileDownloadState()
class ConfirmingDownload(val metadata: RemoteProfileInfo?) : ProfileDownloadState()
class Downloading : ProfileDownloadState()
class Finalizing : ProfileDownloadState()
}

View file

@ -0,0 +1,9 @@
package net.typeblog.lpac_jni
// TODO: We need to export profilePolicyRules here as well (currently unsupported by lpac)
data class RemoteProfileInfo(
val iccid: String,
val name: String,
val providerName: String,
val profileClass: ProfileClass,
)

View file

@ -1,6 +1,7 @@
package net.typeblog.lpac_jni.impl
import android.util.Log
import net.typeblog.lpac_jni.ProfileDownloadInput
import net.typeblog.lpac_jni.ApduInterface
import net.typeblog.lpac_jni.EuiccInfo2
import net.typeblog.lpac_jni.HttpInterface
@ -9,6 +10,7 @@ import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.LocalProfileInfo
import net.typeblog.lpac_jni.LocalProfileNotification
import net.typeblog.lpac_jni.LpacJni
import net.typeblog.lpac_jni.ProfileClass
import net.typeblog.lpac_jni.ProfileDownloadCallback
import net.typeblog.lpac_jni.Version
import java.util.concurrent.locks.ReentrantLock
@ -117,7 +119,7 @@ class LocalProfileAssistantImpl(
val ret = mutableListOf<LocalProfileInfo>()
while (curr != 0L) {
val state = LocalProfileInfo.State.fromString(LpacJni.profileGetStateString(curr))
val clazz = LocalProfileInfo.Clazz.fromString(LpacJni.profileGetClassString(curr))
val clazz = ProfileClass.fromString(LpacJni.profileGetClassString(curr))
ret.add(
LocalProfileInfo(
LpacJni.profileGetIccid(curr),
@ -214,16 +216,13 @@ class LocalProfileAssistantImpl(
LpacJni.es10cDeleteProfile(contextHandle, iccid) == 0
}
override fun downloadProfile(
smdp: String, matchingId: String?, imei: String?,
confirmationCode: String?, callback: ProfileDownloadCallback
) = lock.withLock {
override fun downloadProfile(input: ProfileDownloadInput, callback: ProfileDownloadCallback) = lock.withLock {
val res = LpacJni.downloadProfile(
contextHandle,
smdp,
matchingId,
imei,
confirmationCode,
input.address,
input.matchingId,
input.imei,
input.confirmationCode,
callback
)

View file

@ -1,6 +1,10 @@
cc_defaults {
name: "lpac-jni-defaults",
local_include_dirs: ["lpac"],
local_include_dirs: [
"cjson",
"lpac",
"lpac/cjson-ext",
],
sdk_version: "current",
cflags: ["-Wno-error"],
}
@ -9,7 +13,8 @@ cc_library_static {
name: "liblpac-cjson",
defaults: ["lpac-jni-defaults"],
srcs: [
"lpac/cjson/*.c",
"cjson/cjson/*.c",
"lpac/cjson-ext/cjson-ext/*.c",
],
}

@ -0,0 +1 @@
Subproject commit c859b25da02955fef659d658b8f324b5cde87be3

@ -1 +1 @@
Subproject commit 90f7104847d4bb392b275746da20a55177a67573
Subproject commit d214738fa0bdb23faf5833d3d798963079a00468

View file

@ -11,8 +11,11 @@ endef
include $(CLEAR_VARS)
# libcjson
LOCAL_MODULE := lpac-cjson
LOCAL_C_INCLUDES := \
$(LOCAL_PATH)/cjson
LOCAL_SRC_FILES := \
$(call all-c-files-under, lpac/cjson)
$(call all-c-files-under, cjson/cjson) \
$(call all-c-files-under, lpac/cjson-ext/cjson-ext)
include $(BUILD_STATIC_LIBRARY)
include $(CLEAR_VARS)
@ -20,7 +23,9 @@ include $(CLEAR_VARS)
LOCAL_MODULE := lpac-euicc
LOCAL_STATIC_LIBRARIES := lpac-cjson
LOCAL_C_INCLUDES := \
$(LOCAL_PATH)/lpac
$(LOCAL_PATH)/lpac \
$(LOCAL_PATH)/lpac/cjson-ext \
$(LOCAL_PATH)/cjson
LOCAL_SRC_FILES := \
$(call all-c-files-under, lpac/euicc)
include $(BUILD_STATIC_LIBRARY)
@ -32,4 +37,4 @@ LOCAL_C_INCLUDES := \
$(LOCAL_PATH)/lpac
LOCAL_SRC_FILES := \
$(call all-c-files-under, lpac-jni)
include $(BUILD_SHARED_LIBRARY)
include $(BUILD_SHARED_LIBRARY)

View file

@ -1,5 +1,6 @@
#include <euicc/es9p.h>
#include <euicc/es10b.h>
#include <euicc/es8p.h>
#include <stdlib.h>
#include <string.h>
#include <syslog.h>
@ -12,48 +13,148 @@ jobject download_state_downloading;
jobject download_state_finalizing;
jmethodID on_state_update;
jclass confirming_download_class;
jmethodID confirming_download_constructor;
jclass remote_profile_info_class;
jmethodID remote_profile_info_constructor;
jobject profile_class_testing;
jobject profile_class_provisioning;
jobject profile_class_operational;
void lpac_download_init() {
LPAC_JNI_SETUP_ENV;
jclass download_state_class = (*env)->FindClass(env,
"net/typeblog/lpac_jni/ProfileDownloadCallback$DownloadState");
jfieldID download_state_preparing_field = (*env)->GetStaticFieldID(env, download_state_class,
"Preparing",
"Lnet/typeblog/lpac_jni/ProfileDownloadCallback$DownloadState;");
download_state_preparing = (*env)->GetStaticObjectField(env, download_state_class,
download_state_preparing_field);
download_state_preparing = (*env)->NewGlobalRef(env, download_state_preparing);
jfieldID download_state_connecting_field = (*env)->GetStaticFieldID(env, download_state_class,
"Connecting",
"Lnet/typeblog/lpac_jni/ProfileDownloadCallback$DownloadState;");
download_state_connecting = (*env)->GetStaticObjectField(env, download_state_class,
download_state_connecting_field);
download_state_connecting = (*env)->NewGlobalRef(env, download_state_connecting);
jfieldID download_state_authenticating_field = (*env)->GetStaticFieldID(env,
download_state_class,
"Authenticating",
"Lnet/typeblog/lpac_jni/ProfileDownloadCallback$DownloadState;");
download_state_authenticating = (*env)->GetStaticObjectField(env, download_state_class,
download_state_authenticating_field);
download_state_authenticating = (*env)->NewGlobalRef(env, download_state_authenticating);
jfieldID download_state_downloading_field = (*env)->GetStaticFieldID(env, download_state_class,
"Downloading",
"Lnet/typeblog/lpac_jni/ProfileDownloadCallback$DownloadState;");
download_state_downloading = (*env)->GetStaticObjectField(env, download_state_class,
download_state_downloading_field);
download_state_downloading = (*env)->NewGlobalRef(env, download_state_downloading);
jfieldID download_state_finalizng_field = (*env)->GetStaticFieldID(env, download_state_class,
"Finalizing",
"Lnet/typeblog/lpac_jni/ProfileDownloadCallback$DownloadState;");
download_state_finalizing = (*env)->GetStaticObjectField(env, download_state_class,
download_state_finalizng_field);
download_state_finalizing = (*env)->NewGlobalRef(env, download_state_finalizing);
jclass preparing_class = (*env)->FindClass(env,
"net/typeblog/lpac_jni/ProfileDownloadState$Preparing");
jmethodID preparing_constructor = (*env)->GetMethodID(env, preparing_class,
"<init>",
"()V");
jobject _download_state_preparing = (*env)->NewObject(env, preparing_class,
preparing_constructor);
download_state_preparing = (*env)->NewGlobalRef(env, _download_state_preparing);
(*env)->DeleteLocalRef(env, _download_state_preparing);
jclass connecting_class = (*env)->FindClass(env,
"net/typeblog/lpac_jni/ProfileDownloadState$Connecting");
jmethodID connecting_constructor = (*env)->GetMethodID(env, connecting_class,
"<init>",
"()V");
jobject _download_state_connecting = (*env)->NewObject(env, connecting_class,
connecting_constructor);
download_state_connecting = (*env)->NewGlobalRef(env, _download_state_connecting);
(*env)->DeleteLocalRef(env, _download_state_connecting);
jclass authenticating_class = (*env)->FindClass(env,
"net/typeblog/lpac_jni/ProfileDownloadState$Authenticating");
jmethodID authenticating_constructor = (*env)->GetMethodID(env, authenticating_class,
"<init>",
"()V");
jobject _download_state_authenticating = (*env)->NewObject(env, authenticating_class,
authenticating_constructor);
download_state_authenticating = (*env)->NewGlobalRef(env, _download_state_authenticating);
(*env)->DeleteLocalRef(env, _download_state_authenticating);
jclass downloading_class = (*env)->FindClass(env,
"net/typeblog/lpac_jni/ProfileDownloadState$Downloading");
jmethodID downloading_constructor = (*env)->GetMethodID(env, downloading_class,
"<init>",
"()V");
jobject _download_state_downloading = (*env)->NewObject(env, downloading_class,
downloading_constructor);
download_state_downloading = (*env)->NewGlobalRef(env, _download_state_downloading);
(*env)->DeleteLocalRef(env, _download_state_downloading);
jclass finalizing_class = (*env)->FindClass(env,
"net/typeblog/lpac_jni/ProfileDownloadState$Finalizing");
jmethodID finalizing_constructor = (*env)->GetMethodID(env, finalizing_class,
"<init>",
"()V");
jobject _download_state_finalizing = (*env)->NewObject(env, finalizing_class,
finalizing_constructor);
download_state_finalizing = (*env)->NewGlobalRef(env, _download_state_finalizing);
(*env)->DeleteLocalRef(env, _download_state_finalizing);
jclass download_callback_class = (*env)->FindClass(env,
"net/typeblog/lpac_jni/ProfileDownloadCallback");
on_state_update = (*env)->GetMethodID(env, download_callback_class, "onStateUpdate",
"(Lnet/typeblog/lpac_jni/ProfileDownloadCallback$DownloadState;)V");
on_state_update = (*env)->GetMethodID(env, download_callback_class, "onStatusUpdate",
"(Lnet/typeblog/lpac_jni/ProfileDownloadState;)Z");
jclass _confirming_download_class = (*env)->FindClass(env,
"net/typeblog/lpac_jni/ProfileDownloadState$ConfirmingDownload");
confirming_download_class = (*env)->NewGlobalRef(env, _confirming_download_class);
confirming_download_constructor = (*env)->GetMethodID(env,
confirming_download_class,
"<init>",
"(Lnet/typeblog/lpac_jni/RemoteProfileInfo;)V");
jclass profile_class_class = (*env)->FindClass(env, "net/typeblog/lpac_jni/ProfileClass");
jfieldID profile_class_testing_field = (*env)->GetStaticFieldID(env, profile_class_class,
"Testing",
"Lnet/typeblog/lpac_jni/ProfileClass;");
profile_class_testing = (*env)->GetStaticObjectField(env, profile_class_class,
profile_class_testing_field);
profile_class_testing = (*env)->NewGlobalRef(env, profile_class_testing);
jfieldID profile_class_provisioning_field = (*env)->GetStaticFieldID(env, profile_class_class,
"Provisioning",
"Lnet/typeblog/lpac_jni/ProfileClass;");
profile_class_provisioning = (*env)->GetStaticObjectField(env, profile_class_class,
profile_class_provisioning_field);
profile_class_provisioning = (*env)->NewGlobalRef(env, profile_class_provisioning);
jfieldID profile_class_operational_field = (*env)->GetStaticFieldID(env, profile_class_class,
"Operational",
"Lnet/typeblog/lpac_jni/ProfileClass;");
profile_class_operational = (*env)->GetStaticObjectField(env, profile_class_class,
profile_class_operational_field);
profile_class_operational = (*env)->NewGlobalRef(env, profile_class_operational);
jclass _remote_profile_info_class = (*env)->FindClass(env,
"net/typeblog/lpac_jni/RemoteProfileInfo");
remote_profile_info_class = (*env)->NewGlobalRef(env, _remote_profile_info_class);
remote_profile_info_constructor = (*env)->GetMethodID(env, remote_profile_info_class, "<init>",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lnet/typeblog/lpac_jni/ProfileClass;)V");
}
static jobject profile_class_from_es10c_profile_class(enum es10c_profile_class profile_class) {
switch (profile_class) {
case ES10C_PROFILE_CLASS_TEST:
return profile_class_testing;
case ES10C_PROFILE_CLASS_PROVISIONING:
return profile_class_provisioning;
case ES10C_PROFILE_CLASS_OPERATIONAL:
default:
// In es10c profiles are considered operational if the field is missing (null).
return profile_class_operational;
}
}
static jobject create_remote_profile_info(JNIEnv *env,
struct es8p_metadata *profile_metadata) {
jobject profile_class = NULL;
jstring metadata_iccid = NULL;
jstring metadata_profile_name = NULL;
jstring metadata_provider_name = NULL;
jobject remote_profile_info = NULL;
metadata_iccid = toJString(env, profile_metadata->iccid);
metadata_profile_name = toJString(env, profile_metadata->profileName);
metadata_provider_name = toJString(env, profile_metadata->serviceProviderName);
profile_class = profile_class_from_es10c_profile_class(profile_metadata->profileClass);
remote_profile_info = (*env)->NewObject(env, remote_profile_info_class,
remote_profile_info_constructor,
metadata_iccid,
metadata_profile_name,
metadata_provider_name,
profile_class);
if (metadata_iccid != NULL)
(*env)->DeleteLocalRef(env, metadata_iccid);
if (metadata_profile_name != NULL)
(*env)->DeleteLocalRef(env, metadata_profile_name);
if (metadata_provider_name != NULL)
(*env)->DeleteLocalRef(env, metadata_provider_name);
return remote_profile_info;
}
JNIEXPORT jint JNICALL
@ -62,11 +163,15 @@ Java_net_typeblog_lpac_1jni_LpacJni_downloadProfile(JNIEnv *env, jobject thiz, j
jstring imei, jstring confirmation_code,
jobject callback) {
struct euicc_ctx *ctx = (struct euicc_ctx *) handle;
struct es8p_metadata *profile_metadata = NULL;
struct es10b_load_bound_profile_package_result es10b_load_bound_profile_package_result;
const char *_confirmation_code = NULL;
const char *_matching_id = NULL;
const char *_smdp = NULL;
const char *_imei = NULL;
jobject remote_profile_info = NULL;
jobject confirming_download_state = NULL;
jboolean confirmed = JNI_TRUE;
int ret;
if (confirmation_code != NULL)
@ -79,7 +184,12 @@ Java_net_typeblog_lpac_1jni_LpacJni_downloadProfile(JNIEnv *env, jobject thiz, j
ctx->http.server_address = _smdp;
(*env)->CallVoidMethod(env, callback, on_state_update, download_state_preparing);
confirmed = (*env)->CallBooleanMethod(env, callback, on_state_update, download_state_preparing);
if (!confirmed) {
ret = -ES10B_ERROR_REASON_UNDEFINED;
goto out;
}
ret = es10b_get_euicc_challenge_and_info(ctx);
syslog(LOG_INFO, "es10b_get_euicc_challenge_and_info %d", ret);
if (ret < 0) {
@ -87,7 +197,12 @@ Java_net_typeblog_lpac_1jni_LpacJni_downloadProfile(JNIEnv *env, jobject thiz, j
goto out;
}
(*env)->CallVoidMethod(env, callback, on_state_update, download_state_connecting);
confirmed = (*env)->CallBooleanMethod(env, callback, on_state_update, download_state_connecting);
if (!confirmed) {
ret = -ES10B_ERROR_REASON_UNDEFINED;
goto out;
}
ret = es9p_initiate_authentication(ctx);
syslog(LOG_INFO, "es9p_initiate_authentication %d", ret);
if (ret < 0) {
@ -95,7 +210,12 @@ Java_net_typeblog_lpac_1jni_LpacJni_downloadProfile(JNIEnv *env, jobject thiz, j
goto out;
}
(*env)->CallVoidMethod(env, callback, on_state_update, download_state_authenticating);
confirmed = (*env)->CallBooleanMethod(env, callback, on_state_update, download_state_authenticating);
if (!confirmed) {
ret = -ES10B_ERROR_REASON_UNDEFINED;
goto out;
}
ret = es10b_authenticate_server(ctx, _matching_id, _imei);
syslog(LOG_INFO, "es10b_authenticate_server %d", ret);
if (ret < 0) {
@ -109,7 +229,53 @@ Java_net_typeblog_lpac_1jni_LpacJni_downloadProfile(JNIEnv *env, jobject thiz, j
goto out;
}
(*env)->CallVoidMethod(env, callback, on_state_update, download_state_downloading);
if (ctx->http._internal.prepare_download_param != NULL &&
ctx->http._internal.prepare_download_param->b64_profileMetadata != NULL) {
ret = es8p_metadata_parse(&profile_metadata,
ctx->http._internal.prepare_download_param->b64_profileMetadata);
if (ret < 0) {
ret = -ES10B_ERROR_REASON_UNDEFINED;
goto out;
}
remote_profile_info = create_remote_profile_info(env, profile_metadata);
if (remote_profile_info == NULL) {
ret = -ES10B_ERROR_REASON_UNDEFINED;
goto out;
}
}
confirming_download_state = (*env)->NewObject(env,
confirming_download_class,
confirming_download_constructor,
remote_profile_info);
if (confirming_download_state == NULL) {
ret = -ES10B_ERROR_REASON_UNDEFINED;
goto out;
}
confirmed = (*env)->CallBooleanMethod(env, callback, on_state_update, confirming_download_state);
if (remote_profile_info != NULL) {
(*env)->DeleteLocalRef(env, remote_profile_info);
remote_profile_info = NULL;
}
if (confirming_download_state != NULL) {
(*env)->DeleteLocalRef(env, confirming_download_state);
confirming_download_state = NULL;
}
if (!confirmed) {
ret = -ES10B_ERROR_REASON_UNDEFINED;
goto out;
}
confirmed = (*env)->CallBooleanMethod(env, callback, on_state_update, download_state_downloading);
if (!confirmed) {
ret = -ES10B_ERROR_REASON_UNDEFINED;
goto out;
}
ret = es10b_prepare_download(ctx, _confirmation_code);
syslog(LOG_INFO, "es10b_prepare_download %d", ret);
if (ret < 0) {
@ -121,7 +287,12 @@ Java_net_typeblog_lpac_1jni_LpacJni_downloadProfile(JNIEnv *env, jobject thiz, j
if (ret < 0)
goto out;
(*env)->CallVoidMethod(env, callback, on_state_update, download_state_finalizing);
confirmed = (*env)->CallBooleanMethod(env, callback, on_state_update, download_state_finalizing);
if (!confirmed) {
ret = -ES10B_ERROR_REASON_UNDEFINED;
goto out;
}
ret = es10b_load_bound_profile_package(ctx, &es10b_load_bound_profile_package_result);
syslog(LOG_INFO, "es10b_load_bound_profile_package %d, reason %d", ret, es10b_load_bound_profile_package_result.errorReason);
if (ret < 0) {
@ -141,6 +312,12 @@ Java_net_typeblog_lpac_1jni_LpacJni_downloadProfile(JNIEnv *env, jobject thiz, j
(*env)->ReleaseStringUTFChars(env, smdp, _smdp);
if (_imei != NULL)
(*env)->ReleaseStringUTFChars(env, imei, _imei);
if (remote_profile_info != NULL)
(*env)->DeleteLocalRef(env, remote_profile_info);
if (confirming_download_state != NULL)
(*env)->DeleteLocalRef(env, confirming_download_state);
if (profile_metadata != NULL)
es8p_metadata_free(&profile_metadata);
return ret;
}
@ -181,4 +358,4 @@ Java_net_typeblog_lpac_1jni_LpacJni_downloadErrCodeToString(JNIEnv *env, jobject
default:
return toJString(env, "ES10B_ERROR_REASON_UNDEFINED");
}
}
}