diff --git a/config.xml b/config.xml index 5c50adc386..5101e1fa59 100644 --- a/config.xml +++ b/config.xml @@ -78,6 +78,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hooks/post-process.js b/hooks/post-process.js index 51bd6b3901..449669cf4c 100644 --- a/hooks/post-process.js +++ b/hooks/post-process.js @@ -25,11 +25,18 @@ fs.copyFileSync(gradleFilePath, androidGradleFilePath); // directories that are required later in the build. Keep the generated tree and // only overlay this project's custom resources on top of it. copyDirRecursively(localResPath, resPath); +// Cordova can retain plugin Java from an earlier install during prepare. +// Keep the System plugin's icon map in sync with the launcher resources. +copyDirRecursively( + path.resolve(__dirname, '../src/plugins/system/android'), + path.resolve(__dirname, '../platforms/android/app/src/main/java') +); enableLegacyJni(); enableStaticContext(); disableSplashFadeOnRealmeAndroid13(); removeLegacyKeyboardWorkaround(); patchTargetSdkVersion(); +stripLauncherFilterFromMainActivity(); function getPackageName() { const configPath = path.resolve(__dirname, '../config.xml'); @@ -66,6 +73,43 @@ function getTmpDir() { } } +/** + * Removes the MAIN/LAUNCHER intent-filter from MainActivity in the generated + * AndroidManifest. The launcher role is handled by activity-aliases instead + * (see config.xml), so that switching app icons at runtime only toggles aliases + * and never disables the running MainActivity component (which would otherwise + * force-stop/restart the app). + */ +function stripLauncherFilterFromMainActivity() { + const prefix = execSync('npm prefix').toString().trim(); + const manifestPath = path.join( + prefix, + 'platforms/android/app/src/main/AndroidManifest.xml' + ); + + if (!fs.existsSync(manifestPath)) { + console.warn('[Cordova Hook] ⚠️ AndroidManifest.xml not found'); + return; + } + + let content = fs.readFileSync(manifestPath, 'utf-8'); + const updated = content.replace( + /(]*\bname="MainActivity"[^>]*>)\s*]*>\s*]*\/>\s*]*\/>\s*<\/intent-filter>/, + '$1' + ); + + if (updated !== content) { + fs.writeFileSync(manifestPath, updated, 'utf-8'); + console.log( + '[Cordova Hook] ✅ Removed launcher intent-filter from MainActivity' + ); + } else { + console.log( + '[Cordova Hook] ✅ Launcher intent-filter already removed, skipping' + ); + } +} + function patchTargetSdkVersion() { const prefix = execSync('npm prefix').toString().trim(); const gradleFile = path.join(prefix, 'platforms/android/app/build.gradle'); diff --git a/package-lock.json b/package-lock.json index aebffe68b0..7550aea35f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6099,9 +6099,9 @@ "peer": true }, "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", "license": "MIT", "peer": true, "dependencies": { @@ -8839,21 +8839,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -8867,42 +8852,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", @@ -10715,17 +10664,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" @@ -10741,6 +10690,9 @@ "@minify-html/node": { "optional": true }, + "@napi-rs/image": { + "optional": true + }, "@swc/core": { "optional": true }, @@ -10765,12 +10717,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 } @@ -13106,9 +13067,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, @@ -13760,9 +13721,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, @@ -13777,11 +13738,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/package.json b/package.json index c223b7aa05..2a2a7deae2 100644 --- a/package.json +++ b/package.json @@ -45,10 +45,10 @@ "com.foxdebug.acode.rk.exec.proot": {}, "cordova-plugin-iap": {}, "com.foxdebug.acode.rk.customtabs": {}, - "cordova-plugin-system": {}, "cordova-plugin-crashhandler": {}, "cordova-plugin-advanced-http": {}, - "cordova-plugin-acode-webview": {} + "cordova-plugin-acode-webview": {}, + "cordova-plugin-system": {} }, "platforms": [ "android" diff --git a/res/android/drawable/ic_acode_aurora_pulse_background.xml b/res/android/drawable/ic_acode_aurora_pulse_background.xml new file mode 100644 index 0000000000..8a22a85ce9 --- /dev/null +++ b/res/android/drawable/ic_acode_aurora_pulse_background.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/android/drawable/ic_acode_aurora_pulse_foreground.xml b/res/android/drawable/ic_acode_aurora_pulse_foreground.xml new file mode 100644 index 0000000000..be024ec1ed --- /dev/null +++ b/res/android/drawable/ic_acode_aurora_pulse_foreground.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + diff --git a/res/android/drawable/ic_acode_blueprint_background.xml b/res/android/drawable/ic_acode_blueprint_background.xml new file mode 100644 index 0000000000..a5d145a4f7 --- /dev/null +++ b/res/android/drawable/ic_acode_blueprint_background.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/res/android/drawable/ic_acode_blueprint_foreground.xml b/res/android/drawable/ic_acode_blueprint_foreground.xml new file mode 100644 index 0000000000..cadd5f8c44 --- /dev/null +++ b/res/android/drawable/ic_acode_blueprint_foreground.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/android/drawable/ic_acode_midnight_circuit_background.xml b/res/android/drawable/ic_acode_midnight_circuit_background.xml new file mode 100644 index 0000000000..523498c8a9 --- /dev/null +++ b/res/android/drawable/ic_acode_midnight_circuit_background.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/android/drawable/ic_acode_midnight_circuit_foreground.xml b/res/android/drawable/ic_acode_midnight_circuit_foreground.xml new file mode 100644 index 0000000000..f5a20b48e5 --- /dev/null +++ b/res/android/drawable/ic_acode_midnight_circuit_foreground.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/android/drawable/ic_acode_monochrome.xml b/res/android/drawable/ic_acode_monochrome.xml new file mode 100644 index 0000000000..7efafe6edf --- /dev/null +++ b/res/android/drawable/ic_acode_monochrome.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/res/android/drawable/ic_acode_pixel_party_background.xml b/res/android/drawable/ic_acode_pixel_party_background.xml new file mode 100644 index 0000000000..885b6071b2 --- /dev/null +++ b/res/android/drawable/ic_acode_pixel_party_background.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/res/android/drawable/ic_acode_pixel_party_foreground.xml b/res/android/drawable/ic_acode_pixel_party_foreground.xml new file mode 100644 index 0000000000..e4de7dcaa6 --- /dev/null +++ b/res/android/drawable/ic_acode_pixel_party_foreground.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000000..d087b0ef40 --- /dev/null +++ b/res/android/drawable/ic_acode_solar_flare_background.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/res/android/drawable/ic_acode_solar_flare_foreground.xml b/res/android/drawable/ic_acode_solar_flare_foreground.xml new file mode 100644 index 0000000000..f6dca5d8fe --- /dev/null +++ b/res/android/drawable/ic_acode_solar_flare_foreground.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + diff --git a/res/android/drawable/ic_acode_terminal_glow_background.xml b/res/android/drawable/ic_acode_terminal_glow_background.xml new file mode 100644 index 0000000000..36c8d69d38 --- /dev/null +++ b/res/android/drawable/ic_acode_terminal_glow_background.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/res/android/drawable/ic_acode_terminal_glow_foreground.xml b/res/android/drawable/ic_acode_terminal_glow_foreground.xml new file mode 100644 index 0000000000..ea4edbb646 --- /dev/null +++ b/res/android/drawable/ic_acode_terminal_glow_foreground.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_aurora_pulse.xml b/res/android/mipmap-anydpi-v26/ic_acode_aurora_pulse.xml new file mode 100644 index 0000000000..6b8c715762 --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_aurora_pulse.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_aurora_pulse_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_aurora_pulse_round.xml new file mode 100644 index 0000000000..6b8c715762 --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_aurora_pulse_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_blueprint.xml b/res/android/mipmap-anydpi-v26/ic_acode_blueprint.xml new file mode 100644 index 0000000000..6415762a4d --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_blueprint.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_blueprint_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_blueprint_round.xml new file mode 100644 index 0000000000..6415762a4d --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_blueprint_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_midnight_circuit.xml b/res/android/mipmap-anydpi-v26/ic_acode_midnight_circuit.xml new file mode 100644 index 0000000000..5b709ba70f --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_midnight_circuit.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_midnight_circuit_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_midnight_circuit_round.xml new file mode 100644 index 0000000000..5b709ba70f --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_midnight_circuit_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_pixel_party.xml b/res/android/mipmap-anydpi-v26/ic_acode_pixel_party.xml new file mode 100644 index 0000000000..a622de3912 --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_pixel_party.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_pixel_party_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_pixel_party_round.xml new file mode 100644 index 0000000000..a622de3912 --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_pixel_party_round.xml @@ -0,0 +1,6 @@ + + + + + + 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/res/android/mipmap-anydpi-v26/ic_acode_solar_flare.xml b/res/android/mipmap-anydpi-v26/ic_acode_solar_flare.xml new file mode 100644 index 0000000000..8d0f1caf5e --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_solar_flare.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_solar_flare_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_solar_flare_round.xml new file mode 100644 index 0000000000..8d0f1caf5e --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_solar_flare_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_terminal_glow.xml b/res/android/mipmap-anydpi-v26/ic_acode_terminal_glow.xml new file mode 100644 index 0000000000..7f47bfab6a --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_terminal_glow.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_terminal_glow_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_terminal_glow_round.xml new file mode 100644 index 0000000000..7f47bfab6a --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_terminal_glow_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-hdpi/ic_acode_aurora_pulse.webp b/res/android/mipmap-hdpi/ic_acode_aurora_pulse.webp new file mode 100644 index 0000000000..39deea40e1 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_aurora_pulse.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_aurora_pulse_round.webp b/res/android/mipmap-hdpi/ic_acode_aurora_pulse_round.webp new file mode 100644 index 0000000000..c9cb2bd1ac Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_aurora_pulse_round.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_blueprint.webp b/res/android/mipmap-hdpi/ic_acode_blueprint.webp new file mode 100644 index 0000000000..42cf0c189d Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_blueprint.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_blueprint_round.webp b/res/android/mipmap-hdpi/ic_acode_blueprint_round.webp new file mode 100644 index 0000000000..bca3c15ee5 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_blueprint_round.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_midnight_circuit.webp b/res/android/mipmap-hdpi/ic_acode_midnight_circuit.webp new file mode 100644 index 0000000000..cb16b9adc4 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_midnight_circuit.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_midnight_circuit_round.webp b/res/android/mipmap-hdpi/ic_acode_midnight_circuit_round.webp new file mode 100644 index 0000000000..8513056d20 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_midnight_circuit_round.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_pixel_party.webp b/res/android/mipmap-hdpi/ic_acode_pixel_party.webp new file mode 100644 index 0000000000..0ac4cb690d Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_pixel_party.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_pixel_party_round.webp b/res/android/mipmap-hdpi/ic_acode_pixel_party_round.webp new file mode 100644 index 0000000000..30626db1c1 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_pixel_party_round.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_solar_flare.webp b/res/android/mipmap-hdpi/ic_acode_solar_flare.webp new file mode 100644 index 0000000000..fd2d385601 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_solar_flare.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_solar_flare_round.webp b/res/android/mipmap-hdpi/ic_acode_solar_flare_round.webp new file mode 100644 index 0000000000..da58ca975f Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_solar_flare_round.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_terminal_glow.webp b/res/android/mipmap-hdpi/ic_acode_terminal_glow.webp new file mode 100644 index 0000000000..71b0ff5e2e Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_terminal_glow.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_terminal_glow_round.webp b/res/android/mipmap-hdpi/ic_acode_terminal_glow_round.webp new file mode 100644 index 0000000000..37d2c5c20b Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_terminal_glow_round.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_aurora_pulse.webp b/res/android/mipmap-mdpi/ic_acode_aurora_pulse.webp new file mode 100644 index 0000000000..70f922685e Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_aurora_pulse.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_aurora_pulse_round.webp b/res/android/mipmap-mdpi/ic_acode_aurora_pulse_round.webp new file mode 100644 index 0000000000..625f333f3f Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_aurora_pulse_round.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_blueprint.webp b/res/android/mipmap-mdpi/ic_acode_blueprint.webp new file mode 100644 index 0000000000..39471e5979 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_blueprint.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_blueprint_round.webp b/res/android/mipmap-mdpi/ic_acode_blueprint_round.webp new file mode 100644 index 0000000000..f987b87e09 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_blueprint_round.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_midnight_circuit.webp b/res/android/mipmap-mdpi/ic_acode_midnight_circuit.webp new file mode 100644 index 0000000000..67aed7b99e Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_midnight_circuit.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_midnight_circuit_round.webp b/res/android/mipmap-mdpi/ic_acode_midnight_circuit_round.webp new file mode 100644 index 0000000000..019f90f831 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_midnight_circuit_round.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_pixel_party.webp b/res/android/mipmap-mdpi/ic_acode_pixel_party.webp new file mode 100644 index 0000000000..4f5702fda9 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_pixel_party.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_pixel_party_round.webp b/res/android/mipmap-mdpi/ic_acode_pixel_party_round.webp new file mode 100644 index 0000000000..deac35bbac Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_pixel_party_round.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_solar_flare.webp b/res/android/mipmap-mdpi/ic_acode_solar_flare.webp new file mode 100644 index 0000000000..c7cbf81bf2 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_solar_flare.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_solar_flare_round.webp b/res/android/mipmap-mdpi/ic_acode_solar_flare_round.webp new file mode 100644 index 0000000000..ed64b73c22 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_solar_flare_round.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_terminal_glow.webp b/res/android/mipmap-mdpi/ic_acode_terminal_glow.webp new file mode 100644 index 0000000000..100406746e Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_terminal_glow.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_terminal_glow_round.webp b/res/android/mipmap-mdpi/ic_acode_terminal_glow_round.webp new file mode 100644 index 0000000000..9e5d1de40b Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_terminal_glow_round.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_aurora_pulse.webp b/res/android/mipmap-xhdpi/ic_acode_aurora_pulse.webp new file mode 100644 index 0000000000..f1c02e51d2 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_aurora_pulse.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_aurora_pulse_round.webp b/res/android/mipmap-xhdpi/ic_acode_aurora_pulse_round.webp new file mode 100644 index 0000000000..d0954ac52f Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_aurora_pulse_round.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_blueprint.webp b/res/android/mipmap-xhdpi/ic_acode_blueprint.webp new file mode 100644 index 0000000000..eb9c22ffa5 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_blueprint.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_blueprint_round.webp b/res/android/mipmap-xhdpi/ic_acode_blueprint_round.webp new file mode 100644 index 0000000000..9ee9d89350 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_blueprint_round.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_midnight_circuit.webp b/res/android/mipmap-xhdpi/ic_acode_midnight_circuit.webp new file mode 100644 index 0000000000..881a1aecf9 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_midnight_circuit.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_midnight_circuit_round.webp b/res/android/mipmap-xhdpi/ic_acode_midnight_circuit_round.webp new file mode 100644 index 0000000000..96207773f7 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_midnight_circuit_round.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_pixel_party.webp b/res/android/mipmap-xhdpi/ic_acode_pixel_party.webp new file mode 100644 index 0000000000..df57252484 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_pixel_party.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_pixel_party_round.webp b/res/android/mipmap-xhdpi/ic_acode_pixel_party_round.webp new file mode 100644 index 0000000000..4514e58f71 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_pixel_party_round.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_solar_flare.webp b/res/android/mipmap-xhdpi/ic_acode_solar_flare.webp new file mode 100644 index 0000000000..0f217b7e15 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_solar_flare.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_solar_flare_round.webp b/res/android/mipmap-xhdpi/ic_acode_solar_flare_round.webp new file mode 100644 index 0000000000..5423db41bf Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_solar_flare_round.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_terminal_glow.webp b/res/android/mipmap-xhdpi/ic_acode_terminal_glow.webp new file mode 100644 index 0000000000..fb96a64a8e Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_terminal_glow.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_terminal_glow_round.webp b/res/android/mipmap-xhdpi/ic_acode_terminal_glow_round.webp new file mode 100644 index 0000000000..8d62dfe0b2 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_terminal_glow_round.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_aurora_pulse.webp b/res/android/mipmap-xxhdpi/ic_acode_aurora_pulse.webp new file mode 100644 index 0000000000..28a57cc13b Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_aurora_pulse.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_aurora_pulse_round.webp b/res/android/mipmap-xxhdpi/ic_acode_aurora_pulse_round.webp new file mode 100644 index 0000000000..7cd2079a46 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_aurora_pulse_round.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_blueprint.webp b/res/android/mipmap-xxhdpi/ic_acode_blueprint.webp new file mode 100644 index 0000000000..78078a8b05 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_blueprint.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_blueprint_round.webp b/res/android/mipmap-xxhdpi/ic_acode_blueprint_round.webp new file mode 100644 index 0000000000..b808964308 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_blueprint_round.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_midnight_circuit.webp b/res/android/mipmap-xxhdpi/ic_acode_midnight_circuit.webp new file mode 100644 index 0000000000..858a8a950f Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_midnight_circuit.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_midnight_circuit_round.webp b/res/android/mipmap-xxhdpi/ic_acode_midnight_circuit_round.webp new file mode 100644 index 0000000000..de722e07c2 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_midnight_circuit_round.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_pixel_party.webp b/res/android/mipmap-xxhdpi/ic_acode_pixel_party.webp new file mode 100644 index 0000000000..f70c7da953 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_pixel_party.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_pixel_party_round.webp b/res/android/mipmap-xxhdpi/ic_acode_pixel_party_round.webp new file mode 100644 index 0000000000..e3953df9ee Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_pixel_party_round.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_solar_flare.webp b/res/android/mipmap-xxhdpi/ic_acode_solar_flare.webp new file mode 100644 index 0000000000..9f172e6cf8 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_solar_flare.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_solar_flare_round.webp b/res/android/mipmap-xxhdpi/ic_acode_solar_flare_round.webp new file mode 100644 index 0000000000..c9ae98cb08 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_solar_flare_round.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_terminal_glow.webp b/res/android/mipmap-xxhdpi/ic_acode_terminal_glow.webp new file mode 100644 index 0000000000..6dc9706dd5 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_terminal_glow.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_terminal_glow_round.webp b/res/android/mipmap-xxhdpi/ic_acode_terminal_glow_round.webp new file mode 100644 index 0000000000..44739c9f26 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_terminal_glow_round.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_aurora_pulse.webp b/res/android/mipmap-xxxhdpi/ic_acode_aurora_pulse.webp new file mode 100644 index 0000000000..c05f83a87f Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_aurora_pulse.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_aurora_pulse_round.webp b/res/android/mipmap-xxxhdpi/ic_acode_aurora_pulse_round.webp new file mode 100644 index 0000000000..8b7a9e5d82 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_aurora_pulse_round.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_blueprint.webp b/res/android/mipmap-xxxhdpi/ic_acode_blueprint.webp new file mode 100644 index 0000000000..04dcc97dd4 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_blueprint.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_blueprint_round.webp b/res/android/mipmap-xxxhdpi/ic_acode_blueprint_round.webp new file mode 100644 index 0000000000..63e5981b27 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_blueprint_round.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_midnight_circuit.webp b/res/android/mipmap-xxxhdpi/ic_acode_midnight_circuit.webp new file mode 100644 index 0000000000..d3c296fd15 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_midnight_circuit.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_midnight_circuit_round.webp b/res/android/mipmap-xxxhdpi/ic_acode_midnight_circuit_round.webp new file mode 100644 index 0000000000..7483fbb3b9 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_midnight_circuit_round.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_pixel_party.webp b/res/android/mipmap-xxxhdpi/ic_acode_pixel_party.webp new file mode 100644 index 0000000000..d453fe7be0 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_pixel_party.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_pixel_party_round.webp b/res/android/mipmap-xxxhdpi/ic_acode_pixel_party_round.webp new file mode 100644 index 0000000000..21b19bc5c5 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_pixel_party_round.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_solar_flare.webp b/res/android/mipmap-xxxhdpi/ic_acode_solar_flare.webp new file mode 100644 index 0000000000..5295472a05 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_solar_flare.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_solar_flare_round.webp b/res/android/mipmap-xxxhdpi/ic_acode_solar_flare_round.webp new file mode 100644 index 0000000000..fbe5fb4ce3 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_solar_flare_round.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_terminal_glow.webp b/res/android/mipmap-xxxhdpi/ic_acode_terminal_glow.webp new file mode 100644 index 0000000000..0d792f43e5 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_terminal_glow.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_terminal_glow_round.webp b/res/android/mipmap-xxxhdpi/ic_acode_terminal_glow_round.webp new file mode 100644 index 0000000000..dc90dfe269 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_terminal_glow_round.webp differ diff --git a/src/dialogs/confirm.js b/src/dialogs/confirm.js index 393dd592a0..2145c22ba2 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, direction?: "ltr" | "rtl", aboveOverlay?: boolean}} [options] + * @param {{checkboxText?: string, returnState?: boolean, signal?: AbortSignal, direction?: "ltr" | "rtl", aboveOverlay?: boolean}} [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${options.aboveOverlay ? " above-overlay" : ""}`, @@ -72,24 +76,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 9149ce9f1b..f593714cca 100644 --- a/src/lang/ar-ye.json +++ b/src/lang/ar-ye.json @@ -717,7 +717,10 @@ "pinned tab": "تبويب مثبت", "unpin tab before closing": "قم بإلغاء تثبيت التبويب قبل إغلاقه.", "app font": "خط التطبيق", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "اختيار الخط المستخدم في واجهة التطبيق.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (تشغيل أمر ثنائي)", "lsp-transport-method-websocket": "WebSocket (الاتصال عبر رابط ws/wss)", "lsp-websocket-url": "رابط WebSocket", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "هل تريد مشاهدة إعلان بمكافأة لتطبيق أيقونة التطبيق هذه؟", + "rewarded ad unavailable": "الإعلانات بمكافأة غير متاحة الآن. يُرجى المحاولة مرة أخرى.", + "rewarded ad failed": "تعذر إكمال الإعلان. يُرجى المحاولة مرة أخرى.", + "rewarded ad incomplete": "شاهد الإعلان حتى النهاية للحصول على المكافأة.", "plugin-review:dialog-label": "تقييمات الإضافة", "plugin-review:account-check-failed": "تعذّر التحقق من حسابك. حاول مرة أخرى.", "plugin-review:sign-in": "سجّل الدخول لكتابة تقييم", diff --git a/src/lang/be-by.json b/src/lang/be-by.json index 240f92549a..14562075a7 100644 --- a/src/lang/be-by.json +++ b/src/lang/be-by.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "Паглядзець рэкламу з узнагародай, каб ужыць гэты значок праграмы?", + "rewarded ad unavailable": "Рэклама з узнагародай зараз недаступная. Паспрабуйце яшчэ раз.", + "rewarded ad failed": "Не ўдалося завяршыць прагляд рэкламы. Паспрабуйце яшчэ раз.", + "rewarded ad incomplete": "Даглядзіце рэкламу да канца, каб атрымаць узнагароду.", "plugin-review:dialog-label": "Водгукі пра плагін", "plugin-review:account-check-failed": "Не ўдалося праверыць ваш уліковы запіс. Паспрабуйце яшчэ раз.", "plugin-review:sign-in": "Увайсці, каб пакінуць водгук", diff --git a/src/lang/bn-bd.json b/src/lang/bn-bd.json index 3dad218691..75cc364b1f 100644 --- a/src/lang/bn-bd.json +++ b/src/lang/bn-bd.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "এই অ্যাপ আইকনটি প্রয়োগ করতে একটি পুরস্কারযুক্ত বিজ্ঞাপন দেখবেন?", + "rewarded ad unavailable": "পুরস্কারযুক্ত বিজ্ঞাপন এখন উপলভ্য নেই। অনুগ্রহ করে আবার চেষ্টা করুন।", + "rewarded ad failed": "বিজ্ঞাপনটি সম্পূর্ণ করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।", + "rewarded ad incomplete": "পুরস্কার পেতে বিজ্ঞাপনটি সম্পূর্ণ দেখুন।", "plugin-review:dialog-label": "প্লাগইন পর্যালোচনা", "plugin-review:account-check-failed": "আপনার অ্যাকাউন্ট যাচাই করা যায়নি। আবার চেষ্টা করুন।", "plugin-review:sign-in": "পর্যালোচনা দিতে সাইন ইন করুন", diff --git a/src/lang/cs-cz.json b/src/lang/cs-cz.json index 6e3070fffb..38b332d152 100644 --- a/src/lang/cs-cz.json +++ b/src/lang/cs-cz.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "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.", "plugin-review:dialog-label": "Recenze pluginu", "plugin-review:account-check-failed": "Váš účet se nepodařilo ověřit. Zkuste to znovu.", "plugin-review:sign-in": "Přihlásit se a napsat recenzi", diff --git a/src/lang/de-de.json b/src/lang/de-de.json index 157c1e985d..a3c9447c7f 100644 --- a/src/lang/de-de.json +++ b/src/lang/de-de.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "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.", "plugin-review:dialog-label": "Plugin-Bewertungen", "plugin-review:account-check-failed": "Dein Konto konnte nicht überprüft werden. Versuche es erneut.", "plugin-review:sign-in": "Zum Bewerten anmelden", diff --git a/src/lang/en-us.json b/src/lang/en-us.json index 23ae1c2679..2f7a159f7e 100644 --- a/src/lang/en-us.json +++ b/src/lang/en-us.json @@ -766,7 +766,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "unsaved selected tabs warning": "Some selected tabs are not saved. Choose what to do.", "save selected tabs": "Save selected tabs", "close selected tabs": "Close selected tabs", @@ -836,6 +839,10 @@ "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.", + "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.", "plugin-review:dialog-label": "Plugin reviews", "plugin-review:account-check-failed": "Unable to check your account. Try again.", "plugin-review:sign-in": "Sign in to review", diff --git a/src/lang/es-sv.json b/src/lang/es-sv.json index 18ee5d6146..276965e483 100644 --- a/src/lang/es-sv.json +++ b/src/lang/es-sv.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "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.", "plugin-review:dialog-label": "Reseñas del complemento", "plugin-review:account-check-failed": "No se pudo verificar tu cuenta. Inténtalo de nuevo.", "plugin-review:sign-in": "Inicia sesión para opinar", diff --git a/src/lang/fr-fr.json b/src/lang/fr-fr.json index 4f7b96c83e..5cf8f8b56f 100644 --- a/src/lang/fr-fr.json +++ b/src/lang/fr-fr.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "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.", "plugin-review:dialog-label": "Avis sur l’extension", "plugin-review:account-check-failed": "Impossible de vérifier votre compte. Réessayez.", "plugin-review:sign-in": "Se connecter pour donner un avis", diff --git a/src/lang/he-il.json b/src/lang/he-il.json index 5e65d36f14..4c306eb535 100644 --- a/src/lang/he-il.json +++ b/src/lang/he-il.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "לצפות בפרסומת מתגמלת כדי להחיל את סמל האפליקציה הזה?", + "rewarded ad unavailable": "פרסומות מתגמלות אינן זמינות כרגע. יש לנסות שוב.", + "rewarded ad failed": "לא ניתן היה להשלים את הפרסומת. יש לנסות שוב.", + "rewarded ad incomplete": "יש לצפות בפרסומת עד הסוף כדי לקבל את התגמול.", "plugin-review:dialog-label": "ביקורות על התוסף", "plugin-review:account-check-failed": "לא ניתן לבדוק את החשבון שלך. נסה שוב.", "plugin-review:sign-in": "יש להתחבר כדי לכתוב ביקורת", diff --git a/src/lang/hi-in.json b/src/lang/hi-in.json index f1ff016551..97857b6bc6 100644 --- a/src/lang/hi-in.json +++ b/src/lang/hi-in.json @@ -716,8 +716,11 @@ "unpin tab": "टैब अनपिन करें", "pinned tab": "पिन किया गया टैब", "unpin tab before closing": "Unpin the tab before closing it.", + "app icon": "App icon", + "app icon changed": "App icon changed", "app font": "ऐप फ़ॉन्ट", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "क्या इस ऐप आइकन को लागू करने के लिए इनाम वाला एक विज्ञापन देखना चाहते हैं?", + "rewarded ad unavailable": "इनाम वाले विज्ञापन अभी उपलब्ध नहीं हैं। कृपया फिर से कोशिश करें।", + "rewarded ad failed": "विज्ञापन पूरा नहीं हो सका। कृपया फिर से कोशिश करें।", + "rewarded ad incomplete": "इनाम पाने के लिए विज्ञापन पूरा देखें।", "plugin-review:dialog-label": "प्लगइन समीक्षाएँ", "plugin-review:account-check-failed": "आपके खाते की जाँच नहीं हो सकी। फिर से कोशिश करें।", "plugin-review:sign-in": "समीक्षा देने के लिए साइन इन करें", diff --git a/src/lang/hu-hu.json b/src/lang/hu-hu.json index f8e76f3ac0..5616f4f889 100644 --- a/src/lang/hu-hu.json +++ b/src/lang/hu-hu.json @@ -717,7 +717,10 @@ "pinned tab": "Rögzített lap", "unpin tab before closing": "Lap rögzítésének megszüntetése bezárás előtt.", "app font": "Alkalmazás betűtípusa", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Válassza ki az alkalmazás egész felületén használandó betűtípust.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (bináris parancs futtatása)", "lsp-transport-method-websocket": "WebSocket (kapcsolódás ws/wss webcímhez)", "lsp-websocket-url": "WebSocket webcím", @@ -836,6 +839,10 @@ "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.", + "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.", "plugin-review:dialog-label": "Bővítményértékelések", "plugin-review:account-check-failed": "Nem sikerült ellenőrizni a fiókját. Próbálja újra.", "plugin-review:sign-in": "Megjegyzés írásához be kell jelentkezni", diff --git a/src/lang/id-id.json b/src/lang/id-id.json index f2560fd6d8..9b38fe0ef1 100644 --- a/src/lang/id-id.json +++ b/src/lang/id-id.json @@ -717,7 +717,10 @@ "pinned tab": "Tab yang tersematkan", "unpin tab before closing": "Lepas sematan tab sebelum menutupnya.", "app font": "Font Aplikasi", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Pilih font yang digunakan di seluruh antarmuka aplikasi.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (luncurkan perintah biner)", "lsp-transport-method-websocket": "WebSocket (hubungkan ke sebuah URL ws/wss)", "lsp-websocket-url": "URL WebSocket", @@ -836,6 +839,10 @@ "settings-info-app-use-file-operation-exclusions": "Lewati berkas 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 pembungkus kata (word wrap) dinonaktifkan. Masukkan nilai piksel bilangan bulat non-negatif.", + "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.", "plugin-review:dialog-label": "Ulasan plugin", "plugin-review:account-check-failed": "Akun Anda tidak dapat diperiksa. Coba lagi.", "plugin-review:sign-in": "Masuk untuk memberi ulasan", diff --git a/src/lang/index.d.ts b/src/lang/index.d.ts index 5777e10477..b8291061ed 100644 --- a/src/lang/index.d.ts +++ b/src/lang/index.d.ts @@ -769,7 +769,10 @@ declare type LangStrings = { "pinned tab": string; "unpin tab before closing": string; "app font": string; + "app icon": string; + "app icon changed": string; "settings-info-app-font-family": string; + "settings-info-app-icon": string; "unsaved selected tabs warning": string; "save selected tabs": string; "close selected tabs": string; @@ -839,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; "plugin-review:dialog-label": string; "plugin-review:account-check-failed": string; "plugin-review:sign-in": string; diff --git a/src/lang/ir-fa.json b/src/lang/ir-fa.json index 7d9cbedea2..8d7957a6f7 100644 --- a/src/lang/ir-fa.json +++ b/src/lang/ir-fa.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "برای اعمال این نماد برنامه، یک تبلیغ پاداش‌دار تماشا می‌کنید؟", + "rewarded ad unavailable": "تبلیغات پاداش‌دار در حال حاضر در دسترس نیستند. لطفاً دوباره تلاش کنید.", + "rewarded ad failed": "تبلیغ کامل نشد. لطفاً دوباره تلاش کنید.", + "rewarded ad incomplete": "برای دریافت پاداش، تبلیغ را تا پایان تماشا کنید.", "plugin-review:dialog-label": "نظرهای افزونه", "plugin-review:account-check-failed": "امکان بررسی حساب شما نبود. دوباره تلاش کنید.", "plugin-review:sign-in": "برای ثبت نظر وارد شوید", diff --git a/src/lang/it-it.json b/src/lang/it-it.json index 28c6ba3bd0..9d09694c3c 100644 --- a/src/lang/it-it.json +++ b/src/lang/it-it.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "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.", "plugin-review:dialog-label": "Recensioni del plugin", "plugin-review:account-check-failed": "Impossibile verificare il tuo account. Riprova.", "plugin-review:sign-in": "Accedi per lasciare una recensione", diff --git a/src/lang/ja-jp.json b/src/lang/ja-jp.json index 2329d81b84..9901ac78ac 100644 --- a/src/lang/ja-jp.json +++ b/src/lang/ja-jp.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "リワード広告を1本視聴して、このアプリアイコンを適用しますか?", + "rewarded ad unavailable": "現在リワード広告を利用できません。もう一度お試しください。", + "rewarded ad failed": "広告を完了できませんでした。もう一度お試しください。", + "rewarded ad incomplete": "報酬を獲得するには、広告を最後まで視聴してください。", "plugin-review:dialog-label": "プラグインのレビュー", "plugin-review:account-check-failed": "アカウントを確認できませんでした。もう一度お試しください。", "plugin-review:sign-in": "ログインしてレビュー", diff --git a/src/lang/ko-kr.json b/src/lang/ko-kr.json index d8d1daec75..1e1a369da5 100644 --- a/src/lang/ko-kr.json +++ b/src/lang/ko-kr.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "보상형 광고 한 편을 시청하고 이 앱 아이콘을 적용할까요?", + "rewarded ad unavailable": "현재 보상형 광고를 이용할 수 없습니다. 다시 시도해 주세요.", + "rewarded ad failed": "광고를 완료하지 못했습니다. 다시 시도해 주세요.", + "rewarded ad incomplete": "보상을 받으려면 광고를 끝까지 시청하세요.", "plugin-review:dialog-label": "플러그인 리뷰", "plugin-review:account-check-failed": "계정을 확인할 수 없습니다. 다시 시도하세요.", "plugin-review:sign-in": "로그인하고 리뷰 작성", diff --git a/src/lang/ln-ln.json b/src/lang/ln-ln.json index e0a4fb68a8..b49912e1e3 100644 --- a/src/lang/ln-ln.json +++ b/src/lang/ln-ln.json @@ -88,7 +88,6 @@ "text wrap": "Kokanga milongo ya makomi", "theme": "Motema ya app", "title-listfiles": "Tanga ba fichiers", - "Ekoki te kolongola fichier": "unable to delete file", "unable to open file": "Limbisa, ekoki te kofungola fichier", "unable to open folder": "Limbisa, ekoki te kofungola dossier", "unable to save file": "Limbisa, ekoki te kobomba fichier", @@ -241,7 +240,6 @@ "no plugins found": "Plugin moko te ezwami", "name": "Kombo", "username": "Kombo ya mosaleli", - "ekoki kozala te": "optional", "hostname": "Hostname", "password": "Liloba ya sekele", "security type": "Lolenge ya libateli", @@ -272,8 +270,6 @@ "retry ftp/sftp when fail": "Meka FTP/SFTP lisusu soki elongi te", "more": "Mosusu", "thank you :)": "Matondi :)", - "Kosomba ezali kozela": "Kosomba ezali kozela", - "Ebengami": "Ebengami", "local": "Ya mboka", "remote": "Ya mosika", "show console toggler": "Lakisa bouton ya console", @@ -835,5 +831,48 @@ "horizontal scroll margin": "Marge ya kokita na ngámbo", "settings-info-horizontal-scroll-margin": "Bakisa esika ya pamba nsima ya ligne oyo eleki molai ntango retour à la ligne ezali kosalelama te. Tia motuya mobimba ya pixels oyo ezali négatif te.", "conflict error": "Matata! Zela liboso ya kosala commit mosusu.", - "terminal:failsafe": "Mode FailSafe" + "terminal:failsafe": "Mode FailSafe", + "app icon": "App icon", + "app icon changed": "App icon changed", + "settings-info-app-icon": "Choose the app icon displayed on your device.", + "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.", + "unable to delete file": "unable to delete file", + "optional": "optional", + "purchase pending": "purchase pending", + "cancelled": "cancelled", + "plugin-review:dialog-label": "Plugin reviews", + "plugin-review:account-check-failed": "Unable to check your account. Try again.", + "plugin-review:sign-in": "Sign in to review", + "plugin-review:edit": "Edit your review", + "plugin-review:add": "Add a review...", + "plugin-review:placeholder": "Share your experience...", + "plugin-review:input-label": "Plugin review", + "plugin-review:vote-group-label": "Vote", + "plugin-review:thumbs-up": "Thumbs up", + "plugin-review:thumbs-down": "Thumbs down", + "plugin-review:delete": "Delete review", + "plugin-review:load-failed": "Unable to load reviews.", + "plugin-review:retry": "Retry", + "plugin-review:empty-title": "No reviews yet", + "plugin-review:empty-description": "Be the first to share your experience.", + "plugin-review:yours": "Your review", + "plugin-review:community": "Community reviews", + "plugin-review:no-other-reviews": "No other reviews yet.", + "plugin-review:login-incomplete": "Login was not completed.", + "plugin-review:sign-in-failed": "Unable to sign in.", + "plugin-review:session-expired-review": "Your session expired. Sign in again to review.", + "plugin-review:save-failed": "Unable to save review.", + "plugin-review:delete-confirmation": "Delete your review? This cannot be undone.", + "plugin-review:session-expired-continue": "Your session expired. Sign in again to continue.", + "plugin-review:delete-failed": "Unable to delete review.", + "plugin-review:you": "You", + "plugin-review:edit-action": "Edit review", + "plugin-review:recommended": "Recommended this plugin", + "plugin-review:error-max-length": "Review must be {max} characters or less.", + "plugin-review:error-downvote-comment": "Tell the developer what could be improved.", + "plugin-review:error-empty": "Add a comment or choose thumbs up.", + "plugin-review:unrated": "Unrated" } diff --git a/src/lang/ml-in.json b/src/lang/ml-in.json index 137e1311d2..d0f3823fea 100644 --- a/src/lang/ml-in.json +++ b/src/lang/ml-in.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "ഈ ആപ്പ് ഐക്കൺ ഉപയോഗിക്കാൻ പ്രതിഫലമുള്ള ഒരു പരസ്യം കാണണോ?", + "rewarded ad unavailable": "പ്രതിഫലമുള്ള പരസ്യങ്ങൾ ഇപ്പോൾ ലഭ്യമല്ല. ദയവായി വീണ്ടും ശ്രമിക്കുക.", + "rewarded ad failed": "പരസ്യം പൂർത്തിയാക്കാനായില്ല. ദയവായി വീണ്ടും ശ്രമിക്കുക.", + "rewarded ad incomplete": "പ്രതിഫലം ലഭിക്കാൻ പരസ്യം മുഴുവനായി കാണുക.", "plugin-review:dialog-label": "പ്ലഗിൻ അവലോകനങ്ങൾ", "plugin-review:account-check-failed": "നിങ്ങളുടെ അക്കൗണ്ട് പരിശോധിക്കാനായില്ല. വീണ്ടും ശ്രമിക്കുക.", "plugin-review:sign-in": "അവലോകനം നൽകാൻ സൈൻ ഇൻ ചെയ്യുക", diff --git a/src/lang/mm-unicode.json b/src/lang/mm-unicode.json index 2a504340ee..9f67ac2983 100644 --- a/src/lang/mm-unicode.json +++ b/src/lang/mm-unicode.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "ဤအက်ပ်အိုင်ကွန်ကို အသုံးပြုရန် ဆုရကြော်ငြာတစ်ခုကို ကြည့်မလား။", + "rewarded ad unavailable": "ဆုရကြော်ငြာများကို လောလောဆယ် မရနိုင်ပါ။ ထပ်မံကြိုးစားပါ။", + "rewarded ad failed": "ကြော်ငြာကို အဆုံးအထိ မပြသနိုင်ခဲ့ပါ။ ထပ်မံကြိုးစားပါ။", + "rewarded ad incomplete": "ဆုရရှိရန် ကြော်ငြာကို အဆုံးအထိ ကြည့်ပါ။", "plugin-review:dialog-label": "ပလပ်အင် သုံးသပ်ချက်များ", "plugin-review:account-check-failed": "သင့်အကောင့်ကို စစ်ဆေး၍မရပါ။ ထပ်မံကြိုးစားပါ။", "plugin-review:sign-in": "သုံးသပ်ချက်ရေးရန် အကောင့်ဝင်ပါ", diff --git a/src/lang/mm-zawgyi.json b/src/lang/mm-zawgyi.json index 2962e24868..de23b6e30c 100644 --- a/src/lang/mm-zawgyi.json +++ b/src/lang/mm-zawgyi.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "ဤအက္ပ္အိုင္ကြန္ကို အသုံးျပဳရန္ ဆုရေၾကာ္ျငာတစ္ခုကို ၾကည့္မလား။", + "rewarded ad unavailable": "ဆုရေၾကာ္ျငာမ်ားကို ေလာေလာဆယ္ မရႏိုင္ပါ။ ထပ္မံႀကိဳးစားပါ။", + "rewarded ad failed": "ေၾကာ္ျငာကို အဆုံးအထိ မျပသႏိုင္ခဲ့ပါ။ ထပ္မံႀကိဳးစားပါ။", + "rewarded ad incomplete": "ဆုရရွိရန္ ေၾကာ္ျငာကို အဆုံးအထိ ၾကည့္ပါ။", "plugin-review:dialog-label": "ပလပ္အင္ သုံးသပ္ခ်က္မ်ား", "plugin-review:account-check-failed": "သင့္အေကာင့္ကို စစ္ေဆး၍မရပါ။ ထပ္မံႀကိဳးစားပါ။", "plugin-review:sign-in": "သုံးသပ္ခ်က္ေရးရန္ အေကာင့္ဝင္ပါ", diff --git a/src/lang/pl-pl.json b/src/lang/pl-pl.json index 9f9aa52498..890c7082b7 100644 --- a/src/lang/pl-pl.json +++ b/src/lang/pl-pl.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "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ę.", "plugin-review:dialog-label": "Opinie o wtyczce", "plugin-review:account-check-failed": "Nie udało się sprawdzić Twojego konta. Spróbuj ponownie.", "plugin-review:sign-in": "Zaloguj się, aby dodać opinię", diff --git a/src/lang/pt-br.json b/src/lang/pt-br.json index 1affbea199..09938cbf79 100644 --- a/src/lang/pt-br.json +++ b/src/lang/pt-br.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "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.", "plugin-review:dialog-label": "Avaliações do plugin", "plugin-review:account-check-failed": "Não foi possível verificar sua conta. Tente novamente.", "plugin-review:sign-in": "Entre para avaliar", diff --git a/src/lang/pu-in.json b/src/lang/pu-in.json index 22734a25c9..7f29eb6f43 100644 --- a/src/lang/pu-in.json +++ b/src/lang/pu-in.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "ਕੀ ਇਸ ਐਪ ਆਈਕਨ ਨੂੰ ਲਾਗੂ ਕਰਨ ਲਈ ਇੱਕ ਇਨਾਮ ਵਾਲਾ ਇਸ਼ਤਿਹਾਰ ਦੇਖਣਾ ਹੈ?", + "rewarded ad unavailable": "ਇਨਾਮ ਵਾਲੇ ਇਸ਼ਤਿਹਾਰ ਇਸ ਵੇਲੇ ਉਪਲਬਧ ਨਹੀਂ ਹਨ। ਕਿਰਪਾ ਕਰਕੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।", + "rewarded ad failed": "ਇਸ਼ਤਿਹਾਰ ਪੂਰਾ ਨਹੀਂ ਹੋ ਸਕਿਆ। ਕਿਰਪਾ ਕਰਕੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।", + "rewarded ad incomplete": "ਇਨਾਮ ਪ੍ਰਾਪਤ ਕਰਨ ਲਈ ਇਸ਼ਤਿਹਾਰ ਪੂਰਾ ਦੇਖੋ।", "plugin-review:dialog-label": "ਪਲੱਗਇਨ ਸਮੀਖਿਆਵਾਂ", "plugin-review:account-check-failed": "ਤੁਹਾਡੇ ਖਾਤੇ ਦੀ ਜਾਂਚ ਨਹੀਂ ਹੋ ਸਕੀ। ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।", "plugin-review:sign-in": "ਸਮੀਖਿਆ ਦੇਣ ਲਈ ਸਾਈਨ ਇਨ ਕਰੋ", diff --git a/src/lang/ru-ru.json b/src/lang/ru-ru.json index 1d28242fce..0453983187 100644 --- a/src/lang/ru-ru.json +++ b/src/lang/ru-ru.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "Посмотреть рекламу с вознаграждением, чтобы применить этот значок приложения?", + "rewarded ad unavailable": "Реклама с вознаграждением сейчас недоступна. Повторите попытку.", + "rewarded ad failed": "Не удалось завершить просмотр рекламы. Повторите попытку.", + "rewarded ad incomplete": "Досмотрите рекламу до конца, чтобы получить награду.", "plugin-review:dialog-label": "Отзывы о плагине", "plugin-review:account-check-failed": "Не удалось проверить вашу учётную запись. Повторите попытку.", "plugin-review:sign-in": "Войти, чтобы оставить отзыв", diff --git a/src/lang/tl-ph.json b/src/lang/tl-ph.json index 811030d742..3342744dec 100644 --- a/src/lang/tl-ph.json +++ b/src/lang/tl-ph.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "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.", "plugin-review:dialog-label": "Mga review ng plugin", "plugin-review:account-check-failed": "Hindi masuri ang iyong account. Subukan muli.", "plugin-review:sign-in": "Mag-sign in para mag-review", diff --git a/src/lang/tr-tr.json b/src/lang/tr-tr.json index 742f48ad5f..68bc5dafb7 100644 --- a/src/lang/tr-tr.json +++ b/src/lang/tr-tr.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "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.", "plugin-review:dialog-label": "Eklenti değerlendirmeleri", "plugin-review:account-check-failed": "Hesabınız kontrol edilemedi. Tekrar deneyin.", "plugin-review:sign-in": "Değerlendirmek için oturum açın", diff --git a/src/lang/uk-ua.json b/src/lang/uk-ua.json index b6522b1d60..062101b415 100644 --- a/src/lang/uk-ua.json +++ b/src/lang/uk-ua.json @@ -717,7 +717,10 @@ "pinned tab": "Закріплена вкладка", "unpin tab before closing": "Відкріпіть вкладку перед її закриттям.", "app font": "Шрифт програми", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Виберіть шрифт, який буде використовуватися в інтерфейсі програми.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (запуск бінарної команди)", "lsp-transport-method-websocket": "WebSocket (підключення до URL-адреси ws/wss)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "Переглянути рекламу з винагородою, щоб застосувати цю піктограму застосунку?", + "rewarded ad unavailable": "Реклама з винагородою зараз недоступна. Спробуйте ще раз.", + "rewarded ad failed": "Не вдалося завершити перегляд реклами. Спробуйте ще раз.", + "rewarded ad incomplete": "Додивіться рекламу до кінця, щоб отримати винагороду.", "plugin-review:dialog-label": "Відгуки про плагін", "plugin-review:account-check-failed": "Не вдалося перевірити ваш обліковий запис. Спробуйте ще раз.", "plugin-review:sign-in": "Увійти, щоб залишити відгук", diff --git a/src/lang/uz-uz.json b/src/lang/uz-uz.json index 1f4272b967..9b284ee11b 100644 --- a/src/lang/uz-uz.json +++ b/src/lang/uz-uz.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "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.", "plugin-review:dialog-label": "Plagin sharhlari", "plugin-review:account-check-failed": "Hisobingizni tekshirib bo‘lmadi. Qayta urinib ko‘ring.", "plugin-review:sign-in": "Sharh qoldirish uchun kiring", diff --git a/src/lang/vi-vn.json b/src/lang/vi-vn.json index fd4eee2626..131cb3fe91 100644 --- a/src/lang/vi-vn.json +++ b/src/lang/vi-vn.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "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.", "plugin-review:dialog-label": "Đánh giá plugin", "plugin-review:account-check-failed": "Không thể kiểm tra tài khoản của bạn. Hãy thử lại.", "plugin-review:sign-in": "Đăng nhập để đánh giá", diff --git a/src/lang/zh-cn.json b/src/lang/zh-cn.json index d8067ddb03..b5eb3008b8 100644 --- a/src/lang/zh-cn.json +++ b/src/lang/zh-cn.json @@ -717,7 +717,10 @@ "pinned tab": "已固定的标签页", "unpin tab before closing": "关闭前请先取消固定该标签页。", "app font": "应用字体", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "选择在应用界面中使用的字体。", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO(启动可执行命令)", "lsp-transport-method-websocket": "WebSocket(连接到 ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "settings-info-app-use-file-operation-exclusions": "在复制和粘贴操作期间,跳过符合排除规则的文件和文件夹。", "horizontal scroll margin": "水平滚动边距", "settings-info-horizontal-scroll-margin": "在关闭自动换行时,为最长行的末尾添加空白区域。请输入任意非负整数的像素值。", + "confirm app icon reward": "观看一条激励广告以应用此应用图标?", + "rewarded ad unavailable": "激励广告暂时不可用,请重试。", + "rewarded ad failed": "广告未能完成,请重试。", + "rewarded ad incomplete": "请完整观看广告以获得奖励。", "plugin-review:dialog-label": "插件评价", "plugin-review:account-check-failed": "无法检查你的账户,请重试。", "plugin-review:sign-in": "登录后评价", diff --git a/src/lang/zh-hant.json b/src/lang/zh-hant.json index 2bbe289d28..5de4315970 100644 --- a/src/lang/zh-hant.json +++ b/src/lang/zh-hant.json @@ -717,7 +717,10 @@ "pinned tab": "已固定的標籤頁", "unpin tab before closing": "關閉前請先取消固定該標籤頁。", "app font": "應用字體", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "選擇在應用介面中使用的字體。", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO(啟動可執行命令)", "lsp-transport-method-websocket": "WebSocket(連線至 ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "settings-info-app-use-file-operation-exclusions": "在複製和粘貼操作期間,跳過符合排除規則的文件和文件夾。", "horizontal scroll margin": "水平滾動邊距", "settings-info-horizontal-scroll-margin": "在關閉自動換行時,爲最長行的末尾添加空白區域。請輸入任意非負整數的像素值。", + "confirm app icon reward": "觀看一則獎勵廣告以套用此應用程式圖示?", + "rewarded ad unavailable": "獎勵廣告暫時無法使用,請再試一次。", + "rewarded ad failed": "廣告未能完成,請再試一次。", + "rewarded ad incomplete": "請完整觀看廣告以獲得獎勵。", "plugin-review:dialog-label": "外掛評論", "plugin-review:account-check-failed": "無法檢查你的帳戶,請再試一次。", "plugin-review:sign-in": "登入以撰寫評論", diff --git a/src/lang/zh-tw.json b/src/lang/zh-tw.json index add0b58a04..1fa59224d3 100644 --- a/src/lang/zh-tw.json +++ b/src/lang/zh-tw.json @@ -717,7 +717,10 @@ "pinned tab": "Pinned tab", "unpin tab before closing": "Unpin the tab before closing it.", "app font": "App font", + "app icon": "App icon", + "app icon changed": "App icon changed", "settings-info-app-font-family": "Choose the font used across the app interface.", + "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", "lsp-transport-method-websocket": "WebSocket (connect to a ws/wss URL)", "lsp-websocket-url": "WebSocket URL", @@ -836,6 +839,10 @@ "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.", + "confirm app icon reward": "觀看一則獎勵廣告以套用此應用程式圖示?", + "rewarded ad unavailable": "獎勵廣告目前無法使用,請再試一次。", + "rewarded ad failed": "廣告未能完成,請再試一次。", + "rewarded ad incomplete": "請完整觀看廣告以獲得獎勵。", "plugin-review:dialog-label": "外掛評論", "plugin-review:account-check-failed": "無法檢查你的帳號,請再試一次。", "plugin-review:sign-in": "登入以撰寫評論", 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 new file mode 100644 index 0000000000..3ad3737683 --- /dev/null +++ b/src/lib/appIcons.js @@ -0,0 +1,54 @@ +/** + * Available app icons that can be selected from the settings. + * The `id` matches the icon names accepted by `system.setAppIcon`. + * The `image` is a relative path (relative to the www root) to an SVG + * preview that is rendered inside the UI. + */ + +// 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", + image: "icons/ic_acode_midnight_circuit.svg", + }, + { + id: "aurora_pulse", + label: "Aurora Pulse", + image: "icons/ic_acode_aurora_pulse.svg", + }, + { + id: "terminal_glow", + label: "Terminal Glow", + image: "icons/ic_acode_terminal_glow.svg", + }, + { + id: "solar_flare", + label: "Solar Flare", + image: "icons/ic_acode_solar_flare.svg", + }, + { + id: "blueprint", + label: "Blueprint", + image: "icons/ic_acode_blueprint.svg", + }, + { + id: "pixel_party", + label: "Pixel Party", + image: "icons/ic_acode_pixel_party.svg", + }, +]; + +export const APP_ICON_IDS = APP_ICONS.map((icon) => icon.id); + +export function getAppIconLabel(id) { + const icon = APP_ICONS.find((item) => item.id === id); + return icon ? icon.label : APP_ICONS[0].label; +} 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/lib/settings.js b/src/lib/settings.js index 7421f3d2cd..8827faadbc 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -217,6 +217,7 @@ class Settings { developerMode: false, shiftClickSelection: true, showShareButton: true, + appIcon: "default", }; this.value = structuredClone(this.#defaultSettings); } diff --git a/src/pages/appIconSetting/appIconSetting.js b/src/pages/appIconSetting/appIconSetting.js new file mode 100644 index 0000000000..1d4afadffd --- /dev/null +++ b/src/pages/appIconSetting/appIconSetting.js @@ -0,0 +1,87 @@ +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 createAppIconSelection from "lib/appIconSelection"; +import { APP_ICONS } from "lib/appIcons"; +import appSettings from "lib/settings"; +import helpers from "utils/helpers"; + +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"); + + actionStack.push({ + id: "appIcon", + action: () => { + $page.hide(); + }, + }); + + $page.onhide = () => { + controller.abort(); + loader.removeTitleLoader(); + $page.removeEventListener("click", clickHandler); + actionStack.remove("appIcon"); + resolve(); + }; + + $page.body =
; + + app.append($page); + renderIcons(); + helpers.showAd(); + + $page.addEventListener("click", clickHandler); + + return new Promise((res) => { + resolve = res; + }); + + function renderIcons() { + const current = appSettings.value.appIcon || "default"; + $list.el.content = APP_ICONS.map((icon) => { + const isCurrent = icon.id === current; + return ( + + ); + }); + } + + async function clickHandler(e) { + const $target = e.target.closest("[data-icon]"); + if (!$target) return; + const iconId = $target.dataset.icon; + await selectIcon(iconId); + } +} diff --git a/src/pages/appIconSetting/appIconSetting.scss b/src/pages/appIconSetting/appIconSetting.scss new file mode 100644 index 0000000000..814f6d9e28 --- /dev/null +++ b/src/pages/appIconSetting/appIconSetting.scss @@ -0,0 +1,89 @@ +wc-page.app-icon-page { + background: var(--secondary-color); + + .app-icon-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); + gap: 0.9rem; + width: 100%; + max-width: 48rem; + margin: 0 auto; + padding: 1rem 1rem 5.5rem; + box-sizing: border-box; + } + + .app-icon-item { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + margin: 0; + padding: 1rem 0.75rem; + border: none; + border-radius: 1rem; + background: transparent; + cursor: pointer; + font-family: inherit; + transition: background-color 140ms ease; + + &:focus, + &:active { + background: color-mix( + in srgb, + var(--secondary-color), + var(--popup-text-color) 4% + ); + } + + &.current { + background: color-mix(in srgb, var(--secondary-color), var(--active-color) 10%); + } + + &.current .app-icon-preview { + box-shadow: 0 0 0 2px var(--active-color); + } + } + + .app-icon-preview { + display: block; + width: 4.2rem; + height: 4.2rem; + border-radius: 0.9rem; + overflow: hidden; + background: var(--popup-background-color); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); + + img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + } + } + + .app-icon-name { + display: block; + font-size: 0.85rem; + line-height: 1.3; + font-weight: 600; + color: var(--popup-text-color); + text-align: center; + word-break: break-word; + } + + .app-icon-check { + position: absolute; + top: 0.4rem; + right: 0.4rem; + display: flex; + align-items: center; + justify-content: center; + width: 1.2rem; + height: 1.2rem; + border-radius: 50%; + background: var(--active-color); + color: var(--button-text-color); + font-size: 0.8rem; + } +} diff --git a/src/pages/appIconSetting/index.js b/src/pages/appIconSetting/index.js new file mode 100644 index 0000000000..6718f0c619 --- /dev/null +++ b/src/pages/appIconSetting/index.js @@ -0,0 +1,5 @@ +export default function appIconSetting(...args) { + return import( + /* webpackChunkName: "appIconSetting" */ "./appIconSetting" + ).then((module) => module.default(...args)); +} 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 c97ef0c77d..53e0f6a6e7 100644 --- a/src/plugins/system/android/com/foxdebug/system/System.java +++ b/src/plugins/system/android/com/foxdebug/system/System.java @@ -76,6 +76,21 @@ public class System extends CordovaPlugin { private static final String TAG = "SystemPlugin"; private CallbackContext requestPermissionCallback; + private static final Map APP_ICON_ALIASES; + + 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"); + 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 7ce6cf196b..55d1341b62 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"; @@ -298,48 +295,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 index cec4ae2d48..bbcadbda15 100644 --- a/tests/unit/confirm.test.js +++ b/tests/unit/confirm.test.js @@ -1,103 +1,125 @@ // @vitest-environment happy-dom import tag from "html-tag-js"; -import { - afterAll, - afterEach, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; - -const mocks = vi.hoisted(() => ({ - push: vi.fn(), - remove: vi.fn(), - restoreTheme: vi.fn(), -})); - -vi.mock("components/checkbox", () => ({ default: () => null })); -vi.mock("dompurify", () => ({ - default: { sanitize: vi.fn((value) => value) }, +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +vi.mock("lib/settings", () => ({ + default: { value: { confirmOnExit: false } }, })); -vi.mock("lib/actionStack", () => ({ - default: { - push: mocks.push, - remove: mocks.remove, - }, +vi.mock("lib/restoreTheme", () => ({ default: vi.fn() })); +vi.mock("components/checkbox", () => ({ + default: () => document.createElement("input"), })); -vi.mock("lib/restoreTheme", () => ({ default: mocks.restoreTheme })); - import confirm from "dialogs/confirm"; - -const originalApp = globalThis.app; -const originalStrings = globalThis.strings; -const originalTag = globalThis.tag; - -function restoreGlobal(name, value) { - if (value === undefined) { - Reflect.deleteProperty(globalThis, name); - } else { - globalThis[name] = value; - } -} - -function getDialog() { - return document.querySelector(".prompt.confirm"); -} - -function getButtons() { - return document.querySelectorAll(".button-container button"); -} - -describe("confirm dialog layering", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.clearAllMocks(); - document.body.replaceChildren(); - globalThis.tag = tag; - globalThis.app = document.body; - globalThis.strings = { ok: "OK", cancel: "Cancel" }; - }); - - afterEach(() => { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - document.body.replaceChildren(); +import actionStack from "lib/actionStack"; +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + actionStack.setMark(); + document.body.replaceChildren(); + vi.stubGlobal("app", document.body); + vi.stubGlobal("strings", { ok: "OK", cancel: "Cancel" }); + vi.stubGlobal("tag", tag); +}); +afterEach(() => { + actionStack.clearFromMark(); + vi.runAllTimers(); + vi.restoreAllMocks(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + document.body.replaceChildren(); +}); +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, }); - - afterAll(() => { - restoreGlobal("app", originalApp); - restoreGlobal("strings", originalStrings); - restoreGlobal("tag", originalTag); + expect(document.querySelector(".confirm").className).toBe("prompt confirm"); + 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("applies the overlay layer without losing RTL and resolves true", async () => { + const result = confirm("Delete", "Delete this review?", false, { + direction: "rtl", + aboveOverlay: true, }); - - it("keeps the default layer and resolves false when cancelled", async () => { - const result = confirm("Delete", "Delete this review?"); - const dialog = getDialog(); - - expect(dialog).not.toBeNull(); - expect(dialog.className).toBe("prompt confirm"); - - getButtons()[0].click(); - - await expect(result).resolves.toBe(false); + const dialog = document.querySelector(".confirm"); + expect(dialog.className).toBe("prompt confirm above-overlay"); + expect(dialog.dir).toBe("rtl"); + dialog.querySelectorAll("button")[1].click(); + await expect(result).resolves.toBe(true); + 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("applies the overlay layer without losing RTL and resolves true", async () => { - const result = confirm("Delete", "Delete this review?", false, { - direction: "rtl", - aboveOverlay: true, +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 dialog = getDialog(); - - expect(dialog).not.toBeNull(); - expect(dialog.className).toBe("prompt confirm above-overlay"); - expect(dialog.dir).toBe("rtl"); - - getButtons()[1].click(); + 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); + }, +); - await expect(result).resolves.toBe(true); +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 @@ + + Acode Aurora Pulse app icon + White Acode mark floating over cyan, violet, and pink aurora ribbons. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + Acode Blueprint app icon + Technical blue Acode mark over a crisp drafting grid. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + Acode Midnight Circuit app icon + Electric blue Acode mark on a midnight circuit board. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + Acode Pixel Party app icon + White Acode mark with cyan and pink pixel offsets on deep purple. + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/icons/ic_acode_pro.svg b/www/icons/ic_acode_pro.svg new file mode 100644 index 0000000000..3eb809143b --- /dev/null +++ b/www/icons/ic_acode_pro.svg @@ -0,0 +1,29 @@ + + Acode Pro app icon + Golden Acode mark on a midnight gradient. + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/icons/ic_acode_solar_flare.svg b/www/icons/ic_acode_solar_flare.svg new file mode 100644 index 0000000000..a5c1b38899 --- /dev/null +++ b/www/icons/ic_acode_solar_flare.svg @@ -0,0 +1,28 @@ + + Acode Solar Flare app icon + Warm white Acode mark over a vivid radial sunset. + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + Acode Terminal Glow app icon + Phosphor green Acode mark on a dark terminal grid. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +