Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c3313e5
test(demo): prepare e2e testing infrastructure
fadi-george Apr 15, 2026
00d9a0d
refactor(demo): centralize toast notifications
fadi-george Apr 15, 2026
54a92dc
ci: add E2E testing workflow with Appium
fadi-george Apr 15, 2026
cf49f5e
chore(demo): bump react-native-onesignal to 5.4.3
fadi-george Apr 15, 2026
cdda732
chore(demo): enable ccache and optimize setup script
fadi-george Apr 15, 2026
db97d94
ci: add caching for iOS builds and dependencies
fadi-george Apr 15, 2026
fab3776
ci: upgrade cache action and simplify cache keys
fadi-george Apr 15, 2026
4aac4ad
chore(demo): limit android build to arm64-v8a
fadi-george Apr 15, 2026
437c4ab
ci: add real iOS codesigning for e2e builds
fadi-george Apr 20, 2026
44c7895
refactor(demo): replace AppContext with useOneSignal hook
fadi-george Apr 20, 2026
40339b1
refactor(demo): replace LoadingOverlay with inline loading states
fadi-george Apr 20, 2026
f0b7833
refactor(demo): remove OneSignalRepository layer
fadi-george Apr 20, 2026
8743251
refactor(demo): add isReady flag to OneSignal init
fadi-george Apr 20, 2026
a2f5843
fix(android): track Activity early to fix cold-start init
fadi-george Apr 21, 2026
45c0920
style(demo): clarify action button labels
fadi-george Apr 21, 2026
2b06ee0
style(demo): add icon to Add Row button
fadi-george Apr 21, 2026
9717a54
refactor(demo): standardize multipair testIDs
fadi-george Apr 21, 2026
797af4d
chore(demo): add custom AppHeader component
fadi-george Apr 21, 2026
ba9e1d4
chore(demo): update iOS project and lockfile
fadi-george Apr 21, 2026
3060b69
style(android): reformat debug log statement
fadi-george Apr 21, 2026
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
60 changes: 60 additions & 0 deletions .github/actions/setup-demo/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: 'Setup Demo'
description: 'Installs toolchains, builds the SDK, sets up the demo app, and creates the .env file'
inputs:
onesignal-app-id:
description: 'OneSignal App ID for the demo .env'
required: true
onesignal-api-key:
description: 'OneSignal API Key for the demo .env'
required: true
install-pods:
description: 'Whether to run pod update for iOS'
required: false
default: 'false'
runs:
using: 'composite'
steps:
- name: Set up Vite+
uses: voidzero-dev/setup-vp@v1
with:
cache: true
run-install: true

- name: Set up Bun
uses: oven-sh/setup-bun@v2

- name: Cache bun dependencies
uses: actions/cache@v5
with:
path: examples/demo/node_modules
key: bun-${{ runner.os }}-${{ hashFiles('examples/demo/bun.lock') }}
restore-keys: bun-${{ runner.os }}-

- name: Install and set up demo
shell: bash
working-directory: examples/demo
run: |
bun run setup
Comment thread
fadi-george marked this conversation as resolved.
bun install

- name: Cache CocoaPods
if: inputs.install-pods == 'true'
uses: actions/cache@v5
with:
path: examples/demo/ios/Pods
key: pods-${{ runner.os }}-${{ hashFiles('examples/demo/ios/Podfile.lock') }}
restore-keys: pods-${{ runner.os }}-

- name: Update CocoaPods
if: inputs.install-pods == 'true'
shell: bash
working-directory: examples/demo
run: bun run update:pods

- name: Create demo .env
shell: bash
working-directory: examples/demo
run: |
echo "ONESIGNAL_APP_ID=${{ inputs.onesignal-app-id }}" > .env
echo "ONESIGNAL_API_KEY=${{ inputs.onesignal-api-key }}" >> .env
echo "E2E_MODE=true" >> .env
152 changes: 152 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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@v6

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

