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)}
>
@@ -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 @@
+
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 @@
-
-
+
+