diff --git a/Docs/Integration.md b/Docs/Integration.md index 3667ae9..409cd0c 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -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__sub_` 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. diff --git a/README.md b/README.md index e42d927..dcc7695 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Kapusch.TikTokApisForAndroidComponents/Native/Android/tiktokinterop/src/main/java/com/kapusch/tiktok/androidinterop/TikTokShareActivity.kt b/src/Kapusch.TikTokApisForAndroidComponents/Native/Android/tiktokinterop/src/main/java/com/kapusch/tiktok/androidinterop/TikTokShareActivity.kt index 1bc0b34..a29be23 100644 --- a/src/Kapusch.TikTokApisForAndroidComponents/Native/Android/tiktokinterop/src/main/java/com/kapusch/tiktok/androidinterop/TikTokShareActivity.kt +++ b/src/Kapusch.TikTokApisForAndroidComponents/Native/Android/tiktokinterop/src/main/java/com/kapusch/tiktok/androidinterop/TikTokShareActivity.kt @@ -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 @@ -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) @@ -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 } @@ -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) } @@ -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 @@ -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) } diff --git a/src/Kapusch.TikTokApisForAndroidComponents/nuget-readme.md b/src/Kapusch.TikTokApisForAndroidComponents/nuget-readme.md index 8a2218f..c12dad1 100644 --- a/src/Kapusch.TikTokApisForAndroidComponents/nuget-readme.md +++ b/src/Kapusch.TikTokApisForAndroidComponents/nuget-readme.md @@ -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.