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
191 changes: 191 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
name: E2E Tests

on:
push:
branches:
- rel/**
workflow_dispatch:
inputs:
platform:
description: 'Platform to test'
required: true
default: 'both'
type: choice
options:
- android
- ios
- both

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build-android:
if: >-
github.event_name == 'push' ||
github.event.inputs.platform == 'android' ||
github.event.inputs.platform == 'both'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '17'

- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'

- name: Install MAUI Android workload
run: dotnet workload install maui-android

- name: Create demo .env
working-directory: examples/demo
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
# roughly halves the APK size (~30MB → ~19MB) versus the default
# multi-ABI build.
- name: Build release APK
working-directory: examples/demo
run: |
dotnet publish demo.csproj \
-f net10.0-android \
-c Release \
-p:AndroidPackageFormat=apk \
-p:RuntimeIdentifier=android-arm64

- name: Locate APK
id: apk
working-directory: examples/demo
run: |
APK=$(ls bin/Release/net10.0-android/android-arm64/*-Signed.apk | head -n1)
if [ -z "$APK" ]; then
echo "::error::No signed APK produced by dotnet publish"
exit 1
fi
cp "$APK" demo.apk
echo "path=examples/demo/demo.apk" >> "$GITHUB_OUTPUT"

- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: demo-apk
path: ${{ steps.apk.outputs.path }}
retention-days: 1
compression-level: 0

build-ios:
if: >-
github.event_name == 'push' ||
github.event.inputs.platform == 'ios' ||
github.event.inputs.platform == 'both'
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'

- name: Install MAUI iOS workload
run: dotnet workload install maui-ios

- name: Create demo .env
working-directory: examples/demo
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
with:
p12-base64: ${{ secrets.APPIUM_IOS_DEV_CERT_P12_BASE64 }}
p12-password: ${{ secrets.APPIUM_IOS_DEV_CERT_PASSWORD }}
asc-key-id: ${{ secrets.APPIUM_APP_STORE_CONNECT_KEY_ID }}
asc-issuer-id: ${{ secrets.APPIUM_APP_STORE_CONNECT_ISSUER_ID }}
asc-private-key: ${{ secrets.APPIUM_APP_STORE_CONNECT_PRIVATE_KEY }}

# RuntimeIdentifier=ios-arm64 is required for a real device build that
# BrowserStack can install.
# Do NOT add -p:UseInterpreter=true here. It shrinks the IPA but breaks
# the Notification Service Extension (image notifications stop working)
# because the interpreter path drops the ObjC class registration for
# the NSE binding in OneSignalSDK.DotNet.iOS.
- name: Build signed IPA
working-directory: examples/demo
run: |
dotnet publish demo.csproj \
-f net10.0-ios \
-c Release \
-p:RuntimeIdentifier=ios-arm64 \
-p:ArchiveOnBuild=true

- name: Locate IPA
id: ipa
working-directory: examples/demo
run: |
IPA=$(ls bin/Release/net10.0-ios/ios-arm64/publish/*.ipa | head -n1)
if [ -z "$IPA" ]; then
echo "::error::No IPA produced by dotnet publish"
exit 1
fi
cp "$IPA" demo.ipa
echo "path=examples/demo/demo.ipa" >> "$GITHUB_OUTPUT"

- name: Verify aps-environment in IPA
working-directory: examples/demo
run: |
unzip -oq demo.ipa -d /tmp/ipa
APP=$(ls -d /tmp/ipa/Payload/*.app | head -n1)
codesign -d --entitlements - "$APP" 2>&1 | tee /tmp/entitlements.txt
if ! grep -q 'aps-environment' /tmp/entitlements.txt; then
echo "::error::Built IPA is missing aps-environment entitlement; push subscription will not work"
exit 1
fi

- name: Upload IPA
uses: actions/upload-artifact@v4
with:
name: demo-ipa
path: ${{ steps.ipa.outputs.path }}
retention-days: 1
compression-level: 0

e2e-android:
needs: build-android
uses: OneSignal/sdk-shared/.github/workflows/appium-e2e.yml@main
secrets: inherit
with:
platform: android
app-artifact: demo-apk
app-filename: demo.apk
sdk-type: dotnet
build-name: dotnet-android-${{ github.ref_name }}-${{ github.run_number }}

e2e-ios:
needs: build-ios
uses: OneSignal/sdk-shared/.github/workflows/appium-e2e.yml@main
secrets: inherit
with:
platform: ios
app-artifact: demo-ipa
app-filename: demo.ipa
sdk-type: dotnet
build-name: dotnet-ios-${{ github.ref_name }}-${{ github.run_number }}
44 changes: 40 additions & 4 deletions OneSignalSDK.DotNet.Android/Utilities/ToNativeConversion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,21 @@ public static Com.OneSignal.Android.Debug.LogLevel ToLogLevel(LogLevel logLevel)
var javaMap = new Dictionary<string, Java.Lang.Object>();
foreach (var kvp in dict)
{
if (kvp.Value == null)
{
javaMap[kvp.Key] = null!;
continue;
}

var javaValue = ToJavaObject(kvp.Value);
if (javaValue != null)
javaMap[kvp.Key] = javaValue;
if (javaValue == null)
// ToJavaObject returns null for types it doesn't know how to
// marshal (e.g. a POCO or DateTime in a tags dict). Drop the
// entry silently to mirror the iOS path; the caller will
// notice the missing value in OneSignal and fix the input.
continue;

javaMap[kvp.Key] = javaValue;
}
return javaMap;
}
Comment thread
fadi-george marked this conversation as resolved.
Expand All @@ -117,7 +129,18 @@ public static Com.OneSignal.Android.Debug.LogLevel ToLogLevel(LogLevel logLevel)
var javaMap = new Java.Util.HashMap();
foreach (var kvp in dict)
{
javaMap.Put(kvp.Key, ToJavaObject(kvp.Value));
if (kvp.Value == null)
{
javaMap.Put(kvp.Key, null);
continue;
}

var javaValue = ToJavaObject(kvp.Value);
if (javaValue == null)
// Unsupported value type - skip silently (see DictToJavaMap).
continue;

javaMap.Put(kvp.Key, javaValue);
}
return javaMap;
}
Expand All @@ -130,7 +153,20 @@ public static Com.OneSignal.Android.Debug.LogLevel ToLogLevel(LogLevel logLevel)
var javaList = new Java.Util.ArrayList();
foreach (var item in list)
{
javaList.Add(ToJavaObject(item));
if (item == null)
{
javaList.Add(null);
continue;
}

var javaValue = ToJavaObject(item);
if (javaValue == null)
// Unsupported item type - skip silently (see DictToJavaMap).
// Index alignment with the source list isn't preserved; the
// caller is expected to use supported types throughout.
continue;

javaList.Add(javaValue);
}
return javaList;
}
Expand Down
33 changes: 12 additions & 21 deletions examples/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ SDK reference via project reference:

---

## OneSignal Repository (SDK API Mapping)
## SDK API Mapping

Use the static `OneSignal` class from `OneSignalSDK.DotNet`:
Call the static `OneSignal` class from `OneSignalSDK.DotNet` directly inside `AppViewModel`. There is no repository wrapper.

| Operation | SDK Call |
|---|---|
Expand Down Expand Up @@ -178,13 +178,14 @@ AppViewModel extends `ObservableObject` (CommunityToolkit.Mvvm):
- `[ObservableProperty]` fields generate properties + INotifyPropertyChanged
- `[RelayCommand]` methods for actions
- `ObservableCollection<T>` for list state (AliasesList, EmailsList, SmsNumbersList, TagsList, TriggersList)
- Receives `OneSignalRepository` and `PreferencesService` via constructor injection
- Receives `PreferencesService` and `OneSignalApiService` via constructor injection
- Calls `OneSignal.*` static APIs directly (no repository layer)

Register with MAUI DI container:
```csharp
builder.Services.AddSingleton<AppViewModel>();
builder.Services.AddSingleton<OneSignalRepository>();
builder.Services.AddSingleton<PreferencesService>();
builder.Services.AddSingleton<OneSignalApiService>();
```

Persistence: `Microsoft.Maui.Storage.Preferences`
Expand All @@ -196,8 +197,7 @@ Persistence: `Microsoft.Maui.Storage.Preferences`
XAML controls in `Controls/`:
- `SectionCard.xaml` — Frame/Border with title Label, optional info ImageButton, ContentView child (BindableProperty)
- `ToggleRow.xaml` — Label + description Label + Switch in a Grid with IsToggled two-way binding
- `LoadingOverlay.xaml` — AbsoluteLayout overlay with centered ActivityIndicator, IsVisible bound to IsLoading
- `LogView.xaml` — sticky at top, VerticalStackLayout inside ScrollView (not CollectionView), default expanded, Material icons via `mi:MauiIcon` (ExpandLess/ExpandMore for collapse toggle, Delete for clear), auto-scroll via `ScrollView.ScrollToAsync`
- `LoadingState.xaml` — Inline ActivityIndicator shown by list-bearing sections (Aliases, Emails, SMS, Tags) while `AppViewModel.IsLoading` is true and the list is empty

Button styles defined in `App.xaml` (PrimaryButton style, DestructiveButton style), not separate controls.

Expand All @@ -223,14 +223,9 @@ Implement in `Resources/Styles/`:

---

## Log View
## Logging

- Use `VerticalStackLayout` inside `ScrollView` (100dp container is small, CollectionView is overkill)
- Material icons via `mi:MauiIcon`: `Delete` for trash, `ExpandLess`/`ExpandMore` for collapse toggle
- Collapse/expand toggled in code-behind: `CollapseArrow.Icon = MaterialIcons.ExpandLess/ExpandMore`
- AutomationId on each element (e.g. `AutomationId="log_entry_0_message"`)
- LogManager singleton uses `INotifyPropertyChanged` or event for reactive updates
- Console output via `Debug.WriteLine`
Use `System.Diagnostics.Debug.WriteLine` for debug logging; do not build a custom in-app log viewer.

---

Expand Down Expand Up @@ -275,20 +270,16 @@ examples/demo/
├── Services/
│ ├── OneSignalApiService.cs # REST API client (HttpClient)
│ ├── PreferencesService.cs # Preferences wrapper
│ ├── TooltipHelper.cs # Fetches tooltips from remote URL
│ └── LogManager.cs # Singleton logger with INotifyPropertyChanged
├── Repositories/
│ └── OneSignalRepository.cs # Centralized SDK calls
│ └── TooltipHelper.cs # Fetches tooltips from remote URL
├── ViewModels/
│ └── AppViewModel.cs # ObservableObject with all UI state
├── Pages/
│ ├── MainPage.xaml / MainPage.xaml.cs # Main scrollable page (includes LogView)
│ ├── MainPage.xaml / MainPage.xaml.cs # Main scrollable page
│ └── SecondaryPage.xaml / .cs # "Secondary Activity" page
├── Controls/
│ ├── SectionCard.xaml # Card with title and info icon
│ ├── ToggleRow.xaml # Label + Switch
│ ├── LoadingOverlay.xaml # Full-screen loading spinner
│ ├── LogView.xaml # Collapsible log viewer (Appium-ready)
│ ├── LoadingState.xaml # Inline list loading spinner
│ └── Sections/
│ ├── AppSection.xaml
│ ├── PushSection.xaml
Expand All @@ -301,7 +292,7 @@ examples/demo/
│ ├── TagsSection.xaml
│ ├── OutcomesSection.xaml
│ ├── TriggersSection.xaml
│ ├── TrackEventSection.xaml
│ ├── CustomEventsSection.xaml
│ └── LocationSection.xaml
├── Resources/
│ ├── Styles/
Expand Down
3 changes: 3 additions & 0 deletions examples/demo/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
# 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
12 changes: 9 additions & 3 deletions examples/demo/Controls/DialogInputHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ public static class DialogInputHelper
Key = "value",
Placeholder = placeholder,
AutomationId = entryAutomationId,
Keyboard = keyboard ?? Keyboard.Default,
Keyboard = keyboard ?? Keyboard.Plain,
},
},
confirmText,
confirmAutomationId
confirmAutomationId ?? "singleinput_confirm_button"
);
return result != null && result.TryGetValue("value", out var value) ? value : null;
}
Expand Down Expand Up @@ -93,7 +93,7 @@ public static class DialogInputHelper
title,
row,
confirmText,
confirmAutomationId,
confirmAutomationId ?? "singlepair_confirm_button",
() =>
new Dictionary<string, string>(StringComparer.Ordinal)
{
Expand Down Expand Up @@ -181,12 +181,18 @@ Func<Dictionary<string, string>> getResult
return result?.Result;
}

// IsTextPredictionEnabled / IsSpellCheckEnabled disabled to drop iOS's
// QuickType prediction strip (frees vertical space above the keyboard)
// and avoid autocorrect mangling test input like `dotnet_ios`. Mirrors
// react-native-onesignal/examples/demo/src/theme.ts > AppInputProps.
private static Entry BuildEntry(DialogInputField field) =>
new()
{
Placeholder = field.Placeholder,
AutomationId = field.AutomationId ?? string.Empty,
Keyboard = field.Keyboard,
IsTextPredictionEnabled = false,
IsSpellCheckEnabled = false,
};

internal static Button ActionButton(string text, string? automationId = null) =>
Expand Down
Loading
Loading