一、接入指南
1、直投流程
为了保障安全性,直投2.0针对开放能力做了校验。直投2.0流程如下:
2、直投2.0 Deeplink 参数介绍
2.1deeplink必要参数
直投的deeplink都是以mimarket://details开头:
参数名 | 参数类型 | 参数含义 |
id | string | 跳转的具体应用包名 |
appClientId | string | 小米应用商店为您分配的业务编码 |
nonce | string | 时间戳参数(生成算法见附录),默认24小时后过期 |
sign | string | deeplink参数加密后的签名 |
senderPackageName | string | 应用跳转来源,即媒体应用包名,可用于后续定向回传下载状态 |
detailStyle | int | 详情页交互形式 1:全屏 |
2.2获取和使用deeplink的时序图
2.3直投2.0 deeplink 的生成和使用
生成广告时,每次deeplink都需要重新生成
2.3.1前提:生成密钥
- 密钥A:商店生成
- 密钥B:调用方生成
1、使用keytool工具生成非对称密钥B和公钥证书,其中密钥B要求长度是RSA2(1024位)、格式是PKCS12,公钥证书要求是.cer格式。
参考步骤:
// 1、生成test.keystore文件
keytool --genkeypair -alias serverkey -sigalg SHA256withRSA -keyalg RSA -keysize 1024 -keystore test.keystore
// 2、生成test.cer文件
keytool -exportcert -keystore test.keystore -storepass xxxx -file test.cer -alias serverkey -rfc
// 3、转换test.p12文件
keytool -importkeystore -srckeystore test.keystore -srcstoretype jks -srcstorepass xxx -srcalias serverkey -deststoretype pkcs12 -deststorepass xxxx -destalias serverkey -destkeystore test.p12
2、将生成好的*.cer公钥证书打包成zip文件, 找销售同学协助邮件(或参考能力申请文档)申请密钥A
3、小米应用商店审核通过后,会分配业务编码(appClientId)和密钥A给到申请业务
2.3.2第一步:deeplink参数拼接
时间戳参数nonce的值按以下算法生成:
import java.security.SecureRandom;
public static String generateNonce() {
Random random = new SecureRandom();
long n = random.nextLong();
int m = (int) (System.currentTimeMillis() / (1000 * 60));
return n + ":" + m;
}
将这些deeplink参数按照参数名的字典序进行升序排序,再将这些参数名及其对应的参数值拼接起来,得到一个字符串,示例如下:
appClientId=test&detailStyle=1&id=com.miui.fm&nonce=-8162956393368422160:27234970&senderPackageName=com.android.browser
2.3.3第二步:deeplink参数签名
为了保证deeplink的安全性,您需要使用商店分配的密钥A与您自行生成的密钥B对第一步拼接得到的字符串进行签名加密操作,加密整体流程如下图所示:
(1)采用HmacSHA256算法对第二步生成的字符串进行加密,加密时需要使用应用商店分配的密钥A,加密得到的字符串即为原始签名,示例如下:778abf7c498d7328fc7d66a20721fde13f9126860cbd913b2bacf2fda27ce547。加密的示例代码如下:
String originalSign = sha256Hmac("appClientId=test&detailStyle=1&id=com.miui.fm&nonce=-8162956393368422160:27234970&senderPackageName=com.android.browser", "${商店分配的密钥A}");
(2)采用RSA加密算法对原始签名进行加密,加密时需要使用您自己生成的密钥B。示例代码如下,其中用到的getPrivateKeyByPKCS12和encryptByPrivateKey方法:
//获取私钥
PrivateKey privateKey = getPrivateKeyByPKCS12("${密钥B对应的p12格式文件路径}", "${密钥B对应的密码}");
//RSA加密
byte[] result = encryptByPrivateKey(originalSign.getBytes(), privateKey);
(3)对RSA加密的结果进行base64编码,示例代码如下:
String strBase64 = Base64.encodeBase64String(result);
得到的base64字符串如下:
lJro03hzdk1SsGgLJseBJqORtCAHQd6bkFbzAkYYUJc+03B0snkSzsS2UdK5+DRVt0WaLfd7VslS
qRl5EBa5X26m8+vLQxYdEDu4GSDrdKLjif0n5Zlxivc8Z6ZcZzJpA5laGgNcSG5z3cFBeQO5XRjc
Jkfx9rOrcsHuG78ufRKdkKWf732QTXG5lzeiq1/iIyhqhtvCp/BvM9JScwZZXtWjZ/qXXTMryOsD
eQx9YlyRo3bmk5AnTWjN1n86NRtodTjFCf4U40H6V/Z+Yuwr0Px+NT7MN6Z0SA9AGrNdz/4kUq3F
IPq7SivAy3vKgHQs6ge8J4vifYtXWtEWtq5BBA==
(4)对base64字符串进行url编码,示例代码如下:
String sign = URLEncoder.encode(strBase64, "utf-8");
url编码后得到的是最终的签名,即为参数sign的值,形如:
lJro03hzdk1SsGgLJseBJqORtCAHQd6bkFbzAkYYUJc%2B03B0snkSzsS2UdK5%2BDRVt0WaLfd7VslS%0D%0AqRl5EBa5X26m8%2BvLQxYdEDu4GSDrdKLjif0n5Zlxivc8Z6ZcZzJpA5laGgNcSG5z3cFBeQO5XRjc%0D%0AJkfx9rOrcsHuG78ufRKdkKWf732QTXG5lzeiq1%2FiIyhqhtvCp%2FBvM9JScwZZXtWjZ%2FqXXTMryOsD%0D%0AeQx9YlyRo3bmk5AnTWjN1n86NRtodTjFCf4U40H6V%2FZ%2BYuwr0Px%2BNT7MN6Z0SA9AGrNdz%2F4kUq3F%0D%0AIPq7SivAy3vKgHQs6ge8J4vifYtXWtEWtq5BBA%3D%3D%0D%0A
(5)对于生成的签名sign,建议您使用公钥对其进行解密,验证结果是否与原始签名一致。示例代码如下:
//url解码
String enResult = URLDecoder.decode(sign, "utf-8");
//获取公钥
PublicKey publicKey = getPublicKeyByX509Cer("${密钥B对应的公钥证书的文件路径}");
//使用公钥解密
byte[] decodedResult = decryptByPublicKey(Base64.decodeBase64(enResult), publicKey);
//判断解密结果与原始签名是否一致
System.out.printlin(decodedResult.equals(originalSign));
2.3.4第三步:deeplink生成
将第二步得到的签名作为参数sign的值拼接到第一步生成的字符串之后,然后在该字符串的开头拼上mimarket://details?前缀,生成最终完整的deeplink,示例如下:
mimarket://details?appClientId=test&detailStyle=1&id=com.miui.fm&nonce=-8162956393368422160:27234970&senderPackageName=com.android.browser&sign=lJro03hzdk1SsGgLJseBJqORtCAHQd6bkFbzAkYYUJc%2B03B0snkSzsS2UdK5%2BDRVt0WaLfd7VslS%0D%0AqRl5EBa5X26m8%2BvLQxYdEDu4GSDrdKLjif0n5Zlxivc8Z6ZcZzJpA5laGgNcSG5z3cFBeQO5XRjc%0D%0AJkfx9rOrcsHuG78ufRKdkKWf732QTXG5lzeiq1%2FiIyhqhtvCp%2FBvM9JScwZZXtWjZ%2FqXXTMryOsD%0D%0AeQx9YlyRo3bmk5AnTWjN1n86NRtodTjFCf4U40H6V%2FZ%2BYuwr0Px%2BNT7MN6Z0SA9AGrNdz%2F4kUq3F%0D%0AIPq7SivAy3vKgHQs6ge8J4vifYtXWtEWtq5BBA%3D%3D%0D%0A
2.3.5第四步: deeplink下发和跳转
调用方的服务端生成deeplink后下发到调用方App,在App上通过deeplink跳转应用商店,如果商店验签通过,则会调起指定样式的详情页。客户端调用请在自身的任务栈中发起下载,不要设置intent的 FLAG_ACTIVITY_NEW_TASK,这样不会跳转到商店而中断用户行为。示例代码如下:
var intent = Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse("${生成的deeplink}"));
startActivity(intent);
二、功能说明
1. 状态回传
通过接入状态回传,小米应用商店会将直投中关键链路节点的信息回传给调用的媒体APP。状态回传能力通过后台配置更改来支持,与调用方的本身的权限相关,不需要使用deeplink来控制。
回传的信息包括:
- 详情页开放能力的权限校验结果
- 下载进度
- 下载状态
使用Android系统的广播方式来传递信息,调用方需要注册相应的广播来接收回调信息。
1.1 接收权限校验结果
回传的参数信息
参数 | 描述 | 类型 |
styleCheckResult | 样式鉴权结果:0成功、1失败 | int |
packageName | 下载应用包名 | String |
示例代码
object DirectMailStatusReceiver: BroadcastReceiver() {
val CHECK_RESULT = "com.xiaomi.market.DIRECT_MAIL_CHECK_RESULT"
fun register(){
appContext.registerReceiver(this, IntentFilter(CHECK_RESULT))
}
override fun onReceive(context: Context?, intent: Intent?) {
when(intent?.action){
CHECK_RESULT ->{
val data = intent.extras
var styleResult = data?.getInt("styleCheckResult")
var packageName = data?.getString("packageName")
}
}
}
}
public class DirectMailStatusReceiver extends BroadcastReceiver {
private static final String CHECK_RESULT = "com.xiaomi.market.DIRECT_MAIL_CHECK_RESULT";
public void register(){
appContext.registerReceiver(this, new IntentFilter(CHECK_RESULT))
}
@Override
public void onReceive(Context context, Intent intent) {
if (CHECK_RESULT.equals(intent.getAction())) {
Bundle data = intent.getExtras();
int styleResult = data.getInt("styleCheckResult");
String packageName = data.getString("packageName");
}
}
}
1.2 接收状态变更结果
需要注册的广播
action:com.xiaomi.market.DIRECT_MAIL_STATUS
回传的参数信息
参数 | 描述 | 类型 |
statusCode | 状态码: 1001 // 商店CTA联网授权拒绝 1004 //接口请求失败 1005 //状态回传鉴权失败 3001 //开始下载 3002 //下载成功 3007 //开始安装 3008 //安装成功 5001 //详情页内打开app | int |
packageName | 下载应用包名 | String |
示例代码
object DirectMailStatusReceiver: BroadcastReceiver() {
val DM_STATUS = "com.xiaomi.market.DIRECT_MAIL_STATUS"
fun register(){
appContext.registerReceiver(this, IntentFilter(DM_STATUS))
}
override fun onReceive(context: Context?, intent: Intent?) {
when(intent?.action){
DM_STATUS ->{
val data = intent.extras
var statusCode = data?.getInt("statusCode")
var packageName = data?.getString("packageName")
}
}
}
}
public class DirectMailStatusReceiver extends BroadcastReceiver {
private static final String DM_STATUS = "com.xiaomi.market.DIRECT_MAIL_STATUS";
public void register(){
appContext.registerReceiver(this, new IntentFilter(DM_STATUS))
}
@Override
public void onReceive(Context context, Intent intent) {
if (DM_STATUS.equals(intent.getAction())) {
Bundle data = intent.getExtras();
int statusCode = data.getInt("statusCode");
String packageName = data.getString("packageName");
}
}
}
1.3 接收下载进度
需要注册广播
action:com.xiaomi.market.DIRECT_MAIL_DOWNLOAD_PROGRESS
回传的参数信息
参数 | 描述 | 类型 |
downloadProgress | 进度值:0-100 | int |
packageName | 下载应用包名 | String |
示例代码
object DirectMailStatusReceiver: BroadcastReceiver() {
val DOWNLOAD_PROGRESS = "com.xiaomi.market.DIRECT_MAIL_DOWNLOAD_PROGRESS"
fun register(){
appContext.registerReceiver(this, IntentFilter(DOWNLOAD_PROGRESS))
}
override fun onReceive(context: Context?, intent: Intent?) {
when(intent?.action){
DOWNLOAD_PROGRESS ->{
val data = intent.extras
var downloadProgress = data?.getInt("downloadProgress")
var packageName = data?.getString("packageName")
}
}
}
}
public class DirectMailStatusReceiver extends BroadcastReceiver {
private static final String DOWNLOAD_PROGRESS = "com.xiaomi.market.DIRECT_MAIL_DOWNLOAD_PROGRESS";
public void register(){
appContext.registerReceiver(this, new IntentFilter(DOWNLOAD_PROGRESS))
}
@Override
public void onReceive(Context context, Intent intent) {
if (DOWNLOAD_PROGRESS.equals(intent.getAction())) {
Bundle data = intent.getExtras();
int downloadProgress = data.getInt("downloadProgress");
String packageName = data.getString("packageName");
}
}
}
1.4 主动查询应用下载状态
通过ContentResolver提供的call方法
Bundle call(@NonNull Uri uri, @NonNull String method,@Nullable String arg, @Nullable Bundle extras)
请求参数
参数 | 类型 | 值 |
uri | String | Uri.parse("content://com.xiaomi.market.provider.DirectMailProvider/status")(固定) |
method | string | queryDownloadStatus (固定) |
arg | String | null |
extras | bundle | 传递一个包名列表bundle.putStringArrayList("packageNames", arrayListOf("com.xxx.xx","com.xxx.xx")) |
查询结果
返回 bundle列表 ArrayList<Bundle>
- Bundle内包含packageName(包名),status(当前状态)progress(下载进度)
key | 描述 | 类型 |
packageName | 应用包名 | string |
status | 当前状态 -1//没有查询到该应用下载信息 3001 //开始下载 3002 //下载成功 3007 //开始安装 3008 //安装成功 | int |
progress | 当前应用安装进度 -1 //非下载状态下 0-100//下载进度 | int |
查询代码示例
val bundle = Bundle()
bundle.putStringArrayList("packageNames", arrayListOf("com.xxx.xx","com.xxx.xx"))
val bundleResult = contentResolver.call(
Uri.parse("content://com.xiaomi.market.provider.DirectMailProvider/status"),
"queryDownloadStatus",
null,
bundle)
val list = bundleResult?.getParcelableArrayList<Bundle>("packageNames")
list?.forEach { item ->
Log.d(TAG,"packageName=${item?.getString("packageName")},status=${item?.getInt("status")},progress=${item?.getInt("progress")}")
}
Bundle bundle = new Bundle();
bundle.putStringArrayList("packageNames", new ArrayList<>(Arrays.asList("com.xxx.xxx", "com.xxx.xxx")));
Bundle bundleResult = getContentResolver().call(Uri.parse("content://com.xiaomi.market.provider.DirectMailProvider/status"),
"queryDownloadStatus",
null,
bundle);
if (bundleResult != null) {
List list = bundleResult.getParcelableArrayList("packageNames");
if (list != null && list.size() > 0) {
for (Object item : list) {
Bundle bundleItem = (Bundle) item;
Log.d(TAG,"packageName="+bundleItem.getString("packageName")+",status="+bundleItem.getInt("status")+",progress="+bundleItem.getInt("progress"));
}
}
}
2、虚拟归因(虚拟分包)
2.1虚拟归因流程简述
- 广告平台:
- 接入直投并申请参数分包鉴权;
- 接入商店参数分包能力,下发参数分包信息。
- 广告主:
- 广告主APP客户端对接客户端虚拟归因查询能力(本文档)
- 广告主获取到归因信息并用于广告主APP内部归因
- 广告主将归因给小米渠道的转化回传广告平台
2.2 参数传入
referrer格式要求
使用虚拟分包需要在deeplink中增加referrer字段(参考附录1)携带虚拟分包的渠道参数信息:
referrer | string | 虚拟分包参数,需要url编码(形如:source%3Dxxx%26medium%3Dcpc%26campaign%3Ddoublescore%26term%3Ddatadriven%26content%3Da) |
referrer必须包含以下参数至少1个,并且必须符合各参数的格式要求,不得携带除下列参数之外的其他参数,否则会鉴权失败:
参数 | 定义 | 格式要求 |
adGroupId | 广告组 id | 数字,最大长度 10 字符 |
campaignId | 广告计划 id | 数字,最大长度 10 字符 |
adid | 广告创意 id | 数字,最大长度 10 字符 |
channel | 智能分包渠道号 | 数字,最大长度 10 字符 |
callback | ocpx回传字段 | 自定义字符串,最大长度400字符 |
2.3 信息查询
小米应用商店客户端提供查询接口,供三方调用,使用provider查询标准接口
var cursor = contentResolver.query(uri, null, null, null, null)
注意
使用该接口将涉及contentResolver的获取,ContentResolver对象会与小米应用商店进行通信,部分场景下将被识别为关联启动,请您注意如下。
- 需要在隐私声明中加上关联启动相关条例。
- 获取用户隐私同意后,再进行接口的调用。
目标应用(指deeplink中id参数的应用包名)安装完成后可通过小米应用商店提供的接口查询信息,查询使用Android系统的ContentResolver.query接口,传入小米应用商店指定的Uri地址即可请求获取。
1、构造商店查询的uri,固定为 "content://com.xiaomi.market.provider.DirectMailProvider/referrer"
2、调用ContentResolver.query接口,第一个参数传入构造的uri
var cursor = contentResolver.query(uri, null, null, null, null)
3、解析ContentResolver.query接口返回的Cursor对象
- cursor为空,没有信息
- cursor不为空,您可以使用Cursor.getString(X)获取相关信息,X可以是信息类型的常量或常量值,如下表所示:
常量 | 常量值 | 描述 |
INDEX_PACKAGE_NAME | 0 | 包名 |
INDEX_INSTALL_FINISH_TIME | 1 | 应用安装完成时间 |
INDEX_REFERRER | 2 | referrer参数 |
INDEX_START_DOWNLOAD_TIME | 3 | 应用开始下载时间 |
注意:只有目标应用可以查询自身的信息,调用方无法查询目标应用的信息。例如,A应用通过调用小米应用商店直投能力安装了应用B,并且在调用的deeplink参数中传递了referrer参数。在应用B安装完成后,那么B应用可以通过上述接口查询到自身的referrer信息,而A应用无法查询到。
3 、拉活服务
拉活服务、拉活归因只有在4.70.0(versionCode: 40005440)及以上版本的小米应用商店客户端才支持。
3.1 接入拉活服务
接入直投2.0时,需要申请开通直投2.0拉活服务能力【按需开通拉活能力、拉活状态回传、拉活归因能力】。开通后,生成直投dp时,需要拼接 milaunch=true参数,此参数参与直投签名的鉴权。
如dp: mimarket://details?detailStyle=1&milaunch=true&xxx
3.2 接入拉活状态回传能力
通过接入拉活状态回传,小米应用商店会将拉活结果信息回传给调用的媒体APP。拉活状态回传能力通过后台配置更改来支持,与调用方的本身的权限相关,不需要使用deeplink来控制。
3.2.1 需要注册的广播
action:com.xiaomi.market.DIRECT_MAIL_STATUS
3.2.2 回传的参数信息
3.2.3示例代码
object DirectMailStatusReceiver: BroadcastReceiver() {
val DM_STATUS = "com.xiaomi.market.DIRECT_MAIL_STATUS"
fun register(){
appContext.registerReceiver(this, IntentFilter(DM_STATUS))
}
override fun onReceive(context: Context?, intent: Intent?) {
when(intent?.action){
DM_STATUS ->{
val data = intent.extras
var statusCode = data?.getInt("statusCode")
var packageName = data?.getString("packageName")
}
}
}
}
3.3 接入拉活归因查询能力
说明
- 应用只能查询其自身的归因信息
- 用户手动清空小米应用商店缓存,会造成归因数据的缺失。
- 用户卸载应用后,小米应用商店缓存的归因信息会丢失。
- 对于同一包名,新保存的归因信息,会覆盖旧的归因信息。
3.3.1归因参数查询
小米应用商店客户端提供查询接口,供三方调用,使用provider查询标准接口
var cursor = contentResolver.query(uri, null, null, null, null)
注意
使用该接口将涉及contentResolver的获取,ContentResolver对象会与小米应用商店进行通信,部分场景下将被识别为关联启动,请您注意如下。
- 需要在隐私声明中加上关联启动相关条例。
- 获取用户隐私同意后,再进行接口的调用。
3.3.2请求参数
参数 | 类型 | 值 |
uriString | String | content://com.xiaomi.market.provider.DirectMailProvider/referrer/v3 |
projection | String[] | null |
selection | String | null |
selectionArgs | String[] | null |
sortOrder | String | null |
3.3.3查询结果
- 返回cursor中,如果有拉活归因信息,则查询 "launch_referrer"时,有值。对应信息为拉活信息的jsonString.
- 返回cursor中,如果没有"launch_referrer",或者"launch_referrer"对应的value为空,则表明无拉活归因信息。
3.3.4示例
private val TAG = "ReferrerTest"
private val PROVIDER_URI = "content://com.xiaomi.market.provider.DirectMailProvider/referrer/v3"
const val PACKAGE_NAME = "package_name"
const val INSTALL_REFERRER = "install_referrer"
const val LAUNCH_REFERRER = "launch_referrer"
private fun getReferrerInfoTest(path: String): String? {
var testReferrerStr = ""
var packageName: String? = null
var installReferrer: String? = null
var launchReferrer: String? = null
var cursor: Cursor? = null
val uri: Uri = Uri.parse(PROVIDER_URI + path)
val contentResolver: ContentResolver = contentResolver
try {
cursor = contentResolver.query(uri, null, null, null, null)
if (cursor != null) {
cursor.moveToFirst()
packageName = getStringOrNull(cursor, PACKAGE_NAME)
installReferrer = getStringOrNull(cursor, INSTALL_REFERRER)
launchReferrer = getStringOrNull(cursor, LAUNCH_REFERRER)
val iterator = cursor.columnNames.iterator()
while (iterator.hasNext()) {
val columnName = iterator.next()
val value = when (columnName) {
PACKAGE_NAME -> packageName
INSTALL_REFERRER -> installReferrer
LAUNCH_REFERRER -> launchReferrer
else -> null
}
Log.i(TAG, "当前返回key 为: $columnName --> vaule 为: $value")
testReferrerStr += " $columnName -> $value , \n"
}
} else {
testReferrerStr = null
Log.i(TAG, "cursor = null")
}
} catch (e: Exception) {
//处理异常
Log.e(TAG, "${e.message}")
} finally {
cursor?.close()
}
return testReferrerStr
}
private fun getStringOrNull(cursor: Cursor, column: String): String? {
return if (cursor.columnNames.contains(column)) {
cursor.getStringOrNull(cursor.getColumnIndexOrThrow(column))
} else {
null
}
}
4、注意事项
- 接入完成后,小米应用商店为您分配的业务编码(appClientId)和密钥请注意妥善保存
- 本文使用的deeplink必须由广告服务端生成然后下发到广告sdk,一定不要在客户端生成
- 一个deeplink只能单台设备使用
- 生成deeplink时建议实时生成,目前默认24小时后deeplink会过期,如果nonce中的时间戳过期会鉴权失败
三、附录
1. deeplink完整参数
时间戳参数nonce生成算法(java)
import java.security.SecureRandom;
public static String generateNonce() {
Random random = new SecureRandom();
long n = random.nextLong();
int m = (int) (System.currentTimeMillis() / (1000 * 60));
return n + ":" + m;
}
2. deeplink生成示例(java)
package rsa;
import com.alibaba.fastjson.JSONObject;
import org.apache.commons.codec.binary.Base64;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Enumeration;
public class Example {
private static final int KEY_SIZE = 2048;
private static final int GROUP_SIZE = KEY_SIZE / 8;
private static final int ENCRYPT_GROUP_SIZE = GROUP_SIZE - 11;
/**
* 私钥加密
*
* @param data
* @return
* @throws Exception
*/
public static byte[] encryptByPrivateKey(byte[] data, PrivateKey privateKey) throws Exception {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
return codeImpl(data, cipher, ENCRYPT_GROUP_SIZE);
}
/**
* 公钥解密
*
* @param data
* @return
* @throws Exception
*/
public static byte[] decryptByPublicKey(byte[] data, PublicKey publicKey) throws Exception {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, publicKey);
return codeImpl(data, cipher, GROUP_SIZE);
}
private static byte[] codeImpl(byte[] data, Cipher cipher, int groupSize) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int idx = 0;
while (idx < data.length) {
int remain = data.length - idx;
int segsize = remain > groupSize ? groupSize : remain;
baos.write(cipher.doFinal(data, idx, segsize));
idx += segsize;
}
return baos.toByteArray();
}
/**
* 获取私钥
*
* @param p12Is
* @param password
* @return
* @throws Exception
*/
public static PrivateKey getPrivateKeyByPKCS12(InputStream p12Is, String password) throws Exception {
try {
KeyStore ks = KeyStore.getInstance("PKCS12");
char[] pwd = null;
if ((password == null) || password.trim().equals("")) {
pwd = null;
} else {
pwd = password.toCharArray();
}
ks.load(p12Is, pwd);
Enumeration<String> aliases = ks.aliases();
String keyAlias = null;
if (aliases.hasMoreElements()) {
keyAlias = aliases.nextElement();
}
return (PrivateKey) ks.getKey(keyAlias, pwd);
} finally {
if (p12Is != null) {
try {
p12Is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
public static PrivateKey getPrivateKeyByPKCS12(String p12File, String password) throws Exception {
return getPrivateKeyByPKCS12(new FileInputStream(p12File), password);
}
/**
* 获取公钥
*
* @param cerfile
* @return
* @throws Exception
*/
public static PublicKey getPublicKeyByX509Cer(String cerfile) throws Exception {
return getPublicKeyByX509Cer(new FileInputStream(cerfile));
}
private static PublicKey getPublicKeyByX509Cer(InputStream x509Is) throws Exception {
try {
CertificateFactory certificatefactory = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate) certificatefactory.generateCertificate(x509Is);
System.out.println(JSONObject.toJSONString(cert));
System.out.println(cert.getIssuerDN().getName());
System.out.println(cert.getIssuerX500Principal().getName());
return cert.getPublicKey();
} finally {
if (x509Is != null) {
try {
x509Is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
/**
* sha256_HMAC加密
*
* @param message
* 消息
* @return 加密后字符串
*/
public static String sha256Hmac(String message, String serverkey) {
String hash = "";
try {
Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
SecretKeySpec secret_key = new SecretKeySpec(serverkey.getBytes(), "HmacSHA256");
sha256_HMAC.init(secret_key);
byte[] bytes = sha256_HMAC.doFinal(message.getBytes());
hash = byteArrayToHexString(bytes);
} catch (Exception e) {
System.out.println("Error HmacSHA256 ===========" + e.getMessage());
}
return hash;
}
/**
* 将加密后的字节数组转换成字符串
*
* @param b
* 字节数组
* @return 字符串
*/
public static String byteArrayToHexString(byte[] b) {
StringBuilder hs = new StringBuilder();
String stmp;
for (int n = 0; b != null && n < b.length; n++) {
stmp = Integer.toHexString(b[n] & 0XFF);
if (stmp.length() == 1)
hs.append('0');
hs.append(stmp);
}
return hs.toString().toLowerCase();
}
public static void main(String[] args) throws Exception {
// 原始数据(所有按照按照ascii升序排列)
String info = "appClientId=test&detailStyle=1&id=com.miui.fm&nonce=-8162956393368422160:27234970&senderPackageName=com.android.browser";
// 加签
String originalSign = sha256Hmac(info, "${商店分配的密钥A}");
System.out.println("生成的签名:" + originalSign);
// 对签名利用私钥加密保护
// 获取私钥
PrivateKey privateKey = getPrivateKeyByPKCS12("${密钥B对应的p12格式文件路径}", "${密钥B的密码}");
// RSA加密
byte[] result = encryptByPrivateKey(originalSign.getBytes(), privateKey);
// base64编码
String strBase64 = Base64.encodeBase64String(result);
// url编码
String sign = URLEncoder.encode(strBase64, "utf-8");
// 传输 到商店。。。。。。。。。。。。。。。。。。。经过网络传输
// url解码
String enResult = URLDecoder.decode(sign, "utf-8");
// 获取公钥
PublicKey publicKey = getPublicKeyByX509Cer("${密钥B对应的公钥证书的文件路径}");
// 使用公钥解密
byte[] decodedResult = decryptByPublicKey(Base64.decodeBase64(enResult), publicKey);
String decodedSign = new String(decodedResult, "utf-8");
System.out.println("解密后的签名:" + decodedSign);
//判断解密结果与原始签名是否一致
System.out.println(decodedSign.equals(originalSign));
}
}
2.1 测试用例
注:仅用于本地代码测试验证
2.1.1 测试密钥数据
用来测试的私钥(密钥B)
名称:test.p12
地址:https://kpan.mioffice.cn/webfolder/ext/K8Lz8GJFMuw%40?n=0.9659223941950801
密码:dm11
https://xiaomi.f.mioffice.cn/docx/doxk4D1TsYWSmpFdP8jfLV4Q3vb#doxk4DMAFvuiOi9ytNqzp6wTdgg
密码:123456
用来测试的公钥(密钥B对应的公钥证书)
名称:test.cer
地址:https://kpan.mioffice.cn/webfolder/ext/7k5T4od%23yx4%40?n=0.5354798806971743
密码:dm11
https://xiaomi.f.mioffice.cn/docx/doxk4D1TsYWSmpFdP8jfLV4Q3vb#doxk4G3B6qOsQppYGLY6e65ei9e
-----BEGIN CERTIFICATE-----
MIICcjCCAdugAwIBAgIEOTfYLDANBgkqhkiG9w0BAQsFADBsMRAwDgYDVQQGEwdV
bmtub3duMRAwDgYDVQQIEwdVbmtub3duMRAwDgYDVQQHEwdVbmtub3duMRAwDgYD
VQQKEwdVbmtub3duMRAwDgYDVQQLEwdVbmtub3duMRAwDgYDVQQDEwdVbmtub3du
MB4XDTIzMTExNDEyMTgwNVoXDTI0MDIxMjEyMTgwNVowbDEQMA4GA1UEBhMHVW5r
bm93bjEQMA4GA1UECBMHVW5rbm93bjEQMA4GA1UEBxMHVW5rbm93bjEQMA4GA1UE
ChMHVW5rbm93bjEQMA4GA1UECxMHVW5rbm93bjEQMA4GA1UEAxMHVW5rbm93bjCB
nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAh6xO38mlOEzEKmzun5of8I3Fojgc
VdW4vv32ZcDFmyUMum9wG7R5Tny4C1RWp/HCmlGEE3TizxNpqpE1x7dliUP32l0E
SkMUWlIKpWXAEBJgtRmcoW0lG3yZjN+ytfpRDHV+GWathEBKyJsPZbGg5U1X72hR
7VS7NU0D9N3qFk8CAwEAAaMhMB8wHQYDVR0OBBYEFBJTHr3IcPrfYPKnrZIUCzi9
wpHGMA0GCSqGSIb3DQEBCwUAA4GBAELvU1FNIrjJw/DhlowZk4SLkSXZYpRGKsdT
boY1tpwzOPci0c0vBghhTrD6QPMFGbRZaOgr4MrgOQPL/ndqYX7EPypXImdyyHgO
l6l2qG4WNHhJHb3HjgWWtZTTDh2vm7U79A8GUdDUiOSCeAEsb64X007TE76kXGYn
wZoAbC0L
-----END CERTIFICATE-----
用来测试的商店分配的密钥A
wcEvOZqgNtOURyNMdOm8Rg==
2.1.2 测试输出数据
- 原始数据:
appClientId=test&detailStyle=1&id=com.miui.fm&nonce=-8162956393368422160:27234970&senderPackageName=com.android.browser
- 原始签名:
c0ce1c8f64b568236353aa920c662fde6043f3d0c1990db129aae59ef55f8229
- 私钥加密后Base64编码后的签名:
OxKP1KgHoSdKmqCyjHTd4Y7CiLs21e/YtYS9f6jX+Vey+S6ZOJwxy+K20j/jp35iuuVf1/W8PrB8
0fANFrEOFaAZvvcrMNKExuzIyiDeISGpHffO0ooNCU5kUf69DKZE2eT7LpI416uu9irXsJq3WDbt
MlLQa+pBDBKEK9KysQY=
- URL编码后的签名(即DP上的签名):
OxKP1KgHoSdKmqCyjHTd4Y7CiLs21e%2FYtYS9f6jX%2BVey%2BS6ZOJwxy%2BK20j%2Fjp35iuuVf1%2FW8PrB8%0D%0A0fANFrEOFaAZvvcrMNKExuzIyiDeISGpHffO0ooNCU5kUf69DKZE2eT7LpI416uu9irXsJq3WDbt%0D%0AMlLQa%2BpBDBKEK9KysQY%3D%0D%0A
3. 常见直投错误码
错误码 | 问题说明 |
11、16 | 参数错误。请检查deeplink必须参数是否完整。 |
12 | 链接过期。生成deeplink时建议实时生成,目前默认24小时后deeplink会过期,如果nonce中的时间戳过期会鉴权失败。 |
13 | 签名无效。一般是由于加解密或编码错误导致的,可以参考附录2的测试用例进行排查。 |
14 | 无效设备。一个deeplink只能单台设备使用。 |
15 | 链接使用超限。单个Deeplink在单台设备上30min中内的请求次数,默认上限为30次。 |
18 | 调起媒体错误。deeplink上的senderPackageName参数与实际调起deeplink的媒体包名不一致。 |
19 | 直投编码错误。可以联系商店侧进行直投配置排查。 |
24 | 媒体不在白名单。 如果未配置媒体白名单,则可能是以上各种错误导致鉴权失败,然后触发兜底鉴权逻辑从而返回该错误码,需要联系商店侧技术人员进行进一步排查。 |
28 | 直投当前阶段仅开放应用品类,对游戏品类不适用 |
四、常见问题QA
Q1: “客户端接入时,客户端调用请在自身的任务栈中发起下载,不要设置intent的 FLAG_ACTIVITY_NEW_TASK”,这个是不是必须的?
A1: 非必须,客户端调用请在自身的任务栈中发起下载,不要设置intent的 FLAG_ACTIVITY_NEW_TASK,目的是这样不会跳转到商店而中断用户行为,提高下载率。当未设置FLAG_ACTIVITY_NEW_TASK时,startActivity 时,不要使用application/service的context,需要使用activity的context。
Q2: 接入状态回传了,但没收到状态回传结果,只收到了1005(状态回传鉴权失败)code
A2:这是由于状态回传鉴权失败了。具体原因可能有:
a、实际调起Deeplink的包名校验失败
- 实际调起Deeplink的包名和Deeplink中的senderPackageName不一致
- 实际调起Deeplink的包名没有加入直投配置中媒体白名单的授权
b、Deeplink已经被使用过,一个deeplink只能单台设备使用,否则可能会鉴权失败
c、签名校验失败
- 签名生成方法错误,具体可根据示例代码检查签名生成方法是否有误
- 签名已过期,每个Deeplink是有效期的,根据直投配置中的链接有效期进行判断
d、直投配置没有授权状态回传能力
Q3: 【状态回传】如果在应用商店下载一半,然后从微博导流过来继续下,会向微博发送后续下载完成,开始安装这些状态的广播吗?
A3: 不会发送的,状态回传和最开始发起下载任务的callerPackage有关。会回传给最后一次发起下载的callerPackageName。发起下载是指发起原始下载(未下载→开始下载)
Q4:senderPackageName是什么?有没有什么限制?
A4:senderPackageName是媒体APP包名或宿主APP包名,比如从小米浏览器跳转商店详情页,senderPackageName就是小米浏览器的包名。senderPackageName有安全性限制,如果广告平台/媒体有诉求的话,可以将指定的宿主APP包名加入安全授权白名单
Q5:证书、密钥B、appClientId和密钥A有没有过期时间?
A5:都是一次性生成好的,没有过期时间
Q6:如果宿主APP没有广告SDK,sdkVersion该如何填?
A6:可以填宿主APP的版本号
Q7:referrer如何填写?source, medium, campaign, term, content 中哪些是必须参数?
A7:source, medium, campaign, term, content是归因参数的模板规范,根据合作方需求可以选择其中任何一个或几个,非必填
content://com.xiaomi.marked.provider.DirectMailProvider 接口
Q8:此接口是客户端需要调用的接口吗?
A8:是的,调用provider标准接口查询商店是否支持相应功能
Q9:此接口的调用是否会影响性能?
A9:不会,本地测试的查询时间平均5ms左右