diff --git a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt index c741f49..1cb3650 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt @@ -10,7 +10,7 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha private var seService: SEService? = null private suspend fun ensureSEService() { - if (seService == null) { + if (seService == null || !seService!!.isConnected) { seService = connectSEService(context) } } diff --git a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt index a978677..ec676d1 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt @@ -6,10 +6,12 @@ import android.util.Log import im.angry.openeuicc.di.AppContainer import im.angry.openeuicc.util.* import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout open class DefaultEuiccChannelManager( protected val appContainer: AppContainer, @@ -87,14 +89,18 @@ open class DefaultEuiccChannelManager( } } + override suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List? { + 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? = runBlocking { - for (card in uiccCards) { - if (card.physicalSlotIndex != physicalSlotId) continue - return@runBlocking card.ports.mapNotNull { tryOpenEuiccChannel(it) } - .ifEmpty { null } - } - return@runBlocking null + findAllEuiccChannelsByPhysicalSlot(physicalSlotId) } 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() { withContext(Dispatchers.IO) { for (uiccInfo in uiccCards) { @@ -132,4 +157,12 @@ open class DefaultEuiccChannelManager( channels.clear() 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) + } + } } \ No newline at end of file diff --git a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelManager.kt b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelManager.kt index b0bce1d..5ee40f2 100644 --- a/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelManager.kt +++ b/app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelManager.kt @@ -8,6 +8,15 @@ interface EuiccChannelManager { */ 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 */ @@ -24,6 +33,7 @@ interface EuiccChannelManager { * Returns all EuiccChannels corresponding to a **physical** slot * Multiple channels are possible in the case of MEP */ + suspend fun findAllEuiccChannelsByPhysicalSlot(physicalSlotId: Int): List? fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List? /** diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt index 2e162b5..c02f7fd 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/EuiccManagementFragment.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.lang.Exception open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, EuiccChannelFragmentMarker { @@ -132,48 +131,51 @@ open class EuiccManagementFragment : Fragment(), EuiccProfilesChangedListener, fab.isEnabled = false lifecycleScope.launch { - try { - if (enable) { - doEnableProfile(iccid) + beginTrackedOperation { + val res = if (enable) { + channel.lpa.enableProfile(iccid) } else { - doDisableProfile(iccid) + channel.lpa.disableProfile(iccid) } - refresh() - fab.isEnabled = true - } catch (e: TimeoutCancellationException) { - // 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() + + 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.tryReconnectSlot(slotId, 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() } - } 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) { popup.inflate(R.menu.profile_options) if (profile.isEnabled) { diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt index ede48ea..7586354 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDeleteFragment.kt @@ -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")!!) preferenceRepository.notificationDeleteFlow.first() } diff --git a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDownloadFragment.kt b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDownloadFragment.kt index 8ab09d9..78a54f2 100644 --- a/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDownloadFragment.kt +++ b/app-common/src/main/java/im/angry/openeuicc/ui/ProfileDownloadFragment.kt @@ -189,15 +189,25 @@ class ProfileDownloadFragment : BaseMaterialDialogFragment(), } } - private suspend fun doDownloadProfile(server: String, code: String?, confirmationCode: String?, imei: String?) = channel.lpa.beginOperation { - downloadProfile(server, code, imei, confirmationCode, object : ProfileDownloadCallback { - override fun onStateUpdate(state: ProfileDownloadCallback.DownloadState) { - lifecycleScope.launch(Dispatchers.Main) { - progress.isIndeterminate = false - progress.progress = state.progress + private suspend fun doDownloadProfile( + server: String, + code: String?, + confirmationCode: String?, + imei: String? + ) = 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 // Only send notifications if the user allowed us to diff --git a/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt b/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt index 7c984c7..feee0cd 100644 --- a/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt +++ b/app-common/src/main/java/im/angry/openeuicc/util/EuiccChannelFragmentUtils.kt @@ -1,8 +1,14 @@ package im.angry.openeuicc.util import android.os.Bundle +import android.util.Log import androidx.fragment.app.Fragment 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 @@ -28,6 +34,27 @@ val T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker get() = 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.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 { fun onEuiccProfilesChanged() } \ No newline at end of file diff --git a/app-unpriv/src/main/java/im/angry/openeuicc/UnprivilegedOpenEuiccApplication.kt b/app-unpriv/src/main/java/im/angry/openeuicc/UnprivilegedOpenEuiccApplication.kt index 5541501..7993b50 100644 --- a/app-unpriv/src/main/java/im/angry/openeuicc/UnprivilegedOpenEuiccApplication.kt +++ b/app-unpriv/src/main/java/im/angry/openeuicc/UnprivilegedOpenEuiccApplication.kt @@ -15,7 +15,8 @@ class UnprivilegedOpenEuiccApplication : OpenEuiccApplication() { override fun onCreate() { super.onCreate() - Thread.setDefaultUncaughtExceptionHandler { _, _ -> + Thread.setDefaultUncaughtExceptionHandler { _, e -> + e.printStackTrace() Intent(this, LogsActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LocalProfileAssistant.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LocalProfileAssistant.kt index b40ba10..118a963 100644 --- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LocalProfileAssistant.kt +++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/LocalProfileAssistant.kt @@ -1,14 +1,6 @@ package net.typeblog.lpac_jni -import android.util.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - interface LocalProfileAssistant { - companion object { - private const val TAG = "LocalProfileAssistant" - } - val valid: Boolean get() = try { // 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 // The IO context in Kotlin's coroutine library is recommended. - fun enableProfile(iccid: String, reconnectTimeout: Long = 0): Boolean - fun disableProfile(iccid: String, reconnectTimeout: Long = 0): Boolean + fun enableProfile(iccid: String): Boolean + fun disableProfile(iccid: String): Boolean fun deleteProfile(iccid: String): Boolean fun downloadProfile(smdp: String, matchingId: String?, imei: String?, @@ -37,24 +29,6 @@ interface LocalProfileAssistant { fun deleteNotification(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( iccid: String, nickname: String ): Boolean diff --git a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt index 246ead2..98a3969 100644 --- a/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt +++ b/libs/lpac-jni/src/main/java/net/typeblog/lpac_jni/impl/LocalProfileAssistantImpl.kt @@ -1,9 +1,6 @@ package net.typeblog.lpac_jni.impl 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.ApduInterface import net.typeblog.lpac_jni.EuiccInfo2 @@ -14,8 +11,8 @@ import net.typeblog.lpac_jni.LocalProfileNotification import net.typeblog.lpac_jni.ProfileDownloadCallback class LocalProfileAssistantImpl( - private val apduInterface: ApduInterface, - private val httpInterface: HttpInterface + apduInterface: ApduInterface, + httpInterface: HttpInterface ): LocalProfileAssistant { companion object { private const val TAG = "LocalProfileAssistantImpl" @@ -31,48 +28,6 @@ class LocalProfileAssistantImpl( 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 get() = LpacJni.es10cGetProfilesInfo(contextHandle)!!.asList() @@ -87,21 +42,11 @@ class LocalProfileAssistantImpl( override val euiccInfo2: EuiccInfo2? get() = LpacJni.es10cexGetEuiccInfo2(contextHandle) - override fun enableProfile(iccid: String, reconnectTimeout: Long): Boolean { - val res = LpacJni.es10cEnableProfile(contextHandle, iccid) == 0 - if (reconnectTimeout > 0) { - tryReconnect(reconnectTimeout) - } - return res - } + override fun enableProfile(iccid: String): Boolean = + LpacJni.es10cEnableProfile(contextHandle, iccid) == 0 - override fun disableProfile(iccid: String, reconnectTimeout: Long): Boolean { - val res = LpacJni.es10cDisableProfile(contextHandle, iccid) == 0 - if (reconnectTimeout > 0) { - tryReconnect(reconnectTimeout) - } - return res - } + override fun disableProfile(iccid: String): Boolean = + LpacJni.es10cDisableProfile(contextHandle, iccid) == 0 override fun deleteProfile(iccid: String): Boolean { return LpacJni.es10cDeleteProfile(contextHandle, iccid) == 0