diff --git a/.ci/container/Dockerfile b/.ci/container/Dockerfile index e1a7dd69bb0..4d63ad0aa64 100644 --- a/.ci/container/Dockerfile +++ b/.ci/container/Dockerfile @@ -10,6 +10,8 @@ RUN apt-get update && apt-get install -y \ zip \ git \ python3 \ + gcc \ + gcc-mingw-w64-x86-64 \ xvfb \ ant \ maven \ diff --git a/.github/workflows/ad-cn1lib-ios-native-check.yml b/.github/workflows/ad-cn1lib-ios-native-check.yml new file mode 100644 index 00000000000..cc5870ac308 --- /dev/null +++ b/.github/workflows/ad-cn1lib-ios-native-check.yml @@ -0,0 +1,195 @@ +name: Ad cn1lib iOS native check + +# The Objective-C in an ad cn1lib is shipped as source: our builds never +# compile it, the customer's Xcode is the first compiler that ever sees it, and +# the pod it is written against keeps moving underneath it. That is how +# cn1-admob reached users referencing GADSimulatorID, which the Google Mobile +# Ads SDK removed in version 12, and BRIDGE_RETAINED, which has never existed +# anywhere in the port. Both surfaced as a failed app build. +# +# So the native sources are compiled here against the very pod the library +# pins, in a throwaway static-library target. A static library archives .o +# files without linking, so the externs the real build supplies (the translated +# AdMobCallback entry point) do not have to resolve. + +on: + workflow_dispatch: + pull_request: + branches: [master] + paths: + - 'maven/cn1-admob/**' + - 'maven/cn1-applovin/**' + - 'maven/cn1-unity-levelplay/**' + - 'vm/ByteCodeTranslator/src/cn1_globals.h' + - '.github/workflows/ad-cn1lib-ios-native-check.yml' + push: + branches: [master] + paths: + - 'maven/cn1-admob/**' + - 'maven/cn1-applovin/**' + - 'maven/cn1-unity-levelplay/**' + - 'vm/ByteCodeTranslator/src/cn1_globals.h' + - '.github/workflows/ad-cn1lib-ios-native-check.yml' + +concurrency: + # These jobs run on macOS, where the runner pool is small and a queued run + # holds its slots until it finishes. Without this, a branch that is pushed + # several times queues every superseded run behind the current one -- five + # obsolete runs of this workflow, 6 macOS jobs each, sat queued on this + # branch before it was added. + # + # Keyed on the pull request number rather than head_ref, which is the source + # branch name with no fork identity in it: two pull requests opened from + # different forks that both use a common branch name -- master, patch-1 -- + # would share a group and cancel each other's check. On push to master there + # is no pull request, so the group falls back to the unique run_id and every + # master commit is still checked in full. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + compile-native-sources: + name: clang ${{ matrix.lib }} (ARC ${{ matrix.arc }}) + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + lib: [cn1-admob, cn1-applovin, cn1-unity-levelplay] + # Both memory models, because which one applies is not ours to assume. + # The generated app target is manual-retain-release + # (CLANG_ENABLE_OBJC_ARC = NO in + # vm/ByteCodeTranslator/src/template/template.xcodeproj), and that is + # what compiles a cn1lib's sources today -- but IPhoneBuilder also adds + # -fobjc-arc to individual files (arcPhaseFixScript does it for + # CN1Vision.m, CN1Language.m and CN1Inference.m), so ARC is reachable + # too. Checking only one model would accept, for instance, a __bridge + # cast that is an error under MRR, or a missing release that ARC hides. + arc: [NO, YES] + steps: + - uses: actions/checkout@v6 + + - name: Resolve the pod this library pins + id: pod + # Read it out of codenameone_library_required.properties rather than + # repeating it here, so the version the check compiles against is the + # version apps build against, by construction. + run: | + set -euo pipefail + props="maven/${{ matrix.lib }}/common/codenameone_library_required.properties" + line="$(sed -n 's/^codename1\.arg\.ios\.pods=//p' "$props" | head -1)" + if [ -z "$line" ]; then + echo "::error::No codename1.arg.ios.pods in $props"; exit 1 + fi + name="${line%% *}" + version="" + if [ "$name" != "$line" ]; then + version="${line#* }" + fi + echo "name=$name" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Pod: $name ${version:-(unpinned)}" + + - name: Stage the probe sources + run: | + set -euxo pipefail + PROBE=/tmp/probe-${{ matrix.lib }}-arc${{ matrix.arc }} + mkdir -p "$PROBE/CN1AdProbe" + # Objective-C and Objective-C++ alike. check-cn1lib-native-coverage.py + # counts a .mm file as an iOS native source, so staging only .m would + # let a broken Objective-C++ bridge be reported as covered while + # nothing ever compiled it. + cp maven/${{ matrix.lib }}/ios/src/main/objectivec/*.h "$PROBE/CN1AdProbe/" 2>/dev/null || true + cp maven/${{ matrix.lib }}/ios/src/main/objectivec/*.m "$PROBE/CN1AdProbe/" 2>/dev/null || true + cp maven/${{ matrix.lib }}/ios/src/main/objectivec/*.mm "$PROBE/CN1AdProbe/" 2>/dev/null || true + # An empty probe would build green and check nothing. find is used + # rather than ls because ls reports failure when either glob is + # unmatched, which is the normal case for a library with no .mm. + staged=$(find "$PROBE/CN1AdProbe" -maxdepth 1 \( -name '*.m' -o -name '*.mm' \) | wc -l) + if [ "$staged" -eq 0 ]; then + echo "::error::No Objective-C sources staged for ${{ matrix.lib }}"; exit 1 + fi + + # These sources call back into Java through the generated entry point + # and use JAVA_INT / JAVA_OBJECT / fromNSString without importing + # anything: in a real build the translator's -Prefix.pch pulls in + # cn1_globals.h for them. Reproduce that here, using the port's own + # header so a change to those macros is caught too. + cp vm/ByteCodeTranslator/src/cn1_globals.h "$PROBE/" + # Generated per translation from the app's class list; nothing in the + # ad bridges reads it, so an empty stand-in is enough to let + # cn1_globals.h parse on its own. + printf '#pragma once\n' > "$PROBE/cn1_class_method_index.h" + cat > "$PROBE/CN1AdProbe-Prefix.pch" <<'PCH' + #ifdef __OBJC__ + #import + #import + #endif + #include "cn1_globals.h" + PCH + ls -la "$PROBE" "$PROBE/CN1AdProbe" + + - name: Install xcodegen + run: brew install xcodegen + + - name: Synthesise the Xcode project + run: | + set -euxo pipefail + PROBE=/tmp/probe-${{ matrix.lib }}-arc${{ matrix.arc }} + # The heredoc is quoted so the shell leaves it alone; the matrix + # value below is substituted by Actions before this script runs, so + # the generated project carries a literal YES or NO. + cat > "$PROBE/project.yml" <<'YML' + name: CN1AdProbe + options: + bundleIdPrefix: com.codenameone.cn1ads + deploymentTarget: + iOS: "15.0" + targets: + CN1AdProbe: + type: library.static + platform: iOS + sources: + - path: CN1AdProbe + settings: + base: + CLANG_ENABLE_MODULES: YES + CLANG_ENABLE_OBJC_ARC: ${{ matrix.arc }} + CODE_SIGNING_ALLOWED: NO + GCC_PREFIX_HEADER: CN1AdProbe-Prefix.pch + GCC_PRECOMPILE_PREFIX_HEADER: NO + HEADER_SEARCH_PATHS: $(inherited) $(SRCROOT) + YML + cd "$PROBE" + xcodegen generate --spec project.yml + + - name: pod install + run: | + set -euxo pipefail + PROBE=/tmp/probe-${{ matrix.lib }}-arc${{ matrix.arc }} + cd "$PROBE" + { + echo "platform :ios, '15.0'" + echo "target 'CN1AdProbe' do" + echo " use_frameworks!" + if [ -n "${{ steps.pod.outputs.version }}" ]; then + echo " pod '${{ steps.pod.outputs.name }}', '${{ steps.pod.outputs.version }}'" + else + echo " pod '${{ steps.pod.outputs.name }}'" + fi + echo "end" + } > Podfile + cat Podfile + pod install --repo-update + + - name: xcodebuild + run: | + set -euxo pipefail + PROBE=/tmp/probe-${{ matrix.lib }}-arc${{ matrix.arc }} + cd "$PROBE" + xcodebuild -workspace CN1AdProbe.xcworkspace \ + -scheme CN1AdProbe \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO \ + build diff --git a/.github/workflows/ai-cn1lib-native-check.yml b/.github/workflows/ai-cn1lib-native-check.yml index 2d41910c144..e2bafc10b9d 100644 --- a/.github/workflows/ai-cn1lib-native-check.yml +++ b/.github/workflows/ai-cn1lib-native-check.yml @@ -20,9 +20,25 @@ on: - 'maven/cn1-ai-stablediffusion/**' - 'scripts/gen-ai-cn1libs.py' +concurrency: + # These jobs run on macOS, where the runner pool is small and a queued run + # holds its slots until it finishes. Without this, a branch that is pushed + # several times queues every superseded run behind the current one -- five + # obsolete runs of this workflow, 4 macOS jobs each, sat queued on this + # branch before it was added. + # + # Keyed on the pull request number rather than head_ref, which is the source + # branch name with no fork identity in it: two pull requests opened from + # different forks that both use a common branch name -- master, patch-1 -- + # would share a group and cancel each other's check. On push to master there + # is no pull request, so the group falls back to the unique run_id and every + # master commit is still checked in full. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: xcodebuild-cn1libs: - name: xcodebuild ${{ matrix.lib }} + name: xcodebuild ${{ matrix.lib }} (ARC ${{ matrix.arc }}) # macos-14 ships Xcode 15.4 by default but also has Xcode 16.x # under /Applications/Xcode_16.X.app. xcodegen 2.45.4 emits # objectVersion=77 (Xcode 16-format) projects, so we explicitly @@ -31,9 +47,17 @@ jobs: strategy: fail-fast: false matrix: + # Each library is compiled under both memory models. The generated app + # target is manual-retain-release (CLANG_ENABLE_OBJC_ARC = NO in + # vm/ByteCodeTranslator/src/template/template.xcodeproj) and that is + # what compiles a cn1lib's sources today, while IPhoneBuilder can add + # -fobjc-arc to an individual file, so ARC is reachable too. An + # ARC-only check accepts code that does not build for customers. include: - - { lib: cn1-ai-whisper, pod: '' } # links static libwhisper.a - - { lib: cn1-ai-stablediffusion, pod: '' } # links Swift runner + - { lib: cn1-ai-whisper, pod: '', arc: NO } # links static libwhisper.a + - { lib: cn1-ai-whisper, pod: '', arc: YES } + - { lib: cn1-ai-stablediffusion, pod: '', arc: NO } # links Swift runner + - { lib: cn1-ai-stablediffusion, pod: '', arc: YES } steps: - uses: actions/checkout@v6 @@ -61,10 +85,22 @@ jobs: - name: Synthesise Xcode project via xcodegen run: | set -euxo pipefail - PROBE=/tmp/probe-${{ matrix.lib }} + PROBE=/tmp/probe-${{ matrix.lib }}-arc${{ matrix.arc }} mkdir -p "$PROBE/CN1AIProbe" + # Objective-C and Objective-C++ alike. check-cn1lib-native-coverage.py + # counts a .mm file as an iOS native source, so staging only .m would + # let a broken Objective-C++ bridge be reported as covered while + # nothing ever compiled it. cp maven/${{ matrix.lib }}/ios/src/main/objectivec/*.h "$PROBE/CN1AIProbe/" 2>/dev/null || true - cp maven/${{ matrix.lib }}/ios/src/main/objectivec/*.m "$PROBE/CN1AIProbe/" + cp maven/${{ matrix.lib }}/ios/src/main/objectivec/*.m "$PROBE/CN1AIProbe/" 2>/dev/null || true + cp maven/${{ matrix.lib }}/ios/src/main/objectivec/*.mm "$PROBE/CN1AIProbe/" 2>/dev/null || true + # An empty probe would build green and check nothing. find is used + # rather than ls because ls reports failure when either glob is + # unmatched, which is the normal case for a library with no .mm. + staged=$(find "$PROBE/CN1AIProbe" -maxdepth 1 \( -name '*.m' -o -name '*.mm' \) | wc -l) + if [ "$staged" -eq 0 ]; then + echo "::error::No Objective-C sources staged for ${{ matrix.lib }}"; exit 1 + fi ls -la "$PROBE/CN1AIProbe/" cat > "$PROBE/project.yml" <<'YAML' @@ -87,7 +123,7 @@ jobs: settings: base: CLANG_ENABLE_MODULES: YES - CLANG_ENABLE_OBJC_ARC: YES + CLANG_ENABLE_OBJC_ARC: ${{ matrix.arc }} CODE_SIGNING_ALLOWED: NO YAML cd "$PROBE" @@ -101,7 +137,7 @@ jobs: # xcodebuild can build directly. run: | set -euxo pipefail - PROBE=/tmp/probe-${{ matrix.lib }} + PROBE=/tmp/probe-${{ matrix.lib }}-arc${{ matrix.arc }} cd "$PROBE" cat > Podfile < @@ -139,12 +140,14 @@ + compiled the port. They live in scripts/ because + check-cn1lib-android-api.py compiles every other + cn1lib against the same two. --> - + @@ -194,7 +197,7 @@ source="1.8" target="1.8" includeantruntime="false" debug="false" nowarn="true" failonerror="true"> - + 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 baa4db647ce..693c02811ca 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 @@ -28,7 +28,6 @@ import com.codename1.impl.android.AndroidImplementation; import com.codename1.impl.android.AndroidNativeUtil; -import com.codename1.ui.PeerComponent; import com.google.ads.mediation.admob.AdMobAdapter; import com.google.android.gms.ads.AdError; @@ -326,7 +325,12 @@ public void disposeFullScreen(int handle) { ads.remove(handle); } - public PeerComponent createBanner(final int handle, final String adUnitId, final int sizeType, final int widthDp) { + /// Returns the raw Android view rather than a peer component. The + /// generated AdMobNativeStub wraps whatever this method returns in + /// PeerComponent.create(), so returning a peer here makes + /// AndroidImplementation.createNativePeer reject its own AndroidPeer with + /// an IllegalArgumentException the first time a banner is shown. + public View createBanner(final int handle, final String adUnitId, final int sizeType, final int widthDp) { final Activity activity = AndroidNativeUtil.getActivity(); if (activity == null) { return null; @@ -341,7 +345,7 @@ public void run() { out[0] = adView; } }); - return out[0] == null ? null : PeerComponent.create(out[0]); + return out[0]; } private static AdSize mapSize(Activity activity, int sizeType, int widthDp) { diff --git a/maven/cn1-admob/common/codenameone_library_required.properties b/maven/cn1-admob/common/codenameone_library_required.properties index 396ad727ed6..032e3db0969 100644 --- a/maven/cn1-admob/common/codenameone_library_required.properties +++ b/maven/cn1-admob/common/codenameone_library_required.properties @@ -8,7 +8,12 @@ # codename1.arg.ios.plistInject=GADApplicationIdentifierca-app-pub-XXXXXXXX~YYYYYYYY # # iOS: Google Mobile Ads SDK (bundles the User Messaging Platform for consent). -codename1.arg.ios.pods=Google-Mobile-Ads-SDK +# Pinned to a major version on purpose. Google renames and removes Objective-C +# symbols across majors -- SDK 12 dropped GADSimulatorID, which turned an +# unpinned pod into a broken Xcode build for every app using this library. The +# native sources here are compiled against this same constraint by +# .github/workflows/ad-cn1lib-ios-native-check.yml, so bump both together. +codename1.arg.ios.pods=Google-Mobile-Ads-SDK ~> 13.0 # Android: Google Mobile Ads SDK + User Messaging Platform (UMP) for GDPR consent. codename1.arg.android.gradleDep=implementation 'com.google.android.gms:play-services-ads:24.0.0'\n implementation 'com.google.android.ump:user-messaging-platform:3.1.0' # AndroidGradleBuilder supplies INTERNET for every Android build. diff --git a/maven/cn1-admob/ios/src/main/objectivec/com_codename1_ads_admob_AdMobNativeImpl.m b/maven/cn1-admob/ios/src/main/objectivec/com_codename1_ads_admob_AdMobNativeImpl.m index b695217a766..452edad349d 100644 --- a/maven/cn1-admob/ios/src/main/objectivec/com_codename1_ads_admob_AdMobNativeImpl.m +++ b/maven/cn1-admob/ios/src/main/objectivec/com_codename1_ads_admob_AdMobNativeImpl.m @@ -1,3 +1,25 @@ +/* + * 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. + */ /* * iOS implementation of the AdMob native bridge, built on the modern Google * Mobile Ads (GMA) SDK (GADInterstitialAd / GADRewardedAd / @@ -9,9 +31,11 @@ * com.codename1.ads.admob.AdMobCallback.fire(...), keyed by an integer handle, * which keeps the native->Java binding surface to one function. * - * This native layer is validated by an on-device iOS build; adjust the GMA - * symbol names here to the pod version pinned in - * codenameone_library_required.properties if Google renames an API. + * The GMA symbol names here have to match the pod pinned in + * codenameone_library_required.properties, and Google does rename and remove + * them across major versions. Nothing in an app build catches that before the + * customer's Xcode does, so ad-cn1lib-ios-native-check.yml compiles this file + * against that pod on every PR that touches it. */ #import "com_codename1_ads_admob_AdMobNativeImpl.h" #import @@ -19,6 +43,32 @@ #import #import +// Handing a UIView to Codename One as a native peer is a pointer cast whose +// spelling depends on the memory model the file is compiled under. There is no +// BRIDGE_RETAINED anywhere in the port, and a retained cast would be wrong here +// anyway: NativeIPhoneView retains the peer itself and releases it when the +// component is collected, while cn1Banners holds the view for as long as the +// banner exists. +#ifndef BRIDGE_CAST +#if __has_feature(objc_arc) +#define BRIDGE_CAST __bridge +#else +#define BRIDGE_CAST +#endif +#endif + +// The generated app target is manual retain/release (CLANG_ENABLE_OBJC_ARC = NO +// in the translator's template project), so an object handed to one of the +// dictionaries or to a strong property below is owned twice over: once by the +// alloc and once by the container that retains it. Releasing the extra +// reference outright would not compile under ARC, where it does not exist and +// release is forbidden, so ownership is handed over through this macro. +#if __has_feature(objc_arc) +#define CN1_HANDOVER(x) (x) +#else +#define CN1_HANDOVER(x) [(x) autorelease] +#endif + // Generated entry point for com.codename1.ads.admob.AdMobCallback.fire(int,int,int,String,String,int) extern 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, @@ -100,6 +150,17 @@ @interface CN1FullScreenAd : NSObject @end @implementation CN1FullScreenAd +#if !__has_feature(objc_arc) +- (void)dealloc { + // MRR releases nothing for us when the holder goes away. Clearing through + // the synthesized setters does it without naming the ivars. + self.adUnitId = nil; + self.ad = nil; + self.delegate = nil; + self.ssv = nil; + [super dealloc]; +} +#endif @end @interface CN1Banner : NSObject @@ -108,6 +169,15 @@ @interface CN1Banner : NSObject @end @implementation CN1Banner +#if !__has_feature(objc_arc) +- (void)dealloc { + // MRR releases nothing for us when the holder goes away. Clearing through + // the synthesized setters does it without naming the ivars. + self.view = nil; + self.delegate = nil; + [super dealloc]; +} +#endif @end static NSMutableDictionary *cn1FullScreenAds; @@ -123,13 +193,43 @@ @implementation CN1Banner request.keywords = [keywords componentsSeparatedByString:@","]; } if (nonPersonalized) { - GADExtras *extras = [[GADExtras alloc] init]; + GADExtras *extras = CN1_HANDOVER([[GADExtras alloc] init]); extras.additionalParameters = @{@"npa": @"1"}; [request registerAdNetworkExtras:extras]; } return request; } +// The three state privacy flags AdConfig sends: 1 means yes, 2 means no and +// anything else leaves the signal unset. tagForChildDirectedTreatment and +// tagForUnderAgeOfConsent are marked deprecated in favour of +// ageRestrictedTreatment, which collapses child, teen and unspecified into one +// enum and therefore cannot express an explicit "no". These two can, and they +// are what the Android side sends, so the bridge keeps using them. +static void cn1ApplyPrivacyFlags(GADRequestConfiguration *cfg, int childDirected, + int underAge, int maxRating) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + if (childDirected == 1) { + cfg.tagForChildDirectedTreatment = @YES; + } else if (childDirected == 2) { + cfg.tagForChildDirectedTreatment = @NO; + } + if (underAge == 1) { + cfg.tagForUnderAgeOfConsent = @YES; + } else if (underAge == 2) { + cfg.tagForUnderAgeOfConsent = @NO; + } +#pragma clang diagnostic pop + switch (maxRating) { + case 1: cfg.maxAdContentRating = GADMaxAdContentRatingGeneral; break; + case 2: cfg.maxAdContentRating = GADMaxAdContentRatingParentalGuidance; break; + case 3: cfg.maxAdContentRating = GADMaxAdContentRatingTeen; break; + case 4: cfg.maxAdContentRating = GADMaxAdContentRatingMatureAudience; break; + default: break; + } +} + @implementation com_codename1_ads_admob_AdMobNativeImpl -(void)initialize:(NSString*)param param1:(BOOL)param1 param2:(int)param2 param3:(int)param3 param4:(int)param4 { @@ -139,25 +239,27 @@ -(void)initialize:(NSString*)param param1:(BOOL)param1 param2:(int)param2 param3 } dispatch_async(dispatch_get_main_queue(), ^{ GADRequestConfiguration *cfg = GADMobileAds.sharedInstance.requestConfiguration; - NSMutableArray *devices = [NSMutableArray array]; - if (param1) { - [devices addObject:GADSimulatorID]; - } + // param1 is AdConfig.testMode, and it deliberately adds nothing to the + // list: the SDK counts every simulator as a test device on its own, and + // the GADSimulatorID constant that used to say so was removed in SDK 12. + // Explicit device ids still arrive through param. if (param != nil && param.length > 0) { + NSMutableArray *devices = [NSMutableArray array]; [devices addObjectsFromArray:[param componentsSeparatedByString:@","]]; + if (devices.count > 0) { + cfg.testDeviceIdentifiers = devices; + } } - if (devices.count > 0) { - cfg.testDeviceIdentifiers = devices; - } + cn1ApplyPrivacyFlags(cfg, param2, param3, param4); [[GADMobileAds sharedInstance] startWithCompletionHandler:nil]; }); } -(BOOL)createFullScreen:(int)param param1:(int)param1 param2:(NSString*)param2 { - CN1FullScreenAd *fs = [[CN1FullScreenAd alloc] init]; + CN1FullScreenAd *fs = CN1_HANDOVER([[CN1FullScreenAd alloc] init]); fs.format = param1; fs.adUnitId = param2; - fs.delegate = [[CN1AdDelegate alloc] init]; + fs.delegate = CN1_HANDOVER([[CN1AdDelegate alloc] init]); fs.delegate.handle = param; cn1FullScreenAds[@(param)] = fs; return YES; @@ -166,7 +268,8 @@ -(BOOL)createFullScreen:(int)param param1:(int)param1 param2:(NSString*)param2 { -(void)setServerSideVerification:(int)param param1:(NSString*)param1 param2:(NSString*)param2 { CN1FullScreenAd *fs = cn1FullScreenAds[@(param)]; if (fs == nil) { return; } - GADServerSideVerificationOptions *opts = [[GADServerSideVerificationOptions alloc] init]; + GADServerSideVerificationOptions *opts = + CN1_HANDOVER([[GADServerSideVerificationOptions alloc] init]); if (param1 != nil) { opts.userIdentifier = param1; } if (param2 != nil) { opts.customRewardString = param2; } fs.ssv = opts; @@ -270,18 +373,18 @@ -(void*)createBanner:(int)param param1:(NSString*)param1 param2:(int)param2 para size = GADCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(width); } } - bannerView = [[GADBannerView alloc] initWithAdSize:size]; + bannerView = CN1_HANDOVER([[GADBannerView alloc] initWithAdSize:size]); bannerView.adUnitID = param1; bannerView.rootViewController = cn1RootController(); - CN1Banner *holder = [[CN1Banner alloc] init]; + CN1Banner *holder = CN1_HANDOVER([[CN1Banner alloc] init]); holder.view = bannerView; - holder.delegate = [[CN1BannerDelegate alloc] init]; + holder.delegate = CN1_HANDOVER([[CN1BannerDelegate alloc] init]); holder.delegate.handle = param; bannerView.delegate = holder.delegate; cn1Banners[@(param)] = holder; }); // Hand the UIView to Codename One as a native peer. - return (BRIDGE_RETAINED void*)bannerView; + return (BRIDGE_CAST void*)bannerView; } -(void)loadBanner:(int)param param1:(NSString*)param1 param2:(NSString*)param2 param3:(BOOL)param3 { @@ -303,7 +406,7 @@ -(void)requestConsent:(BOOL)param { if (@available(iOS 14, *)) { [ATTrackingManager requestTrackingAuthorizationWithCompletionHandler:^(ATTrackingManagerAuthorizationStatus status) {}]; } - UMPRequestParameters *parameters = [[UMPRequestParameters alloc] init]; + UMPRequestParameters *parameters = CN1_HANDOVER([[UMPRequestParameters alloc] init]); parameters.tagForUnderAgeOfConsent = param; [UMPConsentInformation.sharedInstance requestConsentInfoUpdateWithParameters:parameters completionHandler:^(NSError *_Nullable error) { diff --git a/maven/cn1-applovin/android/src/main/java/com/codename1/ads/applovin/AppLovinNativeImpl.java b/maven/cn1-applovin/android/src/main/java/com/codename1/ads/applovin/AppLovinNativeImpl.java index 51a3d432459..17ba1a740d7 100644 --- a/maven/cn1-applovin/android/src/main/java/com/codename1/ads/applovin/AppLovinNativeImpl.java +++ b/maven/cn1-applovin/android/src/main/java/com/codename1/ads/applovin/AppLovinNativeImpl.java @@ -23,10 +23,10 @@ package com.codename1.ads.applovin; import android.app.Activity; +import android.view.View; import com.codename1.impl.android.AndroidImplementation; import com.codename1.impl.android.AndroidNativeUtil; -import com.codename1.ui.PeerComponent; import com.applovin.mediation.MaxAd; import com.applovin.mediation.MaxAdListener; @@ -183,7 +183,12 @@ public void disposeFullScreen(int handle) { ads.remove(handle); } - public PeerComponent createBanner(final int handle, final String adUnitId, final int sizeType, final int widthDp) { + /// Returns the raw Android view rather than a peer component. The + /// generated AppLovinNativeStub wraps whatever this method returns in + /// PeerComponent.create(), so returning a peer here makes + /// AndroidImplementation.createNativePeer reject its own AndroidPeer with + /// an IllegalArgumentException the first time a banner is shown. + public View createBanner(final int handle, final String adUnitId, final int sizeType, final int widthDp) { final Activity activity = AndroidNativeUtil.getActivity(); if (activity == null) { return null; @@ -196,7 +201,7 @@ public void run() { out[0] = adView; } }); - return out[0] == null ? null : PeerComponent.create(out[0]); + return out[0]; } public void loadBanner(final int handle, final String keywords, final String contentUrl, final boolean nonPersonalized) { diff --git a/maven/cn1-applovin/common/codenameone_library_required.properties b/maven/cn1-applovin/common/codenameone_library_required.properties index 5d761d70376..f8a44e12476 100644 --- a/maven/cn1-applovin/common/codenameone_library_required.properties +++ b/maven/cn1-applovin/common/codenameone_library_required.properties @@ -7,8 +7,12 @@ # codename1.arg.android.xapplication= # codename1.arg.ios.plistInject=AppLovinSdkKeyYOUR_SDK_KEY # -# iOS: AppLovin MAX SDK. -codename1.arg.ios.pods=AppLovinSDK +# iOS: AppLovin MAX SDK, pinned to a major version: MAX 13 replaced the SDK +# initialization API outright, and an unpinned pod silently moves the native +# sources onto an SDK they were never compiled against. The check in +# .github/workflows/ad-cn1lib-ios-native-check.yml compiles them against this +# same constraint, so bump both together. +codename1.arg.ios.pods=AppLovinSDK ~> 13.6 # Android: AppLovin MAX SDK. codename1.arg.android.gradleDep=implementation 'com.applovin:applovin-sdk:13.0.1' # AndroidGradleBuilder supplies INTERNET for every Android build. diff --git a/maven/cn1-applovin/common/src/main/java/com/codename1/ads/applovin/AppLovinProvider.java b/maven/cn1-applovin/common/src/main/java/com/codename1/ads/applovin/AppLovinProvider.java index 031748eab6b..6baf99fe4a2 100644 --- a/maven/cn1-applovin/common/src/main/java/com/codename1/ads/applovin/AppLovinProvider.java +++ b/maven/cn1-applovin/common/src/main/java/com/codename1/ads/applovin/AppLovinProvider.java @@ -83,8 +83,10 @@ public boolean isSupported() { @Override public boolean isFormatSupported(AdFormat format) { - // Banner plus the four full screen formats; native ads are not yet wired. - return format != AdFormat.NATIVE; + // MAX has no rewarded-interstitial -- both bridges return false for it + // from createFullScreen -- and native ads are not wired, so neither can + // be promised here. + return format != AdFormat.REWARDED_INTERSTITIAL && format != AdFormat.NATIVE; } @Override diff --git a/maven/cn1-applovin/ios/src/main/objectivec/com_codename1_ads_applovin_AppLovinNativeImpl.m b/maven/cn1-applovin/ios/src/main/objectivec/com_codename1_ads_applovin_AppLovinNativeImpl.m index fb011e1dbdd..ec387646ba7 100644 --- a/maven/cn1-applovin/ios/src/main/objectivec/com_codename1_ads_applovin_AppLovinNativeImpl.m +++ b/maven/cn1-applovin/ios/src/main/objectivec/com_codename1_ads_applovin_AppLovinNativeImpl.m @@ -1,7 +1,30 @@ +/* + * 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. + */ /* * iOS implementation of the AppLovin MAX native bridge (MAInterstitialAd / * MARewardedAd / MAAppOpenAd / MAAdView). Shipped as source and compiled by the - * Codename One iOS build; validated on device. Every event is reported back to + * Codename One iOS build, and compiled against the pinned AppLovinSDK pod by + * .github/workflows/ad-cn1lib-ios-native-check.yml. Every event is reported back to * Java through the single static fan-in method * com.codename1.ads.applovin.AppLovinCallback.fire(...), keyed by an integer * handle. @@ -11,6 +34,32 @@ #import #import +// Handing a UIView to Codename One as a native peer is a pointer cast whose +// spelling depends on the memory model the file is compiled under. There is no +// BRIDGE_RETAINED anywhere in the port, and a retained cast would be wrong here +// anyway: NativeIPhoneView retains the peer itself and releases it when the +// component is collected, while the banner dictionary holds the view for as +// long as the banner exists. +#ifndef BRIDGE_CAST +#if __has_feature(objc_arc) +#define BRIDGE_CAST __bridge +#else +#define BRIDGE_CAST +#endif +#endif + +// The generated app target is manual retain/release (CLANG_ENABLE_OBJC_ARC = NO +// in the translator's template project), so an object handed to one of the +// dictionaries or to a strong property below is owned twice over: once by the +// alloc and once by the container that retains it. Releasing the extra +// reference outright would not compile under ARC, where it does not exist and +// release is forbidden, so ownership is handed over through this macro. +#if __has_feature(objc_arc) +#define CN1_HANDOVER(x) (x) +#else +#define CN1_HANDOVER(x) [(x) autorelease] +#endif + extern void com_codename1_ads_applovin_AppLovinCallback_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); @@ -85,6 +134,16 @@ @interface CN1MaxFullScreen : NSObject @property (nonatomic) BOOL loaded; @end @implementation CN1MaxFullScreen +#if !__has_feature(objc_arc) +- (void)dealloc { + // MRR releases nothing for us when the holder goes away. Clearing through + // the synthesized setters does it without naming the ivars. + self.adUnitId = nil; + self.ad = nil; + self.delegate = nil; + [super dealloc]; +} +#endif @end @interface CN1MaxBanner : NSObject @@ -92,6 +151,15 @@ @interface CN1MaxBanner : NSObject @property (nonatomic, strong) CN1MaxBannerDelegate *delegate; @end @implementation CN1MaxBanner +#if !__has_feature(objc_arc) +- (void)dealloc { + // MRR releases nothing for us when the holder goes away. Clearing through + // the synthesized setters does it without naming the ivars. + self.view = nil; + self.delegate = nil; + [super dealloc]; +} +#endif @end static NSMutableDictionary *cn1FullScreen; @@ -105,17 +173,34 @@ -(void)initialize:(NSString*)param param1:(BOOL)param1 param2:(int)param2 param3 cn1Banners = [[NSMutableDictionary alloc] init]; } dispatch_async(dispatch_get_main_queue(), ^{ - [ALSdk shared].mediationProvider = @"max"; - [[ALSdk shared] initializeSdkWithCompletionHandler:^(ALSdkConfiguration *configuration) {}]; + // MAX SDK 13 replaced the implicit "read the key out of Info.plist and + // initialize" call with an explicit configuration object, so the key + // the app injected through ios.plistInject is read here. + NSString *sdkKey = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"AppLovinSdkKey"]; + if (sdkKey == nil) { + sdkKey = @""; + } + NSArray *testDevices = (param != nil && param.length > 0) + ? [param componentsSeparatedByString:@","] : @[]; + ALSdkInitializationConfiguration *config = + [ALSdkInitializationConfiguration configurationWithSdkKey:sdkKey + builderBlock:^(ALSdkInitializationConfigurationBuilder *builder) { + builder.mediationProvider = ALMediationProviderMAX; + if (testDevices.count > 0) { + builder.testDeviceAdvertisingIdentifiers = testDevices; + } + }]; + [[ALSdk shared] initializeWithConfiguration:config + completionHandler:^(ALSdkConfiguration *configuration) {}]; }); } -(BOOL)createFullScreen:(int)param param1:(int)param1 param2:(NSString*)param2 { if (param1 == 3) { return NO; } // no rewarded-interstitial in MAX - CN1MaxFullScreen *fs = [[CN1MaxFullScreen alloc] init]; + CN1MaxFullScreen *fs = CN1_HANDOVER([[CN1MaxFullScreen alloc] init]); fs.format = param1; fs.adUnitId = param2; - fs.delegate = [[CN1MaxDelegate alloc] init]; + fs.delegate = CN1_HANDOVER([[CN1MaxDelegate alloc] init]); fs.delegate.handle = param; cn1FullScreen[@(param)] = fs; return YES; @@ -130,7 +215,8 @@ -(void)loadFullScreen:(int)param param1:(NSString*)param1 param2:(NSString*)para if (fs == nil) { return; } dispatch_async(dispatch_get_main_queue(), ^{ if (fs.format == CN1_FORMAT_INTERSTITIAL) { - MAInterstitialAd *ad = [[MAInterstitialAd alloc] initWithAdUnitIdentifier:fs.adUnitId]; + MAInterstitialAd *ad = + CN1_HANDOVER([[MAInterstitialAd alloc] initWithAdUnitIdentifier:fs.adUnitId]); ad.delegate = fs.delegate; fs.ad = ad; [ad loadAd]; @@ -140,7 +226,8 @@ -(void)loadFullScreen:(int)param param1:(NSString*)param1 param2:(NSString*)para fs.ad = ad; [ad loadAd]; } else if (fs.format == CN1_FORMAT_APP_OPEN) { - MAAppOpenAd *ad = [[MAAppOpenAd alloc] initWithAdUnitIdentifier:fs.adUnitId]; + MAAppOpenAd *ad = + CN1_HANDOVER([[MAAppOpenAd alloc] initWithAdUnitIdentifier:fs.adUnitId]); ad.delegate = fs.delegate; fs.ad = ad; [ad loadAd]; @@ -185,15 +272,15 @@ -(void)disposeFullScreen:(int)param { -(void*)createBanner:(int)param param1:(NSString*)param1 param2:(int)param2 param3:(int)param3 { __block MAAdView *bannerView = nil; dispatch_sync(dispatch_get_main_queue(), ^{ - bannerView = [[MAAdView alloc] initWithAdUnitIdentifier:param1]; - CN1MaxBanner *holder = [[CN1MaxBanner alloc] init]; + bannerView = CN1_HANDOVER([[MAAdView alloc] initWithAdUnitIdentifier:param1]); + CN1MaxBanner *holder = CN1_HANDOVER([[CN1MaxBanner alloc] init]); holder.view = bannerView; - holder.delegate = [[CN1MaxBannerDelegate alloc] init]; + holder.delegate = CN1_HANDOVER([[CN1MaxBannerDelegate alloc] init]); holder.delegate.handle = param; bannerView.delegate = holder.delegate; cn1Banners[@(param)] = holder; }); - return (BRIDGE_RETAINED void*)bannerView; + return (BRIDGE_CAST void*)bannerView; } -(void)loadBanner:(int)param param1:(NSString*)param1 param2:(NSString*)param2 param3:(BOOL)param3 { diff --git a/maven/cn1-unity-levelplay/android/src/main/java/com/codename1/ads/levelplay/LevelPlayNativeImpl.java b/maven/cn1-unity-levelplay/android/src/main/java/com/codename1/ads/levelplay/LevelPlayNativeImpl.java index 815f3edbe77..58972705af9 100644 --- a/maven/cn1-unity-levelplay/android/src/main/java/com/codename1/ads/levelplay/LevelPlayNativeImpl.java +++ b/maven/cn1-unity-levelplay/android/src/main/java/com/codename1/ads/levelplay/LevelPlayNativeImpl.java @@ -25,41 +25,54 @@ import android.app.Activity; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; +import android.view.View; import com.codename1.impl.android.AndroidImplementation; import com.codename1.impl.android.AndroidNativeUtil; -import com.codename1.ui.PeerComponent; - -import com.ironsource.mediationsdk.IronSource; -import com.ironsource.mediationsdk.IronSourceBannerLayout; -import com.ironsource.mediationsdk.ISBannerSize; -import com.ironsource.mediationsdk.adunit.adapter.utility.AdInfo; -import com.ironsource.mediationsdk.logger.IronSourceError; -import com.ironsource.mediationsdk.model.Placement; -import com.ironsource.mediationsdk.sdk.LevelPlayBannerListener; -import com.ironsource.mediationsdk.sdk.LevelPlayInterstitialListener; -import com.ironsource.mediationsdk.sdk.LevelPlayRewardedVideoListener; + +import com.unity3d.mediation.LevelPlay; +import com.unity3d.mediation.LevelPlayAdError; +import com.unity3d.mediation.LevelPlayAdInfo; +import com.unity3d.mediation.LevelPlayAdSize; +import com.unity3d.mediation.LevelPlayConfiguration; +import com.unity3d.mediation.LevelPlayInitError; +import com.unity3d.mediation.LevelPlayInitListener; +import com.unity3d.mediation.LevelPlayInitRequest; +import com.unity3d.mediation.LevelPlayPrivacySettings; +import com.unity3d.mediation.banner.LevelPlayBannerAdView; +import com.unity3d.mediation.banner.LevelPlayBannerAdViewListener; +import com.unity3d.mediation.interstitial.LevelPlayInterstitialAd; +import com.unity3d.mediation.interstitial.LevelPlayInterstitialAdListener; +import com.unity3d.mediation.rewarded.LevelPlayReward; +import com.unity3d.mediation.rewarded.LevelPlayRewardedAd; +import com.unity3d.mediation.rewarded.LevelPlayRewardedAdListener; import java.util.HashMap; import java.util.Map; /// Android implementation of the Unity LevelPlay (ironSource) native bridge. -/// Shipped as source and compiled by the Codename One Android build; validated -/// on device. +/// Shipped as source and compiled by the Codename One Android build, and by +/// scripts/check-cn1lib-android-api.py against the SDK pinned in +/// codenameone_library_required.properties. /// -/// LevelPlay's interstitial and rewarded placements are singletons (not per ad -/// unit), so the bridge routes the active full screen handle to the singleton -/// callbacks; banners are per instance. +/// Written against the unified LevelPlay API (LevelPlayInterstitialAd, +/// LevelPlayRewardedAd, LevelPlayBannerAdView), where every ad is an object +/// bound to one ad unit. The older IronSource entry points this bridge used +/// were singletons per format, which forced it to route callbacks through a +/// "currently active handle" field and lose events whenever two ads of the same +/// format were in flight; one ad object per handle removes that. public class LevelPlayNativeImpl { private static final int FORMAT_INTERSTITIAL = 1; private static final int FORMAT_REWARDED = 2; - private static final int FORMAT_APP_OPEN = 4; - private final Map formats = new HashMap(); - private final Map banners = new HashMap(); - private int activeInterstitial = -1; - private int activeRewarded = -1; - private boolean listenersBound; + private final Map ads = new HashMap(); + private final Map banners = new HashMap(); + + private static final class FullScreenHolder { + int format; + LevelPlayInterstitialAd interstitial; + LevelPlayRewardedAd rewarded; + } public void initialize(final String testDeviceIds, final boolean testMode, final int tagForChildDirected, final int tagForUnderAge, @@ -70,8 +83,26 @@ public void initialize(final String testDeviceIds, final boolean testMode, } activity.runOnUiThread(new Runnable() { public void run() { - bindListeners(); - IronSource.init(activity, readAppKey(activity)); + // LevelPlay has no test device list: test ads are switched on + // per ad unit in the dashboard, and the test suite is opened + // from the app rather than by a flag here. So testMode and + // testDeviceIds have no counterpart on this platform. + if (tagForChildDirected == 1) { + LevelPlayPrivacySettings.setCOPPA(true); + } else if (tagForChildDirected == 2) { + LevelPlayPrivacySettings.setCOPPA(false); + } + LevelPlayInitRequest request = + new LevelPlayInitRequest.Builder(readAppKey(activity)).build(); + LevelPlay.init(activity, request, new LevelPlayInitListener() { + public void onInitSuccess(LevelPlayConfiguration configuration) { + } + + public void onInitFailed(LevelPlayInitError error) { + LevelPlayCallback.fire(0, LevelPlayCallback.FAILED, + error.getErrorCode(), error.getErrorMessage(), null, 0); + } + }); } }); } @@ -89,100 +120,144 @@ private static String readAppKey(Activity activity) { return ""; } - private void bindListeners() { - if (listenersBound) { - return; + public boolean createFullScreen(final int handle, final int format, final String adUnitId) { + if (format != FORMAT_INTERSTITIAL && format != FORMAT_REWARDED) { + // LevelPlay has no dedicated app-open or rewarded-interstitial + // format, and the provider expects false rather than an ad object + // that never loads. + return false; } - listenersBound = true; - IronSource.setLevelPlayInterstitialListener(new LevelPlayInterstitialListener() { - public void onAdReady(AdInfo adInfo) { fire(activeInterstitial, LevelPlayCallback.LOADED, 0, null, null, 0); } - public void onAdLoadFailed(IronSourceError e) { fire(activeInterstitial, LevelPlayCallback.FAILED, e.getErrorCode(), e.getErrorMessage(), null, 0); } - public void onAdOpened(AdInfo adInfo) { fire(activeInterstitial, LevelPlayCallback.SHOWN, 0, null, null, 0); fire(activeInterstitial, LevelPlayCallback.IMPRESSION, 0, null, null, 0); } - public void onAdShowFailed(IronSourceError e, AdInfo adInfo) { fire(activeInterstitial, LevelPlayCallback.SHOW_FAILED, e.getErrorCode(), e.getErrorMessage(), null, 0); } - public void onAdClicked(AdInfo adInfo) { fire(activeInterstitial, LevelPlayCallback.CLICKED, 0, null, null, 0); } - public void onAdClosed(AdInfo adInfo) { fire(activeInterstitial, LevelPlayCallback.DISMISSED, 0, null, null, 0); } - }); - IronSource.setLevelPlayRewardedVideoListener(new LevelPlayRewardedVideoListener() { - public void onAdAvailable(AdInfo adInfo) { fire(activeRewarded, LevelPlayCallback.LOADED, 0, null, null, 0); } - public void onAdUnavailable() { fire(activeRewarded, LevelPlayCallback.FAILED, LevelPlayErrorCodes.NOT_READY, "No fill", null, 0); } - public void onAdOpened(AdInfo adInfo) { fire(activeRewarded, LevelPlayCallback.SHOWN, 0, null, null, 0); fire(activeRewarded, LevelPlayCallback.IMPRESSION, 0, null, null, 0); } - public void onAdShowFailed(IronSourceError e, AdInfo adInfo) { fire(activeRewarded, LevelPlayCallback.SHOW_FAILED, e.getErrorCode(), e.getErrorMessage(), null, 0); } - public void onAdClicked(Placement placement, AdInfo adInfo) { fire(activeRewarded, LevelPlayCallback.CLICKED, 0, null, null, 0); } - public void onAdRewarded(Placement placement, AdInfo adInfo) { fire(activeRewarded, LevelPlayCallback.REWARD, 0, null, placement.getRewardName(), placement.getRewardAmount()); } - public void onAdClosed(AdInfo adInfo) { fire(activeRewarded, LevelPlayCallback.DISMISSED, 0, null, null, 0); } - }); - } - - private static void fire(int handle, int event, int code, String message, String rewardType, int rewardAmount) { - if (handle >= 0) { - LevelPlayCallback.fire(handle, event, code, message, rewardType, rewardAmount); + final Activity activity = AndroidNativeUtil.getActivity(); + if (activity == null) { + return false; } - } + final FullScreenHolder holder = new FullScreenHolder(); + holder.format = format; + AndroidImplementation.runOnUiThreadAndBlock(new Runnable() { + public void run() { + if (format == FORMAT_REWARDED) { + LevelPlayRewardedAd ad = new LevelPlayRewardedAd(adUnitId); + ad.setListener(new LevelPlayRewardedAdListener() { + public void onAdLoaded(LevelPlayAdInfo adInfo) { + LevelPlayCallback.fire(handle, LevelPlayCallback.LOADED, 0, null, null, 0); + } - public boolean createFullScreen(int handle, int format, String adUnitId) { - if (format == FORMAT_APP_OPEN) { - return false; // LevelPlay has no dedicated app-open format - } - formats.put(handle, format); + public void onAdLoadFailed(LevelPlayAdError error) { + LevelPlayCallback.fire(handle, LevelPlayCallback.FAILED, + error.getErrorCode(), error.getErrorMessage(), null, 0); + } + + public void onAdDisplayed(LevelPlayAdInfo adInfo) { + LevelPlayCallback.fire(handle, LevelPlayCallback.SHOWN, 0, null, null, 0); + LevelPlayCallback.fire(handle, LevelPlayCallback.IMPRESSION, 0, null, null, 0); + } + + public void onAdDisplayFailed(LevelPlayAdError error, LevelPlayAdInfo adInfo) { + LevelPlayCallback.fire(handle, LevelPlayCallback.SHOW_FAILED, + error.getErrorCode(), error.getErrorMessage(), null, 0); + } + + public void onAdRewarded(LevelPlayReward reward, LevelPlayAdInfo adInfo) { + LevelPlayCallback.fire(handle, LevelPlayCallback.REWARD, 0, null, + reward.getName(), reward.getAmount()); + } + + public void onAdClicked(LevelPlayAdInfo adInfo) { + LevelPlayCallback.fire(handle, LevelPlayCallback.CLICKED, 0, null, null, 0); + } + + public void onAdClosed(LevelPlayAdInfo adInfo) { + LevelPlayCallback.fire(handle, LevelPlayCallback.DISMISSED, 0, null, null, 0); + } + }); + holder.rewarded = ad; + } else { + LevelPlayInterstitialAd ad = new LevelPlayInterstitialAd(adUnitId); + ad.setListener(new LevelPlayInterstitialAdListener() { + public void onAdLoaded(LevelPlayAdInfo adInfo) { + LevelPlayCallback.fire(handle, LevelPlayCallback.LOADED, 0, null, null, 0); + } + + public void onAdLoadFailed(LevelPlayAdError error) { + LevelPlayCallback.fire(handle, LevelPlayCallback.FAILED, + error.getErrorCode(), error.getErrorMessage(), null, 0); + } + + public void onAdDisplayed(LevelPlayAdInfo adInfo) { + LevelPlayCallback.fire(handle, LevelPlayCallback.SHOWN, 0, null, null, 0); + LevelPlayCallback.fire(handle, LevelPlayCallback.IMPRESSION, 0, null, null, 0); + } + + public void onAdDisplayFailed(LevelPlayAdError error, LevelPlayAdInfo adInfo) { + LevelPlayCallback.fire(handle, LevelPlayCallback.SHOW_FAILED, + error.getErrorCode(), error.getErrorMessage(), null, 0); + } + + public void onAdClicked(LevelPlayAdInfo adInfo) { + LevelPlayCallback.fire(handle, LevelPlayCallback.CLICKED, 0, null, null, 0); + } + + public void onAdClosed(LevelPlayAdInfo adInfo) { + LevelPlayCallback.fire(handle, LevelPlayCallback.DISMISSED, 0, null, null, 0); + } + }); + holder.interstitial = ad; + } + } + }); + ads.put(handle, holder); return true; } public void setServerSideVerification(int handle, String userId, String customData) { if (userId != null) { - IronSource.setUserId(userId); + LevelPlay.setDynamicUserId(userId); } } - public void loadFullScreen(final int handle, String keywords, String contentUrl, boolean nonPersonalized) { - final Integer format = formats.get(handle); + public void loadFullScreen(final int handle, String keywords, String contentUrl, + boolean nonPersonalized) { + final FullScreenHolder holder = ads.get(handle); final Activity activity = AndroidNativeUtil.getActivity(); - if (format == null || activity == null) { + if (holder == null || activity == null) { return; } activity.runOnUiThread(new Runnable() { public void run() { - if (format == FORMAT_INTERSTITIAL) { - activeInterstitial = handle; - IronSource.loadInterstitial(); - } else if (format == FORMAT_REWARDED) { - activeRewarded = handle; - if (IronSource.isRewardedVideoAvailable()) { - LevelPlayCallback.fire(handle, LevelPlayCallback.LOADED, 0, null, null, 0); - } + if (holder.rewarded != null) { + holder.rewarded.loadAd(); + } else if (holder.interstitial != null) { + holder.interstitial.loadAd(); } } }); } public boolean isFullScreenLoaded(int handle) { - Integer format = formats.get(handle); - if (format == null) { + FullScreenHolder holder = ads.get(handle); + if (holder == null) { return false; } - if (format == FORMAT_INTERSTITIAL) { - return IronSource.isInterstitialReady(); + if (holder.rewarded != null) { + return holder.rewarded.isAdReady(); } - if (format == FORMAT_REWARDED) { - return IronSource.isRewardedVideoAvailable(); - } - return false; + return holder.interstitial != null && holder.interstitial.isAdReady(); } public void showFullScreen(final int handle) { - final Integer format = formats.get(handle); + final FullScreenHolder holder = ads.get(handle); final Activity activity = AndroidNativeUtil.getActivity(); - if (format == null || activity == null) { - LevelPlayCallback.fire(handle, LevelPlayCallback.SHOW_FAILED, LevelPlayErrorCodes.NOT_READY, "No ad loaded", null, 0); + if (holder == null || activity == null) { + LevelPlayCallback.fire(handle, LevelPlayCallback.SHOW_FAILED, + LevelPlayErrorCodes.NOT_READY, "No ad loaded", null, 0); return; } activity.runOnUiThread(new Runnable() { public void run() { - if (format == FORMAT_INTERSTITIAL) { - activeInterstitial = handle; - IronSource.showInterstitial(); - } else if (format == FORMAT_REWARDED) { - activeRewarded = handle; - IronSource.showRewardedVideo(); + if (holder.rewarded != null) { + holder.rewarded.showAd(activity); + } else if (holder.interstitial != null) { + holder.interstitial.showAd(activity); } } }); @@ -192,50 +267,85 @@ public void setAppOpenAutoShow(int handle, boolean enabled) { } public void disposeFullScreen(int handle) { - formats.remove(handle); + ads.remove(handle); } - public PeerComponent createBanner(final int handle, final String adUnitId, final int sizeType, final int widthDp) { + /// Returns the raw Android view rather than a peer component. The + /// generated LevelPlayNativeStub wraps whatever this method returns in + /// PeerComponent.create(), so returning a peer here makes + /// AndroidImplementation.createNativePeer reject its own AndroidPeer with + /// an IllegalArgumentException the first time a banner is shown. + public View createBanner(final int handle, final String adUnitId, final int sizeType, + final int widthDp) { final Activity activity = AndroidNativeUtil.getActivity(); if (activity == null) { return null; } - final IronSourceBannerLayout[] out = new IronSourceBannerLayout[1]; + final LevelPlayBannerAdView[] out = new LevelPlayBannerAdView[1]; AndroidImplementation.runOnUiThreadAndBlock(new Runnable() { public void run() { - IronSourceBannerLayout banner = IronSource.createBanner(activity, ISBannerSize.BANNER); - banner.setLevelPlayBannerListener(new LevelPlayBannerListener() { - public void onAdLoaded(AdInfo adInfo) { LevelPlayCallback.fire(handle, LevelPlayCallback.LOADED, 0, null, null, 0); LevelPlayCallback.fire(handle, LevelPlayCallback.IMPRESSION, 0, null, null, 0); } - public void onAdLoadFailed(IronSourceError e) { LevelPlayCallback.fire(handle, LevelPlayCallback.FAILED, e.getErrorCode(), e.getErrorMessage(), null, 0); } - public void onAdClicked(AdInfo adInfo) { LevelPlayCallback.fire(handle, LevelPlayCallback.CLICKED, 0, null, null, 0); } - public void onAdScreenPresented(AdInfo adInfo) { } - public void onAdScreenDismissed(AdInfo adInfo) { } - public void onAdLeftApplication(AdInfo adInfo) { } + LevelPlayBannerAdView.Config config = new LevelPlayBannerAdView.Config.Builder() + .setAdSize(mapSize(activity, sizeType, widthDp)) + .build(); + LevelPlayBannerAdView banner = + new LevelPlayBannerAdView(activity, adUnitId, config); + banner.setBannerListener(new LevelPlayBannerAdViewListener() { + public void onAdLoaded(LevelPlayAdInfo adInfo) { + LevelPlayCallback.fire(handle, LevelPlayCallback.LOADED, 0, null, null, 0); + LevelPlayCallback.fire(handle, LevelPlayCallback.IMPRESSION, 0, null, null, 0); + } + + public void onAdLoadFailed(LevelPlayAdError error) { + LevelPlayCallback.fire(handle, LevelPlayCallback.FAILED, + error.getErrorCode(), error.getErrorMessage(), null, 0); + } + + public void onAdClicked(LevelPlayAdInfo adInfo) { + LevelPlayCallback.fire(handle, LevelPlayCallback.CLICKED, 0, null, null, 0); + } }); banners.put(handle, banner); out[0] = banner; } }); - return out[0] == null ? null : PeerComponent.create(out[0]); + return out[0]; + } + + private static LevelPlayAdSize mapSize(Activity activity, int sizeType, int widthDp) { + switch (sizeType) { + case 1: return LevelPlayAdSize.BANNER; + case 2: return LevelPlayAdSize.LARGE; + case 3: return LevelPlayAdSize.MEDIUM_RECTANGLE; + case 4: return LevelPlayAdSize.LEADERBOARD; + default: { + LevelPlayAdSize adaptive = widthDp > 0 + ? LevelPlayAdSize.createAdaptiveAdSize(activity, Integer.valueOf(widthDp)) + : LevelPlayAdSize.createAdaptiveAdSize(activity); + // createAdaptiveAdSize is documented to return null when the + // container is too small to hold any adaptive size. + return adaptive == null ? LevelPlayAdSize.BANNER : adaptive; + } + } } - public void loadBanner(final int handle, String keywords, String contentUrl, boolean nonPersonalized) { + public void loadBanner(final int handle, String keywords, String contentUrl, + boolean nonPersonalized) { final Activity activity = AndroidNativeUtil.getActivity(); if (activity == null) { return; } activity.runOnUiThread(new Runnable() { public void run() { - IronSourceBannerLayout banner = banners.get(handle); + LevelPlayBannerAdView banner = banners.get(handle); if (banner != null) { - IronSource.loadBanner(banner); + banner.loadAd(); } } }); } public void disposeBanner(final int handle) { - final IronSourceBannerLayout banner = banners.remove(handle); + final LevelPlayBannerAdView banner = banners.remove(handle); if (banner == null) { return; } @@ -243,15 +353,16 @@ public void disposeBanner(final int handle) { if (activity != null) { activity.runOnUiThread(new Runnable() { public void run() { - IronSource.destroyBanner(banner); + banner.destroy(); } }); } } public void requestConsent(boolean underAgeOfConsent) { - // LevelPlay consent is set via IronSource.setConsent(...) from your CMP; - // report "not required" so the cross-platform flow can proceed. + // LevelPlay takes consent from your CMP through + // LevelPlayPrivacySettings rather than presenting a form of its own, so + // report "not required" and let the cross-platform flow proceed. LevelPlayCallback.fire(0, LevelPlayCallback.CONSENT_COMPLETE, 2, null, null, 0); } diff --git a/maven/cn1-unity-levelplay/common/codenameone_library_required.properties b/maven/cn1-unity-levelplay/common/codenameone_library_required.properties index 595e35919b3..5cedee5caea 100644 --- a/maven/cn1-unity-levelplay/common/codenameone_library_required.properties +++ b/maven/cn1-unity-levelplay/common/codenameone_library_required.properties @@ -6,8 +6,16 @@ # codename1.arg.android.xapplication= # codename1.arg.ios.plistInject=LevelPlayAppKeyYOUR_APP_KEY # -# iOS: Unity LevelPlay (ironSource) SDK. -codename1.arg.ios.pods=IronSourceSDK -# Android: Unity LevelPlay mediation SDK. -codename1.arg.android.gradleDep=implementation 'com.unity3d.ads-mediation:mediation-sdk:8.4.0' +# iOS: Unity LevelPlay (ironSource) SDK, pinned to a major version. Unity ships +# the unified LevelPlay API (LPMInterstitialAd / LPMRewardedAd / +# LPMBannerAdView) from 9.x and removed the singleton ironSource entry points +# this library used to call, so an unpinned pod silently changes which API the +# native sources are compiled against. The check in +# .github/workflows/ad-cn1lib-ios-native-check.yml compiles them against this +# same constraint, and the Android pin below is the same SDK generation. +codename1.arg.ios.pods=IronSourceSDK ~> 9.6 +# Android: Unity LevelPlay mediation SDK. Verified by +# scripts/check-cn1lib-android-api.py, which compiles the Android sources +# against exactly this artifact. +codename1.arg.android.gradleDep=implementation 'com.unity3d.ads-mediation:mediation-sdk:9.6.0' # AndroidGradleBuilder supplies INTERNET for every Android build. diff --git a/maven/cn1-unity-levelplay/common/src/main/java/com/codename1/ads/levelplay/LevelPlayProvider.java b/maven/cn1-unity-levelplay/common/src/main/java/com/codename1/ads/levelplay/LevelPlayProvider.java index 0a3fc0b6baa..eefbac353f8 100644 --- a/maven/cn1-unity-levelplay/common/src/main/java/com/codename1/ads/levelplay/LevelPlayProvider.java +++ b/maven/cn1-unity-levelplay/common/src/main/java/com/codename1/ads/levelplay/LevelPlayProvider.java @@ -83,8 +83,13 @@ public boolean isSupported() { @Override public boolean isFormatSupported(AdFormat format) { - // Banner plus the four full screen formats; native ads are not yet wired. - return format != AdFormat.NATIVE; + // What LevelPlay mediates, which is not the whole enum: it has no + // rewarded-interstitial and no app-open format, and native ads are not + // wired. Both bridges reject those from createFullScreen, so a + // predicate promising them would have callers ask for an ad that comes + // back null. + return format == AdFormat.BANNER || format == AdFormat.INTERSTITIAL + || format == AdFormat.REWARDED; } @Override diff --git a/maven/cn1-unity-levelplay/ios/src/main/objectivec/com_codename1_ads_levelplay_LevelPlayNativeImpl.m b/maven/cn1-unity-levelplay/ios/src/main/objectivec/com_codename1_ads_levelplay_LevelPlayNativeImpl.m index f9a7237a3d5..5d59af29ad7 100644 --- a/maven/cn1-unity-levelplay/ios/src/main/objectivec/com_codename1_ads_levelplay_LevelPlayNativeImpl.m +++ b/maven/cn1-unity-levelplay/ios/src/main/objectivec/com_codename1_ads_levelplay_LevelPlayNativeImpl.m @@ -1,16 +1,73 @@ +/* + * 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. + */ /* * iOS implementation of the Unity LevelPlay (ironSource) native bridge. Shipped - * as source and compiled by the Codename One iOS build; validated on device. + * as source and compiled by the Codename One iOS build, and by + * ad-cn1lib-ios-native-check.yml against the pod pinned in + * codenameone_library_required.properties. + * + * Written against the unified LevelPlay API (LPMInterstitialAd / LPMRewardedAd + * / LPMBannerAdView), where every ad is an object bound to one ad unit. The + * singleton ironSource entry points this bridge used to call are gone from the + * SDK, and they forced callbacks through a "currently active handle" field that + * lost events whenever two ads of the same format were in flight; one ad object + * per handle removes that. + * * Events are reported back to Java through the single static fan-in method * com.codename1.ads.levelplay.LevelPlayCallback.fire(...), keyed by an integer - * handle. LevelPlay interstitial/rewarded placements are singletons, so the - * active full screen handle is routed to the singleton delegate callbacks. + * handle. */ #import "com_codename1_ads_levelplay_LevelPlayNativeImpl.h" #import #import #import +// Handing a UIView to Codename One as a native peer is a pointer cast whose +// spelling depends on the memory model the file is compiled under. There is no +// BRIDGE_RETAINED anywhere in the port, and a retained cast would be wrong here +// anyway: NativeIPhoneView retains the peer itself and releases it when the +// component is collected, while the banner dictionary holds the view for as +// long as the banner exists. +#ifndef BRIDGE_CAST +#if __has_feature(objc_arc) +#define BRIDGE_CAST __bridge +#else +#define BRIDGE_CAST +#endif +#endif + +// The generated app target is manual retain/release (CLANG_ENABLE_OBJC_ARC = NO +// in the translator's template project), so an object handed to one of the +// dictionaries or to a strong property below is owned twice over: once by the +// alloc and once by the container that retains it. Releasing the extra +// reference outright would not compile under ARC, where it does not exist and +// release is forbidden, so ownership is handed over through this macro. +#if __has_feature(objc_arc) +#define CN1_HANDOVER(x) (x) +#else +#define CN1_HANDOVER(x) [(x) autorelease] +#endif + extern void com_codename1_ads_levelplay_LevelPlayCallback_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); @@ -28,11 +85,8 @@ extern void com_codename1_ads_levelplay_LevelPlayCallback_fire___int_int_int_jav #define CN1_FORMAT_INTERSTITIAL 1 #define CN1_FORMAT_REWARDED 2 -static int cn1ActiveInterstitial = -1; -static int cn1ActiveRewarded = -1; -static int cn1CurrentBannerHandle = -1; -static NSMutableDictionary *cn1Formats; // handle -> format -static NSMutableDictionary *cn1BannerViews; // handle -> wrapper UIView +static NSMutableDictionary *cn1FullScreen; // handle -> CN1LPFullScreen +static NSMutableDictionary *cn1Banners; // handle -> CN1LPBanner static void cn1Fire(int handle, int event, int code, NSString *message, NSString *rewardType, int rewardAmount) { if (handle < 0) { return; } @@ -51,61 +105,133 @@ static void cn1Fire(int handle, int event, int code, NSString *message, NSString return k == nil ? @"" : k; } -// Singleton delegate bridging IronSource interstitial + rewarded + banner. -@interface CN1LevelPlayDelegate : NSObject +// One delegate per full screen handle. LPM ads hold their delegate weakly, so +// the holder below owns both the ad and its delegate. +@interface CN1LPFullScreenDelegate : NSObject +@property (nonatomic) int handle; @end -@implementation CN1LevelPlayDelegate -// Interstitial -- (void)interstitialDidLoad { cn1Fire(cn1ActiveInterstitial, CN1_AD_LOADED, 0, nil, nil, 0); } -- (void)interstitialDidFailToLoadWithError:(NSError *)error { cn1Fire(cn1ActiveInterstitial, CN1_AD_FAILED, (int)error.code, error.localizedDescription, nil, 0); } -- (void)interstitialDidOpen { cn1Fire(cn1ActiveInterstitial, CN1_AD_SHOWN, 0, nil, nil, 0); cn1Fire(cn1ActiveInterstitial, CN1_AD_IMPRESSION, 0, nil, nil, 0); } -- (void)interstitialDidShow {} -- (void)interstitialDidFailToShowWithError:(NSError *)error { cn1Fire(cn1ActiveInterstitial, CN1_AD_SHOW_FAILED, (int)error.code, error.localizedDescription, nil, 0); } -- (void)didClickInterstitial { cn1Fire(cn1ActiveInterstitial, CN1_AD_CLICKED, 0, nil, nil, 0); } -- (void)interstitialDidClose { cn1Fire(cn1ActiveInterstitial, CN1_AD_DISMISSED, 0, nil, nil, 0); } -// Rewarded -- (void)rewardedVideoHasChangedAvailability:(BOOL)available { if (available) cn1Fire(cn1ActiveRewarded, CN1_AD_LOADED, 0, nil, nil, 0); } -- (void)rewardedVideoDidOpen { cn1Fire(cn1ActiveRewarded, CN1_AD_SHOWN, 0, nil, nil, 0); cn1Fire(cn1ActiveRewarded, CN1_AD_IMPRESSION, 0, nil, nil, 0); } -- (void)rewardedVideoDidFailToShowWithError:(NSError *)error { cn1Fire(cn1ActiveRewarded, CN1_AD_SHOW_FAILED, (int)error.code, error.localizedDescription, nil, 0); } -- (void)didClickRewardedVideo:(ISPlacementInfo *)placementInfo { cn1Fire(cn1ActiveRewarded, CN1_AD_CLICKED, 0, nil, nil, 0); } -- (void)didReceiveRewardForPlacement:(ISPlacementInfo *)placementInfo { cn1Fire(cn1ActiveRewarded, CN1_AD_REWARD, 0, nil, placementInfo.rewardName, placementInfo.rewardAmount.intValue); } -- (void)rewardedVideoDidClose { cn1Fire(cn1ActiveRewarded, CN1_AD_DISMISSED, 0, nil, nil, 0); } -- (void)rewardedVideoDidStart {} -- (void)rewardedVideoDidEnd {} -// Banner: the loaded ISBannerView is inserted into the wrapper handed to CN1. -- (void)bannerDidLoad:(ISBannerView *)bannerView { - UIView *wrapper = cn1BannerViews[@(cn1CurrentBannerHandle)]; - if (wrapper != nil) { - bannerView.frame = wrapper.bounds; - bannerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - [wrapper addSubview:bannerView]; - } - cn1Fire(cn1CurrentBannerHandle, CN1_AD_LOADED, 0, nil, nil, 0); - cn1Fire(cn1CurrentBannerHandle, CN1_AD_IMPRESSION, 0, nil, nil, 0); -} -- (void)bannerDidFailToLoadWithError:(NSError *)error { cn1Fire(cn1CurrentBannerHandle, CN1_AD_FAILED, (int)error.code, error.localizedDescription, nil, 0); } -- (void)didClickBanner { cn1Fire(cn1CurrentBannerHandle, CN1_AD_CLICKED, 0, nil, nil, 0); } -- (void)bannerWillPresentScreen {} -- (void)bannerDidDismissScreen {} -- (void)bannerWillLeaveApplication {} +@implementation CN1LPFullScreenDelegate +- (void)didLoadAdWithAdInfo:(LPMAdInfo *)adInfo { + cn1Fire(self.handle, CN1_AD_LOADED, 0, nil, nil, 0); +} +- (void)didFailToLoadAdWithAdUnitId:(NSString *)adUnitId error:(NSError *)error { + cn1Fire(self.handle, CN1_AD_FAILED, (int)error.code, error.localizedDescription, nil, 0); +} +- (void)didDisplayAdWithAdInfo:(LPMAdInfo *)adInfo { + cn1Fire(self.handle, CN1_AD_SHOWN, 0, nil, nil, 0); + cn1Fire(self.handle, CN1_AD_IMPRESSION, 0, nil, nil, 0); +} +- (void)didFailToDisplayAdWithAdInfo:(LPMAdInfo *)adInfo error:(NSError *)error { + cn1Fire(self.handle, CN1_AD_SHOW_FAILED, (int)error.code, error.localizedDescription, nil, 0); +} +- (void)didClickAdWithAdInfo:(LPMAdInfo *)adInfo { + cn1Fire(self.handle, CN1_AD_CLICKED, 0, nil, nil, 0); +} +- (void)didCloseAdWithAdInfo:(LPMAdInfo *)adInfo { + cn1Fire(self.handle, CN1_AD_DISMISSED, 0, nil, nil, 0); +} +- (void)didRewardAdWithAdInfo:(LPMAdInfo *)adInfo reward:(LPMReward *)reward { + cn1Fire(self.handle, CN1_AD_REWARD, 0, nil, reward.name, (int)reward.amount); +} +@end + +@interface CN1LPBannerDelegate : NSObject +@property (nonatomic) int handle; +@end + +@implementation CN1LPBannerDelegate +- (void)didLoadAdWithAdInfo:(LPMAdInfo *)adInfo { + cn1Fire(self.handle, CN1_AD_LOADED, 0, nil, nil, 0); + cn1Fire(self.handle, CN1_AD_IMPRESSION, 0, nil, nil, 0); +} +- (void)didFailToLoadAdWithAdUnitId:(NSString *)adUnitId error:(NSError *)error { + cn1Fire(self.handle, CN1_AD_FAILED, (int)error.code, error.localizedDescription, nil, 0); +} +- (void)didClickAdWithAdInfo:(LPMAdInfo *)adInfo { + cn1Fire(self.handle, CN1_AD_CLICKED, 0, nil, nil, 0); +} +@end + +@interface CN1LPFullScreen : NSObject +@property (nonatomic) int format; +@property (nonatomic, strong) LPMInterstitialAd *interstitial; +@property (nonatomic, strong) LPMRewardedAd *rewarded; +@property (nonatomic, strong) CN1LPFullScreenDelegate *delegate; +@end + +@implementation CN1LPFullScreen +#if !__has_feature(objc_arc) +- (void)dealloc { + // MRR releases nothing for us when the holder goes away. Clearing through + // the synthesized setters does it without naming the ivars. + self.interstitial = nil; + self.rewarded = nil; + self.delegate = nil; + [super dealloc]; +} +#endif +@end + +@interface CN1LPBanner : NSObject +@property (nonatomic, strong) LPMBannerAdView *view; +@property (nonatomic, strong) CN1LPBannerDelegate *delegate; +@end + +@implementation CN1LPBanner +#if !__has_feature(objc_arc) +- (void)dealloc { + // MRR releases nothing for us when the holder goes away. Clearing through + // the synthesized setters does it without naming the ivars. + self.view = nil; + self.delegate = nil; + [super dealloc]; +} +#endif @end -static CN1LevelPlayDelegate *cn1Delegate; +// sizeType matches the SIZE_* constants in com.codename1.ads.BannerAd. +static LPMAdSize *cn1BannerSize(int sizeType, int widthDp) { + switch (sizeType) { + case 1: return [LPMAdSize bannerSize]; + case 2: return [LPMAdSize largeSize]; + case 3: return [LPMAdSize mediumRectangleSize]; + case 4: return [LPMAdSize leaderBoardSize]; + default: { + CGFloat width = widthDp > 0 ? widthDp : [UIScreen mainScreen].bounds.size.width; + LPMAdSize *adaptive = [LPMAdSize createAdaptiveAdSizeWithWidth:width]; + // The adaptive factory returns nil when no adaptive size fits the + // width it was given. + return adaptive == nil ? [LPMAdSize bannerSize] : adaptive; + } + } +} @implementation com_codename1_ads_levelplay_LevelPlayNativeImpl -(void)initialize:(NSString*)param param1:(BOOL)param1 param2:(int)param2 param3:(int)param3 param4:(int)param4 { - if (cn1Formats == nil) { - cn1Formats = [[NSMutableDictionary alloc] init]; - cn1BannerViews = [[NSMutableDictionary alloc] init]; - cn1Delegate = [[CN1LevelPlayDelegate alloc] init]; + if (cn1FullScreen == nil) { + cn1FullScreen = [[NSMutableDictionary alloc] init]; + cn1Banners = [[NSMutableDictionary alloc] init]; } dispatch_async(dispatch_get_main_queue(), ^{ - [IronSource setInterstitialDelegate:cn1Delegate]; - [IronSource setRewardedVideoDelegate:cn1Delegate]; - [IronSource setBannerDelegate:cn1Delegate]; - [IronSource initWithAppKey:cn1AppKey()]; + // LevelPlay has no test device list: test ads are switched on per ad + // unit in the dashboard, so param and param1 (AdConfig's testDeviceIds + // and testMode) have no counterpart on this platform. + if (param2 == 1) { + [LPMPrivacySettings setCOPPA:YES]; + } else if (param2 == 2) { + [LPMPrivacySettings setCOPPA:NO]; + } + LPMInitRequestBuilder *initBuilder = + CN1_HANDOVER([[LPMInitRequestBuilder alloc] initWithAppKey:cn1AppKey()]); + LPMInitRequest *request = [initBuilder build]; + [LevelPlay initWithRequest:request + completion:^(LPMConfiguration *config, NSError *error) { + if (error != nil) { + cn1Fire(0, CN1_AD_FAILED, (int)error.code, error.localizedDescription, nil, 0); + } + }]; }); } @@ -113,49 +239,56 @@ -(BOOL)createFullScreen:(int)param param1:(int)param1 param2:(NSString*)param2 { if (param1 != CN1_FORMAT_INTERSTITIAL && param1 != CN1_FORMAT_REWARDED) { return NO; // LevelPlay has no dedicated app-open / rewarded-interstitial } - cn1Formats[@(param)] = @(param1); + CN1LPFullScreen *fs = CN1_HANDOVER([[CN1LPFullScreen alloc] init]); + fs.format = param1; + fs.delegate = CN1_HANDOVER([[CN1LPFullScreenDelegate alloc] init]); + fs.delegate.handle = param; + if (param1 == CN1_FORMAT_REWARDED) { + fs.rewarded = CN1_HANDOVER([[LPMRewardedAd alloc] initWithAdUnitId:param2]); + [fs.rewarded setDelegate:fs.delegate]; + } else { + fs.interstitial = CN1_HANDOVER([[LPMInterstitialAd alloc] initWithAdUnitId:param2]); + [fs.interstitial setDelegate:fs.delegate]; + } + cn1FullScreen[@(param)] = fs; return YES; } -(void)setServerSideVerification:(int)param param1:(NSString*)param1 param2:(NSString*)param2 { - if (param1 != nil) { [IronSource setUserId:param1]; } + if (param1 != nil) { [LevelPlay setDynamicUserId:param1]; } } -(void)loadFullScreen:(int)param param1:(NSString*)param1 param2:(NSString*)param2 param3:(BOOL)param3 { - NSNumber *fmt = cn1Formats[@(param)]; - if (fmt == nil) { return; } + CN1LPFullScreen *fs = cn1FullScreen[@(param)]; + if (fs == nil) { return; } dispatch_async(dispatch_get_main_queue(), ^{ - if (fmt.intValue == CN1_FORMAT_INTERSTITIAL) { - cn1ActiveInterstitial = param; - [IronSource loadInterstitial]; + if (fs.rewarded != nil) { + [fs.rewarded loadAd]; } else { - cn1ActiveRewarded = param; - if ([IronSource hasRewardedVideo]) { cn1Fire(param, CN1_AD_LOADED, 0, nil, nil, 0); } + [fs.interstitial loadAd]; } }); } -(BOOL)isFullScreenLoaded:(int)param { - NSNumber *fmt = cn1Formats[@(param)]; - if (fmt == nil) { return NO; } - if (fmt.intValue == CN1_FORMAT_INTERSTITIAL) { return [IronSource hasInterstitial]; } - return [IronSource hasRewardedVideo]; + CN1LPFullScreen *fs = cn1FullScreen[@(param)]; + if (fs == nil) { return NO; } + if (fs.rewarded != nil) { return [fs.rewarded isAdReady]; } + return [fs.interstitial isAdReady]; } -(void)showFullScreen:(int)param { - NSNumber *fmt = cn1Formats[@(param)]; - if (fmt == nil) { + CN1LPFullScreen *fs = cn1FullScreen[@(param)]; + if (fs == nil) { cn1Fire(param, CN1_AD_SHOW_FAILED, 100, @"No ad loaded", nil, 0); return; } dispatch_async(dispatch_get_main_queue(), ^{ UIViewController *root = cn1RootController(); - if (fmt.intValue == CN1_FORMAT_INTERSTITIAL) { - cn1ActiveInterstitial = param; - [IronSource showInterstitialWithViewController:root]; + if (fs.rewarded != nil) { + [fs.rewarded showAdWithViewController:root placementName:nil]; } else { - cn1ActiveRewarded = param; - [IronSource showRewardedVideoWithViewController:root]; + [fs.interstitial showAdWithViewController:root placementName:nil]; } }); } @@ -163,29 +296,51 @@ -(void)showFullScreen:(int)param { -(void)setAppOpenAutoShow:(int)param param1:(BOOL)param1 {} -(void)disposeFullScreen:(int)param { - [cn1Formats removeObjectForKey:@(param)]; + [cn1FullScreen removeObjectForKey:@(param)]; } -(void*)createBanner:(int)param param1:(NSString*)param1 param2:(int)param2 param3:(int)param3 { - __block UIView *wrapper = nil; + __block LPMBannerAdView *bannerView = nil; dispatch_sync(dispatch_get_main_queue(), ^{ - // The ISBannerView arrives asynchronously via the delegate, so hand - // Codename One a wrapper view and add the banner to it on load. - wrapper = [[UIView alloc] initWithFrame:CGRectZero]; - cn1BannerViews[@(param)] = wrapper; + LPMBannerAdViewConfigBuilder *builder = + CN1_HANDOVER([[LPMBannerAdViewConfigBuilder alloc] init]); + LPMBannerAdViewConfig *config = + [[builder setWithAdSize:cn1BannerSize(param2, param3)] build]; + bannerView = CN1_HANDOVER([[LPMBannerAdView alloc] initWithAdUnitId:param1 config:config]); + CN1LPBanner *holder = CN1_HANDOVER([[CN1LPBanner alloc] init]); + holder.view = bannerView; + holder.delegate = CN1_HANDOVER([[CN1LPBannerDelegate alloc] init]); + holder.delegate.handle = param; + [bannerView setDelegate:holder.delegate]; + cn1Banners[@(param)] = holder; }); - return (BRIDGE_RETAINED void*)wrapper; + // LPMBannerAdView is a UIView, so it is the peer itself. + return (BRIDGE_CAST void*)bannerView; } -(void)loadBanner:(int)param param1:(NSString*)param1 param2:(NSString*)param2 param3:(BOOL)param3 { + CN1LPBanner *holder = cn1Banners[@(param)]; + if (holder == nil) { return; } dispatch_async(dispatch_get_main_queue(), ^{ - cn1CurrentBannerHandle = param; - [IronSource loadBannerWithViewController:cn1RootController() size:ISBannerSize_BANNER]; + [holder.view loadAdWithViewController:cn1RootController()]; }); } -(void)disposeBanner:(int)param { - [cn1BannerViews removeObjectForKey:@(param)]; + CN1LPBanner *holder = cn1Banners[@(param)]; + if (holder == nil) { return; } + // The dictionary is the holder's only owner under MRR, so removing the + // entry first would deallocate it before the block below is copied and + // leave that block holding a dangling pointer. Reading the view out here + // means the block captures -- and so retains -- the object it needs, and + // the entry can go immediately afterwards rather than inside the block, + // which would let a banner recreated on the same handle be removed by a + // disposal still in flight. + LPMBannerAdView *view = holder.view; + dispatch_async(dispatch_get_main_queue(), ^{ + [view destroy]; + }); + [cn1Banners removeObjectForKey:@(param)]; } -(void)requestConsent:(BOOL)param { diff --git a/scripts/check-cn1lib-android-api.py b/scripts/check-cn1lib-android-api.py new file mode 100755 index 00000000000..cb9a2a416b7 --- /dev/null +++ b/scripts/check-cn1lib-android-api.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Compile every cn1lib's Android sources against the SDK that cn1lib pins. + +A cn1lib's Android implementation is shipped as source. Our build packages +src/main/java as *resources*, so no compiler of ours ever looks at it -- the +customer's Gradle build is the first one that does. That is how cn1-admob +reached users calling addNetworkExtrasBundle with a class that is not a +MediationExtrasReceiver, and a consent listener nested on the wrong interface +(PR #5570): a broken app build for everyone who included the library, with +green CI behind it. + +So the artifacts named in each library's android.gradleDep are fetched and its +sources compiled against exactly those, purely as a check. Nothing here is +packaged. + +The port classes these sources call are stubbed by +scripts/cn1lib-api-check/stubs rather than resolved from the Android port, +because the port is profile-gated and empty on a fresh checkout; the stubs are +guarded against the port's real declarations below, so they cannot drift. + + scripts/check-cn1lib-android-api.py [--require-all] [lib ...] + +With no libraries named, checks every maven/cn1-* that ships Android sources. A +library whose inputs are missing (no android.jar, no compiled core) is skipped +with a note so a partial local tree still gives a useful answer; CI passes +--require-all, where a skip means the gate quietly stopped covering something. +""" + +import os +import re +import shutil +import subprocess +import sys +import urllib.request +import zipfile + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STUBS = os.path.join(REPO, 'scripts', 'cn1lib-api-check', 'stubs') +PORT_SRC = os.path.join(REPO, 'Ports', 'Android', 'src', 'com', 'codename1', + 'impl', 'android') + +# The stubs above are hand-written stand-ins. Each entry is a declaration that +# has to still be present in the port, or the stub is lying about the API the +# library is being compiled against. +STUB_GUARDS = [ + ('AndroidNativeUtil.java', 'public static Activity getActivity()'), + ('AndroidImplementation.java', + 'public static void runOnUiThreadAndBlock(final Runnable r)'), +] + +MAVEN_REPOS = [ + 'https://dl.google.com/dl/android/maven2', + 'https://repo1.maven.org/maven2', +] + +# An "umbrella" artifact carries the version an app declares but none of the +# classes; the implementation lives in a sibling it pins to the same version. +ARTIFACT_SUBSTITUTIONS = { + ('com.google.android.gms', 'play-services-ads'): 'play-services-ads-lite', +} + +GRADLE_DEP = re.compile(r"'([\w.\-]+):([\w.\-]+):([\w.\-]+)'") + + +def log(message): + sys.stdout.write(message + '\n') + sys.stdout.flush() + + +def libraries(): + maven = os.path.join(REPO, 'maven') + for name in sorted(os.listdir(maven)): + if not name.startswith('cn1-'): + continue + src = os.path.join(maven, name, 'android', 'src', 'main', 'java') + if not os.path.isdir(src): + continue + if any(f.endswith('.java') + for _b, _d, files in os.walk(src) for f in files): + yield name + + +def java_sources(root): + for base, _dirs, files in os.walk(root): + for name in sorted(files): + if name.endswith('.java'): + yield os.path.join(base, name) + + +def pinned_artifacts(lib): + props = os.path.join(REPO, 'maven', lib, 'common', + 'codenameone_library_required.properties') + if not os.path.isfile(props): + return [] + with open(props, encoding='utf-8') as f: + text = f.read() + line = '' + for raw in text.splitlines(): + if raw.startswith('codename1.arg.android.gradleDep='): + line = raw.split('=', 1)[1] + break + out = [] + for group, artifact, version in GRADLE_DEP.findall(line): + artifact = ARTIFACT_SUBSTITUTIONS.get((group, artifact), artifact) + out.append((group, artifact, version)) + return out + + +def fetch(group, artifact, version, cache): + """Download one artifact and return the jar of classes inside it.""" + base = '%s/%s/%s/%s-%s' % (group.replace('.', '/'), artifact, version, + artifact, version) + for ext in ('aar', 'jar'): + local = os.path.join(cache, '%s-%s.%s' % (artifact, version, ext)) + if not os.path.isfile(local): + for repo in MAVEN_REPOS: + url = '%s/%s.%s' % (repo, base, ext) + try: + with urllib.request.urlopen(url, timeout=120) as response: + data = response.read() + except Exception: + continue + with open(local, 'wb') as f: + f.write(data) + break + if not os.path.isfile(local): + continue + if ext == 'jar': + return local + # An .aar is a zip whose compiled code is classes.jar. + extracted = os.path.join(cache, '%s-%s-classes.jar' % (artifact, version)) + if not os.path.isfile(extracted): + with zipfile.ZipFile(local) as z: + if 'classes.jar' not in z.namelist(): + return None + with z.open('classes.jar') as src, open(extracted, 'wb') as dst: + shutil.copyfileobj(src, dst) + return extracted + return None + + +def javac_version(javac): + """The major Java version of a javac, or 0 if it will not run.""" + try: + result = subprocess.run([javac, '-version'], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + except OSError: + return 0 + text = result.stdout.decode('utf-8', 'replace').strip() + match = re.search(r'javac (\d+)(?:\.(\d+))?', text) + if not match: + return 0 + major = int(match.group(1)) + if major == 1: + return int(match.group(2) or 0) + return major + + +def resolve_javac(): + """A javac new enough to read the SDKs these libraries pin. + + Android SDKs ship Java 11 class files (LevelPlay 9.6 is one), which javac 8 + refuses to read at all, and the Android build itself runs on JDK 17. So the + check follows the Android toolchain rather than whichever JDK the + surrounding job happens to be on. + """ + candidates = [] + for var in ('JAVA17_HOME', 'JAVA_HOME_17', 'JAVA_HOME_21', 'JAVA_HOME_11', + 'JAVA_HOME'): + home = os.environ.get(var) + if home: + candidates.append(os.path.join(home, 'bin', 'javac')) + found = shutil.which('javac') + if found: + candidates.append(found) + for javac in candidates: + if javac_version(javac) >= 11: + return javac + return None + + +def core_classpath(): + """Where codenameone-core's classes are, compiled or installed.""" + classes = os.path.join(REPO, 'maven', 'core', 'target', 'classes') + if os.path.isdir(classes): + return classes + home = os.path.expanduser('~/.m2/repository/com/codenameone/codenameone-core') + if os.path.isdir(home): + for version in sorted(os.listdir(home), reverse=True): + jar = os.path.join(home, version, + 'codenameone-core-%s.jar' % version) + if os.path.isfile(jar): + return jar + return None + + +def android_jar(): + for candidate in (os.environ.get('CN1_BINARIES'), + os.path.join(REPO, 'maven', 'target', 'cn1-binaries'), + os.path.join(os.path.dirname(REPO), 'cn1-binaries')): + if not candidate: + continue + jar = os.path.join(candidate, 'android', 'android.jar') + if os.path.isfile(jar): + return jar + return None + + +def check_stub_guards(): + problems = [] + for name, declaration in STUB_GUARDS: + port_file = os.path.join(PORT_SRC, name) + if not os.path.isfile(port_file): + continue + with open(port_file, encoding='utf-8', errors='replace') as f: + if declaration not in f.read(): + problems.append( + 'scripts/cn1lib-api-check/stubs no longer matches the ' + 'Android port: %s does not declare "%s". Update the stub to ' + 'the port\'s current signature.' % (name, declaration)) + return problems + + +def check_library(lib, cache, classpath_base, javac): + src = os.path.join(REPO, 'maven', lib, 'android', 'src', 'main', 'java') + sources = list(java_sources(src)) + list(java_sources(STUBS)) + classpath = list(classpath_base) + for group, artifact, version in pinned_artifacts(lib): + jar = fetch(group, artifact, version, cache) + if jar is None: + return ['%s: could not resolve the pinned artifact %s:%s:%s named ' + 'in android.gradleDep.' % (lib, group, artifact, version)] + classpath.append(jar) + out = os.path.join(cache, lib + '-classes') + os.makedirs(out, exist_ok=True) + # The library's own portable half (the callback fan-in, the constants) is + # on the sourcepath rather than the classpath, so the check needs nothing + # built first. + common = os.path.join(REPO, 'maven', lib, 'common', 'src', 'main', 'java') + command = [javac, '-nowarn', '-Xlint:-options', + '-source', '1.8', '-target', '1.8', + '-encoding', 'UTF-8', '-d', out, + '-sourcepath', common, + '-classpath', os.pathsep.join(classpath)] + sources + result = subprocess.run(command, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + if result.returncode != 0: + text = result.stdout.decode('utf-8', 'replace').strip() + return ['%s: does not compile against the SDK it pins:\n%s' % (lib, text)] + log(' %s: compiles against %s' % ( + lib, ', '.join('%s:%s' % (a, v) for _g, a, v in pinned_artifacts(lib)) + or 'core and the Android SDK alone')) + return [] + + +def main(argv): + require_all = '--require-all' in argv + wanted = [a for a in argv if not a.startswith('-')] + + problems = check_stub_guards() + + javac = resolve_javac() + jar = android_jar() + core = core_classpath() + missing = [] + if javac is None: + missing.append('a JDK 11 or newer javac (set JAVA17_HOME); the pinned ' + 'SDKs ship class files javac 8 cannot read') + if jar is None: + missing.append('android.jar (stage cn1-binaries, or set CN1_BINARIES)') + if core is None: + missing.append('codenameone-core classes (build maven/core)') + if missing: + message = 'check-cn1lib-android-api: missing ' + '; '.join(missing) + if require_all: + sys.stderr.write(message + '\n') + return 1 + log(message + ' -- skipping') + return 1 if problems else 0 + + libs = [lib for lib in libraries() if not wanted or lib in wanted] + if wanted: + for name in wanted: + if name not in libs: + sys.stderr.write('%s ships no Android sources\n' % name) + return 1 + log('check-cn1lib-android-api: %d librar%s' + % (len(libs), 'y' if len(libs) == 1 else 'ies')) + + cache = os.path.join(REPO, 'maven', 'target', 'cn1lib-api-check') + os.makedirs(cache, exist_ok=True) + classpath_base = [jar, core] + for lib in libs: + problems.extend(check_library(lib, cache, classpath_base, javac)) + + if problems: + sys.stderr.write('\n') + for problem in problems: + sys.stderr.write(problem + '\n\n') + return 1 + log('check-cn1lib-android-api: no findings') + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/scripts/check-cn1lib-native-coverage.py b/scripts/check-cn1lib-native-coverage.py new file mode 100755 index 00000000000..3a28c4b5be9 --- /dev/null +++ b/scripts/check-cn1lib-native-coverage.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Fail when a cn1lib's native sources are not compiled by any CI job. + +Native sources in a cn1lib are shipped, never built by us. Two workflows close +that gap by compiling them -- ai-cn1lib-native-check.yml and +ad-cn1lib-ios-native-check.yml -- but a workflow only covers the libraries +named in its matrix, so a new cn1lib is uncovered by default and nothing says +so. That is exactly how cn1-admob and cn1-unity-levelplay shipped Objective-C +that had never been through a compiler. + +This checks the inverse of what those workflows check: not "does the code +compile" but "is there a job that would have found out". It also requires a +library that pulls a CocoaPod to pin it, because an unpinned pod moves the API +underneath sources that are only compiled when the pod is fetched. + +Coverage is read from evidence rather than from a workflow's name, and per +job rather than per file. A job counts only if it stages a cn1lib's +Objective-C and runs xcodebuild over it, its matrix is read from that job +alone, and the library also has to appear in the workflow's trigger paths. + +Each of those rules exists because dropping it lets something claim coverage +it does not have: a matrix over "lib:" in an unrelated packaging job, a +packaging job sitting in the same file as a real native check, or a library +the workflow never fires for. The trigger paths are read per trigger, since a +library listed under push but not pull_request is compiled only after it has +already merged. Jobs and triggers are separated by indentation rather than +with a YAML parser, so this keeps running wherever python3 does. + +The Android half needs no registry: check-cn1lib-android-api.py enumerates +libraries from the filesystem, so it cannot miss one. + + scripts/check-cn1lib-native-coverage.py +""" + +import os +import re +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +WORKFLOWS = os.path.join(REPO, '.github', 'workflows') + +# Both spellings of a GitHub Actions matrix over libraries: +# lib: [cn1-admob, cn1-applovin] +# - { lib: cn1-ai-whisper, pod: '' } +MATRIX_LIST = re.compile(r'lib:\s*\[([^\]]*)\]') +MATRIX_ENTRY = re.compile(r'\{\s*lib:\s*([\w.\-]+)') + +POD_HINT = re.compile(r'^codename1\.arg\.ios\.pods=(.*)$', re.M) + + +def libraries_with_ios_sources(): + maven = os.path.join(REPO, 'maven') + for name in sorted(os.listdir(maven)): + if not name.startswith('cn1-'): + continue + objc = os.path.join(maven, name, 'ios', 'src', 'main', 'objectivec') + if not os.path.isdir(objc): + continue + if any(f.endswith(('.m', '.mm')) for f in os.listdir(objc)): + yield name + + +JOB_START = re.compile(r'^ ([A-Za-z_][\w.\-]*):\s*$') +# The triggers a change to a library has to run under. push alone would mean +# the break is found after it merged; pull_request alone leaves master +# unguarded against anything that lands another way. +REQUIRED_TRIGGERS = ('pull_request', 'push') + + +def jobs(text): + """Yield each job's body from a workflow, split on indentation. + + A YAML parser would be tidier, but PyYAML is not in the standard library + and this has to run in the CI container as it is. + """ + lines = text.splitlines() + starts = [] + in_jobs = False + for index, line in enumerate(lines): + if line.startswith('jobs:'): + in_jobs = True + continue + if not in_jobs: + continue + # A non-indented line ends the jobs mapping. + if line.strip() and not line.startswith(' ') and not line.startswith('#'): + break + if JOB_START.match(line): + starts.append(index) + for position, start in enumerate(starts): + end = starts[position + 1] if position + 1 < len(starts) else len(lines) + yield '\n'.join(lines[start:end]) + + +def indented_block(text, header, indent): + """The lines under `header` that are indented past it, as one string.""" + lines = text.splitlines() + prefix = ' ' * indent + out = [] + collecting = False + for line in lines: + if line.startswith(prefix + header): + collecting = True + continue + if collecting: + if line.strip() and not line.startswith(prefix + ' '): + break + out.append(line) + return '\n'.join(out) + + +def triggers_for(text, lib): + """True when every required trigger lists this library's path. + + on: is read as a block and each trigger inside it separately, because a + path present under one trigger and missing from the other reads as covered + to any whole-file search while half the cases run nothing. + """ + on_block = indented_block(text, 'on:', 0) + entry = "maven/%s/**" % lib + for trigger in REQUIRED_TRIGGERS: + if entry not in indented_block(on_block, trigger + ':', 2): + return False + return True + + +def compiles_cn1lib_natives(body): + """True when a job stages a cn1lib's Objective-C and builds it. + + Naming a library in a matrix proves nothing on its own; these two markers + are what separate a native check from any other job that happens to loop + over libraries. Applied per job, because one file can hold both. + """ + return 'ios/src/main/objectivec' in body and 'xcodebuild' in body + + +def covered_libraries(): + """Two maps: libraries a native check compiles, and libraries a native + check names but never triggers for.""" + covered = {} + untriggered = {} + if not os.path.isdir(WORKFLOWS): + return covered, untriggered + for name in sorted(os.listdir(WORKFLOWS)): + if not name.endswith(('.yml', '.yaml')): + continue + with open(os.path.join(WORKFLOWS, name), encoding='utf-8') as f: + text = f.read() + found = set() + for body in jobs(text): + if not compiles_cn1lib_natives(body): + continue + found.update(MATRIX_ENTRY.findall(body)) + for group in MATRIX_LIST.findall(body): + found.update(part.strip() for part in group.split(',')) + for lib in found: + if not lib.startswith('cn1-'): + continue + # A library the workflow never triggers for is compiled only when + # something else in that workflow's paths changes, which is not + # coverage of a change to the library. Recorded separately so the + # finding can say which of the two is missing. + if triggers_for(text, lib): + covered.setdefault(lib, name) + else: + untriggered.setdefault(lib, name) + return covered, untriggered + + +def pod_findings(lib): + props = os.path.join(REPO, 'maven', lib, 'common', + 'codenameone_library_required.properties') + if not os.path.isfile(props): + return [] + with open(props, encoding='utf-8') as f: + text = f.read() + findings = [] + for value in POD_HINT.findall(text): + for pod in value.split(','): + pod = pod.strip() + if not pod: + continue + if ' ' not in pod: + findings.append( + '%s pulls the CocoaPod "%s" without a version. Pin it (for ' + 'example "%s ~> 1.0") so the SDK the native sources are ' + 'compiled against cannot change without a commit.' + % (lib, pod, pod)) + return findings + + +def main(): + covered, untriggered = covered_libraries() + findings = [] + libs = list(libraries_with_ios_sources()) + for lib in libs: + if lib in untriggered: + findings.append( + "%s is in %s's matrix, but 'maven/%s/**' is missing from at " + "least one of that workflow's %s trigger path lists, so a " + "change to the library does not run the check in every context. " + "Add it to each." + % (lib, untriggered[lib], lib, ' and '.join(REQUIRED_TRIGGERS))) + elif lib not in covered: + findings.append( + '%s ships Objective-C under maven/%s/ios/src/main/objectivec ' + 'but no workflow both compiles it and triggers on its path, so ' + 'nothing compiles it before a customer does. Add it to a ' + 'native-check workflow.' + % (lib, lib)) + findings.extend(pod_findings(lib)) + + if findings: + sys.stderr.write('cn1lib native sources without CI coverage:\n\n') + for finding in findings: + sys.stderr.write(' ' + finding + '\n') + sys.stderr.write('\n') + return 1 + for lib in libs: + print(' %s: compiled by %s' % (lib, covered[lib])) + print('check-cn1lib-native-coverage: %d librar%s covered' + % (len(libs), 'y' if len(libs) == 1 else 'ies')) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/check-cn1lib-native-sources.py b/scripts/check-cn1lib-native-sources.py new file mode 100755 index 00000000000..c232fdd39d5 --- /dev/null +++ b/scripts/check-cn1lib-native-sources.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Compile the toolchain-light native sources every cn1lib ships. + +Two platforms in a cn1lib need nothing but a C compiler or node to check: + + * the native win32/Linux ports compile the library's C glue into the app, the + same way the iOS build compiles its Objective-C, so a typo there is a broken + customer build; and + * the JavaScript port loads the library's .js implementation verbatim, where a + syntax error is a runtime failure with no build step to catch it. + +Neither is covered by the per-platform workflows, which need Xcode or an +Android SDK. This runs anywhere. + +The Windows sources are compiled for a Windows target rather than the host, +because most of what is Windows-specific in them sits behind _WIN32 -- the +windows.h include, LoadLibraryA, GetProcAddress. Compiling them as host C +parses the #else half and reports success, which is a green light for code +nothing read. A mingw-w64 cross compiler supplies the Win32 headers for that; +the port itself is built with clang-cl, so this gate is about the API existing +and the syntax parsing, not about matching that ABI. + + scripts/check-cn1lib-native-sources.py [--require-all] + +Not covered here, deliberately: cn1-ai-whisper's android-aar JNI sources. They +need the NDK and a whisper.cpp checkout, and unlike everything above they are +compiled by us into a committed .aar rather than by the customer, so a break +shows up when we rebuild that binary and can never reach an app build. +""" + +import os +import shutil +import subprocess +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TRANSLATOR_SRC = os.path.join(REPO, 'vm', 'ByteCodeTranslator', 'src') +# cn1_globals.h includes cn1_win_compat.h under _WIN32 and pthread.h otherwise, +# so the Windows half does not even parse without the compat header beside it. +PORT_HEADERS = ['cn1_globals.h', 'cn1_win_compat.h'] + + +def libraries(): + maven = os.path.join(REPO, 'maven') + for name in sorted(os.listdir(maven)): + if name.startswith('cn1-') and os.path.isdir(os.path.join(maven, name)): + yield name + + +def sources(lib, *parts): + root = os.path.join(REPO, 'maven', lib, *parts) + if not os.path.isdir(root): + return + for base, _dirs, files in os.walk(root): + for name in sorted(files): + yield os.path.join(base, name) + + +def c_sources(lib): + """Yield (platform, path) for the desktop ports' C glue.""" + for platform in ('linux', 'win'): + for path in sources(lib, platform, 'src', 'main', 'c'): + if path.endswith('.c'): + yield platform, path + + +def windows_compiler(): + """A compiler that targets Windows, or None.""" + explicit = os.environ.get('CC_WIN') + if explicit: + return explicit + if sys.platform == 'win32': + return os.environ.get('CC') or shutil.which('cc') or shutil.which('gcc') + for name in ('x86_64-w64-mingw32-gcc', 'i686-w64-mingw32-gcc'): + found = shutil.which(name) + if found: + return found + return None + + +def js_sources(lib): + for path in sources(lib, 'javascript', 'src', 'main', 'javascript'): + if path.endswith('.js'): + yield path + + +def prepare_headers(work): + """The include directory a translated project would give these sources.""" + os.makedirs(work, exist_ok=True) + for header in PORT_HEADERS: + shutil.copyfile(os.path.join(TRANSLATOR_SRC, header), + os.path.join(work, header)) + # Generated per translation from the app's class list; the glue does not + # read it, so an empty stand-in lets cn1_globals.h parse on its own. + with open(os.path.join(work, 'cn1_class_method_index.h'), 'w') as f: + f.write('#pragma once\n') + return work + + +def main(argv): + require_all = '--require-all' in argv + findings = [] + skipped = [] + + cc = os.environ.get('CC') or shutil.which('cc') or shutil.which('gcc') + win_cc = windows_compiler() + node = shutil.which('node') + work = prepare_headers(os.path.join(REPO, 'maven', 'target', + 'cn1lib-native-sources')) + + checked = 0 + for lib in libraries(): + for platform, path in c_sources(lib): + rel = os.path.relpath(path, REPO) + compiler = win_cc if platform == 'win' else cc + if compiler is None: + skipped.append('%s (no %s; set %s)' + % (rel, + 'Windows-targeting compiler' if platform == 'win' + else 'C compiler', + 'CC_WIN' if platform == 'win' else 'CC')) + continue + result = subprocess.run([compiler, '-fsyntax-only', '-I', work, path], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + if result.returncode != 0: + findings.append('%s does not compile:\n%s' + % (rel, result.stdout.decode('utf-8', 'replace').strip())) + else: + checked += 1 + print(' %s: compiles (%s target)' + % (rel, 'Win32' if platform == 'win' else 'host')) + + for path in js_sources(lib): + rel = os.path.relpath(path, REPO) + if node is None: + skipped.append('%s (no node)' % rel) + continue + result = subprocess.run([node, '--check', path], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + if result.returncode != 0: + findings.append('%s is not valid JavaScript:\n%s' + % (rel, result.stdout.decode('utf-8', 'replace').strip())) + else: + checked += 1 + print(' %s: parses' % rel) + + if skipped: + message = ('check-cn1lib-native-sources: skipped %d source(s): %s' + % (len(skipped), '; '.join(skipped))) + if require_all: + sys.stderr.write(message + '\n') + return 1 + print(message) + + if findings: + sys.stderr.write('\n') + for finding in findings: + sys.stderr.write(finding + '\n\n') + return 1 + print('check-cn1lib-native-sources: %d source(s) checked, no findings' + % checked) + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/scripts/check-native-peer-returns.py b/scripts/check-native-peer-returns.py new file mode 100755 index 00000000000..9f12f6ca5f5 --- /dev/null +++ b/scripts/check-native-peer-returns.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Check that native-interface peer methods return a native view, not a peer. + +A NativeInterface method declared to return PeerComponent is special-cased by +every builder: AndroidGradleBuilder emits + + return PeerComponent.create(impl.createBanner(handle, adUnitId, ...)); + +and IPhoneBuilder the long[] equivalent. The generated stub does the wrapping, +so the platform implementation has to hand back the *native* object -- an +android.view.View on Android, a void* on iOS. An implementation that returns a +PeerComponent instead gets wrapped twice, and +AndroidImplementation.createNativePeer rejects its own AndroidPeer with + + java.lang.IllegalArgumentException: + com.codename1.impl.android.AndroidImplementation$AndroidPeer + +the first time the component is shown. Nothing catches that before a device +run: the double wrap is valid Java, so the API check compiles it happily, and +it only fails when the peer is created. cn1-admob, cn1-applovin and +cn1-unity-levelplay all shipped with it. + +Run with no arguments from the repository root; exits non-zero on a finding. +""" + +import os +import re +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# "PeerComponent createBanner(int handle, ...);" in an interface body. +IFACE_METHOD = re.compile( + r'(?The signature below is checked against * {@code Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java} diff --git a/maven/cn1-admob/android/src/api-check/java/com/codename1/impl/android/AndroidNativeUtil.java b/scripts/cn1lib-api-check/stubs/com/codename1/impl/android/AndroidNativeUtil.java similarity index 85% rename from maven/cn1-admob/android/src/api-check/java/com/codename1/impl/android/AndroidNativeUtil.java rename to scripts/cn1lib-api-check/stubs/com/codename1/impl/android/AndroidNativeUtil.java index dafdddb716b..6a15742761a 100644 --- a/maven/cn1-admob/android/src/api-check/java/com/codename1/impl/android/AndroidNativeUtil.java +++ b/scripts/cn1lib-api-check/stubs/com/codename1/impl/android/AndroidNativeUtil.java @@ -25,17 +25,18 @@ import android.app.Activity; /** - * Compile-only stand-in for the Android port's class of the same name, used by - * nothing but this module's Ads SDK API check. Never packaged. + * Compile-only stand-in for the Android port's class of the same name, shared by + * every cn1lib API check: cn1-admob's Maven-driven one and + * scripts/check-cn1lib-android-api.py, which covers all of them. Never packaged. * - *

The check exists to catch cn1-admob drifting off the Google Ads SDK it - * pins, and it must run everywhere -- including a fresh release checkout, where + *

The checks exist to catch a cn1lib drifting off the SDK it pins, and they + * must run everywhere -- including a fresh release checkout, where * {@code codenameone-android} is built from an empty source directory because * the {@code compile-android} profile activates on a {@code cn1.binaries} * directory that the {@code download} profile only creates in {@code initialize}, * after Maven has already evaluated the model. Depending on the real port would * make this check fail the release build for a reason that has nothing to do - * with AdMob. + * with the library under test. * *

The signature below is checked against * {@code Ports/Android/src/com/codename1/impl/android/AndroidNativeUtil.java}