diff --git a/examples/plugin-local-notif/.env.example b/examples/plugin-local-notif/.env.example index 96620213..2c398f31 100644 --- a/examples/plugin-local-notif/.env.example +++ b/examples/plugin-local-notif/.env.example @@ -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 diff --git a/examples/plugin-local-notif/DotEnv.cs b/examples/plugin-local-notif/DotEnv.cs index 3ff7262c..43d32061 100644 --- a/examples/plugin-local-notif/DotEnv.cs +++ b/examples/plugin-local-notif/DotEnv.cs @@ -2,6 +2,8 @@ namespace PluginLocalNotifDemo; public static class DotEnv { + public const string OneSignalAppIdPlaceholder = "your-onesignal-app-id"; + private static readonly Dictionary Values = new(); private static bool _loaded; @@ -54,4 +56,9 @@ var line in reader.ReadToEnd().Split('\n', StringSplitOptions.RemoveEmptyEntries } public static string Get(string key) => Values.TryGetValue(key, out var value) ? value : ""; + + public static string OneSignalAppId => Get("ONESIGNAL_APP_ID").Trim(); + + public static bool HasOneSignalAppId => + !string.IsNullOrWhiteSpace(OneSignalAppId) && OneSignalAppId != OneSignalAppIdPlaceholder; } diff --git a/examples/plugin-local-notif/MainPage.cs b/examples/plugin-local-notif/MainPage.cs index b3c2dea0..025281e1 100644 --- a/examples/plugin-local-notif/MainPage.cs +++ b/examples/plugin-local-notif/MainPage.cs @@ -1,3 +1,5 @@ +using System.Text; +using System.Text.Json; using OneSignalSDK.DotNet; using OneSignalSDK.DotNet.Core.Notifications; using OneSignalSDK.DotNet.Core.User; @@ -33,9 +35,12 @@ public MainPage() LineBreakMode = LineBreakMode.WordWrap, }; - OneSignal.Notifications.PermissionChanged += OnPermissionChanged; - OneSignal.User.PushSubscription.Changed += OnPushSubscriptionChanged; - OneSignal.User.Changed += OnUserChanged; + if (DotEnv.HasOneSignalAppId) + { + OneSignal.Notifications.PermissionChanged += OnPermissionChanged; + OneSignal.User.PushSubscription.Changed += OnPushSubscriptionChanged; + OneSignal.User.Changed += OnUserChanged; + } var requestOneSignalPermissionButton = new Button { @@ -44,6 +49,12 @@ public MainPage() }; requestOneSignalPermissionButton.Clicked += async (s, e) => { + if (!DotEnv.HasOneSignalAppId) + { + SetStatus("Set ONESIGNAL_APP_ID in .env before requesting OneSignal permission."); + return; + } + var granted = await OneSignal.Notifications.RequestPermissionAsync(true); SetStatus($"OneSignal permission granted: {granted}"); RefreshPermissionLabel(); @@ -88,6 +99,28 @@ 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(); + } + catch (Exception exception) + { + SetStatus($"OneSignal notification failed: {exception.Message}"); + } + finally + { + showOneSignalNotificationButton.IsEnabled = true; + } + }; + var clearButton = new Button { Text = "Clear Delivered Notifications", @@ -95,6 +128,12 @@ public MainPage() }; clearButton.Clicked += (s, e) => { + if (!DotEnv.HasOneSignalAppId) + { + SetStatus("Set ONESIGNAL_APP_ID in .env before clearing OneSignal notifications."); + return; + } + OneSignal.Notifications.ClearAllNotifications(); SetStatus("Cleared delivered notifications through OneSignal."); }; @@ -133,6 +172,7 @@ public MainPage() requestOneSignalPermissionButton, requestLocalPermissionButton, showLocalNotificationButton, + showOneSignalNotificationButton, clearButton, refreshPushInfoButton, _statusLabel, @@ -153,17 +193,28 @@ protected override void OnAppearing() private void RefreshPermissionLabel() { + if (!DotEnv.HasOneSignalAppId) + { + _permissionLabel.Text = "OneSignal permission: unavailable (app ID not set)"; + return; + } + _permissionLabel.Text = $"OneSignal permission: {OneSignal.Notifications.Permission}"; } private void RefreshPushInfoLabel() { + if (!DotEnv.HasOneSignalAppId) + { + _pushInfoLabel.Text = "OneSignal push subscription: unavailable (app ID not set)"; + return; + } + 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) @@ -185,6 +236,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 + { + ["app_id"] = DotEnv.OneSignalAppId, + ["headings"] = new Dictionary { ["en"] = "Simple Notification" }, + ["contents"] = new Dictionary + { + ["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") + ); + var responseJson = await response.Content.ReadAsStringAsync(); + + SetStatus( + response.IsSuccessStatusCode + ? $"OneSignal notification requested: {responseJson}" + : $"OneSignal notification failed: {responseJson}" + ); + } + private void SetStatus(string message) { MainThread.BeginInvokeOnMainThread(() => diff --git a/examples/plugin-local-notif/MauiProgram.cs b/examples/plugin-local-notif/MauiProgram.cs index 16002d69..e61bc674 100644 --- a/examples/plugin-local-notif/MauiProgram.cs +++ b/examples/plugin-local-notif/MauiProgram.cs @@ -6,26 +6,39 @@ namespace PluginLocalNotifDemo; public static class MauiProgram { - private const string DefaultAppId = "77e32082-ea27-42e3-a898-c72e141824ef"; - public static MauiApp CreateMauiApp() { var builder = MauiApp.CreateBuilder(); - builder.UseMauiApp().UseLocalNotification(); + builder + .UseMauiApp() + .UseLocalNotification(options => + { +#if IOS + options.AddiOS(ios => + { + ios.SetCustomUserNotificationCenterDelegate( + new OneSignalCompatibleNotificationDelegate() + ); + }); +#endif + }); var app = builder.Build(); DotEnv.Load(); - var envAppId = DotEnv.Get("ONESIGNAL_APP_ID"); - var appId = - string.IsNullOrWhiteSpace(envAppId) || envAppId == "your-onesignal-app-id" - ? DefaultAppId - : envAppId.Trim(); - OneSignal.Debug.LogLevel = OsLogLevel.VERBOSE; - OneSignal.Initialize(appId); + if (DotEnv.HasOneSignalAppId) + { + OneSignal.Initialize(DotEnv.OneSignalAppId); + } + else + { + System.Diagnostics.Debug.WriteLine( + "Set ONESIGNAL_APP_ID in .env to initialize OneSignal." + ); + } OneSignal.Notifications.WillDisplay += (s, e) => System.Diagnostics.Debug.WriteLine("OneSignal notification will display"); diff --git a/examples/plugin-local-notif/Platforms/iOS/OneSignalCompatibleNotificationDelegate.cs b/examples/plugin-local-notif/Platforms/iOS/OneSignalCompatibleNotificationDelegate.cs new file mode 100644 index 00000000..cf21c4e4 --- /dev/null +++ b/examples/plugin-local-notif/Platforms/iOS/OneSignalCompatibleNotificationDelegate.cs @@ -0,0 +1,87 @@ +using Foundation; +using Plugin.LocalNotification.Platforms; +using UserNotifications; + +namespace PluginLocalNotifDemo; + +public sealed class OneSignalCompatibleNotificationDelegate : UserNotificationCenterDelegate +{ + // OneSignal's swizzle can exchange method implementations on this class, so + // depending on which slot iOS invokes, either the normal overrides or the + // onesignal-prefixed exports receive the callback. Both route remote pushes + // to foreground presentation and leave local notifications to the plugin. + public override void WillPresentNotification( + UNUserNotificationCenter center, + UNNotification notification, + Action completionHandler + ) + { + if (HandledAsRemotePush(notification, completionHandler)) + return; + + base.WillPresentNotification(center, notification, completionHandler); + } + + public override void DidReceiveNotificationResponse( + UNUserNotificationCenter center, + UNNotificationResponse response, + Action completionHandler + ) + { + if (response.Notification.Request.Trigger is UNPushNotificationTrigger) + { + completionHandler(); + return; + } + + base.DidReceiveNotificationResponse(center, response, completionHandler); + } + + [Export("onesignalUserNotificationCenter:willPresentNotification:withCompletionHandler:")] + public void OneSignalWillPresentNotification( + UNUserNotificationCenter center, + UNNotification notification, + Action completionHandler + ) + { + if (HandledAsRemotePush(notification, completionHandler)) + return; + + base.WillPresentNotification(center, notification, completionHandler); + } + + [Export( + "onesignalUserNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:" + )] + public void OneSignalDidReceiveNotificationResponse( + UNUserNotificationCenter center, + UNNotificationResponse response, + Action completionHandler + ) + { + if (response.Notification.Request.Trigger is UNPushNotificationTrigger) + { + completionHandler(); + return; + } + + base.DidReceiveNotificationResponse(center, response, completionHandler); + } + + private static bool HandledAsRemotePush( + UNNotification notification, + Action completionHandler + ) + { + if (notification.Request.Trigger is not UNPushNotificationTrigger) + return false; + + completionHandler( + UNNotificationPresentationOptions.Banner + | UNNotificationPresentationOptions.List + | UNNotificationPresentationOptions.Sound + | UNNotificationPresentationOptions.Badge + ); + return true; + } +} diff --git a/examples/plugin-local-notif/README.md b/examples/plugin-local-notif/README.md index bc7a0194..03f125be 100644 --- a/examples/plugin-local-notif/README.md +++ b/examples/plugin-local-notif/README.md @@ -3,6 +3,11 @@ Minimal .NET MAUI app for investigating the iOS interaction reported in [GitHub issue #132](https://github.com/OneSignal/OneSignal-DotNet-SDK/issues/132). +This example is a temporary compatibility workaround, not a first-party +OneSignal local notification implementation. OneSignal hopes to support local +notifications directly in a future SDK release, at which point that support +should replace this delegate shim. No release timeline is currently committed. + ## What This Reproduces Issue #132 reports an iOS crash when `OneSignalSDK.DotNet` and @@ -23,7 +28,10 @@ onesignalUserNotificationCenter:willPresentNotification:withCompletionHandler: Both failures point at the same interaction: OneSignal's native iOS SDK swizzles `UNUserNotificationCenterDelegate` methods, while `Plugin.LocalNotification` -installs its own delegate that only implements the normal Apple selectors. +installs its own delegate that only implements the normal Apple selectors. This +sample registers a small iOS delegate shim that exports the OneSignal-prefixed +selectors and overrides the plugin's normal handlers. Remote pushes are shown as +foreground banners; local notifications keep the plugin's behavior. ## Run @@ -31,6 +39,7 @@ From this directory, run iOS: ```sh cp .env.example .env +# Set ONESIGNAL_APP_ID in .env before launching. ./run-ios.sh ``` @@ -50,16 +59,29 @@ devices. 3. Tap `Request LocalNotification Permission`. 4. Tap `Show Local Notification` while the app is foregrounded. 5. If the notification is delivered, tap it from Notification Center. -6. Watch device logs for the `onesignalUserNotificationCenter:*` selector crash. +6. Send a OneSignal push while the app is foregrounded, then send another while + the app is backgrounded and tap it from Notification Center. +7. Watch device logs to confirm the `onesignalUserNotificationCenter:*` selector + paths no longer crash. + +To reproduce the original issue #132 crash, remove the +`OneSignalCompatibleNotificationDelegate` registration from `MauiProgram.cs` and +delete `Platforms/iOS/OneSignalCompatibleNotificationDelegate.cs`, then repeat +the iOS push steps. -The bundled app ID matches the main demo app's default ID when -`ONESIGNAL_APP_ID` is missing or still set to the placeholder. To test against a -different OneSignal app, set `ONESIGNAL_APP_ID` in `.env`. +Set `ONESIGNAL_APP_ID` in `.env` before running the sample. The app does not +fall back to a built-in OneSignal app id. ## Notes The native OneSignal iOS SDK now documents disabling swizzling via `OneSignal_disable_swizzling` and manually forwarding notification delegate methods. This .NET binding currently does not expose the newer manual forwarding -APIs, so this sample keeps swizzling enabled and demonstrates the reported -interaction directly. +APIs, so this sample keeps swizzling enabled and uses the delegate shim as a +local compatibility workaround. + +Because OneSignal's swizzle exchanges implementations on the shim class, the +normal delegate callbacks bypass OneSignal's own notification processing. With +this workaround, OneSignal iOS foreground lifecycle events (`WillDisplay`, +`Clicked`) may not fire; the durable fix is exposing the manual forwarding APIs +in the binding.