Compare commits
169 Commits
v0.10.4-be
...
v0.11.22-b
Author | SHA1 | Date | |
---|---|---|---|
dea635fa6a | |||
682026ed0a | |||
210100da2b | |||
d52660235b | |||
e386b5ab8a | |||
abf7be222d | |||
4c5b9304c0 | |||
4cfe6af044 | |||
3f0c4068dd | |||
7981ca29c5 | |||
187efa8fc5 | |||
cd27ff7f2d | |||
6f6a25511b | |||
4e17bbcfd1 | |||
814e269d1d | |||
6b7d962b87 | |||
9fba747802 | |||
c7cd35b6a1 | |||
a8a3fce33a | |||
3a38cedcf5 | |||
69ccefcf1a | |||
d3932f317d | |||
895deeead5 | |||
4c04af3868 | |||
07c490bb0e | |||
a081d553bb | |||
3bc5837999 | |||
9fbe524818 | |||
c21a9d7292 | |||
9c6068b270 | |||
cd86d6112b | |||
1112c79c14 | |||
08555bac75 | |||
6db31e2b24 | |||
48d2532323 | |||
f1fc43a3e7 | |||
280827d8ec | |||
05ee0f9c48 | |||
ef06ae289e | |||
bd0e322465 | |||
a93a2411fa | |||
26e6eef72e | |||
e49a6e311b | |||
53d3397651 | |||
fe540f5e61 | |||
234374224b | |||
83390f648a | |||
1143b6a546 | |||
0f3e029312 | |||
c0120f4e40 | |||
a0199f0ceb | |||
0528936e5a | |||
4de98b2f36 | |||
dfb5f5596c | |||
2e706aac47 | |||
24a600e595 | |||
1596a44ec5 | |||
9ee2be76ca | |||
83b770294d | |||
2679d5a1aa | |||
e49c09c0ff | |||
c9318ef2b5 | |||
2e88c8eede | |||
8648c1bea7 | |||
b22e2bab0c | |||
57f7bf44c2 | |||
ce526d8d26 | |||
5f3eeb9971 | |||
e67a6b8627 | |||
f8e99bb0cb | |||
09b5dd41d3 | |||
b1bd36408c | |||
54d8dff32f | |||
7b1416e28e | |||
926e7b89ce | |||
43d4f89d61 | |||
2190da162d | |||
f10bb5ac91 | |||
8e52f9666d | |||
a8a47bb153 | |||
728dafcc28 | |||
d53b21906c | |||
d6dcac0f97 | |||
dae5a67652 | |||
508fcccec9 | |||
cc8a4c3760 | |||
814e2b7306 | |||
2e159c9886 | |||
b82d28f2a7 | |||
3c61735706 | |||
a2879f5bfa | |||
b57f023739 | |||
c376a7abec | |||
31c6cc3f6f | |||
8de8438aeb | |||
2b0225dd5b | |||
f6af3a7998 | |||
bd29d7bc10 | |||
ffb3516a4b | |||
6a5e7942ee | |||
859158e84a | |||
435116e10b | |||
a788d9d7cd | |||
4be3478b97 | |||
fe0126095a | |||
d5fdf28a98 | |||
f06d245e20 | |||
2b4f94b407 | |||
5f7e342e6b | |||
191776d0d5 | |||
ea81b0e66e | |||
86131ae3ce | |||
64ded1d720 | |||
a11c2f1d37 | |||
890787f87f | |||
c5ff1de950 | |||
56658abd60 | |||
b60622e2cb | |||
e149f0b225 | |||
d9729f08c0 | |||
eda5c1bac6 | |||
5574ea870b | |||
9f03234ac1 | |||
b2503dd43d | |||
e01ca704bc | |||
6aa4ace8e2 | |||
d762467a31 | |||
b07cce8ecd | |||
8002a946b2 | |||
fd9aebc5b2 | |||
1be38d361f | |||
32c40ae7b3 | |||
07223d81c7 | |||
78baee7265 | |||
348c33dfe9 | |||
c408d70ae6 | |||
3ae4e7cc8a | |||
dab0f2bb72 | |||
4baf6bcd3b | |||
db4517aa13 | |||
55d4d1f978 | |||
f89ac5965f | |||
d5ebaa161f | |||
a4c014a8bf | |||
bbaa42fb01 | |||
4fe311bc03 | |||
ea68b97ff7 | |||
6e0f6b528e | |||
a2c227931e | |||
15ad3bb439 | |||
b03d7fba1a | |||
31c491d7c5 | |||
71c80f11f5 | |||
eef4d33431 | |||
d56342e907 | |||
c72c0fdb57 | |||
ffe29009ed | |||
60e3b68ebd | |||
ee4d0f259f | |||
0ecfbef0a0 | |||
1b60e75ca7 | |||
abcfa389e8 | |||
a64bd67ef1 | |||
4252c2711b | |||
52913b0450 | |||
427b0ed8d2 | |||
a85d6d4f08 | |||
05f712603c | |||
fa2a80e34c |
14
README.md
@ -19,9 +19,21 @@ Currently supported App sources:
|
||||
- Third Party F-Droid Repos
|
||||
- Any URLs ending with `/fdroid/<word>`, where `<word>` can be anything - most often `repo`
|
||||
- [Steam](https://store.steampowered.com/mobile)
|
||||
- [Telegram App](https://telegram.org)
|
||||
- [VLC](https://www.videolan.org/vlc/download-android.html)
|
||||
- [Neutron Code](https://neutroncode.com)
|
||||
- "HTML" (Fallback)
|
||||
- Any other URL that returns an HTML page with links to APK files (if multiple, the last file alphabetically is picked)
|
||||
|
||||
## Installation
|
||||
|
||||
[<img src="https://github.com/machiav3lli/oandbackupx/blob/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png"
|
||||
alt="Get it on GitHub"
|
||||
height="80">](https://github.com/ImranR98/Obtainium/releases)
|
||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
|
||||
alt="Get it on IzzyOnDroid"
|
||||
height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.imranr.obtainium)
|
||||
|
||||
## Limitations
|
||||
- App installs happen asynchronously and the success/failure of an install cannot be determined directly. This results in install statuses and versions sometimes being out of sync with the OS until the next launch or until the problem is manually corrected.
|
||||
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
|
||||
@ -31,4 +43,4 @@ Currently supported App sources:
|
||||
|
||||
| <img src="./assets/screenshots/1.apps.png" alt="Apps Page" /> | <img src="./assets/screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./assets/screenshots/3.material_you.png" alt="Material You" /> |
|
||||
| ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| <img src="./assets/screenshots/4.app.png" alt="App Page" /> | <img src="./assets/screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./assets/screenshots/6.apk_install.png" alt="App Installation" /> |
|
||||
| <img src="./assets/screenshots/4.app.png" alt="App Page" /> | <img src="./assets/screenshots/5.app_opts.png" alt="App Options" /> | <img src="./assets/screenshots/6.app_webview.png" alt="App Web View" /> |
|
||||
|
@ -3,7 +3,8 @@
|
||||
<application
|
||||
android:label="Obtainium"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@ -51,7 +52,8 @@
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28"/>
|
||||
android:maxSdkVersion="29"/>
|
||||
</manifest>
|
Before Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 7.7 KiB |
Before Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 25 KiB |
46
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@ -0,0 +1,46 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:viewportWidth="142.129"
|
||||
android:viewportHeight="142.129"
|
||||
android:width="503.6066dp"
|
||||
android:height="503.6066dp">
|
||||
<group
|
||||
android:translateX="-30.39437"
|
||||
android:translateY="-54.68043">
|
||||
<path
|
||||
android:pathData="M109.8808 153.22596c-0.73146 -0.38777 -5.00657 -2.75679 -25.032416 -13.87149 -5.57273 -3.09297 -10.93823 -6.06723 -11.92332 -6.60948 -2.23728 -1.23152 -2.58105 -1.53456 -2.58105 -2.27528 0 -0.3879 0.89293 -2.87231 2.98561 -8.30689 1.64209 -4.2644 3.09426 -8.0014 3.22705 -8.30444 0.3024 -0.69008 0.78972 -1.27621 1.26573 -1.52236 0.44558 -0.23042 11.58052 -4.29685 12.14814 -4.43644 0.61355 -0.1509 1.1428 0.13977 1.45487 0.79901 0.14976 0.31638 0.77213 1.94934 1.38303 3.6288 0.6109 1.67945 1.52036 4.16275 2.02104 5.51844 1.14709 3.10604 1.18992 3.54589 0.3912 4.01771 -0.2117 0.12505 -1.58874 0.66539 -3.06009 1.20075 -1.47136 0.53536 -2.87533 1.08982 -3.11993 1.23213 -0.56422 0.32826 -0.64913 0.83523 -0.20815 1.24273 0.17523 0.16193 3.00434 1.77571 6.28691 3.58618 9.174936 5.06035 8.665596 4.83136 9.277626 4.17097 0.29987 -0.32356 5.78141 -14.266 6.09596 -15.50521 0.1344 -0.5295 0.11969 -0.60308 -0.16695 -0.83519 -0.39165 -0.31714 -0.335 -0.33071 -3.93797 0.9431 -3.56937 1.26192 -3.90926 1.28864 -4.38744 0.34488 -0.25108 -0.49556 -4.095796 -11.05481 -4.334456 -11.90432 -0.15438 -0.5495 0.0344 -1.0717 0.49701 -1.37482 0.19228 -0.12598 2.990116 -1.19935 6.217406 -2.38526 4.78924 -1.75986 6.0081 -2.15842 6.63117 -2.16837 0.8037 -0.0128 0.90917 0.0424 15.64514 8.19599 1.02104 0.56495 1.56579 1.15961 1.56579 1.70925 0 0.21814 -3.6538 9.91011 -8.11957 21.53771 -6.2982 16.39877 -8.19916 21.21114 -8.4744 21.45338 -0.46789 0.41179 -0.8512 0.39392 -1.74794 -0.0815z"
|
||||
android:strokeWidth="0.139">
|
||||
<aapt:attr
|
||||
name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="76.74697"
|
||||
android:startY="113.4246"
|
||||
android:endX="110.6445"
|
||||
android:endY="152.5006"
|
||||
android:tileMode="clamp">
|
||||
<item
|
||||
android:color="#9B58DC"
|
||||
android:offset="0" />
|
||||
<item
|
||||
android:color="#321C92"
|
||||
android:offset="1" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
<aapt:attr
|
||||
name="android:strokeColor">
|
||||
<gradient
|
||||
android:startX="76.74697"
|
||||
android:startY="113.4246"
|
||||
android:endX="110.6445"
|
||||
android:endY="152.5006"
|
||||
android:tileMode="clamp">
|
||||
<item
|
||||
android:color="#9B58DC"
|
||||
android:offset="0" />
|
||||
<item
|
||||
android:color="#321C92"
|
||||
android:offset="1" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</group>
|
||||
</vector>
|
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
Before Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 6.2 KiB |
30
assets/ca/lets-encrypt-r3.pem
Normal file
@ -0,0 +1,30 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw
|
||||
WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
|
||||
RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
|
||||
AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP
|
||||
R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx
|
||||
sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm
|
||||
NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg
|
||||
Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG
|
||||
/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC
|
||||
AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB
|
||||
Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA
|
||||
FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw
|
||||
AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw
|
||||
Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB
|
||||
gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W
|
||||
PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl
|
||||
ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz
|
||||
CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm
|
||||
lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4
|
||||
avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2
|
||||
yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O
|
||||
yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids
|
||||
hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+
|
||||
HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv
|
||||
MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX
|
||||
nLRbwHOoq7hHwg==
|
||||
-----END CERTIFICATE-----
|
Before Width: | Height: | Size: 109 KiB |
78
assets/graphics/icon.svg
Normal file
@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="142.12897mm"
|
||||
height="142.12897mm"
|
||||
viewBox="0 0 142.12897 142.12897"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
sodipodi:docname="icon.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="2.4175295"
|
||||
inkscape:cx="371.03994"
|
||||
inkscape:cy="273.62644"
|
||||
inkscape:window-width="2256"
|
||||
inkscape:window-height="1427"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" /><defs
|
||||
id="defs2"><linearGradient
|
||||
inkscape:collect="always"
|
||||
id="linearGradient3657"><stop
|
||||
style="stop-color:#9b58dc;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3653" /><stop
|
||||
style="stop-color:#321c92;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop3655" /></linearGradient><linearGradient
|
||||
inkscape:collect="always"
|
||||
id="linearGradient945"><stop
|
||||
style="stop-color:#9b58dc;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop941" /><stop
|
||||
style="stop-color:#321c92;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop943" /></linearGradient><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient945"
|
||||
id="linearGradient947"
|
||||
x1="76.787094"
|
||||
y1="113.40435"
|
||||
x2="110.68458"
|
||||
y2="152.48038"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(-0.04012535,0.02025786)" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient3657"
|
||||
id="linearGradient3659"
|
||||
x1="76.787094"
|
||||
y1="113.40435"
|
||||
x2="110.68458"
|
||||
y2="152.48038"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(-0.04012535,0.02025786)" /></defs><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-30.394373,-54.680428)"><path
|
||||
style="fill:url(#linearGradient3659);fill-opacity:1;stroke:url(#linearGradient947);stroke-width:0.139;stroke-dasharray:none"
|
||||
d="m 109.8808,153.22596 c -0.73146,-0.38777 -5.00657,-2.75679 -25.032416,-13.87149 -5.57273,-3.09297 -10.93823,-6.06723 -11.92332,-6.60948 -2.23728,-1.23152 -2.58105,-1.53456 -2.58105,-2.27528 0,-0.3879 0.89293,-2.87231 2.98561,-8.30689 1.64209,-4.2644 3.09426,-8.0014 3.22705,-8.30444 0.3024,-0.69008 0.78972,-1.27621 1.26573,-1.52236 0.44558,-0.23042 11.58052,-4.29685 12.14814,-4.43644 0.61355,-0.1509 1.1428,0.13977 1.45487,0.79901 0.14976,0.31638 0.77213,1.94934 1.38303,3.6288 0.6109,1.67945 1.52036,4.16275 2.02104,5.51844 1.14709,3.10604 1.18992,3.54589 0.3912,4.01771 -0.2117,0.12505 -1.58874,0.66539 -3.06009,1.20075 -1.47136,0.53536 -2.87533,1.08982 -3.11993,1.23213 -0.56422,0.32826 -0.64913,0.83523 -0.20815,1.24273 0.17523,0.16193 3.00434,1.77571 6.28691,3.58618 9.174936,5.06035 8.665596,4.83136 9.277626,4.17097 0.29987,-0.32356 5.78141,-14.266 6.09596,-15.50521 0.1344,-0.5295 0.11969,-0.60308 -0.16695,-0.83519 -0.39165,-0.31714 -0.335,-0.33071 -3.93797,0.9431 -3.56937,1.26192 -3.90926,1.28864 -4.38744,0.34488 -0.25108,-0.49556 -4.095796,-11.05481 -4.334456,-11.90432 -0.15438,-0.5495 0.0344,-1.0717 0.49701,-1.37482 0.19228,-0.12598 2.990116,-1.19935 6.217406,-2.38526 4.78924,-1.75986 6.0081,-2.15842 6.63117,-2.16837 0.8037,-0.0128 0.90917,0.0424 15.64514,8.19599 1.02104,0.56495 1.56579,1.15961 1.56579,1.70925 0,0.21814 -3.6538,9.91011 -8.11957,21.53771 -6.2982,16.39877 -8.19916,21.21114 -8.4744,21.45338 -0.46789,0.41179 -0.8512,0.39392 -1.74794,-0.0815 z"
|
||||
id="path239" /></g></svg>
|
After Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 234 KiB |
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 238 KiB |
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 140 KiB |
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 139 KiB |
Before Width: | Height: | Size: 188 KiB |
BIN
assets/screenshots/5.app_opts.png
Normal file
After Width: | Height: | Size: 118 KiB |
Before Width: | Height: | Size: 192 KiB |
BIN
assets/screenshots/6.app_webview.png
Normal file
After Width: | Height: | Size: 262 KiB |
@ -74,7 +74,6 @@
|
||||
"changeX": "Ändern {}",
|
||||
"installUpdateApps": "Apps installieren/aktualisieren",
|
||||
"installUpdateSelectedApps": "Ausgewählte Apps installieren/aktualisieren",
|
||||
"onlyWorksWithNonEVDApps": "Funktioniert nur bei Apps, deren Installationsstatus nicht automatisch erkannt werden kann (ungewöhnlich).",
|
||||
"markXSelectedAppsAsUpdated": "Markiere {} ausgewählte Apps als aktuell?",
|
||||
"no": "Nein",
|
||||
"yes": "Ja",
|
||||
@ -178,7 +177,6 @@
|
||||
"installedVersionX": "Installierte Version: {}",
|
||||
"lastUpdateCheckX": "Letzte Aktualisierungsprüfung: {}",
|
||||
"remove": "Entfernen",
|
||||
"removeAppQuestion": "App entfernen?",
|
||||
"yesMarkUpdated": "Ja, als aktualisiert markieren",
|
||||
"fdroid": "F-Droid",
|
||||
"appIdOrName": "App ID oder Name",
|
||||
@ -209,8 +207,24 @@
|
||||
"addCategory": "Kategorie hinzufügen",
|
||||
"label": "Bezeichnung",
|
||||
"language": "Sprache",
|
||||
"storagePermissionDenied": "Storage permission denied",
|
||||
"selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.",
|
||||
"storagePermissionDenied": "Speicherberechtigung verweigert",
|
||||
"selectedCategorizeWarning": "Dadurch werden alle bestehenden Kategorieeinstellungen für die ausgewählten Apps ersetzt.",
|
||||
"filterAPKsByRegEx": "APKs nach regulärem Ausdruck filtern",
|
||||
"removeFromObtainium": "Aus Obtainium entfernen",
|
||||
"uninstallFromDevice": "Vom Gerät deinstallieren",
|
||||
"onlyWorksWithNonVersionDetectApps": "Funktioniert nur bei Apps mit deaktivierter Versionserkennung.",
|
||||
"releaseDateAsVersion": "Veröffentlichungsdatum als Version verwenden",
|
||||
"releaseDateAsVersionExplanation": "Diese Option sollte nur für Apps verwendet werden, bei denen die Versionserkennung nicht korrekt funktioniert, aber ein Veröffentlichungsdatum verfügbar ist.",
|
||||
"changes": "Änderungen",
|
||||
"releaseDate": "Veröffentlichungsdatum",
|
||||
"importFromURLsInFile": "Importieren von URLs aus Datei ( z.B. OPML)",
|
||||
"versionDetection": "Versionserkennung",
|
||||
"standardVersionDetection": "Standardversionserkennung",
|
||||
"groupByCategory": "Group by Category",
|
||||
"removeAppQuestion": {
|
||||
"one": "App entfernen?",
|
||||
"other": "App entfernen?"
|
||||
},
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minute erneut",
|
||||
"other": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minuten erneut"
|
||||
|
@ -74,7 +74,6 @@
|
||||
"changeX": "Change {}",
|
||||
"installUpdateApps": "Install/Update Apps",
|
||||
"installUpdateSelectedApps": "Install/Update Selected Apps",
|
||||
"onlyWorksWithNonEVDApps": "Only works for Apps whose install status cannot be automatically detected (uncommon).",
|
||||
"markXSelectedAppsAsUpdated": "Mark {} Selected Apps as Updated?",
|
||||
"no": "No",
|
||||
"yes": "Yes",
|
||||
@ -178,7 +177,6 @@
|
||||
"installedVersionX": "Installed Version: {}",
|
||||
"lastUpdateCheckX": "Last Update Check: {}",
|
||||
"remove": "Remove",
|
||||
"removeAppQuestion": "Remove App?",
|
||||
"yesMarkUpdated": "Yes, Mark as Updated",
|
||||
"fdroid": "F-Droid",
|
||||
"appIdOrName": "App ID or Name",
|
||||
@ -211,6 +209,22 @@
|
||||
"language": "Language",
|
||||
"storagePermissionDenied": "Storage permission denied",
|
||||
"selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.",
|
||||
"filterAPKsByRegEx": "Filter APKs by Regular Expression",
|
||||
"removeFromObtainium": "Remove from Obtainium",
|
||||
"uninstallFromDevice": "Uninstall from Device",
|
||||
"onlyWorksWithNonVersionDetectApps": "Only works for Apps with version detection disabled.",
|
||||
"releaseDateAsVersion": "Use Release Date as Version",
|
||||
"releaseDateAsVersionExplanation": "This option should only be used for Apps where version detection does not work correctly, but a release date is available.",
|
||||
"changes": "Changes",
|
||||
"releaseDate": "Release Date",
|
||||
"importFromURLsInFile": "Import from URLs in File (like OPML)",
|
||||
"versionDetection": "Version Detection",
|
||||
"standardVersionDetection": "Standard version detection",
|
||||
"groupByCategory": "Group by Category",
|
||||
"removeAppQuestion": {
|
||||
"one": "Remove App?",
|
||||
"other": "Remove Apps?"
|
||||
},
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Too many requests (rate limited) - try again in {} minute",
|
||||
"other": "Too many requests (rate limited) - try again in {} minutes"
|
||||
|
272
assets/translations/fa.json
Normal file
@ -0,0 +1,272 @@
|
||||
{
|
||||
"invalidURLForSource": "آدرس اینترنتی برنامه {} معتبر نیست",
|
||||
"noReleaseFound": "نسخه مناسبی پیدا نشد",
|
||||
"noVersionFound": "نمی توان نسخه منتشر شده را تعیین کرد",
|
||||
"urlMatchesNoSource": "آدرس اینترنتی با منبع شناخته شده مطابقت ندارد",
|
||||
"cantInstallOlderVersion": "نمی توان نسخه قدیمی یک برنامه را نصب کرد",
|
||||
"appIdMismatch": "شناسه بسته دانلود شده با شناسه برنامه موجود مطابقت ندارد",
|
||||
"functionNotImplemented": "این کلاس این تابع را پیاده سازی نکرده است",
|
||||
"placeholder": "نگهدارنده مکان",
|
||||
"someErrors": "برخی از خطاها رخ داده است",
|
||||
"unexpectedError": "خطای غیرمنتظره",
|
||||
"ok": "باشه",
|
||||
"and": "و",
|
||||
"startedBgUpdateTask": "شروع بررسی بروزرسانی BG",
|
||||
"bgUpdateIgnoreAfterIs": "نادیده گرفتن بروزرسانی BG بعد از {} است",
|
||||
"startedActualBGUpdateCheck": "بررسی بهروزرسانی واقعی BG آغاز شد",
|
||||
"bgUpdateTaskFinished": "کار بررسی بهروزرسانی BG تمام شد",
|
||||
"firstRun": "این اولین اجرای Obtainium است",
|
||||
"settingUpdateCheckIntervalTo": "تنظیم فاصله بهروزرسانی روی {}",
|
||||
"githubPATLabel": "توکن دسترسی شخصی گیت هاب(محدودیت نرخ را افزایش میدهد)",
|
||||
"githubPATHint": "PAT باید در این قالب باشد: username:token",
|
||||
"githubPATFormat": "username:token",
|
||||
"githubPATLinkText": "درباره گیتهاب PATs",
|
||||
"includePrereleases": "شامل نسخه های اولیه",
|
||||
"fallbackToOlderReleases": "بازگشت به نسخه های قدیمی تر",
|
||||
"filterReleaseTitlesByRegEx": "عناوین انتشار را با بیان منظم فیلتر کنید",
|
||||
"invalidRegEx": "عبارت منظم نامعتبر است",
|
||||
"noDescription": "بدون توضیحات",
|
||||
"cancel": "لغو",
|
||||
"continue": "ادامه دهید",
|
||||
"requiredInBrackets": "(ضروری)",
|
||||
"dropdownNoOptsError": "خطا: کشویی باید حداقل یک گزینه داشته باشد",
|
||||
"colour": "رنگ",
|
||||
"githubStarredRepos": "مخازن ستاره دار گیتهاب",
|
||||
"uname": "نام کاربری",
|
||||
"wrongArgNum": "تعداد آرگومان های ارائه شده اشتباه است",
|
||||
"xIsTrackOnly": "{} فقط ردیابی",
|
||||
"source": "منبع",
|
||||
"app": "برنامه",
|
||||
"appsFromSourceAreTrackOnly": "برنامههای این منبع «فقط ردیابی» هستند",
|
||||
"youPickedTrackOnly": "شما گزینه ی «فقط ردیابی» را انتخاب کرده اید",
|
||||
"trackOnlyAppDescription": "برنامه برای به روز رسانی ها ردیابی می شود، اما Obtainium قادر به دانلود یا نصب آن نخواهد بود.",
|
||||
"cancelled": "لغو شد",
|
||||
"appAlreadyAdded": "برنامه قبلاً اضافه شده است",
|
||||
"alreadyUpToDateQuestion": "برنامه از قبل به روز شده است؟",
|
||||
"addApp": "افزودن برنامه",
|
||||
"appSourceURL": "آدرس اینترنتی منبع برنامه",
|
||||
"error": "خطا",
|
||||
"add": "اضافه کردن",
|
||||
"searchSomeSourcesLabel": "جستجو (فقط برخی منابع)",
|
||||
"search": "جستجو کردن",
|
||||
"additionalOptsFor": "گزینه های اضافی برای {}",
|
||||
"supportedSourcesBelow": "منابع پشتیبانی شده:",
|
||||
"trackOnlyInBrackets": "«فقط ردیابی»",
|
||||
"searchableInBrackets": "(قابل جستجو)",
|
||||
"appsString": "برنامه ها",
|
||||
"noApps": "برنامه ای وجود ندارد",
|
||||
"noAppsForFilter": "برنامه ای برای فیلتر کردن وجود ندارد",
|
||||
"byX": "توسط {}",
|
||||
"percentProgress": "پیش رفتن: {}%",
|
||||
"pleaseWait": "لطفا صبر کنید",
|
||||
"updateAvailable": "بروزرسانی در دسترس",
|
||||
"estimateInBracketsShort": "(تخمین)",
|
||||
"notInstalled": "نصب نشده",
|
||||
"estimateInBrackets": "(تخمین زدن)",
|
||||
"selectAll": "انتخاب همه",
|
||||
"deselectN": "لغو انتخاب {}",
|
||||
"xWillBeRemovedButRemainInstalled": "{} از Obtainium حذف میشود اما روی دستگاه نصب میماند.",
|
||||
"removeSelectedAppsQuestion": "برنامه های انتخابی حذف شود؟",
|
||||
"removeSelectedApps": "حذف برنامه های انتخاب شده",
|
||||
"updateX": "به روز رسانی {}",
|
||||
"installX": "نصب {}",
|
||||
"markXTrackOnlyAsUpdated": "علامت {}\n(فقط ردیابی)\nبروز شده",
|
||||
"changeX": "تغییر دادن {}",
|
||||
"installUpdateApps": "نصب/بهروزرسانی برنامهها",
|
||||
"installUpdateSelectedApps": "برنامههای انتخابی را نصب/بهروزرسانی کنید",
|
||||
"markXSelectedAppsAsUpdated": "{} برنامه های انتخابی را به عنوان به روز علامت گذاری کنید؟",
|
||||
"no": "خیر",
|
||||
"yes": "بله",
|
||||
"markSelectedAppsUpdated": "برنامه های انتخاب شده را به عنوان به روز علامت گذاری کنید",
|
||||
"pinToTop": "پین به بالا",
|
||||
"unpinFromTop": "برداشتن پین از بالا",
|
||||
"resetInstallStatusForSelectedAppsQuestion": "وضعیت نصب برنامههای انتخابی بازنشانی شود؟",
|
||||
"installStatusOfXWillBeResetExplanation": "وضعیت نصب برنامههای انتخابشده بازنشانی میشود.\n\nاگر نسخه برنامه نشاندادهشده در Obtainium به دلیل بهروزرسانیهای ناموفق یا مشکلات دیگر نادرست باشد، میتواند کمک کند.",
|
||||
"shareSelectedAppURLs": "اشتراک گذاری آدرس اینترنتی برنامه های انتخاب شده",
|
||||
"resetInstallStatus": "بازنشانی وضعیت نصب",
|
||||
"more": "بیشتر",
|
||||
"removeOutdatedFilter": "فیلتر برنامه قدیمی را حذف کنید",
|
||||
"showOutdatedOnly": "فقط برنامه های قدیمی را نشان دهید",
|
||||
"filter": "فیلتر",
|
||||
"filterActive": "فیلتر *",
|
||||
"filterApps": "فیلتر کردن برنامه ها",
|
||||
"appName": "نام برنامه",
|
||||
"author": "سازنده",
|
||||
"upToDateApps": "برنامه های به روز",
|
||||
"nonInstalledApps": "برنامه های نصب نشده",
|
||||
"importExport": "وادر کردن/صادر کردن",
|
||||
"settings": "تنظیمات",
|
||||
"exportedTo": "صادر کردن به{}",
|
||||
"obtainiumExport": "صادرکردن Obtainium",
|
||||
"invalidInput": "ورودی نامعتبر",
|
||||
"importedX": "وارد شده {}",
|
||||
"obtainiumImport": "واردکردن Obtainium",
|
||||
"importFromURLList": "وارد کردن از فهرست آدرس اینترنتی",
|
||||
"searchQuery": "جستجوی سوال",
|
||||
"appURLList": "فهرست آدرس اینترنتی برنامه",
|
||||
"line": "خط",
|
||||
"searchX": "جستجو {}",
|
||||
"noResults": "نتیجه ای پیدا نشد",
|
||||
"importX": "وارد کردن {}",
|
||||
"importedAppsIdDisclaimer": "ممکن است برنامههای وارد شده به اشتباه بهعنوان \"نصب نشده\" نشان داده شوند.\nبرای رفع این مشکل، آنها را دوباره از طریق Obtainium نصب کنید.\nاین نباید روی دادههای برنامه تأثیر بگذارد.\n\nفقط بر روی آدرس اینترنتی و روشهای وارد کردن شخص ثالث تأثیر میگذارد.",
|
||||
"importErrors": "خطاهای وارد کردن",
|
||||
"importedXOfYApps": "{} از {} برنامه وارد شد.",
|
||||
"followingURLsHadErrors": "آدرس های اینترنتی زیر دارای خطا بودند:",
|
||||
"okay": "باشه",
|
||||
"selectURL": "آدرس اینترنتی انتخاب شده",
|
||||
"selectURLs": "آدرس های اینترنتی انتخاب شده",
|
||||
"pick": "انتخاب",
|
||||
"theme": "تم",
|
||||
"dark": "تاریک",
|
||||
"light": "روشن",
|
||||
"followSystem": "هماهنگ با سیستم",
|
||||
"obtainium": "Obtainium",
|
||||
"materialYou": "Material You",
|
||||
"appSortBy": "مرتب سازی برنامه بر اساس",
|
||||
"authorName": "سازنده/اسم",
|
||||
"nameAuthor": "اسم/سازنده",
|
||||
"asAdded": "همانطور که اضافه شد",
|
||||
"appSortOrder": "ترتیب مرتب سازی برنامه",
|
||||
"ascending": "صعودی",
|
||||
"descending": "نزولی",
|
||||
"bgUpdateCheckInterval": "فاصله بررسی بهروزرسانی در پسزمینه",
|
||||
"neverManualOnly": "هرگز - فقط دستی",
|
||||
"appearance": "ظاهر",
|
||||
"showWebInAppView": "نمایش صفحه وب منبع در نمای برنامه",
|
||||
"pinUpdates": "بهروزرسانیها را به نمای بالای برنامهها پین کنید",
|
||||
"updates": "به روز رسانی ها",
|
||||
"sourceSpecific": "منبع خاص",
|
||||
"appSource": "منبع برنامه",
|
||||
"noLogs": "بدون گزارش",
|
||||
"appLogs": "گزارش های برنامه",
|
||||
"close": "بستن",
|
||||
"share": "اشتراک گذاری",
|
||||
"appNotFound": "برنامه پیدا نشد",
|
||||
"obtainiumExportHyphenatedLowercase": "صادر کردن-obtainium",
|
||||
"pickAnAPK": "یک APK انتخاب کنید",
|
||||
"appHasMoreThanOnePackage": "{} بیش از یک بسته دارد:",
|
||||
"deviceSupportsXArch": "دستگاه شما از معماری پردازنده {} پشتیبانی میکند",
|
||||
"deviceSupportsFollowingArchs": "دستگاه شما از معماری های پردازنده زیر پشتیبانی می کند:",
|
||||
"warning": "اخطار",
|
||||
"sourceIsXButPackageFromYPrompt": "منبع برنامه \"{}\" است اما بسته انتشار از \"{}\" آمده است. ادامه هید؟",
|
||||
"updatesAvailable": "بروزرسانی در دسترس ",
|
||||
"updatesAvailableNotifDescription": "به کاربر اطلاع می دهد که به روز رسانی برای یک یا چند برنامه ردیابی شده توسط Obtainium در دسترس است",
|
||||
"noNewUpdates": "به روز رسانی جدیدی وجود ندارد.",
|
||||
"xHasAnUpdate": "{} یک به روز رسانی دارد.",
|
||||
"appsUpdated": "برنامه ها به روز شدند",
|
||||
"appsUpdatedNotifDescription": "به کاربر اطلاع می دهد که به روز رسانی یک یا چند برنامه در پس زمینه اعمال شده است",
|
||||
"xWasUpdatedToY": "{} به {} به روز شد.",
|
||||
"errorCheckingUpdates": "خطا در بررسی بهروزرسانیها",
|
||||
"errorCheckingUpdatesNotifDescription": "اعلانی که وقتی بررسی بهروزرسانی پسزمینه ناموفق است نشان میدهد",
|
||||
"appsRemoved": "برنامه ها حذف شدند",
|
||||
"appsRemovedNotifDescription": "به کاربر اطلاع می دهد که یک یا چند برنامه به دلیل خطا در هنگام بارگیری حذف شده است",
|
||||
"xWasRemovedDueToErrorY": "{} به دلیل این خطا حذف شد: {}",
|
||||
"completeAppInstallation": "نصب کامل برنامه",
|
||||
"obtainiumMustBeOpenToInstallApps": "Obtainium باید برای نصب برنامه ها باز باشد",
|
||||
"completeAppInstallationNotifDescription": "از کاربر میخواهد برای پایان نصب برنامه به Obtainium برگردد",
|
||||
"checkingForUpdates": "بررسی بهروزرسانیها",
|
||||
"checkingForUpdatesNotifDescription": "اعلان گذرا که هنگام بررسی به روز رسانی ظاهر می شود",
|
||||
"pleaseAllowInstallPerm": "لطفاً به Obtainium اجازه دهید برنامهها را نصب کند",
|
||||
"trackOnly": "فقط ردیابی",
|
||||
"errorWithHttpStatusCode": "خطا {}",
|
||||
"versionCorrectionDisabled": "تصحیح نسخه غیرفعال شد (به نظر می رسد افزونه کار نمی کند)",
|
||||
"unknown": "ناشناخته",
|
||||
"none": "هیچ",
|
||||
"never": "هرگز",
|
||||
"latestVersionX": "آخرین نسخه: {}",
|
||||
"installedVersionX": "نسخه نصب شده: {}",
|
||||
"lastUpdateCheckX": "بررسی آخرین بهروزرسانی: {}",
|
||||
"remove": "حذف",
|
||||
"yesMarkUpdated": "بله، علامت گذاری به عنوان به روز شده",
|
||||
"fdroid": "F-Droid",
|
||||
"appIdOrName": "شناسه یا نام برنامه",
|
||||
"appWithIdOrNameNotFound": "هیچ برنامه ای با آن شناسه یا نام یافت نشد",
|
||||
"reposHaveMultipleApps": "مخازن ممکن است شامل چندین برنامه باشد",
|
||||
"fdroidThirdPartyRepo": "مخازن شخص ثالث F-Droid",
|
||||
"steam": "Steam",
|
||||
"steamMobile": "Steam Mobile",
|
||||
"steamChat": "Steam Chat",
|
||||
"install": "نصب",
|
||||
"markInstalled": "علامت گذاری به عنوان نصب شده",
|
||||
"update": "به روز رسانی",
|
||||
"markUpdated": "علامت گذاری به روز شد",
|
||||
"additionalOptions": "گزینه های اضافی",
|
||||
"disableVersionDetection": "غیرفعال کردن تشخیص نسخه",
|
||||
"noVersionDetectionExplanation": "این گزینه فقط باید برای برنامه هایی استفاده شود که تشخیص نسخه به درستی کار نمی کند.",
|
||||
"downloadingX": "در حال دانلود {}",
|
||||
"downloadNotifDescription": "کاربر را از پیشرفت دانلود یک برنامه مطلع می کند",
|
||||
"noAPKFound": "APK پیدا نشد فایل",
|
||||
"noVersionDetection": "بدون تشخیص نسخه",
|
||||
"categorize": "دسته بندی کردن",
|
||||
"categories": "دسته بندی ها",
|
||||
"category": "دسته بندی",
|
||||
"noCategory": "بدون دسته بندی",
|
||||
"noCategories": "بدون دسته بندی ها",
|
||||
"deleteCategoriesQuestion": "دسته بندی ها حذف شوند؟",
|
||||
"categoryDeleteWarning": "همه برنامهها در دستههای حذف شده روی دستهبندی نشده تنظیم میشوند.",
|
||||
"addCategory": "اضافه کردن دسته",
|
||||
"label": "برچسب",
|
||||
"language": "زبان",
|
||||
"storagePermissionDenied": "مجوز ذخیره سازی رد شد",
|
||||
"selectedCategorizeWarning": "این جایگزین تنظیمات دسته بندی موجود برای برنامه های انتخابی می شود.",
|
||||
"filterAPKsByRegEx": "فایلهای APK را با نظم فیلتر کنید",
|
||||
"removeFromObtainium": "از Obtainium حذف کنید",
|
||||
"uninstallFromDevice": "حذف نصب از دستگاه",
|
||||
"onlyWorksWithNonVersionDetectApps": "فقط برای برنامههایی کار میکند که تشخیص نسخه غیرفعال است.",
|
||||
"releaseDateAsVersion": "از تاریخ انتشار به عنوان نسخه استفاده کنید",
|
||||
"releaseDateAsVersionExplanation": "این گزینه فقط باید برای برنامه هایی استفاده شود که تشخیص نسخه به درستی کار نمی کند، اما تاریخ انتشار در دسترس است.",
|
||||
"changes": "تغییرات",
|
||||
"releaseDate": "تاریخ انتشار",
|
||||
"importFromURLsInFile": "وارد کردن از آدرس های اینترنتی موجود در فایل (مانند OPML)",
|
||||
"versionDetection": "تشخیص نسخه",
|
||||
"standardVersionDetection": "تشخیص نسخه استاندارد",
|
||||
"groupByCategory": "Group by Category",
|
||||
"removeAppQuestion": {
|
||||
"one": "برنامه حذف شود؟",
|
||||
"other": "برنامه ها حذف شوند؟"
|
||||
},
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "درخواستهای بسیار زیاد (نرخ محدود) - {} دقیقه دیگر دوباره امتحان کنید",
|
||||
"other": "درخواست های بسیار زیاد (نرخ محدود) - بعد از {} دقیقه دوباره امتحان کنید"
|
||||
},
|
||||
"bgUpdateGotErrorRetryInMinutes": {
|
||||
"one": "بررسی بهروزرسانی BG با یک {} مواجه شد، یک بررسی مجدد را در {} دقیقه برنامهریزی میکند",
|
||||
"other": "بررسی بهروزرسانی BG با {} مواجه شد، یک بررسی مجدد را در {} دقیقه برنامهریزی میکند"
|
||||
},
|
||||
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
|
||||
"one": "بررسی بهروزرسانی BG پیدا شد {} بهروزرسانی - در صورت نیاز به کاربر اطلاع میدهد",
|
||||
"other": "بررسی بهروزرسانی BG {} بهروزرسانیهای یافت شده - در صورت نیاز به کاربر اطلاع میدهد"
|
||||
},
|
||||
"apps": {
|
||||
"one": "برنامه {}",
|
||||
"other": "{} برنامه ها"
|
||||
},
|
||||
"url": {
|
||||
"one": "{} آدرس اینترنتی",
|
||||
"other": "{} آدرس های اینترنتی"
|
||||
},
|
||||
"minute": {
|
||||
"one": "{} دقیقه",
|
||||
"other": "{} دقیقه"
|
||||
},
|
||||
"hour": {
|
||||
"one": "{} ساعت",
|
||||
"other": "{} ساعت"
|
||||
},
|
||||
"day": {
|
||||
"one": "{} روز",
|
||||
"other": "{} روز"
|
||||
},
|
||||
"clearedNLogsBeforeXAfterY": {
|
||||
"one": "گزارش {n} پاک شد (قبل از = {پیش از}، بعد = {بعد})",
|
||||
"other": "{n} گزارش پاک شد (قبل از = {پیش از}، بعد = {بعد})"
|
||||
},
|
||||
"xAndNMoreUpdatesAvailable": {
|
||||
"one": "{} و 1 برنامه دیگر بهروزرسانی دارند.",
|
||||
"other": "{} و {} برنامه دیگر به روز رسانی دارند."
|
||||
},
|
||||
"xAndNMoreUpdatesInstalled": {
|
||||
"one": "{} و 1 برنامه دیگر به روز شدند.",
|
||||
"other": "{} و {} برنامه دیگر به روز شدند."
|
||||
}
|
||||
}
|
272
assets/translations/fr.json
Normal file
@ -0,0 +1,272 @@
|
||||
{
|
||||
"invalidURLForSource": "URL d'application {} invalide",
|
||||
"noReleaseFound": "Impossible de trouver une version appropriée",
|
||||
"noVersionFound": "Impossible de déterminer la version de la version",
|
||||
"urlMatchesNoSource": "L'URL ne correspond pas à une source connue",
|
||||
"cantInstallOlderVersion": "Impossible d'installer une ancienne version d'une application",
|
||||
"appIdMismatch": "L'ID de paquet téléchargé ne correspond pas à l'ID de l'application existante",
|
||||
"functionNotImplemented": "Cette classe n'a pas implémenté cette fonction",
|
||||
"placeholder": "Espace réservé",
|
||||
"someErrors": "Des erreurs se sont produites",
|
||||
"unexpectedError": "Erreur inattendue",
|
||||
"ok": "Okay",
|
||||
"and": "et",
|
||||
"startedBgUpdateTask": "Démarrage de la tâche de vérification de mise à jour en arrière-plan",
|
||||
"bgUpdateIgnoreAfterIs": "Mise à jour en arrière-plan est ignoré après {}",
|
||||
"startedActualBGUpdateCheck": "Démarrage de la vérification de la mise à jour en arrière-plan",
|
||||
"bgUpdateTaskFinished": "Tâche de vérification de la mise à jour en arrière-plan terminée",
|
||||
"firstRun": "Il s'agit de la toute première exécution d'Obtainium",
|
||||
"settingUpdateCheckIntervalTo": "Définition de l'intervalle de mise à jour sur {}",
|
||||
"githubPATLabel": "Jeton d'Accès Personnel GitHub (Augmente la limite de débit)",
|
||||
"githubPATHint": "Le JAP doit être dans ce format : username:token",
|
||||
"githubPATFormat": "username:token",
|
||||
"githubPATLinkText": "À propos des JAP GitHub",
|
||||
"includePrereleases": "Inclure les avant-premières",
|
||||
"fallbackToOlderReleases": "Retour aux anciennes versions",
|
||||
"filterReleaseTitlesByRegEx": "Filtrer les titres de version par expression régulière",
|
||||
"invalidRegEx": "Expression régulière invalide",
|
||||
"noDescription": "Pas de description",
|
||||
"cancel": "Annuler",
|
||||
"continue": "Continuer",
|
||||
"requiredInBrackets": "(Requis)",
|
||||
"dropdownNoOptsError": "ERREUR : LE DÉROULEMENT DOIT AVOIR AU MOINS UNE OPT",
|
||||
"colour": "Couleur",
|
||||
"githubStarredRepos": "Dépôts étoilés GitHub",
|
||||
"uname": "Nom d'utilisateur",
|
||||
"wrongArgNum": "Mauvais nombre d'arguments fournis",
|
||||
"xIsTrackOnly": "{} est en 'Suivi uniquement'",
|
||||
"source": "Source",
|
||||
"app": "Application",
|
||||
"appsFromSourceAreTrackOnly": "Les applications de cette source sont en 'Suivi uniquement'.",
|
||||
"youPickedTrackOnly": "Vous avez sélectionné l'option 'Suivi uniquement'.",
|
||||
"trackOnlyAppDescription": "L'application sera suivie pour les mises à jour, mais Obtainium ne pourra pas la télécharger ou l'installer.",
|
||||
"cancelled": "Annulé",
|
||||
"appAlreadyAdded": "Application déjà ajoutée",
|
||||
"alreadyUpToDateQuestion": "Application déjà à jour ?",
|
||||
"addApp": "Ajouter une application",
|
||||
"appSourceURL": "URL de la source de l'application",
|
||||
"error": "Erreur",
|
||||
"add": "Ajoutée",
|
||||
"searchSomeSourcesLabel": "Rechercher (certaines sources uniquement)",
|
||||
"search": "Rechercher",
|
||||
"additionalOptsFor": "Options supplémentaires pour {}",
|
||||
"supportedSourcesBelow": "Sources prises en charge :",
|
||||
"trackOnlyInBrackets": "(Suivi uniquement)",
|
||||
"searchableInBrackets": "(Recherchable)",
|
||||
"appsString": "Applications",
|
||||
"noApps": "Aucune application",
|
||||
"noAppsForFilter": "Aucune application pour le filtre",
|
||||
"byX": "Par {}",
|
||||
"percentProgress": "Progrès: {}%",
|
||||
"pleaseWait": "Veuillez patienter",
|
||||
"updateAvailable": "Mise à jour disponible",
|
||||
"estimateInBracketsShort": "(Est.)",
|
||||
"notInstalled": "Pas installé",
|
||||
"estimateInBrackets": "(Estimation)",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"deselectN": "Déselectionner {}",
|
||||
"xWillBeRemovedButRemainInstalled": "{} sera supprimé d'Obtainium mais restera installé sur l'appareil.",
|
||||
"removeSelectedAppsQuestion": "Supprimer les applications sélectionnées ?",
|
||||
"removeSelectedApps": "Supprimer les applications sélectionnées",
|
||||
"updateX": "Mise à jour {}",
|
||||
"installX": "Installer {}",
|
||||
"markXTrackOnlyAsUpdated": "Marquer {}\n(Suivi uniquement)\nas mis à jour",
|
||||
"changeX": "Changer {}",
|
||||
"installUpdateApps": "Installer/Mettre à jour les applications",
|
||||
"installUpdateSelectedApps": "Installer/Mettre à jour les applications sélectionnées",
|
||||
"markXSelectedAppsAsUpdated": "Marquer {} les applications sélectionnées comme mises à jour ?",
|
||||
"no": "Non",
|
||||
"yes": "Oui",
|
||||
"markSelectedAppsUpdated": "Marquer les applications sélectionnées comme mises à jour",
|
||||
"pinToTop": "Épingler en haut",
|
||||
"unpinFromTop": "Détacher du haut",
|
||||
"resetInstallStatusForSelectedAppsQuestion": "Réinitialiser l'état d'installation des applications sélectionnées ?",
|
||||
"installStatusOfXWillBeResetExplanation": "L'état d'installation de toutes les applications sélectionnées sera réinitialisé.\n\nCela peut aider lorsque la version de l'application affichée dans Obtainium est incorrecte en raison d'échecs de mises à jour ou d'autres problèmes.",
|
||||
"shareSelectedAppURLs": "Partager les URL d'application sélectionnées",
|
||||
"resetInstallStatus": "Réinitialiser le statut d'installation",
|
||||
"more": "Plus",
|
||||
"removeOutdatedFilter": "Supprimer le filtre d'application obsolète",
|
||||
"showOutdatedOnly": "Afficher uniquement les applications obsolètes",
|
||||
"filter": "Filtre",
|
||||
"filterActive": "Filtre *",
|
||||
"filterApps": "Filtrer les applications",
|
||||
"appName": "Nom de l'application",
|
||||
"author": "Auteur",
|
||||
"upToDateApps": "Applications à jour",
|
||||
"nonInstalledApps": "Applications non installées",
|
||||
"importExport": "Importer/Exporter",
|
||||
"settings": "Paramètres",
|
||||
"exportedTo": "Exporté vers {}",
|
||||
"obtainiumExport": "Exportation d'Obtainium",
|
||||
"invalidInput": "Entrée invalide",
|
||||
"importedX": "Importé {}",
|
||||
"obtainiumImport": "Importation d'Obtainium",
|
||||
"importFromURLList": "Importer à partir de la liste d'URL",
|
||||
"searchQuery": "Requête de recherche",
|
||||
"appURLList": "Liste d'URL d'application",
|
||||
"line": "Queue",
|
||||
"searchX": "Rechercher {}",
|
||||
"noResults": "Aucun résultat trouvé",
|
||||
"importX": "Importer {}",
|
||||
"importedAppsIdDisclaimer": "Les applications importées peuvent s'afficher à tort comme \"Non installées\".\nPour résoudre ce problème, réinstallez-les via Obtainium.\nCela ne devrait pas affecter les données de l'application.\n\nN'affecte que les URL et les méthodes d'importation tierces.",
|
||||
"importErrors": "Erreurs d'importation",
|
||||
"importedXOfYApps": "{} sur {} applications importées.",
|
||||
"followingURLsHadErrors": "Les URL suivantes comportaient des erreurs :",
|
||||
"okay": "Okay",
|
||||
"selectURL": "Sélectionnez l'URL",
|
||||
"selectURLs": "Sélectionnez les URLs",
|
||||
"pick": "Prendre",
|
||||
"theme": "Thème",
|
||||
"dark": "Sombre",
|
||||
"light": "Clair",
|
||||
"followSystem": "Suivre le système",
|
||||
"obtainium": "Obtainium",
|
||||
"materialYou": "Material You",
|
||||
"appSortBy": "Applications triées par",
|
||||
"authorName": "Auteur/Nom",
|
||||
"nameAuthor": "Nom/Auteur",
|
||||
"asAdded": "Comme ajouté",
|
||||
"appSortOrder": "Ordre de tri des applications",
|
||||
"ascending": "Ascendant",
|
||||
"descending": "Descendanr",
|
||||
"bgUpdateCheckInterval": "Intervalle de vérification des mises à jour en arrière-plan",
|
||||
"neverManualOnly": "Jamais - Manuel uniquement",
|
||||
"appearance": "Apparence",
|
||||
"showWebInAppView": "Afficher la page Web source dans la vue de l'application",
|
||||
"pinUpdates": "Épingler les mises à jour dans la vue Top des applications",
|
||||
"updates": "Mises à jour",
|
||||
"sourceSpecific": "Spécifique à la source",
|
||||
"appSource": "Source de l'application",
|
||||
"noLogs": "Aucun journal",
|
||||
"appLogs": "Journaux d'application",
|
||||
"close": "Fermer",
|
||||
"share": "Partager",
|
||||
"appNotFound": "Application introuvable",
|
||||
"obtainiumExportHyphenatedLowercase": "obtainium-export",
|
||||
"pickAnAPK": "Choisissez un APK",
|
||||
"appHasMoreThanOnePackage": "{} a plus d'un paquet :",
|
||||
"deviceSupportsXArch": "Votre appareil prend en charge l'architecture de processeur {}.",
|
||||
"deviceSupportsFollowingArchs": "Votre appareil prend en charge les architectures CPU suivantes :",
|
||||
"warning": "Avertissement",
|
||||
"sourceIsXButPackageFromYPrompt": "La source de l'application est '{}' mais le paquet de version provient de '{}'. Continuer?",
|
||||
"updatesAvailable": "Mises à jour disponibles",
|
||||
"updatesAvailableNotifDescription": "Avertit l'utilisateur que des mises à jour sont disponibles pour une ou plusieurs applications suivies par Obtainium",
|
||||
"noNewUpdates": "Aucune nouvelle mise à jour.",
|
||||
"xHasAnUpdate": "{} a une mise à jour.",
|
||||
"appsUpdated": "Applications mises à jour",
|
||||
"appsUpdatedNotifDescription": "Avertit l'utilisateur que les mises à jour d'une ou plusieurs applications ont été appliquées en arrière-plan",
|
||||
"xWasUpdatedToY": "{} a été mis à jour pour {}.",
|
||||
"errorCheckingUpdates": "Erreur lors de la vérification des mises à jour",
|
||||
"errorCheckingUpdatesNotifDescription": "Une notification qui s'affiche lorsque la vérification de la mise à jour en arrière-plan échoue",
|
||||
"appsRemoved": "Applications supprimées",
|
||||
"appsRemovedNotifDescription": "Avertit l'utilisateur qu'une ou plusieurs applications ont été supprimées en raison d'erreurs lors de leur chargement",
|
||||
"xWasRemovedDueToErrorY": "{} a été supprimé en raison de cette erreur : {}",
|
||||
"completeAppInstallation": "Installation complète de l'application",
|
||||
"obtainiumMustBeOpenToInstallApps": "Obtainium doit être ouvert pour installer des applications",
|
||||
"completeAppInstallationNotifDescription": "Demande à l'utilisateur de retourner sur Obtainium pour terminer l'installation d'une application",
|
||||
"checkingForUpdates": "Vérification des mises à jour",
|
||||
"checkingForUpdatesNotifDescription": "Notification transitoire qui apparaît lors de la recherche de mises à jour",
|
||||
"pleaseAllowInstallPerm": "Veuillez autoriser Obtainium à installer des applications",
|
||||
"trackOnly": "Suivi uniquement",
|
||||
"errorWithHttpStatusCode": "Erreur {}",
|
||||
"versionCorrectionDisabled": "Correction de version désactivée (le plugin ne semble pas fonctionner)",
|
||||
"unknown": "Inconnu",
|
||||
"none": "Aucun",
|
||||
"never": "Jamais",
|
||||
"latestVersionX": "Dernière version: {}",
|
||||
"installedVersionX": "Version installée : {}",
|
||||
"lastUpdateCheckX": "Vérification de la dernière mise à jour : {}",
|
||||
"remove": "Retirer",
|
||||
"yesMarkUpdated": "Oui, marquer comme mis à jour",
|
||||
"fdroid": "F-Droid",
|
||||
"appIdOrName": "ID ou nom de l'application",
|
||||
"appWithIdOrNameNotFound": "Aucune application n'a été trouvée avec cet identifiant ou ce nom",
|
||||
"reposHaveMultipleApps": "Les dépôts peuvent contenir plusieurs applications",
|
||||
"fdroidThirdPartyRepo": "Dépôt tiers F-Droid",
|
||||
"steam": "Steam",
|
||||
"steamMobile": "Steam Mobile",
|
||||
"steamChat": "Steam Chat",
|
||||
"install": "Installer",
|
||||
"markInstalled": "Marquer installée",
|
||||
"update": "Mettre à jour",
|
||||
"markUpdated": "Marquer à jour",
|
||||
"additionalOptions": "Options additionelles",
|
||||
"disableVersionDetection": "Désactiver la détection de version",
|
||||
"noVersionDetectionExplanation": "Cette option ne doit être utilisée que pour les applications où la détection de version ne fonctionne pas correctement.",
|
||||
"downloadingX": "Téléchargement {}",
|
||||
"downloadNotifDescription": "Avertit l'utilisateur de la progression du téléchargement d'une application",
|
||||
"noAPKFound": "Aucun APK trouvé",
|
||||
"noVersionDetection": "Pas de détection de version",
|
||||
"categorize": "Catégoriser",
|
||||
"categories": "Catégories",
|
||||
"category": "Catégorie",
|
||||
"noCategory": "No Category",
|
||||
"noCategories": "Aucune catégorie",
|
||||
"deleteCategoriesQuestion": "Supprimer les catégories ?",
|
||||
"categoryDeleteWarning": "Toutes les applications dans les catégories supprimées seront définies sur non catégorisées.",
|
||||
"addCategory": "Ajouter une catégorie",
|
||||
"label": "Étiquette",
|
||||
"language": "Langue",
|
||||
"storagePermissionDenied": "Autorisation de stockage refusée",
|
||||
"selectedCategorizeWarning": "Cela remplacera tous les paramètres de catégorie existants pour les applications sélectionnées.",
|
||||
"filterAPKsByRegEx": "Filtrer les APK par expression régulière",
|
||||
"removeFromObtainium": "Supprimer d'Obtainium",
|
||||
"uninstallFromDevice": "Désinstaller de l'appareil",
|
||||
"onlyWorksWithNonVersionDetectApps": "Fonctionne uniquement pour les applications avec la détection de version désactivée.",
|
||||
"releaseDateAsVersion": "Utiliser la date de sortie comme version",
|
||||
"releaseDateAsVersionExplanation": "Cette option ne doit être utilisée que pour les applications où la détection de version ne fonctionne pas correctement, mais une date de sortie est disponible.",
|
||||
"changes": "Changements",
|
||||
"releaseDate": "Date de sortie",
|
||||
"importFromURLsInFile": "Importer à partir d'URL dans un fichier (comme OPML)",
|
||||
"versionDetection": "Détection des versions",
|
||||
"standardVersionDetection": "Détection de version standard",
|
||||
"groupByCategory": "Group by Category",
|
||||
"removeAppQuestion": {
|
||||
"one": "Supprimer l'application ?",
|
||||
"other": "Supprimer les applications ?"
|
||||
},
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Trop de demandes (taux limité) - réessayez dans {} minute",
|
||||
"other": "Trop de demandes (taux limité) - réessayez dans {} minutes"
|
||||
},
|
||||
"bgUpdateGotErrorRetryInMinutes": {
|
||||
"one": "La vérification de la mise à jour en arrière-plan a rencontré un {}, planifiera une nouvelle tentative de vérification dans {} minute",
|
||||
"other": "La vérification de la mise à jour en arrière-plan a rencontré un {}, planifiera une nouvelle tentative de vérification dans {} minutes"
|
||||
},
|
||||
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
|
||||
"one": "La vérification des mises à jour en arrière-plan trouvée {} mise à jour - avertira l'utilisateur si nécessaire",
|
||||
"other": "La vérification des mises à jour en arrière-plan a trouvé {} mises à jour - avertira l'utilisateur si nécessaire"
|
||||
},
|
||||
"apps": {
|
||||
"one": "{} Application",
|
||||
"other": "{} Applications"
|
||||
},
|
||||
"url": {
|
||||
"one": "{} URL",
|
||||
"other": "{} URLs"
|
||||
},
|
||||
"minute": {
|
||||
"one": "{} Minute",
|
||||
"other": "{} Minutes"
|
||||
},
|
||||
"hour": {
|
||||
"one": "{} Heure",
|
||||
"other": "{} Heures"
|
||||
},
|
||||
"day": {
|
||||
"one": "{} Jour",
|
||||
"other": "{} Jours"
|
||||
},
|
||||
"clearedNLogsBeforeXAfterY": {
|
||||
"one": "{n} journal effacé (avant = {before}, après = {after})",
|
||||
"other": "{n} journaux effacés (avant = {before}, après = {after})"
|
||||
},
|
||||
"xAndNMoreUpdatesAvailable": {
|
||||
"one": "{} et 1 autre application ont des mises à jour.",
|
||||
"other": "{} et {} autres applications ont des mises à jour."
|
||||
},
|
||||
"xAndNMoreUpdatesInstalled": {
|
||||
"one": "{} et 1 autre application ont été mises à jour.",
|
||||
"other": "{} et {} autres applications ont été mises à jour."
|
||||
}
|
||||
}
|
@ -34,7 +34,7 @@
|
||||
"githubStarredRepos": "GitHub Csillagos Repo-k",
|
||||
"uname": "Felh.név",
|
||||
"wrongArgNum": "Rossz számú argumentumot adott meg",
|
||||
"xIsTrackOnly": "A(z) {} csak nyomkövethető",
|
||||
"xIsTrackOnly": "A(z) {} csak nyomonkövethető",
|
||||
"source": "Forrás",
|
||||
"app": "App",
|
||||
"appsFromSourceAreTrackOnly": "Az ebből a forrásból származó alkalmazások 'Csak nyomon követhetőek'.",
|
||||
@ -56,7 +56,7 @@
|
||||
"appsString": "Appok",
|
||||
"noApps": "Nincs App",
|
||||
"noAppsForFilter": "Nincsenek appok a szűrőhöz",
|
||||
"byX": "{} által",
|
||||
"byX": "Fejlesztő: {}",
|
||||
"percentProgress": "Folyamat: {}%",
|
||||
"pleaseWait": "Kis türelmet",
|
||||
"updateAvailable": "Frissítés érhető el",
|
||||
@ -74,12 +74,11 @@
|
||||
"changeX": "Változás {}",
|
||||
"installUpdateApps": "Appok telepítése/frissítése",
|
||||
"installUpdateSelectedApps": "Telepítse/frissítse a kiválasztott appokat",
|
||||
"onlyWorksWithNonEVDApps": "Csak azoknál az alkalmazásoknál működik, amelyek telepítési állapota nem észlelhető autom. (nem gyakori).",
|
||||
"markXSelectedAppsAsUpdated": "Megjelöl {} kiválasztott alkalmazást frissítettként?",
|
||||
"no": "Nem",
|
||||
"yes": "Igen",
|
||||
"markSelectedAppsUpdated": "Jelölje meg a kiválasztott appokat frissítettként",
|
||||
"pinToTop": "Rögzítés a felülre",
|
||||
"pinToTop": "Rögzítés felülre",
|
||||
"unpinFromTop": "Eltávolít felülről",
|
||||
"resetInstallStatusForSelectedAppsQuestion": "Visszaállítja a kiválasztott appok telepítési állapotát?",
|
||||
"installStatusOfXWillBeResetExplanation": "A kiválasztott appok telepítési állapota visszaáll.\n\nEz akkor segíthet, ha az Obtainiumban megjelenített app verzió hibás, frissítések vagy egyéb problémák miatt.",
|
||||
@ -178,7 +177,6 @@
|
||||
"installedVersionX": "Telepített verzió: {}",
|
||||
"lastUpdateCheckX": "Frissítés ellenőrizve: {}",
|
||||
"remove": "Eltávolítás",
|
||||
"removeAppQuestion": "Eltávolítja az alkalmazást?",
|
||||
"yesMarkUpdated": "Igen, megjelölés frissítettként",
|
||||
"fdroid": "F-Droid",
|
||||
"appIdOrName": "App ID vagy név",
|
||||
@ -193,7 +191,7 @@
|
||||
"update": "Frissít",
|
||||
"markUpdated": "Frissítettnek jelöl",
|
||||
"additionalOptions": "További lehetőségek",
|
||||
"disableVersionDetection": "Verzióérzékelés letiltása",
|
||||
"disableVersionDetection": "Verzió érzékelés letiltása",
|
||||
"noVersionDetectionExplanation": "Ezt a beállítást csak olyan alkalmazásoknál szabad használni, ahol a verzióérzékelés nem működik megfelelően.",
|
||||
"downloadingX": "{} letöltés",
|
||||
"downloadNotifDescription": "Értesíti a felhasználót az app letöltésének előrehaladásáról",
|
||||
@ -210,6 +208,22 @@
|
||||
"language": "Nyelv",
|
||||
"storagePermissionDenied": "Tárhely engedély megtagadva",
|
||||
"selectedCategorizeWarning": "Ez felváltja a kiválasztott alkalmazások meglévő kategória-beállításait.",
|
||||
"filterAPKsByRegEx": "Az APK-k szűrése reguláris kifejezéssel",
|
||||
"removeFromObtainium": "Eltávolítás az Obtainiumból",
|
||||
"uninstallFromDevice": "Eltávolítás a készülékről",
|
||||
"onlyWorksWithNonVersionDetectApps": "Csak azoknál az alkalmazásoknál működik, amelyeknél a verzióérzékelés le van tiltva.",
|
||||
"releaseDateAsVersion": "Használja a Kiadás dátumát, mint verziót",
|
||||
"releaseDateAsVersionExplanation": "Ezt a beállítást csak olyan alkalmazásoknál szabad használni, ahol a verzió érzékelése nem működik megfelelően, de elérhető a kiadás dátuma.",
|
||||
"changes": "Változtatások",
|
||||
"releaseDate": "Kiadás dátuma",
|
||||
"importFromURLsInFile": "Importálás fájlban található URL-ből (mint pl. OPML)",
|
||||
"versionDetection": "Verzió érzékelés",
|
||||
"standardVersionDetection": "Alapért. verzió érzékelés",
|
||||
"groupByCategory": "Group by Category",
|
||||
"removeAppQuestion": {
|
||||
"one": "Eltávolítja az alkalmazást?",
|
||||
"other": "Eltávolítja az alkalmazást?"
|
||||
},
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Túl sok kérés (korlátozott arány) – próbálja újra {} perc múlva",
|
||||
"other": "Túl sok kérés (korlátozott arány) – próbálja újra {} perc múlva"
|
||||
|
@ -56,9 +56,9 @@
|
||||
"appsString": "App",
|
||||
"noApps": "Nessuna App",
|
||||
"noAppsForFilter": "Nessuna App per i filtri selezionati",
|
||||
"byX": "Da {}",
|
||||
"byX": "Di {}",
|
||||
"percentProgress": "Progresso: {}%",
|
||||
"pleaseWait": "Attendere prego",
|
||||
"pleaseWait": "In attesa",
|
||||
"updateAvailable": "Aggiornamento disponibile",
|
||||
"estimateInBracketsShort": "(prev.)",
|
||||
"notInstalled": "Non installato",
|
||||
@ -74,7 +74,6 @@
|
||||
"changeX": "Modifica {}",
|
||||
"installUpdateApps": "Installa/Aggiorna App",
|
||||
"installUpdateSelectedApps": "Installa/Aggiorna le App selezionate",
|
||||
"onlyWorksWithNonEVDApps": "Funziona solo per le App il cui stato d'installazione non può essere rilevato automaticamente (inconsueto).",
|
||||
"markXSelectedAppsAsUpdated": "Contrassegnare le {} App selezionate come aggiornate?",
|
||||
"no": "No",
|
||||
"yes": "Sì",
|
||||
@ -95,7 +94,7 @@
|
||||
"author": "Autore",
|
||||
"upToDateApps": "App aggiornate",
|
||||
"nonInstalledApps": "App non installate",
|
||||
"importExport": "Importa - Esporta",
|
||||
"importExport": "Importa/Esporta",
|
||||
"settings": "Impostazioni",
|
||||
"exportedTo": "Esportato in {}",
|
||||
"obtainiumExport": "Esporta da Obtainium",
|
||||
@ -178,7 +177,6 @@
|
||||
"installedVersionX": "Versione installata: {}",
|
||||
"lastUpdateCheckX": "Ultimo controllo degli aggiornamenti: {}",
|
||||
"remove": "Rimuovi",
|
||||
"removeAppQuestion": "Rimuovere l'App?",
|
||||
"yesMarkUpdated": "Sì, contrassegna come aggiornato",
|
||||
"fdroid": "F-Droid",
|
||||
"appIdOrName": "ID o nome dell'App",
|
||||
@ -209,8 +207,24 @@
|
||||
"addCategory": "Aggiungi categoria",
|
||||
"label": "Etichetta",
|
||||
"language": "Lingua",
|
||||
"storagePermissionDenied": "Storage permission denied",
|
||||
"selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.",
|
||||
"storagePermissionDenied": "Accesso ai file non autorizzato",
|
||||
"selectedCategorizeWarning": "Ciò sostituirà le impostazioni di categoria esistenti per le App selezionate.",
|
||||
"filterAPKsByRegEx": "Filtra file APK con espressioni regolari",
|
||||
"removeFromObtainium": "Rimuovi da Obtainium",
|
||||
"uninstallFromDevice": "Disinstalla dal dispositivo",
|
||||
"onlyWorksWithNonVersionDetectApps": "Funziona solo per le App con il rilevamento della versione disattivato.",
|
||||
"releaseDateAsVersion": "Usa data di rilascio come versione",
|
||||
"releaseDateAsVersionExplanation": "Questa opzione dovrebbe essere usata solo per le App in cui il rilevamento della versione non funziona correttamente, ma è disponibile una data di rilascio.",
|
||||
"changes": "Novità",
|
||||
"releaseDate": "Data di rilascio",
|
||||
"importFromURLsInFile": "Importa da URL in file (come OPML)",
|
||||
"versionDetection": "Rilevamento di versione",
|
||||
"standardVersionDetection": "Rilevamento di versione standard",
|
||||
"groupByCategory": "Group by Category",
|
||||
"removeAppQuestion": {
|
||||
"one": "Rimuovere l'App?",
|
||||
"other": "Rimuovere le App?"
|
||||
},
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Troppe richieste (traffico limitato) - riprova tra {} minuto",
|
||||
"other": "Troppe richieste (traffico limitato) - riprova tra {} minuti"
|
||||
|
@ -7,7 +7,7 @@
|
||||
"appIdMismatch": "ダウンロードしたパッケージのIDが既存のApp IDと一致しません",
|
||||
"functionNotImplemented": "このクラスはこの機能を実装していません",
|
||||
"placeholder": "プレースホルダー",
|
||||
"someErrors": "いくつかのエラーが発生しました",
|
||||
"someErrors": "何らかのエラーが発生しました",
|
||||
"unexpectedError": "予期せぬエラーが発生しました",
|
||||
"ok": "OK",
|
||||
"and": "と",
|
||||
@ -74,7 +74,6 @@
|
||||
"changeX": "{} を変更する",
|
||||
"installUpdateApps": "アプリのインストール/アップデート",
|
||||
"installUpdateSelectedApps": "選択したアプリのインストール/アップデート",
|
||||
"onlyWorksWithNonEVDApps": "インストール状況を自動検出できないアプリ(一般的でないもの)のみ動作します。",
|
||||
"markXSelectedAppsAsUpdated": "{}個の選択したアプリをアップデート済みとしてマークしますか?",
|
||||
"no": "いいえ",
|
||||
"yes": "はい",
|
||||
@ -82,7 +81,7 @@
|
||||
"pinToTop": "トップに固定",
|
||||
"unpinFromTop": "トップから固定解除",
|
||||
"resetInstallStatusForSelectedAppsQuestion": "選択したアプリのインストール状態をリセットしますか?",
|
||||
"installStatusOfXWillBeResetExplanation": "選択したアプリのインストール状態がリセットされます。\n\nアップデートに失敗するなどして、Obtainiumに表示されるアプリのバージョンが正しくない場合に役立ちます。",
|
||||
"installStatusOfXWillBeResetExplanation": "選択したアプリのインストール状態がリセットされます。\n\nアップデートに失敗した場合など、Obtainiumに表示されるアプリのバージョンが正しくない場合に有効です。",
|
||||
"shareSelectedAppURLs": "選択したアプリのURLを共有する",
|
||||
"resetInstallStatus": "インストール状態をリセットする",
|
||||
"more": "もっと見る",
|
||||
@ -108,8 +107,8 @@
|
||||
"line": "行",
|
||||
"searchX": "{}で検索",
|
||||
"noResults": "結果は見つかりませんでした",
|
||||
"importX": "{}をインポートする",
|
||||
"importedAppsIdDisclaimer": "インポートしたアプリが「未インストール」と表示されることがあります。\nこれを解決するには、Obtainiumから再インストールしてください。\nアプリのデータには影響しません。\n\nURLとサードパーティーのインポートメソッドにのみ影響します。",
|
||||
"importX": "{}をインポート",
|
||||
"importedAppsIdDisclaimer": "インポートしたアプリが「未インストール」と表示されることがあります。\nこれを解決するには、Obtainiumから再インストールしてください。\nアプリのデータには影響しません。\n\nURLとサードパーティのインポートメソッドにのみ影響します。",
|
||||
"importErrors": "インポートエラー",
|
||||
"importedXOfYApps": "{} / {} アプリをインポートしました",
|
||||
"followingURLsHadErrors": "以下のURLでエラーが発生しました:",
|
||||
@ -133,7 +132,7 @@
|
||||
"bgUpdateCheckInterval": "バックグラウンドでのアップデート確認の間隔",
|
||||
"neverManualOnly": "手動",
|
||||
"appearance": "外観",
|
||||
"showWebInAppView": "アプリビューにソースウェブページを表示する",
|
||||
"showWebInAppView": "アプリページにソースのWebページを表示する",
|
||||
"pinUpdates": "アップデートがあるアプリをトップに固定する",
|
||||
"updates": "アップデート",
|
||||
"sourceSpecific": "Github アクセストークン",
|
||||
@ -178,13 +177,12 @@
|
||||
"installedVersionX": "インストールされたバージョン: {}",
|
||||
"lastUpdateCheckX": "最終アップデート確認: {}",
|
||||
"remove": "削除",
|
||||
"removeAppQuestion": "アプリを削除しますか?",
|
||||
"yesMarkUpdated": "はい、アップデート済みとしてマークします",
|
||||
"fdroid": "F-Droid",
|
||||
"appIdOrName": "アプリのIDまたは名前",
|
||||
"appWithIdOrNameNotFound": "そのIDや名前を持つアプリは見つかりませんでした",
|
||||
"reposHaveMultipleApps": "リポジトリには複数のアプリが含まれることがあります",
|
||||
"fdroidThirdPartyRepo": "F-Droid Third-Party Repo",
|
||||
"fdroidThirdPartyRepo": "F-Droid サードパーティリポジトリ",
|
||||
"steam": "Steam",
|
||||
"steamMobile": "Steam Mobile",
|
||||
"steamChat": "Steam Chat",
|
||||
@ -211,6 +209,22 @@
|
||||
"language": "言語",
|
||||
"storagePermissionDenied": "ストレージ権限が拒否されました",
|
||||
"selectedCategorizeWarning": "これにより、選択したアプリの既存のカテゴリ設定がすべて置き換えられます。",
|
||||
"filterAPKsByRegEx": "正規表現でAPKを絞り込む",
|
||||
"removeFromObtainium": "Obtainiumから削除する",
|
||||
"uninstallFromDevice": "デバイスからアンインストールする",
|
||||
"onlyWorksWithNonVersionDetectApps": "バージョン検出を無効にしているアプリにのみ動作します。",
|
||||
"releaseDateAsVersion": "リリース日をバージョンとして使用する",
|
||||
"releaseDateAsVersionExplanation": "このオプションは、バージョン検出が正しく機能しないアプリで、リリース日が利用可能な場合にのみ使用する必要があります。",
|
||||
"changes": "変更点",
|
||||
"releaseDate": "リリース日",
|
||||
"importFromURLsInFile": "ファイル(OPMLなど)内のURLからインポート",
|
||||
"versionDetection": "バージョン検出",
|
||||
"standardVersionDetection": "標準のバージョン検出",
|
||||
"groupByCategory": "Group by Category",
|
||||
"removeAppQuestion": {
|
||||
"one": "アプリを削除しますか?",
|
||||
"other": "アプリを削除しますか?"
|
||||
},
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "リクエストが多すぎます(レート制限)- {}分後に再試行してください",
|
||||
"other": "リクエストが多すぎます(レート制限)- {}分後に再試行してください"
|
||||
|
@ -178,7 +178,6 @@
|
||||
"installedVersionX": "已安装: {}",
|
||||
"lastUpdateCheckX": "最后检查: {}",
|
||||
"remove": "删除",
|
||||
"removeAppQuestion": "删除应用?",
|
||||
"yesMarkUpdated": "'是的,标为已更新",
|
||||
"fdroid": "F-Droid",
|
||||
"appIdOrName": "应用 ID 或名称",
|
||||
@ -211,6 +210,21 @@
|
||||
"language": "语言",
|
||||
"storagePermissionDenied": "存储权限已被拒绝",
|
||||
"selectedCategorizeWarning": "这将取代所选应用程序的任何现有类别",
|
||||
"filterAPKsByRegEx": "Filter APKs by Regular Expression",
|
||||
"removeFromObtainium": "Remove from Obtainium",
|
||||
"uninstallFromDevice": "Uninstall from Device",
|
||||
"releaseDateAsVersion": "Use Release Date as Version",
|
||||
"releaseDateAsVersionExplanation": "This option should only be used for Apps where version detection does not work correctly, but a release date is available.",
|
||||
"changes": "Changes",
|
||||
"releaseDate": "Release Date",
|
||||
"importFromURLsInFile": "Import from URLs in File (like OPML)",
|
||||
"versionDetection": "Version Detection",
|
||||
"standardVersionDetection": "Standard version detection",
|
||||
"groupByCategory": "Group by Category",
|
||||
"removeAppQuestion": {
|
||||
"one": "删除应用?",
|
||||
"other": "删除应用?"
|
||||
},
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "请求过多 (API 限制) - 在 {} 分钟后重试",
|
||||
"other": "请求过多 (API 限制) - 在 {} 分钟后重试"
|
||||
|
@ -1,5 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
@ -7,6 +11,23 @@ class APKMirror extends AppSource {
|
||||
APKMirror() {
|
||||
host = 'apkmirror.com';
|
||||
enforceTrackOnly = true;
|
||||
|
||||
additionalSourceAppSpecificSettingFormItems = [
|
||||
[
|
||||
GeneratedFormSwitch('fallbackToOlderReleases',
|
||||
label: tr('fallbackToOlderReleases'), defaultValue: true)
|
||||
],
|
||||
[
|
||||
GeneratedFormTextField('filterReleaseTitlesByRegEx',
|
||||
label: tr('filterReleaseTitlesByRegEx'),
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
return regExValidator(value);
|
||||
}
|
||||
])
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
@ -28,12 +49,38 @@ class APKMirror extends AppSource {
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
bool fallbackToOlderReleases =
|
||||
additionalSettings['fallbackToOlderReleases'] == true;
|
||||
String? regexFilter =
|
||||
(additionalSettings['filterReleaseTitlesByRegEx'] as String?)
|
||||
?.isNotEmpty ==
|
||||
true
|
||||
? additionalSettings['filterReleaseTitlesByRegEx']
|
||||
: null;
|
||||
Response res = await get(Uri.parse('$standardUrl/feed'));
|
||||
if (res.statusCode == 200) {
|
||||
String? titleString = parse(res.body)
|
||||
.querySelector('item')
|
||||
?.querySelector('title')
|
||||
?.innerHtml;
|
||||
var items = parse(res.body).querySelectorAll('item');
|
||||
dynamic targetRelease;
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
if (!fallbackToOlderReleases && i > 0) break;
|
||||
String? nameToFilter = items[i].querySelector('title')?.innerHtml;
|
||||
if (regexFilter != null &&
|
||||
nameToFilter != null &&
|
||||
!RegExp(regexFilter).hasMatch(nameToFilter.trim())) {
|
||||
continue;
|
||||
}
|
||||
targetRelease = items[i];
|
||||
break;
|
||||
}
|
||||
String? titleString = targetRelease?.querySelector('title')?.innerHtml;
|
||||
String? dateString = targetRelease
|
||||
?.querySelector('pubDate')
|
||||
?.innerHtml
|
||||
.split(' ')
|
||||
.sublist(0, 5)
|
||||
.join(' ');
|
||||
DateTime? releaseDate =
|
||||
dateString != null ? HttpDate.parse('$dateString GMT') : null;
|
||||
String? version = titleString
|
||||
?.substring(RegExp('[0-9]').firstMatch(titleString)?.start ?? 0,
|
||||
RegExp(' by ').firstMatch(titleString)?.start ?? 0)
|
||||
@ -44,7 +91,8 @@ class APKMirror extends AppSource {
|
||||
if (version == null || version.isEmpty) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, [], getAppNames(standardUrl));
|
||||
return APKDetails(version, [], getAppNames(standardUrl),
|
||||
releaseDate: releaseDate);
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
|
@ -26,15 +26,7 @@ class Codeberg extends AppSource {
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RegExp(value);
|
||||
} catch (e) {
|
||||
return tr('invalidRegEx');
|
||||
}
|
||||
return null;
|
||||
return regExValidator(value);
|
||||
}
|
||||
])
|
||||
]
|
||||
@ -62,9 +54,9 @@ class Codeberg extends AppSource {
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
bool includePrereleases = additionalSettings['includePrereleases'];
|
||||
bool includePrereleases = additionalSettings['includePrereleases'] == true;
|
||||
bool fallbackToOlderReleases =
|
||||
additionalSettings['fallbackToOlderReleases'];
|
||||
additionalSettings['fallbackToOlderReleases'] == true;
|
||||
String? regexFilter =
|
||||
(additionalSettings['filterReleaseTitlesByRegEx'] as String?)
|
||||
?.isNotEmpty ==
|
||||
@ -72,7 +64,7 @@ class Codeberg extends AppSource {
|
||||
? additionalSettings['filterReleaseTitlesByRegEx']
|
||||
: null;
|
||||
Response res = await get(Uri.parse(
|
||||
'https://$host/api/v1/repos${standardUrl.substring('https://$host'.length)}/releases'));
|
||||
'https://$host/api/v1/repos${standardUrl.substring('https://$host'.length)}/releases?per_page=100'));
|
||||
if (res.statusCode == 200) {
|
||||
var releases = jsonDecode(res.body) as List<dynamic>;
|
||||
|
||||
@ -120,11 +112,17 @@ class Codeberg extends AppSource {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
String? version = targetRelease['tag_name'];
|
||||
DateTime? releaseDate = targetRelease['published_at'] != null
|
||||
? DateTime.parse(targetRelease['published_at'])
|
||||
: null;
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
var changeLog = targetRelease['body'].toString();
|
||||
return APKDetails(version, targetRelease['apkUrls'] as List<String>,
|
||||
getAppNames(standardUrl));
|
||||
getAppNames(standardUrl),
|
||||
releaseDate: releaseDate,
|
||||
changeLog: changeLog.isEmpty ? null : changeLog);
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
|
@ -27,9 +27,6 @@ class FDroid extends AppSource {
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
String? tryInferringAppId(String standardUrl,
|
||||
{Map<String, dynamic> additionalSettings = const {}}) {
|
||||
|
@ -69,6 +69,8 @@ class FDroidRepo extends AppSource {
|
||||
foundApps[0].querySelector('name')?.innerHtml ?? appIdOrName;
|
||||
var releases = foundApps[0].querySelectorAll('package');
|
||||
String? latestVersion = releases[0].querySelector('version')?.innerHtml;
|
||||
String? added = releases[0].querySelector('added')?.innerHtml;
|
||||
DateTime? releaseDate = added != null ? DateTime.parse(added) : null;
|
||||
if (latestVersion == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
@ -78,7 +80,8 @@ class FDroidRepo extends AppSource {
|
||||
element.querySelector('apkname') != null)
|
||||
.map((e) => '$standardUrl/${e.querySelector('apkname')!.innerHtml}')
|
||||
.toList();
|
||||
return APKDetails(latestVersion, apkUrls, AppNames(authorName, appName));
|
||||
return APKDetails(latestVersion, apkUrls, AppNames(authorName, appName),
|
||||
releaseDate: releaseDate);
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
|
@ -65,15 +65,7 @@ class GitHub extends AppSource {
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RegExp(value);
|
||||
} catch (e) {
|
||||
return tr('invalidRegEx');
|
||||
}
|
||||
return null;
|
||||
return regExValidator(value);
|
||||
}
|
||||
])
|
||||
]
|
||||
@ -109,9 +101,9 @@ class GitHub extends AppSource {
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
bool includePrereleases = additionalSettings['includePrereleases'];
|
||||
bool includePrereleases = additionalSettings['includePrereleases'] == true;
|
||||
bool fallbackToOlderReleases =
|
||||
additionalSettings['fallbackToOlderReleases'];
|
||||
additionalSettings['fallbackToOlderReleases'] == true;
|
||||
String? regexFilter =
|
||||
(additionalSettings['filterReleaseTitlesByRegEx'] as String?)
|
||||
?.isNotEmpty ==
|
||||
@ -119,7 +111,7 @@ class GitHub extends AppSource {
|
||||
? additionalSettings['filterReleaseTitlesByRegEx']
|
||||
: null;
|
||||
Response res = await get(Uri.parse(
|
||||
'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
|
||||
'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases?per_page=100'));
|
||||
if (res.statusCode == 200) {
|
||||
var releases = jsonDecode(res.body) as List<dynamic>;
|
||||
|
||||
@ -162,11 +154,17 @@ class GitHub extends AppSource {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
String? version = targetRelease['tag_name'];
|
||||
DateTime? releaseDate = targetRelease['published_at'] != null
|
||||
? DateTime.parse(targetRelease['published_at'])
|
||||
: null;
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
var changeLog = targetRelease['body'].toString();
|
||||
return APKDetails(version, targetRelease['apkUrls'] as List<String>,
|
||||
getAppNames(standardUrl));
|
||||
getAppNames(standardUrl),
|
||||
releaseDate: releaseDate,
|
||||
changeLog: changeLog.isEmpty ? null : changeLog);
|
||||
} else {
|
||||
rateLimitErrorCheck(res);
|
||||
throw getObtainiumHttpError(res);
|
||||
|
@ -54,10 +54,14 @@ class GitLab extends AppSource {
|
||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||
var version =
|
||||
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
||||
var releaseDateString = entry?.querySelector('updated')?.innerHtml;
|
||||
DateTime? releaseDate =
|
||||
releaseDateString != null ? DateTime.parse(releaseDateString) : null;
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, apkUrls, GitHub().getAppNames(standardUrl));
|
||||
return APKDetails(version, apkUrls, GitHub().getAppNames(standardUrl),
|
||||
releaseDate: releaseDate);
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
|
@ -10,9 +10,6 @@ class HTML extends AppSource {
|
||||
return url;
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
@ -27,6 +24,10 @@ class HTML extends AppSource {
|
||||
.where((element) => element.toLowerCase().endsWith('.apk'))
|
||||
.toList();
|
||||
links.sort((a, b) => a.split('/').last.compareTo(b.split('/').last));
|
||||
if (additionalSettings['apkFilterRegEx'] != null) {
|
||||
var reg = RegExp(additionalSettings['apkFilterRegEx']);
|
||||
links = links.where((element) => reg.hasMatch(element)).toList();
|
||||
}
|
||||
if (links.isEmpty) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
@ -37,7 +38,9 @@ class HTML extends AppSource {
|
||||
.map((e) => e.toLowerCase().startsWith('http://') ||
|
||||
e.toLowerCase().startsWith('https://')
|
||||
? e
|
||||
: '${uri.origin}/$e')
|
||||
: e.startsWith('/')
|
||||
? '${uri.origin}/$e'
|
||||
: '${uri.origin}/${uri.path}/$e')
|
||||
.toList();
|
||||
return APKDetails(version, apkUrls, AppNames(uri.host, tr('app')));
|
||||
} else {
|
||||
|
@ -18,9 +18,6 @@ class IzzyOnDroid extends AppSource {
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
String? tryInferringAppId(String standardUrl,
|
||||
{Map<String, dynamic> additionalSettings = const {}}) {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
@ -29,19 +30,37 @@ class Mullvad extends AppSource {
|
||||
) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
|
||||
if (res.statusCode == 200) {
|
||||
var version = parse(res.body)
|
||||
.querySelector('p.subtitle.is-6')
|
||||
?.querySelector('a')
|
||||
?.attributes['href']
|
||||
?.split('/')
|
||||
.last;
|
||||
if (version == null) {
|
||||
var versions = parse(res.body)
|
||||
.querySelectorAll('p')
|
||||
.map((e) => e.innerHtml)
|
||||
.where((p) => p.contains('Latest version: '))
|
||||
.map((e) {
|
||||
var match = RegExp('[0-9]+(\\.[0-9]+)*').firstMatch(e);
|
||||
if (match == null) {
|
||||
return '';
|
||||
} else {
|
||||
return e.substring(match.start, match.end);
|
||||
}
|
||||
})
|
||||
.where((element) => element.isNotEmpty)
|
||||
.toList();
|
||||
if (versions.isEmpty) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
String? changeLog;
|
||||
try {
|
||||
changeLog = (await GitHub().getLatestAPKDetails(
|
||||
'https://github.com/mullvad/mullvadvpn-app',
|
||||
{'fallbackToOlderReleases': true}))
|
||||
.changeLog;
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
return APKDetails(
|
||||
version,
|
||||
versions[0],
|
||||
['https://mullvad.net/download/app/apk/latest'],
|
||||
AppNames(name, 'Mullvad-VPN'));
|
||||
AppNames(name, 'Mullvad-VPN'),
|
||||
changeLog: changeLog);
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
|
111
lib/app_sources/neutroncode.dart
Normal file
@ -0,0 +1,111 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class NeutronCode extends AppSource {
|
||||
NeutronCode() {
|
||||
host = 'neutroncode.com';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/downloads/file/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => standardUrl;
|
||||
|
||||
String monthNameToNumberString(String s) {
|
||||
switch (s.toLowerCase()) {
|
||||
case 'january':
|
||||
return '01';
|
||||
case 'february':
|
||||
return '02';
|
||||
case 'march':
|
||||
return '03';
|
||||
case 'april':
|
||||
return '04';
|
||||
case 'may':
|
||||
return '05';
|
||||
case 'june':
|
||||
return '06';
|
||||
case 'july':
|
||||
return '07';
|
||||
case 'august':
|
||||
return '08';
|
||||
case 'september':
|
||||
return '09';
|
||||
case 'october':
|
||||
return '10';
|
||||
case 'november':
|
||||
return '11';
|
||||
case 'december':
|
||||
return '12';
|
||||
default:
|
||||
throw ArgumentError('Invalid month name: $s');
|
||||
}
|
||||
}
|
||||
|
||||
customDateParse(String dateString) {
|
||||
List<String> parts = dateString.split(' ');
|
||||
if (parts.length != 3) {
|
||||
return null;
|
||||
}
|
||||
String result = '';
|
||||
for (var s in parts.reversed) {
|
||||
try {
|
||||
try {
|
||||
int.parse(s);
|
||||
result += '$s-';
|
||||
} catch (e) {
|
||||
result += '${monthNameToNumberString(s)}-';
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return result.substring(0, result.length - 1);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
Response res = await get(Uri.parse(standardUrl));
|
||||
if (res.statusCode == 200) {
|
||||
var http = parse(res.body);
|
||||
var name = http.querySelector('.pd-title')?.innerHtml;
|
||||
var filename = http.querySelector('.pd-filename .pd-float')?.innerHtml;
|
||||
if (filename == null) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
var version =
|
||||
http.querySelector('.pd-version-txt')?.nextElementSibling?.innerHtml;
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
String? apkUrl = 'https://$host/download/$filename';
|
||||
var dateStringOriginal =
|
||||
http.querySelector('.pd-date-txt')?.nextElementSibling?.innerHtml;
|
||||
var dateString = dateStringOriginal != null
|
||||
? (customDateParse(dateStringOriginal))
|
||||
: null;
|
||||
var changeLogElements = http.querySelectorAll('.pd-fdesc p');
|
||||
return APKDetails(version, [apkUrl],
|
||||
AppNames(runtimeType.toString(), name ?? standardUrl.split('/').last),
|
||||
releaseDate: dateString != null ? DateTime.parse(dateString) : null,
|
||||
changeLog: changeLogElements.isNotEmpty
|
||||
? changeLogElements.last.innerHtml
|
||||
: null);
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
@ -13,9 +13,6 @@ class Signal extends AppSource {
|
||||
return 'https://$host';
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
|
@ -18,9 +18,6 @@ class SourceForge extends AppSource {
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
|
@ -10,7 +10,10 @@ class SteamMobile extends AppSource {
|
||||
host = 'store.steampowered.com';
|
||||
name = tr('steam');
|
||||
additionalSourceAppSpecificSettingFormItems = [
|
||||
[GeneratedFormDropdown('app', apks.entries.toList(), label: tr('app'))]
|
||||
[
|
||||
GeneratedFormDropdown('app', apks.entries.toList(),
|
||||
label: tr('app'), defaultValue: apks.entries.toList()[0].key)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@ -21,9 +24,6 @@ class SteamMobile extends AppSource {
|
||||
return 'https://$host';
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
@ -35,7 +35,8 @@ class SteamMobile extends AppSource {
|
||||
if (apkNamePrefix == null) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
String apkInURLRegexPattern = '/$apkNamePrefix-[^/]+\\.apk\$';
|
||||
String apkInURLRegexPattern =
|
||||
'/$apkNamePrefix-([0-9]+\\.)*[0-9]+\\.apk\$';
|
||||
var links = parse(res.body)
|
||||
.querySelectorAll('a')
|
||||
.map((e) => e.attributes['href'] ?? '')
|
||||
|
40
lib/app_sources/telegramapp.dart
Normal file
@ -0,0 +1,40 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class TelegramApp extends AppSource {
|
||||
TelegramApp() {
|
||||
host = 'telegram.org';
|
||||
name = 'Telegram ${tr('app')}';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
return 'https://$host';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
Response res = await get(Uri.parse('https://t.me/s/TAndroidAPK'));
|
||||
if (res.statusCode == 200) {
|
||||
var http = parse(res.body);
|
||||
var messages =
|
||||
http.querySelectorAll('.tgme_widget_message_text.js-message_text');
|
||||
var version = messages.isNotEmpty
|
||||
? messages.last.innerHtml.split('\n').first.trim().split(' ').first
|
||||
: null;
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
String? apkUrl = 'https://telegram.org/dl/android/apk';
|
||||
return APKDetails(version, [apkUrl], AppNames('Telegram', 'Telegram'));
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
62
lib/app_sources/vlc.dart
Normal file
@ -0,0 +1,62 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/html.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class VLC extends AppSource {
|
||||
VLC() {
|
||||
host = 'videolan.org';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
return 'https://$host';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
Response res = await get(
|
||||
Uri.parse('https://www.videolan.org/vlc/download-android.html'));
|
||||
if (res.statusCode == 200) {
|
||||
var dwUrlBase = 'get.videolan.org/vlc-android';
|
||||
var dwLinks = parse(res.body)
|
||||
.querySelectorAll('a')
|
||||
.where((element) =>
|
||||
element.attributes['href']?.contains(dwUrlBase) ?? false)
|
||||
.toList();
|
||||
String? version = dwLinks.isNotEmpty
|
||||
? dwLinks.first.attributes['href']
|
||||
?.split('/')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.last
|
||||
: null;
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
String? targetUrl = 'https://$dwUrlBase/$version/';
|
||||
Response res2 = await get(Uri.parse(targetUrl));
|
||||
String mirrorDwBase =
|
||||
'https://plug-mirror.rcac.purdue.edu/vlc/vlc-android/$version/';
|
||||
List<String> apkUrls = [];
|
||||
if (res2.statusCode == 200) {
|
||||
apkUrls = parse(res2.body)
|
||||
.querySelectorAll('a')
|
||||
.map((e) => e.attributes['href'])
|
||||
.where((h) =>
|
||||
h != null && h.isNotEmpty && h.toLowerCase().endsWith('.apk'))
|
||||
.map((e) => mirrorDwBase + e!)
|
||||
.toList();
|
||||
} else {
|
||||
throw getObtainiumHttpError(res2);
|
||||
}
|
||||
|
||||
return APKDetails(version, apkUrls, AppNames('VideoLAN', 'VLC'));
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
75
lib/app_sources/whatsapp.dart
Normal file
@ -0,0 +1,75 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class WhatsApp extends AppSource {
|
||||
WhatsApp() {
|
||||
host = 'whatsapp.com';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
return 'https://$host';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
|
||||
Response res = await get(Uri.parse('https://www.whatsapp.com/android'));
|
||||
if (res.statusCode == 200) {
|
||||
var targetLinks = parse(res.body)
|
||||
.querySelectorAll('a')
|
||||
.map((e) => e.attributes['href'])
|
||||
.where((e) => e != null)
|
||||
.where((e) =>
|
||||
e!.contains('scontent.whatsapp.net') &&
|
||||
e.contains('WhatsApp.apk'))
|
||||
.toList();
|
||||
if (targetLinks.isEmpty) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
return targetLinks[0]!;
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
Response res = await get(Uri.parse('https://www.whatsapp.com/android'));
|
||||
if (res.statusCode == 200) {
|
||||
var targetElements = parse(res.body)
|
||||
.querySelectorAll('p')
|
||||
.where((element) => element.innerHtml.contains('Version '))
|
||||
.toList();
|
||||
if (targetElements.isEmpty) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
var vLines = targetElements[0]
|
||||
.innerHtml
|
||||
.split('\n')
|
||||
.where((element) => element.contains('Version '))
|
||||
.toList();
|
||||
if (vLines.isEmpty) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
var versionMatch = RegExp('[0-9]+(\\.[0-9]+)+').firstMatch(vLines[0]);
|
||||
if (versionMatch == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
String version =
|
||||
vLines[0].substring(versionMatch.start, versionMatch.end);
|
||||
return APKDetails(
|
||||
version,
|
||||
[
|
||||
'https://www.whatsapp.com/android?v=$version&=thisIsaPlaceholder&a=realURLPrefetchedAtDownloadTime'
|
||||
],
|
||||
AppNames('Meta', 'WhatsApp'));
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
@ -48,6 +48,7 @@ class GeneratedFormTextField extends GeneratedFormItem {
|
||||
|
||||
class GeneratedFormDropdown extends GeneratedFormItem {
|
||||
late List<MapEntry<String, String>>? opts;
|
||||
List<String>? disabledOptKeys;
|
||||
|
||||
GeneratedFormDropdown(
|
||||
String key,
|
||||
@ -55,6 +56,7 @@ class GeneratedFormDropdown extends GeneratedFormItem {
|
||||
String label = 'Input',
|
||||
List<Widget> belowWidgets = const [],
|
||||
String defaultValue = '',
|
||||
this.disabledOptKeys,
|
||||
List<String? Function(String? value)> additionalValidators = const [],
|
||||
}) : super(key,
|
||||
label: label,
|
||||
@ -150,6 +152,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
Map<String, dynamic> values = {};
|
||||
late List<List<Widget>> formInputs;
|
||||
List<List<Widget>> rows = [];
|
||||
String? initKey;
|
||||
|
||||
// If any value changes, call this to update the parent with value and validity
|
||||
void someValueChanged({bool isBuilding = false}) {
|
||||
@ -169,13 +172,10 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
widget.onValueChanges(returnValues, valid, isBuilding);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
initForm() {
|
||||
initKey = widget.key.toString();
|
||||
// Initialize form values as all empty
|
||||
values.clear();
|
||||
int j = 0;
|
||||
for (var row in widget.items) {
|
||||
for (var e in row) {
|
||||
values[e.key] = e.defaultValue;
|
||||
@ -227,10 +227,15 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
return DropdownButtonFormField(
|
||||
decoration: InputDecoration(labelText: formItem.label),
|
||||
value: values[formItem.key],
|
||||
items: formItem.opts!
|
||||
.map((e2) =>
|
||||
DropdownMenuItem(value: e2.key, child: Text(e2.value)))
|
||||
.toList(),
|
||||
items: formItem.opts!.map((e2) {
|
||||
var enabled =
|
||||
formItem.disabledOptKeys?.contains(e2.key) != true;
|
||||
return DropdownMenuItem(
|
||||
value: e2.key,
|
||||
enabled: enabled,
|
||||
child: Opacity(
|
||||
opacity: enabled ? 1 : 0.5, child: Text(e2.value)));
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
values[formItem.key] = value ?? formItem.opts!.first.key;
|
||||
@ -245,8 +250,17 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
someValueChanged(isBuilding: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initForm();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.key.toString() != initKey) {
|
||||
initForm();
|
||||
}
|
||||
for (var r = 0; r < formInputs.length; r++) {
|
||||
for (var e = 0; e < formInputs[r].length; e++) {
|
||||
if (widget.items[r][e] is GeneratedFormSwitch) {
|
||||
@ -453,10 +467,9 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
if (rowInputs.key > 0) {
|
||||
rows.add([
|
||||
SizedBox(
|
||||
height: widget.items[rowInputs.key][0] is GeneratedFormSwitch &&
|
||||
widget.items[rowInputs.key - 1][0] is! GeneratedFormSwitch
|
||||
? 25
|
||||
: 8,
|
||||
height: widget.items[rowInputs.key - 1][0] is GeneratedFormSwitch
|
||||
? 8
|
||||
: 25,
|
||||
)
|
||||
]);
|
||||
}
|
||||
@ -470,6 +483,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
rowItems.add(Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
rowInput.value,
|
||||
...widget.items[rowInputs.key][rowInput.key].belowWidgets
|
||||
|
@ -29,7 +29,7 @@ class NoReleasesError extends ObtainiumError {
|
||||
}
|
||||
|
||||
class NoAPKError extends ObtainiumError {
|
||||
NoAPKError() : super(tr('noReleaseFound'));
|
||||
NoAPKError() : super(tr('noAPKFound'));
|
||||
}
|
||||
|
||||
class NoVersionError extends ObtainiumError {
|
||||
|
@ -21,7 +21,7 @@ import 'package:easy_localization/src/easy_localization_controller.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:easy_localization/src/localization.dart';
|
||||
|
||||
const String currentVersion = '0.10.4';
|
||||
const String currentVersion = '0.11.22';
|
||||
const String currentReleaseTag =
|
||||
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
|
||||
@ -33,7 +33,9 @@ const supportedLocales = [
|
||||
Locale('it'),
|
||||
Locale('ja'),
|
||||
Locale('hu'),
|
||||
Locale('de')
|
||||
Locale('de'),
|
||||
Locale('fa'),
|
||||
Locale('fr')
|
||||
];
|
||||
const fallbackLocale = Locale('en');
|
||||
const localeDir = 'assets/translations';
|
||||
@ -145,6 +147,14 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
try {
|
||||
ByteData data =
|
||||
await PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem');
|
||||
SecurityContext.defaultContext
|
||||
.setTrustedCertificatesBytes(data.buffer.asUint8List());
|
||||
} catch (e) {
|
||||
// Already added, do nothing (see #375)
|
||||
}
|
||||
await EasyLocalization.ensureInitialized();
|
||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
@ -208,7 +218,15 @@ class _ObtainiumState extends State<Obtainium> {
|
||||
{'includePrereleases': true},
|
||||
null,
|
||||
false)
|
||||
]);
|
||||
], onlyIfExists: false);
|
||||
}
|
||||
if (!supportedLocales
|
||||
.map((e) => e.languageCode)
|
||||
.contains(context.locale.languageCode) ||
|
||||
settingsProvider.forcedLocale == null &&
|
||||
context.deviceLocale.languageCode !=
|
||||
context.locale.languageCode) {
|
||||
settingsProvider.resetLocaleSafe(context);
|
||||
}
|
||||
// Register the background update task according to the user's setting
|
||||
if (existingUpdateInterval != settingsProvider.updateInterval) {
|
||||
|
@ -32,18 +32,23 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
Map<String, dynamic> additionalSettings = {};
|
||||
bool additionalSettingsValid = true;
|
||||
List<String> pickedCategories = [];
|
||||
int searchnum = 0;
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||
|
||||
bool doingSomething = gettingAppInfo || searching;
|
||||
|
||||
changeUserInput(String input, bool valid, bool isBuilding) {
|
||||
changeUserInput(String input, bool valid, bool isBuilding,
|
||||
{bool isSearch = false}) {
|
||||
userInput = input;
|
||||
if (!isBuilding) {
|
||||
setState(() {
|
||||
if (isSearch) {
|
||||
searchnum++;
|
||||
}
|
||||
var source = valid ? sourceProvider.getSource(userInput) : null;
|
||||
if (pickedSource.runtimeType != source.runtimeType) {
|
||||
pickedSource = source;
|
||||
@ -59,59 +64,63 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
}
|
||||
}
|
||||
|
||||
getTrackOnlyConfirmationIfNeeded(bool userPickedTrackOnly) async {
|
||||
return (!((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
|
||||
// ignore: use_build_context_synchronously
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('xIsTrackOnly', args: [
|
||||
pickedSource!.enforceTrackOnly
|
||||
? tr('source')
|
||||
: tr('app')
|
||||
]),
|
||||
items: const [],
|
||||
message:
|
||||
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
|
||||
);
|
||||
}) ==
|
||||
null));
|
||||
}
|
||||
|
||||
getReleaseDateAsVersionConfirmationIfNeeded(
|
||||
bool userPickedTrackOnly) async {
|
||||
return (!(additionalSettings['versionDetection'] ==
|
||||
'releaseDateAsVersion' &&
|
||||
// ignore: use_build_context_synchronously
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('releaseDateAsVersion'),
|
||||
items: const [],
|
||||
message: tr('releaseDateAsVersionExplanation'),
|
||||
);
|
||||
}) ==
|
||||
null));
|
||||
}
|
||||
|
||||
addApp({bool resetUserInputAfter = false}) async {
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
var settingsProvider = context.read<SettingsProvider>();
|
||||
() async {
|
||||
try {
|
||||
var settingsProvider = context.read<SettingsProvider>();
|
||||
var userPickedTrackOnly = additionalSettings['trackOnly'] == true;
|
||||
var userPickedNoVersionDetection =
|
||||
additionalSettings['noVersionDetection'] == true;
|
||||
var cont = true;
|
||||
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('xIsTrackOnly', args: [
|
||||
pickedSource!.enforceTrackOnly
|
||||
? tr('source')
|
||||
: tr('app')
|
||||
]),
|
||||
items: const [],
|
||||
message:
|
||||
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
|
||||
);
|
||||
}) ==
|
||||
null) {
|
||||
cont = false;
|
||||
}
|
||||
if (userPickedNoVersionDetection &&
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('disableVersionDetection'),
|
||||
items: const [],
|
||||
message: tr('noVersionDetectionExplanation'),
|
||||
);
|
||||
}) ==
|
||||
null) {
|
||||
cont = false;
|
||||
}
|
||||
if (cont) {
|
||||
HapticFeedback.selectionClick();
|
||||
App? app;
|
||||
if ((await getTrackOnlyConfirmationIfNeeded(userPickedTrackOnly)) &&
|
||||
(await getReleaseDateAsVersionConfirmationIfNeeded(
|
||||
userPickedTrackOnly))) {
|
||||
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
|
||||
App app = await sourceProvider.getApp(
|
||||
app = await sourceProvider.getApp(
|
||||
pickedSource!, userInput, additionalSettings,
|
||||
trackOnlyOverride: trackOnly,
|
||||
noVersionDetectionOverride: userPickedNoVersionDetection);
|
||||
trackOnlyOverride: trackOnly);
|
||||
if (!trackOnly) {
|
||||
await settingsProvider.getInstallPermission();
|
||||
}
|
||||
// Only download the APK here if you need to for the package ID
|
||||
if (sourceProvider.isTempId(app.id) &&
|
||||
if (sourceProvider.isTempId(app) &&
|
||||
app.additionalSettings['trackOnly'] != true) {
|
||||
// ignore: use_build_context_synchronously
|
||||
var apkUrl = await appsProvider.confirmApkUrl(app, context);
|
||||
@ -131,28 +140,233 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
app.installedVersion = app.latestVersion;
|
||||
}
|
||||
app.categories = pickedCategories;
|
||||
await appsProvider.saveApps([app]);
|
||||
|
||||
return app;
|
||||
await appsProvider.saveApps([app], onlyIfExists: false);
|
||||
}
|
||||
}()
|
||||
.then((app) {
|
||||
if (app != null) {
|
||||
Navigator.push(context,
|
||||
MaterialPageRoute(builder: (context) => AppPage(appId: app.id)));
|
||||
Navigator.push(globalNavigatorKey.currentContext ?? context,
|
||||
MaterialPageRoute(builder: (context) => AppPage(appId: app!.id)));
|
||||
}
|
||||
}).catchError((e) {
|
||||
} catch (e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
} finally {
|
||||
setState(() {
|
||||
gettingAppInfo = false;
|
||||
if (resetUserInputAfter) {
|
||||
changeUserInput('', false, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget getUrlInputRow() => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GeneratedForm(
|
||||
key: Key(searchnum.toString()),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormTextField('appSourceURL',
|
||||
label: tr('appSourceURL'),
|
||||
defaultValue: userInput,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
try {
|
||||
sourceProvider
|
||||
.getSource(value ?? '')
|
||||
.standardizeURL(
|
||||
preStandardizeUrl(value ?? ''));
|
||||
} catch (e) {
|
||||
return e is String
|
||||
? e
|
||||
: e is ObtainiumError
|
||||
? e.toString()
|
||||
: tr('error');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
])
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
changeUserInput(
|
||||
values['appSourceURL']!, valid, isBuilding);
|
||||
})),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
gettingAppInfo
|
||||
? const CircularProgressIndicator()
|
||||
: ElevatedButton(
|
||||
onPressed: doingSomething ||
|
||||
pickedSource == null ||
|
||||
(pickedSource!.combinedAppSpecificSettingFormItems
|
||||
.isNotEmpty &&
|
||||
!additionalSettingsValid)
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.selectionClick();
|
||||
addApp();
|
||||
},
|
||||
child: Text(tr('add')))
|
||||
],
|
||||
);
|
||||
|
||||
runSearch() async {
|
||||
setState(() {
|
||||
searching = true;
|
||||
});
|
||||
try {
|
||||
var results = await Future.wait(sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.map((e) => e.search(searchQuery)));
|
||||
|
||||
// .then((results) async {
|
||||
// Interleave results instead of simple reduce
|
||||
Map<String, String> res = {};
|
||||
var si = 0;
|
||||
var done = false;
|
||||
while (!done) {
|
||||
done = true;
|
||||
for (var r in results) {
|
||||
if (r.length > si) {
|
||||
done = false;
|
||||
res.addEntries([r.entries.elementAt(si)]);
|
||||
}
|
||||
}
|
||||
si++;
|
||||
}
|
||||
List<String>? selectedUrls = res.isEmpty
|
||||
? []
|
||||
// ignore: use_build_context_synchronously
|
||||
: await showDialog<List<String>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return UrlSelectionModal(
|
||||
urlsWithDescriptions: res,
|
||||
selectedByDefault: false,
|
||||
onlyOneSelectionAllowed: true,
|
||||
);
|
||||
});
|
||||
if (selectedUrls != null && selectedUrls.isNotEmpty) {
|
||||
changeUserInput(selectedUrls[0], true, false, isSearch: true);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e, context);
|
||||
} finally {
|
||||
setState(() {
|
||||
searching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool shouldShowSearchBar() =>
|
||||
sourceProvider.sources.where((e) => e.canSearch).isNotEmpty &&
|
||||
pickedSource == null &&
|
||||
userInput.isEmpty;
|
||||
|
||||
Widget getSearchBarRow() => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GeneratedForm(
|
||||
items: [
|
||||
[
|
||||
GeneratedFormTextField('searchSomeSources',
|
||||
label: tr('searchSomeSourcesLabel'), required: false),
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (values.isNotEmpty && valid && !isBuilding) {
|
||||
setState(() {
|
||||
searchQuery = values['searchSomeSources']!.trim();
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: searchQuery.isEmpty || doingSomething
|
||||
? null
|
||||
: () {
|
||||
runSearch();
|
||||
},
|
||||
child: Text(tr('search')))
|
||||
],
|
||||
);
|
||||
|
||||
Widget getAdditionalOptsCol() => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Divider(
|
||||
height: 64,
|
||||
),
|
||||
Text(
|
||||
tr('additionalOptsFor',
|
||||
args: [pickedSource?.name ?? tr('source')]),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.primary)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
GeneratedForm(
|
||||
key: Key(pickedSource.runtimeType.toString()),
|
||||
items: pickedSource!.combinedAppSpecificSettingFormItems,
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (!isBuilding) {
|
||||
setState(() {
|
||||
additionalSettings = values;
|
||||
additionalSettingsValid = valid;
|
||||
});
|
||||
}
|
||||
}),
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
CategoryEditorSelector(
|
||||
alignment: WrapAlignment.start,
|
||||
onSelected: (categories) {
|
||||
pickedCategories = categories;
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget getSourcesListWidget() => Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 48,
|
||||
),
|
||||
Text(
|
||||
tr('supportedSourcesBelow'),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
...sourceProvider.sources
|
||||
.map((e) => GestureDetector(
|
||||
onTap: e.host != null
|
||||
? () {
|
||||
launchUrlString('https://${e.host}',
|
||||
mode: LaunchMode.externalApplication);
|
||||
}
|
||||
: null,
|
||||
child: Text(
|
||||
'${e.name}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
|
||||
style: TextStyle(
|
||||
decoration: e.host != null
|
||||
? TextDecoration.underline
|
||||
: TextDecoration.none,
|
||||
fontStyle: FontStyle.italic),
|
||||
)))
|
||||
.toList()
|
||||
]));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
@ -163,227 +377,16 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GeneratedForm(
|
||||
items: [
|
||||
[
|
||||
GeneratedFormTextField('appSourceURL',
|
||||
label: tr('appSourceURL'),
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
try {
|
||||
sourceProvider
|
||||
.getSource(value ?? '')
|
||||
.standardizeURL(
|
||||
preStandardizeUrl(
|
||||
value ?? ''));
|
||||
} catch (e) {
|
||||
return e is String
|
||||
? e
|
||||
: e is ObtainiumError
|
||||
? e.toString()
|
||||
: tr('error');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
])
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
changeUserInput(values['appSourceURL']!,
|
||||
valid, isBuilding);
|
||||
})),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
gettingAppInfo
|
||||
? const CircularProgressIndicator()
|
||||
: ElevatedButton(
|
||||
onPressed: doingSomething ||
|
||||
pickedSource == null ||
|
||||
(pickedSource!
|
||||
.combinedAppSpecificSettingFormItems
|
||||
.isNotEmpty &&
|
||||
!additionalSettingsValid)
|
||||
? null
|
||||
: addApp,
|
||||
child: Text(tr('add')))
|
||||
],
|
||||
),
|
||||
if (sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.isNotEmpty &&
|
||||
pickedSource == null &&
|
||||
userInput.isEmpty)
|
||||
getUrlInputRow(),
|
||||
if (shouldShowSearchBar())
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
if (sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.isNotEmpty &&
|
||||
pickedSource == null &&
|
||||
userInput.isEmpty)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GeneratedForm(
|
||||
items: [
|
||||
[
|
||||
GeneratedFormTextField(
|
||||
'searchSomeSources',
|
||||
label: tr('searchSomeSourcesLabel'),
|
||||
required: false),
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (values.isNotEmpty &&
|
||||
valid &&
|
||||
!isBuilding) {
|
||||
setState(() {
|
||||
searchQuery =
|
||||
values['searchSomeSources']!.trim();
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: searchQuery.isEmpty || doingSomething
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
searching = true;
|
||||
});
|
||||
Future.wait(sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.map((e) =>
|
||||
e.search(searchQuery)))
|
||||
.then((results) async {
|
||||
// Interleave results instead of simple reduce
|
||||
Map<String, String> res = {};
|
||||
var si = 0;
|
||||
var done = false;
|
||||
while (!done) {
|
||||
done = true;
|
||||
for (var r in results) {
|
||||
if (r.length > si) {
|
||||
done = false;
|
||||
res.addEntries(
|
||||
[r.entries.elementAt(si)]);
|
||||
}
|
||||
}
|
||||
si++;
|
||||
}
|
||||
List<String>? selectedUrls = res
|
||||
.isEmpty
|
||||
? []
|
||||
: await showDialog<List<String>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return UrlSelectionModal(
|
||||
urlsWithDescriptions: res,
|
||||
selectedByDefault: false,
|
||||
onlyOneSelectionAllowed:
|
||||
true,
|
||||
);
|
||||
});
|
||||
if (selectedUrls != null &&
|
||||
selectedUrls.isNotEmpty) {
|
||||
changeUserInput(
|
||||
selectedUrls[0], true, false);
|
||||
addApp(resetUserInputAfter: true);
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
searching = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Text(tr('search')))
|
||||
],
|
||||
),
|
||||
if (shouldShowSearchBar()) getSearchBarRow(),
|
||||
if (pickedSource != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Divider(
|
||||
height: 64,
|
||||
),
|
||||
Text(
|
||||
tr('additionalOptsFor',
|
||||
args: [pickedSource?.name ?? tr('source')]),
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
GeneratedForm(
|
||||
items: pickedSource!
|
||||
.combinedAppSpecificSettingFormItems,
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (!isBuilding) {
|
||||
setState(() {
|
||||
additionalSettings = values;
|
||||
additionalSettingsValid = valid;
|
||||
});
|
||||
}
|
||||
}),
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
CategoryEditorSelector(
|
||||
alignment: WrapAlignment.start,
|
||||
onSelected: (categories) {
|
||||
pickedCategories = categories;
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
getAdditionalOptsCol()
|
||||
else
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 48,
|
||||
),
|
||||
Text(
|
||||
tr('supportedSourcesBelow'),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
...sourceProvider.sources
|
||||
.map((e) => GestureDetector(
|
||||
onTap: e.host != null
|
||||
? () {
|
||||
launchUrlString(
|
||||
'https://${e.host}',
|
||||
mode: LaunchMode
|
||||
.externalApplication);
|
||||
}
|
||||
: null,
|
||||
child: Text(
|
||||
'${e.name}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
|
||||
style: TextStyle(
|
||||
decoration: e.host != null
|
||||
? TextDecoration.underline
|
||||
: TextDecoration.none,
|
||||
fontStyle: FontStyle.italic),
|
||||
)))
|
||||
.toList()
|
||||
])),
|
||||
getSourcesListWidget(),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/main.dart';
|
||||
@ -34,382 +35,414 @@ class _AppPageState extends State<AppPage> {
|
||||
});
|
||||
}
|
||||
|
||||
bool areDownloadsRunning = appsProvider.areDownloadsRunning();
|
||||
|
||||
var sourceProvider = SourceProvider();
|
||||
AppInMemory? app = appsProvider.apps[widget.appId];
|
||||
var source = app != null ? sourceProvider.getSource(app.app.url) : null;
|
||||
if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) {
|
||||
if (!areDownloadsRunning && prevApp == null && app != null) {
|
||||
prevApp = app;
|
||||
getUpdate(app.app.id);
|
||||
}
|
||||
var trackOnly = app?.app.additionalSettings['trackOnly'] == true;
|
||||
|
||||
var infoColumn = Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (app?.app.url != null) {
|
||||
launchUrlString(app?.app.url ?? '',
|
||||
mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
app?.app.url ?? '',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12),
|
||||
)),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
Text(
|
||||
tr('latestVersionX', args: [app?.app.latestVersion ?? tr('unknown')]),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
'${tr('installedVersionX', args: [
|
||||
app?.app.installedVersion ?? tr('none')
|
||||
])}${trackOnly ? ' ${tr('estimateInBrackets')}\n\n${tr('xIsTrackOnly', args: [
|
||||
tr('app')
|
||||
])}' : ''}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
Text(
|
||||
tr('lastUpdateCheckX', args: [
|
||||
app?.app.lastUpdateCheck == null
|
||||
? tr('never')
|
||||
: '\n${app?.app.lastUpdateCheck?.toLocal()}'
|
||||
]),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 48,
|
||||
),
|
||||
CategoryEditorSelector(
|
||||
alignment: WrapAlignment.center,
|
||||
preselected:
|
||||
app?.app.categories != null ? app!.app.categories.toSet() : {},
|
||||
onSelected: (categories) {
|
||||
if (app != null) {
|
||||
app.app.categories = categories;
|
||||
appsProvider.saveApps([app.app]);
|
||||
}
|
||||
}),
|
||||
],
|
||||
);
|
||||
bool isVersionDetectionStandard =
|
||||
app?.app.additionalSettings['versionDetection'] ==
|
||||
'standardVersionDetection';
|
||||
|
||||
var fullInfoColumn = Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 150),
|
||||
app?.installedInfo != null
|
||||
? Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Image.memory(
|
||||
app!.installedInfo!.icon!,
|
||||
height: 150,
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
])
|
||||
: Container(),
|
||||
const SizedBox(
|
||||
height: 25,
|
||||
),
|
||||
Text(
|
||||
app?.installedInfo?.name ?? app?.app.name ?? tr('app'),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
Text(
|
||||
tr('byX', args: [app?.app.author ?? tr('unknown')]),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
infoColumn,
|
||||
const SizedBox(height: 150)
|
||||
],
|
||||
);
|
||||
getInfoColumn() => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (app?.app.url != null) {
|
||||
launchUrlString(app?.app.url ?? '',
|
||||
mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
app?.app.url ?? '',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12),
|
||||
)),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
Text(
|
||||
tr('latestVersionX',
|
||||
args: [app?.app.latestVersion ?? tr('unknown')]),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
'${tr('installedVersionX', args: [
|
||||
app?.app.installedVersion ?? tr('none')
|
||||
])}${trackOnly ? ' ${tr('estimateInBrackets')}\n\n${tr('xIsTrackOnly', args: [
|
||||
tr('app')
|
||||
])}' : ''}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
if (app?.app.installedVersion != null &&
|
||||
!isVersionDetectionStandard)
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
Text(
|
||||
tr('noVersionDetection'),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
Text(
|
||||
tr('lastUpdateCheckX', args: [
|
||||
app?.app.lastUpdateCheck == null
|
||||
? tr('never')
|
||||
: '\n${app?.app.lastUpdateCheck?.toLocal()}'
|
||||
]),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 48,
|
||||
),
|
||||
CategoryEditorSelector(
|
||||
alignment: WrapAlignment.center,
|
||||
preselected: app?.app.categories != null
|
||||
? app!.app.categories.toSet()
|
||||
: {},
|
||||
onSelected: (categories) {
|
||||
if (app != null) {
|
||||
app.app.categories = categories;
|
||||
appsProvider.saveApps([app.app]);
|
||||
}
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
getFullInfoColumn() => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 125),
|
||||
app?.installedInfo != null
|
||||
? Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Image.memory(
|
||||
app!.installedInfo!.icon!,
|
||||
height: 150,
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
])
|
||||
: Container(),
|
||||
const SizedBox(
|
||||
height: 25,
|
||||
),
|
||||
Text(
|
||||
app?.installedInfo?.name ?? app?.app.name ?? tr('app'),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
Text(
|
||||
tr('byX', args: [app?.app.author ?? tr('unknown')]),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
Text(
|
||||
app?.app.id ?? '',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
app?.app.releaseDate == null
|
||||
? const SizedBox.shrink()
|
||||
: Text(
|
||||
app!.app.releaseDate.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
getInfoColumn(),
|
||||
const SizedBox(height: 150)
|
||||
],
|
||||
);
|
||||
|
||||
getAppWebView() => app != null
|
||||
? WebViewWidget(
|
||||
controller: WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(Theme.of(context).colorScheme.background)
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onWebResourceError: (WebResourceError error) {
|
||||
if (error.isForMainFrame == true) {
|
||||
showError(
|
||||
ObtainiumError(error.description, unexpected: true),
|
||||
context);
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse(app.app.url)))
|
||||
: Container();
|
||||
|
||||
showMarkUpdatedDialog() {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(tr('alreadyUpToDateQuestion')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(tr('no'))),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.selectionClick();
|
||||
var updatedApp = app?.app;
|
||||
if (updatedApp != null) {
|
||||
updatedApp.installedVersion = updatedApp.latestVersion;
|
||||
appsProvider.saveApps([updatedApp]);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(tr('yesMarkUpdated')))
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
showAdditionalOptionsDialog() async {
|
||||
return await showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
var items =
|
||||
(source?.combinedAppSpecificSettingFormItems ?? []).map((row) {
|
||||
row = row.map((e) {
|
||||
if (app?.app.additionalSettings[e.key] != null) {
|
||||
e.defaultValue = app?.app.additionalSettings[e.key];
|
||||
}
|
||||
return e;
|
||||
}).toList();
|
||||
return row;
|
||||
}).toList();
|
||||
|
||||
items = items.map((row) {
|
||||
row = row.map((e) {
|
||||
if (e.key == 'versionDetection' && e is GeneratedFormDropdown) {
|
||||
e.disabledOptKeys ??= [];
|
||||
if (app?.app.installedVersion != null &&
|
||||
app?.app.additionalSettings['versionDetection'] !=
|
||||
'releaseDateAsVersion' &&
|
||||
!appsProvider.isVersionDetectionPossible(app)) {
|
||||
e.disabledOptKeys!.add('standardVersionDetection');
|
||||
}
|
||||
if (app?.app.releaseDate == null) {
|
||||
e.disabledOptKeys!.add('releaseDateAsVersion');
|
||||
}
|
||||
}
|
||||
return e;
|
||||
}).toList();
|
||||
return row;
|
||||
}).toList();
|
||||
|
||||
return GeneratedFormModal(
|
||||
title: tr('additionalOptions'),
|
||||
items: items,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
handleAdditionalOptionChanges(Map<String, dynamic>? values) {
|
||||
if (app != null && values != null) {
|
||||
Map<String, dynamic> originalSettings = app.app.additionalSettings;
|
||||
app.app.additionalSettings = values;
|
||||
if (source?.enforceTrackOnly == true) {
|
||||
app.app.additionalSettings['trackOnly'] = true;
|
||||
// ignore: use_build_context_synchronously
|
||||
showError(tr('appsFromSourceAreTrackOnly'), context);
|
||||
}
|
||||
if (app.app.additionalSettings['versionDetection'] ==
|
||||
'releaseDateAsVersion') {
|
||||
if (originalSettings['versionDetection'] != 'releaseDateAsVersion') {
|
||||
if (app.app.releaseDate != null) {
|
||||
bool isUpdated =
|
||||
app.app.installedVersion == app.app.latestVersion;
|
||||
app.app.latestVersion =
|
||||
app.app.releaseDate!.microsecondsSinceEpoch.toString();
|
||||
if (isUpdated) {
|
||||
app.app.installedVersion = app.app.latestVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (originalSettings['versionDetection'] ==
|
||||
'releaseDateAsVersion') {
|
||||
app.app.installedVersion =
|
||||
app.installedInfo?.versionName ?? app.app.installedVersion;
|
||||
}
|
||||
appsProvider.saveApps([app.app]).then((value) {
|
||||
getUpdate(app.app.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getInstallOrUpdateButton() => TextButton(
|
||||
onPressed: (app?.app.installedVersion == null ||
|
||||
app?.app.installedVersion != app?.app.latestVersion) &&
|
||||
!areDownloadsRunning
|
||||
? () async {
|
||||
try {
|
||||
HapticFeedback.heavyImpact();
|
||||
if (app?.app.additionalSettings['trackOnly'] != true) {
|
||||
await settingsProvider.getInstallPermission();
|
||||
}
|
||||
var res = await appsProvider.downloadAndInstallLatestApps(
|
||||
[app!.app.id], globalNavigatorKey.currentContext);
|
||||
if (res.isNotEmpty && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e, context);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: Text(app?.app.installedVersion == null
|
||||
? !trackOnly
|
||||
? tr('install')
|
||||
: tr('markInstalled')
|
||||
: !trackOnly
|
||||
? tr('update')
|
||||
: tr('markUpdated')));
|
||||
|
||||
getBottomSheetMenu() => Padding(
|
||||
padding:
|
||||
EdgeInsets.fromLTRB(0, 0, 0, MediaQuery.of(context).padding.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (app?.app.installedVersion != null &&
|
||||
app?.app.installedVersion != app?.app.latestVersion &&
|
||||
!isVersionDetectionStandard &&
|
||||
!trackOnly)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: showMarkUpdatedDialog,
|
||||
tooltip: tr('markUpdated'),
|
||||
icon: const Icon(Icons.done)),
|
||||
if (source != null &&
|
||||
source.combinedAppSpecificSettingFormItems.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: () async {
|
||||
var values =
|
||||
await showAdditionalOptionsDialog();
|
||||
handleAdditionalOptionChanges(values);
|
||||
},
|
||||
tooltip: tr('additionalOptions'),
|
||||
icon: const Icon(Icons.edit)),
|
||||
if (app != null && app.installedInfo != null)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
appsProvider.openAppSettings(app.app.id);
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
tooltip: tr('settings'),
|
||||
),
|
||||
if (app != null && settingsProvider.showAppWebpage)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
content: getInfoColumn(),
|
||||
title: Text(
|
||||
'${app.app.name} ${tr('byX', args: [
|
||||
app.app.author
|
||||
])}'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(tr('continue')))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
tooltip: tr('more')),
|
||||
const SizedBox(width: 16.0),
|
||||
Expanded(child: getInstallOrUpdateButton()),
|
||||
const SizedBox(width: 16.0),
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: () {
|
||||
appsProvider.removeAppsWithModal(
|
||||
context, [app!.app]).then((value) {
|
||||
if (value == true) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.error,
|
||||
surfaceTintColor:
|
||||
Theme.of(context).colorScheme.error),
|
||||
child: Text(tr('remove')),
|
||||
)),
|
||||
])),
|
||||
if (app?.downloadProgress != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
|
||||
child: LinearProgressIndicator(
|
||||
value: app!.downloadProgress! / 100))
|
||||
],
|
||||
));
|
||||
|
||||
return Scaffold(
|
||||
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: RefreshIndicator(
|
||||
child: settingsProvider.showAppWebpage
|
||||
? app != null
|
||||
? WebViewWidget(
|
||||
controller: WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(
|
||||
Theme.of(context).colorScheme.background)
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onWebResourceError: (WebResourceError error) {
|
||||
if (error.isForMainFrame == true) {
|
||||
showError(
|
||||
ObtainiumError(error.description,
|
||||
unexpected: true),
|
||||
context);
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse(app.app.url)))
|
||||
: Container()
|
||||
: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(children: [fullInfoColumn])),
|
||||
],
|
||||
),
|
||||
onRefresh: () async {
|
||||
if (app != null) {
|
||||
getUpdate(app.app.id);
|
||||
}
|
||||
}),
|
||||
bottomSheet: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
0, 0, 0, MediaQuery.of(context).padding.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (app?.app.installedVersion != null &&
|
||||
!trackOnly &&
|
||||
app?.app.installedVersion != app?.app.latestVersion)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(tr(
|
||||
'alreadyUpToDateQuestion')),
|
||||
content: Text(
|
||||
tr('onlyWorksWithNonEVDApps'),
|
||||
style: const TextStyle(
|
||||
fontWeight:
|
||||
FontWeight.bold,
|
||||
fontStyle:
|
||||
FontStyle.italic)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: Text(tr('no'))),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
var updatedApp = app?.app;
|
||||
if (updatedApp != null) {
|
||||
updatedApp
|
||||
.installedVersion =
|
||||
updatedApp
|
||||
.latestVersion;
|
||||
appsProvider.saveApps(
|
||||
[updatedApp]);
|
||||
}
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: Text(
|
||||
tr('yesMarkUpdated')))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
tooltip: tr('markUpdated'),
|
||||
icon: const Icon(Icons.done)),
|
||||
if (source != null &&
|
||||
source
|
||||
.combinedAppSpecificSettingFormItems.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: () {
|
||||
showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
var items = source
|
||||
.combinedAppSpecificSettingFormItems
|
||||
.map((row) {
|
||||
row.map((e) {
|
||||
if (app?.app.additionalSettings[
|
||||
e.key] !=
|
||||
null) {
|
||||
e.defaultValue = app?.app
|
||||
.additionalSettings[
|
||||
e.key];
|
||||
}
|
||||
return e;
|
||||
}).toList();
|
||||
return row;
|
||||
}).toList();
|
||||
return GeneratedFormModal(
|
||||
title: tr('additionalOptions'),
|
||||
items: items);
|
||||
}).then((values) {
|
||||
if (app != null && values != null) {
|
||||
var changedApp = app.app;
|
||||
changedApp.additionalSettings =
|
||||
values;
|
||||
if (source.enforceTrackOnly) {
|
||||
changedApp.additionalSettings[
|
||||
'trackOnly'] = true;
|
||||
showError(
|
||||
tr('appsFromSourceAreTrackOnly'),
|
||||
context);
|
||||
}
|
||||
appsProvider.saveApps(
|
||||
[changedApp]).then((value) {
|
||||
getUpdate(changedApp.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
tooltip: tr('additionalOptions'),
|
||||
icon: const Icon(Icons.settings)),
|
||||
if (app != null && settingsProvider.showAppWebpage)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
content: infoColumn,
|
||||
title: Text(
|
||||
'${app.app.name} ${tr('byX', args: [
|
||||
app.app.author
|
||||
])}'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(tr('continue')))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
tooltip: tr('more')),
|
||||
const SizedBox(width: 16.0),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: (app?.app.installedVersion == null ||
|
||||
app?.app.installedVersion !=
|
||||
app?.app.latestVersion) &&
|
||||
!appsProvider.areDownloadsRunning()
|
||||
? () {
|
||||
HapticFeedback.heavyImpact();
|
||||
() async {
|
||||
if (app?.app.additionalSettings[
|
||||
'trackOnly'] !=
|
||||
true) {
|
||||
await settingsProvider
|
||||
.getInstallPermission();
|
||||
}
|
||||
}()
|
||||
.then((value) {
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApps(
|
||||
[app!.app.id],
|
||||
globalNavigatorKey
|
||||
.currentContext).then(
|
||||
(res) {
|
||||
if (res.isNotEmpty && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
}
|
||||
: null,
|
||||
child: Text(app?.app.installedVersion == null
|
||||
? !trackOnly
|
||||
? tr('install')
|
||||
: tr('markInstalled')
|
||||
: !trackOnly
|
||||
? tr('update')
|
||||
: tr('markUpdated')))),
|
||||
const SizedBox(width: 16.0),
|
||||
ElevatedButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(tr('removeAppQuestion')),
|
||||
content: Text(tr(
|
||||
'xWillBeRemovedButRemainInstalled',
|
||||
args: [
|
||||
app?.installedInfo?.name ??
|
||||
app?.app.name ??
|
||||
tr('app')
|
||||
])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
appsProvider.removeApps(
|
||||
[app!.app.id]).then((_) {
|
||||
int count = 0;
|
||||
Navigator.of(context)
|
||||
.popUntil((_) =>
|
||||
count++ >= 2);
|
||||
});
|
||||
},
|
||||
child: Text(tr('remove'))),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(tr('cancel')))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.error,
|
||||
surfaceTintColor:
|
||||
Theme.of(context).colorScheme.error),
|
||||
child: Text(tr('remove')),
|
||||
),
|
||||
])),
|
||||
if (app?.downloadProgress != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
|
||||
child: LinearProgressIndicator(
|
||||
value: app!.downloadProgress! / 100))
|
||||
],
|
||||
)),
|
||||
);
|
||||
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: RefreshIndicator(
|
||||
child: settingsProvider.showAppWebpage
|
||||
? getAppWebView()
|
||||
: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(children: [getFullInfoColumn()])),
|
||||
],
|
||||
),
|
||||
onRefresh: () async {
|
||||
if (app != null) {
|
||||
getUpdate(app.app.id);
|
||||
}
|
||||
}),
|
||||
bottomSheet: getBottomSheetMenu());
|
||||
}
|
||||
}
|
||||
|
1535
lib/pages/apps.dart
@ -63,21 +63,29 @@ class _HomePageState extends State<HomePage> {
|
||||
.map((e) =>
|
||||
NavigationDestination(icon: Icon(e.icon), label: e.title))
|
||||
.toList(),
|
||||
onDestinationSelected: (int index) {
|
||||
onDestinationSelected: (int index) async {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
if (index == 0) {
|
||||
if (index == 0) {
|
||||
while ((pages[0].widget.key as GlobalKey<AppsPageState>)
|
||||
.currentState !=
|
||||
null) {
|
||||
// Avoid duplicate GlobalKey error
|
||||
await Future.delayed(const Duration(microseconds: 1));
|
||||
}
|
||||
setState(() {
|
||||
selectedIndexHistory.clear();
|
||||
} else if (selectedIndexHistory.isEmpty ||
|
||||
(selectedIndexHistory.isNotEmpty &&
|
||||
selectedIndexHistory.last != index)) {
|
||||
});
|
||||
} else if (selectedIndexHistory.isEmpty ||
|
||||
(selectedIndexHistory.isNotEmpty &&
|
||||
selectedIndexHistory.last != index)) {
|
||||
setState(() {
|
||||
int existingInd = selectedIndexHistory.indexOf(index);
|
||||
if (existingInd >= 0) {
|
||||
selectedIndexHistory.removeAt(existingInd);
|
||||
}
|
||||
selectedIndexHistory.add(index);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
selectedIndex:
|
||||
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,
|
||||
|
@ -30,6 +30,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
var appsProvider = context.read<AppsProvider>();
|
||||
var settingsProvider = context.read<SettingsProvider>();
|
||||
|
||||
var outlineButtonStyle = ButtonStyle(
|
||||
shape: MaterialStateProperty.all(
|
||||
StadiumBorder(
|
||||
@ -41,6 +42,253 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
),
|
||||
);
|
||||
|
||||
urlListImport({String? initValue, bool overrideInitValid = false}) {
|
||||
showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
initValid: overrideInitValid,
|
||||
title: tr('importFromURLList'),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormTextField('appURLList',
|
||||
defaultValue: initValue ?? '',
|
||||
label: tr('appURLList'),
|
||||
max: 7,
|
||||
additionalValidators: [
|
||||
(dynamic value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
var lines = value.trim().split('\n');
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
try {
|
||||
sourceProvider.getSource(lines[i]);
|
||||
} catch (e) {
|
||||
return '${tr('line')} ${i + 1}: $e';
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
])
|
||||
]
|
||||
],
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
var urls = (values['appURLList'] as String).split('\n');
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
appsProvider.addAppsByURL(urls).then((errors) {
|
||||
if (errors.isEmpty) {
|
||||
showError(tr('importedX', args: [plural('apps', urls.length)]),
|
||||
context);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength: urls.length, errors: errors);
|
||||
});
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runObtainiumExport() {
|
||||
HapticFeedback.selectionClick();
|
||||
appsProvider.exportApps().then((String path) {
|
||||
showError(tr('exportedTo', args: [path]), context);
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
}
|
||||
|
||||
runObtainiumImport() {
|
||||
HapticFeedback.selectionClick();
|
||||
FilePicker.platform.pickFiles().then((result) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
if (result != null) {
|
||||
String data = File(result.files.single.path!).readAsStringSync();
|
||||
try {
|
||||
jsonDecode(data);
|
||||
} catch (e) {
|
||||
throw ObtainiumError(tr('invalidInput'));
|
||||
}
|
||||
appsProvider.importApps(data).then((value) {
|
||||
var cats = settingsProvider.categories;
|
||||
appsProvider.apps.forEach((key, value) {
|
||||
for (var c in value.app.categories) {
|
||||
if (!cats.containsKey(c)) {
|
||||
cats[c] = generateRandomLightColor().value;
|
||||
}
|
||||
}
|
||||
});
|
||||
appsProvider.addMissingCategories(settingsProvider);
|
||||
showError(tr('importedX', args: [plural('apps', value)]), context);
|
||||
});
|
||||
} else {
|
||||
// User canceled the picker
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
runUrlImport() {
|
||||
FilePicker.platform.pickFiles().then((result) {
|
||||
if (result != null) {
|
||||
urlListImport(
|
||||
overrideInitValid: true,
|
||||
initValue: RegExp('https?://[^"]+')
|
||||
.allMatches(
|
||||
File(result.files.single.path!).readAsStringSync())
|
||||
.map((e) => e.input.substring(e.start, e.end))
|
||||
.toSet()
|
||||
.toList()
|
||||
.where((url) {
|
||||
try {
|
||||
sourceProvider.getSource(url);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}).join('\n'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runSourceSearch(AppSource source) {
|
||||
() async {
|
||||
var values = await showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('searchX', args: [source.name]),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormTextField('searchQuery',
|
||||
label: tr('searchQuery'))
|
||||
]
|
||||
],
|
||||
);
|
||||
});
|
||||
if (values != null &&
|
||||
(values['searchQuery'] as String?)?.isNotEmpty == true) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
var urlsWithDescriptions =
|
||||
await source.search(values['searchQuery'] as String);
|
||||
if (urlsWithDescriptions.isNotEmpty) {
|
||||
var selectedUrls =
|
||||
// ignore: use_build_context_synchronously
|
||||
await showDialog<List<String>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return UrlSelectionModal(
|
||||
urlsWithDescriptions: urlsWithDescriptions,
|
||||
selectedByDefault: false,
|
||||
);
|
||||
});
|
||||
if (selectedUrls != null && selectedUrls.isNotEmpty) {
|
||||
var errors = await appsProvider.addAppsByURL(selectedUrls);
|
||||
if (errors.isEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showError(
|
||||
tr('importedX', args: [plural('app', selectedUrls.length)]),
|
||||
context);
|
||||
} else {
|
||||
// ignore: use_build_context_synchronously
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength: selectedUrls.length, errors: errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw ObtainiumError(tr('noResults'));
|
||||
}
|
||||
}
|
||||
}()
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
runMassSourceImport(MassAppUrlSource source) {
|
||||
() async {
|
||||
var values = await showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('importX', args: [source.name]),
|
||||
items: source.requiredArgs
|
||||
.map((e) => [GeneratedFormTextField(e, label: e)])
|
||||
.toList(),
|
||||
);
|
||||
});
|
||||
if (values != null) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
var urlsWithDescriptions = await source.getUrlsWithDescriptions(
|
||||
values.values.map((e) => e.toString()).toList());
|
||||
var selectedUrls =
|
||||
// ignore: use_build_context_synchronously
|
||||
await showDialog<List<String>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return UrlSelectionModal(
|
||||
urlsWithDescriptions: urlsWithDescriptions);
|
||||
});
|
||||
if (selectedUrls != null) {
|
||||
var errors = await appsProvider.addAppsByURL(selectedUrls);
|
||||
if (errors.isEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showError(
|
||||
tr('importedX', args: [plural('app', selectedUrls.length)]),
|
||||
context);
|
||||
} else {
|
||||
// ignore: use_build_context_synchronously
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength: selectedUrls.length, errors: errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
@ -60,18 +308,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
onPressed: appsProvider.apps.isEmpty ||
|
||||
importInProgress
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.selectionClick();
|
||||
appsProvider
|
||||
.exportApps()
|
||||
.then((String path) {
|
||||
showError(
|
||||
tr('exportedTo', args: [path]),
|
||||
context);
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
},
|
||||
: runObtainiumExport,
|
||||
child: Text(tr('obtainiumExport')))),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
@ -81,59 +318,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
style: outlineButtonStyle,
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.selectionClick();
|
||||
FilePicker.platform
|
||||
.pickFiles()
|
||||
.then((result) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
if (result != null) {
|
||||
String data = File(
|
||||
result.files.single.path!)
|
||||
.readAsStringSync();
|
||||
try {
|
||||
jsonDecode(data);
|
||||
} catch (e) {
|
||||
throw ObtainiumError(
|
||||
tr('invalidInput'));
|
||||
}
|
||||
appsProvider
|
||||
.importApps(data)
|
||||
.then((value) {
|
||||
var cats =
|
||||
settingsProvider.categories;
|
||||
appsProvider.apps
|
||||
.forEach((key, value) {
|
||||
for (var c
|
||||
in value.app.categories) {
|
||||
if (!cats.containsKey(c)) {
|
||||
cats[c] =
|
||||
generateRandomLightColor()
|
||||
.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
settingsProvider.categories =
|
||||
cats;
|
||||
showError(
|
||||
tr('importedX', args: [
|
||||
plural('apps', value)
|
||||
]),
|
||||
context);
|
||||
});
|
||||
} else {
|
||||
// User canceled the picker
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
: runObtainiumImport,
|
||||
child: Text(tr('obtainiumImport'))))
|
||||
],
|
||||
),
|
||||
@ -150,88 +335,26 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
],
|
||||
)
|
||||
else
|
||||
const Divider(
|
||||
height: 32,
|
||||
Column(
|
||||
children: [
|
||||
const Divider(
|
||||
height: 32,
|
||||
),
|
||||
TextButton(
|
||||
onPressed:
|
||||
importInProgress ? null : urlListImport,
|
||||
child: Text(
|
||||
tr('importFromURLList'),
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed:
|
||||
importInProgress ? null : runUrlImport,
|
||||
child: Text(
|
||||
tr('importFromURLsInFile'),
|
||||
)),
|
||||
],
|
||||
),
|
||||
TextButton(
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('importFromURLList'),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormTextField(
|
||||
'appURLList',
|
||||
label: tr('appURLList'),
|
||||
max: 7,
|
||||
additionalValidators: [
|
||||
(dynamic value) {
|
||||
if (value != null &&
|
||||
value.isNotEmpty) {
|
||||
var lines = value
|
||||
.trim()
|
||||
.split('\n');
|
||||
for (int i = 0;
|
||||
i < lines.length;
|
||||
i++) {
|
||||
try {
|
||||
sourceProvider
|
||||
.getSource(
|
||||
lines[i]);
|
||||
} catch (e) {
|
||||
return '${tr('line')} ${i + 1}: $e';
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
])
|
||||
]
|
||||
],
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
var urls =
|
||||
(values['appURLList'] as String)
|
||||
.split('\n');
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
appsProvider
|
||||
.addAppsByURL(urls)
|
||||
.then((errors) {
|
||||
if (errors.isEmpty) {
|
||||
showError(
|
||||
tr('importedX', args: [
|
||||
plural('apps', urls.length)
|
||||
]),
|
||||
context);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength: urls.length,
|
||||
errors: errors);
|
||||
});
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
tr('importFromURLList'),
|
||||
)),
|
||||
...sourceProvider.sources
|
||||
.where((element) => element.canSearch)
|
||||
.map((source) => Column(
|
||||
@ -243,104 +366,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
() async {
|
||||
var values = await showDialog<
|
||||
Map<String,
|
||||
dynamic>?>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('searchX',
|
||||
args: [
|
||||
source.name
|
||||
]),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormTextField(
|
||||
'searchQuery',
|
||||
label: tr(
|
||||
'searchQuery'))
|
||||
]
|
||||
],
|
||||
);
|
||||
});
|
||||
if (values != null &&
|
||||
(values['searchQuery']
|
||||
as String?)
|
||||
?.isNotEmpty ==
|
||||
true) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
var urlsWithDescriptions =
|
||||
await source.search(
|
||||
values['searchQuery']
|
||||
as String);
|
||||
if (urlsWithDescriptions
|
||||
.isNotEmpty) {
|
||||
var selectedUrls =
|
||||
await showDialog<
|
||||
List<
|
||||
String>?>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return UrlSelectionModal(
|
||||
urlsWithDescriptions:
|
||||
urlsWithDescriptions,
|
||||
selectedByDefault:
|
||||
false,
|
||||
);
|
||||
});
|
||||
if (selectedUrls !=
|
||||
null &&
|
||||
selectedUrls
|
||||
.isNotEmpty) {
|
||||
var errors =
|
||||
await appsProvider
|
||||
.addAppsByURL(
|
||||
selectedUrls);
|
||||
if (errors.isEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showError(
|
||||
tr('importedX',
|
||||
args: [
|
||||
plural(
|
||||
'app',
|
||||
selectedUrls
|
||||
.length)
|
||||
]),
|
||||
context);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength:
|
||||
selectedUrls
|
||||
.length,
|
||||
errors:
|
||||
errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw ObtainiumError(
|
||||
tr('noResults'));
|
||||
}
|
||||
}
|
||||
}()
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
runSourceSearch(source);
|
||||
},
|
||||
child: Text(
|
||||
tr('searchX', args: [source.name])))
|
||||
@ -356,91 +382,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
() async {
|
||||
var values = await showDialog<
|
||||
Map<String,
|
||||
dynamic>?>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('importX',
|
||||
args: [
|
||||
source.name
|
||||
]),
|
||||
items:
|
||||
source
|
||||
.requiredArgs
|
||||
.map(
|
||||
(e) => [
|
||||
GeneratedFormTextField(e,
|
||||
label: e)
|
||||
])
|
||||
.toList(),
|
||||
);
|
||||
});
|
||||
if (values != null) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
var urlsWithDescriptions =
|
||||
await source
|
||||
.getUrlsWithDescriptions(
|
||||
values.values
|
||||
.map((e) =>
|
||||
e.toString())
|
||||
.toList());
|
||||
var selectedUrls =
|
||||
await showDialog<
|
||||
List<String>?>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return UrlSelectionModal(
|
||||
urlsWithDescriptions:
|
||||
urlsWithDescriptions);
|
||||
});
|
||||
if (selectedUrls != null) {
|
||||
var errors =
|
||||
await appsProvider
|
||||
.addAppsByURL(
|
||||
selectedUrls);
|
||||
if (errors.isEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showError(
|
||||
tr('importedX',
|
||||
args: [
|
||||
plural(
|
||||
'app',
|
||||
selectedUrls
|
||||
.length)
|
||||
]),
|
||||
context);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength:
|
||||
selectedUrls
|
||||
.length,
|
||||
errors:
|
||||
errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
runMassSourceImport(source);
|
||||
},
|
||||
child: Text(
|
||||
tr('importX', args: [source.name])))
|
||||
@ -564,18 +506,22 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')),
|
||||
content: Column(children: [
|
||||
...urlWithDescriptionSelections.keys.map((urlWithD) {
|
||||
select(bool? value) {
|
||||
setState(() {
|
||||
value ??= false;
|
||||
if (value! && widget.onlyOneSelectionAllowed) {
|
||||
selectOnlyOne(urlWithD.key);
|
||||
} else {
|
||||
urlWithDescriptionSelections[urlWithD] = value!;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Row(children: [
|
||||
Checkbox(
|
||||
value: urlWithDescriptionSelections[urlWithD],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
value ??= false;
|
||||
if (value! && widget.onlyOneSelectionAllowed) {
|
||||
selectOnlyOne(urlWithD.key);
|
||||
} else {
|
||||
urlWithDescriptionSelections[urlWithD] = value!;
|
||||
}
|
||||
});
|
||||
select(value);
|
||||
}),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
@ -599,12 +545,17 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
const TextStyle(decoration: TextDecoration.underline),
|
||||
textAlign: TextAlign.start,
|
||||
)),
|
||||
Text(
|
||||
urlWithD.value.length > 128
|
||||
? '${urlWithD.value.substring(0, 128)}...'
|
||||
: urlWithD.value,
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic, fontSize: 12),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
select(!(urlWithDescriptionSelections[urlWithD] ?? false));
|
||||
},
|
||||
child: Text(
|
||||
urlWithD.value.length > 128
|
||||
? '${urlWithD.value.substring(0, 128)}...'
|
||||
: urlWithD.value,
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
|
@ -4,7 +4,6 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/main.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
@ -89,6 +88,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
});
|
||||
|
||||
var sortDropdown = DropdownButtonFormField(
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(labelText: tr('appSortBy')),
|
||||
value: settingsProvider.sortColumn,
|
||||
items: [
|
||||
@ -103,6 +103,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
DropdownMenuItem(
|
||||
value: SortColumnSettings.added,
|
||||
child: Text(tr('asAdded')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SortColumnSettings.releaseDate,
|
||||
child: Text(tr('releaseDate')),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
@ -112,6 +116,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
});
|
||||
|
||||
var orderDropdown = DropdownButtonFormField(
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(labelText: tr('appSortOrder')),
|
||||
value: settingsProvider.sortOrder,
|
||||
items: [
|
||||
@ -148,7 +153,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
if (value != null) {
|
||||
context.setLocale(Locale(value));
|
||||
} else {
|
||||
context.resetLocale();
|
||||
settingsProvider.resetLocaleSafe(context);
|
||||
}
|
||||
});
|
||||
|
||||
@ -258,6 +263,18 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
})
|
||||
],
|
||||
),
|
||||
height16,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(tr('groupByCategory')),
|
||||
Switch(
|
||||
value: settingsProvider.groupByCategory,
|
||||
onChanged: (value) {
|
||||
settingsProvider.groupByCategory = value;
|
||||
})
|
||||
],
|
||||
),
|
||||
const Divider(
|
||||
height: 16,
|
||||
),
|
||||
@ -428,6 +445,7 @@ class _CategoryEditorSelectorState extends State<CategoryEditorSelector> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var settingsProvider = context.watch<SettingsProvider>();
|
||||
var appsProvider = context.watch<AppsProvider>();
|
||||
storedValues = settingsProvider.categories.map((key, value) => MapEntry(
|
||||
key,
|
||||
MapEntry(value,
|
||||
@ -451,8 +469,9 @@ class _CategoryEditorSelectorState extends State<CategoryEditorSelector> {
|
||||
if (!isBuilding) {
|
||||
storedValues =
|
||||
values['categories'] as Map<String, MapEntry<int, bool>>;
|
||||
settingsProvider.categories =
|
||||
storedValues.map((key, value) => MapEntry(key, value.key));
|
||||
settingsProvider.setCategories(
|
||||
storedValues.map((key, value) => MapEntry(key, value.key)),
|
||||
appsProvider: appsProvider);
|
||||
if (widget.onSelected != null) {
|
||||
widget.onSelected!(storedValues.keys
|
||||
.where((k) => storedValues[k]!.value)
|
||||
|
@ -5,6 +5,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:android_intent_plus/flag.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -12,6 +13,8 @@ import 'package:flutter/services.dart';
|
||||
import 'package:install_plugin_v2/install_plugin_v2.dart';
|
||||
import 'package:installed_apps/app_info.dart';
|
||||
import 'package:installed_apps/installed_apps.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/logs_provider.dart';
|
||||
import 'package:obtainium/providers/notifications_provider.dart';
|
||||
@ -23,6 +26,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:flutter_fgbg/flutter_fgbg.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:android_intent_plus/android_intent.dart';
|
||||
|
||||
class AppInMemory {
|
||||
late App app;
|
||||
@ -69,6 +73,18 @@ List<String> generateStandardVersionRegExStrings() {
|
||||
List<String> standardVersionRegExStrings =
|
||||
generateStandardVersionRegExStrings();
|
||||
|
||||
Set<String> findStandardFormatsForVersion(String version, bool strict) {
|
||||
// If !strict, even a substring match is valid
|
||||
Set<String> results = {};
|
||||
for (var pattern in standardVersionRegExStrings) {
|
||||
if (RegExp('${strict ? '^' : ''}$pattern${strict ? '\$' : ''}')
|
||||
.hasMatch(version)) {
|
||||
results.add(pattern);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
class AppsProvider with ChangeNotifier {
|
||||
// In memory App state (should always be kept in sync with local storage versions)
|
||||
Map<String, AppInMemory> apps = {};
|
||||
@ -141,56 +157,68 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<DownloadedApk> downloadApp(App app, BuildContext? context) async {
|
||||
var fileName =
|
||||
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
|
||||
String downloadUrl = await SourceProvider()
|
||||
.getSource(app.url)
|
||||
.apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
|
||||
NotificationsProvider? notificationsProvider =
|
||||
context?.read<NotificationsProvider>();
|
||||
var notif = DownloadNotification(app.name, 100);
|
||||
notificationsProvider?.cancel(notif.id);
|
||||
int? prevProg;
|
||||
File downloadedFile =
|
||||
await downloadFile(downloadUrl, fileName, (double? progress) {
|
||||
int? prog = progress?.ceil();
|
||||
var notifId = DownloadNotification(app.name, 0).id;
|
||||
if (apps[app.id] != null) {
|
||||
apps[app.id]!.downloadProgress = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
try {
|
||||
var fileName =
|
||||
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
|
||||
String downloadUrl = await SourceProvider()
|
||||
.getSource(app.url)
|
||||
.apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
|
||||
var notif = DownloadNotification(app.name, 100);
|
||||
notificationsProvider?.cancel(notif.id);
|
||||
int? prevProg;
|
||||
File downloadedFile =
|
||||
await downloadFile(downloadUrl, fileName, (double? progress) {
|
||||
int? prog = progress?.ceil();
|
||||
if (apps[app.id] != null) {
|
||||
apps[app.id]!.downloadProgress = progress;
|
||||
notifyListeners();
|
||||
}
|
||||
notif = DownloadNotification(app.name, prog ?? 100);
|
||||
if (prog != null && prevProg != prog) {
|
||||
notificationsProvider?.notify(notif);
|
||||
}
|
||||
prevProg = prog;
|
||||
});
|
||||
// Delete older versions of the APK if any
|
||||
for (var file in downloadedFile.parent.listSync()) {
|
||||
var fn = file.path.split('/').last;
|
||||
if (fn.startsWith('${app.id}-') &&
|
||||
fn.endsWith('.apk') &&
|
||||
fn != fileName) {
|
||||
file.delete();
|
||||
}
|
||||
}
|
||||
// If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
|
||||
// The former case should be handled (give the App its real ID), the latter is a security issue
|
||||
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
|
||||
if (app.id != newInfo.packageName) {
|
||||
if (apps[app.id] != null && !SourceProvider().isTempId(app)) {
|
||||
throw IDChangedError();
|
||||
}
|
||||
var originalAppId = app.id;
|
||||
app.id = newInfo.packageName;
|
||||
downloadedFile = downloadedFile.renameSync(
|
||||
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
|
||||
if (apps[originalAppId] != null) {
|
||||
await removeApps([originalAppId]);
|
||||
await saveApps([app]);
|
||||
}
|
||||
}
|
||||
return DownloadedApk(app.id, downloadedFile);
|
||||
} finally {
|
||||
notificationsProvider?.cancel(notifId);
|
||||
if (apps[app.id] != null) {
|
||||
apps[app.id]!.downloadProgress = progress;
|
||||
apps[app.id]!.downloadProgress = null;
|
||||
notifyListeners();
|
||||
}
|
||||
notif = DownloadNotification(app.name, prog ?? 100);
|
||||
if (prog != null && prevProg != prog) {
|
||||
notificationsProvider?.notify(notif);
|
||||
}
|
||||
prevProg = prog;
|
||||
});
|
||||
notificationsProvider?.cancel(notif.id);
|
||||
// Delete older versions of the APK if any
|
||||
for (var file in downloadedFile.parent.listSync()) {
|
||||
var fn = file.path.split('/').last;
|
||||
if (fn.startsWith('${app.id}-') &&
|
||||
fn.endsWith('.apk') &&
|
||||
fn != fileName) {
|
||||
file.delete();
|
||||
}
|
||||
}
|
||||
// If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
|
||||
// The former case should be handled (give the App its real ID), the latter is a security issue
|
||||
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
|
||||
if (app.id != newInfo.packageName) {
|
||||
if (apps[app.id] != null && !SourceProvider().isTempId(app.id)) {
|
||||
throw IDChangedError();
|
||||
}
|
||||
var originalAppId = app.id;
|
||||
app.id = newInfo.packageName;
|
||||
downloadedFile = downloadedFile.renameSync(
|
||||
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
|
||||
if (apps[originalAppId] != null) {
|
||||
await removeApps([originalAppId]);
|
||||
await saveApps([app]);
|
||||
}
|
||||
}
|
||||
return DownloadedApk(app.id, downloadedFile);
|
||||
}
|
||||
|
||||
bool areDownloadsRunning() => apps.values
|
||||
@ -247,7 +275,11 @@ class AppsProvider with ChangeNotifier {
|
||||
!(await canDowngradeApps())) {
|
||||
throw DowngradeError();
|
||||
}
|
||||
await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium');
|
||||
await InstallPlugin.installApk(file.file.path, obtainiumId);
|
||||
if (file.appId == obtainiumId) {
|
||||
// Obtainium prompt should be lowest
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
apps[file.appId]!.app.installedVersion =
|
||||
apps[file.appId]!.app.latestVersion;
|
||||
// Don't correct install status as installation may not be done yet
|
||||
@ -255,6 +287,15 @@ class AppsProvider with ChangeNotifier {
|
||||
attemptToCorrectInstallStatus: false);
|
||||
}
|
||||
|
||||
void uninstallApp(String appId) async {
|
||||
var intent = AndroidIntent(
|
||||
action: 'android.intent.action.DELETE',
|
||||
data: 'package:$appId',
|
||||
flags: <int>[Flag.FLAG_ACTIVITY_NEW_TASK],
|
||||
package: 'vnd.android.package-archive');
|
||||
await intent.launch();
|
||||
}
|
||||
|
||||
Future<String?> confirmApkUrl(App app, BuildContext? context) async {
|
||||
// If the App has more than one APK, the user should pick one (if context provided)
|
||||
String? apkUrl = app.apkUrls[app.preferredApkIndex];
|
||||
@ -262,6 +303,7 @@ class AppsProvider with ChangeNotifier {
|
||||
List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
|
||||
|
||||
if (app.apkUrls.length > 1 && context != null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
apkUrl = await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
@ -281,6 +323,7 @@ class AppsProvider with ChangeNotifier {
|
||||
if (apkUrl != null &&
|
||||
getHost(apkUrl) != getHost(app.url) &&
|
||||
context != null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
if (await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
@ -438,100 +481,120 @@ class AppsProvider with ChangeNotifier {
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
if (!res) {
|
||||
logs.add(tr('versionCorrectionDisabled'));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// If the App says it is installed but installedInfo is null, set it to not installed
|
||||
// If there is any other mismatch between installedInfo and installedVersion, try reconciling them intelligently
|
||||
// If that fails, just set it to the actual version string (all we can do at that point)
|
||||
// Don't save changes, just return the object if changes were made (else null)
|
||||
bool isVersionDetectionPossible(AppInMemory? app) {
|
||||
return app?.app.additionalSettings['trackOnly'] != true &&
|
||||
app?.app.additionalSettings['versionDetection'] !=
|
||||
'releaseDateAsVersion' &&
|
||||
app?.installedInfo?.versionName != null &&
|
||||
app?.app.installedVersion != null &&
|
||||
reconcileVersionDifferences(
|
||||
app!.installedInfo!.versionName!, app.app.installedVersion!) !=
|
||||
null;
|
||||
}
|
||||
|
||||
// Given an App and it's on-device info...
|
||||
// Reconcile unexpected differences between its reported installed version, real installed version, and reported latest version
|
||||
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
|
||||
var modded = false;
|
||||
var trackOnly = app.additionalSettings['trackOnly'] == true;
|
||||
var noVersionDetection =
|
||||
app.additionalSettings['noVersionDetection'] == true;
|
||||
var noVersionDetection = app.additionalSettings['versionDetection'] !=
|
||||
'standardVersionDetection';
|
||||
// FIRST, COMPARE THE APP'S REPORTED AND REAL INSTALLED VERSIONS, WHERE ONE IS NULL
|
||||
if (installedInfo == null && app.installedVersion != null && !trackOnly) {
|
||||
// App says it's installed but isn't really (and isn't track only) - set to not installed
|
||||
app.installedVersion = null;
|
||||
modded = true;
|
||||
} else if (installedInfo?.versionName != null &&
|
||||
app.installedVersion == null) {
|
||||
// App says it's not installed but really is - set to installed and use real package versionName
|
||||
app.installedVersion = installedInfo!.versionName;
|
||||
modded = true;
|
||||
} else if (installedInfo?.versionName != null &&
|
||||
}
|
||||
// SECOND, RECONCILE DIFFERENCES BETWEEN THE APP'S REPORTED AND REAL INSTALLED VERSIONS, WHERE NEITHER IS NULL
|
||||
if (installedInfo?.versionName != null &&
|
||||
installedInfo!.versionName != app.installedVersion &&
|
||||
!noVersionDetection) {
|
||||
String? correctedInstalledVersion = reconcileRealAndInternalVersions(
|
||||
// App's reported version and real version don't match (and it uses standard version detection)
|
||||
// If they share a standard format (and are still different under it), update the reported version accordingly
|
||||
var correctedInstalledVersion = reconcileVersionDifferences(
|
||||
installedInfo.versionName!, app.installedVersion!);
|
||||
if (correctedInstalledVersion != null) {
|
||||
app.installedVersion = correctedInstalledVersion;
|
||||
if (correctedInstalledVersion?.key == false) {
|
||||
app.installedVersion = correctedInstalledVersion!.value;
|
||||
modded = true;
|
||||
}
|
||||
}
|
||||
// THIRD, RECONCILE THE APP'S REPORTED INSTALLED AND LATEST VERSIONS
|
||||
if (app.installedVersion != null &&
|
||||
app.installedVersion != app.latestVersion &&
|
||||
!noVersionDetection) {
|
||||
app.installedVersion = reconcileRealAndInternalVersions(
|
||||
app.installedVersion!, app.latestVersion,
|
||||
matchMode: true) ??
|
||||
app.installedVersion;
|
||||
// App's reported installed and latest versions don't match (and it uses standard version detection)
|
||||
// If they share a standard format, make sure the App's reported installed version uses that format
|
||||
var correctedInstalledVersion =
|
||||
reconcileVersionDifferences(app.installedVersion!, app.latestVersion);
|
||||
if (correctedInstalledVersion?.key == true) {
|
||||
app.installedVersion = correctedInstalledVersion!.value;
|
||||
modded = true;
|
||||
}
|
||||
}
|
||||
// FOURTH, DISABLE VERSION DETECTION IF ENABLED AND THE REPORTED/REAL INSTALLED VERSIONS ARE NOT STANDARDIZED
|
||||
if (installedInfo != null &&
|
||||
app.additionalSettings['versionDetection'] ==
|
||||
'standardVersionDetection' &&
|
||||
!isVersionDetectionPossible(AppInMemory(app, null, installedInfo))) {
|
||||
app.additionalSettings['versionDetection'] = 'noVersionDetection';
|
||||
logs.add('Could not reconcile version formats for: ${app.id}');
|
||||
modded = true;
|
||||
}
|
||||
// if (app.installedVersion != null &&
|
||||
// app.additionalSettings['versionDetection'] ==
|
||||
// 'standardVersionDetection') {
|
||||
// var correctedInstalledVersion =
|
||||
// reconcileVersionDifferences(app.installedVersion!, app.latestVersion);
|
||||
// if (correctedInstalledVersion == null) {
|
||||
// app.additionalSettings['versionDetection'] = 'noVersionDetection';
|
||||
// logs.add('Could not reconcile version formats for: ${app.id}');
|
||||
// modded = true;
|
||||
// }
|
||||
// }
|
||||
|
||||
return modded ? app : null;
|
||||
}
|
||||
|
||||
String? reconcileRealAndInternalVersions(
|
||||
String realVersion, String internalVersion,
|
||||
{bool matchMode = false}) {
|
||||
// 1. If one or both of these can't be converted to a "standard" format, return null (leave as is)
|
||||
// 2. If both have a "standard" format under which they are equal, return null (leave as is)
|
||||
// 3. If both have a "standard" format in common but are unequal, return realVersion (this means it was changed externally)
|
||||
// If in matchMode, the outcomes of rules 2 and 3 are reversed, and the "real" version is not matched strictly
|
||||
// Matchmode to be used when comparing internal install version and internal latest version
|
||||
|
||||
bool doStringsMatchUnderRegEx(
|
||||
String pattern, String value1, String value2) {
|
||||
var r = RegExp(pattern);
|
||||
var m1 = r.firstMatch(value1);
|
||||
var m2 = r.firstMatch(value2);
|
||||
return m1 != null && m2 != null
|
||||
? value1.substring(m1.start, m1.end) ==
|
||||
value2.substring(m2.start, m2.end)
|
||||
: false;
|
||||
}
|
||||
|
||||
Set<String> findStandardFormatsForVersion(String version, bool strict) {
|
||||
Set<String> results = {};
|
||||
for (var pattern in standardVersionRegExStrings) {
|
||||
if (RegExp('${strict ? '^' : ''}$pattern${strict ? '\$' : ''}')
|
||||
.hasMatch(version)) {
|
||||
results.add(pattern);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
var realStandardVersionFormats =
|
||||
findStandardFormatsForVersion(realVersion, true);
|
||||
var internalStandardVersionFormats =
|
||||
findStandardFormatsForVersion(internalVersion, false);
|
||||
MapEntry<bool, String>? reconcileVersionDifferences(
|
||||
String templateVersion, String comparisonVersion) {
|
||||
// Returns null if the versions don't share a common standard format
|
||||
// Returns <true, comparisonVersion> if they share a common format and are equal
|
||||
// Returns <false, templateVersion> if they share a common format but are not equal
|
||||
// templateVersion must fully match a standard format, while comparisonVersion can have a substring match
|
||||
var templateVersionFormats =
|
||||
findStandardFormatsForVersion(templateVersion, true);
|
||||
var comparisonVersionFormats =
|
||||
findStandardFormatsForVersion(comparisonVersion, false);
|
||||
var commonStandardFormats =
|
||||
realStandardVersionFormats.intersection(internalStandardVersionFormats);
|
||||
templateVersionFormats.intersection(comparisonVersionFormats);
|
||||
if (commonStandardFormats.isEmpty) {
|
||||
return null; // Incompatible; no "enhanced detection"
|
||||
return null;
|
||||
}
|
||||
for (String pattern in commonStandardFormats) {
|
||||
if (doStringsMatchUnderRegEx(pattern, internalVersion, realVersion)) {
|
||||
return matchMode
|
||||
? internalVersion
|
||||
: null; // Enhanced detection says no change
|
||||
if (doStringsMatchUnderRegEx(
|
||||
pattern, comparisonVersion, templateVersion)) {
|
||||
return MapEntry(true, comparisonVersion);
|
||||
}
|
||||
}
|
||||
return matchMode
|
||||
? null
|
||||
: realVersion; // Enhanced detection says something changed
|
||||
return MapEntry(false, templateVersion);
|
||||
}
|
||||
|
||||
bool doStringsMatchUnderRegEx(String pattern, String value1, String value2) {
|
||||
var r = RegExp(pattern);
|
||||
var m1 = r.firstMatch(value1);
|
||||
var m2 = r.firstMatch(value2);
|
||||
return m1 != null && m2 != null
|
||||
? value1.substring(m1.start, m1.end) ==
|
||||
value2.substring(m2.start, m2.end)
|
||||
: false;
|
||||
}
|
||||
|
||||
Future<void> loadApps() async {
|
||||
@ -543,7 +606,21 @@ class AppsProvider with ChangeNotifier {
|
||||
List<App> newApps = (await getAppsDir())
|
||||
.listSync()
|
||||
.where((item) => item.path.toLowerCase().endsWith('.json'))
|
||||
.map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync())))
|
||||
.map((e) {
|
||||
try {
|
||||
return App.fromJson(jsonDecode(File(e.path).readAsStringSync()));
|
||||
} catch (err) {
|
||||
if (err is FormatException) {
|
||||
logs.add('Corrupt JSON when loading App (will be ignored): $e');
|
||||
e.renameSync('${e.path}.corrupt');
|
||||
return App(
|
||||
'', '', '', '', '', '', [], 0, {}, DateTime.now(), false);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
})
|
||||
.where((element) => element.id.isNotEmpty)
|
||||
.toList();
|
||||
var idsToDelete = apps.values
|
||||
.map((e) => e.app.id)
|
||||
@ -586,7 +663,8 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> saveApps(List<App> apps,
|
||||
{bool attemptToCorrectInstallStatus = true}) async {
|
||||
{bool attemptToCorrectInstallStatus = true,
|
||||
bool onlyIfExists = true}) async {
|
||||
attemptToCorrectInstallStatus =
|
||||
attemptToCorrectInstallStatus && (await doesInstalledAppsPluginWork());
|
||||
for (var app in apps) {
|
||||
@ -597,9 +675,15 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
File('${(await getAppsDir()).path}/${app.id}.json')
|
||||
.writeAsStringSync(jsonEncode(app.toJson()));
|
||||
this.apps.update(
|
||||
app.id, (value) => AppInMemory(app, value.downloadProgress, info),
|
||||
ifAbsent: () => AppInMemory(app, null, info));
|
||||
try {
|
||||
this.apps.update(
|
||||
app.id, (value) => AppInMemory(app, value.downloadProgress, info),
|
||||
ifAbsent: onlyIfExists ? null : () => AppInMemory(app, null, info));
|
||||
} catch (e) {
|
||||
if (e is! ArgumentError || e.name != 'key') {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
@ -619,6 +703,72 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> removeAppsWithModal(BuildContext context, List<App> apps) async {
|
||||
var showUninstallOption = apps
|
||||
.where((a) =>
|
||||
a.installedVersion != null &&
|
||||
a.additionalSettings['trackOnly'] != true)
|
||||
.isNotEmpty;
|
||||
var values = await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: plural('removeAppQuestion', apps.length),
|
||||
items: !showUninstallOption
|
||||
? []
|
||||
: [
|
||||
[
|
||||
GeneratedFormSwitch('rmAppEntry',
|
||||
label: tr('removeFromObtainium'), defaultValue: true)
|
||||
],
|
||||
[
|
||||
GeneratedFormSwitch('uninstallApp',
|
||||
label: tr('uninstallFromDevice'))
|
||||
]
|
||||
],
|
||||
initValid: true,
|
||||
);
|
||||
});
|
||||
if (values != null) {
|
||||
bool uninstall = values['uninstallApp'] == true && showUninstallOption;
|
||||
bool remove = values['rmAppEntry'] == true || !showUninstallOption;
|
||||
if (uninstall) {
|
||||
for (var i = 0; i < apps.length; i++) {
|
||||
if (apps[i].installedVersion != null) {
|
||||
uninstallApp(apps[i].id);
|
||||
apps[i].installedVersion = null;
|
||||
}
|
||||
}
|
||||
await saveApps(apps, attemptToCorrectInstallStatus: false);
|
||||
}
|
||||
if (remove) {
|
||||
await removeApps(apps.map((e) => e.id).toList());
|
||||
}
|
||||
return uninstall || remove;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> openAppSettings(String appId) async {
|
||||
final AndroidIntent intent = AndroidIntent(
|
||||
action: 'action_application_details_settings',
|
||||
data: 'package:$appId',
|
||||
);
|
||||
await intent.launch();
|
||||
}
|
||||
|
||||
addMissingCategories(SettingsProvider settingsProvider) {
|
||||
var cats = settingsProvider.categories;
|
||||
apps.forEach((key, value) {
|
||||
for (var c in value.app.categories) {
|
||||
if (!cats.containsKey(c)) {
|
||||
cats[c] = generateRandomLightColor().value;
|
||||
}
|
||||
}
|
||||
});
|
||||
settingsProvider.setCategories(cats, appsProvider: this);
|
||||
}
|
||||
|
||||
Future<App?> checkUpdate(String appId) async {
|
||||
App? currentApp = apps[appId]!.app;
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
@ -698,13 +848,7 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<String> exportApps() async {
|
||||
Directory? exportDir = Directory('/storage/emulated/0/Download');
|
||||
String path = 'Downloads'; // TODO: See if hardcoding this can be avoided
|
||||
if (!exportDir.existsSync()) {
|
||||
exportDir = await getExternalStorageDirectory();
|
||||
path = exportDir!.path;
|
||||
}
|
||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt <= 28) {
|
||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt <= 29) {
|
||||
if (await Permission.storage.isDenied) {
|
||||
await Permission.storage.request();
|
||||
}
|
||||
@ -712,6 +856,18 @@ class AppsProvider with ChangeNotifier {
|
||||
throw ObtainiumError(tr('storagePermissionDenied'));
|
||||
}
|
||||
}
|
||||
Directory? exportDir = Directory('/storage/emulated/0/Download');
|
||||
String path = 'Downloads'; // TODO: See if hardcoding this can be avoided
|
||||
var downloadsAccessible = false;
|
||||
try {
|
||||
downloadsAccessible = exportDir.existsSync();
|
||||
} catch (e) {
|
||||
logs.add('Error accessing Downloads (will use fallback): $e');
|
||||
}
|
||||
if (!downloadsAccessible) {
|
||||
exportDir = await getExternalStorageDirectory();
|
||||
path = exportDir!.path;
|
||||
}
|
||||
File export = File(
|
||||
'${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json');
|
||||
export.writeAsStringSync(
|
||||
@ -731,7 +887,7 @@ class AppsProvider with ChangeNotifier {
|
||||
a.installedVersion = apps[a.id]?.app.installedVersion;
|
||||
}
|
||||
}
|
||||
await saveApps(importedApps);
|
||||
await saveApps(importedApps, onlyIfExists: false);
|
||||
notifyListeners();
|
||||
return importedApps.length;
|
||||
}
|
||||
@ -751,7 +907,7 @@ class AppsProvider with ChangeNotifier {
|
||||
if (apps.containsKey(app.id)) {
|
||||
errorsMap.addAll({app.id: tr('appAlreadyAdded')});
|
||||
} else {
|
||||
await saveApps([app]);
|
||||
await saveApps([app], onlyIfExists: false);
|
||||
}
|
||||
}
|
||||
List<List<String>> errors =
|
||||
|
@ -6,8 +6,9 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/main.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@ -18,7 +19,7 @@ enum ThemeSettings { system, light, dark }
|
||||
|
||||
enum ColourSettings { basic, materialYou }
|
||||
|
||||
enum SortColumnSettings { added, nameAuthor, authorName }
|
||||
enum SortColumnSettings { added, nameAuthor, authorName, releaseDate }
|
||||
|
||||
enum SortOrderSettings { ascending, descending }
|
||||
|
||||
@ -140,6 +141,15 @@ class SettingsProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get groupByCategory {
|
||||
return prefs?.getBool('groupByCategory') ?? false;
|
||||
}
|
||||
|
||||
set groupByCategory(bool show) {
|
||||
prefs?.setBool('groupByCategory', show);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String? getSettingString(String settingId) {
|
||||
return prefs?.getString(settingId);
|
||||
}
|
||||
@ -152,7 +162,22 @@ class SettingsProvider with ChangeNotifier {
|
||||
Map<String, int> get categories =>
|
||||
Map<String, int>.from(jsonDecode(prefs?.getString('categories') ?? '{}'));
|
||||
|
||||
set categories(Map<String, int> cats) {
|
||||
void setCategories(Map<String, int> cats, {AppsProvider? appsProvider}) {
|
||||
if (appsProvider != null) {
|
||||
List<App> changedApps = appsProvider.apps.values
|
||||
.map((a) {
|
||||
var n1 = a.app.categories.length;
|
||||
a.app.categories.removeWhere((c) => !cats.keys.contains(c));
|
||||
return n1 > a.app.categories.length ? a.app : null;
|
||||
})
|
||||
.where((element) => element != null)
|
||||
.map((e) => e as App)
|
||||
.toList();
|
||||
if (changedApps.isNotEmpty) {
|
||||
appsProvider.saveApps(changedApps,
|
||||
attemptToCorrectInstallStatus: false);
|
||||
}
|
||||
}
|
||||
prefs?.setString('categories', jsonEncode(cats));
|
||||
notifyListeners();
|
||||
}
|
||||
@ -179,4 +204,15 @@ class SettingsProvider with ChangeNotifier {
|
||||
|
||||
bool setEqual(Set<String> a, Set<String> b) =>
|
||||
a.length == b.length && a.union(b).length == a.length;
|
||||
|
||||
void resetLocaleSafe(BuildContext context) {
|
||||
if (context.supportedLocales
|
||||
.map((e) => e.languageCode)
|
||||
.contains(context.deviceLocale.languageCode)) {
|
||||
context.resetLocale();
|
||||
} else {
|
||||
context.setLocale(context.fallbackLocale!);
|
||||
context.deleteSaveLocale();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -15,9 +15,12 @@ import 'package:obtainium/app_sources/gitlab.dart';
|
||||
import 'package:obtainium/app_sources/izzyondroid.dart';
|
||||
import 'package:obtainium/app_sources/html.dart';
|
||||
import 'package:obtainium/app_sources/mullvad.dart';
|
||||
import 'package:obtainium/app_sources/neutroncode.dart';
|
||||
import 'package:obtainium/app_sources/signal.dart';
|
||||
import 'package:obtainium/app_sources/sourceforge.dart';
|
||||
import 'package:obtainium/app_sources/steammobile.dart';
|
||||
import 'package:obtainium/app_sources/telegramapp.dart';
|
||||
import 'package:obtainium/app_sources/vlc.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/mass_app_sources/githubstars.dart';
|
||||
@ -33,8 +36,11 @@ class APKDetails {
|
||||
late String version;
|
||||
late List<String> apkUrls;
|
||||
late AppNames names;
|
||||
late DateTime? releaseDate;
|
||||
late String? changeLog;
|
||||
|
||||
APKDetails(this.version, this.apkUrls, this.names);
|
||||
APKDetails(this.version, this.apkUrls, this.names,
|
||||
{this.releaseDate, this.changeLog});
|
||||
}
|
||||
|
||||
class App {
|
||||
@ -50,6 +56,8 @@ class App {
|
||||
late DateTime? lastUpdateCheck;
|
||||
bool pinned = false;
|
||||
List<String> categories;
|
||||
late DateTime? releaseDate;
|
||||
late String? changeLog;
|
||||
App(
|
||||
this.id,
|
||||
this.url,
|
||||
@ -62,7 +70,9 @@ class App {
|
||||
this.additionalSettings,
|
||||
this.lastUpdateCheck,
|
||||
this.pinned,
|
||||
{this.categories = const []});
|
||||
{this.categories = const [],
|
||||
this.releaseDate,
|
||||
this.changeLog});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@ -97,6 +107,20 @@ class App {
|
||||
additionalSettings['noVersionDetection'] =
|
||||
json['noVersionDetection'] == 'true' || json['trackOnly'] == true;
|
||||
}
|
||||
// Convert bool style version detection options to dropdown style
|
||||
if (additionalSettings['noVersionDetection'] == true) {
|
||||
additionalSettings['versionDetection'] = 'noVersionDetection';
|
||||
if (additionalSettings['releaseDateAsVersion'] == true) {
|
||||
additionalSettings['versionDetection'] = 'releaseDateAsVersion';
|
||||
additionalSettings.remove('releaseDateAsVersion');
|
||||
}
|
||||
if (additionalSettings['noVersionDetection'] != null) {
|
||||
additionalSettings.remove('noVersionDetection');
|
||||
}
|
||||
if (additionalSettings['releaseDateAsVersion'] != null) {
|
||||
additionalSettings.remove('releaseDateAsVersion');
|
||||
}
|
||||
}
|
||||
// Ensure additionalSettings are correctly typed
|
||||
for (var item in formItems) {
|
||||
if (additionalSettings[item.key] != null) {
|
||||
@ -134,7 +158,12 @@ class App {
|
||||
.toList()
|
||||
: json['category'] != null
|
||||
? [json['category'] as String]
|
||||
: []);
|
||||
: [],
|
||||
releaseDate: json['releaseDate'] == null
|
||||
? null
|
||||
: DateTime.fromMicrosecondsSinceEpoch(json['releaseDate']),
|
||||
changeLog:
|
||||
json['changeLog'] == null ? null : json['changeLog'] as String);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@ -149,7 +178,9 @@ class App {
|
||||
'additionalSettings': jsonEncode(additionalSettings),
|
||||
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
|
||||
'pinned': pinned,
|
||||
'categories': categories
|
||||
'categories': categories,
|
||||
'releaseDate': releaseDate?.microsecondsSinceEpoch,
|
||||
'changeLog': changeLog
|
||||
};
|
||||
}
|
||||
|
||||
@ -198,6 +229,7 @@ class AppSource {
|
||||
String? host;
|
||||
late String name;
|
||||
bool enforceTrackOnly = false;
|
||||
bool changeLogIfAnyIsMarkDown = true;
|
||||
|
||||
AppSource() {
|
||||
name = runtimeType.toString();
|
||||
@ -225,7 +257,28 @@ class AppSource {
|
||||
label: tr('trackOnly'),
|
||||
)
|
||||
],
|
||||
[GeneratedFormSwitch('noVersionDetection', label: tr('noVersionDetection'))]
|
||||
[
|
||||
GeneratedFormDropdown(
|
||||
'versionDetection',
|
||||
[
|
||||
MapEntry(
|
||||
'standardVersionDetection', tr('standardVersionDetection')),
|
||||
MapEntry('releaseDateAsVersion', tr('releaseDateAsVersion')),
|
||||
MapEntry('noVersionDetection', tr('noVersionDetection'))
|
||||
],
|
||||
label: tr('versionDetection'),
|
||||
defaultValue: 'standardVersionDetection')
|
||||
],
|
||||
[
|
||||
GeneratedFormTextField('apkFilterRegEx',
|
||||
label: tr('filterAPKsByRegEx'),
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
return regExValidator(value);
|
||||
}
|
||||
])
|
||||
]
|
||||
];
|
||||
|
||||
// Previous 2 variables combined into one at runtime for convenient usage
|
||||
@ -269,6 +322,18 @@ abstract class MassAppUrlSource {
|
||||
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
|
||||
}
|
||||
|
||||
regExValidator(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RegExp(value);
|
||||
} catch (e) {
|
||||
return tr('invalidRegEx');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class SourceProvider {
|
||||
// Add more source classes here so they are available via the service
|
||||
List<AppSource> sources = [
|
||||
@ -277,12 +342,16 @@ class SourceProvider {
|
||||
Codeberg(),
|
||||
FDroid(),
|
||||
IzzyOnDroid(),
|
||||
Mullvad(),
|
||||
Signal(),
|
||||
FDroidRepo(),
|
||||
SourceForge(),
|
||||
APKMirror(),
|
||||
FDroidRepo(),
|
||||
Mullvad(),
|
||||
Signal(),
|
||||
VLC(),
|
||||
// WhatsApp(), // As of 2023-03-20 this is unusable as the version on the webpage is months out of date
|
||||
TelegramApp(),
|
||||
SteamMobile(),
|
||||
NeutronCode(),
|
||||
HTML() // This should ALWAYS be the last option as they are tried in order
|
||||
];
|
||||
|
||||
@ -326,38 +395,34 @@ class SourceProvider {
|
||||
return false;
|
||||
}
|
||||
|
||||
String generateTempID(AppNames names, AppSource source) =>
|
||||
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}';
|
||||
String generateTempID(
|
||||
String standardUrl, Map<String, dynamic> additionalSettings) =>
|
||||
(standardUrl + additionalSettings.toString()).hashCode.toString();
|
||||
|
||||
bool isTempId(String id) {
|
||||
List<String> parts = id.split('_');
|
||||
if (parts.length < 3) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < parts.length - 1; i++) {
|
||||
if (RegExp('.*[A-Z].*').hasMatch(parts[i])) {
|
||||
// TODO: Look into RegEx for non-Latin characters
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
bool isTempId(App app) {
|
||||
// return app.id == generateTempID(app.url, app.additionalSettings);
|
||||
return RegExp('^[0-9]+\$').hasMatch(app.id);
|
||||
}
|
||||
|
||||
Future<App> getApp(
|
||||
AppSource source, String url, Map<String, dynamic> additionalSettings,
|
||||
{App? currentApp,
|
||||
bool trackOnlyOverride = false,
|
||||
noVersionDetectionOverride = false}) async {
|
||||
{App? currentApp, bool trackOnlyOverride = false}) async {
|
||||
if (trackOnlyOverride || source.enforceTrackOnly) {
|
||||
additionalSettings['trackOnly'] = true;
|
||||
}
|
||||
if (noVersionDetectionOverride) {
|
||||
additionalSettings['noVersionDetection'] = true;
|
||||
}
|
||||
var trackOnly = additionalSettings['trackOnly'] == true;
|
||||
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
||||
APKDetails apk =
|
||||
await source.getLatestAPKDetails(standardUrl, additionalSettings);
|
||||
if (additionalSettings['versionDetection'] == 'releaseDateAsVersion' &&
|
||||
apk.releaseDate != null) {
|
||||
apk.version = apk.releaseDate!.microsecondsSinceEpoch.toString();
|
||||
}
|
||||
if (additionalSettings['apkFilterRegEx'] != null) {
|
||||
var reg = RegExp(additionalSettings['apkFilterRegEx']);
|
||||
apk.apkUrls =
|
||||
apk.apkUrls.where((element) => reg.hasMatch(element)).toList();
|
||||
}
|
||||
if (apk.apkUrls.isEmpty && !trackOnly) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
@ -368,7 +433,7 @@ class SourceProvider {
|
||||
currentApp?.id ??
|
||||
source.tryInferringAppId(standardUrl,
|
||||
additionalSettings: additionalSettings) ??
|
||||
generateTempID(apk.names, source),
|
||||
generateTempID(standardUrl, additionalSettings),
|
||||
standardUrl,
|
||||
apk.names.author[0].toUpperCase() + apk.names.author.substring(1),
|
||||
name.trim().isNotEmpty
|
||||
@ -381,7 +446,9 @@ class SourceProvider {
|
||||
additionalSettings,
|
||||
DateTime.now(),
|
||||
currentApp?.pinned ?? false,
|
||||
categories: currentApp?.categories ?? const []);
|
||||
categories: currentApp?.categories ?? const [],
|
||||
releaseDate: apk.releaseDate,
|
||||
changeLog: apk.changeLog);
|
||||
}
|
||||
|
||||
// Returns errors in [results, errors] instead of throwing them
|
||||
|
502
pubspec.lock
13
pubspec.yaml
@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.10.4+110 # When changing this, update the tag in main() accordingly
|
||||
version: 0.11.22+144 # When changing this, update the tag in main() accordingly
|
||||
|
||||
environment:
|
||||
sdk: '>=2.18.2 <3.0.0'
|
||||
@ -58,12 +58,13 @@ dependencies:
|
||||
android_alarm_manager_plus: ^2.1.0
|
||||
sqflite: ^2.2.0+3
|
||||
easy_localization: ^3.0.1
|
||||
android_intent_plus: ^3.1.5
|
||||
flutter_markdown: ^0.6.14
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_launcher_icons: ^0.11.0
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
@ -72,12 +73,6 @@ dev_dependencies:
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^2.0.1
|
||||
|
||||
flutter_icons:
|
||||
android: true
|
||||
image_path: "assets/graphics/icon.png"
|
||||
adaptive_icon_background: "#FFFFFF"
|
||||
adaptive_icon_foreground: "assets/graphics/icon.png"
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
@ -96,6 +91,8 @@ flutter:
|
||||
|
||||
assets:
|
||||
- assets/translations/
|
||||
- assets/graphics/
|
||||
- assets/ca/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/assets-and-images/#resolution-aware
|
||||
|