diff --git a/Docs/Integration.md b/Docs/Integration.md index e67e0ac..b870f5b 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -1,7 +1,27 @@ # Integration Reference `Kapusch.TikTok.iOS`, configure the three public TikTok values in the app `Info.plist`, and forward both open-URL and universal-link callbacks to `NativeTikTokShare`. +The wrapper rejects URLs that do not match the configured `TikTokURLScheme` or +`TikTokRedirectURI`; host applications should still route only those URLs to the +TikTok handler. -`ShareImagesAsync` expects two to twelve Photos local identifiers for image sharing. The host app is responsible for add-only Photos authorization and for explaining that the images remain in the photo library. +`ShareImagesAsync` expects one to twelve Photos local identifiers for image sharing. +TikTok OpenSDK 2.5.0 does not accept file paths or in-memory image data for this +flow. The host app is responsible for add-only Photos authorization and for +explaining that the images remain in the photo library. + +OpenSDK does not always return a callback when the user switches back to the host +app without completing or cancelling in TikTok. A host that waits for +`ShareImagesAsync` should: + +1. observe that it actually entered the background after sending the request; +2. observe the subsequent return to the active state; +3. leave a short grace period for the official callback; +4. call `NativeTikTokShare.AbandonPendingShare()` if the request is still pending. + +Abandoning completes the pending task once with `Cancelled` and the stable error +code `returned_without_callback`. A late OpenSDK callback is ignored. Do not call +this API for an activation event that was not preceded by the share handoff +entering the background. The official 2.5.0 Core and Share privacy manifests declare no collected data and no tracking. Audit the final IPA because the rest of the application can add other declarations. diff --git a/samples/Kapusch.TikTok.iOS.Sample/README.md b/samples/Kapusch.TikTok.iOS.Sample/README.md index b05dd01..1e19167 100644 --- a/samples/Kapusch.TikTok.iOS.Sample/README.md +++ b/samples/Kapusch.TikTok.iOS.Sample/README.md @@ -17,15 +17,20 @@ public override bool ContinueUserActivity( && NativeTikTokShare.HandleOpenUrl(url.Handle); ``` -After obtaining add-only Photos permission and creating two assets, pass their local identifiers in display order: +After obtaining add-only Photos permission and creating one or more assets, pass their local identifiers in display order: ```csharp var result = await NativeTikTokShare.ShareImagesAsync( - [firstImageAsset.LocalIdentifier, secondImageAsset.LocalIdentifier], + [firstImageAsset.LocalIdentifier], CancellationToken.None ); Console.WriteLine($"TikTok share: {result.Status} {result.ErrorCode}"); ``` +If the host app returns from the background while this task remains pending, give +the SDK callback a short grace period and then call +`NativeTikTokShare.AbandonPendingShare()`. The task completes as `Cancelled` with +`returned_without_callback`, and any later SDK callback is ignored. + The SDK handoff and callback require an approved TikTok application and a physical device with TikTok installed. diff --git a/scripts/validate-package.sh b/scripts/validate-package.sh index 1267e12..2708970 100755 --- a/scripts/validate-package.sh +++ b/scripts/validate-package.sh @@ -3,11 +3,36 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" package="${1:-$repo_root/artifacts/nuget/Kapusch.TikTok.iOS.1.0.0.nupkg}" +managed_source="$repo_root/src/Kapusch.TikTokApisForiOSComponents/TikTok/NativeTikTokShare.iOS.cs" if [ ! -f "$package" ]; then echo "Package not found: $package" >&2 exit 1 fi +contains_fixed_text() { + local pattern="$1" + local file="$2" + + if command -v rg >/dev/null 2>&1; then + rg -F "$pattern" "$file" >/dev/null + else + grep -Fq -- "$pattern" "$file" + fi +} + +if contains_fixed_text "NativeLibrary.GetExport" "$managed_source"; then + echo "TikTok Share must use static __Internal imports so iOS retains its native symbols." >&2 + exit 1 +fi + +for symbol in \ + ktk_tiktok_handle_open_url \ + ktk_tiktok_share_images \ + ktk_tiktok_abandon_pending_share; do + contains_fixed_text "EntryPoint = \"$symbol\"" "$managed_source" \ + || { echo "Missing static TikTok Share import: $symbol" >&2; exit 1; } +done + entries="$(unzip -Z1 "$package")" for expected in \ "ktkshare.xcframework/ios-arm64/libKapuschTikTokShareInterop_iphoneos.a" \ diff --git a/src/Kapusch.TikTokApisForiOSComponents/Native/iOS/KapuschTikTokShareInterop/Sources/KapuschTikTokShareInterop/Interop.swift b/src/Kapusch.TikTokApisForiOSComponents/Native/iOS/KapuschTikTokShareInterop/Sources/KapuschTikTokShareInterop/Interop.swift index df4cb24..b8932be 100644 --- a/src/Kapusch.TikTokApisForiOSComponents/Native/iOS/KapuschTikTokShareInterop/Sources/KapuschTikTokShareInterop/Interop.swift +++ b/src/Kapusch.TikTokApisForiOSComponents/Native/iOS/KapuschTikTokShareInterop/Sources/KapuschTikTokShareInterop/Interop.swift @@ -16,8 +16,32 @@ private enum ShareStatus: Int32 { case failed = 3 } +private final class ShareSession { + let callback: KapuschTikTokShareCallback + let context: UnsafeMutableRawPointer + private(set) var didFinish = false + + init(callback: @escaping KapuschTikTokShareCallback, context: UnsafeMutableRawPointer) { + self.callback = callback + self.context = context + } + + func finish(status: ShareStatus, errorCode: String? = nil) { + guard !didFinish else { return } + didFinish = true + invoke(callback, status: status, errorCode: errorCode, context: context) + } +} + private final class ShareState { nonisolated(unsafe) static var request: TikTokShareRequest? + nonisolated(unsafe) static var session: ShareSession? + + static func clear(_ session: ShareSession) { + guard self.session === session else { return } + request = nil + self.session = nil + } } private func invoke( @@ -39,14 +63,14 @@ public func ktk_tiktok_share_images( _ callback: @escaping KapuschTikTokShareCallback, _ context: UnsafeMutableRawPointer ) { - guard ShareState.request == nil else { + guard ShareState.session == nil else { invoke(callback, status: .failed, errorCode: "already_in_progress", context: context) return } let data = Data(String(cString: localIdentifiersJson).utf8) guard let localIdentifiers = try? JSONDecoder().decode([String].self, from: data), - localIdentifiers.count >= 2, + localIdentifiers.count >= 1, localIdentifiers.count <= 12 else { invoke(callback, status: .failed, errorCode: "invalid_image_identifiers", context: context) @@ -71,37 +95,57 @@ public func ktk_tiktok_share_images( redirectURI: redirectURI ) request.customConfig = .init(clientKey: clientKey, callerUrlScheme: urlScheme) + let session = ShareSession(callback: callback, context: context) ShareState.request = request + ShareState.session = session let sent = request.send { response in - defer { ShareState.request = nil } + defer { ShareState.clear(session) } guard let response = response as? TikTokShareResponse else { - invoke(callback, status: .failed, errorCode: "invalid_share_response", context: context) + session.finish(status: .failed, errorCode: "invalid_share_response") return } switch response.shareState { case .success: - invoke(callback, status: .success, context: context) + session.finish(status: .success) case .saveAsDraft: - invoke(callback, status: .draft, context: context) + session.finish(status: .draft) case .cancelled: - invoke(callback, status: .cancelled, context: context) + session.finish(status: .cancelled) default: - invoke( - callback, + session.finish( status: .failed, - errorCode: "tiktok_share_\(response.shareState.rawValue)_\(response.errorCode.rawValue)", - context: context + errorCode: "tiktok_share_\(response.shareState.rawValue)_\(response.errorCode.rawValue)" ) } } if !sent { - ShareState.request = nil - invoke(callback, status: .failed, errorCode: "share_request_not_sent", context: context) + session.finish(status: .failed, errorCode: "share_request_not_sent") + ShareState.clear(session) } } +@_cdecl("ktk_tiktok_abandon_pending_share") +public func ktk_tiktok_abandon_pending_share() -> Bool { + guard let session = ShareState.session else { return false } + session.finish(status: .cancelled, errorCode: "returned_without_callback") + ShareState.clear(session) + return true +} + @_cdecl("ktk_tiktok_handle_open_url") public func ktk_tiktok_handle_open_url(_ urlPtr: UnsafeMutableRawPointer) -> Bool { let url = Unmanaged.fromOpaque(urlPtr).takeUnretainedValue() as URL + let info = Bundle.main.infoDictionary ?? [:] + let callbackScheme = info["TikTokURLScheme"] as? String + let redirectURI = (info["TikTokRedirectURI"] as? String).flatMap(URL.init(string:)) + let isCustomSchemeCallback = + url.scheme?.caseInsensitiveCompare(callbackScheme ?? "") == .orderedSame + let isUniversalLinkCallback = + url.scheme?.caseInsensitiveCompare(redirectURI?.scheme ?? "") == .orderedSame + && url.host?.caseInsensitiveCompare(redirectURI?.host ?? "") == .orderedSame + && url.path == redirectURI?.path + guard isCustomSchemeCallback || isUniversalLinkCallback else { + return false + } return TikTokURLHandler.handleOpenURL(url) } diff --git a/src/Kapusch.TikTokApisForiOSComponents/TikTok/NativeTikTokShare.iOS.cs b/src/Kapusch.TikTokApisForiOSComponents/TikTok/NativeTikTokShare.iOS.cs index ade3edc..3697dc5 100644 --- a/src/Kapusch.TikTokApisForiOSComponents/TikTok/NativeTikTokShare.iOS.cs +++ b/src/Kapusch.TikTokApisForiOSComponents/TikTok/NativeTikTokShare.iOS.cs @@ -4,7 +4,7 @@ namespace Kapusch.TikTok.iOS; -public static unsafe class NativeTikTokShare +public static unsafe partial class NativeTikTokShare { public static Task ShareImagesAsync( IReadOnlyList localIdentifiers, @@ -12,7 +12,7 @@ public static Task ShareImagesAsync( ) { ArgumentNullException.ThrowIfNull(localIdentifiers); - if (localIdentifiers.Count is < 2 or > 12) + if (localIdentifiers.Count is < 1 or > 12) throw new ArgumentOutOfRangeException(nameof(localIdentifiers)); cancellationToken.ThrowIfCancellationRequested(); @@ -33,7 +33,7 @@ public static Task ShareImagesAsync( var jsonPointer = Marshal.StringToCoTaskMemUTF8(json); try { - ResolveShareImages()(jsonPointer, &ShareCallback, GCHandle.ToIntPtr(handle)); + ShareImagesNative(jsonPointer, &ShareCallback, GCHandle.ToIntPtr(handle)); } catch { @@ -51,9 +51,11 @@ public static bool HandleOpenUrl(IntPtr nsUrlHandle) { if (nsUrlHandle == IntPtr.Zero) return false; - return ResolveHandleOpenUrl()(nsUrlHandle) != 0; + return HandleOpenUrlNative(nsUrlHandle) != 0; } + public static bool AbandonPendingShare() => AbandonPendingShareNative() != 0; + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static void ShareCallback(int status, IntPtr errorCode, IntPtr context) { @@ -74,20 +76,16 @@ private static void ShareCallback(int status, IntPtr errorCode, IntPtr context) } } - private static IntPtr Resolve(string symbol) => - NativeLibrary.GetExport(NativeLibrary.GetMainProgramHandle(), symbol); + [LibraryImport("__Internal", EntryPoint = "ktk_tiktok_share_images")] + private static partial void ShareImagesNative( + IntPtr localIdentifiersJson, + delegate* unmanaged[Cdecl] callback, + IntPtr context + ); - private static delegate* unmanaged[Cdecl]< - IntPtr, - delegate* unmanaged[Cdecl], - IntPtr, - void> ResolveShareImages() => - (delegate* unmanaged[Cdecl]< - IntPtr, - delegate* unmanaged[Cdecl], - IntPtr, - void>)Resolve("ktk_tiktok_share_images"); + [LibraryImport("__Internal", EntryPoint = "ktk_tiktok_handle_open_url")] + private static partial byte HandleOpenUrlNative(IntPtr nsUrlHandle); - private static delegate* unmanaged[Cdecl] ResolveHandleOpenUrl() => - (delegate* unmanaged[Cdecl])Resolve("ktk_tiktok_handle_open_url"); + [LibraryImport("__Internal", EntryPoint = "ktk_tiktok_abandon_pending_share")] + private static partial byte AbandonPendingShareNative(); }