Android 13中的 Open Mobile API
Open Mobile API Specification v3.2 简称 OMAPI
Android SE的多种形态: eSE、UICC、SD Card
Android 9.0 SecureElementService 初始化流程分析 很清晰的UML类图和调用流程
API 接口在 Specification 6.2 章节,本文基于 Android 13 的 OMAPI 实现(遵循 v3.3 规范),涉及了 framework 和 packages 层,具体实现与原始规范有些许不同
一、SE涉及场景及用途
SE 也就是 Secure Element,译为 “安全元素”
主要应用场景在 手机手表交通卡、门禁、虚拟钱包、虚拟SIM卡,以及其他身份认证的且对安全级别有一定要求的业务。
目前 Android 手机主要有三种SE的实现 eSE、UICC、microSD,这些带有SE的芯片有独立的储存和计算能力,可以进行 Applet 安装与个人化等一些列自定义的安全行为操作。
简单提一下这三种SE实现:
eSE:Embedded ,硬件内嵌在手机形式,通过 NFC Controller 使用
UICC:物理插槽形式,日常使用的SIM卡,在Android层面叫UICC,现在 eSIM 阶段新增了 EUICC,而且 Android 提供了一套另外的 eSIM 服务框架
microSD:物理插槽形式,早期使用的外部 SD 存储卡
Tips:
Android9 之前需引入 org.simalliance.openmobileapi.jar,从 Android9 开始并入了Android SDK,在 android.se.omapi 包下,且在 /packages/apps/ 下新增了SE相关模块(SecureElement)
二、上层 framework
包路径:/frameworks/base/omapi/core/java/android/se/omapi/
目录下的所有文件:
Channel.java
Reader.java
SEService.java
Session.java
ISecureElementChannel.aidl
ISecureElementListener.aidl
ISecureElementReader.aidl
ISecureElementService.aidl
ISecureElementSession.aidl
Transport API class diagram overview
和原始的 OMAPI规范 还是有点区别
本地图片插入 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EEMPBi9Y-1668668098943)(C:\Users\bzb\Pictures\ccsmec\智能卡\OMA类图.jpg)]
一、SEService.java
SEService(Context context, Executor executor, OnConnectedListener listener)
对应底层类 SecureElementService.java
内部接口: OnConnectedListener
初始化为耗时操作,异步进行,成功连接后进行回调
1.void onConnected();
方法:
- Reader getUiccReader(int)
- Reader[] getReaders() 返回可用的Reader列表,元素不能重复,即使没有插卡也返回,底层是 Terminal内部类SecureElementReader(Reader相关的通信也由它完成), 内部有 ArrayList<SecureElementSession> mSessions
- boolean isConnected() 当此服务成功连接时为true
- void shutdown() 释放所有由此SEService分配的SE资源,建议在程序结束时调用,此方法执行后,isConnected返回false
- String getVersion() 返回此实现基于哪一个 Open Mobile API 规范版本计算方法:版本号 = 主版本号*1000 + 副版本号,(例如:“3001”是基于OMAPI规范v3.1的实现)
二、Reader.java
Reader(@NonNull SEService service, @NonNull String name, @NonNull ISecureElementReader reader)
对应底层类 Terminal.java,此类非常重要(/packages/apps/SecureElement/src/com/android/se/Terminal.java)
方法:
- String getName()
- SEService getSEService()
- boolean reset()
- boolean isSecureElementPresent()
- Session openSession()
- void closeSessions()
三、Session.java
Reader getReader()
byte[] getATR()
void close()
boolean isClosed()
void closeChannels()
Channel openBasicChannel(byte[] aid)
Channel openLogicalChannel(byte[] aid)
Channel openBasicChannel(byte[] aid, Byte P2)
Channel openLogicalChannel(byte[] aid, Byte P2)
四、Channel.java
void close()
boolean isClosed()
boolean selectNext() 遍历匹配相同部分AID的所有applet, true: 在该通道上成功选择了一个新的Applet; false: 保持原有选中
boolean isBasicChannel()
Session getSession()
byte[] getSelectResponse()
byte[] transmit(byte[] command)
void setTransmitExpectDataWithWarningSW(boolean expectData)
三、framework流程:
通过 SEService 类构造传入注册服务接口,获取 Reader 得到 Session 实例,通过 Session 可以打开 Channel(包括 BasicChannel 和 LogicalChannel),Channel 中提供了发送 APDU 命令的方法。
总结步骤:
- 连接 SEService,需要几百毫秒时间
- 指定 Reader 或者 getReaders 遍历取出能使用的 Reader
- 通过 Reader 连接一个 Session
- 通过 Session 的 openBasicChannel(aid, p2) / openLogicChannel(aid, p2) 打开一个 Channel
- 通过 Channel.transmit(cmd) 发送 APDU 指令
- 通过 Channel.close() 关闭通道
- 通过 Session 关闭会话,或者通过 Reader.closeSessions() 关闭所有已打开的会话
- 通过 SEService.shutdown() 断开自身连接,此方法内包了含上一步骤
四、底层 packages
包路径:/packages/apps/SecureElement/src/com/android/se/
目录下的文夹件和类:
internal/
security/
Channel.java
CommandApduValidator.java
SEApplication.java
SecureElementService.java
Terminal.java
一、SecureElementService.java
getReader()
createTerminals() {addTerminals(ESE_TERMINAL);addTerminals(UICC_TERMINAL);
}
addTerminals(String terminalName) {...name = terminalName + Integer.toString(index);Terminal terminal = new Terminal(name, this);terminal.initialize(index == 1);mTerminals.put(name, terminal);...
}// Seesion 的Binder桥接
final class SecureElementSession extends ISecureElementSession.Stub {closeChannels()openBasicChannel(...)openLogicalChannel(...)
}
二、Terminal.java
setUpChannelAccess(...)
isSecureElementPresent()
initialize(boolean retryOnFail)
initializeAccessControl() {if (mAccessControlEnforcer == null) {mAccessControlEnforcer = new AccessControlEnforcer(this);}mAccessControlEnforcer.initialize();
}byte[] transmit(byte[] cmd);
byte[] transmitInternal(byte[] cmd) {...// mSEHal 也就是 SecureElement.java// 年轻人我劝你不要去看后面的源码了, 深得很你把握不住, 硬件层的各种 CPPmSEHal.transmit(byteArrayToArrayList(cmd));
}// Reader 的Binder桥接
final class SecureElementReader extends ISecureElementReader.Stub {getAtr()reset()openSeesion()isSecureElementPresent()private getTerminal()
}
三、AccessControlEnforcer.java
initialize()
reset()
setUpChannelAccess(...)
readSecurityProfile()
arf...PKCS15
ara...
四、Channel.java
byte[] transmit(byte[] command) {CommandApduValidator.execute(command);checkCommand(command);// 一大堆命令检查, 接着还是通过 Terminal 发送return mTerminal.transmit(command);
}final class SecureElementChannel extends ISecureElementChannel.Stub {close()boolean selectNext()byte[] transmit(byte[] command) {Channel.this.transmit(command);}
}
常用名词
Trusted Service Manager (TSM)
Issuer Security Domain (ISD)
Access Rule Files (ARF)
Access Rule Application (ARA)
Access Rule Application Master (ARA-M)
Access Rule Application Client (ARA-C)
五、packages流程
framework 层的 SEService.java 在构造里通过 ISecureElementChannel.aidl 类绑定到了
packages 层的服务 SecureElementService.java 上,后续都通过此服务交互。以下三个比较重要的入口类,SecureElementService 会在 onCreate() 里初始化,并创建多个 Terminal 实例,主要是 eSE 和 UICC(SIM),它们可能会有多个,类似(eSE1、eSE2、SIM1、SIM2)。
这里即是上层调用的 openSeesion、openBasicChannel()、openLogicalChannel() 到 C++ 层的入口,也就是在这里做了 AccessRule 的校验(我在这儿就遇到了问题),规则校验具体实现入口在 AccessControlEnforcer.java,此类在 Terminal 中初始化。
顺便提两句,第一个是在上层调用 openSeesion 过程中有一个 isSecureElementPresent() 的检查,如果返回 false 会直接抛出异常 “Secure Element is not present.”,也就是当前 SE 不可用(具体原因暂时未解,查看了一些cpp的实现里直接返回的true,但上层确实有返回false,排除异常的情况),所以调用前需要在 Reader 里先行判断。
第二个是 openXXXChannel() 过程中,会有一个 setUpChannelAccess() 方法进行访问规则的校验,关于 AccessRule GPD 有一本非常厚的 SE_Access_Control_v1.1.pdf 的规范描述。
当规则校验通过后,Channel 也就打开了,此时可以在上层使用 Channel.transmit() 发送 APDU 指令,且此方法进行了响应。可以是一条指令也可以是多条,发送完成后即返回结果,另外还提供了一个方法用来获取结果 getSelectResponse(),没太细究它们俩直接的区别。
五、使用案例
5.1 检查设备支持
可以使用 PackageManager.hasSystemFeature 检查设备是否支持需要的 SE 区域,或者使用 PackageManager.systemAvailableFeatures 列出所有支持的特性,从里面找如下三个
FEATURE_SE_OMAPI_ESE
FEATURE_SE_OMAPI_UICC
FEATURE_SE_OMAPI_SD
Android 官方相关描述:
Open Mobile API reader support
On Android 11 and higher, Open Mobile API (OMAPI) supports checking for eSE, SD, and UICC support hardware on devices with the following flags:
FEATURE_SE_OMAPI_ESE
FEATURE_SE_OMAPI_SD
FEATURE_SE_OMAPI_UICC
Use these values with getSystemAvailableFeatures() or hasSystemFeature() to check for device support.
if (requireContext().packageManager.hasSystemFeature(PackageManager.FEATURE_SE_OMAPI_UICC)) {SIMLog.e("系统是否支持 OMAPI UICC_SE 硬件功能:true")lifecycleScope.launch {delay(2000)mOperator.getEID() // 做了个测试调用, 里面使用 OMAPI 执行了一次 APDU 发送}} else {SIMLog.e("系统是否支持 OMAPI UICC_SE 硬件功能:false")}requireContext().packageManager.systemAvailableFeatures.forEach {Log.e("Flyme-SIM", "系统已支持的硬件功能:$it")}
5.2 客户端调用 SEService.java
使用了 framework 下的类 SEService.java,也就是SDK自带的包 android.se.omapi,调用的相关日志在后续有贴出来,主要是经历了一个错误记录下来
// Android 客户端代码import android.se.omapi.SEService private var mUICCReader: Reader? = nullprivate val mSEService = SEService(context, ThreadUtils.getSinglePool()) {SIMLog.e("SEService已连接", TAG)}init {// SEService 大概需要几百毫秒进行连接ThreadUtils.getMainHandler().postDelayed({SIMLog.e("SEService连接状态:${mSEService.isConnected}", TAG)if (mSEService.isConnected) {// mUICCReader = mSEService.getUiccReader(SLOT_INDEX) // 指定卡槽拿到的Reader不是SE, 直接使用遍历的方式// 我的遍历结果// 已存在的Reader: name=eSE1, isSecureElementPresent=true// 已存在的Reader: name=SIM1, isSecureElementPresent=false// 已存在的Reader: name=SIM2, isSecureElementPresent=falsefor (reader in mSEService.readers) {SIMLog.printD("已存在的Reader: name=${reader.name}, isSecureElementPresent=${reader.isSecureElementPresent}")if (reader.isSecureElementPresent) mUICCReader = reader}}}, 1000)}/*** 发送 APDU 指令* 一次发送, 包含 选择Applet、打开通道、读取响应、关闭通道* 1.打开 Reader、Session、Channel* 2.通过 Channel 选择 Applet,并打开逻辑通道* 3.发送 APDU,并接收响应* 4.关闭 Reader、Session、Channel** @param p2 '00', '04', '08', '0C'*/private fun send(command: ByteArray, aid: ByteArray = AID_BYTE, p2: Byte = 0x00): ByteArray? {var resp: ByteArray? = nullmUICCReader?.let { reader ->log("sendApdu: Reader.isSecureElementPresent=${reader.isSecureElementPresent}")try {if (!reader.isSecureElementPresent) {log("此 Reader 不支持 SE")return null}val openSession = reader.openSession()// 在执行 openSession.openLogicalChannel(aid, p2) 时,遇到一个错误// java.lang.SecurityException:// Exception in setUpChannelAccess() java.security.AccessControlException: SecureElement-AccessControlEnforcerno APDU access allowed!openSession.openLogicalChannel(aid, p2)?.let {log("发送的req:${command.bytesToHexString()}")it.transmit(command)resp = it.selectResponselog("返回的resp:${resp?.bytesToHexString()}")log("返回是否成功:${isSendSuccess(resp)}")it.close()}openSession.close()} catch (e: Exception) {log("发送APDU异常:$e")}} ?: kotlin.run {log("mUICCReader还未初始化完成, SEService还在连接中")}return resp}
5.3 主要讲一下这个错误
执行 openSession.openLogicalChannel(aid, p2) 遇到错误:
java.lang.SecurityException:
Exception in setUpChannelAccess()
java.security.AccessControlException:SecureElement-AccessControlEnforcerno APDU access allowed!
5.3.1
查看源码:
/frameworks/base/omapi/java/android/se/omapi/Session.java
Session.javapublic @Nullable Channel openLogicalChannel(@Nullable byte[] aid, @Nullable byte p2) throws IOException {if (!mService.isConnected()) {throw new IllegalStateException("service not connected to system");}synchronized (mLock) {try {// 执行到这一行, 调用到 ISecureElementChannel.aidlISecureElementChannel channel = mSession.openLogicalChannel(aid,p2,mReader.getSEService().getListener());if (channel == null) {return null;}return new Channel(mService, this, channel);} catch (ServiceSpecificException e) {...} catch (RemoteException e) {throw new IllegalStateException(e.getMessage());}}}
5.3.2
后续调用离开了 framework 层到了底层目录 packages:
错误里提到一个关键类和一个方法:AccessControlEnforcerno.java 和 setUpChannelAccess(),该类在 Terminal.java 中初始化
首先来看下 Terminal.java
Terminal.java/*** Initializes the Access Control for this Terminal*/private synchronized void initializeAccessControl() throws IOException,MissingResourceException{...synchronized(mLock) {if (mAccessControlEnforcer == null) {mAccessControlEnforcer = new AccessControlEnforcer (this);}try {mAccessControlEnforcer.initialize();} catch (IOException | MissingResourceException e) {mAccessControlEnforcer = null;throw e;}}}/*** Opens a logical Channel with AID for the given package name or uuid*/public Channel openLogicalChannel(SecureElementSession session, byte[] aid, byte p2,ISecureElementListener listener, String packageName,byte[] uuid, int pid) throws IOException, NoSuchElementException {...ChannelAccess channelAccess = null;if (packageName != null || uuid != null) {channelAccess = setUpChannelAccess(aid, packageName, uuid, pid, false);}...return logicalChannel;}// 在 Terminal.java 同名的 setUpChannelAccess 方法里调用了 mAccessControlEnforcer.setUpChannelAccess/*** Initialize the Access Control and set up the channel access.*/ private ChannelAccess setUpChannelAccess(byte[] aid, String packageName, byte[] uuid, int pid,boolean isBasicChannel) throws IOException, MissingResourceException {ChannelAccess channelAccess =mAccessControlEnforcer.setUpChannelAccess(aid, packageName, uuid, checkRefreshTag);}
5.3.3
接下来就到了最终目的类 AccessControlEnforcerno.java
/packages/apps/SecureElement/src/com/android/se/security/AccessControlEnforcerno.java
AccessControlEnforcerno.java/** Initializes the Access Control for the Secure Element */public synchronized void initialize() throws IOException, MissingResourceException {...readSecurityProfile();...}// 后面两个方法就是完整代码了/** Sets up the Channel Access for the given Package */public ChannelAccess setUpChannelAccess(byte[] aid, String packageName, byte[] uuid,boolean checkRefreshTag) throws IOException, MissingResourceException {ChannelAccess channelAccess = null;// check result of channel access during initialization procedureif (mInitialChannelAccess.getAccess() == ChannelAccess.ACCESS.DENIED) {throw new AccessControlException(mTag + "access denied: " + mInitialChannelAccess.getReason());}// this is the new GP Access Control Enforcer implementationif (mUseAra || mUseArf) {channelAccess = internal_setUpChannelAccess(aid, packageName, uuid,checkRefreshTag);}if (channelAccess == null || (channelAccess.getApduAccess() != ChannelAccess.ACCESS.ALLOWED&& !channelAccess.isUseApduFilter())) {// 关键点来了, 摆明了就是一个系统权限问题if (mFullAccess) {// if full access is set then we reuse the initial channel access,// since we got so far it allows everything with a descriptive reason.channelAccess = mInitialChannelAccess;} else {// 错误就是在这里抛出的, mFullAccess 是个全局方法, 在初始化时调用了throw new AccessControlException(mTag + "no APDU access allowed!");}}channelAccess.setPackageName(packageName);return channelAccess.clone();}private void readSecurityProfile() {// 非 debug 模式下写死了 mFullAccess = false 的,可以将手机 root 或者使用 magisk app 修改为 Debugif (!Build.IS_DEBUGGABLE) {mUseArf = true;mUseAra = true;mFullAccess = false; // Per default we don't grant full access.} else {String level = SystemProperties.get("service.seek", "useara usearf");level = SystemProperties.get("persist.service.seek", level);if (level.contains("usearf")) {mUseArf = true;} else {mUseArf = false;}if (level.contains("useara")) {mUseAra = true;} else {mUseAra = false;}if (level.contains("fullaccess")) {// 全局只有这一处将 mFullAccess 赋值成了 true// 也就是上面 SystemProperties 配置的 "service.seek" 值起了决定性作用mFullAccess = true;} else {mFullAccess = false;} }if (!mTerminal.getName().startsWith(SecureElementService.UICC_TERMINAL)) {// ARF is supported only on UICC.mUseArf = false;}Log.i(mTag, "Allowed ACE mode: ara=" + mUseAra + " arf=" + mUseArf + " fullaccess="+ mFullAccess);}
5.3.4 修改系统属性
在系统已经 root 的情况下,直接使用命令修改掉这两个字段的值
adb shell setprop ro.debuggable "1" //正常情况下 已root 设备就是 1
adb shell setprop service.seek "useara usearf fullaccess"
adb shell setprop persist.service.seek "useara usearf fullaccess"#设置后也可以查看
adb shell getprop service.seek
5.3.5 调用日志
17:56:39.838 4481 SecureElementService com.android.se D getReaders() eSE1
17:56:39.838 4481 SecureElementService com.android.se D getReaders() SIM1
17:56:39.838 4481 SecureElementService com.android.se D getReaders() SIM2
17:56:39.838 4481 SecureElementService com.android.se I isCtsRunning false
17:56:39.838 4481 SecureElementService com.android.se D getReader() SIM1
17:56:39.839 4481 SecureElementService com.android.se D getReader() SIM2
17:56:39.839 4481 SecureElementService com.android.se D getReader() eSE117:56:40.856 4481 SecureElementService com.android.se I openLogicalChannel() AID = 00a4040000, P2 = 0
17:56:40.857 4481 SecureElement-Terminal-eSE1 com.android.se I mzoma Access Check, pkg is com.ccsmec.sim
17:56:40.859 4481 SecureElement-Terminal-eSE1 com.android.se W Enable access control on logical channel for com.ccsmec.sim
17:56:40.859 4481 SecureElement-AccessControlEnforcer com.android.se I setUpChannelAccess() aid = 00a4040000
17:56:40.859 4481 SecureElement-AccessControlEnforcer com.android.se I setUpChannelAccess() packageName = com.ccsmec.sim
17:56:40.860 4481 SecureElement-Terminal-eSE1 com.android.se I mzoma Access Check, pkg is com.ccsmec.sim
17:56:40.915 4481 SecureElement-AccessControlEnforcer com.android.se I checkCommand() : Access = ALLOWED APDU Access = ALLOWED Reason = Unspecified
17:56:40.928 4481 SecureElement-Terminal-eSE1 com.android.se I Sent : 81cadf2000
17:56:40.928 4481 SecureElement-Terminal-eSE1 com.android.se I Received : df20084a46485fc8d7b30b9000
17:56:40.928 4481 SecureElement-AraController com.android.se I Refresh tag unchanged. Using access rules from cache.
17:56:40.962 4481 SecureElement-AccessControlEnforcer com.android.se I getAccessRule() appCert = dba6d7c5929b57a81387d144da3d04a5a3f32137
17:56:40.962 4481 SecureElement-AccessControlEnforcer com.android.se I getAccessRule() appCert = a89c2be6dbe8eefbcce61b5c02a04d99862ef42fbb147c1a7f1d14e742e7db54
17:56:40.964 4481 SecureElement-AccessRuleCache com.android.se I findAccessRule() not found
Android 13中的 Open Mobile API相关推荐
- Android 13 第一个开发者版本来了,网友直呼:Android 12 还没玩透!
整理 | 苏宓 出品 | CSDN(ID:CSDNnews) 2 月 10 日,Google 宣布 Android 13 首个预览版面向开发者开放,此版本重点聚焦隐私和安全.提供开发者生产力.应用兼容 ...
- Android 13:一文看懂两大重磅升级
以下内容来自公众号code小生,关注每日干货及时送达 来源 | 快科技 近日,谷歌2022年I/O开发者大会召开,作为重头戏,Android 13 Beta 2版本公开,面向"亲儿子&quo ...
- 最新版发布,快扶我起来,Android 13 现已正式发布至 AOSP
作者 / Seang Chau, VP of Engineering 今天,Android 13 的源代码推送至 Android 开源项目 (AOSP),并正式发布最新版本的 Android.对于开发 ...
- Android 13 功能和变更列表
类别 类型 名称 核心功能 新功能和 API OpenJDK 11 更新 Android 13 包含许多更新,以与 OpenJDK 11 LTS 版本保持一致. 图形 新功能和 API 可编程的着色器 ...
- Android 13 新特性及适配指南
Android 13(API 33)于 2022年8月15日 正式发布(发布时间较往年早了一些),正式版Release源代码也于当日被推送到AOSP Android开源项目. 截止到笔者撰写这篇文章时 ...
- 欢迎体验 | Android 13 开发者预览版 2
作者 / Dave Burke, VP of Engineering 上个月,我们 发布了 Android 13 的首个开发者预览版.Android 13 围绕我们的核心主题打造,即隐私和安全.开发者 ...
- Android 13 开发者预览版
1.行为变更 1.1隐私权 1.1.1针对附近 Wi-Fi 设备的新运行时权限 Android 13 引入了 NEARBY_WIFI_DEVICES 运行时权限,该权限属于 NEARBY_DEVICE ...
- Android 13 更新内容(开发需知)
功能和 API 概览 Android 13 面向开发者引入了一些出色的新功能和 API.以下几部分内容可帮助您了解适用于您的应用的功能并开始使用相关 API. 有关新增.修改和移除的 API 的详细列 ...
- Android 13 适配指南
Android 13 适配指南 是的,你没看错,现在就要带你适配 Android13. 2022 的Google I/O 发布了 Android 13 beta 2 和 Android 13 Beta ...
- Android 13 来了,你不能错过的 10+ 新变化~
作者:TechMerger 当部分 Pixel 用户还在纠结要不要试试面向大屏设备优化的 Android 12L Beta 时,Google 毫不客气地放出了 Android 13 首个开发者预览版本 ...
最新文章
- mysql 去重取出最小值_5000字总结MySQL单表查询,新手看这一篇足够了!
- 国外公司制造无需人参与的AI训狗机器
- java对象转为json字符串
- 机器视觉 · 立体相机
- shell排序_Java后端技术精选:希尔排序
- SVN系列操作(一)
- java切面1.6需要的包_Java技术 AspectJ
- C++(STL):26 ---关联式容器set用法
- webpack+vue-cli 中proxyTable配置接口地址代理
- php多人点餐可以看到对方点的菜,千万不要小看你身边那个会点菜的人,因为
- linux p7zip密码,linux下7zip使用方法
- MYSQL SHELL 到底是个什么局 剑指 “大芒果”
- tomcat 无端口号访问
- 计算机视觉实战(八)直方图与傅里叶变换
- FileUtils(文件读写操作工具类)
- 一个很酷的快速开发代码生成器系统
- delphi 剪切板变量_Delphi操作剪贴板
- 微信小程序添加服务器域名
- Chrome的插件安装与使用
- 数据驱动的软件智能化开发| ChinaOSC
热门文章
- 关于三极管集电极电压1V以上后伏安特性曲线不右移
- win7计算机远程桌面连接,Win 7连向Win7系统远程桌面连接设置
- 华为手机安装debug时出现无效安装和与操作系统不兼容问题解决
- linux dropbox自动同步,CentOS 6.x使用dropbox并定时同步数据
- 解决build.gradle文件报错No candidates found for method call xxxxxxx
- HTML期末设计——代码篇
- 最佳学习方法(10)学习方法介绍
- 打开后缀html,文件名后缀打开
- 密码学大事件! SHA-1 哈希碰撞实例
- 印度官方语言有几种_印度货币上有17种语言,你知道每种语言有多少人在用吗?...