Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Docs/Integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,23 @@ runtime graph and validates the resulting AAB for missing or duplicate classes.
The callback activity is exported because TikTok launches it with the result.
It rejects direct sharing when TikTok is no longer installed and never calls the
SDK's install-landing flow from that state.

## Share result contract

Start `AndroidTikTokInterop.ShareActivityClassName` for a result and pass at
least two existing image paths through `AndroidTikTokInterop.ExtraImagePaths`.
The wrapper publishes these result extras:

- `ExtraStatus`: `success`, `draft`, `cancelled`, or `failed`.
- `ExtraErrorCode`: present only when the status is `failed`.

TikTok cancellation responses (`-2` or sub-error `20013`) map to `cancelled`.
Other native failures retain their raw error and sub-error as a stable
`tiktok_<error>_sub_<sub-error>` diagnostic. A manual return to the caller
without an OpenSDK callback returns `failed` with
`returned_without_callback`, so the consuming UI does not remain pending.

The wrapper launches the request through the official OpenSDK `ShareApi`.
Cold-start latency and process recreation remain controlled by Android and the
installed TikTok application; consumers must not treat process continuity as
proof of share completion.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
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.
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package com.kapusch.tiktok.androidinterop

import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.core.content.FileProvider
import com.tiktok.open.sdk.core.appcheck.TikTokAppCheckUtil
import com.tiktok.open.sdk.share.MediaType
Expand All @@ -18,15 +20,19 @@ class TikTokShareActivity : Activity() {
const val EXTRA_STATUS = "ktk_status"
const val EXTRA_ERROR_CODE = "ktk_error_code"
private const val CLIENT_KEY_META_DATA = "com.kapusch.tiktok.CLIENT_KEY"
private const val LOG_TAG = "KapuschTikTokShare"
private const val STATUS_SUCCESS = "success"
private const val STATUS_DRAFT = "draft"
private const val STATUS_CANCELLED = "cancelled"
private const val STATUS_FAILED = "failed"
private const val ERROR_USER_CANCELLED = -2
private const val ERROR_DRAFT_SAVED = 20016
private const val ERROR_SHARE_CANCELLED = 20013
}

private lateinit var shareApi: ShareApi
private var waitingForCallback = false
private var leftForTikTok = false

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand All @@ -35,6 +41,7 @@ class TikTokShareActivity : Activity() {
if (handleTikTokCallback(intent)) return
if (savedInstanceState != null) {
waitingForCallback = savedInstanceState.getBoolean("waiting_for_callback", false)
leftForTikTok = savedInstanceState.getBoolean("left_for_tiktok", false)
return
}

Expand All @@ -47,8 +54,23 @@ class TikTokShareActivity : Activity() {
handleTikTokCallback(intent)
}

override fun onPause() {
if (waitingForCallback) {
leftForTikTok = true
}
super.onPause()
}

override fun onResume() {
super.onResume()
if (waitingForCallback && leftForTikTok && !handleTikTokCallback(intent)) {
finishWith(STATUS_FAILED, "returned_without_callback")
}
}

override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean("waiting_for_callback", waitingForCallback)
outState.putBoolean("left_for_tiktok", leftForTikTok)
super.onSaveInstanceState(outState)
}

Expand All @@ -64,7 +86,7 @@ class TikTokShareActivity : Activity() {
return
}

val clientKey = applicationInfo.metaData?.getString(CLIENT_KEY_META_DATA).orEmpty()
val clientKey = readClientKey()
if (clientKey.isBlank() || clientKey.startsWith("${'$'}{")) {
finishWith(STATUS_FAILED, "missing_client_key")
return
Expand Down Expand Up @@ -94,27 +116,63 @@ class TikTokShareActivity : Activity() {
packageName = packageName,
resultActivityFullPath = javaClass.name,
)
Log.i(
LOG_TAG,
"Launching OpenSDK share target=$targetPackage mediaCount=${uris.size}",
)
val launchResult = shareApi.share(request)
if (launchResult.result != 0) {
finishWith(STATUS_FAILED, "launch_${launchResult.result}")
Log.e(LOG_TAG, "OpenSDK rejected share launch result=${launchResult.result}")
finishWith(STATUS_FAILED, "share_request_not_sent")
return
}

waitingForCallback = true
}

private fun readClientKey(): String =
try {
@Suppress("DEPRECATION")
val appInfo = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
appInfo.metaData?.getString(CLIENT_KEY_META_DATA).orEmpty()
} catch (_: PackageManager.NameNotFoundException) {
""
}

private fun handleTikTokCallback(callbackIntent: Intent?): Boolean {
val response = shareApi.getShareResponseFromIntent(callbackIntent) ?: return false
Log.i(
LOG_TAG,
"Share response"
+ " errorCode=${response.errorCode}"
+ " subErrorCode=${response.subErrorCode}"
+ " state=${response.state.orEmpty()}"
+ " errorMessage=${response.errorMsg.orEmpty()}",
)
when {
response.errorCode == 0 -> finishWith(STATUS_SUCCESS, null)
response.errorCode == ERROR_DRAFT_SAVED -> finishWith(STATUS_DRAFT, null)
response.subErrorCode == ERROR_DRAFT_SAVED -> finishWith(STATUS_DRAFT, null)
response.errorCode == ERROR_USER_CANCELLED ||
response.subErrorCode == ERROR_SHARE_CANCELLED ->
finishWith(STATUS_CANCELLED, null)
response.errorMsg?.contains("cancel", ignoreCase = true) == true ->
finishWith(STATUS_CANCELLED, null)
else -> finishWith(STATUS_FAILED, "tiktok_${response.errorCode}")
else ->
finishWith(
STATUS_FAILED,
buildErrorCode(response.errorCode, response.subErrorCode),
)
}
return true
}

private fun buildErrorCode(errorCode: Int, subErrorCode: Int?): String =
if (subErrorCode == null || subErrorCode == 0) {
"tiktok_$errorCode"
} else {
"tiktok_${errorCode}_sub_$subErrorCode"
}

private fun finishWith(status: String, errorCode: String?) {
val result = Intent().putExtra(EXTRA_STATUS, status)
errorCode?.let { result.putExtra(EXTRA_ERROR_CODE, it) }
Expand Down
5 changes: 5 additions & 0 deletions src/Kapusch.TikTokApisForAndroidComponents/nuget-readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ Pinned TikTok OpenSDK Android 2.3.1 Core + Share wrapper for .NET Android.
Native runtime dependencies are bundled by default. Advanced consumers with a
compatible application-owned dependency graph may set
`KapuschTikTokAndroidIncludeBundledRuntimeDependencies=false`.

The interop Activity maps OpenSDK callbacks to `success`, `draft`, `cancelled`,
or `failed`, preserves native error codes, and reports
`returned_without_callback` when the user manually returns before TikTok sends
a result.
Loading