Compare commits

...

12 commits

Author SHA1 Message Date
14687acc73 jmp: Customize the timeout dialog
All checks were successful
/ build-debug (push) Successful in 4m0s
2024-03-30 16:47:30 -04:00
267332afa4 Merge remote-tracking branch 'openeuicc/master' into jmp 2024-03-30 16:43:32 -04:00
f046d40f2c privileged: Disable slot mapping support for pre-T
Fixes #15.
2024-03-30 15:36:30 -04:00
80adac68c8 ui: Use KiB instead of KB for free space
Fixes #19.
2024-03-30 15:30:40 -04:00
1ed5a4de38 chore: Uprev lpac
Fixes #20
2024-03-29 17:31:06 -04:00
4b842c4afe feat: Add an "verdict" to compatibility checks 2024-03-23 20:56:42 -04:00
2517fc817e ComaptibilityCheck: Clarify "Known Broken" 2024-03-23 11:17:10 -04:00
e48f9aa828 refactor: Channel validity, and reconnection
* ApduInterfaces also need a concept of validity based on the underlying
  APDU channel. For example, OMAPI depends on SEService being still
  connected.
* We then rely on this validity to wait for reconnection; we do not need
  to manually remove all channels under a slot because the rest will be
  invalid anyway, and the next attempt at connection will lazily
  recreate the channel.
* We had to manage channels manually before during reconnect because
  `valid` may result in SIGSEGV's when the underlying APDU channel has
  become invalid. This is avoided by the validity concept added to APDU
  channels.
2024-03-22 21:08:59 -04:00
1ac683f9ab refactor: Reconnecting channels is a EuiccChannelManager responsibility
Reconnecting did not work properly for OMAPI, because in that case we
have to reconnect SEService as well.
2024-03-21 22:21:24 -04:00
7834e0348a refactor: Move notification tracking logic to EuiccChannelFragmentUtils 2024-03-21 21:29:20 -04:00
92d8f9079f EuiccManagementFragment: Show alert dialog if timed out waiting for SIM 2024-03-21 21:16:14 -04:00
d9d0cf2e75 CompatibilityCheck: Show unknown status if OMAPI feature flag is not found 2024-03-20 20:03:45 -04:00
21 changed files with 273 additions and 164 deletions

View file

@ -10,7 +10,7 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
private var seService: SEService? = null private var seService: SEService? = null
private suspend fun ensureSEService() { private suspend fun ensureSEService() {
if (seService == null) { if (seService == null || !seService!!.isConnected) {
seService = connectSEService(context) seService = connectSEService(context)
} }
} }

View file

