Skip to content
Closed
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
33 changes: 33 additions & 0 deletions OneSignalSDK.DotNet.iOS.Binding/ApiDefinitions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@

namespace Com.OneSignal.iOS
{
[Internal]
[BaseType(typeof(NSObject))]
interface OneSignalCoreHelper
{
[Static]
[Export("isOneSignalPayload:")]
bool IsOneSignalPayload(NSDictionary payload);
}

// @interface OSNotification : NSObject
[BaseType(typeof(NSObject))]
interface OSNotification
Expand Down Expand Up @@ -344,6 +353,30 @@ [NullAllowed] OSNotificationLifecycleListener listener
//[Static, Abstract]
[Export("clearAll")]
void ClearAll();

[Export("didRegisterForRemoteNotificationsWithDeviceToken:")]
void DidRegisterForRemoteNotifications(NSData deviceToken);

[Export("didFailToRegisterForRemoteNotificationsWithError:")]
void DidFailToRegisterForRemoteNotifications(NSError error);

[Export("didReceiveRemoteNotification:completionHandler:")]
void DidReceiveRemoteNotification(
NSDictionary userInfo,
Action<UIBackgroundFetchResult> completionHandler
);

[Export("willPresentNotificationWithPayload:completion:")]
void WillPresentNotification(
NSDictionary payload,
OSNotificationDisplayResponse completion
);

[Export("didReceiveNotificationResponse:")]
void DidReceiveNotificationResponse(UNNotificationResponse response);

[Export("setBadgeCount:")]
void SetBadgeCount(nint badgeCount);
}

// @interface OSPushSubscriptionChangedState : NSObject
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
</PropertyGroup>
<ItemGroup>
<Folder Include="Resources\" />
<InternalsVisibleTo Include="OneSignalSDK.DotNet.iOS" />
</ItemGroup>
<ItemGroup>
<ObjcBindingCoreSource Include="StructsAndEnums.cs" />
Expand Down
141 changes: 141 additions & 0 deletions OneSignalSDK.DotNet.iOS/NotificationsManualIntegration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
using Foundation;
using UIKit;
using UserNotifications;
using OneSignalNative = Com.OneSignal.iOS.OneSignal;

namespace OneSignalSDK.DotNet.iOS;

