From b84ce6e5e86b0b43a01b644dd2568ba2edb8f750 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 22:49:15 +0100 Subject: [PATCH 01/12] build(business): lock TikTok Business SDK Android 1.7.1 inputs Pin the upstream release AAR (byte-identical to the JitPack artifact), its license and the Gradle distribution by SHA-256 in a lock file separate from the Share package lock, and add a restore script that fails on any checksum mismatch. Co-Authored-By: Claude Opus 5.5 --- .../Android/business-lockstate.txt | 8 +++ .../Native/Android/restore-upstream.sh | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 DependencyLocks/Android/business-lockstate.txt create mode 100755 src/Kapusch.TikTok.Business.Android/Native/Android/restore-upstream.sh diff --git a/DependencyLocks/Android/business-lockstate.txt b/DependencyLocks/Android/business-lockstate.txt new file mode 100644 index 0000000..5c3d21c --- /dev/null +++ b/DependencyLocks/Android/business-lockstate.txt @@ -0,0 +1,8 @@ +# Kapusch.TikTok.Business.Android upstream inputs: +# TikTok Business SDK Android 1.7.1 release AAR (byte-identical to the JitPack +# artifact com.github.tiktok:tiktok-business-android-sdk:1.7.1). +https://github.com/tiktok/tiktok-business-android-sdk/releases/download/1.7.1/tiktok-business-android-sdk-1.7.1.aar 629561d285b1cfb7b6c513f59a68c4f38f0d3cc5d2c85c7b8a57a8a957bcbf52 tiktok-business-android-sdk-1.7.1.aar +# Upstream license at the same tag. +https://raw.githubusercontent.com/tiktok/tiktok-business-android-sdk/1.7.1/LICENSE d9245608d897216466775f788e54c0986900ce222867477b5d96acd3c935bb14 TIKTOK-BUSINESS-SDK-LICENSE +# Gradle distribution used to build the interop AAR. +https://services.gradle.org/distributions/gradle-8.7-bin.zip 544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d gradle-8.7-bin.zip diff --git a/src/Kapusch.TikTok.Business.Android/Native/Android/restore-upstream.sh b/src/Kapusch.TikTok.Business.Android/Native/Android/restore-upstream.sh new file mode 100755 index 0000000..f6313ba --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Native/Android/restore-upstream.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Downloads every input listed in DependencyLocks/Android/business-lockstate.txt +# into Native/Android/upstream/ and fails on any SHA-256 mismatch. Files that are +# already present with the locked checksum are reused. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$ROOT_DIR/../../../.." && pwd)" +LOCK_FILE="$REPO_ROOT/DependencyLocks/Android/business-lockstate.txt" +UPSTREAM_DIR="$ROOT_DIR/upstream" + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +mkdir -p "$UPSTREAM_DIR" +count=0 +while read -r url expected_sha filename; do + [ -z "${url:-}" ] && continue + case "$url" in \#*) continue ;; esac + if [ -z "${expected_sha:-}" ] || [ -z "${filename:-}" ]; then + echo "Malformed lock line: $url" >&2 + exit 1 + fi + target="$UPSTREAM_DIR/$filename" + if [ -f "$target" ] && [ "$(sha256_of "$target")" = "$expected_sha" ]; then + count=$((count + 1)) + continue + fi + tmp="$target.download" + curl -fsSL --retry 3 --retry-delay 1 -o "$tmp" "$url" + actual_sha="$(sha256_of "$tmp")" + if [ "$actual_sha" != "$expected_sha" ]; then + rm -f "$tmp" + echo "Checksum mismatch for $filename. Expected $expected_sha, got $actual_sha." >&2 + exit 1 + fi + mv "$tmp" "$target" + count=$((count + 1)) +done < "$LOCK_FILE" + +if [ "$count" -eq 0 ]; then + echo "No locked inputs found in $LOCK_FILE" >&2 + exit 1 +fi +echo "Restored $count locked TikTok Business inputs into $UPSTREAM_DIR" From 82c4cbb6ed2c14bfec2fbaa0bf7d9446af8538f5 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 22:49:15 +0100 Subject: [PATCH 02/12] feat(business): add Kotlin JNI facade over the TikTok Business SDK The businessinterop module compiles against the locked upstream classes only and ships as its own AAR next to the unchanged upstream AAR. The Kotlin object maps a Bundle configuration to TTConfig, initializes on the main thread, reports the init outcome exactly once through a ValueCallback, and exposes track, identify, logout, flush and startTrack. Consumer R8 rules keep the reflective and optional dependencies and do not fail when Billing or Install Referrer are absent. Co-Authored-By: Claude Opus 5.5 --- .../Native/Android/build.gradle.kts | 4 + .../Native/Android/build.sh | 53 + .../Android/businessinterop/build.gradle.kts | 19 + .../businessinterop/consumer-rules.pro | 17 + .../src/main/AndroidManifest.xml | 3 + .../androidinterop/TikTokBusinessInterop.kt | 188 ++ .../Native/Android/gradle.properties | 4 + .../Android/gradle/verification-metadata.xml | 1612 +++++++++++++++++ .../Native/Android/settings.gradle.kts | 9 + 9 files changed, 1909 insertions(+) create mode 100644 src/Kapusch.TikTok.Business.Android/Native/Android/build.gradle.kts create mode 100755 src/Kapusch.TikTok.Business.Android/Native/Android/build.sh create mode 100644 src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/build.gradle.kts create mode 100644 src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/consumer-rules.pro create mode 100644 src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/src/main/AndroidManifest.xml create mode 100644 src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/src/main/java/com/kapusch/tiktok/business/androidinterop/TikTokBusinessInterop.kt create mode 100644 src/Kapusch.TikTok.Business.Android/Native/Android/gradle.properties create mode 100644 src/Kapusch.TikTok.Business.Android/Native/Android/gradle/verification-metadata.xml create mode 100644 src/Kapusch.TikTok.Business.Android/Native/Android/settings.gradle.kts diff --git a/src/Kapusch.TikTok.Business.Android/Native/Android/build.gradle.kts b/src/Kapusch.TikTok.Business.Android/Native/Android/build.gradle.kts new file mode 100644 index 0000000..87e30de --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Native/Android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.library") version "8.3.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false +} diff --git a/src/Kapusch.TikTok.Business.Android/Native/Android/build.sh b/src/Kapusch.TikTok.Business.Android/Native/Android/build.sh new file mode 100755 index 0000000..201f240 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Native/Android/build.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Builds the Kapusch TikTok Business interop AAR against the checksum-locked +# upstream TikTok Business SDK AAR and stages the package payload in build/. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="$ROOT_DIR/build" +UPSTREAM_DIR="$ROOT_DIR/upstream" +VERIFICATION_METADATA="$ROOT_DIR/gradle/verification-metadata.xml" +SDK_VERSION="1.7.1" +SDK_AAR="tiktok-business-android-sdk-$SDK_VERSION.aar" +GRADLE_VERSION="8.7" +GRADLE_HOME="$ROOT_DIR/.gradle/gradle-$GRADLE_VERSION" +GRADLE_BIN="$GRADLE_HOME/bin/gradle" +GRADLE_USER_HOME="${GRADLE_USER_HOME:-$ROOT_DIR/.gradle/user-home}" +export GRADLE_USER_HOME + +if [ ! -f "$VERIFICATION_METADATA" ]; then + echo "Missing Gradle verification metadata: $VERIFICATION_METADATA" >&2 + exit 1 +fi + +ANDROID_SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" +if [ -z "$ANDROID_SDK" ]; then + [ -d "$HOME/Library/Android/sdk" ] && ANDROID_SDK="$HOME/Library/Android/sdk" + [ -z "$ANDROID_SDK" ] && [ -d "$HOME/Android/Sdk" ] && ANDROID_SDK="$HOME/Android/Sdk" +fi +if [ -z "$ANDROID_SDK" ]; then + echo "ANDROID_SDK_ROOT (or ANDROID_HOME) is required." >&2 + exit 1 +fi + +"$ROOT_DIR/restore-upstream.sh" + +# The interop module compiles against the upstream classes only; the upstream +# AAR itself is shipped unchanged next to the interop AAR. +rm -rf "$UPSTREAM_DIR/compile" +mkdir -p "$UPSTREAM_DIR/compile" +unzip -q -o "$UPSTREAM_DIR/$SDK_AAR" classes.jar -d "$UPSTREAM_DIR/compile" + +mkdir -p "$ROOT_DIR/.gradle" "$GRADLE_USER_HOME" +printf 'sdk.dir=%s\n' "$ANDROID_SDK" > "$ROOT_DIR/local.properties" +if [ ! -x "$GRADLE_BIN" ]; then + unzip -q -o "$UPSTREAM_DIR/gradle-$GRADLE_VERSION-bin.zip" -d "$ROOT_DIR/.gradle" +fi + +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR/aar" "$BUILD_DIR/licenses" +"$GRADLE_BIN" --no-daemon -p "$ROOT_DIR" :businessinterop:assembleRelease +cp "$ROOT_DIR/businessinterop/build/outputs/aar/businessinterop-release.aar" "$BUILD_DIR/aar/ktk-business-interop-release.aar" +cp "$UPSTREAM_DIR/$SDK_AAR" "$BUILD_DIR/aar/$SDK_AAR" +cp "$UPSTREAM_DIR/TIKTOK-BUSINESS-SDK-LICENSE" "$BUILD_DIR/licenses/TIKTOK-BUSINESS-SDK-LICENSE" +echo "TikTok Business Android native assets staged in $BUILD_DIR" diff --git a/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/build.gradle.kts b/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/build.gradle.kts new file mode 100644 index 0000000..1c4aa57 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} +android { + namespace = "com.kapusch.tiktok.business.androidinterop" + compileSdk = 34 + defaultConfig { minSdk = 21; consumerProguardFiles("consumer-rules.pro") } + buildTypes { release { isMinifyEnabled = false } } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } + kotlinOptions { jvmTarget = "17" } +} +dependencies { + // The checksum-locked upstream AAR is shipped as its own package entry, so the + // interop only compiles against its classes and never repackages them. + compileOnly(files("../upstream/compile/classes.jar")) + // Runtime AndroidX and Kotlin classes come from managed NuGet dependencies of + // the .NET package (see the csproj), not from this AAR. +} diff --git a/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/consumer-rules.pro b/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/consumer-rules.pro new file mode 100644 index 0000000..d784c86 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/consumer-rules.pro @@ -0,0 +1,17 @@ +# Kapusch TikTok Business interop: called from .NET through JNI by name. +-keep class com.kapusch.tiktok.business.androidinterop.TikTokBusinessInterop { public static *; } + +# The upstream AAR keeps com.tiktok.** and the Billing API itself. The SDK also +# reaches these classes reflectively or through optional compile-only +# dependencies, as listed by the TikTok Business SDK Android integration guide. +-keep class com.tiktok.** { *; } +-keep class com.android.installreferrer.api.** { *; } +-keep class androidx.lifecycle.** { *; } +-keep class com.google.android.gms.ads.identifier.AdvertisingIdClient { public *; } +-keep class com.google.android.gms.ads.identifier.AdvertisingIdClient$Info { public *; } + +# Optional, consumer-owned dependencies. The SDK degrades gracefully when they +# are absent, so missing classes must not fail an R8 build. +-dontwarn com.android.installreferrer.** +-dontwarn com.android.billingclient.** +-dontwarn com.google.android.gms.ads.identifier.** diff --git a/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/src/main/AndroidManifest.xml b/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c91836f --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/src/main/java/com/kapusch/tiktok/business/androidinterop/TikTokBusinessInterop.kt b/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/src/main/java/com/kapusch/tiktok/business/androidinterop/TikTokBusinessInterop.kt new file mode 100644 index 0000000..2699e95 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/src/main/java/com/kapusch/tiktok/business/androidinterop/TikTokBusinessInterop.kt @@ -0,0 +1,188 @@ +package com.kapusch.tiktok.business.androidinterop + +import android.app.Application +import android.content.Context +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.webkit.ValueCallback +import com.tiktok.TikTokBusinessSdk +import com.tiktok.appevents.base.TTBaseEvent +import org.json.JSONObject +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Thin JNI surface over the official TikTok Business SDK. + * + * The upstream SDK owns event persistence, batching and retries. This facade only + * translates a [Bundle] configuration into `TTConfig`, guarantees main-thread + * initialization, and reports the initialization outcome exactly once. + * Nothing here touches the SDK before [initialize] is called. + */ +object TikTokBusinessInterop { + private const val LOG_TAG = "KapuschTikTokBusiness" + + const val KEY_APP_ID = "app_id" + const val KEY_TIKTOK_APP_ID = "tiktok_app_id" + const val KEY_ACCESS_TOKEN = "access_token" + const val KEY_DEBUG_MODE = "debug_mode" + const val KEY_LOG_LEVEL = "log_level" + const val KEY_DISABLE_AUTO_START = "disable_auto_start" + const val KEY_DISABLE_AUTOMATIC_TRACKING = "disable_automatic_tracking" + const val KEY_DISABLE_INSTALL_TRACKING = "disable_install_tracking" + const val KEY_DISABLE_LAUNCH_TRACKING = "disable_launch_tracking" + const val KEY_DISABLE_RETENTION_TRACKING = "disable_retention_tracking" + const val KEY_DISABLE_PAYMENT_TRACKING = "disable_payment_tracking" + const val KEY_DISABLE_ADVERTISER_ID_COLLECTION = "disable_advertiser_id_collection" + const val KEY_DISABLE_ENHANCED_DATA_POSTBACK = "disable_enhanced_data_postback" + const val KEY_DISABLE_MONITOR = "disable_monitor" + const val KEY_LIMITED_DATA_USE = "limited_data_use" + + const val RESULT_SUCCESS = "success" + const val RESULT_ERROR_CODE = "error_code" + const val RESULT_ERROR_MESSAGE = "error_message" + const val RESULT_NATIVE_ERROR_CODE = "native_error_code" + + private val APP_ID_PATTERN = Regex("^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+$") + private val TIKTOK_APP_ID_PATTERN = Regex("^(\\d+,)*\\d+$") + + private val mainHandler by lazy { Handler(Looper.getMainLooper()) } + + @JvmStatic + fun initialize(context: Context, config: Bundle, callback: ValueCallback) { + val completed = AtomicBoolean(false) + val complete: (Bundle) -> Unit = { result -> + if (completed.compareAndSet(false, true)) { + try { + callback.onReceiveValue(result) + } catch (error: Throwable) { + Log.e(LOG_TAG, "Initialization callback failed", error) + } + } + } + val run = Runnable { + try { + initializeOnMainThread(context, config, complete) + } catch (error: Throwable) { + Log.e(LOG_TAG, "TikTok Business SDK initialization threw", error) + complete(failureResult("native_exception", error.javaClass.simpleName, null)) + } + } + if (Looper.myLooper() == Looper.getMainLooper()) { + run.run() + } else { + mainHandler.post(run) + } + } + + private fun initializeOnMainThread(context: Context, config: Bundle, complete: (Bundle) -> Unit) { + if (TikTokBusinessSdk.isInitialized()) { + complete(successResult()) + return + } + + val appId = config.getString(KEY_APP_ID).orEmpty().trim() + val tiktokAppId = config.getString(KEY_TIKTOK_APP_ID).orEmpty().replace(" ", "") + val accessToken = config.getString(KEY_ACCESS_TOKEN).orEmpty().trim() + if (!APP_ID_PATTERN.matches(appId)) { + complete(failureResult("invalid_app_id", "AppId must be the Android package name.", null)) + return + } + if (!TIKTOK_APP_ID_PATTERN.matches(tiktokAppId)) { + complete(failureResult("invalid_tiktok_app_id", "TikTokAppId must be one or more comma-separated numeric ids.", null)) + return + } + if (accessToken.isEmpty()) { + complete(failureResult("missing_access_token", "AccessToken (the Events Manager app secret) is required.", null)) + return + } + + val application = context.applicationContext as? Application + if (application == null) { + complete(failureResult("invalid_context", "An Application context is required.", null)) + return + } + + val ttConfig = TikTokBusinessSdk.TTConfig(application, accessToken) + .setAppId(appId) + .setTTAppId(tiktokAppId) + .setLogLevel(logLevel(config.getInt(KEY_LOG_LEVEL, 0))) + if (config.getBoolean(KEY_DEBUG_MODE, false)) ttConfig.openDebugMode() + if (config.getBoolean(KEY_DISABLE_AUTO_START, false)) ttConfig.disableAutoStart() + if (config.getBoolean(KEY_DISABLE_AUTOMATIC_TRACKING, false)) ttConfig.disableAutoEvents() + if (config.getBoolean(KEY_DISABLE_INSTALL_TRACKING, false)) ttConfig.disableInstallLogging() + if (config.getBoolean(KEY_DISABLE_LAUNCH_TRACKING, false)) ttConfig.disableLaunchLogging() + if (config.getBoolean(KEY_DISABLE_RETENTION_TRACKING, false)) ttConfig.disableRetentionLogging() + if (config.getBoolean(KEY_DISABLE_PAYMENT_TRACKING, false)) ttConfig.disableAutoIapTrack() + if (config.getBoolean(KEY_DISABLE_ADVERTISER_ID_COLLECTION, false)) ttConfig.disableAdvertiserIDCollection() + if (config.getBoolean(KEY_DISABLE_ENHANCED_DATA_POSTBACK, false)) ttConfig.disableAutoEnhancedDataPostbackEvent() + if (config.getBoolean(KEY_DISABLE_MONITOR, false)) ttConfig.disableMonitor() + if (config.getBoolean(KEY_LIMITED_DATA_USE, false)) ttConfig.enableLimitedDataUse() + + TikTokBusinessSdk.initializeSdk(ttConfig, object : TikTokBusinessSdk.TTInitCallback { + override fun success() { + complete(successResult()) + } + + override fun fail(code: Int, msg: String?) { + Log.w(LOG_TAG, "TikTok Business SDK initialization failed code=$code") + complete(failureResult("tiktok_$code", msg, code)) + } + }) + } + + @JvmStatic + fun isInitialized(): Boolean = TikTokBusinessSdk.isInitialized() + + @JvmStatic + fun sdkVersion(): String = com.tiktok.BuildConfig.VERSION_NAME + + @JvmStatic + fun startTrack() { + TikTokBusinessSdk.startTrack() + } + + /** + * Tracks one event. [propertiesJson] is a JSON object produced by the managed + * layer; an empty [eventId] lets the SDK generate one. + */ + @JvmStatic + fun trackEvent(name: String, propertiesJson: String?, eventId: String?) { + if (name.isBlank()) return + val properties = if (propertiesJson.isNullOrBlank()) JSONObject() else JSONObject(propertiesJson) + val id = eventId?.takeIf { it.isNotBlank() } + TikTokBusinessSdk.trackTTEvent(TTBaseEvent(name, properties, id)) + } + + @JvmStatic + fun identify(externalId: String?, externalUserName: String?, phoneNumber: String?, email: String?) { + TikTokBusinessSdk.identify(externalId, externalUserName, phoneNumber, email) + } + + @JvmStatic + fun logout() { + TikTokBusinessSdk.logout() + } + + @JvmStatic + fun flush() { + TikTokBusinessSdk.flush() + } + + private fun logLevel(value: Int): TikTokBusinessSdk.LogLevel = when (value) { + 1 -> TikTokBusinessSdk.LogLevel.INFO + 2 -> TikTokBusinessSdk.LogLevel.WARN + 3 -> TikTokBusinessSdk.LogLevel.DEBUG + else -> TikTokBusinessSdk.LogLevel.NONE + } + + private fun successResult(): Bundle = Bundle().apply { putBoolean(RESULT_SUCCESS, true) } + + private fun failureResult(code: String, message: String?, nativeCode: Int?): Bundle = Bundle().apply { + putBoolean(RESULT_SUCCESS, false) + putString(RESULT_ERROR_CODE, code) + message?.let { putString(RESULT_ERROR_MESSAGE, it) } + nativeCode?.let { putInt(RESULT_NATIVE_ERROR_CODE, it) } + } +} diff --git a/src/Kapusch.TikTok.Business.Android/Native/Android/gradle.properties b/src/Kapusch.TikTok.Business.Android/Native/Android/gradle.properties new file mode 100644 index 0000000..47d0e71 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Native/Android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true + diff --git a/src/Kapusch.TikTok.Business.Android/Native/Android/gradle/verification-metadata.xml b/src/Kapusch.TikTok.Business.Android/Native/Android/gradle/verification-metadata.xml new file mode 100644 index 0000000..00dfa56 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Native/Android/gradle/verification-metadata.xml @@ -0,0 +1,1612 @@ + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Kapusch.TikTok.Business.Android/Native/Android/settings.gradle.kts b/src/Kapusch.TikTok.Business.Android/Native/Android/settings.gradle.kts new file mode 100644 index 0000000..cb4bdd0 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Native/Android/settings.gradle.kts @@ -0,0 +1,9 @@ +pluginManagement { + repositories { google(); mavenCentral(); gradlePluginPortal() } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { google(); mavenCentral() } +} +rootProject.name = "KapuschTikTokBusinessInteropAndroid" +include(":businessinterop") From 01b60a1f4aafc82da25c4b8e1f76418df9faffc2 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:10:51 +0100 Subject: [PATCH 03/12] build(business): package Kapusch.TikTok.Business.Android Add a second packable project that ships the interop AAR, the locked upstream AAR and its license, with buildTransitive props/targets that inject only these two vendor AARs and support a source-mode switch. Shared AndroidX and Kotlin classes come from managed NuGets aligned on the AndroidX lifecycle 2.10 family (minSdk 23), so the package unifies with Billing 8.3 and AppCompat instead of duplicating classes. Billing and Install Referrer stay consumer-owned. Co-Authored-By: Claude Opus 5.5 --- .../Kapusch.TikTok.Business.Android.csproj | 65 +++++++++++++++++++ .../Kapusch.TikTok.Business.Android.props | 6 ++ .../Kapusch.TikTok.Business.Android.targets | 20 ++++++ .../nuget-readme.md | 26 ++++++++ 4 files changed, 117 insertions(+) create mode 100644 src/Kapusch.TikTok.Business.Android/Kapusch.TikTok.Business.Android.csproj create mode 100644 src/Kapusch.TikTok.Business.Android/buildTransitive/Kapusch.TikTok.Business.Android.props create mode 100644 src/Kapusch.TikTok.Business.Android/buildTransitive/Kapusch.TikTok.Business.Android.targets create mode 100644 src/Kapusch.TikTok.Business.Android/nuget-readme.md diff --git a/src/Kapusch.TikTok.Business.Android/Kapusch.TikTok.Business.Android.csproj b/src/Kapusch.TikTok.Business.Android/Kapusch.TikTok.Business.Android.csproj new file mode 100644 index 0000000..12014fd --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Kapusch.TikTok.Business.Android.csproj @@ -0,0 +1,65 @@ + + + net10.0-android + 23 + enable + enable + $(DefaultItemExcludes);Native/** + Kapusch.TikTok.Business.Android + Kapusch + .NET Android interop for the TikTok Business SDK (App Events): consent-gated initialization, standard events and attribution controls. + tiktok;business;app-events;attribution;android;maui + MIT + https://github.com/Kapusch/TikTokApisForAndroidComponents + true + true + false + true + $(NoWarn);NU5128 + Kapusch.TikTok.Business.Android + Kapusch.TikTok.Business.Android + 1.0.0 + nuget-readme.md + Native/Android/build + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Kapusch.TikTok.Business.Android/buildTransitive/Kapusch.TikTok.Business.Android.props b/src/Kapusch.TikTok.Business.Android/buildTransitive/Kapusch.TikTok.Business.Android.props new file mode 100644 index 0000000..0a5bb7a --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/buildTransitive/Kapusch.TikTok.Business.Android.props @@ -0,0 +1,6 @@ + + + false + false + + diff --git a/src/Kapusch.TikTok.Business.Android/buildTransitive/Kapusch.TikTok.Business.Android.targets b/src/Kapusch.TikTok.Business.Android/buildTransitive/Kapusch.TikTok.Business.Android.targets new file mode 100644 index 0000000..4f56938 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/buildTransitive/Kapusch.TikTok.Business.Android.targets @@ -0,0 +1,20 @@ + + + + <_KapuschTikTokBusinessAndroidNativeRoot>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../android')) + <_KapuschTikTokBusinessAndroidNativeRoot Condition="'$(UseKapuschTikTokBusinessAndroidInteropFromSource)' == 'true'">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../Native/Android/build')) + + + + + + + + + + diff --git a/src/Kapusch.TikTok.Business.Android/nuget-readme.md b/src/Kapusch.TikTok.Business.Android/nuget-readme.md new file mode 100644 index 0000000..33c6928 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/nuget-readme.md @@ -0,0 +1,26 @@ +# Kapusch.TikTok.Business.Android + +.NET Android interop for the TikTok Business SDK (App Events) 1.7.1. + +- Ships the checksum-locked upstream AAR unchanged plus a thin Kotlin facade. + Consumer builds never download native artifacts. +- Nothing is loaded or sent until `TikTokBusinessSdk.InitializeAsync` is called: + gate that call on the user's consent. +- Shared AndroidX and Kotlin classes come from managed NuGet dependencies. + Google Play Billing and Install Referrer stay owned by the application. +- Requires Android API 23 or later (AndroidX lifecycle 2.10). + +```csharp +var result = await TikTokBusinessSdk.InitializeAsync( + new TikTokBusinessConfig(appId: "com.example.app", tikTokAppId: "7000000000000000000", accessToken: secret) + { + DisablePaymentTracking = true, + }); + +TikTokBusinessSdk.TrackStartTrial(value: 0m, currency: "EUR", eventId: transactionId); +``` + +Automatic in-app purchase tracking reports every store transaction as +`Purchase` at catalog price, including free-trial starts. Subscription apps +should set `DisablePaymentTracking` and send `StartTrial` / `Subscribe` +manually. See `Docs/Business/Integration.md` in the repository. From 9a2f0578b4d12c078f0e0f3b81ff2f2cff5751ee Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:10:51 +0100 Subject: [PATCH 04/12] feat(business): add TikTokBusinessSdk C# API Expose a static TikTokBusinessSdk with consent-gated InitializeAsync returning the SDK completion result, StartTrack, SetTrackingEnabled, TrackEvent with JSON-serialized properties and an optional event id, typed StartTrial/Subscribe/Purchase/Registration/Login helpers, Identify/Logout/Flush, IsInitialized and SdkVersion. Constants cover every standard event name, property name and currency of SDK 1.7.1. Calls before initialization are ignored and nothing native is loaded. Co-Authored-By: Claude Opus 5.5 --- .../Internal/NativeTikTokBusinessInterop.cs | 176 +++++++ .../Internal/TikTokBusinessJson.cs | 154 ++++++ .../TikTokBusinessConfig.cs | 113 +++++ .../TikTokBusinessContent.cs | 17 + .../TikTokBusinessEventNames.cs | 106 ++++ .../TikTokBusinessInitResult.cs | 44 ++ .../TikTokBusinessPropertyNames.cs | 62 +++ .../TikTokBusinessSdk.cs | 480 ++++++++++++++++++ 8 files changed, 1152 insertions(+) create mode 100644 src/Kapusch.TikTok.Business.Android/Internal/NativeTikTokBusinessInterop.cs create mode 100644 src/Kapusch.TikTok.Business.Android/Internal/TikTokBusinessJson.cs create mode 100644 src/Kapusch.TikTok.Business.Android/TikTokBusinessConfig.cs create mode 100644 src/Kapusch.TikTok.Business.Android/TikTokBusinessContent.cs create mode 100644 src/Kapusch.TikTok.Business.Android/TikTokBusinessEventNames.cs create mode 100644 src/Kapusch.TikTok.Business.Android/TikTokBusinessInitResult.cs create mode 100644 src/Kapusch.TikTok.Business.Android/TikTokBusinessPropertyNames.cs create mode 100644 src/Kapusch.TikTok.Business.Android/TikTokBusinessSdk.cs diff --git a/src/Kapusch.TikTok.Business.Android/Internal/NativeTikTokBusinessInterop.cs b/src/Kapusch.TikTok.Business.Android/Internal/NativeTikTokBusinessInterop.cs new file mode 100644 index 0000000..9e4c06e --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Internal/NativeTikTokBusinessInterop.cs @@ -0,0 +1,176 @@ +using Android.OS; +using Android.Runtime; +using Android.Webkit; + +namespace Kapusch.TikTok.Business.Android.Internal; + +/// +/// JNI calls into com.kapusch.tiktok.business.androidinterop.TikTokBusinessInterop. +/// The Java class is resolved lazily, so nothing native is loaded before the first call. +/// +internal static class NativeTikTokBusinessInterop +{ + private const string ClassName = "com/kapusch/tiktok/business/androidinterop/TikTokBusinessInterop"; + + // Bundle keys and result keys shared with TikTokBusinessInterop.kt. + internal const string KeyAppId = "app_id"; + internal const string KeyTikTokAppId = "tiktok_app_id"; + internal const string KeyAccessToken = "access_token"; + internal const string KeyDebugMode = "debug_mode"; + internal const string KeyLogLevel = "log_level"; + internal const string KeyDisableAutoStart = "disable_auto_start"; + internal const string KeyDisableAutomaticTracking = "disable_automatic_tracking"; + internal const string KeyDisableInstallTracking = "disable_install_tracking"; + internal const string KeyDisableLaunchTracking = "disable_launch_tracking"; + internal const string KeyDisableRetentionTracking = "disable_retention_tracking"; + internal const string KeyDisablePaymentTracking = "disable_payment_tracking"; + internal const string KeyDisableAdvertiserIdCollection = "disable_advertiser_id_collection"; + internal const string KeyDisableEnhancedDataPostback = "disable_enhanced_data_postback"; + internal const string KeyDisableMonitor = "disable_monitor"; + internal const string KeyLimitedDataUse = "limited_data_use"; + + private const string ResultSuccess = "success"; + private const string ResultErrorCode = "error_code"; + private const string ResultErrorMessage = "error_message"; + private const string ResultNativeErrorCode = "native_error_code"; + + private static readonly Lazy Class = new( + () => JNIEnv.FindClass(ClassName), + LazyThreadSafetyMode.ExecutionAndPublication + ); + + internal static void Initialize(Bundle config, Action onResult) + { + var context = global::Android.App.Application.Context; + var callback = new InitCallback(onResult); + JNIEnv.CallStaticVoidMethod( + Class.Value, + Method( + "initialize", + "(Landroid/content/Context;Landroid/os/Bundle;Landroid/webkit/ValueCallback;)V" + ), + new JValue(context), + new JValue(config), + new JValue(callback) + ); + } + + internal static bool IsInitialized() => + JNIEnv.CallStaticBooleanMethod(Class.Value, Method("isInitialized", "()Z")); + + internal static string SdkVersion() => + JNIEnv.GetString( + JNIEnv.CallStaticObjectMethod(Class.Value, Method("sdkVersion", "()Ljava/lang/String;")), + JniHandleOwnership.TransferLocalRef + ) ?? string.Empty; + + internal static void TrackEvent(string name, string? propertiesJson, string? eventId) + { + var jName = JNIEnv.NewString(name); + var jProperties = NewStringOrNull(propertiesJson); + var jEventId = NewStringOrNull(eventId); + try + { + JNIEnv.CallStaticVoidMethod( + Class.Value, + Method("trackEvent", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"), + new JValue(jName), + new JValue(jProperties), + new JValue(jEventId) + ); + } + finally + { + DeleteLocalRef(jName); + DeleteLocalRef(jProperties); + DeleteLocalRef(jEventId); + } + } + + internal static void Identify(string externalId, string? externalUserName, string? phoneNumber, string? email) + { + var jId = JNIEnv.NewString(externalId); + var jName = NewStringOrNull(externalUserName); + var jPhone = NewStringOrNull(phoneNumber); + var jEmail = NewStringOrNull(email); + try + { + JNIEnv.CallStaticVoidMethod( + Class.Value, + Method( + "identify", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V" + ), + new JValue(jId), + new JValue(jName), + new JValue(jPhone), + new JValue(jEmail) + ); + } + finally + { + DeleteLocalRef(jId); + DeleteLocalRef(jName); + DeleteLocalRef(jPhone); + DeleteLocalRef(jEmail); + } + } + + internal static void StartTrack() => CallVoid("startTrack"); + + internal static void Logout() => CallVoid("logout"); + + internal static void Flush() => CallVoid("flush"); + + private static void CallVoid(string name) => + JNIEnv.CallStaticVoidMethod(Class.Value, Method(name, "()V")); + + private static IntPtr Method(string name, string signature) => + JNIEnv.GetStaticMethodID(Class.Value, name, signature); + + private static IntPtr NewStringOrNull(string? value) => + value is null ? IntPtr.Zero : JNIEnv.NewString(value); + + private static void DeleteLocalRef(IntPtr handle) + { + if (handle != IntPtr.Zero) + { + JNIEnv.DeleteLocalRef(handle); + } + } + + internal static TikTokBusinessInitResult ToResult(Bundle? bundle) + { + if (bundle is null) + { + return TikTokBusinessInitResult.Failure( + TikTokBusinessInitErrorCodes.NativeException, + "The native layer returned no initialization result." + ); + } + if (bundle.GetBoolean(ResultSuccess, false)) + { + return TikTokBusinessInitResult.Success; + } + int? nativeCode = bundle.ContainsKey(ResultNativeErrorCode) ? bundle.GetInt(ResultNativeErrorCode) : null; + return TikTokBusinessInitResult.Failure( + bundle.GetString(ResultErrorCode) ?? TikTokBusinessInitErrorCodes.NativeException, + bundle.GetString(ResultErrorMessage), + nativeCode + ); + } + + /// Receives the one-shot initialization result from the Kotlin facade. + private sealed class InitCallback : Java.Lang.Object, IValueCallback + { + private Action? _onResult; + + internal InitCallback(Action onResult) => _onResult = onResult; + + public void OnReceiveValue(Java.Lang.Object? value) + { + var onResult = Interlocked.Exchange(ref _onResult, null); + onResult?.Invoke(ToResult(value as Bundle ?? value?.JavaCast())); + } + } +} diff --git a/src/Kapusch.TikTok.Business.Android/Internal/TikTokBusinessJson.cs b/src/Kapusch.TikTok.Business.Android/Internal/TikTokBusinessJson.cs new file mode 100644 index 0000000..80f8ee6 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Internal/TikTokBusinessJson.cs @@ -0,0 +1,154 @@ +using System.Buffers; +using System.Collections; +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace Kapusch.TikTok.Business.Android.Internal; + +/// +/// Serializes event properties to the JSON object the native facade turns into an +/// org.json.JSONObject. Uses directly so it stays +/// trimming and AOT safe without reflection-based serialization. +/// +internal static class TikTokBusinessJson +{ + internal static string? Serialize(IReadOnlyDictionary? properties) + { + if (properties is null || properties.Count == 0) + { + return null; + } + + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + foreach (var (key, value) in properties) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("Event property names must not be empty.", nameof(properties)); + } + if (value is null) + { + continue; + } + writer.WritePropertyName(key); + WriteValue(writer, value, key); + } + writer.WriteEndObject(); + } + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + private static void WriteValue(Utf8JsonWriter writer, object value, string path) + { + switch (value) + { + case string text: + writer.WriteStringValue(text); + return; + case bool flag: + writer.WriteBooleanValue(flag); + return; + case byte or sbyte or short or ushort or int or uint or long: + writer.WriteNumberValue(Convert.ToInt64(value, CultureInfo.InvariantCulture)); + return; + case ulong unsigned: + writer.WriteNumberValue(unsigned); + return; + case decimal number: + writer.WriteNumberValue(number); + return; + case float single: + WriteFinite(writer, single, path); + return; + case double real: + WriteFinite(writer, real, path); + return; + case TikTokBusinessContent content: + WriteContent(writer, content); + return; + case IReadOnlyDictionary map: + WriteObject(writer, map, path); + return; + case IDictionary map: + WriteObject(writer, new Dictionary(map), path); + return; + case IEnumerable sequence: + writer.WriteStartArray(); + foreach (var item in sequence) + { + if (item is null) + { + throw new ArgumentException($"Event property '{path}' contains a null array item."); + } + WriteValue(writer, item, path); + } + writer.WriteEndArray(); + return; + default: + throw new ArgumentException( + $"Event property '{path}' has unsupported type {value.GetType().FullName}. " + + "Use string, bool, a number, TikTokBusinessContent, a dictionary or a list." + ); + } + } + + private static void WriteFinite(Utf8JsonWriter writer, double value, string path) + { + if (!double.IsFinite(value)) + { + throw new ArgumentException($"Event property '{path}' must be a finite number."); + } + writer.WriteNumberValue(value); + } + + private static void WriteObject(Utf8JsonWriter writer, IReadOnlyDictionary map, string path) + { + writer.WriteStartObject(); + foreach (var (key, value) in map) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException($"Event property '{path}' contains an empty name."); + } + if (value is null) + { + continue; + } + writer.WritePropertyName(key); + WriteValue(writer, value, path + "." + key); + } + writer.WriteEndObject(); + } + + // Mirrors com.tiktok.appevents.contents.TTContentParams#toJSONObject, which sends + // the price as a string. + private static void WriteContent(Utf8JsonWriter writer, TikTokBusinessContent content) + { + writer.WriteStartObject(); + if (content.Quantity is { } quantity) + { + writer.WriteNumber(TikTokBusinessPropertyNames.ContentQuantity, quantity); + } + WriteOptional(writer, TikTokBusinessPropertyNames.ContentId, content.ContentId); + WriteOptional(writer, TikTokBusinessPropertyNames.ContentCategory, content.ContentCategory); + WriteOptional(writer, TikTokBusinessPropertyNames.ContentName, content.ContentName); + WriteOptional(writer, TikTokBusinessPropertyNames.ContentBrand, content.Brand); + if (content.Price is { } price) + { + writer.WriteString(TikTokBusinessPropertyNames.ContentPrice, price.ToString(CultureInfo.InvariantCulture)); + } + writer.WriteEndObject(); + } + + private static void WriteOptional(Utf8JsonWriter writer, string name, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + writer.WriteString(name, value); + } + } +} diff --git a/src/Kapusch.TikTok.Business.Android/TikTokBusinessConfig.cs b/src/Kapusch.TikTok.Business.Android/TikTokBusinessConfig.cs new file mode 100644 index 0000000..dde8f98 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/TikTokBusinessConfig.cs @@ -0,0 +1,113 @@ +namespace Kapusch.TikTok.Business.Android; + +/// +/// Configuration passed to . +/// +/// +/// Every opt-out defaults to , which matches the native SDK defaults. +/// Properties marked "iOS only" exist so shared code can configure both platforms with one +/// object; they are ignored on Android. +/// +public sealed class TikTokBusinessConfig +{ + /// Creates a configuration with the three identifiers the SDK requires. + /// The store app id. On Android this is the application package name. + /// One TikTok App ID, or several separated by commas. + /// The app secret generated in TikTok Events Manager. + public TikTokBusinessConfig(string appId, string tikTokAppId, string accessToken) + { + AppId = appId; + TikTokAppId = tikTokAppId; + AccessToken = accessToken; + } + + /// Creates a configuration that reports to several TikTok App IDs. + public TikTokBusinessConfig(string appId, IEnumerable tikTokAppIds, string accessToken) + : this(appId, string.Join(',', tikTokAppIds ?? throw new ArgumentNullException(nameof(tikTokAppIds))), accessToken) + { + } + + /// The store app id. On Android this is the application package name. + public string AppId { get; } + + /// + /// One TikTok App ID, or several separated by commas without a trailing comma. + /// The SDK does not initialize when the ids do not match in Events Manager. + /// + public string TikTokAppId { get; } + + /// + /// The app secret generated in TikTok Events Manager. The native SDKs name this value + /// accessToken. Never commit it to source control. + /// + public string AccessToken { get; } + + /// Enables the SDK debug mode used by Events Manager "Test events". + public bool DebugMode { get; init; } + + /// Native SDK log verbosity. Defaults to . + public TikTokBusinessLogLevel LogLevel { get; init; } = TikTokBusinessLogLevel.None; + + /// + /// Initializes the SDK but holds network delivery until . + /// Note that the SDK still requests its remote configuration during initialization. + /// + public bool DisableAutoStart { get; init; } + + /// + /// Disables the automatic lifecycle events (InstallApp, LaunchAPP, 2Dretention). + /// On Android this does not disable automatic in-app purchase tracking: use + /// as well. + /// + public bool DisableAutomaticTracking { get; init; } + + /// Disables the automatic InstallApp event. + public bool DisableInstallTracking { get; init; } + + /// Disables the automatic LaunchAPP event. + public bool DisableLaunchTracking { get; init; } + + /// Disables the automatic 2Dretention event. + public bool DisableRetentionTracking { get; init; } + + /// + /// Asks TikTok to disable automatic in-app purchase tracking. The native SDK sends this + /// preference to TikTok's configuration endpoint, which makes the final decision, so verify + /// in Events Manager that no automatic Purchase arrives. + /// + public bool DisablePaymentTracking { get; init; } + + /// iOS only (deprecated upstream since 1.4.0). Ignored on Android. + public bool DisableAppTrackingDialog { get; init; } + + /// iOS only: stops the SDK from updating SKAdNetwork conversion values. Ignored on Android. + public bool DisableSKAdNetworkSupport { get; init; } + + /// Android: stops the SDK from reading the Google advertising id (GAID). + public bool DisableAdvertiserIdCollection { get; init; } + + /// Disables Enhanced Data Postback (automatic UI interaction capture). + public bool DisableEnhancedDataPostback { get; init; } + + /// Android: disables the SDK's own health and performance metrics. + public bool DisableMonitor { get; init; } + + /// Enables Limited Data Use mode (for example for US state privacy laws). + public bool LimitedDataUse { get; init; } +} + +/// Native TikTok Business SDK log verbosity. +public enum TikTokBusinessLogLevel +{ + /// No native logs. + None = 0, + + /// Informational logs. + Info = 1, + + /// Warnings. + Warn = 2, + + /// Debug output. + Debug = 3, +} diff --git a/src/Kapusch.TikTok.Business.Android/TikTokBusinessContent.cs b/src/Kapusch.TikTok.Business.Android/TikTokBusinessContent.cs new file mode 100644 index 0000000..487e060 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/TikTokBusinessContent.cs @@ -0,0 +1,17 @@ +namespace Kapusch.TikTok.Business.Android; + +/// One item of the standard contents property. +/// Identifier of the item. +/// Display name of the item. +/// Category of the item. +/// Brand of the item. +/// Unit price. +/// Quantity. +public sealed record TikTokBusinessContent( + string? ContentId = null, + string? ContentName = null, + string? ContentCategory = null, + string? Brand = null, + decimal? Price = null, + int? Quantity = null +); diff --git a/src/Kapusch.TikTok.Business.Android/TikTokBusinessEventNames.cs b/src/Kapusch.TikTok.Business.Android/TikTokBusinessEventNames.cs new file mode 100644 index 0000000..299e3e9 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/TikTokBusinessEventNames.cs @@ -0,0 +1,106 @@ +namespace Kapusch.TikTok.Business.Android; + +/// +/// Standard event names of the TikTok Business SDK for Android 1.7.1, copied verbatim from +/// com.tiktok.appevents.base.EventName, TTContentsEventConstants.ContentsEventName +/// and TTConst.AutoEvents. +/// +/// +/// The app SDK has no CompletePayment event: that name belongs to the web pixel taxonomy. +/// Use for app purchases. +/// +public static class TikTokBusinessEventNames +{ + /// Automatic: first initialization on a device. Can also be sent manually when automatic tracking is disabled. + public const string InstallApp = "InstallApp"; + + /// Automatic: every launch. Can also be sent manually when automatic tracking is disabled. + public const string LaunchApp = "LaunchAPP"; + + /// Automatic: the user opened the app again the day after install. + public const string SecondDayRetention = "2Dretention"; + + /// A free trial started. + public const string StartTrial = "StartTrial"; + + /// A paid subscription started. + public const string Subscribe = "Subscribe"; + + /// A purchase completed. Also emitted by automatic in-app purchase tracking when it is enabled. + public const string Purchase = "Purchase"; + + /// The user created an account. + public const string Registration = "Registration"; + + /// The user signed in. + public const string Login = "Login"; + + /// An item was added to the cart. + public const string AddToCart = "AddToCart"; + + /// An item was added to a wishlist. + public const string AddToWishlist = "AddToWishlist"; + + /// The checkout flow started. + public const string Checkout = "Checkout"; + + /// A content or product page was viewed. + public const string ViewContent = "ViewContent"; + + /// Payment details were added. + public const string AddPaymentInfo = "AddPaymentInfo"; + + /// A search was performed. + public const string Search = "Search"; + + /// A level was achieved. + public const string AchieveLevel = "AchieveLevel"; + + /// A tutorial was completed. + public const string CompleteTutorial = "CompleteTutorial"; + + /// A group was created. + public const string CreateGroup = "CreateGroup"; + + /// A role or character was created. + public const string CreateRole = "CreateRole"; + + /// A lead was generated. + public const string GenerateLead = "GenerateLead"; + + /// An in-app ad was clicked. + public const string InAppAdClick = "InAppADClick"; + + /// + /// An in-app ad impression. The Android SDK spells it InAppAdImpr; the iOS SDK spells it + /// InAppADImpr. + /// + public const string InAppAdImpression = "InAppAdImpr"; + + /// Impression-level ad revenue. + public const string ImpressionLevelAdRevenue = "ImpressionLevelAdRevenue"; + + /// A group was joined. + public const string JoinGroup = "JoinGroup"; + + /// Content was rated. + public const string Rate = "Rate"; + + /// In-app credits were spent. + public const string SpendCredits = "SpendCredits"; + + /// An achievement was unlocked. + public const string UnlockAchievement = "UnlockAchievement"; + + /// Deprecated upstream. + [Obsolete("Deprecated by the TikTok Business SDK.")] + public const string LoanApplication = "LoanApplication"; + + /// Deprecated upstream. + [Obsolete("Deprecated by the TikTok Business SDK.")] + public const string LoanApproval = "LoanApproval"; + + /// Deprecated upstream. + [Obsolete("Deprecated by the TikTok Business SDK.")] + public const string LoanDisbursal = "LoanDisbursal"; +} diff --git a/src/Kapusch.TikTok.Business.Android/TikTokBusinessInitResult.cs b/src/Kapusch.TikTok.Business.Android/TikTokBusinessInitResult.cs new file mode 100644 index 0000000..283bfad --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/TikTokBusinessInitResult.cs @@ -0,0 +1,44 @@ +namespace Kapusch.TikTok.Business.Android; + +/// Outcome of . +/// Whether the native SDK reported a successful initialization. +/// +/// A stable diagnostic when is . Wrapper codes are +/// listed in ; native SDK failures use tiktok_<code>. +/// +/// The native or wrapper message, for logs only. +/// The raw native SDK error code, when the SDK reported one. +public sealed record TikTokBusinessInitResult( + bool IsSuccess, + string? ErrorCode = null, + string? ErrorMessage = null, + int? NativeErrorCode = null +) +{ + internal static TikTokBusinessInitResult Success { get; } = new(true); + + internal static TikTokBusinessInitResult Failure(string code, string? message = null, int? nativeCode = null) => + new(false, code, message, nativeCode); +} + +/// Stable error codes produced by the wrapper itself. +public static class TikTokBusinessInitErrorCodes +{ + /// is not a valid Android package name. + public const string InvalidAppId = "invalid_app_id"; + + /// is not a comma-separated list of numeric ids. + public const string InvalidTikTokAppId = "invalid_tiktok_app_id"; + + /// is empty. + public const string MissingAccessToken = "missing_access_token"; + + /// No Android application context is available. + public const string InvalidContext = "invalid_context"; + + /// The native interop classes are not in the app (native assets disabled or stripped). + public const string NativeUnavailable = "native_unavailable"; + + /// The native layer threw while initializing. + public const string NativeException = "native_exception"; +} diff --git a/src/Kapusch.TikTok.Business.Android/TikTokBusinessPropertyNames.cs b/src/Kapusch.TikTok.Business.Android/TikTokBusinessPropertyNames.cs new file mode 100644 index 0000000..12817cb --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/TikTokBusinessPropertyNames.cs @@ -0,0 +1,62 @@ +namespace Kapusch.TikTok.Business.Android; + +/// +/// Standard event property names of the TikTok Business SDK for Android 1.7.1 +/// (TTContentsEventConstants.Params and TTContentParams). +/// +/// TikTok discards custom property keys, so prefer these names. +public static class TikTokBusinessPropertyNames +{ + /// Monetary value of the event, as a number. + public const string Value = "value"; + + /// ISO 4217 currency code, see . + public const string Currency = "currency"; + + /// Identifier of the content or product. + public const string ContentId = "content_id"; + + /// Type of content, for example product or subscription. + public const string ContentType = "content_type"; + + /// Free-form description. + public const string Description = "description"; + + /// Array of content items, see the Content* item keys. + public const string Contents = "contents"; + + /// Order identifier. + public const string OrderId = "order_id"; + + /// Content item key: unit price. + public const string ContentPrice = "price"; + + /// Content item key: quantity. + public const string ContentQuantity = "quantity"; + + /// Content item key: category. + public const string ContentCategory = "content_category"; + + /// Content item key: name. + public const string ContentName = "content_name"; + + /// Content item key: brand. + public const string ContentBrand = "brand"; +} + +/// Currency codes accepted by the TikTok Business SDK for Android 1.7.1 (TTContentsEventConstants.Currency). +public static class TikTokBusinessCurrencies +{ + /// All supported ISO 4217 codes. + public static IReadOnlySet Supported { get; } = new HashSet(StringComparer.Ordinal) + { + "AED", "ARS", "AUD", "BDT", "BHD", "BIF", "BOB", "BRL", "CAD", "CHF", "CLP", "CNY", "COP", "CRC", + "CZK", "DKK", "DZD", "EGP", "EUR", "GBP", "GTQ", "HKD", "HNL", "HUF", "IDR", "ILS", "INR", "ISK", + "JPY", "KES", "KHR", "KRW", "KWD", "KZT", "MAD", "MOP", "MXN", "MYR", "NGN", "NIO", "NOK", "NZD", + "OMR", "PEN", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RUB", "SAR", "SEK", "SGD", "THB", "TRY", + "TWD", "UAH", "USD", "VES", "VND", "ZAR", "BGN", "IQD", "JOD", "LBP", "TZS", + }; + + /// Returns whether is supported by the SDK. + public static bool IsSupported(string? currency) => currency is not null && Supported.Contains(currency); +} diff --git a/src/Kapusch.TikTok.Business.Android/TikTokBusinessSdk.cs b/src/Kapusch.TikTok.Business.Android/TikTokBusinessSdk.cs new file mode 100644 index 0000000..2cbea2e --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/TikTokBusinessSdk.cs @@ -0,0 +1,480 @@ +using System.Text.RegularExpressions; +using Android.OS; +using Kapusch.TikTok.Business.Android.Internal; + +namespace Kapusch.TikTok.Business.Android; + +/// +/// .NET Android entry point for the TikTok Business SDK (App Events). +/// +/// +/// +/// Nothing native is loaded and nothing is sent until is called. +/// Gate that call on the user's consent. Calls made before initialization are ignored. +/// +/// +/// The SDK supports one initialization per process: the first successful configuration wins. +/// Call from the main thread when possible; the wrapper marshals +/// to the main thread otherwise, as the SDK requires. +/// +/// +public static partial class TikTokBusinessSdk +{ + /// The TikTok Business SDK for Android version bundled in this package. + public const string BundledSdkVersion = "1.7.1"; + + private static readonly object Gate = new(); + private static Task? _initialization; + private static volatile bool _forwardingEnabled = true; + + /// Whether the native SDK reports that it is initialized. + public static bool IsInitialized + { + get + { + if (Volatile.Read(ref _initialization) is null) + { + return false; + } + try + { + return NativeTikTokBusinessInterop.IsInitialized(); + } + catch (Java.Lang.Throwable) + { + return false; + } + } + } + + /// + /// The native SDK version, read from the native library once it is available, otherwise + /// . + /// + public static string SdkVersion + { + get + { + if (Volatile.Read(ref _initialization) is null) + { + return BundledSdkVersion; + } + try + { + var version = NativeTikTokBusinessInterop.SdkVersion(); + return string.IsNullOrEmpty(version) ? BundledSdkVersion : version; + } + catch (Java.Lang.Throwable) + { + return BundledSdkVersion; + } + } + } + + /// + /// Whether manual calls are forwarded to the SDK. See . + /// + public static bool IsTrackingEnabled => _forwardingEnabled; + + /// + /// Initializes the TikTok Business SDK and completes with the SDK's own completion callback. + /// + /// + /// Concurrent or repeated calls share the same native initialization. A failed initialization + /// can be retried. Cancelling only stops waiting: the + /// native initialization keeps running. + /// + public static Task InitializeAsync( + TikTokBusinessConfig config, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(config); + var invalid = Validate(config); + if (invalid is not null) + { + return Task.FromResult(invalid); + } + + Task initialization; + lock (Gate) + { + initialization = _initialization ??= StartInitialization(config); + } + return cancellationToken.CanBeCanceled ? initialization.WaitAsync(cancellationToken) : initialization; + } + + /// + /// Starts network delivery when the SDK was initialized with + /// . Ignored before initialization. + /// + public static void StartTrack() + { + var initialization = Volatile.Read(ref _initialization); + if (initialization is null) + { + return; + } + if (initialization.IsCompletedSuccessfully) + { + if (initialization.Result.IsSuccess) + { + NativeTikTokBusinessInterop.StartTrack(); + } + return; + } + initialization.ContinueWith( + static task => + { + if (task.IsCompletedSuccessfully && task.Result.IsSuccess) + { + NativeTikTokBusinessInterop.StartTrack(); + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + } + + /// + /// Enables or disables forwarding of manual calls (events, identify, logout, flush). + /// + /// + /// Enabling also calls . + /// + /// Android limitation: the native SDK has no public API to pause delivery once started. + /// Disabling stops the wrapper from forwarding new calls, but automatic events and events + /// already queued by the SDK are still delivered. To honour a consent withdrawal fully, + /// persist the choice and do not call on the next launch. + /// + /// + public static void SetTrackingEnabled(bool enabled) + { + _forwardingEnabled = enabled; + if (enabled) + { + StartTrack(); + } + } + + /// Tracks an event with optional standard properties and a deduplication id. + /// A name from , or a custom name. + /// + /// Property values may be strings, booleans, numbers, , + /// dictionaries or lists. Prefer : TikTok discards + /// custom property keys. values are omitted. + /// + /// + /// Deduplication id, for example the store transaction id. The SDK generates one when omitted. + /// + public static void TrackEvent( + string eventName, + IReadOnlyDictionary? properties = null, + string? eventId = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(eventName); + var json = TikTokBusinessJson.Serialize(properties); + if (!CanForward()) + { + return; + } + NativeTikTokBusinessInterop.TrackEvent(eventName, json, string.IsNullOrWhiteSpace(eventId) ? null : eventId); + } + + /// Tracks . + /// Amount actually charged for the trial start, usually 0. + /// ISO 4217 code from ; required with . + /// Deduplication id, for example the store transaction id. + /// Product or plan identifier. + /// Content type, for example subscription. + /// Optional description. + /// Optional content items. + public static void TrackStartTrial( + decimal? value = null, + string? currency = null, + string? eventId = null, + string? contentId = null, + string? contentType = null, + string? description = null, + IReadOnlyList? contents = null + ) => + TrackCommerce( + TikTokBusinessEventNames.StartTrial, + value, + currency, + eventId, + contentId, + contentType, + description, + contents + ); + + /// Tracks . + /// Amount actually charged. + /// ISO 4217 code from ; required with . + /// Deduplication id, for example the store transaction id. + /// Product or plan identifier. + /// Content type, for example subscription. + /// Optional description. + /// Optional content items. + public static void TrackSubscribe( + decimal? value = null, + string? currency = null, + string? eventId = null, + string? contentId = null, + string? contentType = null, + string? description = null, + IReadOnlyList? contents = null + ) => + TrackCommerce( + TikTokBusinessEventNames.Subscribe, + value, + currency, + eventId, + contentId, + contentType, + description, + contents + ); + + /// Tracks . + /// Amount actually charged. + /// ISO 4217 code from ; required with . + /// Deduplication id, for example the store order id. + /// Product identifier. + /// Content type, for example product. + /// Optional description. + /// Optional content items. + public static void TrackPurchase( + decimal? value = null, + string? currency = null, + string? eventId = null, + string? contentId = null, + string? contentType = null, + string? description = null, + IReadOnlyList? contents = null + ) => + TrackCommerce( + TikTokBusinessEventNames.Purchase, + value, + currency, + eventId, + contentId, + contentType, + description, + contents + ); + + /// Tracks . + public static void TrackRegistration(string? eventId = null) => + TrackEvent(TikTokBusinessEventNames.Registration, null, eventId); + + /// Tracks . + public static void TrackLogin(string? eventId = null) => + TrackEvent(TikTokBusinessEventNames.Login, null, eventId); + + /// + /// Advanced matching: associates the following events with a user. The SDK hashes the + /// values with SHA-256 on the device before sending them. + /// + public static void Identify( + string externalId, + string? externalUserName = null, + string? phoneNumber = null, + string? email = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(externalId); + if (!CanForward()) + { + return; + } + NativeTikTokBusinessInterop.Identify(externalId, externalUserName, phoneNumber, email); + } + + /// Clears the identity set by . + public static void Logout() + { + if (CanForward()) + { + NativeTikTokBusinessInterop.Logout(); + } + } + + /// Asks the SDK to send queued events now. This is not a delivery receipt. + public static void Flush() + { + if (CanForward()) + { + NativeTikTokBusinessInterop.Flush(); + } + } + + private static void TrackCommerce( + string eventName, + decimal? value, + string? currency, + string? eventId, + string? contentId, + string? contentType, + string? description, + IReadOnlyList? contents + ) + { + if (currency is not null && !TikTokBusinessCurrencies.IsSupported(currency)) + { + throw new ArgumentException( + $"Currency '{currency}' is not supported by the TikTok Business SDK.", + nameof(currency) + ); + } + if (value is not null && currency is null) + { + throw new ArgumentException("A currency is required when a value is set.", nameof(currency)); + } + + var properties = new Dictionary(StringComparer.Ordinal); + if (value is { } amount) + { + properties[TikTokBusinessPropertyNames.Value] = amount; + } + AddIfPresent(properties, TikTokBusinessPropertyNames.Currency, currency); + AddIfPresent(properties, TikTokBusinessPropertyNames.ContentId, contentId); + AddIfPresent(properties, TikTokBusinessPropertyNames.ContentType, contentType); + AddIfPresent(properties, TikTokBusinessPropertyNames.Description, description); + if (contents is { Count: > 0 }) + { + properties[TikTokBusinessPropertyNames.Contents] = contents; + } + TrackEvent(eventName, properties, eventId); + } + + private static void AddIfPresent(Dictionary properties, string key, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + properties[key] = value; + } + } + + private static bool CanForward() => _forwardingEnabled && Volatile.Read(ref _initialization) is not null; + + private static Task StartInitialization(TikTokBusinessConfig config) + { + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var task = completion.Task; + task.ContinueWith( + static (finished, _) => + { + if (!finished.IsCompletedSuccessfully || !finished.Result.IsSuccess) + { + lock (Gate) + { + if (ReferenceEquals(_initialization, finished)) + { + _initialization = null; + } + } + } + }, + null, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + + try + { + using var bundle = ToBundle(config); + NativeTikTokBusinessInterop.Initialize(bundle, result => completion.TrySetResult(result)); + } + catch (Java.Lang.ClassNotFoundException error) + { + completion.TrySetResult( + TikTokBusinessInitResult.Failure(TikTokBusinessInitErrorCodes.NativeUnavailable, error.Message) + ); + } + catch (Java.Lang.NoClassDefFoundError error) + { + completion.TrySetResult( + TikTokBusinessInitResult.Failure(TikTokBusinessInitErrorCodes.NativeUnavailable, error.Message) + ); + } + catch (Java.Lang.Throwable error) + { + completion.TrySetResult( + TikTokBusinessInitResult.Failure(TikTokBusinessInitErrorCodes.NativeException, error.Message) + ); + } + return task; + } + + private static Bundle ToBundle(TikTokBusinessConfig config) + { + var bundle = new Bundle(); + bundle.PutString(NativeTikTokBusinessInterop.KeyAppId, config.AppId.Trim()); + bundle.PutString(NativeTikTokBusinessInterop.KeyTikTokAppId, NormalizeTikTokAppId(config.TikTokAppId)); + bundle.PutString(NativeTikTokBusinessInterop.KeyAccessToken, config.AccessToken.Trim()); + bundle.PutBoolean(NativeTikTokBusinessInterop.KeyDebugMode, config.DebugMode); + bundle.PutInt(NativeTikTokBusinessInterop.KeyLogLevel, (int)config.LogLevel); + bundle.PutBoolean(NativeTikTokBusinessInterop.KeyDisableAutoStart, config.DisableAutoStart); + bundle.PutBoolean(NativeTikTokBusinessInterop.KeyDisableAutomaticTracking, config.DisableAutomaticTracking); + bundle.PutBoolean(NativeTikTokBusinessInterop.KeyDisableInstallTracking, config.DisableInstallTracking); + bundle.PutBoolean(NativeTikTokBusinessInterop.KeyDisableLaunchTracking, config.DisableLaunchTracking); + bundle.PutBoolean(NativeTikTokBusinessInterop.KeyDisableRetentionTracking, config.DisableRetentionTracking); + bundle.PutBoolean(NativeTikTokBusinessInterop.KeyDisablePaymentTracking, config.DisablePaymentTracking); + bundle.PutBoolean( + NativeTikTokBusinessInterop.KeyDisableAdvertiserIdCollection, + config.DisableAdvertiserIdCollection + ); + bundle.PutBoolean( + NativeTikTokBusinessInterop.KeyDisableEnhancedDataPostback, + config.DisableEnhancedDataPostback + ); + bundle.PutBoolean(NativeTikTokBusinessInterop.KeyDisableMonitor, config.DisableMonitor); + bundle.PutBoolean(NativeTikTokBusinessInterop.KeyLimitedDataUse, config.LimitedDataUse); + return bundle; + } + + private static TikTokBusinessInitResult? Validate(TikTokBusinessConfig config) + { + if (string.IsNullOrWhiteSpace(config.AppId) || !AndroidPackageName().IsMatch(config.AppId.Trim())) + { + return TikTokBusinessInitResult.Failure( + TikTokBusinessInitErrorCodes.InvalidAppId, + "AppId must be the Android package name." + ); + } + if ( + string.IsNullOrWhiteSpace(config.TikTokAppId) + || !TikTokAppIdList().IsMatch(NormalizeTikTokAppId(config.TikTokAppId)) + ) + { + return TikTokBusinessInitResult.Failure( + TikTokBusinessInitErrorCodes.InvalidTikTokAppId, + "TikTokAppId must be one or more comma-separated numeric ids." + ); + } + if (string.IsNullOrWhiteSpace(config.AccessToken)) + { + return TikTokBusinessInitResult.Failure( + TikTokBusinessInitErrorCodes.MissingAccessToken, + "AccessToken (the Events Manager app secret) is required." + ); + } + return null; + } + + private static string NormalizeTikTokAppId(string value) => value.Replace(" ", string.Empty, StringComparison.Ordinal); + + // Same patterns as com.tiktok.util.RegexUtil in the Android SDK. + [GeneratedRegex(@"^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$")] + private static partial Regex AndroidPackageName(); + + [GeneratedRegex(@"^(\d+,)*\d+$")] + private static partial Regex TikTokAppIdList(); +} From fd3ed6b541fc90a66b7b6d4ff735cb894ad09c54 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:12:11 +0100 Subject: [PATCH 05/12] build(validation): validate the Business package and keep packages apart validate-business-package.sh checks the required entries, that exactly two vendor AARs ship with no bundled runtime dependencies, that the upstream AAR matches its lock byte for byte, the JNI signatures the managed layer calls, the consumer R8 rules, the managed NuGet dependencies and that Billing / Install Referrer stay consumer-owned. The Share validator now also rejects any Business artifact, and verify-public.py scans tracked files for private identifiers. Co-Authored-By: Claude Opus 5.5 --- scripts/validate-business-package.sh | 124 +++++++++++++++++++++++++++ scripts/validate-package.sh | 5 ++ scripts/verify-public.py | 26 ++++++ 3 files changed, 155 insertions(+) create mode 100755 scripts/validate-business-package.sh create mode 100644 scripts/verify-public.py diff --git a/scripts/validate-business-package.sh b/scripts/validate-business-package.sh new file mode 100755 index 0000000..543aae6 --- /dev/null +++ b/scripts/validate-business-package.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Validates a packed Kapusch.TikTok.Business.Android nupkg. Update this script in +# the same commit as any change to the Business package contents. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +sdk_version="1.7.1" +sdk_aar="tiktok-business-android-sdk-$sdk_version.aar" +lock_file="$repo_root/DependencyLocks/Android/business-lockstate.txt" +verification="$repo_root/src/Kapusch.TikTok.Business.Android/Native/Android/gradle/verification-metadata.xml" + +package="${1:-}" +if [ -z "$package" ]; then + shopt -s nullglob + candidates=("$repo_root"/artifacts/nuget/Kapusch.TikTok.Business.Android.*.nupkg) + shopt -u nullglob + if [ "${#candidates[@]}" -ne 1 ]; then + echo "Expected exactly one Kapusch.TikTok.Business.Android nupkg in artifacts/nuget, found ${#candidates[@]}. Pass the path explicitly." >&2 + exit 1 + fi + package="${candidates[0]}" +fi +if [ ! -f "$package" ]; then + echo "Package not found: $package" >&2 + exit 1 +fi + +fail() { + echo "Business package validation failed: $*" >&2 + exit 1 +} + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +entries="$(unzip -Z1 "$package")" +for expected in \ + "android/aar/ktk-business-interop-release.aar" \ + "android/aar/$sdk_aar" \ + "buildTransitive/Kapusch.TikTok.Business.Android.props" \ + "buildTransitive/Kapusch.TikTok.Business.Android.targets" \ + "lib/net10.0-android36.0/Kapusch.TikTok.Business.Android.dll" \ + "licenses/TIKTOK-BUSINESS-SDK-LICENSE" \ + "THIRD_PARTY_NOTICES.md" \ + "nuget-readme.md"; do + grep -Fxq "$expected" <<<"$entries" || fail "missing package entry $expected" +done + +# Exactly two vendor AARs and no bundled runtime dependencies: shared AndroidX, +# Kotlin, Billing and Install Referrer classes must never be repackaged here. +aar_count="$(grep -Ec '^android/aar/[^/]+\.aar$' <<<"$entries")" +[ "$aar_count" -eq 2 ] || fail "expected exactly 2 AARs under android/aar, found $aar_count" +if grep -Eq '^android/deps/' <<<"$entries"; then + fail "the Business package must not bundle runtime dependencies (android/deps)" +fi +if grep -Eiq '\.(aar|jar)$' <<<"$(grep -Ev '^android/aar/(ktk-business-interop-release|tiktok-business-android-sdk-[0-9.]+)\.aar$' <<<"$entries")"; then + fail "unexpected Java archive in package" +fi +# The Share package owns the OpenSDK; never mix the two. +if grep -Eq 'tiktok-open-sdk|ktk-tiktokinterop|Kapusch\.TikTok\.Android\.(props|targets)' <<<"$entries"; then + fail "OpenSDK Share artifacts found in the Business package" +fi + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT +unzip -q "$package" "android/aar/*.aar" "*.nuspec" -d "$tmp_dir" + +# The upstream AAR is shipped byte-for-byte as locked. +expected_sha="$(awk -v f="$sdk_aar" '$3 == f {print $2}' "$lock_file")" +[ -n "$expected_sha" ] || fail "$sdk_aar is not locked in $lock_file" +actual_sha="$(sha256_of "$tmp_dir/android/aar/$sdk_aar")" +[ "$actual_sha" = "$expected_sha" ] || fail "$sdk_aar checksum $actual_sha does not match lock $expected_sha" + +unzip -p "$tmp_dir/android/aar/$sdk_aar" classes.jar > "$tmp_dir/sdk-classes.jar" +jar tf "$tmp_dir/sdk-classes.jar" | grep -Fxq 'com/tiktok/TikTokBusinessSdk.class' || fail "upstream AAR lacks TikTokBusinessSdk" + +unzip -p "$tmp_dir/android/aar/ktk-business-interop-release.aar" classes.jar > "$tmp_dir/interop-classes.jar" +interop_classes="$(jar tf "$tmp_dir/interop-classes.jar" | grep '\.class$')" +grep -Fxq 'com/kapusch/tiktok/business/androidinterop/TikTokBusinessInterop.class' <<<"$interop_classes" || fail "interop AAR lacks TikTokBusinessInterop" +if grep -Ev '^com/kapusch/tiktok/business/androidinterop/' <<<"$interop_classes" | grep -q .; then + fail "interop AAR repackages classes outside its own namespace" +fi + +# JNI entry points used by the managed layer. +javap -cp "$tmp_dir/interop-classes.jar" com.kapusch.tiktok.business.androidinterop.TikTokBusinessInterop > "$tmp_dir/interop.javap" +for signature in \ + 'public static final void initialize(android.content.Context, android.os.Bundle, android.webkit.ValueCallback);' \ + 'public static final boolean isInitialized();' \ + 'public static final java.lang.String sdkVersion();' \ + 'public static final void startTrack();' \ + 'public static final void trackEvent(java.lang.String, java.lang.String, java.lang.String);' \ + 'public static final void identify(java.lang.String, java.lang.String, java.lang.String, java.lang.String);' \ + 'public static final void logout();' \ + 'public static final void flush();'; do + grep -Fq "$signature" "$tmp_dir/interop.javap" || fail "interop JNI signature changed or missing: $signature" +done + +rules="$(unzip -p "$tmp_dir/android/aar/ktk-business-interop-release.aar" proguard.txt)" +for rule in \ + '-keep class com.kapusch.tiktok.business.androidinterop.TikTokBusinessInterop { public static *; }' \ + '-keep class com.tiktok.** { *; }' \ + '-dontwarn com.android.installreferrer.**' \ + '-dontwarn com.android.billingclient.**'; do + grep -Fq -- "$rule" <<<"$rules" || fail "interop consumer rules lack: $rule" +done + +nuspec="$(cat "$tmp_dir"/*.nuspec)" +for dependency in Xamarin.AndroidX.Annotation Xamarin.AndroidX.Lifecycle.Process Xamarin.Kotlin.StdLib; do + grep -Fq "id=\"$dependency\"" <<<"$nuspec" || fail "missing managed dependency $dependency" +done +if grep -Eq 'id="[^"]*(BillingClient|InstallReferrer)[^"]*"' <<<"$nuspec"; then + fail "Billing and Install Referrer must stay consumer-owned" +fi + +grep -Eq "/$sdk_version/tiktok-business-android-sdk-$sdk_version\.aar [0-9a-f]{64} " "$lock_file" || fail "lock file does not pin SDK $sdk_version" +grep -Eq '&2 + exit 1 +fi + tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' EXIT unzip -q "$package" "android/aar/*.aar" -d "$tmp_dir" diff --git a/scripts/verify-public.py b/scripts/verify-public.py new file mode 100644 index 0000000..1d6a4d0 --- /dev/null +++ b/scripts/verify-public.py @@ -0,0 +1,26 @@ +"""Fails when a tracked file contains a private application identifier. + +The pattern is written with hex escapes so this scanner does not itself contain +the names it rejects. Keep every doc, sample, test and script generic. +""" +from pathlib import Path +import re +import subprocess +import sys + +root = Path(__file__).resolve().parents[1] +files = subprocess.check_output(["git", "ls-files"], cwd=root, text=True).splitlines() +private = re.compile( + rb"(?i)\x62\x6c\x6f\x6f\x6d[ &_-]*(?:\x61\x6e\x64[ _-]*)?(?:\x66\x6c\x79|\x79)" + rb"|\x62\x6c\x6f\x6f\x6d\x79\x61\x70\x70\x73" +) +failures = [] +for name in files: + path = root / name + if not path.is_file(): + continue + if private.search(path.read_bytes()): + failures.append(name) +if failures: + sys.exit("Private identifier found in: " + ", ".join(failures)) +print(f"Public tree scan passed ({len(files)} tracked files)") From d2f9b30f1d43092461957aa332e1cad0bcf88b78 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:14:55 +0100 Subject: [PATCH 06/12] docs(sample): add a clean-consumer TikTok Business sample A plain .NET Android app restores the packed package from artifacts/nuget and shows the consent-gated recipe: the SDK is only initialized after the user accepts. Credentials come from an untracked asset copied from a template. Release builds run R8; an MSBuild switch drops Billing and Install Referrer to prove the package links without them. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + .../Assets/TikTokBusiness.template.json | 6 + ...usch.TikTok.Business.Android.Sample.csproj | 42 ++++++ .../MainActivity.cs | 122 ++++++++++++++++++ .../NuGet.Config | 16 +++ .../Properties/AndroidManifest.xml | 5 + .../README.md | 37 ++++++ samples/README.md | 11 +- 8 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 samples/Kapusch.TikTok.Business.Android.Sample/Assets/TikTokBusiness.template.json create mode 100644 samples/Kapusch.TikTok.Business.Android.Sample/Kapusch.TikTok.Business.Android.Sample.csproj create mode 100644 samples/Kapusch.TikTok.Business.Android.Sample/MainActivity.cs create mode 100644 samples/Kapusch.TikTok.Business.Android.Sample/NuGet.Config create mode 100644 samples/Kapusch.TikTok.Business.Android.Sample/Properties/AndroidManifest.xml create mode 100644 samples/Kapusch.TikTok.Business.Android.Sample/README.md diff --git a/.gitignore b/.gitignore index b3184d0..d5fd0f3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ src/**/Native/Android/local.properties src/**/Native/Android/upstream/ src/**/Native/Android/tiktok-open-sdk-core src/**/Native/Android/tiktok-open-sdk-share +samples/**/Assets/*.local.json diff --git a/samples/Kapusch.TikTok.Business.Android.Sample/Assets/TikTokBusiness.template.json b/samples/Kapusch.TikTok.Business.Android.Sample/Assets/TikTokBusiness.template.json new file mode 100644 index 0000000..e482398 --- /dev/null +++ b/samples/Kapusch.TikTok.Business.Android.Sample/Assets/TikTokBusiness.template.json @@ -0,0 +1,6 @@ +{ + "appId": "com.kapusch.tiktokbusinessandroid.sample", + "tikTokAppId": "YOUR_TIKTOK_APP_ID", + "accessToken": "YOUR_EVENTS_MANAGER_APP_SECRET", + "debugMode": true +} diff --git a/samples/Kapusch.TikTok.Business.Android.Sample/Kapusch.TikTok.Business.Android.Sample.csproj b/samples/Kapusch.TikTok.Business.Android.Sample/Kapusch.TikTok.Business.Android.Sample.csproj new file mode 100644 index 0000000..9e51ee6 --- /dev/null +++ b/samples/Kapusch.TikTok.Business.Android.Sample/Kapusch.TikTok.Business.Android.Sample.csproj @@ -0,0 +1,42 @@ + + + + net10.0-android + 23 + Exe + enable + enable + com.kapusch.tiktokbusinessandroid.sample + TikTok Business Sample + 1 + 1.0 + false + 1.0.0 + + + r8 + apk + false + + + + + + + + + + + + + + + diff --git a/samples/Kapusch.TikTok.Business.Android.Sample/MainActivity.cs b/samples/Kapusch.TikTok.Business.Android.Sample/MainActivity.cs new file mode 100644 index 0000000..fca2395 --- /dev/null +++ b/samples/Kapusch.TikTok.Business.Android.Sample/MainActivity.cs @@ -0,0 +1,122 @@ +using System.Text.Json; +using Android.App; +using Android.OS; +using Android.Util; +using Android.Widget; +using Kapusch.TikTok.Business.Android; + +namespace Kapusch.TikTok.Business.Android.Sample; + +/// +/// Consent-gated integration example. Nothing reaches the TikTok SDK until the user taps +/// "Accept" (or the activity is started with the auto_consent boolean extra for +/// scripted smoke tests). +/// +[Activity(Label = "TikTok Business Sample", MainLauncher = true, Exported = true)] +public sealed class MainActivity : Activity +{ + private const string LogTag = "KtkBusinessSample"; + private const string LocalConfigAsset = "TikTokBusiness.local.json"; + private TextView? _log; + + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + var layout = new LinearLayout(this) { Orientation = Orientation.Vertical }; + var accept = new Button(this) { Text = "Accept ad measurement" }; + var decline = new Button(this) { Text = "Decline" }; + _log = new TextView(this); + layout.AddView(accept); + layout.AddView(decline); + layout.AddView(_log); + SetContentView(layout); + + accept.Click += async (_, _) => await OnConsentGrantedAsync(); + decline.Click += (_, _) => Write("Consent declined: the SDK stays uninitialized."); + + Write($"Bundled TikTok Business SDK {TikTokBusinessSdk.BundledSdkVersion}; initialized={TikTokBusinessSdk.IsInitialized}"); + if (Intent?.GetBooleanExtra("auto_consent", false) == true) + { + _ = OnConsentGrantedAsync(); + } + } + + private async Task OnConsentGrantedAsync() + { + var config = LoadConfig(); + if (config is null) + { + Write($"Missing Assets/{LocalConfigAsset}. Copy TikTokBusiness.template.json and fill in your ids."); + return; + } + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + TikTokBusinessInitResult result; + try + { + result = await TikTokBusinessSdk.InitializeAsync(config, timeout.Token); + } + catch (System.OperationCanceledException) + { + Write("Initialization timed out."); + return; + } + Write( + $"Init success={result.IsSuccess} code={result.ErrorCode} native={result.NativeErrorCode} " + + $"initialized={TikTokBusinessSdk.IsInitialized} sdk={TikTokBusinessSdk.SdkVersion}" + ); + if (!result.IsSuccess) + { + return; + } + + // Manual events with a store transaction id as event_id, as recommended when automatic + // in-app purchase tracking is disabled. + TikTokBusinessSdk.TrackStartTrial(value: 0m, currency: "EUR", eventId: "sample-trial-0001", contentId: "sample.monthly", contentType: "subscription"); + TikTokBusinessSdk.TrackSubscribe(value: 4.99m, currency: "EUR", eventId: "sample-sub-0001", contentId: "sample.monthly", contentType: "subscription"); + TikTokBusinessSdk.TrackEvent( + TikTokBusinessEventNames.ViewContent, + new Dictionary + { + [TikTokBusinessPropertyNames.ContentId] = "sample.paywall", + [TikTokBusinessPropertyNames.Contents] = new[] { new TikTokBusinessContent(ContentId: "sample.monthly", Price: 4.99m, Quantity: 1) }, + } + ); + TikTokBusinessSdk.TrackRegistration(); + TikTokBusinessSdk.Flush(); + Write("Tracked StartTrial, Subscribe, ViewContent and Registration; flush requested."); + } + + private TikTokBusinessConfig? LoadConfig() + { + try + { + using var stream = Assets!.Open(LocalConfigAsset); + using var document = JsonDocument.Parse(stream); + var root = document.RootElement; + return new TikTokBusinessConfig( + root.GetProperty("appId").GetString() ?? PackageName!, + root.GetProperty("tikTokAppId").GetString() ?? string.Empty, + root.GetProperty("accessToken").GetString() ?? string.Empty + ) + { + DebugMode = root.TryGetProperty("debugMode", out var debug) && debug.GetBoolean(), + LogLevel = TikTokBusinessLogLevel.Debug, + // Recommended for subscription apps: report trials and subscriptions manually. + DisablePaymentTracking = true, + DisableEnhancedDataPostback = true, + }; + } + catch (Java.IO.IOException) + { + return null; + } + } + + private void Write(string message) + { + Log.Info(LogTag, message); + RunOnUiThread(() => _log?.Append(message + "\n")); + } +} diff --git a/samples/Kapusch.TikTok.Business.Android.Sample/NuGet.Config b/samples/Kapusch.TikTok.Business.Android.Sample/NuGet.Config new file mode 100644 index 0000000..2e3b578 --- /dev/null +++ b/samples/Kapusch.TikTok.Business.Android.Sample/NuGet.Config @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/samples/Kapusch.TikTok.Business.Android.Sample/Properties/AndroidManifest.xml b/samples/Kapusch.TikTok.Business.Android.Sample/Properties/AndroidManifest.xml new file mode 100644 index 0000000..4080131 --- /dev/null +++ b/samples/Kapusch.TikTok.Business.Android.Sample/Properties/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/samples/Kapusch.TikTok.Business.Android.Sample/README.md b/samples/Kapusch.TikTok.Business.Android.Sample/README.md new file mode 100644 index 0000000..6dd3595 --- /dev/null +++ b/samples/Kapusch.TikTok.Business.Android.Sample/README.md @@ -0,0 +1,37 @@ +# TikTok Business SDK sample + +A plain .NET Android app that consumes the **packed** `Kapusch.TikTok.Business.Android` +package from `../../artifacts/nuget`, like an application would. It shows the +consent-gated recipe: nothing reaches the SDK until the user taps **Accept**. + +## Configure (never commit credentials) + +```sh +cp Assets/TikTokBusiness.template.json Assets/TikTokBusiness.local.json +# edit appId (the app package name), tikTokAppId and accessToken (Events Manager app secret) +``` + +`Assets/*.local.json` is git-ignored. Without it the app starts and reports the +missing configuration. + +## Build and run + +```sh +bash src/Kapusch.TikTok.Business.Android/Native/Android/build.sh +dotnet pack src/Kapusch.TikTok.Business.Android/Kapusch.TikTok.Business.Android.csproj -c Release -o artifacts/nuget +dotnet build samples/Kapusch.TikTok.Business.Android.Sample/Kapusch.TikTok.Business.Android.Sample.csproj -c Release -p:RuntimeIdentifier=android-arm64 +``` + +Run from the repository root. Release builds use R8, so they also prove that the +consumer rules keep what the SDK needs and that no class is duplicated. + +- `-p:SampleWithoutOptionalAndroidDeps=true` drops Google Play Billing and Install + Referrer to prove the package links without them. +- After repacking the same version, clear `~/.nuget/packages/kapusch.tiktok.business.android` + or pack a new version and pass `-p:KapuschTikTokBusinessAndroidVersion=`. +- For a scripted smoke test, start the activity with `--ez auto_consent true`. + The sample logs under the `KtkBusinessSample` tag. + +Use Events Manager **Test events** with `debugMode: true` to see events arrive. +The sample sets `DisablePaymentTracking` and sends `StartTrial` and `Subscribe` +manually with a transaction id as `event_id`. diff --git a/samples/README.md b/samples/README.md index d62b0dd..80babeb 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,5 +1,10 @@ # Samples -`Kapusch.TikTok.Android.Sample` documents the smallest Android integration surface without committing a client key. - -Provide the manifest placeholder locally and two readable JPEG paths before runtime testing. CI validates the package itself because editor handoff requires an approved TikTok application and a physical device. +- `Kapusch.TikTok.Android.Sample` documents the smallest OpenSDK Share integration + surface without committing a client key. Provide the manifest placeholder locally + and two readable JPEG paths before runtime testing. CI validates the package itself + because editor handoff requires an approved TikTok application and a physical device. +- `Kapusch.TikTok.Business.Android.Sample` is a buildable clean-consumer app for the + TikTok Business SDK package. CI builds it in Release with R8, with and without the + optional Billing and Install Referrer dependencies. Credentials come from an + untracked local asset. From 2d0deee11e900cf809fd2b5c1f9eadc7eabc3c83 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:14:24 +0100 Subject: [PATCH 07/12] ci: build, validate and consume the Business package on pull requests Add a public-tree scan job and a Business job on the same pinned runner and toolchain as the Share job: native build, pack, package validation, then a clean-consumer Release build of the sample with R8, once with Billing 8.3 and Install Referrer and once without them. The Share job is unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eeae92b..30e0609 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,13 @@ permissions: contents: read jobs: + public_scan: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Scan tracked files for private identifiers + run: python3 scripts/verify-public.py + build_pack_android: # Pinned: on ubuntu-26.04 (ubuntu-latest since Oct 2026) the android workload install fails with "Disk quota exceeded". runs-on: ubuntu-24.04 @@ -37,3 +44,41 @@ jobs: with: name: nuget path: artifacts/nuget/*.nupkg + + build_pack_business_android: + # Same pinned runner and toolchain as the Share package job. + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.203" + - uses: actions/setup-java@v4 + with: + distribution: microsoft + java-version: "21" + - uses: android-actions/setup-android@v4 + with: + cmdline-tools-version: '12266719' + packages: 'platform-tools' + - name: Install Android workload + run: dotnet workload install android --version 10.0.203 + - name: Build locked TikTok Business SDK wrapper + run: bash src/Kapusch.TikTok.Business.Android/Native/Android/build.sh + - name: Pack + run: dotnet pack src/Kapusch.TikTok.Business.Android/Kapusch.TikTok.Business.Android.csproj -c Release -o artifacts/nuget + - name: Validate package + run: scripts/validate-business-package.sh + - name: Clean-consumer sample (Release, R8, with Billing and Install Referrer) + run: >- + dotnet build samples/Kapusch.TikTok.Business.Android.Sample/Kapusch.TikTok.Business.Android.Sample.csproj + -c Release -p:RuntimeIdentifier=android-arm64 + - name: Clean-consumer sample (Release, R8, without optional dependencies) + run: | + rm -rf samples/Kapusch.TikTok.Business.Android.Sample/bin samples/Kapusch.TikTok.Business.Android.Sample/obj + dotnet build samples/Kapusch.TikTok.Business.Android.Sample/Kapusch.TikTok.Business.Android.Sample.csproj \ + -c Release -p:RuntimeIdentifier=android-arm64 -p:SampleWithoutOptionalAndroidDeps=true + - uses: actions/upload-artifact@v4 + with: + name: nuget-business + path: artifacts/nuget/Kapusch.TikTok.Business.Android.*.nupkg From c88e9ebad429dcae52a33524affb5f1261cc2474 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:14:24 +0100 Subject: [PATCH 08/12] ci: publish each package from its own tag family Keep a single publish workflow so the existing NuGet Trusted Publishing policy covers both packages. v* tags still release Kapusch.TikTok.Android; business-v* tags release Kapusch.TikTok.Business.Android, which must be X.Y.Z or X.Y.Z-rc.N. Manual runs pick the package explicitly. Releases refuse to overwrite an existing NuGet.org version, pack with the source commit, push only the selected nupkg and upload it as an artifact. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 123 ++++++++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 751ff55..da828b5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,8 +1,21 @@ name: publish +# One workflow releases either package so the NuGet Trusted Publishing policy +# bound to this file covers both: +# - tags v* -> Kapusch.TikTok.Android (OpenSDK Share), unchanged contract; +# - tags business-v* -> Kapusch.TikTok.Business.Android. +# GitHub tag filters match the whole ref name, so "v*" never matches "business-v*". on: workflow_dispatch: inputs: + package: + description: "Package to publish" + required: true + default: share + type: choice + options: + - share + - business manual_version: description: "Publish this version to NuGet.org; leave empty for a GitHub Packages preview" required: false @@ -10,6 +23,7 @@ on: push: tags: - "v*" + - "business-v*" permissions: contents: read @@ -28,26 +42,84 @@ jobs: with: fetch-depth: 0 + - name: Select package + id: package + shell: bash + env: + REF: ${{ github.ref }} + EVENT: ${{ github.event_name }} + INPUT_PACKAGE: ${{ github.event.inputs.package }} + run: | + if [[ "$REF" =~ ^refs/tags/business-v ]]; then + TAG_PACKAGE="business" + elif [[ "$REF" =~ ^refs/tags/v ]]; then + TAG_PACKAGE="share" + else + TAG_PACKAGE="" + fi + + if [[ "$EVENT" == "workflow_dispatch" ]]; then + PACKAGE="${INPUT_PACKAGE:-share}" + if [[ -n "$TAG_PACKAGE" && "$TAG_PACKAGE" != "$PACKAGE" ]]; then + echo "ERROR: tag $REF belongs to the $TAG_PACKAGE package, not $PACKAGE." + exit 1 + fi + else + PACKAGE="$TAG_PACKAGE" + fi + + case "$PACKAGE" in + share) + echo "package_id=Kapusch.TikTok.Android" >> "$GITHUB_OUTPUT" + echo "csproj=src/Kapusch.TikTokApisForAndroidComponents/Kapusch.TikTokApisForAndroidComponents.csproj" >> "$GITHUB_OUTPUT" + echo "native_build=src/Kapusch.TikTokApisForAndroidComponents/Native/Android/build.sh" >> "$GITHUB_OUTPUT" + echo "validator=scripts/validate-package.sh" >> "$GITHUB_OUTPUT" + echo "tag_prefix=v" >> "$GITHUB_OUTPUT" + ;; + business) + echo "package_id=Kapusch.TikTok.Business.Android" >> "$GITHUB_OUTPUT" + echo "csproj=src/Kapusch.TikTok.Business.Android/Kapusch.TikTok.Business.Android.csproj" >> "$GITHUB_OUTPUT" + echo "native_build=src/Kapusch.TikTok.Business.Android/Native/Android/build.sh" >> "$GITHUB_OUTPUT" + echo "validator=scripts/validate-business-package.sh" >> "$GITHUB_OUTPUT" + echo "tag_prefix=business-v" >> "$GITHUB_OUTPUT" + ;; + *) + echo "ERROR: cannot determine the package to publish for $EVENT on $REF." + exit 1 + ;; + esac + echo "package=$PACKAGE" >> "$GITHUB_OUTPUT" + echo "Publishing package: $PACKAGE" + - name: Determine package version id: version shell: bash + env: + REF: ${{ github.ref }} + MANUAL_VERSION: ${{ github.event.inputs.manual_version }} + PACKAGE: ${{ steps.package.outputs.package }} + CSPROJ: ${{ steps.package.outputs.csproj }} + TAG_PREFIX: ${{ steps.package.outputs.tag_prefix }} run: | - REF="${{ github.ref }}" - MANUAL_VERSION="${{ github.event.inputs.manual_version }}" - CSPROJ="src/Kapusch.TikTokApisForAndroidComponents/Kapusch.TikTokApisForAndroidComponents.csproj" BASE_VERSION=$(grep -oE '[^<]+' "$CSPROJ" | head -n 1 | sed 's///') if [[ -n "$MANUAL_VERSION" ]]; then VERSION="$MANUAL_VERSION" PUBLISH_TARGET="nuget" - elif [[ "$REF" =~ ^refs/tags/v ]]; then - VERSION="${REF#refs/tags/v}" + elif [[ "$REF" == refs/tags/${TAG_PREFIX}* ]]; then + VERSION="${REF#refs/tags/${TAG_PREFIX}}" PUBLISH_TARGET="nuget" else VERSION="${BASE_VERSION}-preview.${{ github.run_number }}.$(git rev-parse --short HEAD)" PUBLISH_TARGET="github" fi + if [[ "$PACKAGE" == "business" && "$PUBLISH_TARGET" == "nuget" ]] \ + && ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "ERROR: Business releases must be X.Y.Z or X.Y.Z-rc.N (got '$VERSION')." + exit 1 + fi + if [[ "$PUBLISH_TARGET" == "nuget" ]]; then git fetch origin master if ! git merge-base --is-ancestor "${{ github.sha }}" origin/master; then @@ -59,6 +131,21 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "publish_target=$PUBLISH_TARGET" >> "$GITHUB_OUTPUT" + - name: Refuse to republish an existing NuGet.org version + if: steps.version.outputs.publish_target == 'nuget' + shell: bash + env: + PACKAGE_ID: ${{ steps.package.outputs.package_id }} + VERSION: ${{ steps.version.outputs.version }} + run: | + id_lower="$(tr '[:upper:]' '[:lower:]' <<<"$PACKAGE_ID")" + version_lower="$(tr '[:upper:]' '[:lower:]' <<<"$VERSION")" + index="$(curl -fsS "https://api.nuget.org/v3-flatcontainer/$id_lower/index.json" || true)" + if grep -Fq "\"$version_lower\"" <<<"$index"; then + echo "ERROR: $PACKAGE_ID $VERSION is already on NuGet.org. Versions are immutable: publish a new version." + exit 1 + fi + - uses: actions/setup-dotnet@v4 with: dotnet-version: "10.0.203" @@ -77,18 +164,22 @@ jobs: - name: Install Android workload run: dotnet workload install android --version 10.0.203 - - name: Build pinned Core + Share wrapper - run: bash src/Kapusch.TikTokApisForAndroidComponents/Native/Android/build.sh + - name: Build pinned native assets + run: bash "${{ steps.package.outputs.native_build }}" - name: Pack run: | - dotnet pack src/Kapusch.TikTokApisForAndroidComponents/Kapusch.TikTokApisForAndroidComponents.csproj \ + dotnet pack "${{ steps.package.outputs.csproj }}" \ -c Release \ -o artifacts/nuget \ - /p:PackageVersion="${{ steps.version.outputs.version }}" + /p:PackageVersion="${{ steps.version.outputs.version }}" \ + /p:ContinuousIntegrationBuild=true \ + /p:RepositoryCommit="${{ github.sha }}" - name: Validate package - run: scripts/validate-package.sh "artifacts/nuget/Kapusch.TikTok.Android.${{ steps.version.outputs.version }}.nupkg" + run: | + "${{ steps.package.outputs.validator }}" \ + "artifacts/nuget/${{ steps.package.outputs.package_id }}.${{ steps.version.outputs.version }}.nupkg" - name: Push to NuGet.org if: steps.version.outputs.publish_target == 'nuget' @@ -100,17 +191,21 @@ jobs: - name: Push to NuGet.org if: steps.version.outputs.publish_target == 'nuget' run: | - dotnet nuget push artifacts/nuget/*.nupkg \ + dotnet nuget push "artifacts/nuget/${{ steps.package.outputs.package_id }}.${{ steps.version.outputs.version }}.nupkg" \ --api-key "${{ steps.nuget_login.outputs.NUGET_API_KEY }}" \ - --source "https://api.nuget.org/v3/index.json" \ - --skip-duplicate + --source "https://api.nuget.org/v3/index.json" - name: Push to GitHub Packages if: steps.version.outputs.publish_target == 'github' env: NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - dotnet nuget push artifacts/nuget/*.nupkg \ + dotnet nuget push "artifacts/nuget/${{ steps.package.outputs.package_id }}.${{ steps.version.outputs.version }}.nupkg" \ --api-key "$NUGET_AUTH_TOKEN" \ --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" \ --skip-duplicate + + - uses: actions/upload-artifact@v4 + with: + name: ${{ steps.package.outputs.package_id }}-${{ steps.version.outputs.version }} + path: artifacts/nuget/${{ steps.package.outputs.package_id }}.${{ steps.version.outputs.version }}.nupkg From 1b1e2e9b30be639030366d55ffe305d62a59bf2b Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:15:37 +0100 Subject: [PATCH 09/12] docs(business): ship Business-specific third-party notices Pack THIRD_PARTY_NOTICES.Business.md as the Business package notices so the Share package keeps its notices file unchanged, and document the data the SDK collects for privacy and Play Data Safety reviews. The validator checks the notices name the bundled SDK version. Co-Authored-By: Claude Opus 5.5 --- THIRD_PARTY_NOTICES.Business.md | 26 +++++++++++++++++++ scripts/validate-business-package.sh | 2 ++ .../Kapusch.TikTok.Business.Android.csproj | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 THIRD_PARTY_NOTICES.Business.md diff --git a/THIRD_PARTY_NOTICES.Business.md b/THIRD_PARTY_NOTICES.Business.md new file mode 100644 index 0000000..9fae880 --- /dev/null +++ b/THIRD_PARTY_NOTICES.Business.md @@ -0,0 +1,26 @@ +# Third-party notices (Kapusch.TikTok.Business.Android) + +This package redistributes, unchanged, the TikTok Business SDK for Android 1.7.1 +(`tiktok-business-android-sdk-1.7.1.aar`, Copyright (c) 2020 TikTok Inc.), licensed +under the MIT license. The license text is included as +`licenses/TIKTOK-BUSINESS-SDK-LICENSE`. The artifact is pinned by SHA-256 in +`DependencyLocks/Android/business-lockstate.txt`. + +The package does not redistribute any other third-party Java library. The +following runtime dependencies are resolved by the consuming application: + +- AndroidX Annotation, AndroidX Lifecycle Process and the Kotlin standard library, + through the managed `Xamarin.AndroidX.*` and `Xamarin.Kotlin.StdLib` NuGet + packages declared as dependencies; +- Google Play Billing Library and Google Play Install Referrer Library, optional + and owned by the application. + +## Data collection note + +The SDK merges the `android.permission.INTERNET` and +`com.google.android.gms.permission.AD_ID` permissions. When initialized it reads +the Google advertising identifier (unless advertiser id collection is disabled), +the Play install referrer when that library is present, device and app +information, and in-app purchases when automatic purchase tracking is active. It +sends this data to TikTok. The consuming application must cover it in its privacy +policy, consent flow and Google Play Data Safety declaration. diff --git a/scripts/validate-business-package.sh b/scripts/validate-business-package.sh index 543aae6..6eea46d 100755 --- a/scripts/validate-business-package.sh +++ b/scripts/validate-business-package.sh @@ -109,6 +109,8 @@ for rule in \ grep -Fq -- "$rule" <<<"$rules" || fail "interop consumer rules lack: $rule" done +unzip -p "$package" THIRD_PARTY_NOTICES.md | grep -Fq "TikTok Business SDK for Android $sdk_version" || fail "THIRD_PARTY_NOTICES.md does not describe the Business SDK $sdk_version" + nuspec="$(cat "$tmp_dir"/*.nuspec)" for dependency in Xamarin.AndroidX.Annotation Xamarin.AndroidX.Lifecycle.Process Xamarin.Kotlin.StdLib; do grep -Fq "id=\"$dependency\"" <<<"$nuspec" || fail "missing managed dependency $dependency" diff --git a/src/Kapusch.TikTok.Business.Android/Kapusch.TikTok.Business.Android.csproj b/src/Kapusch.TikTok.Business.Android/Kapusch.TikTok.Business.Android.csproj index 12014fd..4513d12 100644 --- a/src/Kapusch.TikTok.Business.Android/Kapusch.TikTok.Business.Android.csproj +++ b/src/Kapusch.TikTok.Business.Android/Kapusch.TikTok.Business.Android.csproj @@ -52,7 +52,7 @@ - + From 8eafcee6a92079812729b396eadd1bc167be9365 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:17:08 +0100 Subject: [PATCH 10/12] docs: describe both packages and the Business integration contract README now lists the Share and Business packages with their tag families. Docs/Business/Integration.md covers requirements, the consent-gated initialization recipe, result codes, the configuration and event reference, the automatic purchase double counting warning, Android permissions, R8 and Data Safety, and the iOS ATT / SKAdNetwork guidance. Docs/Release.md documents independent releases, and AGENTS.md, CONTRIBUTING.md and the PR template gain the Business rules and validation commands. Co-Authored-By: Claude Opus 5.5 --- .github/pull_request_template.md | 2 + AGENTS.md | 15 +- CONTRIBUTING.md | 20 ++- Docs/Business/Integration.md | 243 +++++++++++++++++++++++++++++++ Docs/Business/SourceMode.md | 21 +++ Docs/Release.md | 46 ++++++ README.md | 64 ++++++-- 7 files changed, 391 insertions(+), 20 deletions(-) create mode 100644 Docs/Business/Integration.md create mode 100644 Docs/Business/SourceMode.md create mode 100644 Docs/Release.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2b77955..c3f9481 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,5 +8,7 @@ - [ ] Built native assets: `bash src/Kapusch.TikTokApisForAndroidComponents/Native/Android/build.sh` - [ ] Packed NuGet: `dotnet pack src/Kapusch.TikTokApisForAndroidComponents/Kapusch.TikTokApisForAndroidComponents.csproj -c Release -o artifacts/nuget` - [ ] Validated package: `scripts/validate-package.sh` +- [ ] Business package (if touched): built, packed and validated with `scripts/validate-business-package.sh`, sample built in Release +- [ ] Public tree scan: `python3 scripts/verify-public.py` - [ ] Updated dependency locks, Gradle verification and notices if upstream inputs changed - [ ] Updated docs if behavior or integration changed diff --git a/AGENTS.md b/AGENTS.md index 90abde0..a98cb66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,19 +3,25 @@ ## Goals - Produce a reproducible `Kapusch.TikTok.Android` NuGet for TikTok OpenSDK Core + Share 2.3.1. -- Do not commit client keys, signing material, or app-specific secrets. +- Produce a reproducible `Kapusch.TikTok.Business.Android` NuGet for the TikTok Business SDK 1.7.1. +- Keep the two packages independent: no shared native artifact, separate locks, validators and release tags (`v*` and `business-v*`). +- Do not commit client keys, app secrets, signing material, or app-specific secrets. ## Packaging constraints - Public OSS repo: keep docs and samples generic and not app-specific. - Consumers must never download native dependencies. Build and package all required AAR/JAR artifacts here. - Keep the OpenSDK version, source archive and checksums pinned in `DependencyLocks/Android/lockstate.txt` and Gradle verification metadata. +- Keep the Business SDK AAR, its license and the Gradle distribution pinned in `DependencyLocks/Android/business-lockstate.txt`, plus the Business Gradle verification metadata. +- The Business package ships only the upstream AAR and the interop AAR. Shared AndroidX/Kotlin classes come from managed NuGets; Google Play Billing and Install Referrer stay consumer-owned. Never bundle them. +- Nothing may initialize the Business SDK or reach the network before `TikTokBusinessSdk.InitializeAsync` is called. - Do not patch upstream TikTok sources silently. Document and checksum-audit every necessary patch. ## Repo layout -- `src/Kapusch.TikTokApisForAndroidComponents/` — managed API, native wrapper and buildTransitive assets. -- `DependencyLocks/Android/` — locked upstream source and native dependency inventory. +- `src/Kapusch.TikTokApisForAndroidComponents/` — Share package: managed API, native wrapper and buildTransitive assets. +- `src/Kapusch.TikTok.Business.Android/` — Business package: managed API, Kotlin JNI facade and buildTransitive assets. +- `DependencyLocks/Android/` — locked upstream inputs for both packages. - `scripts/` — deterministic package validation. - `Docs/` — integration and source-mode documentation. - `samples/` — secret-free integration example. @@ -23,4 +29,5 @@ ## Safety - Do not add dependency ingestion paths without documenting and locking them. -- When package contents change, update `scripts/validate-package.sh` in the same commit. +- When package contents change, update `scripts/validate-package.sh` (Share) or `scripts/validate-business-package.sh` (Business) in the same commit. +- Keep every tracked file generic: `scripts/verify-public.py` must pass. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb1853a..5bbe9b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,13 +19,29 @@ dotnet pack src/Kapusch.TikTokApisForAndroidComponents/Kapusch.TikTokApisForAndr scripts/validate-package.sh ``` -For direct project consumption, see [`Docs/SourceMode.md`](Docs/SourceMode.md). +Business package: + +```sh +bash src/Kapusch.TikTok.Business.Android/Native/Android/build.sh +dotnet pack src/Kapusch.TikTok.Business.Android/Kapusch.TikTok.Business.Android.csproj \ + -c Release \ + -o artifacts/nuget +scripts/validate-business-package.sh +dotnet build samples/Kapusch.TikTok.Business.Android.Sample/Kapusch.TikTok.Business.Android.Sample.csproj \ + -c Release -p:RuntimeIdentifier=android-arm64 +python3 scripts/verify-public.py +``` + +For direct project consumption, see [`Docs/SourceMode.md`](Docs/SourceMode.md) and +[`Docs/Business/SourceMode.md`](Docs/Business/SourceMode.md). Releases follow +[`Docs/Release.md`](Docs/Release.md). ## Pull requests - Keep changes focused. -- Never commit TikTok credentials or signing material. +- Never commit TikTok credentials, app secrets or signing material. - Update `DependencyLocks/Android/lockstate.txt`, Gradle verification metadata and third-party notices together when changing OpenSDK or runtime dependencies. +- Update `DependencyLocks/Android/business-lockstate.txt`, the Business Gradle verification metadata, `THIRD_PARTY_NOTICES.Business.md`, `BundledSdkVersion` and the validator together when changing the Business SDK. Regenerate verification metadata with `gradle --write-verification-metadata sha256` and review the diff. - Validate both bundled-runtime and consumer-provided-runtime modes when changing package injection. Contributions are licensed under the repository MIT license. diff --git a/Docs/Business/Integration.md b/Docs/Business/Integration.md new file mode 100644 index 0000000..40575e0 --- /dev/null +++ b/Docs/Business/Integration.md @@ -0,0 +1,243 @@ +# Kapusch.TikTok.Business.Android integration + +`Kapusch.TikTok.Business.Android` wraps the TikTok Business SDK for Android 1.7.1 +(also called the TikTok App Events SDK). It is independent of the +`Kapusch.TikTok.Android` OpenSDK Share package: the two can be used separately or +together. + +## Requirements + +- .NET 10 for Android, minimum Android API 23. The AndroidX lifecycle 2.10 family + the package depends on, like Google Play Billing 8.3, requires API 23. +- An app connected to TikTok Events Manager with **SDK** as the data source. You + need three values from Events Manager: + - **App ID**: on Android, the application package name; + - **TikTok App ID**: a numeric id per ad account; several can be passed, + separated by commas; + - **App Secret**: passed as `AccessToken`, the name the native SDK uses. + +Never commit the app secret. Load it from your own build-time or remote +configuration. + +## What the package contains + +- `android/aar/tiktok-business-android-sdk-1.7.1.aar`: the upstream AAR, unchanged + and checked against its SHA-256 lock. +- `android/aar/ktk-business-interop-release.aar`: a Kotlin facade called through + JNI, with consumer R8 rules. +- Managed dependencies on `Xamarin.AndroidX.Annotation`, + `Xamarin.AndroidX.Lifecycle.Process` and `Xamarin.Kotlin.StdLib`. Shared Java + classes therefore unify with the rest of the app instead of being duplicated. + +Consumer builds never download native artifacts from this package. + +### Dependencies the application owns + +The upstream SDK declares these libraries as compile-only. The package does not +bundle them, so they never duplicate a copy the application already ships. + +| Library | Needed for | How to provide it | +|---|---|---| +| Google Play Billing (v5 to v9) | Automatic in-app purchase tracking only | Usually already present through a subscription SDK. Otherwise `Xamarin.Android.Google.BillingClient`. | +| Google Play Install Referrer 2.2 | Play install-referrer attribution | `Xamarin.Google.Android.InstallReferrer` (it downloads the AAR from Google's Maven at build time). | +| Google Play services Ads Identifier | Optional | The SDK reads the GAID by reflection and falls back to binding the Play services ID service, so no library is required. | + +The SDK detects each one at runtime and degrades gracefully when it is missing. +The consumer R8 rules include `-dontwarn` entries, so an R8 build still succeeds +without them. + +## Consent-gated initialization + +Nothing native is loaded, and no network request is made, until +`TikTokBusinessSdk.InitializeAsync` is called. TikTok's own compliance guidance +says not to initialize the SDK before consent. Holding back only the event +delivery is not enough, because the SDK requests its remote configuration from +`analytics.us.tiktok.com` during initialization, even with `DisableAutoStart`. + +Recommended recipe: + +1. Persist the user's choice (accepted, declined, or not asked yet). +2. On every launch, when the choice is **accepted**, call `InitializeAsync` early, + ideally from `MainApplication.OnCreate` on the main thread. When the choice is + **declined** or **not asked yet**, do not call it. +3. When the user accepts during the session, call `InitializeAsync` at that moment. + The SDK still reports `InstallApp` on its first initialization on the device. +4. When the user withdraws consent, persist the choice and call + `SetTrackingEnabled(false)`. On Android this stops the wrapper from forwarding + new calls. The native SDK has no public API to pause delivery once it has + started, so automatic and already queued events can still be sent until the + process ends. From the next launch, step 2 keeps the SDK uninitialized. + +```csharp +using Kapusch.TikTok.Business.Android; + +var config = new TikTokBusinessConfig( + appId: "com.example.app", + tikTokAppId: settings.TikTokAppId, + accessToken: settings.TikTokAppSecret) +{ + // Recommended for subscription apps, see below. + DisablePaymentTracking = true, + DisableEnhancedDataPostback = true, + DebugMode = isDebugBuild, +}; + +using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); +var result = await TikTokBusinessSdk.InitializeAsync(config, timeout.Token); +if (!result.IsSuccess) +{ + logger.LogWarning("TikTok init failed: {Code} {Message}", result.ErrorCode, result.ErrorMessage); +} +``` + +`InitializeAsync` completes from the SDK's own completion callback. Concurrent and +repeated calls share one native initialization, because the SDK allows only one +per process. A failed initialization can be retried. Cancelling the token only +stops waiting. Calls to the tracking methods made before initialization are +ignored. + +To queue events at startup but send them only after an explicit step, set +`DisableAutoStart = true` and later call `TikTokBusinessSdk.StartTrack()`. + +### Result codes + +| `ErrorCode` | Meaning | +|---|---| +| `invalid_app_id` | `AppId` is not an Android package name (validated with the SDK's own pattern). | +| `invalid_tiktok_app_id` | `TikTokAppId` is not a comma-separated list of numeric ids. | +| `missing_access_token` | `AccessToken` is empty. | +| `invalid_context` | No Android application context is available. | +| `native_unavailable` | The native classes are missing, for example because `DisableKapuschTikTokBusinessAndroidNativeAssets=true`. | +| `native_exception` | The native layer threw during initialization. | +| `tiktok_` | The SDK's `TTInitCallback.fail(code, message)`. `NativeErrorCode` holds the raw code. | + +## Configuration reference + +| Property | Native call | Notes | +|---|---|---| +| `DebugMode` | `openDebugMode()` | Shows events in Events Manager **Test events**. | +| `LogLevel` | `setLogLevel` | `None` (default), `Info`, `Warn`, `Debug`. | +| `DisableAutoStart` | `disableAutoStart()` | Holds delivery until `StartTrack()`. | +| `DisableAutomaticTracking` | `disableAutoEvents()` | Disables `InstallApp`, `LaunchAPP` and `2Dretention`. On Android it does **not** disable automatic purchase tracking. | +| `DisableInstallTracking` / `DisableLaunchTracking` / `DisableRetentionTracking` | `disableInstallLogging()` / `disableLaunchLogging()` / `disableRetentionLogging()` | One automatic event each. | +| `DisablePaymentTracking` | `disableAutoIapTrack()` | See the double counting warning below. | +| `DisableAdvertiserIdCollection` | `disableAdvertiserIDCollection()` | Stops GAID collection. Also remove the `AD_ID` permission, see below. | +| `DisableEnhancedDataPostback` | `disableAutoEnhancedDataPostbackEvent()` | Stops automatic UI interaction capture. | +| `DisableMonitor` | `disableMonitor()` | Stops the SDK's own health metrics. | +| `LimitedDataUse` | `enableLimitedDataUse()` | Limited Data Use mode (US state privacy laws). | +| `DisableAppTrackingDialog`, `DisableSKAdNetworkSupport` | iOS only | Ignored on Android. They exist so shared code can configure both platforms. | + +## Events + +Use `TikTokBusinessEventNames` and `TikTokBusinessPropertyNames`. They mirror the +SDK 1.7.1 constants verbatim. TikTok discards custom property keys. + +```csharp +// Free trial started: value is what was actually charged, usually 0. +TikTokBusinessSdk.TrackStartTrial(value: 0m, currency: "EUR", eventId: transactionId, + contentId: productId, contentType: "subscription"); + +// Paid subscription started. +TikTokBusinessSdk.TrackSubscribe(value: 9.99m, currency: "EUR", eventId: transactionId, + contentId: productId, contentType: "subscription"); + +// Any standard or custom event. +TikTokBusinessSdk.TrackEvent(TikTokBusinessEventNames.ViewContent, new Dictionary +{ + [TikTokBusinessPropertyNames.ContentId] = "paywall", + [TikTokBusinessPropertyNames.Contents] = new[] { new TikTokBusinessContent(ContentId: productId, Price: 9.99m, Quantity: 1) }, +}); +``` + +- Pass a stable `eventId`, for example the store transaction id, so TikTok can + deduplicate retries. The SDK generates one when it is omitted. +- The typed helpers reject a currency the SDK does not support + (`TikTokBusinessCurrencies.Supported`) and a value without a currency. +- Property values may be strings, booleans, numbers, `TikTokBusinessContent`, + dictionaries or lists. `null` values are omitted. +- There is no `CompletePayment` app event. That name belongs to TikTok's web pixel. + Use `Purchase`. +- `InstallApp`, `LaunchAPP` and `2Dretention` are automatic. Send them manually + only if you disabled the automatic ones. +- The Android SDK spells the in-app ad impression event `InAppAdImpr`; the iOS SDK + spells it `InAppADImpr`. Each package uses its own SDK's spelling. + +`Identify(externalId, externalUserName, phoneNumber, email)` enables advanced +matching. The SDK hashes the values with SHA-256 on the device. `Logout()` clears +the identity. `Flush()` asks the SDK to send queued events now; it is not a +delivery receipt. + +## Automatic in-app purchase tracking: avoid double counting + +When Google Play Billing is in the app, the SDK's automatic in-app purchase +tracking reads purchases through its own `BillingClient` and reports each one as +`Purchase`. For a subscription with a trial phase it uses the price of the paid +phase. A free-trial start is therefore reported as a full-price `Purchase`, and +it is never reported as `StartTrial` or `Subscribe`. On iOS, the StoreKit +observer likewise reports every transaction, renewals included, as `Purchase` at +catalog price. + +If you also send `StartTrial` / `Subscribe` / `Purchase` yourself, or forward +purchases from a server, the same conversion is counted twice with inflated +values. + +Recommendation for subscription apps: + +1. Set `DisablePaymentTracking = true`. +2. Send `StartTrial` and `Subscribe` (and `Purchase` for one-time products) + manually, with the amount actually charged and the transaction id as `eventId`. +3. Verify in Events Manager **Test events** that no automatic `Purchase` arrives. + The SDK only forwards `DisablePaymentTracking` to TikTok's configuration + endpoint, and the server makes the final decision. There is no other local + switch on Android: `DisableAutomaticTracking` does not cover purchases. + +## Android permissions, R8 and Data Safety + +- The SDK merges `INTERNET` and `com.google.android.gms.permission.AD_ID`. If you + set `DisableAdvertiserIdCollection`, also remove the permission in the app + manifest: + + ```xml + + ``` + +- The interop AAR's consumer rules keep the facade, `com.tiktok.**`, the Install + Referrer API, `androidx.lifecycle.**` and the reflective `AdvertisingIdClient` + members. Validate your own Release build with R8 enabled. +- The SDK installs a chained default uncaught-exception handler during + initialization. When you use other crash reporters, initialize them first and + check that crashes are still reported. +- Declare the data the SDK sends to TikTok (device identifiers, app activity, + purchase history when automatic purchase tracking is active) in Google Play + Data Safety, as data **shared** with a third party. + +## iOS counterpart: ATT and SKAdNetwork + +These points apply to the iOS package and to shared app code: + +- **ATT.** Ask for App Tracking Transparency only after the user accepts in your + own consent flow, and never after a refusal. Without ATT authorization the SDK + sends no IDFA, and events still flow with the IDFV. +- **Tracking domains.** Do not list `analytics.us.tiktok.com` in + `NSPrivacyTrackingDomains`. iOS blocks every request to a listed domain when ATT + is not granted, which would also stop install measurement for those users. +- **SKAdNetwork.** Only one SDK in the app should update SKAdNetwork conversion + values. If another SDK owns them, set `DisableSKAdNetworkSupport = true` and + upload the matching schema in Events Manager only if TikTok asks for one. An + advertised app does not need `SKAdNetworkItems`. + +## Coexistence with the OpenSDK Share package + +The Business SDK and the OpenSDK use different Java packages (`com.tiktok.*` +versus `com.tiktok.open.sdk.*`) and share no artifact. The Share package bundles +its own runtime dependency graph by default. In an app that already gets AndroidX +and Kotlin from NuGet, such as any MAUI app, set +`KapuschTikTokAndroidIncludeBundledRuntimeDependencies=false` to avoid duplicate +classes, as described in [`../Integration.md`](../Integration.md). + +## MSBuild properties + +| Property | Default | Effect | +|---|---|---| +| `DisableKapuschTikTokBusinessAndroidNativeAssets` | `false` | Skips injecting both AARs. `InitializeAsync` then returns `native_unavailable`. | +| `UseKapuschTikTokBusinessAndroidInteropFromSource` | `false` | Uses a sibling checkout's native build. See [`SourceMode.md`](SourceMode.md). | diff --git a/Docs/Business/SourceMode.md b/Docs/Business/SourceMode.md new file mode 100644 index 0000000..74630c6 --- /dev/null +++ b/Docs/Business/SourceMode.md @@ -0,0 +1,21 @@ +# Business package source mode + +Source mode lets an application consume the Business managed project and its +already-built native assets from a sibling checkout. The consuming app never +downloads native dependencies. + +1. Build the native assets: + + ```sh + bash src/Kapusch.TikTok.Business.Android/Native/Android/build.sh + ``` + +2. Add a `ProjectReference` to + `src/Kapusch.TikTok.Business.Android/Kapusch.TikTok.Business.Android.csproj`. +3. Import both `buildTransitive/Kapusch.TikTok.Business.Android.props` and + `buildTransitive/Kapusch.TikTok.Business.Android.targets` from the checkout. +4. Set `UseKapuschTikTokBusinessAndroidInteropFromSource=true` in the consuming + Android project. + +The targets fail with an explicit error when the source-mode AARs are missing. +The managed NuGet dependencies flow through the `ProjectReference` as usual. diff --git a/Docs/Release.md b/Docs/Release.md new file mode 100644 index 0000000..7ff8e0d --- /dev/null +++ b/Docs/Release.md @@ -0,0 +1,46 @@ +# Release + +Each package is released independently by `.github/workflows/publish.yml`. Using +a single workflow keeps one NuGet Trusted Publishing policy for the repository. + +| Package | Tag | Version taken from the tag | +|---|---|---| +| `Kapusch.TikTok.Android` | `vX.Y.Z` or `vX.Y.Z-rc.N` | `v1.0.1` → `1.0.1` | +| `Kapusch.TikTok.Business.Android` | `business-vX.Y.Z` or `business-vX.Y.Z-rc.N` | `business-v1.0.0-rc.1` → `1.0.0-rc.1` | + +GitHub tag filters match the whole tag name, so `v*` never matches `business-v*`. +Business versions must be `X.Y.Z` or `X.Y.Z-rc.N`. + +## Rules + +- Release only from commits reachable from `origin/master`. The workflow refuses + anything else. +- Versions are immutable. The workflow refuses to publish a version that already + exists on NuGet.org; publish a new version instead. Never move or delete a + published tag. +- Start a new package line with release candidates (`-rc.N`) and promote a + qualified candidate by tagging the same commit without the suffix. + +## Channels + +- **Tag push:** packs, validates and publishes the matching package to NuGet.org + through Trusted Publishing (`NuGet/login`, OIDC). +- **Manual run with `manual_version`:** publishes that version of the selected + `package` (`share` or `business`) to NuGet.org, with the same master-reachability check. +- **Manual run without a version:** publishes `X.Y.Z-preview..` of the + selected package to GitHub Packages. + +Every run uploads the packed nupkg as a workflow artifact. Packs set +`RepositoryCommit` to the released commit. + +## Release a Business version + +```sh +git checkout master && git pull --ff-only +git tag -a business-v1.0.0-rc.1 -m "Kapusch.TikTok.Business.Android 1.0.0-rc.1" +git push origin business-v1.0.0-rc.1 +``` + +Before promoting a candidate, qualify it on a device: Events Manager **Test +events** must show `InstallApp`, `LaunchAPP` and the manual events, and no +automatic `Purchase` when `DisablePaymentTracking` is set. diff --git a/README.md b/README.md index dcc7695..81c7bdb 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,22 @@ -# Kapusch.TikTok.Android +# TikTokApisForAndroidComponents -.NET Android wrapper for TikTok OpenSDK 2.3.1 Core + Share. +.NET Android packages for two independent TikTok SDKs. Native sources and +artifacts are restored, checksum-verified and built in this repository; NuGet +consumers never download native artifacts. -Native source and dependencies are restored and built in this repository. The -NuGet consumer never downloads native artifacts. The wrapper converts local -image paths to read-only `FileProvider` URIs, grants TikTok access, and maps the -TikTok callback to stable status values. It delegates the launch to the -official `ShareApi` and recovers if the user returns to the caller without a -TikTok callback. +| Package | Upstream SDK | Purpose | Release tags | +|---|---|---|---| +| `Kapusch.TikTok.Android` | TikTok OpenSDK 2.3.1 Core + Share | Share images to TikTok | `vX.Y.Z` | +| `Kapusch.TikTok.Business.Android` | TikTok Business SDK (App Events) 1.7.1 | Install and in-app event attribution for TikTok ads | `business-vX.Y.Z` | + +The two packages share no native artifact and can be used separately or together. + +## Kapusch.TikTok.Android (OpenSDK Share) + +The wrapper converts local image paths to read-only `FileProvider` URIs, grants +TikTok access, and maps the TikTok callback to stable status values. It delegates +the launch to the official `ShareApi` and recovers if the user returns to the +caller without a TikTok callback. ```sh bash src/Kapusch.TikTokApisForAndroidComponents/Native/Android/build.sh @@ -24,11 +33,38 @@ already provide a compatible AndroidX/Kotlin/Google Play Services graph can set only after validating the complete application dependency graph, because it transfers runtime dependency ownership back to the consuming app. -## Repository guides +See [`Docs/Integration.md`](Docs/Integration.md) and [`Docs/SourceMode.md`](Docs/SourceMode.md). + +## Kapusch.TikTok.Business.Android (Business SDK) + +A static `TikTokBusinessSdk` API over the official SDK: + +- consent-gated `InitializeAsync`: nothing native loads before it is called; +- automatic-event and attribution opt-outs; +- `TrackEvent` plus typed `StartTrial`, `Subscribe`, `Purchase`, `Registration` + and `Login` helpers, with `event_id` deduplication; +- constants for every standard event, property and currency of the SDK; +- `Identify`, `Logout`, `Flush` and `StartTrack`. -- [`Docs/Integration.md`](Docs/Integration.md) — application integration contract. -- [`Docs/SourceMode.md`](Docs/SourceMode.md) — consume a sibling checkout without package-time downloads. -- [`samples/`](samples/) — secret-free minimal integration notes. -- [`CONTRIBUTING.md`](CONTRIBUTING.md) — local validation and dependency-update rules. +The upstream AAR ships unchanged next to a thin Kotlin JNI facade. Shared AndroidX +and Kotlin classes come from managed NuGet dependencies. Google Play Billing and +Install Referrer stay owned by the application. Minimum Android API 23. + +```sh +bash src/Kapusch.TikTok.Business.Android/Native/Android/build.sh +dotnet pack src/Kapusch.TikTok.Business.Android/Kapusch.TikTok.Business.Android.csproj -c Release -o artifacts/nuget +scripts/validate-business-package.sh +``` + +Read [`Docs/Business/Integration.md`](Docs/Business/Integration.md) before +shipping. It covers the consent recipe, automatic purchase double counting, +permissions and ATT / SKAdNetwork guidance. + +## Repository guides -Tags reachable from `master` publish to NuGet.org. Manual runs without a version publish a preview package to GitHub Packages. +- [`Docs/Integration.md`](Docs/Integration.md): Share package integration contract. +- [`Docs/Business/Integration.md`](Docs/Business/Integration.md): Business package integration contract. +- [`Docs/SourceMode.md`](Docs/SourceMode.md) and [`Docs/Business/SourceMode.md`](Docs/Business/SourceMode.md): consume a sibling checkout without package-time downloads. +- [`Docs/Release.md`](Docs/Release.md): tag families and release channels. +- [`samples/`](samples/): secret-free integration examples. +- [`CONTRIBUTING.md`](CONTRIBUTING.md): local validation and dependency-update rules. From 35a5a63a5f26eaf16d8b7d7e3029e8f031977f9a Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:19:05 +0100 Subject: [PATCH 11/12] fix(business): make initialization retry deterministic and calls non-fatal Create the shared initialization task under the lock but start the native call outside it, and clear a failed task before completing it so a caller that awaited the failure can retry immediately. The Kotlin facade now logs and swallows exceptions from fire-and-forget measurement calls so they never propagate into the host app. Co-Authored-By: Claude Opus 5.5 --- .../androidinterop/TikTokBusinessInterop.kt | 29 +++---- .../TikTokBusinessSdk.cs | 78 +++++++++++-------- 2 files changed, 63 insertions(+), 44 deletions(-) diff --git a/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/src/main/java/com/kapusch/tiktok/business/androidinterop/TikTokBusinessInterop.kt b/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/src/main/java/com/kapusch/tiktok/business/androidinterop/TikTokBusinessInterop.kt index 2699e95..80b49ad 100644 --- a/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/src/main/java/com/kapusch/tiktok/business/androidinterop/TikTokBusinessInterop.kt +++ b/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/src/main/java/com/kapusch/tiktok/business/androidinterop/TikTokBusinessInterop.kt @@ -139,35 +139,38 @@ object TikTokBusinessInterop { fun sdkVersion(): String = com.tiktok.BuildConfig.VERSION_NAME @JvmStatic - fun startTrack() { - TikTokBusinessSdk.startTrack() - } + fun startTrack() = guarded("startTrack") { TikTokBusinessSdk.startTrack() } /** * Tracks one event. [propertiesJson] is a JSON object produced by the managed * layer; an empty [eventId] lets the SDK generate one. */ @JvmStatic - fun trackEvent(name: String, propertiesJson: String?, eventId: String?) { - if (name.isBlank()) return + fun trackEvent(name: String, propertiesJson: String?, eventId: String?) = guarded("trackEvent") { + if (name.isBlank()) return@guarded val properties = if (propertiesJson.isNullOrBlank()) JSONObject() else JSONObject(propertiesJson) val id = eventId?.takeIf { it.isNotBlank() } TikTokBusinessSdk.trackTTEvent(TTBaseEvent(name, properties, id)) } @JvmStatic - fun identify(externalId: String?, externalUserName: String?, phoneNumber: String?, email: String?) { - TikTokBusinessSdk.identify(externalId, externalUserName, phoneNumber, email) - } + fun identify(externalId: String?, externalUserName: String?, phoneNumber: String?, email: String?) = + guarded("identify") { TikTokBusinessSdk.identify(externalId, externalUserName, phoneNumber, email) } @JvmStatic - fun logout() { - TikTokBusinessSdk.logout() - } + fun logout() = guarded("logout") { TikTokBusinessSdk.logout() } @JvmStatic - fun flush() { - TikTokBusinessSdk.flush() + fun flush() = guarded("flush") { TikTokBusinessSdk.flush() } + + // Measurement calls are fire-and-forget: a failure is logged and never + // propagates into the host application. + private inline fun guarded(operation: String, block: () -> Unit) { + try { + block() + } catch (error: Throwable) { + Log.w(LOG_TAG, "TikTok Business SDK $operation failed: ${error.javaClass.simpleName}") + } } private fun logLevel(value: Int): TikTokBusinessSdk.LogLevel = when (value) { diff --git a/src/Kapusch.TikTok.Business.Android/TikTokBusinessSdk.cs b/src/Kapusch.TikTok.Business.Android/TikTokBusinessSdk.cs index 2cbea2e..9849bf9 100644 --- a/src/Kapusch.TikTok.Business.Android/TikTokBusinessSdk.cs +++ b/src/Kapusch.TikTok.Business.Android/TikTokBusinessSdk.cs @@ -97,9 +97,24 @@ public static Task InitializeAsync( } Task initialization; + TaskCompletionSource? owner = null; lock (Gate) { - initialization = _initialization ??= StartInitialization(config); + if (_initialization is null) + { + owner = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + _initialization = owner.Task; + } + initialization = _initialization; + } + + // Only the caller that created the shared task starts the native initialization, and it + // does so outside the lock so a synchronous native result cannot re-enter it. + if (owner is not null) + { + StartInitialization(config, owner); } return cancellationToken.CanBeCanceled ? initialization.WaitAsync(cancellationToken) : initialization; } @@ -361,56 +376,57 @@ private static void AddIfPresent(Dictionary properties, string k private static bool CanForward() => _forwardingEnabled && Volatile.Read(ref _initialization) is not null; - private static Task StartInitialization(TikTokBusinessConfig config) + private static void StartInitialization( + TikTokBusinessConfig config, + TaskCompletionSource completion + ) { - var completion = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously - ); - var task = completion.Task; - task.ContinueWith( - static (finished, _) => - { - if (!finished.IsCompletedSuccessfully || !finished.Result.IsSuccess) - { - lock (Gate) - { - if (ReferenceEquals(_initialization, finished)) - { - _initialization = null; - } - } - } - }, - null, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default - ); - try { using var bundle = ToBundle(config); - NativeTikTokBusinessInterop.Initialize(bundle, result => completion.TrySetResult(result)); + NativeTikTokBusinessInterop.Initialize(bundle, result => Complete(completion, result)); } catch (Java.Lang.ClassNotFoundException error) { - completion.TrySetResult( + Complete( + completion, TikTokBusinessInitResult.Failure(TikTokBusinessInitErrorCodes.NativeUnavailable, error.Message) ); } catch (Java.Lang.NoClassDefFoundError error) { - completion.TrySetResult( + Complete( + completion, TikTokBusinessInitResult.Failure(TikTokBusinessInitErrorCodes.NativeUnavailable, error.Message) ); } catch (Java.Lang.Throwable error) { - completion.TrySetResult( + Complete( + completion, TikTokBusinessInitResult.Failure(TikTokBusinessInitErrorCodes.NativeException, error.Message) ); } - return task; + } + + // A failure clears the shared task before completing it, so a caller that awaited the + // failure can retry immediately and gets a fresh native initialization. + private static void Complete( + TaskCompletionSource completion, + TikTokBusinessInitResult result + ) + { + if (!result.IsSuccess) + { + lock (Gate) + { + if (ReferenceEquals(_initialization, completion.Task)) + { + _initialization = null; + } + } + } + completion.TrySetResult(result); } private static Bundle ToBundle(TikTokBusinessConfig config) From 04b59f74d71844d9ceed200dc16ed0290e039175 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Mon, 26 Oct 2026 23:35:27 +0100 Subject: [PATCH 12/12] fix(business): complete Gradle verification metadata for cold caches Regenerate the Business verification metadata from an empty Gradle home so BOM metadata resolved only on a cold cache is recorded, and trust the Linux aapt2 artifact next to the macOS one, both checked against the Google Maven published checksums, as the Share build already does. Co-Authored-By: Claude Opus 5.5 --- .../Android/gradle/verification-metadata.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Kapusch.TikTok.Business.Android/Native/Android/gradle/verification-metadata.xml b/src/Kapusch.TikTok.Business.Android/Native/Android/gradle/verification-metadata.xml index 00dfa56..c580d04 100644 --- a/src/Kapusch.TikTok.Business.Android/Native/Android/gradle/verification-metadata.xml +++ b/src/Kapusch.TikTok.Business.Android/Native/Android/gradle/verification-metadata.xml @@ -138,6 +138,17 @@ + + + + + + + + + + + @@ -1515,11 +1526,17 @@ + + + + + +