Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(messaging, ios): avoid hang on notification registration #7797

Merged
merged 7 commits into from
May 20, 2024
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
3 changes: 2 additions & 1 deletion .github/workflows/tests_e2e_android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ jobs:
# 23-25, 28-29 appears to work locally but fails in CI
# 26 does not support performance tracing due to hardware acceleration bugs
# min-possible + max-possible skew looks like 29 and 34 then
api-level: [30, 34]
# running anything below 34 fails at the moment though, so using only it for now
api-level: [34]
arch: [x86_64]
target: [google_apis]
# This is useful for benchmarking, do 0, 1, 2, etc (up to 256 max job-per-matrix limit) for averages
Expand Down
21 changes: 20 additions & 1 deletion .github/workflows/tests_e2e_ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ jobs:
name: Xcode Compile Cache
with:
key: ${{ runner.os }}-v2 # makes a unique key w/related restore key internally
create-symlink: true
max-size: 1500M

- name: Yarn Install
Expand Down Expand Up @@ -138,7 +139,6 @@ jobs:

- name: Build iOS App
run: |
export PATH="/usr/lib/ccache:/usr/local/opt/ccache/libexec:$PATH"
export CCACHE_SLOPPINESS=clang_index_store,file_stat_matches,include_file_ctime,include_file_mtime,ivfsoverlay,pch_defines,modules,system_headers,time_macros
export CCACHE_FILECLONE=true
export CCACHE_DEPEND=true
Expand All @@ -148,6 +148,8 @@ jobs:
export SKIP_BUNDLING=1
export RCT_NO_LAUNCH_PACKAGER=1
set -o pipefail
echo $PATH
which clang
yarn tests:ios:build
ccache -s
shell: bash
Expand All @@ -171,6 +173,11 @@ jobs:
curl --output /dev/null --silent --head --fail "http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false&inlineSourceMap=true"
echo "...javascript bundle ready"

- name: Record App Video
# With a little delay so the detox test below has time to spawn it, missing the first part of boot is fine
continue-on-error: true
run: nohup sh -c "sleep 30 && xcrun simctl io booted recordVideo --codec=h264 -f simulator.mp4 2>&1 &"

- name: Create Simulator Log
# With a little delay so the detox test below has time to spawn it, missing the first part of boot is fine
# If you boot the simulator separately from detox, some other race fails and detox testee never sends ready to proxy
Expand All @@ -181,6 +188,18 @@ jobs:
timeout-minutes: 50
run: yarn tests:ios:test-cover

- name: Stop App Video
if: always()
run: killall -INT simctl

- name: Upload App Video
uses: actions/upload-artifact@v4
continue-on-error: true
if: always()
with:
name: simulator_video
path: simulator.mp4

- name: Upload Simulator Log
uses: actions/upload-artifact@v4
if: always()
Expand Down
79 changes: 43 additions & 36 deletions packages/messaging/e2e/messaging.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ async function isAPNSCapableSimulator() {
}

