Google Play 内嵌安装(SDK)

本页介绍了第三方 SDK 如何集成内嵌安装,这是 Google Play 的一项新测试功能,可在半屏界面中显示 Google Play 应用产品详情。借助内嵌安装,用户无需离开应用上下文即可体验顺畅的应用安装流程。

第三方 SDK 开发者可以将内嵌安装功能集成到其 SDK 中,以便使用这些 SDK 的应用开发者能够为其应用启用内嵌安装。

要求

如需在应用中显示内嵌安装半屏界面,请执行以下操作:

  • 最低 Google Play 版本必须为 40.4
  • Android API 级别必须为 23 或更高级别

流程架构

内嵌安装流程架构如下图所示:

图 1:内嵌安装流程架构概览。
  1. Google Play 服务器会生成经过身份验证的关联数据加密 (AEAD) 加密密钥,并将这些密钥提取到 Google Cloud Platform (GCP) Secret Manager 实例中。
  2. 第三方集成商从 GCP Secret Manager 检索 AEAD 密钥。
  3. 第三方集成商会对内嵌安装 Intent 数据进行加密,生成在用于调用内嵌安装 intent 的深层链接中传递的密文,并在响应中将深层链接发送给客户端。
  4. 当用户访问深层链接时,Google Play 应用会处理该 intent。

如需将第三方 SDK 配置为使用内嵌安装流程,请完成以下步骤。

在 Google Cloud 项目中创建服务账号

在此步骤中,您将使用 Google Cloud 控制台设置服务账号。

  1. 设置 Google Cloud 项目:
    • 创建 Google Cloud 组织。创建 Google Workspace 或 Cloud Identity 账号并将其与您的域名相关联后,系统会自动创建组织资源。如需了解详情,请参阅创建和管理组织资源
    • 使用在上一步中创建的 Google Cloud 账号登录 GCP Console,然后创建一个 Google Cloud 项目。如需了解详情,请参阅创建 Google Cloud 项目
  2. 在创建的 Google Cloud 项目中创建一个服务账号。服务账号用作 Google Cloud Identity,代表您的服务器访问对称密钥。如需了解详情,请参阅创建服务账号
  3. 使用在意向表单中输入的同一 Google Workspace 客户 ID (GWCID) / Dasher ID。
  4. 创建并下载相应服务账号的私钥。
  5. 为该服务账号创建密钥。如需了解详情,请参阅创建服务账号密钥
  6. 下载服务账号密钥并将其保存在服务器上,以便随时访问,因为该密钥用于身份验证,以访问对称密钥的 Google Cloud 资源。有关详情,请参阅获取服务账号密钥

检索凭据

在此步骤中,您需要从 Secret Manager 中检索对称密钥,并将其安全地存储在您自己的服务器存储空间中(例如,存储在 JSON 文件中)。此密钥用于生成内嵌安装数据密文。

secret_id/secretId 值是指 Secret Manager 中的 Secret 名称;此名称是通过在 Play 提供的 sdk_id 值前面添加 hsdp-3p-key- 生成的。例如,如果 sdk_idabc,则 Secret 名称为 hsdp-3p-key-abc

秘密版本每周二下午 2 点(世界协调时间)更新一次。第二新的密钥在下一次轮替之前会继续有效,并且密钥材料应每周重新提取和存储。

Python 示例

以下代码示例使用存储在 JSON 文件中的访问令牌来访问 GCP Secret Manager 中的密钥材料,并将其打印到控制台。

#!/usr/bin/env python3
# Import the Secret Manager client library.
from google.cloud import secretmanager
from google.oauth2 import service_account
import google_crc32c

# Create a service account key file.
service_account_key_file = "<json key file of the service account>"
credentials = service_account.Credentials.from_service_account_file(service_account_key_file)

# Create the Secret Manager client.
client = secretmanager.SecretManagerServiceClient(
  credentials=credentials
)

