Compare commits

...

2 commits

Author SHA1 Message Date
Peter Cai 1ac683f9ab refactor: Reconnecting channels is a EuiccChannelManager responsibility
All checks were successful
/ build-debug (push) Successful in 4m13s
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
Peter Cai 7834e0348a refactor: Move notification tracking logic to EuiccChannelFragmentUtils 2024-03-21 21:29:20 -04:00
10 changed files with 144 additions and 142 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,14 +89,18 @@ 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 }
}
return@runBlocking null
} }
override fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? = override fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? =
@ -106,6 +112,25 @@ open class DefaultEuiccChannelManager(
} }
} }
override suspend fun tryReconnectSlot(physicalSlotId: Int, timeoutMillis: Long) {
invalidateByPhysicalSlot(physicalSlotId)
withTimeout(timeoutMillis) {
while (true) {
try {
findAllEuiccChannelsByPhysicalSlot(physicalSlotId)!!.forEach {
check(it.valid) { "Invalid channel" }
}
break
} catch (e: Exception) {
Log.d(TAG, "Slot reconnect failure, retrying in 1000 ms")
invalidateByPhysicalSlot(physicalSlotId)
}
delay(1000)
}
}
}
override suspend fun enumerateEuiccChannels() { override suspend fun enumerateEuiccChannels() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
for (uiccInfo in uiccCards) { for (uiccInfo in uiccCards) {
@ -132,4 +157,12 @@ open class DefaultEuiccChannelManager(
channels.clear() channels.clear()
euiccChannelFactory.cleanup() euiccChannelFactory.cleanup()
} }
private fun invalidateByPhysicalSlot(physicalSlotId: Int) {
val toRemove = channels.filter { it.valid && it.slotId == physicalSlotId }
for (channel in toRemove) {
channel.close()
channels.remove(channel)
}
}
} }

View file

@ -8,6 +8,15 @@ interface EuiccChannelManager {
*/ */
suspend fun enumerateEuiccChannels() suspend fun enumerateEuiccChannels()
/**
* Reconnect ALL EuiccChannels belonging to a physical slot
* Throws TimeoutCancellationException when timed out
* If this operation times out, none of the channels belonging to the slot will be
* guaranteed to be consistent. The caller should either call invalidate()
* and try again later, or the application should simply exit entirely.
*/
suspend fun tryReconnectSlot(physicalSlotId: Int, timeoutMillis: Long = 1000)
/** /**
* Returns the EuiccChannel corresponding to a **logical** slot * Returns the EuiccChannel corresponding to a **logical** slot
*/ */
@ -24,6 +33,7 @@ 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>?
/** /**

View file

@ -31,7 +31,6 @@ 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 {
@ -132,48 +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)
} }
refresh()
fab.isEnabled = true if (!res) {
} catch (e: TimeoutCancellationException) { Log.d(TAG, "Failed to enable / disable profile $iccid")
// Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid Toast.makeText(context, R.string.toast_profile_enable_failed, Toast.LENGTH_LONG)
AlertDialog.Builder(requireContext()).apply { .show()
setMessage(R.string.enable_disable_timeout) return@beginTrackedOperation false
setPositiveButton(android.R.string.ok) { dialog, _ -> }
dialog.dismiss()
requireActivity().finish() try {
} euiccChannelManager.tryReconnectSlot(slotId, timeoutMillis = 30 * 1000)
setOnDismissListener { _ -> } catch (e: TimeoutCancellationException) {
requireActivity().finish() withContext(Dispatchers.Main) {
} // Timed out waiting for SIM to come back online, we can no longer assume that the LPA is still valid
show() 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()
} }
} 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

@ -189,15 +189,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

@ -15,7 +15,8 @@ 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

@ -1,14 +1,6 @@
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 { get() = try {
// If we can read both eID and profiles properly, we are likely looking at // If we can read both eID and profiles properly, we are likely looking at
@ -27,8 +19,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 +29,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
@ -14,8 +11,8 @@ import net.typeblog.lpac_jni.LocalProfileNotification
import net.typeblog.lpac_jni.ProfileDownloadCallback import net.typeblog.lpac_jni.ProfileDownloadCallback
class LocalProfileAssistantImpl( class LocalProfileAssistantImpl(
private val apduInterface: ApduInterface, apduInterface: ApduInterface,
private val httpInterface: HttpInterface httpInterface: HttpInterface
): LocalProfileAssistant { ): LocalProfileAssistant {
companion object { companion object {
private const val TAG = "LocalProfileAssistantImpl" private const val TAG = "LocalProfileAssistantImpl"
@ -31,48 +28,6 @@ class LocalProfileAssistantImpl(
httpInterface.usePublicKeyIds(pkids) httpInterface.usePublicKeyIds(pkids)
} }
private fun tryReconnect(timeoutMillis: Long) = runBlocking {
withTimeout(timeoutMillis) {
try {
LpacJni.euiccFini(contextHandle)
LpacJni.destroyContext(contextHandle)
contextHandle = -1
} catch (e: Exception) {
// Ignored
}
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,21 +42,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) {
tryReconnect(reconnectTimeout)
}
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) {
tryReconnect(reconnectTimeout)
}
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