Android 13中的 Open Mobile API

Open Mobile API Specification v3.2 简称 OMAPI

Android OMApi 接口架构和实现方式参考

Android OMApi 特性支持参考

aospxref Android 13 源码浏览网站

建议扩展阅读:

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的实现 eSEUICCmicroSD,这些带有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规范 还是有点区别
在这里插入图片描述


一、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(包括 BasicChannelLogicalChannel),Channel 中提供了发送 APDU 命令的方法。

总结步骤:

  1. 连接 SEService,需要几百毫秒时间
  2. 指定 Reader 或者 getReaders 遍历取出能使用的 Reader
  3. 通过 Reader 连接一个 Session
  4. 通过 Session 的 openBasicChannel(aid, p2) / openLogicChannel(aid, p2) 打开一个 Channel
  5. 通过 Channel.transmit(cmd) 发送 APDU 指令
  6. 通过 Channel.close() 关闭通道
  7. 通过 Session 关闭会话,或者通过 Reader.closeSessions() 关闭所有已打开的会话
  8. 通过 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
	// 年轻人我劝你不要去看后面的源码了, 深得很你把握不住, 硬件层的各种 CPP
	mSEHal.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 实例,主要是 eSEUICC(SIM),它们可能会有多个,类似(eSE1、eSE2、SIM1、SIM2)。

这里即是上层调用的 openSeesionopenBasicChannel()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? = null
    private 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=false
                for (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? = null

        mUICCReader?.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.selectResponse
                    log("返回的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!

这个问题出现是正常的,因为OMAPI的规范里是有AC规则校验的,正常情况下应用什么都没做去访问SE肯定是没有权限的。
解决方法1:如果是使用SIM-SE,就把应用hash写入SIM卡,如果是使用eSE,就把应用hash写入手机系统;
解决方法2:修改系统源码,注释掉AC规则校验的相关逻辑,我尝试了下面的方式修改校验,结果是无效的

5.3.1

查看源码:

/frameworks/base/omapi/java/android/se/omapi/Session.java

	
	Session.java

    public @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.aidl
                ISecureElementChannel 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.javasetUpChannelAccess(),该类在 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 procedure
        if (mInitialChannelAccess.getAccess() == ChannelAccess.ACCESS.DENIED) {
                throw new AccessControlException(mTag + "access denied: " + mInitialChannelAccess.getReason());
            }

        // this is the new GP Access Control Enforcer implementation
        if (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 修改为 Debug
        if (!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() eSE1

17: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
  • 4
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 25
    评论
要调用OPENAI API实现chatgpt对话,需要按照以下步骤进行操作: 1. 首先,注册OPENAI API账户并获取API密钥。 2. 创建一个Android项目,并添加相应的依赖项,以便可以使用HTTP请求库。 3. 使用HTTP请求库发送POST请求到OPENAI API,以获取chatgpt对话结果。可以使用以下代码片段: ``` String url = "https://api.openai.com/v1/engines/davinci-codex/completions"; String apiKey = "YOUR_API_KEY"; JSONObject postData = new JSONObject(); postData.put("prompt", "Hello, how are you?"); postData.put("max_tokens", 50); postData.put("temperature", 0.7); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + apiKey) .POST(HttpRequest.BodyPublishers.ofString(postData.toString())) .build(); HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); String responseBody = response.body(); ``` 在上面的代码,我们首先指定了OPENAI API的URL和API密钥。然后,我们创建了一个JSON对象,其包含要发送给API的参数,例如“prompt”(对话的开头),“max_tokens”(要生成的最大令牌数)和“temperature”(输出的随机性)。最后,我们使用HTTP请求库发送POST请求,并从响应获取对话结果。 4. 解析OPENAI API的响应,并将结果显示在Android应用程序。可以使用以下代码片段: ``` JSONObject responseJson = new JSONObject(responseBody); JSONArray choices = responseJson.getJSONArray("choices"); if (choices.length() > 0) { JSONObject choice = choices.getJSONObject(0); String text = choice.getString("text"); // display text in app UI } else { // handle error } ``` 在上面的代码,我们首先将响应体解析为JSON对象。然后,我们从响应提取对话结果,将其显示在应用程序的用户界面。 总之,要调用OPENAI API实现chatgpt对话,需要使用HTTP请求库发送POST请求,并解析响应以获取对话结果。然后,将结果显示在Android应用程序

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 25
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

柯基爱蹦跶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值