diff --git a/config.xml b/config.xml index 5101e1fa5..3a2067d33 100644 --- a/config.xml +++ b/config.xml @@ -79,6 +79,12 @@ + + android:targetActivity="com.foxdebug.system.LauncherActivity"> @@ -100,7 +106,7 @@ android:icon="@mipmap/ic_acode_pro" android:roundIcon="@mipmap/ic_acode_pro_round" android:label="@string/app_name" - android:targetActivity=".MainActivity"> + android:targetActivity="com.foxdebug.system.LauncherActivity"> @@ -114,7 +120,7 @@ android:icon="@mipmap/ic_acode_midnight_circuit" android:roundIcon="@mipmap/ic_acode_midnight_circuit_round" android:label="@string/app_name" - android:targetActivity=".MainActivity"> + android:targetActivity="com.foxdebug.system.LauncherActivity"> @@ -128,7 +134,7 @@ android:icon="@mipmap/ic_acode_aurora_pulse" android:roundIcon="@mipmap/ic_acode_aurora_pulse_round" android:label="@string/app_name" - android:targetActivity=".MainActivity"> + android:targetActivity="com.foxdebug.system.LauncherActivity"> @@ -142,7 +148,7 @@ android:icon="@mipmap/ic_acode_terminal_glow" android:roundIcon="@mipmap/ic_acode_terminal_glow_round" android:label="@string/app_name" - android:targetActivity=".MainActivity"> + android:targetActivity="com.foxdebug.system.LauncherActivity"> @@ -156,7 +162,7 @@ android:icon="@mipmap/ic_acode_solar_flare" android:roundIcon="@mipmap/ic_acode_solar_flare_round" android:label="@string/app_name" - android:targetActivity=".MainActivity"> + android:targetActivity="com.foxdebug.system.LauncherActivity"> @@ -170,7 +176,7 @@ android:icon="@mipmap/ic_acode_blueprint" android:roundIcon="@mipmap/ic_acode_blueprint_round" android:label="@string/app_name" - android:targetActivity=".MainActivity"> + android:targetActivity="com.foxdebug.system.LauncherActivity"> @@ -184,7 +190,119 @@ android:icon="@mipmap/ic_acode_pixel_party" android:roundIcon="@mipmap/ic_acode_pixel_party_round" android:label="@string/app_name" - android:targetActivity=".MainActivity"> + android:targetActivity="com.foxdebug.system.LauncherActivity"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hooks/post-process.js b/hooks/post-process.js index 449669cf4..b62bf499b 100644 --- a/hooks/post-process.js +++ b/hooks/post-process.js @@ -2,6 +2,7 @@ const path = require('path'); const fs = require('fs'); const { execSync } = require('child_process'); +const { parse } = require('elementtree'); const buildFilePath = path.resolve(__dirname, '../build.json'); const copyToPath = path.resolve(__dirname, '../platforms/android/build.json'); @@ -37,6 +38,30 @@ disableSplashFadeOnRealmeAndroid13(); removeLegacyKeyboardWorkaround(); patchTargetSdkVersion(); stripLauncherFilterFromMainActivity(); +syncLauncherActivities(); + +// Cordova can retain old aliases when their targetActivity changes. Replace +// only our launcher entries, keeping the target activity before its aliases. +function syncLauncherActivities() { + const config = parse(fs.readFileSync(path.resolve(__dirname, '../config.xml'), 'utf8')); + const launcherConfig = config.findall('.//config-file').find(element => + element.findall('activity').some(activity => + activity.get('android:name') === 'com.foxdebug.system.LauncherActivity' + ) + ); + const manifestPath = path.resolve(__dirname, '../platforms/android/app/src/main/AndroidManifest.xml'); + if (!launcherConfig || !fs.existsSync(manifestPath)) return; + + const manifest = parse(fs.readFileSync(manifestPath, 'utf8')); + const application = manifest.find('application'); + const entries = launcherConfig.getchildren(); + const names = new Set(entries.map(entry => entry.get('android:name'))); + for (const entry of [...application.getchildren()]) { + if (names.has(entry.get('android:name'))) application.remove(entry); + } + for (const entry of entries) application.append(entry); + fs.writeFileSync(manifestPath, manifest.write({ indent: 4 }), 'utf8'); +} function getPackageName() { const configPath = path.resolve(__dirname, '../config.xml'); @@ -76,9 +101,8 @@ 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). + * (see config.xml). They forward through LauncherActivity so the editor runs + * under its stable MainActivity component when an icon alias is disabled. */ function stripLauncherFilterFromMainActivity() { const prefix = execSync('npm prefix').toString().trim(); diff --git a/package-lock.json b/package-lock.json index 7550aea35..356909fbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -130,6 +130,7 @@ "cordova-plugin-system": "file:src/plugins/system", "cordova-plugin-websocket": "file:src/plugins/websocket", "css-loader": "^7.1.4", + "elementtree": "^0.1.7", "happy-dom": "^20.11.6", "mini-css-extract-plugin": "^2.10.2", "path-browserify": "^1.0.1", diff --git a/package.json b/package.json index 2a2a7deae..fb5ce726f 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "cordova-plugin-system": "file:src/plugins/system", "cordova-plugin-websocket": "file:src/plugins/websocket", "css-loader": "^7.1.4", + "elementtree": "^0.1.7", "happy-dom": "^20.11.6", "mini-css-extract-plugin": "^2.10.2", "path-browserify": "^1.0.1", diff --git a/res/android/drawable-xxxhdpi/ic_acode_cobalt_background.webp b/res/android/drawable-xxxhdpi/ic_acode_cobalt_background.webp new file mode 100644 index 000000000..a666b242f Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_cobalt_background.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_cobalt_foreground.webp b/res/android/drawable-xxxhdpi/ic_acode_cobalt_foreground.webp new file mode 100644 index 000000000..6bb07508b Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_cobalt_foreground.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_glacier_background.webp b/res/android/drawable-xxxhdpi/ic_acode_glacier_background.webp new file mode 100644 index 000000000..ca74d9498 Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_glacier_background.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_glacier_foreground.webp b/res/android/drawable-xxxhdpi/ic_acode_glacier_foreground.webp new file mode 100644 index 000000000..24cd1c30b Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_glacier_foreground.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_lilac_background.webp b/res/android/drawable-xxxhdpi/ic_acode_lilac_background.webp new file mode 100644 index 000000000..90e2d4c93 Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_lilac_background.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_lilac_foreground.webp b/res/android/drawable-xxxhdpi/ic_acode_lilac_foreground.webp new file mode 100644 index 000000000..fd6dfbb87 Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_lilac_foreground.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_porcelain_background.webp b/res/android/drawable-xxxhdpi/ic_acode_porcelain_background.webp new file mode 100644 index 000000000..f3f22e8f0 Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_porcelain_background.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_porcelain_foreground.webp b/res/android/drawable-xxxhdpi/ic_acode_porcelain_foreground.webp new file mode 100644 index 000000000..f6cb4503a Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_porcelain_foreground.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_prism_background.webp b/res/android/drawable-xxxhdpi/ic_acode_prism_background.webp new file mode 100644 index 000000000..8fd87d25c Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_prism_background.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_prism_foreground.webp b/res/android/drawable-xxxhdpi/ic_acode_prism_foreground.webp new file mode 100644 index 000000000..e043df19f Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_prism_foreground.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_tangerine_background.webp b/res/android/drawable-xxxhdpi/ic_acode_tangerine_background.webp new file mode 100644 index 000000000..f083f430b Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_tangerine_background.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_tangerine_foreground.webp b/res/android/drawable-xxxhdpi/ic_acode_tangerine_foreground.webp new file mode 100644 index 000000000..be0ba61bf Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_tangerine_foreground.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_tidal_background.webp b/res/android/drawable-xxxhdpi/ic_acode_tidal_background.webp new file mode 100644 index 000000000..f722df612 Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_tidal_background.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_tidal_foreground.webp b/res/android/drawable-xxxhdpi/ic_acode_tidal_foreground.webp new file mode 100644 index 000000000..4ef12a493 Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_tidal_foreground.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_volt_background.webp b/res/android/drawable-xxxhdpi/ic_acode_volt_background.webp new file mode 100644 index 000000000..3313a82f7 Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_volt_background.webp differ diff --git a/res/android/drawable-xxxhdpi/ic_acode_volt_foreground.webp b/res/android/drawable-xxxhdpi/ic_acode_volt_foreground.webp new file mode 100644 index 000000000..31fd3252f Binary files /dev/null and b/res/android/drawable-xxxhdpi/ic_acode_volt_foreground.webp differ diff --git a/res/android/drawable/ic_acode_collection_monochrome.xml b/res/android/drawable/ic_acode_collection_monochrome.xml new file mode 100644 index 000000000..8223e5d17 --- /dev/null +++ b/res/android/drawable/ic_acode_collection_monochrome.xml @@ -0,0 +1,2 @@ + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_cobalt.xml b/res/android/mipmap-anydpi-v26/ic_acode_cobalt.xml new file mode 100644 index 000000000..d4b5d0e31 --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_cobalt.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_cobalt_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_cobalt_round.xml new file mode 100644 index 000000000..d4b5d0e31 --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_cobalt_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_glacier.xml b/res/android/mipmap-anydpi-v26/ic_acode_glacier.xml new file mode 100644 index 000000000..a00e251e6 --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_glacier.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_glacier_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_glacier_round.xml new file mode 100644 index 000000000..a00e251e6 --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_glacier_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_lilac.xml b/res/android/mipmap-anydpi-v26/ic_acode_lilac.xml new file mode 100644 index 000000000..0230eabeb --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_lilac.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_lilac_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_lilac_round.xml new file mode 100644 index 000000000..0230eabeb --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_lilac_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_porcelain.xml b/res/android/mipmap-anydpi-v26/ic_acode_porcelain.xml new file mode 100644 index 000000000..9568f2891 --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_porcelain.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_porcelain_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_porcelain_round.xml new file mode 100644 index 000000000..9568f2891 --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_porcelain_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_prism.xml b/res/android/mipmap-anydpi-v26/ic_acode_prism.xml new file mode 100644 index 000000000..95bd7387b --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_prism.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_prism_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_prism_round.xml new file mode 100644 index 000000000..95bd7387b --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_prism_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_tangerine.xml b/res/android/mipmap-anydpi-v26/ic_acode_tangerine.xml new file mode 100644 index 000000000..aa9061dd7 --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_tangerine.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_tangerine_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_tangerine_round.xml new file mode 100644 index 000000000..aa9061dd7 --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_tangerine_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_tidal.xml b/res/android/mipmap-anydpi-v26/ic_acode_tidal.xml new file mode 100644 index 000000000..723b04dad --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_tidal.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_tidal_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_tidal_round.xml new file mode 100644 index 000000000..723b04dad --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_tidal_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_volt.xml b/res/android/mipmap-anydpi-v26/ic_acode_volt.xml new file mode 100644 index 000000000..bd27a037c --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_volt.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-anydpi-v26/ic_acode_volt_round.xml b/res/android/mipmap-anydpi-v26/ic_acode_volt_round.xml new file mode 100644 index 000000000..bd27a037c --- /dev/null +++ b/res/android/mipmap-anydpi-v26/ic_acode_volt_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/android/mipmap-hdpi/ic_acode_cobalt.webp b/res/android/mipmap-hdpi/ic_acode_cobalt.webp new file mode 100644 index 000000000..193a564b5 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_cobalt.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_cobalt_round.webp b/res/android/mipmap-hdpi/ic_acode_cobalt_round.webp new file mode 100644 index 000000000..1b8213b72 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_cobalt_round.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_glacier.webp b/res/android/mipmap-hdpi/ic_acode_glacier.webp new file mode 100644 index 000000000..6beaa5fae Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_glacier.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_glacier_round.webp b/res/android/mipmap-hdpi/ic_acode_glacier_round.webp new file mode 100644 index 000000000..b9cac3611 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_glacier_round.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_lilac.webp b/res/android/mipmap-hdpi/ic_acode_lilac.webp new file mode 100644 index 000000000..8b74449b9 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_lilac.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_lilac_round.webp b/res/android/mipmap-hdpi/ic_acode_lilac_round.webp new file mode 100644 index 000000000..891ff1496 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_lilac_round.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_porcelain.webp b/res/android/mipmap-hdpi/ic_acode_porcelain.webp new file mode 100644 index 000000000..709ace1e6 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_porcelain.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_porcelain_round.webp b/res/android/mipmap-hdpi/ic_acode_porcelain_round.webp new file mode 100644 index 000000000..84475d205 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_porcelain_round.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_prism.webp b/res/android/mipmap-hdpi/ic_acode_prism.webp new file mode 100644 index 000000000..f91639efe Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_prism.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_prism_round.webp b/res/android/mipmap-hdpi/ic_acode_prism_round.webp new file mode 100644 index 000000000..7cdf3500e Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_prism_round.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_tangerine.webp b/res/android/mipmap-hdpi/ic_acode_tangerine.webp new file mode 100644 index 000000000..5c9a7cb13 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_tangerine.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_tangerine_round.webp b/res/android/mipmap-hdpi/ic_acode_tangerine_round.webp new file mode 100644 index 000000000..13aabca10 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_tangerine_round.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_tidal.webp b/res/android/mipmap-hdpi/ic_acode_tidal.webp new file mode 100644 index 000000000..d7e58dacd Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_tidal.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_tidal_round.webp b/res/android/mipmap-hdpi/ic_acode_tidal_round.webp new file mode 100644 index 000000000..b55a39702 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_tidal_round.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_volt.webp b/res/android/mipmap-hdpi/ic_acode_volt.webp new file mode 100644 index 000000000..3491622e9 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_volt.webp differ diff --git a/res/android/mipmap-hdpi/ic_acode_volt_round.webp b/res/android/mipmap-hdpi/ic_acode_volt_round.webp new file mode 100644 index 000000000..50531d738 Binary files /dev/null and b/res/android/mipmap-hdpi/ic_acode_volt_round.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_cobalt.webp b/res/android/mipmap-mdpi/ic_acode_cobalt.webp new file mode 100644 index 000000000..331cea363 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_cobalt.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_cobalt_round.webp b/res/android/mipmap-mdpi/ic_acode_cobalt_round.webp new file mode 100644 index 000000000..0abf556c4 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_cobalt_round.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_glacier.webp b/res/android/mipmap-mdpi/ic_acode_glacier.webp new file mode 100644 index 000000000..24f999360 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_glacier.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_glacier_round.webp b/res/android/mipmap-mdpi/ic_acode_glacier_round.webp new file mode 100644 index 000000000..04a33650e Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_glacier_round.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_lilac.webp b/res/android/mipmap-mdpi/ic_acode_lilac.webp new file mode 100644 index 000000000..d57510581 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_lilac.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_lilac_round.webp b/res/android/mipmap-mdpi/ic_acode_lilac_round.webp new file mode 100644 index 000000000..f67e3772f Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_lilac_round.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_porcelain.webp b/res/android/mipmap-mdpi/ic_acode_porcelain.webp new file mode 100644 index 000000000..e2b63cb19 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_porcelain.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_porcelain_round.webp b/res/android/mipmap-mdpi/ic_acode_porcelain_round.webp new file mode 100644 index 000000000..d58038ffb Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_porcelain_round.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_prism.webp b/res/android/mipmap-mdpi/ic_acode_prism.webp new file mode 100644 index 000000000..b0d952c28 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_prism.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_prism_round.webp b/res/android/mipmap-mdpi/ic_acode_prism_round.webp new file mode 100644 index 000000000..297dea04f Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_prism_round.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_tangerine.webp b/res/android/mipmap-mdpi/ic_acode_tangerine.webp new file mode 100644 index 000000000..ff3d5ae74 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_tangerine.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_tangerine_round.webp b/res/android/mipmap-mdpi/ic_acode_tangerine_round.webp new file mode 100644 index 000000000..8ce36a606 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_tangerine_round.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_tidal.webp b/res/android/mipmap-mdpi/ic_acode_tidal.webp new file mode 100644 index 000000000..e7070f707 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_tidal.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_tidal_round.webp b/res/android/mipmap-mdpi/ic_acode_tidal_round.webp new file mode 100644 index 000000000..f61afecf6 Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_tidal_round.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_volt.webp b/res/android/mipmap-mdpi/ic_acode_volt.webp new file mode 100644 index 000000000..aca1d9c2c Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_volt.webp differ diff --git a/res/android/mipmap-mdpi/ic_acode_volt_round.webp b/res/android/mipmap-mdpi/ic_acode_volt_round.webp new file mode 100644 index 000000000..f94f1215a Binary files /dev/null and b/res/android/mipmap-mdpi/ic_acode_volt_round.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_cobalt.webp b/res/android/mipmap-xhdpi/ic_acode_cobalt.webp new file mode 100644 index 000000000..9750c4e11 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_cobalt.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_cobalt_round.webp b/res/android/mipmap-xhdpi/ic_acode_cobalt_round.webp new file mode 100644 index 000000000..95e5e7dd1 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_cobalt_round.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_glacier.webp b/res/android/mipmap-xhdpi/ic_acode_glacier.webp new file mode 100644 index 000000000..115098cc0 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_glacier.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_glacier_round.webp b/res/android/mipmap-xhdpi/ic_acode_glacier_round.webp new file mode 100644 index 000000000..4c013861c Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_glacier_round.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_lilac.webp b/res/android/mipmap-xhdpi/ic_acode_lilac.webp new file mode 100644 index 000000000..7eab5c98a Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_lilac.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_lilac_round.webp b/res/android/mipmap-xhdpi/ic_acode_lilac_round.webp new file mode 100644 index 000000000..df6058f1b Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_lilac_round.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_porcelain.webp b/res/android/mipmap-xhdpi/ic_acode_porcelain.webp new file mode 100644 index 000000000..ab6ba4ce0 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_porcelain.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_porcelain_round.webp b/res/android/mipmap-xhdpi/ic_acode_porcelain_round.webp new file mode 100644 index 000000000..176a5c8b2 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_porcelain_round.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_prism.webp b/res/android/mipmap-xhdpi/ic_acode_prism.webp new file mode 100644 index 000000000..c6cb63441 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_prism.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_prism_round.webp b/res/android/mipmap-xhdpi/ic_acode_prism_round.webp new file mode 100644 index 000000000..3fc13ec13 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_prism_round.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_tangerine.webp b/res/android/mipmap-xhdpi/ic_acode_tangerine.webp new file mode 100644 index 000000000..d8ee1b15b Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_tangerine.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_tangerine_round.webp b/res/android/mipmap-xhdpi/ic_acode_tangerine_round.webp new file mode 100644 index 000000000..a34d0aeed Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_tangerine_round.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_tidal.webp b/res/android/mipmap-xhdpi/ic_acode_tidal.webp new file mode 100644 index 000000000..ab8fda2d6 Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_tidal.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_tidal_round.webp b/res/android/mipmap-xhdpi/ic_acode_tidal_round.webp new file mode 100644 index 000000000..f8379cc0e Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_tidal_round.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_volt.webp b/res/android/mipmap-xhdpi/ic_acode_volt.webp new file mode 100644 index 000000000..dfd16392d Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_volt.webp differ diff --git a/res/android/mipmap-xhdpi/ic_acode_volt_round.webp b/res/android/mipmap-xhdpi/ic_acode_volt_round.webp new file mode 100644 index 000000000..859b6430d Binary files /dev/null and b/res/android/mipmap-xhdpi/ic_acode_volt_round.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_cobalt.webp b/res/android/mipmap-xxhdpi/ic_acode_cobalt.webp new file mode 100644 index 000000000..e3aa4c940 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_cobalt.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_cobalt_round.webp b/res/android/mipmap-xxhdpi/ic_acode_cobalt_round.webp new file mode 100644 index 000000000..8ea97be1e Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_cobalt_round.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_glacier.webp b/res/android/mipmap-xxhdpi/ic_acode_glacier.webp new file mode 100644 index 000000000..501ea8c79 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_glacier.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_glacier_round.webp b/res/android/mipmap-xxhdpi/ic_acode_glacier_round.webp new file mode 100644 index 000000000..45296f7bf Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_glacier_round.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_lilac.webp b/res/android/mipmap-xxhdpi/ic_acode_lilac.webp new file mode 100644 index 000000000..aa4619ad7 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_lilac.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_lilac_round.webp b/res/android/mipmap-xxhdpi/ic_acode_lilac_round.webp new file mode 100644 index 000000000..cca471246 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_lilac_round.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_porcelain.webp b/res/android/mipmap-xxhdpi/ic_acode_porcelain.webp new file mode 100644 index 000000000..a1c6aca1b Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_porcelain.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_porcelain_round.webp b/res/android/mipmap-xxhdpi/ic_acode_porcelain_round.webp new file mode 100644 index 000000000..2ca993a66 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_porcelain_round.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_prism.webp b/res/android/mipmap-xxhdpi/ic_acode_prism.webp new file mode 100644 index 000000000..7eea99ec1 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_prism.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_prism_round.webp b/res/android/mipmap-xxhdpi/ic_acode_prism_round.webp new file mode 100644 index 000000000..5d2e4dee4 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_prism_round.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_tangerine.webp b/res/android/mipmap-xxhdpi/ic_acode_tangerine.webp new file mode 100644 index 000000000..89b5be201 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_tangerine.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_tangerine_round.webp b/res/android/mipmap-xxhdpi/ic_acode_tangerine_round.webp new file mode 100644 index 000000000..db7b7870a Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_tangerine_round.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_tidal.webp b/res/android/mipmap-xxhdpi/ic_acode_tidal.webp new file mode 100644 index 000000000..7df2e5563 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_tidal.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_tidal_round.webp b/res/android/mipmap-xxhdpi/ic_acode_tidal_round.webp new file mode 100644 index 000000000..ffc4ee32e Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_tidal_round.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_volt.webp b/res/android/mipmap-xxhdpi/ic_acode_volt.webp new file mode 100644 index 000000000..3c17341f0 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_volt.webp differ diff --git a/res/android/mipmap-xxhdpi/ic_acode_volt_round.webp b/res/android/mipmap-xxhdpi/ic_acode_volt_round.webp new file mode 100644 index 000000000..83a8cb7a4 Binary files /dev/null and b/res/android/mipmap-xxhdpi/ic_acode_volt_round.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_cobalt.webp b/res/android/mipmap-xxxhdpi/ic_acode_cobalt.webp new file mode 100644 index 000000000..63f24c837 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_cobalt.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_cobalt_round.webp b/res/android/mipmap-xxxhdpi/ic_acode_cobalt_round.webp new file mode 100644 index 000000000..3de6aadc5 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_cobalt_round.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_glacier.webp b/res/android/mipmap-xxxhdpi/ic_acode_glacier.webp new file mode 100644 index 000000000..70f36574d Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_glacier.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_glacier_round.webp b/res/android/mipmap-xxxhdpi/ic_acode_glacier_round.webp new file mode 100644 index 000000000..342c35d22 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_glacier_round.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_lilac.webp b/res/android/mipmap-xxxhdpi/ic_acode_lilac.webp new file mode 100644 index 000000000..57266c0f9 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_lilac.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_lilac_round.webp b/res/android/mipmap-xxxhdpi/ic_acode_lilac_round.webp new file mode 100644 index 000000000..985ff0e71 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_lilac_round.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_porcelain.webp b/res/android/mipmap-xxxhdpi/ic_acode_porcelain.webp new file mode 100644 index 000000000..335364fa9 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_porcelain.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_porcelain_round.webp b/res/android/mipmap-xxxhdpi/ic_acode_porcelain_round.webp new file mode 100644 index 000000000..355b5ed51 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_porcelain_round.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_prism.webp b/res/android/mipmap-xxxhdpi/ic_acode_prism.webp new file mode 100644 index 000000000..da2ee3395 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_prism.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_prism_round.webp b/res/android/mipmap-xxxhdpi/ic_acode_prism_round.webp new file mode 100644 index 000000000..daf0e5523 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_prism_round.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_tangerine.webp b/res/android/mipmap-xxxhdpi/ic_acode_tangerine.webp new file mode 100644 index 000000000..9b9b646b6 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_tangerine.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_tangerine_round.webp b/res/android/mipmap-xxxhdpi/ic_acode_tangerine_round.webp new file mode 100644 index 000000000..08f8ecbd1 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_tangerine_round.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_tidal.webp b/res/android/mipmap-xxxhdpi/ic_acode_tidal.webp new file mode 100644 index 000000000..d6190e3e0 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_tidal.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_tidal_round.webp b/res/android/mipmap-xxxhdpi/ic_acode_tidal_round.webp new file mode 100644 index 000000000..c416abfcb Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_tidal_round.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_volt.webp b/res/android/mipmap-xxxhdpi/ic_acode_volt.webp new file mode 100644 index 000000000..55893a453 Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_volt.webp differ diff --git a/res/android/mipmap-xxxhdpi/ic_acode_volt_round.webp b/res/android/mipmap-xxxhdpi/ic_acode_volt_round.webp new file mode 100644 index 000000000..5c9577acd Binary files /dev/null and b/res/android/mipmap-xxxhdpi/ic_acode_volt_round.webp differ diff --git a/src/dialogs/loader.js b/src/dialogs/loader.js index dfb98946f..88f6a725d 100644 --- a/src/dialogs/loader.js +++ b/src/dialogs/loader.js @@ -7,6 +7,7 @@ import restoreTheme from "lib/restoreTheme"; let loaderIsImmortal = false; let onCancelCallback = null; let cancelButtonTimeout = null; +let destroyTimeout = null; let $currentDialog = null; let $currentMask = null; const titleLoaderId = "__title-loader"; @@ -39,16 +40,18 @@ function create(titleText, message = "", options = {}) { titleText = ""; } - const $oldLoader = tag.get("#__loader"); - const $oldMask = tag.get("#__loader-mask"); - - if ($oldLoader) $oldLoader.remove(); + clearTimeout(destroyTimeout); + destroyTimeout = null; + const replacingActiveLoader = + $currentDialog && !$currentDialog.classList.contains("hide"); + $currentDialog?.remove(); + $currentMask?.remove(); const $message = Ref(); const $titleSpan = Ref(); - const $mask = $oldMask || ; - const $dialog = $oldLoader || ( + const $mask = ; + const $dialog = (
{titleText} @@ -82,11 +85,11 @@ function create(titleText, message = "", options = {}) { }, options.timeout); } - if (!$oldLoader) { - actionStack.freeze(); - document.body.append($dialog, $mask); - restoreTheme(true); - } + $currentDialog = $dialog; + $currentMask = $mask; + actionStack.freeze(); + document.body.append($dialog, $mask); + if (!replacingActiveLoader) restoreTheme(true); return { setTitle(title) { @@ -124,23 +127,24 @@ function createTitleLoader() { * Removes the loader from DOM permanently */ function destroy() { - const loaderDiv = tag.get("#__loader"); - const mask = tag.get("#__loader-mask"); + const loaderDiv = $currentDialog; + const mask = $currentMask; clearTimeout(cancelButtonTimeout); cancelButtonTimeout = null; onCancelCallback = null; + if (!loaderDiv || loaderDiv.classList.contains("hide")) return; restoreTheme(); - if (!loaderDiv && !mask) { - actionStack.unfreeze(); - return; - } - - loaderDiv?.classList.add("hide"); - setTimeout(() => { + loaderDiv.classList.add("hide"); + destroyTimeout = setTimeout(() => { + // A newer loader must retain its dialog and Back/Escape lock. + if ($currentDialog !== loaderDiv) return; + destroyTimeout = null; + loaderDiv.remove(); + mask?.remove(); + $currentDialog = null; + $currentMask = null; actionStack.unfreeze(); - if (loaderDiv?.isConnected) loaderDiv.remove(); - if (mask?.isConnected) mask.remove(); }, 300); } @@ -148,30 +152,19 @@ function destroy() { * Hides the loading dialog box temporarily and can be restored using show method */ function hide() { - const loaderDiv = tag.get("#__loader"); - const mask = tag.get("#__loader-mask"); - - if (loaderDiv) { - $currentDialog = loaderDiv; - loaderDiv.remove(); - } - if (mask) { - $currentMask = mask; - mask.remove(); - } + $currentDialog?.remove(); + $currentMask?.remove(); } /** * Shows previously hidden dialog box. */ function show() { - if ($currentDialog) { + if ($currentDialog && !$currentDialog.isConnected) { app.append($currentDialog); - $currentDialog = null; } - if ($currentMask) { + if ($currentMask && !$currentMask.isConnected) { app.append($currentMask); - $currentMask = null; } } diff --git a/src/dialogs/style.scss b/src/dialogs/style.scss index 0380b5c0d..52199568a 100644 --- a/src/dialogs/style.scss +++ b/src/dialogs/style.scss @@ -64,7 +64,8 @@ } &.confirm, - &.alert { + &.alert, + &.app-icon-dialog { .title { text-transform: uppercase; } @@ -212,10 +213,9 @@ z-index: 111; background-color: rgb(0, 0, 0); opacity: 0.4; - animation: mask-enter 180ms ease-out forwards; - ~ .mask { - opacity: 0; + &:has(~ .prompt:not(.hide) + .mask) { + opacity: 0 !important; } } @@ -226,8 +226,6 @@ opacity: 0 !important; & + .mask { - animation: none; - transition: opacity 180ms ease-in-out; opacity: 0 !important; } } diff --git a/src/lib/appIconSelection.js b/src/lib/appIconSelection.js index feced4613..dab2ce2d0 100644 --- a/src/lib/appIconSelection.js +++ b/src/lib/appIconSelection.js @@ -11,51 +11,59 @@ 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; +export default async function selectAppIcon( + iconId, + { signal, onBusy, onLoading, onChange }, +) { + 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 }); + selecting = true; + onBusy(true); + try { + if (icon.requiresPro && !config.HAS_PRO) { + // External checkout manages its own login loader and confirmation. + if (!helpers.shouldAllowExternalPurchase()) onLoading(true); + await requestProPurchase({ signal }); + return; + } + const needsReward = iconId !== "default" && adRewards.canShowAds(); + if (needsReward) { + const confirmed = await confirm( + strings["app icon"], + strings["confirm app icon reward"], + false, + { signal }, + ); + if (!confirmed || signal.aborted) return; + } + onLoading(true); + // Pro or a pass may have become active while confirmation was open. + if (needsReward && adRewards.canShowAds()) { + const earned = await showRewardedAd({ signal }); + if (signal.aborted) return; + if (!earned) { + toast(strings["rewarded ad incomplete"]); 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); } - }; + 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) { + onLoading(false); + onBusy(false); + } + } } diff --git a/src/lib/appIcons.js b/src/lib/appIcons.js index 3ad373768..57314c544 100644 --- a/src/lib/appIcons.js +++ b/src/lib/appIcons.js @@ -44,11 +44,22 @@ export const APP_ICONS = [ label: "Pixel Party", image: "icons/ic_acode_pixel_party.svg", }, + { id: "prism", label: "Prism", image: "icons/ic_acode_prism.svg" }, + { + id: "porcelain", + label: "Porcelain", + image: "icons/ic_acode_porcelain.svg", + }, + { + id: "tangerine", + label: "Tangerine", + image: "icons/ic_acode_tangerine.svg", + }, + { id: "tidal", label: "Tidal", image: "icons/ic_acode_tidal.svg" }, + { id: "lilac", label: "Lilac", image: "icons/ic_acode_lilac.svg" }, + { id: "volt", label: "Volt", image: "icons/ic_acode_volt.svg" }, + { id: "cobalt", label: "Cobalt", image: "icons/ic_acode_cobalt.svg" }, + { id: "glacier", label: "Glacier", image: "icons/ic_acode_glacier.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/pages/appIconSetting/appIconSetting.js b/src/pages/appIconSetting/appIconSetting.js index 1d4afadff..7d3f32a35 100644 --- a/src/pages/appIconSetting/appIconSetting.js +++ b/src/pages/appIconSetting/appIconSetting.js @@ -1,87 +1,137 @@ +import "dialogs/style.scss"; 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 selectAppIcon from "lib/appIconSelection"; import { APP_ICONS } from "lib/appIcons"; +import restoreTheme from "lib/restoreTheme"; import appSettings from "lib/settings"; -import helpers from "utils/helpers"; + +let activeDialog; +let nextDialogId = 0; export default function appIconSetting() { - const title = strings["app icon"] || "App icon"; - const $page = Page(title); - const $list = Ref(); + if (activeDialog) return activeDialog; + const actionId = `app-icon-${++nextDialogId}`; + const previousFocus = document.activeElement; 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, - }); + const current = appSettings.value.appIcon || "default"; + let loading = false; 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) => { + const closed = 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 ( + const $list = ( +
+ {APP_ICONS.map((icon) => ( - ); - }); + ))} +
+ ); + const $close = ; + const $dialog = ( +
+ + {strings["app icon"] || "App icon"} + + {$list} +
{$close}
+
+ ); + const $mask = ; + const selectionOptions = { + signal: controller.signal, + onBusy(value) { + $list.setAttribute("aria-busy", String(value)); + if (value && $list.contains(document.activeElement)) $close.focus(); + for (const button of $list.querySelectorAll("button")) { + button.disabled = value; + } + }, + onLoading(value) { + if (loading === value) return; + loading = value; + $dialog.inert = value; + if (value) loader.create(strings["app icon"], strings["loading..."]); + else loader.destroy(); + }, + onChange: close, + }; + + activeDialog = closed; + $list.addEventListener("click", clickHandler); + $close.addEventListener("click", close); + $mask.addEventListener("click", close); + $dialog.addEventListener("keydown", keyHandler); + actionStack.push({ id: actionId, action: close }); + app.append($dialog, $mask); + restoreTheme(true); + ($list.querySelector(".current") || $close).focus(); + return closed; + + function close() { + if (controller.signal.aborted) return; + controller.abort(); + if (loading) loader.destroy(); + actionStack.remove(actionId); + $list.removeEventListener("click", clickHandler); + $close.removeEventListener("click", close); + $mask.removeEventListener("click", close); + $dialog.removeEventListener("keydown", keyHandler); + $dialog.classList.add("hide"); + $dialog.inert = true; + restoreTheme(); + setTimeout( + () => { + $dialog.remove(); + $mask.remove(); + activeDialog = undefined; + if (previousFocus?.isConnected) previousFocus.focus(); + resolve(); + }, + document.body.classList.contains("no-animation") ? 0 : 180, + ); } - async function clickHandler(e) { + function clickHandler(e) { const $target = e.target.closest("[data-icon]"); - if (!$target) return; - const iconId = $target.dataset.icon; - await selectIcon(iconId); + if ($target) selectAppIcon($target.dataset.icon, selectionOptions); + } + + function keyHandler(e) { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + actionStack.pop(); + } else if (e.key === "Tab") { + // A confirmation may be open while focus still belongs to the picker. + const prompts = document.querySelectorAll(".prompt:not(.hide)"); + if (prompts[prompts.length - 1] !== $dialog) return; + const buttons = $dialog.querySelectorAll("button:not(:disabled)"); + const first = buttons[0]; + const last = buttons[buttons.length - 1]; + if (e.shiftKey && e.target === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && e.target === last) { + e.preventDefault(); + first.focus(); + } + } } } diff --git a/src/pages/appIconSetting/appIconSetting.scss b/src/pages/appIconSetting/appIconSetting.scss index 814f6d9e2..d72f1ae77 100644 --- a/src/pages/appIconSetting/appIconSetting.scss +++ b/src/pages/appIconSetting/appIconSetting.scss @@ -1,25 +1,43 @@ -wc-page.app-icon-page { - background: var(--secondary-color); +.prompt.app-icon-dialog { + // Keep the existing confirmation dialogs and their masks above the picker. + z-index: 111; + width: calc(100vw - 32px); + max-width: 28rem; + box-sizing: border-box; + + & + .mask { + z-index: 110; + } + + > .title, + > .button-container { + flex-shrink: 0; + } + + > .button-container { + margin-top: 0.5rem; + } .app-icon-list { display: grid; - grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); - gap: 0.9rem; + grid-template-columns: repeat(auto-fill, minmax(5.25rem, 1fr)); + gap: 0.5rem; + align-content: start; width: 100%; - max-width: 48rem; + min-height: 0; + overflow-y: auto; margin: 0 auto; - padding: 1rem 1rem 5.5rem; + padding: 0.75rem; box-sizing: border-box; } .app-icon-item { position: relative; display: flex; - flex-direction: column; align-items: center; - gap: 0.6rem; + justify-content: center; margin: 0; - padding: 1rem 0.75rem; + padding: 0.5rem; border: none; border-radius: 1rem; background: transparent; @@ -37,11 +55,30 @@ wc-page.app-icon-page { } &.current { - background: color-mix(in srgb, var(--secondary-color), var(--active-color) 10%); + background: transparent; + } + + &.current::after { + content: "\e9ab"; + position: absolute; + top: 0; + left: calc(50% + 2.1rem); + transform: translateX(-50%); + display: grid; + place-items: center; + width: max(20px, 1.4rem); + height: max(20px, 1.4rem); + box-sizing: border-box; + border: 2px solid var(--popup-background-color); + border-radius: 50%; + background: var(--active-color); + color: #fff; + font: max(12px, 1rem) / 1 "code-editor-icon"; + pointer-events: none; } - &.current .app-icon-preview { - box-shadow: 0 0 0 2px var(--active-color); + &[data-icon="default"] .app-icon-preview { + background: #3a3e54; } } @@ -61,29 +98,4 @@ wc-page.app-icon-page { 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 index 6718f0c61..fa01ba7ea 100644 --- a/src/pages/appIconSetting/index.js +++ b/src/pages/appIconSetting/index.js @@ -1,5 +1,41 @@ +import { APP_ICONS } from "lib/appIcons"; +import openAppIconDialog from "./appIconSetting"; + +const previews = new Map(); + export default function appIconSetting(...args) { - return import( - /* webpackChunkName: "appIconSetting" */ "./appIconSetting" - ).then((module) => module.default(...args)); + preloadAppIconSetting(); + return openAppIconDialog(...args); +} + +export function preloadAppIconSetting() { + return Promise.allSettled( + APP_ICONS.map(({ image }) => preloadPreview(image)), + ); +} + +function preloadPreview(src) { + if (previews.has(src)) return previews.get(src); + const image = new Image(); + const ready = new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = reject; + image.src = src; + }) + .then(async () => { + // A decoded preview is preferable, but a loaded image is still usable. + if (typeof image.decode === "function") + await image.decode().catch(() => {}); + return image; + }) + .catch((error) => { + previews.delete(src); + throw error; + }) + .finally(() => { + image.onload = null; + image.onerror = null; + }); + previews.set(src, ready); + return ready; } diff --git a/src/plugins/admob/src/android/cordova/AdMob.kt b/src/plugins/admob/src/android/cordova/AdMob.kt index b3f459b72..45b32656f 100644 --- a/src/plugins/admob/src/android/cordova/AdMob.kt +++ b/src/plugins/admob/src/android/cordova/AdMob.kt @@ -226,13 +226,28 @@ class AdMob : CordovaPlugin() { } } + override fun onReset() { + clearAdState() + super.onReset() + } + override fun onDestroy() { + clearAdState() + super.onDestroy() + } + + private fun clearAdState() { readyCallbackContext = null - for (ad in ads.toMap().values) { - ad.onDestroy() + eventQueue.clear() + val previousAds = synchronized(ads) { + ads.values.toList().also { ads.clear() } + } + cordova.activity.runOnUiThread { + for (ad in previousAds) { + ad.onDestroy() + } + Banner.destroyParentView() } - Banner.destroyParentView() - super.onDestroy() } companion object { diff --git a/src/plugins/admob/src/android/cordova/ads/AdBase.kt b/src/plugins/admob/src/android/cordova/ads/AdBase.kt index 0f4a2d708..4a9238524 100644 --- a/src/plugins/admob/src/android/cordova/ads/AdBase.kt +++ b/src/plugins/admob/src/android/cordova/ads/AdBase.kt @@ -45,7 +45,7 @@ abstract class AdBase(ctx: ExecuteContext) { fun destroy() { plugin.activity.runOnUiThread { - ads.remove(id) + ads.remove(id, this) } } @@ -75,6 +75,8 @@ abstract class AdBase(ctx: ExecuteContext) { } fun emit(eventName: String, data: Map = mapOf()) { + // A reload can recreate the same ad ID while old SDK callbacks finish. + if (ads[id] !== this) return plugin.emit(eventName, mapOf("adId" to id) + data) } diff --git a/src/plugins/system/android/com/foxdebug/system/LauncherActivity.java b/src/plugins/system/android/com/foxdebug/system/LauncherActivity.java new file mode 100644 index 000000000..eae45915f --- /dev/null +++ b/src/plugins/system/android/com/foxdebug/system/LauncherActivity.java @@ -0,0 +1,19 @@ +package com.foxdebug.system; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +/** Keeps the editor activity independent of the switchable launcher aliases. */ +public class LauncherActivity extends Activity { + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Intent intent = new Intent(getIntent()); + intent.setClassName(getPackageName(), getPackageName() + ".MainActivity"); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + startActivity(intent); + finish(); + } +} diff --git a/src/plugins/system/android/com/foxdebug/system/System.java b/src/plugins/system/android/com/foxdebug/system/System.java index 53e0f6a6e..88993a7a9 100644 --- a/src/plugins/system/android/com/foxdebug/system/System.java +++ b/src/plugins/system/android/com/foxdebug/system/System.java @@ -88,6 +88,14 @@ public class System extends CordovaPlugin { aliases.put("solar_flare", "MainActivityIconSolarFlare"); aliases.put("blueprint", "MainActivityIconBlueprint"); aliases.put("pixel_party", "MainActivityIconPixelParty"); + aliases.put("prism", "MainActivityIconPrism"); + aliases.put("porcelain", "MainActivityIconPorcelain"); + aliases.put("tangerine", "MainActivityIconTangerine"); + aliases.put("tidal", "MainActivityIconTidal"); + aliases.put("lilac", "MainActivityIconLilac"); + aliases.put("volt", "MainActivityIconVolt"); + aliases.put("cobalt", "MainActivityIconCobalt"); + aliases.put("glacier", "MainActivityIconGlacier"); APP_ICON_ALIASES = Collections.unmodifiableMap(aliases); } @@ -2206,10 +2214,9 @@ private void setNativeContextMenuDisabled(boolean 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. + *

The aliases target LauncherActivity, which forwards to MainActivity and + * finishes. Android can finish activities launched through a disabled alias + * even with DONT_KILL_APP, so the editor must run under its own component. * * @param iconName Icon id (e.g. "midnight_circuit") or "default" to restore * the original launcher icon. @@ -2226,13 +2233,24 @@ private void setAppIcon(String iconName, CallbackContext callback) { return; } + ComponentName selected = new ComponentName( + packageName, packageName + "." + APP_ICON_ALIASES.get(key) + ); + // Keep a launcher entry available throughout the switch. + if (pm.getComponentEnabledSetting(selected) != PackageManager.COMPONENT_ENABLED_STATE_ENABLED) { + pm.setComponentEnabledSetting( + selected, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP + ); + } for (Map.Entry entry : APP_ICON_ALIASES.entrySet()) { - boolean enabled = entry.getKey().equals(key); + if (entry.getKey().equals(key)) continue; + ComponentName component = new ComponentName(packageName, packageName + "." + entry.getValue()); + if (pm.getComponentEnabledSetting(component) == PackageManager.COMPONENT_ENABLED_STATE_DISABLED) continue; pm.setComponentEnabledSetting( - new ComponentName(packageName, packageName + "." + entry.getValue()), - enabled - ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED - : PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + component, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ); } diff --git a/src/plugins/system/plugin.xml b/src/plugins/system/plugin.xml index ed80bf0cb..567207a1c 100644 --- a/src/plugins/system/plugin.xml +++ b/src/plugins/system/plugin.xml @@ -37,6 +37,7 @@ + diff --git a/src/settings/appSettings.js b/src/settings/appSettings.js index 2817bb52f..7d5e27be1 100644 --- a/src/settings/appSettings.js +++ b/src/settings/appSettings.js @@ -6,13 +6,12 @@ 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 appIconSetting, { preloadAppIconSetting } from "pages/appIconSetting"; import FontManager from "pages/fontManager"; import QuickToolsSettings from "pages/quickTools"; import encodings, { getEncoding } from "utils/encodings"; @@ -21,6 +20,7 @@ import { isPlayStoreInstall } from "utils/installSource"; import Url from "utils/Url"; export default function otherSettings() { + preloadAppIconSetting(); const values = appSettings.value; const title = strings["app settings"].capitalize(); const installedFromPlayStore = isPlayStoreInstall(); @@ -69,8 +69,6 @@ export default function otherSettings() { { 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.", @@ -405,18 +403,7 @@ export default function otherSettings() { 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; + return appIconSetting(); case "appFont": await fonts.setAppFont(value); diff --git a/tests/unit/androidPrepare.test.js b/tests/unit/androidPrepare.test.js index 8f29d5a15..b06c7c7e9 100644 --- a/tests/unit/androidPrepare.test.js +++ b/tests/unit/androidPrepare.test.js @@ -6,6 +6,7 @@ import vm from "node:vm"; import { expect, it } from "vitest"; const require = createRequire(import.meta.url); +const { parse } = require("elementtree"); const hook = fs.readFileSync( new URL("../../hooks/post-process.js", import.meta.url), "utf8", @@ -20,7 +21,27 @@ it("refreshes stale System plugin Java alongside icons on repeated Android prepa return target; }; try { - write("config.xml", ''); + write( + "config.xml", + fs.readFileSync(new URL("../../config.xml", import.meta.url), "utf8"), + ); + const manifest = write( + "platforms/android/app/src/main/AndroidManifest.xml", + ` + + + + + + + + + + + + + `, + ); write("build-extras.gradle", "// build configuration"); write("res/android/drawable/ic_acode_pro.xml", ""); const source = write( @@ -31,6 +52,9 @@ it("refreshes stale System plugin Java alongside icons on repeated Android prepa "platforms/android/app/src/main/java/com/foxdebug/system/System.java", 'aliases.put("default", "MainActivityIconDefault");', ); + const launcher = + "src/plugins/system/android/com/foxdebug/system/LauncherActivity.java"; + write(launcher, "// launcher routing"); const unrelated = write( "platforms/android/app/src/main/java/other/Plugin.java", "// other plugin", @@ -52,11 +76,41 @@ it("refreshes stale System plugin Java alongside icons on repeated Android prepa }, }); prepare(); + const preparedManifest = fs.readFileSync(manifest, "utf8"); + const application = parse(preparedManifest).find("application"); + const aliases = application.findall("activity-alias"); + expect(aliases).toHaveLength(17); + expect(aliases[0].get("android:name")).toBe("other.Alias"); + expect( + aliases + .slice(1) + .every( + (alias) => + alias.get("android:targetActivity") === + "com.foxdebug.system.LauncherActivity", + ), + ).toBe(true); + expect(application.getchildren()[2].get("android:name")).toBe( + "com.foxdebug.system.LauncherActivity", + ); + expect(application.find("activity").findall("intent-filter")).toHaveLength( + 0, + ); + expect( + fs.readFileSync( + path.join( + root, + "platforms/android/app/src/main/java/com/foxdebug/system/LauncherActivity.java", + ), + "utf8", + ), + ).toBe(fs.readFileSync(path.join(root, launcher), "utf8")); expect(fs.readFileSync(generated, "utf8")).toBe( fs.readFileSync(source, "utf8"), ); fs.appendFileSync(source, "\n// subsequent native edit"); prepare(); + expect(fs.readFileSync(manifest, "utf8")).toBe(preparedManifest); expect(fs.readFileSync(generated, "utf8")).toBe( fs.readFileSync(source, "utf8"), ); @@ -74,3 +128,62 @@ it("refreshes stale System plugin Java alongside icons on repeated Android prepa fs.rmSync(root, { recursive: true, force: true }); } }); + +it("preserves all launcher aliases while routing them through the registered launcher activity", () => { + const config = parse( + fs.readFileSync(new URL("../../config.xml", import.meta.url), "utf8"), + ); + const application = config + .findall(".//config-file") + .find((element) => element.findall("activity-alias").length); + const children = application.getchildren(); + const launcher = children[0]; + expect(launcher.tag).toBe("activity"); + expect(launcher.attrib).toMatchObject({ + "android:name": "com.foxdebug.system.LauncherActivity", + "android:noHistory": "true", + "android:relinquishTaskIdentity": "true", + "android:theme": "@android:style/Theme.NoDisplay", + }); + const aliases = application.findall("activity-alias"); + expect(aliases.map((alias) => alias.get("android:name"))).toEqual([ + ".MainActivityIconDefault", + ".MainActivityIconPro", + ".MainActivityIconMidnightCircuit", + ".MainActivityIconAuroraPulse", + ".MainActivityIconTerminalGlow", + ".MainActivityIconSolarFlare", + ".MainActivityIconBlueprint", + ".MainActivityIconPixelParty", + ".MainActivityIconPrism", + ".MainActivityIconPorcelain", + ".MainActivityIconTangerine", + ".MainActivityIconTidal", + ".MainActivityIconLilac", + ".MainActivityIconVolt", + ".MainActivityIconCobalt", + ".MainActivityIconGlacier", + ]); + for (const [index, alias] of aliases.entries()) { + expect(alias.get("android:targetActivity")).toBe( + launcher.get("android:name"), + ); + expect(alias.get("android:enabled")).toBe(index === 0 ? "true" : "false"); + expect(alias.get("android:exported")).toBe("true"); + } + const plugin = parse( + fs.readFileSync( + new URL("../../src/plugins/system/plugin.xml", import.meta.url), + "utf8", + ), + ); + expect( + plugin + .findall(".//source-file") + .some( + (file) => + file.get("src") === + "android/com/foxdebug/system/LauncherActivity.java", + ), + ).toBe(true); +}); diff --git a/tests/unit/appIconSelection.test.js b/tests/unit/appIconSelection.test.js index fc78dfc5b..6a0244a75 100644 --- a/tests/unit/appIconSelection.test.js +++ b/tests/unit/appIconSelection.test.js @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ toast: vi.fn(), error: vi.fn(), pass: false, + external: false, })); vi.mock("components/toast", () => ({ default: mocks.toast })); vi.mock("dialogs/confirm", () => ({ default: mocks.confirm })); @@ -22,31 +23,38 @@ vi.mock("lib/removeAds", () => ({ requestProPurchase: mocks.purchase })); vi.mock("utils/helpers", () => ({ default: { error: mocks.error, + shouldAllowExternalPurchase: () => mocks.external, promisify: (fn, ...args) => new Promise((resolve, reject) => fn(...args, resolve, reject)), }, })); -import createAppIconSelection from "lib/appIconSelection"; +import selectAppIcon from "lib/appIconSelection"; function harness() { const controller = new AbortController(); const onBusy = vi.fn(); + const onLoading = vi.fn(); const onChange = vi.fn(); return { controller, onBusy, + onLoading, onChange, - select: createAppIconSelection({ - signal: controller.signal, - onBusy, - onChange, - }), + select(iconId) { + return selectAppIcon(iconId, { + signal: controller.signal, + onBusy, + onLoading, + onChange, + }); + }, }; } beforeEach(() => { vi.clearAllMocks(); mocks.config.HAS_PRO = false; mocks.pass = false; + mocks.external = false; mocks.settings.value.appIcon = "default"; mocks.settings.update.mockImplementation(async ({ appIcon }) => { mocks.settings.value.appIcon = appIcon; @@ -64,16 +72,24 @@ beforeEach(() => { }); describe("icon selection", () => { - it("opens Pro purchase without watching an ad or applying the locked icon", async () => { + it.each([ + false, + true, + ])("opens Pro purchase with external checkout %s", async (external) => { + mocks.external = external; mocks.pass = true; const h = harness(); mocks.purchase.mockImplementation(async () => { + expect(h.onLoading.mock.calls).toEqual(external ? [] : [[true]]); mocks.config.HAS_PRO = true; }); await h.select("pro"); expect(mocks.purchase).toHaveBeenCalledOnce(); expect(mocks.reward).not.toHaveBeenCalled(); expect(system.setAppIcon).not.toHaveBeenCalled(); + expect(h.onLoading.mock.calls).toEqual( + external ? [[false]] : [[true], [false]], + ); await h.select("pro"); expect(system.setAppIcon).toHaveBeenCalledWith( "pro", @@ -89,7 +105,17 @@ describe("icon selection", () => { mocks.settings.value.appIcon = "pixel_party"; mocks.config.HAS_PRO = kind === "paid"; mocks.pass = kind === "pass"; - await harness().select(kind === "default" ? "default" : "midnight_circuit"); + const h = harness(); + const pending = h.select( + kind === "default" + ? "default" + : kind === "paid" + ? "pro" + : "midnight_circuit", + ); + expect(h.onLoading).toHaveBeenCalledExactlyOnceWith(true); + await pending; + expect(h.onLoading.mock.calls).toEqual([[true], [false]]); expect(mocks.confirm).not.toHaveBeenCalled(); expect(mocks.reward).not.toHaveBeenCalled(); expect(mocks.settings.update).toHaveBeenCalledOnce(); @@ -100,6 +126,7 @@ describe("icon selection", () => { await h.select("default"); await h.select("unknown"); expect(h.onBusy).not.toHaveBeenCalled(); + expect(h.onLoading).not.toHaveBeenCalled(); let confirm; mocks.confirm.mockImplementation( () => @@ -111,10 +138,19 @@ describe("icon selection", () => { await h.select("solar_flare"); expect(mocks.confirm).toHaveBeenCalledOnce(); expect(mocks.reward).not.toHaveBeenCalled(); + expect(h.onBusy).toHaveBeenCalledExactlyOnceWith(true); + expect(h.onLoading).not.toHaveBeenCalled(); + mocks.reward.mockImplementation(async () => { + expect(h.onLoading).toHaveBeenCalledExactlyOnceWith(true); + await h.select("solar_flare"); + return true; + }); confirm(true); await first; expect(system.setAppIcon.mock.calls[0][0]).toBe("pixel_party"); expect(h.onBusy.mock.calls).toEqual([[true], [false]]); + expect(h.onLoading.mock.calls).toEqual([[true], [false]]); + expect(mocks.reward).toHaveBeenCalledOnce(); expect(mocks.toast).toHaveBeenCalledTimes(1); }); it.each([ @@ -137,7 +173,57 @@ describe("icon selection", () => { expect(mocks.settings.update).not.toHaveBeenCalled(); expect(mocks.toast).not.toHaveBeenCalledWith("Changed"); expect(h.onBusy).toHaveBeenLastCalledWith(false); + expect(h.onLoading.mock.calls).toEqual( + kind === "decline" ? [[false]] : [[true], [false]], + ); if (kind === "decline") expect(mocks.reward).not.toHaveBeenCalled(); + if (kind === "load failure" || kind === "native failure") { + expect(mocks.error).toHaveBeenCalledOnce(); + } else { + expect(mocks.error).not.toHaveBeenCalled(); + } + if (kind === "incomplete") + expect(mocks.toast).toHaveBeenCalledWith("Incomplete"); + }); + it("shows success only after the native change has been persisted", async () => { + mocks.pass = true; + let applied, persisted; + system.setAppIcon.mockImplementation((id, success) => { + applied = success; + }); + mocks.settings.update.mockImplementationOnce( + () => + new Promise((resolve) => { + persisted = resolve; + }), + ); + const h = harness(); + const pending = h.select("pixel_party"); + expect(mocks.toast).not.toHaveBeenCalled(); + applied(); + await vi.waitFor(() => expect(persisted).toBeTypeOf("function")); + expect(h.onChange).not.toHaveBeenCalled(); + expect(mocks.toast).not.toHaveBeenCalled(); + persisted(); + await pending; + expect(h.onChange).toHaveBeenCalledOnce(); + expect(mocks.toast).toHaveBeenCalledExactlyOnceWith("Changed"); + }); + it.each([ + "pro", + "pass", + ])("rechecks %s access after confirmation before loading", async (kind) => { + const h = harness(); + mocks.confirm.mockImplementation(async () => { + expect(h.onLoading).not.toHaveBeenCalled(); + mocks.config.HAS_PRO = kind === "pro"; + mocks.pass = kind === "pass"; + return true; + }); + await h.select("pixel_party"); + expect(mocks.reward).not.toHaveBeenCalled(); + expect(system.setAppIcon).toHaveBeenCalledOnce(); + expect(h.onLoading.mock.calls).toEqual([[true], [false]]); }); it("ignores a reward received after leaving the picker", async () => { let finish; @@ -156,6 +242,7 @@ describe("icon selection", () => { expect(system.setAppIcon).not.toHaveBeenCalled(); expect(h.onChange).not.toHaveBeenCalled(); expect(mocks.toast).not.toHaveBeenCalled(); + expect(h.onLoading).toHaveBeenCalledExactlyOnceWith(true); }); it("does not apply after leaving during confirmation", async () => { const h = harness(); @@ -166,5 +253,29 @@ describe("icon selection", () => { await h.select("pixel_party"); expect(mocks.reward).not.toHaveBeenCalled(); expect(system.setAppIcon).not.toHaveBeenCalled(); + expect(h.onLoading).not.toHaveBeenCalled(); + }); + it("keeps a reopened picker locked until an earlier native change is persisted", async () => { + mocks.config.HAS_PRO = true; + let applied; + system.setAppIcon.mockImplementationOnce((id, success) => { + applied = success; + }); + const previous = harness(); + const pending = previous.select("pixel_party"); + previous.controller.abort(); + const reopened = harness(); + await reopened.select("solar_flare"); + expect(system.setAppIcon).toHaveBeenCalledOnce(); + expect(reopened.onBusy).not.toHaveBeenCalled(); + applied(); + await pending; + expect(mocks.settings.value.appIcon).toBe("pixel_party"); + expect(previous.onChange).not.toHaveBeenCalled(); + expect(previous.onLoading).toHaveBeenCalledExactlyOnceWith(true); + expect(mocks.toast).not.toHaveBeenCalled(); + await reopened.select("solar_flare"); + expect(mocks.settings.value.appIcon).toBe("solar_flare"); + expect(reopened.onChange).toHaveBeenCalledOnce(); }); }); diff --git a/tests/unit/appIconSetting.test.js b/tests/unit/appIconSetting.test.js new file mode 100644 index 000000000..0c58352de --- /dev/null +++ b/tests/unit/appIconSetting.test.js @@ -0,0 +1,622 @@ +// @vitest-environment happy-dom + +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import vm from "node:vm"; +import { transformSync } from "@babel/core"; +import DOMPurify from "dompurify"; +import tag from "html-tag-js"; +import Ref from "html-tag-js/ref"; +import { APP_ICONS } from "lib/appIcons"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + settings: { value: { appIcon: "default" }, update: vi.fn() }, + config: { HAS_PRO: false }, + reward: vi.fn(), + purchase: vi.fn(), + external: false, + toast: vi.fn(), + error: vi.fn(), +})); +vi.mock("lib/settings", () => ({ default: mocks.settings })); +vi.mock("lib/config", () => ({ default: mocks.config })); +vi.mock("lib/restoreTheme", () => ({ default: vi.fn() })); +vi.mock("components/checkbox", () => ({ + default: () => document.createElement("input"), +})); +vi.mock("components/toast", () => ({ default: mocks.toast })); +vi.mock("lib/adRewards", () => ({ + default: { canShowAds: () => !mocks.config.HAS_PRO }, +})); +vi.mock("lib/rewardedAd", () => ({ default: mocks.reward })); +vi.mock("lib/removeAds", () => ({ requestProPurchase: mocks.purchase })); +vi.mock("utils/helpers", () => ({ + default: { + error: mocks.error, + shouldAllowExternalPurchase: () => mocks.external, + promisify: (fn, ...args) => + new Promise((resolve, reject) => fn(...args, resolve, reject)), + }, +})); +import actionStack from "lib/actionStack"; +import selectAppIcon from "lib/appIconSelection"; +import restoreTheme from "lib/restoreTheme"; + +createRequire(path.resolve("package.json"))("html-tag-js/polyfill"); + +const compiled = new Map(); + +// Use the existing JSX transform, with module dependencies supplied by each test. +function loadModule(name, dependencies, globals = {}) { + const filename = path.resolve("src/pages/appIconSetting", `${name}.js`); + if (!compiled.has(filename)) { + compiled.set( + filename, + transformSync(readFileSync(filename, "utf8"), { + filename, + babelrc: false, + configFile: false, + presets: [ + [ + "@babel/preset-env", + { targets: { node: "current" }, modules: "commonjs" }, + ], + ], + plugins: [ + "html-tag-js/jsx/syntax-parser.js", + "html-tag-js/jsx/jsx-to-tag.js", + ], + }).code, + ); + } + const exports = {}; + vm.runInNewContext( + compiled.get(filename), + { + exports, + tag, + AbortController, + document, + setTimeout, + require(id) { + if (!(id in dependencies)) throw new Error(`Unexpected import: ${id}`); + return dependencies[id](); + }, + ...globals, + }, + { filename }, + ); + return exports; +} + +function preloadHarness() { + const images = []; + const open = vi.fn(async () => "opened"); + const entry = loadModule( + "index", + { + "lib/appIcons": () => ({ APP_ICONS }), + "./appIconSetting": () => ({ __esModule: true, default: open }), + }, + { + Image: class { + decode = vi.fn().mockResolvedValue(undefined); + constructor() { + images.push(this); + } + }, + }, + ); + return { ...entry, images, open }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + actionStack.setMark(); + vi.stubGlobal("app", document.body); + vi.stubGlobal("tag", tag); + vi.stubGlobal("strings", { + "app icon": "App icon", + "loading...": "Loading...", + "app icon changed": "Changed", + "confirm app icon reward": "Watch?", + "rewarded ad incomplete": "Incomplete", + close: "Close", + ok: "OK", + cancel: "Cancel", + }); + vi.stubGlobal("system", { setAppIcon: vi.fn((id, success) => success()) }); + mocks.config.HAS_PRO = false; + mocks.external = false; + mocks.settings.value.appIcon = "default"; + mocks.settings.update.mockImplementation(async ({ appIcon }) => { + mocks.settings.value.appIcon = appIcon; + }); + mocks.reward.mockResolvedValue(true); + mocks.purchase.mockResolvedValue(false); +}); +afterEach(() => { + actionStack.clearFromMark(); + vi.runAllTimers(); + actionStack.unfreeze(); + vi.restoreAllMocks(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + document.body.replaceChildren(); +}); + +it("does no startup work and shares pending and successful preloads without opening", async () => { + const h = preloadHarness(); + expect(h.images).toHaveLength(0); + const first = h.preloadAppIconSetting(); + const second = h.preloadAppIconSetting(); + expect(h.images.map((image) => image.src)).toEqual( + APP_ICONS.map((i) => i.image), + ); + for (const image of h.images) image.onload(); + await Promise.all([first, second]); + await h.preloadAppIconSetting(); + expect(h.images).toHaveLength(APP_ICONS.length); + expect(h.open).not.toHaveBeenCalled(); + for (const image of h.images) { + expect(image.decode).toHaveBeenCalledOnce(); + expect(image.onload).toBeNull(); + expect(image.onerror).toBeNull(); + } +}); + +it("retries only failed previews when opening the picker", async () => { + const h = preloadHarness(); + const first = h.preloadAppIconSetting(); + h.images[0].onerror(new Error("Unavailable image")); + for (const image of h.images.slice(1)) image.onload(); + await first; + await expect(h.default()).resolves.toBe("opened"); + expect(h.images).toHaveLength(APP_ICONS.length + 1); + expect(h.images.at(-1).src).toBe(APP_ICONS[0].image); + expect(h.images[0].onerror).toBeNull(); + h.images.at(-1).onload(); + await h.preloadAppIconSetting(); +}); + +it("opens while previews are pending and falls back when decoding is unavailable or fails", async () => { + const h = preloadHarness(); + const preload = h.preloadAppIconSetting(); + h.images[0].decode = undefined; + h.images[1].decode.mockRejectedValue(new Error("Decode failed")); + await expect(h.default("argument")).resolves.toBe("opened"); + expect(h.open).toHaveBeenCalledExactlyOnceWith("argument"); + for (const image of h.images) image.onload(); + await preload; + await h.preloadAppIconSetting(); + expect(h.images).toHaveLength(APP_ICONS.length); +}); + +function dialogHarness() { + const trigger = document.createElement("button"); + app.append(trigger); + trigger.focus(); + const loader = loadModule( + "../../dialogs/loader", + { + "components/tailSpin.js": () => () => "", + dompurify: () => DOMPurify, + "html-tag-js/ref": () => Ref, + "lib/actionStack": () => actionStack, + "lib/restoreTheme": () => restoreTheme, + }, + { app, strings, clearTimeout }, + ).default; + vi.spyOn(loader, "create"); + vi.spyOn(loader, "destroy"); + let selection; + const select = vi.fn((iconId, options) => { + selection = options; + return selectAppIcon(iconId, options); + }); + const module = loadModule( + "appIconSetting", + { + "dialogs/style.scss": () => ({}), + "./appIconSetting.scss": () => ({}), + "dialogs/loader": () => loader, + "lib/actionStack": () => actionStack, + "lib/appIconSelection": () => select, + "lib/appIcons": () => ({ APP_ICONS }), + "lib/settings": () => mocks.settings, + "lib/restoreTheme": () => restoreTheme, + }, + { app, strings }, + ); + const closed = module.default(); + const dialog = app.querySelector(".app-icon-dialog"); + return { + open: module.default, + closed, + dialog, + get selection() { + return selection; + }, + loader, + trigger, + close: dialog.querySelector(".button-container button"), + click(id = "pixel_party") { + app.querySelector(`.app-icon-dialog [data-icon="${id}"] img`).click(); + return select.mock.results.at(-1)?.value; + }, + }; +} + +it("opens one complete grid and closes only after native success, preserving the toast", async () => { + const append = vi.spyOn(app, "append"); + append.mockImplementation((...nodes) => { + const [dialog] = nodes; + if (dialog.classList.contains("app-icon-dialog")) { + expect(dialog.querySelectorAll("img")).toHaveLength(APP_ICONS.length); + expect(dialog.querySelector(".current").dataset.icon).toBe("default"); + } + for (const node of nodes) app.appendChild(node); + }); + mocks.config.HAS_PRO = true; + const h = dialogHarness(); + expect(h.open()).toBe(h.closed); + expect(actionStack.length).toBe(1); + expect(h.dialog.getAttribute("aria-modal")).toBe("true"); + expect(document.activeElement.getAttribute("aria-pressed")).toBe("true"); + expect(h.dialog.querySelector(".app-icon-list").textContent.trim()).toBe(""); + for (const icon of APP_ICONS) { + const button = h.dialog.querySelector(`[data-icon="${icon.id}"]`); + expect(button.getAttribute("aria-label")).toBe(icon.label); + expect(button.querySelector("img").alt).toBe(""); + } + expect( + [...h.dialog.querySelectorAll("img")].every((i) => i.loading !== "lazy"), + ).toBe(true); + await h.click("default"); + expect(h.loader.create).not.toHaveBeenCalled(); + let applied; + system.setAppIcon.mockImplementation((id, success) => { + applied = success; + }); + const pending = h.click(); + expect(h.dialog.classList.contains("hide")).toBe(false); + expect(h.loader.create).toHaveBeenCalledExactlyOnceWith("App icon", "Loading..."); + expect(h.dialog.inert).toBe(true); + expect(document.querySelector("#__loader button")).toBeNull(); + await actionStack.pop(); + h.close.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + expect(h.selection.signal.aborted).toBe(false); + expect(actionStack.length).toBe(1); + applied(); + await pending; + expect(mocks.settings.value.appIcon).toBe("pixel_party"); + expect(mocks.toast).toHaveBeenCalledExactlyOnceWith("Changed"); + vi.runAllTimers(); + await h.closed; + expect(h.dialog.isConnected).toBe(false); + expect(h.loader.destroy).toHaveBeenCalledOnce(); + expect(document.querySelector("#__loader, #__loader-mask")).toBeNull(); + expect(document.activeElement).toBe(h.trigger); +}); + +it.each([ + "success", + "failure", +])("keeps a reopened picker's loader active across old cleanup until %s", async (outcome) => { + mocks.config.HAS_PRO = true; + const h = dialogHarness(); + await h.click(); + const previousLoader = document.querySelector("#__loader"); + expect(previousLoader.classList.contains("hide")).toBe(true); + await vi.advanceTimersByTimeAsync(180); + await h.closed; + + const closed = h.open(); + const dialog = app.querySelector(".app-icon-dialog"); + let finish; + system.setAppIcon.mockImplementationOnce((id, success, failure) => { + finish = () => (outcome === "success" ? success() : failure("Failed")); + }); + const pending = h.click("solar_flare"); + try { + const currentLoader = document.querySelector("#__loader"); + expect(currentLoader).not.toBeNull(); + expect(currentLoader).not.toBe(previousLoader); + expect(currentLoader.classList.contains("hide")).toBe(false); + await vi.advanceTimersByTimeAsync(120); + expect(document.querySelector("#__loader")).toBe(currentLoader); + expect(document.querySelectorAll("#__loader-mask")).toHaveLength(1); + expect(dialog.inert).toBe(true); + await actionStack.pop(); + dialog + .querySelector(".button-container button") + .dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + expect(h.selection.signal.aborted).toBe(false); + expect(actionStack.length).toBe(1); + } finally { + finish(); + await pending; + } + await vi.advanceTimersByTimeAsync(300); + expect(document.querySelector("#__loader, #__loader-mask")).toBeNull(); + if (outcome === "failure") { + expect(dialog.inert).toBe(false); + expect( + [...dialog.querySelectorAll("button")].every( + (button) => !button.disabled, + ), + ).toBe(true); + expect(mocks.error).toHaveBeenCalledExactlyOnceWith("Failed"); + await actionStack.pop(); + await vi.advanceTimersByTimeAsync(180); + } + await closed; + expect(actionStack.length).toBe(0); + expect(mocks.toast).toHaveBeenCalledTimes(outcome === "success" ? 2 : 1); +}); + +it("replaces loader content and cancellation without breaking hide and show", async () => { + const h = dialogHarness(); + const oldCancel = vi.fn(); + const cancel = vi.fn(); + h.loader.create("Old title", "Old message", { + timeout: 100, + oncancel: oldCancel, + }); + await vi.advanceTimersByTimeAsync(50); + const replacement = h.loader.create("New title", "New message", { + timeout: 200, + oncancel: cancel, + }); + const dialog = document.querySelector("#__loader"); + expect(dialog.querySelector(".title").textContent.trim()).toBe("New title"); + expect(dialog.textContent).toContain("New message"); + replacement.setTitle("Updated title"); + replacement.setMessage("Updated message"); + expect(dialog.querySelector(".title").textContent.trim()).toBe( + "Updated title", + ); + expect(dialog.textContent).toContain("Updated message"); + replacement.hide(); + expect(document.querySelector("#__loader, #__loader-mask")).toBeNull(); + replacement.show(); + expect(document.querySelector("#__loader")).toBe(dialog); + await vi.advanceTimersByTimeAsync(50); + expect(dialog.querySelector("button")).toBeNull(); + await vi.advanceTimersByTimeAsync(150); + dialog.querySelector("button").click(); + expect(cancel).toHaveBeenCalledOnce(); + expect(oldCancel).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(300); + replacement.show(); + expect(document.querySelector("#__loader, #__loader-mask")).toBeNull(); + await actionStack.pop(); + await vi.advanceTimersByTimeAsync(180); + await h.closed; + expect(restoreTheme.mock.calls).toEqual([[true], [true], [], []]); +}); + +it("locks tiles before confirmation and shows the loader through reward and application", async () => { + let reward, applied; + const h = dialogHarness(); + mocks.reward.mockImplementation(() => { + expect(h.loader.create).toHaveBeenCalledOnce(); + return new Promise((resolve) => { + reward = resolve; + }); + }); + system.setAppIcon.mockImplementation((id, success) => { + applied = success; + }); + const pending = h.click(); + h.click("solar_flare"); + expect(actionStack.length).toBe(2); + expect( + h.dialog.querySelector(".app-icon-list").getAttribute("aria-busy"), + ).toBe("true"); + expect( + [...h.dialog.querySelectorAll(".app-icon-item")].every( + (button) => button.disabled, + ), + ).toBe(true); + expect(h.loader.create).not.toHaveBeenCalled(); + expect(h.loader.destroy).not.toHaveBeenCalled(); + expect(mocks.reward).not.toHaveBeenCalled(); + app.querySelectorAll(".confirm button")[1].click(); + await vi.waitFor(() => expect(reward).toBeTypeOf("function")); + h.click("solar_flare"); + expect(mocks.reward).toHaveBeenCalledOnce(); + reward(true); + await vi.waitFor(() => expect(applied).toBeTypeOf("function")); + expect(h.loader.create).toHaveBeenCalledOnce(); + expect(h.loader.destroy).not.toHaveBeenCalled(); + expect(system.setAppIcon.mock.calls[0][0]).toBe("pixel_party"); + applied(); + await pending; + vi.runAllTimers(); + await h.closed; + expect(h.loader.destroy).toHaveBeenCalledOnce(); +}); + +it("never shows a loader when the picker closes during confirmation", async () => { + const h = dialogHarness(); + const pending = h.click(); + h.close.click(); + await pending; + vi.runAllTimers(); + await h.closed; + expect(h.loader.create).not.toHaveBeenCalled(); + expect(h.loader.destroy).not.toHaveBeenCalled(); + expect(mocks.reward).not.toHaveBeenCalled(); + expect(system.setAppIcon).not.toHaveBeenCalled(); + expect(actionStack.length).toBe(0); +}); + +it.each([ + "back", + "outside", + "close", + "escape", +])("dismisses through %s once and preserves a reopened picker's handler", async (method) => { + const h = dialogHarness(); + const id = h.dialog.getAttribute("aria-labelledby").replace(/-title$/, ""); + const staleClose = actionStack.get(id).action; + if (method === "back") await actionStack.pop(); + if (method === "outside") h.dialog.nextElementSibling.click(); + if (method === "close") h.close.click(); + if (method === "escape") + h.close.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + staleClose(); + vi.runAllTimers(); + await h.closed; + expect(actionStack.length).toBe(0); + expect(document.querySelectorAll(".prompt, .mask")).toHaveLength(0); + expect(restoreTheme.mock.calls).toEqual([[true], []]); + expect(document.activeElement).toBe(h.trigger); + const next = h.open(); + staleClose(); + expect(actionStack.length).toBe(1); + expect( + app.querySelector(".app-icon-dialog").getAttribute("aria-labelledby"), + ).not.toBe(`${id}-title`); + await actionStack.pop(); + vi.runAllTimers(); + await next; +}); + +it.each([ + "back", + "cancel", + "reward failure", + "native failure", + "purchase", +])("keeps the picker and images usable after %s", async (outcome) => { + if (outcome === "reward failure") + mocks.reward.mockRejectedValueOnce(new Error("Unavailable")); + if (outcome === "native failure") + system.setAppIcon.mockImplementationOnce((id, ok, fail) => fail("Failed")); + const h = dialogHarness(); + const images = [...h.dialog.querySelectorAll("img")]; + const pending = h.click(outcome === "purchase" ? "pro" : "pixel_party"); + if (outcome !== "purchase") { + expect(actionStack.length).toBe(2); + expect(h.loader.create).not.toHaveBeenCalled(); + if (outcome === "back") await actionStack.pop(); + else + app + .querySelectorAll(".confirm button") + [outcome === "cancel" ? 0 : 1].click(); + } + await pending; + vi.runAllTimers(); + const loaderCount = outcome === "back" || outcome === "cancel" ? 0 : 1; + expect(h.loader.create).toHaveBeenCalledTimes(loaderCount); + expect(h.loader.destroy).toHaveBeenCalledTimes(loaderCount); + expect(actionStack.length).toBe(1); + expect(h.dialog.classList.contains("hide")).toBe(false); + expect(h.dialog.inert).toBe(false); + expect(document.querySelector("#__loader, #__loader-mask")).toBeNull(); + expect( + [...h.dialog.querySelectorAll("button")].every((b) => !b.disabled), + ).toBe(true); + expect(mocks.settings.value.appIcon).toBe("default"); + for (const [i, image] of [...h.dialog.querySelectorAll("img")].entries()) + expect(image).toBe(images[i]); + mocks.config.HAS_PRO = true; + await h.click(); + vi.runAllTimers(); + await h.closed; + expect(mocks.settings.value.appIcon).toBe("pixel_party"); +}); + +it("leaves the external Pro flow's loader under its own control", async () => { + mocks.external = true; + const h = dialogHarness(); + mocks.purchase.mockImplementation(async () => { + h.loader.create("Login", "Loading..."); + }); + await h.click("pro"); + expect(h.loader.create).toHaveBeenCalledExactlyOnceWith("Login", "Loading..."); + expect(h.loader.destroy).not.toHaveBeenCalled(); + expect(h.dialog.inert).toBe(false); + expect(document.querySelector("#__loader .title").textContent.trim()).toBe("Login"); + h.loader.destroy(); + vi.runAllTimers(); + await actionStack.pop(); + vi.runAllTimers(); + await h.closed; + expect(h.loader.destroy).toHaveBeenCalledOnce(); +}); + +it("cleans up a pending reward and ignores late callbacks after reopening", async () => { + let reward; + mocks.reward.mockImplementation( + () => + new Promise((resolve) => { + reward = resolve; + }), + ); + const h = dialogHarness(); + const pending = h.click(); + app.querySelectorAll(".confirm button")[1].click(); + await vi.waitFor(() => expect(reward).toBeTypeOf("function")); + expect(h.loader.create).toHaveBeenCalledOnce(); + h.selection.onChange(); + vi.runAllTimers(); + await h.closed; + expect(h.loader.destroy).toHaveBeenCalledOnce(); + const next = h.open(); + reward(true); + await pending; + h.selection.onChange(); + expect(h.loader.create).toHaveBeenCalledOnce(); + expect(h.loader.destroy).toHaveBeenCalledOnce(); + expect(system.setAppIcon).not.toHaveBeenCalled(); + expect(actionStack.length).toBe(1); + expect(app.querySelector(".app-icon-dialog").classList.contains("hide")).toBe( + false, + ); + await actionStack.pop(); + vi.runAllTimers(); + await next; +}); + +it("wraps keyboard focus but lets Escape dismiss the top confirmation first", async () => { + const h = dialogHarness(); + const first = h.dialog.querySelector("button"); + first.dispatchEvent( + new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }), + ); + expect(document.activeElement).toBe(h.close); + h.close.dispatchEvent( + new KeyboardEvent("keydown", { key: "Tab", bubbles: true }), + ); + expect(document.activeElement).toBe(first); + const pending = h.click(); + expect(document.activeElement).toBe(h.close); + const tab = new KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + h.close.dispatchEvent(tab); + expect(tab.defaultPrevented).toBe(false); + h.close.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + await pending; + expect(actionStack.length).toBe(1); + expect(h.selection.signal.aborted).toBe(false); + h.close.click(); + vi.runAllTimers(); + await h.closed; +}); diff --git a/tests/unit/appIcons.test.js b/tests/unit/appIcons.test.js index 77df9a005..646aa5754 100644 --- a/tests/unit/appIcons.test.js +++ b/tests/unit/appIcons.test.js @@ -1,5 +1,7 @@ +import fs from "node:fs"; +import { parse } from "elementtree"; +import { APP_ICONS } from "lib/appIcons"; import { describe, expect, it } from "vitest"; -import { APP_ICONS, getAppIconLabel } from "lib/appIcons"; describe("appIcons", () => { it("exposes the default icon first", () => { @@ -12,13 +14,28 @@ describe("appIcons", () => { } }); - 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"); - }); + it("keeps picker IDs, native mappings, and launcher aliases in sync", () => { + const native = fs.readFileSync( + new URL( + "../../src/plugins/system/android/com/foxdebug/system/System.java", + import.meta.url, + ), + "utf8", + ); + const mappings = [ + ...native.matchAll(/aliases\.put\("([^"]+)", "([^"]+)"\);/g), + ].map(([, id, name]) => [id, name]); + expect(mappings.map(([id]) => id)).toEqual(APP_ICONS.map(({ id }) => id)); + expect(new Set(mappings.map(([, name]) => name)).size).toBe( + APP_ICONS.length, + ); + const config = parse( + fs.readFileSync(new URL("../../config.xml", import.meta.url), "utf8"), + ); + expect( + config + .findall(".//activity-alias") + .map((alias) => alias.get("android:name")), + ).toEqual(mappings.map(([, name]) => `.${name}`)); }); }); diff --git a/www/icons/ic_acode_cobalt.svg b/www/icons/ic_acode_cobalt.svg new file mode 100644 index 000000000..b43eaf92c --- /dev/null +++ b/www/icons/ic_acode_cobalt.svg @@ -0,0 +1,6 @@ +Acode CobaltBlue glaze · from 02 Porcelain. Original Acode logo geometry; review concept. + + + + + \ No newline at end of file diff --git a/www/icons/ic_acode_glacier.svg b/www/icons/ic_acode_glacier.svg new file mode 100644 index 000000000..b035c833b --- /dev/null +++ b/www/icons/ic_acode_glacier.svg @@ -0,0 +1,6 @@ +Acode GlacierCarved ice · from 05 Tidal. Original Acode logo geometry; review concept. + + + + + \ No newline at end of file diff --git a/www/icons/ic_acode_lilac.svg b/www/icons/ic_acode_lilac.svg new file mode 100644 index 000000000..0f1abb2c3 --- /dev/null +++ b/www/icons/ic_acode_lilac.svg @@ -0,0 +1,6 @@ +Acode LilacSoft enamel · lavender and plum. Original Acode logo geometry; review concept. + + + + + \ No newline at end of file diff --git a/www/icons/ic_acode_porcelain.svg b/www/icons/ic_acode_porcelain.svg new file mode 100644 index 000000000..b78da2bcf --- /dev/null +++ b/www/icons/ic_acode_porcelain.svg @@ -0,0 +1,6 @@ +Acode PorcelainGlazed jade · soft ivory. Original Acode logo geometry; review concept. + + + + + \ No newline at end of file diff --git a/www/icons/ic_acode_prism.svg b/www/icons/ic_acode_prism.svg new file mode 100644 index 000000000..b41a4f7b1 --- /dev/null +++ b/www/icons/ic_acode_prism.svg @@ -0,0 +1,6 @@ +Acode PrismIridescent glass · midnight violet. Original Acode logo geometry; review concept. + + + + + \ No newline at end of file diff --git a/www/icons/ic_acode_tangerine.svg b/www/icons/ic_acode_tangerine.svg new file mode 100644 index 000000000..ca561eb8a --- /dev/null +++ b/www/icons/ic_acode_tangerine.svg @@ -0,0 +1,6 @@ +Acode TangerineLayered paper · peach and vermilion. Original Acode logo geometry; review concept. + + + + + \ No newline at end of file diff --git a/www/icons/ic_acode_tidal.svg b/www/icons/ic_acode_tidal.svg new file mode 100644 index 000000000..f5c2f8d1d --- /dev/null +++ b/www/icons/ic_acode_tidal.svg @@ -0,0 +1,6 @@ +Acode TidalFrosted ice · flowing ocean blue. Original Acode logo geometry; review concept. + + + + + \ No newline at end of file diff --git a/www/icons/ic_acode_volt.svg b/www/icons/ic_acode_volt.svg new file mode 100644 index 000000000..4a87e0405 --- /dev/null +++ b/www/icons/ic_acode_volt.svg @@ -0,0 +1,6 @@ +Acode VoltElectric chartreuse · deep racing green. Original Acode logo geometry; review concept. + + + + + \ No newline at end of file