From 8fe6985b34538e51f79b075db34ac085e27b6edb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:18:53 +0300 Subject: [PATCH 01/14] Fix AdMob iOS linking, Android banner sizing, and mock interstitial display --- .../workflows/ad-cn1lib-ios-native-check.yml | 17 ++++ .../workflows/admob-android-runtime-check.yml | 45 +++++++++++ .../codename1/ads/admob/AdMobNativeImpl.java | 9 ++- .../codenameone_library_appended.properties | 4 +- maven/cn1-admob/common/pom.xml | 2 + .../codename1/ads/mock/MockAdProvider.java | 51 +++++++++++- maven/core-unittests/pom.xml | 6 ++ .../com/codename1/ads/MockAdProviderTest.java | 81 +++++++++++++++++++ scripts/check-admob-ios-link.sh | 62 ++++++++++++++ .../cn1lib-api-check/admob-runtime/.gitignore | 3 + .../cn1lib-api-check/admob-runtime/README.md | 12 +++ .../admob-runtime/build.gradle | 33 ++++++++ .../admob-runtime/gradle.properties | 1 + .../admob-runtime/settings.gradle | 3 + .../ads/admob/BannerMeasurementTest.java | 28 +++++++ .../src/main/AndroidManifest.xml | 7 ++ .../codename1/ads/admob/AdMobCallback.java | 15 ++++ .../codename1/ads/admob/ProbeActivity.java | 8 ++ .../impl/android/AndroidImplementation.java | 11 +++ .../impl/android/AndroidNativeUtil.java | 6 ++ .../template.xcodeproj/project.pbxproj | 8 ++ 21 files changed, 406 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/admob-android-runtime-check.yml create mode 100644 maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java create mode 100755 scripts/check-admob-ios-link.sh create mode 100644 scripts/cn1lib-api-check/admob-runtime/.gitignore create mode 100644 scripts/cn1lib-api-check/admob-runtime/README.md create mode 100644 scripts/cn1lib-api-check/admob-runtime/build.gradle create mode 100644 scripts/cn1lib-api-check/admob-runtime/gradle.properties create mode 100644 scripts/cn1lib-api-check/admob-runtime/settings.gradle create mode 100644 scripts/cn1lib-api-check/admob-runtime/src/androidTest/java/com/codename1/ads/admob/BannerMeasurementTest.java create mode 100644 scripts/cn1lib-api-check/admob-runtime/src/main/AndroidManifest.xml create mode 100644 scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/AdMobCallback.java create mode 100644 scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/ProbeActivity.java create mode 100644 scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidImplementation.java create mode 100644 scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidNativeUtil.java diff --git a/.github/workflows/ad-cn1lib-ios-native-check.yml b/.github/workflows/ad-cn1lib-ios-native-check.yml index 738daa72248..0d61b1b62ce 100644 --- a/.github/workflows/ad-cn1lib-ios-native-check.yml +++ b/.github/workflows/ad-cn1lib-ios-native-check.yml @@ -20,6 +20,8 @@ on: - 'maven/cn1-admob/**' - 'maven/cn1-applovin/**' - 'maven/cn1-unity-levelplay/**' + - 'scripts/check-admob-ios-link.sh' + - 'vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj' - 'vm/ByteCodeTranslator/src/cn1_globals.h' - 'vm/ByteCodeTranslator/src/cn1_virtual_thread.h' - '.github/workflows/ad-cn1lib-ios-native-check.yml' @@ -29,6 +31,8 @@ on: - 'maven/cn1-admob/**' - 'maven/cn1-applovin/**' - 'maven/cn1-unity-levelplay/**' + - 'scripts/check-admob-ios-link.sh' + - 'vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj' - 'vm/ByteCodeTranslator/src/cn1_globals.h' - 'vm/ByteCodeTranslator/src/cn1_virtual_thread.h' - '.github/workflows/ad-cn1lib-ios-native-check.yml' @@ -200,3 +204,16 @@ jobs: -destination 'generic/platform=iOS Simulator' \ CODE_SIGNING_ALLOWED=NO \ build + + link-admob-app: + name: Link AdMob app (${{ matrix.sdk }}) + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + sdk: [iphoneos, iphonesimulator] + steps: + - uses: actions/checkout@v6 + - run: brew install xcodegen + - name: Link the native bridge and SDK into an app + run: scripts/check-admob-ios-link.sh "$RUNNER_TEMP/admob-link" "${{ matrix.sdk }}" diff --git a/.github/workflows/admob-android-runtime-check.yml b/.github/workflows/admob-android-runtime-check.yml new file mode 100644 index 00000000000..e5f7533bab3 --- /dev/null +++ b/.github/workflows/admob-android-runtime-check.yml @@ -0,0 +1,45 @@ +name: AdMob Android banner runtime check + +on: + workflow_dispatch: + pull_request: + branches: [master] + paths: + - 'maven/cn1-admob/**' + - 'scripts/cn1lib-api-check/admob-runtime/**' + - '.github/workflows/admob-android-runtime-check.yml' + push: + branches: [master] + paths: + - 'maven/cn1-admob/**' + - 'scripts/cn1lib-api-check/admob-runtime/**' + - '.github/workflows/admob-android-runtime-check.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + banner-measurement: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.13' + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + disable-animations: true + script: gradle -p scripts/cn1lib-api-check/admob-runtime connectedDebugAndroidTest diff --git a/maven/cn1-admob/android/src/main/java/com/codename1/ads/admob/AdMobNativeImpl.java b/maven/cn1-admob/android/src/main/java/com/codename1/ads/admob/AdMobNativeImpl.java index 693c02811ca..a9a5e9c7d82 100644 --- a/maven/cn1-admob/android/src/main/java/com/codename1/ads/admob/AdMobNativeImpl.java +++ b/maven/cn1-admob/android/src/main/java/com/codename1/ads/admob/AdMobNativeImpl.java @@ -340,7 +340,14 @@ public View createBanner(final int handle, final String adUnitId, final int size public void run() { AdView adView = new AdView(activity); adView.setAdUnitId(adUnitId); - adView.setAdSize(mapSize(activity, sizeType, widthDp)); + AdSize size = mapSize(activity, sizeType, widthDp); + adView.setAdSize(size); + // AndroidPeer reads measured dimensions in its default peer mode. + // The AdView has no parent yet, so Android has not measured it: + // wrapping it now would cache a 1px preferred height in CN1. + adView.measure( + View.MeasureSpec.makeMeasureSpec(size.getWidthInPixels(activity), View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(size.getHeightInPixels(activity), View.MeasureSpec.EXACTLY)); banners.put(handle, adView); out[0] = adView; } diff --git a/maven/cn1-admob/common/codenameone_library_appended.properties b/maven/cn1-admob/common/codenameone_library_appended.properties index f47bb32ba04..d92d1bde09d 100644 --- a/maven/cn1-admob/common/codenameone_library_appended.properties +++ b/maven/cn1-admob/common/codenameone_library_appended.properties @@ -1 +1,3 @@ -# Reserved for build hints appended to the consuming app's properties. +# Google Mobile Ads contains C++ objects even when the host app is Objective-C. +# Append, preserving any frameworks the application already links. +codename1.arg.ios.add_libs=;libc++.tbd diff --git a/maven/cn1-admob/common/pom.xml b/maven/cn1-admob/common/pom.xml index 6764e82abcd..e02149edf41 100644 --- a/maven/cn1-admob/common/pom.xml +++ b/maven/cn1-admob/common/pom.xml @@ -73,6 +73,8 @@ + diff --git a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java index 2b9277bbebb..8627413f4b8 100644 --- a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java +++ b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java @@ -38,10 +38,14 @@ import com.codename1.ads.spi.BannerAdSession; import com.codename1.ads.spi.FullScreenAdSession; import com.codename1.ads.spi.NativeAdProvider; +import com.codename1.ui.Button; import com.codename1.ui.CN; +import com.codename1.ui.Command; import com.codename1.ui.Component; import com.codename1.ui.Container; +import com.codename1.ui.Form; import com.codename1.ui.Label; +import com.codename1.ui.events.ActionEvent; import com.codename1.ui.geom.Dimension; import com.codename1.ui.layouts.BorderLayout; @@ -116,6 +120,8 @@ private static final class MockFullScreen implements FullScreenAdSession { private final AdFormat format; private AdSessionCallback cb; private boolean loaded; + private Form adForm; + private Form previousForm; MockFullScreen(AdFormat format) { this.format = format; @@ -147,13 +153,48 @@ public void show() { cb.onShowFailed(new AdError(AdError.CODE_INTERNAL, "mock", "No ad loaded")); return; } + if (adForm != null) { + cb.onShowFailed(new AdError(AdError.CODE_INTERNAL, "mock", "An ad is already showing")); + return; + } loaded = false; + previousForm = CN.getCurrentForm(); + adForm = new Form("Mock advertisement", new BorderLayout()); + adForm.add(BorderLayout.CENTER, new Label("Advertisement")); + Button close = new Button("Close ad"); + close.addActionListener(evt -> closeAd(true)); + adForm.add(BorderLayout.SOUTH, close); + adForm.setBackCommand(new Command("Close ad") { + @Override + public void actionPerformed(ActionEvent evt) { + closeAd(true); + } + }); + adForm.show(); cb.onShown(); - cb.onImpression(); - if (format == AdFormat.REWARDED || format == AdFormat.REWARDED_INTERSTITIAL) { - cb.onUserEarnedReward(new RewardItem("coins", 10)); + // A listener may dispose the ad synchronously from onShown(). + if (adForm != null) { + cb.onImpression(); + } + } + + private void closeAd(boolean notify) { + if (adForm == null) { + return; + } + Form closing = adForm; + Form previous = previousForm; + adForm = null; + previousForm = null; + if (previous != null && CN.getCurrentForm() == closing) { + previous.showBack(); + } + if (notify) { + if (format == AdFormat.REWARDED || format == AdFormat.REWARDED_INTERSTITIAL) { + cb.onUserEarnedReward(new RewardItem("coins", 10)); + } + cb.onDismissed(); } - cb.onDismissed(); } @Override @@ -162,6 +203,8 @@ public void setAutoShowOnForeground(boolean enabled) { @Override public void dispose() { + loaded = false; + closeAd(false); } } diff --git a/maven/core-unittests/pom.xml b/maven/core-unittests/pom.xml index 584ed4d1e2f..f4a9bfc2347 100644 --- a/maven/core-unittests/pom.xml +++ b/maven/core-unittests/pom.xml @@ -167,6 +167,12 @@ + + com.codenameone + cn1-ads-mock + ${project.version} + test + com.codenameone codenameone-factory diff --git a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java new file mode 100644 index 00000000000..b9894e6a3c4 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java @@ -0,0 +1,81 @@ +package com.codename1.ads; + +import com.codename1.ads.mock.MockAdProvider; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Form; +import com.codename1.ui.events.ActionEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class MockAdProviderTest extends UITestBase { + private final List events = new ArrayList(); + + @org.junit.jupiter.api.BeforeEach + void resetEvents() { events.clear(); } + + private AdListener listener() { + return new AdListener() { + @Override public void onShown() { events.add("shown"); } + @Override public void onImpression() { events.add("impression"); } + @Override public void onDismissed() { events.add("dismissed"); } + }; + } + + @FormTest + void interstitialStaysVisibleUntilClosedAndRestoresPreviousForm() { + Form previous = CN.getCurrentForm(); + MockAdProvider.install(); + InterstitialAd ad = new InterstitialAd("mock"); + ad.setAdListener(listener()); + ad.load(); + ad.show(); + Form showing = CN.getCurrentForm(); + assertNotSame(previous, showing); + assertEquals("Mock advertisement", showing.getTitle()); + assertEquals(Arrays.asList("shown", "impression"), events); + Button close = (Button) showing.getContentPane().getComponentAt(1); + close.pressed(); + close.released(); + assertSame(previous, CN.getCurrentForm()); + assertEquals(Arrays.asList("shown", "impression", "dismissed"), events); + showing.getBackCommand().actionPerformed(new ActionEvent(showing)); + assertEquals(3, events.size(), "Closing twice must not repeat callbacks"); + ad.dispose(); + } + + @FormTest + void rewardedBackCloseDeliversRewardBeforeDismissal() { + Form previous = CN.getCurrentForm(); + MockAdProvider.install(); + RewardedAd ad = new RewardedAd("mock"); + ad.setAdListener(listener()); + ad.setOnUserEarnedRewardListener(reward -> events.add("reward")); + ad.load(); + ad.show(); + Form showing = CN.getCurrentForm(); + assertEquals(Arrays.asList("shown", "impression"), events); + showing.getBackCommand().actionPerformed(new ActionEvent(showing)); + assertSame(previous, CN.getCurrentForm()); + assertEquals(Arrays.asList("shown", "impression", "reward", "dismissed"), events); + ad.dispose(); + } + + @FormTest + void disposingVisibleAdRestoresFormWithoutRewardOrDismissal() { + Form previous = CN.getCurrentForm(); + MockAdProvider.install(); + RewardedAd ad = new RewardedAd("mock"); + ad.setAdListener(listener()); + ad.setOnUserEarnedRewardListener(reward -> events.add("reward")); + ad.load(); + ad.show(); + ad.dispose(); + assertSame(previous, CN.getCurrentForm()); + assertEquals(Arrays.asList("shown", "impression"), events); + } +} diff --git a/scripts/check-admob-ios-link.sh b/scripts/check-admob-ios-link.sh new file mode 100755 index 00000000000..683aafe02be --- /dev/null +++ b/scripts/check-admob-ios-link.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Link a real Objective-C app against the shipped bridge and pinned SDK. +# Usage: scripts/check-admob-ios-link.sh [output-directory] [iphoneos|iphonesimulator] +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PROBE="${1:-$(mktemp -d /tmp/cn1-admob-link.XXXXXX)}" +SDK="${2:-iphoneos}" +mkdir -p "$PROBE/Sources" +cp "$ROOT"/maven/cn1-admob/ios/src/main/objectivec/* "$PROBE/Sources/" +cp "$ROOT"/vm/ByteCodeTranslator/src/{cn1_globals.h,cn1_virtual_thread.h} "$PROBE/" +printf '#pragma once\n' > "$PROBE/cn1_class_method_index.h" +cat > "$PROBE/Prefix.pch" <<'PCH' +#import +#import +#include "cn1_globals.h" +PCH +cat > "$PROBE/Sources/main.m" <<'OBJC' +#import "com_codename1_ads_admob_AdMobNativeImpl.h" +// Only generated Java runtime entry points are stubbed. All SDK references +// must resolve through the real CocoaPods integration and final app linker. +struct ThreadLocalData* getThreadLocalData(void) { return NULL; } +JAVA_OBJECT fromNSString(CODENAME_ONE_THREAD_STATE, NSString* str) { return JAVA_NULL; } +void com_codename1_ads_admob_AdMobCallback_fire___int_int_int_java_lang_String_java_lang_String_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_INT handle, JAVA_INT event, JAVA_INT code, + JAVA_OBJECT message, JAVA_OBJECT rewardType, JAVA_INT rewardAmount) {} +int main(int argc, char** argv) { + @autoreleasepool { + return [[[com_codename1_ads_admob_AdMobNativeImpl alloc] init] isSupported] ? 0 : 1; + } +} +OBJC +python3 - "$ROOT" "$PROBE" <<'PY' +import json, pathlib, re, sys +root, probe = map(pathlib.Path, sys.argv[1:]) +props = root / 'maven/cn1-admob/common' +pod = re.search(r'^codename1.arg.ios.pods=(.+)$', (props / 'codenameone_library_required.properties').read_text(), re.M).group(1) +name, version = pod.split(' ', 1) +(probe / 'Podfile').write_text("platform :ios, '14.0'\ntarget 'AdMobLinkProbe' do\n use_frameworks!\n pod '%s', '%s'\nend\n" % (name, version)) +# Consume the same search paths and library hints as a generated CN1 app. +template = (root / 'vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj').read_text() +blocks = re.findall(r'LIBRARY_SEARCH_PATHS = \((.*?)\);', template, re.S) +paths = re.findall(r'"([^"]+)"', blocks[0]) +assert all(re.findall(r'"([^"]+)"', block) == paths for block in blocks) +paths = [p for p in paths if 'template-src' not in p] +appended = (props / 'codenameone_library_appended.properties').read_text() +match = re.search(r'^codename1.arg.ios.add_libs=(.*)$', appended, re.M) +libs = [] if not match else [v for v in match.group(1).split(';') if v] +settings = {'CLANG_ENABLE_MODULES': 'YES', 'CLANG_ENABLE_OBJC_ARC': 'NO', + 'CODE_SIGNING_ALLOWED': 'NO', 'GENERATE_INFOPLIST_FILE': 'YES', + 'GCC_PREFIX_HEADER': 'Prefix.pch', 'GCC_PRECOMPILE_PREFIX_HEADER': 'NO', + 'HEADER_SEARCH_PATHS': ['$(inherited)', '$(SRCROOT)'], + 'LIBRARY_SEARCH_PATHS': paths, 'OTHER_LDFLAGS': ['$(inherited)', '-ObjC'], + 'DEAD_CODE_STRIPPING': 'NO'} +project = {'name': 'AdMobLinkProbe', 'options': {'bundleIdPrefix': 'com.codenameone.test', 'deploymentTarget': {'iOS': '14.0'}}, + 'targets': {'AdMobLinkProbe': {'type': 'application', 'platform': 'iOS', 'sources': ['Sources'], + 'dependencies': [{'sdk': lib} for lib in libs], 'settings': {'base': settings}}}} +(probe / 'project.yml').write_text(json.dumps(project, indent=2)) +PY +(cd "$PROBE" && xcodegen generate && pod install) +xcodebuild -workspace "$PROBE/AdMobLinkProbe.xcworkspace" -scheme AdMobLinkProbe \ + -configuration Release -sdk "$SDK" -derivedDataPath "$PROBE/build-$SDK" \ + CODE_SIGNING_ALLOWED=NO build diff --git a/scripts/cn1lib-api-check/admob-runtime/.gitignore b/scripts/cn1lib-api-check/admob-runtime/.gitignore new file mode 100644 index 00000000000..aa8487e5205 --- /dev/null +++ b/scripts/cn1lib-api-check/admob-runtime/.gitignore @@ -0,0 +1,3 @@ +.gradle/ +build/ +local.properties diff --git a/scripts/cn1lib-api-check/admob-runtime/README.md b/scripts/cn1lib-api-check/admob-runtime/README.md new file mode 100644 index 00000000000..b0d6f396485 --- /dev/null +++ b/scripts/cn1lib-api-check/admob-runtime/README.md @@ -0,0 +1,12 @@ +# AdMob banner measurement regression + +With JDK 17, Android SDK 36, Gradle 8.13, and a running Android emulator: + +```sh +gradle -p scripts/cn1lib-api-check/admob-runtime connectedDebugAndroidTest +``` + +Compiles the shipped native implementation against its declared Google SDKs. +Checks all five banner formats have their SDK pixel dimensions before CN1 wraps +or attaches the view, without requesting ads. Only the CN1 activity/thread bridge +and Java callback sink are replaced; Android and AdView are real. diff --git a/scripts/cn1lib-api-check/admob-runtime/build.gradle b/scripts/cn1lib-api-check/admob-runtime/build.gradle new file mode 100644 index 00000000000..a12e0d88b18 --- /dev/null +++ b/scripts/cn1lib-api-check/admob-runtime/build.gradle @@ -0,0 +1,33 @@ +plugins { id 'com.android.application' version '8.13.2' } + +def root = file('../../..') +def props = new Properties() +file("$root/maven/cn1-admob/common/codenameone_library_required.properties").withInputStream { props.load(it) } + +android { + namespace 'com.codename1.ads.admob.probe' + compileSdk 36 + defaultConfig { + applicationId 'com.codename1.ads.admob.probe' + minSdk 23 + targetSdk 36 + testInstrumentationRunner 'android.test.InstrumentationTestRunner' + testInstrumentationRunnerArguments class: 'com.codename1.ads.admob.BannerMeasurementTest' + } + useLibrary 'android.test.runner' + useLibrary 'android.test.base' + sourceSets.main.java.srcDirs += ["$root/maven/cn1-admob/android/src/main/java"] + compileOptions { sourceCompatibility JavaVersion.VERSION_1_8; targetCompatibility JavaVersion.VERSION_1_8 } +} +dependencies { + // Compile and run against precisely the dependencies shipped to apps. + (props.getProperty('codename1.arg.android.gradleDep') =~ /'([^']+)'/).each { implementation it[1] } +} + +tasks.register('stageErrorCodes', Copy) { + from "$root/maven/cn1-admob/common/src/main/java" + include '**/AdMobErrorCodes.java' + into layout.buildDirectory.dir('generated/common') +} +android.sourceSets.main.java.srcDir layout.buildDirectory.dir('generated/common') +tasks.named('preBuild').configure { dependsOn 'stageErrorCodes' } diff --git a/scripts/cn1lib-api-check/admob-runtime/gradle.properties b/scripts/cn1lib-api-check/admob-runtime/gradle.properties new file mode 100644 index 00000000000..5bac8ac5046 --- /dev/null +++ b/scripts/cn1lib-api-check/admob-runtime/gradle.properties @@ -0,0 +1 @@ +android.useAndroidX=true diff --git a/scripts/cn1lib-api-check/admob-runtime/settings.gradle b/scripts/cn1lib-api-check/admob-runtime/settings.gradle new file mode 100644 index 00000000000..509a8e9018a --- /dev/null +++ b/scripts/cn1lib-api-check/admob-runtime/settings.gradle @@ -0,0 +1,3 @@ +pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } +dependencyResolutionManagement { repositories { google(); mavenCentral() } } +rootProject.name = 'admob-runtime' diff --git a/scripts/cn1lib-api-check/admob-runtime/src/androidTest/java/com/codename1/ads/admob/BannerMeasurementTest.java b/scripts/cn1lib-api-check/admob-runtime/src/androidTest/java/com/codename1/ads/admob/BannerMeasurementTest.java new file mode 100644 index 00000000000..4d7427358f6 --- /dev/null +++ b/scripts/cn1lib-api-check/admob-runtime/src/androidTest/java/com/codename1/ads/admob/BannerMeasurementTest.java @@ -0,0 +1,28 @@ +package com.codename1.ads.admob; + +import android.test.ActivityInstrumentationTestCase2; +import com.google.android.gms.ads.AdView; +import com.google.android.gms.ads.AdSize; + +public class BannerMeasurementTest extends ActivityInstrumentationTestCase2 { + public BannerMeasurementTest() { super(ProbeActivity.class); } + + public void testBannerHasSdkDimensionsBeforePeerOrAdLoad() throws Throwable { + final ProbeActivity activity = getActivity(); + runTestOnUiThread(new Runnable() { + public void run() { + AdMobNativeImpl bridge = new AdMobNativeImpl(); + for (int format = 0; format <= 4; format++) { + AdView banner = (AdView) bridge.createBanner(format, + "ca-app-pub-3940256099942544/6300978111", format, 320); + assertNull("Must be sized before it is attached to CN1", banner.getParent()); + AdSize size = banner.getAdSize(); + assertEquals(size.getWidthInPixels(activity), banner.getMeasuredWidth()); + assertEquals(size.getHeightInPixels(activity), banner.getMeasuredHeight()); + assertTrue("A SOUTH layout must reserve visible ad height", banner.getMeasuredHeight() > 1); + bridge.disposeBanner(format); + } + } + }); + } +} diff --git a/scripts/cn1lib-api-check/admob-runtime/src/main/AndroidManifest.xml b/scripts/cn1lib-api-check/admob-runtime/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..dd29704a13e --- /dev/null +++ b/scripts/cn1lib-api-check/admob-runtime/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/AdMobCallback.java b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/AdMobCallback.java new file mode 100644 index 00000000000..15d516f67c4 --- /dev/null +++ b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/AdMobCallback.java @@ -0,0 +1,15 @@ +package com.codename1.ads.admob; + +// No Java runtime is needed to measure a native banner. No ad requests are sent. +public final class AdMobCallback { + public static final int LOADED = 1; + public static final int FAILED = 2; + public static final int SHOWN = 3; + public static final int SHOW_FAILED = 4; + public static final int DISMISSED = 5; + public static final int IMPRESSION = 6; + public static final int CLICKED = 7; + public static final int REWARD = 8; + public static final int CONSENT_COMPLETE = 9; + public static void fire(int handle, int event, int code, String message, String rewardType, int amount) {} +} diff --git a/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/ProbeActivity.java b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/ProbeActivity.java new file mode 100644 index 00000000000..ebb3027794b --- /dev/null +++ b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/ProbeActivity.java @@ -0,0 +1,8 @@ +package com.codename1.ads.admob; + +public class ProbeActivity extends android.app.Activity { + @Override public void onCreate(android.os.Bundle state) { + super.onCreate(state); + com.codename1.impl.android.AndroidNativeUtil.activity = this; + } +} diff --git a/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidImplementation.java b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidImplementation.java new file mode 100644 index 00000000000..afd16dbaaac --- /dev/null +++ b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidImplementation.java @@ -0,0 +1,11 @@ +package com.codename1.impl.android; + +public final class AndroidImplementation { + public static void runOnUiThreadAndBlock(Runnable task) { + // Instrumentation invokes createBanner on Android's UI thread. + if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) { + throw new AssertionError("The probe must run on the UI thread"); + } + task.run(); + } +} diff --git a/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidNativeUtil.java b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidNativeUtil.java new file mode 100644 index 00000000000..5c97e266078 --- /dev/null +++ b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidNativeUtil.java @@ -0,0 +1,6 @@ +package com.codename1.impl.android; + +public final class AndroidNativeUtil { + public static android.app.Activity activity; + public static android.app.Activity getActivity() { return activity; } +} diff --git a/vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj b/vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj index 304a953222f..37c451c9b06 100644 --- a/vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj +++ b/vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj @@ -309,6 +309,10 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/template-src", + // Prebuilt pods can require Swift compatibility archives even in + // an Objective-C app. TOOLCHAIN_DIR can point at the Metal toolchain. + "$(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME)", + "$(SDKROOT)/usr/lib/swift", ); }; name = Debug; @@ -352,6 +356,10 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/template-src", + // Prebuilt pods can require Swift compatibility archives even in + // an Objective-C app. TOOLCHAIN_DIR can point at the Metal toolchain. + "$(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME)", + "$(SDKROOT)/usr/lib/swift", ); }; name = Release; From 1c559afb16bc10086d77fb50b3fae16e752ec826 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:20:30 +0300 Subject: [PATCH 02/14] Add required copyright headers to advertising regression tests --- .../com/codename1/ads/MockAdProviderTest.java | 22 +++++++++++++++++++ .../ads/admob/BannerMeasurementTest.java | 22 +++++++++++++++++++ .../codename1/ads/admob/AdMobCallback.java | 22 +++++++++++++++++++ .../codename1/ads/admob/ProbeActivity.java | 22 +++++++++++++++++++ .../impl/android/AndroidImplementation.java | 22 +++++++++++++++++++ .../impl/android/AndroidNativeUtil.java | 22 +++++++++++++++++++ 6 files changed, 132 insertions(+) diff --git a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java index b9894e6a3c4..a3b6e892c16 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.ads; import com.codename1.ads.mock.MockAdProvider; diff --git a/scripts/cn1lib-api-check/admob-runtime/src/androidTest/java/com/codename1/ads/admob/BannerMeasurementTest.java b/scripts/cn1lib-api-check/admob-runtime/src/androidTest/java/com/codename1/ads/admob/BannerMeasurementTest.java index 4d7427358f6..76fe3db739a 100644 --- a/scripts/cn1lib-api-check/admob-runtime/src/androidTest/java/com/codename1/ads/admob/BannerMeasurementTest.java +++ b/scripts/cn1lib-api-check/admob-runtime/src/androidTest/java/com/codename1/ads/admob/BannerMeasurementTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.ads.admob; import android.test.ActivityInstrumentationTestCase2; diff --git a/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/AdMobCallback.java b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/AdMobCallback.java index 15d516f67c4..106e7962fe8 100644 --- a/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/AdMobCallback.java +++ b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/AdMobCallback.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.ads.admob; // No Java runtime is needed to measure a native banner. No ad requests are sent. diff --git a/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/ProbeActivity.java b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/ProbeActivity.java index ebb3027794b..7ece17a0155 100644 --- a/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/ProbeActivity.java +++ b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/ProbeActivity.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.ads.admob; public class ProbeActivity extends android.app.Activity { diff --git a/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidImplementation.java b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidImplementation.java index afd16dbaaac..15d4eeed2b1 100644 --- a/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidImplementation.java +++ b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidImplementation.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.impl.android; public final class AndroidImplementation { diff --git a/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidNativeUtil.java b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidNativeUtil.java index 5c97e266078..1b13db08411 100644 --- a/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidNativeUtil.java +++ b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidNativeUtil.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.impl.android; public final class AndroidNativeUtil { From 3a372f73bf58f1b1aced54ea57faea30e7aa0573 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 06:14:09 +0300 Subject: [PATCH 03/14] Link SDK stub libraries in generated Apple projects --- .../workflows/ad-cn1lib-ios-native-check.yml | 15 +-- scripts/check-admob-ios-link.sh | 93 +++++++++++++------ .../tools/translator/ByteCodeTranslator.java | 9 +- .../BytecodeInstructionIntegrationTest.java | 15 ++- 4 files changed, 95 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ad-cn1lib-ios-native-check.yml b/.github/workflows/ad-cn1lib-ios-native-check.yml index 0d61b1b62ce..5aeece40b47 100644 --- a/.github/workflows/ad-cn1lib-ios-native-check.yml +++ b/.github/workflows/ad-cn1lib-ios-native-check.yml @@ -21,9 +21,8 @@ on: - 'maven/cn1-applovin/**' - 'maven/cn1-unity-levelplay/**' - 'scripts/check-admob-ios-link.sh' - - 'vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj' - - 'vm/ByteCodeTranslator/src/cn1_globals.h' - - 'vm/ByteCodeTranslator/src/cn1_virtual_thread.h' + - 'vm/ByteCodeTranslator/**' + - 'vm/pom.xml' - '.github/workflows/ad-cn1lib-ios-native-check.yml' push: branches: [master] @@ -32,9 +31,8 @@ on: - 'maven/cn1-applovin/**' - 'maven/cn1-unity-levelplay/**' - 'scripts/check-admob-ios-link.sh' - - 'vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj' - - 'vm/ByteCodeTranslator/src/cn1_globals.h' - - 'vm/ByteCodeTranslator/src/cn1_virtual_thread.h' + - 'vm/ByteCodeTranslator/**' + - 'vm/pom.xml' - '.github/workflows/ad-cn1lib-ios-native-check.yml' concurrency: @@ -214,6 +212,9 @@ jobs: sdk: [iphoneos, iphonesimulator] steps: - uses: actions/checkout@v6 - - run: brew install xcodegen + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '8' - name: Link the native bridge and SDK into an app run: scripts/check-admob-ios-link.sh "$RUNNER_TEMP/admob-link" "${{ matrix.sdk }}" diff --git a/scripts/check-admob-ios-link.sh b/scripts/check-admob-ios-link.sh index 683aafe02be..4f31758eaec 100755 --- a/scripts/check-admob-ios-link.sh +++ b/scripts/check-admob-ios-link.sh @@ -5,6 +5,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" PROBE="${1:-$(mktemp -d /tmp/cn1-admob-link.XXXXXX)}" SDK="${2:-iphoneos}" +# Use Java 8, matching the translator build in CI. +mvn -B -f "$ROOT/vm/pom.xml" -pl ByteCodeTranslator -am -DskipTests package mkdir -p "$PROBE/Sources" cp "$ROOT"/maven/cn1-admob/ios/src/main/objectivec/* "$PROBE/Sources/" cp "$ROOT"/vm/ByteCodeTranslator/src/{cn1_globals.h,cn1_virtual_thread.h} "$PROBE/" @@ -29,34 +31,73 @@ int main(int argc, char** argv) { } } OBJC -python3 - "$ROOT" "$PROBE" <<'PY' -import json, pathlib, re, sys -root, probe = map(pathlib.Path, sys.argv[1:]) +# Translate a minimal Java entry point, with the shipped library hint. The +# resulting framework references and library search paths stay intact below. +cat > "$PROBE/AdMobLinkProbe.java" <<'JAVA' +public class AdMobLinkProbe { + public static void main(String[] args) {} +} +JAVA +javac -d "$PROBE/Sources" "$PROBE/AdMobLinkProbe.java" +LIBS="$(sed -n 's/^codename1\.arg\.ios\.add_libs=;*//p' \ + "$ROOT/maven/cn1-admob/common/codenameone_library_appended.properties")" +java -jar "$ROOT/vm/ByteCodeTranslator/dist/ByteCodeTranslator.jar" ios \ + "$PROBE/Sources" "$PROBE/generated" AdMobLinkProbe com.codenameone.test \ + AdMobLinkProbe 1.0 ios "${LIBS:-none}" +PROJECT="$PROBE/generated/dist" +cp "$PROBE"/{cn1_globals.h,cn1_virtual_thread.h,cn1_class_method_index.h,Prefix.pch} "$PROJECT/" +plutil -convert json -o "$PROBE/project.json" "$PROJECT/AdMobLinkProbe.xcodeproj/project.pbxproj" +python3 - "$ROOT" "$PROBE" "$PROJECT" "$LIBS" <<'PYTHON' +import json, pathlib, plistlib, re, sys +root, probe, project = map(pathlib.Path, sys.argv[1:4]) props = root / 'maven/cn1-admob/common' pod = re.search(r'^codename1.arg.ios.pods=(.+)$', (props / 'codenameone_library_required.properties').read_text(), re.M).group(1) name, version = pod.split(' ', 1) -(probe / 'Podfile').write_text("platform :ios, '14.0'\ntarget 'AdMobLinkProbe' do\n use_frameworks!\n pod '%s', '%s'\nend\n" % (name, version)) -# Consume the same search paths and library hints as a generated CN1 app. -template = (root / 'vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj').read_text() -blocks = re.findall(r'LIBRARY_SEARCH_PATHS = \((.*?)\);', template, re.S) -paths = re.findall(r'"([^"]+)"', blocks[0]) -assert all(re.findall(r'"([^"]+)"', block) == paths for block in blocks) -paths = [p for p in paths if 'template-src' not in p] -appended = (props / 'codenameone_library_appended.properties').read_text() -match = re.search(r'^codename1.arg.ios.add_libs=(.*)$', appended, re.M) -libs = [] if not match else [v for v in match.group(1).split(';') if v] -settings = {'CLANG_ENABLE_MODULES': 'YES', 'CLANG_ENABLE_OBJC_ARC': 'NO', - 'CODE_SIGNING_ALLOWED': 'NO', 'GENERATE_INFOPLIST_FILE': 'YES', - 'GCC_PREFIX_HEADER': 'Prefix.pch', 'GCC_PRECOMPILE_PREFIX_HEADER': 'NO', - 'HEADER_SEARCH_PATHS': ['$(inherited)', '$(SRCROOT)'], - 'LIBRARY_SEARCH_PATHS': paths, 'OTHER_LDFLAGS': ['$(inherited)', '-ObjC'], - 'DEAD_CODE_STRIPPING': 'NO'} -project = {'name': 'AdMobLinkProbe', 'options': {'bundleIdPrefix': 'com.codenameone.test', 'deploymentTarget': {'iOS': '14.0'}}, - 'targets': {'AdMobLinkProbe': {'type': 'application', 'platform': 'iOS', 'sources': ['Sources'], - 'dependencies': [{'sdk': lib} for lib in libs], 'settings': {'base': settings}}}} -(probe / 'project.yml').write_text(json.dumps(project, indent=2)) -PY -(cd "$PROBE" && xcodegen generate && pod install) -xcodebuild -workspace "$PROBE/AdMobLinkProbe.xcworkspace" -scheme AdMobLinkProbe \ +(project / 'Podfile').write_text("platform :ios, '14.0'\ntarget 'AdMobLinkProbe' do\n use_frameworks!\n pod '%s', '%s'\nend\n" % (name, version)) +data = json.loads((probe / 'project.json').read_text()) +objects = data['objects'] +# A successful link alone is insufficient: other frameworks may supply C++ +# transitively. Verify the shipped hint is an explicit SDK library input before +# trimming the application scaffolding. +app = next(obj for obj in objects.values() if obj['isa'] == 'PBXNativeTarget' and obj['name'] == 'AdMobLinkProbe') +frameworks = next(objects[ref] for ref in app['buildPhases'] if objects[ref]['isa'] == 'PBXFrameworksBuildPhase') +linked = {objects[ref]['fileRef'] for ref in frameworks['files']} +resources = next(objects[ref] for ref in app['buildPhases'] if objects[ref]['isa'] == 'PBXResourcesBuildPhase') +copied = {objects[ref]['fileRef'] for ref in resources['files']} +for lib in filter(None, sys.argv[4].split(';')): + if lib.endswith('.tbd'): + matches = [(ref, obj) for ref, obj in objects.items() + if obj['isa'] == 'PBXFileReference' and obj.get('name') == lib] + assert len(matches) == 1, 'Missing SDK library reference: ' + lib + ref, obj = matches[0] + assert obj.get('path') == 'usr/lib/' + lib and obj.get('sourceTree') == 'SDKROOT', obj + assert obj.get('lastKnownFileType') == 'sourcecode.text-based-dylib-definition', obj + assert ref in linked and ref not in copied, 'Library hint is not a linker input: ' + lib +# Only replace translated runtime sources with the callback stubs above. Keep +# the translator's Frameworks phase, SDK paths and LIBRARY_SEARCH_PATHS verbatim. +source_names = {'main.m', 'com_codename1_ads_admob_AdMobNativeImpl.m'} +for obj in objects.values(): + if obj['isa'] == 'PBXSourcesBuildPhase': + obj['files'] = [ref for ref in obj['files'] + if objects[objects[ref]['fileRef']].get('path') in source_names] + elif obj['isa'] == 'PBXResourcesBuildPhase': + obj['files'] = [] + elif obj['isa'] == 'XCBuildConfiguration': + settings = obj['buildSettings'] + settings.pop('INFOPLIST_FILE', None) + settings.update({'CLANG_ENABLE_MODULES': 'YES', 'CODE_SIGNING_ALLOWED': 'NO', + 'GENERATE_INFOPLIST_FILE': 'YES', 'IPHONEOS_DEPLOYMENT_TARGET': '14.0', + 'PRODUCT_BUNDLE_IDENTIFIER': 'com.codenameone.test.AdMobLinkProbe', + 'GCC_PREFIX_HEADER': 'Prefix.pch', 'GCC_PRECOMPILE_PREFIX_HEADER': 'NO', + 'HEADER_SEARCH_PATHS': ['$(inherited)', '$(SRCROOT)'], + 'OTHER_LDFLAGS': ['$(inherited)', '-ObjC'], 'DEAD_CODE_STRIPPING': 'NO'}) +# Fail explicitly if staging stopped including the real bridge or entry point. +sources = next(objects[ref] for ref in app['buildPhases'] if objects[ref]['isa'] == 'PBXSourcesBuildPhase') +assert len(sources['files']) == len(source_names), sources +with (project / 'AdMobLinkProbe.xcodeproj/project.pbxproj').open('wb') as output: + plistlib.dump(data, output) +PYTHON +(cd "$PROJECT" && pod install) +xcodebuild -workspace "$PROJECT/AdMobLinkProbe.xcworkspace" -scheme AdMobLinkProbe \ -configuration Release -sdk "$SDK" -derivedDataPath "$PROBE/build-$SDK" \ CODE_SIGNING_ALLOWED=NO build diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index 585249e0c2e..84ec56ae23b 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -936,10 +936,10 @@ private static void handleAppleOutput(ByteCodeTranslator b, File[] sources, File fileListEntry.append(file); fileListEntry.append(" */ = {isa = PBXFileReference; lastKnownFileType = "); fileListEntry.append(getFileType(file)); - if(file.endsWith(".framework") || file.endsWith(".dylib") || file.endsWith(".a")) { + if(file.endsWith(".framework") || file.endsWith(".dylib") || file.endsWith(".tbd") || file.endsWith(".a")) { fileListEntry.append("; name = \""); fileListEntry.append(file); - if(file.endsWith(".dylib")) { + if(file.endsWith(".dylib") || file.endsWith(".tbd")) { fileListEntry.append("\"; path = \"usr/lib/"); fileListEntry.append(file); fileListEntry.append("\"; sourceTree = SDKROOT; };\n"); @@ -1021,7 +1021,7 @@ private static void handleAppleOutput(ByteCodeTranslator b, File[] sources, File fileThreeEntry.append(" */,\n"); } } else { - if(file.endsWith(".a") || file.endsWith(".framework") || file.endsWith(".dylib") || (file.endsWith("Info.plist") && !"GoogleService-Info.plist".equals(file)) || file.endsWith(".pch")) { + if(file.endsWith(".a") || file.endsWith(".framework") || file.endsWith(".dylib") || file.endsWith(".tbd") || (file.endsWith("Info.plist") && !"GoogleService-Info.plist".equals(file)) || file.endsWith(".pch")) { frameworks.append(" 0"); frameworks.append(referenceValue); frameworks.append("18E9ABBC002F3D1D /* "); @@ -1409,6 +1409,9 @@ private static String getFileType(String s) { if(s.endsWith(".dylib")) { return "compiled.mach-o.dylib"; } + if(s.endsWith(".tbd")) { + return "sourcecode.text-based-dylib-definition"; + } if(s.endsWith(".h")) { return "sourcecode.c.h"; } diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/BytecodeInstructionIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/BytecodeInstructionIntegrationTest.java index 0e88b50b8aa..e3031959ee8 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/BytecodeInstructionIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/BytecodeInstructionIntegrationTest.java @@ -1004,6 +1004,8 @@ void getFileTypeReturnsCorrectTypes() throws Exception { m.setAccessible(true); assertEquals("wrapper.framework", m.invoke(null, "foo.framework")); + assertEquals("compiled.mach-o.dylib", m.invoke(null, "libz.dylib")); + assertEquals("sourcecode.text-based-dylib-definition", m.invoke(null, "libc++.tbd")); assertEquals("sourcecode.c.objc", m.invoke(null, "foo.m")); assertEquals("file", m.invoke(null, "foo.txt")); assertEquals("wrapper.plug-in", m.invoke(null, "foo.bundle")); @@ -1049,7 +1051,7 @@ void handleIosOutputGeneratesProjectStructure(CompilerHelper.CompilerConfig conf "ios", sourceDir.toAbsolutePath().toString(), outputDir.toAbsolutePath().toString(), - "MyAppIOS", "com.example", "My App", "1.0", "ios", "none" + "MyAppIOS", "com.example", "My App", "1.0", "ios", "libc++.tbd" }; ByteCodeTranslator.OutputType originalOutput = ByteCodeTranslator.output; @@ -1069,6 +1071,17 @@ void handleIosOutputGeneratesProjectStructure(CompilerHelper.CompilerConfig conf assertTrue(pbxproj.contains("CoreText.framework"), "iOS projects must link CoreText for IOSNative bundled font registration"); + String stubReference = fileReferenceLine(pbxproj, "libc++.tbd"); + assertTrue(stubReference.contains("lastKnownFileType = sourcecode.text-based-dylib-definition")); + assertTrue(stubReference.contains("path = \"usr/lib/libc++.tbd\"")); + assertTrue(stubReference.contains("sourceTree = SDKROOT")); + assertTrue(buildPhase(pbxproj, "PBXFrameworksBuildPhase").contains("libc++.tbd"), + "SDK library stubs must be linked into the app"); + assertFalse(buildPhase(pbxproj, "PBXResourcesBuildPhase").contains("libc++.tbd"), + "SDK library stubs must not be copied as application resources"); + assertTrue(buildPhase(pbxproj, "PBXFrameworksBuildPhase").contains("libz.dylib"), + "legacy dylib hints must continue to link"); + // The assembly file must be typed AND filed as a source. An extension // Xcode does not recognise gets `lastKnownFileType = file` and lands in // the Resources phase, where it is copied into the bundle and never From 7fe28bc8f3187279e2e38e8e7c3903ad0bed8abb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:11:50 +0300 Subject: [PATCH 04/14] Use Zulu Java 8 for ARM64 macOS AdMob checks --- .github/workflows/ad-cn1lib-ios-native-check.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ad-cn1lib-ios-native-check.yml b/.github/workflows/ad-cn1lib-ios-native-check.yml index 5aeece40b47..d1d282330dc 100644 --- a/.github/workflows/ad-cn1lib-ios-native-check.yml +++ b/.github/workflows/ad-cn1lib-ios-native-check.yml @@ -212,9 +212,10 @@ jobs: sdk: [iphoneos, iphonesimulator] steps: - uses: actions/checkout@v6 - - uses: actions/setup-java@v4 + # Temurin 8 is unavailable on macOS ARM64; Zulu supplies native Java 8. + - uses: actions/setup-java@v5 with: - distribution: temurin + distribution: zulu java-version: '8' - name: Link the native bridge and SDK into an app run: scripts/check-admob-ios-link.sh "$RUNNER_TEMP/admob-link" "${{ matrix.sdk }}" From a0523d64c044bc353b41ef3821b8dc660f009cb7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:25:16 +0300 Subject: [PATCH 05/14] Handle mock ad dismissal from dialogs and app startup --- .../codename1/ads/mock/MockAdProvider.java | 13 ++- .../com/codename1/ads/MockAdProviderTest.java | 94 +++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java index 8627413f4b8..818a6cef0a9 100644 --- a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java +++ b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java @@ -43,6 +43,7 @@ import com.codename1.ui.Command; import com.codename1.ui.Component; import com.codename1.ui.Container; +import com.codename1.ui.Dialog; import com.codename1.ui.Form; import com.codename1.ui.Label; import com.codename1.ui.events.ActionEvent; @@ -159,6 +160,10 @@ public void show() { } loaded = false; previousForm = CN.getCurrentForm(); + if (previousForm == null) { + // App-open ads may be presented before the first application form. + previousForm = new Form(); + } adForm = new Form("Mock advertisement", new BorderLayout()); adForm.add(BorderLayout.CENTER, new Label("Advertisement")); Button close = new Button("Close ad"); @@ -187,7 +192,13 @@ private void closeAd(boolean notify) { adForm = null; previousForm = null; if (previous != null && CN.getCurrentForm() == closing) { - previous.showBack(); + if (previous instanceof Dialog) { + // showBack() would enter another modal wait and prevent the + // reward/dismissal callbacks from disposing the caller dialog. + ((Dialog) previous).showModeless(); + } else { + previous.showBack(); + } } if (notify) { if (format == AdFormat.REWARDED || format == AdFormat.REWARDED_INTERSTITIAL) { diff --git a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java index a3b6e892c16..91a3f1bc663 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java @@ -27,11 +27,15 @@ import com.codename1.junit.UITestBase; import com.codename1.ui.Button; import com.codename1.ui.CN; +import com.codename1.ui.Dialog; import com.codename1.ui.Form; import com.codename1.ui.events.ActionEvent; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; class MockAdProviderTest extends UITestBase { @@ -100,4 +104,94 @@ void disposingVisibleAdRestoresFormWithoutRewardOrDismissal() { assertSame(previous, CN.getCurrentForm()); assertEquals(Arrays.asList("shown", "impression"), events); } + + @FormTest + void modalDialogCanBeDisposedAndNavigatedAwayFromInDismissalCallback() { + MockAdProvider.install(); + Dialog dialog = new Dialog("Modal caller"); + Form destination = new Form("After dismissal"); + RewardedAd ad = new RewardedAd("mock"); + AtomicReference failure = new AtomicReference(); + Timer watchdog = new Timer(true); + boolean[] started = {false}; + ad.setAdListener(new AdListener() { + @Override public void onDismissed() { + events.add("dismissed"); + assertSame(dialog, CN.getCurrentForm()); + dialog.dispose(); + destination.show(); + } + }); + ad.setOnUserEarnedRewardListener(reward -> events.add("reward")); + dialog.addShowListener(evt -> { + if (started[0]) { + return; + } + started[0] = true; + CN.callSerially(() -> { + try { + ad.load(); + ad.show(); + Form showing = CN.getCurrentForm(); + // Unblock the broken implementation so failure is an assertion, + // not a hung EDT. The fixed close delivers callbacks immediately. + watchdog.schedule(new TimerTask() { + @Override public void run() { + CN.callSerially(() -> { + if (!events.contains("dismissed")) { + events.add("watchdog"); + dialog.dispose(); + } + }); + } + }, 1000); + showing.getBackCommand().actionPerformed(new ActionEvent(showing)); + } catch (Throwable t) { + failure.set(t); + dialog.dispose(); + } + }); + }); + try { + dialog.show(); + assertNull(failure.get(), "Dismissal callback must see the restored dialog"); + assertEquals(Arrays.asList("reward", "dismissed"), events); + assertSame(destination, CN.getCurrentForm()); + } finally { + watchdog.cancel(); + ad.dispose(); + dialog.dispose(); + } + } + + @FormTest + void appOpenAdWithoutPreviousFormCanCloseGoBackOrDispose() { + MockAdProvider.install(); + for (int action = 0; action < 3; action++) { + events.clear(); + implementation.setCurrentForm(null); + assertNull(CN.getCurrentForm()); + AppOpenAd ad = new AppOpenAd("mock"); + ad.setAdListener(listener()); + ad.load(); + ad.show(); + Form showing = CN.getCurrentForm(); + assertEquals("Mock advertisement", showing.getTitle()); + if (action == 0) { + Button close = (Button) showing.getContentPane().getComponentAt(1); + close.pressed(); + close.released(); + } else if (action == 1) { + showing.getBackCommand().actionPerformed(new ActionEvent(showing)); + } else { + ad.dispose(); + } + assertNotNull(CN.getCurrentForm()); + assertNotSame(showing, CN.getCurrentForm(), "Dismissal must remove the ad even at startup"); + assertEquals(action == 2 ? Arrays.asList("shown", "impression") + : Arrays.asList("shown", "impression", "dismissed"), events); + ad.dispose(); + } + } + } From d410c967635f8616497d3e5b8f62d3ba62cafcff Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:40:45 +0300 Subject: [PATCH 06/14] Preserve caller dialog modality when dismissing mock ads --- .../codename1/ads/mock/MockAdProvider.java | 34 +++++--------- .../com/codename1/ads/MockAdProviderTest.java | 47 +++++++++++++++++++ 2 files changed, 60 insertions(+), 21 deletions(-) diff --git a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java index 818a6cef0a9..aa35cf29631 100644 --- a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java +++ b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java @@ -44,7 +44,6 @@ import com.codename1.ui.Component; import com.codename1.ui.Container; import com.codename1.ui.Dialog; -import com.codename1.ui.Form; import com.codename1.ui.Label; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.geom.Dimension; @@ -121,8 +120,7 @@ private static final class MockFullScreen implements FullScreenAdSession { private final AdFormat format; private AdSessionCallback cb; private boolean loaded; - private Form adForm; - private Form previousForm; + private Dialog adForm; MockFullScreen(AdFormat format) { this.format = format; @@ -159,12 +157,12 @@ public void show() { return; } loaded = false; - previousForm = CN.getCurrentForm(); - if (previousForm == null) { - // App-open ads may be presented before the first application form. - previousForm = new Form(); - } - adForm = new Form("Mock advertisement", new BorderLayout()); + // Dialog disposal restores its caller directly, without re-showing a + // modal caller or changing how that caller behaves on later shows. + // It also supplies a fallback form when presented during app startup. + adForm = new Dialog("Mock advertisement", new BorderLayout()); + adForm.setNativeWindowMode(false); + adForm.setAutoDispose(false); adForm.add(BorderLayout.CENTER, new Label("Advertisement")); Button close = new Button("Close ad"); close.addActionListener(evt -> closeAd(true)); @@ -175,7 +173,7 @@ public void actionPerformed(ActionEvent evt) { closeAd(true); } }); - adForm.show(); + adForm.showAtPosition(0, 0, 0, 0, false); cb.onShown(); // A listener may dispose the ad synchronously from onShown(). if (adForm != null) { @@ -187,19 +185,13 @@ private void closeAd(boolean notify) { if (adForm == null) { return; } - Form closing = adForm; - Form previous = previousForm; + Dialog closing = adForm; adForm = null; - previousForm = null; - if (previous != null && CN.getCurrentForm() == closing) { - if (previous instanceof Dialog) { - // showBack() would enter another modal wait and prevent the - // reward/dismissal callbacks from disposing the caller dialog. - ((Dialog) previous).showModeless(); - } else { - previous.showBack(); - } + if (CN.getCurrentForm() != closing) { + // Application code may already have navigated away from the ad. + closing.setPreviousForm(null); } + closing.dispose(); if (notify) { if (format == AdFormat.REWARDED || format == AdFormat.REWARDED_INTERSTITIAL) { cb.onUserEarnedReward(new RewardItem("coins", 10)); diff --git a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java index 91a3f1bc663..7872e1e6267 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java @@ -157,6 +157,16 @@ void modalDialogCanBeDisposedAndNavigatedAwayFromInDismissalCallback() { assertNull(failure.get(), "Dismissal callback must see the restored dialog"); assertEquals(Arrays.asList("reward", "dismissed"), events); assertSame(destination, CN.getCurrentForm()); + // Reusing the same caller must still enter its original modal wait. + // Queue disposal from onShow so only the modal event loop can run it + // before show() returns. + boolean[] disposedDuringShow = {false}; + dialog.addShowListener(evt -> CN.callSerially(() -> { + disposedDuringShow[0] = true; + dialog.dispose(); + })); + dialog.show(); + assertTrue(disposedDuringShow[0], "A reused modal caller must still block until disposed"); } finally { watchdog.cancel(); ad.dispose(); @@ -194,4 +204,41 @@ void appOpenAdWithoutPreviousFormCanCloseGoBackOrDispose() { } } + @FormTest + void modelessCallerStaysModelessAfterAdDisposal() { + MockAdProvider.install(); + Dialog caller = new Dialog("Modeless caller"); + caller.showModeless(); + InterstitialAd ad = new InterstitialAd("mock"); + try { + ad.load(); + ad.show(); + ad.dispose(); + assertSame(caller, CN.getCurrentForm()); + caller.dispose(); + boolean[] disposedDuringShow = {false}; + caller.addShowListener(evt -> CN.callSerially(() -> { + disposedDuringShow[0] = true; + caller.dispose(); + })); + caller.show(); + assertFalse(disposedDuringShow[0], "A reused modeless caller must not wait for disposal"); + } finally { + ad.dispose(); + caller.dispose(); + } + } + + @FormTest + void disposalDoesNotUndoApplicationNavigation() { + MockAdProvider.install(); + InterstitialAd ad = new InterstitialAd("mock"); + ad.load(); + ad.show(); + Form destination = new Form("Application destination"); + destination.show(); + ad.dispose(); + assertSame(destination, CN.getCurrentForm()); + } + } From 5aa45fb5afde03b0c3f9ef9456f37c3e543a782e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:00:42 +0300 Subject: [PATCH 07/14] Preserve nested dialog navigation when disposing mock ads --- CodenameOne/src/com/codename1/ui/Dialog.java | 15 +++++ .../codename1/ads/mock/MockAdProvider.java | 11 +++- .../com/codename1/ads/MockAdProviderTest.java | 59 +++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/Dialog.java b/CodenameOne/src/com/codename1/ui/Dialog.java index 86fbf13f67e..0b0a079e07c 100644 --- a/CodenameOne/src/com/codename1/ui/Dialog.java +++ b/CodenameOne/src/com/codename1/ui/Dialog.java @@ -1187,6 +1187,21 @@ private void initImpl(String dialogUIID, String dialogTitleUIID, Layout lm) { deregisterAnimated(this); } + /// Returns the form beneath this dialog in the navigation stack. This may be + /// another dialog when dialogs are nested. + /// + /// #### Returns + /// + /// the previous form, or null when no previous form is associated with this dialog + /// + /// #### See also + /// + /// - `#setPreviousForm(Form)` + @Override + public Form getPreviousForm() { + return super.getPreviousForm(); + } + /// When the dialog is disposed this form will show. Notice that this can only be set after show was invoked! /// /// #### Parameters diff --git a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java index aa35cf29631..002ba1cd48c 100644 --- a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java +++ b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java @@ -44,6 +44,7 @@ import com.codename1.ui.Component; import com.codename1.ui.Container; import com.codename1.ui.Dialog; +import com.codename1.ui.Form; import com.codename1.ui.Label; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.geom.Dimension; @@ -187,8 +188,14 @@ private void closeAd(boolean notify) { } Dialog closing = adForm; adForm = null; - if (CN.getCurrentForm() != closing) { - // Application code may already have navigated away from the ad. + Form current = CN.getCurrentForm(); + while (current instanceof Dialog && current != closing) { + current = ((Dialog) current).getPreviousForm(); + } + if (current != closing) { + // Suppress restoration only after unrelated navigation. If an + // upper dialog still points to the ad, dispose() needs this link + // to splice the ad out of the active dialog stack. closing.setPreviousForm(null); } closing.dispose(); diff --git a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java index 7872e1e6267..4ec6a4fee1d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java @@ -241,4 +241,63 @@ void disposalDoesNotUndoApplicationNavigation() { assertSame(destination, CN.getCurrentForm()); } + @FormTest + void disposalSplicesAdOutOfNestedDialogs() { + MockAdProvider.install(); + Form application = CN.getCurrentForm(); + for (int depth = 1; depth <= 2; depth++) { + events.clear(); + InterstitialAd ad = new InterstitialAd("mock"); + ad.setAdListener(listener()); + Dialog[] overlays = new Dialog[depth]; + try { + ad.load(); + ad.show(); + for (int i = 0; i < depth; i++) { + overlays[i] = new Dialog("Application overlay " + i); + overlays[i].showModeless(); + } + ad.dispose(); + ad.dispose(); + assertSame(overlays[depth - 1], CN.getCurrentForm(), + "Disposing an underlying ad must leave the top dialog visible"); + assertEquals(Arrays.asList("shown", "impression"), events); + for (int i = depth - 1; i >= 0; i--) { + overlays[i].dispose(); + assertSame(i == 0 ? application : overlays[i - 1], CN.getCurrentForm(), + "Dialog disposal must skip the removed ad and restore its caller"); + } + } finally { + for (int i = depth - 1; i >= 0; i--) { + if (overlays[i] != null) { + overlays[i].dispose(); + } + } + ad.dispose(); + application.show(); + } + } + } + + @FormTest + void disposalDoesNotUndoNavigationToAnUnrelatedDialog() { + MockAdProvider.install(); + InterstitialAd ad = new InterstitialAd("mock"); + Form destination = new Form("Application destination"); + Dialog unrelated = new Dialog("Unrelated dialog"); + try { + ad.load(); + ad.show(); + destination.show(); + unrelated.showModeless(); + ad.dispose(); + assertSame(unrelated, CN.getCurrentForm()); + unrelated.dispose(); + assertSame(destination, CN.getCurrentForm()); + } finally { + ad.dispose(); + unrelated.dispose(); + } + } + } From 15fcd1917a96346d45783f9dd9d8344b53e056fb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:21:02 +0300 Subject: [PATCH 08/14] Serialize mock ad lifecycle transitions on the EDT --- .../codename1/ads/mock/MockAdProvider.java | 23 ++++- .../com/codename1/ads/MockAdProviderTest.java | 88 +++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java index 002ba1cd48c..d43a48d9734 100644 --- a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java +++ b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java @@ -120,7 +120,9 @@ public void loadNativeAd(String adUnitId, AdRequest request, private static final class MockFullScreen implements FullScreenAdSession { private final AdFormat format; private AdSessionCallback cb; - private boolean loaded; + // Mutated on the EDT; readiness may be queried by a worker. + private volatile boolean loaded; + private boolean disposed; private Dialog adForm; MockFullScreen(AdFormat format) { @@ -138,6 +140,13 @@ public void setServerSideVerificationOptions(ServerSideVerificationOptions optio @Override public void load(AdRequest request) { + if (!CN.isEdt()) { + CN.callSerially(() -> load(request)); + return; + } + if (disposed) { + return; + } loaded = true; cb.onLoaded(); } @@ -149,6 +158,13 @@ public boolean isLoaded() { @Override public void show() { + if (!CN.isEdt()) { + CN.callSerially(this::show); + return; + } + if (disposed) { + return; + } if (!loaded) { cb.onShowFailed(new AdError(AdError.CODE_INTERNAL, "mock", "No ad loaded")); return; @@ -213,6 +229,11 @@ public void setAutoShowOnForeground(boolean enabled) { @Override public void dispose() { + if (!CN.isEdt()) { + CN.callSerially(this::dispose); + return; + } + disposed = true; loaded = false; closeAd(false); } diff --git a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java index 4ec6a4fee1d..86519805f3b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java @@ -36,6 +36,7 @@ import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.CountDownLatch; import static org.junit.jupiter.api.Assertions.*; class MockAdProviderTest extends UITestBase { @@ -300,4 +301,91 @@ void disposalDoesNotUndoNavigationToAnUnrelatedDialog() { } } + @FormTest + void workerShowThenDisposeDoesNotStrandQueuedDialog() throws Exception { + MockAdProvider.install(); + Form previous = CN.getCurrentForm(); + InterstitialAd ad = new InterstitialAd("mock"); + ad.setAdListener(listener()); + AtomicReference failure = new AtomicReference(); + Thread worker = new Thread(() -> { + try { + ad.load(); + ad.show(); + ad.dispose(); + } catch (Throwable t) { + failure.set(t); + } + }, "mock-ad-worker"); + try { + // Keep the EDT here until the worker has queued both operations. + // invokeAndBlock would pump the queue and hide the failing ordering. + worker.start(); + worker.join(1000); + assertFalse(worker.isAlive(), "Worker operations must not wait for the EDT"); + assertNull(failure.get()); + CountDownLatch drained = new CountDownLatch(1); + CN.callSerially(drained::countDown); + waitFor(drained, 1000); + assertSame(previous, CN.getCurrentForm(), "Queued presentation must not resurrect a disposed ad"); + assertEquals(Arrays.asList("shown", "impression"), events); + } finally { + ad.dispose(); + previous.show(); + } + } + + @FormTest + void onShownCanDisposeSynchronouslyWithoutAnImpression() { + MockAdProvider.install(); + Form previous = CN.getCurrentForm(); + InterstitialAd ad = new InterstitialAd("mock"); + ad.setAdListener(new AdListener() { + @Override public void onShown() { + events.add("shown"); + assertTrue(CN.isEdt()); + assertNotSame(previous, CN.getCurrentForm()); + ad.dispose(); + } + @Override public void onImpression() { events.add("impression"); } + }); + ad.load(); + ad.show(); + assertSame(previous, CN.getCurrentForm()); + assertEquals(Arrays.asList("shown"), events); + } + + @FormTest + void edtDisposalCancelsAlreadyQueuedWorkerPresentation() throws Exception { + MockAdProvider.install(); + Form previous = CN.getCurrentForm(); + InterstitialAd ad = new InterstitialAd("mock"); + ad.setAdListener(listener()); + AtomicReference failure = new AtomicReference(); + Thread worker = new Thread(() -> { + try { + ad.load(); + ad.show(); + } catch (Throwable t) { + failure.set(t); + } + }, "mock-ad-queued-presentation"); + try { + worker.start(); + worker.join(1000); + assertFalse(worker.isAlive()); + assertNull(failure.get()); + // An EDT caller can dispose before the queued load/show get a turn. + ad.dispose(); + CountDownLatch drained = new CountDownLatch(1); + CN.callSerially(drained::countDown); + waitFor(drained, 1000); + assertSame(previous, CN.getCurrentForm()); + assertTrue(events.isEmpty(), "A disposed session must ignore pending presentation"); + } finally { + ad.dispose(); + previous.show(); + } + } + } From 69c8e7c690668e18e74e0957675b670a90cf0030 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:09:47 +0300 Subject: [PATCH 09/14] Simplify mock full-screen ads to layered-pane overlays --- CodenameOne/src/com/codename1/ui/Dialog.java | 15 --- .../codename1/ads/mock/MockAdProvider.java | 68 ++++++----- .../com/codename1/ads/MockAdProviderTest.java | 111 ++++++++---------- 3 files changed, 92 insertions(+), 102 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/Dialog.java b/CodenameOne/src/com/codename1/ui/Dialog.java index 0b0a079e07c..86fbf13f67e 100644 --- a/CodenameOne/src/com/codename1/ui/Dialog.java +++ b/CodenameOne/src/com/codename1/ui/Dialog.java @@ -1187,21 +1187,6 @@ private void initImpl(String dialogUIID, String dialogTitleUIID, Layout lm) { deregisterAnimated(this); } - /// Returns the form beneath this dialog in the navigation stack. This may be - /// another dialog when dialogs are nested. - /// - /// #### Returns - /// - /// the previous form, or null when no previous form is associated with this dialog - /// - /// #### See also - /// - /// - `#setPreviousForm(Form)` - @Override - public Form getPreviousForm() { - return super.getPreviousForm(); - } - /// When the dialog is disposed this form will show. Notice that this can only be set after show was invoked! /// /// #### Parameters diff --git a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java index d43a48d9734..72add5ae799 100644 --- a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java +++ b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java @@ -43,12 +43,12 @@ import com.codename1.ui.Command; import com.codename1.ui.Component; import com.codename1.ui.Container; -import com.codename1.ui.Dialog; import com.codename1.ui.Form; import com.codename1.ui.Label; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.geom.Dimension; import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.LayeredLayout; /// A deterministic, network-free ad provider for tests and screenshots. It /// renders fixed, labelled "ads" with stable colours, text and sizes (no @@ -116,14 +116,18 @@ public void loadNativeAd(String adUnitId, AdRequest request, } /// Deterministic full screen ad: fires the lifecycle events and presents a - /// fixed close-able form. + /// full-screen overlay with a Close button. private static final class MockFullScreen implements FullScreenAdSession { private final AdFormat format; private AdSessionCallback cb; // Mutated on the EDT; readiness may be queried by a worker. private volatile boolean loaded; private boolean disposed; - private Dialog adForm; + private Container overlay; + private Container layer; + private Form host; + private Command previousBack; + private Command closeCommand; MockFullScreen(AdFormat format) { this.format = format; @@ -169,52 +173,60 @@ public void show() { cb.onShowFailed(new AdError(AdError.CODE_INTERNAL, "mock", "No ad loaded")); return; } - if (adForm != null) { + if (overlay != null) { cb.onShowFailed(new AdError(AdError.CODE_INTERNAL, "mock", "An ad is already showing")); return; } loaded = false; - // Dialog disposal restores its caller directly, without re-showing a - // modal caller or changing how that caller behaves on later shows. - // It also supplies a fallback form when presented during app startup. - adForm = new Dialog("Mock advertisement", new BorderLayout()); - adForm.setNativeWindowMode(false); - adForm.setAutoDispose(false); - adForm.add(BorderLayout.CENTER, new Label("Advertisement")); + host = CN.getCurrentForm(); + if (host == null) { + host = new Form(); + host.show(); + } + overlay = new Container(new BorderLayout()); + overlay.setGrabsPointerEvents(true); + overlay.setUIID("Form"); + overlay.getAllStyles().setBgTransparency(255); + overlay.add(BorderLayout.CENTER, new Label("Mock advertisement")); Button close = new Button("Close ad"); close.addActionListener(evt -> closeAd(true)); - adForm.add(BorderLayout.SOUTH, close); - adForm.setBackCommand(new Command("Close ad") { + overlay.add(BorderLayout.SOUTH, close); + previousBack = host.getBackCommand(); + closeCommand = new Command("Close ad") { @Override public void actionPerformed(ActionEvent evt) { closeAd(true); } - }); - adForm.showAtPosition(0, 0, 0, 0, false); + }; + host.setBackCommand(closeCommand); + layer = host.getFormLayeredPane(MockAdProvider.class, true); + layer.setLayout(new LayeredLayout()); + layer.add(overlay); + host.revalidate(); cb.onShown(); // A listener may dispose the ad synchronously from onShown(). - if (adForm != null) { + if (overlay != null) { cb.onImpression(); } } private void closeAd(boolean notify) { - if (adForm == null) { + if (overlay == null) { return; } - Dialog closing = adForm; - adForm = null; - Form current = CN.getCurrentForm(); - while (current instanceof Dialog && current != closing) { - current = ((Dialog) current).getPreviousForm(); + overlay.remove(); + overlay = null; + if (layer.getComponentCount() == 0) { + layer.remove(); } - if (current != closing) { - // Suppress restoration only after unrelated navigation. If an - // upper dialog still points to the ad, dispose() needs this link - // to splice the ad out of the active dialog stack. - closing.setPreviousForm(null); + if (host.getBackCommand() == closeCommand) { + host.setBackCommand(previousBack); } - closing.dispose(); + host.revalidate(); + layer = null; + host = null; + previousBack = null; + closeCommand = null; if (notify) { if (format == AdFormat.REWARDED || format == AdFormat.REWARDED_INTERSTITIAL) { cb.onUserEarnedReward(new RewardItem("coins", 10)); diff --git a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java index 86519805f3b..442890374ea 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java @@ -26,6 +26,9 @@ import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; import com.codename1.ui.Button; +import com.codename1.ui.Command; +import com.codename1.ui.Container; +import com.codename1.ui.Label; import com.codename1.ui.CN; import com.codename1.ui.Dialog; import com.codename1.ui.Form; @@ -33,8 +36,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Timer; -import java.util.TimerTask; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.CountDownLatch; import static org.junit.jupiter.api.Assertions.*; @@ -45,6 +46,18 @@ class MockAdProviderTest extends UITestBase { @org.junit.jupiter.api.BeforeEach void resetEvents() { events.clear(); } + private Container adLayer(Form form) { + return form.getFormLayeredPane(MockAdProvider.class, true); + } + + private Container adOverlay(Form form) { + return (Container) adLayer(form).getComponentAt(0); + } + + private void assertNoAd(Form form) { + assertEquals(0, adLayer(form).getComponentCount()); + } + private AdListener listener() { return new AdListener() { @Override public void onShown() { events.add("shown"); } @@ -54,23 +67,30 @@ private AdListener listener() { } @FormTest - void interstitialStaysVisibleUntilClosedAndRestoresPreviousForm() { + void interstitialOverlayStaysVisibleUntilClosed() { Form previous = CN.getCurrentForm(); + Command previousBack = new Command("Application back"); + previous.setBackCommand(previousBack); MockAdProvider.install(); InterstitialAd ad = new InterstitialAd("mock"); ad.setAdListener(listener()); ad.load(); ad.show(); Form showing = CN.getCurrentForm(); - assertNotSame(previous, showing); - assertEquals("Mock advertisement", showing.getTitle()); + assertSame(previous, showing); + assertEquals(showing.getWidth(), adOverlay(showing).getWidth()); + assertEquals(showing.getHeight(), adOverlay(showing).getHeight()); + assertEquals("Mock advertisement", ((Label) adOverlay(showing).getComponentAt(0)).getText()); assertEquals(Arrays.asList("shown", "impression"), events); - Button close = (Button) showing.getContentPane().getComponentAt(1); + Command closeCommand = showing.getBackCommand(); + Button close = (Button) adOverlay(showing).getComponentAt(1); close.pressed(); close.released(); assertSame(previous, CN.getCurrentForm()); assertEquals(Arrays.asList("shown", "impression", "dismissed"), events); - showing.getBackCommand().actionPerformed(new ActionEvent(showing)); + closeCommand.actionPerformed(new ActionEvent(showing)); + assertNoAd(showing); + assertSame(previousBack, showing.getBackCommand()); assertEquals(3, events.size(), "Closing twice must not repeat callbacks"); ad.dispose(); } @@ -107,71 +127,40 @@ void disposingVisibleAdRestoresFormWithoutRewardOrDismissal() { } @FormTest - void modalDialogCanBeDisposedAndNavigatedAwayFromInDismissalCallback() { + void overlayLeavesModalCallerAvailableToDismissalCallback() { MockAdProvider.install(); Dialog dialog = new Dialog("Modal caller"); Form destination = new Form("After dismissal"); RewardedAd ad = new RewardedAd("mock"); AtomicReference failure = new AtomicReference(); - Timer watchdog = new Timer(true); - boolean[] started = {false}; ad.setAdListener(new AdListener() { @Override public void onDismissed() { events.add("dismissed"); assertSame(dialog, CN.getCurrentForm()); + assertNoAd(dialog); dialog.dispose(); destination.show(); } }); ad.setOnUserEarnedRewardListener(reward -> events.add("reward")); - dialog.addShowListener(evt -> { - if (started[0]) { - return; + dialog.addShowListener(evt -> CN.callSerially(() -> { + try { + ad.load(); + ad.show(); + dialog.getBackCommand().actionPerformed(new ActionEvent(dialog)); + } catch (Throwable t) { + failure.set(t); + } finally { + dialog.dispose(); } - started[0] = true; - CN.callSerially(() -> { - try { - ad.load(); - ad.show(); - Form showing = CN.getCurrentForm(); - // Unblock the broken implementation so failure is an assertion, - // not a hung EDT. The fixed close delivers callbacks immediately. - watchdog.schedule(new TimerTask() { - @Override public void run() { - CN.callSerially(() -> { - if (!events.contains("dismissed")) { - events.add("watchdog"); - dialog.dispose(); - } - }); - } - }, 1000); - showing.getBackCommand().actionPerformed(new ActionEvent(showing)); - } catch (Throwable t) { - failure.set(t); - dialog.dispose(); - } - }); - }); + })); try { dialog.show(); - assertNull(failure.get(), "Dismissal callback must see the restored dialog"); + assertNull(failure.get()); assertEquals(Arrays.asList("reward", "dismissed"), events); assertSame(destination, CN.getCurrentForm()); - // Reusing the same caller must still enter its original modal wait. - // Queue disposal from onShow so only the modal event loop can run it - // before show() returns. - boolean[] disposedDuringShow = {false}; - dialog.addShowListener(evt -> CN.callSerially(() -> { - disposedDuringShow[0] = true; - dialog.dispose(); - })); - dialog.show(); - assertTrue(disposedDuringShow[0], "A reused modal caller must still block until disposed"); } finally { - watchdog.cancel(); ad.dispose(); - dialog.dispose(); } } @@ -187,9 +176,9 @@ void appOpenAdWithoutPreviousFormCanCloseGoBackOrDispose() { ad.load(); ad.show(); Form showing = CN.getCurrentForm(); - assertEquals("Mock advertisement", showing.getTitle()); + assertEquals("Mock advertisement", ((Label) adOverlay(showing).getComponentAt(0)).getText()); if (action == 0) { - Button close = (Button) showing.getContentPane().getComponentAt(1); + Button close = (Button) adOverlay(showing).getComponentAt(1); close.pressed(); close.released(); } else if (action == 1) { @@ -198,7 +187,8 @@ void appOpenAdWithoutPreviousFormCanCloseGoBackOrDispose() { ad.dispose(); } assertNotNull(CN.getCurrentForm()); - assertNotSame(showing, CN.getCurrentForm(), "Dismissal must remove the ad even at startup"); + assertSame(showing, CN.getCurrentForm()); + assertNoAd(showing); assertEquals(action == 2 ? Arrays.asList("shown", "impression") : Arrays.asList("shown", "impression", "dismissed"), events); ad.dispose(); @@ -243,7 +233,7 @@ void disposalDoesNotUndoApplicationNavigation() { } @FormTest - void disposalSplicesAdOutOfNestedDialogs() { + void disposalRemovesOverlayWithoutChangingNestedDialogs() { MockAdProvider.install(); Form application = CN.getCurrentForm(); for (int depth = 1; depth <= 2; depth++) { @@ -266,7 +256,7 @@ void disposalSplicesAdOutOfNestedDialogs() { for (int i = depth - 1; i >= 0; i--) { overlays[i].dispose(); assertSame(i == 0 ? application : overlays[i - 1], CN.getCurrentForm(), - "Dialog disposal must skip the removed ad and restore its caller"); + "Dialog disposal must restore its original caller"); } } finally { for (int i = depth - 1; i >= 0; i--) { @@ -302,7 +292,7 @@ void disposalDoesNotUndoNavigationToAnUnrelatedDialog() { } @FormTest - void workerShowThenDisposeDoesNotStrandQueuedDialog() throws Exception { + void workerShowThenDisposeRemovesQueuedOverlay() throws Exception { MockAdProvider.install(); Form previous = CN.getCurrentForm(); InterstitialAd ad = new InterstitialAd("mock"); @@ -327,7 +317,8 @@ void workerShowThenDisposeDoesNotStrandQueuedDialog() throws Exception { CountDownLatch drained = new CountDownLatch(1); CN.callSerially(drained::countDown); waitFor(drained, 1000); - assertSame(previous, CN.getCurrentForm(), "Queued presentation must not resurrect a disposed ad"); + assertSame(previous, CN.getCurrentForm(), "Queued presentation must not change the current form"); + assertNoAd(previous); assertEquals(Arrays.asList("shown", "impression"), events); } finally { ad.dispose(); @@ -344,7 +335,8 @@ void onShownCanDisposeSynchronouslyWithoutAnImpression() { @Override public void onShown() { events.add("shown"); assertTrue(CN.isEdt()); - assertNotSame(previous, CN.getCurrentForm()); + assertSame(previous, CN.getCurrentForm()); + assertEquals(1, adLayer(previous).getComponentCount()); ad.dispose(); } @Override public void onImpression() { events.add("impression"); } @@ -381,6 +373,7 @@ void edtDisposalCancelsAlreadyQueuedWorkerPresentation() throws Exception { CN.callSerially(drained::countDown); waitFor(drained, 1000); assertSame(previous, CN.getCurrentForm()); + assertNoAd(previous); assertTrue(events.isEmpty(), "A disposed session must ignore pending presentation"); } finally { ad.dispose(); From 59125bdaf039ae42162bbac0b56f0cf4ef88f560 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:42:51 +0300 Subject: [PATCH 10/14] Reject overlapping mock ads on the same form --- .../codename1/ads/mock/MockAdProvider.java | 20 ++++-- .../com/codename1/ads/MockAdProviderTest.java | 64 +++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java index 72add5ae799..7a976c404b6 100644 --- a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java +++ b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java @@ -177,12 +177,21 @@ public void show() { cb.onShowFailed(new AdError(AdError.CODE_INTERNAL, "mock", "An ad is already showing")); return; } - loaded = false; - host = CN.getCurrentForm(); - if (host == null) { - host = new Form(); - host.show(); + Form target = CN.getCurrentForm(); + if (target == null) { + target = new Form(); + target.show(); } + Container targetLayer = target.getFormLayeredPane(MockAdProvider.class, true); + // Sessions share this layer. Reject overlap before consuming the ad + // or replacing the active session's Back command. + if (targetLayer.getComponentCount() != 0) { + cb.onShowFailed(new AdError(AdError.CODE_INTERNAL, "mock", "An ad is already showing")); + return; + } + loaded = false; + host = target; + layer = targetLayer; overlay = new Container(new BorderLayout()); overlay.setGrabsPointerEvents(true); overlay.setUIID("Form"); @@ -199,7 +208,6 @@ public void actionPerformed(ActionEvent evt) { } }; host.setBackCommand(closeCommand); - layer = host.getFormLayeredPane(MockAdProvider.class, true); layer.setLayout(new LayeredLayout()); layer.add(overlay); host.revalidate(); diff --git a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java index 442890374ea..8e835624035 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java @@ -381,4 +381,68 @@ void edtDisposalCancelsAlreadyQueuedWorkerPresentation() throws Exception { } } + @FormTest + void overlappingAdIsRejectedAndCanRetryAfterFirstIsDisposed() { + MockAdProvider.install(); + Form host = CN.getCurrentForm(); + Command applicationBack = new Command("Application back"); + host.setBackCommand(applicationBack); + InterstitialAd first = new InterstitialAd("first"); + AppOpenAd second = new AppOpenAd("second"); + second.setAdListener(new AdListener() { + @Override public void onShowFailed(AdError error) { events.add("rejected"); } + @Override public void onShown() { events.add("shown"); } + @Override public void onDismissed() { events.add("dismissed"); } + }); + try { + first.load(); + second.load(); + first.show(); + Command firstClose = host.getBackCommand(); + second.show(); + assertEquals(Arrays.asList("rejected"), events); + assertTrue(second.isLoaded(), "Rejected presentation must not consume the loaded ad"); + assertSame(firstClose, host.getBackCommand()); + assertEquals(1, adLayer(host).getComponentCount()); + first.dispose(); + assertSame(applicationBack, host.getBackCommand()); + second.show(); + assertEquals(Arrays.asList("rejected", "shown"), events); + host.getBackCommand().actionPerformed(new ActionEvent(host)); + assertEquals(Arrays.asList("rejected", "shown", "dismissed"), events); + assertSame(applicationBack, host.getBackCommand()); + assertNoAd(host); + } finally { + second.dispose(); + first.dispose(); + } + } + + @FormTest + void disposingRejectedAdLeavesVisibleAdAndBackCommandAlone() { + MockAdProvider.install(); + Form host = CN.getCurrentForm(); + Command applicationBack = new Command("Application back"); + host.setBackCommand(applicationBack); + InterstitialAd first = new InterstitialAd("first"); + InterstitialAd second = new InterstitialAd("second"); + try { + first.load(); + second.load(); + first.show(); + Command firstClose = host.getBackCommand(); + Container firstOverlay = adOverlay(host); + second.show(); + second.dispose(); + assertSame(firstClose, host.getBackCommand()); + assertSame(firstOverlay, adOverlay(host)); + firstClose.actionPerformed(new ActionEvent(host)); + assertSame(applicationBack, host.getBackCommand()); + assertNoAd(host); + } finally { + second.dispose(); + first.dispose(); + } + } + } From f68bd144c590a8abed33ea562c1d8db770a600b7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:45:05 +0300 Subject: [PATCH 11/14] Keep keyboard focus within mock full-screen ads --- .../codename1/ads/mock/MockAdProvider.java | 36 ++++++++- .../com/codename1/ads/MockAdProviderTest.java | 75 +++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java index 7a976c404b6..6d8d9dee05d 100644 --- a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java +++ b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java @@ -42,6 +42,7 @@ import com.codename1.ui.CN; import com.codename1.ui.Command; import com.codename1.ui.Component; +import com.codename1.ui.ComponentSelector; import com.codename1.ui.Container; import com.codename1.ui.Form; import com.codename1.ui.Label; @@ -49,6 +50,7 @@ import com.codename1.ui.geom.Dimension; import com.codename1.ui.layouts.BorderLayout; import com.codename1.ui.layouts.LayeredLayout; +import java.util.ArrayList; /// A deterministic, network-free ad provider for tests and screenshots. It /// renders fixed, labelled "ads" with stable colours, text and sizes (no @@ -126,6 +128,9 @@ private static final class MockFullScreen implements FullScreenAdSession { private Container overlay; private Container layer; private Form host; + private Component previousFocus; + private final ArrayList blockedFocus = new ArrayList(); + private Command previousDefault; private Command previousBack; private Command closeCommand; @@ -197,9 +202,25 @@ public void show() { overlay.setUIID("Form"); overlay.getAllStyles().setBgTransparency(255); overlay.add(BorderLayout.CENTER, new Label("Mock advertisement")); - Button close = new Button("Close ad"); + Button close = new Button("Close ad") { + @Override + public void keyReleased(int keyCode) { + // Finish Form.keyReleased() before restoring the host's default + // command, so this same Enter press cannot activate it too. + CN.callSerially(() -> super.keyReleased(keyCode)); + } + }; close.addActionListener(evt -> closeAd(true)); overlay.add(BorderLayout.SOUTH, close); + previousFocus = host.getFocused(); + for (Component component : ComponentSelector.select("*", host)) { + if (component.isFocusable()) { + blockedFocus.add(component); + component.setFocusable(false); + } + } + previousDefault = host.getDefaultCommand(); + host.setDefaultCommand(null); previousBack = host.getBackCommand(); closeCommand = new Command("Close ad") { @Override @@ -211,6 +232,7 @@ public void actionPerformed(ActionEvent evt) { layer.setLayout(new LayeredLayout()); layer.add(overlay); host.revalidate(); + close.requestFocus(); cb.onShown(); // A listener may dispose the ad synchronously from onShown(). if (overlay != null) { @@ -230,7 +252,19 @@ private void closeAd(boolean notify) { if (host.getBackCommand() == closeCommand) { host.setBackCommand(previousBack); } + for (Component component : blockedFocus) { + component.setFocusable(true); + } + blockedFocus.clear(); + if (host.getDefaultCommand() == null) { + host.setDefaultCommand(previousDefault); + } + if (previousFocus == null || previousFocus.getComponentForm() == host) { + host.setFocused(previousFocus); + } host.revalidate(); + previousFocus = null; + previousDefault = null; layer = null; host = null; previousBack = null; diff --git a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java index 8e835624035..348fc3bbe70 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java @@ -29,6 +29,9 @@ import com.codename1.ui.Command; import com.codename1.ui.Container; import com.codename1.ui.Label; +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.TextField; import com.codename1.ui.CN; import com.codename1.ui.Dialog; import com.codename1.ui.Form; @@ -445,4 +448,76 @@ void disposingRejectedAdLeavesVisibleAdAndBackCommandAlone() { } } + @FormTest + void overlayKeepsTypingAndFocusTraversalAwayFromHostControls() { + MockAdProvider.install(); + Form host = CN.getCurrentForm(); + int[] typed = {0}; + TextField input = new TextField() { + @Override public void keyPressed(int key) { typed[0]++; } + }; + Button underlying = new Button("Underlying action"); + Label label = new Label("Not focusable"); + host.addAll(input, underlying, label); + host.revalidate(); + input.requestFocus(); + assertSame(input, host.getFocused()); + InterstitialAd ad = new InterstitialAd("mock"); + try { + ad.load(); + ad.show(); + Component close = adOverlay(host).getComponentAt(1); + assertSame(close, host.getFocused()); + host.keyPressed('a'); + host.keyReleased('a'); + for (int key : new int[]{Display.GAME_UP, Display.GAME_DOWN, Display.GAME_LEFT, Display.GAME_RIGHT}) { + host.keyPressed(key); + host.keyReleased(key); + assertSame(close, host.getFocused()); + } + assertNull(host.getNextComponent(close), "Tab must not reach a covered control"); + assertNull(host.getPreviousComponent(close), "Shift-Tab must not reach a covered control"); + assertEquals(0, typed[0]); + ad.dispose(); + assertSame(input, host.getFocused()); + assertTrue(input.isFocusable()); + assertTrue(underlying.isFocusable()); + assertFalse(label.isFocusable()); + } finally { + ad.dispose(); + } + } + + @FormTest + void keyboardCloseDoesNotAlsoTriggerHostDefaultCommand() { + MockAdProvider.install(); + Form host = CN.getCurrentForm(); + Button underlying = new Button("Underlying action"); + underlying.addActionListener(evt -> events.add("underlying")); + host.add(underlying); + host.revalidate(); + underlying.requestFocus(); + Command defaultCommand = new Command("Default action") { + @Override public void actionPerformed(ActionEvent evt) { events.add("default"); } + }; + host.setDefaultCommand(defaultCommand); + InterstitialAd ad = new InterstitialAd("mock"); + ad.setAdListener(listener()); + try { + ad.load(); + ad.show(); + host.keyPressed(Display.GAME_FIRE); + host.keyReleased(Display.GAME_FIRE); + CountDownLatch drained = new CountDownLatch(1); + CN.callSerially(drained::countDown); + waitFor(drained, 1000); + assertEquals(Arrays.asList("shown", "impression", "dismissed"), events); + assertNoAd(host); + assertSame(underlying, host.getFocused()); + assertSame(defaultCommand, host.getDefaultCommand()); + } finally { + ad.dispose(); + } + } + } From 2962ccf25a6f67a13e895c89daf8d969b52c03eb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:33:04 +0300 Subject: [PATCH 12/14] Isolate mock full-screen ad input in its own Form --- .../codename1/ads/mock/MockAdProvider.java | 158 ++++++-------- .../com/codename1/ads/MockAdProviderTest.java | 202 ++++++++++++------ 2 files changed, 211 insertions(+), 149 deletions(-) diff --git a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java index 6d8d9dee05d..6cff018b8bc 100644 --- a/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java +++ b/maven/cn1-ads-mock/src/main/java/com/codename1/ads/mock/MockAdProvider.java @@ -42,15 +42,13 @@ import com.codename1.ui.CN; import com.codename1.ui.Command; import com.codename1.ui.Component; -import com.codename1.ui.ComponentSelector; import com.codename1.ui.Container; import com.codename1.ui.Form; import com.codename1.ui.Label; import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; import com.codename1.ui.geom.Dimension; import com.codename1.ui.layouts.BorderLayout; -import com.codename1.ui.layouts.LayeredLayout; -import java.util.ArrayList; /// A deterministic, network-free ad provider for tests and screenshots. It /// renders fixed, labelled "ads" with stable colours, text and sizes (no @@ -118,21 +116,15 @@ public void loadNativeAd(String adUnitId, AdRequest request, } /// Deterministic full screen ad: fires the lifecycle events and presents a - /// full-screen overlay with a Close button. + /// separate Form with a Close button. private static final class MockFullScreen implements FullScreenAdSession { private final AdFormat format; private AdSessionCallback cb; // Mutated on the EDT; readiness may be queried by a worker. private volatile boolean loaded; private boolean disposed; - private Container overlay; - private Container layer; - private Form host; - private Component previousFocus; - private final ArrayList blockedFocus = new ArrayList(); - private Command previousDefault; - private Command previousBack; - private Command closeCommand; + private MockAdForm adForm; + private static MockAdForm activeForm; MockFullScreen(AdFormat format) { this.format = format; @@ -178,102 +170,94 @@ public void show() { cb.onShowFailed(new AdError(AdError.CODE_INTERNAL, "mock", "No ad loaded")); return; } - if (overlay != null) { + if (activeForm != null) { cb.onShowFailed(new AdError(AdError.CODE_INTERNAL, "mock", "An ad is already showing")); return; } - Form target = CN.getCurrentForm(); - if (target == null) { - target = new Form(); - target.show(); - } - Container targetLayer = target.getFormLayeredPane(MockAdProvider.class, true); - // Sessions share this layer. Reject overlap before consuming the ad - // or replacing the active session's Back command. - if (targetLayer.getComponentCount() != 0) { - cb.onShowFailed(new AdError(AdError.CODE_INTERNAL, "mock", "An ad is already showing")); - return; + Form previous = CN.getCurrentForm(); + if (previous == null) { + previous = new Form(); } loaded = false; - host = target; - layer = targetLayer; - overlay = new Container(new BorderLayout()); - overlay.setGrabsPointerEvents(true); - overlay.setUIID("Form"); - overlay.getAllStyles().setBgTransparency(255); - overlay.add(BorderLayout.CENTER, new Label("Mock advertisement")); - Button close = new Button("Close ad") { - @Override - public void keyReleased(int keyCode) { - // Finish Form.keyReleased() before restoring the host's default - // command, so this same Enter press cannot activate it too. - CN.callSerially(() -> super.keyReleased(keyCode)); - } - }; + adForm = new MockAdForm(previous); + activeForm = adForm; + Button close = new Button("Close ad"); close.addActionListener(evt -> closeAd(true)); - overlay.add(BorderLayout.SOUTH, close); - previousFocus = host.getFocused(); - for (Component component : ComponentSelector.select("*", host)) { - if (component.isFocusable()) { - blockedFocus.add(component); - component.setFocusable(false); - } - } - previousDefault = host.getDefaultCommand(); - host.setDefaultCommand(null); - previousBack = host.getBackCommand(); - closeCommand = new Command("Close ad") { + adForm.add(BorderLayout.CENTER, new Label("Mock advertisement")); + adForm.add(BorderLayout.SOUTH, close); + adForm.setBackCommand(new Command("Close ad") { @Override public void actionPerformed(ActionEvent evt) { closeAd(true); } - }; - host.setBackCommand(closeCommand); - layer.setLayout(new LayeredLayout()); - layer.add(overlay); - host.revalidate(); + }); + adForm.show(); close.requestFocus(); cb.onShown(); - // A listener may dispose the ad synchronously from onShown(). - if (overlay != null) { + if (adForm != null) { cb.onImpression(); } } private void closeAd(boolean notify) { - if (overlay == null) { + if (adForm == null) { return; } - overlay.remove(); - overlay = null; - if (layer.getComponentCount() == 0) { - layer.remove(); - } - if (host.getBackCommand() == closeCommand) { - host.setBackCommand(previousBack); - } - for (Component component : blockedFocus) { - component.setFocusable(true); - } - blockedFocus.clear(); - if (host.getDefaultCommand() == null) { - host.setDefaultCommand(previousDefault); - } - if (previousFocus == null || previousFocus.getComponentForm() == host) { - host.setFocused(previousFocus); + MockAdForm closing = adForm; + adForm = null; + closing.closed = true; + // Restore on the EDT queue so a modal caller can show again without + // blocking dispose(), and deliver callbacks after it becomes current. + CN.callSerially(() -> { + if (activeForm == closing) { + activeForm = null; + } + closing.restorePrevious(() -> { + if (notify) { + if (format == AdFormat.REWARDED || format == AdFormat.REWARDED_INTERSTITIAL) { + cb.onUserEarnedReward(new RewardItem("coins", 10)); + } + cb.onDismissed(); + } + }); + }); + } + + private static final class MockAdForm extends Form { + private final Form previous; + private boolean closed; + + MockAdForm(Form previous) { + super(new BorderLayout()); + this.previous = previous; + // If an application dialog covered the ad when it was disposed, + // return to the caller when that dialog eventually uncovers it. + addShowListener(evt -> { + if (closed) { + CN.callSerially(() -> restorePrevious(null)); + } + }); } - host.revalidate(); - previousFocus = null; - previousDefault = null; - layer = null; - host = null; - previousBack = null; - closeCommand = null; - if (notify) { - if (format == AdFormat.REWARDED || format == AdFormat.REWARDED_INTERSTITIAL) { - cb.onUserEarnedReward(new RewardItem("coins", 10)); + + private void restorePrevious(Runnable afterRestore) { + if (CN.getCurrentForm() != this) { + if (afterRestore != null) { + afterRestore.run(); + } + return; + } + if (afterRestore != null) { + // A modal Dialog can flush the EDT before becoming current. + // Its show event, rather than a queued task, marks restoration. + previous.addShowListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + previous.removeShowListener(this); + afterRestore.run(); + } + }); } - cb.onDismissed(); + previous.showBack(); } } diff --git a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java index 348fc3bbe70..5de1eb94799 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java @@ -49,16 +49,18 @@ class MockAdProviderTest extends UITestBase { @org.junit.jupiter.api.BeforeEach void resetEvents() { events.clear(); } - private Container adLayer(Form form) { - return form.getFormLayeredPane(MockAdProvider.class, true); + private Container adContent(Form form) { + return form.getContentPane(); } - private Container adOverlay(Form form) { - return (Container) adLayer(form).getComponentAt(0); + private void drainEdt() { + CountDownLatch drained = new CountDownLatch(1); + CN.callSerially(() -> CN.callSerially(drained::countDown)); + waitFor(drained, 1000); } private void assertNoAd(Form form) { - assertEquals(0, adLayer(form).getComponentCount()); + assertSame(form, CN.getCurrentForm()); } private AdListener listener() { @@ -70,7 +72,7 @@ private AdListener listener() { } @FormTest - void interstitialOverlayStaysVisibleUntilClosed() { + void interstitialFormStaysVisibleUntilClosed() { Form previous = CN.getCurrentForm(); Command previousBack = new Command("Application back"); previous.setBackCommand(previousBack); @@ -80,22 +82,26 @@ void interstitialOverlayStaysVisibleUntilClosed() { ad.load(); ad.show(); Form showing = CN.getCurrentForm(); - assertSame(previous, showing); - assertEquals(showing.getWidth(), adOverlay(showing).getWidth()); - assertEquals(showing.getHeight(), adOverlay(showing).getHeight()); - assertEquals("Mock advertisement", ((Label) adOverlay(showing).getComponentAt(0)).getText()); + assertNotSame(previous, showing); + assertFalse(showing instanceof Dialog); + assertEquals(implementation.getDisplayWidth(), showing.getWidth()); + assertEquals(implementation.getDisplayHeight(), showing.getHeight()); + assertEquals("Mock advertisement", ((Label) adContent(showing).getComponentAt(0)).getText()); assertEquals(Arrays.asList("shown", "impression"), events); Command closeCommand = showing.getBackCommand(); - Button close = (Button) adOverlay(showing).getComponentAt(1); + Button close = (Button) adContent(showing).getComponentAt(1); close.pressed(); close.released(); + drainEdt(); assertSame(previous, CN.getCurrentForm()); assertEquals(Arrays.asList("shown", "impression", "dismissed"), events); closeCommand.actionPerformed(new ActionEvent(showing)); - assertNoAd(showing); - assertSame(previousBack, showing.getBackCommand()); + drainEdt(); + assertNoAd(previous); + assertSame(previousBack, previous.getBackCommand()); assertEquals(3, events.size(), "Closing twice must not repeat callbacks"); ad.dispose(); + drainEdt(); } @FormTest @@ -110,9 +116,11 @@ void rewardedBackCloseDeliversRewardBeforeDismissal() { Form showing = CN.getCurrentForm(); assertEquals(Arrays.asList("shown", "impression"), events); showing.getBackCommand().actionPerformed(new ActionEvent(showing)); + drainEdt(); assertSame(previous, CN.getCurrentForm()); assertEquals(Arrays.asList("shown", "impression", "reward", "dismissed"), events); ad.dispose(); + drainEdt(); } @FormTest @@ -125,12 +133,13 @@ void disposingVisibleAdRestoresFormWithoutRewardOrDismissal() { ad.load(); ad.show(); ad.dispose(); + drainEdt(); assertSame(previous, CN.getCurrentForm()); assertEquals(Arrays.asList("shown", "impression"), events); } @FormTest - void overlayLeavesModalCallerAvailableToDismissalCallback() { + void adRestoresModalCallerBeforeDismissalCallback() { MockAdProvider.install(); Dialog dialog = new Dialog("Modal caller"); Form destination = new Form("After dismissal"); @@ -142,21 +151,31 @@ void overlayLeavesModalCallerAvailableToDismissalCallback() { assertSame(dialog, CN.getCurrentForm()); assertNoAd(dialog); dialog.dispose(); + drainEdt(); destination.show(); } }); ad.setOnUserEarnedRewardListener(reward -> events.add("reward")); - dialog.addShowListener(evt -> CN.callSerially(() -> { - try { - ad.load(); - ad.show(); - dialog.getBackCommand().actionPerformed(new ActionEvent(dialog)); - } catch (Throwable t) { - failure.set(t); - } finally { - dialog.dispose(); + boolean[] started = {false}; + dialog.addShowListener(evt -> { + if (started[0]) { + return; } - })); + started[0] = true; + CN.callSerially(() -> { + try { + ad.load(); + ad.show(); + CN.getCurrentForm().getBackCommand().actionPerformed(new ActionEvent(dialog)); + drainEdt(); + } catch (Throwable t) { + failure.set(t); + } finally { + dialog.dispose(); + drainEdt(); + } + }); + }); try { dialog.show(); assertNull(failure.get()); @@ -164,6 +183,7 @@ void overlayLeavesModalCallerAvailableToDismissalCallback() { assertSame(destination, CN.getCurrentForm()); } finally { ad.dispose(); + drainEdt(); } } @@ -179,22 +199,25 @@ void appOpenAdWithoutPreviousFormCanCloseGoBackOrDispose() { ad.load(); ad.show(); Form showing = CN.getCurrentForm(); - assertEquals("Mock advertisement", ((Label) adOverlay(showing).getComponentAt(0)).getText()); + assertEquals("Mock advertisement", ((Label) adContent(showing).getComponentAt(0)).getText()); if (action == 0) { - Button close = (Button) adOverlay(showing).getComponentAt(1); + Button close = (Button) adContent(showing).getComponentAt(1); close.pressed(); close.released(); + drainEdt(); } else if (action == 1) { showing.getBackCommand().actionPerformed(new ActionEvent(showing)); + drainEdt(); } else { ad.dispose(); + drainEdt(); } assertNotNull(CN.getCurrentForm()); - assertSame(showing, CN.getCurrentForm()); - assertNoAd(showing); + assertNotSame(showing, CN.getCurrentForm()); assertEquals(action == 2 ? Arrays.asList("shown", "impression") : Arrays.asList("shown", "impression", "dismissed"), events); ad.dispose(); + drainEdt(); } } @@ -208,18 +231,23 @@ void modelessCallerStaysModelessAfterAdDisposal() { ad.load(); ad.show(); ad.dispose(); + drainEdt(); assertSame(caller, CN.getCurrentForm()); caller.dispose(); + drainEdt(); boolean[] disposedDuringShow = {false}; caller.addShowListener(evt -> CN.callSerially(() -> { disposedDuringShow[0] = true; caller.dispose(); + drainEdt(); })); caller.show(); assertFalse(disposedDuringShow[0], "A reused modeless caller must not wait for disposal"); } finally { ad.dispose(); + drainEdt(); caller.dispose(); + drainEdt(); } } @@ -232,11 +260,12 @@ void disposalDoesNotUndoApplicationNavigation() { Form destination = new Form("Application destination"); destination.show(); ad.dispose(); + drainEdt(); assertSame(destination, CN.getCurrentForm()); } @FormTest - void disposalRemovesOverlayWithoutChangingNestedDialogs() { + void disposedAdReturnsToCallerAfterNestedDialogsClose() { MockAdProvider.install(); Form application = CN.getCurrentForm(); for (int depth = 1; depth <= 2; depth++) { @@ -252,12 +281,15 @@ void disposalRemovesOverlayWithoutChangingNestedDialogs() { overlays[i].showModeless(); } ad.dispose(); + drainEdt(); ad.dispose(); + drainEdt(); assertSame(overlays[depth - 1], CN.getCurrentForm(), "Disposing an underlying ad must leave the top dialog visible"); assertEquals(Arrays.asList("shown", "impression"), events); for (int i = depth - 1; i >= 0; i--) { overlays[i].dispose(); + drainEdt(); assertSame(i == 0 ? application : overlays[i - 1], CN.getCurrentForm(), "Dialog disposal must restore its original caller"); } @@ -265,9 +297,11 @@ void disposalRemovesOverlayWithoutChangingNestedDialogs() { for (int i = depth - 1; i >= 0; i--) { if (overlays[i] != null) { overlays[i].dispose(); + drainEdt(); } } ad.dispose(); + drainEdt(); application.show(); } } @@ -285,17 +319,21 @@ void disposalDoesNotUndoNavigationToAnUnrelatedDialog() { destination.show(); unrelated.showModeless(); ad.dispose(); + drainEdt(); assertSame(unrelated, CN.getCurrentForm()); unrelated.dispose(); + drainEdt(); assertSame(destination, CN.getCurrentForm()); } finally { ad.dispose(); + drainEdt(); unrelated.dispose(); + drainEdt(); } } @FormTest - void workerShowThenDisposeRemovesQueuedOverlay() throws Exception { + void workerShowThenDisposeRestoresCaller() throws Exception { MockAdProvider.install(); Form previous = CN.getCurrentForm(); InterstitialAd ad = new InterstitialAd("mock"); @@ -317,14 +355,13 @@ void workerShowThenDisposeRemovesQueuedOverlay() throws Exception { worker.join(1000); assertFalse(worker.isAlive(), "Worker operations must not wait for the EDT"); assertNull(failure.get()); - CountDownLatch drained = new CountDownLatch(1); - CN.callSerially(drained::countDown); - waitFor(drained, 1000); - assertSame(previous, CN.getCurrentForm(), "Queued presentation must not change the current form"); + drainEdt(); + assertSame(previous, CN.getCurrentForm(), "Queued disposal must restore the original form"); assertNoAd(previous); assertEquals(Arrays.asList("shown", "impression"), events); } finally { ad.dispose(); + drainEdt(); previous.show(); } } @@ -338,14 +375,15 @@ void onShownCanDisposeSynchronouslyWithoutAnImpression() { @Override public void onShown() { events.add("shown"); assertTrue(CN.isEdt()); - assertSame(previous, CN.getCurrentForm()); - assertEquals(1, adLayer(previous).getComponentCount()); + assertNotSame(previous, CN.getCurrentForm()); + assertEquals(2, adContent(CN.getCurrentForm()).getComponentCount()); ad.dispose(); } @Override public void onImpression() { events.add("impression"); } }); ad.load(); ad.show(); + drainEdt(); assertSame(previous, CN.getCurrentForm()); assertEquals(Arrays.asList("shown"), events); } @@ -372,14 +410,13 @@ void edtDisposalCancelsAlreadyQueuedWorkerPresentation() throws Exception { assertNull(failure.get()); // An EDT caller can dispose before the queued load/show get a turn. ad.dispose(); - CountDownLatch drained = new CountDownLatch(1); - CN.callSerially(drained::countDown); - waitFor(drained, 1000); + drainEdt(); assertSame(previous, CN.getCurrentForm()); assertNoAd(previous); assertTrue(events.isEmpty(), "A disposed session must ignore pending presentation"); } finally { ad.dispose(); + drainEdt(); previous.show(); } } @@ -401,23 +438,28 @@ void overlappingAdIsRejectedAndCanRetryAfterFirstIsDisposed() { first.load(); second.load(); first.show(); - Command firstClose = host.getBackCommand(); + Form showing = CN.getCurrentForm(); + Command firstClose = showing.getBackCommand(); second.show(); assertEquals(Arrays.asList("rejected"), events); assertTrue(second.isLoaded(), "Rejected presentation must not consume the loaded ad"); - assertSame(firstClose, host.getBackCommand()); - assertEquals(1, adLayer(host).getComponentCount()); + assertSame(firstClose, showing.getBackCommand()); + assertSame(showing, CN.getCurrentForm()); first.dispose(); + drainEdt(); assertSame(applicationBack, host.getBackCommand()); second.show(); assertEquals(Arrays.asList("rejected", "shown"), events); - host.getBackCommand().actionPerformed(new ActionEvent(host)); + CN.getCurrentForm().getBackCommand().actionPerformed(new ActionEvent(host)); + drainEdt(); assertEquals(Arrays.asList("rejected", "shown", "dismissed"), events); assertSame(applicationBack, host.getBackCommand()); assertNoAd(host); } finally { second.dispose(); + drainEdt(); first.dispose(); + drainEdt(); } } @@ -433,23 +475,28 @@ void disposingRejectedAdLeavesVisibleAdAndBackCommandAlone() { first.load(); second.load(); first.show(); - Command firstClose = host.getBackCommand(); - Container firstOverlay = adOverlay(host); + Form showing = CN.getCurrentForm(); + Command firstClose = showing.getBackCommand(); + Container firstContent = adContent(CN.getCurrentForm()); second.show(); second.dispose(); - assertSame(firstClose, host.getBackCommand()); - assertSame(firstOverlay, adOverlay(host)); + drainEdt(); + assertSame(firstClose, showing.getBackCommand()); + assertSame(firstContent, adContent(CN.getCurrentForm())); firstClose.actionPerformed(new ActionEvent(host)); + drainEdt(); assertSame(applicationBack, host.getBackCommand()); assertNoAd(host); } finally { second.dispose(); + drainEdt(); first.dispose(); + drainEdt(); } } @FormTest - void overlayKeepsTypingAndFocusTraversalAwayFromHostControls() { + void adFormKeepsTypingAndFocusTraversalAwayFromHostControls() { MockAdProvider.install(); Form host = CN.getCurrentForm(); int[] typed = {0}; @@ -466,25 +513,28 @@ void overlayKeepsTypingAndFocusTraversalAwayFromHostControls() { try { ad.load(); ad.show(); - Component close = adOverlay(host).getComponentAt(1); - assertSame(close, host.getFocused()); - host.keyPressed('a'); - host.keyReleased('a'); + Form showing = CN.getCurrentForm(); + Component close = adContent(showing).getComponentAt(1); + assertSame(close, showing.getFocused()); + showing.keyPressed('a'); + showing.keyReleased('a'); for (int key : new int[]{Display.GAME_UP, Display.GAME_DOWN, Display.GAME_LEFT, Display.GAME_RIGHT}) { - host.keyPressed(key); - host.keyReleased(key); - assertSame(close, host.getFocused()); + showing.keyPressed(key); + showing.keyReleased(key); + assertSame(close, showing.getFocused()); } - assertNull(host.getNextComponent(close), "Tab must not reach a covered control"); - assertNull(host.getPreviousComponent(close), "Shift-Tab must not reach a covered control"); + assertNull(showing.getNextComponent(close), "Tab must not reach a covered control"); + assertNull(showing.getPreviousComponent(close), "Shift-Tab must not reach a covered control"); assertEquals(0, typed[0]); ad.dispose(); + drainEdt(); assertSame(input, host.getFocused()); assertTrue(input.isFocusable()); assertTrue(underlying.isFocusable()); assertFalse(label.isFocusable()); } finally { ad.dispose(); + drainEdt(); } } @@ -506,17 +556,45 @@ void keyboardCloseDoesNotAlsoTriggerHostDefaultCommand() { try { ad.load(); ad.show(); - host.keyPressed(Display.GAME_FIRE); - host.keyReleased(Display.GAME_FIRE); - CountDownLatch drained = new CountDownLatch(1); - CN.callSerially(drained::countDown); - waitFor(drained, 1000); + Form showing = CN.getCurrentForm(); + showing.keyPressed(Display.GAME_FIRE); + showing.keyReleased(Display.GAME_FIRE); + drainEdt(); assertEquals(Arrays.asList("shown", "impression", "dismissed"), events); assertNoAd(host); assertSame(underlying, host.getFocused()); assertSame(defaultCommand, host.getDefaultCommand()); } finally { ad.dispose(); + drainEdt(); + } + } + + @FormTest + void fullScreenAdBlocksHostKeyAndGameKeyListeners() { + MockAdProvider.install(); + Form host = CN.getCurrentForm(); + host.addKeyListener('x', evt -> events.add("shortcut")); + host.addKeyListener(Display.GAME_FIRE, evt -> events.add("enter")); + host.addGameKeyListener(Display.GAME_FIRE, evt -> events.add("game-fire")); + InterstitialAd ad = new InterstitialAd("mock"); + try { + ad.load(); + ad.show(); + Form showing = CN.getCurrentForm(); + showing.keyPressed('x'); + showing.keyReleased('x'); + showing.keyPressed(Display.GAME_FIRE); + showing.keyReleased(Display.GAME_FIRE); + drainEdt(); + assertTrue(events.isEmpty(), "Ad input must not invoke covered form shortcuts: " + events); + assertSame(host, CN.getCurrentForm()); + host.keyReleased('x'); + host.keyReleased(Display.GAME_FIRE); + assertEquals(Arrays.asList("shortcut", "enter", "game-fire"), events); + } finally { + ad.dispose(); + drainEdt(); } } From af07e57f6fdb4ae42990509f777f509f202c046b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:34:53 +0300 Subject: [PATCH 13/14] Scope Swift compatibility search paths to AdMob apps --- .../workflows/ad-cn1lib-ios-native-check.yml | 2 + .../builders/AdMobIosLinkSettings.java | 62 +++++++++++++++++ .../com/codename1/builders/IPhoneBuilder.java | 1 + .../builders/AdMobIosLinkSettingsTest.java | 69 +++++++++++++++++++ scripts/check-admob-ios-link.sh | 33 ++++++++- .../template.xcodeproj/project.pbxproj | 8 --- 6 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AdMobIosLinkSettings.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AdMobIosLinkSettingsTest.java diff --git a/.github/workflows/ad-cn1lib-ios-native-check.yml b/.github/workflows/ad-cn1lib-ios-native-check.yml index d1d282330dc..2c423052792 100644 --- a/.github/workflows/ad-cn1lib-ios-native-check.yml +++ b/.github/workflows/ad-cn1lib-ios-native-check.yml @@ -21,6 +21,7 @@ on: - 'maven/cn1-applovin/**' - 'maven/cn1-unity-levelplay/**' - 'scripts/check-admob-ios-link.sh' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AdMobIosLinkSettings.java' - 'vm/ByteCodeTranslator/**' - 'vm/pom.xml' - '.github/workflows/ad-cn1lib-ios-native-check.yml' @@ -31,6 +32,7 @@ on: - 'maven/cn1-applovin/**' - 'maven/cn1-unity-levelplay/**' - 'scripts/check-admob-ios-link.sh' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AdMobIosLinkSettings.java' - 'vm/ByteCodeTranslator/**' - 'vm/pom.xml' - '.github/workflows/ad-cn1lib-ios-native-check.yml' diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AdMobIosLinkSettings.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AdMobIosLinkSettings.java new file mode 100644 index 00000000000..928f00b0e51 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AdMobIosLinkSettings.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +/** Link settings needed by the Google Mobile Ads pod's prebuilt Swift objects. */ +final class AdMobIosLinkSettings { + private AdMobIosLinkSettings() { + } + + static void apply(File project, String pods) throws IOException { + boolean usesAdMob = false; + if (pods != null) { + for (String pod : pods.split("[,;]")) { + if ("Google-Mobile-Ads-SDK".equals(pod.trim().split("\\s+", 2)[0])) { + usesAdMob = true; + break; + } + } + } + if (!usesAdMob) { + return; + } + String contents = new String(Files.readAllBytes(project.toPath()), StandardCharsets.UTF_8); + // TOOLCHAIN_DIR can select the separately installed Metal toolchain. + String swiftPath = "$(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME)"; + if (contents.contains(swiftPath)) { + return; + } + String anchor = "LIBRARY_SEARCH_PATHS = ("; + if (!contents.contains(anchor)) { + throw new IOException("Cannot add AdMob link paths: generated project has no library search paths"); + } + contents = contents.replace(anchor, anchor + "\n\t\t\t\t\t\"" + swiftPath + + "\",\n\t\t\t\t\t\"$(SDKROOT)/usr/lib/swift\","); + Files.write(project.toPath(), contents.getBytes(StandardCharsets.UTF_8)); + } +} 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 af84a03d582..0b316e96f0d 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 @@ -6677,6 +6677,7 @@ public void usesClassMethod(String cls, String method) { // that the change will work for all builds. I made it "only" for the cocoapods version // to prevent inadvertent breaking of versioned builds etc... if (runPods) { + AdMobIosLinkSettings.apply(pbx, iosPods); replaceAllInFile(pbx, "ARCHS = [^;]+;", "ARCHS = \"\\$(ARCHS_STANDARD)\";"); replaceAllInFile(pbx, "VALID_ARCHS = [^;]+;", "VALID_ARCHS = \"\\$(ARCHS_STANDARD)\";"); } else { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AdMobIosLinkSettingsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AdMobIosLinkSettingsTest.java new file mode 100644 index 00000000000..927be88995e --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AdMobIosLinkSettingsTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class AdMobIosLinkSettingsTest { + @TempDir Path temp; + + private byte[] template() throws Exception { + return Files.readAllBytes(Paths.get("../../vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj")); + } + + @Test + void ordinaryAndUnrelatedPodProjectsRemainByteForByteUnchanged() throws Exception { + byte[] original = template(); + assertFalse(new String(original, StandardCharsets.UTF_8).contains("/usr/lib/swift")); + Path project = temp.resolve("project.pbxproj"); + for (String pods : new String[]{null, "", "Alamofire", "Google-Mobile-Ads-SDK-Other ~> 13.0"}) { + Files.write(project, original); + AdMobIosLinkSettings.apply(project.toFile(), pods); + assertArrayEquals(original, Files.readAllBytes(project), String.valueOf(pods)); + } + } + + @Test + void adMobGetsPathsInBothConfigurationsAndPreservesExistingPaths() throws Exception { + Path project = temp.resolve("project.pbxproj"); + for (String pods : new String[]{"Google-Mobile-Ads-SDK ~> 13.0", + "OtherPod, Google-Mobile-Ads-SDK ~> 13.0; ThirdPod", "Google-Mobile-Ads-SDK"}) { + Files.write(project, template()); + AdMobIosLinkSettings.apply(project.toFile(), pods); + String result = new String(Files.readAllBytes(project), StandardCharsets.UTF_8); + assertEquals(2, result.split("XcodeDefault.xctoolchain/usr/lib/swift", -1).length - 1); + assertEquals(2, result.split("/usr/lib/swift\",", -1).length - 1); + assertTrue(result.contains("$(PROJECT_DIR)/template-src")); + assertTrue(result.contains("$(inherited)")); + byte[] first = Files.readAllBytes(project); + AdMobIosLinkSettings.apply(project.toFile(), pods); + assertArrayEquals(first, Files.readAllBytes(project)); + } + } +} diff --git a/scripts/check-admob-ios-link.sh b/scripts/check-admob-ios-link.sh index 4f31758eaec..cc909e0bf4a 100755 --- a/scripts/check-admob-ios-link.sh +++ b/scripts/check-admob-ios-link.sh @@ -45,6 +45,37 @@ java -jar "$ROOT/vm/ByteCodeTranslator/dist/ByteCodeTranslator.jar" ios \ "$PROBE/Sources" "$PROBE/generated" AdMobLinkProbe com.codenameone.test \ AdMobLinkProbe 1.0 ios "${LIBS:-none}" PROJECT="$PROBE/generated/dist" +# Apply exactly the AdMob-only app settings used by IPhoneBuilder. The shared +# translator template must remain free of Swift paths for unrelated apps. +cat > "$PROBE/ApplyAdMobSettings.java" <<'JAVA' +package com.codename1.builders; +import java.io.*; +import java.nio.file.*; +import java.util.*; +public class ApplyAdMobSettings { + public static void main(String[] args) throws Exception { + File project = new File(args[0]); + byte[] original = Files.readAllBytes(project.toPath()); + if (new String(original, "UTF-8").contains("/usr/lib/swift")) { + throw new AssertionError("Swift paths leaked into the shared translator template"); + } + AdMobIosLinkSettings.apply(project, "UnrelatedPod"); + if (!Arrays.equals(original, Files.readAllBytes(project.toPath()))) { + throw new AssertionError("AdMob settings changed an unrelated project"); + } + Properties hints = new Properties(); + try (InputStream input = new FileInputStream(args[1])) { hints.load(input); } + AdMobIosLinkSettings.apply(project, hints.getProperty("codename1.arg.ios.pods")); + } +} +JAVA +mkdir -p "$PROBE/link-settings" +javac -d "$PROBE/link-settings" \ + "$ROOT/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AdMobIosLinkSettings.java" \ + "$PROBE/ApplyAdMobSettings.java" +java -cp "$PROBE/link-settings" com.codename1.builders.ApplyAdMobSettings \ + "$PROJECT/AdMobLinkProbe.xcodeproj/project.pbxproj" \ + "$ROOT/maven/cn1-admob/common/codenameone_library_required.properties" cp "$PROBE"/{cn1_globals.h,cn1_virtual_thread.h,cn1_class_method_index.h,Prefix.pch} "$PROJECT/" plutil -convert json -o "$PROBE/project.json" "$PROJECT/AdMobLinkProbe.xcodeproj/project.pbxproj" python3 - "$ROOT" "$PROBE" "$PROJECT" "$LIBS" <<'PYTHON' @@ -74,7 +105,7 @@ for lib in filter(None, sys.argv[4].split(';')): assert obj.get('lastKnownFileType') == 'sourcecode.text-based-dylib-definition', obj assert ref in linked and ref not in copied, 'Library hint is not a linker input: ' + lib # Only replace translated runtime sources with the callback stubs above. Keep -# the translator's Frameworks phase, SDK paths and LIBRARY_SEARCH_PATHS verbatim. +# the translator's Frameworks phase and the builder's AdMob-only search paths. source_names = {'main.m', 'com_codename1_ads_admob_AdMobNativeImpl.m'} for obj in objects.values(): if obj['isa'] == 'PBXSourcesBuildPhase': diff --git a/vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj b/vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj index 37c451c9b06..304a953222f 100644 --- a/vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj +++ b/vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj @@ -309,10 +309,6 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/template-src", - // Prebuilt pods can require Swift compatibility archives even in - // an Objective-C app. TOOLCHAIN_DIR can point at the Metal toolchain. - "$(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME)", - "$(SDKROOT)/usr/lib/swift", ); }; name = Debug; @@ -356,10 +352,6 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/template-src", - // Prebuilt pods can require Swift compatibility archives even in - // an Objective-C app. TOOLCHAIN_DIR can point at the Metal toolchain. - "$(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME)", - "$(SDKROOT)/usr/lib/swift", ); }; name = Release; From dcce3bcaae374df45652ba303f0e8baf761a2f36 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:46:48 +0300 Subject: [PATCH 14/14] Use existing Swift support for the AdMob library --- .../workflows/ad-cn1lib-ios-native-check.yml | 2 - .../objectivec/CN1AdMobSwiftSupport.swift | 26 +++++++ .../builders/AdMobIosLinkSettings.java | 62 ----------------- .../com/codename1/builders/IPhoneBuilder.java | 1 - .../builders/AdMobIosLinkSettingsTest.java | 69 ------------------- scripts/check-admob-ios-link.sh | 59 ++++++++-------- 6 files changed, 53 insertions(+), 166 deletions(-) create mode 100644 maven/cn1-admob/ios/src/main/objectivec/CN1AdMobSwiftSupport.swift delete mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AdMobIosLinkSettings.java delete mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AdMobIosLinkSettingsTest.java diff --git a/.github/workflows/ad-cn1lib-ios-native-check.yml b/.github/workflows/ad-cn1lib-ios-native-check.yml index 2c423052792..d1d282330dc 100644 --- a/.github/workflows/ad-cn1lib-ios-native-check.yml +++ b/.github/workflows/ad-cn1lib-ios-native-check.yml @@ -21,7 +21,6 @@ on: - 'maven/cn1-applovin/**' - 'maven/cn1-unity-levelplay/**' - 'scripts/check-admob-ios-link.sh' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AdMobIosLinkSettings.java' - 'vm/ByteCodeTranslator/**' - 'vm/pom.xml' - '.github/workflows/ad-cn1lib-ios-native-check.yml' @@ -32,7 +31,6 @@ on: - 'maven/cn1-applovin/**' - 'maven/cn1-unity-levelplay/**' - 'scripts/check-admob-ios-link.sh' - - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AdMobIosLinkSettings.java' - 'vm/ByteCodeTranslator/**' - 'vm/pom.xml' - '.github/workflows/ad-cn1lib-ios-native-check.yml' diff --git a/maven/cn1-admob/ios/src/main/objectivec/CN1AdMobSwiftSupport.swift b/maven/cn1-admob/ios/src/main/objectivec/CN1AdMobSwiftSupport.swift new file mode 100644 index 00000000000..261bb3206ff --- /dev/null +++ b/maven/cn1-admob/ios/src/main/objectivec/CN1AdMobSwiftSupport.swift @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +// Google Mobile Ads includes prebuilt Swift objects. An app-target Swift source +// activates CN1's existing Swift support and Xcode's compatibility-library +// linkage, including when all of the application's own native code is Objective-C. +import Foundation diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AdMobIosLinkSettings.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AdMobIosLinkSettings.java deleted file mode 100644 index 928f00b0e51..00000000000 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AdMobIosLinkSettings.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -package com.codename1.builders; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; - -/** Link settings needed by the Google Mobile Ads pod's prebuilt Swift objects. */ -final class AdMobIosLinkSettings { - private AdMobIosLinkSettings() { - } - - static void apply(File project, String pods) throws IOException { - boolean usesAdMob = false; - if (pods != null) { - for (String pod : pods.split("[,;]")) { - if ("Google-Mobile-Ads-SDK".equals(pod.trim().split("\\s+", 2)[0])) { - usesAdMob = true; - break; - } - } - } - if (!usesAdMob) { - return; - } - String contents = new String(Files.readAllBytes(project.toPath()), StandardCharsets.UTF_8); - // TOOLCHAIN_DIR can select the separately installed Metal toolchain. - String swiftPath = "$(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME)"; - if (contents.contains(swiftPath)) { - return; - } - String anchor = "LIBRARY_SEARCH_PATHS = ("; - if (!contents.contains(anchor)) { - throw new IOException("Cannot add AdMob link paths: generated project has no library search paths"); - } - contents = contents.replace(anchor, anchor + "\n\t\t\t\t\t\"" + swiftPath - + "\",\n\t\t\t\t\t\"$(SDKROOT)/usr/lib/swift\","); - Files.write(project.toPath(), contents.getBytes(StandardCharsets.UTF_8)); - } -} 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 0b316e96f0d..af84a03d582 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 @@ -6677,7 +6677,6 @@ public void usesClassMethod(String cls, String method) { // that the change will work for all builds. I made it "only" for the cocoapods version // to prevent inadvertent breaking of versioned builds etc... if (runPods) { - AdMobIosLinkSettings.apply(pbx, iosPods); replaceAllInFile(pbx, "ARCHS = [^;]+;", "ARCHS = \"\\$(ARCHS_STANDARD)\";"); replaceAllInFile(pbx, "VALID_ARCHS = [^;]+;", "VALID_ARCHS = \"\\$(ARCHS_STANDARD)\";"); } else { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AdMobIosLinkSettingsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AdMobIosLinkSettingsTest.java deleted file mode 100644 index 927be88995e..00000000000 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AdMobIosLinkSettingsTest.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -package com.codename1.builders; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import static org.junit.jupiter.api.Assertions.*; - -class AdMobIosLinkSettingsTest { - @TempDir Path temp; - - private byte[] template() throws Exception { - return Files.readAllBytes(Paths.get("../../vm/ByteCodeTranslator/src/template/template.xcodeproj/project.pbxproj")); - } - - @Test - void ordinaryAndUnrelatedPodProjectsRemainByteForByteUnchanged() throws Exception { - byte[] original = template(); - assertFalse(new String(original, StandardCharsets.UTF_8).contains("/usr/lib/swift")); - Path project = temp.resolve("project.pbxproj"); - for (String pods : new String[]{null, "", "Alamofire", "Google-Mobile-Ads-SDK-Other ~> 13.0"}) { - Files.write(project, original); - AdMobIosLinkSettings.apply(project.toFile(), pods); - assertArrayEquals(original, Files.readAllBytes(project), String.valueOf(pods)); - } - } - - @Test - void adMobGetsPathsInBothConfigurationsAndPreservesExistingPaths() throws Exception { - Path project = temp.resolve("project.pbxproj"); - for (String pods : new String[]{"Google-Mobile-Ads-SDK ~> 13.0", - "OtherPod, Google-Mobile-Ads-SDK ~> 13.0; ThirdPod", "Google-Mobile-Ads-SDK"}) { - Files.write(project, template()); - AdMobIosLinkSettings.apply(project.toFile(), pods); - String result = new String(Files.readAllBytes(project), StandardCharsets.UTF_8); - assertEquals(2, result.split("XcodeDefault.xctoolchain/usr/lib/swift", -1).length - 1); - assertEquals(2, result.split("/usr/lib/swift\",", -1).length - 1); - assertTrue(result.contains("$(PROJECT_DIR)/template-src")); - assertTrue(result.contains("$(inherited)")); - byte[] first = Files.readAllBytes(project); - AdMobIosLinkSettings.apply(project.toFile(), pods); - assertArrayEquals(first, Files.readAllBytes(project)); - } - } -} diff --git a/scripts/check-admob-ios-link.sh b/scripts/check-admob-ios-link.sh index cc909e0bf4a..ea30b76efd6 100755 --- a/scripts/check-admob-ios-link.sh +++ b/scripts/check-admob-ios-link.sh @@ -45,37 +45,6 @@ java -jar "$ROOT/vm/ByteCodeTranslator/dist/ByteCodeTranslator.jar" ios \ "$PROBE/Sources" "$PROBE/generated" AdMobLinkProbe com.codenameone.test \ AdMobLinkProbe 1.0 ios "${LIBS:-none}" PROJECT="$PROBE/generated/dist" -# Apply exactly the AdMob-only app settings used by IPhoneBuilder. The shared -# translator template must remain free of Swift paths for unrelated apps. -cat > "$PROBE/ApplyAdMobSettings.java" <<'JAVA' -package com.codename1.builders; -import java.io.*; -import java.nio.file.*; -import java.util.*; -public class ApplyAdMobSettings { - public static void main(String[] args) throws Exception { - File project = new File(args[0]); - byte[] original = Files.readAllBytes(project.toPath()); - if (new String(original, "UTF-8").contains("/usr/lib/swift")) { - throw new AssertionError("Swift paths leaked into the shared translator template"); - } - AdMobIosLinkSettings.apply(project, "UnrelatedPod"); - if (!Arrays.equals(original, Files.readAllBytes(project.toPath()))) { - throw new AssertionError("AdMob settings changed an unrelated project"); - } - Properties hints = new Properties(); - try (InputStream input = new FileInputStream(args[1])) { hints.load(input); } - AdMobIosLinkSettings.apply(project, hints.getProperty("codename1.arg.ios.pods")); - } -} -JAVA -mkdir -p "$PROBE/link-settings" -javac -d "$PROBE/link-settings" \ - "$ROOT/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AdMobIosLinkSettings.java" \ - "$PROBE/ApplyAdMobSettings.java" -java -cp "$PROBE/link-settings" com.codename1.builders.ApplyAdMobSettings \ - "$PROJECT/AdMobLinkProbe.xcodeproj/project.pbxproj" \ - "$ROOT/maven/cn1-admob/common/codenameone_library_required.properties" cp "$PROBE"/{cn1_globals.h,cn1_virtual_thread.h,cn1_class_method_index.h,Prefix.pch} "$PROJECT/" plutil -convert json -o "$PROBE/project.json" "$PROJECT/AdMobLinkProbe.xcodeproj/project.pbxproj" python3 - "$ROOT" "$PROBE" "$PROJECT" "$LIBS" <<'PYTHON' @@ -105,7 +74,7 @@ for lib in filter(None, sys.argv[4].split(';')): assert obj.get('lastKnownFileType') == 'sourcecode.text-based-dylib-definition', obj assert ref in linked and ref not in copied, 'Library hint is not a linker input: ' + lib # Only replace translated runtime sources with the callback stubs above. Keep -# the translator's Frameworks phase and the builder's AdMob-only search paths. +# the translator's Frameworks phase, SDK paths and library search paths. source_names = {'main.m', 'com_codename1_ads_admob_AdMobNativeImpl.m'} for obj in objects.values(): if obj['isa'] == 'PBXSourcesBuildPhase': @@ -129,6 +98,32 @@ with (project / 'AdMobLinkProbe.xcodeproj/project.pbxproj').open('wb') as output plistlib.dump(data, output) PYTHON (cd "$PROJECT" && pod install) +# Mirror IPhoneBuilder's existing Swift-source handling after pod integration. +# Do not supply custom Swift library search paths to make the probe link. +ruby - "$PROJECT" <<'RUBY' +require 'xcodeproj' +root = ARGV[0] +project = Xcodeproj::Project.open(File.join(root, 'AdMobLinkProbe.xcodeproj')) +app = project.targets.find { |target| target.name == 'AdMobLinkProbe' } +Dir.glob(File.join(root, 'AdMobLinkProbe-src', '**', '*.swift')).each do |path| + relative = Pathname.new(path).relative_path_from(Pathname.new(root)).to_s + ref = project.files.find { |file| file.path == relative } || project.main_group.new_file(relative) + app.source_build_phase.add_file_reference(ref, true) unless app.source_build_phase.files_references.include?(ref) + app.resources_build_phase.remove_file_reference(ref) +end +unless Dir.glob(File.join(root, '**', '*.swift')).empty? + File.write(File.join(root, 'cn1-Bridging-Header.h'), "// Codename One generated Swift bridging header\n") + project.build_configurations.each { |config| config.build_settings['SWIFT_VERSION'] = '5.0' } + app.build_configurations.each do |config| + config.build_settings['DEFINES_MODULE'] = 'YES' + config.build_settings['SWIFT_OBJC_BRIDGING_HEADER'] = '$(SRCROOT)/cn1-Bridging-Header.h' + end +end +support = app.source_build_phase.files_references.select { |file| File.basename(file.path) == 'CN1AdMobSwiftSupport.swift' } +raise 'The shipped AdMob Swift source is missing from the app compile phase' unless support.size == 1 +raise 'AdMob Swift source must not be copied as a resource' if app.resources_build_phase.files_references.include?(support.first) +project.save +RUBY xcodebuild -workspace "$PROJECT/AdMobLinkProbe.xcworkspace" -scheme AdMobLinkProbe \ -configuration Release -sdk "$SDK" -derivedDataPath "$PROBE/build-$SDK" \ CODE_SIGNING_ALLOWED=NO build