diff --git a/config.xml b/config.xml index d71d196c30..45838a5aa0 100644 --- a/config.xml +++ b/config.xml @@ -93,6 +93,20 @@ + + + + + + + =8.0.0" - } - }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -8830,42 +8815,6 @@ "node": ">=4" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esrecurse/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -10678,17 +10627,17 @@ } }, "node_modules/minimizer-webpack-plugin": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", - "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.10.0.tgz", + "integrity": "sha512-2//60T4S0X1Ug/dqLDOgDaGlZsN1Lv8hf8oB0NOV/KIHSaXlPwXBBIbim79dE2Z7NEs52ES8YHoSv6Lmsk+XNg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", + "@jridgewell/trace-mapping": "^0.3.31", "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "terser": "^5.31.1" + "schema-utils": "^4.3.3", + "terser": "^5.51.0" }, "engines": { "node": ">= 10.13.0" @@ -10704,6 +10653,9 @@ "@minify-html/node": { "optional": true }, + "@napi-rs/image": { + "optional": true + }, "@swc/core": { "optional": true }, @@ -10728,12 +10680,21 @@ "html-minifier-terser": { "optional": true }, + "imagemin": { + "optional": true + }, "lightningcss": { "optional": true }, "postcss": { "optional": true }, + "sharp": { + "optional": true + }, + "svgo": { + "optional": true + }, "uglify-js": { "optional": true } @@ -13036,9 +12997,9 @@ } }, "node_modules/terser": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.50.0.tgz", - "integrity": "sha512-CN9BVxWhgS/hRxtUMjtC2uRWSTcSfQFHMDWma6sKKfIivCD91sM+FOPfvwoaRMqCSrUpe1nv3jDamd9eEQ4y+w==", + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -13699,9 +13660,9 @@ "license": "MIT" }, "node_modules/webpack": { - "version": "5.109.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.2.tgz", - "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", + "version": "5.110.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.3.tgz", + "integrity": "sha512-GuizBzRvo9YPpyoNMf3ag7AzxbaW85qrRSqTha345KyJbAFPt3/cMzBM0h+RWg7SK/7DdzRLINP3LvQ0hvr4hg==", "dev": true, "license": "MIT", "peer": true, @@ -13716,11 +13677,10 @@ "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.24.4", "es-module-lexer": "^2.1.0", - "eslint-scope": "5.1.1", "events": "^3.2.0", "graceful-fs": "^4.2.11", "mime-db": "^1.54.0", - "minimizer-webpack-plugin": "^5.6.1", + "minimizer-webpack-plugin": "^5.7.0", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", diff --git a/res/android/drawable/ic_acode_aurora_pulse_background.xml b/res/android/drawable/ic_acode_aurora_pulse_background.xml index ce30f7f4c3..8a22a85ce9 100644 --- a/res/android/drawable/ic_acode_aurora_pulse_background.xml +++ b/res/android/drawable/ic_acode_aurora_pulse_background.xml @@ -5,7 +5,10 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - + + + @@ -36,4 +39,5 @@ + diff --git a/res/android/drawable/ic_acode_aurora_pulse_foreground.xml b/res/android/drawable/ic_acode_aurora_pulse_foreground.xml index 99dbb87b8d..be024ec1ed 100644 --- a/res/android/drawable/ic_acode_aurora_pulse_foreground.xml +++ b/res/android/drawable/ic_acode_aurora_pulse_foreground.xml @@ -5,13 +5,13 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - + - + diff --git a/res/android/drawable/ic_acode_blueprint_background.xml b/res/android/drawable/ic_acode_blueprint_background.xml index 11b508de04..a5d145a4f7 100644 --- a/res/android/drawable/ic_acode_blueprint_background.xml +++ b/res/android/drawable/ic_acode_blueprint_background.xml @@ -5,7 +5,10 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - + + + @@ -18,4 +21,5 @@ + diff --git a/res/android/drawable/ic_acode_blueprint_foreground.xml b/res/android/drawable/ic_acode_blueprint_foreground.xml index d48af39543..cadd5f8c44 100644 --- a/res/android/drawable/ic_acode_blueprint_foreground.xml +++ b/res/android/drawable/ic_acode_blueprint_foreground.xml @@ -5,13 +5,13 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - + - + diff --git a/res/android/drawable/ic_acode_midnight_circuit_background.xml b/res/android/drawable/ic_acode_midnight_circuit_background.xml index 0ef755aa3b..523498c8a9 100644 --- a/res/android/drawable/ic_acode_midnight_circuit_background.xml +++ b/res/android/drawable/ic_acode_midnight_circuit_background.xml @@ -5,7 +5,10 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - + + + @@ -21,4 +24,5 @@ + diff --git a/res/android/drawable/ic_acode_midnight_circuit_foreground.xml b/res/android/drawable/ic_acode_midnight_circuit_foreground.xml index 42de5c1761..f5a20b48e5 100644 --- a/res/android/drawable/ic_acode_midnight_circuit_foreground.xml +++ b/res/android/drawable/ic_acode_midnight_circuit_foreground.xml @@ -5,13 +5,13 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - + - + diff --git a/res/android/drawable/ic_acode_monochrome.xml b/res/android/drawable/ic_acode_monochrome.xml index 38536a8a9a..7efafe6edf 100644 --- a/res/android/drawable/ic_acode_monochrome.xml +++ b/res/android/drawable/ic_acode_monochrome.xml @@ -5,7 +5,7 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - + diff --git a/res/android/drawable/ic_acode_pixel_party_background.xml b/res/android/drawable/ic_acode_pixel_party_background.xml index 2a5fc569c7..885b6071b2 100644 --- a/res/android/drawable/ic_acode_pixel_party_background.xml +++ b/res/android/drawable/ic_acode_pixel_party_background.xml @@ -5,7 +5,10 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - + + + @@ -18,4 +21,5 @@ + diff --git a/res/android/drawable/ic_acode_pixel_party_foreground.xml b/res/android/drawable/ic_acode_pixel_party_foreground.xml index 2898db06e5..e4de7dcaa6 100644 --- a/res/android/drawable/ic_acode_pixel_party_foreground.xml +++ b/res/android/drawable/ic_acode_pixel_party_foreground.xml @@ -5,19 +5,19 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - + - + - + diff --git a/res/android/drawable/ic_acode_pro_background.xml b/res/android/drawable/ic_acode_pro_background.xml new file mode 100644 index 0000000000..17de912b4f --- /dev/null +++ b/res/android/drawable/ic_acode_pro_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/res/android/drawable/ic_acode_pro_foreground.xml b/res/android/drawable/ic_acode_pro_foreground.xml new file mode 100644 index 0000000000..0681f914bd --- /dev/null +++ b/res/android/drawable/ic_acode_pro_foreground.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/android/drawable/ic_acode_solar_flare_background.xml b/res/android/drawable/ic_acode_solar_flare_background.xml index 6f9589c538..d087b0ef40 100644 --- a/res/android/drawable/ic_acode_solar_flare_background.xml +++ b/res/android/drawable/ic_acode_solar_flare_background.xml @@ -5,7 +5,10 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - + + + @@ -18,4 +21,5 @@ + diff --git a/res/android/drawable/ic_acode_solar_flare_foreground.xml b/res/android/drawable/ic_acode_solar_flare_foreground.xml index 06eeca1ad2..f6dca5d8fe 100644 --- a/res/android/drawable/ic_acode_solar_flare_foreground.xml +++ b/res/android/drawable/ic_acode_solar_flare_foreground.xml @@ -5,13 +5,13 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - + - + diff --git a/res/android/drawable/ic_acode_terminal_glow_background.xml b/res/android/drawable/ic_acode_terminal_glow_background.xml index afd79b72b2..36c8d69d38 100644 --- a/res/android/drawable/ic_acode_terminal_glow_background.xml +++ b/res/android/drawable/ic_acode_terminal_glow_background.xml @@ -5,7 +5,10 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - + + + @@ -18,4 +21,5 @@ + diff --git a/res/android/drawable/ic_acode_terminal_glow_foreground.xml b/res/android/drawable/ic_acode_terminal_glow_foreground.xml index ee65cf2ef5..ea4edbb646 100644 --- a/res/android/drawable/ic_acode_terminal_glow_foreground.xml +++ b/res/android/drawable/ic_acode_terminal_glow_foreground.xml @@ -5,13 +5,13 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - + - + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_pro.xml b/res/android/mipmap-anydpi-v26/ic_acode_pro.xml new file mode 100644 index 0000000000..0ba93a08ee --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_pro.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_pro_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_pro_round.xml new file mode 100644 index 0000000000..0ba93a08ee --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_pro_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/dialogs/confirm.js b/src/dialogs/confirm.js index e42e360458..d9d187a0cf 100644 --- a/src/dialogs/confirm.js +++ b/src/dialogs/confirm.js @@ -3,16 +3,26 @@ import DOMPurify from "dompurify"; import actionStack from "lib/actionStack"; import restoreTheme from "lib/restoreTheme"; +let nextConfirmId = 0; + /** * Confirm dialog box * @param {string} titleText Title text * @param {string} [message] Alert message * @param {boolean} [isHTML] Whether the message is HTML - * @param {{checkboxText?: string, returnState?: boolean}} [options] + * @param {{checkboxText?: string, returnState?: boolean, signal?: AbortSignal}} [options] * @returns {Promise} */ function confirm(titleText, message, isHTML, options = {}) { return new Promise((resolve) => { + if (options.signal?.aborted) { + resolve( + options.returnState ? { confirmed: false, checked: false } : false, + ); + return; + } + const actionId = `confirm-${++nextConfirmId}`; + let closed = false; if (!message && titleText) { message = titleText; titleText = ""; @@ -42,17 +52,11 @@ function confirm(titleText, message, isHTML, options = {}) { }; const okBtn = tag("button", { textContent: strings.ok, - onclick: function () { - hide(); - resolve(getResponse(true)); - }, + onclick: () => close(true), }); const cancelBtn = tag("button", { textContent: strings.cancel, - onclick: function () { - hide(); - resolve(getResponse(false)); - }, + onclick: cancel, }); const confirmDiv = tag("div", { className: "prompt confirm", @@ -71,24 +75,37 @@ function confirm(titleText, message, isHTML, options = {}) { }); actionStack.push({ - id: "confirm", - action: hideAlert, + id: actionId, + action: cancel, }); app.append(confirmDiv, mask); restoreTheme(true); + options.signal?.addEventListener("abort", cancel, { once: true }); + + function cancel() { + close(false); + } + + function close(confirmed) { + if (closed) return; + closed = true; + options.signal?.removeEventListener("abort", cancel); + hide(); + resolve(getResponse(confirmed)); + } function hideAlert() { confirmDiv.classList.add("hide"); restoreTheme(); setTimeout(() => { - app.removeChild(confirmDiv); - app.removeChild(mask); + confirmDiv.remove(); + mask.remove(); }, 300); } function hide() { - actionStack.remove("confirm"); + actionStack.remove(actionId); hideAlert(); } }); diff --git a/src/handlers/purchase.js b/src/handlers/purchase.js index 394ab9eb6c..980258eba5 100644 --- a/src/handlers/purchase.js +++ b/src/handlers/purchase.js @@ -3,7 +3,11 @@ import helpers from "utils/helpers"; export default function purchaseListener(onpurchase, onerror) { return [ (purchases) => { - const [purchase] = purchases; + const purchase = purchases?.[0]; + if (!purchase) { + onerror?.(strings.failed); + return; + } if (purchase.purchaseState === iap.PURCHASE_STATE_PURCHASED) { if (!purchase.isAcknowledged) { iap.acknowledgePurchase( @@ -36,7 +40,7 @@ export default function purchaseListener(onpurchase, onerror) { } let message = - error === iap.USER_CANCELED ? strings.failed : strings.canceled; + error === iap.USER_CANCELED ? strings.canceled : strings.failed; if (typeof onerror === "function") onerror(message); }, diff --git a/src/lang/ar-ye.json b/src/lang/ar-ye.json index 473b04bcda..e96e78e135 100644 --- a/src/lang/ar-ye.json +++ b/src/lang/ar-ye.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "هل تريد مشاهدة إعلان بمكافأة لتطبيق أيقونة التطبيق هذه؟", + "rewarded ad unavailable": "الإعلانات بمكافأة غير متاحة الآن. يُرجى المحاولة مرة أخرى.", + "rewarded ad failed": "تعذر إكمال الإعلان. يُرجى المحاولة مرة أخرى.", + "rewarded ad incomplete": "شاهد الإعلان حتى النهاية للحصول على المكافأة." } diff --git a/src/lang/be-by.json b/src/lang/be-by.json index d66afe7c82..6cddef6f07 100644 --- a/src/lang/be-by.json +++ b/src/lang/be-by.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "Паглядзець рэкламу з узнагародай, каб ужыць гэты значок праграмы?", + "rewarded ad unavailable": "Рэклама з узнагародай зараз недаступная. Паспрабуйце яшчэ раз.", + "rewarded ad failed": "Не ўдалося завяршыць прагляд рэкламы. Паспрабуйце яшчэ раз.", + "rewarded ad incomplete": "Даглядзіце рэкламу да канца, каб атрымаць узнагароду." } diff --git a/src/lang/bn-bd.json b/src/lang/bn-bd.json index 43e6fb41d7..166738aff1 100644 --- a/src/lang/bn-bd.json +++ b/src/lang/bn-bd.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "এই অ্যাপ আইকনটি প্রয়োগ করতে একটি পুরস্কারযুক্ত বিজ্ঞাপন দেখবেন?", + "rewarded ad unavailable": "পুরস্কারযুক্ত বিজ্ঞাপন এখন উপলভ্য নেই। অনুগ্রহ করে আবার চেষ্টা করুন।", + "rewarded ad failed": "বিজ্ঞাপনটি সম্পূর্ণ করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।", + "rewarded ad incomplete": "পুরস্কার পেতে বিজ্ঞাপনটি সম্পূর্ণ দেখুন।" } diff --git a/src/lang/cs-cz.json b/src/lang/cs-cz.json index 4fd8d00241..69a1605a87 100644 --- a/src/lang/cs-cz.json +++ b/src/lang/cs-cz.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "Chcete zhlédnout reklamu s odměnou a použít tuto ikonu aplikace?", + "rewarded ad unavailable": "Reklamy s odměnou nejsou nyní dostupné. Zkuste to znovu.", + "rewarded ad failed": "Reklamu se nepodařilo dokončit. Zkuste to znovu.", + "rewarded ad incomplete": "Pro získání odměny zhlédněte reklamu až do konce." } diff --git a/src/lang/de-de.json b/src/lang/de-de.json index caf309f716..1efd40fe58 100644 --- a/src/lang/de-de.json +++ b/src/lang/de-de.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "Eine belohnte Anzeige ansehen, um dieses App-Symbol anzuwenden?", + "rewarded ad unavailable": "Belohnte Anzeigen sind derzeit nicht verfügbar. Bitte versuche es erneut.", + "rewarded ad failed": "Die Anzeige konnte nicht abgeschlossen werden. Bitte versuche es erneut.", + "rewarded ad incomplete": "Sieh dir die Anzeige bis zum Ende an, um die Belohnung zu erhalten." } diff --git a/src/lang/en-us.json b/src/lang/en-us.json index 95ddfa20fa..53063bc74c 100644 --- a/src/lang/en-us.json +++ b/src/lang/en-us.json @@ -838,5 +838,9 @@ "managed": "Managed", "acode service": "Acode Service", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "Watch one rewarded ad to apply this app icon?", + "rewarded ad unavailable": "Rewarded ads are unavailable right now. Please try again.", + "rewarded ad failed": "The ad could not be completed. Please try again.", + "rewarded ad incomplete": "Finish watching the ad to earn the reward." } diff --git a/src/lang/es-sv.json b/src/lang/es-sv.json index 3b6b47ab87..2147ba35fe 100644 --- a/src/lang/es-sv.json +++ b/src/lang/es-sv.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "¿Ver un anuncio con recompensa para aplicar este icono de la aplicación?", + "rewarded ad unavailable": "Los anuncios con recompensa no están disponibles en este momento. Inténtalo de nuevo.", + "rewarded ad failed": "No se pudo completar el anuncio. Inténtalo de nuevo.", + "rewarded ad incomplete": "Termina de ver el anuncio para obtener la recompensa." } diff --git a/src/lang/fr-fr.json b/src/lang/fr-fr.json index 832f1e77b6..ac2403ef35 100644 --- a/src/lang/fr-fr.json +++ b/src/lang/fr-fr.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "Regarder une publicité récompensée pour appliquer cette icône d’application ?", + "rewarded ad unavailable": "Les publicités récompensées sont indisponibles pour le moment. Réessayez.", + "rewarded ad failed": "La publicité n’a pas pu être terminée. Réessayez.", + "rewarded ad incomplete": "Regardez la publicité jusqu’à la fin pour obtenir la récompense." } diff --git a/src/lang/he-il.json b/src/lang/he-il.json index 8c9e736a04..56b2071a6e 100644 --- a/src/lang/he-il.json +++ b/src/lang/he-il.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "לצפות בפרסומת מתגמלת כדי להחיל את סמל האפליקציה הזה?", + "rewarded ad unavailable": "פרסומות מתגמלות אינן זמינות כרגע. יש לנסות שוב.", + "rewarded ad failed": "לא ניתן היה להשלים את הפרסומת. יש לנסות שוב.", + "rewarded ad incomplete": "יש לצפות בפרסומת עד הסוף כדי לקבל את התגמול." } diff --git a/src/lang/hi-in.json b/src/lang/hi-in.json index bc5f49c45d..a26d5eaae9 100644 --- a/src/lang/hi-in.json +++ b/src/lang/hi-in.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "कॉपी करते टाइम एक्सक्लूज़न्स अप्लाई करें", "settings-info-app-use-file-operation-exclusions": "कॉपी और पेस्ट ऑपरेशन्स के दौरान एक्सक्लूज़न पैटर्न्स से मैच करने वाले फ़ाइल्स और फ़ोल्डर्स स्किप कर।", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "क्या इस ऐप आइकन को लागू करने के लिए इनाम वाला एक विज्ञापन देखना चाहते हैं?", + "rewarded ad unavailable": "इनाम वाले विज्ञापन अभी उपलब्ध नहीं हैं। कृपया फिर से कोशिश करें।", + "rewarded ad failed": "विज्ञापन पूरा नहीं हो सका। कृपया फिर से कोशिश करें।", + "rewarded ad incomplete": "इनाम पाने के लिए विज्ञापन पूरा देखें।" } diff --git a/src/lang/hu-hu.json b/src/lang/hu-hu.json index 208bdb9148..086b82bb69 100644 --- a/src/lang/hu-hu.json +++ b/src/lang/hu-hu.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Kizárások használata másoláskor", "settings-info-app-use-file-operation-exclusions": "A kizárási mintáknak megfelelő fájlok és mappák kihagyása másoláskor és beillesztéskor.", "horizontal scroll margin": "Vízszintes görgetési margó", - "settings-info-horizontal-scroll-margin": "A leghosszabb sor után egy üres hely lesz hozzáadva a képernyő széléhez, ha a sorok tördelése ki van kapcsolva. Adjon meg egy nem negatív egész pixelértéket." + "settings-info-horizontal-scroll-margin": "A leghosszabb sor után egy üres hely lesz hozzáadva a képernyő széléhez, ha a sorok tördelése ki van kapcsolva. Adjon meg egy nem negatív egész pixelértéket.", + "confirm app icon reward": "Megnézel egy jutalmazott hirdetést az alkalmazásikon beállításához?", + "rewarded ad unavailable": "A jutalmazott hirdetések jelenleg nem érhetők el. Próbáld újra.", + "rewarded ad failed": "A hirdetést nem sikerült befejezni. Próbáld újra.", + "rewarded ad incomplete": "Nézd végig a hirdetést a jutalom megszerzéséhez." } diff --git a/src/lang/id-id.json b/src/lang/id-id.json index 056e56d11e..dd62d02bb0 100644 --- a/src/lang/id-id.json +++ b/src/lang/id-id.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Terapkan pengecualian saat menyalin", "settings-info-app-use-file-operation-exclusions": "Lewati file dan folder yang cocok dengan pola pengecualian selama operasi salin dan tempel.", "horizontal scroll margin": "Margin gulir horizontal", - "settings-info-horizontal-scroll-margin": "Menambahkan ruang kosong setelah baris terpanjang saat pembungkusan kata (word wrap) nonaktif. Masukkan nilai piksel utuh non-negatif apa pun." + "settings-info-horizontal-scroll-margin": "Menambahkan ruang kosong setelah baris terpanjang saat pembungkusan kata (word wrap) nonaktif. Masukkan nilai piksel utuh non-negatif apa pun.", + "confirm app icon reward": "Tonton satu iklan berhadiah untuk menerapkan ikon aplikasi ini?", + "rewarded ad unavailable": "Iklan berhadiah saat ini tidak tersedia. Silakan coba lagi.", + "rewarded ad failed": "Iklan tidak dapat diselesaikan. Silakan coba lagi.", + "rewarded ad incomplete": "Tonton iklan hingga selesai untuk mendapatkan hadiah." } diff --git a/src/lang/index.d.ts b/src/lang/index.d.ts index ad4f4e7c91..84a5387d7c 100644 --- a/src/lang/index.d.ts +++ b/src/lang/index.d.ts @@ -842,6 +842,10 @@ declare type LangStrings = { "acode service": string; "horizontal scroll margin": string; "settings-info-horizontal-scroll-margin": string; + "confirm app icon reward": string; + "rewarded ad unavailable": string; + "rewarded ad failed": string; + "rewarded ad incomplete": string; }; declare var strings: LangStrings; diff --git a/src/lang/ir-fa.json b/src/lang/ir-fa.json index 6a2cd28e9f..c1ef78a612 100644 --- a/src/lang/ir-fa.json +++ b/src/lang/ir-fa.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "برای اعمال این نماد برنامه، یک تبلیغ پاداش‌دار تماشا می‌کنید؟", + "rewarded ad unavailable": "تبلیغات پاداش‌دار در حال حاضر در دسترس نیستند. لطفاً دوباره تلاش کنید.", + "rewarded ad failed": "تبلیغ کامل نشد. لطفاً دوباره تلاش کنید.", + "rewarded ad incomplete": "برای دریافت پاداش، تبلیغ را تا پایان تماشا کنید." } diff --git a/src/lang/it-it.json b/src/lang/it-it.json index 773406b7f4..775c346e91 100644 --- a/src/lang/it-it.json +++ b/src/lang/it-it.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "Guardare un annuncio con ricompensa per applicare questa icona dell’app?", + "rewarded ad unavailable": "Gli annunci con ricompensa non sono disponibili al momento. Riprova.", + "rewarded ad failed": "Non è stato possibile completare l’annuncio. Riprova.", + "rewarded ad incomplete": "Guarda l’annuncio fino alla fine per ottenere la ricompensa." } diff --git a/src/lang/ja-jp.json b/src/lang/ja-jp.json index e6cd76ce51..326b525fc2 100644 --- a/src/lang/ja-jp.json +++ b/src/lang/ja-jp.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "リワード広告を1本視聴して、このアプリアイコンを適用しますか?", + "rewarded ad unavailable": "現在リワード広告を利用できません。もう一度お試しください。", + "rewarded ad failed": "広告を完了できませんでした。もう一度お試しください。", + "rewarded ad incomplete": "報酬を獲得するには、広告を最後まで視聴してください。" } diff --git a/src/lang/ko-kr.json b/src/lang/ko-kr.json index 59b042f120..b24526e9f9 100644 --- a/src/lang/ko-kr.json +++ b/src/lang/ko-kr.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "보상형 광고 한 편을 시청하고 이 앱 아이콘을 적용할까요?", + "rewarded ad unavailable": "현재 보상형 광고를 이용할 수 없습니다. 다시 시도해 주세요.", + "rewarded ad failed": "광고를 완료하지 못했습니다. 다시 시도해 주세요.", + "rewarded ad incomplete": "보상을 받으려면 광고를 끝까지 시청하세요." } diff --git a/src/lang/ml-in.json b/src/lang/ml-in.json index cdb382f146..cc44821d8f 100644 --- a/src/lang/ml-in.json +++ b/src/lang/ml-in.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "ഈ ആപ്പ് ഐക്കൺ ഉപയോഗിക്കാൻ പ്രതിഫലമുള്ള ഒരു പരസ്യം കാണണോ?", + "rewarded ad unavailable": "പ്രതിഫലമുള്ള പരസ്യങ്ങൾ ഇപ്പോൾ ലഭ്യമല്ല. ദയവായി വീണ്ടും ശ്രമിക്കുക.", + "rewarded ad failed": "പരസ്യം പൂർത്തിയാക്കാനായില്ല. ദയവായി വീണ്ടും ശ്രമിക്കുക.", + "rewarded ad incomplete": "പ്രതിഫലം ലഭിക്കാൻ പരസ്യം മുഴുവനായി കാണുക." } diff --git a/src/lang/mm-unicode.json b/src/lang/mm-unicode.json index 5d129f6a0d..2c0ca208c0 100644 --- a/src/lang/mm-unicode.json +++ b/src/lang/mm-unicode.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "ဤအက်ပ်အိုင်ကွန်ကို အသုံးပြုရန် ဆုရကြော်ငြာတစ်ခုကို ကြည့်မလား။", + "rewarded ad unavailable": "ဆုရကြော်ငြာများကို လောလောဆယ် မရနိုင်ပါ။ ထပ်မံကြိုးစားပါ။", + "rewarded ad failed": "ကြော်ငြာကို အဆုံးအထိ မပြသနိုင်ခဲ့ပါ။ ထပ်မံကြိုးစားပါ။", + "rewarded ad incomplete": "ဆုရရှိရန် ကြော်ငြာကို အဆုံးအထိ ကြည့်ပါ။" } diff --git a/src/lang/mm-zawgyi.json b/src/lang/mm-zawgyi.json index 15a20628ff..0e9f1b919e 100644 --- a/src/lang/mm-zawgyi.json +++ b/src/lang/mm-zawgyi.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "ဤအက္ပ္အိုင္ကြန္ကို အသုံးျပဳရန္ ဆုရေၾကာ္ျငာတစ္ခုကို ၾကည့္မလား။", + "rewarded ad unavailable": "ဆုရေၾကာ္ျငာမ်ားကို ေလာေလာဆယ္ မရႏိုင္ပါ။ ထပ္မံႀကိဳးစားပါ။", + "rewarded ad failed": "ေၾကာ္ျငာကို အဆုံးအထိ မျပသႏိုင္ခဲ့ပါ။ ထပ္မံႀကိဳးစားပါ။", + "rewarded ad incomplete": "ဆုရရွိရန္ ေၾကာ္ျငာကို အဆုံးအထိ ၾကည့္ပါ။" } diff --git a/src/lang/pl-pl.json b/src/lang/pl-pl.json index 08c033ef7d..843821641a 100644 --- a/src/lang/pl-pl.json +++ b/src/lang/pl-pl.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "Obejrzeć reklamę z nagrodą, aby zastosować tę ikonę aplikacji?", + "rewarded ad unavailable": "Reklamy z nagrodą są teraz niedostępne. Spróbuj ponownie.", + "rewarded ad failed": "Nie udało się ukończyć reklamy. Spróbuj ponownie.", + "rewarded ad incomplete": "Obejrzyj reklamę do końca, aby otrzymać nagrodę." } diff --git a/src/lang/pt-br.json b/src/lang/pt-br.json index bfa58da503..6ac0faea49 100644 --- a/src/lang/pt-br.json +++ b/src/lang/pt-br.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "Assistir a um anúncio com recompensa para aplicar este ícone do aplicativo?", + "rewarded ad unavailable": "Os anúncios com recompensa estão indisponíveis no momento. Tente novamente.", + "rewarded ad failed": "Não foi possível concluir o anúncio. Tente novamente.", + "rewarded ad incomplete": "Assista ao anúncio até o fim para receber a recompensa." } diff --git a/src/lang/pu-in.json b/src/lang/pu-in.json index fea22b68f2..c0030f7bb2 100644 --- a/src/lang/pu-in.json +++ b/src/lang/pu-in.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "ਕੀ ਇਸ ਐਪ ਆਈਕਨ ਨੂੰ ਲਾਗੂ ਕਰਨ ਲਈ ਇੱਕ ਇਨਾਮ ਵਾਲਾ ਇਸ਼ਤਿਹਾਰ ਦੇਖਣਾ ਹੈ?", + "rewarded ad unavailable": "ਇਨਾਮ ਵਾਲੇ ਇਸ਼ਤਿਹਾਰ ਇਸ ਵੇਲੇ ਉਪਲਬਧ ਨਹੀਂ ਹਨ। ਕਿਰਪਾ ਕਰਕੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।", + "rewarded ad failed": "ਇਸ਼ਤਿਹਾਰ ਪੂਰਾ ਨਹੀਂ ਹੋ ਸਕਿਆ। ਕਿਰਪਾ ਕਰਕੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।", + "rewarded ad incomplete": "ਇਨਾਮ ਪ੍ਰਾਪਤ ਕਰਨ ਲਈ ਇਸ਼ਤਿਹਾਰ ਪੂਰਾ ਦੇਖੋ।" } diff --git a/src/lang/ru-ru.json b/src/lang/ru-ru.json index 032234b11a..3fa063c1db 100644 --- a/src/lang/ru-ru.json +++ b/src/lang/ru-ru.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "Посмотреть рекламу с вознаграждением, чтобы применить этот значок приложения?", + "rewarded ad unavailable": "Реклама с вознаграждением сейчас недоступна. Повторите попытку.", + "rewarded ad failed": "Не удалось завершить просмотр рекламы. Повторите попытку.", + "rewarded ad incomplete": "Досмотрите рекламу до конца, чтобы получить награду." } diff --git a/src/lang/tl-ph.json b/src/lang/tl-ph.json index cb4698d93b..2d9d72f94c 100644 --- a/src/lang/tl-ph.json +++ b/src/lang/tl-ph.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "Manood ng isang ad na may gantimpala upang gamitin ang icon na ito ng app?", + "rewarded ad unavailable": "Hindi available ngayon ang mga ad na may gantimpala. Pakisubukang muli.", + "rewarded ad failed": "Hindi nakumpleto ang ad. Pakisubukang muli.", + "rewarded ad incomplete": "Tapusin ang panonood ng ad upang makuha ang gantimpala." } diff --git a/src/lang/tr-tr.json b/src/lang/tr-tr.json index f0a5b2faa8..407ca5a8dc 100644 --- a/src/lang/tr-tr.json +++ b/src/lang/tr-tr.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "Bu uygulama simgesini kullanmak için bir ödüllü reklam izlensin mi?", + "rewarded ad unavailable": "Ödüllü reklamlar şu anda kullanılamıyor. Lütfen tekrar deneyin.", + "rewarded ad failed": "Reklam tamamlanamadı. Lütfen tekrar deneyin.", + "rewarded ad incomplete": "Ödülü kazanmak için reklamı sonuna kadar izleyin." } diff --git a/src/lang/uk-ua.json b/src/lang/uk-ua.json index 983a7630f7..d4817eb12a 100644 --- a/src/lang/uk-ua.json +++ b/src/lang/uk-ua.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "Переглянути рекламу з винагородою, щоб застосувати цю піктограму застосунку?", + "rewarded ad unavailable": "Реклама з винагородою зараз недоступна. Спробуйте ще раз.", + "rewarded ad failed": "Не вдалося завершити перегляд реклами. Спробуйте ще раз.", + "rewarded ad incomplete": "Додивіться рекламу до кінця, щоб отримати винагороду." } diff --git a/src/lang/uz-uz.json b/src/lang/uz-uz.json index 3d0e77b3c1..da3c2725e4 100644 --- a/src/lang/uz-uz.json +++ b/src/lang/uz-uz.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "Ushbu ilova belgisini qo‘llash uchun bitta mukofotli reklamani ko‘rasizmi?", + "rewarded ad unavailable": "Mukofotli reklamalar hozir mavjud emas. Qayta urinib ko‘ring.", + "rewarded ad failed": "Reklamani yakunlab bo‘lmadi. Qayta urinib ko‘ring.", + "rewarded ad incomplete": "Mukofotni olish uchun reklamani oxirigacha tomosha qiling." } diff --git a/src/lang/vi-vn.json b/src/lang/vi-vn.json index 69a9fcc1ed..69f567b7ac 100644 --- a/src/lang/vi-vn.json +++ b/src/lang/vi-vn.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "Xem một quảng cáo có thưởng để áp dụng biểu tượng ứng dụng này?", + "rewarded ad unavailable": "Quảng cáo có thưởng hiện không khả dụng. Vui lòng thử lại.", + "rewarded ad failed": "Không thể hoàn tất quảng cáo. Vui lòng thử lại.", + "rewarded ad incomplete": "Xem hết quảng cáo để nhận phần thưởng." } diff --git a/src/lang/zh-cn.json b/src/lang/zh-cn.json index 9e80d2ef52..aaf2d0813f 100644 --- a/src/lang/zh-cn.json +++ b/src/lang/zh-cn.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "复制时应用排除项", "settings-info-app-use-file-operation-exclusions": "在复制和粘贴操作期间,跳过符合排除规则的文件和文件夹。", "horizontal scroll margin": "水平滚动边距", - "settings-info-horizontal-scroll-margin": "在关闭自动换行时,为最长行的末尾添加空白区域。请输入任意非负整数的像素值。" + "settings-info-horizontal-scroll-margin": "在关闭自动换行时,为最长行的末尾添加空白区域。请输入任意非负整数的像素值。", + "confirm app icon reward": "观看一条激励广告以应用此应用图标?", + "rewarded ad unavailable": "激励广告暂时不可用,请重试。", + "rewarded ad failed": "广告未能完成,请重试。", + "rewarded ad incomplete": "请完整观看广告以获得奖励。" } diff --git a/src/lang/zh-hant.json b/src/lang/zh-hant.json index f71439ad9f..bf2ac76f22 100644 --- a/src/lang/zh-hant.json +++ b/src/lang/zh-hant.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "複製時應用排除項", "settings-info-app-use-file-operation-exclusions": "在複製和粘貼操作期間,跳過符合排除規則的文件和文件夾。", "horizontal scroll margin": "水平滾動邊距", - "settings-info-horizontal-scroll-margin": "在關閉自動換行時,爲最長行的末尾添加空白區域。請輸入任意非負整數的像素值。" + "settings-info-horizontal-scroll-margin": "在關閉自動換行時,爲最長行的末尾添加空白區域。請輸入任意非負整數的像素值。", + "confirm app icon reward": "觀看一則獎勵廣告以套用此應用程式圖示?", + "rewarded ad unavailable": "獎勵廣告暫時無法使用,請再試一次。", + "rewarded ad failed": "廣告未能完成,請再試一次。", + "rewarded ad incomplete": "請完整觀看廣告以獲得獎勵。" } diff --git a/src/lang/zh-tw.json b/src/lang/zh-tw.json index 947f624e2e..1204930d69 100644 --- a/src/lang/zh-tw.json +++ b/src/lang/zh-tw.json @@ -838,5 +838,9 @@ "apply exclusions when copying": "Apply exclusions when copying", "settings-info-app-use-file-operation-exclusions": "Skip files and folders matching the exclusion patterns during copy and paste operations.", "horizontal scroll margin": "Horizontal scroll margin", - "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value." + "settings-info-horizontal-scroll-margin": "Adds empty space after the longest line when word wrap is off. Enter any non-negative whole-pixel value.", + "confirm app icon reward": "觀看一則獎勵廣告以套用此應用程式圖示?", + "rewarded ad unavailable": "獎勵廣告目前無法使用,請再試一次。", + "rewarded ad failed": "廣告未能完成,請再試一次。", + "rewarded ad incomplete": "請完整觀看廣告以獲得獎勵。" } diff --git a/src/lib/adRewards.js b/src/lib/adRewards.js index b5b0b22e48..459226dd79 100644 --- a/src/lib/adRewards.js +++ b/src/lib/adRewards.js @@ -3,13 +3,15 @@ import { createRewardStateLifecycle, isRewardPassActive, } from "./adRewardBannerPolicy.mjs"; -import auth from "./auth"; import config from "./config"; +import showRewardedAd, { + isRewardedAdSupported, + isWatchingRewardedAd, +} from "./rewardedAd"; import secureAdRewardState from "./secureAdRewardState"; -import { adUnitIdRewarded, setBannerSuppressed } from "./startAd"; +import { setBannerSuppressed } from "./startAd"; const ONE_HOUR = 60 * 60 * 1000; -const REWARDED_RESULT_TIMEOUT_MS = 90 * 1000; const OFFERS = [ { @@ -153,78 +155,19 @@ async function getRewardIdentity() { } } -async function createRewardedAd(offer, step, sessionId) { - if (!admob?.RewardedAd) { - throw new Error("Rewarded ads are not available in this build."); - } - - const userId = await getRewardIdentity(); - const customData = [ - `session=${sessionId}`, - `offer=${offer.id}`, - `step=${step}`, - `ads=${offer.adsRequired}`, - ].join("&"); - - return new admob.RewardedAd({ - adUnitId: adUnitIdRewarded, +async function showRewardedStep(offer, step, sessionId) { + const earned = await showRewardedAd({ serverSideVerification: { - userId, - customData, + userId: await getRewardIdentity(), + customData: [ + `session=${sessionId}`, + `offer=${offer.id}`, + `step=${step}`, + `ads=${offer.adsRequired}`, + ].join("&"), }, }); -} - -function waitForRewardedResult(ad) { - return new Promise((resolve, reject) => { - let earned = false; - let settled = false; - const timeoutId = setTimeout(() => { - fail( - new Error("Rewarded ad timed out before completion. Please try again."), - ); - }, REWARDED_RESULT_TIMEOUT_MS); - - const finish = (result) => { - if (settled) return; - settled = true; - clearTimeout(timeoutId); - resolve(result); - }; - - const fail = (error) => { - if (settled) return; - settled = true; - clearTimeout(timeoutId); - reject( - error instanceof Error - ? error - : new Error(error?.message || "Rewarded ad failed."), - ); - }; - - ad.on("reward", () => { - earned = true; - }); - - ad.on("dismiss", () => { - finish({ earned }); - }); - - ad.on("showfail", fail); - ad.on("loadfail", fail); - }); -} - -async function showRewardedStep(offer, step, sessionId) { - const rewardedAd = await createRewardedAd(offer, step, sessionId); - const resultPromise = waitForRewardedResult(rewardedAd); - await rewardedAd.load(); - await rewardedAd.show(); - const result = await resultPromise; - if (!result.earned) { - throw new Error("Reward not earned. The ad was closed before completion."); - } + if (!earned) throw new Error(strings["rewarded ad incomplete"]); } export default { @@ -285,13 +228,13 @@ export default { return Boolean(!config.HAS_PRO && !this.isAdFreeActive()); }, isRewardedSupported() { - return Boolean(!config.HAS_PRO && admob?.RewardedAd && adUnitIdRewarded); + return isRewardedAdSupported(); }, getRewardedUnavailableReason() { if (config.HAS_PRO) { return "Ads are already disabled on this build."; } - if (!admob?.RewardedAd) { + if (!isRewardedAdSupported()) { return "Rewarded ads are unavailable on this device."; } return ""; @@ -303,7 +246,7 @@ export default { }; }, isWatchingReward() { - return Boolean(activeWatchPromise); + return Boolean(activeWatchPromise) || isWatchingRewardedAd(); }, async watchOffer(offerId, { onStep } = {}) { if (activeWatchPromise) { @@ -318,18 +261,18 @@ export default { throw new Error(this.getRewardedUnavailableReason()); } - await rewardLifecycle.refresh({ notifyExpiry: false }); - const redemptionStatus = this.canRedeemNow(); - if (!redemptionStatus.ok) { - throw new Error(redemptionStatus.reason); - } - const sessionId = typeof crypto?.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; activeWatchPromise = (async () => { + await rewardLifecycle.refresh({ notifyExpiry: false }); + const redemptionStatus = this.canRedeemNow(); + if (!redemptionStatus.ok) { + throw new Error(redemptionStatus.reason); + } + for (let step = 1; step <= offer.adsRequired; step += 1) { onStep?.({ step, diff --git a/src/lib/appIconSelection.js b/src/lib/appIconSelection.js new file mode 100644 index 0000000000..feced46132 --- /dev/null +++ b/src/lib/appIconSelection.js @@ -0,0 +1,61 @@ +import toast from "components/toast"; +import confirm from "dialogs/confirm"; +import helpers from "utils/helpers"; +import adRewards from "./adRewards"; +import { APP_ICONS } from "./appIcons"; +import config from "./config"; +import { requestProPurchase } from "./removeAds"; +import showRewardedAd from "./rewardedAd"; +import appSettings from "./settings"; + +// Also exclude a newly opened picker while an earlier native change is pending. +let selecting = false; + +export default function createAppIconSelection({ signal, onBusy, onChange }) { + return async function selectIcon(iconId) { + const icon = APP_ICONS.find(({ id }) => id === iconId); + if (!icon || selecting || signal.aborted) return; + if (iconId === (appSettings.value.appIcon || "default")) return; + + selecting = true; + onBusy(true); + try { + if (icon.requiresPro && !config.HAS_PRO) { + await requestProPurchase({ signal }); + return; + } + if (iconId !== "default" && adRewards.canShowAds()) { + const confirmed = await confirm( + strings["app icon"], + strings["confirm app icon reward"], + false, + { signal }, + ); + if (!confirmed || signal.aborted) return; + // Pro or a pass may have become active while confirmation was open. + if (adRewards.canShowAds()) { + const earned = await showRewardedAd({ signal }); + if (signal.aborted) return; + if (!earned) { + toast(strings["rewarded ad incomplete"]); + return; + } + } + } + if (signal.aborted || (icon.requiresPro && !config.HAS_PRO)) return; + await helpers.promisify(system.setAppIcon, iconId); + // Once Android has applied the change, keep persisted state in sync + // even if the picker closed while the native callback was in flight. + await appSettings.update({ appIcon: iconId }, false); + if (!signal.aborted) { + onChange(); + toast(strings["app icon changed"]); + } + } catch (error) { + if (!signal.aborted) helpers.error(error); + } finally { + selecting = false; + if (!signal.aborted) onBusy(false); + } + }; +} diff --git a/src/lib/appIcons.js b/src/lib/appIcons.js index 44c3343815..3ad3737683 100644 --- a/src/lib/appIcons.js +++ b/src/lib/appIcons.js @@ -8,6 +8,12 @@ // Update system.java map too if this is updated export const APP_ICONS = [ { id: "default", label: "Default", image: "icons/ic_acode_default.svg" }, + { + id: "pro", + label: "Acode Pro", + image: "icons/ic_acode_pro.svg", + requiresPro: true, + }, { id: "midnight_circuit", label: "Midnight Circuit", diff --git a/src/lib/removeAds.js b/src/lib/removeAds.js index b825b7aeb0..fcb0bcb3b9 100644 --- a/src/lib/removeAds.js +++ b/src/lib/removeAds.js @@ -1,36 +1,138 @@ +import toast from "components/toast"; +import confirm from "dialogs/confirm"; +import loader from "dialogs/loader"; import purchaseListener from "handlers/purchase"; -import config from "./config.js"; -import { BANNER_SUPPRESSION_REASON, setBannerSuppressed } from "./startAd.js"; +import helpers from "utils/helpers"; +import auth from "./auth"; +import config from "./config"; +import customTab from "./customTab"; +import { BANNER_SUPPRESSION_REASON, setBannerSuppressed } from "./startAd"; -/** - * Remove ads after purchase - * @returns {Promise} - */ -export default function removeAds() { - return new Promise((resolve, reject) => { - iap.getProducts(["acode_pro_new"], (products) => { - const [product] = products; +let activePurchase = null; +let activeRequest = null; - iap.setPurchaseUpdatedListener(...purchaseListener(onpurchase, reject)); +function enablePro() { + config.HAS_PRO = true; + setBannerSuppressed(BANNER_SUPPRESSION_REASON.PRO, true); +} - iap.purchase( - product.productId, - (code) => { - // ignore - }, - (err) => { - alert(strings.error, err); +/** Purchase Pro through Play billing. Resolves only after purchase confirmation. */ +export default function removeAds({ signal } = {}) { + if (signal?.aborted) return Promise.reject(strings.canceled); + if (activePurchase) return activePurchase; + activePurchase = new Promise((resolve, reject) => { + let settled = false; + let launched = false; + let purchased = false; + function cancel() { + if (!launched) fail(strings.canceled); + } + function fail(error) { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", cancel); + reject(error); + } + function onpurchase() { + // A pending purchase can be confirmed after the caller has settled. + if (purchased) return; + purchased = true; + settled = true; + signal?.removeEventListener("abort", cancel); + enablePro(); + // The cache is reverified by the existing startup purchase checks. + try { + localStorage.setItem("acode_pro", "true"); + } catch (error) { + console.warn("Unable to cache Pro purchase", error); + } + toast(strings["thank you :)"]); + resolve(); + } + signal?.addEventListener("abort", cancel, { once: true }); + try { + iap.getProducts( + ["acode_pro_new"], + (products) => { + if (settled) return; + try { + const product = products?.find( + ({ productId }) => productId === "acode_pro_new", + ); + if (!product) return fail(strings["no-product-info"]); + iap.setPurchaseUpdatedListener( + ...purchaseListener(onpurchase, fail), + ); + launched = true; + iap.purchase(product.productId, () => {}, fail); + } catch (error) { + fail(error); + } }, + fail, ); - }); + } catch (error) { + fail(error); + } + }).finally(() => { + activePurchase = null; + }); + return activePurchase; +} - function onpurchase() { - resolve(null); - // For caching, later verified so no need to worry about - localStorage.setItem("acode_pro", "true"); - config.HAS_PRO = true; - setBannerSuppressed(BANNER_SUPPRESSION_REASON.PRO, true); - toast(strings["thank you :)"]); +/** Reuse the Remove ads entry point; external checkout completes via its intent. */ +export function requestProPurchase({ signal } = {}) { + if (signal?.aborted) return Promise.resolve(false); + if (config.HAS_PRO) return Promise.resolve(true); + if (activeRequest) return activeRequest; + activeRequest = (async () => { + if (!helpers.shouldAllowExternalPurchase()) { + try { + await removeAds({ signal }); + return config.HAS_PRO; + } catch (error) { + if ( + error === strings.canceled || + error === globalThis.iap?.USER_CANCELED + ) + return false; + throw error; + } + } + + loader.create(strings.login, strings["loading..."]); + try { + let user = await auth.getLoggedInUser(); + if (signal?.aborted) return false; + if (!user) { + // Dismiss the modal loader so Android Back can cancel confirmation. + loader.destroy(); + const confirmed = await confirm( + strings.confirm, + strings["confirm-login"], + false, + { signal }, + ); + if (!confirmed || signal?.aborted) return false; + loader.create(strings.login, strings["loading..."]); + await auth.login(); + if (signal?.aborted) return false; + user = await auth.getLoggedInUser(); + } + if (signal?.aborted) return false; + if (!user) throw new Error(strings.failed); + if (user.acode_pro) { + enablePro(); + return true; + } + } finally { + loader.destroy(); } + if (signal?.aborted) return false; + await customTab(`${config.BASE_URL}/pro?redirect=app`); + return config.HAS_PRO; + })().finally(() => { + activeRequest = null; }); + return activeRequest; } diff --git a/src/lib/rewardedAd.js b/src/lib/rewardedAd.js new file mode 100644 index 0000000000..fcb375b6e5 --- /dev/null +++ b/src/lib/rewardedAd.js @@ -0,0 +1,120 @@ +import config from "./config"; +import startAd, { + adUnitIdRewarded, + getPrivacyState, + initialized, +} from "./startAd"; + +const RESULT_TIMEOUT_MS = 90_000; +let active = false; +let nextId = 0; + +export function isRewardedAdSupported() { + return Boolean( + !config.HAS_PRO && + typeof admob !== "undefined" && + admob.RewardedAd && + window.ANDROID_SDK_INT >= 29 && + adUnitIdRewarded, + ); +} + +export function isWatchingRewardedAd() { + return active; +} + +/** Show one reward, without granting an ad-free pass or applying its reward. */ +export default function showRewardedAd({ + serverSideVerification, + signal, +} = {}) { + if (signal?.aborted) return Promise.resolve(false); + if (active || !isRewardedAdSupported()) { + return Promise.reject(new Error(strings["rewarded ad unavailable"])); + } + active = true; + return new Promise((resolve, reject) => { + let earned = false; + let settled = false; + let presenting = false; + let cleaned = false; + const unsubscribe = []; + const timeout = setTimeout( + () => finish(new Error(strings["rewarded ad failed"])), + RESULT_TIMEOUT_MS, + ); + + function cleanup() { + // A cancelled/timed-out caller must not allow another full-screen ad + // until the native ad actually dismisses (show() resolves on opening). + if (presenting || cleaned) return; + cleaned = true; + for (const off of unsubscribe.splice(0)) off(); + active = false; + } + + function finish(error, result = false) { + if (!settled) { + settled = true; + clearTimeout(timeout); + signal?.removeEventListener("abort", cancel); + if (error) reject(error); + else resolve(result); + } + cleanup(); + } + + function cancel() { + finish(null); + } + + signal?.addEventListener("abort", cancel, { once: true }); + void (async () => { + await startAd(); + if (settled) return; + if ( + !isRewardedAdSupported() || + !initialized || + !getPrivacyState().canRequestAds + ) { + throw new Error(strings["rewarded ad unavailable"]); + } + const ad = new admob.RewardedAd({ + id: `acode-reward-${++nextId}`, + adUnitId: adUnitIdRewarded, + ...(serverSideVerification ? { serverSideVerification } : {}), + }); + unsubscribe.push( + ad.on("reward", () => { + if (!settled) earned = true; + }), + ad.on("dismiss", () => { + presenting = false; + finish(null, earned && !signal?.aborted); + }), + ad.on("showfail", () => { + presenting = false; + finish(new Error(strings["rewarded ad failed"])); + }), + ad.on("loadfail", () => + finish(new Error(strings["rewarded ad failed"])), + ), + () => { + void ad.destroy().catch((error) => { + console.warn("Failed to destroy rewarded ad", error); + }); + }, + ); + await ad.load(); + if (settled) return; + presenting = true; + if ((await ad.show()) === false) { + presenting = false; + finish(new Error(strings["rewarded ad failed"])); + } + })().catch(() => { + presenting = false; + finish(new Error(strings["rewarded ad failed"])); + }); + }); +} diff --git a/src/pages/appIconSetting/appIconSetting.js b/src/pages/appIconSetting/appIconSetting.js index f532a7b0d3..1d4afadffd 100644 --- a/src/pages/appIconSetting/appIconSetting.js +++ b/src/pages/appIconSetting/appIconSetting.js @@ -1,8 +1,10 @@ import "./appIconSetting.scss"; import Page from "components/page"; +import loader from "dialogs/loader"; import Ref from "html-tag-js/ref"; import actionStack from "lib/actionStack"; -import { APP_ICONS, getAppIconLabel } from "lib/appIcons"; +import createAppIconSelection from "lib/appIconSelection"; +import { APP_ICONS } from "lib/appIcons"; import appSettings from "lib/settings"; import helpers from "utils/helpers"; @@ -10,6 +12,21 @@ export default function appIconSetting() { const title = strings["app icon"] || "App icon"; const $page = Page(title); const $list = Ref(); + const controller = new AbortController(); + let busy = false; + const selectIcon = createAppIconSelection({ + signal: controller.signal, + onBusy(value) { + busy = value; + if (value) loader.showTitleLoader(); + else loader.removeTitleLoader(); + $list.el.setAttribute("aria-busy", String(value)); + for (const button of $list.el.querySelectorAll("button")) { + button.disabled = value; + } + }, + onChange: renderIcons, + }); let resolve; $page.classList.add("app-icon-page"); @@ -17,11 +34,13 @@ export default function appIconSetting() { id: "appIcon", action: () => { $page.hide(); - $page.removeEventListener("click", clickHandler); }, }); $page.onhide = () => { + controller.abort(); + loader.removeTitleLoader(); + $page.removeEventListener("click", clickHandler); actionStack.remove("appIcon"); resolve(); }; @@ -47,6 +66,8 @@ export default function appIconSetting() { className={`app-icon-item ${isCurrent ? "current" : ""}`} data-icon={icon.id} type="button" + disabled={busy} + aria-pressed={String(isCurrent)} > {icon.label} @@ -63,32 +84,4 @@ export default function appIconSetting() { const iconId = $target.dataset.icon; await selectIcon(iconId); } - - async function selectIcon(iconId) { - const current = appSettings.value.appIcon || "default"; - - if (iconId === current) return; - - try { - await new Promise((resolve, reject) => { - system.setAppIcon( - iconId, - async () => { - try { - await appSettings.update({ appIcon: iconId }); - renderIcons(); - resolve(); - } catch (error) { - reject(error); - } - }, - (error) => { - reject(error); - }, - ); - }); - } catch (error) { - helpers.error(error); - } - } } diff --git a/src/plugins/admob/src/android/cordova/AdMob.kt b/src/plugins/admob/src/android/cordova/AdMob.kt index 52ecec35d8..b3f459b728 100644 --- a/src/plugins/admob/src/android/cordova/AdMob.kt +++ b/src/plugins/admob/src/android/cordova/AdMob.kt @@ -45,6 +45,7 @@ class AdMob : CordovaPlugin() { Actions.START to ::executeStart, Actions.CONFIGURE to ::executeConfigure, Actions.AD_CREATE to ::executeAdCreate, + Actions.AD_DESTROY to ::executeAdDestroy, Actions.AD_IS_LOADED to ::executeAdIsLoaded, Actions.AD_LOAD to ::executeAdLoad, Actions.AD_SHOW to ::executeAdShow, @@ -140,6 +141,14 @@ class AdMob : CordovaPlugin() { } ?: ctx.reject("ad cls is missing") } + private fun executeAdDestroy(ctx: ExecuteContext) { + val id = ctx.optId() ?: return ctx.reject("id is required") + cordova.activity.runOnUiThread { + ads[id]?.onDestroy() + ctx.resolve() + } + } + private fun executeAdIsLoaded(ctx: ExecuteContext) { cordova.activity.runOnUiThread { ctx.optAdOrReject()?.let { ad -> diff --git a/src/plugins/admob/src/android/cordova/Generated.kt b/src/plugins/admob/src/android/cordova/Generated.kt index 53b3e11bde..20db58553e 100644 --- a/src/plugins/admob/src/android/cordova/Generated.kt +++ b/src/plugins/admob/src/android/cordova/Generated.kt @@ -3,6 +3,7 @@ package admob.plus.cordova object Actions { const val AD_CREATE = "adCreate" + const val AD_DESTROY = "adDestroy" const val AD_HIDE = "adHide" const val AD_IS_LOADED = "adIsLoaded" const val AD_LOAD = "adLoad" diff --git a/src/plugins/admob/src/android/cordova/ads/Rewarded.kt b/src/plugins/admob/src/android/cordova/ads/Rewarded.kt index 89a7a9dce1..545c5240d6 100644 --- a/src/plugins/admob/src/android/cordova/ads/Rewarded.kt +++ b/src/plugins/admob/src/android/cordova/ads/Rewarded.kt @@ -26,21 +26,35 @@ fun buildServerSideVerificationOptions(opts: JSONObject): ServerSideVerification class Rewarded(ctx: ExecuteContext) : AdBase(ctx) { private var mAd: RewardedAd? = null + private var destroyed = false + private var pendingLoad: ExecuteContext? = null + override fun onDestroy() { + if (destroyed) return + destroyed = true + pendingLoad?.reject("Ad is destroyed") + pendingLoad = null clear() super.onDestroy() } override fun load(ctx: ExecuteContext) { + if (destroyed) return ctx.reject("Ad is destroyed") + pendingLoad?.reject("Ad load replaced") + pendingLoad = ctx clear() RewardedAd.load(plugin.activity, adUnitId, adRequest, object : RewardedAdLoadCallback() { override fun onAdFailedToLoad(loadAdError: LoadAdError) { + if (destroyed || pendingLoad !== ctx) return + pendingLoad = null mAd = null emit(Events.AD_LOAD_FAIL, loadAdError) ctx.reject(loadAdError.toString()) } override fun onAdLoaded(rewardedAd: RewardedAd) { + if (destroyed || pendingLoad !== ctx) return + pendingLoad = null mAd = rewardedAd rewardedAd.onPaidEventListener = paidEventListener("rewarded") { rewardedAd.responseInfo } diff --git a/src/plugins/admob/src/www/ads/base.ts b/src/plugins/admob/src/www/ads/base.ts index 35395e0697..b9fab64dc9 100644 --- a/src/plugins/admob/src/www/ads/base.ts +++ b/src/plugins/admob/src/www/ads/base.ts @@ -19,6 +19,8 @@ export class MobileAd { protected readonly opts: T; private _initPromise: ReturnType | undefined; + private _destroyPromise: Promise | undefined; + private _destroyed = false; constructor(opts: T) { this.opts = opts; @@ -79,13 +81,30 @@ export class MobileAd { return execAsync("adHide", [{ id: this.id }]); } + protected destroy(): Promise { + if (this._destroyPromise) return this._destroyPromise; + this._destroyed = true; + if (MobileAd.allAds[this.id] === this) delete MobileAd.allAds[this.id]; + this._destroyPromise = (async () => { + // Creation may already be in flight; dispose only after it settles. + if (this._initPromise) { + await this._initPromise; + await execAsync("adDestroy", [{ id: this.id }]); + } + })(); + return this._destroyPromise; + } + protected async init() { + if (this._destroyed) throw new Error("Ad is destroyed"); // biome-ignore lint/suspicious/noAssignInExpressions: - return (this._initPromise ??= this._init()); + await (this._initPromise ??= this._init()); + if (this._destroyed) throw new Error("Ad is destroyed"); } private async _init() { await admob.start(); + if (this._destroyed) return; const cls = (this.constructor as unknown as { cls?: string }).cls ?? diff --git a/src/plugins/admob/src/www/ads/rewarded.ts b/src/plugins/admob/src/www/ads/rewarded.ts index 9f1b853e0c..c26ac659ce 100644 --- a/src/plugins/admob/src/www/ads/rewarded.ts +++ b/src/plugins/admob/src/www/ads/rewarded.ts @@ -23,4 +23,9 @@ export class RewardedAd extends MobileAd { public show() { return super.show(); } + + /** Release an Android rewarded ad after dismissal or before presentation. */ + public destroy() { + return super.destroy(); + } } diff --git a/src/plugins/admob/src/www/common.ts b/src/plugins/admob/src/www/common.ts index bdd8ef81ae..a3c0f0e133 100644 --- a/src/plugins/admob/src/www/common.ts +++ b/src/plugins/admob/src/www/common.ts @@ -2,6 +2,7 @@ export const CordovaService = "AdMob"; export type CordovaAction = | "adCreate" + | "adDestroy" | "adHide" | "adIsLoaded" | "adLoad" diff --git a/src/plugins/admob/www/admob.js b/src/plugins/admob/www/admob.js index a586f5832a..3dc046dac2 100644 --- a/src/plugins/admob/www/admob.js +++ b/src/plugins/admob/www/admob.js @@ -81,6 +81,7 @@ function execAsync(action, args) { class MobileAd { constructor(opts) { var _a; + this._destroyed = false; this.opts = opts; this.id = (_a = opts.id) !== null && _a !== void 0 ? _a : opts.adUnitId; MobileAd.allAds[this.id] = this; @@ -137,17 +138,38 @@ class MobileAd { return execAsync("adHide", [{ id: this.id }]); }); } + destroy() { + if (this._destroyPromise) + return this._destroyPromise; + this._destroyed = true; + if (MobileAd.allAds[this.id] === this) + delete MobileAd.allAds[this.id]; + this._destroyPromise = (() => __awaiter(this, void 0, void 0, function* () { + // Creation may already be in flight; dispose only after it settles. + if (this._initPromise) { + yield this._initPromise; + yield execAsync("adDestroy", [{ id: this.id }]); + } + }))(); + return this._destroyPromise; + } init() { - var _a; return __awaiter(this, void 0, void 0, function* () { + var _a; + if (this._destroyed) + throw new Error("Ad is destroyed"); // biome-ignore lint/suspicious/noAssignInExpressions: - return ((_a = this._initPromise) !== null && _a !== void 0 ? _a : (this._initPromise = this._init())); + yield ((_a = this._initPromise) !== null && _a !== void 0 ? _a : (this._initPromise = this._init())); + if (this._destroyed) + throw new Error("Ad is destroyed"); }); } _init() { - var _a; return __awaiter(this, void 0, void 0, function* () { + var _a; yield admob.start(); + if (this._destroyed) + return; const cls = (_a = this.constructor.cls) !== null && _a !== void 0 ? _a : this.constructor.name; return execAsync("adCreate", [Object.assign(Object.assign({}, this.opts), { id: this.id, cls })]); }); @@ -318,6 +340,10 @@ class RewardedAd extends MobileAd { show() { return super.show(); } + /** Release an Android rewarded ad after dismissal or before presentation. */ + destroy() { + return super.destroy(); + } } RewardedAd.cls = "RewardedAd"; diff --git a/src/plugins/system/android/com/foxdebug/system/System.java b/src/plugins/system/android/com/foxdebug/system/System.java index 094c4eab6d..53e0f6a6e7 100644 --- a/src/plugins/system/android/com/foxdebug/system/System.java +++ b/src/plugins/system/android/com/foxdebug/system/System.java @@ -81,6 +81,7 @@ public class System extends CordovaPlugin { static { Map aliases = new HashMap<>(); aliases.put("default", "MainActivityIconDefault"); + aliases.put("pro", "MainActivityIconPro"); aliases.put("midnight_circuit", "MainActivityIconMidnightCircuit"); aliases.put("aurora_pulse", "MainActivityIconAuroraPulse"); aliases.put("terminal_glow", "MainActivityIconTerminalGlow"); diff --git a/src/settings/mainSettings.js b/src/settings/mainSettings.js index 2946f57c72..d43a866ca4 100644 --- a/src/settings/mainSettings.js +++ b/src/settings/mainSettings.js @@ -1,14 +1,11 @@ import settingsPage from "components/settingsPage"; import confirm from "dialogs/confirm"; -import loader from "dialogs/loader"; import rateBox from "dialogs/rateBox"; import actionStack from "lib/actionStack"; -import auth from "lib/auth"; import config from "lib/config"; -import customTab from "lib/customTab"; import openFile from "lib/openFile"; import { bindPrivacyChoices } from "lib/privacyChoicesController.mjs"; -import removeAds from "lib/removeAds"; +import { requestProPurchase } from "lib/removeAds"; import appSettings from "lib/settings"; import settings from "lib/settings"; import { showPrivacyOptions, subscribePrivacyState } from "lib/startAd"; @@ -289,48 +286,7 @@ export default function mainSettings() { case "removeads": try { - if (!helpers.shouldAllowExternalPurchase()) { - await removeAds(); - this.remove(); - break; - } - - loader.create(strings.login, strings["loading..."]); - - try { - let user = await auth.getLoggedInUser(); - if (!user) { - const confirmation = await confirm( - strings.confirm, - strings["confirm-login"], - ); - - if (!confirmation) { - return; - } - - loader.show(); - await auth.login(); - - user = await auth.getLoggedInUser(); - } - - if (!user) { - throw new Error("Unable to fetch user"); - } - - if (user.acode_pro) { - this.remove(); - return; - } - } catch (error) { - helpers.error(error); - return; - } finally { - loader.destroy(); - } - - customTab(`${config.BASE_URL}/pro?redirect=app`).catch(helpers.error); + if (await requestProPurchase()) this.remove(); } catch (error) { helpers.error(error); } diff --git a/tests/admob/adRewards.test.js b/tests/admob/adRewards.test.js new file mode 100644 index 0000000000..de4fb1ffa8 --- /dev/null +++ b/tests/admob/adRewards.test.js @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + show: vi.fn(), + getStatus: vi.fn(), + redeem: vi.fn(), + suppress: vi.fn(), +})); +vi.mock("components/toast", () => ({ default: vi.fn() })); +vi.mock("lib/config", () => ({ default: { HAS_PRO: false } })); +vi.mock("lib/rewardedAd", () => ({ + default: mocks.show, + isRewardedAdSupported: () => true, + isWatchingRewardedAd: () => false, +})); +vi.mock("lib/startAd", () => ({ setBannerSuppressed: mocks.suppress })); +vi.mock("lib/secureAdRewardState", () => ({ + default: { getStatus: mocks.getStatus, redeem: mocks.redeem }, +})); +let adRewards; +beforeEach(async () => { + vi.resetModules(); + vi.useFakeTimers(); + vi.clearAllMocks(); + vi.stubGlobal("window", {}); + vi.stubGlobal("user", { id: "test-user" }); + vi.stubGlobal("strings", { "rewarded ad incomplete": "Incomplete" }); + mocks.getStatus.mockResolvedValue({ canRedeem: true }); + mocks.show.mockResolvedValue(true); + mocks.redeem.mockResolvedValue({ + canRedeem: true, + isActive: true, + adFreeUntil: Date.now() + 3_600_000, + appliedDurationMs: 3_600_000, + }); + ({ default: adRewards } = await import("lib/adRewards")); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it("shares duplicate pass requests during status refresh and preserves step verification", async () => { + let refreshed; + mocks.getStatus.mockImplementationOnce( + () => + new Promise((resolve) => { + refreshed = resolve; + }), + ); + const first = adRewards.watchOffer("focus"); + const duplicate = adRewards.watchOffer("focus"); + refreshed({ canRedeem: true }); + await Promise.all([first, duplicate]); + expect(mocks.show).toHaveBeenCalledTimes(2); + const firstMetadata = mocks.show.mock.calls[0][0].serverSideVerification; + const secondMetadata = mocks.show.mock.calls[1][0].serverSideVerification; + expect(firstMetadata.userId).toBe("test-user"); + expect(firstMetadata.customData).toContain("&offer=focus&step=1&ads=2"); + expect(secondMetadata.customData).toBe( + firstMetadata.customData.replace("step=1", "step=2"), + ); + expect(mocks.redeem).toHaveBeenCalledExactlyOnceWith("focus"); + expect(adRewards.isAdFreeActive()).toBe(true); +}); +it("never grants a pass for an incomplete reward and permits retry", async () => { + mocks.show.mockResolvedValueOnce(false); + await expect(adRewards.watchOffer("quick")).rejects.toThrow("Incomplete"); + expect(mocks.redeem).not.toHaveBeenCalled(); + expect(adRewards.isWatchingReward()).toBe(false); + await adRewards.watchOffer("quick"); + expect(mocks.redeem).toHaveBeenCalledExactlyOnceWith("quick"); +}); diff --git a/tests/admob/rewardedAd.test.js b/tests/admob/rewardedAd.test.js new file mode 100644 index 0000000000..06a726e271 --- /dev/null +++ b/tests/admob/rewardedAd.test.js @@ -0,0 +1,342 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RewardedAd } from "../../src/plugins/admob/src/www/ads/rewarded"; +const mocks = vi.hoisted(() => ({ + start: vi.fn(), + config: { HAS_PRO: false }, + ready: true, + allowed: true, +})); +vi.mock("lib/config", () => ({ default: mocks.config })); +vi.mock("lib/startAd", () => ({ + default: mocks.start, + adUnitIdRewarded: "test-unit", + get initialized() { + return mocks.ready; + }, + getPrivacyState: () => ({ canRequestAds: mocks.allowed }), +})); +let showRewardedAd; +let instances; +const flush = async () => { + for (let i = 0; i < 8; i++) await Promise.resolve(); +}; +class Ad { + constructor(options) { + this.options = options; + this.id = options.id; + this.listeners = new Map(); + instances.push(this); + } + on(name, listener) { + this.listeners.set(name, listener); + return () => this.listeners.delete(name); + } + emit(name) { + this.listeners.get(name)?.(); + } + load = vi.fn().mockResolvedValue(); + show = vi.fn().mockResolvedValue(); + destroy = vi.fn().mockResolvedValue(); +} +beforeEach(async () => { + vi.resetModules(); + vi.useFakeTimers(); + instances = []; + mocks.config.HAS_PRO = false; + mocks.ready = true; + mocks.allowed = true; + mocks.start.mockReset().mockResolvedValue(); + vi.stubGlobal("window", { ANDROID_SDK_INT: 36 }); + vi.stubGlobal("admob", { RewardedAd: Ad }); + vi.stubGlobal("strings", { + "rewarded ad unavailable": "Unavailable", + "rewarded ad failed": "Failed", + }); + ({ default: showRewardedAd } = await import("lib/rewardedAd")); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("shared rewarded ad lifecycle", () => { + it("waits for reward AND dismissal, retaining verification metadata and cleaning listeners", async () => { + const verification = { userId: "guest", customData: "offer=quick&step=1" }; + const done = vi.fn(); + const result = showRewardedAd({ serverSideVerification: verification }); + result.then(done); + await flush(); + const ad = instances[0]; + expect(ad.options.serverSideVerification).toEqual(verification); + expect(ad.options.adUnitId).toBe("test-unit"); + ad.emit("reward"); + await flush(); + expect(done).not.toHaveBeenCalled(); + ad.emit("dismiss"); + await expect(result).resolves.toBe(true); + expect(ad.listeners.size).toBe(0); + expect(ad.destroy).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }); + it.each([ + "dismiss", + "loadfail", + "showfail", + "load rejection", + "show rejection", + "show false", + ])("settles and cleans up on %s", async (event) => { + if (event === "loadfail") + vi.spyOn(Ad.prototype, "on").mockImplementationOnce( + function (name, callback) { + this.load.mockImplementation(async () => { + this.emit("loadfail"); + throw new Error("network"); + }); + this.listeners.set(name, callback); + return () => this.listeners.delete(name); + }, + ); + if (event === "load rejection") + vi.spyOn(Ad.prototype, "on").mockImplementationOnce( + function (name, callback) { + this.load.mockRejectedValue(new Error("network")); + this.listeners.set(name, callback); + return () => this.listeners.delete(name); + }, + ); + if (event === "show rejection") + vi.spyOn(Ad.prototype, "on").mockImplementationOnce( + function (name, callback) { + this.show.mockRejectedValue(new Error("show")); + this.listeners.set(name, callback); + return () => this.listeners.delete(name); + }, + ); + if (event === "show false") + vi.spyOn(Ad.prototype, "on").mockImplementationOnce( + function (name, callback) { + this.show.mockResolvedValue(false); + this.listeners.set(name, callback); + return () => this.listeners.delete(name); + }, + ); + const result = showRewardedAd(); + const assertion = + event === "dismiss" + ? expect(result).resolves.toBe(false) + : expect(result).rejects.toThrow("Failed"); + await flush(); + instances[0].emit(event); + await assertion; + expect(instances[0].listeners.size).toBe(0); + expect(instances[0].destroy).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + const retry = showRewardedAd(); + await flush(); + instances[1].emit("reward"); + instances[1].emit("dismiss"); + await expect(retry).resolves.toBe(true); + vi.restoreAllMocks(); + }); + it.each([ + "paid", + "missing SDK", + "unsupported Android", + "no consent", + "not initialized", + ])("does not load ads for %s", async (reason) => { + if (reason === "paid") mocks.config.HAS_PRO = true; + if (reason === "missing SDK") vi.stubGlobal("admob", undefined); + if (reason === "unsupported Android") window.ANDROID_SDK_INT = 28; + if (reason === "no consent") mocks.allowed = false; + if (reason === "not initialized") mocks.ready = false; + await expect(showRewardedAd()).rejects.toThrow(); + expect(instances).toHaveLength(0); + }); + it("cancels loading and ignores late callbacks without unlocking a subsequent ad", async () => { + let finishInit; + mocks.start.mockImplementationOnce( + () => + new Promise((resolve) => { + finishInit = resolve; + }), + ); + const controller = new AbortController(); + const old = showRewardedAd({ signal: controller.signal }); + controller.abort(); + await expect(old).resolves.toBe(false); + const next = showRewardedAd(); + await flush(); + finishInit(); + await flush(); + expect(instances).toHaveLength(1); + await expect(showRewardedAd()).rejects.toThrow("Unavailable"); + instances[0].emit("dismiss"); + await next; + }); + it("holds the full-screen lock after cancellation until native dismissal", async () => { + const controller = new AbortController(); + const result = showRewardedAd({ signal: controller.signal }); + await flush(); + controller.abort(); + await expect(result).resolves.toBe(false); + expect(instances[0].destroy).not.toHaveBeenCalled(); + await expect(showRewardedAd()).rejects.toThrow("Unavailable"); + instances[0].emit("reward"); + instances[0].emit("dismiss"); + const next = showRewardedAd(); + await flush(); + instances[1].emit("dismiss"); + await next; + expect(instances[0].listeners.size).toBe(0); + expect(instances[0].destroy).toHaveBeenCalledOnce(); + }); + it("ignores an abandoned load rejection while a newer ad is active", async () => { + let rejectLoad; + vi.spyOn(Ad.prototype, "on").mockImplementationOnce( + function (name, callback) { + this.load.mockImplementation( + () => + new Promise((resolve, reject) => { + rejectLoad = reject; + }), + ); + this.listeners.set(name, callback); + return () => this.listeners.delete(name); + }, + ); + const controller = new AbortController(); + const old = showRewardedAd({ signal: controller.signal }); + await flush(); + controller.abort(); + await expect(old).resolves.toBe(false); + expect(instances[0].listeners.size).toBe(0); + expect(instances[0].destroy).toHaveBeenCalledOnce(); + const next = showRewardedAd(); + await flush(); + rejectLoad(new Error("Late failure")); + await flush(); + expect(instances[0].show).not.toHaveBeenCalled(); + await expect(showRewardedAd()).rejects.toThrow("Unavailable"); + instances[1].emit("dismiss"); + await next; + vi.restoreAllMocks(); + }); + it("rejects a presentation timeout and ignores late reward and dismissal", async () => { + const result = showRewardedAd(); + const assertion = expect(result).rejects.toThrow("Failed"); + await flush(); + await vi.advanceTimersByTimeAsync(90_000); + await assertion; + await expect(showRewardedAd()).rejects.toThrow("Unavailable"); + expect(instances[0].destroy).not.toHaveBeenCalled(); + instances[0].emit("reward"); + instances[0].emit("dismiss"); + expect(instances[0].listeners.size).toBe(0); + expect(instances[0].destroy).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }); + it("times out initialization and never presents a late ad", async () => { + let complete; + mocks.start.mockImplementation( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const result = showRewardedAd(); + const assertion = expect(result).rejects.toThrow("Failed"); + await vi.advanceTimersByTimeAsync(90_000); + await assertion; + complete(); + await flush(); + expect(instances).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("rewarded wrapper disposal", () => { + let calls; + beforeEach(() => { + calls = []; + admob.start = vi.fn().mockResolvedValue(); + vi.stubGlobal("cordova", { + exec: vi.fn((resolve, reject, service, action, args) => { + calls.push({ action, id: args[0].id, resolve, reject }); + if (action !== "adCreate" && action !== "adLoad") resolve(); + }), + }); + }); + it("disposes an unused ad once without creating native resources", async () => { + const ad = new RewardedAd({ id: "unused", adUnitId: "test" }); + expect(window.admobAds.unused).toBe(ad); + const disposed = ad.destroy(); + expect(ad.destroy()).toBe(disposed); + await disposed; + expect(window.admobAds.unused).toBeUndefined(); + expect(calls).toEqual([]); + await expect(ad.load()).rejects.toThrow("Ad is destroyed"); + }); + it.each(["SDK startup", "native creation"])( + "cancels during %s without issuing a late load or show", + async (phase) => { + let started; + if (phase === "SDK startup") { + admob.start.mockImplementation( + () => + new Promise((resolve) => { + started = resolve; + }), + ); + } + const ad = new RewardedAd({ id: "old", adUnitId: "test" }); + const loading = ad.load(); + const rejected = expect(loading).rejects.toThrow("Ad is destroyed"); + await flush(); + const disposed = ad.destroy(); + expect(ad.destroy()).toBe(disposed); + const next = new RewardedAd({ id: "next", adUnitId: "test" }); + if (started) started(); + else calls.find(({ action }) => action === "adCreate").resolve(); + await Promise.all([disposed, rejected]); + expect(calls.filter(({ action }) => action === "adLoad")).toEqual([]); + expect( + calls + .filter(({ action }) => action === "adDestroy") + .map(({ id }) => id), + ).toEqual(["old"]); + expect(window.admobAds.old).toBeUndefined(); + expect(window.admobAds.next).toBe(next); + await expect(ad.show()).rejects.toThrow("Ad is destroyed"); + await next.destroy(); + }, + ); + it("destroys a pending native load and ignores its late completion", async () => { + const ad = new RewardedAd({ id: "loading", adUnitId: "test" }); + const loading = ad.load(); + const rejected = expect(loading).rejects.toThrow("Ad is destroyed"); + await flush(); + calls[0].resolve(); + await flush(); + const load = calls.find(({ action }) => action === "adLoad"); + cordova.exec.mockImplementation( + (resolve, reject, service, action, args) => { + calls.push({ action, id: args[0].id }); + load.reject(new Error("Ad is destroyed")); + resolve(); + }, + ); + await ad.destroy(); + load.resolve(); + await rejected; + await ad.destroy(); + expect(calls.map(({ action }) => action)).toEqual([ + "adCreate", + "adLoad", + "adDestroy", + ]); + expect(window.admobAds.loading).toBeUndefined(); + }); +}); diff --git a/tests/unit/androidPrepare.test.js b/tests/unit/androidPrepare.test.js new file mode 100644 index 0000000000..8f29d5a15a --- /dev/null +++ b/tests/unit/androidPrepare.test.js @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import vm from "node:vm"; +import { expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const hook = fs.readFileSync( + new URL("../../hooks/post-process.js", import.meta.url), + "utf8", +); + +it("refreshes stale System plugin Java alongside icons on repeated Android prepares", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "acode-prepare-")); + const write = (file, content) => { + const target = path.join(root, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + return target; + }; + try { + write("config.xml", ''); + write("build-extras.gradle", "// build configuration"); + write("res/android/drawable/ic_acode_pro.xml", ""); + const source = write( + "src/plugins/system/android/com/foxdebug/system/System.java", + 'aliases.put("pro", "MainActivityIconPro");', + ); + const generated = write( + "platforms/android/app/src/main/java/com/foxdebug/system/System.java", + 'aliases.put("default", "MainActivityIconDefault");', + ); + const unrelated = write( + "platforms/android/app/src/main/java/other/Plugin.java", + "// other plugin", + ); + const prepare = () => + vm.runInNewContext(hook, { + __dirname: path.join(root, "hooks"), + process: { env: { TMPDIR: root } }, + console: { log() {}, warn() {}, error() {} }, + require(id) { + if (id === "child_process") + return { + execSync(command) { + expect(command).toBe("npm prefix"); + return Buffer.from(root); + }, + }; + return require(id); + }, + }); + prepare(); + expect(fs.readFileSync(generated, "utf8")).toBe( + fs.readFileSync(source, "utf8"), + ); + fs.appendFileSync(source, "\n// subsequent native edit"); + prepare(); + expect(fs.readFileSync(generated, "utf8")).toBe( + fs.readFileSync(source, "utf8"), + ); + expect(fs.readFileSync(unrelated, "utf8")).toBe("// other plugin"); + expect( + fs.readFileSync( + path.join( + root, + "platforms/android/app/src/main/res/drawable/ic_acode_pro.xml", + ), + "utf8", + ), + ).toBe(""); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/appIconSelection.test.js b/tests/unit/appIconSelection.test.js new file mode 100644 index 0000000000..fc78dfc5b0 --- /dev/null +++ b/tests/unit/appIconSelection.test.js @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + config: { HAS_PRO: false }, + settings: { value: { appIcon: "default" }, update: vi.fn() }, + confirm: vi.fn(), + reward: vi.fn(), + purchase: vi.fn(), + toast: vi.fn(), + error: vi.fn(), + pass: false, +})); +vi.mock("components/toast", () => ({ default: mocks.toast })); +vi.mock("dialogs/confirm", () => ({ default: mocks.confirm })); +vi.mock("lib/config", () => ({ default: mocks.config })); +vi.mock("lib/settings", () => ({ default: mocks.settings })); +vi.mock("lib/adRewards", () => ({ + default: { canShowAds: () => !mocks.config.HAS_PRO && !mocks.pass }, +})); +vi.mock("lib/rewardedAd", () => ({ default: mocks.reward })); +vi.mock("lib/removeAds", () => ({ requestProPurchase: mocks.purchase })); +vi.mock("utils/helpers", () => ({ + default: { + error: mocks.error, + promisify: (fn, ...args) => + new Promise((resolve, reject) => fn(...args, resolve, reject)), + }, +})); +import createAppIconSelection from "lib/appIconSelection"; + +function harness() { + const controller = new AbortController(); + const onBusy = vi.fn(); + const onChange = vi.fn(); + return { + controller, + onBusy, + onChange, + select: createAppIconSelection({ + signal: controller.signal, + onBusy, + onChange, + }), + }; +} +beforeEach(() => { + vi.clearAllMocks(); + mocks.config.HAS_PRO = false; + mocks.pass = false; + mocks.settings.value.appIcon = "default"; + mocks.settings.update.mockImplementation(async ({ appIcon }) => { + mocks.settings.value.appIcon = appIcon; + }); + mocks.confirm.mockResolvedValue(true); + mocks.reward.mockResolvedValue(true); + mocks.purchase.mockResolvedValue(false); + vi.stubGlobal("strings", { + "app icon": "App icon", + "confirm app icon reward": "Watch?", + "app icon changed": "Changed", + "rewarded ad incomplete": "Incomplete", + }); + vi.stubGlobal("system", { setAppIcon: vi.fn((id, success) => success()) }); +}); + +describe("icon selection", () => { + it("opens Pro purchase without watching an ad or applying the locked icon", async () => { + mocks.pass = true; + const h = harness(); + mocks.purchase.mockImplementation(async () => { + mocks.config.HAS_PRO = true; + }); + await h.select("pro"); + expect(mocks.purchase).toHaveBeenCalledOnce(); + expect(mocks.reward).not.toHaveBeenCalled(); + expect(system.setAppIcon).not.toHaveBeenCalled(); + await h.select("pro"); + expect(system.setAppIcon).toHaveBeenCalledWith( + "pro", + expect.any(Function), + expect.any(Function), + ); + }); + it.each([ + "default", + "paid", + "pass", + ])("skips rewarded ads for %s", async (kind) => { + mocks.settings.value.appIcon = "pixel_party"; + mocks.config.HAS_PRO = kind === "paid"; + mocks.pass = kind === "pass"; + await harness().select(kind === "default" ? "default" : "midnight_circuit"); + expect(mocks.confirm).not.toHaveBeenCalled(); + expect(mocks.reward).not.toHaveBeenCalled(); + expect(mocks.settings.update).toHaveBeenCalledOnce(); + expect(mocks.toast).toHaveBeenCalledWith("Changed"); + }); + it("ignores current/unknown icons and serializes confirmation and reward", async () => { + const h = harness(); + await h.select("default"); + await h.select("unknown"); + expect(h.onBusy).not.toHaveBeenCalled(); + let confirm; + mocks.confirm.mockImplementation( + () => + new Promise((resolve) => { + confirm = resolve; + }), + ); + const first = h.select("pixel_party"); + await h.select("solar_flare"); + expect(mocks.confirm).toHaveBeenCalledOnce(); + expect(mocks.reward).not.toHaveBeenCalled(); + confirm(true); + await first; + expect(system.setAppIcon.mock.calls[0][0]).toBe("pixel_party"); + expect(h.onBusy.mock.calls).toEqual([[true], [false]]); + expect(mocks.toast).toHaveBeenCalledTimes(1); + }); + it.each([ + "decline", + "incomplete", + "load failure", + "native failure", + ])("keeps the current selection on %s", async (kind) => { + if (kind === "decline") mocks.confirm.mockResolvedValue(false); + if (kind === "incomplete") mocks.reward.mockResolvedValue(false); + if (kind === "load failure") + mocks.reward.mockRejectedValue(new Error("Unavailable")); + if (kind === "native failure") + system.setAppIcon.mockImplementation((id, ok, fail) => + fail("Native failure"), + ); + const h = harness(); + await h.select("pixel_party"); + expect(mocks.settings.value.appIcon).toBe("default"); + expect(mocks.settings.update).not.toHaveBeenCalled(); + expect(mocks.toast).not.toHaveBeenCalledWith("Changed"); + expect(h.onBusy).toHaveBeenLastCalledWith(false); + if (kind === "decline") expect(mocks.reward).not.toHaveBeenCalled(); + }); + it("ignores a reward received after leaving the picker", async () => { + let finish; + mocks.reward.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const h = harness(); + const pending = h.select("pixel_party"); + await vi.waitFor(() => expect(finish).toBeTypeOf("function")); + h.controller.abort(); + finish(true); + await pending; + expect(system.setAppIcon).not.toHaveBeenCalled(); + expect(h.onChange).not.toHaveBeenCalled(); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + it("does not apply after leaving during confirmation", async () => { + const h = harness(); + mocks.confirm.mockImplementation(async () => { + h.controller.abort(); + return true; + }); + await h.select("pixel_party"); + expect(mocks.reward).not.toHaveBeenCalled(); + expect(system.setAppIcon).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/confirm.test.js b/tests/unit/confirm.test.js new file mode 100644 index 0000000000..0029941e48 --- /dev/null +++ b/tests/unit/confirm.test.js @@ -0,0 +1,117 @@ +import { Window } from "happy-dom"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +vi.mock("lib/settings", () => ({ + default: { value: { confirmOnExit: false } }, +})); +vi.mock("lib/restoreTheme", () => ({ default: vi.fn() })); +vi.mock("components/checkbox", () => ({ + default: () => document.createElement("input"), +})); +import confirm from "dialogs/confirm"; +import actionStack from "lib/actionStack"; +let window; +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + actionStack.setMark(); + window = new Window(); + vi.stubGlobal("document", window.document); + vi.stubGlobal("app", window.document.body); + vi.stubGlobal("strings", { ok: "OK", cancel: "Cancel" }); + vi.stubGlobal("tag", (name, options) => { + const el = document.createElement(name); + for (const [key, value] of Object.entries(options)) { + if (key === "children") el.append(...value); + else if (value !== undefined) el[key] = value; + } + return el; + }); +}); +afterEach(() => { + actionStack.clearFromMark(); + vi.runAllTimers(); + vi.restoreAllMocks(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + window.happyDOM.cancelAsync(); +}); +it.each([ + "back", + "cancel", + "abort", +])("settles %s as cancellation and cleans the dialog", async (method) => { + const controller = new AbortController(); + const result = confirm("Icon", "Watch?", false, { + signal: controller.signal, + }); + if (method === "back") await actionStack.pop(); + if (method === "cancel") document.querySelector("button").click(); + if (method === "abort") controller.abort(); + await expect(result).resolves.toBe(false); + vi.runAllTimers(); + expect(app.children.length).toBe(0); + expect(actionStack.length).toBe(0); +}); +it("preserves checkbox response and ignores subsequent dismissals", async () => { + const push = vi.spyOn(actionStack, "push"); + const remove = vi.spyOn(actionStack, "remove"); + const controller = new AbortController(); + const result = confirm("Icon", "Watch?", false, { + checkboxText: "Remember", + returnState: true, + signal: controller.signal, + }); + document.querySelector("input").checked = true; + document.querySelectorAll("button")[1].click(); + controller.abort(); + push.mock.calls[0][0].action(); + await expect(result).resolves.toEqual({ confirmed: true, checked: true }); + expect(remove).toHaveBeenCalledOnce(); + expect(actionStack.length).toBe(0); +}); + +it.each(["back", "cancel", "ok", "abort"])( + "preserves the lower confirmation's Back handler after %s dismisses the top", + async (method) => { + const firstClosed = vi.fn(); + confirm("First", "First confirmation").then(firstClosed); + const controller = new AbortController(); + const second = confirm("Second", "Second confirmation", false, { + signal: controller.signal, + }); + const top = document.querySelectorAll(".confirm")[1]; + if (method === "back") await actionStack.pop(); + if (method === "cancel") top.querySelector("button").click(); + if (method === "ok") top.querySelectorAll("button")[1].click(); + if (method === "abort") controller.abort(); + await expect(second).resolves.toBe(method === "ok"); + vi.runAllTimers(); + expect(document.querySelectorAll(".confirm")).toHaveLength(1); + expect(actionStack.length).toBe(1); + expect(firstClosed).not.toHaveBeenCalled(); + + await actionStack.pop(); + expect(firstClosed).toHaveBeenCalledExactlyOnceWith(false); + vi.runAllTimers(); + expect(app.children.length).toBe(0); + expect(actionStack.length).toBe(0); + }, +); + +it("preserves the top confirmation when the lower one is aborted", async () => { + const controller = new AbortController(); + const first = confirm("First", "First confirmation", false, { + signal: controller.signal, + }); + const secondClosed = vi.fn(); + confirm("Second", "Second confirmation").then(secondClosed); + controller.abort(); + await expect(first).resolves.toBe(false); + expect(actionStack.length).toBe(1); + expect(secondClosed).not.toHaveBeenCalled(); + await actionStack.pop(); + expect(secondClosed).toHaveBeenCalledExactlyOnceWith(false); + vi.runAllTimers(); + expect(app.children.length).toBe(0); + expect(actionStack.length).toBe(0); +}); diff --git a/tests/unit/removeAds.test.js b/tests/unit/removeAds.test.js new file mode 100644 index 0000000000..cb6fecc7a9 --- /dev/null +++ b/tests/unit/removeAds.test.js @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +const mocks = vi.hoisted(() => ({ + config: { HAS_PRO: false, BASE_URL: "https://acode.app" }, + toast: vi.fn(), + suppress: vi.fn(), + confirm: vi.fn(), + customTab: vi.fn(), + auth: { getLoggedInUser: vi.fn(), login: vi.fn() }, + loader: { create: vi.fn(), show: vi.fn(), destroy: vi.fn() }, + external: false, +})); +vi.mock("components/toast", () => ({ default: mocks.toast })); +vi.mock("dialogs/confirm", () => ({ default: mocks.confirm })); +vi.mock("dialogs/loader", () => ({ default: mocks.loader })); +vi.mock("utils/helpers", () => ({ + default: { + error: vi.fn(), + shouldAllowExternalPurchase: () => mocks.external, + }, +})); +vi.mock("lib/auth", () => ({ default: mocks.auth })); +vi.mock("lib/config", () => ({ default: mocks.config })); +vi.mock("lib/customTab", () => ({ default: mocks.customTab })); +vi.mock("lib/startAd", () => ({ + BANNER_SUPPRESSION_REASON: { PRO: "pro" }, + setBannerSuppressed: mocks.suppress, +})); +import removeAds, { requestProPurchase } from "lib/removeAds"; +let purchaseUpdated; +let purchaseError; +beforeEach(() => { + vi.clearAllMocks(); + mocks.config.HAS_PRO = false; + mocks.external = false; + mocks.auth.getLoggedInUser + .mockReset() + .mockResolvedValue({ acode_pro: false }); + mocks.auth.login.mockResolvedValue(); + mocks.confirm.mockResolvedValue(true); + mocks.customTab.mockResolvedValue(); + vi.stubGlobal("strings", { + "no-product-info": "No product", + "purchase pending": "Pending", + failed: "Failed", + canceled: "Cancelled", + "thank you :)": "Thanks", + "confirm-login": "Login?", + }); + vi.stubGlobal("localStorage", { setItem: vi.fn() }); + vi.stubGlobal("iap", { + USER_CANCELED: 1, + ITEM_ALREADY_OWNED: 7, + PURCHASE_STATE_PURCHASED: 1, + PURCHASE_STATE_PENDING: 2, + getProducts: vi.fn((ids, ok) => ok([{ productId: "acode_pro_new" }])), + setPurchaseUpdatedListener: vi.fn((ok, fail) => { + purchaseUpdated = ok; + purchaseError = fail; + }), + purchase: vi.fn(), + acknowledgePurchase: vi.fn((token, ok) => ok()), + }); +}); + +describe("shared Pro purchase flow", () => { + it.each([ + "empty products", + "product error", + "product exception", + "launch error", + "empty purchase", + "pending", + "cancel", + ])("settles %s without granting Pro", async (kind) => { + if (kind === "empty products") + iap.getProducts.mockImplementation((ids, ok) => ok([])); + if (kind === "product error") + iap.getProducts.mockImplementation((ids, ok, fail) => + fail("Products failed"), + ); + if (kind === "product exception") + iap.getProducts.mockImplementation(() => { + throw new Error("Billing unavailable"); + }); + if (kind === "launch error") + iap.purchase.mockImplementation((id, ok, fail) => fail("Launch failed")); + const pending = removeAds(); + const assertion = expect(pending).rejects.toBeDefined(); + if (kind === "empty purchase") purchaseUpdated([]); + if (kind === "pending") purchaseUpdated([{ purchaseState: 2 }]); + if (kind === "cancel") purchaseError(1); + await assertion; + expect(mocks.config.HAS_PRO).toBe(false); + expect(mocks.suppress).not.toHaveBeenCalled(); + }); + it("shares an active billing request and grants Pro once after acknowledgement", async () => { + const first = removeAds(); + const duplicate = removeAds(); + expect(duplicate).toBe(first); + const value = [ + { purchaseState: 1, isAcknowledged: false, purchaseToken: "test" }, + ]; + purchaseUpdated(value); + await first; + purchaseUpdated(value); + expect(iap.purchase).toHaveBeenCalledOnce(); + expect(mocks.config.HAS_PRO).toBe(true); + expect(mocks.toast).toHaveBeenCalledOnce(); + expect(mocks.suppress).toHaveBeenCalledWith("pro", true); + }); + it("treats cancelled billing as a cancelled request", async () => { + const result = requestProPurchase(); + purchaseError(1); + await expect(result).resolves.toBe(false); + }); + it("honors a later confirmed purchase after reporting its pending state", async () => { + const result = removeAds(); + const assertion = expect(result).rejects.toBe("Pending"); + purchaseUpdated([{ purchaseState: 2 }]); + await assertion; + expect(mocks.config.HAS_PRO).toBe(false); + purchaseUpdated([{ purchaseState: 1, isAcknowledged: true }]); + expect(mocks.config.HAS_PRO).toBe(true); + expect(mocks.toast).toHaveBeenCalledOnce(); + }); + it("cancels pending product lookup and ignores its late result", async () => { + let productsLoaded; + iap.getProducts.mockImplementation((ids, ok) => { + productsLoaded = ok; + }); + const controller = new AbortController(); + const result = requestProPurchase({ signal: controller.signal }); + controller.abort(); + await expect(result).resolves.toBe(false); + productsLoaded([{ productId: "acode_pro_new" }]); + expect(iap.purchase).not.toHaveBeenCalled(); + expect(mocks.config.HAS_PRO).toBe(false); + }); + it("uses the existing external checkout and does not grant Pro merely for opening it", async () => { + mocks.external = true; + await expect(requestProPurchase()).resolves.toBe(false); + expect(mocks.customTab).toHaveBeenCalledWith( + "https://acode.app/pro?redirect=app", + ); + expect(iap.purchase).not.toHaveBeenCalled(); + expect(mocks.loader.destroy).toHaveBeenCalledOnce(); + }); + it("refreshes confirmed account Pro without checkout", async () => { + mocks.external = true; + mocks.auth.getLoggedInUser.mockResolvedValue({ acode_pro: true }); + await expect(requestProPurchase()).resolves.toBe(true); + expect(mocks.config.HAS_PRO).toBe(true); + expect(mocks.customTab).not.toHaveBeenCalled(); + }); + it("honors login cancellation and page closure before checkout", async () => { + mocks.external = true; + mocks.auth.getLoggedInUser.mockResolvedValue(null); + mocks.confirm.mockResolvedValue(false); + await expect(requestProPurchase()).resolves.toBe(false); + expect(mocks.auth.login).not.toHaveBeenCalled(); + const controller = new AbortController(); + mocks.auth.getLoggedInUser.mockImplementation(async () => { + controller.abort(); + return { acode_pro: false }; + }); + await expect( + requestProPurchase({ signal: controller.signal }), + ).resolves.toBe(false); + expect(mocks.customTab).not.toHaveBeenCalled(); + }); +}); diff --git a/www/icons/ic_acode_aurora_pulse.svg b/www/icons/ic_acode_aurora_pulse.svg index f188ae3dde..a5341b80bf 100644 --- a/www/icons/ic_acode_aurora_pulse.svg +++ b/www/icons/ic_acode_aurora_pulse.svg @@ -31,7 +31,7 @@ - - + + diff --git a/www/icons/ic_acode_blueprint.svg b/www/icons/ic_acode_blueprint.svg index 129975a8aa..0c65026b46 100644 --- a/www/icons/ic_acode_blueprint.svg +++ b/www/icons/ic_acode_blueprint.svg @@ -32,7 +32,7 @@ - - + + diff --git a/www/icons/ic_acode_default.svg b/www/icons/ic_acode_default.svg index 751a762f59..8114270ea6 100644 --- a/www/icons/ic_acode_default.svg +++ b/www/icons/ic_acode_default.svg @@ -8,7 +8,7 @@ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" id="eqleqe3ploak1" - viewBox="0 0 544.369200 452.626030" + viewBox="-57.73621212 -103.60620137 659.84242424 659.84242424" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" version="1.1" diff --git a/www/icons/ic_acode_midnight_circuit.svg b/www/icons/ic_acode_midnight_circuit.svg index 772e82baf2..7de6506f59 100644 --- a/www/icons/ic_acode_midnight_circuit.svg +++ b/www/icons/ic_acode_midnight_circuit.svg @@ -27,7 +27,7 @@ - - + + diff --git a/www/icons/ic_acode_pixel_party.svg b/www/icons/ic_acode_pixel_party.svg index 40e37b72aa..6c5981a6c1 100644 --- a/www/icons/ic_acode_pixel_party.svg +++ b/www/icons/ic_acode_pixel_party.svg @@ -22,8 +22,8 @@ - - - + + + diff --git a/www/icons/ic_acode_pro.svg b/www/icons/ic_acode_pro.svg new file mode 100644 index 0000000000..3eb809143b --- /dev/null +++ b/www/icons/ic_acode_pro.svg @@ -0,0 +1,29 @@ + + Acode Pro app icon + Golden Acode mark on a midnight gradient. + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/icons/ic_acode_solar_flare.svg b/www/icons/ic_acode_solar_flare.svg index df2a783193..a5c1b38899 100644 --- a/www/icons/ic_acode_solar_flare.svg +++ b/www/icons/ic_acode_solar_flare.svg @@ -22,7 +22,7 @@ - - + + diff --git a/www/icons/ic_acode_terminal_glow.svg b/www/icons/ic_acode_terminal_glow.svg index f5285aba12..1dbaa40f10 100644 --- a/www/icons/ic_acode_terminal_glow.svg +++ b/www/icons/ic_acode_terminal_glow.svg @@ -27,7 +27,7 @@ - - + +