refactor: Wrap EuiccChannelManager in an Android Service instance
All checks were successful
/ build-debug (push) Successful in 4m54s

This allows MUCH better lifecycle control over EuiccChannelManager. We
no longer have to keep all opened APDU channels open until the
application is destroyed. Instead, they can be closed as long as no
component is bound to this Service instance.

A catch is that other long-running services must bind to this service
as-needed, otherwise a binding is going to keep the service always
alive. This only affects the EuiccService implementation, and a
suspending/blocking helper function is added to deal with this case.
This commit is contained in:
Peter Cai 2024-05-04 17:29:10 -04:00
parent 0f655f1f1f
commit 59f3597874
19 changed files with 256 additions and 49 deletions

View file

@ -28,5 +28,9 @@
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
tools:replace="screenOrientation" />
<service
android:name="im.angry.openeuicc.service.EuiccChannelManagerService"
android:exported="false" />
</application>
</manifest>

View file

@ -0,0 +1,10 @@
package im.angry.openeuicc.core
import android.app.Service
import im.angry.openeuicc.di.AppContainer
class DefaultEuiccChannelManagerFactory(private val appContainer: AppContainer) :
EuiccChannelManagerFactory {
override fun createEuiccChannelManager(serviceContext: Service) =
DefaultEuiccChannelManager(appContainer, serviceContext)
}

View file

@ -0,0 +1,7 @@
package im.angry.openeuicc.core
import android.app.Service
interface EuiccChannelManagerFactory {
fun createEuiccChannelManager(serviceContext: Service): EuiccChannelManager
}

View file

@ -4,11 +4,13 @@ import android.telephony.SubscriptionManager
import android.telephony.TelephonyManager
import im.angry.openeuicc.core.EuiccChannelFactory
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.core.EuiccChannelManagerFactory
import im.angry.openeuicc.util.*
interface AppContainer {
val telephonyManager: TelephonyManager
val euiccChannelManager: EuiccChannelManager
val euiccChannelManagerFactory: EuiccChannelManagerFactory
val subscriptionManager: SubscriptionManager
val preferenceRepository: PreferenceRepository
val uiComponentFactory: UiComponentFactory

View file

@ -5,7 +5,9 @@ import android.telephony.SubscriptionManager
import android.telephony.TelephonyManager
import im.angry.openeuicc.core.DefaultEuiccChannelFactory
import im.angry.openeuicc.core.DefaultEuiccChannelManager
import im.angry.openeuicc.core.DefaultEuiccChannelManagerFactory
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.core.EuiccChannelManagerFactory
import im.angry.openeuicc.util.*
open class DefaultAppContainer(context: Context) : AppContainer {
@ -17,6 +19,10 @@ open class DefaultAppContainer(context: Context) : AppContainer {
DefaultEuiccChannelManager(this, context)
}
override val euiccChannelManagerFactory: EuiccChannelManagerFactory by lazy {
DefaultEuiccChannelManagerFactory(this)
}
override val subscriptionManager by lazy {
context.getSystemService(SubscriptionManager::class.java)!!
}

View file

@ -0,0 +1,41 @@
package im.angry.openeuicc.service
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
/**
* An Android Service wrapper for EuiccChannelManager.
* The purpose of this wrapper is mainly lifecycle-wise: having a Service allows the manager
* instance to have its own independent lifecycle. This way it can be created as requested and
* destroyed when no other components are bound to this service anymore.
* This behavior allows us to avoid keeping the APDU channels open at all times. For example,
* the EuiccService implementation should *only* bind to this service when it requires an
* 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.
*/
class EuiccChannelManagerService : Service(), OpenEuiccContextMarker {
inner class LocalBinder : Binder() {
val service = this@EuiccChannelManagerService
}
private val euiccChannelManagerDelegate = lazy {
appContainer.euiccChannelManagerFactory.createEuiccChannelManager(this)
}
val euiccChannelManager: EuiccChannelManager by euiccChannelManagerDelegate
override fun onBind(intent: Intent?): IBinder = 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()
}
}
}

View file

@ -0,0 +1,48 @@
package im.angry.openeuicc.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import androidx.appcompat.app.AppCompatActivity
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.service.EuiccChannelManagerService
abstract class BaseEuiccAccessActivity : AppCompatActivity() {
lateinit var euiccChannelManager: EuiccChannelManager
private val euiccChannelManagerServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
euiccChannelManager =
(service!! as EuiccChannelManagerService.LocalBinder).service.euiccChannelManager
onInit()
}
override fun onServiceDisconnected(name: ComponentName?) {
// These activities should never lose the EuiccChannelManagerService connection
finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bindService(
Intent(this, EuiccChannelManagerService::class.java),
euiccChannelManagerServiceConnection,
Context.BIND_AUTO_CREATE
)
}
override fun onDestroy() {
super.onDestroy()
unbindService(euiccChannelManagerServiceConnection)
}
/**
* When called, euiccChannelManager is guaranteed to have been initialized
*/
abstract fun onInit()
}

