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 資料,產生用於叫用內嵌安裝意圖的深層連結中傳遞的密文,並在回應中將深層連結傳送給用戶端。
  4. 當深層連結被追蹤時,Google Play 應用程式會處理意圖。

如要設定第三方 SDK,以便使用內嵌安裝程序,請完成下列步驟。

在 Google Cloud 專案中建立服務帳戶

在這個步驟中,您將使用 Google Cloud 控制台設定服務帳戶。

  1. 設定 Google Cloud 專案:
    • 建立 Google Cloud 組織。建立 Google Workspace 或 Cloud Identity 帳戶,並與網域名稱建立關聯後,系統就會自動建立機構資源。詳情請參閱「建立及管理機構資源」。
    • 使用上一個步驟建立的 Google Cloud 帳戶登入 GCP 控制台,然後建立 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 內的密鑰名稱;這個名稱是將 hsdp-3p-key- 前置字串加到 Play 提供的 sdk_id 值所產生。舉例來說,如果 sdk_idabc,密碼名稱就是 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. Maven 依附元件新增至 Java 專案。
  3. 呼叫 SecretManagerServiceClient.create(),系統會自動擷取驗證資訊 (因為步驟 1)。

這些步驟會透過下列方式修改 Java 實作:

  • 不必建立 CredentialsProviderSecretManagerServiceSettings 物件。
  • 將呼叫變更為 SecretManagerServiceClient.create(),不含任何引數。

建立密文並產生深層連結

在這個步驟中,您會使用 Tink 密碼學程式庫,從 InlineInstallData protobuf 物件建立 enifd (InlineInstallData 密文)。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,意圖會深層連結至 Google Play。
enifd 第三方 SDK 的加密 ID。
lft 內部 ID。
3pAuthCallerId SDK ID。
商店資訊 選用參數,用於指定自訂商店資訊的目標。
推薦人 選用的推薦連結追蹤字串。

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>)。

啟動內嵌安裝意圖

如要測試上一個步驟中產生的深層連結,請將 Android 裝置 (確認已啟用 USB 偵錯) 連接至已安裝 ADB 的工作站,然後執行下列指令:

adb shell am start "<output_from_the_previous_python_or_java_code>"

在用戶端程式碼中,使用下列其中一種方法 (Kotlin 或 Java) 傳送意圖。

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 文件中找到該範例。