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");
+ aliases.put("solar_flare", "MainActivityIconSolarFlare");
+ aliases.put("blueprint", "MainActivityIconBlueprint");
+ aliases.put("pixel_party", "MainActivityIconPixelParty");
+ APP_ICON_ALIASES = Collections.unmodifiableMap(aliases);
+ }
+
private Activity activity;
private Context context;
private int REQ_PERMISSIONS = 1;
@@ -159,6 +174,9 @@ public void run() {
}
);
return true;
+ case "set-app-icon":
+ setAppIcon(arg1, callbackContext);
+ return true;
case "get-cordova-intent":
getCordovaIntent(callbackContext);
return true;
@@ -2185,6 +2203,45 @@ private void setNativeContextMenuDisabled(boolean disabled) {
webView.setNativeContextMenuDisabled(disabled);
}
+ /**
+ * Dynamically changes the app icon by toggling activity-alias components.
+ *
+ * The launcher icon is always represented by an activity-alias (including
+ * the default icon) so that the running MainActivity component never has to
+ * be disabled. Disabling the currently running component would make Android
+ * force-stop/restart the app, so only aliases are toggled here.
+ *
+ * @param iconName Icon id (e.g. "midnight_circuit") or "default" to restore
+ * the original launcher icon.
+ * @param callback Callback invoked with the result.
+ */
+ private void setAppIcon(String iconName, CallbackContext callback) {
+ try {
+ String packageName = context.getPackageName();
+ PackageManager pm = context.getPackageManager();
+ String key = iconName == null ? "default" : iconName.toLowerCase();
+
+ if (!APP_ICON_ALIASES.containsKey(key)) {
+ callback.error("Unknown app icon: " + iconName);
+ return;
+ }
+
+ for (Map.Entry entry : APP_ICON_ALIASES.entrySet()) {
+ boolean enabled = entry.getKey().equals(key);
+ pm.setComponentEnabledSetting(
+ new ComponentName(packageName, packageName + "." + entry.getValue()),
+ enabled
+ ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED
+ : PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+ PackageManager.DONT_KILL_APP
+ );
+ }
+ callback.success();
+ } catch (Exception e) {
+ callback.error(e.toString());
+ }
+ }
+
private void extractAsset(
String assetName,
String destinationPath,
diff --git a/src/plugins/system/system.d.ts b/src/plugins/system/system.d.ts
index aa2ef8bf7f..6131045068 100644
--- a/src/plugins/system/system.d.ts
+++ b/src/plugins/system/system.d.ts
@@ -295,6 +295,13 @@ interface System {
onSuccess?: () => void,
onFail?: OnFail,
): void;
+ /**
+ * Change the app icon at runtime.
+ * @param iconName Icon id, e.g. "midnight_circuit", or "default" to restore the original icon
+ * @param onSuccess
+ * @param onFail
+ */
+ setAppIcon(iconName: string, onSuccess: OnSuccessBool, onFail: OnFail): void;
}
interface Window{
diff --git a/src/plugins/system/www/plugin.js b/src/plugins/system/www/plugin.js
index 8bebd47efd..c8722e7e4a 100644
--- a/src/plugins/system/www/plugin.js
+++ b/src/plugins/system/www/plugin.js
@@ -231,6 +231,15 @@ module.exports = {
[String(!!disabled)]
);
},
+ /**
+ * Change the app icon at runtime.
+ * @param iconName Icon id, e.g. "midnight_circuit", or "default" to restore the original icon
+ * @param onSuccess
+ * @param onFail
+ */
+ setAppIcon: function (iconName, onSuccess, onFail) {
+ cordova.exec(onSuccess, onFail, 'System', 'set-app-icon', [iconName]);
+ },
getGlobalSetting: function (key, onSuccess, onFail) {
cordova.exec(onSuccess, onFail, 'System', 'get-global-setting', [key]);
},
diff --git a/src/settings/appSettings.js b/src/settings/appSettings.js
index a7f912f74c..2817bb52ff 100644
--- a/src/settings/appSettings.js
+++ b/src/settings/appSettings.js
@@ -6,11 +6,13 @@ import loader from "dialogs/loader";
import select from "dialogs/select";
import actions from "handlers/quickTools";
import actionStack from "lib/actionStack";
+import { getAppIconLabel } from "lib/appIcons";
import config from "lib/config";
import fonts from "lib/fonts";
import lang from "lib/lang";
import openFile from "lib/openFile";
import appSettings from "lib/settings";
+import appIconSetting from "pages/appIconSetting";
import FontManager from "pages/fontManager";
import QuickToolsSettings from "pages/quickTools";
import encodings, { getEncoding } from "utils/encodings";
@@ -64,6 +66,17 @@ export default function otherSettings() {
info: strings["settings-info-app-fullscreen"],
category: categories.interface,
},
+ {
+ key: "appIcon",
+ text: strings["app icon"] || "App icon",
+ value: values.appIcon || "default",
+ valueText: (value) => getAppIconLabel(value),
+ info:
+ strings["settings-info-app-icon"] ||
+ "Choose the app icon displayed on your device.",
+ category: categories.interface,
+ chevron: true,
+ },
{
key: "uiZoom",
text: strings["ui zoom"] || "UI zoom",
@@ -391,6 +404,20 @@ export default function otherSettings() {
FontManager();
return;
+ case "appIcon":
+ await appIconSetting();
+ {
+ const item = items.find((i) => i.key === "appIcon");
+ if (item) item.value = appSettings.value.appIcon || "default";
+ const $value = this.get(".setting-trailing-value");
+ if ($value) {
+ $value.textContent = getAppIconLabel(
+ appSettings.value.appIcon || "default",
+ );
+ }
+ }
+ return;
+
case "appFont":
await fonts.setAppFont(value);
break;
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/appIcons.test.js b/tests/unit/appIcons.test.js
new file mode 100644
index 0000000000..77df9a0050
--- /dev/null
+++ b/tests/unit/appIcons.test.js
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { APP_ICONS, getAppIconLabel } from "lib/appIcons";
+
+describe("appIcons", () => {
+ it("exposes the default icon first", () => {
+ expect(APP_ICONS[0].id).toBe("default");
+ });
+
+ it("references an svg preview for each icon", () => {
+ for (const icon of APP_ICONS) {
+ expect(icon.image).toMatch(/\.svg$/);
+ }
+ });
+
+ describe("getAppIconLabel", () => {
+ it("returns the label for a known icon", () => {
+ expect(getAppIconLabel("midnight_circuit")).toBe("Midnight Circuit");
+ });
+
+ it("falls back to the default label for unknown icons", () => {
+ expect(getAppIconLabel("unknown_icon")).toBe("Default");
+ });
+ });
+});
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
new file mode 100644
index 0000000000..a5341b80bf
--- /dev/null
+++ b/www/icons/ic_acode_aurora_pulse.svg
@@ -0,0 +1,37 @@
+
diff --git a/www/icons/ic_acode_blueprint.svg b/www/icons/ic_acode_blueprint.svg
new file mode 100644
index 0000000000..0c65026b46
--- /dev/null
+++ b/www/icons/ic_acode_blueprint.svg
@@ -0,0 +1,38 @@
+
diff --git a/www/icons/ic_acode_default.svg b/www/icons/ic_acode_default.svg
new file mode 100644
index 0000000000..8114270ea6
--- /dev/null
+++ b/www/icons/ic_acode_default.svg
@@ -0,0 +1,227 @@
+
+
diff --git a/www/icons/ic_acode_midnight_circuit.svg b/www/icons/ic_acode_midnight_circuit.svg
new file mode 100644
index 0000000000..7de6506f59
--- /dev/null
+++ b/www/icons/ic_acode_midnight_circuit.svg
@@ -0,0 +1,33 @@
+
diff --git a/www/icons/ic_acode_pixel_party.svg b/www/icons/ic_acode_pixel_party.svg
new file mode 100644
index 0000000000..6c5981a6c1
--- /dev/null
+++ b/www/icons/ic_acode_pixel_party.svg
@@ -0,0 +1,29 @@
+
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
new file mode 100644
index 0000000000..a5c1b38899
--- /dev/null
+++ b/www/icons/ic_acode_solar_flare.svg
@@ -0,0 +1,28 @@
+
diff --git a/www/icons/ic_acode_terminal_glow.svg b/www/icons/ic_acode_terminal_glow.svg
new file mode 100644
index 0000000000..1dbaa40f10
--- /dev/null
+++ b/www/icons/ic_acode_terminal_glow.svg
@@ -0,0 +1,33 @@
+