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