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/.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 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 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/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/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/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. diff --git a/THIRD_PARTY_NOTICES.Business.md b/THIRD_PARTY_NOTICES.Business.md new file mode 100644 index 0000000..9fae880 --- /dev/null +++ b/THIRD_PARTY_NOTICES.Business.md @@ -0,0 +1,26 @@ +# Third-party notices (Kapusch.TikTok.Business.Android) + +This package redistributes, unchanged, the TikTok Business SDK for Android 1.7.1 +(`tiktok-business-android-sdk-1.7.1.aar`, Copyright (c) 2020 TikTok Inc.), licensed +under the MIT license. The license text is included as +`licenses/TIKTOK-BUSINESS-SDK-LICENSE`. The artifact is pinned by SHA-256 in +`DependencyLocks/Android/business-lockstate.txt`. + +The package does not redistribute any other third-party Java library. The +following runtime dependencies are resolved by the consuming application: + +- AndroidX Annotation, AndroidX Lifecycle Process and the Kotlin standard library, + through the managed `Xamarin.AndroidX.*` and `Xamarin.Kotlin.StdLib` NuGet + packages declared as dependencies; +- Google Play Billing Library and Google Play Install Referrer Library, optional + and owned by the application. + +## Data collection note + +The SDK merges the `android.permission.INTERNET` and +`com.google.android.gms.permission.AD_ID` permissions. When initialized it reads +the Google advertising identifier (unless advertiser id collection is disabled), +the Play install referrer when that library is present, device and app +information, and in-app purchases when automatic purchase tracking is active. It +sends this data to TikTok. The consuming application must cover it in its privacy +policy, consent flow and Google Play Data Safety declaration. diff --git a/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. 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()) 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/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/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/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" 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") 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(); +} 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.