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
22 changes: 21 additions & 1 deletion Docs/Integration.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 7 additions & 2 deletions samples/Kapusch.TikTok.iOS.Sample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 25 additions & 0 deletions scripts/validate-package.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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<NSURL>.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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@

namespace Kapusch.TikTok.iOS;

public static unsafe class NativeTikTokShare
public static unsafe partial class NativeTikTokShare
{
public static Task<NativeTikTokShareResult> ShareImagesAsync(
IReadOnlyList<string> localIdentifiers,
CancellationToken cancellationToken = default
)
{
ArgumentNullException.ThrowIfNull(localIdentifiers);
if (localIdentifiers.Count is < 2 or > 12)
if (localIdentifiers.Count is < 1 or > 12)
throw new ArgumentOutOfRangeException(nameof(localIdentifiers));
cancellationToken.ThrowIfCancellationRequested();

Expand All @@ -33,7 +33,7 @@ public static Task<NativeTikTokShareResult> ShareImagesAsync(
var jsonPointer = Marshal.StringToCoTaskMemUTF8(json);
try
{
ResolveShareImages()(jsonPointer, &ShareCallback, GCHandle.ToIntPtr(handle));
ShareImagesNative(jsonPointer, &ShareCallback, GCHandle.ToIntPtr(handle));
}
catch
{
Expand All @@ -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)
{
Expand All @@ -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]<int, IntPtr, IntPtr, void> callback,
IntPtr context
);

private static delegate* unmanaged[Cdecl]<
IntPtr,
delegate* unmanaged[Cdecl]<int, IntPtr, IntPtr, void>,
IntPtr,
void> ResolveShareImages() =>
(delegate* unmanaged[Cdecl]<
IntPtr,
delegate* unmanaged[Cdecl]<int, IntPtr, IntPtr, void>,
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]<IntPtr, byte> ResolveHandleOpenUrl() =>
(delegate* unmanaged[Cdecl]<IntPtr, byte>)Resolve("ktk_tiktok_handle_open_url");
[LibraryImport("__Internal", EntryPoint = "ktk_tiktok_abandon_pending_share")]
private static partial byte AbandonPendingShareNative();
}
Loading