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
21 changes: 21 additions & 0 deletions .github/workflows/e2e_tests_messaging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,27 @@ jobs:
TESTS_E2E_FIREBASE_OPTIONS_DART: ${{ secrets.TESTS_E2E_FIREBASE_OPTIONS_DART }}
TESTS_E2E_GOOGLE_SERVICES_JSON: ${{ secrets.TESTS_E2E_GOOGLE_SERVICES_JSON }}

# Apps that turn off the Firebase app delegate proxy get remote
# notifications through a different path in the plugin: run the delivery
# tests again with it disabled.
ios-app-delegate-proxy-disabled:
needs: changes
if: needs.changes.outputs.ios == 'true'
uses: ./.github/workflows/reusable_e2e_ios.yaml
with:
package-path: 'packages/firebase_messaging/firebase_messaging'
package-scope: 'firebase_messaging*'
cache-key-suffix: 'messaging'
test-target: 'integration_test/remote_notification_delivery_test.dart'
native-config-args: '--live-tier-plist=messaging'
inject-config-secrets: true
use-firebase-emulators: false
info-plist-overrides: 'FirebaseAppDelegateProxyEnabled=false'
nightly_test_mode: ${{ inputs.nightly_test_mode == true }}
secrets:
TESTS_E2E_FIREBASE_OPTIONS_DART: ${{ secrets.TESTS_E2E_FIREBASE_OPTIONS_DART }}
TESTS_E2E_GOOGLE_SERVICES_JSON: ${{ secrets.TESTS_E2E_GOOGLE_SERVICES_JSON }}

macos:
needs: changes
if: needs.changes.outputs.macos == 'true'
Expand Down
35 changes: 35 additions & 0 deletions .github/workflows/reusable_e2e_ios.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ on:
covered either way.
type: boolean
default: true
info-plist-overrides:
description: >-
Space-separated `Key=true|false` booleans written to the example's
ios/Runner/Info.plist before building, to run the tests under another
app configuration (e.g. `FirebaseAppDelegateProxyEnabled=false`).
type: string
default: ''
nightly_test_mode:
description: >-
Passed down from nightly.yaml. Shortens the job timeout to 5 minutes.
Expand Down Expand Up @@ -122,6 +129,7 @@ jobs:
PACKAGE_PATH: ${{ inputs.package-path }}
TEST_TARGET: ${{ inputs.test-target }}
NATIVE_CONFIG_ARGS: ${{ inputs.native-config-args }}
INFO_PLIST_OVERRIDES: ${{ inputs.info-plist-overrides }}
timeout-minutes: ${{ inputs.nightly_test_mode && 5 || 60 }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
Expand Down Expand Up @@ -208,6 +216,21 @@ jobs:
if [ -f Podfile ]; then pod deintegrate; fi
rm -f Podfile Podfile.lock
rm -rf Pods
- name: Apply Info.plist overrides
if: inputs.info-plist-overrides != ''
working-directory: ${{ inputs.package-path }}/example/ios/Runner
run: |
for override in $INFO_PLIST_OVERRIDES; do
key="${override%%=*}"
value="${override#*=}"
if [[ ! "$key" =~ ^[A-Za-z0-9_]+$ || ! "$value" =~ ^(true|false)$ ]]; then
echo "::error::Invalid Info.plist override '$override', expected Key=true|false."
exit 1
fi
/usr/libexec/PlistBuddy -c "Delete :$key" Info.plist 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :$key bool $value" Info.plist
echo "Info.plist: $key = $value"
done
- name: Pods Cache
if: ${{ !inputs.ios-spm }}
continue-on-error: true
Expand Down Expand Up @@ -248,6 +271,18 @@ jobs:
fi
set -e
ccache -s
# Fail rather than silently test the default configuration.
if [ $BUILD_EXIT -eq 0 ]; then
for override in $INFO_PLIST_OVERRIDES; do
key="${override%%=*}"
value="${override#*=}"
built="$(/usr/libexec/PlistBuddy -c "Print :$key" build/ios/iphonesimulator/Runner.app/Info.plist)"
if [ "$built" != "$value" ]; then
echo "::error::Built Info.plist has $key = '$built', expected '$value'."
exit 1
fi
done
fi
exit $BUILD_EXIT
- name: Install Cloud Functions dependencies
if: inputs.use-firebase-emulators
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:firebase_messaging_example/firebase_options.dart';

import 'remote_notification_delivery.dart';
import 'report_test_results.dart';

/// Test helpers that use UiAutomation to mutate runtime permissions during
Expand Down Expand Up @@ -246,6 +247,8 @@ void main() {
);
});

