diff --git a/config.xml b/config.xml index 5101e1fa5..33b1afdfa 100644 --- a/config.xml +++ b/config.xml @@ -190,6 +190,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/lang/ar-ye.json b/src/lang/ar-ye.json index f593714cc..6678116c0 100644 --- a/src/lang/ar-ye.json +++ b/src/lang/ar-ye.json @@ -719,6 +719,7 @@ "app font": "خط التطبيق", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "اختيار الخط المستخدم في واجهة التطبيق.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (تشغيل أمر ثنائي)", diff --git a/src/lang/be-by.json b/src/lang/be-by.json index 14562075a..a84724eea 100644 --- a/src/lang/be-by.json +++ b/src/lang/be-by.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/bn-bd.json b/src/lang/bn-bd.json index 75cc364b1..bd708c149 100644 --- a/src/lang/bn-bd.json +++ b/src/lang/bn-bd.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/cs-cz.json b/src/lang/cs-cz.json index 38b332d15..ef63d564a 100644 --- a/src/lang/cs-cz.json +++ b/src/lang/cs-cz.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/de-de.json b/src/lang/de-de.json index a3c9447c7..aff6d5ccd 100644 --- a/src/lang/de-de.json +++ b/src/lang/de-de.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/en-us.json b/src/lang/en-us.json index 2f7a159f7..3fd9aeaef 100644 --- a/src/lang/en-us.json +++ b/src/lang/en-us.json @@ -768,6 +768,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "unsaved selected tabs warning": "Some selected tabs are not saved. Choose what to do.", diff --git a/src/lang/es-sv.json b/src/lang/es-sv.json index 276965e48..9224bc496 100644 --- a/src/lang/es-sv.json +++ b/src/lang/es-sv.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/fr-fr.json b/src/lang/fr-fr.json index 5cf8f8b56..c235733b1 100644 --- a/src/lang/fr-fr.json +++ b/src/lang/fr-fr.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/he-il.json b/src/lang/he-il.json index 4c306eb53..ad91de7a8 100644 --- a/src/lang/he-il.json +++ b/src/lang/he-il.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/hi-in.json b/src/lang/hi-in.json index 97857b6bc..3484e1e56 100644 --- a/src/lang/hi-in.json +++ b/src/lang/hi-in.json @@ -718,6 +718,7 @@ "unpin tab before closing": "Unpin the tab before closing it.", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "app font": "ऐप फ़ॉन्ट", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", diff --git a/src/lang/hu-hu.json b/src/lang/hu-hu.json index 5616f4f88..3fdd558a5 100644 --- a/src/lang/hu-hu.json +++ b/src/lang/hu-hu.json @@ -719,6 +719,7 @@ "app font": "Alkalmazás betűtípusa", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Válassza ki az alkalmazás egész felületén használandó betűtípust.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (bináris parancs futtatása)", diff --git a/src/lang/id-id.json b/src/lang/id-id.json index 9b38fe0ef..ed6b5b9da 100644 --- a/src/lang/id-id.json +++ b/src/lang/id-id.json @@ -719,6 +719,7 @@ "app font": "Font Aplikasi", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Pilih font yang digunakan di seluruh antarmuka aplikasi.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (luncurkan perintah biner)", diff --git a/src/lang/ir-fa.json b/src/lang/ir-fa.json index 8d7957a6f..16607822f 100644 --- a/src/lang/ir-fa.json +++ b/src/lang/ir-fa.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/it-it.json b/src/lang/it-it.json index 9d09694c3..020260355 100644 --- a/src/lang/it-it.json +++ b/src/lang/it-it.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/ja-jp.json b/src/lang/ja-jp.json index 9901ac78a..38b6a2f7f 100644 --- a/src/lang/ja-jp.json +++ b/src/lang/ja-jp.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/ko-kr.json b/src/lang/ko-kr.json index 1e1a369da..2f629696c 100644 --- a/src/lang/ko-kr.json +++ b/src/lang/ko-kr.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/ln-ln.json b/src/lang/ln-ln.json index b49912e1e..4ded10753 100644 --- a/src/lang/ln-ln.json +++ b/src/lang/ln-ln.json @@ -834,6 +834,7 @@ "terminal:failsafe": "Mode FailSafe", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "confirm app icon reward": "Watch one rewarded ad to apply this app icon?", "rewarded ad unavailable": "Rewarded ads are unavailable right now. Please try again.", diff --git a/src/lang/ml-in.json b/src/lang/ml-in.json index d0f3823fe..30206dec6 100644 --- a/src/lang/ml-in.json +++ b/src/lang/ml-in.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/mm-unicode.json b/src/lang/mm-unicode.json index 9f67ac298..c06e71395 100644 --- a/src/lang/mm-unicode.json +++ b/src/lang/mm-unicode.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/mm-zawgyi.json b/src/lang/mm-zawgyi.json index de23b6e30..eb1db6c80 100644 --- a/src/lang/mm-zawgyi.json +++ b/src/lang/mm-zawgyi.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/pl-pl.json b/src/lang/pl-pl.json index 890c7082b..e5f80015c 100644 --- a/src/lang/pl-pl.json +++ b/src/lang/pl-pl.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/pt-br.json b/src/lang/pt-br.json index 09938cbf7..835871305 100644 --- a/src/lang/pt-br.json +++ b/src/lang/pt-br.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/pu-in.json b/src/lang/pu-in.json index 7f29eb6f4..71539f916 100644 --- a/src/lang/pu-in.json +++ b/src/lang/pu-in.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/ru-ru.json b/src/lang/ru-ru.json index 045398318..d8df5b492 100644 --- a/src/lang/ru-ru.json +++ b/src/lang/ru-ru.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/tl-ph.json b/src/lang/tl-ph.json index 3342744de..8dbb3ca6c 100644 --- a/src/lang/tl-ph.json +++ b/src/lang/tl-ph.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/tr-tr.json b/src/lang/tr-tr.json index 68bc5dafb..9c449ac4d 100644 --- a/src/lang/tr-tr.json +++ b/src/lang/tr-tr.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/uk-ua.json b/src/lang/uk-ua.json index 062101b41..48a3b5be0 100644 --- a/src/lang/uk-ua.json +++ b/src/lang/uk-ua.json @@ -719,6 +719,7 @@ "app font": "Шрифт програми", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Виберіть шрифт, який буде використовуватися в інтерфейсі програми.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (запуск бінарної команди)", diff --git a/src/lang/uz-uz.json b/src/lang/uz-uz.json index 9b284ee11..037f61d07 100644 --- a/src/lang/uz-uz.json +++ b/src/lang/uz-uz.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/vi-vn.json b/src/lang/vi-vn.json index 131cb3fe9..06312a1fc 100644 --- a/src/lang/vi-vn.json +++ b/src/lang/vi-vn.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lang/zh-cn.json b/src/lang/zh-cn.json index b5eb3008b..28c7fc0d5 100644 --- a/src/lang/zh-cn.json +++ b/src/lang/zh-cn.json @@ -719,6 +719,7 @@ "app font": "应用字体", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "选择在应用界面中使用的字体。", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO(启动可执行命令)", diff --git a/src/lang/zh-hant.json b/src/lang/zh-hant.json index 5de431597..370b3bf47 100644 --- a/src/lang/zh-hant.json +++ b/src/lang/zh-hant.json @@ -719,6 +719,7 @@ "app font": "應用字體", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "選擇在應用介面中使用的字體。", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO(啟動可執行命令)", diff --git a/src/lang/zh-tw.json b/src/lang/zh-tw.json index 1fa59224d..ad7ad2f79 100644 --- a/src/lang/zh-tw.json +++ b/src/lang/zh-tw.json @@ -719,6 +719,7 @@ "app font": "App font", "app icon": "App icon", "app icon changed": "App icon changed", + "app icon change warning": "The app will exit after the app icon is changed.", "settings-info-app-font-family": "Choose the font used across the app interface.", "settings-info-app-icon": "Choose the app icon displayed on your device.", "lsp-transport-method-stdio": "STDIO (launch a binary command)", diff --git a/src/lib/appIconSelection.js b/src/lib/appIconSelection.js index feced4613..3a2b6e8b0 100644 --- a/src/lib/appIconSelection.js +++ b/src/lib/appIconSelection.js @@ -11,51 +11,69 @@ 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 { + // Applying an icon toggles the launcher alias, which restarts the app. + // Warn before any purchase or rewarded ad so the user can back out. + const proceed = await confirm( + strings["app icon"], + strings["app icon change warning"] || + "The app will exit after the app icon is changed.", + false, + { signal }, + ); + if (!proceed || signal.aborted) return; + 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/System.java b/src/plugins/system/android/com/foxdebug/system/System.java index 53e0f6a6e..ec72520f0 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); } @@ -2221,18 +2229,25 @@ private void setAppIcon(String iconName, CallbackContext callback) { PackageManager pm = context.getPackageManager(); String key = iconName == null ? "default" : iconName.toLowerCase(); - if (!APP_ICON_ALIASES.containsKey(key)) { + String targetAlias = APP_ICON_ALIASES.get(key); + if (targetAlias == null) { callback.error("Unknown app icon: " + iconName); return; } + pm.setComponentEnabledSetting( + new ComponentName(packageName, packageName + "." + targetAlias), + 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; + } pm.setComponentEnabledSetting( new ComponentName(packageName, packageName + "." + entry.getValue()), - enabled - ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED - : PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + 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..cf5172c5b 100644 --- a/src/plugins/system/plugin.xml +++ b/src/plugins/system/plugin.xml @@ -35,10 +35,10 @@ - - - - + + + + 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..3cfbd10f3 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,26 @@ 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( @@ -52,11 +72,25 @@ 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"); + // The editor activity keeps its own component: the launcher filter is + // stripped and aliases target it directly, with no launcher indirection. + expect(application.find("activity").findall("intent-filter")).toHaveLength( + 0, + ); + const aliases = application.findall("activity-alias"); + expect(aliases).toHaveLength(2); + expect(aliases.map((alias) => alias.get("android:name"))).toEqual([ + ".MainActivityIconDefault", + "other.Alias", + ]); 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 +108,41 @@ it("refreshes stale System plugin Java alongside icons on repeated Android prepa fs.rmSync(root, { recursive: true, force: true }); } }); + +it("keeps every launcher alias pointing at MainActivity without a launcher indirection", () => { + 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(); + expect(children.map((child) => child.tag)).toEqual( + children.map(() => "activity-alias"), + ); + expect(children.some((child) => child.tag === "activity")).toBe(false); + 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(".MainActivity"); + expect(alias.get("android:enabled")).toBe(index === 0 ? "true" : "false"); + expect(alias.get("android:exported")).toBe("true"); + } +}); diff --git a/tests/unit/appIconSelection.test.js b/tests/unit/appIconSelection.test.js index fc78dfc5b..2987cc8d4 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; @@ -56,6 +64,8 @@ beforeEach(() => { mocks.purchase.mockResolvedValue(false); vi.stubGlobal("strings", { "app icon": "App icon", + "app icon change warning": + "The app will exit after the app icon is changed.", "confirm app icon reward": "Watch?", "app icon changed": "Changed", "rewarded ad incomplete": "Incomplete", @@ -64,16 +74,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,32 +107,67 @@ 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"); - expect(mocks.confirm).not.toHaveBeenCalled(); + const h = harness(); + const pending = h.select( + kind === "default" + ? "default" + : kind === "paid" + ? "pro" + : "midnight_circuit", + ); + // The exit warning is confirmed before any loader is shown. + expect(h.onLoading).not.toHaveBeenCalled(); + await pending; + expect(h.onLoading.mock.calls).toEqual([[true], [false]]); + // The app-exit warning is still shown before the icon is applied. + expect(mocks.confirm).toHaveBeenCalledOnce(); expect(mocks.reward).not.toHaveBeenCalled(); expect(mocks.settings.update).toHaveBeenCalledOnce(); expect(mocks.toast).toHaveBeenCalledWith("Changed"); }); + it("warns that the app will exit before applying an icon", async () => { + const h = harness(); + await h.select("pixel_party"); + expect(mocks.confirm.mock.calls[0][0]).toBe("App icon"); + expect(mocks.confirm.mock.calls[0][1]).toBe( + "The app will exit after the app icon is changed.", + ); + expect(system.setAppIcon).toHaveBeenCalledOnce(); + }); it("ignores current/unknown icons and serializes confirmation and reward", async () => { const h = harness(); await h.select("default"); await h.select("unknown"); expect(h.onBusy).not.toHaveBeenCalled(); - let confirm; + expect(h.onLoading).not.toHaveBeenCalled(); + const confirms = []; mocks.confirm.mockImplementation( () => new Promise((resolve) => { - confirm = resolve; + confirms.push(resolve); }), ); const first = h.select("pixel_party"); await h.select("solar_flare"); expect(mocks.confirm).toHaveBeenCalledOnce(); expect(mocks.reward).not.toHaveBeenCalled(); - confirm(true); + 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; + }); + // The exit warning is confirmed first, then the rewarded-ad prompt. + confirms[0](true); + await vi.waitFor(() => expect(confirms).toHaveLength(2)); + confirms[1](true); await first; + expect(mocks.confirm).toHaveBeenCalledTimes(2); 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 +190,58 @@ 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"); + await vi.waitFor(() => expect(applied).toBeTypeOf("function")); + 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 +260,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 +271,30 @@ 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"); + await vi.waitFor(() => expect(applied).toBeTypeOf("function")); + 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..74050176a --- /dev/null +++ b/tests/unit/appIconSetting.test.js @@ -0,0 +1,656 @@ +// @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", + "app icon change warning": + "The app will exit after the app icon is changed.", + "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 pendingConfirm() { + const dialogs = document.querySelectorAll(".prompt.confirm:not(.hide)"); + return dialogs[dialogs.length - 1]; +} + +function confirmSelection(accept = true) { + const buttons = pendingConfirm().querySelectorAll(".button-container button"); + buttons[accept ? 1 : 0].click(); +} + +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); + // The exit warning is confirmed before the loader is shown. + expect(pendingConfirm()).toBeTruthy(); + expect(h.loader.create).not.toHaveBeenCalled(); + confirmSelection(); + await vi.waitFor(() => expect(applied).toBeTypeOf("function")); + 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(); + const first = h.click(); + confirmSelection(); + await first; + 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"); + confirmSelection(); + await vi.waitFor(() => expect(finish).toBeTypeOf("function")); + 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(); + // The exit warning comes first, then the rewarded-ad prompt. + confirmSelection(); + await vi.waitFor(() => expect(pendingConfirm()).toBeTruthy()); + confirmSelection(); + 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"); + // Every selection first confirms the exit warning. + expect(actionStack.length).toBe(2); + expect(h.loader.create).not.toHaveBeenCalled(); + if (outcome === "back") { + await actionStack.pop(); + } else { + confirmSelection(outcome !== "cancel"); + if (outcome === "reward failure" || outcome === "native failure") { + // Rewarded-ad icons ask for the ad after the exit warning. + await vi.waitFor(() => expect(pendingConfirm()).toBeTruthy()); + confirmSelection(); + } + } + 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; + const applied = h.click(); + confirmSelection(); + await applied; + 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..."); + }); + const pending = h.click("pro"); + confirmSelection(); + await pending; + 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(); + confirmSelection(); + await vi.waitFor(() => expect(pendingConfirm()).toBeTruthy()); + confirmSelection(); + 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