Merge branch 'mep'

This commit is contained in:
Peter Cai 2023-12-28 17:15:26 -05:00
commit bbfffe3abf
31 changed files with 994 additions and 142 deletions

View file

@ -1,24 +1,17 @@
package im.angry.openeuicc.core
import im.angry.openeuicc.util.*
import net.typeblog.lpac_jni.LocalProfileAssistant
// A custom type to avoid compatibility issues with UiccCardInfo / UiccPortInfo
data class EuiccChannelInfo(
val slotId: Int,
val cardId: Int,
val name: String,
val imei: String,
val removable: Boolean
)
abstract class EuiccChannel(
info: EuiccChannelInfo
port: UiccPortInfoCompat
) {
val slotId = info.slotId
val cardId = info.cardId
val name = info.name
val imei = info.imei
val removable = info.removable
val slotId = port.card.physicalSlotIndex // PHYSICAL slot
val logicalSlotId = port.logicalSlotIndex
val portId = port.portIndex
val cardId = port.card.cardId
val removable = port.card.isRemovable
val isMEP = port.card.isMultipleEnabledProfilesSupported
abstract val lpa: LocalProfileAssistant
val valid: Boolean

View file

@ -1,13 +1,13 @@
package im.angry.openeuicc.core
import android.annotation.SuppressLint
import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import android.se.omapi.SEService
import android.telephony.UiccCardInfo
import android.telephony.SubscriptionManager
import android.util.Log
import im.angry.openeuicc.OpenEuiccApplication
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
@ -17,7 +17,6 @@ import java.lang.IllegalArgumentException
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@SuppressLint("MissingPermission") // We rely on ARA-based privileges, not READ_PRIVILEGED_PHONE_STATE
open class EuiccChannelManager(protected val context: Context) {
companion object {
const val TAG = "EuiccChannelManager"
@ -52,29 +51,34 @@ open class EuiccChannelManager(protected val context: Context) {
}
}
protected open fun tryOpenEuiccChannelPrivileged(uiccInfo: UiccCardInfo, channelInfo: EuiccChannelInfo): EuiccChannel? {
protected open fun tryOpenEuiccChannelPrivileged(port: UiccPortInfoCompat): EuiccChannel? {
// No-op when unprivileged
return null
}
protected fun tryOpenEuiccChannelUnprivileged(uiccInfo: UiccCardInfo, channelInfo: EuiccChannelInfo): EuiccChannel? {
Log.i(TAG, "Trying OMAPI for slot ${uiccInfo.slotIndex}")
protected fun tryOpenEuiccChannelUnprivileged(port: UiccPortInfoCompat): EuiccChannel? {
if (port.portIndex != 0) {
Log.w(TAG, "OMAPI channel attempted on non-zero portId, ignoring")
return null
}
Log.i(TAG, "Trying OMAPI for physical slot ${port.card.physicalSlotIndex}")
try {
return OmapiChannel(seService!!, channelInfo)
return OmapiChannel(seService!!, port)
} catch (e: IllegalArgumentException) {
// Failed
Log.w(TAG, "OMAPI APDU interface unavailable for slot ${uiccInfo.slotIndex}.")
Log.w(TAG, "OMAPI APDU interface unavailable for physical slot ${port.card.physicalSlotIndex}.")
}
return null
}
private suspend fun tryOpenEuiccChannel(uiccInfo: UiccCardInfo): EuiccChannel? {
private suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
lock.withLock {
ensureSEService()
val existing = channels.find { it.slotId == uiccInfo.slotIndex }
val existing = channels.find { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex }
if (existing != null) {
if (existing.valid) {
if (existing.valid && port.logicalSlotIndex == existing.logicalSlotId) {
return existing
} else {
existing.close()
@ -82,18 +86,15 @@ open class EuiccChannelManager(protected val context: Context) {
}
}
val channelInfo = EuiccChannelInfo(
uiccInfo.slotIndex,
uiccInfo.cardId,
"SIM ${uiccInfo.slotIndex}",
tm.getImei(uiccInfo.slotIndex) ?: return null,
uiccInfo.isRemovable
)
if (port.logicalSlotIndex == SubscriptionManager.INVALID_SIM_SLOT_INDEX) {
// We can only open channels on ports that are actually enabled
return null
}
var euiccChannel: EuiccChannel? = tryOpenEuiccChannelPrivileged(uiccInfo, channelInfo)
var euiccChannel: EuiccChannel? = tryOpenEuiccChannelPrivileged(port)
if (euiccChannel == null) {
euiccChannel = tryOpenEuiccChannelUnprivileged(uiccInfo, channelInfo)
euiccChannel = tryOpenEuiccChannelUnprivileged(port)
}
if (euiccChannel != null) {
@ -104,16 +105,52 @@ open class EuiccChannelManager(protected val context: Context) {
}
}
private suspend fun findEuiccChannelBySlot(slotId: Int): EuiccChannel? {
return tm.uiccCardsInfo.find { it.slotIndex == slotId }?.let {
tryOpenEuiccChannel(it)
fun findEuiccChannelBySlotBlocking(logicalSlotId: Int): EuiccChannel? =
runBlocking {
if (!checkPrivileges()) return@runBlocking null
withContext(Dispatchers.IO) {
for (card in tm.uiccCardsInfoCompat) {
for (port in card.ports) {
if (port.logicalSlotIndex == logicalSlotId) {
return@withContext tryOpenEuiccChannel(port)
}
}
}
null
}
}
fun findEuiccChannelByPhysicalSlotBlocking(physicalSlotId: Int): EuiccChannel? = runBlocking {
if (!checkPrivileges()) return@runBlocking null
withContext(Dispatchers.IO) {
for (card in tm.uiccCardsInfoCompat) {
if (card.physicalSlotIndex != physicalSlotId) continue
for (port in card.ports) {
tryOpenEuiccChannel(port)?.let { return@withContext it }
}
}
null
}
}
fun findEuiccChannelBySlotBlocking(slotId: Int): EuiccChannel? = runBlocking {
fun findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId: Int): List<EuiccChannel>? = runBlocking {
if (!checkPrivileges()) return@runBlocking null
for (card in tm.uiccCardsInfoCompat) {
if (card.physicalSlotIndex != physicalSlotId) continue
return@runBlocking card.ports.mapNotNull { tryOpenEuiccChannel(it) }
.ifEmpty { null }
}
return@runBlocking null
}
fun findEuiccChannelByPortBlocking(physicalSlotId: Int, portId: Int): EuiccChannel? = runBlocking {
if (!checkPrivileges()) return@runBlocking null
withContext(Dispatchers.IO) {
findEuiccChannelBySlot(slotId)
tm.uiccCardsInfoCompat.find { it.physicalSlotIndex == physicalSlotId }?.let { card ->
card.ports.find { it.portIndex == portId }?.let { tryOpenEuiccChannel(it) }
}
}
}
@ -123,9 +160,11 @@ open class EuiccChannelManager(protected val context: Context) {
withContext(Dispatchers.IO) {
ensureSEService()
for (uiccInfo in tm.uiccCardsInfo) {
if (tryOpenEuiccChannel(uiccInfo) != null) {
Log.d(TAG, "Found eUICC on slot ${uiccInfo.slotIndex}")
for (uiccInfo in tm.uiccCardsInfoCompat) {
for (port in uiccInfo.ports) {
if (tryOpenEuiccChannel(port) != null) {
Log.d(TAG, "Found eUICC on slot ${uiccInfo.physicalSlotIndex} port ${port.portIndex}")
}
}
}
}
@ -146,7 +185,7 @@ open class EuiccChannelManager(protected val context: Context) {
seService = null
}
open fun notifyEuiccProfilesChanged(slotId: Int) {
open fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
// No-op for unprivileged
}
}

View file

@ -3,6 +3,7 @@ package im.angry.openeuicc.core
import android.se.omapi.Channel
import android.se.omapi.SEService
import android.se.omapi.Session
import im.angry.openeuicc.util.UiccPortInfoCompat
import net.typeblog.lpac_jni.ApduInterface
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.impl.HttpInterfaceImpl
@ -10,13 +11,13 @@ import net.typeblog.lpac_jni.impl.LocalProfileAssistantImpl
class OmapiApduInterface(
private val service: SEService,
private val info: EuiccChannelInfo
private val port: UiccPortInfoCompat
): ApduInterface {
private lateinit var session: Session
private lateinit var lastChannel: Channel
override fun connect() {
session = service.getUiccReader(info.slotId + 1).openSession()
session = service.getUiccReader(port.logicalSlotIndex + 1).openSession()
}
override fun disconnect() {
@ -50,9 +51,9 @@ class OmapiApduInterface(
class OmapiChannel(
service: SEService,
info: EuiccChannelInfo,
) : EuiccChannel(info) {
port: UiccPortInfoCompat,
) : EuiccChannel(port) {
override val lpa: LocalProfileAssistant = LocalProfileAssistantImpl(
OmapiApduInterface(service, info),
OmapiApduInterface(service, port),
HttpInterfaceImpl())
}

View file

@ -8,23 +8,26 @@ import im.angry.openeuicc.util.openEuiccApplication
interface EuiccFragmentMarker
fun <T> newInstanceEuicc(clazz: Class<T>, slotId: Int): T where T: Fragment, T: EuiccFragmentMarker {
fun <T> newInstanceEuicc(clazz: Class<T>, slotId: Int, portId: Int): T where T: Fragment, T: EuiccFragmentMarker {
val instance = clazz.newInstance()
instance.arguments = Bundle().apply {
putInt("slotId", slotId)
putInt("portId", portId)
}
return instance
}
val <T> T.slotId: Int where T: Fragment, T: EuiccFragmentMarker
get() = requireArguments().getInt("slotId")
val <T> T.portId: Int where T: Fragment, T: EuiccFragmentMarker
get() = requireArguments().getInt("portId")
val <T> T.euiccChannelManager: EuiccChannelManager where T: Fragment, T: EuiccFragmentMarker
get() = openEuiccApplication.euiccChannelManager
val <T> T.channel: EuiccChannel where T: Fragment, T: EuiccFragmentMarker
get() =
euiccChannelManager.findEuiccChannelBySlotBlocking(slotId)!!
euiccChannelManager.findEuiccChannelByPortBlocking(slotId, portId)!!
interface EuiccProfilesChangedListener {
fun onEuiccProfilesChanged()

View file

@ -8,6 +8,7 @@ import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.PopupMenu
import android.widget.TextView
@ -26,19 +27,19 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.lang.Exception
class EuiccManagementFragment : Fragment(), EuiccFragmentMarker, EuiccProfilesChangedListener {
open class EuiccManagementFragment : Fragment(), EuiccFragmentMarker, EuiccProfilesChangedListener {
companion object {
const val TAG = "EuiccManagementFragment"
fun newInstance(slotId: Int): EuiccManagementFragment =
newInstanceEuicc(EuiccManagementFragment::class.java, slotId)
fun newInstance(slotId: Int, portId: Int): EuiccManagementFragment =
newInstanceEuicc(EuiccManagementFragment::class.java, slotId, portId)
}
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var fab: FloatingActionButton
private lateinit var profileList: RecyclerView
private val adapter = EuiccProfileAdapter(listOf())
private val adapter = EuiccProfileAdapter()
override fun onCreateView(
inflater: LayoutInflater,
@ -62,7 +63,7 @@ class EuiccManagementFragment : Fragment(), EuiccFragmentMarker, EuiccProfilesCh
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
fab.setOnClickListener {
ProfileDownloadFragment.newInstance(slotId)
ProfileDownloadFragment.newInstance(slotId, portId)
.show(childFragmentManager, ProfileDownloadFragment.TAG)
}
}
@ -76,18 +77,21 @@ class EuiccManagementFragment : Fragment(), EuiccFragmentMarker, EuiccProfilesCh
refresh()
}
protected open suspend fun onCreateFooterViews(parent: ViewGroup): List<View> = listOf()
@SuppressLint("NotifyDataSetChanged")
private fun refresh() {
swipeRefresh.isRefreshing = true
lifecycleScope.launch {
val profiles = withContext(Dispatchers.IO) {
euiccChannelManager.notifyEuiccProfilesChanged(slotId)
euiccChannelManager.notifyEuiccProfilesChanged(channel.logicalSlotId)
channel.lpa.profiles
}
withContext(Dispatchers.Main) {
adapter.profiles = profiles.operational
adapter.footerViews = onCreateFooterViews(profileList)
adapter.notifyDataSetChanged()
swipeRefresh.isRefreshing = false
}
@ -130,7 +134,30 @@ class EuiccManagementFragment : Fragment(), EuiccFragmentMarker, EuiccProfilesCh
channel.lpa.disableProfile(iccid)
}
inner class ViewHolder(private val root: View) : RecyclerView.ViewHolder(root) {
sealed class ViewHolder(root: View) : RecyclerView.ViewHolder(root) {
enum class Type(val value: Int) {
PROFILE(0),
FOOTER(1);
companion object {
fun fromInt(value: Int) =
Type.values().first { it.value == value }
}
}
}
inner class FooterViewHolder: ViewHolder(FrameLayout(requireContext())) {
fun attach(view: View) {
view.parent?.let { (it as ViewGroup).removeView(view) }
(itemView as FrameLayout).addView(view)
}
fun detach() {
(itemView as FrameLayout).removeAllViews()
}
}
inner class ProfileViewHolder(private val root: View) : ViewHolder(root) {
private val iccid: TextView = root.findViewById(R.id.iccid)
private val name: TextView = root.findViewById(R.id.name)
private val state: TextView = root.findViewById(R.id.state)
@ -195,12 +222,12 @@ class EuiccManagementFragment : Fragment(), EuiccFragmentMarker, EuiccProfilesCh
true
}
R.id.rename -> {
ProfileRenameFragment.newInstance(slotId, profile.iccid, profile.displayName)
ProfileRenameFragment.newInstance(slotId, portId, profile.iccid, profile.displayName)
.show(childFragmentManager, ProfileRenameFragment.TAG)
true
}
R.id.delete -> {
ProfileDeleteFragment.newInstance(slotId, profile.iccid, profile.displayName)
ProfileDeleteFragment.newInstance(slotId, portId, profile.iccid, profile.displayName)
.show(childFragmentManager, ProfileDeleteFragment.TAG)
true
}
@ -208,16 +235,49 @@ class EuiccManagementFragment : Fragment(), EuiccFragmentMarker, EuiccProfilesCh
}
}
inner class EuiccProfileAdapter(var profiles: List<LocalProfileInfo>) : RecyclerView.Adapter<ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.euicc_profile, parent, false)
return ViewHolder(view)
}
inner class EuiccProfileAdapter : RecyclerView.Adapter<ViewHolder>() {
var profiles: List<LocalProfileInfo> = listOf()
var footerViews: List<View> = listOf()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
when (ViewHolder.Type.fromInt(viewType)) {
ViewHolder.Type.PROFILE -> {
val view = LayoutInflater.from(parent.context).inflate(R.layout.euicc_profile, parent, false)
ProfileViewHolder(view)
}
ViewHolder.Type.FOOTER -> {
FooterViewHolder()
}
}
override fun getItemViewType(position: Int): Int =
when {
position < profiles.size -> {
ViewHolder.Type.PROFILE.value
}
position >= profiles.size && position < profiles.size + footerViews.size -> {
ViewHolder.Type.FOOTER.value
}
else -> -1
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.setProfile(profiles[position])
when (holder) {
is ProfileViewHolder -> {
holder.setProfile(profiles[position])
}
is FooterViewHolder -> {
holder.attach(footerViews[position - profiles.size])
}
}
}
override fun getItemCount(): Int = profiles.size
override fun onViewRecycled(holder: ViewHolder) {
if (holder is FooterViewHolder) {
holder.detach()
}
}
override fun getItemCount(): Int = profiles.size + footerViews.size
}
}

View file

@ -11,6 +11,7 @@ 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.core.EuiccChannelManager
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
@ -72,23 +73,26 @@ open class MainActivity : AppCompatActivity() {
return true
}
protected open fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment =
EuiccManagementFragment.newInstance(channel.slotId, channel.portId)
private suspend fun init() {
withContext(Dispatchers.IO) {
manager.enumerateEuiccChannels()
manager.knownChannels.forEach {
Log.d(TAG, it.name)
Log.d(TAG, "slot ${it.slotId} port ${it.portId}")
Log.d(TAG, it.lpa.eID)
// Request the system to refresh the list of profiles every time we start
// Note that this is currently supposed to be no-op when unprivileged,
// but it could change in the future
manager.notifyEuiccProfilesChanged(it.slotId)
manager.notifyEuiccProfilesChanged(it.logicalSlotId)
}
}
withContext(Dispatchers.Main) {
manager.knownChannels.forEach { channel ->
spinnerAdapter.add(channel.name)
fragments.add(EuiccManagementFragment.newInstance(channel.slotId))
manager.knownChannels.sortedBy { it.logicalSlotId }.forEach { channel ->
spinnerAdapter.add(getString(R.string.channel_name_format, channel.logicalSlotId))
fragments.add(createEuiccManagementFragment(channel))
}
if (fragments.isNotEmpty()) {

View file

@ -16,8 +16,8 @@ class ProfileDeleteFragment : DialogFragment(), EuiccFragmentMarker {
companion object {
const val TAG = "ProfileDeleteFragment"
fun newInstance(slotId: Int, iccid: String, name: String): ProfileDeleteFragment {
val instance = newInstanceEuicc(ProfileDeleteFragment::class.java, slotId)
fun newInstance(slotId: Int, portId: Int, iccid: String, name: String): ProfileDeleteFragment {
val instance = newInstanceEuicc(ProfileDeleteFragment::class.java, slotId, portId)
instance.requireArguments().apply {
putString("iccid", iccid)
putString("name", name)

View file

@ -1,5 +1,6 @@
package im.angry.openeuicc.ui
import android.annotation.SuppressLint
import android.app.Dialog
import android.os.Bundle
import android.text.Editable
@ -16,19 +17,20 @@ import com.google.android.material.textfield.TextInputLayout
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import im.angry.openeuicc.common.R
import im.angry.openeuicc.util.openEuiccApplication
import im.angry.openeuicc.util.setWidthPercent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.typeblog.lpac_jni.ProfileDownloadCallback
import java.lang.Exception
import kotlin.Exception
class ProfileDownloadFragment : DialogFragment(), EuiccFragmentMarker, Toolbar.OnMenuItemClickListener {
companion object {
const val TAG = "ProfileDownloadFragment"
fun newInstance(slotId: Int): ProfileDownloadFragment =
newInstanceEuicc(ProfileDownloadFragment::class.java, slotId)
fun newInstance(slotId: Int, portId: Int): ProfileDownloadFragment =
newInstanceEuicc(ProfileDownloadFragment::class.java, slotId, portId)
}
private lateinit var toolbar: Toolbar
@ -105,9 +107,16 @@ class ProfileDownloadFragment : DialogFragment(), EuiccFragmentMarker, Toolbar.O
setWidthPercent(95)
}
@SuppressLint("MissingPermission")
override fun onStart() {
super.onStart()
profileDownloadIMEI.editText!!.text = Editable.Factory.getInstance().newEditable(channel.imei)
profileDownloadIMEI.editText!!.text = Editable.Factory.getInstance().newEditable(
try {
openEuiccApplication.telephonyManager.getImei(channel.logicalSlotId)
} catch (e: Exception) {
""
}
)
lifecycleScope.launch(Dispatchers.IO) {
// Fetch remaining NVRAM

View file

@ -25,8 +25,8 @@ class ProfileRenameFragment : DialogFragment(), EuiccFragmentMarker {
companion object {
const val TAG = "ProfileRenameFragment"
fun newInstance(slotId: Int, iccid: String, currentName: String): ProfileRenameFragment {
val instance = newInstanceEuicc(ProfileRenameFragment::class.java, slotId)
fun newInstance(slotId: Int, portId: Int, iccid: String, currentName: String): ProfileRenameFragment {
val instance = newInstanceEuicc(ProfileRenameFragment::class.java, slotId, portId)
instance.requireArguments().apply {
putString("iccid", iccid)
putString("currentName", currentName)

View file

@ -1,5 +1,6 @@
package im.angry.openeuicc.util
import net.typeblog.lpac_jni.LocalProfileAssistant
import net.typeblog.lpac_jni.LocalProfileInfo
val LocalProfileInfo.displayName: String
@ -8,4 +9,10 @@ val LocalProfileInfo.displayName: String
val List<LocalProfileInfo>.operational: List<LocalProfileInfo>
get() = filter {
it.profileClass == LocalProfileInfo.Clazz.Operational
}
}
fun LocalProfileAssistant.disableActiveProfileWithUndo(): () -> Unit =
profiles.find { it.state == LocalProfileInfo.State.Enabled }?.let {
disableProfile(it.iccid)
return { enableProfile(it.iccid) }
} ?: { }

View file

@ -0,0 +1,83 @@
package im.angry.openeuicc.util
import android.annotation.SuppressLint
import android.os.Build
import android.telephony.TelephonyManager
import android.telephony.UiccCardInfo
import android.telephony.UiccPortInfo
import im.angry.openeuicc.util.*
import java.lang.RuntimeException
@Suppress("DEPRECATION")
class UiccCardInfoCompat(val inner: UiccCardInfo) {
val physicalSlotIndex: Int
get() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
inner.physicalSlotIndex
} else {
inner.slotIndex
}
val ports: Collection<UiccPortInfoCompat>
get() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
inner.ports.map { UiccPortInfoCompat(it, this) }
} else {
listOf(UiccPortInfoCompat(null, this))
}
val isEuicc: Boolean
get() = inner.isEuicc
val isRemovable: Boolean
get() = inner.isRemovable
val isMultipleEnabledProfilesSupported: Boolean
get() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
inner.isMultipleEnabledProfilesSupported
} else {
false
}
val cardId: Int
get() = inner.cardId
}
class UiccPortInfoCompat(private val _inner: Any?, val card: UiccCardInfoCompat) {
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
check(_inner != null && _inner is UiccPortInfo) {
"_inner is not UiccPortInfo on TIRAMISU"
}
}
}
val inner: UiccPortInfo
get() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
_inner as UiccPortInfo
} else {
throw RuntimeException("UiccPortInfo does not exist before T")
}
val portIndex: Int
get() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
inner.portIndex
} else {
0
}
val logicalSlotIndex: Int
get() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
inner.logicalSlotIndex
} else {
card.physicalSlotIndex // logical is the same as physical below TIRAMISU
}
}
val TelephonyManager.uiccCardsInfoCompat: List<UiccCardInfoCompat>
@SuppressLint("MissingPermission")
get() = uiccCardsInfo.map { UiccCardInfoCompat(it) }

View file

@ -3,6 +3,8 @@
<string name="no_euicc">No eUICC card on this device is accessible by this app.\nInsert a supported eUICC card, or try out the privileged OpenEUICC app instead.</string>
<string name="unknown">Unknown</string>
<string name="channel_name_format">Logical Slot %d</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="provider">Provider:</string>

View file

@ -57,6 +57,8 @@ android {
}
dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
compileOnly project(':libs:hidden-apis-stub')
implementation project(':libs:hidden-apis-shim')
implementation project(':libs:lpac-jni')

View file

@ -1,7 +1,6 @@
package im.angry.openeuicc.core
import android.content.Context
import android.telephony.UiccCardInfo
import android.util.Log
import im.angry.openeuicc.OpenEuiccApplication
import im.angry.openeuicc.util.*
@ -11,21 +10,21 @@ import java.lang.IllegalArgumentException
class PrivilegedEuiccChannelManager(context: Context): EuiccChannelManager(context) {
override fun checkPrivileges() = true // TODO: Implement proper system app check
override fun tryOpenEuiccChannelPrivileged(uiccInfo: UiccCardInfo, channelInfo: EuiccChannelInfo): EuiccChannel? {
if (uiccInfo.isRemovable) {
override fun tryOpenEuiccChannelPrivileged(port: UiccPortInfoCompat): EuiccChannel? {
if (port.card.isRemovable) {
// Attempt unprivileged (OMAPI) before TelephonyManager
// but still try TelephonyManager in case OMAPI is broken
super.tryOpenEuiccChannelUnprivileged(uiccInfo, channelInfo)?.let { return it }
super.tryOpenEuiccChannelUnprivileged(port)?.let { return it }
}
if (uiccInfo.isEuicc) {
Log.i(TAG, "Trying TelephonyManager for slot ${uiccInfo.slotIndex}")
if (port.card.isEuicc) {
Log.i(TAG, "Trying TelephonyManager for slot ${port.card.physicalSlotIndex} port ${port.portIndex}")
// TODO: On Tiramisu, we should also connect all available "ports" for MEP support
try {
return TelephonyManagerChannel(channelInfo, tm)
return TelephonyManagerChannel(port, tm)
} catch (e: IllegalArgumentException) {
// Failed
Log.w(TAG, "TelephonyManager APDU interface unavailable for slot ${uiccInfo.slotIndex}, falling back")
Log.w(TAG, "TelephonyManager APDU interface unavailable for slot ${port.card.physicalSlotIndex} port ${port.portIndex}, falling back")
}
}
return null
@ -46,9 +45,9 @@ class PrivilegedEuiccChannelManager(context: Context): EuiccChannelManager(conte
}
}
override fun notifyEuiccProfilesChanged(slotId: Int) {
override fun notifyEuiccProfilesChanged(logicalSlotId: Int) {
(context.applicationContext as OpenEuiccApplication).subscriptionManager.apply {
findEuiccChannelBySlotBlocking(slotId)?.let {
findEuiccChannelBySlotBlocking(logicalSlotId)?.let {
tryRefreshCachedEuiccInfo(it.cardId)
}
}

View file

@ -9,7 +9,7 @@ import net.typeblog.lpac_jni.impl.HttpInterfaceImpl
import net.typeblog.lpac_jni.impl.LocalProfileAssistantImpl
class TelephonyManagerApduInterface(
private val info: EuiccChannelInfo,
private val port: UiccPortInfoCompat,
private val tm: TelephonyManager
): ApduInterface {
private var lastChannel: Int = -1
@ -25,9 +25,9 @@ class TelephonyManagerApduInterface(
override fun logicalChannelOpen(aid: ByteArray): Int {
check(lastChannel == -1) { "Already initialized" }
val hex = aid.encodeHex()
val channel = tm.iccOpenLogicalChannelBySlot(info.slotId, hex, 0)
val channel = tm.iccOpenLogicalChannelByPortCompat(port.card.physicalSlotIndex, port.portIndex, hex, 0)
if (channel.status != IccOpenLogicalChannelResponse.STATUS_NO_ERROR || channel.channel == IccOpenLogicalChannelResponse.INVALID_CHANNEL) {
throw IllegalArgumentException("Cannot open logical channel " + hex + " via TelephonManager on slot " + info.slotId);
throw IllegalArgumentException("Cannot open logical channel $hex via TelephonManager on slot ${port.card.physicalSlotIndex} port ${port.portIndex}");
}
lastChannel = channel.channel
return lastChannel
@ -35,7 +35,7 @@ class TelephonyManagerApduInterface(
override fun logicalChannelClose(handle: Int) {
check(handle == lastChannel) { "Invalid channel handle " }
tm.iccCloseLogicalChannelBySlot(info.slotId, handle)
tm.iccCloseLogicalChannelByPortCompat(port.card.physicalSlotIndex, port.portIndex, handle)
lastChannel = -1
}
@ -49,18 +49,18 @@ class TelephonyManagerApduInterface(
val p3 = tx[4].toUByte().toInt()
val p4 = tx.drop(5).toByteArray().encodeHex()
return tm.iccTransmitApduLogicalChannelBySlot(info.slotId, lastChannel,
return tm.iccTransmitApduLogicalChannelByPortCompat(port.card.physicalSlotIndex, port.portIndex, lastChannel,
cla, instruction, p1, p2, p3, p4)?.decodeHex() ?: byteArrayOf()
}
}
class TelephonyManagerChannel(
info: EuiccChannelInfo,
port: UiccPortInfoCompat,
private val tm: TelephonyManager
) : EuiccChannel(info) {
) : EuiccChannel(port) {
override val lpa: LocalProfileAssistant = LocalProfileAssistantImpl(
TelephonyManagerApduInterface(info, tm),
TelephonyManagerApduInterface(port, tm),
HttpInterfaceImpl()
)
}

View file

@ -1,20 +1,35 @@
package im.angry.openeuicc.service
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.OpenEuiccApplication
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.util.*
import java.lang.IllegalStateException
class OpenEuiccService : EuiccService() {
companion object {
const val TAG = "OpenEuiccService"
}
private val openEuiccApplication
get() = application as OpenEuiccApplication
private fun findChannel(slotId: Int): EuiccChannel? =
private fun findChannel(physicalSlotId: Int): EuiccChannel? =
openEuiccApplication.euiccChannelManager
.findEuiccChannelBySlotBlocking(slotId)
.findEuiccChannelByPhysicalSlotBlocking(physicalSlotId)
private fun findChannel(slotId: Int, portId: Int): EuiccChannel? =
openEuiccApplication.euiccChannelManager
.findEuiccChannelByPortBlocking(slotId, portId)
private fun findAllChannels(physicalSlotId: Int): List<EuiccChannel>? =
openEuiccApplication.euiccChannelManager
.findAllEuiccChannelsByPhysicalSlotBlocking(physicalSlotId)
override fun onGetEid(slotId: Int): String? =
findChannel(slotId)?.lpa?.eID
@ -25,6 +40,47 @@ class OpenEuiccService : EuiccService() {
private fun EuiccChannel.profileExists(iccid: String?) =
lpa.profiles.any { it.iccid == iccid }
private fun ensurePortIsMapped(slotId: Int, portId: Int) {
val mappings = openEuiccApplication.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 {
openEuiccApplication.telephonyManager.simSlotMapping = mappings
return
} catch (_: Exception) {
}
// Sometimes hardware supports one ordering but not the reverse
openEuiccApplication.telephonyManager.simSlotMapping = mappings.reversed()
}
private fun <T> retryWithTimeout(timeoutMillis: Int, backoff: Int = 1000, f: () -> T?): T? {
val startTimeMillis = System.currentTimeMillis()
do {
try {
f()?.let { return@retryWithTimeout it }
} catch (_: Exception) {
// Ignore
} finally {
Thread.sleep(backoff.toLong())
}
} while (System.currentTimeMillis() - startTimeMillis < timeoutMillis)
return null
}
override fun onGetOtaStatus(slotId: Int): Int {
// Not implemented
return 5 // EUICC_OTA_STATUS_UNAVAILABLE
@ -56,6 +112,7 @@ class OpenEuiccService : EuiccService() {
}
override fun onGetEuiccProfileInfoList(slotId: Int): GetEuiccProfileInfoListResult? {
Log.i(TAG, "onGetEuiccProfileInfoList slotId=$slotId")
val channel = findChannel(slotId) ?: return null
val profiles = channel.lpa.profiles.operational.map {
EuiccProfileInfo.Builder(it.iccid).apply {
@ -86,23 +143,27 @@ class OpenEuiccService : EuiccService() {
}
override fun onDeleteSubscription(slotId: Int, iccid: String): Int {
Log.i(TAG, "onDeleteSubscription slotId=$slotId iccid=$iccid")
try {
val channel = findChannel(slotId) ?: return RESULT_FIRST_USER
val channels = findAllChannels(slotId) ?: return RESULT_FIRST_USER
if (!channel.profileExists(iccid)) {
if (!channels[0].profileExists(iccid)) {
return RESULT_FIRST_USER
}
val profile = channel.lpa.profiles.find {
it.iccid == iccid
} ?: return RESULT_FIRST_USER
// If the profile is enabled by ANY channel (port), we cannot delete it
channels.forEach { channel ->
val profile = channel.lpa.profiles.find {
it.iccid == iccid
} ?: return RESULT_FIRST_USER
if (profile.state == LocalProfileInfo.State.Enabled) {
// Must disable the profile first
return RESULT_FIRST_USER
if (profile.state == LocalProfileInfo.State.Enabled) {
// Must disable the profile first
return RESULT_FIRST_USER
}
}
return if (channel.lpa.deleteProfile(iccid)) {
return if (channels[0].lpa.deleteProfile(iccid)) {
RESULT_OK
} else {
RESULT_FIRST_USER
@ -112,40 +173,63 @@ class OpenEuiccService : EuiccService() {
}
}
// TODO: on some devices we need to update the mapping (and potentially disable a pSIM)
// for eSIM to be usable, in which case we will have to respect forceDeactivateSim.
// This is the same for our custom LUI. Both have to take this into consideration.
@Deprecated("Deprecated in Java")
override fun onSwitchToSubscription(
slotId: Int,
iccid: String?,
forceDeactivateSim: Boolean
): Int {
try {
val channel = findChannel(slotId) ?: return RESULT_FIRST_USER
): Int =
// -1 = any port
onSwitchToSubscriptionWithPort(slotId, -1, iccid, forceDeactivateSim)
if (!channel.profileExists(iccid)) {
override fun onSwitchToSubscriptionWithPort(
slotId: Int,
portIndex: Int,
iccid: String?,
forceDeactivateSim: Boolean
): Int {
Log.i(TAG,"onSwitchToSubscriptionWithPort slotId=$slotId portIndex=$portIndex iccid=$iccid forceDeactivateSim=$forceDeactivateSim")
try {
// 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 channel = if (portIndex == -1) {
retryWithTimeout(5000) { findChannel(slotId) }
} else {
retryWithTimeout(5000) { findChannel(slotId, portIndex) }
} ?: run {
if (!forceDeactivateSim) {
// The user must select which SIM to deactivate
return@onSwitchToSubscriptionWithPort RESULT_MUST_DEACTIVATE_SIM
} else {
try {
// If we are allowed to deactivate any SIM we like, try mapping the indicated port first
ensurePortIsMapped(slotId, portIndex)
retryWithTimeout(5000) { findChannel(slotId, portIndex) }
} catch (e: Exception) {
// We cannot map the port (or it is already mapped)
// but we can also use any port available on the card
retryWithTimeout(5000) { findChannel(slotId) }
} ?: return@onSwitchToSubscriptionWithPort RESULT_FIRST_USER
}
}
if (iccid != null && !channel.profileExists(iccid)) {
Log.i(TAG, "onSwitchToSubscriptionWithPort iccid=$iccid not found")
return RESULT_FIRST_USER
}
if (iccid == null) {
// Disable active profile
val activeProfile = channel.lpa.profiles.find {
it.state == LocalProfileInfo.State.Enabled
} ?: return RESULT_OK
// Disable any active profile first if present
channel.lpa.profiles.find {
it.state == LocalProfileInfo.State.Enabled
}?.let { if (!channel.lpa.disableProfile(it.iccid)) return RESULT_FIRST_USER }
return if (channel.lpa.disableProfile(activeProfile.iccid)) {
RESULT_OK
} else {
RESULT_FIRST_USER
}
} else {
return if (channel.lpa.enableProfile(iccid)) {
RESULT_OK
} else {
RESULT_FIRST_USER
if (iccid != null) {
if (!channel.lpa.enableProfile(iccid)) {
return RESULT_FIRST_USER
}
}
return RESULT_OK
} catch (e: Exception) {
return RESULT_FIRST_USER
} finally {
@ -154,6 +238,7 @@ class OpenEuiccService : EuiccService() {
}
override fun onUpdateSubscriptionNickname(slotId: Int, iccid: String, nickname: String?): Int {
Log.i(TAG, "onUpdateSubscriptionNickname slotId=$slotId iccid=$iccid nickname=$nickname")
val channel = findChannel(slotId) ?: return RESULT_FIRST_USER
if (!channel.profileExists(iccid)) {
return RESULT_FIRST_USER

View file

@ -0,0 +1,24 @@
package im.angry.openeuicc.ui
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import im.angry.openeuicc.R
class PrivilegedEuiccManagementFragment: EuiccManagementFragment() {
companion object {
fun newInstance(slotId: Int, portId: Int): EuiccManagementFragment =
newInstanceEuicc(PrivilegedEuiccManagementFragment::class.java, slotId, portId)
}
override suspend fun onCreateFooterViews(parent: ViewGroup): List<View> =
if (channel.isMEP) {
val view = layoutInflater.inflate(R.layout.footer_mep, parent, false)
view.findViewById<Button>(R.id.footer_mep_slot_mapping).setOnClickListener {
(requireActivity() as PrivilegedMainActivity).showSlotMappingFragment()
}
listOf(view)
} else {
listOf()
}
}

View file

@ -4,6 +4,7 @@ import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import im.angry.openeuicc.R
import im.angry.openeuicc.core.EuiccChannel
import im.angry.openeuicc.util.*
class PrivilegedMainActivity : MainActivity() {
@ -20,13 +21,23 @@ class PrivilegedMainActivity : MainActivity() {
return true
}
internal fun showSlotMappingFragment() =
SlotMappingFragment().show(supportFragmentManager, SlotMappingFragment.TAG)
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.dsds -> {
tm.dsdsEnabled = !item.isChecked
tm.setDsdsEnabled(openEuiccApplication.euiccChannelManager, !item.isChecked)
Toast.makeText(this, R.string.toast_dsds_switched, Toast.LENGTH_LONG).show()
finish()
true
}
R.id.slot_mapping -> {
showSlotMappingFragment()
true
}
else -> super.onOptionsItemSelected(item)
}
override fun createEuiccManagementFragment(channel: EuiccChannel): EuiccManagementFragment =
PrivilegedEuiccManagementFragment.newInstance(channel.slotId, channel.portId)
}

View file

@ -0,0 +1,201 @@
package im.angry.openeuicc.ui
import android.annotation.SuppressLint
import android.os.Bundle
import android.telephony.TelephonyManager
import android.telephony.UiccSlotMapping
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.AdapterView.OnItemSelectedListener
import android.widget.ArrayAdapter
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import im.angry.openeuicc.OpenEuiccApplication
import im.angry.openeuicc.R
import im.angry.openeuicc.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SlotMappingFragment: DialogFragment(), OnMenuItemClickListener {
companion object {
const val TAG = "SlotMappingFragment"
}
private val tm: TelephonyManager by lazy {
(requireContext().applicationContext as OpenEuiccApplication).telephonyManager
}
private val ports: List<UiccPortInfoCompat> by lazy {
tm.uiccCardsInfoCompat.flatMap { it.ports }
}
private val portsDesc: List<String> by lazy {
ports.map { getString(R.string.slot_mapping_port, it.card.physicalSlotIndex, it.portIndex) }
}
private lateinit var toolbar: Toolbar
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: SlotMappingAdapter
private lateinit var helpTextView: TextView
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_slot_mapping, container, false)
toolbar = view.findViewById(R.id.toolbar)
toolbar.inflateMenu(R.menu.fragment_slot_mapping)
recyclerView = view.findViewById(R.id.mapping_list)
recyclerView.layoutManager =
LinearLayoutManager(view.context, LinearLayoutManager.VERTICAL, false)
helpTextView = view.findViewById(R.id.mapping_help)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
toolbar.title = getString(R.string.slot_mapping)
toolbar.setNavigationOnClickListener { dismiss() }
toolbar.setOnMenuItemClickListener(this)
}
override fun onResume() {
super.onResume()
setWidthPercent(85)
init()
}
@SuppressLint("NotifyDataSetChanged")
private fun init() {
lifecycleScope.launch(Dispatchers.Main) {
val mapping = withContext(Dispatchers.IO) {
tm.simSlotMapping
}
adapter = SlotMappingAdapter(mapping.toMutableList().apply {
sortBy { it.logicalSlotIndex }
})
recyclerView.adapter = adapter
adapter.notifyDataSetChanged()
helpTextView.text = buildHelpText()
}
}
private fun commit() {
lifecycleScope.launch(Dispatchers.Main) {
try {
withContext(Dispatchers.IO) {
// Use the utility method from PrivilegedTelephonyUtils to ensure
// unmapped ports have all profiles disabled
tm.updateSimSlotMapping(openEuiccApplication.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()
openEuiccApplication.euiccChannelManager.invalidate()
requireActivity().finish()
}
}
private suspend fun buildHelpText() = withContext(Dispatchers.IO) {
val nLogicalSlots = adapter.mappings.size
val cards = openEuiccApplication.telephonyManager.uiccCardsInfoCompat
val nPhysicalSlots = cards.size
var idxMepCard = -1
var nMepPorts = 0
for (card in cards) {
if (card.isMultipleEnabledProfilesSupported) {
idxMepCard = card.physicalSlotIndex
nMepPorts = card.ports.size
}
}
val mayEnableDSDS =
openEuiccApplication.telephonyManager.supportsDSDS && !openEuiccApplication.telephonyManager.dsdsEnabled
val extraText =
if (nLogicalSlots == 1 && mayEnableDSDS) {
getString(R.string.slot_mapping_help_dsds)
} else if (idxMepCard != -1) {
getString(R.string.slot_mapping_help_mep, idxMepCard, nMepPorts)
} else {
""
}
getString(R.string.slot_mapping_help, nLogicalSlots, nPhysicalSlots, extraText)
}
override fun onMenuItemClick(item: MenuItem?): Boolean =
when (item!!.itemId) {
R.id.ok -> {
commit()
true
}
else -> false
}
inner class ViewHolder(root: View): RecyclerView.ViewHolder(root), OnItemSelectedListener {
private val textViewLogicalSlot: TextView = root.findViewById(R.id.slot_mapping_logical_slot)
private val spinnerPorts: Spinner = root.findViewById(R.id.slot_mapping_ports)
init {
spinnerPorts.adapter = ArrayAdapter(requireContext(), im.angry.openeuicc.common.R.layout.spinner_item, portsDesc)
spinnerPorts.onItemSelectedListener = this
}
private lateinit var mappings: MutableList<UiccSlotMapping>
private var mappingId: Int = -1
fun attachView(mappings: MutableList<UiccSlotMapping>, mappingId: Int) {
this.mappings = mappings
this.mappingId = mappingId
textViewLogicalSlot.text = getString(R.string.slot_mapping_logical_slot, mappings[mappingId].logicalSlotIndex)
spinnerPorts.setSelection(ports.indexOfFirst {
it.card.physicalSlotIndex == mappings[mappingId].physicalSlotIndex
&& it.portIndex == mappings[mappingId].portIndex
})
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
check(this::mappings.isInitialized) { "mapping not assigned" }
mappings[mappingId] =
UiccSlotMapping(
ports[position].portIndex, ports[position].card.physicalSlotIndex, mappings[mappingId].logicalSlotIndex)
}
override fun onNothingSelected(parent: AdapterView<*>?) {
}
}
inner class SlotMappingAdapter(val mappings: MutableList<UiccSlotMapping>): RecyclerView.Adapter<ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.fragment_slot_mapping_item, parent, false)
return ViewHolder(view)
}
override fun getItemCount(): Int = mappings.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.attachView(mappings, position)
}
}
}

View file

@ -0,0 +1,39 @@
package im.angry.openeuicc.util
import android.os.Build
import android.telephony.IccOpenLogicalChannelResponse
import android.telephony.TelephonyManager
// TODO: Usage of new APIs from T or later will still break build in-tree on lower AOSP versions
// Maybe older versions should simply include hidden-apis-shim when building?
fun TelephonyManager.iccOpenLogicalChannelByPortCompat(
slotIndex: Int, portIndex: Int, aid: String?, p2: Int
): IccOpenLogicalChannelResponse =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
iccOpenLogicalChannelByPort(slotIndex, portIndex, aid, p2)
} else {
iccOpenLogicalChannelBySlot(slotIndex, aid, p2)
}
fun TelephonyManager.iccCloseLogicalChannelByPortCompat(
slotIndex: Int, portIndex: Int, channel: Int
) =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
iccCloseLogicalChannelByPort(slotIndex, portIndex, channel)
} else {
iccCloseLogicalChannelBySlot(slotIndex, channel)
}
fun TelephonyManager.iccTransmitApduLogicalChannelByPortCompat(
slotIndex: Int, portIndex: Int, channel: Int,
cla: Int, inst: Int, p1: Int, p2: Int, p3: Int, data: String?
): String? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
iccTransmitApduLogicalChannelByPort(
slotIndex, portIndex, channel, cla, inst, p1, p2, p3, data
)
} else {
iccTransmitApduLogicalChannelBySlot(
slotIndex, channel, cla, inst, p1, p2, p3, data
)
}

View file

@ -2,17 +2,58 @@ package im.angry.openeuicc.util
import android.telephony.SubscriptionManager
import android.telephony.TelephonyManager
import android.telephony.UiccSlotMapping
import im.angry.openeuicc.core.EuiccChannelManager
import kotlinx.coroutines.runBlocking
import java.lang.Exception
val TelephonyManager.supportsDSDS: Boolean
get() = supportedModemCount == 2
var TelephonyManager.dsdsEnabled: Boolean
val TelephonyManager.dsdsEnabled: Boolean
get() = activeModemCount >= 2
set(value) {
switchMultiSimConfig(if (value) { 2 } else {1})
fun TelephonyManager.setDsdsEnabled(euiccManager: EuiccChannelManager, enabled: Boolean) {
runBlocking {
euiccManager.enumerateEuiccChannels()
}
// Disable all eSIM profiles before performing a DSDS switch
euiccManager.knownChannels.forEach {
it.lpa.disableActiveProfileWithUndo()
}
switchMultiSimConfig(if (enabled) { 2 } else { 1 })
}
// Disable eSIM profiles before switching the slot mapping
// This ensures that unmapped eSIM ports never have "ghost" profiles enabled
fun TelephonyManager.updateSimSlotMapping(
euiccManager: EuiccChannelManager, newMapping: Collection<UiccSlotMapping>,
currentMapping: Collection<UiccSlotMapping> = simSlotMapping
) {
val unmapped = currentMapping.filterNot { mapping ->
// If the same physical slot + port pair is not found in the new mapping, it is unmapped
newMapping.any {
it.physicalSlotIndex == mapping.physicalSlotIndex && it.portIndex == mapping.portIndex
}
}
val undo = unmapped.mapNotNull { mapping ->
euiccManager.findEuiccChannelByPortBlocking(mapping.physicalSlotIndex, mapping.portIndex)?.let { channel ->
return@mapNotNull channel.lpa.disableActiveProfileWithUndo()
}
}
try {
simSlotMapping = newMapping
} catch (e: Exception) {
e.printStackTrace()
undo.forEach { it() } // Undo what we just did
throw e // Rethrow for caller to handle
}
}
fun SubscriptionManager.tryRefreshCachedEuiccInfo(cardId: Int) {
if (cardId != 0) {
try {

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/footer_mep_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="40dp"
android:layout_marginEnd="40dp"
android:gravity="center"
android:text="@string/footer_mep"
android:textStyle="italic"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/footer_mep_slot_mapping"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<Button
android:id="@+id/footer_mep_slot_mapping"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/slot_mapping"
android:textColor="?attr/colorAccent"
style="?android:attr/borderlessButtonStyle"
app:layout_constraintTop_toBottomOf="@id/footer_mep_text"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:theme="@style/Theme.OpenEUICC"
android:background="?attr/colorPrimary"
android:elevation="4dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintWidth_percent="1"
app:navigationIcon="?homeAsUpIndicator" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/mapping_list"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="6dp"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintBottom_toTopOf="@id/mapping_help"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<TextView
android:id="@+id/mapping_help"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginBottom="10dp"
android:layout_marginHorizontal="24dp"
android:gravity="center"
android:textSize="12sp"
android:textColor="?attr/colorSecondary"
android:textStyle="italic"
app:layout_constraintTop_toBottomOf="@id/mapping_list"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="48sp"
android:gravity="center">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal">
<TextView
android:id="@+id/slot_mapping_logical_slot"
android:textSize="14sp"
android:layout_width="wrap_content"
android:layout_height="32sp"
android:layout_marginEnd="10sp" />
<Spinner
android:id="@+id/slot_mapping_ports"
android:layout_width="wrap_content"
android:layout_height="32sp" />
</LinearLayout>
</LinearLayout>

View file

@ -7,4 +7,8 @@
android:checkable="true"
android:visible="false"
app:showAsAction="never" />
<item
android:id="@+id/slot_mapping"
android:title="@string/slot_mapping"
app:showAsAction="never" />
</menu>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/ok"
android:icon="@drawable/ic_check_black"
android:title="@string/slot_mapping"
app:showAsAction="ifRoom"/>
</menu>

View file

@ -5,4 +5,15 @@
<string name="dsds">Dual SIM</string>
<string name="toast_dsds_switched">DSDS state switched. Please wait until the modem restarts.</string>
<string name="footer_mep">Multiple Enabled Profiles (MEP) is supported by this slot. To enable or disable this feature, use the \"Slot Mapping\" tool.</string>
<string name="slot_mapping">Slot Mapping</string>
<string name="slot_mapping_logical_slot">Logical slot %d:</string>
<string name="slot_mapping_port">Slot %d Port %d</string>
<string name="slot_mapping_help">Your phone has %d logical and %d physical SIM slots.%s\n\nSelect which physical slot and/or \"port\" you want each logical slot to correspond to. Note that not all mapping modes may be supported by hardware.</string>
<string name="slot_mapping_help_mep">\n\nPhysical slot %d supports Multiple Enabled Profiles (MEP). To use this feature, assign its %d virtual \"ports\" to different logical slots shown above.\n\nWith MEP enabled, the \"ports\" will behave like separate eSIM slots in OpenEUICC, except with a shared profile list.</string>
<string name="slot_mapping_help_dsds">\nDual SIM mode is supported but disabled. If your device comes with an internal eSIM chip, it might not be enabled by default. Change mapping above or enable dual SIM to access your eSIM.</string>
<string name="slot_mapping_completed">Your new slot mapping has been set. Please wait until modem refreshes the slots.</string>
<string name="slot_mapping_failure">The specified mapping might be invalid or unsupported by hardware.</string>
</resources>

View file

@ -31,7 +31,7 @@ android {
}
dependencies {
compileOnly project(':libs:hidden-apis-stub')
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.2'
implementation 'com.google.android.material:material:1.6.1'

View file

@ -3,6 +3,7 @@ package im.angry.openeuicc.util
import android.telephony.IccOpenLogicalChannelResponse
import android.telephony.SubscriptionManager
import android.telephony.TelephonyManager
import android.telephony.UiccSlotMapping
import java.lang.reflect.Method
// Hidden APIs via reflection to enable building without AOSP source tree
@ -14,12 +15,24 @@ private val iccOpenLogicalChannelBySlot: Method by lazy {
Int::class.java, String::class.java, Int::class.java
)
}
private val iccOpenLogicalChannelByPort: Method by lazy {
TelephonyManager::class.java.getMethod(
"iccOpenLogicalChannelByPort",
Int::class.java, Int::class.java, String::class.java, Int::class.java
)
}
private val iccCloseLogicalChannelBySlot: Method by lazy {
TelephonyManager::class.java.getMethod(
"iccCloseLogicalChannelBySlot",
Int::class.java, Int::class.java
)
}
private val iccCloseLogicalChannelByPort: Method by lazy {
TelephonyManager::class.java.getMethod(
"iccCloseLogicalChannelByPort",
Int::class.java, Int::class.java, Int::class.java
)
}
private val iccTransmitApduLogicalChannelBySlot: Method by lazy {
TelephonyManager::class.java.getMethod(
"iccTransmitApduLogicalChannelBySlot",
@ -27,15 +40,41 @@ private val iccTransmitApduLogicalChannelBySlot: Method by lazy {
Int::class.java, Int::class.java, Int::class.java, String::class.java
)
}
private val iccTransmitApduLogicalChannelByPort: Method by lazy {
TelephonyManager::class.java.getMethod(
"iccTransmitApduLogicalChannelByPort",
Int::class.java, Int::class.java, Int::class.java, Int::class.java, Int::class.java,
Int::class.java, Int::class.java, Int::class.java, String::class.java
)
}
private val getSimSlotMapping: Method by lazy {
TelephonyManager::class.java.getMethod(
"getSimSlotMapping"
)
}
private val setSimSlotMapping: Method by lazy {
TelephonyManager::class.java.getMethod(
"setSimSlotMapping",
Collection::class.java
)
}
fun TelephonyManager.iccOpenLogicalChannelBySlot(
slotId: Int, appletId: String, p2: Int
slotId: Int, appletId: String?, p2: Int
): IccOpenLogicalChannelResponse =
iccOpenLogicalChannelBySlot.invoke(this, slotId, appletId, p2) as IccOpenLogicalChannelResponse
fun TelephonyManager.iccOpenLogicalChannelByPort(
slotId: Int, portId: Int, appletId: String?, p2: Int
): IccOpenLogicalChannelResponse =
iccOpenLogicalChannelByPort.invoke(this, slotId, portId, appletId, p2) as IccOpenLogicalChannelResponse
fun TelephonyManager.iccCloseLogicalChannelBySlot(slotId: Int, channel: Int): Boolean =
iccCloseLogicalChannelBySlot.invoke(this, slotId, channel) as Boolean
fun TelephonyManager.iccCloseLogicalChannelByPort(slotId: Int, portId: Int, channel: Int): Boolean =
iccCloseLogicalChannelByPort.invoke(this, slotId, portId, channel) as Boolean
fun TelephonyManager.iccTransmitApduLogicalChannelBySlot(
slotId: Int, channel: Int, cla: Int, instruction: Int,
p1: Int, p2: Int, p3: Int, data: String?
@ -44,6 +83,18 @@ fun TelephonyManager.iccTransmitApduLogicalChannelBySlot(
this, slotId, channel, cla, instruction, p1, p2, p3, data
) as String?
fun TelephonyManager.iccTransmitApduLogicalChannelByPort(
slotId: Int, portId: Int, channel: Int, cla: Int, instruction: Int,
p1: Int, p2: Int, p3: Int, data: String?
): String? =
iccTransmitApduLogicalChannelByPort.invoke(
this, slotId, portId, channel, cla, instruction, p1, p2, p3, data
) as String?
var TelephonyManager.simSlotMapping: Collection<UiccSlotMapping>
get() = getSimSlotMapping.invoke(this) as Collection<UiccSlotMapping>
set(new) { setSimSlotMapping.invoke(this, new) }
private val requestEmbeddedSubscriptionInfoListRefresh: Method by lazy {
SubscriptionManager::class.java.getMethod("requestEmbeddedSubscriptionInfoListRefresh", Int::class.java)
}

View file

@ -0,0 +1,73 @@
package android.telephony;
import android.os.Parcel;
import android.os.Parcelable;
public final class UiccSlotMapping implements Parcelable {
public static final Creator<UiccSlotMapping> CREATOR = null;
@Override
public void writeToParcel(Parcel dest, int flags) {
throw new RuntimeException("stub");
}
@Override
public int describeContents() {
return 0;
}
/**
*
* @param portIndex The port index is an enumeration of the ports available on the UICC.
* @param physicalSlotIndex is unique index referring to a physical SIM slot.
* @param logicalSlotIndex is unique index referring to a logical SIM slot.
*
*/
public UiccSlotMapping(int portIndex, int physicalSlotIndex, int logicalSlotIndex) {
throw new RuntimeException("stub");
}
/**
* Port index is the unique index referring to a port belonging to the physical SIM slot.
* If the SIM does not support multiple enabled profiles, the port index is default index 0.
*
* @return port index.
*/
public int getPortIndex() {
throw new RuntimeException("stub");
}
/**
* Gets the physical slot index for the slot that the UICC is currently inserted in.
*
* @return physical slot index which is the index of actual physical UICC slot.
*/
public int getPhysicalSlotIndex() {
throw new RuntimeException("stub");
}
/**
* Gets logical slot index for the slot that the UICC is currently attached.
* Logical slot index is the unique index referring to a logical slot(logical modem stack).
*
* @return logical slot index;
*/
public int getLogicalSlotIndex() {
throw new RuntimeException("stub");
}
@Override
public boolean equals(Object obj) {
throw new RuntimeException("stub");
}
@Override
public int hashCode() {
throw new RuntimeException("stub");
}
@Override
public String toString() {
throw new RuntimeException("stub");
}
}

View file

@ -20,11 +20,10 @@ class LocalProfileAssistantImpl(
}
override val profiles: List<LocalProfileInfo>
get() = LpacJni.es10cGetProfilesInfo(contextHandle)!!.asList() // TODO: Maybe we need better error handling
get() = LpacJni.es10cGetProfilesInfo(contextHandle)!!.asList()
override val eID: String by lazy {
LpacJni.es10cGetEid(contextHandle)!!
}
override val eID: String
get() = LpacJni.es10cGetEid(contextHandle)!!
override val euiccInfo2: EuiccInfo2?
get() = LpacJni.es10cexGetEuiccInfo2(contextHandle)