View file

@ -1,16 +1,13 @@
package im.angry.openeuicc.ui
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DirectProfileDownloadActivity : AppCompatActivity(), SlotSelectFragment.SlotSelectedListener, OpenEuiccContextMarker {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
class DirectProfileDownloadActivity : BaseEuiccAccessActivity(), SlotSelectFragment.SlotSelectedListener, OpenEuiccContextMarker {
override fun onInit() {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
euiccChannelManager.enumerateEuiccChannels()

View file

@ -10,16 +10,14 @@ import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Spinner
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import im.angry.openeuicc.common.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
open class MainActivity : AppCompatActivity(), OpenEuiccContextMarker {
open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
companion object {
const val TAG = "MainActivity"
}
@ -45,10 +43,6 @@ open class MainActivity : AppCompatActivity(), OpenEuiccContextMarker {
tm = telephonyManager
spinnerAdapter = ArrayAdapter<String>(this, R.layout.spinner_item)
lifecycleScope.launch {
init()
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@ -94,6 +88,12 @@ open class MainActivity : AppCompatActivity(), OpenEuiccContextMarker {
else -> super.onOptionsItemSelected(item)
}
override fun onInit() {
lifecycleScope.launch {
init()
}
}
private suspend fun init() {
withContext(Dispatchers.IO) {
euiccChannelManager.enumerateEuiccChannels()

View file

@ -12,7 +12,6 @@ import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.forEach
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
@ -27,7 +26,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.LocalProfileNotification
class NotificationsActivity: AppCompatActivity(), OpenEuiccContextMarker {
class NotificationsActivity: BaseEuiccAccessActivity(), OpenEuiccContextMarker {
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var notificationList: RecyclerView
private val notificationAdapter = NotificationAdapter()
@ -39,7 +38,9 @@ class NotificationsActivity: AppCompatActivity(), OpenEuiccContextMarker {
setContentView(R.layout.activity_notifications)
setSupportActionBar(requireViewById(R.id.toolbar))
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
}
override fun onInit() {
euiccChannel = euiccChannelManager
.findEuiccChannelBySlotBlocking(intent.getIntExtra("logicalSlotId", 0))!!

View file

@ -29,7 +29,8 @@ class SlotSelectFragment : BaseMaterialDialogFragment(), OpenEuiccContextMarker
private lateinit var toolbar: Toolbar
private lateinit var spinner: Spinner
private val channels: List<EuiccChannel> by lazy {
euiccChannelManager.knownChannels.sortedBy { it.logicalSlotId }
(requireActivity() as BaseEuiccAccessActivity).euiccChannelManager
.knownChannels.sortedBy { it.logicalSlotId }
}
override fun onCreateView(

View file

@ -4,9 +4,10 @@ import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.ui.BaseEuiccAccessActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.LocalProfileAssistant
private const val TAG = "EuiccChannelFragmentUtils"
@ -30,6 +31,8 @@ val <T> T.slotId: Int where T: Fragment, T: EuiccChannelFragmentMarker
val <T> T.portId: Int where T: Fragment, T: EuiccChannelFragmentMarker
get() = requireArguments().getInt("portId")
val <T> T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: EuiccChannelFragmentMarker
get() = (requireActivity() as BaseEuiccAccessActivity).euiccChannelManager
val <T> T.channel: EuiccChannel where T: Fragment, T: EuiccChannelFragmentMarker
get() =
euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!!

View file

@ -7,7 +7,6 @@ import android.telephony.TelephonyManager
import androidx.fragment.app.Fragment
import im.angry.openeuicc.OpenEuiccApplication
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.di.AppContainer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
@ -15,7 +14,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.LocalProfileInfo
import java.lang.RuntimeException
import kotlin.RuntimeException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
@ -52,9 +51,6 @@ interface OpenEuiccContextMarker {
val appContainer: AppContainer
get() = openEuiccApplication.appContainer
val euiccChannelManager: EuiccChannelManager
get() = appContainer.euiccChannelManager
val telephonyManager: TelephonyManager
get() = appContainer.telephonyManager
}

View file

@ -5,7 +5,10 @@ import im.angry.openeuicc.di.AppContainer
import im.angry.openeuicc.util.*
import java.lang.Exception
class PrivilegedEuiccChannelManager(appContainer: AppContainer, context: Context) :
class PrivilegedEuiccChannelManager(
appContainer: AppContainer,
context: Context
) :
DefaultEuiccChannelManager(appContainer, context) {
override val uiccCards: Collection<UiccCardInfoCompat>
get() = tm.uiccCardsInfoCompat

View file

@ -0,0 +1,10 @@
package im.angry.openeuicc.core
import android.app.Service
import im.angry.openeuicc.di.AppContainer
class PrivilegedEuiccChannelManagerFactory(private val appContainer: AppContainer) :
EuiccChannelManagerFactory {
override fun createEuiccChannelManager(serviceContext: Service): EuiccChannelManager =
PrivilegedEuiccChannelManager(appContainer, serviceContext)
}

View file

@ -2,14 +2,20 @@ package im.angry.openeuicc.di
import android.content.Context
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.core.EuiccChannelManagerFactory
import im.angry.openeuicc.core.PrivilegedEuiccChannelFactory
import im.angry.openeuicc.core.PrivilegedEuiccChannelManager
import im.angry.openeuicc.core.PrivilegedEuiccChannelManagerFactory
class PrivilegedAppContainer(context: Context) : DefaultAppContainer(context) {
override val euiccChannelManager: EuiccChannelManager by lazy {
PrivilegedEuiccChannelManager(this, context)
}
override val euiccChannelManagerFactory: EuiccChannelManagerFactory by lazy {
PrivilegedEuiccChannelManagerFactory(this)
}
override val uiComponentFactory by lazy {
PrivilegedUiComponentFactory()
}

View file

@ -1,5 +1,7 @@
package im.angry.openeuicc.service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.service.euicc.*
import android.telephony.UiccSlotMapping
@ -8,7 +10,9 @@ import android.telephony.euicc.EuiccInfo
import android.util.Log
import net.typeblog.lpac_jni.LocalProfileInfo
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.runBlocking
import java.lang.IllegalStateException
class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
@ -31,17 +35,51 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
telephonyManager.uiccCardsInfoCompat.firstOrNull { it.isEuicc }?.physicalSlotIndex == physicalSlotId
}
private fun findChannel(physicalSlotId: Int): EuiccChannel? =
euiccChannelManager.findEuiccChannelByPhysicalSlotBlocking(physicalSlotId)
private data class EuiccChannelManagerContext(
val euiccChannelManager: EuiccChannelManager
) {
fun findChannel(physicalSlotId: Int): EuiccChannel? =
euiccChannelManager.findEuiccChannelByPhysicalSlotBlocking(physicalSlotId)
private fun findChannel(slotId: Int, portId: Int): EuiccChannel? =
euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)
fun findChannel(slotId: Int, portId: Int): EuiccChannel? =
euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)
private fun findAllChannels(physicalSlotId: Int): List<EuiccChannel>? =
euiccChannelManager.findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId)
fun findAllChannels(physicalSlotId: Int): List<EuiccChannel>? =
euiccChannelManager.findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId)
}
override fun onGetEid(slotId: Int): String? =
/**
* Bind to EuiccChannelManagerService, run the callback with a EuiccChannelManager instance,
* and then unbind after the callback is finished. All methods in this class that require access
* to a EuiccChannelManager should be wrapped inside this call.
*
* This ensures that we only spawn and connect to APDU channels when we absolutely need to,
* instead of keeping them open unnecessarily in the background at all times.
*/
private inline fun <T> withEuiccChannelManager(fn: EuiccChannelManagerContext.() -> T): T {
val (binder, unbind) = runBlocking {
bindServiceSuspended(
Intent(
this@OpenEuiccService,
EuiccChannelManagerService::class.java
), Context.BIND_AUTO_CREATE
)
}
if (binder == null) {
throw RuntimeException("Unable to bind to EuiccChannelManagerService; aborting")
}
val ret =
EuiccChannelManagerContext((binder as EuiccChannelManagerService.LocalBinder).service.euiccChannelManager).fn()
unbind()
return ret
}
override fun onGetEid(slotId: Int): String? = withEuiccChannelManager {
findChannel(slotId)?.lpa?.eID
}
// When two eSIM cards are present on one device, the Android settings UI
// gets confused and sets the incorrect slotId for profiles from one of
@ -124,7 +162,7 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
return GetDefaultDownloadableSubscriptionListResult(RESULT_OK, arrayOf())
}
override fun onGetEuiccProfileInfoList(slotId: Int): GetEuiccProfileInfoListResult {
override fun onGetEuiccProfileInfoList(slotId: Int): GetEuiccProfileInfoListResult = withEuiccChannelManager {
Log.i(TAG, "onGetEuiccProfileInfoList slotId=$slotId")
if (shouldIgnoreSlot(slotId)) {
Log.i(TAG, "ignoring slot $slotId")
@ -165,7 +203,7 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
return EuiccInfo("Unknown") // TODO: Can we actually implement this?
}
override fun onDeleteSubscription(slotId: Int, iccid: String): Int {
override fun onDeleteSubscription(slotId: Int, iccid: String): Int = withEuiccChannelManager {
Log.i(TAG, "onDeleteSubscription slotId=$slotId iccid=$iccid")
if (shouldIgnoreSlot(slotId)) return RESULT_FIRST_USER
@ -212,7 +250,7 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
portIndex: Int,
iccid: String?,
forceDeactivateSim: Boolean
): Int {
): Int = withEuiccChannelManager {
Log.i(TAG,"onSwitchToSubscriptionWithPort slotId=$slotId portIndex=$portIndex iccid=$iccid forceDeactivateSim=$forceDeactivateSim")
if (shouldIgnoreSlot(slotId)) return RESULT_FIRST_USER
@ -264,22 +302,26 @@ class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
}
}
override fun onUpdateSubscriptionNickname(slotId: Int, iccid: String, nickname: String?): Int {
Log.i(TAG, "onUpdateSubscriptionNickname slotId=$slotId iccid=$iccid nickname=$nickname")
if (shouldIgnoreSlot(slotId)) return RESULT_FIRST_USER
val channel = findChannel(slotId) ?: return RESULT_FIRST_USER
if (!channel.profileExists(iccid)) {
return RESULT_FIRST_USER
override fun onUpdateSubscriptionNickname(slotId: Int, iccid: String, nickname: String?): Int =
withEuiccChannelManager {
Log.i(
TAG,
"onUpdateSubscriptionNickname slotId=$slotId iccid=$iccid nickname=$nickname"
)
if (shouldIgnoreSlot(slotId)) return RESULT_FIRST_USER
val channel = findChannel(slotId) ?: return RESULT_FIRST_USER
if (!channel.profileExists(iccid)) {
return RESULT_FIRST_USER
}
val success = channel.lpa
.setNickname(iccid, nickname!!)
appContainer.subscriptionManager.tryRefreshCachedEuiccInfo(channel.cardId)
return if (success) {
RESULT_OK
} else {
RESULT_FIRST_USER
}
}
val success = channel.lpa
.setNickname(iccid, nickname!!)
appContainer.subscriptionManager.tryRefreshCachedEuiccInfo(channel.cardId)
return if (success) {
RESULT_OK
} else {
RESULT_FIRST_USER
}
}
@Deprecated("Deprecated in Java")
override fun onEraseSubscriptions(slotId: Int): Int {

View file

@ -96,14 +96,17 @@ class SlotMappingFragment: BaseMaterialDialogFragment(),
withContext(Dispatchers.IO) {
// Use the utility method from PrivilegedTelephonyUtils to ensure
// unmapped ports have all profiles disabled
telephonyManager.updateSimSlotMapping(euiccChannelManager, adapter.mappings)
telephonyManager.updateSimSlotMapping(
(requireActivity() as BaseEuiccAccessActivity).euiccChannelManager,
adapter.mappings
)
}
} catch (e: Exception) {
Toast.makeText(requireContext(), R.string.slot_mapping_failure, Toast.LENGTH_LONG).show()
return@launch
}
Toast.makeText(requireContext(), R.string.slot_mapping_completed, Toast.LENGTH_LONG).show()
euiccChannelManager.invalidate()
(requireActivity() as BaseEuiccAccessActivity).euiccChannelManager.invalidate()
requireActivity().finish()
}
}

View file

@ -0,0 +1,27 @@
package im.angry.openeuicc.util
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
suspend fun Context.bindServiceSuspended(intent: Intent, flags: Int): Pair<IBinder?, () -> Unit> =
suspendCoroutine { cont ->
var binder: IBinder?
val conn = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
binder = service
cont.resume(Pair(binder) { unbindService(this) })
}
override fun onServiceDisconnected(name: ComponentName?) {
}
}
bindService(intent, flags, Executors.newSingleThreadExecutor(), conn)
}