From 857d413e685ef2f2337ceb262ffb6aaf77ed8d0c Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:19:10 +0100 Subject: [PATCH 01/10] 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 cf17a8c8ca29201c8cbddae20e52392c8bc98057 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:19:10 +0100 Subject: [PATCH 02/10] 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 and reports the outcome at most once through a ValueCallback, with numeric wrapper codes shared with the iOS package. SDK 1.7.1 returns silently from every initializeSdk call after the first, and its async init step swallows Throwables without calling back. The facade therefore records its single native attempt and answers later attempts with an init-incomplete code instead of re-entering a half-built SDK; the managed caller bounds the wait. Track, identify, logout, flush, startTrack and updateAccessToken run synchronously, return whether the SDK accepted them and never throw into the host. Log levels map to the closest Android SDK level. Consumer R8 rules keep the facade, com.tiktok.** and the reflectively loaded AdvertisingIdClient, and tolerate absent Billing and Install Referrer. The Gradle verification metadata covers cold caches and the Linux aapt2 artifact. 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 | 267 +++ .../Native/Android/gradle.properties | 4 + .../Android/gradle/verification-metadata.xml | 1629 +++++++++++++++++ .../Native/Android/settings.gradle.kts | 9 + 9 files changed, 2005 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..d96a9a2 --- /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. Its direct +# references (AndroidX lifecycle, Install Referrer) are traced by R8 from the kept +# com.tiktok.** classes, so they need no keep rule here: a broad androidx.lifecycle +# keep would stop R8 from shrinking that family in every consuming app. Only the +# Google advertising id classes are reached by reflection. +-keep class com.tiktok.** { *; } +-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..fc27f26 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Native/Android/businessinterop/src/main/java/com/kapusch/tiktok/business/androidinterop/TikTokBusinessInterop.kt @@ -0,0 +1,267 @@ +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`, runs the initialization on the + * main thread, and reports its outcome at most once. Nothing here touches the SDK + * before [initialize] is called. + * + * SDK 1.7.1 constraints this facade works around: + * - `initializeSdk` assigns its static instance before building the event logger, + * then returns silently, without calling back, on every later call; + * - its asynchronous init step logs and swallows any Throwable without calling + * `success()` or `fail()`. + * A second native attempt therefore never completes, and a first attempt can stay + * silent. [nativeInitAttempted] makes later attempts fail at once with + * [ERROR_INIT_INCOMPLETE]; the managed caller bounds the wait for a silent first + * attempt. + */ +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 KEY_LOW_PERFORMANCE_DEVICE = "low_performance_device" + + const val RESULT_SUCCESS = "success" + const val RESULT_TRACKING_ENABLED = "tracking_enabled" + const val RESULT_ERROR_CODE = "error_code" + const val RESULT_ERROR_MESSAGE = "error_message" + + // Wrapper error codes, the same values as TikTokBusinessInitResult in the managed layer. + // Native SDK codes (1.7.1 only reports -2) are forwarded unchanged. + const val ERROR_INVALID_CONFIGURATION = 1001 + const val ERROR_CONFIGURATION_REJECTED = 1002 + const val ERROR_INVALID_CONTEXT = 1101 + const val ERROR_NATIVE_EXCEPTION = 1103 + const val ERROR_INIT_INCOMPLETE = 1104 + + // Same patterns as com.tiktok.util.RegexUtil. The SDK blanks a non-matching id and + // initializes anyway, so reject those values before it sees them. + 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()) } + + // Set on the main thread right before the only call to TikTokBusinessSdk.initializeSdk. + @Volatile + private var nativeInitAttempted = false + + /** + * Initializes the SDK on the main thread and calls [callback] at most once with a + * result [Bundle]. When the SDK init step stays silent there is no callback: the + * managed caller bounds the wait. + */ + @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(ERROR_NATIVE_EXCEPTION, error.javaClass.simpleName)) + } + } + if (Looper.myLooper() == Looper.getMainLooper()) { + run.run() + } else { + mainHandler.post(run) + } + } + + private fun initializeOnMainThread(context: Context, config: Bundle, complete: (Bundle) -> Unit) { + if (TikTokBusinessSdk.isInitialized()) { + // The SDK is a process-wide singleton: report its current state. + complete(successResult()) + return + } + if (nativeInitAttempted) { + // The SDK ignores a second initializeSdk call without calling back, so + // never re-enter it: report the earlier attempt as incomplete instead. + complete( + failureResult( + ERROR_INIT_INCOMPLETE, + "The TikTok Business SDK was already initialized once in this process and did not complete.", + ), + ) + 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 (appId.isEmpty() || tiktokAppId.isEmpty() || accessToken.isEmpty()) { + complete(failureResult(ERROR_INVALID_CONFIGURATION, "invalid_configuration")) + return + } + if (!APP_ID_PATTERN.matches(appId) || !TIKTOK_APP_ID_PATTERN.matches(tiktokAppId)) { + complete(failureResult(ERROR_CONFIGURATION_REJECTED, "configuration_rejected")) + return + } + + val application = context.applicationContext as? Application + if (application == null) { + complete(failureResult(ERROR_INVALID_CONTEXT, "An Application context is required.")) + return + } + + val ttConfig = TikTokBusinessSdk.TTConfig(application, accessToken) + .setAppId(appId) + .setTTAppId(tiktokAppId) + if (config.containsKey(KEY_LOG_LEVEL)) ttConfig.setLogLevel(logLevel(config.getInt(KEY_LOG_LEVEL))) + 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() + if (config.getBoolean(KEY_LOW_PERFORMANCE_DEVICE, false)) ttConfig.setIsLowPerformanceDevice(true) + + nativeInitAttempted = true + 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(code, msg ?: "initialization_failed")) + } + }) + } + + @JvmStatic + fun isInitialized(): Boolean = TikTokBusinessSdk.isInitialized() + + /** Whether the SDK sends events: false until [startTrack] after `disableAutoStart()`. */ + @JvmStatic + fun isTrackingEnabled(): Boolean = TikTokBusinessSdk.isInitialized() && TikTokBusinessSdk.getNetworkSwitch() + + @JvmStatic + fun sdkVersion(): String = com.tiktok.BuildConfig.VERSION_NAME + + /** Test event code shown in Events Manager; the SDK only creates one in debug mode. */ + @JvmStatic + fun testEventCode(): String? = + if (TikTokBusinessSdk.isInitialized()) TikTokBusinessSdk.getTestEventCode()?.takeIf { it.isNotBlank() } else null + + // Every call below runs synchronously on the calling thread and returns true when it was + // handed to the SDK, and false when the SDK is not initialized, an argument is empty, or + // the SDK threw. Measurement is fire-and-forget: a failure is logged and never propagates + // into the host application. + // + // Call order: identify() and logout() change the SDK identity before they return, so an + // event tracked after them carries the new identity. The SDK copies the identity when its + // own event thread builds a queued event, so an event tracked right before them may carry + // the new identity too. TikTokBusinessSdk.getAppEventLogger() returns null outside SDK + // call stacks, so that queue cannot be used to order these calls. + + @JvmStatic + fun startTrack(): Boolean = guarded("startTrack") { TikTokBusinessSdk.startTrack() } + + /** + * Tracks one event. [propertiesJson] is a JSON object produced by the managed + * layer; a null [eventId] lets the SDK generate one. + */ + @JvmStatic + fun trackEvent(name: String, propertiesJson: String?, eventId: String?): Boolean { + if (name.isBlank()) return false + return guarded("trackEvent") { + val properties = if (propertiesJson.isNullOrBlank()) JSONObject() else JSONObject(propertiesJson) + TikTokBusinessSdk.trackTTEvent(TTBaseEvent(name, properties, eventId?.takeIf { it.isNotBlank() })) + } + } + + @JvmStatic + fun identify(externalId: String?, externalUserName: String?, phoneNumber: String?, email: String?): Boolean { + if (listOf(externalId, externalUserName, phoneNumber, email).all { it.isNullOrBlank() }) return false + return guarded("identify") { TikTokBusinessSdk.identify(externalId, externalUserName, phoneNumber, email) } + } + + @JvmStatic + fun logout(): Boolean = guarded("logout") { TikTokBusinessSdk.logout() } + + @JvmStatic + fun flush(): Boolean = guarded("flush") { TikTokBusinessSdk.flush() } + + @JvmStatic + fun updateAccessToken(accessToken: String): Boolean { + if (accessToken.isBlank()) return false + return guarded("updateAccessToken") { TikTokBusinessSdk.updateAccessToken(accessToken) } + } + + private inline fun guarded(operation: String, block: () -> Unit): Boolean { + if (!TikTokBusinessSdk.isInitialized()) return false + return try { + block() + true + } catch (error: Throwable) { + Log.w(LOG_TAG, "TikTok Business SDK $operation failed: ${error.javaClass.simpleName}") + false + } + } + + // Values of the managed TikTokBusinessLogLevel enum, shared with the iOS package. The + // Android SDK orders its levels NONE < INFO < WARN < DEBUG, shows every message at or + // below the configured level, and logs errors at INFO. Each managed level maps to the + // quietest Android level that still shows the messages it asks for. + private fun logLevel(value: Int): TikTokBusinessSdk.LogLevel = when (value) { + 1 -> TikTokBusinessSdk.LogLevel.INFO + 2, 3 -> TikTokBusinessSdk.LogLevel.WARN + 4, 5 -> TikTokBusinessSdk.LogLevel.DEBUG + else -> TikTokBusinessSdk.LogLevel.NONE + } + + private fun successResult(): Bundle = Bundle().apply { + putBoolean(RESULT_SUCCESS, true) + putBoolean(RESULT_TRACKING_ENABLED, TikTokBusinessSdk.getNetworkSwitch()) + } + + private fun failureResult(code: Int, message: String): Bundle = Bundle().apply { + putBoolean(RESULT_SUCCESS, false) + putInt(RESULT_ERROR_CODE, code) + putString(RESULT_ERROR_MESSAGE, message) + } +} 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..c580d04 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Native/Android/gradle/verification-metadata.xml @@ -0,0 +1,1629 @@ + + + + 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 36fbc76a1e031d4d5718a8ea4e8c8f44ad27ca72 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:19:10 +0100 Subject: [PATCH 03/10] docs(business): ship Business-specific third-party notices Add THIRD_PARTY_NOTICES.Business.md for the Business package so the Share package keeps its notices file unchanged, and document the data the SDK collects for privacy and Play Data Safety reviews. Co-Authored-By: Claude Opus 5.5 --- THIRD_PARTY_NOTICES.Business.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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. From 1ea5aceea0a2d967c8b432035feeea65d1bc1aab Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:19:10 +0100 Subject: [PATCH 04/10] build(business): package Kapusch.TikTok.Business.Android Add a second packable project that ships the interop AAR, the locked upstream AAR, its license and the Business notices, 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 | 30 +++++++++ 4 files changed, 121 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..4513d12 --- /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..1bcfd33 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/nuget-readme.md @@ -0,0 +1,30 @@ +# 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). +- Same API and result semantics as `Kapusch.TikTok.Business.iOS`, so shared code + works on both platforms. `InitializeAsync` always completes within about 30 seconds. + +```csharp +var result = await TikTokBusinessSdk.InitializeAsync(new TikTokBusinessConfig +{ + AppId = "com.example.app", + TikTokAppIds = ["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 2310e42887b56db41ae56f42772a0efb3634eb7c Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:19:10 +0100 Subject: [PATCH 05/10] feat(business): add TikTokBusinessSdk C# API aligned with iOS Expose the same public API as Kapusch.TikTok.Business.iOS: a TikTokBusinessConfig record with required AppId, TikTokAppIds and AccessToken, InitializeAsync returning TikTokBusinessInitResult (Started, InitializedTrackingDisabled or Failed, int ErrorCode 0 on success, shared 1001/1002 codes), StartTrack, SetTrackingEnabled, TrackEvent and typed StartTrial/Subscribe/Purchase/Registration/Login helpers, Identify, Logout, Flush, UpdateAccessToken, IsInitialized, IsTrackingEnabled, SdkVersion and TestEventCode. Every call returns false before a successful initialization and nothing native loads until InitializeAsync. InitializeAsync validates the values and throws ArgumentException like iOS, shares one native initialization, returns the current state after a success, and is bounded to 30 seconds by a managed timer that does not depend on the main looper. A success that arrives after the timeout still latches the initialized state; retries are deterministic. A deferred start reports InitializedTrackingDisabled with ErrorCode 0. Android-only codes (11xx), constants and the SetTrackingEnabled(false) limitation of SDK 1.7.1 are documented on the members. Co-Authored-By: Claude Opus 5.5 --- .../Internal/NativeTikTokBusinessInterop.cs | 205 +++++++ .../Internal/TikTokBusinessJson.cs | 156 ++++++ .../TikTokBusinessConfig.cs | 127 +++++ .../TikTokBusinessContent.cs | 23 + .../TikTokBusinessEventNames.cs | 112 ++++ .../TikTokBusinessInitResult.cs | 74 +++ .../TikTokBusinessPropertyNames.cs | 75 +++ .../TikTokBusinessSdk.cs | 526 ++++++++++++++++++ 8 files changed, 1298 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..5d775ee --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Internal/NativeTikTokBusinessInterop.cs @@ -0,0 +1,205 @@ +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"; + internal const string KeyLowPerformanceDevice = "low_performance_device"; + + private const string ResultSuccess = "success"; + private const string ResultTrackingEnabled = "tracking_enabled"; + private const string ResultErrorCode = "error_code"; + private const string ResultErrorMessage = "error_message"; + + 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 bool IsTrackingEnabled() => + JNIEnv.CallStaticBooleanMethod(Class.Value, Method("isTrackingEnabled", "()Z")); + + internal static string? TestEventCode() => + JNIEnv.GetString( + JNIEnv.CallStaticObjectMethod(Class.Value, Method("testEventCode", "()Ljava/lang/String;")), + JniHandleOwnership.TransferLocalRef + ); + + internal static string SdkVersion() => + JNIEnv.GetString( + JNIEnv.CallStaticObjectMethod(Class.Value, Method("sdkVersion", "()Ljava/lang/String;")), + JniHandleOwnership.TransferLocalRef + ) ?? string.Empty; + + internal static bool TrackEvent(string name, string? propertiesJson, string? eventId) + { + var jName = JNIEnv.NewString(name); + var jProperties = NewStringOrNull(propertiesJson); + var jEventId = NewStringOrNull(eventId); + try + { + return JNIEnv.CallStaticBooleanMethod( + Class.Value, + Method("trackEvent", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Z"), + new JValue(jName), + new JValue(jProperties), + new JValue(jEventId) + ); + } + finally + { + DeleteLocalRef(jName); + DeleteLocalRef(jProperties); + DeleteLocalRef(jEventId); + } + } + + internal static bool Identify(string? externalId, string? externalUserName, string? phoneNumber, string? email) + { + var jId = NewStringOrNull(externalId); + var jName = NewStringOrNull(externalUserName); + var jPhone = NewStringOrNull(phoneNumber); + var jEmail = NewStringOrNull(email); + try + { + return JNIEnv.CallStaticBooleanMethod( + Class.Value, + Method( + "identify", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Z" + ), + new JValue(jId), + new JValue(jName), + new JValue(jPhone), + new JValue(jEmail) + ); + } + finally + { + DeleteLocalRef(jId); + DeleteLocalRef(jName); + DeleteLocalRef(jPhone); + DeleteLocalRef(jEmail); + } + } + + internal static bool UpdateAccessToken(string accessToken) + { + var jToken = JNIEnv.NewString(accessToken); + try + { + return JNIEnv.CallStaticBooleanMethod( + Class.Value, + Method("updateAccessToken", "(Ljava/lang/String;)Z"), + new JValue(jToken) + ); + } + finally + { + DeleteLocalRef(jToken); + } + } + + internal static bool StartTrack() => CallBoolean("startTrack"); + + internal static bool Logout() => CallBoolean("logout"); + + internal static bool Flush() => CallBoolean("flush"); + + private static bool CallBoolean(string name) => + JNIEnv.CallStaticBooleanMethod(Class.Value, Method(name, "()Z")); + + 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( + TikTokBusinessInitResult.NativeExceptionErrorCode, + "The native layer returned no initialization result." + ); + } + if (bundle.GetBoolean(ResultSuccess, false)) + { + return new TikTokBusinessInitResult( + bundle.GetBoolean(ResultTrackingEnabled, true) + ? TikTokBusinessInitStatus.Started + : TikTokBusinessInitStatus.InitializedTrackingDisabled + ); + } + return TikTokBusinessInitResult.Failure( + bundle.GetInt(ResultErrorCode, TikTokBusinessInitResult.NativeExceptionErrorCode), + bundle.GetString(ResultErrorMessage) + ); + } + + /// 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..09a6b41 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/Internal/TikTokBusinessJson.cs @@ -0,0 +1,156 @@ +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. Accepts the same value types +/// as the iOS package. +/// +internal static class TikTokBusinessJson +{ + private const int MaxDepth = 8; + + internal static string? WriteProperties(IReadOnlyDictionary? properties) + { + if (properties is null || properties.Count == 0) + { + return null; + } + + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + WriteObject( + writer, + properties.Select(static pair => new KeyValuePair(pair.Key, pair.Value)), + depth: 0 + ); + } + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + internal static string FormatDecimal(decimal value) => + value.ToString("0.############################", CultureInfo.InvariantCulture); + + private static void WriteObject( + Utf8JsonWriter writer, + IEnumerable> properties, + int depth + ) + { + writer.WriteStartObject(); + foreach (var (key, value) in properties) + { + ArgumentException.ThrowIfNullOrEmpty(key, nameof(properties)); + writer.WritePropertyName(key); + WriteValue(writer, value, depth + 1); + } + writer.WriteEndObject(); + } + + private static void WriteValue(Utf8JsonWriter writer, object? value, int depth) + { + if (depth > MaxDepth) + { + throw new ArgumentException("TikTok event properties are nested too deeply."); + } + + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case string text: + writer.WriteStringValue(text); + break; + case bool flag: + writer.WriteBooleanValue(flag); + break; + case decimal number: + writer.WriteNumberValue(number); + break; + case double number when double.IsFinite(number): + writer.WriteNumberValue(number); + break; + case float number when float.IsFinite(number): + writer.WriteNumberValue(number); + break; + case int or long or short or byte or sbyte or ushort or uint: + writer.WriteNumberValue(Convert.ToInt64(value, CultureInfo.InvariantCulture)); + break; + case ulong number: + writer.WriteNumberValue(number); + break; + case DateTimeOffset timestamp: + writer.WriteStringValue(timestamp.ToString("O", CultureInfo.InvariantCulture)); + break; + case DateTime timestamp: + writer.WriteStringValue(timestamp.ToString("O", CultureInfo.InvariantCulture)); + break; + case TikTokBusinessContent content: + WriteContent(writer, content); + break; + case IEnumerable> nested: + WriteObject(writer, nested, depth); + break; + case IDictionary dictionary: + writer.WriteStartObject(); + foreach (DictionaryEntry entry in dictionary) + { + var key = Convert.ToString(entry.Key, CultureInfo.InvariantCulture); + if (string.IsNullOrEmpty(key)) + { + throw new ArgumentException("TikTok event property keys must be non-empty strings."); + } + writer.WritePropertyName(key); + WriteValue(writer, entry.Value, depth + 1); + } + writer.WriteEndObject(); + break; + case IEnumerable items: + writer.WriteStartArray(); + foreach (var item in items) + { + WriteValue(writer, item, depth + 1); + } + writer.WriteEndArray(); + break; + default: + throw new ArgumentException( + $"Unsupported TikTok event property value type '{value.GetType().FullName}'. " + + "Use strings, booleans, numbers, dictionaries, lists or TikTokBusinessContent." + ); + } + } + + // Mirrors com.tiktok.appevents.contents.TTContentParams#toJSONObject: quantity is a + // number, the price a string, and an unset price is omitted. + private static void WriteContent(Utf8JsonWriter writer, TikTokBusinessContent content) + { + writer.WriteStartObject(); + writer.WriteNumber(TikTokBusinessPropertyNames.Quantity, content.Quantity); + WriteOptional(writer, TikTokBusinessPropertyNames.ContentId, content.ContentId); + WriteOptional(writer, TikTokBusinessPropertyNames.ContentCategory, content.ContentCategory); + WriteOptional(writer, TikTokBusinessPropertyNames.ContentName, content.ContentName); + WriteOptional(writer, TikTokBusinessPropertyNames.Brand, content.Brand); + if (content.Price is { } price) + { + writer.WriteString(TikTokBusinessPropertyNames.Price, FormatDecimal(price)); + } + 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..15bc2e5 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/TikTokBusinessConfig.cs @@ -0,0 +1,127 @@ +namespace Kapusch.TikTok.Business.Android; + +/// +/// Configuration passed to . +/// Values come from TikTok Events Manager (Ads Manager, Tools, Events Manager, your app, Settings). +/// +/// +/// Same shape as Kapusch.TikTok.Business.iOS, so shared code can configure both platforms +/// with one object. Every opt-out defaults to , which matches the native +/// SDK defaults. Members marked "iOS only" are accepted and ignored on Android. +/// +public sealed record TikTokBusinessConfig +{ + /// The store identifier of the app. On Android this is the application package name. + public required string AppId { get; init; } + + /// + /// One or several TikTok App IDs (one per ad account). Each ID is numeric. The SDK does not + /// initialize when an ID does not belong to in Events Manager. + /// + public required IReadOnlyList TikTokAppIds { get; init; } + + /// + /// The App Secret shown in Events Manager. The TikTok SDKs name this value accessToken. + /// Never commit it to source control. + /// + public required string AccessToken { get; init; } + + /// Enables the SDK debug mode used by Events Manager "Test events". Never enable it in production. + public bool DebugMode { get; init; } + + /// + /// SDK log level. keeps the SDK default (no logs). The Android SDK only has + /// NONE, INFO, WARN and DEBUG, ordered in that sequence, and logs errors + /// at INFO: maps to INFO, + /// and to + /// WARN, and + /// to DEBUG. + /// + public TikTokBusinessLogLevel? LogLevel { get; init; } + + /// + /// Initializes the SDK but holds network delivery until . + /// The SDK still requests its remote configuration during initialization, so this is not a + /// consent gate: call only after consent instead. + /// Maps to TTConfig.disableAutoStart(). + /// + 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; } + + /// + /// Accepted for API symmetry with iOS, where it is deprecated upstream and has no effect either. + /// 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; } + + /// + /// Stops the SDK from reading the Google advertising id (GAID); maps to + /// disableAdvertiserIDCollection(). No effect on iOS. + /// + public bool DisableAdvertiserIdCollection { get; init; } + + /// Disables Enhanced Data Postback (automatic UI interaction capture). + public bool DisableEnhancedDataPostback { get; init; } + + /// Enables Limited Data Use mode (for example for US state privacy laws). + public bool LimitedDataUse { get; init; } + + /// iOS only: delays the first flush for an App Tracking Transparency decision. Ignored on Android. + public TimeSpan? AttAuthorizationDelay { get; init; } + + /// iOS only: custom user agent. Ignored on Android. + public string? CustomUserAgent { get; init; } + + /// Marks the device as low-performance so the SDK reduces optional work (setIsLowPerformanceDevice). + public bool LowPerformanceDevice { get; init; } + + /// Android only: disables the SDK's own health and performance metrics. + public bool DisableMonitor { get; init; } +} + +/// TikTok SDK log level, with the same values as the iOS package. +public enum TikTokBusinessLogLevel +{ + /// No SDK logs. + None = 0, + + /// Errors only. Android maps this to INFO, the level its SDK logs errors at. + Error = 1, + + /// Warnings and errors. + Warn = 2, + + /// Informational messages. Android maps this to WARN, which also shows INFO. + Info = 3, + + /// Debug messages. + Debug = 4, + + /// Everything. Android maps this to DEBUG. + Verbose = 5, +} diff --git a/src/Kapusch.TikTok.Business.Android/TikTokBusinessContent.cs b/src/Kapusch.TikTok.Business.Android/TikTokBusinessContent.cs new file mode 100644 index 0000000..853f8ea --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/TikTokBusinessContent.cs @@ -0,0 +1,23 @@ +namespace Kapusch.TikTok.Business.Android; + +/// +/// One entry of the standard contents property, matching TTContentParams. Same +/// parameter order and defaults as the iOS package. +/// +/// Content identifier. +/// +/// Unit price, sent as a string like the SDK does. omits it, as the Android +/// SDK's TTContentParams does (the iOS package sends 0 instead). +/// +/// Quantity. +/// Optional category. +/// Optional name. +/// Optional brand. +public sealed record TikTokBusinessContent( + string? ContentId = null, + decimal? Price = null, + int Quantity = 1, + string? ContentCategory = null, + string? ContentName = null, + string? Brand = 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..052135b --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/TikTokBusinessEventNames.cs @@ -0,0 +1,112 @@ +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"; + + /// + /// iOS only: restored purchase, sent automatically by the iOS SDK purchase observer. Listed for + /// parity with the iOS package; the Android SDK never sends it. + /// + public const string PurchaseRestored = "PurchaseRestored"; +} diff --git a/src/Kapusch.TikTok.Business.Android/TikTokBusinessInitResult.cs b/src/Kapusch.TikTok.Business.Android/TikTokBusinessInitResult.cs new file mode 100644 index 0000000..13314d8 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/TikTokBusinessInitResult.cs @@ -0,0 +1,74 @@ +namespace Kapusch.TikTok.Business.Android; + +/// Outcome of . +public enum TikTokBusinessInitStatus +{ + /// The SDK is initialized and sends events. + Started = 0, + + /// + /// The SDK is initialized with and holds + /// delivery until is called. + /// + InitializedTrackingDisabled = 1, + + /// Initialization failed. See . + Failed = 2, +} + +/// Result of . +/// Initialization outcome. +/// +/// 0 when there is no error. Otherwise the SDK error code (TikTok Business SDK for Android +/// 1.7.1 only reports -2, for an empty App ID or TikTok App ID) or one of the wrapper codes +/// declared on this type. +/// +/// SDK or wrapper diagnostic message, for logs only. +public sealed record TikTokBusinessInitResult( + TikTokBusinessInitStatus Status, + int ErrorCode = 0, + string? ErrorMessage = null +) +{ + /// The native layer received an empty App ID, TikTok App ID or App Secret. + public const int InvalidConfigurationErrorCode = 1001; + + /// + /// The values do not match the SDK's own App ID / TikTok App ID patterns. The managed API + /// validates them first and throws , so this indicates a direct + /// native call. + /// + public const int ConfigurationRejectedErrorCode = 1002; + + /// Android only: no Android Application context is available. + public const int InvalidContextErrorCode = 1101; + + /// + /// Android only: the native interop classes are not in the app (native assets disabled with + /// DisableKapuschTikTokBusinessAndroidNativeAssets, or stripped). Retrying does not help. + /// + public const int NativeUnavailableErrorCode = 1102; + + /// Android only: the native layer threw while configuring the SDK. + public const int NativeExceptionErrorCode = 1103; + + /// + /// Android only: an earlier native initialization in this process did not complete. The SDK + /// cannot be initialized twice in a process, so every retry in the same process returns this code. + /// + public const int InitIncompleteErrorCode = 1104; + + /// + /// Android only: the SDK did not report its initialization within 30 seconds (its init step + /// swallows its own exceptions). If it finishes later, + /// becomes and a new call + /// reports the current state; otherwise that call returns . + /// + public const int InitTimeoutErrorCode = 1105; + + /// unless is . + public bool IsSuccess => Status != TikTokBusinessInitStatus.Failed; + + internal static TikTokBusinessInitResult Failure(int code, string? message) => + new(TikTokBusinessInitStatus.Failed, code, message); +} diff --git a/src/Kapusch.TikTok.Business.Android/TikTokBusinessPropertyNames.cs b/src/Kapusch.TikTok.Business.Android/TikTokBusinessPropertyNames.cs new file mode 100644 index 0000000..1ce4509 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/TikTokBusinessPropertyNames.cs @@ -0,0 +1,75 @@ +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. The Android helpers send it as a number, like the SDK's + /// TTContentsEvent.Builder.setValue(double); the iOS package sends a decimal string. + /// + 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. Use with the typed helpers. + public const string Contents = "contents"; + + /// Android only: order identifier (TTContentsEventConstants.Params.EVENT_PROPERTY_ORDER_ID). + public const string OrderId = "order_id"; + + /// Content item price (inside ). + public const string Price = "price"; + + /// Content item quantity (inside ). + public const string Quantity = "quantity"; + + /// Content item category (inside ). + public const string ContentCategory = "content_category"; + + /// Content item name (inside ). + public const string ContentName = "content_name"; + + /// Content item brand (inside ). + public const string Brand = "brand"; + + /// + /// Ad revenue payload of . Listed for + /// parity with the iOS package; the Android SDK has no typed ad revenue API and forwards the key as is. + /// + public const string AdRevenue = "ad_revenue"; +} + +/// +/// Android only: the currency codes listed by the TikTok Business SDK for Android 1.7.1 +/// (TTContentsEventConstants.Currency). The typed helpers accept any ISO 4217 code, like the +/// iOS package; use this list to check a code against the SDK's own enumeration. +/// +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..ba47233 --- /dev/null +++ b/src/Kapusch.TikTok.Business.Android/TikTokBusinessSdk.cs @@ -0,0 +1,526 @@ +using System.Text.RegularExpressions; +using Android.OS; +using Kapusch.TikTok.Business.Android.Internal; + +namespace Kapusch.TikTok.Business.Android; + +/// +/// .NET facade over the TikTok Business SDK (App Events SDK) for Android. Same members and +/// semantics as Kapusch.TikTok.Business.iOS; Android differences are documented per member +/// and in Docs/Business/Integration.md. +/// +/// +/// Nothing native is loaded and nothing is sent until is called: every +/// other member returns (or ) before a successful +/// initialization. Gate on the user's consent. +/// +/// Only switches to the main thread. Every other member calls the SDK +/// synchronously on the calling thread, so calls made in sequence take effect in that order: an event +/// tracked right after carries the identity, and one tracked after +/// does not. Android only: the SDK copies the identity when its event thread +/// builds a queued event, so an event tracked right before or +/// may carry the newer identity. +/// +/// +public static partial class TikTokBusinessSdk +{ + /// Android only: the TikTok Business SDK for Android version bundled in this package. + public const string BundledSdkVersion = "1.7.1"; + + // Upper bound for the SDK to report its initialization. Its init step swallows its own + // exceptions without calling back, so the wait must be bounded here. + private static readonly TimeSpan InitTimeout = TimeSpan.FromSeconds(30); + + private static readonly object Gate = new(); + private static Task? _initialization; + private static volatile bool _nativeEntered; + private static volatile bool _ready; + private static volatile bool _forwardingEnabled = true; + + /// + /// Initializes the TikTok Business SDK on the main thread and completes with the SDK result. + /// The SDK is a process-wide singleton: later calls return the current state without reconfiguring it. + /// + /// + /// + /// Concurrent calls share one native initialization, which always completes within about + /// 30 seconds (). The SDK can be + /// entered only once per process. A failure before it was entered + /// (, + /// ) lets a retry run the checks again. + /// Once it was entered, a retry returns the current state when a timed-out initialization finished + /// late, and otherwise. + /// + /// + /// With the result is + /// with ErrorCode 0. + /// + /// + /// A required value is missing or malformed. + public static Task InitializeAsync(TikTokBusinessConfig config) + { + ArgumentNullException.ThrowIfNull(config); + Validate(config); + + Task initialization; + TaskCompletionSource? owner = null; + lock (Gate) + { + if (Ready()) + { + return Task.FromResult(new TikTokBusinessInitResult(CurrentStatus())); + } + 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 initialization; + } + + /// + /// Starts sending events after an initialization with + /// (startTrack() on Android, which also flushes). Same as SetTrackingEnabled(true). + /// + /// when the SDK is not initialized. + public static bool StartTrack() + { + if (!Ready()) + { + return false; + } + _forwardingEnabled = true; + return Native(NativeTikTokBusinessInterop.StartTrack); + } + + /// Enables or pauses tracking. + /// + /// Android limitation: the SDK has no public API to pause delivery once it has started. + /// Disabling stops this wrapper from forwarding Track*, and + /// calls, which then return and are dropped, not queued + /// as on iOS. Automatic events and events the SDK already holds are still sent until the process + /// ends. On a consent withdrawal also call , persist the choice and do not call + /// on the next launch. Enabling calls . + /// + /// when the SDK is not initialized. + public static bool SetTrackingEnabled(bool enabled) + { + if (enabled) + { + return StartTrack(); + } + if (!Ready()) + { + return false; + } + _forwardingEnabled = false; + return true; + } + + /// Tracks an event. + /// A value or a custom name. + /// + /// Event properties, preferably keys. Values may be strings, + /// booleans, numbers, nested dictionaries, lists or . + /// + /// + /// Optional deduplication identifier (for example a store transaction identifier). The SDK generates one when omitted. + /// + /// when the SDK is not initialized or tracking is disabled. + public static bool TrackEvent( + string eventName, + IReadOnlyDictionary? properties = null, + string? eventId = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(eventName); + var json = TikTokBusinessJson.WriteProperties(properties); + return CanForward() && Native(() => NativeTikTokBusinessInterop.TrackEvent(eventName, json, NullIfEmpty(eventId))); + } + + /// Tracks . + public static bool TrackStartTrial( + decimal? value = null, + string? currency = null, + string? eventId = null, + string? contentId = null, + string? contentType = null + ) => TrackEvent(TikTokBusinessEventNames.StartTrial, CommerceProperties(value, currency, contentId, contentType), eventId); + + /// Tracks . + public static bool TrackSubscribe( + decimal? value = null, + string? currency = null, + string? eventId = null, + string? contentId = null, + string? contentType = null + ) => TrackEvent(TikTokBusinessEventNames.Subscribe, CommerceProperties(value, currency, contentId, contentType), eventId); + + /// + /// Tracks , the App Events equivalent of a completed payment + /// (the app SDK has no CompletePayment event). + /// + public static bool TrackPurchase( + decimal? value = null, + string? currency = null, + string? eventId = null, + string? contentId = null, + string? contentType = null, + IReadOnlyList? contents = null, + string? description = null + ) + { + var properties = CommerceProperties(value, currency, contentId, contentType); + if (contents is { Count: > 0 }) + { + properties[TikTokBusinessPropertyNames.Contents] = contents; + } + if (!string.IsNullOrEmpty(description)) + { + properties[TikTokBusinessPropertyNames.Description] = description; + } + return TrackEvent(TikTokBusinessEventNames.Purchase, properties, eventId); + } + + /// Tracks . + public static bool TrackRegistration(string? eventId = null) => + TrackEvent(TikTokBusinessEventNames.Registration, null, eventId); + + /// Tracks . + public static bool TrackLogin(string? eventId = null) => + TrackEvent(TikTokBusinessEventNames.Login, null, eventId); + + /// + /// Advanced matching. The SDK hashes the values (SHA-256) on the device before sending them. + /// The SDK keeps the first identity until is called. + /// + /// + /// when the SDK is not initialized, tracking is disabled or every value is empty. + /// + public static bool Identify( + string? externalId, + string? externalUserName = null, + string? phoneNumber = null, + string? email = null + ) => + CanForward() + && Native( + () => + NativeTikTokBusinessInterop.Identify( + NullIfEmpty(externalId), + NullIfEmpty(externalUserName), + NullIfEmpty(phoneNumber), + NullIfEmpty(email) + ) + ); + + /// + /// Clears the identity set by and resets the SDK's anonymous user data. + /// Works while tracking is disabled. + /// + /// when the SDK is not initialized. + public static bool Logout() => Ready() && Native(NativeTikTokBusinessInterop.Logout); + + /// Asks the SDK to send queued events now. This is not a delivery receipt. + /// when the SDK is not initialized or tracking is disabled. + public static bool Flush() => CanForward() && Native(NativeTikTokBusinessInterop.Flush); + + /// Replaces the App Secret used by the SDK (for example after a rotation in Events Manager). + /// when the SDK is not initialized. + public static bool UpdateAccessToken(string accessToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(accessToken); + return Ready() && Native(() => NativeTikTokBusinessInterop.UpdateAccessToken(accessToken.Trim())); + } + + /// + /// once initialized the SDK in this process, + /// including the deferred-start state. + /// + public static bool IsInitialized => Ready(); + + /// + /// when the SDK is initialized and allowed to send events. On Android this is + /// also after SetTrackingEnabled(false). + /// + public static bool IsTrackingEnabled => + Ready() && _forwardingEnabled && Native(NativeTikTokBusinessInterop.IsTrackingEnabled); + + /// + /// Version of the bundled TikTok Business SDK, for example 1.7.1. Read from the native + /// library once it is loaded; before that. + /// + public static string SdkVersion + { + get + { + if (!_nativeEntered) + { + return BundledSdkVersion; + } + try + { + var version = NativeTikTokBusinessInterop.SdkVersion(); + return string.IsNullOrEmpty(version) ? BundledSdkVersion : version; + } + catch (Java.Lang.Throwable) + { + return BundledSdkVersion; + } + } + } + + /// + /// Test event code to enter in Events Manager, Test Events. Only available when the SDK runs with + /// ; otherwise. + /// + public static string? TestEventCode + { + get + { + if (!Ready()) + { + return null; + } + try + { + return NativeTikTokBusinessInterop.TestEventCode(); + } + catch (Java.Lang.Throwable) + { + return null; + } + } + } + + private static TikTokBusinessInitStatus CurrentStatus() => + IsTrackingEnabled ? TikTokBusinessInitStatus.Started : TikTokBusinessInitStatus.InitializedTrackingDisabled; + + private static bool CanForward() => _forwardingEnabled && Ready(); + + // Whether the SDK finished initializing in this process. A success that arrives after the + // wait timed out is picked up here, so the state never depends on who observed it first. + private static bool Ready() + { + if (_ready) + { + return true; + } + if (!_nativeEntered || !Native(NativeTikTokBusinessInterop.IsInitialized)) + { + return false; + } + _ready = true; + return true; + } + + // Measurement calls are fire-and-forget: a JNI failure is reported as false, never thrown. + private static bool Native(Func call) + { + try + { + return call(); + } + catch (Java.Lang.Throwable) + { + return false; + } + } + + private static Dictionary CommerceProperties( + decimal? value, + string? currency, + string? contentId, + string? contentType + ) + { + var properties = new Dictionary(StringComparer.Ordinal); + if (value is { } amount) + { + if (amount < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "TikTok event values must not be negative."); + } + if (string.IsNullOrEmpty(currency)) + { + throw new ArgumentException("A currency is required when a value is provided.", nameof(currency)); + } + // TTContentsEvent.Builder.setValue(double) sends a number on Android. + properties[TikTokBusinessPropertyNames.Value] = amount; + } + if (!string.IsNullOrEmpty(currency)) + { + if (!CurrencyPattern().IsMatch(currency)) + { + throw new ArgumentException("Currency must be an ISO 4217 code such as USD or EUR.", nameof(currency)); + } + properties[TikTokBusinessPropertyNames.Currency] = currency; + } + if (!string.IsNullOrEmpty(contentId)) + { + properties[TikTokBusinessPropertyNames.ContentId] = contentId; + } + if (!string.IsNullOrEmpty(contentType)) + { + properties[TikTokBusinessPropertyNames.ContentType] = contentType; + } + return properties; + } + + private static void StartInitialization( + TikTokBusinessConfig config, + TaskCompletionSource completion + ) + { + _nativeEntered = true; + var timeout = new Timer( + static state => + { + var pending = (TaskCompletionSource)state!; + Complete( + pending, + TikTokBusinessInitResult.Failure( + TikTokBusinessInitResult.InitTimeoutErrorCode, + "The TikTok Business SDK did not report its initialization in time." + ) + ); + }, + completion, + InitTimeout, + Timeout.InfiniteTimeSpan + ); + completion.Task.ContinueWith( + static (_, state) => ((Timer)state!).Dispose(), + timeout, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + + try + { + using var bundle = ToBundle(config); + NativeTikTokBusinessInterop.Initialize(bundle, result => Complete(completion, result)); + } + catch (Java.Lang.ClassNotFoundException error) + { + // Nothing native ran: keep every other member away from the missing classes. + _nativeEntered = false; + Complete(completion, TikTokBusinessInitResult.Failure(TikTokBusinessInitResult.NativeUnavailableErrorCode, error.Message)); + } + catch (Java.Lang.NoClassDefFoundError error) + { + // Nothing native ran: keep every other member away from the missing classes. + _nativeEntered = false; + Complete(completion, TikTokBusinessInitResult.Failure(TikTokBusinessInitResult.NativeUnavailableErrorCode, error.Message)); + } + catch (Java.Lang.Throwable error) + { + Complete(completion, TikTokBusinessInitResult.Failure(TikTokBusinessInitResult.NativeExceptionErrorCode, error.Message)); + } + } + + // Called by the native callback, the timeout or a synchronous failure; the first result wins. + // A success always latches the ready state, even after a timeout was reported. A failure + // clears the shared task before completing it, so a caller that awaited it can retry at once. + private static void Complete( + TaskCompletionSource completion, + TikTokBusinessInitResult result + ) + { + if (result.IsSuccess) + { + _ready = true; + } + else + { + lock (Gate) + { + if (!completion.Task.IsCompleted && ReferenceEquals(_initialization, completion.Task)) + { + _initialization = null; + } + } + } + completion.TrySetResult(result); + } + + private static Bundle ToBundle(TikTokBusinessConfig config) + { + var bundle = new Bundle(); + bundle.PutString(NativeTikTokBusinessInterop.KeyAppId, config.AppId.Trim()); + bundle.PutString(NativeTikTokBusinessInterop.KeyTikTokAppId, string.Join(',', config.TikTokAppIds)); + bundle.PutString(NativeTikTokBusinessInterop.KeyAccessToken, config.AccessToken.Trim()); + bundle.PutBoolean(NativeTikTokBusinessInterop.KeyDebugMode, config.DebugMode); + if (config.LogLevel is { } logLevel) + { + bundle.PutInt(NativeTikTokBusinessInterop.KeyLogLevel, (int)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); + bundle.PutBoolean(NativeTikTokBusinessInterop.KeyLowPerformanceDevice, config.LowPerformanceDevice); + return bundle; + } + + private static void Validate(TikTokBusinessConfig config) + { + ArgumentException.ThrowIfNullOrWhiteSpace(config.AppId, nameof(config.AppId)); + ArgumentException.ThrowIfNullOrWhiteSpace(config.AccessToken, nameof(config.AccessToken)); + if (!AndroidPackageName().IsMatch(config.AppId.Trim())) + { + throw new ArgumentException("AppId must be the Android application package name.", nameof(config)); + } + if (config.TikTokAppIds is null || config.TikTokAppIds.Count == 0) + { + throw new ArgumentException("At least one TikTok App ID is required.", nameof(config)); + } + foreach (var tiktokAppId in config.TikTokAppIds) + { + if (string.IsNullOrEmpty(tiktokAppId) || !NumericPattern().IsMatch(tiktokAppId)) + { + throw new ArgumentException("Each TikTok App ID must be numeric, without spaces or commas.", nameof(config)); + } + } + if (config.AttAuthorizationDelay is { } delay && delay < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(config), "AttAuthorizationDelay must not be negative."); + } + } + + private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; + + // Same pattern as com.tiktok.util.RegexUtil#validateAppId in the Android SDK. + [GeneratedRegex(@"^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$", RegexOptions.CultureInvariant)] + private static partial Regex AndroidPackageName(); + + [GeneratedRegex("^[0-9]+$", RegexOptions.CultureInvariant)] + private static partial Regex NumericPattern(); + + [GeneratedRegex("^[A-Z]{3}$", RegexOptions.CultureInvariant)] + private static partial Regex CurrencyPattern(); +} From b54d6ba06ea035a49232e8afe2ea2267e43c56ef Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:19:11 +0100 Subject: [PATCH 06/10] 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, that the Kotlin wrapper error codes match the managed constants, the consumer R8 rules, the notices, the managed NuGet dependencies and that Billing / Install Referrer stay consumer-owned. The Share validator now also rejects any Business artifact. verify-public.py checks tracked files for pasted App Secrets and filled sample templates, and rejects private terms read at run time from KAPUSCH_PRIVATE_TERMS or the untracked .git/info/private-terms file. No private term is tracked, and failures name only "private term #N". Co-Authored-By: Claude Opus 5.5 --- scripts/validate-business-package.sh | 145 +++++++++++++++++++++++++++ scripts/validate-package.sh | 5 + scripts/verify-public.py | 104 +++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100755 scripts/validate-business-package.sh create mode 100755 scripts/verify-public.py diff --git a/scripts/validate-business-package.sh b/scripts/validate-business-package.sh new file mode 100755 index 0000000..d7a4264 --- /dev/null +++ b/scripts/validate-business-package.sh @@ -0,0 +1,145 @@ +#!/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 -constants -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 boolean isTrackingEnabled();' \ + 'public static final java.lang.String sdkVersion();' \ + 'public static final java.lang.String testEventCode();' \ + 'public static final boolean startTrack();' \ + 'public static final boolean trackEvent(java.lang.String, java.lang.String, java.lang.String);' \ + 'public static final boolean identify(java.lang.String, java.lang.String, java.lang.String, java.lang.String);' \ + 'public static final boolean logout();' \ + 'public static final boolean flush();' \ + 'public static final boolean updateAccessToken(java.lang.String);'; do + grep -Fq "$signature" "$tmp_dir/interop.javap" || fail "interop JNI signature changed or missing: $signature" +done + +# Wrapper error codes reported by the Kotlin facade must match the managed constants. +init_result="$repo_root/src/Kapusch.TikTok.Business.Android/TikTokBusinessInitResult.cs" +for pair in \ + ERROR_INVALID_CONFIGURATION:InvalidConfigurationErrorCode \ + ERROR_CONFIGURATION_REJECTED:ConfigurationRejectedErrorCode \ + ERROR_INVALID_CONTEXT:InvalidContextErrorCode \ + ERROR_NATIVE_EXCEPTION:NativeExceptionErrorCode \ + ERROR_INIT_INCOMPLETE:InitIncompleteErrorCode; do + kotlin_name="${pair%%:*}" + managed_name="${pair#*:}" + kotlin_value="$(sed -nE "s/.*public static final int $kotlin_name = (-?[0-9]+);.*/\1/p" "$tmp_dir/interop.javap")" + managed_value="$(sed -nE "s/.*public const int $managed_name = (-?[0-9]+);.*/\1/p" "$init_result")" + [ -n "$kotlin_value" ] && [ "$kotlin_value" = "$managed_value" ] \ + || fail "$kotlin_name ($kotlin_value) does not match TikTokBusinessInitResult.$managed_name ($managed_value)" +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 + +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" +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 100755 index 0000000..adb656b --- /dev/null +++ b/scripts/verify-public.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Fail when a tracked file contains a real-looking credential, a filled sample template or a private term. + +This repository is a public, generic wrapper. The generic checks below are tracked. Terms that +must never appear (for example the names of private consuming apps) are not: they are read at run +time from an untracked source so the scanner never publishes them. + +Private terms, one per line or comma-separated, matched case-insensitively as literal text: + - the KAPUSCH_PRIVATE_TERMS environment variable (CI reads it from a repository secret); + - the file /info/private-terms ('#' starts a comment). +The private-term check is skipped, with a notice, when neither source provides a term. +Failures name the file and the term's position, never the term itself. + +Sample TikTokBusiness.template.json files must also keep placeholder values: real ids and the App +Secret belong in the untracked TikTokBusiness.local.json created from the template. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from pathlib import Path, PurePosixPath + +ROOT = Path(__file__).resolve().parents[1] + +PATTERNS = { + # A TikTok App Secret or Events API access token pasted into a sample, doc or JSON file. + "App Secret or access token": re.compile( + rb"(?i)(?:access_?token|app_?secret)[\"']?\s*[:=]\s*[\"'][0-9a-f]{32,}[\"']" + ), +} + +# Keys of the sample template that must stay placeholders such as "YOUR_TIKTOK_APP_ID". +TEMPLATE_SECRET_KEYS = ("tikTokAppId", "accessToken") +PLACEHOLDER = re.compile(r"^YOUR_[A-Z0-9_]+$") + + +def split_terms(text: str) -> list[str]: + terms = [] + for line in text.splitlines(): + line = line.split("#", 1)[0] + terms.extend(part.strip() for part in line.split(",")) + return [term for term in terms if term] + + +def load_private_terms() -> list[str]: + terms = split_terms(os.environ.get("KAPUSCH_PRIVATE_TERMS", "")) + git_dir = subprocess.check_output(["git", "rev-parse", "--absolute-git-dir"], cwd=ROOT, text=True).strip() + terms_file = Path(git_dir) / "info" / "private-terms" + if terms_file.is_file(): + terms.extend(split_terms(terms_file.read_text(encoding="utf-8"))) + return list(dict.fromkeys(terms)) + + +def filled_sample_keys(name: str, data: bytes) -> str | None: + posix = PurePosixPath(name) + if posix.parts[0] != "samples" or posix.name != "TikTokBusiness.template.json": + return None + try: + template = json.loads(data) + except ValueError: + return "unreadable JSON" + filled = sorted( + key + for key in TEMPLATE_SECRET_KEYS + if not isinstance(template.get(key), str) or not PLACEHOLDER.match(template[key]) + ) + return f"non-placeholder {', '.join(filled)}" if filled else None + + +def main() -> int: + files = [f for f in subprocess.check_output(["git", "ls-files", "-z"], cwd=ROOT).decode().split("\0") if f] + private = [ + (f"private term #{index}", re.compile(re.escape(term.encode()), re.IGNORECASE)) + for index, term in enumerate(load_private_terms(), start=1) + ] + errors = [] + for name in files: + path = ROOT / name + if not path.is_file(): + continue + data = path.read_bytes() + for label, pattern in [*private, *PATTERNS.items()]: + if pattern.search(data): + errors.append(f"{name}: {label}") + template = filled_sample_keys(name, data) + if template: + errors.append(f"{name}: {template}") + if errors: + print("Public tree scan failed:\n " + "\n ".join(errors), file=sys.stderr) + return 1 + if private: + scope = f"{len(private)} private term(s)" + else: + scope = "no private terms configured (set KAPUSCH_PRIVATE_TERMS or .git/info/private-terms)" + print(f"Public tree scan passed ({len(files)} tracked files, {scope})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From d51ea225bcd352ea16b53c56de88475e7e4e7273 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:19:11 +0100 Subject: [PATCH 07/10] 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 | 127 ++++++++++++++++++ .../NuGet.Config | 16 +++ .../Properties/AndroidManifest.xml | 5 + .../README.md | 38 ++++++ samples/README.md | 11 +- 8 files changed, 243 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..2736b53 --- /dev/null +++ b/samples/Kapusch.TikTok.Business.Android.Sample/MainActivity.cs @@ -0,0 +1,127 @@ +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; + } + + // InitializeAsync validates the values first and is bounded: it always completes within + // about 30 seconds. + TikTokBusinessInitResult result; + try + { + result = await TikTokBusinessSdk.InitializeAsync(config); + } + catch (ArgumentException error) + { + Write($"Invalid configuration in Assets/{LocalConfigAsset}: {error.Message}"); + return; + } + Write( + $"Init status={result.Status} code={result.ErrorCode} message={result.ErrorMessage} " + + $"initialized={TikTokBusinessSdk.IsInitialized} tracking={TikTokBusinessSdk.IsTrackingEnabled} " + + $"sdk={TikTokBusinessSdk.SdkVersion} testEventCode={TikTokBusinessSdk.TestEventCode}" + ); + if (!result.IsSuccess) + { + return; + } + + // Manual events with a store transaction id as event_id, as recommended when automatic + // in-app purchase tracking is disabled. + var tracked = + 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(); + var flushed = TikTokBusinessSdk.Flush(); + Write($"Tracked StartTrial, Subscribe, ViewContent and Registration: accepted={tracked}; flush requested={flushed}."); + } + + private TikTokBusinessConfig? LoadConfig() + { + try + { + using var stream = Assets!.Open(LocalConfigAsset); + using var document = JsonDocument.Parse(stream); + var root = document.RootElement; + return new TikTokBusinessConfig + { + AppId = root.GetProperty("appId").GetString() ?? PackageName!, + TikTokAppIds = (root.GetProperty("tikTokAppId").GetString() ?? string.Empty).Split( + ',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ), + AccessToken = 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..45e41fb --- /dev/null +++ b/samples/Kapusch.TikTok.Business.Android.Sample/README.md @@ -0,0 +1,38 @@ +# 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 (one id, or several separated by commas) +# 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 f7c53c12d70d3b7ef8aab18abd3015a0ab050fe4 Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:19:11 +0100 Subject: [PATCH 08/10] ci: build, validate and consume the Business package on pull requests Add a public-tree scan job fed by the KAPUSCH_PRIVATE_TERMS secret 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 | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eeae92b..2fb26f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,16 @@ permissions: contents: read jobs: + public_scan: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Scan tracked files for private terms and credentials + env: + # Private terms kept outside the public tree; the scan skips that check when unset. + KAPUSCH_PRIVATE_TERMS: ${{ secrets.KAPUSCH_PRIVATE_TERMS }} + run: python3 -I 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 +47,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 5d3babf5e65f36af0cd54163905b7392b5dfdcbd Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:19:11 +0100 Subject: [PATCH 09/10] 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 and a tag of the other family is refused. 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 af462956c50b0a8247cb2411ce38ea0e1f2ee0ae Mon Sep 17 00:00:00 2001 From: Jean-Emmanuel BAILLAT Date: Tue, 27 Oct 2026 22:19:11 +0100 Subject: [PATCH 10/10] docs: describe both packages and the Business integration contract README lists the Share and Business packages with their tag families. Docs/Business/Integration.md covers requirements, the consent-gated initialization recipe, the bounded and deterministic initialization, threading and call order, result codes, the configuration and event reference, the automatic purchase double counting warning, Android permissions, R8 and Data Safety, the differences from the iOS package and coexistence with the Share package. Docs/Release.md documents independent releases, and AGENTS.md, CONTRIBUTING.md and the PR template gain the Business rules, validation commands and the private-terms scan. Co-Authored-By: Claude Opus 5.5 --- .github/pull_request_template.md | 2 + AGENTS.md | 15 +- CONTRIBUTING.md | 29 ++- Docs/Business/Integration.md | 322 +++++++++++++++++++++++++++++++ Docs/Business/SourceMode.md | 21 ++ Docs/Release.md | 46 +++++ README.md | 71 +++++-- 7 files changed, 486 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..61de7b1 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 -I 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..d5f8d80 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,13 +19,38 @@ 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 -I scripts/verify-public.py +``` + +`verify-public.py` checks tracked files for pasted App Secrets and checks that the sample +`TikTokBusiness.template.json` keeps placeholder values. It also rejects private terms, such as +the names of private consuming apps, that must never be written in this public tree. Those terms +are never tracked: the scanner reads them at run time, one per line or comma-separated, from the +`KAPUSCH_PRIVATE_TERMS` environment variable and from the untracked file `.git/info/private-terms` +(`#` starts a comment). Failures name the file and `private term #N`, never the term. The check +is skipped with a notice when no term is configured. CI reads the variable from the +`KAPUSCH_PRIVATE_TERMS` repository secret. + +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..674ec8d --- /dev/null +++ b/Docs/Business/Integration.md @@ -0,0 +1,322 @@ +# 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, passed in `TikTokAppIds`; + several can be passed; + - **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. Until a successful initialization +every other member returns `false` (or `null`), and nothing is queued. 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`. It can be called from any thread: the + wrapper runs the SDK initialization 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, call + `SetTrackingEnabled(false)` and `Logout()`. From the next launch, step 2 keeps + the SDK uninitialized. See the Android limitation below. + +```csharp +using Kapusch.TikTok.Business.Android; + +var config = new TikTokBusinessConfig +{ + AppId = "com.example.app", + TikTokAppIds = settings.TikTokAppIds, + AccessToken = settings.TikTokAppSecret, + // Recommended for subscription apps, see below. + DisablePaymentTracking = true, + DisableEnhancedDataPostback = true, + DebugMode = isDebugBuild, +}; + +var result = await TikTokBusinessSdk.InitializeAsync(config); +if (!result.IsSuccess) +{ + logger.LogWarning("TikTok init failed: {Code} {Message}", result.ErrorCode, result.ErrorMessage); +} +``` + +`InitializeAsync` throws `ArgumentException` when a value is missing or +malformed: `AppId` must be an Android package name (checked with the SDK's own +pattern), each TikTok App ID must be numeric. Otherwise it completes from the +SDK's own completion callback, always within about 30 seconds: + +- **One initialization per process.** The SDK is a singleton. Concurrent calls + share one native initialization, and a call after a success returns the current + state (`Started` or `InitializedTrackingDisabled`) without reconfiguring the SDK. +- **The wait is bounded.** SDK 1.7.1 returns silently, without calling back, from + every `initializeSdk` call after the first, and its asynchronous init step + swallows its own exceptions without calling back. The wrapper therefore never + calls `initializeSdk` twice and reports `InitTimeoutErrorCode` (`1105`) when the + SDK stays silent for 30 seconds. +- **Retries are deterministic.** After a failure before the SDK was entered + (`NativeUnavailableErrorCode`, `InvalidContextErrorCode`), a retry runs the + checks again; missing native classes stay missing for the life of the process. + Once the SDK was entered, a retry returns the current state when a timed out + initialization finished late (then `IsInitialized` is already `true`), and + `InitIncompleteErrorCode` (`1104`) otherwise. +- **Deferred start.** With `DisableAutoStart = true` the result is + `InitializedTrackingDisabled` with `ErrorCode` `0`, and the SDK holds delivery + until `TikTokBusinessSdk.StartTrack()`. This is not a consent gate, see above. + +### Threading and call order + +Only `InitializeAsync` switches to the main thread. Every other member calls the +SDK synchronously on the calling thread and returns whether the SDK accepted the +call. `Identify` and `Logout` change the SDK identity before they return, so calls +made in sequence take effect in that order: an event tracked right after `Identify` +carries the identity, and one tracked after `Logout` does not. + +Android limitation: the SDK copies the identity into an event when its own event +thread builds it, shortly after the tracking call. An event tracked right before +`Identify` or `Logout` may therefore carry the newer identity. The SDK offers no +public way to order identity changes behind queued events (its +`getAppEventLogger()` returns `null` outside SDK call stacks). + +### Stopping tracking (Android limitation) + +`SetTrackingEnabled(false)` cannot pause the Android SDK: version 1.7.1 has no +public API to stop delivery once it has started. On Android the wrapper then stops +forwarding `Track*`, `Identify` and `Flush` calls, which return `false` and are +dropped (on iOS they are kept in the SDK's local store). Automatic events and the +events the SDK already holds are still sent until the process ends. +`IsTrackingEnabled` returns `false`. `Logout` still works. `SetTrackingEnabled(true)` +is the same as `StartTrack()`. + +### Result codes + +`TikTokBusinessInitResult` has a `Status` (`Started`, `InitializedTrackingDisabled` +or `Failed`), an `ErrorCode` that is `0` unless the status is `Failed`, and an +`ErrorMessage` for logs. The codes are constants on `TikTokBusinessInitResult`. + +| `ErrorCode` | Constant | Meaning | +|---|---|---| +| `-2` | (SDK) | `TTInitCallback.fail`: the SDK received an empty App ID or TikTok App ID. SDK 1.7.1 reports no other code. | +| `1001` | `InvalidConfigurationErrorCode` | The native layer received an empty App ID, TikTok App ID or App Secret. Shared with iOS. | +| `1002` | `ConfigurationRejectedErrorCode` | The values do not match the SDK's patterns (only reachable by calling the native layer directly). Shared with iOS. | +| `1101` | `InvalidContextErrorCode` | Android only: no Android application context is available. | +| `1102` | `NativeUnavailableErrorCode` | Android only: the native classes are missing, for example because `DisableKapuschTikTokBusinessAndroidNativeAssets=true`. | +| `1103` | `NativeExceptionErrorCode` | Android only: the native layer threw while configuring the SDK. | +| `1104` | `InitIncompleteErrorCode` | Android only: an earlier native initialization in this process did not complete. Every retry in the same process returns this code. | +| `1105` | `InitTimeoutErrorCode` | Android only: the SDK did not report its initialization within 30 seconds. | + +## Configuration reference + +| Property | Native call | Notes | +|---|---|---| +| `DebugMode` | `openDebugMode()` | Shows events in Events Manager **Test events**. | +| `LogLevel` | `setLogLevel` | `null` keeps the SDK default (`NONE`). The SDK only has `NONE`, `INFO`, `WARN`, `DEBUG`, in that order, and logs errors at `INFO`: `Error` maps to `INFO`, `Warn` and `Info` to `WARN`, `Debug` and `Verbose` to `DEBUG`. | +| `DisableAutoStart` | `disableAutoStart()` | Holds delivery until `StartTrack()`; `InitializeAsync` reports `InitializedTrackingDisabled`. | +| `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). | +| `LowPerformanceDevice` | `setIsLowPerformanceDevice(true)` | Lets the SDK reduce optional work. | +| `DisableAppTrackingDialog`, `DisableSKAdNetworkSupport`, `AttAuthorizationDelay`, `CustomUserAgent` | 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 negative value, a value without a currency and a + currency that is not a three-letter ISO 4217 code, like the iOS package. + `TikTokBusinessCurrencies.Supported` lists the codes the Android SDK enumerates. +- The typed helpers send `value` as a number, like the SDK's + `TTContentsEvent.Builder.setValue(double)`. A `TikTokBusinessContent` sends its + price as a string and omits an unset price, like `TTContentParams`. +- Property values may be strings, booleans, numbers, dates, `TikTokBusinessContent`, + dictionaries or lists, nested up to 8 levels. `null` values are sent as JSON `null`. +- Every tracking call returns `false` when the SDK is not initialized or tracking + is disabled, and `true` once the SDK accepted the call. +- 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; it returns `false` when every value is empty. The SDK hashes the values +with SHA-256 on the device and keeps the first identity until `Logout()`. +`Flush()` asks the SDK to send queued events now; it is not a delivery receipt. +`UpdateAccessToken` replaces the App Secret after a rotation. In debug mode, +`TestEventCode` returns the code to enter in Events Manager **Test events**. + +## 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.**` and the + reflectively loaded `AdvertisingIdClient` members only. AndroidX lifecycle and + Install Referrer classes are referenced directly by the SDK, so R8 keeps what it + uses and still shrinks the rest. 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`. + +## Differences from the iOS package + +`Kapusch.TikTok.Business.iOS` exposes the same types, members, configuration +fields, result codes and status semantics, so shared code compiles and behaves the +same on both platforms. The remaining differences come from the two SDKs: + +| Topic | Android | iOS | +|---|---|---| +| `AppId` | Application package name. | Numeric App Store ID. | +| SDK version | 1.7.1 | 1.7.2 | +| `SetTrackingEnabled(false)` | Stops forwarding manual calls (dropped); automatic and held events are still sent. | Pauses sending; events stay queued locally. | +| Event tracked right before `Identify` / `Logout` | May carry the newer identity, see "Threading and call order". | Same call-order contract for events tracked after these calls. | +| Initialization wait | Bounded to 30 seconds (`InitTimeoutErrorCode`). | Completes from the SDK callback. | +| Wrapper error codes | `1001`, `1002` and the Android-only `11xx` codes. | `1001`, `1002`. | +| SDK error codes | `-2` only. | Forwarded unchanged, for example `-2`; the `-1` the SDK reports with `DisableAutoStart` becomes `InitializedTrackingDisabled` with code `0`. | +| `value` in the typed helpers | Number. | Decimal string. | +| `TikTokBusinessContent.Price` unset | Omitted. | `0`. | +| `LogLevel` | `Error` → `INFO`, `Warn`/`Info` → `WARN`, `Debug`/`Verbose` → `DEBUG`. | One SDK level each. | +| In-app ad impression name | `InAppAdImpr` | `InAppADImpr` | +| `PurchaseRestored` | Never sent by the SDK. | Sent by the purchase observer. | +| `DisableAutomaticTracking` | Lifecycle events only, not purchases. | Also stops the purchase observer. | +| Android only | `DisableAdvertiserIdCollection`, `DisableMonitor`, `BundledSdkVersion`, `TikTokBusinessCurrencies`, `TikTokBusinessPropertyNames.OrderId`. | Accepted and ignored where it is a config field. | +| iOS only | Accepted and ignored: `DisableAppTrackingDialog`, `DisableSKAdNetworkSupport`, `AttAuthorizationDelay`, `CustomUserAgent`. | | + +## 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, while the Business package gets +AndroidX and Kotlin from NuGet. An app that references both packages must set +`KapuschTikTokAndroidIncludeBundledRuntimeDependencies=false`, as described in +[`../Integration.md`](../Integration.md): with the bundled graph the Java build +fails on duplicate AndroidX classes. With that setting, a Release build with R8 +that references both packages succeeds. + +## MSBuild properties + +| Property | Default | Effect | +|---|---|---| +| `DisableKapuschTikTokBusinessAndroidNativeAssets` | `false` | Skips injecting both AARs. `InitializeAsync` then returns `NativeUnavailableErrorCode` (`1102`). | +| `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..8e9b0ab 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,45 @@ 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`, bounded to about 30 seconds, that reports + `Started`, `InitializedTrackingDisabled` or `Failed`: 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`, `StartTrack`, `SetTrackingEnabled` and + `UpdateAccessToken`, applied in call order. -- [`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 public API matches `Kapusch.TikTok.Business.iOS` (same types, members, +configuration fields and result codes); platform gaps are listed in the +integration guide. + +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.