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
14 changes: 12 additions & 2 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ jobs:
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true &&
contains(github.event.pull_request.title, 'chore: Release'))
runs-on: macos-latest
# OneSignalSDK.DotNet.csproj adds the net10.0-ios TFM on macOS hosts,
# so the publish build needs an Xcode that satisfies the .NET 10 iOS
# workload. The current workload set (10.0.300+) requires Xcode 26.4,
# which only ships on macos-26 (default Xcode 26.4.1). macos-latest
# still maps to macos-15 with Xcode 26.3 until June 15, 2026.
runs-on: macos-26
outputs:
version: ${{ steps.version.outputs.version }}
steps:
Expand All @@ -41,7 +46,12 @@ jobs:
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"


- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: NuGet login
uses: nuget/login@v1
id: login
Expand Down
19 changes: 11 additions & 8 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ jobs:
run: |
echo "ONESIGNAL_APP_ID=${{ vars.APPIUM_ONESIGNAL_APP_ID }}" > .env
echo "ONESIGNAL_API_KEY=${{ secrets.APPIUM_ONESIGNAL_API_KEY }}" >> .env
echo "E2E_MODE=true" >> .env

# RuntimeIdentifier=android-arm64 ships native code only for arm64-v8a,
# which is what BrowserStack's modern Android device farm runs. This
Expand Down Expand Up @@ -97,7 +96,13 @@ jobs:
github.event_name == 'push' ||
github.event.inputs.platform == 'ios' ||
github.event.inputs.platform == 'both'
runs-on: macos-latest
# macOS 15 runners only ship up to Xcode 26.3, but the .NET 10 iOS
# workload set 10.0.300+ pulls Microsoft.iOS.Sdk.net10.0_26.4 which
# requires Xcode 26.4 ("This version of .NET for iOS (26.4.x) requires
# Xcode 26.4"). macos-26 ships Xcode 26.4.1 as the default, so we pin
# to it explicitly until macos-latest migrates (scheduled for June 15,
# 2026 per the GitHub Actions changelog).
runs-on: macos-26
steps:
- name: Checkout
uses: actions/checkout@v5
Expand All @@ -107,11 +112,10 @@ jobs:
with:
dotnet-version: '10.0.x'

# The .NET 10 iOS workload (Microsoft.iOS.Sdk.net10.0_26.2) requires
# Xcode 26.3+. macOS runners default to an older Xcode (16.4 at the
# time of writing), which fails with "This version of .NET for iOS
# requires Xcode 26.3". latest-stable picks the newest installed Xcode
# and avoids hardcoding a version that may roll forward.
# latest-stable resolves to the newest non-prerelease Xcode on the
# runner (26.4.1 on macos-26 today), which keeps us in sync with
# whatever the .NET iOS workload requires without hardcoding a
# version that may roll forward.
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
Expand All @@ -130,7 +134,6 @@ jobs:
run: |
echo "ONESIGNAL_APP_ID=${{ vars.APPIUM_ONESIGNAL_APP_ID }}" > .env
echo "ONESIGNAL_API_KEY=${{ secrets.APPIUM_ONESIGNAL_API_KEY }}" >> .env
echo "E2E_MODE=true" >> .env

- name: Set up iOS codesigning
uses: OneSignal/sdk-shared/.github/actions/setup-ios-demo-codesigning@main
Expand Down
Binary file modified OneSignalSDK.DotNet.Android.Core.Binding/Jars/core-release.aar
Binary file not shown.
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion OneSignalSDK.DotNet.nuspec
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd">
<metadata>
<version>6.1.7</version>
<version>6.1.8</version>
<id>OneSignalSDK.DotNet</id>
<title>OneSignal SDK for .NET and MAUI</title>
<authors>OneSignal</authors>
Expand Down
1 change: 0 additions & 1 deletion examples/demo/.env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Default App ID (used when ONESIGNAL_APP_ID is empty or missing): 77e32082-ea27-42e3-a898-c72e141824ef
ONESIGNAL_APP_ID=your-onesignal-app-id
ONESIGNAL_API_KEY=your_rest_api_key
E2E_MODE=false

# Optional: Android Notification Channel ID for the WITH SOUND test notification.
# Create one in your OneSignal dashboard under Settings > Android Notification Categories.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
<IsAppExtension>true</IsAppExtension>
<ApplicationId>com.onesignal.example.NSE</ApplicationId>
<CodesignEntitlements>Entitlements.plist</CodesignEntitlements>
<!--
Must match (or be lower than) the host app's MinimumOSVersion. Without
this, MSBuild defaults to the current iOS SDK (e.g. 26.2), which is
higher than the main app's 14.2, and iOS refuses to bind the extension
("No service extension record found for app" / UNErrorDomain 1904),
silently dropping mutable-content pushes — i.e. no image attachments.
-->
<SupportedOSPlatformVersion>14.2</SupportedOSPlatformVersion>
</PropertyGroup>