# Build the resource name of the secret version.
name = f"projects/prod-play-hsdp-3p-caller-auth/secrets/<secret_id>/versions/latest"

# Access the secret version.
response = client.access_secret_version(request={"name": name})

# Verify payload checksum.
crc32c = google_crc32c.Checksum()
crc32c.update(response.payload.data)
if response.payload.data_crc32c != int(crc32c.hexdigest(), 16):
    print("Data corruption detected.")

# A keyset created with "tinkey create-keyset --key-template=AES256_GCM". Note
# that this keyset has the secret key information in cleartext.
keyset = response.payload.data.decode("UTF-8")

# WARNING: Do not print the secret in a production environment. Please store it
# in a secure storage.
with open('<key file name>', 'w') as f:
    f.write(keyset)

Java 示例

以下代码示例使用存储在 JSON 文件中的访问令牌来访问 GCP Secret Manager 中的密钥材料,并将其写入 JSON 文件。

import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.api.gax.core.CredentialsProvider;
import com.google.api.gax.core.FixedCredentialsProvider;
import com.google.auth.oauth2.ServiceAccountCredentials;
import com.google.cloud.secretmanager.v1.AccessSecretVersionResponse;
import com.google.cloud.secretmanager.v1.SecretManagerServiceClient;
import com.google.cloud.secretmanager.v1.SecretManagerServiceSettings;
import com.google.cloud.secretmanager.v1.SecretVersionName;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.zip.CRC32C;
import java.util.zip.Checksum;

/** */
final class ThirdPartySecretAccessGuide {

  private ThirdPartySecretAccessGuide() {}

  public static void main(String[] args) throws IOException {
    accessSecretVersion();
  }

  public static void accessSecretVersion() throws IOException {
    // TODO(developer): Replace these variables before running the sample.
    String projectId = "projectId";
    String secretId = "secretId";
    String versionId = "versionId";
    String accessTokenPrivateKeyPath = "path/to/credentials.json";
    String secretMaterialOutputPath = "path/to/secret.json";
    accessSecretVersion(
        projectId, secretId, versionId, accessTokenPrivateKeyPath, secretMaterialOutputPath);
  }

  // Access the payload for the given secret version if one exists. The version
  // can be a version number as a string (e.g. "5") or an alias (e.g. "latest").
  public static void accessSecretVersion(
      String projectId,
      String secretId,
      String versionId,
      String accessTokenPrivateKeyPath,
      String secretMaterialOutputPath)
      throws IOException {

    // We can explicitly instantiate the SecretManagerServiceClient (below) from a json file if we:
    // 1. Create a CredentialsProvider from a FileInputStream of the JSON file,
    CredentialsProvider credentialsProvider =
        FixedCredentialsProvider.create(
            ServiceAccountCredentials.fromStream(new FileInputStream(accessTokenPrivateKeyPath)));

    // 2. Build a SecretManagerService Settings object from that credentials provider, and
    SecretManagerServiceSettings secretManagerServiceSettings =
        SecretManagerServiceSettings.newBuilder()
            .setCredentialsProvider(credentialsProvider)
            .build();

    // 3. Initialize client that will be used to send requests by passing the settings object to
    // create(). This client only needs to be created once, and can be reused for multiple requests.
    // After completing all of your requests, call the "close" method on the client to safely clean
    // up any remaining background resources.
    try (SecretManagerServiceClient client =
        SecretManagerServiceClient.create(secretManagerServiceSettings)) {
      SecretVersionName secretVersionName = SecretVersionName.of(projectId, secretId, versionId);

      // Access the secret version.
      AccessSecretVersionResponse response = client.accessSecretVersion(secretVersionName);

      // Verify checksum. The used library is available in Java 9+.
      // If using Java 8, you may use the following:
      // https://github.com/google/guava/blob/e62d6a0456420d295089a9c319b7593a3eae4a83/guava/src/com/google/common/hash/Hashing.java#L395
      byte[] data = response.getPayload().getData().toByteArray();
      Checksum checksum = new CRC32C();
      checksum.update(data, 0, data.length);
      if (response.getPayload().getDataCrc32C() != checksum.getValue()) {
        System.out.printf("Data corruption detected.");
        return;
      }

      String payload = response.getPayload().getData().toStringUtf8();
      // Print the secret payload.
      //
      // WARNING: Do not print the secret in a production environment - this
      // snippet is showing how to access the secret material.
      System.out.printf("Plaintext: %s\n", payload);

      // Write the JSON secret material payload to a json file
      try (PrintWriter out =
          new PrintWriter(Files.newBufferedWriter(Paths.get(secretMaterialOutputPath), UTF_8))) {
        out.write(payload);
      } catch (Exception e) {
        e.printStackTrace();
      }
    }
  }
}

