Bootstrap the public demo: Trusted Arbiter for interstitials - #1
Conversation
CloudX and AdMob load in parallel, both fills become bids, and CloudX.arbiter picks which one is shown. The winner is prepared ahead of the show, so the tap that shows an ad makes no network call. The integration is three files: arbiter_interstitial_controller.dart holds the whole cycle and both SDKs' calls, arbiter_events.dart is what it reports through, and demo_config.dart carries the ids. lib/main.dart is the demo screen and is not part of the integration. Only interstitials. Rewarded is the same flow with one extra callback and banners are a different, inline flow, so neither is repeated here. CloudXGoogleWaterfallAdapter and io.cloudx:adapter-googlewaterfall are deliberately left out: they run AdMob demand inside the CloudX auction, which is the opposite of what this demo shows, and shipping them would make the two bids the same demand. Identity is the public io.cloudx.sample dashboard app, matching the public Unity and React Native demos. AdMob ids are Google's test units. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XQvJAmT4AgvqCdgQECTjcV
cloudx_flutter 3.8.0 is not on pub.dev yet, so `flutter pub get` cannot resolve the dependency the previous commit declares. Until the publish lands, the demo points at the local pub host that flutter/scripts/serve-local-pub.py in cloudx-io/mobile serves, and pubspec.lock records that host. Revert this commit once cloudx_flutter is published. Nothing else depends on it. qa/sdk/scripts/flutter/preflight-hermetic.sh --release fails while it is applied, which is the intended behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XQvJAmT4AgvqCdgQECTjcV
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical dependency, SDK constraint, signing, runtime, and platform metadata findings block approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a public Flutter demo integrating CloudX Trusted Arbiter with parallel CloudX/AdMob interstitial loading, winner selection, stored-result presentation, ATT gating, and native platform setup.
Changes:
- Adds arbiter controller, callbacks, configuration, tracking gate, and demo UI.
- Adds Android/iOS SDK, adapter, AdMob, ATT, and SKAdNetwork configuration.
- Adds documentation, dependencies, and Flutter project scaffolding.
Unresolved findings:
- Critical:
pubspec.yamluses a local-only dependency source (3 votes); SDK constraints conflict with the lockfile (3 votes); iOS signing hard-codes a team (1 vote). - Moderate: Missing Google SKAdNetwork identifier (2 votes); controller show and in-flight state failures (2 and 1 votes); busy/load-button state issues (2 and 1 votes).
- Nit: README’s AdMob price example does not match the implementation (1 vote).
File summaries
| File | Reviewed scope |
|---|---|
README.md |
Demo documentation |
pubspec.yaml |
Dependencies and SDK constraints |
pubspec.lock |
Locked dependency graph |
lib/tracking_gate.dart |
iOS ATT gate |
lib/main.dart |
Demo screen and lifecycle |
lib/config/demo_config.dart |
Platform ad configuration |
lib/arbiter/arbiter_interstitial_controller.dart |
Arbiter load, show, and revenue flow |
lib/arbiter/arbiter_events.dart |
Controller callbacks |
ios/RunnerTests/RunnerTests.swift |
iOS test scaffold |
ios/Runner/SceneDelegate.swift |
iOS scene delegate |
ios/Runner/Runner-Bridging-Header.h |
Swift bridging setup |
ios/Runner/Info.plist |
AdMob, ATT, and SKAdNetwork metadata |
ios/Runner/Base.lproj/Main.storyboard |
Main storyboard |
ios/Runner/Base.lproj/LaunchScreen.storyboard |
Launch screen |
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md |
Launch asset guidance |
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json |
Launch asset metadata |
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json |
App icon metadata |
ios/Runner/AppDelegate.swift |
iOS app delegate |
ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings |
Workspace settings |
ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved |
Swift package pins |
ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist |
Xcode workspace checks |
ios/Runner.xcworkspace/contents.xcworkspacedata |
Workspace references |
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme |
Xcode scheme |
ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings |
Project workspace settings |
ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved |
Swift package pins |
ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist |
Xcode checks |
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata |
Project workspace |
ios/Runner.xcodeproj/project.pbxproj |
iOS project configuration |
ios/Podfile.lock |
Locked CocoaPods |
ios/Podfile |
iOS pods and adapters |
ios/Flutter/Release.xcconfig |
Release Flutter settings |
ios/Flutter/Debug.xcconfig |
Debug Flutter settings |
ios/Flutter/AppFrameworkInfo.plist |
Flutter framework metadata |
ios/.gitignore |
iOS ignore rules |
android/settings.gradle.kts |
Android Gradle plugins |
android/gradle/wrapper/gradle-wrapper.properties |
Gradle version |
android/gradle.properties |
Android build properties |
android/build.gradle.kts |
Android repositories and build setup |
android/app/src/profile/AndroidManifest.xml |
Profile manifest |
android/app/src/main/res/values/styles.xml |
Light launch themes |
android/app/src/main/res/values-night/styles.xml |
Dark launch themes |
android/app/src/main/res/drawable/launch_background.xml |
Android launch background |
android/app/src/main/res/drawable-v21/launch_background.xml |
Android API 21 launch background |
android/app/src/main/kotlin/io/cloudx/cloudx_flutter_public_demo/MainActivity.kt |
Android entry point |
android/app/src/main/AndroidManifest.xml |
Android permissions and metadata |
android/app/src/debug/AndroidManifest.xml |
Debug manifest |
android/app/build.gradle.kts |
Android dependencies and adapters |
android/.gitignore |
Android ignore rules |
analysis_options.yaml |
Dart analyzer configuration |
.metadata |
Flutter project metadata |
.gitignore |
Repository ignore rules |
Review details
Files not reviewed (2)
- ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Generated file
- ios/Runner.xcworkspace/contents.xcworkspacedata: Generated file
Suppressed comments (4)
README.md:128
- This shared example says both lines display
loaded: <network> $<price>, but the AdMob callback inmain.dart:337reports only_adMobNetworkName; AdMob intentionally has no bid price. Split the examples or explicitly limit the price suffix to CloudX so the screen guide matches the implementation.
| `CloudX` / `AdMob` | That side's last event: `loading`, `loaded: <network> $<price>`, `load failed: ...`, `showing`, `closed`. |
lib/arbiter/arbiter_interstitial_controller.dart:160
- The AdMob show call can fail through its awaited platform Future (for example when the stored interstitial has expired). With no catch,
_nextWinnerhas already been cleared but_isShowingremains true, so_show()gets an uncaught error and the controller cannot reload. Catch this failure, dispose/reset the AdMob state, and returnfalse(or route it through the existing show-failed event).
lib/arbiter/arbiter_interstitial_controller.dart:74 - The screen uses
isBusyto enableLoad both, but this getter excludes_isShowingeven thoughload()rejects calls while an ad is visible. Pressing the button during the show therefore overwrites the status lines without starting a load. Include_isShowingin the busy state or gate the button onisShowing.
lib/main.dart:162 load()intentionally starts only the missing side, but this unconditional update marks both sides as loading. After a one-sided failure, the retained ad emits no new loaded callback, so its status remainsloadingeven while it is still held as a bid/winner and the diagnostic screen becomes misleading. Update only the side(s) actually being requested or expose per-side loading state.
- Files reviewed: 46/74 changed files
- Comments generated: 6
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
demo_config.dart carries the AdMob ad unit ids, which makes it a reasonable place to look for the application id too. It is not there and cannot be: Google reads it before any Dart runs, so it is the com.google.android.gms.ads.APPLICATION_ID meta-data in AndroidManifest.xml and GADApplicationIdentifier in Info.plist, and the Google Mobile Ads SDK throws at startup without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XQvJAmT4AgvqCdgQECTjcV
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved build, dependency, and interstitial state-management issues remain.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Files not reviewed (2)
- ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Generated file
- ios/Runner.xcworkspace/contents.xcworkspacedata: Generated file
Suppressed comments (5)
lib/arbiter/arbiter_interstitial_controller.dart:130
show()leaves_isShowingfalse while this readiness call is awaited, so the Load button/API can start another_maybePrepareWinner()round against the same fills. That second result can overwrite_nextWinner; if it selects CloudX, it refers to the ad whose first show is still being prepared or consumed. Mark the show as in-flight before awaiting readiness (and clear it on the stale/failure paths), or otherwise serialize loading/arbitration with this check.
lib/arbiter/arbiter_interstitial_controller.dart:74isBusyomits_isShowing, so after a show has started the screen's Load both button remains enabled. Tapping it rewrites the UI toloading/waiting for both sides to settle, whileload()immediately returns because an ad is showing, leaving misleading status until another cycle. Include the showing state in the busy predicate.
lib/main.dart:162- This unconditionally reports both networks as
loading, butload()deliberately reloads only missing fills. After a CloudX win/close, for example, the retained AdMob fill is not requested again and emits no new loaded event, so the demo can permanently displayAdMob: loadingwhile that bid is included in the next arbitration. Update only the sides actually being loaded, or expose the controller's per-side state to the screen.
pubspec.yaml:10 - The package advertises support for Flutter 3.38.1/Dart 3.10, but the committed resolution requires Flutter >=3.44.0 and Dart >=3.12.0. A user on the advertised minimum cannot resolve this lockfile, so either raise these environment constraints to the resolved floors or regenerate dependencies that meet the advertised range.
pubspec.yaml:23 - As committed, a fresh clone following the README cannot run
flutter pub get: the direct SDK dependency is hard-wired to an HTTP pub server on127.0.0.1:8080, and the lockfile records that same local source. This is explicitly a temporary development setting, but it makes the public demo unusable until the SDK is published; please keep this setting out of the mergeable public-demo commit (or otherwise provide a resolvable published dependency).
- Files reviewed: 46/74 changed files
- Comments generated: 1
- Review effort level: Lite
Five findings, all verified against the code before fixing. Signing: the Xcode project hard-coded CloudX's DEVELOPMENT_TEAM in all three configurations. Removed, so signing stays on automatic and picks the cloner's own team. The public Unity and React Native demos both ship without a team id; this project was the only one leaking it. SDK floors: pubspec.lock needs Dart 3.12 and Flutter 3.44, set by webview_flutter_android 4.14.1 and webview_flutter_wkwebview 3.26.1 under google_mobile_ads 9.1.0. The manifest declared 3.10/3.38.1, so an environment it allows could not resolve the committed lock. Raised to match. SKAdNetwork: added Google's cstr6suwn9.skadnetwork. The list held the CloudX adapters only, so Google demand could not attribute on iOS. show() race: _nextWinner was cleared before the isInterstitialReady round trip but _isShowing only after it, so a second tap inside that await could re-arbitrate the fills the first call was about to consume. The show slot is now claimed before the await and released on every path that does not reach a show. Load both: the button stayed enabled with a winner stored or an ad showing, both cases where load() does nothing, so a tap set the rows to "loading" with no callback coming to clear them. Disabled in both. Also corrected what the demo claims about reportRevenueData. Its return value is the ILRD emission result when ILRD telemetry is on, not an acceptance, and the price store drops any revenue of 0.0 - which is what Google's test units pay, so the AdMob bid reaches the arbiter unpriced. The row, the log line, the callback doc and the README now say that instead of "accepted". Copilot's seventh finding is wrong: google_mobile_ads 9.1.0 declares OnPaidEventCallback with a double valueMicros (ad_listeners.dart:36-42), which is what the helper takes. Verified on an emulator and a simulator booted for this run: two bids, ADMOB winner, one interstitial per double tap, Load both greyed while a winner is held, and "0.000000 USD reported (returned true)" on both platforms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XQvJAmT4AgvqCdgQECTjcV
Claiming _isShowing before the isInterstitialReady round trip closed the re-arbitration race, but it opened a worse one: a platform-channel failure throws out of that call, and with the slot already claimed nothing was left to clear it. load() returns early while _isShowing is set, so the controller wedged with both buttons dead and no way back short of restarting the app. The throw also escaped into the button handler unhandled. Both awaits in show() now catch, release the slot and report a show failure through the same callback the SDK uses, matching how the arbiter call already handles a channel failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XQvJAmT4AgvqCdgQECTjcV
There was a problem hiding this comment.
🟡 Changes recommended
One or more issues must be addressed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Files not reviewed (2)
- ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Generated file
- ios/Runner.xcworkspace/contents.xcworkspacedata: Generated file
Suppressed comments (2)
lib/main.dart:175
show()returns false both for a stale winner and when another show is already in progress. This branch treats the latter as stale and calls_load(), which immediately no-ops becausecontroller.isShowingis true, while the UI is left saying it is reloading. Checkcontroller.isShowingbefore taking the stale-winner recovery path.
lib/main.dart:166controller.load()retains the losing ad and reloads only the missing side, so after a CloudX win the next load does not emit an AdMob load callback (and vice versa). Resetting both rows toloadingleaves the retained side permanently misreported as loading, which makes this diagnostic screen incorrect. Update only the side that is actually being loaded or expose/use controller state for the held fills.
- Files reviewed: 46/74 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved moderate issues remain in lib/main.dart, along with a README correctness nit.
Review details
Files not reviewed (2)
- ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Generated file
- ios/Runner.xcworkspace/contents.xcworkspacedata: Generated file
Suppressed comments (4)
Previously missed (2) — in code that hasn't changed since the last review.
lib/main.dart:166
load()intentionally starts only the missing side, and after anoneresult it may only rerun arbitration while both fills are still held. Resetting both rows toloadinghere therefore leaves the held side permanently showingloadingbecause it emits no load callback (and a winnerless retry may emit only an arbiter event). Update only the side(s) actually being loaded, or expose that state from the controller, so the diagnostic rows stay accurate.
lib/main.dart:175- A second tap while the first
show()is awaiting readiness makesshow()returnfalsebecausecontroller.isShowingis still true. This path treats that normal in-flight rejection as a stale winner, changes the rows toloading, and callsload()while the controller is still showing, soload()is a no-op and the UI remains stuck. Checkcontroller.isShowingbefore entering the stale-winner reload path (or disable Show while it is in flight).
README.md:34
- This promises that the checked-in Google test units will make CloudX lose, but the validation for this PR reports the opposite (
platform=ADMOB). With zero ILRD revenue there is no meaningful price comparison, but the eventual winner can still depend on arbiter fallback/tie behavior; the README should not state a deterministic CloudX loss.
Which is why the AdMob ids checked in here make a poor price demo: Google's test
units report a revenue of 0.0, the price store drops any revenue of 0.0, and the
AdMob bid therefore reaches the arbiter with no price at all. Expect CloudX to
lose those rounds. Point the demo at a real AdMob unit that pays to see prices
compete.
lib/main.dart:81
- When ATT is denied, this branch returns before
MobileAds.instance.initialize()is started, but_adMobSdkStatusremainsinitializingforever. That makes the demo report an operation that never began; set the AdMob status to an explicit blocked/not-initialized value alongside the CloudX status.
- Files reviewed: 46/74 changed files
- Comments generated: 0 new
- Review effort level: Lite
The comments explaining what reportRevenueData returns named ILRD telemetry and the internal price store. That is SDK plumbing, and a public demo has no business teaching it. The point a reader needs is smaller: the return value is the call's result, not proof the price was kept, and a revenue of 0.0 is discarded, which is what Google's test units pay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XQvJAmT4AgvqCdgQECTjcV
There was a problem hiding this comment.
🟡 Changes recommended
Four unresolved moderate review findings remain in the controller and demo UI.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Files not reviewed (2)
- ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Generated file
- ios/Runner.xcworkspace/contents.xcworkspacedata: Generated file
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
lib/main.dart:162
- This resets both rows even though
controller.load()intentionally starts only missing sides. After an ad is consumed, the other SDK still holds its fill, and after anoneresult both fills can already be held; in those cases noonAdLoadedcallback follows for the retained side, so its status remainsloadingindefinitely and the diagnostic screen is incorrect. Preserve the retained status or expose/report per-side loading state and update only the sides that are actually requested.
lib/arbiter/arbiter_interstitial_controller.dart:237
- An AdMob
InterstitialAdis not valid indefinitely (loaded interstitials expire after about an hour), but this controller treats any non-null_adMobAdas a current bid. If the screen is left open or this side loses several rounds, an expired object can be selected here andshow()fails instead of refreshing AdMob. Track the load timestamp/expiry and discard or reload stale ads before building bids.
lib/main.dart:163 controller.show()claims_isShowingsynchronously, but the widget can still have the old frame whereLoad bothis enabled until an SDK callback triggers a rebuild. A tap in that window writes theloading/waitingrows beforecontroller.load()returns because it is showing, leaving the diagnostic stuck or misleading. Guard the UI mutation with the controller's current state, not only the button's last build.
- Files reviewed: 46/74 changed files
- Comments generated: 1
- Review effort level: Lite
show() returns false for two different things: a winner that went stale, and a show already in progress. _show() treated both as stale, so a second tap on the still-live Show winner button set the rows to "loading" and called load(), which declines to start anything while an ad is showing. The screen then sat behind the ad describing work that was not happening, and one row stayed stuck there after the ad closed. isShowing tells the two apart, and the reload now happens only for a genuinely stale winner. A successful show also rebuilds immediately instead of waiting for the shown callback, so the button goes flat while the show is under way rather than staying live in the gap, and it is disabled whenever isShowing is set. This is the same defect as the Load both button in 37ae9a8, on the other button. The earlier double-tap check missed it because the interstitial had already covered the screen and swallowed the second tap; this one taps three times in one shell round trip, which lands them before the ad renders. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XQvJAmT4AgvqCdgQECTjcV
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved moderate issues remain in show recovery and demo state handling; initialization documentation also needs clarification.
Review details
Files not reviewed (2)
- ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Generated file
- ios/Runner.xcworkspace/contents.xcworkspacedata: Generated file
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
lib/main.dart:162
_load()marks both rows asloading, butcontroller.load()intentionally starts only missing sides. After an AdMob win is closed the CloudX fill is retained, and after anone/arbiter retry both fills can already be held, so no CloudX load callback follows and the row remainsloadingeven after arbitration completes. Update only the sides actually being loaded (or expose a controller state/event for a re-arbitration) instead of resetting both rows here.
README.md:46
- This copy-and-run instruction omits the required SDK initialization performed in
main.dart: the controller does not initialize either SDK, and its own comments note that CloudX must already be initialized before loading. A consumer following this sentence can callload()before CloudX is ready, and on iOS also needs to complete ATT first; mention initializing Mobile Ads and CloudX (after the ATT decision where applicable) before callingload()/show().
A real integration copies the first three files and calls `load()` and `show()`.
lib/arbiter/arbiter_interstitial_controller.dart:177
- The show slot is claimed before this call, but the actual CloudX show invocation is not awaited or protected by the error handling above. If the platform-channel call throws (or its returned Future fails),
show()completes with an error while_isShowingremains true and the held ad is not cleared, so laterload()calls are rejected permanently and the demo cannot recover. Handle this call like the readiness path: release the slot/fill and reportonAdShowFailedon failure.
lib/main.dart:81 - When ATT is denied or restricted, this branch returns before
MobileAds.instance.initialize()is called, but_adMobSdkStatusremainsinitializingforever. That makes the diagnostic screen report an operation that was never started; set the AdMob status to an explicit blocked/not-initialized value along with the CloudX status.
- Files reviewed: 46/74 changed files
- Comments generated: 0 new
- Review effort level: Lite
The plugin version follows the iOS core, and the docs repo now declares CloudXCore 3.9.0, so the demo moves with it: the pubspec constraint, the Podfile pod, and both rows of the version table. Android stays on io.cloudx:sdk 4.7.0. Resolved from the local pub host, which now serves the 3.9.0 archive: the lock records source hosted with sha256 e35c268c0527c830cdad23599078f9b1f7b56c2b05eb99 b64f4664f89308a368, byte for byte the archive build-local-release.sh produced, and the hermetic preflight confirms no path back into the monorepo. Verified on an emulator and a simulator: pod install pulls CloudXCore 3.9.0, flutter analyze is clean, both platforms build and run, and a round gives two bids with an ADMOB winner on each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XQvJAmT4AgvqCdgQECTjcV
There was a problem hiding this comment.
🔵 Needs a closer look
Two moderate findings remain unresolved, along with one dependency-version documentation nit.
Review details
Files not reviewed (2)
- ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Generated file
- ios/Runner.xcworkspace/contents.xcworkspacedata: Generated file
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
lib/main.dart:162
load()is intentionally incremental: it may reload only the missing side, or just rerun arbitration when both fills are already held (seearbiter_interstitial_controller.dart:87-112). This unconditional update makes both rows sayloadingeven when neither row is loading or when one side remains held, and no callback will restore that retained side's status; after anone/arbiter-failure retry the screen can stay stuck showing work that is not happening. Preserve the existing row values or expose per-network loading state and only mark the sides that actually started loading.
lib/arbiter/arbiter_interstitial_controller.dart:177
- The readiness call is protected, but this actual CloudX show invocation is not. If the listener claim or
showInterstitialplatform call throws synchronously,_isShowingremains true after_nextWinnerwas cleared, so laterload()/show()calls are rejected forever and no failure event reaches the UI. Handle invocation failures here by releasing the show slot and reportingonAdShowFailed, just as the readiness failure path does.
pubspec.yaml:25 - The PR description still refers to
cloudx_flutter3.8.0 and says the dev block should be reverted to^3.8.0, while this checked-in manifest and lockfile consistently resolve 3.9.0. Once the local host is removed, following the stated revert would not reproduce this dependency graph; update the description/revert instructions or align the manifest and lockfile.
- Files reviewed: 46/74 changed files
- Comments generated: 0 new
- Review effort level: Lite
The README already promised this split and did not keep it. Its file table
called the controller "The integration" and main.dart "not part of the
integration", but three SDK calls lived in the screen's State: setMinLogLevel,
MobileAds.instance.initialize and CloudX.initialize. Those three are the
ordering a publisher most needs, and they were buried between setState calls.
lib/cloudx/ now holds every file that touches an SDK and lib/ui/ holds the
screen; main.dart is runApp and nothing else. The moves are pure renames. The
one piece of new code is sdk_startup.dart, which lifts the body of
_ArbiterScreenState._start() into a plain class with no setState and no mounted
checks, and states why ATT has to be answered before CloudX.initialize: CloudX
reads the tracking status at init and never asks for it, so initializing first
sends every request that session without an IDFA and with dnt = 1.
Following the public Unity and React Native demos, which both settled on this
shape: one directory is the copyable unit, the README names the copy list and
says what breaks if you take fewer, and each file repeats that instruction in
its own header so a file lifted out alone still says what it needs.
The boundary is checkable rather than asserted. No CloudX or AdMob call exists
outside lib/cloudx/, and nothing in lib/cloudx/ imports material.dart or builds
a widget. Two files keep a Flutter import for non-UI reasons: tracking_gate.dart
waits on AppLifecycleListener because iOS refuses to present the prompt while
the app is still becoming active, and the controller uses debugPrint. Both are
stated in their headers. Dropping the ATT package from the screen's imports was
the analyzer's own evidence that the split took.
Verified on an emulator and a simulator booted for this run, on both platforms:
ATT authorized, CloudX initialized, AdMob ready, two bids with an ADMOB winner,
the interstitial shown and closed, and 0.000000 USD reported (returned true).
A first Android attempt showed "initialization failed" from HTTP 503
{"error":"server draining"} on the CloudX init endpoint, which is server-side
and unrelated; it cleared on retry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XQvJAmT4AgvqCdgQECTjcV
There was a problem hiding this comment.
🟡 Changes recommended
Three moderate issues remain in startup error handling, lifecycle handling, and refused-state UI reporting; two documentation nits also remain.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Files not reviewed (2)
- ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Generated file
- ios/Runner.xcworkspace/contents.xcworkspacedata: Generated file
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
lib/cloudx/sdk_startup.dart:89
- This doc comment links to
canLoadAds, butSdkStartupResulthas no such member, so generated API docs point readers to a nonexistent contract. Describe the actual conditions instead of linking an undefined property.
README.md:55
- These instructions name the wrong files to drop. From the five-file list above, an app that already initializes CloudX and handles ATT should remove
sdk_startup.dartandtracking_gate.dartbut retaindemo_config.dart; the current wording tells readers to droptracking_gate.dartanddemo_config.dartwhile keeping startup, so the copied integration is not usable as documented.
**Take fewer and it will not build.** The controller reports through
`arbiter_events.dart`, and `sdk_startup.dart` calls `tracking_gate.dart`. If
your app already initializes CloudX and answers the ATT prompt, drop those last
two and keep the first three.
lib/cloudx/sdk_startup.dart:34
run()documents that it never throws, butTrackingGate.request()performs awaited platform-channel calls here without any error handling. If the ATT plugin reports a native/channel failure,_start()rejects and the screen never receives a startup result or failure status; handle this failure and return/report a failed startup outcome just asCloudX.initializeis handled below.
lib/cloudx/tracking_gate.dart:55- There is a lifecycle race between this state check and registering the listener: if the app resumes in that window, no later
onResumeevent is guaranteed, so_waitUntilActive()can remain pending forever and the demo stays at "requesting tracking permission". Recheckbinding.lifecycleStateafter installing the listener and complete the completer when it is already resumed.
- Files reviewed: 48/76 changed files
- Comments generated: 1
- Review effort level: Lite
SdkStartup.run returns before it starts Google Mobile Ads when the ATT prompt is refused, but the screen's refusal branch only updated the CloudX row. The Google Mobile Ads row kept its initial "initializing" and sat there for the life of the app, describing an initialization that was never started. It now says so. Verified on the simulator rather than reasoned: simctl privacy reset all, then "Ask App Not to Track" on the prompt. Tracking reads "denied", CloudX reads "tracking not authorized - ads cannot load", Google Mobile Ads reads "not started - tracking not authorized", and both buttons stay disabled. That run also exercises the ATT gate's refusal path, which had not been covered in this session. Fifth finding in this PR of one shape: a status row asserting something the screen does not know. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XQvJAmT4AgvqCdgQECTjcV
There was a problem hiding this comment.
🔵 Needs a closer look
The unresolved moderate startup error-handling finding can leave initialization incomplete.
Review details
Files not reviewed (2)
- ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Generated file
- ios/Runner.xcworkspace/contents.xcworkspacedata: Generated file
Suppressed comments (6)
Previously missed (1) — in code that hasn't changed since the last review.
lib/cloudx/sdk_startup.dart:90
- This doc comment links to
canLoadAds, butSdkStartupResulthas no such member and the symbol does not exist elsewhere in the project. The generated API documentation is therefore broken and the comment suggests a gating API that this result does not expose; describecloudXInitializeddirectly instead.
README.md:165
- This table says both platform rows show
loaded: <network> $<price>, but the AdMob loaded callback passes only_adMobNetworkName(arbiter_interstitial_controller.dart:380); no AdMob price is displayed. Please document the two formats accurately so readers do not expect a value that the screen cannot show.
| `CloudX` / `AdMob` | That side's last event: `loading`, `loaded: <network> $<price>`, `load failed: ...`, `showing`, `closed`. |
README.md:34
- The README says to expect CloudX to lose with these test units, but the validation described for this PR reports an
ADMOBwinner with the checked-in units. That makes the demo guidance misleading; describe the zero revenue as meaning there is no learned AdMob price, without predicting which bid wins.
Which is why the AdMob ids checked in here make a poor price demo: Google's test
units report a revenue of 0.0, the price store drops any revenue of 0.0, and the
AdMob bid therefore reaches the arbiter with no price at all. Expect CloudX to
lose those rounds. Point the demo at a real AdMob unit that pays to see prices
compete.
README.md:55
- This copy instruction says an app can drop
sdk_startup.dartafter only initializing CloudX and answering ATT, but that file also initializes Google Mobile Ads. Following the documented recipe leaves AdMob uninitialized beforeInterstitialAd.load()is used; require both SDKs to be initialized, or tell the reader to retain/replace the AdMob initialization.
**Take fewer and it will not build.** The controller reports through
`arbiter_events.dart`, and `sdk_startup.dart` calls `tracking_gate.dart`. If
your app already initializes CloudX and answers the ATT prompt, drop those last
two and keep the first three.
README.md:50
- The PR description's “What is in the diff” section still points at
lib/arbiter/...,lib/config/...,lib/tracking_gate.dart, and callslib/main.dartthe demo screen, but the checked-in files are underlib/cloudx/andlib/ui/(main.dartis only the entry point). Please update the description so its file map matches the code reviewers and users will actually find.
| [`lib/cloudx/arbiter_interstitial_controller.dart`](lib/cloudx/arbiter_interstitial_controller.dart) | The integration. The whole load/arbitrate/show cycle and both SDKs' calls, in one file. |
| [`lib/cloudx/arbiter_events.dart`](lib/cloudx/arbiter_events.dart) | The callbacks the controller reports through. |
| [`lib/cloudx/sdk_startup.dart`](lib/cloudx/sdk_startup.dart) | Brings both SDKs up, in the order they have to come up in. |
| [`lib/cloudx/tracking_gate.dart`](lib/cloudx/tracking_gate.dart) | The iOS App Tracking Transparency gate. |
| [`lib/cloudx/demo_config.dart`](lib/cloudx/demo_config.dart) | App key and ad unit ids, per platform. The first file to edit. |
| [`lib/ui/arbiter_screen.dart`](lib/ui/arbiter_screen.dart) | Demo-only UI. Ignore it when reading the integration. |
| [`lib/main.dart`](lib/main.dart) | `runApp`, nothing else. |
lib/cloudx/sdk_startup.dart:34
SdkStartup.run()promises never to throw, butTrackingGate.request()is the only startup await still outside a handler. Either ATT platform-channel call can reject (for example if the plugin/native API is unavailable); that rejection escapes_start()and leaves the screen's initial statuses forever. Catch it and return a refused/failed result before starting either SDK.
- Files reviewed: 48/76 changed files
- Comments generated: 0 new
- Review effort level: Lite
Two things the SDK gained in 3.9.0 reach the screen here. CloudX.initialize now returns a result instead of a nullable configuration, so a failed start carries the SDK's own error code, its name and a message rather than being a bare null. The load and display failures carry the same name through CloudXError.codeName. Both are formatted in one new file, cloudx_failure_text.dart, so the two paths cannot drift: a round that did not fill now reads "No ad available. (NO_FILL[302])" and a rejected app key reads "... (NETWORK_CLIENT_ERROR[103])" instead of a bare number. The message is trimmed because the Android initialize failure arrives with a trailing newline, which otherwise pushed the code onto its own line. io.cloudx:adapter-bigo:6.0.1.0 joins the Android adapter set, which the README describes as the full CloudX set and which was missing it. The adapter needs cleartext traffic to 127.0.0.1, because the BIGO Ads SDK serves some creative assets from a loopback server on the device, so res/xml/network_security_config.xml and the manifest reference go with it. BIGO is Android only; there is no iOS adapter, which is why the Gradle file now lists one network more than the Podfile. Verified on a Pixel 10 Pro, an emulator and an iOS simulator: two bids per round with ADMOB winning, and forced failures showing the named codes on both platforms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XQvJAmT4AgvqCdgQECTjcV
There was a problem hiding this comment.
🟡 Changes recommended
CloudX initialization and ATT failures can still escape the startup error handling and leave the demo stuck.
Get a fresh assessment by requesting another Copilot review.
Review details
Files not reviewed (2)
- ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Generated file
- ios/Runner.xcworkspace/contents.xcworkspacedata: Generated file
Suppressed comments (2)
README.md:57
- This copy guidance only mentions an existing CloudX initialization and ATT prompt, but
sdk_startup.dartalso initializes Google Mobile Ads. A consumer following these instructions can omitMobileAds.instance.initialize()when removing the startup helper; include Google Mobile Ads in the prerequisite.
If your app already initializes CloudX and answers the ATT prompt, drop
`sdk_startup.dart` and `tracking_gate.dart` and keep the rest.
lib/cloudx/sdk_startup.dart:37
- The same
never throwscontract is still violated byTrackingGate.request(): it awaits platform-channel status/prompt calls without handling rejection. If ATT fails before returning a status,_start()aborts and the screen remains atrequesting tracking permission; convert this to a refused/failed startup result (and log it) instead of letting the future escape.
- Files reviewed: 50/78 changed files
- Comments generated: 1
- Review effort level: Lite
Two findings from a line-by-line pass over this PR. The instruction for undoing the dev setting was wrong in two ways, and both were checked rather than reasoned. `git revert 833a3e6` does not apply: it conflicts on pubspec.yaml and modify/deletes pubspec.lock, because two later commits rewrote the lock. And its parent is not the state to return to - that commit declares `cloudx_flutter: ^3.8.0` and the pre-review Dart and Flutter floors, so taking it would undo the pin bump and the floor correction that came out of the first Copilot round. The lock is the part that actually matters: it still records `url: "http://127.0.0.1:8080"` with a sha256 only the local host serves, so it has to be regenerated whatever happens to the manifest. The comment now says to edit the block and re-resolve. The README called banners "a different (inline) flow". Inline is what the SDK calls CloudXAdView, which 3.9.0 leaves unexported, so that sent a reader looking for an API that is not public. Banners are described by what they are instead: a view createBanner places at a fixed screen position. flutter analyze clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XQvJAmT4AgvqCdgQECTjcV
There was a problem hiding this comment.
🔵 Needs a closer look
Two moderate startup error-handling issues remain unresolved, and the failure-code documentation nit needs clarification.
Review details
Files not reviewed (2)
- ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Generated file
- ios/Runner.xcworkspace/contents.xcworkspacedata: Generated file
Suppressed comments (3)
lib/cloudx/cloudx_failure_text.dart:10
- This copy-facing example conflicts with the demonstrated bad-app-key behavior in the PR description, which reports
NETWORK_CLIENT_ERROR[103]for the Android initialize path. Either use the observed code here or explicitly explain the platform/error-path distinction; otherwise readers can be led to diagnose a different failure code than the demo displays.
lib/cloudx/sdk_startup.dart:37 run()promises never to throw, butTrackingGate.request()is awaited outside any error handling. A plugin/platform-channel failure while reading or requesting ATT will rejectrun()and_start()does not catch it, leaving the startup rows at their initial values and producing an unhandled async error. Catch this path and return/report an explicit startup failure (or handle it in the caller).
lib/cloudx/sdk_startup.dart:66SdkStartup.runpromises never to throw, but thisCloudX.initializeawait is still outside atry/catch. A native/platform-channel exception rejects_start()before it can update either SDK status, leaving the screen stuck at its initial rows and violating the startup result contract. Catch the exception and return a failedSdkStartupResult(or otherwise surface it as an initialization failure).
- Files reviewed: 50/78 changed files
- Comments generated: 0 new
- Review effort level: Lite
Taking the public Unity demo's lead on both. That README carries six screenshots out of docs/images/, each one next to the section that explains it. This one had none, so a publisher deciding whether to read further had nothing to look at. Three are added the same way: the ATT prompt beside the step about it, and the settled screen plus the interstitial it shows beside the table that explains the rows. The ATT one earns its place by showing the ordering rule rather than the prompt - CloudX SDK still reads "not initialized" behind it, which is the whole reason tracking_gate.dart exists. The signing note was filed under Versions, three lines after a paragraph about Dart and Flutter SDK floors. The Unity demo groups app key, bundle id and signing team together under "Using your own CloudX app", because those are the three things a publisher changes to make the demo theirs. Signing is now step 5 of Required setup, which is where someone hits it, at their first device build. Screenshots are from this branch's head on an emulator and a simulator I booted; 244 KB for all three. flutter analyze clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XQvJAmT4AgvqCdgQECTjcV
There was a problem hiding this comment.
🟡 Changes recommended
The ATT lifecycle race and retry-time winner replacement remain unresolved, and the platform-specific failure-code example needs clarification.
Get a fresh assessment by requesting another Copilot review.
Review details
Files not reviewed (2)
- ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Generated file
- ios/Runner.xcworkspace/contents.xcworkspacedata: Generated file
Suppressed comments (1)
lib/cloudx/arbiter_interstitial_controller.dart:88
load()does not guard an already prepared winner. If one side failed and the other produced_nextWinner, a caller that retriesload()before showing starts the missing side; when it fills,_handleCloudXLoaded/the AdMob callback calls_maybePrepareWinner()and can replace the winner that was ready to show. The demo UI currently prevents this, but the copied controller exposesload()publicly, so keep the prepared-winner state immutable untilshow()consumes it.
- Files reviewed: 50/81 changed files
- Comments generated: 2
- Review effort level: Lite
Copilot, round 3, and it caught the repository disagreeing with itself. cloudx_failure_text.dart offered "a rejected app key says INVALID_APP_KEY[203]" as its worked example. The PR body's own validation section records the opposite from a deliberately broken key: NETWORK_CLIENT_ERROR[103], because what failed was the HTTP response rather than a key the SDK recognised and rejected. 203 is a real constant, so the name is not invented, but nothing in this demo produces it and a reader would wait for a code that never comes. The example now uses the two codes the forced-failure runs produced on both platforms, NO_FILL[302] and INVALID_AD_UNIT[300], and says outright that choosing the code is the SDK's business: read the name it sent rather than predicting which one a failure will carry. That is the file's whole argument anyway. Also records, in tracking_gate.dart, why reading lifecycleState and then attaching the listener is not the race it looks like. Copilot read it as one; so did I, in an earlier round, before working it out. There is no await between the two statements and Dart only turns the event loop at a suspension point, so no lifecycle message can land in between - and AppLifecycleListener calls onResume unconditionally on the transition to resumed, including from a null previous state, so a first-ever resumed still fires it. Two readers have now had to derive that; a file meant to be copied should say it. No behaviour change. flutter analyze clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XQvJAmT4AgvqCdgQECTjcV
There was a problem hiding this comment.
🔵 Needs a closer look
The broad cross-platform SDK and native-project changes warrant final human review, and a documentation nit remains.
Review details
Files not reviewed (2)
- ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Generated file
- ios/Runner.xcworkspace/contents.xcworkspacedata: Generated file
Suppressed comments (1)
lib/ui/arbiter_screen.dart:57
- This comment says the controller is built once both SDKs are up, but
SdkStartup.run()deliberately returns beforeMobileAds.initialize()completes and only awaits CloudX initialization. The controller can therefore be created while AdMob is still initializing; describe that it is created after CloudX is ready while AdMob initialization continues in parallel, so copied integrations do not infer a stronger ordering than the code provides.
- Files reviewed: 50/81 changed files
- Comments generated: 0 new
- Review effort level: Lite
cloudx_flutter 3.9.0 was published today, so the demo no longer needs the local pub host the dev setting pointed it at. The manifest goes back to a plain version constraint and pubspec.lock records pub.dev with the sha256 of the published archive. Not done by reverting the commit that introduced the dev setting: two later commits rewrote pubspec.lock, so the revert conflicts, and that commit's parent also carries the older ^3.8.0 pin and the pre-review Dart and Flutter floors. The pubspec comment prescribed this path. Verified against a clean room: a fresh clone resolved with an empty PUB_CACHE created only a pub.dev entry and produced a byte-identical lock. The published lib/, android/ and ios/ are identical to the flutter/sdk/3.9.0 tag, and ios/Podfile.lock is unchanged. One full arbiter round ran on an Android emulator and an iOS simulator: two bids, ADMOB won, ad shown, reportRevenueData returned true.
The BIGO network security config declared 127.0.0.1 with no
includeSubdomains attribute, which Android lint reports as an error under
lintVital. That task runs on every release assemble, so
`flutter build apk --release` failed outright:
android/app/src/main/res/xml/network_security_config.xml:12: Error:
Missing includeSubdomains attribute [NetworkSecurityConfig]
The value changes no behaviour: 127.0.0.1 is a literal address with no
subdomains to include, and false is the attribute's default. What matters
to lint is that the choice is explicit. The form here matches the adapter's
own integration note in the monorepo, which has always carried it.
Verified by building the release APK: app-release.apk at 68.5MB.
There was a problem hiding this comment.
🟡 Changes recommended
Resolve the unavailable cloudx_flutter dependency source and preserve a prepared winner when load() is called again.
Get a fresh assessment by requesting another Copilot review.
Review details
Files not reviewed (2)
- ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Generated file
- ios/Runner.xcworkspace/contents.xcworkspacedata: Generated file
Suppressed comments (3)
README.md:58
SdkStartup.runalso starts Google Mobile Ads viaMobileAds.instance.initialize()(sdk_startup.dart:47), but this copy instruction only says an existing app must initialize CloudX and answer ATT before droppingsdk_startup.dart. Following it can remove the only AdMob initialization while the controller still callsInterstitialAd.load; mention Google Mobile Ads here or require callers to initialize it separately.
If your app already initializes CloudX and answers the ATT prompt, drop
`sdk_startup.dart` and `tracking_gate.dart` and keep the rest.
README.md:127
- This sentence reverses the ordering stated immediately before it: the example correctly says the ATT prompt must be answered before CloudX initialization, but then says “initialize first” without making clear that this is the mistake being warned against. A reader can follow that as an instruction and initialize while ATT is still not determined, causing the exact IDFA/dnt behavior this setup avoids; phrase it as a conditional warning.
The prompt is answered while `CloudX SDK` still reads `not initialized`. That
order is the point: initialize first and every request that session goes out
without an IDFA and with `dnt = 1`.
lib/cloudx/arbiter_interstitial_controller.dart:88
load()is still callable while a winner is prepared. In the one-sided-fill case, arbitration can store a CloudX winner while the AdMob side is null; callingload()then starts AdMob, and its callback invokes_maybePrepareWinner(), which can overwrite or clear the existing winner. The UI disables this path, but the public controller contract should preserve a prepared winner for callers that invokeload()directly; return early when_nextWinner != null(or otherwise isolate a new round).
- Files reviewed: 50/81 changed files
- Comments generated: 1
- Review effort level: Lite
| flutter: | ||
| sdk: flutter | ||
|
|
||
| cloudx_flutter: ^3.9.0 |
Summary
The first contents of this repository: a single-screen Trusted Arbiter demo for interstitials, with CloudX and AdMob loading in parallel, both fills becoming bids,
CloudX.arbiterpicking the winner, and the winner shown from a stored result so the show path makes no network call.Interstitials only. Rewarded is the same flow plus one reward callback, and banners are a different shape entirely, so neither is repeated here.
78 files, of which most are the Flutter and Xcode scaffolding
flutter createproduced. The demo itself is seven Dart files.What a reader is meant to copy
Everything under
lib/cloudx/. None of those files builds a widget, and that is checkable:grep -rn "CloudX\.\|MobileAds\." lib/ui lib/main.dartreturns only two strings printed on screen.lib/cloudx/arbiter_interstitial_controller.dartlib/cloudx/arbiter_events.dartlib/cloudx/sdk_startup.dartlib/cloudx/tracking_gate.dartlib/cloudx/cloudx_failure_text.dartlib/cloudx/demo_config.dartlib/ui/arbiter_screen.dartDecisions worth reviewing
google_mobile_adsfrom matching that adapter's exact Google Mobile Ads pin, which is why this app runsgoogle_mobile_ads9.1.0 while the internal demo is held at 7.0.0.io.cloudx:adapter-bigo:6.0.1.0is inandroid/app/build.gradle.ktsand has no iOS counterpart, so the Gradle file lists one network more than the Podfile. It also needs cleartext traffic to127.0.0.1, because the BIGO Ads SDK serves some creative assets from a loopback server on the device; that isandroid/app/src/main/res/xml/network_security_config.xmland theandroid:networkSecurityConfigattribute on<application>.io.cloudx.sampleidentity, matching the public Unity and React Native demos, so all three public demos are one dashboard app.ArbiterConfig.ForceCloudXNoFill; this one uses real units only.Failure lines carry the error code name
Since
cloudx_flutter3.9.0 bothCloudXError.codeNameandCloudXInitializationResult.errorCodeNamecarry the native SDK's own name for the code, read fromCloudXErrorCode.nameon Android and+[CLXError nameForCode:]on iOS.cloudx_failure_text.dartformats both paths so they cannot drift: a round that did not fill readsNo ad available. (NO_FILL[302]), and a rejected app key reads... (NETWORK_CLIENT_ERROR[103])rather than a bare number. The message is trimmed, because the Android initialize failure arrives with a trailing newline that otherwise pushed the code onto a line of its own.The dev-setting commit is resolved
Earlier revisions of this branch resolved
cloudx_flutterfrom a local pub host, because 3.9.0 was not published and the standing decision was that it never would be. Both of those have changed:cloudx_flutter3.9.0 went to pub.dev on 2026-09-13, and commit9917bd5puts the manifest back to a plaincloudx_flutter: ^3.9.0withpubspec.lockrecordinghttps://pub.devand the sha256 of the published archive. Anyone canflutter pub getthis branch now, andpreflight-hermetic.sh --releasepasses rather than failing by design.It was not undone with
git revert 833a3e6. That revert conflicts, becausepubspec.lockwas rewritten twice after it, and its parent also carries the older^3.8.0pin and the pre-review Dart and Flutter floors that the first review round corrected. The manifest was edited and the lock regenerated, which is the patha7de163documented inline.What the loop review changed
Three rounds of Copilot plus a line-by-line pass and a loop check.
a7de163git revert 833a3e6conflicts on both files, and its parent carries^3.8.0and the pre-review SDK floors, so taking it would undo an accepted fix. Also stopped the README calling banners an "inline" flow, which is the SDK's word for the now-unexportedCloudXAdView.6f92fe1docs/images/beside the sections that explain them, and the iOS signing note moved from Versions into Required setup with the app key and bundle id, which is how that demo groups the three things a publisher changes.bee0111cloudx_failure_text.dartofferedINVALID_APP_KEY[203]as its worked example while this PR's own validation recordsNETWORK_CLIENT_ERROR[103]for a rejected key. Now quotes codes this demo actually produces. Also records whytracking_gate.dart's check-then-listen is not the race two readers took it for.9917bd5cloudx_flutterfrom pub.dev now that 3.9.0 is published.7cdf0e2flutter build apk --releasecould not build at all: the BIGO network security config declared127.0.0.1with noincludeSubdomainsattribute, which Android lint reports as an error underlintVital, and that task runs on every release assemble. The attribute changes no behaviour (a literal address has no subdomains, andfalseis the default); lint only requires the choice to be explicit. The form now matchesandroid/adapter-bigo/README.mdin the monorepo, which has always carried it.Left open for a human: the
tracking_gate.dartthread, where the reviewer's race does not exist (noawaitbetween the read and the registration, andAppLifecycleListenerfiresonResumeunconditionally on the transition to resumed) but the invariant was worth writing down.One flag from comparing against the sibling public demos: both the public Unity and React Native demos ship First Look while this one ships Trusted Arbiter, and Flutter is the only one of five platforms with no
integrations/first-look.mdx. The earlier flag abouthttps://docs.cloudx.io/en/flutter/trusted-arbiterreturning 404 is closed:cloudx-io/docs#434merged on 2026-09-13 and that page is live.Validation
Both platforms were run, not just built.
flutter analyze— no issuesArbiter enabled: https://sdk.cloudx.io/arbitrationin the logs, both sides filling (CloudXtestbidder $0.0005, AdMob reservation campaign),arbiter: 2 bid(s)thenplatform=ADMOB, the AdMob interstitial displayed and closed,reportRevenueData(0.0 micros USD, PrecisionType.unknown) returned=true. On the Pixel the BIGO adapter is discovered at startup:network=Bigo Ads, adapterVersion=6.0.1.0, minimumSdkVersion=4.7.0.INVALID_AD_UNIT[300]on both,NETWORK_CLIENT_ERROR[103]for the Android initialize path. Those edits were reverted;git diff lib/cloudx/demo_config.dartis empty.qa/sdk/scripts/flutter/preflight-hermetic.sh --app <this repo> --release— exit 0, resolvedhostedfromhttps://pub.devat 3.9.0, no path dependency into the monorepo.Re-verified against the published package
After the switch to pub.dev, the whole thing was run again from a clean room: a fresh clone of this branch resolved with an empty
PUB_CACHE, which created only apub.deventry and produced a byte-identicalpubspec.lock. The publishedlib/,android/andios/are identical to theflutter/sdk/3.9.0tag bydiff -r, the package's own pins areCloudXCore ~> 3.9.0andio.cloudx:sdk:4.7.0, andios/Podfile.lockwas unchanged bypod install. One full arbiter round ran on an Android emulator and an iOS simulator booted for the run: two bids, ADMOB won, the interstitial shown and closed,reportRevenueData ... returned=trueon both.flutter build apk --debug— built; the APK carries the BIGO resources and adapter classes, andres/xml/network_security_config.xmlsurvives manifest merging.flutter build apk --release— failed before7cdf0e2on the lint error above, and now producesapp-release.apkat 68.5MB.