
这次实现自动 eSIM 安装的经历,可以说相当“有趣”——整个开发过程几乎无法进行实际测试,颇有种“盲写”代码的感觉。本文会先聊聊 eSIM 的相关概念,然后分享一下这段颇为奇葩的开发故事。
核心概念
从 Android 9(API 级别 28)开始,系统就开放了对 eSIM 的操作能力。这意味着你既可以在运营商的官方应用里实现这个功能,也可以在与运营商无关的现有 App 中将其作为一个独立功能加入。值得注意的是,实现这套逻辑不需要在应用清单文件中额外申请权限,所以你完全不用担心应用商店审核会因此遇到阻碍。
我们先来看看 RSP(远程 SIM 配置,Remote SIM Provisioning)的整体架构:

图片来源:https://source.android.com/docs/core/connect/esim-overview
LPA(本地配置助手,Local Profile Assistant)是设备上统一管理 eSIM 配置文件的核心组件,它由两部分构成:
- 后端:通过 eUICC API 来管理设备上的 eSIM。
- 前端:即 LPA UI,简称 LUI,负责与用户交互的可视化部分。当需要用户授权或执行特定操作时,就会调用它。
LPA 与 eUICC(嵌入式通用集成电路卡,embedded Universal Integrated Circuit Card)进行交互。eUICC 允许我们在没有物理 SIM 卡的情况下,下载、存储和切换 eSIM 配置文件。需要明确的是,AOSP 本身并未提供默认的 LPA 实现,这也解释了为何并非所有手机都支持 eSIM。SM-DP+(订阅管理器-数据准备,Subscription Manager — Data Preparation)是提供 eSIM 配置文件下载的服务平台。架构图中的 Operator(运营商)负责生成 eSIM 配置,将其上传到 SM-DP+ 进行安全分发,同时面向终端用户销售相关服务。
顺便提一句:实际上,开发者完全可以自己实现设备上的 LPA——只需继承 android.service.euicc.EuiccService 来实现自己的后端,再通过带有特定 intent-filter 的 Activity 来实现自己的 LUI 即可。不过,本文不会深入探讨这种自定义方案,我们将聚焦于如何使用设备自带的 LPA 来完成功能。接下来,我们就从下载 eSIM 配置文件开始讲起。
下载 eSIM 配置文件
你可以通过 EuiccManager 的 isEnabled 属性来检查设备是否支持 eSIM。如果支持,下一步我们就需要获取用于下载配置文件的激活码。激活码通常可以通过请求你的后端接口获取,它由两部分组成:SM-DP+ 地址和激活令牌。
val smdpAddress = "SMDP.GSMA.COM"
val activationToken = "04386-AGYFT-A74Y8-3F815"
val activationCode = "1\$ \$smdpAddress\$ \$activationToken"
接下来,我们创建用于下载的订阅对象:
val subscription = DownloadableSubscription.forActivationCode(activationCode)
val intent = EsimInstallerBroadcastReceiver.createStartIntent(context)
val pendingIntent = EsimInstallerBroadcastReceiver.createCallbackIntent(
context,
intent,
)
在上面的代码中,创建 PendingIntent 是为了接收 eSIM 配置文件下载的结果回调。EsimInstallerBroadcastReceiver 的内部实现如下:
const val DOWNLOAD_ACTION = "download_subscription"
fun createStartIntent(context: Context): Intent {
return Intent(DOWNLOAD_ACTION).setPackage(context.packageName)
}
fun createCallbackIntent(context: Context, startIntent: Intent): PendingIntent {
return PendingIntent.getBroadcast(
/* context = */ context,
/* requestCode = */ 0,
/* intent = */ startIntent,
/* flags = */ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
}
准备完成后,我们就可以通过以下代码发起配置文件下载:
val euiccManager = context.getSystemService(EUICC_SERVICE) as EuiccManager
euiccManager.downloadSubscription(
/* subscription = */ subscription,
/* switchAfterDownload = */ true,
/* callbackIntent = */ callbackIntent,
)
为了方便,我们通常会开启 switchAfterDownload 标志,让下载完成后自动切换到刚安装的配置。当然,你也可以关闭这个标志,之后自行调用方法单独启用下载好的配置。
downloadSubscription 方法要求应用持有 android.Manifest.permission.WRITE_EMBEDDED_SUBSCRIPTIONS 权限,或者你的应用必须有权限管理当前活动配置和即将下载的配置。如果不满足条件,EsimInstallerBroadcastReceiver 的 onReceive 方法收到的 resultCode 就会是 EuiccManager.EMBEDDED_SUBSCRIPTION_RESULT_RESOLVABLE_ERROR。接下来,我们就讲讲如何处理这个“可解决错误”。
处理配置下载错误
发起下载后,结果会通过 EsimInstallerBroadcastReceiver 的 onReceive 方法返回,我们需要在这里对结果进行分发处理:
override fun onReceive(context: Context, intent: Intent) {
when {
DOWNLOAD_ACTION != intent.action -> return
resultCode == EuiccManager.EMBEDDED_SUBSCRIPTION_RESULT_OK -> {
onEmbeddedSubscriptionSucceed()
}
resultCode == EuiccManager.EMBEDDED_SUBSCRIPTION_RESULT_RESOLVABLE_ERROR -> {
onStartResolutionActivity(intent)
}
else -> onEmbeddedSubscriptionFailed()
}
}
上面代码中,我建议在创建 EsimInstallerBroadcastReceiver 的时候传入对应的回调,这里根据结果调用对应回调即可。成功或失败都可以给用户展示对应的页面,结束整个流程。这套回调机制可以很轻松地嵌入任何架构:如果你用 MVP,就调用 View 通知 Presenter;如果你用 MVVM,就通过 View 通知 ViewModel;如果你用 MVI,就交给你架构中负责界面状态的实体,发送对应的意图。
要处理需要用户介入解决的错误(也就是需要弹出对话框让用户授权某些操作),我们需要再创建一个 PendingIntent 来接收错误解决的结果:
val startIntent = EsimErrorResolverBroadcastReceiver.createStartIntent(activity)
val callbackIntent = EsimErrorResolverBroadcastReceiver.createCallbackIntent(
activity,
startIntent,
)
EsimErrorResolverBroadcastReceiver 的内部实现如下:
const val START_RESOLUTION_ACTION = "start_resolution_action"
fun createStartIntent(context: Context): Intent {
return Intent(START_RESOLUTION_ACTION).setPackage(context.packageName)
}
fun createCallbackIntent(context: Context, startIntent: Intent): PendingIntent {
return PendingIntent.getBroadcast(
/* context = */ context,
/* requestCode = */ 0,
/* intent = */ startIntent,
/* flags = */ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
}
准备完成后,我们就可以启动错误解决流程:
euiccManager.startResolutionActivity(
/* activity = */ activity,
/* requestCode = */ 0,
/* resultIntent = */ resultIntent,
/* callbackIntent = */ callbackIntent,
)
重点注意:这里的 resultIntent 就是之前 EMBEDDED_SUBSCRIPTION_RESULT_RESOLVABLE_ERROR 错误发生时,传给 EsimInstallerBroadcastReceiver 的那个 Intent。
用户与 LUI 交互完成后,错误解决的结果会返回给 EsimErrorResolverBroadcastReceiver 的 onReceive 方法,我们在这里同样调用成功或失败的回调即可:
override fun onReceive(context: Context, intent: Intent) {
when {
START_RESOLUTION_ACTION != intent.action -> return
resultCode == EuiccManager.EMBEDDED_SUBSCRIPTION_RESULT_OK -> onEmbeddedSubscriptionSucceed()
else -> onEmbeddedSubscriptionFailed()
}
}
如果需要的话,你可以解析收到的 Intent,将错误参数上报到日志系统。你可能会关心这些参数:
EXTRA_EMBEDDED_SUBSCRIPTION_ERROR_CODE
EXTRA_EMBEDDED_SUBSCRIPTION_DETAILED_CODE
EXTRA_EMBEDDED_SUBSCRIPTION_OPERATION_CODE
我们已经走完了从下载配置到通过用户交互解决错误完成安装的全流程。那么,还有什么情况会导致配置安装失败呢?
运营商授予应用配置操作权限
因为运营商只能管理自己的配置,所以系统对不同配置的访问做了限制。要让你的应用获得安装对应配置的权限,你需要向运营商提供:
- 必填项:你的应用公钥证书的签名(SHA-1 或 SHA-256)
- 可选项,但强烈建议提供:应用的包名
只要 SM-DP+ 和配置文件中保存了这两个值,系统就会授予你的应用操作对应 eSIM 配置的权限。
支持多活跃配置
从 Android 13(API 级别 33)开始,系统支持 MEP(多启用配置,Multiple Enabled Profiles),允许用户同时激活多个 eSIM 配置。如果你需要深度对接 eUICC,自己管理配置安装的端口,这个特性就非常重要。不过在我们的例子中,因为开启下载时设置了 switchAfterDownload 参数,所以不需要为多配置做额外适配。有了这个参数,我们完全不需要关心配置会安装到哪个端口,系统会按照以下逻辑帮我们处理:
- 单 SIM 模式下,配置会下载安装到现有的单个卡槽(默认端口 0)。
- DSDS(双卡双待)模式下,系统会找到第一个可用端口,在那里激活配置。
- 如果没有可用的激活端口,就会返回
EMBEDDED_SUBSCRIPTION_RESULT_RESOLVABLE_ERROR 错误,在错误解决流程中会提示用户去禁用一个现有配置。
我的 eUICC 开发小故事
我第一次接触这个需求的时候,表情估计和我当年第一次打开 Dagger2 文档时一模一样。但这次我心里更有底——这肯定是个能搞定的任务,哪怕它和改标题样式、拼服务端下发的界面 JSON 这种日常任务完全不同。唯一的不确定性是:当时我把功能写完了,运营商那边还没来得及把我们应用的签名加到他们的基础设施里。结果我写完代码推到 Git 之后,连正常的全流程都测不了。
然而,这件事的结局却好到超出我的预期:运营商那边改完配置后,测试同学一遍就成功安装了 eSIM,我原本预想中的一堆 BUG 一个都没出现。在 Kotlin 这类现代语言的加持下,遵循 Android 官方规范进行开发,其稳定性的确值得信赖。如果你想了解更多类似的移动开发实践或交流心得,欢迎到云栈社区逛逛。
参考资料
- RSP 技术规范
- Android 官方 eSIM 开发文档