<!--
Expand Down
23 changes: 23 additions & 0 deletions examples/demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,26 @@ The app ships with a placeholder OneSignal App ID (`77e32082-ea27-42e3-a898-c72e
```csharp
private const string AppId = "<your-app-id>";
```

## Troubleshooting

### iOS: "requires Xcode X.Y" build error

If `dotnet build -f net10.0-ios` aborts with:

```
error : This version of .NET for iOS (X.Y.NNNNN) requires Xcode X.Y. The current version of Xcode is Z.Z.
```

your active Xcode and the installed `Microsoft.iOS.Sdk.net10.0_X.Y` workload pack are from different release bands. Each pack is bound to the iOS SDK that ships inside a specific Xcode (Xcode 26.3 also ships the iOS 26.2 SDK, so the `_26.2` pack accepts either). Two ways out:

- **Workload is older than your Xcode** (e.g. workload `_26.2` + Xcode 26.5): bump the workload set so it picks up the newest iOS SDK pack.
```sh
dotnet workload update
```
- **Xcode is older than the workload, or you keep multiple Xcodes around** (e.g. workload `_26.4` + Xcode 26.3): side-install a matching Xcode under `/Applications/Xcode_<version>.app` and point only this build at it via `DEVELOPER_DIR`. Your global `xcode-select -p` stays untouched, so simulators booted against the default Xcode keep working.
```sh
DEVELOPER_DIR="/Applications/Xcode_26.4.app/Contents/Developer" ./run-ios.sh
```

The repo's CI pins `runs-on: macos-26` for the same reason: it's the GitHub-hosted runner that currently ships an Xcode (26.4.1) compatible with the latest .NET 10 iOS workload. See [aka.ms/xcode-requirement](https://aka.ms/xcode-requirement) for the current compatibility matrix.
66 changes: 46 additions & 20 deletions examples/demo/Services/OneSignalApiService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using OneSignalDemo.Models;
Expand Down Expand Up @@ -120,43 +121,60 @@ private async Task<bool> SendAsync(

var json = JsonSerializer.Serialize(payload);

const int maxAttempts = 3;
const int maxAttempts = 5;

// Retry on `invalid_player_ids` to absorb the brief race where the
// subscription has been created locally but is not yet visible to the
// /notifications endpoint.
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync(
"https://onesignal.com/api/v1/notifications",
content
);
var responseJson = await response.Content.ReadAsStringAsync();
try
{
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync(
"https://onesignal.com/api/v1/notifications",
content
);
var responseJson = await response.Content.ReadAsStringAsync();

if (!response.IsSuccessStatusCode)
return false;
if (!response.IsSuccessStatusCode)
{
Debug.WriteLine($"Send notification failed: {responseJson}");
return false;
}

if (HasInvalidPlayerIds(responseJson))
{
if (attempt < maxAttempts)
var invalidIds = GetInvalidPlayerIds(responseJson);
if (invalidIds.Count > 0)
{
await Task.Delay(3000 * attempt);
continue;
if (attempt < maxAttempts)
{
var delayMs = 2000 * (1 << (attempt - 1));
await Task.Delay(delayMs);
continue;
}
Debug.WriteLine(
$"Send notification failed: invalid_player_ids [{string.Join(", ", invalidIds)}]"
);
return false;
}

return true;
}
catch (Exception err)
{
Debug.WriteLine($"Send notification error: {err}");
return false;
}

return true;
}

return false;
}

private static bool HasInvalidPlayerIds(string responseJson)
private static List<string> GetInvalidPlayerIds(string responseJson)
{
var result = new List<string>();
if (string.IsNullOrWhiteSpace(responseJson))
return false;
return result;
try
{
using var doc = JsonDocument.Parse(responseJson);
Expand All @@ -168,14 +186,22 @@ private static bool HasInvalidPlayerIds(string responseJson)
&& invalidIds.ValueKind == JsonValueKind.Array
)
{
return invalidIds.GetArrayLength() > 0;
foreach (var id in invalidIds.EnumerateArray())
{
if (id.ValueKind == JsonValueKind.String)
{
var s = id.GetString();
if (!string.IsNullOrEmpty(s))
result.Add(s);
}
}
}
}
catch
{
// Ignore malformed bodies; treat as success since status was 2xx.
}
return false;
return result;
}

public async Task<UserData?> FetchUserAsync(string onesignalId)
Expand Down
55 changes: 39 additions & 16 deletions examples/demo/ViewModels/AppViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public partial class AppViewModel : ObservableObject
[ObservableProperty]
private string _loginButtonText = "LOGIN USER";

[ObservableProperty]
private string _oneSignalId = "—";

// Push section
[ObservableProperty]
private string _pushSubscriptionId = "—";
Expand Down Expand Up @@ -92,9 +95,6 @@ private static readonly (string Status, string Message, string EstimatedTime)[]
public ObservableCollection<KeyValuePair<string, string>> TagsList { get; } = new();
public ObservableCollection<KeyValuePair<string, string>> TriggersList { get; } = new();

public static bool IsE2EMode =>
string.Equals(DotEnv.Get("E2E_MODE"), "true", StringComparison.OrdinalIgnoreCase);

public AppViewModel(PreferencesService prefs, OneSignalApiService apiService)
{
_prefs = prefs;
Expand All @@ -105,19 +105,17 @@ public AppViewModel(PreferencesService prefs, OneSignalApiService apiService)
OneSignal.User.Changed += OnUserChanged;
}

private static string MaskValue(string value) =>
IsE2EMode && value != "—" ? new string('\u2022', value.Length) : value;
private static string FormatPushId(string? value, bool hasNotificationPermission) =>
hasNotificationPermission && !string.IsNullOrEmpty(value) ? value : "—";

private static string MaskPushId(string? value, bool hasNotificationPermission) =>
hasNotificationPermission ? MaskValue(string.IsNullOrEmpty(value) ? "—" : value) : "—";
private static string FormatId(string? value) => string.IsNullOrEmpty(value) ? "—" : value;

private static string FormatToken(string? value) =>
string.IsNullOrEmpty(value) ? "null" : $"{value[..Math.Min(8, value.Length)]}...";

public async Task LoadInitialStateAsync()
{
var rawAppId = _apiService.GetAppId();
AppId = MaskValue(rawAppId);
AppId = _apiService.GetAppId();
ConsentRequired = _prefs.ConsentRequired;
PrivacyConsentGiven = _prefs.PrivacyConsent;
InAppMessagesPaused = OneSignal.InAppMessages.Paused;
Expand All @@ -127,11 +125,14 @@ public async Task LoadInitialStateAsync()
var extId = OneSignal.User.ExternalId ?? _prefs.ExternalUserId;
UpdateUserStatus(extId);

var rawPushId = OneSignal.User.PushSubscription.Id;
PushSubscriptionId = MaskPushId(rawPushId, HasNotificationPermission);
PushSubscriptionId = FormatPushId(
OneSignal.User.PushSubscription.Id,
HasNotificationPermission
);
IsPushEnabled = OneSignal.User.PushSubscription.OptedIn;

var onesignalId = OneSignal.User.OneSignalId;
OneSignalId = FormatId(onesignalId);
if (!string.IsNullOrEmpty(onesignalId))
{
await FetchUserDataFromApiAsync();
Expand Down Expand Up @@ -606,7 +607,7 @@ private void OnPushSubscriptionChanged(object? sender, PushSubscriptionChangedEv
{
var previous = args.State.Previous;
var current = args.State.Current;
PushSubscriptionId = MaskPushId(current.Id, HasNotificationPermission);
PushSubscriptionId = FormatPushId(current.Id, HasNotificationPermission);
IsPushEnabled = current.OptedIn;
Debug.WriteLine(
$"Push subscription changed: id={previous.Id} -> {current.Id}, optedIn={previous.OptedIn} -> {current.OptedIn}, token={FormatToken(previous.Token)} -> {FormatToken(current.Token)}"
Expand All @@ -622,7 +623,10 @@ OneSignalSDK.DotNet.Core.Notifications.NotificationPermissionChangedEventArgs ar
MainThread.BeginInvokeOnMainThread(() =>
{
HasNotificationPermission = args.Permission;
PushSubscriptionId = MaskPushId(OneSignal.User.PushSubscription.Id, HasNotificationPermission);
PushSubscriptionId = FormatPushId(
OneSignal.User.PushSubscription.Id,
HasNotificationPermission
);
Debug.WriteLine($"Permission changed: {args.Permission}");
});
}
Expand All @@ -634,9 +638,28 @@ OneSignalSDK.DotNet.Core.User.UserStateChangedEventArgs args
{
MainThread.BeginInvokeOnMainThread(async () =>
{
var extId = _prefs.ExternalUserId;
UpdateUserStatus(extId);
Debug.WriteLine($"User changed: externalId={extId}");
// IUserState surfaces empty strings (not null) when an ID has not
// been assigned yet, so normalize to null for logging parity with
// the TS demo's `onesignalId=null, externalId=null` format.
var nextOneSignalId = string.IsNullOrEmpty(args.State.Current.OneSignalId)
? null
: args.State.Current.OneSignalId;
var nextExternalId = string.IsNullOrEmpty(args.State.Current.ExternalId)
? null
: args.State.Current.ExternalId;

Debug.WriteLine(
$"User changed: onesignalId={nextOneSignalId ?? "null"}, externalId={nextExternalId ?? "null"}"
);

OneSignalId = FormatId(nextOneSignalId);
UpdateUserStatus(nextExternalId ?? _prefs.ExternalUserId);

// Skip the API fetch until the backend has assigned a OneSignal ID;
// /users/by/onesignal_id/ would 404 otherwise.
if (nextOneSignalId == null)
return;

await FetchUserDataFromApiAsync();
});
}
Expand Down
2 changes: 1 addition & 1 deletion versions.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"android": "5.8.1",
"android": "5.9.2",
"ios": "5.5.1"
}
Loading