Compare commits

...

43 commits

Author SHA1 Message Date
8d76f0eeac Merge remote-tracking branch 'openeuicc/master' into jmp
All checks were successful
/ build-debug (push) Successful in 39m19s
2024-10-21 21:05:31 -04:00
d26a8ddc78 EuiccChannelManagerService: stop using blocking variants unnecessarily 2024-10-20 20:37:44 -04:00
aac457f4b5 Don't dismiss DialogFragment's when in background
They shall be dismissed automatically. Doing it here may cause
IllegalStateException.

Note that even if they are dismissed, the corresponding tasks will
continue to be executed by EuiccChannelManagerService and will not be
cancelled.
2024-10-20 11:47:27 -04:00
2337ad035d Post a notification to signify task failure from service 2024-10-20 11:44:20 -04:00
7197501cca ProfileDownloadFragment: Stop catching all exceptions
..instead, use the error value returned in the foreground task result.

Catching all exceptions will result in cancellation being ignored when
the fragment is destroyed.
2024-10-19 21:13:07 -04:00
4709b6994f Don't call stopSelf() if the coroutine is cancelled 2024-10-19 19:14:14 -04:00
349c8179b0 Force foreground tasks to always complete (i.e. not cancelled) 2024-10-19 19:09:35 -04:00
16b6aceedf ui: Remove more blocking operations in NotificationsActivity 2024-10-12 11:09:16 -04:00
eab96dde05 ui: Reduce blocking operations on transition 2024-10-12 10:49:47 -04:00
84dd16c169 Enable predicative back gestures 2024-10-12 10:39:48 -04:00
d3a04b94a9 Set MSS on es10x commands to 60 for OMAPI channels
This seems to help a LOT with 6601 checksum errors on phones like Pixel
7 Pro. I am seeing virtually none with MSS = 60.
2024-10-10 22:08:32 -04:00
19dc215b3f lpac-jni: Update lpac 2024-10-10 21:52:57 -04:00
ddc421dae7 omapi: Retry on 0x6601 checksum errors
Some removable eUICCs don't play nicely with some modems. When they emit
0x6601 checksum errors, retry at least a few times before giving up.

This fixes support for eSTK.me / JMP eSIM Adapters on Pixel 7 Pro.
2024-10-09 22:45:09 -04:00
69e63b0a8b Stop using runBlocking inside suspend funs 2024-10-09 21:46:49 -04:00
290bdca75a feat: Introduce option for global verbose logging 2024-10-09 20:20:32 -04:00
5c8bbeb217 Fix removable eSIM safeguards description again 2024-10-09 18:14:54 -04:00
ff266a4a9b Un-confusing-ify the "safeguards" description 2024-10-09 18:13:51 -04:00
6b71a746a4 Add monochrome icons for v31+ 2024-10-09 18:10:14 -04:00
165f685abb Remove unused beginTrackedOperation in UI 2024-09-30 19:53:25 -04:00
42942c2816 ui: Handle navbar insets properly 2024-09-30 19:49:24 -04:00
54b4f61fd7 Improve notification channel creation 2024-09-29 21:50:47 -04:00
7661b4b84f Output any foreground task error to Log 2024-09-29 21:45:55 -04:00
479e0ff34a Move profile switching to use the new foreground task flow 2024-09-29 21:44:32 -04:00
79f43e2fda Set notification to alert only once 2024-09-29 17:40:58 -04:00
8573834a03 Improve download progress bar UI 2024-09-29 17:39:32 -04:00
2721f91277 Make foreground notifications much more reliable 2024-09-29 17:36:58 -04:00
653123939c Set notifications to ongoing 2024-09-29 17:07:15 -04:00
48b5f8ce06 Impose timeout on waiting for foreground start 2024-09-29 16:54:49 -04:00
31c06470c6 Move profile deletion to new flow 2024-09-29 16:49:21 -04:00
cf5704be42 Move profile download to the new foreground flow
...and fix race condition introduced by the new flow
2024-09-29 16:32:58 -04:00
f71da0e4ff Update documentation 2024-09-29 15:32:46 -04:00
fe1319537a Make foreground tasks block UI reloads 2024-09-29 15:30:49 -04:00
8de0d86895 ProfileDownloadFragment: Wait for euiccChannelManager to load 2024-09-29 15:14:43 -04:00
64a350d271 lpac-jni: Force reduce connect/read timeout for notification requests 2024-09-29 15:12:18 -04:00
9a77824f79 Enforce updateForegroundNotification to run in the main thread 2024-09-29 14:40:15 -04:00
3add3ffa90 refactor: Launch profile download task inside EuiccChannelManagerService
This task is too long to run directly inside the fragment lifecycle.
Instead, let's launch it inside the service lifecycle scope and use a
MutableStateFlow to notify the UI of progress.

This interface is designed to be extensible to other use cases.
2024-09-29 14:09:17 -04:00
324dcdc563 EuiccManagementFragment: prevent crashes on configuration change
This is... not supposed to happen. We made too many assumptions about
the Fragment lifecycle.
2024-09-28 16:15:17 -04:00
b94eedac0a All LPA methods should be synchronized
Calling them from multiple threads is undefined
2024-09-28 11:15:51 -04:00
a6777d1d17 Apply layout insets to more activities 2024-09-28 11:05:18 -04:00
dc70f7ca46 Fix toolbar id in app-unpriv 2024-09-28 10:58:46 -04:00
77d95e4d02 Remove unused log 2024-09-28 10:44:29 -04:00
4a32f53c06 Commonize activity toolbar layouts 2024-09-28 10:28:24 -04:00
97bc0a0827 chore: uprev to targetSDK 35 and basic edge2edge inset fixes 2024-09-24 22:22:18 -04:00
58 changed files with 985 additions and 213 deletions

View file

@ -5,6 +5,9 @@
<SelectionState runConfigName="app-unpriv">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

View file

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

View file

@ -3,10 +3,14 @@
xmlns:android="http://schemas.android.com/apk/res/android"
package="im.angry.openeuicc.common">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application>
<application
android:enableOnBackInvokedCallback="true"
tools:targetApi="tiramisu">
<activity
android:name="im.angry.openeuicc.ui.SettingsActivity"
android:label="@string/pref_settings" />
@ -31,6 +35,7 @@
<service
android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
android:foregroundServiceType="shortService"
android:exported="false" />
</application>
</manifest>

View file

