OpenEUICC/app/src/main/java/im/angry/openeuicc/service/OpenEuiccService.kt
septs 960f8855ad
Some checks failed
/ build-debug (push) Has been cancelled
refactor: simplify profile operation (#129)
Co-authored-by: Peter Cai <peter@typeblog.net>
Reviewed-on: #129
Co-authored-by: septs <github@septs.pw>
Co-committed-by: septs <github@septs.pw>
2024-12-19 00:18:59 +01:00

412 lines
16 KiB
Kotlin

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
import android.telephony.euicc.DownloadableSubscription
import android.telephony.euicc.EuiccInfo
import android.util.Log
import net.typeblog.lpac_jni.LocalProfileInfo
import im.angry.openeuicc.core.EuiccChannelManager
import im.angry.openeuicc.service.EuiccChannelManagerService.Companion.waitDone
import im.angry.openeuicc.util.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlin.IllegalStateException
class OpenEuiccService : EuiccService(), OpenEuiccContextMarker {
companion object {
const val TAG = "OpenEuiccService"
}
private val hasInternalEuicc by lazy {
telephonyManager.uiccCardsInfoCompat.any { it.isEuicc && !it.isRemovable }
}
// TODO: Should this be configurable?
private fun shouldIgnoreSlot(physicalSlotId: Int) =
if (hasInternalEuicc) {
// For devices with an internal eUICC slot, ignore any removable UICC
telephonyManager.uiccCardsInfoCompat.find { it.physicalSlotIndex == physicalSlotId }!!.isRemovable
} else {
// Otherwise, we can report at least one removable eUICC to the system without confusing
// it too much.
telephonyManager.uiccCardsInfoCompat.firstOrNull { it.isEuicc }?.physicalSlotIndex == physicalSlotId
}
private data class EuiccChannelManagerContext(
val euiccChannelManagerService: EuiccChannelManagerService
) {
val euiccChannelManager
get() = euiccChannelManagerService.euiccChannelManager
}
/**
* 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.
*
* This function cannot be inline because non-local returns may bypass the unbind
*/
private fun <T> withEuiccChannelManager(fn: suspend 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 localBinder = binder as EuiccChannelManagerService.LocalBinder
val ret = runBlocking {
EuiccChannelManagerContext(localBinder.service).fn()
}
unbind()
return ret
}
override fun onGetEid(slotId: Int): String? = withEuiccChannelManager {
val portId = euiccChannelManager.findFirstAvailablePort(slotId)
if (portId < 0) return@withEuiccChannelManager null
euiccChannelManager.withEuiccChannel(slotId, portId) { channel ->
channel.lpa.eID
}
}
private fun ensurePortIsMapped(slotId: Int, portId: Int) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return
}
val mappings = telephonyManager.simSlotMapping.toMutableList()
mappings.firstOrNull { it.physicalSlotIndex == slotId && it.portIndex == portId }?.let {
throw IllegalStateException("Slot $slotId port $portId has already been mapped")
}
val idx = mappings.indexOfFirst { it.physicalSlotIndex != slotId || it.portIndex != portId }
if (idx >= 0) {
mappings[idx] = UiccSlotMapping(portId, slotId, mappings[idx].logicalSlotIndex)
}
mappings.firstOrNull { it.physicalSlotIndex == slotId && it.portIndex == portId } ?: run {
throw IllegalStateException("Cannot map slot $slotId port $portId")
}
try {
telephonyManager.simSlotMapping = mappings
return
} catch (_: Exception) {
// ignore
}
// Sometimes hardware supports one ordering but not the reverse
telephonyManager.simSlotMapping = mappings.reversed()
}
private suspend fun <T> retryWithTimeout(
timeoutMillis: Int,
backoff: Int = 1000,
f: suspend () -> T?
): T? {
val startTimeMillis = System.currentTimeMillis()
do {
try {
f()?.let { return@retryWithTimeout it }
} catch (_: Exception) {
// Ignore
} finally {
delay(backoff.toLong())
}
} while (System.currentTimeMillis() - startTimeMillis < timeoutMillis)
return null
}
override fun onGetOtaStatus(slotId: Int): Int {
// Not implemented
return 5 // EUICC_OTA_STATUS_UNAVAILABLE
}
override fun onStartOtaIfNecessary(
slotId: Int,
statusChangedCallback: OtaStatusChangedCallback?
) {
// Not implemented
}
override fun onGetDownloadableSubscriptionMetadata(
slotId: Int,
subscription: DownloadableSubscription?,
forceDeactivateSim: Boolean
): GetDownloadableSubscriptionMetadataResult {
// Stub: return as-is and do not fetch anything
// This is incompatible with carrier eSIM apps; should we make it compatible?
return GetDownloadableSubscriptionMetadataResult(RESULT_OK, subscription)
}
override fun onGetDefaultDownloadableSubscriptionList(
slotId: Int,
forceDeactivateSim: Boolean
): GetDefaultDownloadableSubscriptionListResult {
// Stub: we do not implement this (as this would require phoning in a central GSMA server)
return GetDefaultDownloadableSubscriptionListResult(RESULT_OK, arrayOf())
}
override fun onGetEuiccProfileInfoList(slotId: Int): GetEuiccProfileInfoListResult = withEuiccChannelManager {
Log.i(TAG, "onGetEuiccProfileInfoList slotId=$slotId")
if (slotId == -1 || shouldIgnoreSlot(slotId)) {
Log.i(TAG, "ignoring slot $slotId")
return@withEuiccChannelManager GetEuiccProfileInfoListResult(
RESULT_FIRST_USER,
arrayOf(),
true
)
}
// TODO: Temporarily enable the slot to access its profiles if it is currently unmapped
val port = euiccChannelManager.findFirstAvailablePort(slotId)
if (port == -1) {
return@withEuiccChannelManager GetEuiccProfileInfoListResult(
RESULT_FIRST_USER,
arrayOf(),
true
)
}
return@withEuiccChannelManager try {
euiccChannelManager.withEuiccChannel(slotId, port) { channel ->
val filteredProfiles =
if (preferenceRepository.unfilteredProfileListFlow.first())
channel.lpa.profiles
else
channel.lpa.profiles.operational
val profiles = filteredProfiles.map {
EuiccProfileInfo.Builder(it.iccid).apply {
setProfileName(it.name)
setNickname(it.displayName)
setServiceProviderName(it.providerName)
setState(
when (it.state) {
LocalProfileInfo.State.Enabled -> EuiccProfileInfo.PROFILE_STATE_ENABLED
LocalProfileInfo.State.Disabled -> EuiccProfileInfo.PROFILE_STATE_DISABLED
}
)
setProfileClass(
when (it.profileClass) {
LocalProfileInfo.Clazz.Testing -> EuiccProfileInfo.PROFILE_CLASS_TESTING
LocalProfileInfo.Clazz.Provisioning -> EuiccProfileInfo.PROFILE_CLASS_PROVISIONING
LocalProfileInfo.Clazz.Operational -> EuiccProfileInfo.PROFILE_CLASS_OPERATIONAL
}
)
}.build()
}
GetEuiccProfileInfoListResult(
RESULT_OK,
profiles.toTypedArray(),
channel.port.card.isRemovable
)
}
} catch (e: EuiccChannelManager.EuiccChannelNotFoundException) {
GetEuiccProfileInfoListResult(
RESULT_FIRST_USER,
arrayOf(),
true
)
}
}
override fun onGetEuiccInfo(slotId: Int): EuiccInfo {
return EuiccInfo("Unknown") // TODO: Can we actually implement this?
}
override fun onDeleteSubscription(slotId: Int, iccid: String): Int = withEuiccChannelManager {
Log.i(TAG, "onDeleteSubscription slotId=$slotId iccid=$iccid")
if (shouldIgnoreSlot(slotId)) return@withEuiccChannelManager RESULT_FIRST_USER
val ports = euiccChannelManager.findAvailablePorts(slotId)
if (ports.isEmpty()) return@withEuiccChannelManager RESULT_FIRST_USER
// Check that the profile has been disabled on all slots
val enabledAnywhere = ports.any { port ->
euiccChannelManager.withEuiccChannel(slotId, port) { channel ->
channel.lpa.profiles.enabled?.iccid == iccid
}
}
if (enabledAnywhere) return@withEuiccChannelManager RESULT_FIRST_USER
euiccChannelManagerService.waitForForegroundTask()
val success = euiccChannelManagerService.launchProfileDeleteTask(slotId, ports[0], iccid)
.waitDone() == null
return@withEuiccChannelManager if (success) {
RESULT_OK
} else {
RESULT_FIRST_USER
}
}
@Deprecated("Deprecated in Java")
override fun onSwitchToSubscription(
slotId: Int,
iccid: String?,
forceDeactivateSim: Boolean
): Int =
// -1 = any port
onSwitchToSubscriptionWithPort(slotId, -1, iccid, forceDeactivateSim)
override fun onSwitchToSubscriptionWithPort(
slotId: Int,
portIndex: Int,
iccid: String?,
forceDeactivateSim: Boolean
): Int = withEuiccChannelManager {
Log.i(TAG,"onSwitchToSubscriptionWithPort slotId=$slotId portIndex=$portIndex iccid=$iccid forceDeactivateSim=$forceDeactivateSim")
if (shouldIgnoreSlot(slotId)) return@withEuiccChannelManager RESULT_FIRST_USER
try {
// First, try to find a pair of slotId and portId we can use for the switching operation
// retryWithTimeout is needed here because this function may be called just after
// AOSP has switched slot mappings, in which case the slots may not be ready yet.
val (foundSlotId, foundPortId) = retryWithTimeout(5000) {
if (portIndex == -1) {
// If port is not indicated, we can use any port
val port = euiccChannelManager.findFirstAvailablePort(slotId).let {
if (it < 0) {
throw IllegalStateException("No mapped port available; may need to try again")
}
it
}
Pair(slotId, port)
} else {
// Else, check until the indicated port is available
euiccChannelManager.withEuiccChannel(slotId, portIndex) { channel ->
if (!channel.valid) {
throw IllegalStateException("Indicated slot / port combination is unavailable; may need to try again")
}
}
Pair(slotId, portIndex)
}
} ?: run {
// Failure case: mapped slots / ports aren't usable per constraints
// If we can't find a usable slot / port already mapped, and we aren't allowed to
// deactivate a SIM, we can only abort
if (!forceDeactivateSim) {
return@withEuiccChannelManager RESULT_MUST_DEACTIVATE_SIM
}
// If port ID is not indicated, we just try to map port 0
// This is because in order to get here, we have to have failed findFirstAvailablePort(),
// which means no eUICC port is mapped or connected properly whatsoever.
val foundPortId = if (portIndex == -1) {
0
} else {
portIndex
}
// Now we can try to map an unused port
try {
ensurePortIsMapped(slotId, foundPortId)
} catch (_: Exception) {
return@withEuiccChannelManager RESULT_FIRST_USER
}
// Wait for availability again
retryWithTimeout(5000) {
euiccChannelManager.withEuiccChannel(slotId, foundPortId) { channel ->
if (!channel.valid) {
throw IllegalStateException("Indicated slot / port combination is unavailable; may need to try again")
}
}
} ?: return@withEuiccChannelManager RESULT_FIRST_USER
Pair(slotId, foundPortId)
}
Log.i(TAG, "Found slotId=$foundSlotId, portId=$foundPortId for switching")
// Now, figure out what they want us to do: disabling a profile, or enabling a new one?
val (foundIccid, enable) = if (iccid == null) {
// iccid == null means disabling
val foundIccid =
euiccChannelManager.withEuiccChannel(foundSlotId, foundPortId) { channel ->
channel.lpa.profiles.enabled?.iccid
} ?: return@withEuiccChannelManager RESULT_FIRST_USER
Pair(foundIccid, false)
} else {
Pair(iccid, true)
}
val res = euiccChannelManagerService.launchProfileSwitchTask(
foundSlotId,
foundPortId,
foundIccid,
enable,
30 * 1000
).waitDone()
if (res != null) return@withEuiccChannelManager RESULT_FIRST_USER
return@withEuiccChannelManager RESULT_OK
} catch (e: Exception) {
return@withEuiccChannelManager RESULT_FIRST_USER
} finally {
euiccChannelManager.invalidate()
}
}
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@withEuiccChannelManager RESULT_FIRST_USER
val port = euiccChannelManager.findFirstAvailablePort(slotId)
if (port < 0) {
return@withEuiccChannelManager RESULT_FIRST_USER
}
euiccChannelManagerService.waitForForegroundTask()
val success =
(euiccChannelManagerService.launchProfileRenameTask(slotId, port, iccid, nickname!!)
.waitDone()) == null
euiccChannelManager.withEuiccChannel(slotId, port) { channel ->
appContainer.subscriptionManager.tryRefreshCachedEuiccInfo(channel.cardId)
}
return@withEuiccChannelManager if (success) {
RESULT_OK
} else {
RESULT_FIRST_USER
}
}
@Deprecated("Deprecated in Java")
override fun onEraseSubscriptions(slotId: Int): Int {
// No-op
return RESULT_FIRST_USER
}
override fun onRetainSubscriptionsForFactoryReset(slotId: Int): Int {
// No-op -- we do not care
return RESULT_FIRST_USER
}
}