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
13 changes: 13 additions & 0 deletions Docs/Integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<NativeFacebookShareResult> SharePhotoAsync(
Expand All @@ -36,7 +56,7 @@ public static Task<NativeFacebookShareResult> SharePhotoAsync(
var imagePathPointer = Marshal.StringToCoTaskMemUTF8(imagePath);
try
{
ResolveSharePhoto()(
SharePhotoNative(
presentingViewControllerHandle,
imagePathPointer,
&ShareCallback,
Expand Down Expand Up @@ -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]<IntPtr, byte, void> ResolveConfigureAndInitialize() =>
(delegate* unmanaged[Cdecl]<IntPtr, byte, void>)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]<int, IntPtr, IntPtr, void>,
IntPtr,
void> ResolveSharePhoto() =>
(delegate* unmanaged[Cdecl]<
IntPtr,
IntPtr,
delegate* unmanaged[Cdecl]<int, IntPtr, IntPtr, void>,
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]<int, IntPtr, IntPtr, void> callback,
IntPtr context
);
}
Original file line number Diff line number Diff line change
@@ -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<CChar>?,
Expand Down Expand Up @@ -32,19 +47,28 @@ 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
self.context = context
}

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,
Expand All @@ -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()
}
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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<UIApplication>
.fromOpaque(applicationPtr)
.takeUnretainedValue()
let url = Unmanaged<NSURL>.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<NSDictionary>.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
)
}
3 changes: 3 additions & 0 deletions src/Kapusch.FacebookApisForiOSComponents/nuget-readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
39 changes: 39 additions & 0 deletions tests/validate-feature-selection.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading