diff --git a/src/components/settingsPage.scss b/src/components/settingsPage.scss index bfe722e73e..e0e9f34b55 100644 --- a/src/components/settingsPage.scss +++ b/src/components/settingsPage.scss @@ -60,6 +60,11 @@ wc-page.main-settings-page { .main-settings-list>.list-item, .settings-section-card>.list-item { display: flex; + + &[hidden] { + display: none; + } + width: 100%; min-height: 64px; margin: 0; diff --git a/src/lib/adConsentCoordinator.mjs b/src/lib/adConsentCoordinator.mjs index 5a23de1737..4391e22aac 100644 --- a/src/lib/adConsentCoordinator.mjs +++ b/src/lib/adConsentCoordinator.mjs @@ -35,6 +35,7 @@ export class AdConsentCoordinator { #state = EMPTY_PRIVACY_STATE; #consentPromise; #adStartPromise; + #privacyOptionsPromise; #listeners = new Set(); constructor({ privacy, initializeAds, onError = console.error }) { @@ -51,7 +52,14 @@ export class AdConsentCoordinator { return (this.#consentPromise ??= this.#gatherAndStart()); } - async showPrivacyOptions() { + showPrivacyOptions() { + this.#privacyOptionsPromise ??= this.#showPrivacyOptions().finally(() => { + this.#privacyOptionsPromise = undefined; + }); + return this.#privacyOptionsPromise; + } + + async #showPrivacyOptions() { const state = await this.#privacy.showOptions(); this.#setState(state); await this.#startAdsIfAllowed(); diff --git a/src/lib/bannerVisibilityController.mjs b/src/lib/bannerVisibilityController.mjs index c02df5b80b..517e914d02 100644 --- a/src/lib/bannerVisibilityController.mjs +++ b/src/lib/bannerVisibilityController.mjs @@ -10,6 +10,9 @@ export const BANNER_SUPPRESSION_REASON = Object.freeze({ REWARDED_PASS: "rewarded-pass", }); +const DEFAULT_RETRY_DELAY_MS = 500; +const TRANSIENT_LOAD_ERROR_CODES = new Set([0, 2, 3, 9]); + export class BannerVisibilityController { #banner = null; #registeredPages = new WeakSet(); @@ -20,9 +23,13 @@ export class BannerVisibilityController { #suppressions = new Set(); #nativeVisible = false; #scheduledVisible = false; + #desiredVisible = false; #operation = Promise.resolve(); #revision = 0; #onError; + #retryTimer = null; + #retryUsed = false; + #stopBannerListeners = []; constructor({ getActivePage, @@ -35,9 +42,13 @@ export class BannerVisibilityController { } setBanner(banner) { + this.#stopListeningToBanner(); + this.#resetRetry(); + this.#revision++; this.#banner = banner; this.#nativeVisible = false; this.#scheduledVisible = false; + this.#listenToBanner(banner); this.reconcile(); } @@ -83,6 +94,12 @@ export class BannerVisibilityController { activePage !== null && this.#registeredPages.has(activePage); const shouldShow = pageRequestsBanner && !this.#keyboardVisible; + const eligibilityChanged = shouldShow !== this.#desiredVisible; + this.#desiredVisible = shouldShow; + + if (!shouldShow || eligibilityChanged) { + this.#resetRetry(); + } if (this.#banner) { this.#banner.active = pageRequestsBanner; @@ -98,7 +115,12 @@ export class BannerVisibilityController { dispose() { this.#stopObserving?.(); this.#stopObserving = null; + this.#stopListeningToBanner(); + this.#resetRetry(); this.#banner = null; + this.#desiredVisible = false; + this.#nativeVisible = false; + this.#scheduledVisible = false; this.#revision++; } @@ -107,9 +129,16 @@ export class BannerVisibilityController { this.#stopObserving = this.#observePageChanges(() => this.reconcile()); } - #queueNativeVisibility(shouldShow) { + #queueNativeVisibility(shouldShow, isRetry = false) { const banner = this.#banner; - if (!banner || shouldShow === this.#scheduledVisible) return; + if ( + !banner || + shouldShow === this.#scheduledVisible || + (shouldShow && + (this.#retryTimer !== null || (this.#retryUsed && !isRetry))) + ) { + return; + } this.#scheduledVisible = shouldShow; const revision = ++this.#revision; @@ -124,14 +153,80 @@ export class BannerVisibilityController { } else { await banner.hide?.(); } + if (revision !== this.#revision || banner !== this.#banner) return; this.#nativeVisible = shouldShow; } catch (error) { + if (revision !== this.#revision || banner !== this.#banner) return; this.#nativeVisible = !shouldShow; this.#scheduledVisible = !shouldShow; this.#onError(error); + if (shouldShow) this.#scheduleRetry(); } }); } + + #listenToBanner(banner) { + if (typeof banner?.on !== "function") return; + + for (const [eventName, listener] of [ + ["load", () => this.#handleBannerLoad()], + ["loadfail", (event) => this.#handleBannerLoadFailure(event)], + ]) { + const stopListening = banner.on(eventName, listener); + if (typeof stopListening === "function") { + this.#stopBannerListeners.push(stopListening); + } + } + } + + #stopListeningToBanner() { + for (const stopListening of this.#stopBannerListeners.splice(0)) { + stopListening(); + } + } + + #handleBannerLoad() { + this.#resetRetry(); + if (!this.#desiredVisible) return; + + this.#nativeVisible = true; + this.#scheduledVisible = true; + } + + #handleBannerLoadFailure(event) { + this.#nativeVisible = false; + this.#scheduledVisible = false; + this.#onError(event); + + const errorCode = Number(event?.code); + if (TRANSIENT_LOAD_ERROR_CODES.has(errorCode)) { + this.#scheduleRetry(); + } else { + this.#retryUsed = true; + } + } + + #scheduleRetry() { + if (!this.#desiredVisible || this.#retryTimer !== null || this.#retryUsed) { + return; + } + + this.#retryUsed = true; + const banner = this.#banner; + this.#retryTimer = setTimeout(() => { + this.#retryTimer = null; + if (!this.#desiredVisible || banner !== this.#banner) return; + this.#queueNativeVisibility(true, true); + }, DEFAULT_RETRY_DELAY_MS); + } + + #resetRetry() { + if (this.#retryTimer !== null) { + clearTimeout(this.#retryTimer); + this.#retryTimer = null; + } + this.#retryUsed = false; + } } function getActivePage() { diff --git a/src/lib/startAd.js b/src/lib/startAd.js index a9dd6a74d5..757dc69429 100644 --- a/src/lib/startAd.js +++ b/src/lib/startAd.js @@ -63,7 +63,9 @@ export function subscribePrivacyState(listener) { } export async function showPrivacyOptions() { - if (!canUseAdmob()) return getPrivacyState(); + if (!canUseAdmob()) { + throw new Error("AdMob Privacy Choices are unavailable."); + } return getConsentCoordinator().showPrivacyOptions(); } diff --git a/src/plugins/admob/src/android/cordova/Privacy.kt b/src/plugins/admob/src/android/cordova/Privacy.kt index 0d34ccf3c6..b2c2264a53 100644 --- a/src/plugins/admob/src/android/cordova/Privacy.kt +++ b/src/plugins/admob/src/android/cordova/Privacy.kt @@ -80,12 +80,26 @@ internal class Privacy(private val plugin: AdMob) { fun showOptions(ctx: ExecuteContext) { plugin.activity.runOnUiThread { - UserMessagingPlatform.showPrivacyOptionsForm(plugin.activity) { formError -> - if (formError != null) { - ctx.reject("UMP ${formError.errorCode}: ${formError.message}") - } else { - ctx.resolve(currentState().toMap()) + val activity = plugin.activity + val state = currentState() + if (!state.privacyOptionsRequired) { + return@runOnUiThread ctx.resolve(state.toMap()) + } + + if (activity.isFinishing || activity.isDestroyed) { + return@runOnUiThread ctx.reject("Privacy Choices are temporarily unavailable") + } + + try { + UserMessagingPlatform.showPrivacyOptionsForm(activity) { formError -> + if (formError != null) { + ctx.reject("UMP ${formError.errorCode}: ${formError.message}") + } else { + ctx.resolve(currentState().toMap()) + } } + } catch (error: Exception) { + ctx.reject(error.message ?: "Unable to open Privacy Choices") } } } diff --git a/src/settings/mainSettings.js b/src/settings/mainSettings.js index 2946f57c72..7ce6cf196b 100644 --- a/src/settings/mainSettings.js +++ b/src/settings/mainSettings.js @@ -251,10 +251,19 @@ export default function mainSettings() { break; case "privacyChoices": + loader.create( + strings["privacy choices"] || "Privacy Choices", + strings["loading..."] || "Loading...", + ); try { await showPrivacyOptions(); } catch (error) { - helpers.error(error); + console.warn("Unable to open AdMob Privacy Choices:", error); + helpers.error( + "Unable to open Privacy Choices. Check your connection and try again.", + ); + } finally { + loader.destroy(); } break; diff --git a/src/sidebarApps/extensions/index.js b/src/sidebarApps/extensions/index.js index e83360dcf8..72ed4378e7 100644 --- a/src/sidebarApps/extensions/index.js +++ b/src/sidebarApps/extensions/index.js @@ -836,11 +836,14 @@ function ListItem({ }); const { default: installPlugin } = await import("lib/installPlugin"); - await installPlugin( - id, - remotePlugin.name, - purchaseToken ? purchaseToken : undefined, - ); + await Promise.all([ + loadAd(), + installPlugin( + id, + remotePlugin.name, + purchaseToken ? purchaseToken : undefined, + ), + ]); const searchInput = container.querySelector('input[name="search-ext"]'); if (searchInput) { @@ -862,6 +865,7 @@ function ListItem({ if (!$installed.collapsed) { $installed.ontoggle(); } + await helpers.showInterstitialIfReady(); async function getPurchase(sku) { const purchases = await helpers.promisify(iap.getPurchases); @@ -910,14 +914,11 @@ function ListItem({ return $el; } -async function loadAd(el) { +async function loadAd() { if (!helpers.canShowAds()) return; try { if (!(await interstitialAd?.isLoaded())) { - const oldText = el.textContent; - el.textContent = strings["loading..."]; await interstitialAd?.load(); - el.textContent = oldText; } } catch (error) { console.error(error); @@ -929,7 +930,7 @@ async function uninstall(id) { const pluginDir = Url.join(PLUGIN_DIR, id); const state = await InstallState.new(id); await Promise.all([ - loadAd(this), + loadAd(), fsOperation(pluginDir).delete(), state.delete(state.storeUrl), ]); diff --git a/tests/admob/adConsentCoordinator.test.js b/tests/admob/adConsentCoordinator.test.js index cb4fe07c70..ecd5a2f7fc 100644 --- a/tests/admob/adConsentCoordinator.test.js +++ b/tests/admob/adConsentCoordinator.test.js @@ -9,6 +9,7 @@ function createHarness({ gatherState, previousState = EMPTY_PRIVACY_STATE, gatherError, + showOptions, }) { let gatherCalls = 0; let getStateCalls = 0; @@ -25,6 +26,7 @@ function createHarness({ return previousState; }, async showOptions() { + if (showOptions) return showOptions(); return gatherState; }, }; @@ -141,12 +143,12 @@ test("deduplicates consent collection and ad initialization", async () => { assert.equal(harness.initializeCalls, 1); }); -test("updates privacy-option visibility and keeps ads single-started", async () => { +test("hides a stale privacy-option entry and keeps ads single-started", async () => { const harness = createHarness({ gatherState: { - consentStatus: "notRequired", + consentStatus: "obtained", canRequestAds: true, - privacyOptionsRequired: false, + privacyOptionsRequired: true, }, }); const states = []; @@ -154,15 +156,15 @@ test("updates privacy-option visibility and keeps ads single-started", async () await harness.coordinator.start(); harness.privacy.showOptions = async () => ({ - consentStatus: "obtained", + consentStatus: "notRequired", canRequestAds: true, - privacyOptionsRequired: true, + privacyOptionsRequired: false, }); await harness.coordinator.showPrivacyOptions(); unsubscribe(); assert.equal(harness.initializeCalls, 1); - assert.equal(states.at(-1).privacyOptionsRequired, true); + assert.equal(states.at(-1).privacyOptionsRequired, false); }); test("normalizes an invalid native state instead of starting ads", async () => { @@ -177,3 +179,62 @@ test("normalizes an invalid native state instead of starting ads", async () => { assert.deepEqual(await harness.coordinator.start(), EMPTY_PRIVACY_STATE); assert.equal(harness.initializeCalls, 0); }); + +test("deduplicates concurrent privacy-option requests", async () => { + let resolveOptions; + let showOptionsCalls = 0; + const optionsResult = new Promise((resolve) => { + resolveOptions = resolve; + }); + const harness = createHarness({ + gatherState: { + consentStatus: "obtained", + canRequestAds: true, + privacyOptionsRequired: true, + }, + showOptions() { + showOptionsCalls++; + return optionsResult; + }, + }); + await harness.coordinator.start(); + + const firstRequest = harness.coordinator.showPrivacyOptions(); + const secondRequest = harness.coordinator.showPrivacyOptions(); + assert.equal(firstRequest, secondRequest); + resolveOptions({ + consentStatus: "obtained", + canRequestAds: true, + privacyOptionsRequired: false, + }); + + assert.deepEqual(await firstRequest, { + consentStatus: "obtained", + canRequestAds: true, + privacyOptionsRequired: false, + }); + assert.equal(showOptionsCalls, 1); +}); + +test("preserves consent state when privacy options reject", async () => { + const gatherState = { + consentStatus: "obtained", + canRequestAds: true, + privacyOptionsRequired: true, + }; + const optionsError = new Error("native failure"); + const harness = createHarness({ + gatherState, + showOptions: async () => { + throw optionsError; + }, + }); + await harness.coordinator.start(); + + await assert.rejects( + harness.coordinator.showPrivacyOptions(), + (error) => error === optionsError, + ); + assert.deepEqual(harness.coordinator.state, gatherState); + assert.equal(harness.initializeCalls, 1); +}); diff --git a/tests/admob/bannerVisibilityController.test.js b/tests/admob/bannerVisibilityController.test.js index cb9d52c347..1ad63d396c 100644 --- a/tests/admob/bannerVisibilityController.test.js +++ b/tests/admob/bannerVisibilityController.test.js @@ -1,21 +1,34 @@ import assert from "node:assert/strict"; -import { test } from "vitest"; +import { afterEach, test, vi } from "vitest"; import { BANNER_SUPPRESSION_REASON, BannerVisibilityController, } from "../../src/lib/bannerVisibilityController.mjs"; -function createHarness() { +function createHarness({ show, hide } = {}) { let activePage = null; let notifyPageChange = () => {}; const calls = []; + const errors = []; + const listeners = new Map(); const banner = { active: false, async show() { calls.push("show"); + return show?.(); }, async hide() { calls.push("hide"); + return hide?.(); + }, + on(eventName, listener) { + let eventListeners = listeners.get(eventName); + if (!eventListeners) { + eventListeners = new Set(); + listeners.set(eventName, eventListeners); + } + eventListeners.add(listener); + return () => eventListeners.delete(listener); }, }; const controller = new BannerVisibilityController({ @@ -27,7 +40,7 @@ function createHarness() { }; }, onError(error) { - throw error; + errors.push(error); }, }); controller.setBanner(banner); @@ -36,6 +49,10 @@ function createHarness() { banner, calls, controller, + emit(eventName, event = {}) { + for (const listener of listeners.get(eventName) ?? []) listener(event); + }, + errors, changePage(page) { activePage = page; notifyPageChange(); @@ -46,6 +63,19 @@ function createHarness() { }; } +async function createEligibleHarness(options) { + const harness = createHarness(options); + const page = {}; + harness.setActivePage(page); + harness.controller.registerPage(page); + await harness.controller.whenIdle(); + return { harness, page }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + test("shows only for a registered active page and ignores repeated syncs", async () => { const harness = createHarness(); const page = {}; @@ -234,3 +264,169 @@ test("serializes an in-flight show before the latest hide request", async () => assert.equal(banner.active, false); assert.deepEqual(calls, ["show", "hide"]); }); + +test("retries one transient failure after 500ms and caps requests at two", async () => { + vi.useFakeTimers(); + const { harness } = await createEligibleHarness(); + + harness.emit("loadfail", { code: 3 }); + harness.emit("loadfail", { code: 3 }); + await vi.advanceTimersByTimeAsync(499); + assert.deepEqual(harness.calls, ["show"]); + await vi.advanceTimersByTimeAsync(1); + await harness.controller.whenIdle(); + + harness.emit("loadfail", { code: 3 }); + harness.controller.reconcile(); + await vi.advanceTimersByTimeAsync(500); + await harness.controller.whenIdle(); + + assert.deepEqual(harness.calls, ["show", "show"]); + assert.equal(harness.errors.length, 3); +}); + +test("successful load restores the retry allowance", async () => { + vi.useFakeTimers(); + const { harness } = await createEligibleHarness(); + + harness.emit("loadfail", { code: 3 }); + await vi.advanceTimersByTimeAsync(500); + await harness.controller.whenIdle(); + harness.emit("load"); + harness.emit("loadfail", { code: 3 }); + await vi.advanceTimersByTimeAsync(500); + await harness.controller.whenIdle(); + + assert.deepEqual(harness.calls, ["show", "show", "show"]); +}); + +test("cancels a retry on navigation and restores it when the page returns", async () => { + vi.useFakeTimers(); + const { harness, page } = await createEligibleHarness(); + + harness.emit("loadfail", { code: 3 }); + harness.changePage({}); + await harness.controller.whenIdle(); + await vi.advanceTimersByTimeAsync(500); + assert.deepEqual(harness.calls, ["show"]); + + harness.changePage(page); + await harness.controller.whenIdle(); + harness.emit("loadfail", { code: 3 }); + await vi.advanceTimersByTimeAsync(500); + await harness.controller.whenIdle(); + + assert.deepEqual(harness.calls, ["show", "show", "show"]); +}); + +test("does not retry non-transient load failures", async () => { + vi.useFakeTimers(); + const { harness } = await createEligibleHarness(); + + harness.emit("loadfail", { code: 1 }); + harness.controller.reconcile(); + await vi.advanceTimersByTimeAsync(500); + await harness.controller.whenIdle(); + + assert.deepEqual(harness.calls, ["show"]); +}); + +test("retries a rejected native show call", async () => { + vi.useFakeTimers(); + let showCalls = 0; + const { harness } = await createEligibleHarness({ + show() { + showCalls++; + if (showCalls === 1) throw new Error("bridge failure"); + }, + }); + + await vi.advanceTimersByTimeAsync(500); + await harness.controller.whenIdle(); + + assert.deepEqual(harness.calls, ["show", "show"]); + assert.equal(harness.errors.length, 1); +}); + +test("keeps a rejected hide operation eligible for reconciliation", async () => { + let hideCalls = 0; + const { harness } = await createEligibleHarness({ + hide() { + hideCalls++; + if (hideCalls === 1) throw new Error("bridge failure"); + }, + }); + + harness.controller.setKeyboardVisible(true); + await harness.controller.whenIdle(); + harness.controller.reconcile(); + await harness.controller.whenIdle(); + + assert.deepEqual(harness.calls, ["show", "hide", "hide"]); + assert.equal(harness.errors.length, 1); +}); + +test("replacement and disposal cancel retries and detach listeners", async () => { + vi.useFakeTimers(); + const { harness } = await createEligibleHarness(); + harness.emit("loadfail", { code: 3 }); + + const replacementCalls = []; + const replacementListeners = new Map(); + harness.controller.setBanner({ + active: false, + async hide() { + replacementCalls.push("hide"); + }, + on(eventName, listener) { + replacementListeners.set(eventName, listener); + return () => replacementListeners.delete(eventName); + }, + async show() { + replacementCalls.push("show"); + }, + }); + await harness.controller.whenIdle(); + const errorsBeforeStaleEvent = harness.errors.length; + harness.emit("loadfail", { code: 3 }); + replacementListeners.get("loadfail")({ code: 3 }); + harness.controller.dispose(); + replacementListeners.get("loadfail")?.({ code: 3 }); + await vi.advanceTimersByTimeAsync(500); + + assert.deepEqual(replacementCalls, ["show"]); + assert.equal(harness.errors.length, errorsBeforeStaleEvent + 1); +}); + +test("ignores a stale show rejection after replacing the banner", async () => { + let rejectOldShow; + const harness = createHarness({ + show: () => + new Promise((_, reject) => { + rejectOldShow = reject; + }), + }); + const page = {}; + harness.setActivePage(page); + harness.controller.registerPage(page); + await new Promise((resolve) => setImmediate(resolve)); + + const replacementCalls = []; + harness.controller.setBanner({ + active: false, + async hide() { + replacementCalls.push("hide"); + }, + on() { + return () => {}; + }, + async show() { + replacementCalls.push("show"); + }, + }); + rejectOldShow(new Error("stale bridge failure")); + await harness.controller.whenIdle(); + + assert.deepEqual(replacementCalls, ["show"]); + assert.deepEqual(harness.errors, []); +});