diff --git a/Docs/Integration.md b/Docs/Integration.md index b9fbe32..89d9009 100644 --- a/Docs/Integration.md +++ b/Docs/Integration.md @@ -33,6 +33,19 @@ disable Meta automatic initialization and call `NativeFacebookShare.ConfigureAndInitialize(app.Handle, trackingAllowed)` only after the ATT decision, immediately before the first share. Then call `NativeFacebookShare.SharePhotoAsync(...)`; no caption is accepted. +Forward Facebook callback URLs to +`NativeFacebookShare.HandleOpenUrl(app.Handle, url.Handle, options.Handle)`. +Share-only host applications should filter by their configured Facebook URL +scheme before invoking this handler. + +The photo wrapper uses ShareKit's native-app dialog mode. A share operation +completes its managed callback at most once, including when ShareKit reports a +synchronous presentation failure followed by a late delegate callback. + +When Facebook returns to the host app, the wrapper clears only Meta's private +bridge marker from the general pasteboard, and does so without reading the +pasteboard. This avoids triggering an iOS paste-authorization prompt while +leaving unrelated pasteboard content untouched. ## 3) Feature selection diff --git a/src/Kapusch.FacebookApisForiOSComponents/Facebook/NativeFacebookShare.iOS.cs b/src/Kapusch.FacebookApisForiOSComponents/Facebook/NativeFacebookShare.iOS.cs index 0774c69..ba796ad 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Facebook/NativeFacebookShare.iOS.cs +++ b/src/Kapusch.FacebookApisForiOSComponents/Facebook/NativeFacebookShare.iOS.cs @@ -3,7 +3,7 @@ namespace Kapusch.Facebook.iOS; -public static unsafe class NativeFacebookShare +public static unsafe partial class NativeFacebookShare { public static void ConfigureAndInitialize( IntPtr uiApplicationHandle, @@ -13,7 +13,27 @@ bool trackingAllowed if (uiApplicationHandle == IntPtr.Zero) throw new ArgumentException("UIApplication is required."); - ResolveConfigureAndInitialize()(uiApplicationHandle, trackingAllowed ? (byte)1 : (byte)0); + ConfigureAndInitializeNative( + uiApplicationHandle, + trackingAllowed ? (byte)1 : (byte)0 + ); + } + + public static bool HandleOpenUrl( + IntPtr uiApplicationHandle, + IntPtr nsUrlHandle, + IntPtr optionsHandle + ) + { + if (uiApplicationHandle == IntPtr.Zero || nsUrlHandle == IntPtr.Zero) + return false; + + return HandleOpenUrlNative( + uiApplicationHandle, + nsUrlHandle, + optionsHandle + ) + != 0; } public static Task SharePhotoAsync( @@ -36,7 +56,7 @@ public static Task SharePhotoAsync( var imagePathPointer = Marshal.StringToCoTaskMemUTF8(imagePath); try { - ResolveSharePhoto()( + SharePhotoNative( presentingViewControllerHandle, imagePathPointer, &ShareCallback, @@ -75,24 +95,27 @@ private static void ShareCallback(int status, IntPtr errorCode, IntPtr context) } } - private static IntPtr Resolve(string symbol) => - NativeLibrary.GetExport(NativeLibrary.GetMainProgramHandle(), symbol); + [LibraryImport( + "__Internal", + EntryPoint = "kfb_facebook_share_configure_and_initialize" + )] + private static partial void ConfigureAndInitializeNative( + IntPtr uiApplicationHandle, + byte trackingAllowed + ); - private static delegate* unmanaged[Cdecl] ResolveConfigureAndInitialize() => - (delegate* unmanaged[Cdecl])Resolve( - "kfb_facebook_share_configure_and_initialize" - ); + [LibraryImport("__Internal", EntryPoint = "kfb_facebook_share_handle_open_url")] + private static partial byte HandleOpenUrlNative( + IntPtr uiApplicationHandle, + IntPtr nsUrlHandle, + IntPtr optionsHandle + ); - private static delegate* unmanaged[Cdecl]< - IntPtr, - IntPtr, - delegate* unmanaged[Cdecl], - IntPtr, - void> ResolveSharePhoto() => - (delegate* unmanaged[Cdecl]< - IntPtr, - IntPtr, - delegate* unmanaged[Cdecl], - IntPtr, - void>)Resolve("kfb_facebook_share_photo"); + [LibraryImport("__Internal", EntryPoint = "kfb_facebook_share_photo")] + private static partial void SharePhotoNative( + IntPtr presentingViewControllerHandle, + IntPtr imagePath, + delegate* unmanaged[Cdecl] callback, + IntPtr context + ); } diff --git a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.swift b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.swift index b9250c8..b938b2c 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.swift +++ b/src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.swift @@ -1,9 +1,24 @@ import Foundation +import OSLog import UIKit import FacebookCore import FacebookShare +private let shareLogger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "Kapusch.Facebook.iOS", + category: "FacebookShare" +) + +private enum FacebookBridgePasteboard { + static let dataType = "com.facebook.Facebook.FBAppBridgeType" + + static func clearPendingShareDataWithoutReading() { + UIPasteboard.general.setData(Data(), forPasteboardType: dataType) + shareLogger.debug("Cleared pending Facebook bridge data without reading the pasteboard.") + } +} + public typealias KapuschFacebookShareCallback = @convention(c) ( Int32, UnsafePointer?, @@ -32,6 +47,7 @@ private func invokeShareCallback( private final class ShareDelegate: NSObject, SharingDelegate { let callback: KapuschFacebookShareCallback let context: UnsafeMutableRawPointer + private(set) var didFinish = false init(callback: @escaping KapuschFacebookShareCallback, context: UnsafeMutableRawPointer) { self.callback = callback @@ -39,12 +55,20 @@ private final class ShareDelegate: NSObject, SharingDelegate { } func sharer(_ sharer: Sharing, didCompleteWithResults results: [String: Any]) { + guard !didFinish else { return } + didFinish = true + shareLogger.info("Facebook share completed.") invokeShareCallback(callback, status: .success, context: context) ShareState.clear() } func sharer(_ sharer: Sharing, didFailWithError error: Error) { + guard !didFinish else { return } + didFinish = true let nsError = error as NSError + shareLogger.error( + "Facebook share failed. domain=\(nsError.domain, privacy: .public) code=\(nsError.code)" + ) invokeShareCallback( callback, status: .failed, @@ -55,6 +79,9 @@ private final class ShareDelegate: NSObject, SharingDelegate { } func sharerDidCancel(_ sharer: Sharing) { + guard !didFinish else { return } + didFinish = true + shareLogger.info("Facebook share cancelled.") invokeShareCallback(callback, status: .cancelled, context: context) ShareState.clear() } @@ -76,6 +103,9 @@ public func kfb_facebook_share_configure_and_initialize( _ applicationPtr: UnsafeMutableRawPointer, _ trackingAllowed: Bool ) { + shareLogger.debug( + "Configuring Facebook Share SDK. trackingAllowed=\(trackingAllowed, privacy: .public)" + ) Settings.shared.isAutoLogAppEventsEnabled = false Settings.shared.isAdvertiserIDCollectionEnabled = trackingAllowed Settings.shared.isAdvertiserTrackingEnabled = trackingAllowed @@ -100,7 +130,9 @@ public func kfb_facebook_share_photo( _ callback: @escaping KapuschFacebookShareCallback, _ context: UnsafeMutableRawPointer ) { + shareLogger.debug("Facebook photo share requested.") guard ShareState.dialog == nil else { + shareLogger.error("Facebook share rejected because another request is in progress.") invokeShareCallback( callback, status: .failed, @@ -112,6 +144,7 @@ public func kfb_facebook_share_photo( let imagePath = String(cString: imagePathPtr) guard let image = UIImage(contentsOfFile: imagePath) else { + shareLogger.error("Facebook share could not decode the image.") invokeShareCallback( callback, status: .failed, @@ -133,9 +166,14 @@ public func kfb_facebook_share_photo( content: content, delegate: delegate ) + dialog.mode = .native ShareState.delegate = delegate ShareState.dialog = dialog - if !dialog.show() { + shareLogger.debug("Facebook ShareDialog canShow=\(dialog.canShow, privacy: .public)") + let shown = dialog.show() + shareLogger.debug("Facebook ShareDialog show returned \(shown, privacy: .public)") + if !shown && !delegate.didFinish { + shareLogger.error("Facebook ShareDialog was unavailable without a delegate error.") ShareState.clear() invokeShareCallback( callback, @@ -145,3 +183,35 @@ public func kfb_facebook_share_photo( ) } } + +@_cdecl("kfb_facebook_share_handle_open_url") +public func kfb_facebook_share_handle_open_url( + _ applicationPtr: UnsafeMutableRawPointer, + _ urlPtr: UnsafeMutableRawPointer, + _ optionsPtr: UnsafeMutableRawPointer? +) -> Bool { + let application = Unmanaged + .fromOpaque(applicationPtr) + .takeUnretainedValue() + let url = Unmanaged.fromOpaque(urlPtr).takeUnretainedValue() as URL + guard let appID = Bundle.main.infoDictionary?["FacebookAppID"] as? String, + url.scheme?.caseInsensitiveCompare("fb\(appID)") == .orderedSame + else { + return false + } + let options: NSDictionary? = { + guard let optionsPtr else { return nil } + return Unmanaged.fromOpaque(optionsPtr).takeUnretainedValue() + }() + let openUrlOptions = options as? [UIApplication.OpenURLOptionsKey: Any] ?? [:] + + if ShareState.dialog != nil { + FacebookBridgePasteboard.clearPendingShareDataWithoutReading() + } + + return ApplicationDelegate.shared.application( + application, + open: url, + options: openUrlOptions + ) +} diff --git a/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md b/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md index c6b26c3..f528b92 100644 --- a/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md +++ b/src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md @@ -6,5 +6,8 @@ This package provides: Set `KapuschFacebookFeatures` to `Login` (default), `Share`, or `Login;Share`. SDK 18.0.2 requires FBAEMKit whenever ShareKit/CoreKit is selected. +Photo sharing uses ShareKit's native-app dialog, guarantees one managed +completion per request, and avoids reading the general pasteboard when cleaning +Meta's app-bridge marker on return. See the repo `README.md` and `Docs/Integration.md` for integration steps. diff --git a/tests/validate-feature-selection.sh b/tests/validate-feature-selection.sh index 6f3bc49..c570c58 100644 --- a/tests/validate-feature-selection.sh +++ b/tests/validate-feature-selection.sh @@ -3,6 +3,45 @@ set -euo pipefail project="tests/FeatureSelection.proj" +share_source="src/Kapusch.FacebookApisForiOSComponents/Facebook/NativeFacebookShare.iOS.cs" +share_interop_source="src/Kapusch.FacebookApisForiOSComponents/Native/iOS/KapuschFacebookAuthInterop/Sources/KapuschFacebookShareInterop/Interop.swift" + +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" "$share_source"; then + echo "Facebook Share must use static __Internal imports so iOS retains its native symbols." >&2 + exit 1 +fi + +for symbol in \ + kfb_facebook_share_configure_and_initialize \ + kfb_facebook_share_handle_open_url \ + kfb_facebook_share_photo; do + contains_fixed_text "EntryPoint = \"$symbol\"" "$share_source" \ + || { echo "Missing static Facebook Share import: $symbol" >&2; exit 1; } +done + +contains_fixed_text "guard !didFinish else { return }" "$share_interop_source" \ + || { echo "Facebook Share callbacks must be idempotent." >&2; exit 1; } +contains_fixed_text "if !shown && !delegate.didFinish" "$share_interop_source" \ + || { echo "Facebook Share must not invoke a second callback after ShareKit reports a synchronous failure." >&2; exit 1; } +contains_fixed_text "dialog.mode = .native" "$share_interop_source" \ + || { echo "Facebook photo share must use the native Facebook dialog instead of the deprecated iOS share sheet." >&2; exit 1; } +contains_fixed_text "FacebookBridgePasteboard.clearPendingShareDataWithoutReading()" "$share_interop_source" \ + || { echo "Facebook native share callbacks must clear bridge data without triggering an iOS paste read." >&2; exit 1; } +if contains_fixed_text "data(forPasteboardType:" "$share_interop_source"; then + echo "Facebook Share wrapper must never read the general pasteboard." >&2 + exit 1 +fi dotnet msbuild "$project" -t:ValidateFeatureSelection -p:ExpectedLoginEnabled=True -p:ExpectedShareEnabled=False