设置应用默认凭据

如果您不想在 Java 实现中使用 CredentialsProvider 将私钥传递给 JSON 文件,可以通过设置应用默认凭证 (ADC) 来修改实现:

  1. 告知客户端库服务账号密钥的存放位置。
  2. 向 Java 项目添加 Maven 依赖项
  3. 调用 SecretManagerServiceClient.create(),该方法会自动获取身份验证信息(因为执行了第 1 步)。

这些步骤通过以下方式修改 Java 实现:

  • 无需创建 CredentialsProviderSecretManagerServiceSettings 对象。
  • 将对 SecretManagerServiceClient.create() 的调用更改为不包含任何实参。

创建密文并生成深层链接

在此步骤中,您将使用 Tink 加密库从 InlineInstallData protobuf 对象创建 enifdInlineInstallData 密文)。InlineInstallData proto 的定义如下:

syntax = "proto2";
package hsdpexperiments;
option java_package = "com.google.hsdpexperiments";
option java_multiple_files = true;

// InlineInstallData is used by 3p auth callers to generate "encrypted inline
// flow data" (enifd) which is decrypted in PGS to verify authenticity and
// freshness.
message InlineInstallData {
  // The timestamp which indicates the time encrypted data is generated.
  // Used to validate freshness (i.e. generation time in past 4 hours).
  // Required.
  optional int64 timestamp_ms = 1;

  // The docid of the app that we want to open inline install page for.
  // This is the package name.
  // Required.
  optional string target_package_name = 2;

  // This is the name of the app requesting the ad from Google Ad Serving
  // system.
  // Required.
  optional string caller_package_name = 3;

  // This is the advertising id that will be collected by 3P Ad SDKs.
  // Optional.
  optional string advertising_id = 4;

  // This is used to indicate the network from where the inline install was
  // requested.
  // Required.
  optional string ad_network_id = 5;
}

在此步骤中,您还将使用以下参数构建深层链接网址:

字段 说明 必需
id 要安装的应用的软件包名称
内嵌 如果请求内嵌式安装半页,则设置为 true;如果为 false,则 intent 深层链接到 Google Play。
enifd 第三方 SDK 的加密标识符。
lft 内部标识符。
3pAuthCallerId SDK 标识符。
商品详情 一个可选参数,用于指定自定义商品详情的目标。
referrer 可选的引荐来源跟踪字符串。

Python 示例

以下命令会根据 InlineInstallData.proto 生成 Python 代码:

protoc InlineInstallData.proto --python_out=.

以下 Python 示例代码会构建 InlineInstallData 并使用对称密钥对其进行加密以创建密文:

#!/usr/bin/env python3

# Import the Secret Manager client library.
import base64
import time
import inline_install_data_pb2 as InlineInstallData
import tink
from tink import aead
from tink import cleartext_keyset_handle

# Read the stored symmetric key.
with open("example3psecret.json", "r") as f:
  keyset = f.read()

"""Encrypt and decrypt using AEAD."""
# Register the AEAD key managers. This is needed to create an Aead primitive later.
aead.register()

