diff --git a/.github/workflows/scripts-ios.yml b/.github/workflows/scripts-ios.yml index 08f7d1216f..073c7d4ac9 100644 --- a/.github/workflows/scripts-ios.yml +++ b/.github/workflows/scripts-ios.yml @@ -8,6 +8,10 @@ on: - 'scripts/build-ios-port.sh' - 'scripts/build-ios-app.sh' - 'scripts/run-ios-ui-tests.sh' + - 'scripts/run-ios-native-tests.sh' + - 'scripts/ios/notification-tests/native-tests/**' + - 'scripts/ios/notification-tests/install-native-notification-tests.sh' + - 'scripts/ios/notification-tests/**' - 'scripts/hellocodenameone/**' - 'scripts/ios/tests/**' - 'scripts/ios/screenshots/**' @@ -32,6 +36,10 @@ on: - 'scripts/build-ios-port.sh' - 'scripts/build-ios-app.sh' - 'scripts/run-ios-ui-tests.sh' + - 'scripts/run-ios-native-tests.sh' + - 'scripts/ios/notification-tests/native-tests/**' + - 'scripts/ios/notification-tests/install-native-notification-tests.sh' + - 'scripts/ios/notification-tests/**' - 'scripts/hellocodenameone/**' - 'scripts/ios/tests/**' - 'scripts/ios/screenshots/**' @@ -145,6 +153,17 @@ jobs: "${{ steps.build-ios-app.outputs.scheme }}" timeout-minutes: 30 + - name: Run native iOS notification tests (XCTest) + env: + ARTIFACTS_DIR: ${{ github.workspace }}/artifacts + run: | + set -euo pipefail + mkdir -p "${ARTIFACTS_DIR}" + ./scripts/run-ios-native-tests.sh \ + "${{ steps.build-ios-app.outputs.workspace }}" \ + "${{ steps.build-ios-app.outputs.scheme }}" + timeout-minutes: 20 + - name: Upload iOS artifacts if: always() uses: actions/upload-artifact@v4 diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index 02e4b3c3c7..f3b0042930 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -160,13 +160,6 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( } com_codename1_impl_ios_IOSImplementation_callback__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); - if (launchOptions[UIApplicationLaunchOptionsLocalNotificationKey]) { - CN1Log(@"Background notification received"); - UILocalNotification *notification = launchOptions[UIApplicationLaunchOptionsLocalNotificationKey]; - com_codename1_impl_ios_IOSImplementation_localNotificationReceived___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG [notification.userInfo valueForKey:@"__ios_id__"])); - application.applicationIconBadgeNumber = 0; - } - id locationValue = [launchOptions objectForKey:UIApplicationLaunchOptionsLocationKey]; if (locationValue) { com_codename1_impl_ios_IOSImplementation_appDidLaunchWithLocation__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); @@ -440,7 +433,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNot } -- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)())completionHandler { +- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler { if (@available(iOS 10, *)) { if( [response.notification.request.content.userInfo valueForKey:@"__ios_id__"] != NULL) { @@ -618,15 +611,6 @@ -(void)application:(UIApplication*)application didChangeStatusBarFrame:(CGRect)o repaintUI(); } -- (void)application:(UIApplication*)application didReceiveLocalNotification:(UILocalNotification*)notification { - CN1Log(@"Received local notification while running: %@", notification); - if( [notification.userInfo valueForKey:@"__ios_id__"] != NULL) - { - NSString* alertValue = [notification.userInfo valueForKey:@"__ios_id__"]; - com_codename1_impl_ios_IOSImplementation_localNotificationReceived___java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG fromNSString(CN1_THREAD_GET_STATE_PASS_ARG alertValue)); - } -} - #ifndef CN1_USE_ARC - (void)dealloc { diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index adc39439f1..fab8a54110 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -9733,18 +9733,63 @@ static void cn1CancelScheduledLocalNotificationById(NSString *nsId) { if (nsId == nil) { return; } - UIApplication *app = [UIApplication sharedApplication]; - NSArray *eventArray = [app scheduledLocalNotifications]; - for (int i = 0; i < [eventArray count]; i++) { - UILocalNotification *n = [eventArray objectAtIndex:i]; - NSDictionary *userInfo = n.userInfo; - NSString *uid = [NSString stringWithFormat:@"%@", [userInfo valueForKey:@"__ios_id__"]]; - if ([nsId isEqualToString:uid]) { - [app cancelLocalNotification:n]; +#ifdef CN1_INCLUDE_NOTIFICATIONS2 + if (@available(iOS 10, *)) { + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block NSMutableArray *matches = [NSMutableArray array]; + [center getPendingNotificationRequestsWithCompletionHandler:^(NSArray * _Nonnull requests) { + for (UNNotificationRequest *request in requests) { + NSString *uid = [NSString stringWithFormat:@"%@", [request.content.userInfo valueForKey:@"__ios_id__"]]; + if ([nsId isEqualToString:uid] || [nsId isEqualToString:request.identifier]) { + [matches addObject:request.identifier]; + } + } + dispatch_semaphore_signal(sem); + }]; + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC))); + if ([matches count] > 0) { + [center removePendingNotificationRequestsWithIdentifiers:matches]; + [center removeDeliveredNotificationsWithIdentifiers:matches]; } } +#endif } +#ifdef CN1_INCLUDE_NOTIFICATIONS2 +static UNNotificationTrigger* cn1CreateNotificationTrigger(JAVA_LONG fireDate, JAVA_INT repeatType) API_AVAILABLE(ios(10.0)); +static UNNotificationTrigger* cn1CreateNotificationTrigger(JAVA_LONG fireDate, JAVA_INT repeatType) { + NSTimeInterval targetTime = fireDate / 1000.0 + 1; + NSDate *targetDate = [NSDate dateWithTimeIntervalSince1970:targetTime]; + NSTimeInterval delta = targetTime - [[NSDate date] timeIntervalSince1970]; + if (delta < 1) { + delta = 1; + } + + NSCalendar *calendar = [NSCalendar currentCalendar]; + NSDateComponents *components; + switch (repeatType) { + case 0: + return [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:delta repeats:NO]; + case 1: + components = [calendar components:(NSCalendarUnitSecond) fromDate:targetDate]; + return [UNCalendarNotificationTrigger triggerWithDateMatchingComponents:components repeats:YES]; + case 3: + components = [calendar components:(NSCalendarUnitMinute | NSCalendarUnitSecond) fromDate:targetDate]; + return [UNCalendarNotificationTrigger triggerWithDateMatchingComponents:components repeats:YES]; + case 4: + components = [calendar components:(NSCalendarUnitHour | NSCalendarUnitMinute | NSCalendarUnitSecond) fromDate:targetDate]; + return [UNCalendarNotificationTrigger triggerWithDateMatchingComponents:components repeats:YES]; + case 5: + components = [calendar components:(NSCalendarUnitWeekday | NSCalendarUnitHour | NSCalendarUnitMinute | NSCalendarUnitSecond) fromDate:targetDate]; + return [UNCalendarNotificationTrigger triggerWithDateMatchingComponents:components repeats:YES]; + default: + CN1Log(@"Unknown repeat interval type %d. Ignoring repeat interval", repeatType); + return [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:delta repeats:NO]; + } +} +#endif + JAVA_VOID com_codename1_impl_ios_IOSNative_sendLocalNotification___java_lang_String_java_lang_String_java_lang_String_java_lang_String_int_long_int_boolean( CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT notificationId, JAVA_OBJECT alertTitle, JAVA_OBJECT alertBody, JAVA_OBJECT alertSound, JAVA_INT badgeNumber, JAVA_LONG fireDate, JAVA_INT repeatType, JAVA_BOOLEAN foreground ) { @@ -9780,112 +9825,32 @@ JAVA_VOID com_codename1_impl_ios_IOSNative_sendLocalNotification___java_lang_Str if (foreground) { [dict setObject: @"true" forKey: @"foreground"]; } - - if (NO && @available(iOS 10, *)) { - // November 23, 2020 - Steve - // Disabling this block, which uses the new UNUserNotifications API for sending local notifications, - // and opting to continue to use the old UILocalNotifications API for now. This is because - // the new API doesn't have an option to use a different repeat interval than the firstFire - // interval, and the UNUserNotifications API can still be used to receive the notification - // in the application delegate class fine. - // Eventually we'll probably want to switch to the new API, but for now, it is just too much work - // to try to replicate the functionality lost by the new API. - dispatch_sync(dispatch_get_main_queue(), ^{ - - UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init]; - - content.title = [NSString localizedUserNotificationStringForKey:title arguments:nil]; - content.body = [NSString localizedUserNotificationStringForKey:body - arguments:nil]; - if (alertSound) { - content.sound = [UNNotificationSound soundNamed:toNSString(CN1_THREAD_STATE_PASS_ARG alertSound)]; - - } - if (badgeNumber >= 0) { - - content.badge = [NSNumber numberWithInt:badgeNumber]; + if (@available(iOS 10, *)) { + UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init]; + content.title = [NSString localizedUserNotificationStringForKey:title arguments:nil]; + content.body = [NSString localizedUserNotificationStringForKey:body arguments:nil]; + if (alertSound != NULL) { + NSString *soundName = toNSString(CN1_THREAD_STATE_PASS_ARG alertSound); + if (soundName != nil && [soundName length] > 0) { + content.sound = [UNNotificationSound soundNamed:soundName]; } - content.userInfo = dict; - - - - - UNTimeIntervalNotificationTrigger *trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:fireDate/1000 - [[NSDate date] timeIntervalSince1970] + 1 repeats:NO]; - - // Create the request object. - UNNotificationRequest* request = [UNNotificationRequest - requestWithIdentifier:toNSString(CN1_THREAD_STATE_PASS_ARG notificationId) content:content trigger:trigger]; - - - - UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; - UNAuthorizationOptions authOptions; - if (@available(iOS 12.0, *)) { - authOptions = UNAuthorizationOptionProvisional | UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge; - - } else { - authOptions = UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge; + } + if (badgeNumber >= 0) { + content.badge = [NSNumber numberWithInt:badgeNumber]; + } + content.userInfo = dict; + UNNotificationTrigger *trigger = cn1CreateNotificationTrigger(fireDate, repeatType); + UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:notificationIdString content:content trigger:trigger]; + UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; + cn1CancelScheduledLocalNotificationById(notificationIdString); + [center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) { + if (error != nil) { + CN1Log(@"Failed to schedule local notification: %@", error.localizedDescription); } - [center requestAuthorizationWithOptions:authOptions - completionHandler:^(BOOL granted, NSError * _Nullable error) { - [center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) { - if (error != nil) { - NSLog(@"%@", error.localizedDescription); - } - }]; - }]; - - }); - - - return; + }]; } else { - UILocalNotification *notification = [[UILocalNotification alloc] init]; - notification.alertTitle = title; - notification.alertBody = body; - - notification.soundName= toNSString(CN1_THREAD_STATE_PASS_ARG alertSound); - notification.fireDate = [NSDate dateWithTimeIntervalSince1970: fireDate/1000 + 1]; - notification.timeZone = [NSTimeZone defaultTimeZone]; - if (badgeNumber >= 0) { - notification.applicationIconBadgeNumber = badgeNumber; - } - switch (repeatType) { - case 0: - notification.repeatInterval = nil; - break; - case 1: - notification.repeatInterval = NSMinuteCalendarUnit; - break; - case 3: - notification.repeatInterval = NSHourCalendarUnit; - break; - case 4: - notification.repeatInterval = NSDayCalendarUnit; - break; - case 5: - notification.repeatInterval = NSWeekCalendarUnit; - break; - default: - CN1Log(@"Unknown repeat interval type %d. Ignoring repeat interval", repeatType); - notification.repeatInterval = nil; - } - - - - notification.userInfo = dict; - - - dispatch_sync(dispatch_get_main_queue(), ^{ - cn1CancelScheduledLocalNotificationById(notificationIdString); -#ifdef __IPHONE_8_0 - if ([UIApplication instancesRespondToSelector:@selector(registerUserNotificationSettings:)]){ - [[UIApplication sharedApplication] registerUserNotificationSettings:[UIUserNotificationSettings settingsForTypes:UIUserNotificationTypeAlert|UIUserNotificationTypeBadge|UIUserNotificationTypeSound categories:nil]]; - } -#endif - [[UIApplication sharedApplication] scheduleLocalNotification: notification]; - }); + CN1Log(@"Ignoring local notification request on iOS versions below 10"); } #endif } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 1e7787d769..422a837cf4 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1633,6 +1633,9 @@ public void usesClassMethod(String cls, String method) { "ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n" + " ENABLE_BITCODE = NO;\n"); } + if (xcodeVersion >= 9) { + replaceAllInFile(pbx, "ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage;", ""); + } } if (useMetal) { @@ -1642,6 +1645,12 @@ public void usesClassMethod(String cls, String method) { } catch (Exception ex) { throw new BuildException("Failed to update infoplist file", ex); } + + try { + normalizeAssetCatalogs(request); + } catch (Exception ex) { + throw new BuildException("Failed to normalize iOS asset catalogs", ex); + } stopwatch.split("Post-VM Setup"); if (runPods) { try { @@ -2687,9 +2696,11 @@ private boolean generateIcons(BuildRequest request) throws Exception { File resDir = getResDir(); BufferedImage iconImage = ImageIO.read(new ByteArrayInputStream(request.getIcon())); - icon512 = new File(iconDirectory, "iTunesArtwork"); + // Legacy iOS icon files are still copied into the root resources, but should not + // be placed inside AppIcon.appiconset as they are not referenced by Contents.json. + icon512 = new File(resDir, "iTunesArtwork"); createFile(icon512, request.getIcon()); - icon57 = new File(iconDirectory, "Icon.png"); + icon57 = new File(resDir, "Icon.png"); createIconFile(icon57, iconImage, 57, 57); createIconFile(new File(iconDirectory, "iPhoneNotification@2x.png"), iconImage, 40, 40); createIconFile(new File(iconDirectory, "iPhoneNotification@3x.png"), iconImage, 60, 60); @@ -2717,9 +2728,6 @@ private boolean generateIcons(BuildRequest request) throws Exception { createIconFile(new File(iconDirectory, "iPadPro@2x.png"), iconImage, 167, 167); createIconFile(new File(iconDirectory, "AppStore.png"), iconImage, 1024, 1024); - - copy(icon512, new File(resDir, icon512.getName())); - copy(icon57, new File(resDir, icon57.getName())); copyIcons(iconDirectory, resDir, "iPhoneNotification@2x.png", "iPhoneNotification@3x.png", @@ -2770,10 +2778,34 @@ private boolean generateLaunchScreen(BuildRequest request) throws Exception { copy(defaultLaunchStoryBoard, launchStoryBoard); } defaultLaunchStoryBoard.delete(); + + // Xcode 9+ uses LaunchScreen.storyboard. Keeping the legacy launch image set + // causes asset-catalog warnings for many missing legacy image files. + File legacyLaunchImages = new File(tmpFile, "dist/" + request.getMainClass() + "-src/Images.xcassets/LaunchImage.launchimage"); + if (legacyLaunchImages.exists()) { + delTree(legacyLaunchImages); + } } return true; } + private void normalizeAssetCatalogs(BuildRequest request) throws IOException { + File appSrcDir = new File(tmpFile, "dist/" + request.getMainClass() + "-src"); + File appIconContents = new File(appSrcDir, "Images.xcassets/AppIcon.appiconset/Contents.json"); + if (appIconContents.exists()) { + replaceInFile(appIconContents, + ",\n {\n \"size\" : \"120x120\",\n \"idiom\" : \"iphone\",\n \"filename\" : \"Icon7@2x.png\",\n \"scale\" : \"1x\"\n },\n {\n \"size\" : \"167x167\",\n \"idiom\" : \"ipad\",\n \"filename\" : \"Icon-167.png\",\n \"scale\" : \"3x\"\n }", + ""); + } + + if (xcodeVersion >= 9) { + File legacyLaunchImages = new File(appSrcDir, "Images.xcassets/LaunchImage.launchimage"); + if (legacyLaunchImages.exists()) { + delTree(legacyLaunchImages); + } + } + } + private static String createReverseGoogleClientId(String clientId) { String[] parts = clientId.split("\\."); diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 60835b8fc4..9a880dc4b9 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -97,13 +97,14 @@ public void runSuite() { }); }); for (BaseTest testClass : TEST_CLASSES) { + final String testName = testClass.getClass().getSimpleName(); CN.callSerially(() -> { - log("CN1SS:INFO:suite starting test=" + testClass); + log("CN1SS:INFO:suite starting test=" + testName); try { testClass.prepare(); testClass.runTest(); } catch (Throwable t) { - log("CN1SS:ERR:suite test=" + testClass + " failed=" + t); + log("CN1SS:ERR:suite test=" + testName + " failed=" + t); t.printStackTrace(); } }); @@ -114,15 +115,15 @@ public void runSuite() { } testClass.cleanup(); if(timeout == 0) { - log("CN1SS:ERR:suite test=" + testClass + " failed due to timeout waiting for DONE"); + log("CN1SS:ERR:suite test=" + testName + " failed due to timeout waiting for DONE"); } else if (testClass.isFailed()) { - log("CN1SS:ERR:suite test=" + testClass + " failed: " + testClass.getFailMessage()); + log("CN1SS:ERR:suite test=" + testName + " failed: " + testClass.getFailMessage()); } else { if (!testClass.shouldTakeScreenshot()) { - log("CN1SS:INFO:test=" + testClass + " screenshot=none"); + log("CN1SS:INFO:test=" + testName + " screenshot=none"); } } - log("CN1SS:INFO:suite finished test=" + testClass); + log("CN1SS:INFO:suite finished test=" + testName); } log("CN1SS:SUITE:FINISHED"); TestReporting.getInstance().testExecutionFinished(getClass().getName()); diff --git a/scripts/hellocodenameone/ios/src/main/objectivec/com_codenameone_examples_hellocodenameone_LocalNotificationNativeImpl.m b/scripts/hellocodenameone/ios/src/main/objectivec/com_codenameone_examples_hellocodenameone_LocalNotificationNativeImpl.m index a0b075c8f7..1fb61420c0 100644 --- a/scripts/hellocodenameone/ios/src/main/objectivec/com_codenameone_examples_hellocodenameone_LocalNotificationNativeImpl.m +++ b/scripts/hellocodenameone/ios/src/main/objectivec/com_codenameone_examples_hellocodenameone_LocalNotificationNativeImpl.m @@ -1,5 +1,6 @@ #import "com_codenameone_examples_hellocodenameone_LocalNotificationNativeImpl.h" #import +#import @implementation com_codenameone_examples_hellocodenameone_LocalNotificationNativeImpl @@ -7,17 +8,25 @@ -(void)clearScheduledLocalNotifications:(NSString*)param{ if (param == nil) { return; } - dispatch_sync(dispatch_get_main_queue(), ^{ - UIApplication *app = [UIApplication sharedApplication]; - NSArray *scheduled = [app scheduledLocalNotifications]; - for (UILocalNotification *notification in scheduled) { - NSDictionary *userInfo = notification.userInfo; - NSString *uid = [NSString stringWithFormat:@"%@", [userInfo valueForKey:@"__ios_id__"]]; - if ([param isEqualToString:uid]) { - [app cancelLocalNotification:notification]; + if (@available(iOS 10.0, *)) { + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block NSMutableArray *matches = [NSMutableArray array]; + [center getPendingNotificationRequestsWithCompletionHandler:^(NSArray * _Nonnull requests) { + for (UNNotificationRequest *request in requests) { + NSString *uid = [NSString stringWithFormat:@"%@", [request.content.userInfo valueForKey:@"__ios_id__"]]; + if ([param isEqualToString:uid]) { + [matches addObject:request.identifier]; + } } + dispatch_semaphore_signal(sem); + }]; + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC))); + if ([matches count] > 0) { + [center removePendingNotificationRequestsWithIdentifiers:matches]; + [center removeDeliveredNotificationsWithIdentifiers:matches]; } - }); + } } -(int)getScheduledLocalNotificationCount:(NSString*)param{ @@ -25,17 +34,20 @@ -(int)getScheduledLocalNotificationCount:(NSString*)param{ return 0; } __block int count = 0; - dispatch_sync(dispatch_get_main_queue(), ^{ - UIApplication *app = [UIApplication sharedApplication]; - NSArray *scheduled = [app scheduledLocalNotifications]; - for (UILocalNotification *notification in scheduled) { - NSDictionary *userInfo = notification.userInfo; - NSString *uid = [NSString stringWithFormat:@"%@", [userInfo valueForKey:@"__ios_id__"]]; - if ([param isEqualToString:uid]) { - count++; + if (@available(iOS 10.0, *)) { + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + [center getPendingNotificationRequestsWithCompletionHandler:^(NSArray * _Nonnull requests) { + for (UNNotificationRequest *request in requests) { + NSString *uid = [NSString stringWithFormat:@"%@", [request.content.userInfo valueForKey:@"__ios_id__"]]; + if ([param isEqualToString:uid]) { + count++; + } } - } - }); + dispatch_semaphore_signal(sem); + }]; + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC))); + } return count; } diff --git a/scripts/ios/notification-tests/README.md b/scripts/ios/notification-tests/README.md new file mode 100644 index 0000000000..2a83f72ded --- /dev/null +++ b/scripts/ios/notification-tests/README.md @@ -0,0 +1,19 @@ +# iOS Native Notification Tests + +This directory contains native XCTest assets used by CI and local scripts to +validate local-notification behavior using the standard Xcode test runner. + +## Files + +- `native-tests/LocalNotificationBehaviorTests.m` + - XCTest cases that verify duplicate identifier replacement, cancel-by-id, + and `__ios_id__` userInfo persistence. +- `install-native-notification-tests.sh` + - Copies test sources into generated Xcode project and wires them into the + `HelloCodenameOneTests` target. + +## Related Runner + +- `scripts/run-ios-native-tests.sh` + - Calls `install-native-notification-tests.sh` and runs `xcodebuild test` + for the generated iOS workspace on simulator. diff --git a/scripts/ios/notification-tests/install-native-notification-tests.sh b/scripts/ios/notification-tests/install-native-notification-tests.sh new file mode 100755 index 0000000000..adf61c832d --- /dev/null +++ b/scripts/ios/notification-tests/install-native-notification-tests.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Inject native XCTest sources into the generated iOS Xcode project. +# This keeps tests in-repo under scripts/ios/notification-tests/native-tests +# while attaching them to the generated HelloCodenameOneTests target. + +set -euo pipefail + +PROJECT_DIR="${1:-}" +TEST_SOURCES_DIR="${2:-scripts/ios/notification-tests/native-tests}" + +if [ -z "$PROJECT_DIR" ] || [ ! -d "$PROJECT_DIR" ]; then + echo "[install-native-notification-tests] project directory missing: $PROJECT_DIR" >&2 + exit 2 +fi +if [ ! -d "$TEST_SOURCES_DIR" ]; then + echo "[install-native-notification-tests] test sources directory missing: $TEST_SOURCES_DIR" >&2 + exit 2 +fi + +PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)" +TEST_SOURCES_DIR="$(cd "$TEST_SOURCES_DIR" && pwd)" +DEST_DIR="$PROJECT_DIR/NativeNotificationTests" +mkdir -p "$DEST_DIR" + +# Copy source files so they live alongside generated project sources. +find "$TEST_SOURCES_DIR" -type f \( -name '*.m' -o -name '*.mm' -o -name '*.swift' -o -name '*.h' \) -print0 | + while IFS= read -r -d '' src; do + cp "$src" "$DEST_DIR/$(basename "$src")" + done + +# Attach copied files to the unit-test target using xcodeproj gem. +ruby - "$PROJECT_DIR" "$DEST_DIR" <<'RUBY' +require 'xcodeproj' +require 'fileutils' + +project_dir = ARGV[0] +dest_dir = ARGV[1] +project_path = Dir[File.join(project_dir, '*.xcodeproj')].first +abort("No .xcodeproj found under #{project_dir}") unless project_path + +project = Xcodeproj::Project.open(project_path) +test_target = project.targets.find { |t| t.product_type == 'com.apple.product-type.bundle.unit-test' } || + project.targets.find { |t| t.name.end_with?('Tests') } +abort("No unit-test target found in #{project_path}") unless test_target +app_target = project.targets.find { |t| t.product_type == 'com.apple.product-type.application' } || + project.targets.find { |t| t.name == test_target.name.sub(/Tests$/, '') } +abort("No app target found in #{project_path}") unless app_target + +# Ensure the unit-test target has a concrete Info.plist file in generated projects. +# Some generated projects point to a plist path that doesn't exist on disk. +plist_name = "#{test_target.name}-Info.plist" +plist_rel = File.join(test_target.name, plist_name) +plist_abs = File.join(project_dir, plist_rel) +FileUtils.mkdir_p(File.dirname(plist_abs)) +unless File.exist?(plist_abs) + File.write(plist_abs, <<~PLIST) + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + + PLIST +end + +# Configure XCTest as a hosted test bundle so APIs that require app process +# context (e.g. UNUserNotificationCenter) are available during tests. +test_target.build_configurations.each do |config| + app_product = app_target.product_name || app_target.name + host_path = "$(BUILT_PRODUCTS_DIR)/#{app_product}.app/#{app_product}" + config.build_settings['TEST_TARGET_NAME'] = app_target.name + config.build_settings['TEST_HOST'] = host_path + config.build_settings['BUNDLE_LOADER'] = host_path + config.build_settings['INFOPLIST_FILE'] = plist_rel +end + +group = project.main_group.find_subpath('NativeNotificationTests', true) +source_files = Dir[File.join(dest_dir, '*.{m,mm,swift}')].sort +abort("No test source files found in #{dest_dir}") if source_files.empty? + +source_files.each do |source| + rel_path = File.join('NativeNotificationTests', File.basename(source)) + file_ref = group.files.find { |f| f.path == rel_path } || group.new_file(rel_path) + unless test_target.source_build_phase.files_references.include?(file_ref) + test_target.source_build_phase.add_file_reference(file_ref, true) + end +end + +framework_group = project.frameworks_group || project.main_group['Frameworks'] || project.main_group.new_group('Frameworks') +user_notifications_ref = framework_group.files.find { |f| f.path == 'System/Library/Frameworks/UserNotifications.framework' } || + framework_group.new_file('System/Library/Frameworks/UserNotifications.framework') +unless test_target.frameworks_build_phase.files_references.include?(user_notifications_ref) + test_target.frameworks_build_phase.add_file_reference(user_notifications_ref, true) +end + +project.save +puts "[install-native-notification-tests] Installed #{source_files.length} native notification test source file(s) into #{test_target.name}" +RUBY diff --git a/scripts/ios/notification-tests/native-tests/LocalNotificationBehaviorTests.m b/scripts/ios/notification-tests/native-tests/LocalNotificationBehaviorTests.m new file mode 100644 index 0000000000..eee8b7496c --- /dev/null +++ b/scripts/ios/notification-tests/native-tests/LocalNotificationBehaviorTests.m @@ -0,0 +1,185 @@ +#import +#import +#import + +#import "com_codenameone_examples_hellocodenameone_LocalNotificationNativeImpl.h" + +@interface CN1FakeNotificationCenter : NSObject +@property(nonatomic, strong) NSMutableDictionary *pending; +@property(nonatomic, strong) NSMutableSet *deliveredRemoved; ++ (instancetype)shared; +- (void)reset; +- (void)addNotificationRequest:(UNNotificationRequest *)request withCompletionHandler:(void (^)(NSError * _Nullable error))completionHandler; +- (void)getPendingNotificationRequestsWithCompletionHandler:(void (^)(NSArray *requests))completionHandler; +- (void)removePendingNotificationRequestsWithIdentifiers:(NSArray *)identifiers; +- (void)removeDeliveredNotificationsWithIdentifiers:(NSArray *)identifiers; +- (void)removeAllPendingNotificationRequests; +- (void)removeAllDeliveredNotifications; +@end + +@implementation CN1FakeNotificationCenter + ++ (instancetype)shared { + static CN1FakeNotificationCenter *center; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + center = [CN1FakeNotificationCenter new]; + center.pending = [NSMutableDictionary dictionary]; + center.deliveredRemoved = [NSMutableSet set]; + }); + return center; +} + +- (void)reset { + [self.pending removeAllObjects]; + [self.deliveredRemoved removeAllObjects]; +} + +- (void)addNotificationRequest:(UNNotificationRequest *)request withCompletionHandler:(void (^)(NSError * _Nullable error))completionHandler { + self.pending[request.identifier] = request; + if (completionHandler) { + completionHandler(nil); + } +} + +- (void)getPendingNotificationRequestsWithCompletionHandler:(void (^)(NSArray *requests))completionHandler { + if (completionHandler) { + completionHandler(self.pending.allValues); + } +} + +- (void)removePendingNotificationRequestsWithIdentifiers:(NSArray *)identifiers { + for (NSString *identifier in identifiers) { + [self.pending removeObjectForKey:identifier]; + } +} + +- (void)removeDeliveredNotificationsWithIdentifiers:(NSArray *)identifiers { + for (NSString *identifier in identifiers) { + [self.deliveredRemoved addObject:identifier]; + } +} + +- (void)removeAllPendingNotificationRequests { + [self.pending removeAllObjects]; +} + +- (void)removeAllDeliveredNotifications { + [self.deliveredRemoved removeAllObjects]; +} + +@end + +static IMP gOriginalCurrentCenterIMP = nil; + +static id cn1FakeCurrentNotificationCenter(id self, SEL _cmd) { + (void)self; + (void)_cmd; + return [CN1FakeNotificationCenter shared]; +} + +@interface LocalNotificationBehaviorTests : XCTestCase +@end + +@implementation LocalNotificationBehaviorTests + ++ (void)setUp { + [super setUp]; + Method classMethod = class_getClassMethod([UNUserNotificationCenter class], @selector(currentNotificationCenter)); + gOriginalCurrentCenterIMP = method_setImplementation(classMethod, (IMP)cn1FakeCurrentNotificationCenter); +} + ++ (void)tearDown { + if (gOriginalCurrentCenterIMP != nil) { + Method classMethod = class_getClassMethod([UNUserNotificationCenter class], @selector(currentNotificationCenter)); + method_setImplementation(classMethod, gOriginalCurrentCenterIMP); + gOriginalCurrentCenterIMP = nil; + } + [super tearDown]; +} + +- (void)setUp { + [super setUp]; + self.continueAfterFailure = NO; + [[CN1FakeNotificationCenter shared] reset]; +} + +- (void)testDuplicateIdentifierReplacesPendingRequest { + NSString *identifier = [self uniqueIdentifier]; + [self addNotificationWithRequestIdentifier:identifier iosId:identifier body:@"first"]; + [self addNotificationWithRequestIdentifier:identifier iosId:identifier body:@"second"]; + + NSArray *matching = [self pendingRequestsMatchingIosId:identifier]; + XCTAssertEqual(matching.count, 1, @"Expected one pending request after replacing duplicate identifier."); + UNNotificationRequest *request = matching.firstObject; + XCTAssertEqualObjects(request.content.body, @"second", @"Expected second request to replace first."); + XCTAssertEqualObjects(request.content.userInfo[@"__ios_id__"], identifier, @"Expected __ios_id__ userInfo roundtrip."); +} + +- (void)testClearByIosIdRemovesAllMatchingRequests { + NSString *iosId = [self uniqueIdentifier]; + [self addNotificationWithRequestIdentifier:[iosId stringByAppendingString:@"-1"] iosId:iosId body:@"a"]; + [self addNotificationWithRequestIdentifier:[iosId stringByAppendingString:@"-2"] iosId:iosId body:@"b"]; + + com_codenameone_examples_hellocodenameone_LocalNotificationNativeImpl *impl = [com_codenameone_examples_hellocodenameone_LocalNotificationNativeImpl new]; + int before = [impl getScheduledLocalNotificationCount:iosId]; + XCTAssertEqual(before, 2, @"Expected two requests with matching __ios_id__."); + + [impl clearScheduledLocalNotifications:iosId]; + + int after = [impl getScheduledLocalNotificationCount:iosId]; + XCTAssertEqual(after, 0, @"Expected all matching requests to be removed."); +} + +- (void)testGetScheduledCountIgnoresNonMatchingRequests { + NSString *target = [self uniqueIdentifier]; + [self addNotificationWithRequestIdentifier:[target stringByAppendingString:@"-ok"] iosId:target body:@"x"]; + [self addNotificationWithRequestIdentifier:[target stringByAppendingString:@"-other"] iosId:[target stringByAppendingString:@"-different"] body:@"y"]; + + com_codenameone_examples_hellocodenameone_LocalNotificationNativeImpl *impl = [com_codenameone_examples_hellocodenameone_LocalNotificationNativeImpl new]; + int count = [impl getScheduledLocalNotificationCount:target]; + XCTAssertEqual(count, 1, @"Expected count to include only matching __ios_id__ values."); +} + +- (NSString *)uniqueIdentifier { + return [NSString stringWithFormat:@"cn1-local-notif-%@", NSUUID.UUID.UUIDString]; +} + +- (void)addNotificationWithRequestIdentifier:(NSString *)requestIdentifier iosId:(NSString *)iosId body:(NSString *)body { + UNMutableNotificationContent *content = [UNMutableNotificationContent new]; + content.title = @"CN1 Local Notification Test"; + content.body = body; + content.userInfo = @{@"__ios_id__": iosId}; + + UNTimeIntervalNotificationTrigger *trigger = + [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:60 repeats:NO]; + UNNotificationRequest *request = + [UNNotificationRequest requestWithIdentifier:requestIdentifier content:content trigger:trigger]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"add notification request"]; + [(id)[UNUserNotificationCenter currentNotificationCenter] + addNotificationRequest:request + withCompletionHandler:^(NSError * _Nullable error) { + XCTAssertNil(error, @"Expected addNotificationRequest to succeed."); + [expectation fulfill]; + }]; + [self waitForExpectations:@[expectation] timeout:2.0]; +} + +- (NSArray *)pendingRequestsMatchingIosId:(NSString *)iosId { + __block NSArray *matching = @[]; + XCTestExpectation *expectation = [self expectationWithDescription:@"fetch pending requests"]; + [(id)[UNUserNotificationCenter currentNotificationCenter] + getPendingNotificationRequestsWithCompletionHandler:^(NSArray * _Nonnull requests) { + NSPredicate *predicate = [NSPredicate predicateWithBlock:^BOOL(UNNotificationRequest *evaluatedObject, NSDictionary *bindings) { + (void)bindings; + return [evaluatedObject.content.userInfo[@"__ios_id__"] isEqualToString:iosId]; + }]; + matching = [requests filteredArrayUsingPredicate:predicate]; + [expectation fulfill]; + }]; + [self waitForExpectations:@[expectation] timeout:2.0]; + return matching; +} + +@end diff --git a/scripts/run-ios-native-tests.sh b/scripts/run-ios-native-tests.sh new file mode 100755 index 0000000000..e2ae3f6c7b --- /dev/null +++ b/scripts/run-ios-native-tests.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Run native XCTest unit tests for generated Codename One iOS project. +# This script: +# 1) installs in-repo native test sources into the generated Xcode project, +# 2) auto-selects an available simulator destination, +# 3) executes `xcodebuild test` using the standard Xcode test runner. + +set -euo pipefail + +ri_log() { echo "[run-ios-native-tests] $1"; } + +if [ $# -lt 1 ]; then + ri_log "Usage: $0 [app_scheme] [test_scheme]" >&2 + exit 2 +fi + +WORKSPACE_PATH="$1" +APP_SCHEME="${2:-}" +TEST_SCHEME="${3:-}" + +if [ ! -d "$WORKSPACE_PATH" ]; then + ri_log "Workspace not found at $WORKSPACE_PATH" >&2 + exit 3 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +if [ -z "$APP_SCHEME" ]; then + APP_SCHEME="$(basename "$WORKSPACE_PATH" .xcworkspace)" +fi +if [ -z "$TEST_SCHEME" ]; then + TEST_SCHEME="${APP_SCHEME}Tests" +fi + +PROJECT_DIR="$(cd "$(dirname "$WORKSPACE_PATH")" && pwd)" + +ri_log "Injecting native notification tests into project at $PROJECT_DIR" +"$REPO_ROOT/scripts/ios/notification-tests/install-native-notification-tests.sh" "$PROJECT_DIR" + +ri_log "Discovering simulator destination for test scheme $TEST_SCHEME" +DESTINATION="$(xcodebuild -workspace "$WORKSPACE_PATH" -scheme "$TEST_SCHEME" -showdestinations 2>/dev/null \ + | sed -n 's/.*{ platform:iOS Simulator,.*id:\([^,}]*\).*/\1/p' \ + | rg -v "placeholder" \ + | head -n 1 \ + | sed 's#^#platform=iOS Simulator,id=#' || true)" +if [ -z "$DESTINATION" ]; then + DESTINATION="platform=iOS Simulator,name=iPhone 16" +fi + +SIMULATOR_ID="$(printf "%s" "$DESTINATION" | sed -n 's/.*id=\([^,]*\).*/\1/p')" +BUNDLE_ID="$(xcodebuild -workspace "$WORKSPACE_PATH" -scheme "$APP_SCHEME" -showBuildSettings 2>/dev/null \ + | sed -n 's/^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = //p' \ + | head -n 1 || true)" + +if [ -n "$SIMULATOR_ID" ]; then + ri_log "Booting simulator $SIMULATOR_ID" + xcrun simctl boot "$SIMULATOR_ID" >/dev/null 2>&1 || true + xcrun simctl bootstatus "$SIMULATOR_ID" -b >/dev/null 2>&1 || true + if [ -n "$BUNDLE_ID" ]; then + ri_log "Granting notifications permission to $BUNDLE_ID on simulator" + xcrun simctl privacy "$SIMULATOR_ID" grant notifications "$BUNDLE_ID" >/dev/null 2>&1 || true + fi +fi + +ARTIFACTS_DIR="${ARTIFACTS_DIR:-$REPO_ROOT/artifacts}" +mkdir -p "$ARTIFACTS_DIR" +TEST_LOG="$ARTIFACTS_DIR/xcode-native-tests.log" + +ri_log "Running xcodebuild test (scheme=$TEST_SCHEME, destination=$DESTINATION)" +set +e +xcodebuild \ + -workspace "$WORKSPACE_PATH" \ + -scheme "$TEST_SCHEME" \ + -destination "$DESTINATION" \ + test | tee "$TEST_LOG" +RC=${PIPESTATUS[0]} +set -e + +ri_log "xcodebuild test exit code: $RC" +exit "$RC"