- name: Set up demo
uses: ./.github/actions/setup-demo
with:
onesignal-app-id: ${{ vars.APPIUM_ONESIGNAL_APP_ID }}
onesignal-api-key: ${{ secrets.APPIUM_ONESIGNAL_API_KEY }}

- name: Build release APK
working-directory: examples/demo/android
run: ./gradlew assembleRelease

- name: Upload APK
uses: actions/upload-artifact@v7
Comment thread
fadi-george marked this conversation as resolved.
with:
name: demo-apk
path: examples/demo/android/app/build/outputs/apk/release/app-release.apk
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@v6

- name: Set up demo
uses: ./.github/actions/setup-demo
with:
onesignal-app-id: ${{ vars.APPIUM_ONESIGNAL_APP_ID }}
onesignal-api-key: ${{ secrets.APPIUM_ONESIGNAL_API_KEY }}
install-pods: 'true'
Comment thread
fadi-george marked this conversation as resolved.

- name: Cache Xcode DerivedData
uses: actions/cache@v5
with:
path: examples/demo/ios/build
key: deriveddata-${{ runner.os }}-${{ hashFiles('examples/demo/ios/Podfile.lock') }}
restore-keys: deriveddata-${{ runner.os }}-

- 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 }}

- name: Build signed IPA
working-directory: examples/demo/ios
run: |
xcodebuild archive \
-workspace demo.xcworkspace \
-scheme demo \
-configuration Release \
-sdk iphoneos \
-destination 'generic/platform=iOS' \
-archivePath build/demo.xcarchive \
-derivedDataPath build \
CODE_SIGN_STYLE=Manual \
COMPILER_INDEX_STORE_ENABLE=NO
xcodebuild -exportArchive \
-archivePath build/demo.xcarchive \
-exportOptionsPlist ExportOptions.plist \
-exportPath build/ipa

