diff --git a/.github/workflows/ad-cn1lib-ios-native-check.yml b/.github/workflows/ad-cn1lib-ios-native-check.yml
index 738daa72248..d1d282330dc 100644
--- a/.github/workflows/ad-cn1lib-ios-native-check.yml
+++ b/.github/workflows/ad-cn1lib-ios-native-check.yml
@@ -20,8 +20,9 @@ on:
- 'maven/cn1-admob/**'
- 'maven/cn1-applovin/**'
- 'maven/cn1-unity-levelplay/**'
- - 'vm/ByteCodeTranslator/src/cn1_globals.h'
- - 'vm/ByteCodeTranslator/src/cn1_virtual_thread.h'
+ - 'scripts/check-admob-ios-link.sh'
+ - 'vm/ByteCodeTranslator/**'
+ - 'vm/pom.xml'
- '.github/workflows/ad-cn1lib-ios-native-check.yml'
push:
branches: [master]
@@ -29,8 +30,9 @@ on:
- 'maven/cn1-admob/**'
- 'maven/cn1-applovin/**'
- 'maven/cn1-unity-levelplay/**'
- - 'vm/ByteCodeTranslator/src/cn1_globals.h'
- - 'vm/ByteCodeTranslator/src/cn1_virtual_thread.h'
+ - 'scripts/check-admob-ios-link.sh'
+ - 'vm/ByteCodeTranslator/**'
+ - 'vm/pom.xml'
- '.github/workflows/ad-cn1lib-ios-native-check.yml'
concurrency:
@@ -200,3 +202,20 @@ 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
+ # Temurin 8 is unavailable on macOS ARM64; Zulu supplies native Java 8.
+ - uses: actions/setup-java@v5
+ with:
+ 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 }}"
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-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/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..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
@@ -38,10 +38,15 @@
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.events.ActionListener;
import com.codename1.ui.geom.Dimension;
import com.codename1.ui.layouts.BorderLayout;
@@ -111,11 +116,15 @@ public void loadNativeAd(String adUnitId, AdRequest request,
}
/// Deterministic full screen ad: fires the lifecycle events and presents a
- /// fixed close-able form.
+ /// separate Form with a Close button.
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 MockAdForm adForm;
+ private static MockAdForm activeForm;
MockFullScreen(AdFormat format) {
this.format = format;
@@ -132,6 +141,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();
}
@@ -143,17 +159,106 @@ 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;
}
+ if (activeForm != null) {
+ 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;
+ adForm = new MockAdForm(previous);
+ activeForm = adForm;
+ Button close = new Button("Close ad");
+ close.addActionListener(evt -> closeAd(true));
+ 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);
+ }
+ });
+ adForm.show();
+ close.requestFocus();
cb.onShown();
- cb.onImpression();
- if (format == AdFormat.REWARDED || format == AdFormat.REWARDED_INTERSTITIAL) {
- cb.onUserEarnedReward(new RewardItem("coins", 10));
+ if (adForm != null) {
+ cb.onImpression();
+ }
+ }
+
+ private void closeAd(boolean notify) {
+ if (adForm == null) {
+ return;
+ }
+ 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));
+ }
+ });
+ }
+
+ 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();
+ }
+ });
+ }
+ previous.showBack();
}
- cb.onDismissed();
}
@Override
@@ -162,6 +267,13 @@ 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/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..5de1eb94799
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/ads/MockAdProviderTest.java
@@ -0,0 +1,601 @@
+/*
+ * 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;
+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.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;
+import com.codename1.ui.events.ActionEvent;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.CountDownLatch;
+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 Container adContent(Form form) {
+ return form.getContentPane();
+ }
+
+ private void drainEdt() {
+ CountDownLatch drained = new CountDownLatch(1);
+ CN.callSerially(() -> CN.callSerially(drained::countDown));
+ waitFor(drained, 1000);
+ }
+
+ private void assertNoAd(Form form) {
+ assertSame(form, CN.getCurrentForm());
+ }
+
+ 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 interstitialFormStaysVisibleUntilClosed() {
+ 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);
+ 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) 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));
+ drainEdt();
+ assertNoAd(previous);
+ assertSame(previousBack, previous.getBackCommand());
+ assertEquals(3, events.size(), "Closing twice must not repeat callbacks");
+ ad.dispose();
+ drainEdt();
+ }
+
+ @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));
+ drainEdt();
+ assertSame(previous, CN.getCurrentForm());
+ assertEquals(Arrays.asList("shown", "impression", "reward", "dismissed"), events);
+ ad.dispose();
+ drainEdt();
+ }
+
+ @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();
+ drainEdt();
+ assertSame(previous, CN.getCurrentForm());
+ assertEquals(Arrays.asList("shown", "impression"), events);
+ }
+
+ @FormTest
+ void adRestoresModalCallerBeforeDismissalCallback() {
+ MockAdProvider.install();
+ Dialog dialog = new Dialog("Modal caller");
+ Form destination = new Form("After dismissal");
+ RewardedAd ad = new RewardedAd("mock");
+ AtomicReference failure = new AtomicReference();
+ ad.setAdListener(new AdListener() {
+ @Override public void onDismissed() {
+ events.add("dismissed");
+ assertSame(dialog, CN.getCurrentForm());
+ assertNoAd(dialog);
+ dialog.dispose();
+ drainEdt();
+ destination.show();
+ }
+ });
+ ad.setOnUserEarnedRewardListener(reward -> events.add("reward"));
+ 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());
+ assertEquals(Arrays.asList("reward", "dismissed"), events);
+ assertSame(destination, CN.getCurrentForm());
+ } finally {
+ ad.dispose();
+ drainEdt();
+ }
+ }
+
+ @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", ((Label) adContent(showing).getComponentAt(0)).getText());
+ if (action == 0) {
+ 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());
+ assertNotSame(showing, CN.getCurrentForm());
+ assertEquals(action == 2 ? Arrays.asList("shown", "impression")
+ : Arrays.asList("shown", "impression", "dismissed"), events);
+ ad.dispose();
+ drainEdt();
+ }
+ }
+
+ @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();
+ 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();
+ }
+ }
+
+ @FormTest
+ void disposalDoesNotUndoApplicationNavigation() {
+ MockAdProvider.install();
+ InterstitialAd ad = new InterstitialAd("mock");
+ ad.load();
+ ad.show();
+ Form destination = new Form("Application destination");
+ destination.show();
+ ad.dispose();
+ drainEdt();
+ assertSame(destination, CN.getCurrentForm());
+ }
+
+ @FormTest
+ void disposedAdReturnsToCallerAfterNestedDialogsClose() {
+ 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();
+ 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");
+ }
+ } finally {
+ for (int i = depth - 1; i >= 0; i--) {
+ if (overlays[i] != null) {
+ overlays[i].dispose();
+ drainEdt();
+ }
+ }
+ ad.dispose();
+ drainEdt();
+ 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();
+ drainEdt();
+ assertSame(unrelated, CN.getCurrentForm());
+ unrelated.dispose();
+ drainEdt();
+ assertSame(destination, CN.getCurrentForm());
+ } finally {
+ ad.dispose();
+ drainEdt();
+ unrelated.dispose();
+ drainEdt();
+ }
+ }
+
+ @FormTest
+ void workerShowThenDisposeRestoresCaller() 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());
+ 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();
+ }
+ }
+
+ @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());
+ 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);
+ }
+
+ @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();
+ drainEdt();
+ assertSame(previous, CN.getCurrentForm());
+ assertNoAd(previous);
+ assertTrue(events.isEmpty(), "A disposed session must ignore pending presentation");
+ } finally {
+ ad.dispose();
+ drainEdt();
+ previous.show();
+ }
+ }
+
+ @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();
+ 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, showing.getBackCommand());
+ assertSame(showing, CN.getCurrentForm());
+ first.dispose();
+ drainEdt();
+ assertSame(applicationBack, host.getBackCommand());
+ second.show();
+ assertEquals(Arrays.asList("rejected", "shown"), events);
+ 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();
+ }
+ }
+
+ @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();
+ Form showing = CN.getCurrentForm();
+ Command firstClose = showing.getBackCommand();
+ Container firstContent = adContent(CN.getCurrentForm());
+ second.show();
+ second.dispose();
+ 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 adFormKeepsTypingAndFocusTraversalAwayFromHostControls() {
+ 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();
+ 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}) {
+ showing.keyPressed(key);
+ showing.keyReleased(key);
+ assertSame(close, showing.getFocused());
+ }
+ 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();
+ }
+ }
+
+ @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();
+ 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();
+ }
+ }
+
+}
diff --git a/scripts/check-admob-ios-link.sh b/scripts/check-admob-ios-link.sh
new file mode 100755
index 00000000000..ea30b76efd6
--- /dev/null
+++ b/scripts/check-admob-ios-link.sh
@@ -0,0 +1,129 @@
+#!/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}"
+# 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/"
+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
+# 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)
+(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.
+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)
+# 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
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..76fe3db739a
--- /dev/null
+++ b/scripts/cn1lib-api-check/admob-runtime/src/androidTest/java/com/codename1/ads/admob/BannerMeasurementTest.java
@@ -0,0 +1,50 @@
+/*
+ * 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;
+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..106e7962fe8
--- /dev/null
+++ b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/AdMobCallback.java
@@ -0,0 +1,37 @@
+/*
+ * 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.
+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..7ece17a0155
--- /dev/null
+++ b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/ads/admob/ProbeActivity.java
@@ -0,0 +1,30 @@
+/*
+ * 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 {
+ @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..15d4eeed2b1
--- /dev/null
+++ b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidImplementation.java
@@ -0,0 +1,33 @@
+/*
+ * 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 {
+ 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..1b13db08411
--- /dev/null
+++ b/scripts/cn1lib-api-check/admob-runtime/src/main/java/com/codename1/impl/android/AndroidNativeUtil.java
@@ -0,0 +1,28 @@
+/*
+ * 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 {
+ public static android.app.Activity activity;
+ public static android.app.Activity getActivity() { return activity; }
+}
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