@ -6,10 +6,12 @@ import android.util.Log
import im.angry.openeuicc.di.AppContainer import im.angry.openeuicc.di.AppContainer
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
open class DefaultEuiccChannelManager( open class DefaultEuiccChannelManager(
protected val appContainer: AppContainer, protected val appContainer: AppContainer,
@ -87,24 +89,54 @@ open class DefaultEuiccChannelManager(
} }
} }
override suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>? {
for (card in uiccCards) {
if (card.physicalSlotIndex != physicalSlotId) continue
return card.ports.mapNotNull { tryOpenEuiccChannel(it) }
.ifEmpty { null }
}
return null
}
override fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>? = override fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>? =
runBlocking { runBlocking {
for (card in uiccCards) { findAllEuiccChannelsByPhysicalSlot(physicalSlotId)
if (card.physicalSlotIndex != physicalSlotId) continue }
return@runBlocking card.ports.mapNotNull { tryOpenEuiccChannel(it) }
.ifEmpty { null } override suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel? =
withContext(Dispatchers.IO) {
uiccCards.find { it.physicalSlotIndex == physicalSlotId }?.let { card ->
card.ports.find { it.portIndex == portId }?.let { tryOpenEuiccChannel(it) }
} }
return@runBlocking null
} }
override fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? = override fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? =
runBlocking { runBlocking {
withContext(Dispatchers.IO) { findEuiccChannelByPort(physicalSlotId, portId)
uiccCards.find { it.physicalSlotIndex == physicalSlotId }?.let { card -> }
card.ports.find { it.portIndex == portId }?.let { tryOpenEuiccChannel(it) }
override suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long) {
// If there is already a valid channel, we close it proactively
// Sometimes the current channel can linger on for a bit even after it should have become invalid
channels.find { it.slotId == physicalSlotId && it.portId == portId }?.apply {
if (valid) close()
}
withTimeout(timeoutMillis) {
while (true) {
try {
// tryOpenEuiccChannel() will automatically dispose of invalid channels
// and recreate when needed
val channel = findEuiccChannelByPortBlocking(physicalSlotId, portId)!!
check(channel.valid) { "Invalid channel" }
break
} catch (e: Exception) {
Log.d(TAG, "Slot $physicalSlotId port $portId reconnect failure, retrying in 1000 ms")
} }
delay(1000)
} }
} }
}
override suspend fun enumerateEuiccChannels() { override suspend fun enumerateEuiccChannels() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {

View file

@ -8,6 +8,13 @@ interface EuiccChannelManager {
*/ */
suspend fun enumerateEuiccChannels() suspend fun enumerateEuiccChannels()
/**
* Wait for a slot + port to reconnect (i.e. become valid again)
* If the port is currently valid, this function will return immediately.
* On timeout, the caller can decide to either try again later, or alert the user with an error
*/
suspend fun waitForReconnect(physicalSlotId: Int, portId: Int, timeoutMillis: Long = 1000)
/** /**
* Returns the EuiccChannel corresponding to a **logical** slot * Returns the EuiccChannel corresponding to a **logical** slot
*/ */
@ -24,11 +31,13 @@ interface EuiccChannelManager {
* Returns all EuiccChannels corresponding to a **physical** slot * Returns all EuiccChannels corresponding to a **physical** slot
* Multiple channels are possible in the case of MEP * Multiple channels are possible in the case of MEP
*/ */
suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List<EuiccChannel>?
fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>? fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>?
/** /**
* Returns the EuiccChannel corresponding to a **physical** slot and a port ID * Returns the EuiccChannel corresponding to a **physical** slot and a port ID
*/ */
suspend fun findEuiccChannelByPort(physicalSlotId: Int, portId: Int): EuiccChannel?
fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel?
/** /**

View file

@ -13,6 +13,9 @@ class OmapiApduInterface(
private lateinit var session: Session private lateinit var session: Session
private lateinit var lastChannel: Channel private lateinit var lastChannel: Channel
override val valid: Boolean
get() = service.isConnected && (this::session.isInitialized && !session.isClosed)
override fun connect() { override fun connect() {
session = service.getUiccReaderCompat(port.logicalSlotIndex + 1).openSession() session = service.getUiccReaderCompat(port.logicalSlotIndex + 1).openSession()
} }

View file

@ -16,6 +16,7 @@ import android.widget.ImageButton
import android.widget.PopupMenu import android.widget.PopupMenu
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -26,10 +27,10 @@ import net.typeblog.lpac_jni.LocalProfileInfo
import im.angry.openeuicc.common.R import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.* import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.lang.Exception
open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
EuiccChannelFragmentMarker { EuiccChannelFragmentMarker {
@ -130,35 +131,51 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener,
fab.isEnabled = false fab.isEnabled = false
lifecycleScope.launch { lifecycleScope.launch {
try { beginTrackedOperation {
if (enable) { val res = if (enable) {
doEnableProfile(iccid) channel.lpa.enableProfile(iccid)
} else { } else {
doDisableProfile(iccid) channel.lpa.disableProfile(iccid)
}
if (!res) {
Log.d(TAG, "Failed to enable / disable profile $iccid")
Toast.makeText(context, R.string.toast_profile_enable_failed, Toast.LENGTH_LONG)
.show()
return@beginTrackedOperation false
}
try {
euiccChannelManager.waitForReconnect(slotId, portId, timeoutMillis = 30 * 1000)
} catch (e: TimeoutCancellationException) {
withContext(Dispatchers.Main) {
// 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()
}
}
return@beginTrackedOperation false
}
if (enable) {
preferenceRepository.notificationEnableFlow.first()
} else {
preferenceRepository.notificationDisableFlow.first()
} }
refresh()
fab.isEnabled = true
} catch (e: Exception) {
Log.d(TAG, "Failed to enable / disable profile $iccid")
Log.d(TAG, Log.getStackTraceString(e))
fab.isEnabled = true
Toast.makeText(context, R.string.toast_profile_enable_failed, Toast.LENGTH_LONG).show()
} }
refresh()
fab.isEnabled = true
} }
} }
private suspend fun doEnableProfile(iccid: String) =
channel.lpa.beginOperation {
channel.lpa.enableProfile(iccid, reconnectTimeout = 15 * 1000) &&
preferenceRepository.notificationEnableFlow.first()
}
private suspend fun doDisableProfile(iccid: String) =
channel.lpa.beginOperation {
channel.lpa.disableProfile(iccid, reconnectTimeout = 15 * 1000) &&
preferenceRepository.notificationDisableFlow.first()
}
protected open fun populatePopupWithProfileActions(popup: PopupMenu, profile: LocalProfileInfo) { protected open fun populatePopupWithProfileActions(popup: PopupMenu, profile: LocalProfileInfo) {
popup.inflate(R.menu.profile_options) popup.inflate(R.menu.profile_options)
if (profile.isEnabled) { if (profile.isEnabled) {

View file

@ -82,7 +82,7 @@ class ProfileDeleteFragment : DialogFragment(), EuiccChannelFragmentMarker {
} }
} }
private suspend fun doDelete() = channel.lpa.beginOperation { private suspend fun doDelete() = beginTrackedOperation {
channel.lpa.deleteProfile(requireArguments().getString("iccid")!!) channel.lpa.deleteProfile(requireArguments().getString("iccid")!!)
preferenceRepository.notificationDeleteFlow.first() preferenceRepository.notificationDeleteFlow.first()
} }

View file

@ -5,7 +5,6 @@ import android.app.Dialog
import android.content.DialogInterface import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.format.Formatter
import android.util.Log import android.util.Log
import android.view.* import android.view.*
import android.widget.ProgressBar import android.widget.ProgressBar
@ -131,7 +130,7 @@ class ProfileDownloadFragment : BaseMaterialDialogFragment(),
// Fetch remaining NVRAM // Fetch remaining NVRAM
val str = channel.lpa.euiccInfo2?.freeNvram?.also { val str = channel.lpa.euiccInfo2?.freeNvram?.also {
freeNvram = it freeNvram = it
}?.let { Formatter.formatShortFileSize(requireContext(), it.toLong()) } }?.let { formatFreeSpace(it) }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
profileDownloadFreeSpace.text = getString(R.string.profile_download_free_space, profileDownloadFreeSpace.text = getString(R.string.profile_download_free_space,
@ -189,15 +188,25 @@ class ProfileDownloadFragment : BaseMaterialDialogFragment(),
} }
} }
private suspend fun doDownloadProfile(server: String, code: String?, confirmationCode: String?, imei: String?) = channel.lpa.beginOperation { private suspend fun doDownloadProfile(
downloadProfile(server, code, imei, confirmationCode, object : ProfileDownloadCallback { server: String,
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) { code: String?,
lifecycleScope.launch(Dispatchers.Main) { confirmationCode: String?,
progress.isIndeterminate = false imei: String?
progress.progress = state.progress ) = beginTrackedOperation {
channel.lpa.downloadProfile(
server,
code,
imei,
confirmationCode,
object : ProfileDownloadCallback {
override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) {
lifecycleScope.launch(Dispatchers.Main) {
progress.isIndeterminate = false
progress.progress = state.progress
}
} }
} })
})
// If we get here, we are successful // If we get here, we are successful
// Only send notifications if the user allowed us to // Only send notifications if the user allowed us to

View file

@ -1,8 +1,14 @@
package im.angry.openeuicc.util package im.angry.openeuicc.util
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import im.angry.openeuicc.core.EuiccChannel import im.angry.openeuicc.core.EuiccChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.LocalProfileAssistant
private const val TAG = "EuiccChannelFragmentUtils"
interface EuiccChannelFragmentMarker: OpenEuiccContextMarker interface EuiccChannelFragmentMarker: OpenEuiccContextMarker
@ -28,6 +34,27 @@ val <T> T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker
get() = get() =
euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!! euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!!
/*
* Begin a "tracked" operation where notifications may be generated by the eSIM
* Automatically handle any newly generated notification during the operation
* if the function "op" returns true.
*/
suspend fun <T> T.beginTrackedOperation(op: suspend () -> Boolean) where T : Fragment, T : EuiccChannelFragmentMarker =
withContext(Dispatchers.IO) {
val latestSeq = channel.lpa.notifications.firstOrNull()?.seqNumber ?: 0
Log.d(TAG, "Latest notification is $latestSeq before operation")
if (op()) {
Log.d(TAG, "Operation has requested notification handling")
// Note that the exact instance of "channel" might have changed here if reconnected;
// so we MUST use the automatic getter for "channel"
channel.lpa.notifications.filter { it.seqNumber > latestSeq }.forEach {
Log.d(TAG, "Handling notification $it")
channel.lpa.handleNotification(it.seqNumber)
}
}
Log.d(TAG, "Operation complete")
}
interface EuiccProfilesChangedListener { interface EuiccProfilesChangedListener {
fun onEuiccProfilesChanged() fun onEuiccProfilesChanged()
} }

View file

@ -20,3 +20,11 @@ fun ByteArray.encodeHex(): String {
} }
return sb.toString() return sb.toString()
} }
fun formatFreeSpace(size: Int): String =
// SIM cards probably won't have much more space anytime soon.
if (size >= 1024) {
"%.2f KiB".format(size.toDouble() / 1024)
} else {
"$size B"
}

View file

@ -16,6 +16,8 @@
<string name="delete">Delete</string> <string name="delete">Delete</string>
<string name="rename">Rename</string> <string name="rename">Rename</string>
<string name="enable_disable_timeout">Timed out waiting for the eSIM chip to switch profiles. You might want to restart the application or even the phone.</string>
<string name="toast_profile_enable_failed">Cannot switch to new eSIM profile.</string> <string name="toast_profile_enable_failed">Cannot switch to new eSIM profile.</string>
<string name="toast_profile_name_too_long">Nickname cannot be longer than 64 characters</string> <string name="toast_profile_name_too_long">Nickname cannot be longer than 64 characters</string>

View file

@ -5,4 +5,6 @@
<string name="purchase_esim">Buy JMP eSIM Adapter</string> <string name="purchase_esim">Buy JMP eSIM Adapter</string>
<string name="purchase_sim_url" translatable="false">https://jmp.chat/esim-adapter</string> <string name="purchase_sim_url" translatable="false">https://jmp.chat/esim-adapter</string>
<string name="pref_info_source_code_url" translatable="false">https://gitea.angry.im/jmp-sim/jmp-sim-manager</string> <string name="pref_info_source_code_url" translatable="false">https://gitea.angry.im/jmp-sim/jmp-sim-manager</string>
<string name="enable_disable_timeout">Timed out waiting for the eSIM chip to switch profiles. Please manually refresh the eSIM adapter by going to SIM Toolkit, and select Tools -> Reboot.</string>
</resources> </resources>

View file

@ -15,7 +15,8 @@ open class UnprivilegedOpenEuiccApplication : OpenEuiccApplication() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Thread.setDefaultUncaughtExceptionHandler { _, _ -> Thread.setDefaultUncaughtExceptionHandler { _, e ->
e.printStackTrace()
Intent(this, LogsActivity::class.java).apply { Intent(this, LogsActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)

View file

@ -16,12 +16,16 @@ fun getCompatibilityChecks(context: Context): List<CompatibilityCheck> =
HasSystemFeaturesCheck(context), HasSystemFeaturesCheck(context),
OmapiConnCheck(context), OmapiConnCheck(context),
IsdrChannelAccessCheck(context), IsdrChannelAccessCheck(context),
KnownBrokenCheck(context) KnownBrokenCheck(context),
Verdict(context),
) )
inline fun <reified T: CompatibilityCheck> List<CompatibilityCheck>.findCheck(): T? =
find { it.javaClass == T::class.java }?.let { it as T }
suspend fun List<CompatibilityCheck>.executeAll(callback: () -> Unit) = withContext(Dispatchers.IO) { suspend fun List<CompatibilityCheck>.executeAll(callback: () -> Unit) = withContext(Dispatchers.IO) {
forEach { forEach {
it.run() it.run(this@executeAll)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
callback() callback()
} }
@ -57,13 +61,13 @@ abstract class CompatibilityCheck(context: Context) {
else -> defaultDescription else -> defaultDescription
} }
protected abstract suspend fun doCheck(): State protected abstract suspend fun doCheck(allChecks: List<CompatibilityCheck>): State
suspend fun run() { suspend fun run(allChecks: List<CompatibilityCheck>) {
state = State.IN_PROGRESS state = State.IN_PROGRESS
delay(200) delay(200)
state = try { state = try {
doCheck() doCheck(allChecks)
} catch (_: Exception) { } catch (_: Exception) {
State.FAILURE State.FAILURE
} }
@ -76,7 +80,7 @@ internal class HasSystemFeaturesCheck(private val context: Context): Compatibili
override val defaultDescription: String override val defaultDescription: String
get() = context.getString(R.string.compatibility_check_system_features_desc) get() = context.getString(R.string.compatibility_check_system_features_desc)
override suspend fun doCheck(): State { override suspend fun doCheck(allChecks: List<CompatibilityCheck>): State {
if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)) { if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)) {
failureDescription = context.getString(R.string.compatibility_check_system_features_no_telephony) failureDescription = context.getString(R.string.compatibility_check_system_features_no_telephony)
return State.FAILURE return State.FAILURE
@ -87,7 +91,7 @@ internal class HasSystemFeaturesCheck(private val context: Context): Compatibili
PackageManager.FEATURE_SE_OMAPI_UICC PackageManager.FEATURE_SE_OMAPI_UICC
)) { )) {
failureDescription = context.getString(R.string.compatibility_check_system_features_no_omapi) failureDescription = context.getString(R.string.compatibility_check_system_features_no_omapi)
return State.FAILURE return State.FAILURE_UNKNOWN
} }
return State.SUCCESS return State.SUCCESS
@ -100,7 +104,7 @@ internal class OmapiConnCheck(private val context: Context): CompatibilityCheck(
override val defaultDescription: String override val defaultDescription: String
get() = context.getString(R.string.compatibility_check_omapi_connectivity_desc) get() = context.getString(R.string.compatibility_check_omapi_connectivity_desc)
override suspend fun doCheck(): State { override suspend fun doCheck(allChecks: List<CompatibilityCheck>): State {
val seService = connectSEService(context) val seService = connectSEService(context)
if (!seService.isConnected) { if (!seService.isConnected) {
failureDescription = context.getString(R.string.compatibility_check_omapi_connectivity_fail) failureDescription = context.getString(R.string.compatibility_check_omapi_connectivity_fail)
@ -132,7 +136,7 @@ internal class IsdrChannelAccessCheck(private val context: Context): Compatibili
override val defaultDescription: String override val defaultDescription: String
get() = context.getString(R.string.compatibility_check_isdr_channel_desc) get() = context.getString(R.string.compatibility_check_isdr_channel_desc)
override suspend fun doCheck(): State { override suspend fun doCheck(allChecks: List<CompatibilityCheck>): State {
val seService = connectSEService(context) val seService = connectSEService(context)
val readers = seService.readers.filter { it.isSIM } val readers = seService.readers.filter { it.isSIM }
if (readers.isEmpty()) { if (readers.isEmpty()) {
@ -200,10 +204,57 @@ internal class KnownBrokenCheck(private val context: Context): CompatibilityChec
failureDescription = context.getString(R.string.compatibility_check_known_broken_fail) failureDescription = context.getString(R.string.compatibility_check_known_broken_fail)
} }
override suspend fun doCheck(): State = override suspend fun doCheck(allChecks: List<CompatibilityCheck>): State =
if (Build.MANUFACTURER.lowercase() in BROKEN_MANUFACTURERS) { if (Build.MANUFACTURER.lowercase() in BROKEN_MANUFACTURERS) {
State.FAILURE State.FAILURE
} else { } else {
State.SUCCESS State.SUCCESS
} }
} }
internal class Verdict(private val context: Context) : CompatibilityCheck(context) {
override val title: String
get() = context.getString(R.string.compatibility_check_verdict)
override val defaultDescription: String
get() = context.getString(R.string.compatibility_check_verdict_desc)
override suspend fun doCheck(allChecks: List<CompatibilityCheck>): State {
if (allChecks.findCheck<KnownBrokenCheck>()?.state == State.FAILURE) {
failureDescription = context.getString(
R.string.compatibility_check_verdict_known_broken,
context.getString(R.string.compatibility_check_verdict_fail_shared)
)
return State.FAILURE
}
if (allChecks.findCheck<OmapiConnCheck>()?.state == State.SUCCESS &&
allChecks.findCheck<IsdrChannelAccessCheck>()?.state == State.SUCCESS
) {
successDescription = context.getString(R.string.compatibility_check_verdict_ok)
return State.SUCCESS
}
if (allChecks.findCheck<OmapiConnCheck>()?.state == State.FAILURE_UNKNOWN ||
allChecks.findCheck<IsdrChannelAccessCheck>()?.state == State.FAILURE_UNKNOWN
) {
// We are not sure because we can't fully check OMAPI
// however we can guess based on feature flags
// TODO: We probably need a "known-good" list for these devices as well?
failureDescription = context.getString(
if (allChecks.findCheck<HasSystemFeaturesCheck>()?.state == State.SUCCESS) {
R.string.compatibility_check_verdict_unknown_likely_ok
} else {
R.string.compatibility_check_verdict_unknown_likely_fail
},
context.getString(R.string.compatibility_check_verdict_fail_shared)
)
return State.FAILURE_UNKNOWN
}
failureDescription = context.getString(
R.string.compatibility_check_verdict_unknown,
context.getString(R.string.compatibility_check_verdict_fail_shared)
)
return State.FAILURE_UNKNOWN
}
}

View file

@ -6,7 +6,7 @@
<string name="compatibility_check_system_features">System Features</string> <string name="compatibility_check_system_features">System Features</string>
<string name="compatibility_check_system_features_desc">Whether your device has all the required features for managing removable eUICC cards. For example, basic telephony and OMAPI support.</string> <string name="compatibility_check_system_features_desc">Whether your device has all the required features for managing removable eUICC cards. For example, basic telephony and OMAPI support.</string>
<string name="compatibility_check_system_features_no_telephony">Your device has no telephony features.</string> <string name="compatibility_check_system_features_no_telephony">Your device has no telephony features.</string>
<string name="compatibility_check_system_features_no_omapi">Your device has no support for accessing SIM cards via OMAPI. If you are using a custom ROM, consider contacting the developer to determine whether it is due to hardware or a missing feature declaration in the OS.</string> <string name="compatibility_check_system_features_no_omapi">Your device / system does not declare support for OMAPI. This could be due to missing support from hardware, or it could be simply due to a missing flag. See the following two checks to determine whether OMAPI is actually supported or not.</string>
<string name="compatibility_check_omapi_connectivity">OMAPI Connectivity</string> <string name="compatibility_check_omapi_connectivity">OMAPI Connectivity</string>
<string name="compatibility_check_omapi_connectivity_desc">Does your device allow access to Secure Elements on SIM cards via OMAPI?</string> <string name="compatibility_check_omapi_connectivity_desc">Does your device allow access to Secure Elements on SIM cards via OMAPI?</string>
<string name="compatibility_check_omapi_connectivity_fail">Unable to detect Secure Element readers for SIM cards via OMAPI. If you have not inserted a SIM in this device, try inserting one and retry this check.</string> <string name="compatibility_check_omapi_connectivity_fail">Unable to detect Secure Element readers for SIM cards via OMAPI. If you have not inserted a SIM in this device, try inserting one and retry this check.</string>
@ -15,7 +15,15 @@
<string name="compatibility_check_isdr_channel_desc">Does your device support opening an ISD-R (management) channel to eSIMs via OMAPI?</string> <string name="compatibility_check_isdr_channel_desc">Does your device support opening an ISD-R (management) channel to eSIMs via OMAPI?</string>
<string name="compatibility_check_isdr_channel_desc_unknown">Cannot determine whether ISD-R access through OMAPI is supported. You might want to retry with SIM cards inserted (any SIM card will do) if not already.</string> <string name="compatibility_check_isdr_channel_desc_unknown">Cannot determine whether ISD-R access through OMAPI is supported. You might want to retry with SIM cards inserted (any SIM card will do) if not already.</string>
<string name="compatibility_check_isdr_channel_desc_partial_fail">OMAPI access to ISD-R is only possible on the following SIM slots: %s.</string> <string name="compatibility_check_isdr_channel_desc_partial_fail">OMAPI access to ISD-R is only possible on the following SIM slots: %s.</string>
<string name="compatibility_check_known_broken">Known Broken?</string> <string name="compatibility_check_known_broken">Not on the Known Broken List</string>
<string name="compatibility_check_known_broken_desc">Making sure your device is not known to have bugs associated with removable eSIMs.</string> <string name="compatibility_check_known_broken_desc">Making sure your device is not known to have bugs associated with removable eSIMs.</string>
<string name="compatibility_check_known_broken_fail">Oops, your device is known to have bugs when accessing removable eSIMs. This does not necessarily mean that it will not work at all, but you will have to proceed with caution.</string> <string name="compatibility_check_known_broken_fail">Oops, your device is known to have bugs when accessing removable eSIMs. This does not necessarily mean that it will not work at all, but you will have to proceed with caution.</string>
<string name="compatibility_check_verdict">Verdict</string>
<string name="compatibility_check_verdict_desc">Based on all previous checks, how likely is your device to be compatible with removable eSIMs?</string>
<string name="compatibility_check_verdict_ok">You can likely use and manage removable eSIMs on this device.</string>
<string name="compatibility_check_verdict_known_broken">Your device is known to be buggy when accessing removable eSIMs.\n%s</string>
<string name="compatibility_check_verdict_unknown_likely_ok">We cannot determine whether removable eSIMs can be managed on your device. Your device does declare support for OMAPI, though, so it is slightly more likely that it will work.\n%s</string>
<string name="compatibility_check_verdict_unknown_likely_fail">We cannot determine whether removable eSIMs can be managed on your device. Since your device does not declare support for OMAPI, it is more likely that managing removable eSIMs on this device is unsupported.\n%s</string>
<string name="compatibility_check_verdict_unknown">We cannot determine whether removable eSIMs can be managed on your device.\n%s</string>
<string name="compatibility_check_verdict_fail_shared">However, a removable eSIM that has already been loaded with an eSIM profile will still work; this verdict is only for management features via the app, such as switching and downloading profiles.</string>
</resources> </resources>

View file

@ -11,6 +11,11 @@ class TelephonyManagerApduInterface(
): ApduInterface { ): ApduInterface {
private var lastChannel: Int = -1 private var lastChannel: Int = -1
override val valid: Boolean
// TelephonyManager channels will never become truly "invalid",
// just that transactions might return errors or nonsense
get() = lastChannel != -1
override fun connect() { override fun connect() {
// Do nothing // Do nothing
} }

View file

@ -1,5 +1,6 @@
package im.angry.openeuicc.service package im.angry.openeuicc.service
import android.os.Build
import android.service.euicc.* import android.service.euicc.*
import android.telephony.UiccSlotMapping import android.telephony.UiccSlotMapping
import android.telephony.euicc.DownloadableSubscription import android.telephony.euicc.DownloadableSubscription
@ -34,6 +35,10 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
lpa.profiles.any { it.iccid == iccid } lpa.profiles.any { it.iccid == iccid }
private fun ensurePortIsMapped(slotId: Int, portId: Int) { private fun ensurePortIsMapped(slotId: Int, portId: Int) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return
}
val mappings = telephonyManager.simSlotMapping.toMutableList() val mappings = telephonyManager.simSlotMapping.toMutableList()
mappings.firstOrNull { it.physicalSlotIndex == slotId && it.portIndex == portId }?.let { mappings.firstOrNull { it.physicalSlotIndex == slotId && it.portIndex == portId }?.let {

View file

@ -1,5 +1,6 @@
package im.angry.openeuicc.ui package im.angry.openeuicc.ui
import android.os.Build
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.Toast import android.widget.Toast
@ -11,6 +12,10 @@ class PrivilegedMainActivity : MainActivity() {
super.onCreateOptionsMenu(menu) super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.activity_main_privileged, menu) menuInflater.inflate(R.menu.activity_main_privileged, menu)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
menu.findItem(R.id.slot_mapping).isVisible = false
}
if (tm.supportsDSDS) { if (tm.supportsDSDS) {
val dsds = menu.findItem(R.id.dsds) val dsds = menu.findItem(R.id.dsds)
dsds.isVisible = true dsds.isVisible = true

View file

@ -9,4 +9,11 @@ interface ApduInterface {
fun logicalChannelOpen(aid: ByteArray): Int fun logicalChannelOpen(aid: ByteArray): Int
fun logicalChannelClose(handle: Int) fun logicalChannelClose(handle: Int)
fun transmit(tx: ByteArray): ByteArray fun transmit(tx: ByteArray): ByteArray
/**
* Is this APDU connection still valid?
* Note that even if this returns true, the underlying connection might be broken anyway;
* callers should further check with the LPA to fully determine the validity of a channel
*/
val valid: Boolean
} }

View file

@ -1,24 +1,7 @@
package net.typeblog.lpac_jni package net.typeblog.lpac_jni
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
interface LocalProfileAssistant { interface LocalProfileAssistant {
companion object {
private const val TAG = "LocalProfileAssistant"
}
val valid: Boolean val valid: Boolean
get() = try {
// If we can read both eID and profiles properly, we are likely looking at
// a valid LocalProfileAssistant
eID
profiles
true
} catch (e: Exception) {
false
}
val profiles: List<LocalProfileInfo> val profiles: List<LocalProfileInfo>
val notifications: List<LocalProfileNotification> val notifications: List<LocalProfileNotification>
val eID: String val eID: String
@ -27,8 +10,8 @@ interface LocalProfileAssistant {
// All blocking functions in this class assume that they are executed on non-Main threads // 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. // The IO context in Kotlin's coroutine library is recommended.
fun enableProfile(iccid: String, reconnectTimeout: Long = 0): Boolean fun enableProfile(iccid: String): Boolean
fun disableProfile(iccid: String, reconnectTimeout: Long = 0): Boolean fun disableProfile(iccid: String): Boolean
fun deleteProfile(iccid: String): Boolean fun deleteProfile(iccid: String): Boolean
fun downloadProfile(smdp: String, matchingId: String?, imei: String?, fun downloadProfile(smdp: String, matchingId: String?, imei: String?,
@ -37,24 +20,6 @@ interface LocalProfileAssistant {
fun deleteNotification(seqNumber: Long): Boolean fun deleteNotification(seqNumber: Long): Boolean
fun handleNotification(seqNumber: Long): Boolean fun handleNotification(seqNumber: Long): Boolean
// Wraps an operation on the eSIM chip (any of the other blocking functions)
// Handles notifications automatically after the operation, unless the lambda executing
// the operation returns false, which inhibits automatic notification processing.
// All code executed within are also wrapped automatically in the IO context.
suspend fun beginOperation(op: suspend LocalProfileAssistant.() -> Boolean) =
withContext(Dispatchers.IO) {
val latestSeq = notifications.firstOrNull()?.seqNumber ?: 0
Log.d(TAG, "Latest notification is $latestSeq before operation")
if (op(this@LocalProfileAssistant)) {
Log.d(TAG, "Operation has requested notification handling")
notifications.filter { it.seqNumber > latestSeq }.forEach {
Log.d(TAG, "Handling notification $it")
handleNotification(it.seqNumber)
}
}
Log.d(TAG, "Operation complete")
}
fun setNickname( fun setNickname(
iccid: String, nickname: String iccid: String, nickname: String
): Boolean ): Boolean

View file

@ -1,9 +1,6 @@
package net.typeblog.lpac_jni.impl package net.typeblog.lpac_jni.impl
import android.util.Log import android.util.Log
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import net.typeblog.lpac_jni.LpacJni import net.typeblog.lpac_jni.LpacJni
import net.typeblog.lpac_jni.ApduInterface import net.typeblog.lpac_jni.ApduInterface
import net.typeblog.lpac_jni.EuiccInfo2 import net.typeblog.lpac_jni.EuiccInfo2
@ -15,12 +12,13 @@ import net.typeblog.lpac_jni.ProfileDownloadCallback
class LocalProfileAssistantImpl( class LocalProfileAssistantImpl(
private val apduInterface: ApduInterface, private val apduInterface: ApduInterface,
private val httpInterface: HttpInterface httpInterface: HttpInterface
): LocalProfileAssistant { ): LocalProfileAssistant {
companion object { companion object {
private const val TAG = "LocalProfileAssistantImpl" private const val TAG = "LocalProfileAssistantImpl"
} }
private var finalized = false
private var contextHandle: Long = LpacJni.createContext(apduInterface, httpInterface) private var contextHandle: Long = LpacJni.createContext(apduInterface, httpInterface)
init { init {
if (LpacJni.euiccInit(contextHandle) < 0) { if (LpacJni.euiccInit(contextHandle) < 0) {
@ -31,47 +29,16 @@ class LocalProfileAssistantImpl(
httpInterface.usePublicKeyIds(pkids) httpInterface.usePublicKeyIds(pkids)
} }
private fun tryReconnect(timeoutMillis: Long) = runBlocking { override val valid: Boolean
withTimeout(timeoutMillis) { get() = !finalized && apduInterface.valid && try {
try { // If we can read both eID and profiles properly, we are likely looking at
LpacJni.euiccFini(contextHandle) // a valid LocalProfileAssistant
LpacJni.destroyContext(contextHandle) eID
contextHandle = -1 profiles
} catch (e: Exception) { true
// Ignored } catch (e: Exception) {
} false
while (true) {
try {
apduInterface.disconnect()
} catch (e: Exception) {
// Ignored
}
try {
apduInterface.connect()
contextHandle = LpacJni.createContext(apduInterface, httpInterface)
check(LpacJni.euiccInit(contextHandle) >= 0) { "Reconnect attempt failed" }
// Validate that we can actually use the APDU channel by trying to read eID and profiles
check(valid) { "Reconnected channel is invalid" }
break
} catch (e: Exception) {
e.printStackTrace()
if (contextHandle != -1L) {
try {
LpacJni.euiccFini(contextHandle)
LpacJni.destroyContext(contextHandle)
contextHandle = -1
} catch (e: Exception) {
// Ignored
}
}
// continue retrying
delay(1000)
}
}
} }
}
override val profiles: List<LocalProfileInfo> override val profiles: List<LocalProfileInfo>
get() = LpacJni.es10cGetProfilesInfo(contextHandle)!!.asList() get() = LpacJni.es10cGetProfilesInfo(contextHandle)!!.asList()
@ -87,29 +54,11 @@ class LocalProfileAssistantImpl(
override val euiccInfo2: EuiccInfo2? override val euiccInfo2: EuiccInfo2?
get() = LpacJni.es10cexGetEuiccInfo2(contextHandle) get() = LpacJni.es10cexGetEuiccInfo2(contextHandle)
override fun enableProfile(iccid: String, reconnectTimeout: Long): Boolean { override fun enableProfile(iccid: String): Boolean =
val res = LpacJni.es10cEnableProfile(contextHandle, iccid) == 0 LpacJni.es10cEnableProfile(contextHandle, iccid) == 0
if (reconnectTimeout > 0) {
try {
tryReconnect(reconnectTimeout)
} catch (e: Exception) {
return false
}
}
return res
}
override fun disableProfile(iccid: String, reconnectTimeout: Long): Boolean { override fun disableProfile(iccid: String): Boolean =
val res = LpacJni.es10cDisableProfile(contextHandle, iccid) == 0 LpacJni.es10cDisableProfile(contextHandle, iccid) == 0
if (reconnectTimeout > 0) {
try {
tryReconnect(reconnectTimeout)
} catch (e: Exception) {
return false
}
}
return res
}
override fun deleteProfile(iccid: String): Boolean { override fun deleteProfile(iccid: String): Boolean {
return LpacJni.es10cDeleteProfile(contextHandle, iccid) == 0 return LpacJni.es10cDeleteProfile(contextHandle, iccid) == 0
@ -134,8 +83,12 @@ class LocalProfileAssistantImpl(
return LpacJni.es10cSetNickname(contextHandle, iccid, nickname) == 0 return LpacJni.es10cSetNickname(contextHandle, iccid, nickname) == 0
} }
@Synchronized
override fun close() { override fun close() {
LpacJni.euiccFini(contextHandle) if (!finalized) {
LpacJni.destroyContext(contextHandle) LpacJni.euiccFini(contextHandle)
LpacJni.destroyContext(contextHandle)
finalized = true
}
} }
} }

@ -1 +1 @@
Subproject commit dc09c3e668fc191694e73fede255a7a0a26f4988 Subproject commit 0bb196977e7f2e276a1076734897ad8c48d9d5ca