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