/// <summary>
/// Supported iOS notification callbacks for apps that disable OneSignal method swizzling.
/// </summary>
public static class NotificationsManualIntegration
{
private const string DisableSwizzlingKey = "OneSignal_disable_swizzling";

/// <summary>
/// Returns whether manual notification forwarding is enabled in the app's Info.plist.
/// </summary>
public static bool IsEnabled =>
(NSBundle.MainBundle.ObjectForInfoDictionary(DisableSwizzlingKey) as NSNumber)?.BoolValue
?? false;

/// <summary>
/// Returns whether a notification payload belongs to OneSignal.
/// </summary>
public static bool IsOneSignalNotification(NSDictionary payload)
{
ArgumentNullException.ThrowIfNull(payload);
return Com.OneSignal.iOS.OneSignalCoreHelper.IsOneSignalPayload(payload);
}

/// <summary>
/// Forwards successful APNs registration to OneSignal.
/// </summary>
public static void DidRegisterForRemoteNotifications(NSData deviceToken)
{
EnsureEnabled();
ArgumentNullException.ThrowIfNull(deviceToken);
OneSignalNative.Notifications.DidRegisterForRemoteNotifications(deviceToken);
}

/// <summary>
/// Forwards failed APNs registration to OneSignal.
/// </summary>
public static void DidFailToRegisterForRemoteNotifications(NSError error)
{
EnsureEnabled();
ArgumentNullException.ThrowIfNull(error);
OneSignalNative.Notifications.DidFailToRegisterForRemoteNotifications(error);
}

/// <summary>
/// Forwards a background remote notification to OneSignal. OneSignal owns the completion handler.
/// </summary>
public static void DidReceiveRemoteNotification(
NSDictionary userInfo,
Action<UIBackgroundFetchResult> completionHandler
)
{
EnsureEnabled();
ArgumentNullException.ThrowIfNull(userInfo);
ArgumentNullException.ThrowIfNull(completionHandler);
OneSignalNative.Notifications.DidReceiveRemoteNotification(userInfo, completionHandler);
}

/// <summary>
/// Handles a OneSignal foreground notification and completes Apple's callback exactly once.
/// </summary>
/// <returns><see langword="true"/> when the notification belonged to OneSignal.</returns>
public static bool TryHandleWillPresentNotification(
UNNotification notification,
UNNotificationPresentationOptions presentationOptions,
Action<UNNotificationPresentationOptions> completionHandler
)
{
EnsureEnabled();
ArgumentNullException.ThrowIfNull(notification);
ArgumentNullException.ThrowIfNull(completionHandler);

var payload = notification.Request.Content.UserInfo;
if (!IsOneSignalNotification(payload))
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both TryHandle* methods call EnsureEnabled() before the IsOneSignalNotification gate. EnsureEnabled throws when the plist flag is missing, so we never get to return false and the coordinator never falls through to the plugin delegate.

In the example that means a missing OneSignal_disable_swizzling key doesn’t quietly skip OneSignal — it aborts the whole WillPresent / DidReceiveResponse path, Apple’s completion never runs, and local notifications hang too. That’s a much worse failure mode than “OneSignal just doesn’t get the callback.”

I’d either:

  1. Check the payload first and only call EnsureEnabled once we know it’s ours, or
  2. Treat “not enabled” as return false (log + fall through) instead of throwing on these two entry points.

The throw still makes sense on the unconditional AppDelegate forwards (DidRegister…, DidReceiveRemoteNotification, etc.), where there’s no alternate owner to hand off to.


OneSignalNative.Notifications.WillPresentNotification(
payload,
displayableNotification =>
completionHandler(
displayableNotification == null
? (UNNotificationPresentationOptions)0
: presentationOptions
)
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small asymmetry with TryHandleNotificationResponse below: the click path wraps the native call in try/finally so Apple’s completion always fires once. Here the only way completionHandler runs is inside OneSignal’s display callback.

Native willPresentNotificationWithPayload:completion: does appear to invoke the block on the happy/error paths I checked, so this isn’t a guaranteed bug — but a sync failure at the P/Invoke boundary would leave willPresent unfinished, and we already chose the safer pattern for clicks. Worth matching that here (try/finally with a once-guard, or equivalent).

return true;
}

/// <summary>
/// Handles a OneSignal notification response and completes Apple's callback exactly once.
/// </summary>
/// <returns><see langword="true"/> when the notification belonged to OneSignal.</returns>
public static bool TryHandleNotificationResponse(
UNNotificationResponse response,
Action completionHandler
)
{
EnsureEnabled();
ArgumentNullException.ThrowIfNull(response);
ArgumentNullException.ThrowIfNull(completionHandler);

if (!IsOneSignalNotification(response.Notification.Request.Content.UserInfo))
return false;

try
{
OneSignalNative.Notifications.DidReceiveNotificationResponse(response);
}
finally
{
completionHandler();
}

return true;
}

/// <summary>
/// Sets the app icon badge count through OneSignal.
/// </summary>
public static void SetBadgeCount(nint badgeCount)
{
EnsureEnabled();
OneSignalNative.Notifications.SetBadgeCount(badgeCount);
}

private static void EnsureEnabled()
{
if (!IsEnabled)
{
throw new InvalidOperationException(
$"Set {DisableSwizzlingKey} to true in Info.plist before using manual integration."
);
}
}
}
1 change: 0 additions & 1 deletion examples/plugin-local-notif/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
# Default App ID (used when ONESIGNAL_APP_ID is empty or missing): 77e32082-ea27-42e3-a898-c72e141824ef
ONESIGNAL_APP_ID=your-onesignal-app-id
5 changes: 5 additions & 0 deletions examples/plugin-local-notif/DotEnv.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ namespace PluginLocalNotifDemo;

public static class DotEnv
{
private const string OneSignalAppIdPlaceholder = "your-onesignal-app-id";
private static readonly Dictionary<string, string> Values = new();
private static bool _loaded;

public static string OneSignalAppId => Get("ONESIGNAL_APP_ID").Trim();
public static bool HasOneSignalAppId =>
!string.IsNullOrWhiteSpace(OneSignalAppId) && OneSignalAppId != OneSignalAppIdPlaceholder;

public static void Load()
{
if (_loaded)
Expand Down
73 changes: 69 additions & 4 deletions examples/plugin-local-notif/MainPage.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Text;
using System.Text.Json;
using OneSignalSDK.DotNet;
using OneSignalSDK.DotNet.Core.Notifications;
using OneSignalSDK.DotNet.Core.User;
Expand Down Expand Up @@ -88,6 +90,24 @@ public MainPage()
);
};

var showOneSignalNotificationButton = new Button
{
Text = "Show OneSignal Notification",
AutomationId = "show_onesignal_notification_button",
};
showOneSignalNotificationButton.Clicked += async (s, e) =>
{
showOneSignalNotificationButton.IsEnabled = false;
try
{
await SendSimpleOneSignalNotificationAsync();
}
finally
{
showOneSignalNotificationButton.IsEnabled = true;
}
};

var clearButton = new Button
{
Text = "Clear Delivered Notifications",
Expand Down Expand Up @@ -133,6 +153,7 @@ public MainPage()
requestOneSignalPermissionButton,
requestLocalPermissionButton,
showLocalNotificationButton,
showOneSignalNotificationButton,
clearButton,
refreshPushInfoButton,
_statusLabel,
Expand Down Expand Up @@ -160,10 +181,9 @@ private void RefreshPushInfoLabel()
{
var pushSubscription = OneSignal.User.PushSubscription;
_pushInfoLabel.Text =
$"OneSignal ID: {FormatValue(OneSignal.User.OneSignalId)}\n"
+ $"Push subscription ID: {FormatValue(pushSubscription.Id)}\n"
+ $"Push opted in: {pushSubscription.OptedIn}\n"
+ $"Push token: {FormatValue(pushSubscription.Token)}";
$"OneSignal ID:\n{FormatValue(OneSignal.User.OneSignalId)}\n"
+ $"Push subscription ID:\n{FormatValue(pushSubscription.Id)}\n"
+ $"Push opted in: {pushSubscription.OptedIn}";
}

private void OnPermissionChanged(object? sender, NotificationPermissionChangedEventArgs args)
Expand All @@ -185,6 +205,51 @@ private void OnUserChanged(object? sender, UserStateChangedEventArgs args)
MainThread.BeginInvokeOnMainThread(RefreshPushInfoLabel);
}

private async Task SendSimpleOneSignalNotificationAsync()
{
if (!DotEnv.HasOneSignalAppId)
{
SetStatus("Set ONESIGNAL_APP_ID in .env before sending a OneSignal notification.");
return;
}

var pushSubscriptionId = OneSignal.User.PushSubscription.Id;
if (string.IsNullOrWhiteSpace(pushSubscriptionId))
{
SetStatus(
"No OneSignal push subscription ID yet. Refresh after permission is granted."
);
return;
}

using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Accept", "application/vnd.onesignal.v1+json");

var payload = new Dictionary<string, object>
{
["app_id"] = DotEnv.OneSignalAppId,
["headings"] = new Dictionary<string, string> { ["en"] = "Simple Notification" },
["contents"] = new Dictionary<string, string>
{
["en"] = "This is a simple push notification",
},
["include_subscription_ids"] = new[] { pushSubscriptionId },
};

var json = JsonSerializer.Serialize(payload);
var response = await client.PostAsync(
"https://onesignal.com/api/v1/notifications",
new StringContent(json, Encoding.UTF8, "application/json")
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This POST has no Authorization header, so OneSignal will 401 it every time. That conflicts with the README / PR testing claim that the in-app “Show OneSignal Notification” action was used to verify foreground display and clicks.

Two related concerns:

  1. Evidence — either this was tested against a local change that isn’t in the PR, or the push came from the dashboard and got attributed to this button. Worth correcting the testing write-up either way.
  2. Footgun.env is already a MauiAsset (appenv). The obvious “fix” is stuffing a REST API key into .env, which then ships inside the ipa/apk.

I’d either drop the button and document “send a test push from the dashboard to the subscription ID shown above,” or route send through something that never embeds a REST key in the client. Matching the main demo’s auth-less pattern doesn’t really help here because this sample is specifically selling that button as the verification path.

var responseJson = await response.Content.ReadAsStringAsync();

SetStatus(
response.IsSuccessStatusCode
? $"OneSignal notification requested: {responseJson}"
: $"OneSignal notification failed: {responseJson}"
);
}

private void SetStatus(string message)
{
MainThread.BeginInvokeOnMainThread(() =>
Expand Down
Loading