@ -33,7 +33,18 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
Log.i(DefaultEuiccChannelManager.TAG, "Trying OMAPI for physical slot ${port.card.physicalSlotIndex}")
try {
return EuiccChannel(port, OmapiApduInterface(seService!!, port))
return EuiccChannel(
port,
OmapiApduInterface(
seService!!,
port,
context.preferenceRepository.verboseLoggingFlow
),
context.preferenceRepository.verboseLoggingFlow
).also {
Log.i(DefaultEuiccChannelManager.TAG, "Is OMAPI channel, setting MSS to 60")
it.lpa.setEs10xMss(60)
}
} catch (e: IllegalArgumentException) {
// Failed
Log.w(
@ -52,7 +63,13 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
if (!conn.claimInterface(usbInterface, true)) return null
return EuiccChannel(
FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
UsbApduInterface(conn, bulkIn, bulkOut)
UsbApduInterface(
conn,
bulkIn,
bulkOut,
context.preferenceRepository.verboseLoggingFlow
),
context.preferenceRepository.verboseLoggingFlow
)
}

View file

@ -173,7 +173,7 @@ open class DefaultEuiccChannelManager(
try {
// tryOpenEuiccChannel() will automatically dispose of invalid channels
// and recreate when needed
val channel = findEuiccChannelByPortBlocking(physicalSlotId, portId)!!
val channel = findEuiccChannelByPort(physicalSlotId, portId)!!
check(channel.valid) { "Invalid channel" }
break
} catch (e: Exception) {

View file

@ -1,6 +1,7 @@
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
@ -9,12 +10,14 @@ 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
val lpa: LocalProfileAssistant = LocalProfileAssistantImpl(apduInterface, HttpInterfaceImpl())
val lpa: LocalProfileAssistant =
LocalProfileAssistantImpl(apduInterface, HttpInterfaceImpl(verboseLoggingFlow))
val valid: Boolean
get() = lpa.valid

View file

@ -5,11 +5,16 @@ import android.se.omapi.SEService
import android.se.omapi.Session
import android.util.Log
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.single
import kotlinx.coroutines.runBlocking
import net.typeblog.lpac_jni.ApduInterface
class OmapiApduInterface(
private val service: SEService,
private val port: UiccPortInfoCompat
private val port: UiccPortInfoCompat,
private val verboseLoggingFlow: Flow<Boolean>
): ApduInterface {
companion object {
const val TAG = "OmapiApduInterface"
@ -49,17 +54,30 @@ class OmapiApduInterface(
"Unknown channel"
}
Log.d(TAG, "OMAPI APDU: ${tx.encodeHex()}")
if (runBlocking { verboseLoggingFlow.first() }) {
Log.d(TAG, "OMAPI APDU: ${tx.encodeHex()}")
}
try {
return lastChannel.transmit(tx).also {
Log.d(TAG, "OMAPI APDU response: ${it.encodeHex()}")
for (i in 0..10) {
val res = lastChannel.transmit(tx)
if (runBlocking { verboseLoggingFlow.first() }) {
Log.d(TAG, "OMAPI APDU response: ${res.encodeHex()}")
}
if (res.size == 2 && res[0] == 0x66.toByte() && res[1] == 0x01.toByte()) {
Log.d(TAG, "Received checksum error 0x6601, retrying (count = $i)")
continue
}
return res
}
throw RuntimeException("Retransmit attempts exhausted; this was likely caused by checksum errors")
} catch (e: Exception) {
Log.e(TAG, "OMAPI APDU exception")
e.printStackTrace()
throw e
}
}
}

View file

@ -4,12 +4,14 @@ import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.util.Log
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.Flow
import net.typeblog.lpac_jni.ApduInterface
class UsbApduInterface(
private val conn: UsbDeviceConnection,
private val bulkIn: UsbEndpoint,
private val bulkOut: UsbEndpoint
private val bulkOut: UsbEndpoint,
private val verboseLoggingFlow: Flow<Boolean>
): ApduInterface {
companion object {
private const val TAG = "UsbApduInterface"
@ -27,7 +29,7 @@ class UsbApduInterface(
throw IllegalArgumentException("Unsupported card reader; T=0 support is required")
}
transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription)
transceiver = UsbCcidTransceiver(conn, bulkIn, bulkOut, ccidDescription, verboseLoggingFlow)
try {
transceiver.iccPowerOn()

View file

@ -5,6 +5,9 @@ import android.hardware.usb.UsbEndpoint
import android.os.SystemClock
import android.util.Log
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import java.nio.ByteBuffer
import java.nio.ByteOrder
@ -18,7 +21,8 @@ class UsbCcidTransceiver(
private val usbConnection: UsbDeviceConnection,
private val usbBulkIn: UsbEndpoint,
private val usbBulkOut: UsbEndpoint,
private val usbCcidDescription: UsbCcidDescription
private val usbCcidDescription: UsbCcidDescription,
private val verboseLoggingFlow: Flow<Boolean>
) {
companion object {
private const val TAG = "UsbCcidTransceiver"
@ -178,7 +182,9 @@ class UsbCcidTransceiver(
readBytes = usbConnection.bulkTransfer(
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
)
Log.d(TAG, "Received " + readBytes + " bytes: " + inputBuffer.encodeHex())
if (runBlocking { verboseLoggingFlow.first() }) {
Log.d(TAG, "Received " + readBytes + " bytes: " + inputBuffer.encodeHex())
}
} while (readBytes <= 0 && attempts-- > 0)
if (readBytes < CCID_HEADER_LENGTH) {
throw UsbTransportException("USB-CCID error - failed to receive CCID header")

View file

@ -1,11 +1,36 @@
package im.angry.openeuicc.service
import android.app.Service
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Binder
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.yield
import net.typeblog.lpac_jni.ProfileDownloadCallback
/**
* An Android Service wrapper for EuiccChannelManager.
@ -17,8 +42,22 @@ import im.angry.openeuicc.util.*
* instance of EuiccChannelManager. UI components can keep being bound to this service for
* their entire lifecycles, since the whole purpose of them is to expose the current state
* to the user.
*
* Additionally, this service is also responsible for long-running "foreground" tasks that
* are not suitable to be managed by UI components. This includes profile downloading, etc.
* When a UI component needs to run one of these tasks, they have to bind to this service
* and call one of the `launch*` methods, which will run the task inside this service's
* lifecycle context and return a Flow instance for the UI component to subscribe to its
* progress.
*/
class EuiccChannelManagerService : Service(), OpenEuiccContextMarker {
class EuiccChannelManagerService : LifecycleService(), OpenEuiccContextMarker {
companion object {
private const val TAG = "EuiccChannelManagerService"
private const val CHANNEL_ID = "tasks"
private const val FOREGROUND_ID = 1000
private const val TASK_FAILURE_ID = 1001
}
inner class LocalBinder : Binder() {
val service = this@EuiccChannelManagerService
}
@ -28,14 +67,341 @@ class EuiccChannelManagerService : Service(), OpenEuiccContextMarker {
}
val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate
override fun onBind(intent: Intent?): IBinder = LocalBinder()
/**
* The state of a "foreground" task (named so due to the need to startForeground())
*/
sealed interface ForegroundTaskState {
data object Idle : ForegroundTaskState
data class InProgress(val progress: Int) : ForegroundTaskState
data class Done(val error: Throwable?) : ForegroundTaskState
}
/**
* This flow emits whenever the service has had a start command, from startService()
* The service self-starts when foreground is required, because other components
* only bind to this service and do not start it per-se.
*/
private val foregroundStarted: MutableSharedFlow<Unit> = MutableSharedFlow()
/**
* This flow is used to emit progress updates when a foreground task is running.
*/
private val foregroundTaskState: MutableStateFlow<ForegroundTaskState> =
MutableStateFlow(ForegroundTaskState.Idle)
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return LocalBinder()
}
override fun onDestroy() {
super.onDestroy()
// This is the whole reason of the existence of this service:
// we can clean up opened channels when no one is using them
if (euiccChannelManagerDelegate.isInitialized()) {
euiccChannelManager.invalidate()
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return super.onStartCommand(intent, flags, startId).also {
lifecycleScope.launch {
foregroundStarted.emit(Unit)
}
}
}
private fun ensureForegroundTaskNotificationChannel() {
val nm = NotificationManagerCompat.from(this)
if (nm.getNotificationChannelCompat(CHANNEL_ID) == null) {
val channel =
NotificationChannelCompat.Builder(
CHANNEL_ID,
NotificationManagerCompat.IMPORTANCE_LOW
)
.setName(getString(R.string.task_notification))
.setVibrationEnabled(false)
.build()
nm.createNotificationChannel(channel)
}
}
private suspend fun updateForegroundNotification(title: String, iconRes: Int) {
ensureForegroundTaskNotificationChannel()
val nm = NotificationManagerCompat.from(this)
val state = foregroundTaskState.value
if (state is ForegroundTaskState.InProgress) {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setProgress(100, state.progress, state.progress == 0)
.setSmallIcon(iconRes)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
if (state.progress == 0) {
startForeground(FOREGROUND_ID, notification)
} else if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
nm.notify(FOREGROUND_ID, notification)
}
// Yield out so that the main looper can handle the notification event
// Without this yield, the notification sent above will not be shown in time.
yield()
} else {
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
private fun postForegroundTaskFailureNotification(title: String) {
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setSmallIcon(R.drawable.ic_x_black)
.build()
NotificationManagerCompat.from(this).notify(TASK_FAILURE_ID, notification)
}
/**
* 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.
*
* The task closure is expected to update foregroundTaskState whenever appropriate.
* If a foreground task is already running, this function returns null.
*
* To wait for foreground tasks to be available, use waitForForegroundTask().
*
* The function will set the state back to Idle once it sees ForegroundTaskState.Done.
*/
private fun launchForegroundTask(
title: String,
failureTitle: String,
iconRes: Int,
task: suspend EuiccChannelManagerService.() -> Unit
): Flow<ForegroundTaskState>? {
// Atomically set the state to InProgress. If this returns true, we are
// the only task currently in progress.
if (!foregroundTaskState.compareAndSet(
ForegroundTaskState.Idle,
ForegroundTaskState.InProgress(0)
)
) {
return null
}
lifecycleScope.launch(Dispatchers.Main) {
// Wait until our self-start command has succeeded.
// We can only call startForeground() after that
val res = withTimeoutOrNull(30 * 1000) {
foregroundStarted.first()
}
if (res == null) {
// The only case where the wait above could time out is if the subscriber
// to the flow is stuck. Or we failed to start foreground.
// In that case, we should just set our state back to Idle -- setting it
// to Done wouldn't help much because nothing is going to then set it Idle.
foregroundTaskState.value = ForegroundTaskState.Idle
return@launch
}
updateForegroundNotification(title, iconRes)
try {
withContext(Dispatchers.IO + NonCancellable) { // Any LPA-related task must always complete
this@EuiccChannelManagerService.task()
}
// This update will be sent by the subscriber (as shown below)
foregroundTaskState.value = ForegroundTaskState.Done(null)
} catch (t: Throwable) {
Log.e(TAG, "Foreground task encountered an error")
Log.e(TAG, Log.getStackTraceString(t))
foregroundTaskState.value = ForegroundTaskState.Done(t)
if (isActive) {
postForegroundTaskFailureNotification(failureTitle)
}
} finally {
if (isActive) {
stopSelf()
}
}
}
// 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 }
}
val isForegroundTaskRunning: Boolean
get() = foregroundTaskState.value != ForegroundTaskState.Idle
suspend fun waitForForegroundTask() {
foregroundTaskState.takeWhile { it != ForegroundTaskState.Idle }
.collect()
}
fun launchProfileDownloadTask(
slotId: Int,
portId: Int,
smdp: String,
matchingId: String?,
confirmationCode: String?,
imei: String?
): Flow<ForegroundTaskState>? =
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.")
}
preferenceRepository.notificationDownloadFlow.first()
}
}
fun launchProfileRenameTask(
slotId: Int,
portId: Int,
iccid: String,
name: String
): Flow<ForegroundTaskState>? =
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")
}
}
fun launchProfileDeleteTask(
slotId: Int,
portId: Int,
iccid: String
): Flow<ForegroundTaskState>? =
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)
preferenceRepository.notificationDeleteFlow.first()
}
}
class SwitchingProfilesRefreshException : Exception()
fun launchProfileSwitchTask(
slotId: Int,
portId: Int,
iccid: String,
enable: Boolean, // Enable or disable the profile indicated in iccid
reconnectTimeoutMillis: Long = 0 // 0 = do not wait for reconnect, useful for USB readers
): Flow<ForegroundTaskState>? =
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) =
if (!channel.lpa.switchProfile(iccid, enable, refresh = true)) {
// Sometimes, we *can* enable or disable the profile, but we cannot
// send the refresh command to the modem because the profile somehow
// makes the modem "busy". In this case, we can still switch by setting
// refresh to false, but then the switch cannot take effect until the
// user resets the modem manually by toggling airplane mode or rebooting.
Pair(channel.lpa.switchProfile(iccid, enable, refresh = false), false)
} else {
Pair(true, true)
}
if (!res) {
throw RuntimeException("Could not switch profile")
}
if (!refreshed) {
// We may have switched the profile, but we could not refresh. Tell the caller about this
throw SwitchingProfilesRefreshException()
}
if (reconnectTimeoutMillis > 0) {
// Add an unconditional delay first to account for any race condition between
// the card sending the refresh command and the modem actually refreshing
delay(reconnectTimeoutMillis / 10)
// This throws TimeoutCancellationException if timed out
euiccChannelManager.waitForReconnect(
slotId,
portId,
reconnectTimeoutMillis / 10 * 9
)
}
preferenceRepository.notificationSwitchFlow.first()
}
}
}

View file

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

View file

@ -19,6 +19,9 @@ import android.widget.PopupMenu
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@ -27,12 +30,12 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton
import net.typeblog.lpac_jni.LocalProfileInfo
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.service.EuiccChannelManagerService
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -76,6 +79,21 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
fab = view.requireViewById(R.id.fab)
profileList = view.requireViewById(R.id.profile_list)
val origFabMarginRight = (fab.layoutParams as ViewGroup.MarginLayoutParams).rightMargin
val origFabMarginBottom = (fab.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin
ViewCompat.setOnApplyWindowInsetsListener(fab) { v, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
rightMargin = origFabMarginRight + bars.right
bottomMargin = origFabMarginBottom + bars.bottom
}
WindowInsetsCompat.CONSUMED
}
setupRootViewInsets(profileList)
return view
}
@ -90,7 +108,10 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
ProfileDownloadFragment.newInstance(slotId, portId)
.show(childFragmentManager, ProfileDownloadFragment.TAG)
}
}
override fun onStart() {
super.onStart()
refresh()
}
@ -133,6 +154,9 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
swipeRefresh.isRefreshing = true
lifecycleScope.launch {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
if (!this@EuiccManagementFragment::disableSafeguardFlow.isInitialized) {
disableSafeguardFlow =
preferenceRepository.disableSafeguardFlow.stateIn(lifecycleScope)
@ -152,67 +176,47 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
}
}
private suspend fun showSwitchFailureText() = withContext(Dispatchers.Main) {
Toast.makeText(
context,
R.string.toast_profile_enable_failed,
Toast.LENGTH_LONG
).show()
}
private fun enableOrDisableProfile(iccid: String, enable: Boolean) {
swipeRefresh.isRefreshing = true
fab.isEnabled = false
lifecycleScope.launch {
beginTrackedOperation {
val (res, refreshed) =
if (!channel.lpa.switchProfile(iccid, enable, refresh = true)) {
// Sometimes, we *can* enable or disable the profile, but we cannot
// send the refresh command to the modem because the profile somehow
// makes the modem "busy". In this case, we can still switch by setting
// refresh to false, but then the switch cannot take effect until the
// user resets the modem manually by toggling airplane mode or rebooting.
Pair(channel.lpa.switchProfile(iccid, enable, refresh = false), false)
} else {
Pair(true, true)
}
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
if (!res) {
Log.d(TAG, "Failed to enable / disable profile $iccid")
withContext(Dispatchers.Main) {
Toast.makeText(
context,
R.string.toast_profile_enable_failed,
Toast.LENGTH_LONG
).show()
}
return@beginTrackedOperation false
val res = euiccChannelManagerService.launchProfileSwitchTask(
slotId,
portId,
iccid,
enable,
reconnectTimeoutMillis = if (isUsb) {
0
} else {
30 * 1000
}
)?.last() as? EuiccChannelManagerService.ForegroundTaskState.Done
if (!refreshed && !isUsb) {
withContext(Dispatchers.Main) {
AlertDialog.Builder(requireContext()).apply {
setMessage(R.string.switch_did_not_refresh)
setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
}
setOnDismissListener { _ ->
requireActivity().finish()
}
show()
}
}
return@beginTrackedOperation true
}
if (res == null) {
showSwitchFailureText()
return@launch
}
if (!isUsb) {
try {
euiccChannelManager.waitForReconnect(
slotId,
portId,
timeoutMillis = 30 * 1000
)
} catch (e: TimeoutCancellationException) {
when (res.error) {
null -> {}
is EuiccChannelManagerService.SwitchingProfilesRefreshException -> {
// This is only really fatal for internal eSIMs
if (!isUsb) {
withContext(Dispatchers.Main) {
// Prevent this Fragment from being used again
invalid = true
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
AlertDialog.Builder(requireContext()).apply {
setMessage(R.string.enable_disable_timeout)
setMessage(R.string.switch_did_not_refresh)
setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
@ -223,12 +227,31 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
show()
}
}
return@beginTrackedOperation false
}
}
preferenceRepository.notificationSwitchFlow.first()
is TimeoutCancellationException -> {
withContext(Dispatchers.Main) {
// Prevent this Fragment from being used again
invalid = true
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
AlertDialog.Builder(requireContext()).apply {
setMessage(R.string.enable_disable_timeout)
setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
}
setOnDismissListener { _ ->
requireActivity().finish()
}
show()
}
}
}
else -> showSwitchFailureText()
}
refresh()
fab.isEnabled = true
}

View file

@ -7,6 +7,7 @@ import android.view.MenuItem
import android.view.View
import android.widget.ScrollView
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
@ -37,15 +38,19 @@ class LogsActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_logs)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
swipeRefresh = requireViewById(R.id.swipe_refresh)
scrollView = requireViewById(R.id.scroll_view)
logText = requireViewById(R.id.log_text)
setupRootViewInsets(scrollView)
swipeRefresh.setOnRefreshListener {
lifecycleScope.launch {
reload()
@ -66,6 +71,10 @@ class LogsActivity : AppCompatActivity() {
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
finish()
true
}
R.id.save -> {
saveLogs.launch(getString(R.string.logs_filename_template,
SimpleDateFormat.getDateTimeInstance().format(Date())

View file

@ -5,7 +5,9 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.hardware.usb.UsbManager
import android.os.Build
import android.os.Bundle
import android.telephony.TelephonyManager
import android.util.Log
@ -13,6 +15,7 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ProgressBar
import androidx.activity.enableEdgeToEdge
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
@ -22,6 +25,7 @@ import com.google.android.material.tabs.TabLayoutMediator
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -29,6 +33,8 @@ import kotlinx.coroutines.withContext
open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
companion object {
const val TAG = "MainActivity"
const val PERMISSION_REQUEST_CODE = 1000
}
private lateinit var loadingProgress: ProgressBar
@ -64,9 +70,11 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
@SuppressLint("WrongConstant", "UnspecifiedRegisterReceiverFlag")
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
loadingProgress = requireViewById(R.id.loading)
tabs = requireViewById(R.id.main_tabs)
viewPager = requireViewById(R.id.view_pager)
@ -113,16 +121,29 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
}
}
private fun ensureNotificationPermissions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
PERMISSION_REQUEST_CODE
)
}
}
private suspend fun init(fromUsbEvent: Boolean = false) {
refreshing = true // We don't check this here -- the check happens in refresh()
loadingProgress.visibility = View.VISIBLE
viewPager.visibility = View.GONE
tabs.visibility = View.GONE
// Prevent concurrent access with any running foreground task
euiccChannelManagerService.waitForForegroundTask()
val knownChannels = withContext(Dispatchers.IO) {
euiccChannelManager.enumerateEuiccChannels().onEach {
Log.d(TAG, "slot ${it.slotId} port ${it.portId}")
Log.d(TAG, it.lpa.eID)
if (preferenceRepository.verboseLoggingFlow.first()) {
Log.d(TAG, it.lpa.eID)
}
// Request the system to refresh the list of profiles every time we start
// Note that this is currently supposed to be no-op when unprivileged,
// but it could change in the future
@ -170,6 +191,10 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
viewPager.currentItem = 0
}
if (pages.size > 0) {
ensureNotificationPermissions()
}
refreshing = false
}
}

View file

@ -11,6 +11,7 @@ import android.view.MenuItem.OnMenuItemClickListener
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AlertDialog
import androidx.core.view.forEach
import androidx.lifecycle.lifecycleScope
@ -35,31 +36,34 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private lateinit var euiccChannel: EuiccChannel
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_notifications)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
}
override fun onInit() {
euiccChannel = euiccChannelManager
.findEuiccChannelBySlotBlocking(intent.getIntExtra("logicalSlotId", 0))!!
swipeRefresh = requireViewById(R.id.swipe_refresh)
notificationList = requireViewById(R.id.recycler_view)
setupRootViewInsets(notificationList)
}
override fun onInit() {
notificationList.layoutManager =
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
notificationList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
notificationList.adapter = notificationAdapter
registerForContextMenu(notificationList)
val logicalSlotId = intent.getIntExtra("logicalSlotId", 0)
// This is slightly different from the MainActivity logic
// due to the length (we don't want to display the full USB product name)
val channelTitle = if (euiccChannel.logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
val channelTitle = if (logicalSlotId == EuiccChannelManager.USB_CHANNEL_ID) {
getString(R.string.usb)
} else {
getString(R.string.channel_name_format, euiccChannel.logicalSlotId)
getString(R.string.channel_name_format, logicalSlotId)
}
title = getString(R.string.profile_notifications_detailed_format, channelTitle)
@ -100,6 +104,18 @@ class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
swipeRefresh.isRefreshing = true
lifecycleScope.launch {
if (!this@NotificationsActivity::euiccChannel.isInitialized) {
withContext(Dispatchers.IO) {
euiccChannelManagerLoaded.await()
euiccChannel = euiccChannelManager.findEuiccChannelBySlotBlocking(
intent.getIntExtra(
"logicalSlotId",
0
)
)!!
}
}
task()
swipeRefresh.isRefreshing = false

View file

@ -3,16 +3,15 @@ package im.angry.openeuicc.ui
import android.app.Dialog
import android.os.Bundle
import android.text.Editable
import android.util.Log
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import java.lang.Exception
class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
companion object {
@ -67,23 +66,27 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).isEnabled = false
lifecycleScope.launch {
try {
doDelete()
} catch (e: Exception) {
Log.d(ProfileDownloadFragment.TAG, "Error deleting profile")
Log.d(ProfileDownloadFragment.TAG, Log.getStackTraceString(e))
} finally {
requireParentFragment().lifecycleScope.launch {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
euiccChannelManagerService.launchProfileDeleteTask(
slotId,
portId,
requireArguments().getString("iccid")!!
)!!.onStart {
if (parentFragment is EuiccProfilesChangedListener) {
// Trigger a refresh in the parent fragment -- it should wait until
// any foreground task is completed before actually doing a refresh
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
}
dismiss()
}
try {
dismiss()
} catch (e: IllegalStateException) {
// Ignored
}
}.collect()
}
}
private suspend fun doDelete() = beginTrackedOperation {
channel.lpa.deleteProfile(requireArguments().getString("iccid")!!)
preferenceRepository.notificationDeleteFlow.first()
}
}

View file

@ -18,12 +18,13 @@ import com.google.android.material.textfield.TextInputLayout
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import im.angry.openeuicc.common.R
import im.angry.openeuicc.service.EuiccChannelManagerService
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.ProfileDownloadCallback
import kotlin.Exception
class ProfileDownloadFragment : BaseMaterialDialogFragment(),
@ -55,7 +56,6 @@ class ProfileDownloadFragment : BaseMaterialDialogFragment(),
private val barcodeScannerLauncher = registerForActivityResult(ScanContract()) { result ->
result.contents?.let { content ->
Log.d(TAG, content)
onScanResult(content)
}
}
@ -149,15 +149,22 @@ class ProfileDownloadFragment : BaseMaterialDialogFragment(),
@SuppressLint("MissingPermission")
override fun onStart() {
super.onStart()
profileDownloadIMEI.editText!!.text = Editable.Factory.getInstance().newEditable(
try {
lifecycleScope.launch(Dispatchers.IO) {
ensureEuiccChannelManager()
if (euiccChannelManagerService.isForegroundTaskRunning) {
withContext(Dispatchers.Main) {
dismiss()
}
return@launch
}
val imei = try {
telephonyManager.getImei(channel.logicalSlotId) ?: ""
} catch (e: Exception) {
""
}
)
lifecycleScope.launch(Dispatchers.IO) {
// Fetch remaining NVRAM
val str = channel.lpa.euiccInfo2?.freeNvram?.also {
freeNvram = it
@ -166,6 +173,8 @@ class ProfileDownloadFragment : BaseMaterialDialogFragment(),
withContext(Dispatchers.Main) {
profileDownloadFreeSpace.text = getString(R.string.profile_download_free_space,
str ?: getText(R.string.unknown))
profileDownloadIMEI.editText!!.text =
Editable.Factory.getInstance().newEditable(imei)
}
}
}
@ -204,17 +213,28 @@ class ProfileDownloadFragment : BaseMaterialDialogFragment(),
progress.visibility = View.VISIBLE
lifecycleScope.launch {
try {
doDownloadProfile(server, code, confirmationCode, imei)
} catch (e: Exception) {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
val res = doDownloadProfile(server, code, confirmationCode, imei)
if (res == null || res.error != null) {
Log.d(TAG, "Error downloading profile")
Log.d(TAG, Log.getStackTraceString(e))
Toast.makeText(context, R.string.profile_download_failed, Toast.LENGTH_LONG).show()
} finally {
if (parentFragment is EuiccProfilesChangedListener) {
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
if (res?.error != null) {
Log.d(TAG, Log.getStackTraceString(res.error))
}
Toast.makeText(requireContext(), R.string.profile_download_failed, Toast.LENGTH_LONG).show()
}
if (parentFragment is EuiccProfilesChangedListener) {
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
}
try {
dismiss()
} catch (e: IllegalStateException) {
// Ignored
}
}
}
@ -224,30 +244,26 @@ class ProfileDownloadFragment : BaseMaterialDialogFragment(),
code: String?,
confirmationCode: String?,
imei: String?
) = beginTrackedOperation {
val res = channel.lpa.downloadProfile(
) = withContext(Dispatchers.Main) {
// The service is responsible for launching the actual blocking part on the IO context
val res = euiccChannelManagerService.launchProfileDownloadTask(
slotId,
portId,
server,
code,
imei,
confirmationCode,
object : ProfileDownloadCallback {
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
lifecycleScope.launch(Dispatchers.Main) {
progress.isIndeterminate = false
progress.progress = state.progress
}
}
})
imei
)!!.onEach {
if (it is EuiccChannelManagerService.ForegroundTaskState.InProgress) {
progress.progress = it.progress
progress.isIndeterminate = it.progress == 0
} else {
progress.progress = 100
progress.isIndeterminate = false
}
}.last()
if (!res) {
// TODO: Provide more details on the error
throw RuntimeException("Failed to download profile; this is typically caused by another error happened before.")
}
// If we get here, we are successful
// This function is wrapped in beginTrackedOperation, so by returning the settings value,
// We only send notifications if the user allowed us to
preferenceRepository.notificationDownloadFlow.first()
res as? EuiccChannelManagerService.ForegroundTaskState.Done
}
override fun onDismiss(dialog: DialogInterface) {

View file

@ -2,7 +2,6 @@ package im.angry.openeuicc.ui
import android.app.Dialog
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -13,11 +12,8 @@ import androidx.lifecycle.lifecycleScope
import com.google.android.material.textfield.TextInputLayout
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.lang.Exception
import java.lang.RuntimeException
class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragmentMarker {
companion object {
@ -97,23 +93,24 @@ class ProfileRenameFragment : BaseMaterialDialogFragment(), EuiccChannelFragment
progress.visibility = View.VISIBLE
lifecycleScope.launch {
ensureEuiccChannelManager()
euiccChannelManagerService.waitForForegroundTask()
euiccChannelManagerService.launchProfileRenameTask(
slotId,
portId,
requireArguments().getString("iccid")!!,
name
)?.collect()
if (parentFragment is EuiccProfilesChangedListener) {
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
}
try {
doRename(name)
} catch (e: Exception) {
Log.d(TAG, "Failed to rename profile")
Log.d(TAG, Log.getStackTraceString(e))
} finally {
if (parentFragment is EuiccProfilesChangedListener) {
(parentFragment as EuiccProfilesChangedListener).onEuiccProfilesChanged()
}
dismiss()
} catch (e: IllegalStateException) {
// Ignored
}
}
}
private suspend fun doRename(name: String) = withContext(Dispatchers.IO) {
if (!channel.lpa.setNickname(requireArguments().getString("iccid")!!, name)) {
throw RuntimeException("Profile nickname not changed")
}
}
}

View file

@ -2,14 +2,18 @@ package im.angry.openeuicc.ui
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.*
class SettingsActivity: AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
setSupportActionBar(requireViewById(R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
supportFragmentManager.beginTransaction()
.replace(R.id.settings_container, SettingsFragment())

View file

@ -3,6 +3,9 @@ package im.angry.openeuicc.ui
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.datastore.preferences.core.Preferences
import androidx.lifecycle.lifecycleScope
import androidx.preference.CheckBoxPreference
@ -44,6 +47,14 @@ class SettingsFragment: PreferenceFragmentCompat() {
findPreference<CheckBoxPreference>("pref_advanced_disable_safeguard_removable_esim")
?.bindBooleanFlow(preferenceRepository.disableSafeguardFlow, PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM)
findPreference<CheckBoxPreference>("pref_advanced_verbose_logging")
?.bindBooleanFlow(preferenceRepository.verboseLoggingFlow, PreferenceKeys.VERBOSE_LOGGING)
}
override fun onStart() {
super.onStart()
setupRootViewInsets(requireView().requireViewById(androidx.preference.R.id.recycler_view))
}
private fun CheckBoxPreference.bindBooleanFlow(flow: Flow<Boolean>, key: Preferences.Key<Boolean>) {

View file

@ -4,9 +4,8 @@ import android.os.Bundle
import androidx.fragment.app.Fragment
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.service.EuiccChannelManagerService
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
interface EuiccChannelFragmentMarker: OpenEuiccContextMarker
@ -35,18 +34,15 @@ val <T> T.isUsb: Boolean where T: Fragment, T: EuiccChannelFragmentMarker
val <T> T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: EuiccChannelFragmentMarker
get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManager
val <T> T.euiccChannelManagerService: EuiccChannelManagerService where T: Fragment, T: EuiccChannelFragmentMarker
get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManagerService
val <T> T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker
get() =
euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!!
suspend fun <T> T.ensureEuiccChannelManager() where T: Fragment, T: EuiccChannelFragmentMarker =
(requireActivity() as BaseEuiccAccessActivity).euiccChannelManagerLoaded.await()
interface EuiccProfilesChangedListener {
fun onEuiccProfilesChanged()
}
suspend fun <T> T.beginTrackedOperation(op: suspend () -> Boolean) where T: Fragment, T: EuiccChannelFragmentMarker {
withContext(Dispatchers.IO) {
euiccChannelManager.beginTrackedOperationBlocking(slotId, portId) {
op()
}
}
}

View file

@ -73,6 +73,42 @@ fun LocalProfileAssistant.disableActiveProfileWithUndo(refreshOnDisable: Boolean
* should be the concern of op() itself, and this function assumes that when
* op() returns, the slotId and portId will correspond to a valid channel again.
*/
suspend inline fun EuiccChannelManager.beginTrackedOperation(
slotId: Int,
portId: Int,
op: () -> Boolean
) {
val latestSeq =
findEuiccChannelByPort(slotId, portId)!!.lpa.notifications.firstOrNull()?.seqNumber
?: 0
Log.d(TAG, "Latest notification is $latestSeq before operation")
if (op()) {
Log.d(TAG, "Operation has requested notification handling")
try {
// Note that the exact instance of "channel" might have changed here if reconnected;
// so we MUST use the automatic getter for "channel"
findEuiccChannelByPort(
slotId,
portId
)?.lpa?.notifications?.filter { it.seqNumber > latestSeq }?.forEach {
Log.d(TAG, "Handling notification $it")
findEuiccChannelByPort(
slotId,
portId
)?.lpa?.handleNotification(it.seqNumber)
}
} catch (e: Exception) {
// Ignore any error during notification handling
e.printStackTrace()
}
}
Log.d(TAG, "Operation complete")
}
/**
* Same as beginTrackedOperation but uses blocking primitives.
* TODO: This function needs to be phased out of use.
*/
inline fun EuiccChannelManager.beginTrackedOperationBlocking(
slotId: Int,
portId: Int,

View file

@ -24,6 +24,7 @@ object PreferenceKeys {
val NOTIFICATION_DELETE = booleanPreferencesKey("notification_delete")
val NOTIFICATION_SWITCH = booleanPreferencesKey("notification_switch")
val DISABLE_SAFEGUARD_REMOVABLE_ESIM = booleanPreferencesKey("disable_safeguard_removable_esim")
val VERBOSE_LOGGING = booleanPreferencesKey("verbose_logging")
}
class PreferenceRepository(context: Context) {
@ -44,6 +45,9 @@ class PreferenceRepository(context: Context) {
val disableSafeguardFlow: Flow<Boolean> =
dataStore.data.map { it[PreferenceKeys.DISABLE_SAFEGUARD_REMOVABLE_ESIM] ?: false }
val verboseLoggingFlow: Flow<Boolean> =
dataStore.data.map { it[PreferenceKeys.VERBOSE_LOGGING] ?: false }
suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
dataStore.edit {
it[key] = value

View file

@ -2,8 +2,16 @@ package im.angry.openeuicc.util
import android.content.res.Resources
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.DialogFragment
import im.angry.openeuicc.common.R
// Source: <https://stackoverflow.com/questions/12478520/how-to-set-dialogfragments-width-and-height>
/**
@ -26,3 +34,39 @@ fun DialogFragment.setWidthPercent(percentage: Int) {
fun DialogFragment.setFullScreen() {
dialog?.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
fun AppCompatActivity.setupToolbarInsets() {
val spacer = requireViewById<View>(R.id.toolbar_spacer)
ViewCompat.setOnApplyWindowInsetsListener(requireViewById(R.id.toolbar)) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = bars.top
}
v.updatePadding(bars.left, v.paddingTop, bars.right, v.paddingBottom)
spacer.updateLayoutParams {
height = v.top
}
WindowInsetsCompat.CONSUMED
}
}
fun setupRootViewInsets(view: ViewGroup) {
// Disable clipToPadding to make sure content actually display
view.clipToPadding = false
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)
v.updatePadding(bars.left, v.paddingTop, bars.right, bars.bottom)
WindowInsetsCompat.CONSUMED
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,13 +5,7 @@
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintWidth_percent="1" />
<include layout="@layout/toolbar_activity" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"

View file

@ -5,13 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintWidth_percent="1" />
<include layout="@layout/toolbar_activity" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/main_tabs"

View file

@ -4,13 +4,7 @@
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintWidth_percent="1" />
<include layout="@layout/toolbar_activity" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"

View file

@ -4,13 +4,7 @@
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintWidth_percent="1" />
<include layout="@layout/toolbar_activity" />
<FrameLayout
android:id="@+id/settings_container"

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<View
android:id="@+id/toolbar_spacer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="?attr/colorSurfaceVariant"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toTopOf="@id/toolbar" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintWidth_percent="1" />
</merge>

View file

@ -33,6 +33,16 @@
<string name="usb_permission_needed">Permission is needed to access the USB smart card reader.</string>
<string name="usb_failed">Cannot connect to eSIM via a USB smart card reader.</string>
<string name="task_notification">Long-running Tasks</string>
<string name="task_profile_download">Downloading eSIM profile</string>
<string name="task_profile_download_failure">Failed to download eSIM profile</string>
<string name="task_profile_rename">Renaming eSIM profile</string>
<string name="task_profile_rename_failure">Failed to rename eSIM profile</string>
<string name="task_profile_delete">Deleting eSIM profile</string>
<string name="task_profile_delete_failure">Failed to delete eSIM profile</string>
<string name="task_profile_switch">Switching eSIM profile</string>
<string name="task_profile_switch_failure">Failed to switch eSIM profile</string>
<string name="profile_download">New eSIM</string>
<string name="profile_download_server">Server (RSP / SM-DP+)</string>
<string name="profile_download_code">Activation Code</string>
@ -74,8 +84,10 @@
<string name="pref_notifications_switch">Switching</string>
<string name="pref_notifications_switch_desc">Send notifications for <i>switching</i> profiles\nNote that this type of notification is unreliable.</string>
<string name="pref_advanced">Advanced</string>
<string name="pref_advanced_disable_safeguard_removable_esim">Disable Safeguards for Removable eSIMs</string>
<string name="pref_advanced_disable_safeguard_removable_esim">Allow Disabling / Deleting Active Profile</string>
<string name="pref_advanced_disable_safeguard_removable_esim_desc">By default, this app prevents you from disabling the active profile on a removable eSIM inserted in the device, because doing so may <i>sometimes</i> render it inaccessible.\nCheck this box to <i>remove</i> this safeguard.</string>
<string name="pref_advanced_verbose_logging">Verbose Logging</string>
<string name="pref_advanced_verbose_logging_desc">Enable verbose logs, which may contain sensitive information. Only share your logs with someone you trust after turning this on.</string>
<string name="pref_advanced_logs">Logs</string>
<string name="pref_advanced_logs_desc">View recent debug logs of the application</string>
<string name="pref_info">Info</string>

View file

@ -30,6 +30,12 @@
app:title="@string/pref_advanced_disable_safeguard_removable_esim"
app:summary="@string/pref_advanced_disable_safeguard_removable_esim_desc" />
<CheckBoxPreference
app:key="pref_advanced_verbose_logging"
app:iconSpaceReserved="false"
app:title="@string/pref_advanced_verbose_logging"
app:summary="@string/pref_advanced_verbose_logging_desc" />
<Preference
app:key="pref_advanced_logs"
app:iconSpaceReserved="false"

View file

@ -8,6 +8,7 @@ java_defaults {
"androidx-constraintlayout_constraintlayout",
"androidx.preference_preference",
"androidx.lifecycle_lifecycle-runtime-ktx",
"androidx.lifecycle_lifecycle-service",
"androidx.swiperefreshlayout_swiperefreshlayout",
"androidx.cardview_cardview",
"androidx.viewpager2_viewpager2",

View file

@ -48,6 +48,7 @@ dependencies {
//noinspection KtxExtensionAvailable
api("androidx.preference:preference:1.2.1")
api("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
api("androidx.lifecycle:lifecycle-service:2.6.2")
api("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
api("androidx.cardview:cardview:1.0.0")
api("androidx.viewpager2:viewpager2:1.1.0")

View file

@ -17,13 +17,13 @@ apply {
android {
namespace = "im.angry.easyeuicc"
compileSdk = 34
compileSdk = 35
ndkVersion = "26.1.10909125"
defaultConfig {
applicationId = "im.angry.easyeuicc"
minSdk = 28
targetSdk = 34
targetSdk = 35
}
buildTypes {

View file

@ -6,6 +6,7 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.children
import androidx.lifecycle.lifecycleScope
@ -22,9 +23,11 @@ class CompatibilityCheckActivity: AppCompatActivity() {
private val adapter = CompatibilityChecksAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_compatibility_check)
setSupportActionBar(requireViewById(R.id.toolbar))
setSupportActionBar(requireViewById(im.angry.openeuicc.common.R.id.toolbar))
setupToolbarInsets()
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
compatibilityCheckList = requireViewById(R.id.recycler_view)
@ -32,6 +35,8 @@ class CompatibilityCheckActivity: AppCompatActivity() {
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
compatibilityCheckList.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
compatibilityCheckList.adapter = adapter
setupRootViewInsets(compatibilityCheckList)
}
override fun onStart() {

View file

@ -4,13 +4,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintWidth_percent="1" />
<include layout="@layout/toolbar_activity" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -12,12 +12,12 @@ apply {
android {
namespace = "im.angry.openeuicc"
compileSdk = 34
compileSdk = 35
defaultConfig {
applicationId = "im.angry.openeuicc"
minSdk = 30
targetSdk = 34
targetSdk = 35
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View file

@ -26,7 +26,15 @@ class PrivilegedEuiccChannelFactory(context: Context) : DefaultEuiccChannelFacto
"Trying TelephonyManager for slot ${port.card.physicalSlotIndex} port ${port.portIndex}"
)
try {
return EuiccChannel(port, TelephonyManagerApduInterface(port, tm))
return EuiccChannel(
port,
TelephonyManagerApduInterface(
port,
tm,
context.preferenceRepository.verboseLoggingFlow
),
context.preferenceRepository.verboseLoggingFlow
)
} catch (e: IllegalArgumentException) {
// Failed
Log.w(

View file

@ -2,13 +2,22 @@ package im.angry.openeuicc.core
import android.telephony.IccOpenLogicalChannelResponse
import android.telephony.TelephonyManager
import android.util.Log
import im.angry.openeuicc.util.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import net.typeblog.lpac_jni.ApduInterface
class TelephonyManagerApduInterface(
private val port: UiccPortInfoCompat,
private val tm: TelephonyManager
private val tm: TelephonyManager,
private val verboseLoggingFlow: Flow<Boolean>
): ApduInterface {
companion object {
const val TAG = "TelephonyManagerApduInterface"
}
private var lastChannel: Int = -1
override val valid: Boolean
@ -45,6 +54,10 @@ class TelephonyManagerApduInterface(
override fun transmit(tx: ByteArray): ByteArray {
check(lastChannel != -1) { "Uninitialized" }
if (runBlocking { verboseLoggingFlow.first() }) {
Log.d(TAG, "TelephonyManager APDU: ${tx.encodeHex()}")
}
val cla = tx[0].toUByte().toInt()
val instruction = tx[1].toUByte().toInt()
val p1 = tx[2].toUByte().toInt()
@ -53,7 +66,17 @@ class TelephonyManagerApduInterface(
val p4 = tx.drop(5).toByteArray().encodeHex()
return tm.iccTransmitApduLogicalChannelByPortCompat(port.card.physicalSlotIndex, port.portIndex, lastChannel,
cla, instruction, p1, p2, p3, p4)?.decodeHex() ?: byteArrayOf()
cla,
instruction,
p1,
p2,
p3,
p4
).also {
if (runBlocking { verboseLoggingFlow.first() }) {
Log.d(TAG, "TelephonyManager APDU response: $it")
}
}?.decodeHex() ?: byteArrayOf()
}
}

View file

@ -2,14 +2,28 @@ package im.angry.openeuicc.ui
import android.content.Intent
import android.view.View
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import im.angry.openeuicc.R
class LuiActivity : AppCompatActivity() {
override fun onStart() {
super.onStart()
enableEdgeToEdge()
setContentView(R.layout.activity_lui)
ViewCompat.setOnApplyWindowInsetsListener(requireViewById(R.id.lui_container)) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)
v.updatePadding(bars.left, bars.top, bars.right, bars.bottom)
WindowInsetsCompat.CONSUMED
}
requireViewById<View>(R.id.lui_skip).setOnClickListener { finish() }
// TODO: Deactivate LuiActivity if there is no eSIM found.
// TODO: Support pre-filled download info (from carrier apps); UX

View file

@ -2,6 +2,7 @@
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/lui_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurfaceVariant">

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -5,7 +5,7 @@ plugins {
android {
namespace = "net.typeblog.lpac_jni"
compileSdk = 34
compileSdk = 35
ndkVersion = "26.1.10909125"
defaultConfig {

View file

@ -8,6 +8,13 @@ interface LocalProfileAssistant {
// Extended EuiccInfo for use with LUIs, containing information such as firmware version
val euiccInfo2: EuiccInfo2?
/**
* Set the max segment size (mss) for all es10x commands. This can help with removable
* eUICCs that may run at a baud rate too fast for the modem.
* By default, this is set to 60 by libeuicc.
*/
fun setEs10xMss(mss: Byte)
// All blocking functions in this class assume that they are executed on non-Main threads
// The IO context in Kotlin's coroutine library is recommended.
fun enableProfile(iccid: String, refresh: Boolean = true): Boolean

View file

@ -9,6 +9,7 @@ internal object LpacJni {
external fun destroyContext(handle: Long)
external fun euiccInit(handle: Long): Int
external fun euiccSetMss(handle: Long, mss: Byte)
external fun euiccFini(handle: Long)
// es10c

View file

@ -1,6 +1,9 @@
package net.typeblog.lpac_jni.impl
import android.util.Log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import net.typeblog.lpac_jni.HttpInterface
import java.net.URL
import java.security.SecureRandom
@ -9,7 +12,7 @@ import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
class HttpInterfaceImpl: HttpInterface {
class HttpInterfaceImpl(private val verboseLoggingFlow: Flow<Boolean>) : HttpInterface {
companion object {
private const val TAG = "HttpInterfaceImpl"
}
@ -23,6 +26,10 @@ class HttpInterfaceImpl: HttpInterface {
): HttpInterface.HttpResponse {
Log.d(TAG, "transmit(url = $url)")
if (runBlocking { verboseLoggingFlow.first() }) {
Log.d(TAG, "HTTP tx = ${tx.decodeToString(throwOnInvalidSequence = false)}")
}
val parsedUrl = URL(url)
if (parsedUrl.protocol != "https") {
throw IllegalArgumentException("SM-DP+ servers must use the HTTPS protocol")
@ -34,6 +41,12 @@ class HttpInterfaceImpl: HttpInterface {
val conn = parsedUrl.openConnection() as HttpsURLConnection
conn.connectTimeout = 2000
if (url.contains("handleNotification")) {
conn.connectTimeout = 1000
conn.readTimeout = 1000
}
conn.sslSocketFactory = sslContext.socketFactory
conn.requestMethod = "POST"
conn.doInput = true
@ -50,7 +63,16 @@ class HttpInterfaceImpl: HttpInterface {
Log.d(TAG, "transmit responseCode = ${conn.responseCode}")
return HttpInterface.HttpResponse(conn.responseCode, conn.inputStream.readBytes())
val bytes = conn.inputStream.readBytes().also {
if (runBlocking { verboseLoggingFlow.first() }) {
Log.d(
TAG,
"HTTP response body = ${it.decodeToString(throwOnInvalidSequence = false)}"
)
}
}
return HttpInterface.HttpResponse(conn.responseCode, bytes)
} catch (e: Exception) {
e.printStackTrace()
throw e

View file

@ -30,6 +30,10 @@ class LocalProfileAssistantImpl(
httpInterface.usePublicKeyIds(pkids)
}
override fun setEs10xMss(mss: Byte) {
LpacJni.euiccSetMss(contextHandle, mss)
}
override val valid: Boolean
get() = !finalized && apduInterface.valid && try {
// If we can read both eID and euiccInfo2 properly, we are likely looking at
@ -42,6 +46,7 @@ class LocalProfileAssistantImpl(
}
override val profiles: List<LocalProfileInfo>
@Synchronized
get() {
val head = LpacJni.es10cGetProfilesInfo(contextHandle)
var curr = head
@ -66,6 +71,7 @@ class LocalProfileAssistantImpl(
}
override val notifications: List<LocalProfileNotification>
@Synchronized
get() {
val head = LpacJni.es10bListNotification(contextHandle)
var curr = head
@ -84,9 +90,11 @@ class LocalProfileAssistantImpl(
}
override val eID: String
@Synchronized
get() = LpacJni.es10cGetEid(contextHandle)!!
override val euiccInfo2: EuiccInfo2?
@Synchronized
get() {
val cInfo = LpacJni.es10cexGetEuiccInfo2(contextHandle)
if (cInfo == 0L) return null
@ -122,12 +130,15 @@ class LocalProfileAssistantImpl(
return ret
}
@Synchronized
override fun enableProfile(iccid: String, refresh: Boolean): Boolean =
LpacJni.es10cEnableProfile(contextHandle, iccid, refresh) == 0
@Synchronized
override fun disableProfile(iccid: String, refresh: Boolean): Boolean =
LpacJni.es10cDisableProfile(contextHandle, iccid, refresh) == 0
@Synchronized
override fun deleteProfile(iccid: String): Boolean =
LpacJni.es10cDeleteProfile(contextHandle, iccid) == 0
@ -144,6 +155,7 @@ class LocalProfileAssistantImpl(
) == 0
}
@Synchronized
override fun deleteNotification(seqNumber: Long): Boolean =
LpacJni.es10bDeleteNotification(contextHandle, seqNumber) == 0
@ -153,6 +165,7 @@ class LocalProfileAssistantImpl(
Log.d(TAG, "handleNotification $seqNumber = $it")
} == 0
@Synchronized
override fun setNickname(iccid: String, nickname: String): Boolean =
LpacJni.es10cSetNickname(contextHandle, iccid, nickname) == 0

@ -1 +1 @@
Subproject commit 0011ea6cc4c045c84f7aac839c1cce7804422355
Subproject commit a5a0516f084936e7e87cf7420fb99283fa3052ef

View file

@ -75,6 +75,13 @@ Java_net_typeblog_lpac_1jni_LpacJni_euiccFini(JNIEnv *env, jobject thiz, jlong h
euicc_fini(ctx);
}
JNIEXPORT void JNICALL
Java_net_typeblog_lpac_1jni_LpacJni_euiccSetMss(JNIEnv *env, jobject thiz, jlong handle,
jbyte mss) {
struct euicc_ctx *ctx = (struct euicc_ctx *) handle;
ctx->es10x_mss = (uint8_t) mss;
}
jstring toJString(JNIEnv *env, const char *pat) {
jbyteArray bytes = NULL;
jstring encoding = NULL;

View file

@ -5,5 +5,7 @@
<permission name="android.permission.WRITE_EMBEDDED_SUBSCRIPTIONS" />
<permission name="android.permission.MODIFY_PHONE_STATE" />
<permission name="android.permission.SECURE_ELEMENT_PRIVILEGED_OPERATION" />
<permission name="android.permission.FOREGROUND_SERVICE" />
<permission name="android.permission.POST_NOTIFICATIONS" />
</privapp-permissions>
</permissions>