remoteNotificationDeliveryTests();

group('getInitialMessage', () {
test('returns null when no initial message', () async {
expect(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Copyright 2026, the Chromium project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:firebase_messaging_platform_interface/firebase_messaging_platform_interface.dart';
// ignore: implementation_imports
import 'package:firebase_messaging_platform_interface/src/method_channel/method_channel_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

/// iOS test helper implemented in `ios/Runner/RemoteNotificationTestChannel.swift`.
const _remoteNotificationsChannel = MethodChannel('tests/remote_notifications');

/// Delivers a data message through the app delegate the same way iOS does when
/// a push arrives. Returns how many times the fetch completion handler was
/// called (`completionCount`) and with which `UIBackgroundFetchResult`
/// (`fetchResult`).
Future<Map<String, Object?>> deliverRemoteNotification(
String messageId, {
required bool inBackground,
}) async {
final delivery = await _remoteNotificationsChannel
.invokeMapMethod<String, Object?>('deliver', {
'inBackground': inBackground,
'userInfo': {
'aps': {'content-available': 1},
'gcm.message_id': messageId,
'google.c.a.e': '1',
'from': 'e2e',
'test': 'remote-notification-delivery',
},
});
return delivery!;
}

const _uiBackgroundFetchResultNewData = 0;

final List<RemoteMessage> _backgroundMessages = <RemoteMessage>[];

@pragma('vm:entry-point')
Future<void> _backgroundMessageHandler(RemoteMessage message) async {
_backgroundMessages.add(message);
}

String _uniqueMessageId(String prefix) =>
'$prefix-${DateTime.now().microsecondsSinceEpoch}';

/// Pushes delivered the way iOS delivers them, run by `e2e_test.dart` and on
/// their own by `remote_notification_delivery_test.dart`.
void remoteNotificationDeliveryTests() {
group(
'remote notification delivery',
() {
test('a data message in the foreground reaches onMessage once', () async {
final messageId = _uniqueMessageId('foreground');
final received = <RemoteMessage>[];
final subscription = FirebaseMessaging.onMessage
.where((message) => message.messageId == messageId)
.listen(received.add);
addTearDown(subscription.cancel);

final delivery = await deliverRemoteNotification(
messageId,
inBackground: false,
);
await Future<void>.delayed(const Duration(milliseconds: 500));

expect(received, hasLength(1));
expect(
received.single.data,
containsPair('test', 'remote-notification-delivery'),
);
expect(delivery['completionCount'], 1);
});

test(
'a data message in the background runs onBackgroundMessage once '
'before the push completes', () async {
FirebaseMessaging.onBackgroundMessage(_backgroundMessageHandler);
final messageId = _uniqueMessageId('background');

final delivery = await deliverRemoteNotification(
messageId,
inBackground: true,
);

expect(
_backgroundMessages.where((m) => m.messageId == messageId),
hasLength(1),
);
expect(delivery['completionCount'], 1);
expect(delivery['fetchResult'], _uiBackgroundFetchResultNewData);
});

test(
'a background message that arrives before onBackgroundMessage is '
'set runs it once it is', () async {
// As when iOS launches the app in the background for a push: the
// message reaches Dart before `main()` has registered the handler.
// Registering it once first tells the native side the app has one.
FirebaseMessaging.onBackgroundMessage(_backgroundMessageHandler);
await Future<void>.delayed(const Duration(milliseconds: 100));
FirebaseMessagingPlatform.onBackgroundMessage = null;
// Messages wait for a handler while the app starts using messaging;
// restart that window, the app has been running for a while.
MethodChannelFirebaseMessaging.setMethodCallHandlers();
final messageId = _uniqueMessageId('before-handler');

final delivery = deliverRemoteNotification(
messageId,
inBackground: true,
);
await Future<void>.delayed(const Duration(milliseconds: 500));
expect(
_backgroundMessages.where((m) => m.messageId == messageId),
isEmpty,
);

FirebaseMessaging.onBackgroundMessage(_backgroundMessageHandler);
final result = await delivery;

expect(
_backgroundMessages.where((m) => m.messageId == messageId),
hasLength(1),
);
expect(result['completionCount'], 1);
expect(result['fetchResult'], _uiBackgroundFetchResultNewData);
});

test(
'messages sent before Dart handles the channel are all kept '
'until it does', () async {
// As when pushes arrive before the app starts using messaging: Flutter
// keeps a single message by default.
final messageIds = [
for (var i = 0; i < 3; i++) _uniqueMessageId('buffered-$i'),
];
final received = <String?>[];
final subscription = FirebaseMessaging.onMessage
.where((message) => messageIds.contains(message.messageId))
.listen((message) => received.add(message.messageId));
addTearDown(subscription.cancel);

MethodChannelFirebaseMessaging.channel.setMethodCallHandler(null);
addTearDown(MethodChannelFirebaseMessaging.setMethodCallHandlers);
for (final messageId in messageIds) {
await deliverRemoteNotification(messageId, inBackground: false);
}
expect(received, isEmpty);

MethodChannelFirebaseMessaging.setMethodCallHandlers();
await Future<void>.delayed(const Duration(milliseconds: 500));

expect(received, messageIds);
});

test('a later push that reuses a message ID is handled again', () async {
// Some servers send pushes straight to APNs with a fixed
// `gcm.message_id`: only the duplicate within one delivery from iOS
// may be dropped.
final messageId = _uniqueMessageId('reused-id');
final received = <RemoteMessage>[];
final subscription = FirebaseMessaging.onMessage
.where((message) => message.messageId == messageId)
.listen(received.add);
addTearDown(subscription.cancel);

final first = await deliverRemoteNotification(
messageId,
inBackground: false,
);
final second = await deliverRemoteNotification(
messageId,
inBackground: false,
);
await Future<void>.delayed(const Duration(milliseconds: 500));

expect(received, hasLength(2));
expect(first['completionCount'], 1);
expect(second['completionCount'], 1);
});
},
// Pushes are delivered through the iOS app delegate.
skip: kIsWeb || defaultTargetPlatform != TargetPlatform.iOS,
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright 2026, the Chromium project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging_example/firebase_options.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import 'remote_notification_delivery.dart';
import 'report_test_results.dart';

/// Runs only the delivery tests, for the CI jobs that build the app with
/// another configuration (see `.github/workflows/e2e_tests_messaging.yaml`).
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
reportTestResultsToDriver(binding);

setUpAll(() async {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
});

remoteNotificationDeliveryTests();
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
F091B1FD01EEB458758808F7 /* RemoteNotificationTestChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A13EA793224BCB838D4F327 /* RemoteNotificationTestChannel.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
Expand Down Expand Up @@ -42,6 +43,7 @@
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
6A13EA793224BCB838D4F327 /* RemoteNotificationTestChannel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotificationTestChannel.swift; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Firebase Cloud Messaging Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Firebase Cloud Messaging Example.app"; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -112,6 +114,7 @@
children = (
27715A442538A1AE00757C2A /* Firebase Cloud Messaging Example.entitlements */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
6A13EA793224BCB838D4F327 /* RemoteNotificationTestChannel.swift */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
Expand Down Expand Up @@ -289,6 +292,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
F091B1FD01EEB458758808F7 /* RemoteNotificationTestChannel.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,11 @@ import UIKit

func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
// Only used by integration_test/e2e_test.dart.
if let registrar = engineBridge.pluginRegistry.registrar(
forPlugin: "RemoteNotificationTestChannel")
{
RemoteNotificationTestChannel.register(with: registrar)
}
}
}
Loading
Loading