Some checks failed
/ build-debug (push) Has been cancelled
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>
412 lines
16 KiB
Kotlin
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
|
|
}
|
|
}
|