- name: Verify aps-environment in IPA
working-directory: examples/demo/ios
run: |
IPA=$(ls build/ipa/*.ipa | head -n1)
unzip -oq "$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@v7
with:
name: demo-ipa
path: examples/demo/ios/build/ipa/demo.ipa
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: app-release.apk
sdk-type: react-native
build-name: react-native-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: react-native
build-name: react-native-ios-${{ github.ref_name }}-${{ github.run_number }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ build
dist
android/build
*.tgz
.rn-sdk-source.stamp

# OSX
#
Expand Down
6 changes: 6 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ android {
dependencies {
implementation "com.facebook.react:react-native:${safeExtGet('reactNativeVersion', '+')}"

// androidx.startup runs our OneSignalInitializer during Application.onCreate so we can register
// an ActivityLifecycleCallbacks before MainActivity.onResume fires. This avoids the cold-start
// race where ReactApplicationContext.getCurrentActivity() returns null and the OneSignal SDK
// ends up holding an ApplicationContext instead of the real Activity.
implementation 'androidx.startup:startup-runtime:1.1.1'

// api is used instead of implementation so the parent :app project can access any of the OneSignal Java
// classes if needed. Such as com.onesignal.NotificationExtenderService
//
Expand Down
14 changes: 13 additions & 1 deletion android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,2 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="com.onesignal.rnonesignalandroid.OneSignalInitializer"
android:value="androidx.startup" />
</provider>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.onesignal.rnonesignalandroid;

import android.app.Activity;
import android.app.Application;
import android.os.Bundle;
import androidx.annotation.Nullable;
import java.lang.ref.WeakReference;

/**
* Tracks the host app's current Activity from Application.onCreate onward.
*
* <p>Registered very early via {@link OneSignalInitializer} (androidx.startup) so it captures the
* first {@code MainActivity.onResume} that fires before the React Native bridge has loaded the JS
* bundle. Without this, {@link com.facebook.react.bridge.ReactApplicationContext#getCurrentActivity()}
* frequently returns {@code null} during cold start in bridgeless mode, causing
* {@code RNOneSignal.initialize} to hand the OneSignal SDK an ApplicationContext instead of the
* real Activity. That in turn leaves {@code ApplicationService.current == null} and queues
* {@code requestPermission()} until the next foreground.
*/
public class ActivityLifecycleTracker implements Application.ActivityLifecycleCallbacks {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this tied to LiveActivities or something like RN rendering lifecycles?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

See other comment

private static final ActivityLifecycleTracker INSTANCE = new ActivityLifecycleTracker();

private volatile WeakReference<Activity> currentActivity = new WeakReference<>(null);

private ActivityLifecycleTracker() {}

public static ActivityLifecycleTracker getInstance() {
return INSTANCE;
}

@Nullable
public Activity getCurrentActivity() {
return currentActivity.get();
}

@Override
public void onActivityCreated(Activity activity, @Nullable Bundle savedInstanceState) {
currentActivity = new WeakReference<>(activity);
}

@Override
public void onActivityStarted(Activity activity) {
currentActivity = new WeakReference<>(activity);
}

@Override
public void onActivityResumed(Activity activity) {
currentActivity = new WeakReference<>(activity);
}

@Override
public void onActivityPaused(Activity activity) {
// Intentionally no-op: keep the reference so a transient overlay (e.g. permission dialog,
// PermissionsActivity) doesn't blank out the current Activity for callers that race with it.
}

@Override
public void onActivityStopped(Activity activity) {
// Intentionally no-op for the same reason as onActivityPaused.
}

@Override
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {}

@Override
public void onActivityDestroyed(Activity activity) {
Activity current = currentActivity.get();
if (current == activity) {
currentActivity = new WeakReference<>(null);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.onesignal.rnonesignalandroid;

import android.app.Application;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.startup.Initializer;
import java.util.Collections;
import java.util.List;

/**
* androidx.startup entry point that registers {@link ActivityLifecycleTracker} against the host
* {@link Application} during {@code Application.onCreate}, before any Activity is created.
*
* <p>This does NOT initialize the OneSignal SDK itself: the App ID is supplied at runtime by JS
* via {@code OneSignal.initialize(appId)}. The job here is purely to capture the current Activity
* early so that when JS later calls initialize, {@code RNOneSignal} can hand a real Activity to
* {@code OneSignal.initWithContext}.
*/
public class OneSignalInitializer implements Initializer<ActivityLifecycleTracker> {

@NonNull
@Override
public ActivityLifecycleTracker create(@NonNull Context context) {
ActivityLifecycleTracker tracker = ActivityLifecycleTracker.getInstance();
Context appContext = context.getApplicationContext();
if (appContext instanceof Application) {
((Application) appContext).registerActivityLifecycleCallbacks(tracker);
}
return tracker;
}

@NonNull
@Override
public List<Class<? extends Initializer<?>>> dependencies() {
return Collections.emptyList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,21 @@ public void initialize(String appId) {
}

ReactApplicationContext reactContext = getReactApplicationContext();
Context context = reactContext.getCurrentActivity();
// Prefer the Activity captured by ActivityLifecycleTracker (registered via androidx.startup
// before MainActivity.onResume), then fall back to ReactApplicationContext's accessor and
// finally the ApplicationContext. Passing the real Activity lets the OneSignal SDK populate
// ApplicationService.current immediately, so requestPermission() can launch the OS dialog
// on the first cold-start instead of waiting for the next foreground event.
Context context = ActivityLifecycleTracker.getInstance().getCurrentActivity();
if (context == null) {
context = reactContext.getCurrentActivity();
}
Comment on lines +241 to +249
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there any context for this change? Like an issue or is it to get certain tests to pass with Appium?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

There was an existing issue where if you do a fresh-start, then nothing is initialized unless you minimize/close the app and reopen. This fixes it so the app re-uses existing activity when app finally launches

if (context == null) {
context = reactContext.getApplicationContext();
}

Logging.debug(
"OneSignal initialize using context: " + context.getClass().getSimpleName(), null);
OneSignal.initWithContext(context, appId);
oneSignalInitDone = true;
}
Expand Down
Loading
Loading