# Create a keyset handle from the cleartext keyset in the previous
# step. The keyset handle provides abstract access to the underlying keyset to
# limit access of the raw key material. WARNING: In practice, it is unlikely
# you will want to use a cleartext_keyset_handle, as it implies that your key
# material is passed in cleartext, which is a security risk.
keyset_handle = cleartext_keyset_handle.read(tink.JsonKeysetReader(keyset))

# Retrieve the Aead primitive we want to use from the keyset handle.
primitive = keyset_handle.primitive(aead.Aead)

inlineInstallData = InlineInstallData.InlineInstallData()
inlineInstallData.timestamp_ms = int(time.time() * 1000)
inlineInstallData.target_package_name = "x.y.z"
inlineInstallData.caller_package_name = "a.b.c"
inlineInstallData.ad_network_id = "<sdk_id>"

# Use the primitive to encrypt a message. In this case the primary key of the
# keyset will be used (which is also the only key in this example).
ciphertext = primitive.encrypt(inlineInstallData.SerializeToString(), b'<sdk_id>')
print(f"InlineInstallData Ciphertext: {ciphertext}")

# Base64 Encoded InlineInstallData Ciphertext
enifd = base64.urlsafe_b64encode(ciphertext).decode('utf-8')
print(enifd)

# Deeplink
print(f"https://play.google.com/d?id={inlineInstallData.target_package_name}\&inline=true\&enifd={enifd}\&lft=1\&3pAuthCallerId={inlineInstallData.ad_network_id}")

运行以下命令以执行 Python 脚本:

python <file_name>.py

Java 示例

以下命令会根据 InlineInstallData.proto 生成 Java 代码:

protoc InlineInstallData.proto --java_out=.

以下 Java 示例代码会构建 InlineInstallData 并使用对称密钥对其进行加密,以创建密文:

package com.google.hsdpexperiments;

import static com.google.common.io.BaseEncoding.base64Url;
import static java.nio.charset.StandardCharsets.UTF_8;

import com.google.common.flags.Flag;
import com.google.common.flags.FlagSpec;
import com.google.common.flags.Flags;
import com.google.crypto.tink.Aead;
import com.google.crypto.tink.InsecureSecretKeyAccess;
import com.google.crypto.tink.KeysetHandle;
import com.google.crypto.tink.TinkJsonProtoKeysetFormat;
import com.google.crypto.tink.aead.AeadConfig;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.Security;
import java.time.Duration;
import org.conscrypt.Conscrypt;

/** info on encryption in https://github.com/google/tink#learn-more */
final class ThirdPartyEnifdGuide {

  @FlagSpec(
      name = "third_party_id",
      help = "the identifier associated with the 3p for which to generate the enifd")
  private static final Flag<String> thirdPartyAuthCallerId = Flag.value("");

  @FlagSpec(name = "package_name", help = "the package name of the target app")
  private static final Flag<String> packageName = Flag.value("");


  @FlagSpec(name = "caller_package_name", help = "the package name of the caller app")
  private static final Flag<String> callerPackageName = Flag.value("");

  @FlagSpec(name = "secret_filename", help = "the path to the json file with the secret material")
  private static final Flag<String> secretFilename = Flag.value("");

  private ThirdPartyEnifdGuide() {}