describe('messaging()', function () {
before(async function () {
// our device registration tests require permissions. Set them up
await firebase.messaging().requestPermission({
alert: true,
badge: true,
sound: true,
provisional: true,
});
});

describe('firebase v8 compatibility', function () {
describe('namespace', function () {
it('accessible from firebase.app()', function () {
Expand Down Expand Up @@ -95,11 +105,9 @@ describe('messaging()', function () {
});

it('successfully unregisters on ios', async function () {
if (device.getPlatform() === 'ios') {
if (device.getPlatform() === 'ios' && !isCI) {
await firebase.messaging().unregisterDeviceForRemoteMessages();
should.equal(firebase.messaging().isDeviceRegisteredForRemoteMessages, false);
// did this happen in logs?
// 2024-02-02 18:35:26.277 Df testing[26266:18d3f] (Detox) 10.20.0 - [FirebaseMessaging][I-FCM002022] Declining request for FCM Token since no APNS Token specified
tryToRegister = await isAPNSCapableSimulator();
if (tryToRegister) {
await firebase.messaging().registerDeviceForRemoteMessages();
Expand All @@ -112,27 +120,25 @@ describe('messaging()', function () {
});

describe('hasPermission', function () {
it('returns true android (default)', async function () {
if (device.getPlatform() === 'android') {
should.equal(await firebase.messaging().hasPermission(), true);
} else {
this.skip();
}
});

it('returns -1 on ios (default)', async function () {
if (device.getPlatform() === 'ios') {
should.equal(await firebase.messaging().hasPermission(), -1);
}
// we request permission in a before block, so both should be truthy
it('returns truthy', async function () {
should.equal(!!(await firebase.messaging().hasPermission()), true);
});
});

describe('requestPermission', function () {
it('resolves 1 on android', async function () {
// we request permission in a before block
it('resolves correctly for default request', async function () {
if (device.getPlatform() === 'android') {
should.equal(await firebase.messaging().requestPermission(), 1);
// our default resolve on android is "authorized"
should.equal(await firebase.messaging().requestPermission({ provisional: true }), 1);
} else {
this.skip();
// our default request on iOS results in "provisional" == 2
// but we may have granted perms by any outside method == 1
should.equal(
(await firebase.messaging().requestPermission({ provisional: true })) >= 1,
true,
);
}
});
});
Expand All @@ -150,7 +156,11 @@ describe('messaging()', function () {
// Make sure we are registered for remote notifications, else no token
aPNSCapableSimulator = await isAPNSCapableSimulator();
simulator = await isSimulator();
if (device.getPlatform() === 'ios' && (!simulator || (simulator && aPNSCapableSimulator))) {
if (
device.getPlatform() === 'ios' &&
!isCI &&
(!simulator || (simulator && aPNSCapableSimulator))
) {
await firebase.messaging().registerDeviceForRemoteMessages();
apnsToken = await firebase.messaging().getAPNSToken();

Expand Down Expand Up @@ -566,7 +576,7 @@ describe('messaging()', function () {
registerDeviceForRemoteMessages,
} = messagingModular;

if (device.getPlatform() === 'ios') {
if (device.getPlatform() === 'ios' && !isCI) {
await unregisterDeviceForRemoteMessages(getMessaging());
should.equal(isDeviceRegisteredForRemoteMessages(getMessaging()), false);
aPNSCapableSimulator = await isAPNSCapableSimulator();
Expand All @@ -585,30 +595,23 @@ describe('messaging()', function () {
});

describe('hasPermission', function () {
it('returns true android (default)', async function () {
it('returns true', async function () {
// our before block requests permission, so both should be truthy
const { getMessaging, hasPermission } = messagingModular;
if (device.getPlatform() === 'android') {
should.equal(await hasPermission(getMessaging()), true);
} else {
this.skip();
}
});

it('returns -1 on ios (default)', async function () {
const { getMessaging, hasPermission } = messagingModular;
if (device.getPlatform() === 'ios') {
should.equal(await hasPermission(getMessaging()), -1);
}
should.equal(!!(await hasPermission(getMessaging())), true);
});
});

describe('requestPermission', function () {
it('resolves 1 on android', async function () {
it('resolves correctly for default request', async function () {
const { getMessaging, requestPermission } = messagingModular;
// our before block requests, android will always be 1
if (device.getPlatform() === 'android') {
should.equal(await requestPermission(getMessaging()), 1);
} else {
this.skip();
// our default request on iOS results in "provisional" == 2
// but we may have granted perms by any outside method == 1
should.equal((await requestPermission(getMessaging(), { provisional: true })) >= 1, true);
}
});
});
Expand All @@ -628,7 +631,11 @@ describe('messaging()', function () {
const { getMessaging, getAPNSToken, registerDeviceForRemoteMessages } = messagingModular;
aPNSCapableSimulator = await isAPNSCapableSimulator();
simulator = await isSimulator();
if (device.getPlatform() === 'ios' && (!simulator || (simulator && aPNSCapableSimulator))) {
if (
device.getPlatform() === 'ios' &&
!isCI &&
(!simulator || (simulator && aPNSCapableSimulator))
) {
await registerDeviceForRemoteMessages(getMessaging());
apnsToken = await getAPNSToken(getMessaging());

Expand Down
39 changes: 37 additions & 2 deletions packages/messaging/ios/RNFBMessaging/RNFBMessagingModule.m
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,15 @@ - (NSDictionary *)constantsToExport {
if (error) {
[RNFBSharedUtils rejectPromiseWithNSError:reject error:error];
} else {
// if we do not attempt to register immediately, registration fails
// later unknown reason why, but this was the only difference between
// using a react-native-permissions vs built-in permissions request in
// a sequence of "request permissions" --> "register for messages" you
// only want to request permission if you want to register for
// messages, so we register directly now - see #7272
dispatch_async(dispatch_get_main_queue(), ^{
[[UIApplication sharedApplication] registerForRemoteNotifications];
});
[self hasPermission:resolve:reject];
}
}];
Expand All @@ -303,8 +312,7 @@ - (NSDictionary *)constantsToExport {
: (RCTPromiseRejectBlock)reject) {
#if TARGET_IPHONE_SIMULATOR
#if !TARGET_CPU_ARM64
// Do the registration on this unsupported simulator, but don't set up to wait for a token that
// won't arrive
// Register on this unsupported simulator, but no waiting for a token that won't arrive
[[UIApplication sharedApplication] registerForRemoteNotifications];
resolve(@([RCTConvert BOOL:@(YES)]));
return;
Expand All @@ -317,6 +325,7 @@ - (NSDictionary *)constantsToExport {
if (@available(iOS 10.0, *)) {
#pragma pop
if ([UIApplication sharedApplication].isRegisteredForRemoteNotifications == YES) {
DLog(@"RNFBMessaging registerForRemoteNotifications - already registered.");
resolve(@([RCTConvert BOOL:@(YES)]));
return;
} else {
Expand All @@ -326,6 +335,32 @@ - (NSDictionary *)constantsToExport {
// Apple docs recommend that registerForRemoteNotifications is always called on app start
// regardless of current status
dispatch_async(dispatch_get_main_queue(), ^{
// Sometimes the registration never completes, which deserves separate attention in other
// areas. This area should protect itself against hanging forever regardless. Just in case,
// check in after a delay and cleanup if required
dispatch_after(
dispatch_time(DISPATCH_TIME_NOW, 10.0 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
if ([RNFBMessagingAppDelegate sharedInstance].registerPromiseResolver != nil) {
// if we got here and resolve/reject are still set, unset, log failure, reject
DLog(@"RNFBMessaging dispatch_after block: we appear to have timed out. Rejecting");
[[RNFBMessagingAppDelegate sharedInstance] setPromiseResolve:nil
andPromiseReject:nil];

[RNFBSharedUtils
rejectPromiseWithUserInfo:reject
userInfo:[@{
@"code" : @"unknown-error",
@"message" :
@"registerDeviceForRemoteMessages requested but "
@"system did not respond. Possibly missing permission."
} mutableCopy]];
return;
} else {
DLog(@"RNFBMessaging dispatch_after: registerDeviceForRemoteMessages handled.");
return;
}
});

[[UIApplication sharedApplication] registerForRemoteNotifications];
});
}
Expand Down
13 changes: 13 additions & 0 deletions tests/e2e/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ config.configurations['android.emu.debug'].device.avdName =
before(async function () {
await detox.init(config);
await device.launchApp();

// WIP - remote messaging notification is hanging in CI
// this chunk of work is intended to grant the required permissions
// for it to succeed. It is not stable though, so this is
// commented out and the relevant test is skipped in CI
// our messaging tests require notification permission now
// this command only works on macOS though, so || true to make it pass everywhere
// execSync(
// `applesimutils --booted --setPermissions notifications=YES --bundle io.invertase.testing || true`,
// );
// after setting perms you have to launch the app again, after a slight delay
// await Utils.sleep(15000);
// await device.launchApp();
await jet.init();
});

Expand Down
Loading
Loading