  public static void main(String[] args) throws Exception {
    // parse flags
    Flags.parse(args);

    // File keyFile = new File(args[0]);
    Path keyFile = Paths.get(secretFilename.get());

    // Create structured inline flow data
    InlineInstallData idrp =
        InlineInstallData.newBuilder()
            .setTargetPackageName(packageName.get())
            .setCallerPackageName(callerPackageName.get())
            .setTimestampMs(System.currentTimeMillis())
            .setAdNetworkId(thirdPartyAuthCallerId.get())
            .build();

    // we can print this out here to make sure it's well formatted, this will help debug
    System.out.println(idrp.toString());

    // Register all AEAD key types with the Tink runtime.
    Conscrypt.checkAvailability();
    Security.addProvider(Conscrypt.newProvider());
    AeadConfig.register();

    // Read AEAD key downloaded from secretmanager into keysethandle
    KeysetHandle handle =
        TinkJsonProtoKeysetFormat.parseKeyset(
            new String(Files.readAllBytes(keyFile), UTF_8), InsecureSecretKeyAccess.get());

    // Generate enifd using tink library
    Aead aead = handle.getPrimitive(Aead.class);
    byte[] plaintext = idrp.toByteArray();
    byte[] ciphertext = aead.encrypt(plaintext, thirdPartyAuthCallerId.get().getBytes(UTF_8));
    String enifd = base64Url().omitPadding().encode(ciphertext);

    // Build deeplink, escaping ampersands (TODO: verify this is necessary while testing e2e)
    String deeplink =
        "https://play.google.com/d?id="
            + packageName.get()
            + "\\&inline=true\\&enifd="
            + enifd
            + "\\&lft=1\\&3pAuthCallerId="
            + thirdPartyAuthCallerId.get();

    System.out.println(deeplink);
  }
}

最后,将 Java 程序构建为二进制文件,并使用以下代码调用该二进制文件:

path/to/binary/ThirdPartyEnifdGuide --secret_filename=path/to/jsonfile/example3psecret.json --package_name=<package_name_of_target_app> --third_party_id=<3p_caller_auth_id>
  • secret_filename 标志用于指定包含密钥材料的 JSON 文件的路径。
  • package_name 标志是目标应用的文档 ID。
  • third_party_id 标志用于指定第三方调用方身份验证 ID(即 <sdk_id>)。

启动内嵌式安装 intent

如需测试在上一步中生成的深层链接,请将 Android 设备(确保已启用 USB 调试)连接到已安装 ADB 的工作站,然后运行以下命令:

adb shell am start "<output_from_the_previous_python_or_java_code>"

在客户端代码中,使用以下方法之一(Kotlin 或 Java)发送 intent。

Kotlin

val intent = Intent(Intent.ACTION_VIEW)
val deepLinkUrl = "<output_from_the_previous_python_or_java_code>"
intent.setPackage("com.android.vending")
intent.data = Uri.parse(deepLinkUrl)
val packageManager = context.getPackageManager()
if (intent.resolveActivity(packageManager) != null) {
  startActivityForResult(intent, 0)
} else {
  // Fallback to deep linking to full Play Store.
}

Java

Intent intent = new Intent(Intent.ACTION_VIEW);
String id = "exampleAppToBeInstalledId";
String deepLinkUrl = "<output_from_the_previous_python_or_java_code>";
intent.setPackage("com.android.vending");
intent.setData(Uri.parse(deepLinkUrl));
PackageManager packageManager = context.getPackageManager();
if (intent.resolveActivity(packageManager) != null) {
  startActivityForResult(intent, 0);
} else {
  // Fallback to deep linking to full Play Store.
}

附录

以下部分针对某些使用情形提供了更多指导。

准备 Python 环境

如需运行 Python 示例代码,请在工作站上设置 Python 环境并安装所需的依赖项。

  1. 设置 Python 环境:

    1. 安装 python3.11(如果已安装,请跳过此步骤):

      sudo apt install python3.11
      
    2. 安装 pip:

      sudo apt-get install pip
      
    3. 安装 virtualenv

      sudo apt install python3-virtualenv
      
    4. 创建虚拟环境(Tink 依赖项必需):

      virtualenv inlineinstall --python=/usr/bin/python3.11
      
  2. 进入虚拟环境:

    source inlineinstall/bin/activate
    
  3. 更新 pip:

    python -m pip install --upgrade pip
    
  4. 安装所需依赖项:

    1. 安装 Tink:

      pip install tink
      
    2. 安装 Google crc32c:

      pip install google-crc32c
      
    3. 安装 Secret Manager:

      pip install google-cloud-secret-manager
      
    4. 安装 protobuf 编译器:

      sudo apt install protobuf-compiler
      

C++ ENIFD 生成

以下是我们编写并在内部验证过的 C++ 示例,用于生成 enifd

可以使用以下 C++ 代码生成 enifd

// A command-line example for using Tink AEAD w/ key template aes128gcmsiv to
// encrypt an InlineInstallData proto.
#include <chrono>
#include <iostream>
#include <memory>
#include <string>

#include "<path_to_protoc_output>/inline_install_data.proto.h"
#include "absl/flags/flag.h"
#include "absl/flags/parse.h"
#include "absl/strings/escaping.h"
#include "absl/strings/string_view.h"
#include "tink/cc/aead.h"
#include "tink/cc/aead_config.h"
#include "tink/cc/aead_key_templates.h"
#include "tink/cc/config/global_registry.h"
#include "tink/cc/examples/util/util.h"
#include "tink/cc/keyset_handle.h"
#include "tink/cc/util/status.h"
#include "tink/cc/util/statusor.h"

ABSL_FLAG(std::string, keyset_filename, "",
          "Keyset file (downloaded from secretmanager) in JSON format");
ABSL_FLAG(std::string, associated_data, "",
          "Associated data for AEAD (default: empty");

namespace {

using ::crypto::tink::Aead;
using ::crypto::tink::AeadConfig;
using ::crypto::tink::KeysetHandle;
using ::crypto::tink::util::Status;
using ::crypto::tink::util::StatusOr;

}  // namespace

namespace tink_cc_examples {

// AEAD example CLI implementation.
void AeadCli(const std::string& keyset_filename,
             absl::string_view associated_data) {
  Status result = AeadConfig::Register();
  if (!result.ok()) {
    std::clog << "Failed to register AeadConfig";
    return;
  }

  // Read the keyset from file.
  StatusOr<std::unique_ptr<KeysetHandle>> keyset_handle =
      ReadJsonCleartextKeyset(keyset_filename);
  if (!keyset_handle.ok()) {
    std::clog << "Failed to read json keyset";
    return;
  }

  // Get the primitive.
  StatusOr<std::unique_ptr<Aead>> aead =
      (*keyset_handle)
          ->GetPrimitive<crypto::tink::Aead>(
              crypto::tink::ConfigGlobalRegistry());
  if (!aead.ok()) {
    std::clog << "Failed to get primitive";
    return;
  }

  // Instantiate the enifd.
  hsdpexperiments::InlineInstallData iid;

  iid.set_timestamp_ms(std::chrono::duration_cast<std::chrono::milliseconds>(
                           std::chrono::system_clock::now().time_since_epoch())
                           .count());
  iid.set_target_package_name("<TARGET_PACKAGE_NAME>");
  iid.set_caller_package_name("<CALLER_PACKAGE_NAME>");
  iid.set_ad_network_id("<SDK_ID>");

  // Compute the output.
  StatusOr<std::string> encrypt_result =
      (*aead)->Encrypt(iid.SerializeAsString(), associated_data);
  if (!encrypt_result.ok()) {
    std::clog << "Failed to encrypt Inline Install Data";
    return;
  }
  const std::string& output = encrypt_result.value();

  std::string enifd;
  absl::WebSafeBase64Escape(output, &enifd);

  std::clog << "enifd: " << enifd << '\n';
}

}  // namespace tink_cc_examples

int main(int argc, char** argv) {
  absl::ParseCommandLine(argc, argv);

  std::string keyset_filename = absl::GetFlag(FLAGS_keyset_filename);
  std::string associated_data = absl::GetFlag(FLAGS_associated_data);

  std::clog << "Using keyset from file " << keyset_filename
            << " to AEAD-encrypt inline install data with associated data '"
            << associated_data << "'." << '\n';

  tink_cc_examples::AeadCli(keyset_filename, associated_data);
  return 0;
}

此代码改编自 Tink 文档中的一个示例