Compare commits
519 Commits
v0.8.3-bet
...
v0.13.7-be
Author | SHA1 | Date | |
---|---|---|---|
0186c00d97 | |||
9294540b5d | |||
0b16c28224 | |||
83028d405a | |||
c4262c3eaa | |||
f0e1831d30 | |||
9efd0dd46e | |||
eb26c0be0b | |||
1ff1c6ca33 | |||
6169915e63 | |||
a0d466a074 | |||
6f9ef6d51e | |||
feb4c2eabc | |||
c2cf39125d | |||
833ece1ef5 | |||
fee23cadfa | |||
4c6303f783 | |||
ce6e6c47db | |||
2ccff15525 | |||
d24f2b4e6d | |||
03fc6a530f | |||
4136734a60 | |||
ca1371260c | |||
03c2ce9a01 | |||
eda5fec37c | |||
e21c6297ff | |||
c6297ea449 | |||
e33cc00266 | |||
96c92c8df9 | |||
e256ada2dc | |||
eb0be196da | |||
1606ad3442 | |||
d212f13345 | |||
f80c9ec33e | |||
7681e23de9 | |||
22a60df40e | |||
431a01f2a5 | |||
0cd4385de7 | |||
0774b3ddc3 | |||
b60b1ed058 | |||
b196715d60 | |||
0673e90dff | |||
59cfa242fb | |||
65ab72ba90 | |||
408bca8951 | |||
480467492a | |||
219b04aedb | |||
a0709856ef | |||
577642850f | |||
e1db024034 | |||
cc268aeeda | |||
d5f7eced8b | |||
cc3c4cc79f | |||
89b61884f1 | |||
33d3fc2d8e | |||
b07f5dd6b6 | |||
b43e13bb56 | |||
3be5543df4 | |||
91ad9efa43 | |||
ee292146d1 | |||
12867634b6 | |||
2e4fe89b85 | |||
b4642e16ad | |||
8ca5964d31 | |||
30c89fe385 | |||
fb9e66332d | |||
84b512f282 | |||
6f9aa85a72 | |||
639fc20fcb | |||
75631e5c5a | |||
9ec345761e | |||
1f9c2c1699 | |||
cbec486ad1 | |||
85ef60d4a8 | |||
44bde571bf | |||
eaaee5e7cd | |||
e1980f4de2 | |||
be9c671a56 | |||
0404449842 | |||
d6366a145e | |||
0a751cf545 | |||
5885ea57ad | |||
f8b326529f | |||
9f5f1174ba | |||
779de58f74 | |||
76e316422c | |||
36273fe02d | |||
03b592521c | |||
a5ef47a060 | |||
289c801fec | |||
73d04b1564 | |||
9469d56144 | |||
d063bca474 | |||
7c592756fe | |||
08586870fb | |||
8b123acdcd | |||
08aa04f812 | |||
dd19fcf6da | |||
04b3c8ad7d | |||
81f66683d2 | |||
392554123b | |||
3e4d5c26ac | |||
86b7f6fef3 | |||
e1d914118f | |||
4a07cf9951 | |||
ce44e200a5 | |||
e8ebf53626 | |||
cdd6a4124c | |||
09c71e4e9f | |||
28a996441c | |||
396bf012c9 | |||
02da24aa75 | |||
3c6e66ce12 | |||
0213b542e3 | |||
b0e8a4a297 | |||
e72b33ebf2 | |||
283722319b | |||
b406bb5c6a | |||
de2b7fa7a1 | |||
be61220af4 | |||
3e732a4317 | |||
9f2db4e4e7 | |||
78141998f4 | |||
934f237e34 | |||
1b2a9a39e3 | |||
dc52fb6181 | |||
9e4ac397d8 | |||
0ec944eae9 | |||
ad250c30e4 | |||
1090f15508 | |||
666941350e | |||
eeadbce8b0 | |||
ce8aeff342 | |||
0d8362a2ed | |||
3b28143a4e | |||
537628f378 | |||
c92d76df98 | |||
b6959e1a8b | |||
1bf648da60 | |||
6a1275e9e4 | |||
df242b91ad | |||
7ea75325bb | |||
0704dfe2ee | |||
6275cbf114 | |||
36b8ef6782 | |||
d274b9a428 | |||
1c2980d1ac | |||
8f0aac057e | |||
e929920a48 | |||
8ed254c7dd | |||
46a00836df | |||
f144ffdded | |||
d597d569e2 | |||
b62475de87 | |||
334ac8d3d6 | |||
9193788356 | |||
8f75ddd43f | |||
a2edc86bfa | |||
0804e680b2 | |||
49affd1bd4 | |||
202ce4f0d5 | |||
361a3e1bc2 | |||
f33a26d4f4 | |||
7aaf56ec8c | |||
ed120016d9 | |||
e8cbac8657 | |||
b66c13d319 | |||
782d055bc3 | |||
d557746965 | |||
e6b05d50b9 | |||
dea635fa6a | |||
682026ed0a | |||
9fe8a200ef | |||
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 | |||
f43e5a2ff1 | |||
b72aa8273e | |||
520f186e4a | |||
e1e97672cf | |||
1494bcd013 | |||
3457a0a12f | |||
b165400a6e | |||
c47bf937f1 | |||
2e19a8c04c | |||
05d4da86ec | |||
e9d1b04d54 | |||
cff5334c25 | |||
a55346fc22 | |||
885df678e5 | |||
bf7b0c5702 | |||
2972da4609 | |||
b8567af98e | |||
ea62c68b40 | |||
08a5af0449 | |||
36f327c16e | |||
768213cb34 | |||
e888fb7120 | |||
1fb68dd674 | |||
5c4bb8f84c | |||
1c8e759494 | |||
081c2a07d2 | |||
02751fe8fa | |||
95f3362a84 | |||
b68cf5a1be | |||
4eb7499591 | |||
98fafe2aa4 | |||
9bac74aadd | |||
0a93117bf0 | |||
451cc41c45 | |||
3b449d0982 | |||
1863f55372 | |||
0c4b8ac79d | |||
e287087753 | |||
82bcc46d42 | |||
1f26188ec6 | |||
794c3e1a81 | |||
16369b4adf | |||
8f16f745be | |||
8ddeb3d776 | |||
21cf9c98d9 | |||
358f910d19 | |||
7a3d74bd05 | |||
6f27f64699 | |||
3341fecb68 | |||
d3bce63ca4 | |||
8aa8b6b698 | |||
3d6c9bbf98 | |||
7af0a8628c | |||
4573ce6bcf | |||
e29d38fa32 | |||
dc82431235 | |||
424b0028bf | |||
46fba9e0a4 | |||
b40be7569b | |||
a173be11eb | |||
0c97b25d99 | |||
f836fd20d8 | |||
2f6917592d | |||
b864fef3ad | |||
8e487592b3 | |||
e9a44746a5 | |||
9123737bf3 | |||
12f70951c2 | |||
c1d56f89f0 | |||
4dfd29f5de | |||
226cfa25e0 | |||
4e0c655538 | |||
45a23e9025 | |||
1e5aa0999a | |||
beeec356e5 | |||
01fa9a2e96 | |||
0da7a36f1a | |||
ed2a4e674f | |||
0f6a683faa | |||
fa4d46b622 | |||
a3f9947f28 | |||
6977858b99 | |||
2ff6acb701 | |||
0c2d6ce84d | |||
9072862862 | |||
3cbaac2f5d | |||
0f8871efcb | |||
ee216cbbba | |||
ebe5b79dc5 | |||
60014c864c | |||
070b6033bd | |||
626bebbe5a | |||
118460ccb9 | |||
26f953dbb0 | |||
99d7595f2d | |||
e2f99c5e71 | |||
1f582d239b | |||
5e6b00718e | |||
56594e6b19 | |||
bbcc3ff9b3 | |||
ee66c53320 | |||
b7d581f8b0 | |||
ead63ba21d | |||
c69404363f | |||
99d0bd2461 | |||
54efda3eea | |||
d76d68329c | |||
b151eb27e1 | |||
6a21045e5b | |||
6aedd9ce37 | |||
f319639a99 | |||
92e6798809 | |||
9a129d41df | |||
0c2654a226 | |||
afc8e41171 | |||
1fe9e4f91e | |||
dbd6dec0a6 | |||
d068db2a57 | |||
dd5c5fd2bc | |||
ac9dadd9d0 | |||
bb0540b644 | |||
819334021a | |||
8ece0bbef9 | |||
6a41283e74 | |||
e6d5c7db3e | |||
d4c016d8ee | |||
63034dd3f9 | |||
67b986de93 | |||
aafe4bc515 | |||
e524335900 | |||
77751fa03f | |||
b4e06ffb8e | |||
af511deeca | |||
71c6db9510 | |||
8fac67c9e9 | |||
c317f23741 | |||
12c0dd8489 | |||
1c7385ab56 | |||
b46347a6e3 | |||
a7104c89dc | |||
347d2c2738 | |||
cc17260e54 | |||
1985dcec3a | |||
d435481f0b | |||
a68d49c71c | |||
2b6a16637e | |||
e46e4e5dbc | |||
848c8eaf5e | |||
ebc48169a1 | |||
54c37641d5 | |||
05ad01bf85 | |||
049b023e01 | |||
f6ca5d42e8 | |||
6d0cac5894 | |||
bfa661c8e0 | |||
e5825fe1d3 | |||
9e09aba444 | |||
8f5e07a5ca | |||
e7f3cdafe5 | |||
14ae43de92 | |||
a8f0d784a2 | |||
b1fb06e90b | |||
481204665c | |||
317b5ac83a | |||
f3b1ca4541 | |||
a00cfa2ba6 | |||
f81f6374bb | |||
da8695834e | |||
c4ba1e9dbc | |||
49862ad2a6 | |||
1b892f4e0d | |||
a4555f07f9 | |||
73fbdd84f0 | |||
a1518480db | |||
fd3ee02e52 | |||
609366675d | |||
fbff498ae1 | |||
bb4e470760 | |||
15183c3a95 |
27
README.md
@ -1,4 +1,4 @@
|
||||
#  Obtainium
|
||||
#  Obtainium
|
||||
|
||||
Get Android App Updates Directly From the Source.
|
||||
|
||||
@ -9,13 +9,34 @@ Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to
|
||||
Currently supported App sources:
|
||||
- [GitHub](https://github.com/)
|
||||
- [GitLab](https://gitlab.com/)
|
||||
- [Codeberg](https://codeberg.org/)
|
||||
- [F-Droid](https://f-droid.org/)
|
||||
- [IzzyOnDroid](https://android.izzysoft.de/)
|
||||
- [Mullvad](https://mullvad.net/en/)
|
||||
- [Signal](https://signal.org/)
|
||||
- [SourceForge](https://sourceforge.net/)
|
||||
- [SourceHut](https://git.sr.ht/)
|
||||
- [APKMirror](https://apkmirror.com/) (Track-Only)
|
||||
- [APKPure](https://apkpure.com/)
|
||||
- Third Party F-Droid Repos
|
||||
- Jenkins Jobs
|
||||
- [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 are assumed to have succeeded; failures and cancelled installs cannot be detected.
|
||||
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
|
||||
- For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable.
|
||||
|
||||
@ -23,4 +44,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"
|
||||
@ -24,6 +25,11 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action
|
||||
android:name="com.android_package_installer.content.SESSION_API_PACKAGE_INSTALLED"
|
||||
android:exported="false"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
@ -45,10 +51,23 @@
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="dev.imranr.obtainium"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"/>
|
||||
</provider>
|
||||
</application>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<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="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>
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 8.1 KiB |
@ -2,4 +2,5 @@
|
||||
<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>
|
||||
|
@ -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 After Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 9.6 KiB |
@ -2,4 +2,5 @@
|
||||
<paths>
|
||||
<external-path path="Android/data/dev.imranr.obtainium/" name="files_root" />
|
||||
<external-path path="." name="external_storage_root" />
|
||||
<external-path name="external_files" path="."/>
|
||||
</paths>
|
@ -26,6 +26,6 @@ subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
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-----
|
BIN
assets/graphics/icon.png
Executable file → Normal file
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 66 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 |
BIN
assets/graphics/icon_small.png
Normal file
After Width: | Height: | Size: 7.6 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 |
285
assets/translations/de.json
Normal file
@ -0,0 +1,285 @@
|
||||
{
|
||||
"invalidURLForSource": "Keine gültige {} App-URL",
|
||||
"noReleaseFound": "Keine passende Version gefunden",
|
||||
"noVersionFound": "Release-Version nicht ermittelbar",
|
||||
"urlMatchesNoSource": "URL stimmt mit keiner bekannten Quelle überein",
|
||||
"cantInstallOlderVersion": "Installation einer älteren App-Version nicht möglich",
|
||||
"appIdMismatch": "Die heruntergeladene Paket-ID stimmt nicht mit der vorhandenen App-ID überein",
|
||||
"functionNotImplemented": "Diese Klasse hat diese Funktion nicht implementiert",
|
||||
"placeholder": "Platzhalter",
|
||||
"someErrors": "Es traten einige Fehler auf",
|
||||
"unexpectedError": "Unerwarteter Fehler",
|
||||
"ok": "Okay",
|
||||
"and": "und",
|
||||
"startedBgUpdateTask": "Hintergrundaktualisierungsprüfung gestartet",
|
||||
"bgUpdateIgnoreAfterIs": "Hintergrundaktualisierung 'ignoreAfter' ist {}",
|
||||
"startedActualBGUpdateCheck": "Überprüfung der Hintergrundaktualisierung gestartet",
|
||||
"bgUpdateTaskFinished": "Hintergrundaktualisierungsprüfung abgeschlossen",
|
||||
"firstRun": "Dies ist der erste Start von Obtainium überhaupt",
|
||||
"settingUpdateCheckIntervalTo": "Aktualisierungsintervall auf {} stellen",
|
||||
"githubPATLabel": "GitHub Personal Access Token (Erhöht das Ratenlimit)",
|
||||
"githubPATHint": "PAT muss in diesem Format sein: Benutzername:Token",
|
||||
"githubPATFormat": "Benutzername:Token",
|
||||
"includePrereleases": "Vorabversionen einbeziehen",
|
||||
"fallbackToOlderReleases": "Fallback auf ältere Versionen",
|
||||
"filterReleaseTitlesByRegEx": "Release-Titel nach regulärem Ausdruck\nfiltern",
|
||||
"invalidRegEx": "Ungültiger regulärer Ausdruck",
|
||||
"noDescription": "Keine Beschreibung",
|
||||
"cancel": "Abbrechen",
|
||||
"continue": "Weiter",
|
||||
"requiredInBrackets": "(Benötigt)",
|
||||
"dropdownNoOptsError": "FEHLER: DROPDOWN MUSS MINDESTENS EINE OPTION HABEN",
|
||||
"colour": "Farbe",
|
||||
"githubStarredRepos": "GitHub Starred Repos",
|
||||
"uname": "Benutzername",
|
||||
"wrongArgNum": "Falsche Anzahl von Argumenten übermittelt",
|
||||
"xIsTrackOnly": "{} ist nur zur Nachverfolgung",
|
||||
"source": "Quelle",
|
||||
"app": "App",
|
||||
"appsFromSourceAreTrackOnly": "Apps aus dieser Quelle sind 'Nur Nachverfolgen'.",
|
||||
"youPickedTrackOnly": "Sie haben die Option 'Nur Nachverfolgen' gewählt.",
|
||||
"trackOnlyAppDescription": "Die App wird auf Updates überwacht, aber Obtainium wird sie nicht herunterladen oder installieren.",
|
||||
"cancelled": "Abgebrochen",
|
||||
"appAlreadyAdded": "App bereits hinzugefügt",
|
||||
"alreadyUpToDateQuestion": "App bereits auf dem neuesten Stand?",
|
||||
"addApp": "App hinzufügen",
|
||||
"appSourceURL": "Quell-URL der App",
|
||||
"error": "Fehler",
|
||||
"add": "Hinzufügen",
|
||||
"searchSomeSourcesLabel": "Suche (nur bestimmte Quellen)",
|
||||
"search": "Suchen",
|
||||
"additionalOptsFor": "Zusatzoptionen für {}",
|
||||
"supportedSourcesBelow": "Unterstützte Quellen:",
|
||||
"trackOnlyInBrackets": "(Nur Nachverfolgen)",
|
||||
"searchableInBrackets": "(Durchsuchbar)",
|
||||
"appsString": "Apps",
|
||||
"noApps": "Keine Apps",
|
||||
"noAppsForFilter": "Keine Apps für ausgewählten Filter",
|
||||
"byX": "Von {}",
|
||||
"percentProgress": "Fortschritt: {}%",
|
||||
"pleaseWait": "Bitte warten",
|
||||
"updateAvailable": "Aktualisierung verfügbar",
|
||||
"estimateInBracketsShort": "(ca.)",
|
||||
"notInstalled": "Nicht installiert",
|
||||
"estimateInBrackets": "(Ungefähr)",
|
||||
"selectAll": "Alle auswählen",
|
||||
"deselectN": "{} abgewählt",
|
||||
"xWillBeRemovedButRemainInstalled": "{} wird aus Obtainium entfernt, bleibt aber auf dem Gerät installiert.",
|
||||
"removeSelectedAppsQuestion": "Ausgewählte Apps entfernen?",
|
||||
"removeSelectedApps": "Ausgewählte Apps entfernen",
|
||||
"updateX": "Aktualisiere {}",
|
||||
"installX": "Installiere {}",
|
||||
"markXTrackOnlyAsUpdated": "Markiere {}\n(Nur Nachverfolgen)\nals aktualisiert",
|
||||
"changeX": "Ändere {}",
|
||||
"installUpdateApps": "Apps installieren/aktualisieren",
|
||||
"installUpdateSelectedApps": "Ausgewählte Apps installieren/aktualisieren",
|
||||
"markXSelectedAppsAsUpdated": "Markiere {} ausgewählte Apps als aktuell?",
|
||||
"no": "Nein",
|
||||
"yes": "Ja",
|
||||
"markSelectedAppsUpdated": "Markiere ausgewählte Apps als aktuell",
|
||||
"pinToTop": "Oben anheften",
|
||||
"unpinFromTop": "'Oben anheften' aufheben",
|
||||
"resetInstallStatusForSelectedAppsQuestion": "Installationsstatus für ausgewählte Apps zurücksetzen?",
|
||||
"installStatusOfXWillBeResetExplanation": "Der Installationsstatus der ausgewählten Apps wird zurückgesetzt. Dies kann hilfreich sein, wenn die in Obtainium angezeigte App-Version aufgrund fehlgeschlagener Aktualisierungen oder anderer Probleme falsch ist.",
|
||||
"shareSelectedAppURLs": "Ausgewählte App-URLs teilen",
|
||||
"resetInstallStatus": "Installationsstatus zurücksetzen",
|
||||
"more": "Mehr",
|
||||
"removeOutdatedFilter": "App-Filter 'Nicht aktuell' entfernen",
|
||||
"showOutdatedOnly": "Nur nicht aktuelle Apps anzeigen",
|
||||
"filter": "Filter",
|
||||
"filterActive": "Filter *",
|
||||
"filterApps": "Apps filtern",
|
||||
"appName": "App Name",
|
||||
"author": "Autor",
|
||||
"upToDateApps": "Apps mit aktueller Version",
|
||||
"nonInstalledApps": "Nicht installierte Apps",
|
||||
"importExport": "Import/Export",
|
||||
"settings": "Einstellungen",
|
||||
"exportedTo": "Exportiert zu {}",
|
||||
"obtainiumExport": "Obtainium Export",
|
||||
"invalidInput": "Ungültige Eingabe",
|
||||
"importedX": "Importiert {}",
|
||||
"obtainiumImport": "Obtainium Import",
|
||||
"importFromURLList": "Importieren aus URL-Liste",
|
||||
"searchQuery": "Suchanfrage",
|
||||
"appURLList": "App URL-Liste",
|
||||
"line": "Linie",
|
||||
"searchX": "Suche {}",
|
||||
"noResults": "Keine Ergebnisse gefunden",
|
||||
"importX": "Import {}",
|
||||
"importedAppsIdDisclaimer": "Importierte Apps werden möglicherweise fälschlicherweise als \"Nicht installiert\" angezeigt. Um dies zu beheben, installieren Sie sie erneut über Obtainium. Dies hat keine Auswirkungen auf App-Daten. Es betrifft nur URL- und Drittanbieter-Importmethoden.",
|
||||
"importErrors": "Importfehler",
|
||||
"importedXOfYApps": "{} von {} Apps importiert.",
|
||||
"followingURLsHadErrors": "Bei folgenden URLs traten Fehler auf:",
|
||||
"okay": "Okay",
|
||||
"selectURL": "URL auswählen",
|
||||
"selectURLs": "URLs auswählen",
|
||||
"pick": "Auswählen",
|
||||
"theme": "Theme",
|
||||
"dark": "Dunkel",
|
||||
"light": "Hell",
|
||||
"followSystem": "System folgen",
|
||||
"obtainium": "Obtainium",
|
||||
"materialYou": "Material You",
|
||||
"useBlackTheme": "Verwende Pure Black Dark Theme",
|
||||
"appSortBy": "App sortieren nach",
|
||||
"authorName": "Autor/Name",
|
||||
"nameAuthor": "Name/Autor",
|
||||
"asAdded": "Wie hinzugefügt",
|
||||
"appSortOrder": "App sortieren nach",
|
||||
"ascending": "Aufsteigend",
|
||||
"descending": "Absteigend",
|
||||
"bgUpdateCheckInterval": "Prüfintervall für Hintergrundaktualisierung",
|
||||
"neverManualOnly": "Nie - nur manuell",
|
||||
"appearance": "Aussehen",
|
||||
"showWebInAppView": "Quellwebseite in der App-Ansicht anzeigen",
|
||||
"pinUpdates": "Apps mit Aktualisierungen oben anheften",
|
||||
"updates": "Aktualisierungen",
|
||||
"sourceSpecific": "Quellenspezifisch",
|
||||
"appSource": "App-Quelle",
|
||||
"noLogs": "Keine Protokolle",
|
||||
"appLogs": "App Protokolle",
|
||||
"close": "Schließen",
|
||||
"share": "Teilen",
|
||||
"appNotFound": "App nicht gefunden",
|
||||
"obtainiumExportHyphenatedLowercase": "obtainium-export",
|
||||
"pickAnAPK": "APK auswählen",
|
||||
"appHasMoreThanOnePackage": "{} verfügt über mehr als ein Paket:",
|
||||
"deviceSupportsXArch": "Ihr Gerät unterstützt die CPU-Architektur {}.",
|
||||
"deviceSupportsFollowingArchs": "Ihr Gerät unterstützt die folgenden CPU-Architekturen:",
|
||||
"warning": "Warnung",
|
||||
"sourceIsXButPackageFromYPrompt": "Die App-Quelle ist '{}', aber das Release-Paket stammt von '{}'. Fortfahren?",
|
||||
"updatesAvailable": "Aktualisierungen verfügbar",
|
||||
"updatesAvailableNotifDescription": "Benachrichtigt den Nutzer, dass Aktualisierungen für eine oder mehrere von Obtainium verfolgte Apps verfügbar sind",
|
||||
"noNewUpdates": "Keine neuen Aktualisierungen.",
|
||||
"xHasAnUpdate": "{} hat eine Aktualisierung.",
|
||||
"appsUpdated": "Apps aktualisiert",
|
||||
"appsUpdatedNotifDescription": "Benachrichtigt den Benutzer, dass Aktualisierungen für eine oder mehrere Apps im Hintergrund durchgeführt wurden",
|
||||
"xWasUpdatedToY": "{} wurde auf {} aktualisiert.",
|
||||
"errorCheckingUpdates": "Fehler beim Prüfen auf Aktualisierungen",
|
||||
"errorCheckingUpdatesNotifDescription": "Eine Benachrichtigung, die angezeigt wird, wenn die Prüfung der Hintergrundaktualisierung fehlschlägt",
|
||||
"appsRemoved": "Apps entfernt",
|
||||
"appsRemovedNotifDescription": "Benachrichtigt den Benutzer, dass eine oder mehrere Apps aufgrund von Fehlern beim Laden entfernt wurden",
|
||||
"xWasRemovedDueToErrorY": "{} wurde aufgrund des folgenden Fehlers entfernt: {}",
|
||||
"completeAppInstallation": "App Installation abschließen",
|
||||
"obtainiumMustBeOpenToInstallApps": "Obtainium muss geöffnet sein, um Apps zu installieren",
|
||||
"completeAppInstallationNotifDescription": "Aufforderung an den Benutzer, zu Obtainium zurückzukehren, um die Installation einer App abzuschließen",
|
||||
"checkingForUpdates": "Nach Aktualisierungen suchen",
|
||||
"checkingForUpdatesNotifDescription": "Vorübergehende Benachrichtigung, die bei der Suche nach Aktualisierungen angezeigt wird",
|
||||
"pleaseAllowInstallPerm": "Bitte erlauben Sie Obtainium die Installation von Apps",
|
||||
"trackOnly": "Nur Nachverfolgen",
|
||||
"errorWithHttpStatusCode": "Fehler {}",
|
||||
"versionCorrectionDisabled": "Versionskorrektur deaktiviert (Plugin scheint nicht zu funktionieren)",
|
||||
"unknown": "Unbekannt",
|
||||
"none": "Keine",
|
||||
"never": "Nie",
|
||||
"latestVersionX": "Neueste Version: {}",
|
||||
"installedVersionX": "Installierte Version: {}",
|
||||
"lastUpdateCheckX": "Letzte Aktualisierungsprüfung: {}",
|
||||
"remove": "Entfernen",
|
||||
"yesMarkUpdated": "Ja, als aktualisiert markieren",
|
||||
"fdroid": "F-Droid Official",
|
||||
"appIdOrName": "App ID oder Name",
|
||||
"appId": "App ID",
|
||||
"appWithIdOrNameNotFound": "Es wurde keine App mit dieser ID oder diesem Namen gefunden",
|
||||
"reposHaveMultipleApps": "Repos können mehrere Apps enthalten",
|
||||
"fdroidThirdPartyRepo": "F-Droid Third-Party Repo",
|
||||
"steam": "Steam",
|
||||
"steamMobile": "Steam Mobile",
|
||||
"steamChat": "Steam Chat",
|
||||
"install": "Installieren",
|
||||
"markInstalled": "Als Installiert markieren",
|
||||
"update": "Aktualisieren",
|
||||
"markUpdated": "Als Aktuell markieren",
|
||||
"additionalOptions": "Zusätzliche Optionen",
|
||||
"disableVersionDetection": "Versionsermittlung deaktivieren",
|
||||
"noVersionDetectionExplanation": "Diese Option sollte nur für Apps verwendet werden, bei denen die Versionserkennung nicht korrekt funktioniert.",
|
||||
"downloadingX": "Lade {} herunter",
|
||||
"downloadNotifDescription": "Benachrichtigt den Nutzer über den Fortschritt beim Herunterladen einer App",
|
||||
"noAPKFound": "Keine APK gefunden",
|
||||
"noVersionDetection": "Keine Versionserkennung",
|
||||
"categorize": "Kategorisieren",
|
||||
"categories": "Kategorien",
|
||||
"category": "Kategorie",
|
||||
"noCategory": "Keine Kategorie",
|
||||
"noCategories": "Keine Kategorien",
|
||||
"deleteCategoriesQuestion": "Kategorien löschen?",
|
||||
"categoryDeleteWarning": "Alle Apps in gelöschten Kategorien werden auf nicht kategorisiert gesetzt.",
|
||||
"addCategory": "Kategorie hinzufügen",
|
||||
"label": "Bezeichnung",
|
||||
"language": "Sprache",
|
||||
"copiedToClipboard": "In die Zwischenablage kopiert",
|
||||
"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": "Nach Kategorie gruppieren",
|
||||
"autoApkFilterByArch": "Nach Möglichkeit versuchen, APKs nach CPU-Architektur zu filtern",
|
||||
"overrideSource": "Quelle überschreiben",
|
||||
"dontShowAgain": "Nicht noch einmal zeigen",
|
||||
"dontShowTrackOnlyWarnings": "Warnung für 'Nur Nachverfolgen' nicht anzeigen",
|
||||
"dontShowAPKOriginWarnings": "Warnung für APK-Herkunft nicht anzeigen",
|
||||
"moveNonInstalledAppsToBottom": "Nicht installierte Apps ans Ende der Apps Ansicht verschieben",
|
||||
"gitlabPATLabel": "GitLab Personal Access Token (Aktiviert Suche)",
|
||||
"about": "Über",
|
||||
"requiresCredentialsInSettings": "Benötigt zusätzliche Anmeldedaten (in den Einstellungen)",
|
||||
"checkOnStart": "Überprüfe einmalig beim Start",
|
||||
"tryInferAppIdFromCode": "Try inferring App ID from source code",
|
||||
"removeAppQuestion": {
|
||||
"one": "App entfernen?",
|
||||
"other": "Apps 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"
|
||||
},
|
||||
"bgUpdateGotErrorRetryInMinutes": {
|
||||
"one": "Bei der Aktualisierungsprüfung im Hintergrund wurde ein {} festgestellt, eine erneute Prüfung wird in {} Minute geplant",
|
||||
"other": "Bei der Aktualisierungsprüfung im Hintergrund wurde ein {} festgestellt, eine erneute Prüfung wird in {} Minuten geplant"
|
||||
},
|
||||
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
|
||||
"one": "Hintergrundaktualisierungsprüfung fand {} Aktualisierung - benachrichtigt den Benutzer, falls erforderlich",
|
||||
"other": "Hintergrundaktualisierungsprüfung fand {} Aktualisierungen - benachrichtigt den Benutzer, falls erforderlich"
|
||||
},
|
||||
"apps": {
|
||||
"one": "{} App",
|
||||
"other": "{} Apps"
|
||||
},
|
||||
"url": {
|
||||
"one": "{} URL",
|
||||
"other": "{} URLs"
|
||||
},
|
||||
"minute": {
|
||||
"one": "{} Minute",
|
||||
"other": "{} Minuten"
|
||||
},
|
||||
"hour": {
|
||||
"one": "{} Stunde",
|
||||
"other": "{} Stunden"
|
||||
},
|
||||
"day": {
|
||||
"one": "{} Tag",
|
||||
"other": "{} Tage"
|
||||
},
|
||||
"clearedNLogsBeforeXAfterY": {
|
||||
"one": "{n} Protokoll gelöscht (vorher = {vorher}, nachher = {nachher})",
|
||||
"other": "{n} Protokolle gelöscht (vorher = {vorher}, nachher = {nachher})"
|
||||
},
|
||||
"xAndNMoreUpdatesAvailable": {
|
||||
"one": "{} und 1 weitere App haben Aktualisierungen.",
|
||||
"other": "{} und {} weitere Apps haben Aktualisierungen."
|
||||
},
|
||||
"xAndNMoreUpdatesInstalled": {
|
||||
"one": "{} und 1 weitere Anwendung wurden aktualisiert.",
|
||||
"other": "{} und {} weitere Anwendungen wurden aktualisiert."
|
||||
}
|
||||
}
|
@ -20,7 +20,6 @@
|
||||
"githubPATLabel": "GitHub Personal Access Token (Increases Rate Limit)",
|
||||
"githubPATHint": "PAT must be in this format: username:token",
|
||||
"githubPATFormat": "username:token",
|
||||
"githubPATLinkText": "'About GitHub PATs",
|
||||
"includePrereleases": "Include prereleases",
|
||||
"fallbackToOlderReleases": "Fallback to older releases",
|
||||
"filterReleaseTitlesByRegEx": "Filter Release Titles by Regular Expression",
|
||||
@ -37,11 +36,12 @@
|
||||
"xIsTrackOnly": "{} is Track-Only",
|
||||
"source": "Source",
|
||||
"app": "App",
|
||||
"appsFromSourceAreTrackOnly": "Apps from this source are 'Track-Only'.' ",
|
||||
"appsFromSourceAreTrackOnly": "Apps from this source are 'Track-Only'.",
|
||||
"youPickedTrackOnly": "You have selected the 'Track-Only' option.",
|
||||
"trackOnlyAppDescription": "The App will be tracked for updates, but Obtainium will not be able to download or install it.",
|
||||
"cancelled": "Cancelled",
|
||||
"appAlreadyAdded": "App already added",
|
||||
"alreadyUpToDateQuestion": "App Already up to Date?",
|
||||
"addApp": "Add App",
|
||||
"appSourceURL": "App Source URL",
|
||||
"error": "Error",
|
||||
@ -57,7 +57,7 @@
|
||||
"noAppsForFilter": "No Apps for Filter",
|
||||
"byX": "By {}",
|
||||
"percentProgress": "Progress: {}%",
|
||||
"pleaseWait": "Please Wait...",
|
||||
"pleaseWait": "Please Wait",
|
||||
"updateAvailable": "Update Available",
|
||||
"estimateInBracketsShort": "(Est.)",
|
||||
"notInstalled": "Not Installed",
|
||||
@ -73,7 +73,6 @@
|
||||
"changeX": "Change {}",
|
||||
"installUpdateApps": "Install/Update Apps",
|
||||
"installUpdateSelectedApps": "Install/Update Selected Apps",
|
||||
"onlyAppliesToInstalledAndOutdatedApps": "Only applies to installed but out of date Apps whose install status cannot be automatically detected.",
|
||||
"markXSelectedAppsAsUpdated": "Mark {} Selected Apps as Updated?",
|
||||
"no": "No",
|
||||
"yes": "Yes",
|
||||
@ -122,6 +121,7 @@
|
||||
"followSystem": "Follow System",
|
||||
"obtainium": "Obtainium",
|
||||
"materialYou": "Material You",
|
||||
"useBlackTheme": "Use Pure Black Dark Theme",
|
||||
"appSortBy": "App Sort By",
|
||||
"authorName": "Author/Name",
|
||||
"nameAuthor": "Name/Author",
|
||||
@ -134,7 +134,7 @@
|
||||
"appearance": "Appearance",
|
||||
"showWebInAppView": "Show Source Webpage in App View",
|
||||
"pinUpdates": "Pin Updates to Top of Apps View",
|
||||
"updates": "Updated",
|
||||
"updates": "Updates",
|
||||
"sourceSpecific": "Source-Specific",
|
||||
"appSource": "App Source",
|
||||
"noLogs": "No Logs",
|
||||
@ -169,6 +169,75 @@
|
||||
"pleaseAllowInstallPerm": "Please allow Obtainium to install Apps",
|
||||
"trackOnly": "Track-Only",
|
||||
"errorWithHttpStatusCode": "Error {}",
|
||||
"versionCorrectionDisabled": "Version correction disabled (plugin doesn't seem to work)",
|
||||
"unknown": "Unknown",
|
||||
"none": "None",
|
||||
"never": "Never",
|
||||
"latestVersionX": "Latest Version: {}",
|
||||
"installedVersionX": "Installed Version: {}",
|
||||
"lastUpdateCheckX": "Last Update Check: {}",
|
||||
"remove": "Remove",
|
||||
"yesMarkUpdated": "Yes, Mark as Updated",
|
||||
"fdroid": "F-Droid Official",
|
||||
"appIdOrName": "App ID or Name",
|
||||
"appId": "App ID",
|
||||
"appWithIdOrNameNotFound": "No App was found with that ID or Name",
|
||||
"reposHaveMultipleApps": "Repos may contain multiple Apps",
|
||||
"fdroidThirdPartyRepo": "F-Droid Third-Party Repo",
|
||||
"steam": "Steam",
|
||||
"steamMobile": "Steam Mobile",
|
||||
"steamChat": "Steam Chat",
|
||||
"install": "Install",
|
||||
"markInstalled": "Mark Installed",
|
||||
"update": "Update",
|
||||
"markUpdated": "Mark Updated",
|
||||
"additionalOptions": "Additional Options",
|
||||
"disableVersionDetection": "Disable Version Detection",
|
||||
"noVersionDetectionExplanation": "This option should only be used for Apps where version detection does not work correctly.",
|
||||
"downloadingX": "Downloading {}",
|
||||
"downloadNotifDescription": "Notifies the user of the progress in downloading an App",
|
||||
"noAPKFound": "No APK found",
|
||||
"noVersionDetection": "No version detection",
|
||||
"categorize": "Categorize",
|
||||
"categories": "Categories",
|
||||
"category": "Category",
|
||||
"noCategory": "No Category",
|
||||
"noCategories": "No Categories",
|
||||
"deleteCategoriesQuestion": "Delete Categories?",
|
||||
"categoryDeleteWarning": "All Apps in deleted categories will be set to uncategorized.",
|
||||
"addCategory": "Add Category",
|
||||
"label": "Label",
|
||||
"language": "Language",
|
||||
"copiedToClipboard": "Copied to Clipboard",
|
||||
"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",
|
||||
"autoApkFilterByArch": "Attempt to filter APKs by CPU architecture if possible",
|
||||
"overrideSource": "Override Source",
|
||||
"dontShowAgain": "Don't show this again",
|
||||
"dontShowTrackOnlyWarnings": "Don't Show 'Track-Only' Warnings",
|
||||
"dontShowAPKOriginWarnings": "Don't Show APK Origin Warnings",
|
||||
"moveNonInstalledAppsToBottom": "Move Non-Installed Apps to Bottom of Apps View",
|
||||
"gitlabPATLabel": "GitLab Personal Access Token (Enables Search)",
|
||||
"about": "About",
|
||||
"requiresCredentialsInSettings": "This needs additional credentials (in Settings)",
|
||||
"checkOnStart": "Check Once on Start",
|
||||
"tryInferAppIdFromCode": "Try inferring App ID from source code",
|
||||
"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"
|
||||
@ -206,11 +275,11 @@
|
||||
"other": "Cleared {n} logs (before = {before}, after = {after})"
|
||||
},
|
||||
"xAndNMoreUpdatesAvailable": {
|
||||
"one": "{} and {} more app have updated.",
|
||||
"one": "{} and 1 more app have updates.",
|
||||
"other": "{} and {} more apps have updates."
|
||||
},
|
||||
"xAndNMoreUpdatesInstalled": {
|
||||
"one": "{} and {} more app were updated.",
|
||||
"one": "{} and 1 more app were updated.",
|
||||
"other": "{} and {} more apps were updated."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
285
assets/translations/es.json
Normal file
@ -0,0 +1,285 @@
|
||||
{
|
||||
"invalidURLForSource": "URL de la aplicación {} no válida",
|
||||
"noReleaseFound": "No se ha podido encontrar una versión válida",
|
||||
"noVersionFound": "No se ha podido determinar la versión de la publicación",
|
||||
"urlMatchesNoSource": "La URL no coincide con ninguna fuente conocida",
|
||||
"cantInstallOlderVersion": "No se puede instalar una versión previa de la aplicación",
|
||||
"appIdMismatch": "La ID del paquete descargado no coincide con la ID de la aplicación instalada",
|
||||
"functionNotImplemented": "Esta clase no ha implementado esta función",
|
||||
"placeholder": "Espacio reservado",
|
||||
"someErrors": "Han ocurrido algunos errores",
|
||||
"unexpectedError": "Error Inesperado",
|
||||
"ok": "Correcto",
|
||||
"and": "y",
|
||||
"startedBgUpdateTask": "Empezada la tarea de comprobación de actualizaciones en segundo plano",
|
||||
"bgUpdateIgnoreAfterIs": "El parámetro ignoreAfter de la actualización en segundo plano es {}",
|
||||
"startedActualBGUpdateCheck": "Ha comenzado la comprobación de actualizaciones en segundo plano",
|
||||
"bgUpdateTaskFinished": "Ha finalizado la comprobación de actualizaciones en segundo plano",
|
||||
"firstRun": "Esta es la primera ejecución de Obtainium",
|
||||
"settingUpdateCheckIntervalTo": "Cambiando intervalo de actualización a {}",
|
||||
"githubPATLabel": "Token de Acceso Personal de GitHub (Reduce tiempos de espera)",
|
||||
"githubPATHint": "El TAP debe tener este formato: nombre_de_usuario:token",
|
||||
"githubPATFormat": "nombre_de_usuario:token",
|
||||
"includePrereleases": "Incluir versiones preliminares",
|
||||
"fallbackToOlderReleases": "Retorceder a versiones previas",
|
||||
"filterReleaseTitlesByRegEx": "Filtra Títulos de Versiones mediantes Expresiones Regulares",
|
||||
"invalidRegEx": "Expresión regular inválida",
|
||||
"noDescription": "Sin descripción",
|
||||
"cancel": "Cancelar",
|
||||
"continue": "Continuar",
|
||||
"requiredInBrackets": "(Requerido)",
|
||||
"dropdownNoOptsError": "ERROR: EL DESPLEGABLE DEBE TENER AL MENOS UNA OPCIÓN",
|
||||
"colour": "Color",
|
||||
"githubStarredRepos": "Repositorios favoritos de GitHub",
|
||||
"uname": "Nombre de usuario",
|
||||
"wrongArgNum": "Número de argumentos provistos inválido",
|
||||
"xIsTrackOnly": "{} es de 'Solo Seguimiento'",
|
||||
"source": "Origen",
|
||||
"app": "Aplicación",
|
||||
"appsFromSourceAreTrackOnly": "Las aplicaciones de este origen son de 'Solo Seguimiento'.",
|
||||
"youPickedTrackOnly": "Debes seleccionar la opción de 'Solo Seguimiento'.",
|
||||
"trackOnlyAppDescription": "Se monitorizará la aplicación en busca de actualizaciones, pero Obtainium no será capaz de descargarla o acutalizarla.",
|
||||
"cancelled": "Cancelado",
|
||||
"appAlreadyAdded": "Aplicación ya añadida",
|
||||
"alreadyUpToDateQuestion": "¿Aplicación ya actualizada?",
|
||||
"addApp": "Añadir Aplicación",
|
||||
"appSourceURL": "URL de Origen de la Aplicación",
|
||||
"error": "Error",
|
||||
"add": "Añadir",
|
||||
"searchSomeSourcesLabel": "Buscar (Solo Algunas Fuentes)",
|
||||
"search": "Buscar",
|
||||
"additionalOptsFor": "Opciones Adicionales para {}",
|
||||
"supportedSourcesBelow": "Fuentes Soportadas:",
|
||||
"trackOnlyInBrackets": "(Solo Seguimiento)",
|
||||
"searchableInBrackets": "(Soporta Búsquedas)",
|
||||
"appsString": "Aplicaciones",
|
||||
"noApps": "Sin Aplicaciones",
|
||||
"noAppsForFilter": "Sin Aplicaciones para Filtrar",
|
||||
"byX": "Por {}",
|
||||
"percentProgress": "Progreso: {}%",
|
||||
"pleaseWait": "Por favor, espere",
|
||||
"updateAvailable": "Actualización Disponible",
|
||||
"estimateInBracketsShort": "(Aprox.)",
|
||||
"notInstalled": "No Instalado",
|
||||
"estimateInBrackets": "(Aproximado)",
|
||||
"selectAll": "Seleccionar Todo",
|
||||
"deselectN": "Deseleccionar {}",
|
||||
"xWillBeRemovedButRemainInstalled": "{} será borrada de Obtainium pero continuará instalada en el dispositivo.",
|
||||
"removeSelectedAppsQuestion": "¿Borrar aplicaciones seleccionadas?",
|
||||
"removeSelectedApps": "Borrar Aplicaciones Seleccionadas",
|
||||
"updateX": "Actualizar {}",
|
||||
"installX": "Instalar {}",
|
||||
"markXTrackOnlyAsUpdated": "Marcar {}\n(Solo Seguimient)\ncomo Actualizada",
|
||||
"changeX": "Cambiar {}",
|
||||
"installUpdateApps": "Instalar/Actualizar Aplicaciones",
|
||||
"installUpdateSelectedApps": "Instalar/Actualizar Aplicaciones Seleccionadas",
|
||||
"markXSelectedAppsAsUpdated": "¿Marcar {} Aplicaciones Seleccionadas como Actualizadas?",
|
||||
"no": "No",
|
||||
"yes": "Sí",
|
||||
"markSelectedAppsUpdated": "Marcar Aplicaciones Seleccionadas como Actualizadas",
|
||||
"pinToTop": "Fijar arriba",
|
||||
"unpinFromTop": "Desfijar de arriba",
|
||||
"resetInstallStatusForSelectedAppsQuestion": "¿Restuarar Estado de Instalación para las Aplicaciones Seleccionadas?",
|
||||
"installStatusOfXWillBeResetExplanation": "El estado de instalación de las aplicaciones seleccionadas será restaurado.\n\nEsto puede ser de utilidad cuando la versión de la aplicación mostrada en Obtainium es incorrecta por actualizaciones fallidas u otros motivos.",
|
||||
"shareSelectedAppURLs": "Compartir URLs de las Aplicaciones Seleccionadas",
|
||||
"resetInstallStatus": "Restaurar Estado de Instalación",
|
||||
"more": "Más",
|
||||
"removeOutdatedFilter": "Elimiar Filtro de Aplicaciones Desactualizado",
|
||||
"showOutdatedOnly": "Mostrar solo Aplicaciones Desactualizadas",
|
||||
"filter": "Filtrar",
|
||||
"filterActive": "Filtrar *",
|
||||
"filterApps": "Filtrar Actualizaciones",
|
||||
"appName": "Nombre de la Aplicación",
|
||||
"author": "Autor",
|
||||
"upToDateApps": "Aplicaciones Actualizadas",
|
||||
"nonInstalledApps": "Aplicaciones No Instaladas",
|
||||
"importExport": "Importar/Exportar",
|
||||
"settings": "Ajustes",
|
||||
"exportedTo": "Exportado a {}",
|
||||
"obtainiumExport": "Exportar Obtainium",
|
||||
"invalidInput": "Input incorrecto",
|
||||
"importedX": "Importado {}",
|
||||
"obtainiumImport": "Importar Obtainium",
|
||||
"importFromURLList": "Importar desde lista de URLs",
|
||||
"searchQuery": "Consulta de Búsqueda",
|
||||
"appURLList": "Lista de URLs de Aplicaciones",
|
||||
"line": "Línea",
|
||||
"searchX": "Buscar {}",
|
||||
"noResults": "Resultados no encontrados",
|
||||
"importX": "Importar {}",
|
||||
"importedAppsIdDisclaimer": "Las Aplicaciones Importadas pueden mostrarse incorrectamente como \"No Instalada\".\nPara arreglar esto, reinstálalas a través de Obtainium.\nEsto no debería afectar a los datos de las aplicaciones.\n\nSolo afecta a las URLs y a los métodos de importación mediante terceros.",
|
||||
"importErrors": "Import Errors",
|
||||
"importedXOfYApps": "{} de {} Aplicaciones importadas.",
|
||||
"followingURLsHadErrors": "Las siguientes URLs tuvieron problemas:",
|
||||
"okay": "Correcto",
|
||||
"selectURL": "Seleccionar URL",
|
||||
"selectURLs": "Seleccionar URLs",
|
||||
"pick": "Escoger",
|
||||
"theme": "Tema",
|
||||
"dark": "Oscuro",
|
||||
"light": "Claro",
|
||||
"followSystem": "Seguir al Sistema",
|
||||
"obtainium": "Obtainium",
|
||||
"materialYou": "Material You",
|
||||
"useBlackTheme": "Usar tema oscuro con negros puros",
|
||||
"appSortBy": "Ordenar Aplicaciones Por",
|
||||
"authorName": "Autor/Nombre",
|
||||
"nameAuthor": "Nombre/Autor",
|
||||
"asAdded": "Según se Añadieron",
|
||||
"appSortOrder": "Orden de Clasificación de Aplicaciones",
|
||||
"ascending": "Ascendente",
|
||||
"descending": "Descendente",
|
||||
"bgUpdateCheckInterval": "Intervalo de Comprobación de Actualizaciones en Segundo Plano",
|
||||
"neverManualOnly": "Nunca - Solo Manual",
|
||||
"appearance": "Apariencia",
|
||||
"showWebInAppView": "Mostrar Vista de la Web de Origen",
|
||||
"pinUpdates": "Fijar Actualizaciones en la Parte Superior de la Vista de Aplicaciones",
|
||||
"updates": "Actualizaciones",
|
||||
"sourceSpecific": "Fuente Específica",
|
||||
"appSource": "Fuente de la Aplicación",
|
||||
"noLogs": "Sin Logs",
|
||||
"appLogs": "Logs de la Aplicación",
|
||||
"close": "Cerrar",
|
||||
"share": "Compartir",
|
||||
"appNotFound": "Aplicación no encontrada",
|
||||
"obtainiumExportHyphenatedLowercase": "obtainium-export",
|
||||
"pickAnAPK": "Elige una APK",
|
||||
"appHasMoreThanOnePackage": "{} tiene más de un paquete:",
|
||||
"deviceSupportsXArch": "Tu dispositivo soporta las siguientes arquitecturas de procesador: {}.",
|
||||
"deviceSupportsFollowingArchs": "Tu dispositivo soporta las siguientes arquitecturas de procesador:",
|
||||
"warning": "Aviso",
|
||||
"sourceIsXButPackageFromYPrompt": "La fuente de la aplicación es '{}' pero el paquete de la actualización viene de '{}'. ¿Desea continuar?",
|
||||
"updatesAvailable": "Actualizaciones Disponibles",
|
||||
"updatesAvailableNotifDescription": "Notifica al usuario de que hay actualizaciones para una o más aplicaciones monitorizadas por Obtainium",
|
||||
"noNewUpdates": "No hay nuevas actualizaciones.",
|
||||
"xHasAnUpdate": "{} tiene una actualización.",
|
||||
"appsUpdated": "Aplicaciones Actualizadas",
|
||||
"appsUpdatedNotifDescription": "Notifica al usuario de que una o más aplicaciones han sido actualizadas en segundo plano",
|
||||
"xWasUpdatedToY": "{} ha sido actualizada a {}.",
|
||||
"errorCheckingUpdates": "Error Buscando Actualizaciones",
|
||||
"errorCheckingUpdatesNotifDescription": "Una notificación que muestra cuándo la comprobación de actualizaciones en segundo plano falla",
|
||||
"appsRemoved": "Aplicaciones Eliminadas",
|
||||
"appsRemovedNotifDescription": "Notifica al usuario que una o más aplicaciones fueron eliminadas por problemas al cargarlas",
|
||||
"xWasRemovedDueToErrorY": "{} ha sido eliminada por: {}",
|
||||
"completeAppInstallation": "Instalación Completa de la Aplicación",
|
||||
"obtainiumMustBeOpenToInstallApps": "Obtainium debe estar abierta para instalar aplicaciones",
|
||||
"completeAppInstallationNotifDescription": "Pide al usuario volver a Obtainium para teminar de instalar una aplicación",
|
||||
"checkingForUpdates": "Buscando Actualizaciones",
|
||||
"checkingForUpdatesNotifDescription": "Notificación temporal que aparece al buscar actualizaciones",
|
||||
"pleaseAllowInstallPerm": "Por favor, permite a Obtainium instalar aplicaciones",
|
||||
"trackOnly": "Solo Seguimiento",
|
||||
"errorWithHttpStatusCode": "Error {}",
|
||||
"versionCorrectionDisabled": "Corrección de versiones desactivada (el plugin parece no funcionar)",
|
||||
"unknown": "Desconocido",
|
||||
"none": "Ninguno",
|
||||
"never": "Nunca",
|
||||
"latestVersionX": "Última Versión: {}",
|
||||
"installedVersionX": "Versión Instalada: {}",
|
||||
"lastUpdateCheckX": "Última Comprobación: {}",
|
||||
"remove": "Eliminar",
|
||||
"yesMarkUpdated": "Sí, Marcar como Actualizada",
|
||||
"fdroid": "Repositorio oficial de F-Droid",
|
||||
"appIdOrName": "ID o Nombre de la Aplicación",
|
||||
"appId": "ID de la Aplicación",
|
||||
"appWithIdOrNameNotFound": "No se han encontrado aplicaciones con esa ID o nombre",
|
||||
"reposHaveMultipleApps": "Los repositorios pueden contener varias aplicaciones",
|
||||
"fdroidThirdPartyRepo": "Rpositorios de terceros de F-Droid",
|
||||
"steam": "Steam",
|
||||
"steamMobile": "Steam Mobile",
|
||||
"steamChat": "Steam Chat",
|
||||
"install": "Instalar",
|
||||
"markInstalled": "Marcar como Instalda",
|
||||
"update": "Actualizar",
|
||||
"markUpdated": "Marcar como Actualizada",
|
||||
"additionalOptions": "Opciones Adicionales",
|
||||
"disableVersionDetection": "Descativar Detección de Versiones",
|
||||
"noVersionDetectionExplanation": "Esta opción solo se debe usar en aplicaciones en las que la deteción de versiones pueda no funcionar correctamente.",
|
||||
"downloadingX": "Descargando {}",
|
||||
"downloadNotifDescription": "Notifica al usuario de progreso de descarga de una aplicación",
|
||||
"noAPKFound": "APK no encontrada",
|
||||
"noVersionDetection": "Sin detección de versiones",
|
||||
"categorize": "Catogorizar",
|
||||
"categories": "Categorías",
|
||||
"category": "Categoría",
|
||||
"noCategory": "Sin Categoría",
|
||||
"noCategories": "Sin Categorías",
|
||||
"deleteCategoriesQuestion": "¿Borrar Categorías?",
|
||||
"categoryDeleteWarning": "Todas las aplicaciones en las categorías borradas serán margadas como 'Sin Categoría'.",
|
||||
"addCategory": "Añadir Categoría",
|
||||
"label": "Nombre",
|
||||
"language": "Idioma",
|
||||
"copiedToClipboard": "Copiado al Portapapeles",
|
||||
"storagePermissionDenied": "Permiso de Almacenamiento rechazado",
|
||||
"selectedCategorizeWarning": "Esto reemplazará cualquier ajuste de categoría para las aplicaicones seleccionadas.",
|
||||
"filterAPKsByRegEx": "Filtrar APKs mediante Expresiones Regulares",
|
||||
"removeFromObtainium": "Eliminar de Obtainium",
|
||||
"uninstallFromDevice": "Desinstalar del Dispositivo",
|
||||
"onlyWorksWithNonVersionDetectApps": "Solo funciona para aplicaciones con la detección de versiones desactivada.",
|
||||
"releaseDateAsVersion": "Usar Fecha de Publicación como Versión",
|
||||
"releaseDateAsVersionExplanation": "Esta opción solo se debería usar con aplicaciones en las que la detección de versiones no funciona pero hay disponible una fecha de publicación.",
|
||||
"changes": "Cambios",
|
||||
"releaseDate": "Fecha de Publicación",
|
||||
"importFromURLsInFile": "Importar de URls en un Archivo (como OPML)",
|
||||
"versionDetection": "Detección de Versiones",
|
||||
"standardVersionDetection": "Detección de versiones estándar",
|
||||
"groupByCategory": "Agrupar por Categoría",
|
||||
"autoApkFilterByArch": "Tratar de filtrar las APKs mediante arquitecturas de procesador si es posible",
|
||||
"overrideSource": "Sobrescribir Fuente",
|
||||
"dontShowAgain": "No mostrar de nuevo",
|
||||
"dontShowTrackOnlyWarnings": "No mostrar avisos de 'Solo Seguimiento'",
|
||||
"dontShowAPKOriginWarnings": "No mostrar avisos de las fuentes de las APks",
|
||||
"moveNonInstalledAppsToBottom": "Move Non-Installed Apps to Bottom of Apps View",
|
||||
"gitlabPATLabel": "GitLab Personal Access Token (Enables Search)",
|
||||
"about": "About",
|
||||
"requiresCredentialsInSettings": "This needs additional credentials (in Settings)",
|
||||
"checkOnStart": "Check Once on Start",
|
||||
"tryInferAppIdFromCode": "Try inferring App ID from source code",
|
||||
"removeAppQuestion": {
|
||||
"one": "¿Eliminar Aplicación?",
|
||||
"other": "¿Eliminar Aplicaciones?"
|
||||
},
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Muchas peticiones (limitado) - prueba de nuevo en {} minuto",
|
||||
"other": "Muchas peticiones (limitado) - prueba de nuevo en {} minutos"
|
||||
},
|
||||
"bgUpdateGotErrorRetryInMinutes": {
|
||||
"one": "La comprobación de actualizaciones en segundo plano se ha encontrado un {}, se volverá a probar en {} minuto",
|
||||
"other": "La comprobación de actualizaciones en segundo plano se ha encontrado un {}, se volverá a probar en {} minutos"
|
||||
},
|
||||
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
|
||||
"one": "La comprobación de actualizaciones en segundo plano ha encontrado {} actualización - se notificará al usuario si es necesario",
|
||||
"other": "La comprobación de actualizaciones en segundo plano ha encontrado {} actualizaciones - se notificará al usuario si es necesario"
|
||||
},
|
||||
"apps": {
|
||||
"one": "{} Aplicación",
|
||||
"other": "{} Aplicaciones"
|
||||
},
|
||||
"url": {
|
||||
"one": "{} URL",
|
||||
"other": "{} URLs"
|
||||
},
|
||||
"minute": {
|
||||
"one": "{} Minuto",
|
||||
"other": "{} Minutos"
|
||||
},
|
||||
"hour": {
|
||||
"one": "{} Hora",
|
||||
"other": "{} Horas"
|
||||
},
|
||||
"day": {
|
||||
"one": "{} Día",
|
||||
"other": "{} Días"
|
||||
},
|
||||
"clearedNLogsBeforeXAfterY": {
|
||||
"one": "Borrado {n} log (previo a = {before}, posterior a = {after})",
|
||||
"other": "Borrados {n} logs (previos a = {before}, posteriores a = {after})"
|
||||
},
|
||||
"xAndNMoreUpdatesAvailable": {
|
||||
"one": "{} y 1 aplicación más tiene actualizaciones.",
|
||||
"other": "{} y {} aplicaciones más tiene actualizaciones."
|
||||
},
|
||||
"xAndNMoreUpdatesInstalled": {
|
||||
"one": "{} y 1 aplicación más han sido actualizadas.",
|
||||
"other": "{} y {} aplicaciones más han sido actualizadas."
|
||||
}
|
||||
}
|
285
assets/translations/fa.json
Normal file
@ -0,0 +1,285 @@
|
||||
{
|
||||
"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",
|
||||
"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",
|
||||
"useBlackTheme": "استفاده از تم تیره سیاه خالص",
|
||||
"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 Official",
|
||||
"appIdOrName": "شناسه یا نام برنامه",
|
||||
"appId": "App ID",
|
||||
"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": "زبان",
|
||||
"copiedToClipboard": "در کلیپ بورد کپی شد",
|
||||
"storagePermissionDenied": "مجوز ذخیره سازی رد شد",
|
||||
"selectedCategorizeWarning": "این جایگزین تنظیمات دسته بندی موجود برای برنامه های انتخابی می شود.",
|
||||
"filterAPKsByRegEx": "فایلهای APK را با نظم فیلتر کنید",
|
||||
"removeFromObtainium": "از Obtainium حذف کنید",
|
||||
"uninstallFromDevice": "حذف نصب از دستگاه",
|
||||
"onlyWorksWithNonVersionDetectApps": "فقط برای برنامههایی کار میکند که تشخیص نسخه غیرفعال است.",
|
||||
"releaseDateAsVersion": "از تاریخ انتشار به عنوان نسخه استفاده کنید",
|
||||
"releaseDateAsVersionExplanation": "این گزینه فقط باید برای برنامه هایی استفاده شود که تشخیص نسخه به درستی کار نمی کند، اما تاریخ انتشار در دسترس است.",
|
||||
"changes": "تغییرات",
|
||||
"releaseDate": "تاریخ انتشار",
|
||||
"importFromURLsInFile": "وارد کردن از آدرس های اینترنتی موجود در فایل (مانند OPML)",
|
||||
"versionDetection": "تشخیص نسخه",
|
||||
"standardVersionDetection": "تشخیص نسخه استاندارد",
|
||||
"groupByCategory": "گروه بر اساس دسته",
|
||||
"autoApkFilterByArch": "در صورت امکان سعی کنید APKها را بر اساس معماری CPU فیلتر کنید",
|
||||
"overrideSource": "نادیده گرفتن منبع",
|
||||
"dontShowAgain": "دوباره این را نشان نده",
|
||||
"dontShowTrackOnlyWarnings": "هشدار 'فقط ردیابی' را نشان ندهید",
|
||||
"dontShowAPKOriginWarnings": "هشدارهای منبع APK را نشان ندهید",
|
||||
"moveNonInstalledAppsToBottom": "Move Non-Installed Apps to Bottom of Apps View",
|
||||
"gitlabPATLabel": "GitLab Personal Access Token (Enables Search)",
|
||||
"about": "About",
|
||||
"requiresCredentialsInSettings": "This needs additional credentials (in Settings)",
|
||||
"checkOnStart": "Check Once on Start",
|
||||
"tryInferAppIdFromCode": "Try inferring App ID from source code",
|
||||
"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": "{} و {} برنامه دیگر به روز شدند."
|
||||
}
|
||||
}
|
285
assets/translations/fr.json
Normal file
@ -0,0 +1,285 @@
|
||||
{
|
||||
"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",
|
||||
"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",
|
||||
"useBlackTheme": "Use Pure Black Dark Theme",
|
||||
"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 Official",
|
||||
"appIdOrName": "ID ou nom de l'application",
|
||||
"appId": "ID 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",
|
||||
"copiedToClipboard": "Copied to Clipboard",
|
||||
"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",
|
||||
"autoApkFilterByArch": "Attempt to filter APKs by CPU architecture if possible",
|
||||
"overrideSource": "Override Source",
|
||||
"dontShowAgain": "Don't show this again",
|
||||
"dontShowTrackOnlyWarnings": "Don't Show the 'Track-Only' Warning",
|
||||
"dontShowAPKOriginWarnings": "Don't Show APK Origin Warnings",
|
||||
"moveNonInstalledAppsToBottom": "Move Non-Installed Apps to Bottom of Apps View",
|
||||
"gitlabPATLabel": "GitLab Personal Access Token (Enables Search)",
|
||||
"about": "About",
|
||||
"requiresCredentialsInSettings": "This needs additional credentials (in Settings)",
|
||||
"checkOnStart": "Check Once on Start",
|
||||
"tryInferAppIdFromCode": "Try inferring App ID from source code",
|
||||
"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."
|
||||
}
|
||||
}
|
284
assets/translations/hu.json
Normal file
@ -0,0 +1,284 @@
|
||||
{
|
||||
"invalidURLForSource": "Érvénytelen a(z) {} app URL-je",
|
||||
"noReleaseFound": "Nem található megfelelő kiadás",
|
||||
"noVersionFound": "Nem sikerült meghatározni a kiadás verzióját",
|
||||
"urlMatchesNoSource": "Az URL nem egyezik ismert forrással",
|
||||
"cantInstallOlderVersion": "Nem telepíthető egy app régebbi verziója",
|
||||
"appIdMismatch": "A letöltött csomagazonosító nem egyezik a meglévő app azonosítóval",
|
||||
"functionNotImplemented": "Ez az osztály nem valósította meg ezt a függvényt",
|
||||
"placeholder": "Helykitöltő",
|
||||
"someErrors": "Néhány hiba történt",
|
||||
"unexpectedError": "Váratlan hiba",
|
||||
"ok": "Oké",
|
||||
"and": "és",
|
||||
"startedBgUpdateTask": "Háttérfrissítés ellenőrzési feladat elindítva",
|
||||
"bgUpdateIgnoreAfterIs": "Háttérfrissítés ignoreAfter a következő: {}",
|
||||
"startedActualBGUpdateCheck": "Elkezdődött a tényleges háttérfrissítés ellenőrzése",
|
||||
"bgUpdateTaskFinished": "A háttérfrissítés ellenőrzési feladat befejeződött",
|
||||
"firstRun": "Ez az Obtainium első futása",
|
||||
"settingUpdateCheckIntervalTo": "A frissítési intervallum beállítása erre: {}",
|
||||
"githubPATLabel": "GitHub személyes hozzáférési token (megnöveli a díjkorlátot)",
|
||||
"githubPATHint": "A PAT-nak a következő formátumban kell lennie: felhasználónév:token",
|
||||
"githubPATFormat": "felhasználónév:token",
|
||||
"includePrereleases": "Tartalmazza az előzetes kiadásokat",
|
||||
"fallbackToOlderReleases": "Visszatérés a régebbi kiadásokhoz",
|
||||
"filterReleaseTitlesByRegEx": "A kiadás címeinek szűrése reguláris kifejezéssel",
|
||||
"invalidRegEx": "Érvénytelen reguláris kifejezés",
|
||||
"noDescription": "Nincs leírás",
|
||||
"cancel": "Mégse",
|
||||
"continue": "Tovább",
|
||||
"requiredInBrackets": "(Kötelező)",
|
||||
"dropdownNoOptsError": "HIBA: A LEDOBÁST LEGALÁBB EGY OPCIÓHOZ KELL RENDELNI",
|
||||
"colour": "Szín",
|
||||
"githubStarredRepos": "GitHub Csillagos Repo-k",
|
||||
"uname": "Felh.név",
|
||||
"wrongArgNum": "Rossz számú argumentumot adott meg",
|
||||
"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'.",
|
||||
"youPickedTrackOnly": "A 'Csak követés' opciót választotta.",
|
||||
"trackOnlyAppDescription": "Az alkalmazás frissítéseit nyomon követi, de az Obtainium nem tudja letölteni vagy telepíteni.",
|
||||
"cancelled": "Törölve",
|
||||
"appAlreadyAdded": "Az app már hozzáadva",
|
||||
"alreadyUpToDateQuestion": "Az app már naprakész?",
|
||||
"addApp": "App hozzáadás",
|
||||
"appSourceURL": "App forrás URL",
|
||||
"error": "Hiba",
|
||||
"add": "Hozzáadás",
|
||||
"searchSomeSourcesLabel": "Keresés (csak egyes források)",
|
||||
"search": "Keresés",
|
||||
"additionalOptsFor": "További lehetőségek a következőhöz: {}",
|
||||
"supportedSourcesBelow": "Támogatott források:",
|
||||
"trackOnlyInBrackets": "(Csak nyomonkövetés)",
|
||||
"searchableInBrackets": "(Kereshető)",
|
||||
"appsString": "Appok",
|
||||
"noApps": "Nincs App",
|
||||
"noAppsForFilter": "Nincsenek appok a szűrőhöz",
|
||||
"byX": "Fejlesztő: {}",
|
||||
"percentProgress": "Folyamat: {}%",
|
||||
"pleaseWait": "Kis türelmet",
|
||||
"updateAvailable": "Frissítés érhető el",
|
||||
"estimateInBracketsShort": "(Becsült)",
|
||||
"notInstalled": "Nem telepített",
|
||||
"estimateInBrackets": "(Becslés)",
|
||||
"selectAll": "Mindet kiválaszt",
|
||||
"deselectN": "Törölje {} kijelölését",
|
||||
"xWillBeRemovedButRemainInstalled": "A(z) {} el lesz távolítva az Obtainiumból, de továbbra is telepítve marad az eszközön.",
|
||||
"removeSelectedAppsQuestion": "Eltávolítja a kiválasztott appokat?",
|
||||
"removeSelectedApps": "Távolítsa el a kiválasztott appokat",
|
||||
"updateX": "Frissítés: {}",
|
||||
"installX": "Telepítés: {}",
|
||||
"markXTrackOnlyAsUpdated": "Jelölje meg: {}\n(Csak nyomon követhető)\nmint Frissített",
|
||||
"changeX": "Változás {}",
|
||||
"installUpdateApps": "Appok telepítése/frissítése",
|
||||
"installUpdateSelectedApps": "Telepítse/frissítse a kiválasztott appokat",
|
||||
"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 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.",
|
||||
"shareSelectedAppURLs": "Ossza meg a kiválasztott app URL címeit",
|
||||
"resetInstallStatus": "Telepítési állapot visszaállítása",
|
||||
"more": "További",
|
||||
"removeOutdatedFilter": "Távolítsa el az elavult app szűrőt",
|
||||
"showOutdatedOnly": "Csak az elavult appok megjelenítése",
|
||||
"filter": "Szűrő",
|
||||
"filterActive": "Szűrő *",
|
||||
"filterApps": "Appok szűrése",
|
||||
"appName": "App név",
|
||||
"author": "Szerző",
|
||||
"upToDateApps": "Naprakész appok",
|
||||
"nonInstalledApps": "Nem telepített appok",
|
||||
"importExport": "Import/Export",
|
||||
"settings": "Beállítások",
|
||||
"exportedTo": "Exportálva ide {}",
|
||||
"obtainiumExport": "Obtainium Export",
|
||||
"invalidInput": "Hibás bemenet",
|
||||
"importedX": "Importálva innen {}",
|
||||
"obtainiumImport": "Obtainium Import",
|
||||
"importFromURLList": "Importálás URL listából",
|
||||
"searchQuery": "Keresési lekérdezés",
|
||||
"appURLList": "App URL lista",
|
||||
"line": "Sor",
|
||||
"searchX": "Keresés {}",
|
||||
"noResults": "Nincs találat",
|
||||
"importX": "Import {}",
|
||||
"importedAppsIdDisclaimer": "Előfordulhat, hogy az importált appok helytelenül \"Nincs telepítve\" jelzéssel jelennek meg.\nA probléma megoldásához telepítse újra őket az Obtainiumon keresztül.\nEz nem érinti az alkalmazásadatokat.\n\nCsak az URL-ekre és a harmadik féltől származó importálási módszerekre vonatkozik..",
|
||||
"importErrors": "Importálási hibák",
|
||||
"importedXOfYApps": "{}/{} app importálva.",
|
||||
"followingURLsHadErrors": "A következő URL-ek hibákat tartalmaztak:",
|
||||
"okay": "Oké",
|
||||
"selectURL": "Válassza ki az URL-t",
|
||||
"selectURLs": "Kiválasztott URL-ek",
|
||||
"pick": "Válasszon",
|
||||
"theme": "Téma",
|
||||
"dark": "Sötét",
|
||||
"light": "Világos",
|
||||
"followSystem": "Rendszer szerint",
|
||||
"obtainium": "Obtainium",
|
||||
"materialYou": "Material You",
|
||||
"useBlackTheme": "Használjon tiszta fekete sötét témát",
|
||||
"appSortBy": "App rendezés...",
|
||||
"authorName": "Szerző/Név",
|
||||
"nameAuthor": "Név/Szerző",
|
||||
"asAdded": "Mint Hozzáadott",
|
||||
"appSortOrder": "Appok rendezése",
|
||||
"ascending": "Emelkedő",
|
||||
"descending": "Csökkenő",
|
||||
"bgUpdateCheckInterval": "Háttérfrissítés ellenőrzés időköze",
|
||||
"neverManualOnly": "Soha – csak manuális",
|
||||
"appearance": "Megjelenés",
|
||||
"showWebInAppView": "Forrás megjelenítése az Appok nézetben",
|
||||
"pinUpdates": "Frissítések kitűzése az App nézet tetejére",
|
||||
"updates": "Frissítések",
|
||||
"sourceSpecific": "Forrás-specifikus",
|
||||
"appSource": "App forrás",
|
||||
"noLogs": "Nincsenek naplók",
|
||||
"appLogs": "App naplók",
|
||||
"close": "Bezár",
|
||||
"share": "Megoszt",
|
||||
"appNotFound": "App nem található",
|
||||
"obtainiumExportHyphenatedLowercase": "obtainium-export",
|
||||
"pickAnAPK": "Válasszon egy APK-t",
|
||||
"appHasMoreThanOnePackage": "A(z) {} egynél több csomaggal rendelkezik:",
|
||||
"deviceSupportsXArch": "Eszköze támogatja a {} CPU architektúrát.",
|
||||
"deviceSupportsFollowingArchs": "Az eszköze a következő CPU architektúrákat támogatja:",
|
||||
"warning": "Figyelem",
|
||||
"sourceIsXButPackageFromYPrompt": "Az alkalmazás forrása „{}”, de a kiadási csomag innen származik: „{}”. Folytatja?",
|
||||
"updatesAvailable": "Frissítések érhetők el",
|
||||
"updatesAvailableNotifDescription": "Értesíti a felhasználót, hogy frissítések állnak rendelkezésre egy vagy több, az Obtainium által nyomon követett alkalmazáshoz",
|
||||
"noNewUpdates": "Nincsenek új frissítések.",
|
||||
"xHasAnUpdate": "A(z) {} frissítést kapott.",
|
||||
"appsUpdated": "Alkalmazások frissítve",
|
||||
"appsUpdatedNotifDescription": "Értesíti a felhasználót, hogy egy/több app frissítése megtörtént a háttérben",
|
||||
"xWasUpdatedToY": "{} frissítve a következőre: {}.",
|
||||
"errorCheckingUpdates": "Hiba a frissítések keresésekor",
|
||||
"errorCheckingUpdatesNotifDescription": "Értesítés, amely akkor jelenik meg, ha a háttérbeli frissítések ellenőrzése sikertelen",
|
||||
"appsRemoved": "Alkalmazások eltávolítva",
|
||||
"appsRemovedNotifDescription": "Értesíti a felhasználót egy vagy több alkalmazás eltávolításáról a betöltésük során fellépő hibák miatt",
|
||||
"xWasRemovedDueToErrorY": "A(z) {} a következő hiba miatt lett eltávolítva: {}",
|
||||
"completeAppInstallation": "Teljes app telepítés",
|
||||
"obtainiumMustBeOpenToInstallApps": "Az Obtainiumnak megnyitva kell lennie az alkalmazások telepítéséhez",
|
||||
"completeAppInstallationNotifDescription": "Megkéri a felhasználót, hogy térjen vissza az Obtainiumhoz, hogy befejezze az alkalmazás telepítését",
|
||||
"checkingForUpdates": "Frissítések keresése",
|
||||
"checkingForUpdatesNotifDescription": "Átmeneti értesítés, amely a frissítések keresésekor jelenik meg",
|
||||
"pleaseAllowInstallPerm": "Kérjük, engedélyezze az Obtainiumnak az alkalmazások telepítését",
|
||||
"trackOnly": "Csak követés",
|
||||
"errorWithHttpStatusCode": "Hiba {}",
|
||||
"versionCorrectionDisabled": "Verzió korrekció letiltva (úgy tűnik, a beépülő modul nem működik)",
|
||||
"unknown": "Ismeretlen",
|
||||
"none": "Egyik sem",
|
||||
"never": "Soha",
|
||||
"latestVersionX": "Legújabb verzió: {}",
|
||||
"installedVersionX": "Telepített verzió: {}",
|
||||
"lastUpdateCheckX": "Frissítés ellenőrizve: {}",
|
||||
"remove": "Eltávolítás",
|
||||
"yesMarkUpdated": "Igen, megjelölés frissítettként",
|
||||
"fdroid": "F-Droid Official",
|
||||
"appIdOrName": "App ID vagy név",
|
||||
"appId": "App ID",
|
||||
"appWithIdOrNameNotFound": "Nem található app ezzel az azonosítóval vagy névvel",
|
||||
"reposHaveMultipleApps": "A repók több alkalmazást is tartalmazhatnak",
|
||||
"fdroidThirdPartyRepo": "F-Droid Harmadik-fél Repo",
|
||||
"steam": "Steam",
|
||||
"steamMobile": "Steam Mobile",
|
||||
"steamChat": "Steam Chat",
|
||||
"install": "Telepít",
|
||||
"markInstalled": "Telepítettnek jelöl",
|
||||
"update": "Frissít",
|
||||
"markUpdated": "Frissítettnek jelöl",
|
||||
"additionalOptions": "További lehetőségek",
|
||||
"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",
|
||||
"noAPKFound": "Nem található APK",
|
||||
"noVersionDetection": "Nincs verzió érzékelés",
|
||||
"categorize": "Kategorizálás",
|
||||
"categories": "Kategóriák",
|
||||
"category": "Kategória",
|
||||
"noCategory": "Nincs kategória",
|
||||
"deleteCategoryQuestion": "Törli a kategóriát?",
|
||||
"categoryDeleteWarning": "A(z) {} összes app kategorizálatlan állapotba kerül.",
|
||||
"addCategory": "Új kategória",
|
||||
"label": "Címke",
|
||||
"language": "Nyelv",
|
||||
"copiedToClipboard": "Másolva a vágólapra",
|
||||
"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": "Csoportosítás Kategória alapján",
|
||||
"autoApkFilterByArch": "Ha lehetséges, próbálja CPU architektúra szerint szűrni az APK-kat",
|
||||
"overrideSource": "Forrás felülbírálása",
|
||||
"dontShowAgain": "Ne mutassa ezt újra",
|
||||
"dontShowTrackOnlyWarnings": "Ne jelenítsen meg 'Csak nyomon követés' figyelmeztetést",
|
||||
"dontShowAPKOriginWarnings": "Ne jelenítsen meg az APK eredetére vonatkozó figyelmeztetéseket",
|
||||
"moveNonInstalledAppsToBottom": "Helyezze át a nem telepített appokat az App nézet aljára",
|
||||
"gitlabPATLabel": "GitLab Personal Access Token (Engedélyezi a Keresést)",
|
||||
"about": "Rólunk",
|
||||
"requiresCredentialsInSettings": "Ehhez további hitelesítő adatokra van szükség (a Beállításokban)",
|
||||
"checkOnStart": "Egyszer az indításkor",
|
||||
"tryInferAppIdFromCode": "Try inferring App ID from source code",
|
||||
"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"
|
||||
},
|
||||
"bgUpdateGotErrorRetryInMinutes": {
|
||||
"one": "A háttérfrissítések ellenőrzése {}-t észlelt, {} perc múlva ütemezi az újrapróbálkozást",
|
||||
"other": "A háttérfrissítések ellenőrzése {}-t észlelt, {} perc múlva ütemezi az újrapróbálkozást"
|
||||
},
|
||||
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
|
||||
"one": "A háttérfrissítés ellenőrzése {} frissítést talált – szükség esetén értesíti a felhasználót",
|
||||
"other": "A háttérfrissítés ellenőrzése {} frissítést talált – szükség esetén értesíti a felhasználót"
|
||||
},
|
||||
"apps": {
|
||||
"one": "{} app",
|
||||
"other": "{} app"
|
||||
},
|
||||
"url": {
|
||||
"one": "{} URL",
|
||||
"other": "{} URL"
|
||||
},
|
||||
"minute": {
|
||||
"one": "{} perc",
|
||||
"other": "{} perc"
|
||||
},
|
||||
"hour": {
|
||||
"one": "{} óra",
|
||||
"other": "{} óra"
|
||||
},
|
||||
"day": {
|
||||
"one": "{} nap",
|
||||
"other": "{} nap"
|
||||
},
|
||||
"clearedNLogsBeforeXAfterY": {
|
||||
"one": "{n} napló törölve (előtte = {előtte}, utána = {utána})",
|
||||
"other": "{n} napló törölve (előtte = {előtte}, utána = {utána})"
|
||||
},
|
||||
"xAndNMoreUpdatesAvailable": {
|
||||
"one": "A(z) {} és 1 további alkalmazás frissítéseket kapott.",
|
||||
"other": "{} és további {} alkalmazás frissítéseket kapott."
|
||||
},
|
||||
"xAndNMoreUpdatesInstalled": {
|
||||
"one": "A(z) {} és 1 további alkalmazás frissítve.",
|
||||
"other": "{} és további {} alkalmazás frissítve."
|
||||
}
|
||||
}
|
285
assets/translations/it.json
Normal file
@ -0,0 +1,285 @@
|
||||
{
|
||||
"invalidURLForSource": "URL dell'App da {} non valido",
|
||||
"noReleaseFound": "Impossibile trovare una release adatta",
|
||||
"noVersionFound": "Impossibile determinare la versione della release",
|
||||
"urlMatchesNoSource": "L'URL non corrisponde ad alcuna fonte conosciuta",
|
||||
"cantInstallOlderVersion": "Impossibile installare una versione precedente di un'App",
|
||||
"appIdMismatch": "L'ID del pacchetto scaricato non corrisponde all'ID dell'App esistente",
|
||||
"functionNotImplemented": "Questa classe non ha implementato questa funzione",
|
||||
"placeholder": "Segnaposto",
|
||||
"someErrors": "Si sono verificati degli errori",
|
||||
"unexpectedError": "Errore imprevisto",
|
||||
"ok": "Va bene",
|
||||
"and": "e",
|
||||
"startedBgUpdateTask": "Avviata l'attività di controllo degli aggiornamenti in background",
|
||||
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
|
||||
"startedActualBGUpdateCheck": "Avviato il controllo effettivo degli aggiornamenti in background",
|
||||
"bgUpdateTaskFinished": "Terminata l'attività di controllo degli aggiornamenti in background",
|
||||
"firstRun": "Questo è il primo avvio di sempre di Obtainium",
|
||||
"settingUpdateCheckIntervalTo": "Fissato intervallo di aggiornamento a {}",
|
||||
"githubPATLabel": "GitHub Personal Access Token (diminuisce limite di traffico)",
|
||||
"githubPATHint": "PAT deve seguire questo formato: username:token",
|
||||
"githubPATFormat": "username:token",
|
||||
"includePrereleases": "Includi prerelease",
|
||||
"fallbackToOlderReleases": "Ripiega su release precedenti",
|
||||
"filterReleaseTitlesByRegEx": "Filtra release con espressioni regolari",
|
||||
"invalidRegEx": "Espressione regolare non valida",
|
||||
"noDescription": "Descrizione assente",
|
||||
"cancel": "Annulla",
|
||||
"continue": "Continua",
|
||||
"requiredInBrackets": "(richiesto)",
|
||||
"dropdownNoOptsError": "ERRORE: LA TENDINA DEVE AVERE ALMENO UN'OPZIONE",
|
||||
"colour": "Colore",
|
||||
"githubStarredRepos": "repository stellati da GitHub",
|
||||
"uname": "Username",
|
||||
"wrongArgNum": "Numero di argomenti forniti errato",
|
||||
"xIsTrackOnly": "{} è in modalità Solo-Monitoraggio",
|
||||
"source": "Fonte",
|
||||
"app": "App",
|
||||
"appsFromSourceAreTrackOnly": "Le App da questa fonte sono in modalità 'Solo-Monitoraggio'.",
|
||||
"youPickedTrackOnly": "È stata selezionata l'opzione 'Solo-Monitoraggio'.",
|
||||
"trackOnlyAppDescription": "L'App sarà monitorata per gli aggiornamenti, ma Obtainium non sarà in grado di scaricarli o di installarli.",
|
||||
"cancelled": "Annullato",
|
||||
"appAlreadyAdded": "App già aggiunta",
|
||||
"alreadyUpToDateQuestion": "L'App è già aggiornata?",
|
||||
"addApp": "Aggiungi App",
|
||||
"appSourceURL": "URL della fonte dell'App",
|
||||
"error": "Errore",
|
||||
"add": "Aggiungi",
|
||||
"searchSomeSourcesLabel": "Cerca (solo per alcune fonti)",
|
||||
"search": "Cerca",
|
||||
"additionalOptsFor": "Opzioni aggiuntive per {}",
|
||||
"supportedSourcesBelow": "Fonti supportate:",
|
||||
"trackOnlyInBrackets": "(Solo-Monitoraggio)",
|
||||
"searchableInBrackets": "(ricercabile)",
|
||||
"appsString": "App",
|
||||
"noApps": "Nessuna App",
|
||||
"noAppsForFilter": "Nessuna App per i filtri selezionati",
|
||||
"byX": "Di {}",
|
||||
"percentProgress": "Progresso: {}%",
|
||||
"pleaseWait": "In attesa",
|
||||
"updateAvailable": "Aggiornamento disponibile",
|
||||
"estimateInBracketsShort": "(prev.)",
|
||||
"notInstalled": "Non installato",
|
||||
"estimateInBrackets": "(previsto)",
|
||||
"selectAll": "Seleziona tutto",
|
||||
"deselectN": "Deseleziona {}",
|
||||
"xWillBeRemovedButRemainInstalled": "Verà effettuata la rimozione di {}, ma non la disinstallazione.",
|
||||
"removeSelectedAppsQuestion": "Rimuovere le App selezionate?",
|
||||
"removeSelectedApps": "Rimuovi le App selezionate",
|
||||
"updateX": "Aggiorna {}",
|
||||
"installX": "Installa {}",
|
||||
"markXTrackOnlyAsUpdated": "Contrassegna {}\n(Solo-Monitoraggio)\ncome aggiornato",
|
||||
"changeX": "Modifica {}",
|
||||
"installUpdateApps": "Installa/Aggiorna App",
|
||||
"installUpdateSelectedApps": "Installa/Aggiorna le App selezionate",
|
||||
"markXSelectedAppsAsUpdated": "Contrassegnare le {} App selezionate come aggiornate?",
|
||||
"no": "No",
|
||||
"yes": "Sì",
|
||||
"markSelectedAppsUpdated": "Contrassegna le App selezionate come aggiornate",
|
||||
"pinToTop": "Fissa in alto",
|
||||
"unpinFromTop": "Rimuovi dall'alto",
|
||||
"resetInstallStatusForSelectedAppsQuestion": "Ripristinare lo stato d'installazione delle App selezionate?",
|
||||
"installStatusOfXWillBeResetExplanation": "Lo stato d'installazione di ogni App selezionata sarà ripristinato.\n\nCiò può essere d'aiuto nel caso in cui la versione mostrata dell'App in Obtainium non è corretta a causa di un aggiornamento fallito o di altri problemi.",
|
||||
"shareSelectedAppURLs": "Condividi gli URL delle App selezionate",
|
||||
"resetInstallStatus": "Ripristina lo stato d'installazione",
|
||||
"more": "Di più",
|
||||
"removeOutdatedFilter": "Rimuovi il filtro per le App non aggiornate",
|
||||
"showOutdatedOnly": "Mostra solo le App non aggiornate",
|
||||
"filter": "Filtri",
|
||||
"filterActive": "Filtri *",
|
||||
"filterApps": "Filtra App",
|
||||
"appName": "Nome dell'App",
|
||||
"author": "Autore",
|
||||
"upToDateApps": "App aggiornate",
|
||||
"nonInstalledApps": "App non installate",
|
||||
"importExport": "Importa/Esporta",
|
||||
"settings": "Impostazioni",
|
||||
"exportedTo": "Esportato in {}",
|
||||
"obtainiumExport": "Esporta da Obtainium",
|
||||
"invalidInput": "Inserimento non valido",
|
||||
"importedX": "Importato {}",
|
||||
"obtainiumImport": "Importa in Obtainium",
|
||||
"importFromURLList": "Importa da lista di URL",
|
||||
"searchQuery": "Stringa di ricerca",
|
||||
"appURLList": "Lista di URL delle App",
|
||||
"line": "Linea",
|
||||
"searchX": "Cerca su {}",
|
||||
"noResults": "Nessun risultato trovato",
|
||||
"importX": "Importa {}",
|
||||
"importedAppsIdDisclaimer": "Le App importate potrebbero essere visualizzate erroneamente come \"Non installate\".\nPer risolvere il problema, reinstallale con Obtainium.\nQuesto non dovrebbe influire sui dati delle App.\n\nRiguarda solo l'URL e i metodi di importazione di terze parti.",
|
||||
"importErrors": "Errori dell'importazione",
|
||||
"importedXOfYApps": "{} App di {} importate.",
|
||||
"followingURLsHadErrors": "I seguenti URL contengono errori:",
|
||||
"okay": "Va bene",
|
||||
"selectURL": "Seleziona l'URL",
|
||||
"selectURLs": "Seleziona gli URL",
|
||||
"pick": "Seleziona",
|
||||
"theme": "Tema",
|
||||
"dark": "Scuro",
|
||||
"light": "Chiaro",
|
||||
"followSystem": "Segui sistema",
|
||||
"obtainium": "Obtainium",
|
||||
"materialYou": "Material You",
|
||||
"useBlackTheme": "Use Pure Black Dark Theme",
|
||||
"appSortBy": "App ordinate per",
|
||||
"authorName": "Autore/Nome",
|
||||
"nameAuthor": "Nome/Autore",
|
||||
"asAdded": "Data di aggiunta",
|
||||
"appSortOrder": "Ordinamento",
|
||||
"ascending": "Ascendente",
|
||||
"descending": "Discendente",
|
||||
"bgUpdateCheckInterval": "Intervallo di controllo degli aggiornamenti in background",
|
||||
"neverManualOnly": "Mai - Solo manuale",
|
||||
"appearance": "Aspetto",
|
||||
"showWebInAppView": "Mostra pagina web dell'App se selezionata",
|
||||
"pinUpdates": "Fissa aggiornamenti disponibili in alto",
|
||||
"updates": "Aggiornamenti",
|
||||
"sourceSpecific": "Specifiche per la fonte",
|
||||
"appSource": "Sorgente dell'App",
|
||||
"noLogs": "Nessun log",
|
||||
"appLogs": "Log dell'App",
|
||||
"close": "Chiudi",
|
||||
"share": "Condividi",
|
||||
"appNotFound": "App non trovata",
|
||||
"obtainiumExportHyphenatedLowercase": "esportazione-obtainium",
|
||||
"pickAnAPK": "Seleziona un APK",
|
||||
"appHasMoreThanOnePackage": "{} offre più di un pacchetto:",
|
||||
"deviceSupportsXArch": "Il dispositivo in uso supporta l'architettura {} della CPU.",
|
||||
"deviceSupportsFollowingArchs": "Il dispositivo in uso supporta le seguenti architetture della CPU:",
|
||||
"warning": "Attenzione",
|
||||
"sourceIsXButPackageFromYPrompt": "L'origine dell'App è '{}' ma il pacchetto della release proviene da '{}'. Continuare?",
|
||||
"updatesAvailable": "Aggiornamenti disponibili",
|
||||
"updatesAvailableNotifDescription": "Notifica all'utente che sono disponibili gli aggiornamenti di una o più App monitorate da Obtainium",
|
||||
"noNewUpdates": "Nessun nuovo aggiornamento.",
|
||||
"xHasAnUpdate": "Aggiornamento disponibile per {}",
|
||||
"appsUpdated": "App aggiornate",
|
||||
"appsUpdatedNotifDescription": "Notifica all'utente che una o più App sono state aggiornate in background",
|
||||
"xWasUpdatedToY": "{} è stato aggiornato a {}.",
|
||||
"errorCheckingUpdates": "Controllo degli errori per gli aggiornamenti",
|
||||
"errorCheckingUpdatesNotifDescription": "Una notifica che mostra quando il controllo degli aggiornamenti in background fallisce",
|
||||
"appsRemoved": "App rimosse",
|
||||
"appsRemovedNotifDescription": "Notifica all'utente che una o più App sono state rimosse a causa di errori durante il caricamento",
|
||||
"xWasRemovedDueToErrorY": "{} è stata rimosso a causa di questo errore: {}",
|
||||
"completeAppInstallation": "Completa l'installazione dell'App",
|
||||
"obtainiumMustBeOpenToInstallApps": "Obtainium deve essere aperto per poter installare le App",
|
||||
"completeAppInstallationNotifDescription": "Chiede all'utente di riaprire Obtainium per terminare l'installazione di un App",
|
||||
"checkingForUpdates": "Controllo degli aggiornamenti in corso",
|
||||
"checkingForUpdatesNotifDescription": "Notifica transitoria che appare durante la verifica degli aggiornamenti",
|
||||
"pleaseAllowInstallPerm": "Per favore permetti a Obtainium di installare le App",
|
||||
"trackOnly": "Solo-Monitoraggio",
|
||||
"errorWithHttpStatusCode": "Errore {}",
|
||||
"versionCorrectionDisabled": "Correzione della versione disabilitata (il plugin non pare funzionare)",
|
||||
"unknown": "Sconosciuto",
|
||||
"none": "Nessuno",
|
||||
"never": "Mai",
|
||||
"latestVersionX": "Ultima versione: {}",
|
||||
"installedVersionX": "Versione installata: {}",
|
||||
"lastUpdateCheckX": "Ultimo controllo degli aggiornamenti: {}",
|
||||
"remove": "Rimuovi",
|
||||
"yesMarkUpdated": "Sì, contrassegna come aggiornato",
|
||||
"fdroid": "F-Droid Official",
|
||||
"appIdOrName": "ID o nome dell'App",
|
||||
"appId": "ID dell'App",
|
||||
"appWithIdOrNameNotFound": "Non è stata trovata alcuna App con quell'ID o nome",
|
||||
"reposHaveMultipleApps": "I repository possono contenere più App",
|
||||
"fdroidThirdPartyRepo": "Repository F-Droid di terze parti",
|
||||
"steam": "Steam",
|
||||
"steamMobile": "Steam Mobile",
|
||||
"steamChat": "Steam Chat",
|
||||
"install": "Installa",
|
||||
"markInstalled": "Contrassegna come installato",
|
||||
"update": "Aggiorna",
|
||||
"markUpdated": "Contrassegna come aggiornato",
|
||||
"additionalOptions": "Opzioni aggiuntive",
|
||||
"disableVersionDetection": "Disattiva il rilevamento della versione",
|
||||
"noVersionDetectionExplanation": "Questa opzione dovrebbe essere usata solo per le App la cui versione non viene rilevata correttamente.",
|
||||
"downloadingX": "Scaricamento di {} in corso",
|
||||
"downloadNotifDescription": "Notifica all'utente lo stato di avanzamento del download di un'App",
|
||||
"noAPKFound": "Nessun APK trovato",
|
||||
"noVersionDetection": "Disattiva rilevamento di versione",
|
||||
"categorize": "Aggiungi a categoria",
|
||||
"categories": "Categorie",
|
||||
"category": "Categoria",
|
||||
"noCategory": "Nessuna categoria",
|
||||
"noCategories": "Nessuna categoria",
|
||||
"deleteCategoriesQuestion": "Eliminare le categorie?",
|
||||
"categoryDeleteWarning": "Tutte le App nelle categorie eliminate saranno impostate come non categorizzate.",
|
||||
"addCategory": "Aggiungi categoria",
|
||||
"label": "Etichetta",
|
||||
"language": "Lingua",
|
||||
"copiedToClipboard": "Copiato negli appunti",
|
||||
"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": "Raggruppa per categoria",
|
||||
"autoApkFilterByArch": "Tenta di filtrare gli APK in base all'architettura della CPU, se possibile",
|
||||
"overrideSource": "Override Source",
|
||||
"dontShowAgain": "Don't show this again",
|
||||
"dontShowTrackOnlyWarnings": "Don't Show the 'Track-Only' Warning",
|
||||
"dontShowAPKOriginWarnings": "Don't Show APK Origin Warnings",
|
||||
"moveNonInstalledAppsToBottom": "Move Non-Installed Apps to Bottom of Apps View",
|
||||
"gitlabPATLabel": "GitLab Personal Access Token (Enables Search)",
|
||||
"about": "About",
|
||||
"requiresCredentialsInSettings": "This needs additional credentials (in Settings)",
|
||||
"checkOnStart": "Check Once on Start",
|
||||
"tryInferAppIdFromCode": "Try inferring App ID from source code",
|
||||
"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"
|
||||
},
|
||||
"bgUpdateGotErrorRetryInMinutes": {
|
||||
"one": "Il controllo degli aggiornamenti in background ha incontrato un {}, nuovo tentativo tra {} minuto",
|
||||
"other": "Il controllo degli aggiornamenti in background ha incontrato un {}, nuovo tentativo tra {} minuti"
|
||||
},
|
||||
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
|
||||
"one": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamento - notificherà l'utente se necessario",
|
||||
"other": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamenti - notificherà l'utente se necessario"
|
||||
},
|
||||
"apps": {
|
||||
"one": "{} App",
|
||||
"other": "{} App"
|
||||
},
|
||||
"url": {
|
||||
"one": "{} URL",
|
||||
"other": "{} URL"
|
||||
},
|
||||
"minute": {
|
||||
"one": "{} minuto",
|
||||
"other": "{} minuti"
|
||||
},
|
||||
"hour": {
|
||||
"one": "{} ora",
|
||||
"other": "{} ore"
|
||||
},
|
||||
"day": {
|
||||
"one": "{} giorno",
|
||||
"other": "{} giorni"
|
||||
},
|
||||
"clearedNLogsBeforeXAfterY": {
|
||||
"one": "Pulito {n} log (prima = {before}, dopo = {after})",
|
||||
"other": "Puliti {n} log (prima = {before}, dopo = {after})"
|
||||
},
|
||||
"xAndNMoreUpdatesAvailable": {
|
||||
"one": "{} e un'altra App hanno aggiornamenti disponibili.",
|
||||
"other": "{} e altre {} App hanno aggiornamenti disponibili."
|
||||
},
|
||||
"xAndNMoreUpdatesInstalled": {
|
||||
"one": "{} e un'altra App sono state aggiornate.",
|
||||
"other": "{} e altre {} App sono state aggiornate."
|
||||
}
|
||||
}
|
285
assets/translations/ja.json
Normal file
@ -0,0 +1,285 @@
|
||||
{
|
||||
"invalidURLForSource": "{}は有効なソースURLではありません",
|
||||
"noReleaseFound": "適切なリリースが見つかりませんでした",
|
||||
"noVersionFound": "リリースバージョンを特定できませんでした",
|
||||
"urlMatchesNoSource": "URLが既知のソースと一致しません",
|
||||
"cantInstallOlderVersion": "旧バージョンのアプリをインストールできません",
|
||||
"appIdMismatch": "ダウンロードしたパッケージのIDが既存のApp IDと一致しません",
|
||||
"functionNotImplemented": "このクラスはこの機能を実装していません",
|
||||
"placeholder": "プレースホルダー",
|
||||
"someErrors": "何らかのエラーが発生しました",
|
||||
"unexpectedError": "予期せぬエラーが発生しました",
|
||||
"ok": "OK",
|
||||
"and": "と",
|
||||
"startedBgUpdateTask": "バックグラウンドのアップデート確認タスクを開始",
|
||||
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
|
||||
"startedActualBGUpdateCheck": "実際のバックグラウンドのアップデート確認を開始",
|
||||
"bgUpdateTaskFinished": "バックグラウンドのアップデート確認タスクを終了",
|
||||
"firstRun": "これがObtainiumの最初の実行です",
|
||||
"settingUpdateCheckIntervalTo": "確認間隔を{}に設定する",
|
||||
"githubPATLabel": "GitHub パーソナルアクセストークン (レート制限の引き上げ)",
|
||||
"githubPATHint": "PATは次の形式でなければなりません: ユーザー名:トークン",
|
||||
"githubPATFormat": "ユーザー名:トークン",
|
||||
"includePrereleases": "プレリリースを含む",
|
||||
"fallbackToOlderReleases": "旧リリースへのフォールバック",
|
||||
"filterReleaseTitlesByRegEx": "正規表現でリリースタイトルを絞り込む",
|
||||
"invalidRegEx": "無効な正規表現",
|
||||
"noDescription": "説明はありません",
|
||||
"cancel": "キャンセル",
|
||||
"continue": "続行",
|
||||
"requiredInBrackets": "(必須)",
|
||||
"dropdownNoOptsError": "エラー: ドロップダウンには、少なくとも1つのオプションが必要です",
|
||||
"colour": "カラー",
|
||||
"githubStarredRepos": "Githubでスターしたリポジトリ",
|
||||
"uname": "ユーザー名",
|
||||
"wrongArgNum": "提供する引数の数が間違っています",
|
||||
"xIsTrackOnly": "{} は「追跡のみ」です",
|
||||
"source": "ソース",
|
||||
"app": "アプリ",
|
||||
"appsFromSourceAreTrackOnly": "このソースからのアプリは「追跡のみ」です。",
|
||||
"youPickedTrackOnly": "「追跡のみ」を選択しています",
|
||||
"trackOnlyAppDescription": "アプリのアップデートは追跡されますが、Obtainiumはアプリのダウンロードやインストールをすることはできません。",
|
||||
"cancelled": "キャンセルしました",
|
||||
"appAlreadyAdded": "アプリはすでに追加されています",
|
||||
"alreadyUpToDateQuestion": "アプリはすでに最新ですか?",
|
||||
"addApp": "アプリの追加",
|
||||
"appSourceURL": "アプリのソースURL",
|
||||
"error": "エラー",
|
||||
"add": "追加",
|
||||
"searchSomeSourcesLabel": "検索 (一部ソースのみ)",
|
||||
"search": "検索",
|
||||
"additionalOptsFor": "{}の追加オプション",
|
||||
"supportedSourcesBelow": "対応するソース:",
|
||||
"trackOnlyInBrackets": "(追跡のみ)",
|
||||
"searchableInBrackets": "(検索可能)",
|
||||
"appsString": "アプリ",
|
||||
"noApps": "アプリはありません",
|
||||
"noAppsForFilter": "フィルターに一致するアプリはありません",
|
||||
"byX": "by {}",
|
||||
"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": "選択したアプリのURLを共有する",
|
||||
"resetInstallStatus": "インストール状態をリセットする",
|
||||
"more": "もっと見る",
|
||||
"removeOutdatedFilter": "アップデートが存在するアプリのフィルターを解除",
|
||||
"showOutdatedOnly": "アップデートが存在するアプリのみ表示する",
|
||||
"filter": "フィルター",
|
||||
"filterActive": "フィルター *",
|
||||
"filterApps": "アプリを絞り込む",
|
||||
"appName": "アプリ名",
|
||||
"author": "作者",
|
||||
"upToDateApps": "最新のアプリ",
|
||||
"nonInstalledApps": "未インストールのアプリ",
|
||||
"importExport": "インポート/エクスポート",
|
||||
"settings": "設定",
|
||||
"exportedTo": "{}にエクスポートしました",
|
||||
"obtainiumExport": "Obtainium エクスポート",
|
||||
"invalidInput": "無効な入力",
|
||||
"importedX": "{}をインポートしました",
|
||||
"obtainiumImport": "Obtainium インポート",
|
||||
"importFromURLList": "URLリストからのインポート",
|
||||
"searchQuery": "検索キーワード",
|
||||
"appURLList": "アプリのURLリスト",
|
||||
"line": "行",
|
||||
"searchX": "{}で検索",
|
||||
"noResults": "結果は見つかりませんでした",
|
||||
"importX": "{}をインポート",
|
||||
"importedAppsIdDisclaimer": "インポートしたアプリが「未インストール」と表示されることがあります。\nこれを解決するには、Obtainiumから再インストールしてください。\nアプリのデータには影響しません。\n\nURLとサードパーティのインポートメソッドにのみ影響します。",
|
||||
"importErrors": "インポートエラー",
|
||||
"importedXOfYApps": "{} / {} アプリをインポートしました",
|
||||
"followingURLsHadErrors": "以下のURLでエラーが発生しました:",
|
||||
"okay": "OK",
|
||||
"selectURL": "URLを選択",
|
||||
"selectURLs": "URLを選択",
|
||||
"pick": "選択",
|
||||
"theme": "テーマ",
|
||||
"dark": "ダーク",
|
||||
"light": "ライト",
|
||||
"followSystem": "システムに従う",
|
||||
"obtainium": "Obtainium",
|
||||
"materialYou": "Material You",
|
||||
"useBlackTheme": "ピュアブラックダークテーマを使用する",
|
||||
"appSortBy": "アプリの並び方",
|
||||
"authorName": "作者名/アプリ名",
|
||||
"nameAuthor": "アプリ名/作者名",
|
||||
"asAdded": "追加順",
|
||||
"appSortOrder": "並び順",
|
||||
"ascending": "昇順",
|
||||
"descending": "降順",
|
||||
"bgUpdateCheckInterval": "バックグラウンドでのアップデート確認の間隔",
|
||||
"neverManualOnly": "手動",
|
||||
"appearance": "外観",
|
||||
"showWebInAppView": "アプリページにソースのWebページを表示する",
|
||||
"pinUpdates": "アップデートがあるアプリをトップに固定する",
|
||||
"updates": "アップデート",
|
||||
"sourceSpecific": "Github アクセストークン",
|
||||
"appSource": "アプリのソース",
|
||||
"noLogs": "ログはありません",
|
||||
"appLogs": "アプリのログ",
|
||||
"close": "閉じる",
|
||||
"share": "共有",
|
||||
"appNotFound": "アプリが見つかりません",
|
||||
"obtainiumExportHyphenatedLowercase": "obtainium-エクスポート",
|
||||
"pickAnAPK": "APKを選択",
|
||||
"appHasMoreThanOnePackage": "{} は複数のパッケージが存在します: ",
|
||||
"deviceSupportsXArch": "お使いのデバイスは {} CPUアーキテクチャに対応しています。",
|
||||
"deviceSupportsFollowingArchs": "お使いのデバイスは、以下のCPUアーキテクチャをサポートしています:",
|
||||
"warning": "警告",
|
||||
"sourceIsXButPackageFromYPrompt": "アプリのソースは'{}'ですが、リリースパッケージは'{}'から来ています。続行しますか?",
|
||||
"updatesAvailable": "アップデートが利用可能",
|
||||
"updatesAvailableNotifDescription": "Obtainiumが追跡している1つまたは複数のアプリのアップデートが利用可能であることをユーザーに通知する",
|
||||
"noNewUpdates": "新しいアップデートはありません",
|
||||
"xHasAnUpdate": "{} のアップデートが利用可能です",
|
||||
"appsUpdated": "アプリをアップデートしました",
|
||||
"appsUpdatedNotifDescription": "1つまたは複数のAppのアップデートがバックグラウンドで適用されたことをユーザーに通知する",
|
||||
"xWasUpdatedToY": "{} が {} にアップデートされました",
|
||||
"errorCheckingUpdates": "アップデート確認中のエラー",
|
||||
"errorCheckingUpdatesNotifDescription": "バックグラウンドでのアップデート確認に失敗した際に表示される通知",
|
||||
"appsRemoved": "削除されたアプリ",
|
||||
"appsRemovedNotifDescription": "アプリの読み込み中にエラーが発生したため、1つまたは複数のアプリが削除されたことをユーザーに通知する",
|
||||
"xWasRemovedDueToErrorY": "このエラーのため、{} は削除されました: {}",
|
||||
"completeAppInstallation": "アプリのインストールを完了する",
|
||||
"obtainiumMustBeOpenToInstallApps": "アプリをインストールするにはObtainiumを開いている必要があります。",
|
||||
"completeAppInstallationNotifDescription": "アプリのインストールを完了するために、Obtainiumに戻る必要があります。",
|
||||
"checkingForUpdates": "アップデートを確認中",
|
||||
"checkingForUpdatesNotifDescription": "アップデートを確認する際に表示される一時的な通知",
|
||||
"pleaseAllowInstallPerm": "Obtainiumによるアプリのインストールを許可してください。",
|
||||
"trackOnly": "追跡のみ",
|
||||
"errorWithHttpStatusCode": "エラー {}",
|
||||
"versionCorrectionDisabled": "バージョン補正無効 (プラグインが動作していません)",
|
||||
"unknown": "不明",
|
||||
"none": "なし",
|
||||
"never": "しない",
|
||||
"latestVersionX": "最新のバージョン: {}",
|
||||
"installedVersionX": "インストールされたバージョン: {}",
|
||||
"lastUpdateCheckX": "最終アップデート確認: {}",
|
||||
"remove": "削除",
|
||||
"yesMarkUpdated": "はい、アップデート済みとしてマークします",
|
||||
"fdroid": "F-Droid Official",
|
||||
"appIdOrName": "アプリのIDまたは名前",
|
||||
"appId": "App ID",
|
||||
"appWithIdOrNameNotFound": "そのIDや名前を持つアプリは見つかりませんでした",
|
||||
"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": "言語",
|
||||
"copiedToClipboard": "クリップボードにコピーしました",
|
||||
"storagePermissionDenied": "ストレージ権限が拒否されました",
|
||||
"selectedCategorizeWarning": "これにより、選択したアプリの既存のカテゴリ設定がすべて置き換えられます。",
|
||||
"filterAPKsByRegEx": "正規表現でAPKを絞り込む",
|
||||
"removeFromObtainium": "Obtainiumから削除する",
|
||||
"uninstallFromDevice": "デバイスからアンインストールする",
|
||||
"onlyWorksWithNonVersionDetectApps": "バージョン検出を無効にしているアプリにのみ動作します。",
|
||||
"releaseDateAsVersion": "リリース日をバージョンとして使用する",
|
||||
"releaseDateAsVersionExplanation": "このオプションは、バージョン検出が正しく機能しないアプリで、リリース日が利用可能な場合にのみ使用する必要があります。",
|
||||
"changes": "変更点",
|
||||
"releaseDate": "リリース日",
|
||||
"importFromURLsInFile": "ファイル(OPMLなど)内のURLからインポート",
|
||||
"versionDetection": "バージョン検出",
|
||||
"standardVersionDetection": "標準のバージョン検出",
|
||||
"groupByCategory": "カテゴリ別にグループ化する",
|
||||
"autoApkFilterByArch": "可能であれば,CPUアーキテクチャによるAPKのフィルタリングを試みる",
|
||||
"overrideSource": "ソースの上書き",
|
||||
"dontShowAgain": "二度と表示しない",
|
||||
"dontShowTrackOnlyWarnings": "「追跡のみ」の警告を表示しない",
|
||||
"dontShowAPKOriginWarnings": "APK Originの警告を表示しない",
|
||||
"moveNonInstalledAppsToBottom": "未インストールのアプリをアプリ一覧の下部に移動させる",
|
||||
"gitlabPATLabel": "GitLab パーソナルアクセストークン (検索を有効化する)",
|
||||
"about": "概要",
|
||||
"requiresCredentialsInSettings": "これには追加の認証が必要です (設定にて)",
|
||||
"checkOnStart": "Check Once on Start",
|
||||
"tryInferAppIdFromCode": "Try inferring App ID from source code",
|
||||
"removeAppQuestion": {
|
||||
"one": "アプリを削除しますか?",
|
||||
"other": "アプリを削除しますか?"
|
||||
},
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "リクエストが多すぎます(レート制限)- {}分後に再試行してください",
|
||||
"other": "リクエストが多すぎます(レート制限)- {}分後に再試行してください"
|
||||
},
|
||||
"bgUpdateGotErrorRetryInMinutes": {
|
||||
"one": "バックグラウンドでのアップデート確認で {} の問題が発生, {} 分後に再試行します",
|
||||
"other": "バックグラウンドでのアップデート確認で {} の問題が発生, {} 分後に再試行します"
|
||||
},
|
||||
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
|
||||
"one": "バックグラウンドでのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します",
|
||||
"other": "バックグラウンドでのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します"
|
||||
},
|
||||
"apps": {
|
||||
"one": "{}個のアプリ",
|
||||
"other": "{}個のアプリ"
|
||||
},
|
||||
"url": {
|
||||
"one": "{}個のURL",
|
||||
"other": "{}個のURL"
|
||||
},
|
||||
"minute": {
|
||||
"one": "{}分",
|
||||
"other": "{}分"
|
||||
},
|
||||
"hour": {
|
||||
"one": "{}時間",
|
||||
"other": "{}時間"
|
||||
},
|
||||
"day": {
|
||||
"one": "{}日",
|
||||
"other": "{}日"
|
||||
},
|
||||
"clearedNLogsBeforeXAfterY": {
|
||||
"one": "{n}個のログをクリアしました (前 = {before}, 後 = {after})",
|
||||
"other": "{n}個のログをクリアしました (前 = {before}, 後 = {after})"
|
||||
},
|
||||
"xAndNMoreUpdatesAvailable": {
|
||||
"one": "{} とさらに {} 個のアプリのアップデートが利用可能です",
|
||||
"other": "{} とさらに {} 個のアプリのアップデートが利用可能です"
|
||||
},
|
||||
"xAndNMoreUpdatesInstalled": {
|
||||
"one": "{} とさらに {} 個のアプリがアップデートされました",
|
||||
"other": "{} とさらに {} 個のアプリがアップデートされました"
|
||||
}
|
||||
}
|
285
assets/translations/pl.json
Normal file
@ -0,0 +1,285 @@
|
||||
{
|
||||
"noDescription": "Brak opisu",
|
||||
"no": "Nie",
|
||||
"okay": "Okej",
|
||||
"appId": "ID aplikacji",
|
||||
"bgUpdateGotErrorRetryInMinutes": {
|
||||
"one": "Sprawdzanie aktualizacji w tle napotkało {}, zaplanuje ponowne sprawdzenie za {} min.",
|
||||
"other": "Sprawdzanie aktualizacji w tle napotkało {}, zaplanuje ponowne sprawdzenie za {} min."
|
||||
},
|
||||
"invalidURLForSource": "Nieprawidłowy adres URL aplikacji {}",
|
||||
"noReleaseFound": "Nie można znaleźć odpowiedniego wydania",
|
||||
"noVersionFound": "Nie można określić wersji wydania",
|
||||
"urlMatchesNoSource": "Adres URL nie pasuje do znanego źródła",
|
||||
"cantInstallOlderVersion": "Nie można zainstalować starszej wersji aplikacji",
|
||||
"appIdMismatch": "Pobrany identyfikator pakietu nie pasuje do istniejącego identyfikatora aplikacji",
|
||||
"functionNotImplemented": "Ta klasa nie zaimplementowała tej funkcji",
|
||||
"placeholder": "Placeholder",
|
||||
"someErrors": "Wystąpiły pewne błędy",
|
||||
"unexpectedError": "Nieoczekiwany błąd",
|
||||
"ok": "Okej",
|
||||
"and": "i",
|
||||
"startedBgUpdateTask": "Rozpoczęto zadanie sprawdzania aktualizacji w tle",
|
||||
"bgUpdateIgnoreAfterIs": "Parametr ignoreAfter aktualizacji w tle to {}",
|
||||
"startedActualBGUpdateCheck": "Rozpoczęto sprawdzanie aktualizacji w tle",
|
||||
"bgUpdateTaskFinished": "Zakończono zadanie sprawdzania aktualizacji w tle",
|
||||
"firstRun": "Jest to pierwsze uruchomienie Obtainium",
|
||||
"settingUpdateCheckIntervalTo": "Ustawianie interwału aktualizacji na {}",
|
||||
"githubPATLabel": "Osobisty Token Dostępu GitHub (zwiększa limit zapytań)",
|
||||
"githubPATHint": "Wymagany format OTD: użytkownik:token",
|
||||
"githubPATFormat": "użytkownik:token",
|
||||
"includePrereleases": "Uwzględnij wersje wstępne",
|
||||
"fallbackToOlderReleases": "Powracaj do starszych wersji",
|
||||
"filterReleaseTitlesByRegEx": "Filtruj tytuły wydań wg. wyrażeń regularnych",
|
||||
"invalidRegEx": "Nieprawidłowe wyrażenie regularne",
|
||||
"cancel": "Anuluj",
|
||||
"continue": "Kontynuuj",
|
||||
"requiredInBrackets": "(Wymagane)",
|
||||
"dropdownNoOptsError": "BŁĄD: LISTA ROZWIJANA MUSI MIEĆ CO NAJMNIEJ JEDNĄ OPCJĘ",
|
||||
"colour": "Kolor",
|
||||
"githubStarredRepos": "Repozytoria GitHub oznaczone gwiazdką",
|
||||
"uname": "Nazwa użytkownika",
|
||||
"wrongArgNum": "Nieprawidłowa liczba podanych argumentów",
|
||||
"xIsTrackOnly": "{} jest tylko obserwowana",
|
||||
"source": "Źródło",
|
||||
"app": "Aplikacja",
|
||||
"appsFromSourceAreTrackOnly": "Aplikacje z tego źródła są „Obserwowane”.",
|
||||
"youPickedTrackOnly": "Wybrano opcję „Tylko obserwuj”.",
|
||||
"trackOnlyAppDescription": "Aplikacja będzie obserwowana pod kątem aktualizacji, ale Obtainium nie będzie w stanie jej pobrać ani zainstalować.",
|
||||
"cancelled": "Anulowano",
|
||||
"appAlreadyAdded": "Aplikacja już została dodana",
|
||||
"alreadyUpToDateQuestion": "Aplikacja jest już aktualna?",
|
||||
"addApp": "Dodaj apkę",
|
||||
"appSourceURL": "Adres URL źródła aplikacji",
|
||||
"error": "Błąd",
|
||||
"add": "Dodaj",
|
||||
"searchSomeSourcesLabel": "Szukaj (tylko niektóre źródła)",
|
||||
"search": "Szukaj",
|
||||
"additionalOptsFor": "Dodatkowe opcje dla {}",
|
||||
"supportedSourcesBelow": "Obsługiwane źródła:",
|
||||
"trackOnlyInBrackets": "(tylko obserwowane)",
|
||||
"searchableInBrackets": "(Wyszukiwalne)",
|
||||
"appsString": "Aplikacje",
|
||||
"noApps": "Brak aplikacji",
|
||||
"noAppsForFilter": "Brak aplikacji dla filtra",
|
||||
"byX": "Autorstwa {}",
|
||||
"percentProgress": "Postęp: {}%",
|
||||
"pleaseWait": "Proszę czekać",
|
||||
"updateAvailable": "Dostępna aktualizacja",
|
||||
"estimateInBracketsShort": "(Szac.)",
|
||||
"notInstalled": "Nie zainstalowano",
|
||||
"estimateInBrackets": "(Szacunkowo)",
|
||||
"selectAll": "Zaznacz wszystkie",
|
||||
"deselectN": "Odznacz {}",
|
||||
"xWillBeRemovedButRemainInstalled": "{} zostanie usunięty z Obtainium, ale pozostanie zainstalowany na urządzeniu.",
|
||||
"removeSelectedAppsQuestion": "Usunąć wybrane aplikacje?",
|
||||
"removeSelectedApps": "Usuń wybrane aplikacje",
|
||||
"updateX": "Zaktualizuj {}",
|
||||
"installX": "Zainstaluj {}",
|
||||
"markXTrackOnlyAsUpdated": "Oznacz {}\n(Tylko obserwowana)\njako zaktualizowaną",
|
||||
"changeX": "Zmień {}",
|
||||
"installUpdateApps": "Instaluj/aktualizuj aplikacje",
|
||||
"installUpdateSelectedApps": "Zainstaluj/zaktualizuj wybrane aplikacje",
|
||||
"markXSelectedAppsAsUpdated": "Oznaczyć {} wybranych aplikacji jako zaktualizowane?",
|
||||
"yes": "Tak",
|
||||
"markSelectedAppsUpdated": "Oznacz wybrane aplikacje jako zaktualizowane",
|
||||
"pinToTop": "Przypnij",
|
||||
"unpinFromTop": "Odepnij",
|
||||
"resetInstallStatusForSelectedAppsQuestion": "Zresetować status instalacji dla wybranych aplikacji?",
|
||||
"installStatusOfXWillBeResetExplanation": "Stan instalacji wybranych aplikacji zostanie zresetowany.\n\nMoże być to pomocne, gdy wersja aplikacji wyświetlana w Obtainium jest nieprawidłowa z powodu nieudanych aktualizacji lub innych problemów.",
|
||||
"shareSelectedAppURLs": "Udostępnij wybrane adresy URL aplikacji",
|
||||
"resetInstallStatus": "Zresetuj stan instalacji",
|
||||
"more": "Więcej",
|
||||
"removeOutdatedFilter": "Usuń filtr nieaktualnych aplikacji",
|
||||
"showOutdatedOnly": "Pokaż tylko nieaktualne aplikacje",
|
||||
"filter": "FIltr",
|
||||
"filterActive": "Filtruj *",
|
||||
"filterApps": "Filtruj aplikacje",
|
||||
"appName": "Nazwa aplikacji",
|
||||
"author": "Autor",
|
||||
"upToDateApps": "Aktualne aplikacje",
|
||||
"nonInstalledApps": "Niezainstalowane aplikacje",
|
||||
"importExport": "Import/Eksport",
|
||||
"settings": "Ustawienia",
|
||||
"exportedTo": "Wyeksportowano do {}",
|
||||
"obtainiumExport": "Eksportuj Obtainium",
|
||||
"invalidInput": "Nieprawidłowe wprowadzenie",
|
||||
"importedX": "Zaimportowano {}",
|
||||
"obtainiumImport": "Import Obtainium",
|
||||
"importFromURLList": "Importuj z listy adresów URL",
|
||||
"searchQuery": "Wyszukiwane zapytanie",
|
||||
"appURLList": "Lista adresów URL aplikacji",
|
||||
"line": "Linia",
|
||||
"searchX": "Przeszukaj {}",
|
||||
"noResults": "Nie znaleziono wyników",
|
||||
"importX": "Importuj {}",
|
||||
"importedAppsIdDisclaimer": "Zaimportowane aplikacje mogą być wyświetlane jako „Niezainstalowane”.\nAby to naprawić, zainstaluj je ponownie za pomocą Obtainium.\nNie powinno to mieć wpływu na dane aplikacji.\n\nDotyczy tylko adresów URL i metod importu innych aplikacji.",
|
||||
"importErrors": "Błędy importowania",
|
||||
"importedXOfYApps": "Zaimportowano {} z {} aplikacji.",
|
||||
"followingURLsHadErrors": "Następujące adresy URL zawierały błędy:",
|
||||
"selectURL": "Wybierz adres URL",
|
||||
"selectURLs": "Wybierz adresy URL",
|
||||
"pick": "Wybierz",
|
||||
"theme": "Motyw",
|
||||
"dark": "Ciemny",
|
||||
"light": "Jasny",
|
||||
"followSystem": "Zgodny z systemem",
|
||||
"obtainium": "Obtainium",
|
||||
"materialYou": "Material You",
|
||||
"useBlackTheme": "Użyj czarnego motywu",
|
||||
"appSortBy": "Sortuj aplikacje według",
|
||||
"authorName": "Autor/Nazwa",
|
||||
"nameAuthor": "Nazwa/Autor",
|
||||
"asAdded": "Dodania",
|
||||
"appSortOrder": "Kolejność sortowania aplikacji",
|
||||
"ascending": "Rosnąco",
|
||||
"descending": "Malejąco",
|
||||
"bgUpdateCheckInterval": "Częstotliwość sprawdzania aktualizacji w tle",
|
||||
"neverManualOnly": "Nigdy - tylko ręcznie",
|
||||
"appearance": "Wygląd",
|
||||
"showWebInAppView": "Pokaż stronę źródłową w widoku aplikacji",
|
||||
"pinUpdates": "Przypnij aktualizacje na górze widoku aplikacji",
|
||||
"updates": "Aktualizacje",
|
||||
"sourceSpecific": "Zależnie od źródła",
|
||||
"appSource": "Źródło aplikacji",
|
||||
"noLogs": "Brak logów",
|
||||
"appLogs": "Logi aplikacji",
|
||||
"close": "Zamknij",
|
||||
"share": "Udostępnij",
|
||||
"appNotFound": "Nie znaleziono aplikacji",
|
||||
"obtainiumExportHyphenatedLowercase": "obtainium-eksport",
|
||||
"pickAnAPK": "Wybierz plik APK",
|
||||
"appHasMoreThanOnePackage": "{} ma więcej niż jeden pakiet:",
|
||||
"deviceSupportsXArch": "Urządzenie obsługuje architekturę procesora {}.",
|
||||
"deviceSupportsFollowingArchs": "Urządzenie obsługuje następujące architektury procesora:",
|
||||
"warning": "Uwaga",
|
||||
"sourceIsXButPackageFromYPrompt": "Źródłem aplikacji jest '{}', ale pakiet wydania pochodzi z '{}'. Kontynuować?",
|
||||
"updatesAvailable": "Dostępne aktualizacje",
|
||||
"updatesAvailableNotifDescription": "Powiadamia użytkownika o dostępności aktualizacji dla jednej lub więcej aplikacji obserwowanych przez Obtainium",
|
||||
"noNewUpdates": "Brak nowych aktualizacji.",
|
||||
"xHasAnUpdate": "{} ma aktualizację.",
|
||||
"appsUpdated": "Zaktualizowane aplikacje",
|
||||
"appsUpdatedNotifDescription": "Powiadamia użytkownika, gdy jedna lub więcej aplikacji zostało zaktualizowanych w tle",
|
||||
"xWasUpdatedToY": "{} zaktualizowano do {}.",
|
||||
"errorCheckingUpdates": "Sprawdzanie błędów aktualizacji",
|
||||
"errorCheckingUpdatesNotifDescription": "Powiadomienie wyświetlane, gdy sprawdzanie aktualizacji w tle nie powiedzie się",
|
||||
"appsRemoved": "Usunięte aplikacje",
|
||||
"appsRemovedNotifDescription": "Powiadamia użytkownika, gdy jedna lub więcej aplikacji zostało usuniętych z powodu błędów wczytywania",
|
||||
"xWasRemovedDueToErrorY": "Usunięto {} z powodu błędu: {}",
|
||||
"completeAppInstallation": "Ukończenie instalacji aplikacji",
|
||||
"obtainiumMustBeOpenToInstallApps": "Aby zainstalować aplikacje, Obtainium musi być otwarte",
|
||||
"completeAppInstallationNotifDescription": "Prosi użytkownika o powrót do Obtainium w celu dokończenia instalacji aplikacji",
|
||||
"checkingForUpdates": "Sprawdzanie aktualizacji",
|
||||
"checkingForUpdatesNotifDescription": "Tymczasowe powiadomienie pojawiające się podczas sprawdzania aktualizacji",
|
||||
"pleaseAllowInstallPerm": "Pozwól Obtainium instalować aplikacje",
|
||||
"trackOnly": "Tylko obserwuj",
|
||||
"errorWithHttpStatusCode": "Błąd {}",
|
||||
"versionCorrectionDisabled": "Korekta wersji wyłączona (wtyczka wydaje się nie działać)",
|
||||
"unknown": "Nieznane",
|
||||
"none": "Brak",
|
||||
"never": "Nigdy",
|
||||
"latestVersionX": "Najnowsza wersja: {}",
|
||||
"installedVersionX": "Zainstalowana wersja: {}",
|
||||
"lastUpdateCheckX": "Ostatnio sprawdzono: {}",
|
||||
"remove": "Usuń",
|
||||
"yesMarkUpdated": "Tak, oznacz jako zaktualizowane",
|
||||
"fdroid": "Oficjalny F-Droid",
|
||||
"appIdOrName": "ID aplikacji lub nazwa",
|
||||
"appWithIdOrNameNotFound": "Nie znaleziono aplikacji o tym identyfikatorze lub nazwie",
|
||||
"reposHaveMultipleApps": "Repozytoria mogą zawierać wiele aplikacji",
|
||||
"fdroidThirdPartyRepo": "Zewnętrzne repo F-Droid",
|
||||
"steam": "Steam",
|
||||
"steamMobile": "Mobilny Steam",
|
||||
"steamChat": "Steam Chat",
|
||||
"install": "Instaluj",
|
||||
"markInstalled": "Oznacz jako zainstalowane",
|
||||
"update": "Zaktualizuj",
|
||||
"markUpdated": "Oznacz jako zaktualizowane",
|
||||
"additionalOptions": "Dodatkowe opcje",
|
||||
"disableVersionDetection": "Wyłącz wykrywanie wersji",
|
||||
"noVersionDetectionExplanation": "Opcja ta powinna być używana tylko w przypadku aplikacji, w których wykrywanie wersji nie działa poprawnie.",
|
||||
"downloadingX": "Pobieranie {}",
|
||||
"downloadNotifDescription": "Powiadamia użytkownika o postępach w pobieraniu aplikacji",
|
||||
"noAPKFound": "Nie znaleziono pakietu APK",
|
||||
"noVersionDetection": "Bez wykrywania wersji",
|
||||
"categorize": "Kategoryzuj",
|
||||
"categories": "Kategorie",
|
||||
"category": "Kategoria",
|
||||
"noCategory": "Bez kategorii",
|
||||
"noCategories": "Brak kategorii",
|
||||
"deleteCategoriesQuestion": "Usunąć kategorie?",
|
||||
"categoryDeleteWarning": "Wszystkie aplikacje w usuniętych kategoriach zostaną ustawione jako nieskategoryzowane.",
|
||||
"addCategory": "Dodaj kategorię",
|
||||
"label": "Etykieta",
|
||||
"language": "Język",
|
||||
"copiedToClipboard": "Skopiowano do schowka",
|
||||
"storagePermissionDenied": "Odmówiono zezwolenia dostępu do pamięci",
|
||||
"selectedCategorizeWarning": "Spowoduje to zastąpienie wszystkich istniejących ustawień kategorii dla wybranych aplikacji.",
|
||||
"filterAPKsByRegEx": "Filtruj pliki APK według wyrażeń regularnych",
|
||||
"removeFromObtainium": "Usuń z Obtainium",
|
||||
"uninstallFromDevice": "Odinstaluj z urządzenia",
|
||||
"onlyWorksWithNonVersionDetectApps": "Działa tylko w przypadku aplikacji z wyłączonym wykrywaniem wersji.",
|
||||
"releaseDateAsVersion": "Użyj daty wydania jako wersji",
|
||||
"releaseDateAsVersionExplanation": "Opcja ta powinna być używana tylko w przypadku aplikacji, w których wykrywanie wersji nie działa poprawnie, ale dostępna jest data wydania.",
|
||||
"changes": "Zmiany",
|
||||
"releaseDate": "Data wydania",
|
||||
"importFromURLsInFile": "Importuj z adresów URL w pliku (typu OPML)",
|
||||
"versionDetection": "Wykrywanie wersji",
|
||||
"standardVersionDetection": "Standardowe wykrywanie wersji",
|
||||
"groupByCategory": "Grupuj według kategorii",
|
||||
"autoApkFilterByArch": "Spróbuj filtrować pliki APK według architektury procesora, jeśli to możliwe",
|
||||
"overrideSource": "Nadpisz źródło",
|
||||
"dontShowAgain": "Nie pokazuj tego ponownie",
|
||||
"dontShowTrackOnlyWarnings": "Nie wyświetlaj ostrzeżeń „Tylko obserwowana”",
|
||||
"dontShowAPKOriginWarnings": "Nie pokazuj ostrzeżeń o pochodzeniu APK",
|
||||
"moveNonInstalledAppsToBottom": "Przenieś niezainstalowane aplikacje na dół widoku aplikacji",
|
||||
"gitlabPATLabel": "Osobisty Token Dostępu GitLab (umożliwia wyszukiwanie)",
|
||||
"about": "Więcej informacji",
|
||||
"requiresCredentialsInSettings": "Wymaga to dodatkowych poświadczeń (w Ustawieniach)",
|
||||
"checkOnStart": "Sprawdź raz przy starcie",
|
||||
"tryInferAppIdFromCode": "Spróbuj wywnioskować identyfikator aplikacji z kodu źródłowego",
|
||||
"removeAppQuestion": {
|
||||
"one": "Usunąć aplikację?",
|
||||
"other": "Usunąć aplikacje?"
|
||||
},
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Zbyt wiele żądań (ograniczona częstotliwość) - spróbuj ponownie za {} min.",
|
||||
"other": "Zbyt wiele żądań (ograniczona częstotliwość) - spróbuj ponownie za {} min."
|
||||
},
|
||||
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
|
||||
"one": "Podczas sprawdzania aktualizacji w tle znaleziono {} aktualizację - w razie potrzeby użytkownik zostanie o tym powiadomiony",
|
||||
"other": "Podczas sprawdzania aktualizacji w tle znaleziono {} akt. - w razie potrzeby użytkownik zostanie o tym powiadomiony"
|
||||
},
|
||||
"apps": {
|
||||
"one": "{} aplik.",
|
||||
"other": "{} aplik."
|
||||
},
|
||||
"url": {
|
||||
"one": "{} adres URL",
|
||||
"other": "{} adr. URL"
|
||||
},
|
||||
"minute": {
|
||||
"one": "{} min.",
|
||||
"other": "{} min."
|
||||
},
|
||||
"hour": {
|
||||
"one": "{} godz.",
|
||||
"other": "{} godz."
|
||||
},
|
||||
"day": {
|
||||
"one": "{} dzień",
|
||||
"other": "{} dni"
|
||||
},
|
||||
"clearedNLogsBeforeXAfterY": {
|
||||
"one": "Wyczyszczono {n} log (przed = {before}, po = {after})",
|
||||
"other": "Wyczyszczono logi: {n} (przed = {before}, po = {after})"
|
||||
},
|
||||
"xAndNMoreUpdatesAvailable": {
|
||||
"one": "{} i jeszcze 1 aplikacja mają aktualizacje.",
|
||||
"other": "{} i {} aplik. otrzymało aktualizacje."
|
||||
},
|
||||
"xAndNMoreUpdatesInstalled": {
|
||||
"one": "Zaktualizowano {} i jeszcze 1 aplikację.",
|
||||
"other": "Zaktualizowano {} i {} aplik."
|
||||
}
|
||||
}
|
285
assets/translations/zh.json
Normal file
@ -0,0 +1,285 @@
|
||||
{
|
||||
"invalidURLForSource": "无效的 {} URL",
|
||||
"noReleaseFound": "找不到合适的发行版",
|
||||
"noVersionFound": "无法确定发行版本号",
|
||||
"urlMatchesNoSource": "URL 与已知的来源不符",
|
||||
"cantInstallOlderVersion": "无法安装旧版本的应用",
|
||||
"appIdMismatch": "所下载 APK 的应用 ID 与现有应用不一致",
|
||||
"functionNotImplemented": "该类未实现此功能",
|
||||
"placeholder": "占位符",
|
||||
"someErrors": "出现了一些错误",
|
||||
"unexpectedError": "意外错误",
|
||||
"ok": "好的",
|
||||
"and": "和",
|
||||
"startedBgUpdateTask": "后台更新检查任务已启动",
|
||||
"bgUpdateIgnoreAfterIs": "后台更新检查间隔为 {}",
|
||||
"startedActualBGUpdateCheck": "开始后台更新检查",
|
||||
"bgUpdateTaskFinished": "后台更新检查任务已完成",
|
||||
"firstRun": "这是 Obtainium 首次启动",
|
||||
"settingUpdateCheckIntervalTo": "更新检查间隔设置为 {}",
|
||||
"githubPATLabel": "GitHub 个人访问令牌(提升 API 请求限额)",
|
||||
"githubPATHint": "个人访问令牌必须为“username:token”的格式",
|
||||
"githubPATFormat": "username:token",
|
||||
"includePrereleases": "包含预发行版",
|
||||
"fallbackToOlderReleases": "将旧发行版作为备选",
|
||||
"filterReleaseTitlesByRegEx": "使用正则表达式筛选发行标题",
|
||||
"invalidRegEx": "无效的正则表达式",
|
||||
"noDescription": "无描述",
|
||||
"cancel": "取消",
|
||||
"continue": "继续",
|
||||
"requiredInBrackets": "(必填)",
|
||||
"dropdownNoOptsError": "错误:下拉菜单必须包含至少一个选项",
|
||||
"colour": "配色",
|
||||
"githubStarredRepos": "GitHub 已星标仓库",
|
||||
"uname": "用户名",
|
||||
"wrongArgNum": "参数数量错误",
|
||||
"xIsTrackOnly": "{}为“仅追踪”模式",
|
||||
"source": "源代码",
|
||||
"app": "应用",
|
||||
"appsFromSourceAreTrackOnly": "此来源的应用为“仅追踪”模式。",
|
||||
"youPickedTrackOnly": "您选择了“仅追踪”。",
|
||||
"trackOnlyAppDescription": "该应用的更新会被追踪,但 Obtainium 无法下载或安装它。",
|
||||
"cancelled": "已取消",
|
||||
"appAlreadyAdded": "此应用已经添加",
|
||||
"alreadyUpToDateQuestion": "应用是否已经为最新版本?",
|
||||
"addApp": "添加应用",
|
||||
"appSourceURL": "来源 URL",
|
||||
"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": "分享选中应用的 URL",
|
||||
"resetInstallStatus": "重置安装状态",
|
||||
"more": "更多",
|
||||
"removeOutdatedFilter": "删除失效的应用筛选",
|
||||
"showOutdatedOnly": "只显示待更新应用",
|
||||
"filter": "筛选",
|
||||
"filterActive": "筛选 *",
|
||||
"filterApps": "筛选应用",
|
||||
"appName": "应用名称",
|
||||
"author": "作者",
|
||||
"upToDateApps": "无需更新的应用",
|
||||
"nonInstalledApps": "未安装的应用",
|
||||
"importExport": "导入/导出",
|
||||
"settings": "设置",
|
||||
"exportedTo": "已导出至 {}",
|
||||
"obtainiumExport": "Obtainium 导出",
|
||||
"invalidInput": "无效的输入",
|
||||
"importedX": "已导入 {}",
|
||||
"obtainiumImport": "Obtainium 导入",
|
||||
"importFromURLList": "从 URL 列表导入",
|
||||
"searchQuery": "搜索查询",
|
||||
"appURLList": "应用 URL 列表",
|
||||
"line": "行",
|
||||
"searchX": "搜索 {}",
|
||||
"noResults": "无结果",
|
||||
"importX": "导入 {}",
|
||||
"importedAppsIdDisclaimer": "导入的应用可能错误地显示为“未安装”。\n请通过 Obtainium 重新安装这些应用来解决此问题。",
|
||||
"importErrors": "导入错误",
|
||||
"importedXOfYApps": "已导入 {} 中的 {} 个应用。",
|
||||
"followingURLsHadErrors": "下列 URL 存在错误:",
|
||||
"okay": "好的",
|
||||
"selectURL": "选择 URL",
|
||||
"selectURLs": "选择 URL",
|
||||
"pick": "选择",
|
||||
"theme": "主题",
|
||||
"dark": "深色",
|
||||
"light": "浅色",
|
||||
"followSystem": "跟随系统",
|
||||
"obtainium": "Obtainium",
|
||||
"materialYou": "Material You",
|
||||
"useBlackTheme": "使用纯黑深色主题",
|
||||
"appSortBy": "排序依据",
|
||||
"authorName": "作者 / 应用名称",
|
||||
"nameAuthor": "应用名称 / 作者",
|
||||
"asAdded": "添加次序",
|
||||
"appSortOrder": "顺序",
|
||||
"ascending": "升序",
|
||||
"descending": "降序",
|
||||
"bgUpdateCheckInterval": "后台更新检查间隔",
|
||||
"neverManualOnly": "手动",
|
||||
"appearance": "外观",
|
||||
"showWebInAppView": "在应用详情页显示来源网页",
|
||||
"pinUpdates": "将待更新应用置顶",
|
||||
"updates": "更新",
|
||||
"sourceSpecific": "来源相关",
|
||||
"appSource": "源代码",
|
||||
"noLogs": "无日志",
|
||||
"appLogs": "日志",
|
||||
"close": "关闭",
|
||||
"share": "分享",
|
||||
"appNotFound": "未找到应用",
|
||||
"obtainiumExportHyphenatedLowercase": "obtainium-export",
|
||||
"pickAnAPK": "选择一个 APK 文件",
|
||||
"appHasMoreThanOnePackage": "{} 有多个架构可用:",
|
||||
"deviceSupportsXArch": "您的设备支持 {} 架构。",
|
||||
"deviceSupportsFollowingArchs": "您的设备支持下列架构:",
|
||||
"warning": "警告",
|
||||
"sourceIsXButPackageFromYPrompt": "此应用的来源是“{}”,但 APK 文件来自“{}”。是否继续?",
|
||||
"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": "应用 ID 或名称",
|
||||
"appId": "App ID",
|
||||
"appWithIdOrNameNotFound": "未找到符合此 ID 或名称的应用",
|
||||
"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": "语言",
|
||||
"copiedToClipboard": "已复制至剪贴板",
|
||||
"storagePermissionDenied": "已拒绝授予存储权限",
|
||||
"selectedCategorizeWarning": "这将覆盖选中应用当前的类别设置。",
|
||||
"filterAPKsByRegEx": "使用正则表达式筛选 APK 文件",
|
||||
"removeFromObtainium": "从 Obtainium 中删除",
|
||||
"uninstallFromDevice": "从设备中卸载",
|
||||
"onlyWorksWithNonVersionDetectApps": "仅适用于禁用版本检测的应用。",
|
||||
"releaseDateAsVersion": "将发行日期作为版本号",
|
||||
"releaseDateAsVersionExplanation": "此选项应该仅用于无法进行版本检测但能够获取发行日期的应用。",
|
||||
"changes": "更新日志",
|
||||
"releaseDate": "发行日期",
|
||||
"importFromURLsInFile": "从文件中的 URL 导入(如 OPML)",
|
||||
"versionDetection": "版本检测",
|
||||
"standardVersionDetection": "常规版本检测",
|
||||
"groupByCategory": "按类别分组显示",
|
||||
"autoApkFilterByArch": "如果可能,尝试按设备支持的 CPU 架构筛选 APK 文件",
|
||||
"overrideSource": "覆盖来源",
|
||||
"dontShowAgain": "不再显示",
|
||||
"dontShowTrackOnlyWarnings": "不显示“仅追踪”模式警告",
|
||||
"dontShowAPKOriginWarnings": "不显示 APK 文件来源警告",
|
||||
"moveNonInstalledAppsToBottom": "将未安装应用置底",
|
||||
"gitlabPATLabel": "GitLab 个人访问令牌(用于搜索)",
|
||||
"about": "相关文档",
|
||||
"requiresCredentialsInSettings": "此功能需要额外的凭据(在“设置”中添加)",
|
||||
"checkOnStart": "启动时进行一次检查",
|
||||
"tryInferAppIdFromCode": "Try inferring App ID from source code",
|
||||
"removeAppQuestion": {
|
||||
"one": "是否删除应用?",
|
||||
"other": "是否删除应用?"
|
||||
},
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "API 请求过于频繁(速率限制)- 在 {} 分钟后重试",
|
||||
"other": "API 请求过于频繁(速率限制)- 在 {} 分钟后重试"
|
||||
},
|
||||
"bgUpdateGotErrorRetryInMinutes": {
|
||||
"one": "后台更新检查遇到了“{}”问题,预定于 {} 分钟后重试",
|
||||
"other": "后台更新检查遇到了“{}”问题,预定于 {} 分钟后重试"
|
||||
},
|
||||
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
|
||||
"one": "后台检查发现 {} 个应用更新 - 如有需要将发出通知",
|
||||
"other": "后台检查发现 {} 个应用更新 - 如有需要将发出通知"
|
||||
},
|
||||
"apps": {
|
||||
"one": "{} 个应用",
|
||||
"other": "{} 个应用"
|
||||
},
|
||||
"url": {
|
||||
"one": "{} 个 URL",
|
||||
"other": "{} 个 URL"
|
||||
},
|
||||
"minute": {
|
||||
"one": "{} 分钟",
|
||||
"other": "{} 分钟"
|
||||
},
|
||||
"hour": {
|
||||
"one": "{} 小时",
|
||||
"other": "{} 小时"
|
||||
},
|
||||
"day": {
|
||||
"one": "{} 天",
|
||||
"other": "{} 天"
|
||||
},
|
||||
"clearedNLogsBeforeXAfterY": {
|
||||
"one": "清除了 {n} 个日志({before} 之前,{after} 之后)",
|
||||
"other": "清除了 {n} 个日志({before} 之前,{after} 之后)"
|
||||
},
|
||||
"xAndNMoreUpdatesAvailable": {
|
||||
"one": "{} 和另外 1 个应用可以更新了。",
|
||||
"other": "{} 和另外 {} 个应用可以更新了。"
|
||||
},
|
||||
"xAndNMoreUpdatesInstalled": {
|
||||
"one": "{} 和另外 1 个应用已更新。",
|
||||
"other": "{} 和另外 {} 个应用已更新。"
|
||||
}
|
||||
}
|
114
lib/app_sources/apkcombo.dart
Normal file
@ -0,0 +1,114 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class APKCombo extends AppSource {
|
||||
APKCombo() {
|
||||
host = 'apkcombo.com';
|
||||
}
|
||||
|
||||
@override
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/+[^/]+/+[^/]+');
|
||||
var match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> tryInferringAppId(String standardUrl,
|
||||
{Map<String, dynamic> additionalSettings = const {}}) async {
|
||||
return Uri.parse(standardUrl).pathSegments.last;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, String> get requestHeaders => {
|
||||
"User-Agent": "curl/8.0.1",
|
||||
"Accept": "*/*",
|
||||
"Connection": "keep-alive",
|
||||
"Host": "$host"
|
||||
};
|
||||
|
||||
Future<List<MapEntry<String, String>>> getApkUrls(String standardUrl) async {
|
||||
var res = await sourceRequest('$standardUrl/download/apk');
|
||||
if (res.statusCode != 200) {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
var html = parse(res.body);
|
||||
return html
|
||||
.querySelectorAll('#variants-tab > div > ul > li')
|
||||
.map((e) {
|
||||
String? arch = e
|
||||
.querySelector('code')
|
||||
?.text
|
||||
.trim()
|
||||
.replaceAll(',', '')
|
||||
.replaceAll(':', '-')
|
||||
.replaceAll(' ', '-');
|
||||
return e.querySelectorAll('a').map((e) {
|
||||
String? url = e.attributes['href'];
|
||||
if (url != null &&
|
||||
!Uri.parse(url).path.toLowerCase().endsWith('.apk')) {
|
||||
url = null;
|
||||
}
|
||||
String verCode =
|
||||
e.querySelector('.info .header .vercode')?.text.trim() ?? '';
|
||||
return MapEntry<String, String>(
|
||||
arch != null ? '$arch-$verCode.apk' : '', url ?? '');
|
||||
}).toList();
|
||||
})
|
||||
.reduce((value, element) => [...value, ...element])
|
||||
.where((element) => element.value.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(
|
||||
String apkUrl, String standardUrl) async {
|
||||
var freshURLs = await getApkUrls(standardUrl);
|
||||
var path2Match = Uri.parse(apkUrl).path;
|
||||
for (var url in freshURLs) {
|
||||
if (Uri.parse(url.value).path == path2Match) {
|
||||
return url.value;
|
||||
}
|
||||
}
|
||||
throw NoAPKError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
String appId = (await tryInferringAppId(standardUrl))!;
|
||||
var preres = await sourceRequest(standardUrl);
|
||||
if (preres.statusCode != 200) {
|
||||
throw getObtainiumHttpError(preres);
|
||||
}
|
||||
var res = parse(preres.body);
|
||||
String? version = res.querySelector('div.version')?.text.trim();
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
String appName = res.querySelector('div.app_name')?.text.trim() ?? appId;
|
||||
String author = res.querySelector('div.author')?.text.trim() ?? appName;
|
||||
List<String> infoArray = res
|
||||
.querySelectorAll('div.information-table > .item > div.value')
|
||||
.map((e) => e.text.trim())
|
||||
.toList();
|
||||
DateTime? releaseDate;
|
||||
if (infoArray.length >= 2) {
|
||||
try {
|
||||
releaseDate = DateFormat('MMMM d, yyyy').parse(infoArray[1]);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return APKDetails(
|
||||
version, await getApkUrls(standardUrl), AppNames(author, appName),
|
||||
releaseDate: releaseDate);
|
||||
}
|
||||
}
|
@ -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,14 +11,31 @@ 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
|
||||
String standardizeURL(String url) {
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(runtimeType.toString());
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -25,14 +46,41 @@ class APKMirror extends AppSource {
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/feed'));
|
||||
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 sourceRequest('$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)
|
||||
@ -43,13 +91,13 @@ class APKMirror extends AppSource {
|
||||
if (version == null || version.isEmpty) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, []);
|
||||
return APKDetails(version, [], getAppNames(standardUrl),
|
||||
releaseDate: releaseDate);
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
||||
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
||||
|
77
lib/app_sources/apkpure.dart
Normal file
@ -0,0 +1,77 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class APKPure extends AppSource {
|
||||
APKPure() {
|
||||
host = 'apkpure.com';
|
||||
}
|
||||
|
||||
@override
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
RegExp standardUrlRegExB = RegExp('^https?://m.$host/+[^/]+/+[^/]+');
|
||||
RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase());
|
||||
if (match != null) {
|
||||
url = 'https://$host/${Uri.parse(url).path}';
|
||||
}
|
||||
RegExp standardUrlRegExA = RegExp('^https?://$host/+[^/]+/+[^/]+');
|
||||
match = standardUrlRegExA.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> tryInferringAppId(String standardUrl,
|
||||
{Map<String, dynamic> additionalSettings = const {}}) async {
|
||||
return Uri.parse(standardUrl).pathSegments.last;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
String appId = (await tryInferringAppId(standardUrl))!;
|
||||
String host = Uri.parse(standardUrl).host;
|
||||
var res = await sourceRequest('$standardUrl/download');
|
||||
if (res.statusCode == 200) {
|
||||
var html = parse(res.body);
|
||||
String? version = html.querySelector('span.info-sdk span')?.text.trim();
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
String? dateString =
|
||||
html.querySelector('span.info-other span.date')?.text.trim();
|
||||
DateTime? releaseDate;
|
||||
try {
|
||||
releaseDate = dateString != null
|
||||
? DateFormat('MMM dd, yyyy').parse(dateString)
|
||||
: null;
|
||||
releaseDate = dateString != null && releaseDate == null
|
||||
? DateFormat('MMMM dd, yyyy').parse(dateString)
|
||||
: null;
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
String type = html.querySelector('a.info-tag')?.text.trim() ?? 'APK';
|
||||
List<MapEntry<String, String>> apkUrls = [
|
||||
MapEntry('$appId.apk', 'https://d.$host/b/$type/$appId?version=latest')
|
||||
];
|
||||
String author = html
|
||||
.querySelector('span.info-sdk')
|
||||
?.text
|
||||
.trim()
|
||||
.substring(version.length + 4) ??
|
||||
Uri.parse(standardUrl).pathSegments.reversed.last;
|
||||
String appName =
|
||||
html.querySelector('h1.info-title')?.text.trim() ?? appId;
|
||||
return APKDetails(version, apkUrls, AppNames(author, appName),
|
||||
releaseDate: releaseDate);
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
77
lib/app_sources/codeberg.dart
Normal file
@ -0,0 +1,77 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class Codeberg extends AppSource {
|
||||
Codeberg() {
|
||||
host = 'codeberg.org';
|
||||
|
||||
additionalSourceSpecificSettingFormItems = [];
|
||||
|
||||
additionalSourceAppSpecificSettingFormItems = [
|
||||
[
|
||||
GeneratedFormSwitch('includePrereleases',
|
||||
label: tr('includePrereleases'), defaultValue: false)
|
||||
],
|
||||
[
|
||||
GeneratedFormSwitch('fallbackToOlderReleases',
|
||||
label: tr('fallbackToOlderReleases'), defaultValue: true)
|
||||
],
|
||||
[
|
||||
GeneratedFormTextField('filterReleaseTitlesByRegEx',
|
||||
label: tr('filterReleaseTitlesByRegEx'),
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
return regExValidator(value);
|
||||
}
|
||||
])
|
||||
]
|
||||
];
|
||||
|
||||
canSearch = true;
|
||||
}
|
||||
|
||||
var gh = GitHub();
|
||||
|
||||
@override
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl/releases';
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
return await gh.getLatestAPKDetailsCommon2(standardUrl, additionalSettings,
|
||||
(bool useTagUrl) async {
|
||||
return 'https://$host/api/v1/repos${standardUrl.substring('https://$host'.length)}/${useTagUrl ? 'tags' : 'releases'}?per_page=100';
|
||||
}, null);
|
||||
}
|
||||
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
||||
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
||||
return AppNames(names[0], names[1]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, List<String>>> search(String query) async {
|
||||
return gh.searchCommon(
|
||||
query,
|
||||
'https://$host/api/v1/repos/search?q=${Uri.encodeQueryComponent(query)}&limit=100',
|
||||
'data');
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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';
|
||||
@ -7,34 +9,35 @@ import 'package:obtainium/providers/source_provider.dart';
|
||||
class FDroid extends AppSource {
|
||||
FDroid() {
|
||||
host = 'f-droid.org';
|
||||
name = tr('fdroid');
|
||||
canSearch = true;
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
RegExp standardUrlRegExB =
|
||||
RegExp('^https?://$host/+[^/]+/+packages/+[^/]+');
|
||||
RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase());
|
||||
if (match != null) {
|
||||
url = 'https://$host/packages/${Uri.parse(url).pathSegments.last}';
|
||||
url =
|
||||
'https://${Uri.parse(url.substring(0, match.end)).host}/packages/${Uri.parse(url).pathSegments.last}';
|
||||
}
|
||||
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
|
||||
match = standardUrlRegExA.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(runtimeType.toString());
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
String? tryInferringAppId(String standardUrl) {
|
||||
Future<String?> tryInferringAppId(String standardUrl,
|
||||
{Map<String, dynamic> additionalSettings = const {}}) async {
|
||||
return Uri.parse(standardUrl).pathSegments.last;
|
||||
}
|
||||
|
||||
APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||
Response res, String apkUrlPrefix) {
|
||||
Response res, String apkUrlPrefix, String standardUrl) {
|
||||
if (res.statusCode == 200) {
|
||||
List<dynamic> releases = jsonDecode(res.body)['packages'] ?? [];
|
||||
if (releases.isEmpty) {
|
||||
@ -48,24 +51,52 @@ class FDroid extends AppSource {
|
||||
.where((element) => element['versionName'] == latestVersion)
|
||||
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
|
||||
.toList();
|
||||
return APKDetails(latestVersion, apkUrls);
|
||||
return APKDetails(latestVersion, getApkUrlsFromUrls(apkUrls),
|
||||
AppNames(name, Uri.parse(standardUrl).pathSegments.last));
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
String? appId = tryInferringAppId(standardUrl);
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
String? appId = await tryInferringAppId(standardUrl);
|
||||
String host = Uri.parse(standardUrl).host;
|
||||
return getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||
await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')),
|
||||
'https://f-droid.org/repo/$appId');
|
||||
await sourceRequest('https://$host/api/v1/packages/$appId'),
|
||||
'https://$host/repo/$appId',
|
||||
standardUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
||||
Future<Map<String, List<String>>> search(String query) async {
|
||||
Response res = await sourceRequest(
|
||||
'https://search.$host/?q=${Uri.encodeQueryComponent(query)}');
|
||||
if (res.statusCode == 200) {
|
||||
Map<String, List<String>> urlsWithDescriptions = {};
|
||||
parse(res.body).querySelectorAll('.package-header').forEach((e) {
|
||||
String? url = e.attributes['href'];
|
||||
if (url != null) {
|
||||
try {
|
||||
standardizeUrl(url);
|
||||
} catch (e) {
|
||||
url = null;
|
||||
}
|
||||
}
|
||||
if (url != null) {
|
||||
urlsWithDescriptions[url] = [
|
||||
e.querySelector('.package-name')?.text.trim() ?? '',
|
||||
e.querySelector('.package-summary')?.text.trim() ??
|
||||
tr('noDescription')
|
||||
];
|
||||
}
|
||||
});
|
||||
return urlsWithDescriptions;
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
78
lib/app_sources/fdroidrepo.dart
Normal file
@ -0,0 +1,78 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class FDroidRepo extends AppSource {
|
||||
FDroidRepo() {
|
||||
name = tr('fdroidThirdPartyRepo');
|
||||
|
||||
additionalSourceAppSpecificSettingFormItems = [
|
||||
[
|
||||
GeneratedFormTextField('appIdOrName',
|
||||
label: tr('appIdOrName'),
|
||||
hint: tr('reposHaveMultipleApps'),
|
||||
required: true)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
String? appIdOrName = additionalSettings['appIdOrName'];
|
||||
if (appIdOrName == null) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
var res = await sourceRequest('$standardUrl/index.xml');
|
||||
if (res.statusCode == 200) {
|
||||
var body = parse(res.body);
|
||||
var foundApps = body.querySelectorAll('application').where((element) {
|
||||
return element.attributes['id'] == appIdOrName;
|
||||
}).toList();
|
||||
if (foundApps.isEmpty) {
|
||||
foundApps = body.querySelectorAll('application').where((element) {
|
||||
return element.querySelector('name')?.innerHtml.toLowerCase() ==
|
||||
appIdOrName.toLowerCase();
|
||||
}).toList();
|
||||
}
|
||||
if (foundApps.isEmpty) {
|
||||
foundApps = body.querySelectorAll('application').where((element) {
|
||||
return element
|
||||
.querySelector('name')
|
||||
?.innerHtml
|
||||
.toLowerCase()
|
||||
.contains(appIdOrName.toLowerCase()) ??
|
||||
false;
|
||||
}).toList();
|
||||
}
|
||||
if (foundApps.isEmpty) {
|
||||
throw ObtainiumError(tr('appWithIdOrNameNotFound'));
|
||||
}
|
||||
var authorName = body.querySelector('repo')?.attributes['name'] ?? name;
|
||||
var appName =
|
||||
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();
|
||||
}
|
||||
List<String> apkUrls = releases
|
||||
.where((element) =>
|
||||
element.querySelector('version')?.innerHtml == latestVersion &&
|
||||
element.querySelector('apkname') != null)
|
||||
.map((e) => '$standardUrl/${e.querySelector('apkname')!.innerHtml}')
|
||||
.toList();
|
||||
return APKDetails(latestVersion, getApkUrlsFromUrls(apkUrls),
|
||||
AppNames(authorName, appName),
|
||||
releaseDate: releaseDate);
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
@ -2,8 +2,11 @@ import 'dart:convert';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/html.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/logs_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@ -11,13 +14,12 @@ import 'package:url_launcher/url_launcher_string.dart';
|
||||
class GitHub extends AppSource {
|
||||
GitHub() {
|
||||
host = 'github.com';
|
||||
|
||||
additionalSourceAppSpecificDefaults = ['true', 'true', ''];
|
||||
appIdInferIsOptional = true;
|
||||
|
||||
additionalSourceSpecificSettingFormItems = [
|
||||
GeneratedFormItem(
|
||||
GeneratedFormTextField('github-creds',
|
||||
label: tr('githubPATLabel'),
|
||||
id: 'github-creds',
|
||||
password: true,
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
@ -36,7 +38,7 @@ class GitHub extends AppSource {
|
||||
hint: tr('githubPATFormat'),
|
||||
belowWidgets: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
height: 4,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
@ -45,38 +47,32 @@ class GitHub extends AppSource {
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text(
|
||||
tr('githubPATLinkText'),
|
||||
tr('about'),
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline, fontSize: 12),
|
||||
))
|
||||
)),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
])
|
||||
];
|
||||
|
||||
additionalSourceAppSpecificFormItems = [
|
||||
additionalSourceAppSpecificSettingFormItems = [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr('includePrereleases'), type: FormItemType.bool)
|
||||
GeneratedFormSwitch('includePrereleases',
|
||||
label: tr('includePrereleases'), defaultValue: false)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr('fallbackToOlderReleases'), type: FormItemType.bool)
|
||||
GeneratedFormSwitch('fallbackToOlderReleases',
|
||||
label: tr('fallbackToOlderReleases'), defaultValue: true)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
GeneratedFormTextField('filterReleaseTitlesByRegEx',
|
||||
label: tr('filterReleaseTitlesByRegEx'),
|
||||
type: FormItemType.string,
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RegExp(value);
|
||||
} catch (e) {
|
||||
return tr('invalidRegEx');
|
||||
}
|
||||
return null;
|
||||
return regExValidator(value);
|
||||
}
|
||||
])
|
||||
]
|
||||
@ -86,11 +82,49 @@ class GitHub extends AppSource {
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
Future<String?> tryInferringAppId(String standardUrl,
|
||||
{Map<String, dynamic> additionalSettings = const {}}) async {
|
||||
const possibleBuildGradleLocations = [
|
||||
'/app/build.gradle',
|
||||
'android/app/build.gradle',
|
||||
'src/app/build.gradle'
|
||||
];
|
||||
for (var path in possibleBuildGradleLocations) {
|
||||
try {
|
||||
var res = await sourceRequest(
|
||||
'${await convertStandardUrlToAPIUrl(standardUrl)}/contents/$path');
|
||||
if (res.statusCode == 200) {
|
||||
try {
|
||||
var body = jsonDecode(res.body);
|
||||
var appId = utf8
|
||||
.decode(base64
|
||||
.decode(body['content'].toString().split('\n').join('')))
|
||||
.split('\n')
|
||||
.map((e) => e.trim())
|
||||
.where((l) => l.startsWith('applicationId "'))
|
||||
.first
|
||||
.split('"')[1];
|
||||
if (appId.isNotEmpty) {
|
||||
return appId;
|
||||
}
|
||||
} catch (err) {
|
||||
LogsProvider().add(
|
||||
'Error parsing build.gradle from ${res.request!.url.toString()}: ${err.toString()}');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore - ID will be extracted from the APK
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(runtimeType.toString());
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -99,56 +133,102 @@ class GitHub extends AppSource {
|
||||
SettingsProvider settingsProvider = SettingsProvider();
|
||||
await settingsProvider.initializeSettings();
|
||||
String? creds = settingsProvider
|
||||
.getSettingString(additionalSourceSpecificSettingFormItems[0].id);
|
||||
.getSettingString(additionalSourceSpecificSettingFormItems[0].key);
|
||||
return creds != null && creds.isNotEmpty ? '$creds@' : '';
|
||||
}
|
||||
|
||||
Future<String> getAPIHost() async =>
|
||||
'https://${await getCredentialPrefixIfAny()}api.$host';
|
||||
|
||||
Future<String> convertStandardUrlToAPIUrl(String standardUrl) async =>
|
||||
'${await getAPIHost()}/repos${standardUrl.substring('https://$host'.length)}';
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl/releases';
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
var includePrereleases =
|
||||
additionalData.isNotEmpty && additionalData[0] == 'true';
|
||||
var fallbackToOlderReleases =
|
||||
additionalData.length >= 2 && additionalData[1] == 'true';
|
||||
var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty
|
||||
? additionalData[2]
|
||||
: null;
|
||||
Response res = await get(Uri.parse(
|
||||
'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
|
||||
Future<APKDetails> getLatestAPKDetailsCommon(String requestUrl,
|
||||
String standardUrl, Map<String, dynamic> additionalSettings,
|
||||
{Function(Response)? onHttpErrorCode}) async {
|
||||
bool includePrereleases = additionalSettings['includePrereleases'] == true;
|
||||
bool fallbackToOlderReleases =
|
||||
additionalSettings['fallbackToOlderReleases'] == true;
|
||||
String? regexFilter =
|
||||
(additionalSettings['filterReleaseTitlesByRegEx'] as String?)
|
||||
?.isNotEmpty ==
|
||||
true
|
||||
? additionalSettings['filterReleaseTitlesByRegEx']
|
||||
: null;
|
||||
Response res = await sourceRequest(requestUrl);
|
||||
if (res.statusCode == 200) {
|
||||
var releases = jsonDecode(res.body) as List<dynamic>;
|
||||
|
||||
List<String> getReleaseAPKUrls(dynamic release) =>
|
||||
List<MapEntry<String, String>> getReleaseAPKUrls(dynamic release) =>
|
||||
(release['assets'] as List<dynamic>?)
|
||||
?.map((e) {
|
||||
return e['browser_download_url'] != null
|
||||
? e['browser_download_url'] as String
|
||||
: '';
|
||||
return e['name'] != null && e['browser_download_url'] != null
|
||||
? MapEntry(e['name'] as String,
|
||||
e['browser_download_url'] as String)
|
||||
: const MapEntry('', '');
|
||||
})
|
||||
.where((element) => element.toLowerCase().endsWith('.apk'))
|
||||
.where((element) => element.key.toLowerCase().endsWith('.apk'))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
DateTime? getReleaseDateFromRelease(dynamic rel) =>
|
||||
rel?['published_at'] != null
|
||||
? DateTime.parse(rel['published_at'])
|
||||
: null;
|
||||
releases.sort((a, b) {
|
||||
// See #478 and #534
|
||||
if (a == b) {
|
||||
return 0;
|
||||
} else if (a == null) {
|
||||
return -1;
|
||||
} else if (b == null) {
|
||||
return 1;
|
||||
} else {
|
||||
var nameA = a['tag_name'] ?? a['name'];
|
||||
var nameB = b['tag_name'] ?? b['name'];
|
||||
var stdFormats = findStandardFormatsForVersion(nameA, true)
|
||||
.intersection(findStandardFormatsForVersion(nameB, true));
|
||||
if (stdFormats.isNotEmpty) {
|
||||
var reg = RegExp(stdFormats.first);
|
||||
var matchA = reg.firstMatch(nameA);
|
||||
var matchB = reg.firstMatch(nameB);
|
||||
return compareAlphaNumeric(
|
||||
(nameA as String).substring(matchA!.start, matchA.end),
|
||||
(nameB as String).substring(matchB!.start, matchB.end));
|
||||
} else {
|
||||
return getReleaseDateFromRelease(a)!
|
||||
.compareTo(getReleaseDateFromRelease(b)!);
|
||||
}
|
||||
}
|
||||
});
|
||||
releases = releases.reversed.toList();
|
||||
dynamic targetRelease;
|
||||
|
||||
var prerrelsSkipped = 0;
|
||||
for (int i = 0; i < releases.length; i++) {
|
||||
if (!fallbackToOlderReleases && i > 0) break;
|
||||
if (!fallbackToOlderReleases && i > prerrelsSkipped) break;
|
||||
if (!includePrereleases && releases[i]['prerelease'] == true) {
|
||||
prerrelsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (releases[i]['draft'] == true) {
|
||||
// Draft releases not supported
|
||||
continue;
|
||||
}
|
||||
var nameToFilter = releases[i]['name'] as String?;
|
||||
if (nameToFilter == null || nameToFilter.trim().isEmpty) {
|
||||
// Some leave titles empty so tag is used
|
||||
nameToFilter = releases[i]['tag_name'] as String;
|
||||
}
|
||||
if (regexFilter != null &&
|
||||
!RegExp(regexFilter)
|
||||
.hasMatch((releases[i]['name'] as String).trim())) {
|
||||
!RegExp(regexFilter).hasMatch(nameToFilter.trim())) {
|
||||
continue;
|
||||
}
|
||||
var apkUrls = getReleaseAPKUrls(releases[i]);
|
||||
if (apkUrls.isEmpty && !trackOnly) {
|
||||
if (apkUrls.isEmpty && additionalSettings['trackOnly'] != true) {
|
||||
continue;
|
||||
}
|
||||
targetRelease = releases[i];
|
||||
@ -158,44 +238,101 @@ class GitHub extends AppSource {
|
||||
if (targetRelease == null) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
String? version = targetRelease['tag_name'];
|
||||
String? version = targetRelease['tag_name'] ?? targetRelease['name'];
|
||||
DateTime? releaseDate = getReleaseDateFromRelease(targetRelease);
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, targetRelease['apkUrls'] as List<String>);
|
||||
var changeLog = targetRelease['body'].toString();
|
||||
return APKDetails(
|
||||
version,
|
||||
targetRelease['apkUrls'] as List<MapEntry<String, String>>,
|
||||
getAppNames(standardUrl),
|
||||
releaseDate: releaseDate,
|
||||
changeLog: changeLog.isEmpty ? null : changeLog);
|
||||
} else {
|
||||
rateLimitErrorCheck(res);
|
||||
if (onHttpErrorCode != null) {
|
||||
onHttpErrorCode(res);
|
||||
}
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
getLatestAPKDetailsCommon2(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
Future<String> Function(bool) reqUrlGenerator,
|
||||
dynamic Function(Response)? onHttpErrorCode) async {
|
||||
try {
|
||||
return await getLatestAPKDetailsCommon(
|
||||
await reqUrlGenerator(false), standardUrl, additionalSettings,
|
||||
onHttpErrorCode: onHttpErrorCode);
|
||||
} catch (err) {
|
||||
if (err is NoReleasesError && additionalSettings['trackOnly'] == true) {
|
||||
return await getLatestAPKDetailsCommon(
|
||||
await reqUrlGenerator(true), standardUrl, additionalSettings,
|
||||
onHttpErrorCode: onHttpErrorCode);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
return await getLatestAPKDetailsCommon2(standardUrl, additionalSettings,
|
||||
(bool useTagUrl) async {
|
||||
return '${await convertStandardUrlToAPIUrl(standardUrl)}/${useTagUrl ? 'tags' : 'releases'}?per_page=100';
|
||||
}, (Response res) {
|
||||
rateLimitErrorCheck(res);
|
||||
});
|
||||
}
|
||||
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
||||
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
||||
return AppNames(names[0], names[1]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> search(String query) async {
|
||||
Response res = await get(Uri.parse(
|
||||
'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100'));
|
||||
Future<Map<String, List<String>>> searchCommon(
|
||||
String query, String requestUrl, String rootProp,
|
||||
{Function(Response)? onHttpErrorCode}) async {
|
||||
Response res = await sourceRequest(requestUrl);
|
||||
if (res.statusCode == 200) {
|
||||
Map<String, String> urlsWithDescriptions = {};
|
||||
for (var e in (jsonDecode(res.body)['items'] as List<dynamic>)) {
|
||||
Map<String, List<String>> urlsWithDescriptions = {};
|
||||
for (var e in (jsonDecode(res.body)[rootProp] as List<dynamic>)) {
|
||||
urlsWithDescriptions.addAll({
|
||||
e['html_url'] as String: e['description'] != null
|
||||
? e['description'] as String
|
||||
: tr('noDescription')
|
||||
e['html_url'] as String: [
|
||||
e['full_name'] as String,
|
||||
((e['archived'] == true ? '[ARCHIVED] ' : '') +
|
||||
(e['description'] != null
|
||||
? e['description'] as String
|
||||
: tr('noDescription')))
|
||||
]
|
||||
});
|
||||
}
|
||||
return urlsWithDescriptions;
|
||||
} else {
|
||||
rateLimitErrorCheck(res);
|
||||
if (onHttpErrorCode != null) {
|
||||
onHttpErrorCode(res);
|
||||
}
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, List<String>>> search(String query) async {
|
||||
return searchCommon(
|
||||
query,
|
||||
'${await getAPIHost()}/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100',
|
||||
'items', onHttpErrorCode: (Response res) {
|
||||
rateLimitErrorCheck(res);
|
||||
});
|
||||
}
|
||||
|
||||
rateLimitErrorCheck(Response res) {
|
||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||
throw RateLimitError(
|
||||
|
@ -1,70 +1,159 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
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/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class GitLab extends AppSource {
|
||||
GitLab() {
|
||||
host = 'gitlab.com';
|
||||
canSearch = true;
|
||||
|
||||
additionalSourceSpecificSettingFormItems = [
|
||||
GeneratedFormTextField('gitlab-creds',
|
||||
label: tr('gitlabPATLabel'),
|
||||
password: true,
|
||||
required: false,
|
||||
belowWidgets: [
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
'https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token',
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text(
|
||||
tr('about'),
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline, fontSize: 12),
|
||||
)),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
)
|
||||
])
|
||||
];
|
||||
|
||||
additionalSourceAppSpecificSettingFormItems = [
|
||||
[
|
||||
GeneratedFormSwitch('fallbackToOlderReleases',
|
||||
label: tr('fallbackToOlderReleases'), defaultValue: true)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(runtimeType.toString());
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
Future<String?> getPATIfAny() async {
|
||||
SettingsProvider settingsProvider = SettingsProvider();
|
||||
await settingsProvider.initializeSettings();
|
||||
String? creds = settingsProvider
|
||||
.getSettingString(additionalSourceSpecificSettingFormItems[0].key);
|
||||
return creds != null && creds.isNotEmpty ? creds : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, List<String>>> search(String query) async {
|
||||
String? PAT = await getPATIfAny();
|
||||
if (PAT == null) {
|
||||
throw CredsNeededError(name);
|
||||
}
|
||||
var url =
|
||||
'https://$host/api/v4/search?private_token=$PAT&scope=projects&search=${Uri.encodeQueryComponent(query)}';
|
||||
var res = await sourceRequest(url);
|
||||
if (res.statusCode != 200) {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
var json = jsonDecode(res.body) as List<dynamic>;
|
||||
Map<String, List<String>> results = {};
|
||||
for (var element in json) {
|
||||
results['https://$host/${element['path_with_namespace']}'] = [
|
||||
element['name_with_namespace'],
|
||||
element['description'] ?? tr('noDescription')
|
||||
];
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl/-/releases';
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
bool fallbackToOlderReleases =
|
||||
additionalSettings['fallbackToOlderReleases'] == true;
|
||||
Response res = await sourceRequest('$standardUrl/-/tags?format=atom');
|
||||
if (res.statusCode == 200) {
|
||||
var standardUri = Uri.parse(standardUrl);
|
||||
var parsedHtml = parse(res.body);
|
||||
var entry = parsedHtml.querySelector('entry');
|
||||
var entryContent =
|
||||
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
|
||||
var apkUrls = [
|
||||
...getLinksFromParsedHTML(
|
||||
entryContent,
|
||||
RegExp(
|
||||
'^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
||||
return '\\${x[0]}';
|
||||
})}/uploads/[^/]+/[^/]+\\.apk\$',
|
||||
caseSensitive: false),
|
||||
standardUri.origin),
|
||||
// GitLab releases may contain links to externally hosted APKs
|
||||
...getLinksFromParsedHTML(entryContent,
|
||||
RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
|
||||
.where((element) => Uri.parse(element).host != '')
|
||||
.toList()
|
||||
];
|
||||
var apkDetailsList = parsedHtml.querySelectorAll('entry').map((entry) {
|
||||
var entryContent = parse(
|
||||
parseFragment(entry.querySelector('content')!.innerHtml).text);
|
||||
var apkUrls = [
|
||||
...getLinksFromParsedHTML(
|
||||
entryContent,
|
||||
RegExp(
|
||||
'^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
||||
return '\\${x[0]}';
|
||||
})}/uploads/[^/]+/[^/]+\\.apk\$',
|
||||
caseSensitive: false),
|
||||
standardUri.origin),
|
||||
// GitLab releases may contain links to externally hosted APKs
|
||||
...getLinksFromParsedHTML(entryContent,
|
||||
RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
|
||||
.where((element) => Uri.parse(element).host != '')
|
||||
.toList()
|
||||
];
|
||||
|
||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||
var version =
|
||||
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
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, getApkUrlsFromUrls(apkUrls),
|
||||
GitHub().getAppNames(standardUrl),
|
||||
releaseDate: releaseDate);
|
||||
});
|
||||
if (apkDetailsList.isEmpty) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
return APKDetails(version, apkUrls);
|
||||
if (fallbackToOlderReleases) {
|
||||
if (additionalSettings['trackOnly'] != true) {
|
||||
apkDetailsList =
|
||||
apkDetailsList.where((e) => e.apkUrls.isNotEmpty).toList();
|
||||
}
|
||||
if (apkDetailsList.isEmpty) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
return apkDetailsList.first;
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
// Same as GitHub
|
||||
return GitHub().getAppNames(standardUrl);
|
||||
}
|
||||
}
|
||||
|
134
lib/app_sources/html.dart
Normal file
@ -0,0 +1,134 @@
|
||||
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';
|
||||
|
||||
String ensureAbsoluteUrl(String ambiguousUrl, Uri referenceAbsoluteUrl) {
|
||||
try {
|
||||
Uri.parse(ambiguousUrl).origin;
|
||||
return ambiguousUrl;
|
||||
} catch (err) {
|
||||
// is relative
|
||||
}
|
||||
var currPathSegments = referenceAbsoluteUrl.path
|
||||
.split('/')
|
||||
.where((element) => element.trim().isNotEmpty)
|
||||
.toList();
|
||||
if (ambiguousUrl.startsWith('/') || currPathSegments.isEmpty) {
|
||||
return '${referenceAbsoluteUrl.origin}/$ambiguousUrl';
|
||||
} else if (ambiguousUrl.split('/').length == 1) {
|
||||
return '${referenceAbsoluteUrl.origin}/${currPathSegments.join('/')}/$ambiguousUrl';
|
||||
} else {
|
||||
return '${referenceAbsoluteUrl.origin}/${currPathSegments.sublist(0, currPathSegments.length - 1).join('/')}/$ambiguousUrl';
|
||||
}
|
||||
}
|
||||
|
||||
int compareAlphaNumeric(String a, String b) {
|
||||
List<String> aParts = _splitAlphaNumeric(a);
|
||||
List<String> bParts = _splitAlphaNumeric(b);
|
||||
|
||||
for (int i = 0; i < aParts.length && i < bParts.length; i++) {
|
||||
String aPart = aParts[i];
|
||||
String bPart = bParts[i];
|
||||
|
||||
bool aIsNumber = _isNumeric(aPart);
|
||||
bool bIsNumber = _isNumeric(bPart);
|
||||
|
||||
if (aIsNumber && bIsNumber) {
|
||||
int aNumber = int.parse(aPart);
|
||||
int bNumber = int.parse(bPart);
|
||||
int cmp = aNumber.compareTo(bNumber);
|
||||
if (cmp != 0) {
|
||||
return cmp;
|
||||
}
|
||||
} else if (!aIsNumber && !bIsNumber) {
|
||||
int cmp = aPart.compareTo(bPart);
|
||||
if (cmp != 0) {
|
||||
return cmp;
|
||||
}
|
||||
} else {
|
||||
// Alphanumeric strings come before numeric strings
|
||||
return aIsNumber ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
return aParts.length.compareTo(bParts.length);
|
||||
}
|
||||
|
||||
List<String> _splitAlphaNumeric(String s) {
|
||||
List<String> parts = [];
|
||||
StringBuffer sb = StringBuffer();
|
||||
|
||||
bool isNumeric = _isNumeric(s[0]);
|
||||
sb.write(s[0]);
|
||||
|
||||
for (int i = 1; i < s.length; i++) {
|
||||
bool currentIsNumeric = _isNumeric(s[i]);
|
||||
if (currentIsNumeric == isNumeric) {
|
||||
sb.write(s[i]);
|
||||
} else {
|
||||
parts.add(sb.toString());
|
||||
sb.clear();
|
||||
sb.write(s[i]);
|
||||
isNumeric = currentIsNumeric;
|
||||
}
|
||||
}
|
||||
|
||||
parts.add(sb.toString());
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
bool _isNumeric(String s) {
|
||||
return s.codeUnitAt(0) >= 48 && s.codeUnitAt(0) <= 57;
|
||||
}
|
||||
|
||||
class HTML extends AppSource {
|
||||
@override
|
||||
// TODO: implement requestHeaders choice, hardcoded for now
|
||||
Map<String, String>? get requestHeaders => {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/81.0"
|
||||
};
|
||||
|
||||
@override
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
var uri = Uri.parse(standardUrl);
|
||||
Response res = await sourceRequest(standardUrl);
|
||||
if (res.statusCode == 200) {
|
||||
List<String> links = parse(res.body)
|
||||
.querySelectorAll('a')
|
||||
.map((element) => element.attributes['href'] ?? '')
|
||||
.where((element) =>
|
||||
Uri.parse(element).path.toLowerCase().endsWith('.apk'))
|
||||
.toList();
|
||||
links.sort(
|
||||
(a, b) => compareAlphaNumeric(a.split('/').last, 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();
|
||||
}
|
||||
var rel = links.last;
|
||||
var apkName = rel.split('/').last;
|
||||
var version = apkName.substring(0, apkName.length - 4);
|
||||
List<String> apkUrls =
|
||||
[rel].map((e) => ensureAbsoluteUrl(e, uri)).toList();
|
||||
return APKDetails(
|
||||
version, getApkUrlsFromUrls(apkUrls), AppNames(uri.host, tr('app')));
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/fdroid.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
@ -9,36 +8,31 @@ class IzzyOnDroid extends AppSource {
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(runtimeType.toString());
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
String? tryInferringAppId(String standardUrl) {
|
||||
Future<String?> tryInferringAppId(String standardUrl,
|
||||
{Map<String, dynamic> additionalSettings = const {}}) async {
|
||||
return FDroid().tryInferringAppId(standardUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
String? appId = tryInferringAppId(standardUrl);
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
String? appId = await tryInferringAppId(standardUrl);
|
||||
return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||
await get(
|
||||
Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')),
|
||||
'https://android.izzysoft.de/frepo/$appId');
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
|
||||
await sourceRequest(
|
||||
'https://apt.izzysoft.de/fdroid/api/v1/packages/$appId'),
|
||||
'https://android.izzysoft.de/frepo/$appId',
|
||||
standardUrl);
|
||||
}
|
||||
}
|
||||
|
66
lib/app_sources/jenkins.dart
Normal file
@ -0,0 +1,66 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class Jenkins extends AppSource {
|
||||
Jenkins() {
|
||||
overrideVersionDetectionFormDefault('releaseDateAsVersion', true);
|
||||
}
|
||||
|
||||
String trimJobUrl(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('.*/job/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url);
|
||||
if (match == null) {
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl/-/releases';
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
standardUrl = trimJobUrl(standardUrl);
|
||||
Response res =
|
||||
await sourceRequest('$standardUrl/lastSuccessfulBuild/api/json');
|
||||
if (res.statusCode == 200) {
|
||||
var json = jsonDecode(res.body);
|
||||
var releaseDate = json['timestamp'] == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int);
|
||||
var version =
|
||||
json['number'] == null ? null : (json['number'] as int).toString();
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
var apkUrls = (json['artifacts'] as List<dynamic>)
|
||||
.map((e) {
|
||||
var path = (e['relativePath'] as String?);
|
||||
if (path != null && path.isNotEmpty) {
|
||||
path = '$standardUrl/lastSuccessfulBuild/artifact/$path';
|
||||
}
|
||||
return path == null
|
||||
? const MapEntry<String, String>('', '')
|
||||
: MapEntry<String, String>(
|
||||
(e['fileName'] ?? e['relativePath']) as String, path);
|
||||
})
|
||||
.where((url) =>
|
||||
url.value.isNotEmpty && url.key.toLowerCase().endsWith('.apk'))
|
||||
.toList();
|
||||
return APKDetails(
|
||||
version,
|
||||
apkUrls,
|
||||
releaseDate: releaseDate,
|
||||
AppNames(Uri.parse(standardUrl).host, standardUrl.split('/').last));
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
||||
@ -9,11 +10,11 @@ class Mullvad extends AppSource {
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(runtimeType.toString());
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -24,28 +25,44 @@ class Mullvad extends AppSource {
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
Response res = await sourceRequest('$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, ['https://mullvad.net/download/app/apk/latest']);
|
||||
versions[0],
|
||||
getApkUrlsFromUrls(['https://mullvad.net/download/app/apk/latest']),
|
||||
AppNames(name, 'Mullvad-VPN'),
|
||||
changeLog: changeLog);
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
|
||||
}
|
||||
}
|
||||
|
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 sourceSpecificStandardizeURL(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 sourceRequest(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, getApkUrlsFromUrls([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);
|
||||
}
|
||||
}
|
||||
}
|
@ -9,19 +9,17 @@ class Signal extends AppSource {
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
return 'https://$host';
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
Response res =
|
||||
await get(Uri.parse('https://updates.$host/android/latest.json'));
|
||||
await sourceRequest('https://updates.$host/android/latest.json');
|
||||
if (res.statusCode == 200) {
|
||||
var json = jsonDecode(res.body);
|
||||
String? apkUrl = json['url'];
|
||||
@ -30,12 +28,10 @@ class Signal extends AppSource {
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, apkUrls);
|
||||
return APKDetails(
|
||||
version, getApkUrlsFromUrls(apkUrls), AppNames(name, 'Signal'));
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
|
||||
}
|
||||
|
@ -9,23 +9,27 @@ class SourceForge extends AppSource {
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
RegExp standardUrlRegExB = RegExp('^https?://$host/p/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase());
|
||||
if (match != null) {
|
||||
url =
|
||||
'https://${Uri.parse(url.substring(0, match.end)).host}/projects/${url.substring(Uri.parse(url.substring(0, match.end)).host.length + '/projects/'.length + 1)}';
|
||||
}
|
||||
RegExp standardUrlRegExA = RegExp('^https?://$host/projects/[^/]+');
|
||||
match = standardUrlRegExA.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(runtimeType.toString());
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/rss?path=/'));
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
Response res = await sourceRequest('$standardUrl/rss?path=/');
|
||||
if (res.statusCode == 200) {
|
||||
var parsedHtml = parse(res.body);
|
||||
var allDownloadLinks =
|
||||
@ -33,7 +37,8 @@ class SourceForge extends AppSource {
|
||||
getVersion(String url) {
|
||||
try {
|
||||
var tokens = url.split('/');
|
||||
return tokens[tokens.length - 3];
|
||||
var fi = tokens.indexOf('files');
|
||||
return tokens[tokens[fi + 2] == 'download' ? fi - 1 : fi + 1];
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
@ -50,15 +55,13 @@ class SourceForge extends AppSource {
|
||||
apkUrlListAllReleases // This can be used skipped for fallback support later
|
||||
.where((element) => getVersion(element) == version)
|
||||
.toList();
|
||||
return APKDetails(version, apkUrlList);
|
||||
return APKDetails(
|
||||
version,
|
||||
getApkUrlsFromUrls(apkUrlList),
|
||||
AppNames(
|
||||
name, standardUrl.substring(standardUrl.lastIndexOf('/') + 1)));
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames(runtimeType.toString(),
|
||||
standardUrl.substring(standardUrl.lastIndexOf('/') + 1));
|
||||
}
|
||||
}
|
||||
|
107
lib/app_sources/sourcehut.dart
Normal file
@ -0,0 +1,107 @@
|
||||
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';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
class SourceHut extends AppSource {
|
||||
SourceHut() {
|
||||
host = 'git.sr.ht';
|
||||
|
||||
additionalSourceAppSpecificSettingFormItems = [
|
||||
[
|
||||
GeneratedFormSwitch('fallbackToOlderReleases',
|
||||
label: tr('fallbackToOlderReleases'), defaultValue: true)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => standardUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
Uri standardUri = Uri.parse(standardUrl);
|
||||
String appName = standardUri.pathSegments.last;
|
||||
bool fallbackToOlderReleases =
|
||||
additionalSettings['fallbackToOlderReleases'] == true;
|
||||
Response res = await sourceRequest('$standardUrl/refs/rss.xml');
|
||||
if (res.statusCode == 200) {
|
||||
var parsedHtml = parse(res.body);
|
||||
List<APKDetails> apkDetailsList = [];
|
||||
int ind = 0;
|
||||
|
||||
for (var entry in parsedHtml.querySelectorAll('item').sublist(0, 6)) {
|
||||
// Limit 5 for speed
|
||||
if (!fallbackToOlderReleases && ind > 0) {
|
||||
break;
|
||||
}
|
||||
String? version = entry.querySelector('title')?.text.trim();
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
String? releaseDateString = entry.querySelector('pubDate')?.innerHtml;
|
||||
String releasePage = '$standardUrl/refs/$version';
|
||||
DateTime? releaseDate;
|
||||
try {
|
||||
releaseDate = releaseDateString != null
|
||||
? DateFormat('E, dd MMM yyyy HH:mm:ss Z').parse(releaseDateString)
|
||||
: null;
|
||||
releaseDate = releaseDateString != null
|
||||
? DateFormat('EEE, dd MMM yyyy HH:mm:ss Z')
|
||||
.parse(releaseDateString)
|
||||
: null;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
var res2 = await sourceRequest(releasePage);
|
||||
List<MapEntry<String, String>> apkUrls = [];
|
||||
if (res2.statusCode == 200) {
|
||||
apkUrls = getApkUrlsFromUrls(parse(res2.body)
|
||||
.querySelectorAll('a')
|
||||
.map((e) => e.attributes['href'] ?? '')
|
||||
.where((e) => e.toLowerCase().endsWith('.apk'))
|
||||
.map((e) => ensureAbsoluteUrl(e, standardUri))
|
||||
.toList());
|
||||
}
|
||||
apkDetailsList.add(APKDetails(
|
||||
version,
|
||||
apkUrls,
|
||||
AppNames(entry.querySelector('author')?.innerHtml.trim() ?? appName,
|
||||
appName),
|
||||
releaseDate: releaseDate));
|
||||
ind++;
|
||||
}
|
||||
if (apkDetailsList.isEmpty) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
if (fallbackToOlderReleases) {
|
||||
if (additionalSettings['trackOnly'] != true) {
|
||||
apkDetailsList =
|
||||
apkDetailsList.where((e) => e.apkUrls.isNotEmpty).toList();
|
||||
}
|
||||
if (apkDetailsList.isEmpty) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
return apkDetailsList.first;
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
62
lib/app_sources/steammobile.dart
Normal file
@ -0,0 +1,62 @@
|
||||
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';
|
||||
|
||||
class SteamMobile extends AppSource {
|
||||
SteamMobile() {
|
||||
host = 'store.steampowered.com';
|
||||
name = tr('steam');
|
||||
additionalSourceAppSpecificSettingFormItems = [
|
||||
[
|
||||
GeneratedFormDropdown('app', apks.entries.toList(),
|
||||
label: tr('app'), defaultValue: apks.entries.toList()[0].key)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
final apks = {'steam': tr('steamMobile'), 'steam-chat-app': tr('steamChat')};
|
||||
|
||||
@override
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
return 'https://$host';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
Response res = await sourceRequest('https://$host/mobile');
|
||||
if (res.statusCode == 200) {
|
||||
var apkNamePrefix = additionalSettings['app'] as String?;
|
||||
if (apkNamePrefix == null) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
String apkInURLRegexPattern =
|
||||
'/$apkNamePrefix-([0-9]+\\.)*[0-9]+\\.apk\$';
|
||||
var links = parse(res.body)
|
||||
.querySelectorAll('a')
|
||||
.map((e) => e.attributes['href'] ?? '')
|
||||
.where((e) => RegExp('https://.*$apkInURLRegexPattern').hasMatch(e))
|
||||
.toList();
|
||||
|
||||
if (links.isEmpty) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
var versionMatch = RegExp(apkInURLRegexPattern).firstMatch(links[0]);
|
||||
if (versionMatch == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
var version = links[0].substring(
|
||||
versionMatch.start + apkNamePrefix.length + 2, versionMatch.end - 4);
|
||||
var apkUrls = [links[0]];
|
||||
return APKDetails(version, getApkUrlsFromUrls(apkUrls),
|
||||
AppNames(name, apks[apkNamePrefix]!));
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
41
lib/app_sources/telegramapp.dart
Normal file
@ -0,0 +1,41 @@
|
||||
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 sourceSpecificStandardizeURL(String url) {
|
||||
return 'https://$host';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
Response res = await sourceRequest('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, getApkUrlsFromUrls([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/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class VLC extends AppSource {
|
||||
VLC() {
|
||||
host = 'videolan.org';
|
||||
}
|
||||
|
||||
@override
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
return 'https://$host';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
Response res = await sourceRequest(
|
||||
'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 sourceRequest(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, getApkUrlsFromUrls(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 sourceSpecificStandardizeURL(String url) {
|
||||
return 'https://$host';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(
|
||||
String apkUrl, String standardUrl) async {
|
||||
Response res = await sourceRequest('https://www.whatsapp.com/android');
|
||||
if (res.statusCode == 200) {
|
||||
var targetLinks = parse(res.body)
|
||||
.querySelectorAll('a')
|
||||
.map((e) => e.attributes['href'] ?? '')
|
||||
.where((e) => e.isNotEmpty)
|
||||
.where((e) =>
|
||||
e.contains('content.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 sourceRequest('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,
|
||||
getApkUrlsFromUrls([
|
||||
'https://www.whatsapp.com/android?v=$version&=thisIsaPlaceholder&a=realURLPrefetchedAtDownloadTime'
|
||||
]),
|
||||
AppNames('Meta', 'WhatsApp'));
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,117 +1,217 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
|
||||
enum FormItemType { string, bool }
|
||||
|
||||
typedef OnValueChanges = void Function(
|
||||
List<String> values, bool valid, bool isBuilding);
|
||||
|
||||
class GeneratedFormItem {
|
||||
abstract class GeneratedFormItem {
|
||||
late String key;
|
||||
late String label;
|
||||
late FormItemType type;
|
||||
late List<Widget> belowWidgets;
|
||||
late dynamic defaultValue;
|
||||
List<dynamic> additionalValidators;
|
||||
dynamic ensureType(dynamic val);
|
||||
|
||||
GeneratedFormItem(this.key,
|
||||
{this.label = 'Input',
|
||||
this.belowWidgets = const [],
|
||||
this.defaultValue,
|
||||
this.additionalValidators = const []});
|
||||
}
|
||||
|
||||
class GeneratedFormTextField extends GeneratedFormItem {
|
||||
late bool required;
|
||||
late int max;
|
||||
late List<String? Function(String? value)> additionalValidators;
|
||||
late String id;
|
||||
late List<Widget> belowWidgets;
|
||||
late String? hint;
|
||||
late List<String>? opts;
|
||||
late bool password;
|
||||
|
||||
GeneratedFormItem(
|
||||
{this.label = 'Input',
|
||||
this.type = FormItemType.string,
|
||||
GeneratedFormTextField(String key,
|
||||
{String label = 'Input',
|
||||
List<Widget> belowWidgets = const [],
|
||||
String defaultValue = '',
|
||||
List<String? Function(String? value)> additionalValidators = const [],
|
||||
this.required = true,
|
||||
this.max = 1,
|
||||
this.additionalValidators = const [],
|
||||
this.id = 'input',
|
||||
this.belowWidgets = const [],
|
||||
this.hint,
|
||||
this.opts,
|
||||
this.key = 'default'});
|
||||
this.password = false})
|
||||
: super(key,
|
||||
label: label,
|
||||
belowWidgets: belowWidgets,
|
||||
defaultValue: defaultValue,
|
||||
additionalValidators: additionalValidators);
|
||||
|
||||
@override
|
||||
String ensureType(val) {
|
||||
return val.toString();
|
||||
}
|
||||
}
|
||||
|
||||
class GeneratedFormDropdown extends GeneratedFormItem {
|
||||
late List<MapEntry<String, String>>? opts;
|
||||
List<String>? disabledOptKeys;
|
||||
|
||||
GeneratedFormDropdown(
|
||||
String key,
|
||||
this.opts, {
|
||||
String label = 'Input',
|
||||
List<Widget> belowWidgets = const [],
|
||||
String defaultValue = '',
|
||||
this.disabledOptKeys,
|
||||
List<String? Function(String? value)> additionalValidators = const [],
|
||||
}) : super(key,
|
||||
label: label,
|
||||
belowWidgets: belowWidgets,
|
||||
defaultValue: defaultValue,
|
||||
additionalValidators: additionalValidators);
|
||||
|
||||
@override
|
||||
String ensureType(val) {
|
||||
return val.toString();
|
||||
}
|
||||
}
|
||||
|
||||
class GeneratedFormSwitch extends GeneratedFormItem {
|
||||
GeneratedFormSwitch(
|
||||
String key, {
|
||||
String label = 'Input',
|
||||
List<Widget> belowWidgets = const [],
|
||||
bool defaultValue = false,
|
||||
List<String? Function(bool value)> additionalValidators = const [],
|
||||
}) : super(key,
|
||||
label: label,
|
||||
belowWidgets: belowWidgets,
|
||||
defaultValue: defaultValue,
|
||||
additionalValidators: additionalValidators);
|
||||
|
||||
@override
|
||||
bool ensureType(val) {
|
||||
return val == true || val == 'true';
|
||||
}
|
||||
}
|
||||
|
||||
class GeneratedFormTagInput extends GeneratedFormItem {
|
||||
late MapEntry<String, String>? deleteConfirmationMessage;
|
||||
late bool singleSelect;
|
||||
late WrapAlignment alignment;
|
||||
late String emptyMessage;
|
||||
late bool showLabelWhenNotEmpty;
|
||||
GeneratedFormTagInput(String key,
|
||||
{String label = 'Input',
|
||||
List<Widget> belowWidgets = const [],
|
||||
Map<String, MapEntry<int, bool>> defaultValue = const {},
|
||||
List<String? Function(Map<String, MapEntry<int, bool>> value)>
|
||||
additionalValidators = const [],
|
||||
this.deleteConfirmationMessage,
|
||||
this.singleSelect = false,
|
||||
this.alignment = WrapAlignment.start,
|
||||
this.emptyMessage = 'Input',
|
||||
this.showLabelWhenNotEmpty = true})
|
||||
: super(key,
|
||||
label: label,
|
||||
belowWidgets: belowWidgets,
|
||||
defaultValue: defaultValue,
|
||||
additionalValidators: additionalValidators);
|
||||
|
||||
@override
|
||||
Map<String, MapEntry<int, bool>> ensureType(val) {
|
||||
return val is Map<String, MapEntry<int, bool>> ? val : {};
|
||||
}
|
||||
}
|
||||
|
||||
typedef OnValueChanges = void Function(
|
||||
Map<String, dynamic> values, bool valid, bool isBuilding);
|
||||
|
||||
class GeneratedForm extends StatefulWidget {
|
||||
const GeneratedForm(
|
||||
{super.key,
|
||||
required this.items,
|
||||
required this.onValueChanges,
|
||||
required this.defaultValues});
|
||||
{super.key, required this.items, required this.onValueChanges});
|
||||
|
||||
final List<List<GeneratedFormItem>> items;
|
||||
final OnValueChanges onValueChanges;
|
||||
final List<String> defaultValues;
|
||||
|
||||
@override
|
||||
State<GeneratedForm> createState() => _GeneratedFormState();
|
||||
}
|
||||
|
||||
// Generates a random light color
|
||||
// Courtesy of ChatGPT 😭 (with a bugfix 🥳)
|
||||
Color generateRandomLightColor() {
|
||||
// Create a random number generator
|
||||
final Random random = Random();
|
||||
|
||||
// Generate random hue, saturation, and value values
|
||||
final double hue = random.nextDouble() * 360;
|
||||
final double saturation = 0.5 + random.nextDouble() * 0.5;
|
||||
final double value = 0.9 + random.nextDouble() * 0.1;
|
||||
|
||||
// Create a HSV color with the random values
|
||||
return HSVColor.fromAHSV(1.0, hue, saturation, value).toColor();
|
||||
}
|
||||
|
||||
class _GeneratedFormState extends State<GeneratedForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late List<List<String>> values;
|
||||
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}) {
|
||||
List<String> returnValues = [];
|
||||
Map<String, dynamic> returnValues = values;
|
||||
var valid = true;
|
||||
for (int r = 0; r < values.length; r++) {
|
||||
for (int i = 0; i < values[r].length; i++) {
|
||||
returnValues.add(values[r][i]);
|
||||
for (int r = 0; r < widget.items.length; r++) {
|
||||
for (int i = 0; i < widget.items[r].length; i++) {
|
||||
if (formInputs[r][i] is TextFormField) {
|
||||
valid = valid &&
|
||||
((formInputs[r][i].key as GlobalKey<FormFieldState>)
|
||||
.currentState
|
||||
?.isValid ??
|
||||
false);
|
||||
var fieldState =
|
||||
(formInputs[r][i].key as GlobalKey<FormFieldState>).currentState;
|
||||
if (fieldState != null) {
|
||||
valid = valid && fieldState.isValid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
widget.onValueChanges(returnValues, valid, isBuilding);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
initForm() {
|
||||
initKey = widget.key.toString();
|
||||
// Initialize form values as all empty
|
||||
int j = 0;
|
||||
values = widget.items
|
||||
.map((row) => row.map((e) {
|
||||
return j < widget.defaultValues.length
|
||||
? widget.defaultValues[j++]
|
||||
: e.opts != null
|
||||
? e.opts!.first
|
||||
: '';
|
||||
}).toList())
|
||||
.toList();
|
||||
values.clear();
|
||||
for (var row in widget.items) {
|
||||
for (var e in row) {
|
||||
values[e.key] = e.defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamically create form inputs
|
||||
formInputs = widget.items.asMap().entries.map((row) {
|
||||
return row.value.asMap().entries.map((e) {
|
||||
if (e.value.type == FormItemType.string && e.value.opts == null) {
|
||||
var formItem = e.value;
|
||||
if (formItem is GeneratedFormTextField) {
|
||||
final formFieldKey = GlobalKey<FormFieldState>();
|
||||
return TextFormField(
|
||||
obscureText: formItem.password,
|
||||
autocorrect: !formItem.password,
|
||||
enableSuggestions: !formItem.password,
|
||||
key: formFieldKey,
|
||||
initialValue: values[row.key][e.key],
|
||||
initialValue: values[formItem.key],
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
values[row.key][e.key] = value;
|
||||
values[formItem.key] = value;
|
||||
someValueChanged();
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
helperText: e.value.label + (e.value.required ? ' *' : ''),
|
||||
hintText: e.value.hint),
|
||||
minLines: e.value.max <= 1 ? null : e.value.max,
|
||||
maxLines: e.value.max <= 1 ? 1 : e.value.max,
|
||||
helperText: formItem.label + (formItem.required ? ' *' : ''),
|
||||
hintText: formItem.hint),
|
||||
minLines: formItem.max <= 1 ? null : formItem.max,
|
||||
maxLines: formItem.max <= 1 ? 1 : formItem.max,
|
||||
validator: (value) {
|
||||
if (e.value.required && (value == null || value.trim().isEmpty)) {
|
||||
return '${e.value.label} ${tr('requiredInBrackets')}';
|
||||
if (formItem.required &&
|
||||
(value == null || value.trim().isEmpty)) {
|
||||
return '${formItem.label} ${tr('requiredInBrackets')}';
|
||||
}
|
||||
for (var validator in e.value.additionalValidators) {
|
||||
for (var validator in formItem.additionalValidators) {
|
||||
String? result = validator(value);
|
||||
if (result != null) {
|
||||
return result;
|
||||
@ -120,20 +220,25 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
return null;
|
||||
},
|
||||
);
|
||||
} else if (e.value.type == FormItemType.string &&
|
||||
e.value.opts != null) {
|
||||
if (e.value.opts!.isEmpty) {
|
||||
} else if (formItem is GeneratedFormDropdown) {
|
||||
if (formItem.opts!.isEmpty) {
|
||||
return Text(tr('dropdownNoOptsError'));
|
||||
}
|
||||
return DropdownButtonFormField(
|
||||
decoration: InputDecoration(labelText: tr('colour')),
|
||||
value: values[row.key][e.key],
|
||||
items: e.value.opts!
|
||||
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
|
||||
.toList(),
|
||||
decoration: InputDecoration(labelText: formItem.label),
|
||||
value: values[formItem.key],
|
||||
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[row.key][e.key] = value ?? e.value.opts!.first;
|
||||
values[formItem.key] = value ?? formItem.opts!.first.key;
|
||||
someValueChanged();
|
||||
});
|
||||
});
|
||||
@ -145,25 +250,217 @@ 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].type == FormItemType.bool) {
|
||||
if (widget.items[r][e] is GeneratedFormSwitch) {
|
||||
formInputs[r][e] = Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(widget.items[r][e].label),
|
||||
Flexible(child: Text(widget.items[r][e].label)),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Switch(
|
||||
value: values[r][e] == 'true',
|
||||
value: values[widget.items[r][e].key],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
values[r][e] = value ? 'true' : '';
|
||||
values[widget.items[r][e].key] = value;
|
||||
someValueChanged();
|
||||
});
|
||||
})
|
||||
],
|
||||
);
|
||||
} else if (widget.items[r][e] is GeneratedFormTagInput) {
|
||||
formInputs[r][e] =
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
if ((values[widget.items[r][e].key]
|
||||
as Map<String, MapEntry<int, bool>>?)
|
||||
?.isNotEmpty ==
|
||||
true &&
|
||||
(widget.items[r][e] as GeneratedFormTagInput)
|
||||
.showLabelWhenNotEmpty)
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
(widget.items[r][e] as GeneratedFormTagInput).alignment ==
|
||||
WrapAlignment.center
|
||||
? CrossAxisAlignment.center
|
||||
: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(widget.items[r][e].label),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
Wrap(
|
||||
alignment:
|
||||
(widget.items[r][e] as GeneratedFormTagInput).alignment,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
(values[widget.items[r][e].key]
|
||||
as Map<String, MapEntry<int, bool>>?)
|
||||
?.isEmpty ==
|
||||
true
|
||||
? Text(
|
||||
(widget.items[r][e] as GeneratedFormTagInput)
|
||||
.emptyMessage,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
...(values[widget.items[r][e].key]
|
||||
as Map<String, MapEntry<int, bool>>?)
|
||||
?.entries
|
||||
.map((e2) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: ChoiceChip(
|
||||
label: Text(e2.key),
|
||||
backgroundColor: Color(e2.value.key).withAlpha(50),
|
||||
selectedColor: Color(e2.value.key),
|
||||
visualDensity: VisualDensity.compact,
|
||||
selected: e2.value.value,
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
(values[widget.items[r][e].key] as Map<String,
|
||||
MapEntry<int, bool>>)[e2.key] =
|
||||
MapEntry(
|
||||
(values[widget.items[r][e].key] as Map<
|
||||
String,
|
||||
MapEntry<int, bool>>)[e2.key]!
|
||||
.key,
|
||||
value);
|
||||
if ((widget.items[r][e]
|
||||
as GeneratedFormTagInput)
|
||||
.singleSelect &&
|
||||
value == true) {
|
||||
for (var key in (values[
|
||||
widget.items[r][e].key]
|
||||
as Map<String, MapEntry<int, bool>>)
|
||||
.keys) {
|
||||
if (key != e2.key) {
|
||||
(values[widget.items[r][e].key] as Map<
|
||||
String,
|
||||
MapEntry<int, bool>>)[key] =
|
||||
MapEntry(
|
||||
(values[widget.items[r][e].key]
|
||||
as Map<
|
||||
String,
|
||||
MapEntry<int,
|
||||
bool>>)[key]!
|
||||
.key,
|
||||
false);
|
||||
}
|
||||
}
|
||||
}
|
||||
someValueChanged();
|
||||
});
|
||||
},
|
||||
));
|
||||
}) ??
|
||||
[const SizedBox.shrink()],
|
||||
(values[widget.items[r][e].key]
|
||||
as Map<String, MapEntry<int, bool>>?)
|
||||
?.values
|
||||
.where((e) => e.value)
|
||||
.isNotEmpty ==
|
||||
true
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
fn() {
|
||||
setState(() {
|
||||
var temp = values[widget.items[r][e].key]
|
||||
as Map<String, MapEntry<int, bool>>;
|
||||
temp.removeWhere((key, value) => value.value);
|
||||
values[widget.items[r][e].key] = temp;
|
||||
someValueChanged();
|
||||
});
|
||||
}
|
||||
|
||||
if ((widget.items[r][e] as GeneratedFormTagInput)
|
||||
.deleteConfirmationMessage !=
|
||||
null) {
|
||||
var message =
|
||||
(widget.items[r][e] as GeneratedFormTagInput)
|
||||
.deleteConfirmationMessage!;
|
||||
showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: message.key,
|
||||
message: message.value,
|
||||
items: const []);
|
||||
}).then((value) {
|
||||
if (value != null) {
|
||||
fn();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.remove),
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: tr('remove'),
|
||||
))
|
||||
: const SizedBox.shrink(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: widget.items[r][e].label,
|
||||
items: [
|
||||
[
|
||||
GeneratedFormTextField('label',
|
||||
label: tr('label'))
|
||||
]
|
||||
]);
|
||||
}).then((value) {
|
||||
String? label = value?['label'];
|
||||
if (label != null) {
|
||||
setState(() {
|
||||
var temp = values[widget.items[r][e].key]
|
||||
as Map<String, MapEntry<int, bool>>?;
|
||||
temp ??= {};
|
||||
if (temp[label] == null) {
|
||||
var singleSelect = (widget.items[r][e]
|
||||
as GeneratedFormTagInput)
|
||||
.singleSelect;
|
||||
var someSelected = temp.entries
|
||||
.where((element) => element.value.value)
|
||||
.isNotEmpty;
|
||||
temp[label] = MapEntry(
|
||||
generateRandomLightColor().value,
|
||||
!(someSelected && singleSelect));
|
||||
values[widget.items[r][e].key] = temp;
|
||||
someValueChanged();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: tr('add'),
|
||||
)),
|
||||
],
|
||||
)
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -173,11 +470,9 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
if (rowInputs.key > 0) {
|
||||
rows.add([
|
||||
SizedBox(
|
||||
height: widget.items[rowInputs.key][0].type == FormItemType.bool &&
|
||||
widget.items[rowInputs.key - 1][0].type ==
|
||||
FormItemType.string
|
||||
? 25
|
||||
: 8,
|
||||
height: widget.items[rowInputs.key - 1][0] is GeneratedFormSwitch
|
||||
? 8
|
||||
: 25,
|
||||
)
|
||||
]);
|
||||
}
|
||||
@ -191,6 +486,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
|
||||
@ -212,18 +508,3 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
String? findGeneratedFormValueByKey(
|
||||
List<GeneratedFormItem> items, List<String> values, String key) {
|
||||
var foundIndex = -1;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].key == key) {
|
||||
foundIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundIndex >= 0 && foundIndex < values.length) {
|
||||
return values[foundIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -8,28 +8,29 @@ class GeneratedFormModal extends StatefulWidget {
|
||||
{super.key,
|
||||
required this.title,
|
||||
required this.items,
|
||||
required this.defaultValues,
|
||||
this.initValid = false,
|
||||
this.message = ''});
|
||||
this.message = '',
|
||||
this.additionalWidgets = const [],
|
||||
this.singleNullReturnButton});
|
||||
|
||||
final String title;
|
||||
final String message;
|
||||
final List<List<GeneratedFormItem>> items;
|
||||
final List<String> defaultValues;
|
||||
final bool initValid;
|
||||
final List<Widget> additionalWidgets;
|
||||
final String? singleNullReturnButton;
|
||||
|
||||
@override
|
||||
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
|
||||
}
|
||||
|
||||
class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
List<String> values = [];
|
||||
Map<String, dynamic> values = {};
|
||||
bool valid = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
values = widget.defaultValues;
|
||||
valid = widget.initValid || widget.items.isEmpty;
|
||||
}
|
||||
|
||||
@ -57,25 +58,29 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
this.valid = valid;
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues: widget.defaultValues)
|
||||
}),
|
||||
if (widget.additionalWidgets.isNotEmpty) ...widget.additionalWidgets
|
||||
]),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: Text(tr('cancel'))),
|
||||
TextButton(
|
||||
onPressed: !valid
|
||||
? null
|
||||
: () {
|
||||
if (valid) {
|
||||
HapticFeedback.selectionClick();
|
||||
Navigator.of(context).pop(values);
|
||||
}
|
||||
},
|
||||
child: Text(tr('continue')))
|
||||
child: Text(widget.singleNullReturnButton == null
|
||||
? tr('cancel')
|
||||
: widget.singleNullReturnButton!)),
|
||||
widget.singleNullReturnButton == null
|
||||
? TextButton(
|
||||
onPressed: !valid
|
||||
? null
|
||||
: () {
|
||||
if (valid) {
|
||||
HapticFeedback.selectionClick();
|
||||
Navigator.of(context).pop(values);
|
||||
}
|
||||
},
|
||||
child: Text(tr('continue')))
|
||||
: const SizedBox.shrink()
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:android_package_installer/android_package_installer.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:obtainium/providers/logs_provider.dart';
|
||||
@ -13,13 +14,10 @@ class ObtainiumError {
|
||||
}
|
||||
}
|
||||
|
||||
class RateLimitError {
|
||||
class RateLimitError extends ObtainiumError {
|
||||
late int remainingMinutes;
|
||||
RateLimitError(this.remainingMinutes);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
plural('tooManyRequestsTryAgainInMinutes', remainingMinutes);
|
||||
RateLimitError(this.remainingMinutes)
|
||||
: super(plural('tooManyRequestsTryAgainInMinutes', remainingMinutes));
|
||||
}
|
||||
|
||||
class InvalidURLError extends ObtainiumError {
|
||||
@ -27,12 +25,17 @@ class InvalidURLError extends ObtainiumError {
|
||||
: super(tr('invalidURLForSource', args: [sourceName]));
|
||||
}
|
||||
|
||||
class CredsNeededError extends ObtainiumError {
|
||||
CredsNeededError(String sourceName)
|
||||
: super(tr('requiresCredentialsInSettings', args: [sourceName]));
|
||||
}
|
||||
|
||||
class NoReleasesError extends ObtainiumError {
|
||||
NoReleasesError() : super(tr('noReleaseFound'));
|
||||
}
|
||||
|
||||
class NoAPKError extends ObtainiumError {
|
||||
NoAPKError() : super(tr('noReleaseFound'));
|
||||
NoAPKError() : super(tr('noAPKFound'));
|
||||
}
|
||||
|
||||
class NoVersionError extends ObtainiumError {
|
||||
@ -47,6 +50,11 @@ class DowngradeError extends ObtainiumError {
|
||||
DowngradeError() : super(tr('cantInstallOlderVersion'));
|
||||
}
|
||||
|
||||
class InstallError extends ObtainiumError {
|
||||
InstallError(int code)
|
||||
: super(PackageInstallerStatus.byCode(code).name.substring(7));
|
||||
}
|
||||
|
||||
class IDChangedError extends ObtainiumError {
|
||||
IDChangedError() : super(tr('appIdMismatch'));
|
||||
}
|
||||
|
@ -16,20 +16,69 @@ import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:easy_localization/src/easy_localization_controller.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:easy_localization/src/localization.dart';
|
||||
|
||||
const String currentVersion = '0.8.3';
|
||||
const String currentVersion = '0.13.7';
|
||||
const String currentReleaseTag =
|
||||
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
|
||||
const int bgUpdateCheckAlarmId = 666;
|
||||
|
||||
List<MapEntry<Locale, String>> supportedLocales = const [
|
||||
MapEntry(Locale('en'), 'English'),
|
||||
MapEntry(Locale('zh'), '汉语'),
|
||||
MapEntry(Locale('it'), 'Italiano'),
|
||||
MapEntry(Locale('ja'), '日本語'),
|
||||
MapEntry(Locale('hu'), 'Magyar'),
|
||||
MapEntry(Locale('de'), 'Deutsch'),
|
||||
MapEntry(Locale('fa'), 'فارسی'),
|
||||
MapEntry(Locale('fr'), 'Français'),
|
||||
MapEntry(Locale('es'), 'Español'),
|
||||
MapEntry(Locale('pl'), 'Polski'),
|
||||
];
|
||||
const fallbackLocale = Locale('en');
|
||||
const localeDir = 'assets/translations';
|
||||
|
||||
final globalNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
Future<void> loadTranslations() async {
|
||||
// See easy_localization/issues/210
|
||||
await EasyLocalizationController.initEasyLocation();
|
||||
var s = SettingsProvider();
|
||||
await s.initializeSettings();
|
||||
var forceLocale = s.forcedLocale;
|
||||
final controller = EasyLocalizationController(
|
||||
saveLocale: true,
|
||||
forceLocale: forceLocale != null ? Locale(forceLocale) : null,
|
||||
fallbackLocale: fallbackLocale,
|
||||
supportedLocales: supportedLocales.map((e) => e.key).toList(),
|
||||
assetLoader: const RootBundleAssetLoader(),
|
||||
useOnlyLangCode: true,
|
||||
useFallbackTranslations: true,
|
||||
path: localeDir,
|
||||
onLoadError: (FlutterError e) {
|
||||
throw e;
|
||||
},
|
||||
);
|
||||
await controller.loadTranslations();
|
||||
Localization.load(controller.locale,
|
||||
translations: controller.translations,
|
||||
fallbackTranslations: controller.fallbackTranslations);
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await EasyLocalization.ensureInitialized();
|
||||
|
||||
await loadTranslations();
|
||||
|
||||
LogsProvider logs = LogsProvider();
|
||||
logs.add(tr('startedBgUpdateTask'));
|
||||
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await EasyLocalization.ensureInitialized();
|
||||
await AndroidAlarmManager.initialize();
|
||||
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
|
||||
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
|
||||
@ -38,7 +87,7 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
||||
var notificationsProvider = NotificationsProvider();
|
||||
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||
try {
|
||||
var appsProvider = AppsProvider(forBGTask: true);
|
||||
var appsProvider = AppsProvider();
|
||||
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||
await appsProvider.loadApps();
|
||||
List<String> existingUpdateIds =
|
||||
@ -53,7 +102,7 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
||||
if (e is RateLimitError || e is SocketException) {
|
||||
var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15;
|
||||
logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes,
|
||||
args: [e.runtimeType.toString()]));
|
||||
args: [e.toString(), remainingMinutes.toString()]));
|
||||
AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes),
|
||||
Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: {
|
||||
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
|
||||
@ -100,6 +149,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(
|
||||
@ -116,9 +173,10 @@ void main() async {
|
||||
Provider(create: (context) => LogsProvider())
|
||||
],
|
||||
child: EasyLocalization(
|
||||
supportedLocales: const [Locale('en')],
|
||||
path: 'assets/translations',
|
||||
fallbackLocale: const Locale('en'),
|
||||
supportedLocales: supportedLocales.map((e) => e.key).toList(),
|
||||
path: localeDir,
|
||||
fallbackLocale: fallbackLocale,
|
||||
useOnlyLangCode: true,
|
||||
child: const Obtainium()),
|
||||
));
|
||||
}
|
||||
@ -159,12 +217,18 @@ class _ObtainiumState extends State<Obtainium> {
|
||||
currentReleaseTag,
|
||||
[],
|
||||
0,
|
||||
['true'],
|
||||
{'includePrereleases': true},
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false)
|
||||
]);
|
||||
], onlyIfExists: false);
|
||||
}
|
||||
if (!supportedLocales
|
||||
.map((e) => e.key.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) {
|
||||
@ -201,11 +265,20 @@ class _ObtainiumState extends State<Obtainium> {
|
||||
darkColorScheme = ColorScheme.fromSeed(
|
||||
seedColor: defaultThemeColour, brightness: Brightness.dark);
|
||||
}
|
||||
|
||||
// set the background and surface colors to pure black in the amoled theme
|
||||
if (settingsProvider.useBlackTheme) {
|
||||
darkColorScheme = darkColorScheme
|
||||
.copyWith(background: Colors.black, surface: Colors.black)
|
||||
.harmonized();
|
||||
}
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Obtainium',
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: context.locale,
|
||||
navigatorKey: globalNavigatorKey,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: settingsProvider.theme == ThemeSettings.dark
|
||||
|
@ -13,17 +13,20 @@ class GitHubStars implements MassAppUrlSource {
|
||||
@override
|
||||
late List<String> requiredArgs = [tr('uname')];
|
||||
|
||||
Future<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions(
|
||||
Future<Map<String, List<String>>> getOnePageOfUserStarredUrlsWithDescriptions(
|
||||
String username, int page) async {
|
||||
Response res = await get(Uri.parse(
|
||||
'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page'));
|
||||
if (res.statusCode == 200) {
|
||||
Map<String, String> urlsWithDescriptions = {};
|
||||
Map<String, List<String>> urlsWithDescriptions = {};
|
||||
for (var e in (jsonDecode(res.body) as List<dynamic>)) {
|
||||
urlsWithDescriptions.addAll({
|
||||
e['html_url'] as String: e['description'] != null
|
||||
? e['description'] as String
|
||||
: tr('noDescription')
|
||||
e['html_url'] as String: [
|
||||
e['full_name'] as String,
|
||||
e['description'] != null
|
||||
? e['description'] as String
|
||||
: tr('noDescription')
|
||||
]
|
||||
});
|
||||
}
|
||||
return urlsWithDescriptions;
|
||||
@ -35,11 +38,12 @@ class GitHubStars implements MassAppUrlSource {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args) async {
|
||||
Future<Map<String, List<String>>> getUrlsWithDescriptions(
|
||||
List<String> args) async {
|
||||
if (args.length != requiredArgs.length) {
|
||||
throw ObtainiumError(tr('wrongArgNum'));
|
||||
}
|
||||
Map<String, String> urlsWithDescriptions = {};
|
||||
Map<String, List<String>> urlsWithDescriptions = {};
|
||||
var page = 1;
|
||||
while (true) {
|
||||
var pageUrls =
|
||||
|
@ -1,12 +1,15 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/app_sources/html.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/pages/app.dart';
|
||||
import 'package:obtainium/pages/import_export.dart';
|
||||
import 'package:obtainium/pages/settings.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
@ -22,379 +25,492 @@ class AddAppPage extends StatefulWidget {
|
||||
|
||||
class _AddAppPageState extends State<AddAppPage> {
|
||||
bool gettingAppInfo = false;
|
||||
bool searching = false;
|
||||
|
||||
String userInput = '';
|
||||
String searchQuery = '';
|
||||
String? pickedSourceOverride;
|
||||
AppSource? pickedSource;
|
||||
List<String> sourceSpecificAdditionalData = [];
|
||||
bool sourceSpecificDataIsValid = true;
|
||||
List<String> otherAdditionalData = [];
|
||||
bool otherAdditionalDataIsValid = true;
|
||||
Map<String, dynamic> additionalSettings = {};
|
||||
bool additionalSettingsValid = true;
|
||||
bool inferAppIdIfOptional = true;
|
||||
List<String> pickedCategories = [];
|
||||
int searchnum = 0;
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||
|
||||
changeUserInput(String input, bool valid, bool isBuilding) {
|
||||
userInput = input;
|
||||
fn() {
|
||||
var source = valid ? sourceProvider.getSource(userInput) : null;
|
||||
if (pickedSource != source) {
|
||||
pickedSource = source;
|
||||
sourceSpecificAdditionalData =
|
||||
source != null ? source.additionalSourceAppSpecificDefaults : [];
|
||||
sourceSpecificDataIsValid = source != null
|
||||
? sourceProvider.ifSourceAppsRequireAdditionalData(source)
|
||||
: true;
|
||||
}
|
||||
}
|
||||
bool doingSomething = gettingAppInfo || searching;
|
||||
|
||||
if (isBuilding) {
|
||||
fn();
|
||||
} else {
|
||||
changeUserInput(String input, bool valid, bool isBuilding,
|
||||
{bool isSearch = false}) {
|
||||
userInput = input;
|
||||
if (!isBuilding) {
|
||||
setState(() {
|
||||
fn();
|
||||
if (isSearch) {
|
||||
searchnum++;
|
||||
}
|
||||
var prevHost = pickedSource?.host;
|
||||
try {
|
||||
var naturalSource =
|
||||
valid ? sourceProvider.getSource(userInput) : null;
|
||||
if (naturalSource != null &&
|
||||
naturalSource.runtimeType.toString() !=
|
||||
HTML().runtimeType.toString()) {
|
||||
// If input has changed to match a regular source, reset the override
|
||||
pickedSourceOverride = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
var source = valid
|
||||
? sourceProvider.getSource(userInput,
|
||||
overrideSource: pickedSourceOverride)
|
||||
: null;
|
||||
if (pickedSource.runtimeType != source.runtimeType ||
|
||||
(prevHost != null && prevHost != source?.host)) {
|
||||
pickedSource = source;
|
||||
additionalSettings = source != null
|
||||
? getDefaultValuesFromFormItems(
|
||||
source.combinedAppSpecificSettingFormItems)
|
||||
: {};
|
||||
additionalSettingsValid = source != null
|
||||
? !sourceProvider.ifRequiredAppSpecificSettingsExist(source)
|
||||
: true;
|
||||
inferAppIdIfOptional = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> getTrackOnlyConfirmationIfNeeded(
|
||||
bool userPickedTrackOnly, SettingsProvider settingsProvider,
|
||||
{bool ignoreHideSetting = false}) async {
|
||||
var useTrackOnly = userPickedTrackOnly || pickedSource!.enforceTrackOnly;
|
||||
if (useTrackOnly &&
|
||||
(!settingsProvider.hideTrackOnlyWarning || ignoreHideSetting)) {
|
||||
// ignore: use_build_context_synchronously
|
||||
var values = await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
initValid: true,
|
||||
title: tr('xIsTrackOnly', args: [
|
||||
pickedSource!.enforceTrackOnly ? tr('source') : tr('app')
|
||||
]),
|
||||
items: [
|
||||
[GeneratedFormSwitch('hide', label: tr('dontShowAgain'))]
|
||||
],
|
||||
message:
|
||||
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
|
||||
);
|
||||
});
|
||||
if (values != null) {
|
||||
settingsProvider.hideTrackOnlyWarning = values['hide'] == true;
|
||||
}
|
||||
return useTrackOnly && values != null;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
var userPickedTrackOnly = findGeneratedFormValueByKey(
|
||||
pickedSource!.additionalAppSpecificSourceAgnosticFormItems,
|
||||
otherAdditionalData,
|
||||
'trackOnlyFormItemKey') ==
|
||||
'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 [],
|
||||
defaultValues: const [],
|
||||
message:
|
||||
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
|
||||
);
|
||||
}) ==
|
||||
null) {
|
||||
cont = false;
|
||||
}
|
||||
if (cont) {
|
||||
HapticFeedback.selectionClick();
|
||||
try {
|
||||
var settingsProvider = context.read<SettingsProvider>();
|
||||
var userPickedTrackOnly = additionalSettings['trackOnly'] == true;
|
||||
App? app;
|
||||
if ((await getTrackOnlyConfirmationIfNeeded(
|
||||
userPickedTrackOnly, settingsProvider)) &&
|
||||
(await getReleaseDateAsVersionConfirmationIfNeeded(
|
||||
userPickedTrackOnly))) {
|
||||
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
|
||||
App app = await sourceProvider.getApp(
|
||||
pickedSource!, userInput, sourceSpecificAdditionalData,
|
||||
trackOnly: trackOnly);
|
||||
if (!trackOnly) {
|
||||
await settingsProvider.getInstallPermission();
|
||||
}
|
||||
app = await sourceProvider.getApp(
|
||||
pickedSource!, userInput, additionalSettings,
|
||||
trackOnlyOverride: trackOnly,
|
||||
overrideSource: pickedSourceOverride,
|
||||
inferAppIdIfOptional: inferAppIdIfOptional);
|
||||
// Only download the APK here if you need to for the package ID
|
||||
if (sourceProvider.isTempId(app.id) && !app.trackOnly) {
|
||||
if (sourceProvider.isTempId(app) &&
|
||||
app.additionalSettings['trackOnly'] != true) {
|
||||
// ignore: use_build_context_synchronously
|
||||
var apkUrl = await appsProvider.confirmApkUrl(app, context);
|
||||
if (apkUrl == null) {
|
||||
throw ObtainiumError(tr('cancelled'));
|
||||
}
|
||||
app.preferredApkIndex = app.apkUrls.indexOf(apkUrl);
|
||||
app.preferredApkIndex =
|
||||
app.apkUrls.map((e) => e.value).toList().indexOf(apkUrl.value);
|
||||
// ignore: use_build_context_synchronously
|
||||
var downloadedApk = await appsProvider.downloadApp(app, context);
|
||||
app.id = downloadedApk.appId;
|
||||
var downloadedArtifact = await appsProvider.downloadApp(
|
||||
app, globalNavigatorKey.currentContext);
|
||||
DownloadedApk? downloadedFile;
|
||||
DownloadedXApkDir? downloadedDir;
|
||||
if (downloadedArtifact is DownloadedApk) {
|
||||
downloadedFile = downloadedArtifact;
|
||||
} else {
|
||||
downloadedDir = downloadedArtifact as DownloadedXApkDir;
|
||||
}
|
||||
app.id = downloadedFile?.appId ?? downloadedDir!.appId;
|
||||
}
|
||||
if (appsProvider.apps.containsKey(app.id)) {
|
||||
throw ObtainiumError(tr('appAlreadyAdded'));
|
||||
}
|
||||
if (app.trackOnly) {
|
||||
if (app.additionalSettings['trackOnly'] == true ||
|
||||
app.additionalSettings['versionDetection'] !=
|
||||
'standardVersionDetection') {
|
||||
app.installedVersion = app.latestVersion;
|
||||
}
|
||||
await appsProvider.saveApps([app]);
|
||||
|
||||
return app;
|
||||
app.categories = pickedCategories;
|
||||
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 ?? '',
|
||||
overrideSource: pickedSourceOverride)
|
||||
.standardizeUrl(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) async {
|
||||
try {
|
||||
return await e.search(searchQuery);
|
||||
} catch (err) {
|
||||
if (err is! CredsNeededError) {
|
||||
rethrow;
|
||||
} else {
|
||||
return <String, List<String>>{};
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// .then((results) async {
|
||||
// Interleave results instead of simple reduce
|
||||
Map<String, List<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++;
|
||||
}
|
||||
if (res.isEmpty) {
|
||||
throw ObtainiumError(tr('noResults'));
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget getHTMLSourceOverrideDropdown() => Column(children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GeneratedForm(
|
||||
items: [
|
||||
[
|
||||
GeneratedFormDropdown(
|
||||
'overrideSource',
|
||||
defaultValue: HTML().runtimeType.toString(),
|
||||
[
|
||||
...sourceProvider.sources.map(
|
||||
(s) => MapEntry(s.runtimeType.toString(), s.name))
|
||||
],
|
||||
label: tr('overrideSource'))
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
fn() {
|
||||
pickedSourceOverride = (values['overrideSource'] == null ||
|
||||
values['overrideSource'] == '')
|
||||
? null
|
||||
: values['overrideSource'];
|
||||
}
|
||||
|
||||
if (!isBuilding) {
|
||||
setState(() {
|
||||
fn();
|
||||
});
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
changeUserInput(userInput, valid, isBuilding);
|
||||
},
|
||||
))
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
)
|
||||
]);
|
||||
|
||||
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,
|
||||
),
|
||||
searching
|
||||
? const CircularProgressIndicator()
|
||||
: ElevatedButton(
|
||||
onPressed: searchQuery.isEmpty || doingSomething
|
||||
? null
|
||||
: () {
|
||||
runSearch();
|
||||
},
|
||||
child: Text(tr('search')))
|
||||
],
|
||||
);
|
||||
|
||||
Widget getAdditionalOptsCol() => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
Text(
|
||||
tr('additionalOptsFor',
|
||||
args: [pickedSource?.name ?? tr('source')]),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold)),
|
||||
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;
|
||||
}),
|
||||
],
|
||||
),
|
||||
if (pickedSource != null && pickedSource!.appIdInferIsOptional)
|
||||
GeneratedForm(
|
||||
key: const Key('inferAppIdIfOptional'),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormSwitch('inferAppIdIfOptional',
|
||||
label: tr('tryInferAppIdFromCode'),
|
||||
defaultValue: inferAppIdIfOptional)
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (!isBuilding) {
|
||||
setState(() {
|
||||
inferAppIdIfOptional = values['inferAppIdIfOptional'];
|
||||
});
|
||||
}
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
Widget getSourcesListWidget() => 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>[
|
||||
body: CustomScrollView(shrinkWrap: true, slivers: <Widget>[
|
||||
CustomAppBar(title: tr('addApp')),
|
||||
SliverFillRemaining(
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GeneratedForm(
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
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[0], valid, isBuilding);
|
||||
},
|
||||
defaultValues: const [])),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
gettingAppInfo
|
||||
? const CircularProgressIndicator()
|
||||
: ElevatedButton(
|
||||
onPressed: gettingAppInfo ||
|
||||
pickedSource == null ||
|
||||
(pickedSource!
|
||||
.additionalSourceAppSpecificFormItems
|
||||
.isNotEmpty &&
|
||||
!sourceSpecificDataIsValid) ||
|
||||
(pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticDefaults
|
||||
.isNotEmpty &&
|
||||
!otherAdditionalDataIsValid)
|
||||
? null
|
||||
: addApp,
|
||||
child: Text(tr('add')))
|
||||
],
|
||||
getUrlInputRow(),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
if (sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.isNotEmpty &&
|
||||
pickedSource == null &&
|
||||
userInput.isEmpty)
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
if (sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.isNotEmpty &&
|
||||
pickedSource == null &&
|
||||
userInput.isEmpty)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GeneratedForm(
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr('searchSomeSourcesLabel'),
|
||||
required: false),
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (values.isNotEmpty && valid) {
|
||||
setState(() {
|
||||
searchQuery = values[0].trim();
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues: const ['']),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: searchQuery.isEmpty || gettingAppInfo
|
||||
? null
|
||||
: () {
|
||||
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, true);
|
||||
addApp(resetUserInputAfter: true);
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
},
|
||||
child: Text(tr('search')))
|
||||
],
|
||||
),
|
||||
if (pickedSource != null &&
|
||||
(pickedSource!.additionalSourceAppSpecificDefaults
|
||||
.isNotEmpty ||
|
||||
pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticFormItems
|
||||
.where((e) => pickedSource!.enforceTrackOnly
|
||||
? e.key != 'trackOnlyFormItemKey'
|
||||
: true)
|
||||
.map((e) => [e])
|
||||
.isNotEmpty))
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Divider(
|
||||
height: 64,
|
||||
),
|
||||
Text(
|
||||
tr('additionalOptsFor', args: [
|
||||
pickedSource?.runtimeType.toString() ??
|
||||
tr('source')
|
||||
]),
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
if (pickedSource!
|
||||
.additionalSourceAppSpecificFormItems
|
||||
.isNotEmpty)
|
||||
GeneratedForm(
|
||||
items: pickedSource!
|
||||
.additionalSourceAppSpecificFormItems,
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (isBuilding) {
|
||||
sourceSpecificAdditionalData = values;
|
||||
sourceSpecificDataIsValid = valid;
|
||||
} else {
|
||||
setState(() {
|
||||
sourceSpecificAdditionalData = values;
|
||||
sourceSpecificDataIsValid = valid;
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues: pickedSource!
|
||||
.additionalSourceAppSpecificDefaults),
|
||||
if (pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticDefaults
|
||||
.isNotEmpty)
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
GeneratedForm(
|
||||
items: pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticFormItems
|
||||
.where((e) => pickedSource!.enforceTrackOnly
|
||||
? e.key != 'trackOnlyFormItemKey'
|
||||
: true)
|
||||
.map((e) => [e])
|
||||
.toList(),
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (isBuilding) {
|
||||
otherAdditionalData = values;
|
||||
otherAdditionalDataIsValid = valid;
|
||||
} else {
|
||||
setState(() {
|
||||
otherAdditionalData = values;
|
||||
otherAdditionalDataIsValid = valid;
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues: pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticDefaults),
|
||||
],
|
||||
)
|
||||
if (pickedSourceOverride != null ||
|
||||
(pickedSource != null &&
|
||||
pickedSource.runtimeType.toString() ==
|
||||
HTML().runtimeType.toString()))
|
||||
getHTMLSourceOverrideDropdown(),
|
||||
if (shouldShowSearchBar()) getSearchBarRow(),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
if (pickedSource != null)
|
||||
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: () {
|
||||
launchUrlString('https://${e.host}',
|
||||
mode:
|
||||
LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text(
|
||||
'${e.runtimeType.toString()}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
|
||||
style: const TextStyle(
|
||||
decoration:
|
||||
TextDecoration.underline,
|
||||
fontStyle: FontStyle.italic),
|
||||
)))
|
||||
.toList()
|
||||
])),
|
||||
getSourcesListWidget(),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
|
@ -1,7 +1,11 @@
|
||||
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';
|
||||
import 'package:obtainium/pages/settings.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
@ -28,280 +32,442 @@ class _AppPageState extends State<AppPage> {
|
||||
getUpdate(String id) {
|
||||
appsProvider.checkUpdate(id).catchError((e) {
|
||||
showError(e, context);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
AppInMemory? app = appsProvider.apps[widget.appId]?.deepCopy();
|
||||
var source = app != null
|
||||
? sourceProvider.getSource(app.app.url,
|
||||
overrideSource: app.app.overrideSource)
|
||||
: null;
|
||||
if (!areDownloadsRunning && prevApp == null && app != null) {
|
||||
prevApp = app;
|
||||
getUpdate(app.app.id);
|
||||
}
|
||||
return Scaffold(
|
||||
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: RefreshIndicator(
|
||||
child: settingsProvider.showAppWebpage
|
||||
? WebView(
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
initialUrl: app?.app.url,
|
||||
javascriptMode: JavascriptMode.unrestricted,
|
||||
)
|
||||
: CustomScrollView(
|
||||
slivers: [
|
||||
SliverFillRemaining(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
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 ?? 'App',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
Text(
|
||||
'By ${app?.app.author ?? 'Unknown'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
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(
|
||||
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
'Installed Version: ${app?.app.installedVersion ?? 'None'}${app?.app.trackOnly == true ? ' (Estimate)\n\nApp is Track-Only' : ''}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
Text(
|
||||
'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic, fontSize: 12),
|
||||
)
|
||||
],
|
||||
)),
|
||||
],
|
||||
var trackOnly = app?.app.additionalSettings['trackOnly'] == true;
|
||||
|
||||
bool isVersionDetectionStandard =
|
||||
app?.app.additionalSettings['versionDetection'] ==
|
||||
'standardVersionDetection';
|
||||
|
||||
getInfoColumn() => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (app?.app.url != null) {
|
||||
launchUrlString(app?.app.url ?? '',
|
||||
mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
Clipboard.setData(ClipboardData(text: app?.app.url ?? ''));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(tr('copiedToClipboard')),
|
||||
));
|
||||
},
|
||||
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?.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);
|
||||
}
|
||||
},
|
||||
),
|
||||
onRefresh: () async {
|
||||
if (app != null) {
|
||||
getUpdate(app.app.id);
|
||||
)
|
||||
..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;
|
||||
}
|
||||
}
|
||||
}),
|
||||
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 &&
|
||||
app?.app.trackOnly == false &&
|
||||
app?.app.installedVersion !=
|
||||
app?.app.latestVersion &&
|
||||
app?.app.enhancedVersionDetection != true)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'App Already up to Date?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text('No')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
var updatedApp = app?.app;
|
||||
if (updatedApp != null) {
|
||||
updatedApp
|
||||
.installedVersion =
|
||||
updatedApp
|
||||
.latestVersion;
|
||||
appsProvider.saveApps(
|
||||
[updatedApp]);
|
||||
}
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Yes, Mark as Updated'))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
tooltip: 'Mark as Updated',
|
||||
icon: const Icon(Icons.done)),
|
||||
if (source != null &&
|
||||
source.additionalSourceAppSpecificFormItems
|
||||
.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: () {
|
||||
showDialog<List<String>>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: 'Additional Options',
|
||||
items: source
|
||||
.additionalSourceAppSpecificFormItems,
|
||||
defaultValues: app != null
|
||||
? app.app.additionalData
|
||||
: source
|
||||
.additionalSourceAppSpecificDefaults);
|
||||
}).then((values) {
|
||||
if (app != null && values != null) {
|
||||
var changedApp = app.app;
|
||||
changedApp.additionalData = values;
|
||||
appsProvider.saveApps(
|
||||
[changedApp]).then((value) {
|
||||
getUpdate(changedApp.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
tooltip: 'Additional Options',
|
||||
icon: const Icon(Icons.settings)),
|
||||
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.trackOnly != true) {
|
||||
await settingsProvider
|
||||
.getInstallPermission();
|
||||
}
|
||||
}()
|
||||
.then((value) {
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApps(
|
||||
[app!.app.id],
|
||||
context).then((res) {
|
||||
if (res.isNotEmpty && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
}
|
||||
: null,
|
||||
child: Text(app?.app.installedVersion == null
|
||||
? app?.app.trackOnly == false
|
||||
? 'Install'
|
||||
: 'Mark Installed'
|
||||
: app?.app.trackOnly == false
|
||||
? 'Update'
|
||||
: 'Mark Updated'))),
|
||||
const SizedBox(width: 16.0),
|
||||
ElevatedButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text('Remove App?'),
|
||||
content: Text(
|
||||
'This will remove \'${app?.installedInfo?.name ?? app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
appsProvider.removeApps(
|
||||
[app!.app.id]).then((_) {
|
||||
int count = 0;
|
||||
Navigator.of(context)
|
||||
.popUntil((_) =>
|
||||
count++ >= 2);
|
||||
});
|
||||
},
|
||||
child: const Text('Remove')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.error,
|
||||
surfaceTintColor:
|
||||
Theme.of(context).colorScheme.error),
|
||||
child: const Text('Remove'),
|
||||
}
|
||||
} else if (originalSettings['versionDetection'] ==
|
||||
'releaseDateAsVersion') {
|
||||
app.app.installedVersion =
|
||||
app.installedInfo?.versionName ?? app.app.installedVersion;
|
||||
}
|
||||
appsProvider.saveApps([app.app]).then((value) {
|
||||
getUpdate(app.app.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getResetInstallStatusButton() => TextButton(
|
||||
onPressed: app?.app == null
|
||||
? null
|
||||
: () {
|
||||
app!.app.installedVersion = null;
|
||||
appsProvider.saveApps([app.app]);
|
||||
},
|
||||
child: Text(
|
||||
tr('resetInstallStatus'),
|
||||
textAlign: TextAlign.center,
|
||||
));
|
||||
|
||||
getInstallOrUpdateButton() => TextButton(
|
||||
onPressed: (app?.app.installedVersion == null ||
|
||||
app?.app.installedVersion != app?.app.latestVersion) &&
|
||||
!areDownloadsRunning
|
||||
? () async {
|
||||
try {
|
||||
HapticFeedback.heavyImpact();
|
||||
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?.downloadProgress != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
|
||||
child: LinearProgressIndicator(
|
||||
value: app!.downloadProgress! / 100))
|
||||
],
|
||||
)),
|
||||
);
|
||||
if (app != null && settingsProvider.showAppWebpage)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
content: getInfoColumn(),
|
||||
title: Text(
|
||||
'${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: (!isVersionDetectionStandard || trackOnly) &&
|
||||
app?.app.installedVersion != null &&
|
||||
app?.app.installedVersion ==
|
||||
app?.app.latestVersion
|
||||
? getResetInstallStatusButton()
|
||||
: 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! >= 0
|
||||
? app.downloadProgress! / 100
|
||||
: null))
|
||||
],
|
||||
));
|
||||
|
||||
return Scaffold(
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
1541
lib/pages/apps.dart
@ -6,6 +6,8 @@ import 'package:obtainium/pages/add_app.dart';
|
||||
import 'package:obtainium/pages/apps.dart';
|
||||
import 'package:obtainium/pages/import_export.dart';
|
||||
import 'package:obtainium/pages/settings.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
@ -24,6 +26,8 @@ class NavigationPageItem {
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
List<int> selectedIndexHistory = [];
|
||||
int prevAppCount = -1;
|
||||
bool prevIsLoading = true;
|
||||
|
||||
List<NavigationPageItem> pages = [
|
||||
NavigationPageItem(tr('appsString'), Icons.apps,
|
||||
@ -36,6 +40,41 @@ class _HomePageState extends State<HomePage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
AppsProvider appsProvider = context.watch<AppsProvider>();
|
||||
|
||||
switchToPage(int index) async {
|
||||
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)) {
|
||||
setState(() {
|
||||
int existingInd = selectedIndexHistory.indexOf(index);
|
||||
if (existingInd >= 0) {
|
||||
selectedIndexHistory.removeAt(existingInd);
|
||||
}
|
||||
selectedIndexHistory.add(index);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!prevIsLoading &&
|
||||
prevAppCount >= 0 &&
|
||||
appsProvider.apps.length > prevAppCount &&
|
||||
selectedIndexHistory.isNotEmpty &&
|
||||
selectedIndexHistory.last == 1) {
|
||||
switchToPage(0);
|
||||
}
|
||||
prevAppCount = appsProvider.apps.length;
|
||||
prevIsLoading = appsProvider.loadingApps;
|
||||
|
||||
return WillPopScope(
|
||||
child: Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
@ -63,21 +102,9 @@ 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) {
|
||||
selectedIndexHistory.clear();
|
||||
} else if (selectedIndexHistory.isEmpty ||
|
||||
(selectedIndexHistory.isNotEmpty &&
|
||||
selectedIndexHistory.last != index)) {
|
||||
int existingInd = selectedIndexHistory.indexOf(index);
|
||||
if (existingInd >= 0) {
|
||||
selectedIndexHistory.removeAt(existingInd);
|
||||
}
|
||||
selectedIndexHistory.add(index);
|
||||
}
|
||||
});
|
||||
await switchToPage(index);
|
||||
},
|
||||
selectedIndex:
|
||||
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,
|
||||
|
@ -9,6 +9,7 @@ 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/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
@ -28,6 +29,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
var appsProvider = context.read<AppsProvider>();
|
||||
var settingsProvider = context.read<SettingsProvider>();
|
||||
|
||||
var outlineButtonStyle = ButtonStyle(
|
||||
shape: MaterialStateProperty.all(
|
||||
StadiumBorder(
|
||||
@ -39,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>[
|
||||
@ -58,16 +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);
|
||||
});
|
||||
},
|
||||
: runObtainiumExport,
|
||||
child: Text(tr('obtainiumExport')))),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
@ -77,50 +318,13 @@ 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) {
|
||||
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'))))
|
||||
],
|
||||
),
|
||||
if (importInProgress)
|
||||
Column(
|
||||
children: const [
|
||||
const Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 14,
|
||||
),
|
||||
@ -131,87 +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(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('importFromURLList'),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr('appURLList'),
|
||||
max: 7,
|
||||
additionalValidators: [
|
||||
(String? 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;
|
||||
}
|
||||
])
|
||||
]
|
||||
],
|
||||
defaultValues: const [],
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
var urls =
|
||||
(values[0] 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(
|
||||
@ -223,105 +366,10 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
() async {
|
||||
var values = await showDialog<
|
||||
List<String>>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('searchX',
|
||||
args: [
|
||||
source
|
||||
.runtimeType
|
||||
.toString()
|
||||
]),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr(
|
||||
'searchQuery'))
|
||||
]
|
||||
],
|
||||
defaultValues: const [],
|
||||
);
|
||||
});
|
||||
if (values != null &&
|
||||
values[0].isNotEmpty) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
var urlsWithDescriptions =
|
||||
await source
|
||||
.search(values[0]);
|
||||
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.runtimeType.toString()
|
||||
])))
|
||||
child: Text(
|
||||
tr('searchX', args: [source.name])))
|
||||
]))
|
||||
.toList(),
|
||||
...sourceProvider.massUrlSources
|
||||
@ -334,86 +382,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
() async {
|
||||
var values = await showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('importX',
|
||||
args: [
|
||||
source.name
|
||||
]),
|
||||
items:
|
||||
source
|
||||
.requiredArgs
|
||||
.map(
|
||||
(e) => [
|
||||
GeneratedFormItem(label: e)
|
||||
])
|
||||
.toList(),
|
||||
defaultValues: const [],
|
||||
);
|
||||
});
|
||||
if (values != null) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
var urlsWithDescriptions =
|
||||
await source
|
||||
.getUrlsWithDescriptions(
|
||||
values);
|
||||
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])))
|
||||
@ -501,7 +470,7 @@ class UrlSelectionModal extends StatefulWidget {
|
||||
this.selectedByDefault = true,
|
||||
this.onlyOneSelectionAllowed = false});
|
||||
|
||||
Map<String, String> urlsWithDescriptions;
|
||||
Map<String, List<String>> urlsWithDescriptions;
|
||||
bool selectedByDefault;
|
||||
bool onlyOneSelectionAllowed;
|
||||
|
||||
@ -510,7 +479,7 @@ class UrlSelectionModal extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
Map<MapEntry<String, String>, bool> urlWithDescriptionSelections = {};
|
||||
Map<MapEntry<String, List<String>>, bool> urlWithDescriptionSelections = {};
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -537,18 +506,79 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')),
|
||||
content: Column(children: [
|
||||
...urlWithDescriptionSelections.keys.map((urlWithD) {
|
||||
return Row(children: [
|
||||
selectThis(bool? value) {
|
||||
setState(() {
|
||||
value ??= false;
|
||||
if (value! && widget.onlyOneSelectionAllowed) {
|
||||
selectOnlyOne(urlWithD.key);
|
||||
} else {
|
||||
urlWithDescriptionSelections[urlWithD] = value!;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var urlLink = GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString(urlWithD.key,
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
urlWithD.value[0],
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
Text(
|
||||
Uri.parse(urlWithD.key).host,
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline, fontSize: 12),
|
||||
)
|
||||
],
|
||||
));
|
||||
|
||||
var descriptionText = Text(
|
||||
urlWithD.value[1].length > 128
|
||||
? '${urlWithD.value[1].substring(0, 128)}...'
|
||||
: urlWithD.value[1],
|
||||
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
|
||||
);
|
||||
|
||||
var selectedUrlsWithDs = urlWithDescriptionSelections.entries
|
||||
.where((e) => e.value)
|
||||
.toList();
|
||||
|
||||
var singleSelectTile = ListTile(
|
||||
title: urlLink,
|
||||
subtitle: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
selectOnlyOne(urlWithD.key);
|
||||
});
|
||||
},
|
||||
child: descriptionText,
|
||||
),
|
||||
leading: Radio<String>(
|
||||
value: urlWithD.key,
|
||||
groupValue: selectedUrlsWithDs.isEmpty
|
||||
? null
|
||||
: selectedUrlsWithDs.first.key.key,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
selectOnlyOne(urlWithD.key);
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
var multiSelectTile = Row(children: [
|
||||
Checkbox(
|
||||
value: urlWithDescriptionSelections[urlWithD],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
value ??= false;
|
||||
if (value! && widget.onlyOneSelectionAllowed) {
|
||||
selectOnlyOne(urlWithD.key);
|
||||
} else {
|
||||
urlWithDescriptionSelections[urlWithD] = value!;
|
||||
}
|
||||
});
|
||||
selectThis(value);
|
||||
}),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
@ -561,23 +591,13 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
urlLink,
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString(urlWithD.key,
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text(
|
||||
Uri.parse(urlWithD.key).path.substring(1),
|
||||
style:
|
||||
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),
|
||||
onTap: () {
|
||||
selectThis(
|
||||
!(urlWithDescriptionSelections[urlWithD] ?? false));
|
||||
},
|
||||
child: descriptionText,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
@ -585,6 +605,10 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
],
|
||||
))
|
||||
]);
|
||||
|
||||
return widget.onlyOneSelectionAllowed
|
||||
? singleSelectTile
|
||||
: multiSelectTile;
|
||||
})
|
||||
]),
|
||||
actions: [
|
||||
|
@ -1,8 +1,12 @@
|
||||
import 'dart:math';
|
||||
|
||||
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/custom_errors.dart';
|
||||
import 'package:obtainium/main.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/logs_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
@ -17,6 +21,21 @@ class SettingsPage extends StatefulWidget {
|
||||
State<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
// Generates a random light color
|
||||
// Courtesy of ChatGPT 😭 (with a bugfix 🥳)
|
||||
Color generateRandomLightColor() {
|
||||
// Create a random number generator
|
||||
final Random random = Random();
|
||||
|
||||
// Generate random hue, saturation, and value values
|
||||
final double hue = random.nextDouble() * 360;
|
||||
final double saturation = 0.5 + random.nextDouble() * 0.5;
|
||||
final double value = 0.9 + random.nextDouble() * 0.1;
|
||||
|
||||
// Create a HSV color with the random values
|
||||
return HSVColor.fromAHSV(1.0, hue, saturation, value).toColor();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -69,6 +88,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
});
|
||||
|
||||
var sortDropdown = DropdownButtonFormField(
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(labelText: tr('appSortBy')),
|
||||
value: settingsProvider.sortColumn,
|
||||
items: [
|
||||
@ -83,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) {
|
||||
@ -92,6 +116,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
});
|
||||
|
||||
var orderDropdown = DropdownButtonFormField(
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(labelText: tr('appSortOrder')),
|
||||
value: settingsProvider.sortOrder,
|
||||
items: [
|
||||
@ -110,6 +135,28 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
});
|
||||
|
||||
var localeDropdown = DropdownButtonFormField(
|
||||
decoration: InputDecoration(labelText: tr('language')),
|
||||
value: settingsProvider.forcedLocale,
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: null,
|
||||
child: Text(tr('followSystem')),
|
||||
),
|
||||
...supportedLocales.map((e) => DropdownMenuItem(
|
||||
value: e.key.toLanguageTag(),
|
||||
child: Text(e.value),
|
||||
))
|
||||
],
|
||||
onChanged: (value) {
|
||||
settingsProvider.forcedLocale = value;
|
||||
if (value != null) {
|
||||
context.setLocale(Locale(value));
|
||||
} else {
|
||||
settingsProvider.resetLocaleSafe(context);
|
||||
}
|
||||
});
|
||||
|
||||
var intervalDropdown = DropdownButtonFormField(
|
||||
decoration: InputDecoration(labelText: tr('bgUpdateCheckInterval')),
|
||||
value: settingsProvider.updateInterval,
|
||||
@ -138,21 +185,17 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
var sourceSpecificFields = sourceProvider.sources.map((e) {
|
||||
if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
|
||||
return GeneratedForm(
|
||||
items: e.additionalSourceSpecificSettingFormItems
|
||||
.map((e) => [e])
|
||||
.toList(),
|
||||
items: e.additionalSourceSpecificSettingFormItems.map((e) {
|
||||
e.defaultValue = settingsProvider.getSettingString(e.key);
|
||||
return [e];
|
||||
}).toList(),
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (valid) {
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
settingsProvider.setSettingString(
|
||||
e.additionalSourceSpecificSettingFormItems[i].id,
|
||||
values[i]);
|
||||
}
|
||||
if (valid && !isBuilding) {
|
||||
values.forEach((key, value) {
|
||||
settingsProvider.setSettingString(key, value);
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues: e.additionalSourceSpecificSettingFormItems.map((e) {
|
||||
return settingsProvider.getSettingString(e.id) ?? '';
|
||||
}).toList());
|
||||
});
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
@ -162,6 +205,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
height: 16,
|
||||
);
|
||||
|
||||
const height32 = SizedBox(
|
||||
height: 32,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
@ -174,13 +221,53 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tr('updates'),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
intervalDropdown,
|
||||
height16,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(child: Text(tr('checkOnStart'))),
|
||||
Switch(
|
||||
value: settingsProvider.checkOnStart,
|
||||
onChanged: (value) {
|
||||
settingsProvider.checkOnStart = value;
|
||||
})
|
||||
],
|
||||
),
|
||||
height32,
|
||||
Text(
|
||||
tr('sourceSpecific'),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
...sourceSpecificFields,
|
||||
height32,
|
||||
Text(
|
||||
tr('appearance'),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
themeDropdown,
|
||||
height16,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(child: Text(tr('useBlackTheme'))),
|
||||
Switch(
|
||||
value: settingsProvider.useBlackTheme,
|
||||
onChanged: (value) {
|
||||
settingsProvider.useBlackTheme = value;
|
||||
})
|
||||
],
|
||||
),
|
||||
colourDropdown,
|
||||
height16,
|
||||
Row(
|
||||
@ -195,10 +282,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
],
|
||||
),
|
||||
height16,
|
||||
localeDropdown,
|
||||
height16,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(tr('showWebInAppView')),
|
||||
Flexible(child: Text(tr('showWebInAppView'))),
|
||||
Switch(
|
||||
value: settingsProvider.showAppWebpage,
|
||||
onChanged: (value) {
|
||||
@ -210,7 +299,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(tr('pinUpdates')),
|
||||
Flexible(child: Text(tr('pinUpdates'))),
|
||||
Switch(
|
||||
value: settingsProvider.pinUpdates,
|
||||
onChanged: (value) {
|
||||
@ -218,25 +307,75 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
})
|
||||
],
|
||||
),
|
||||
const Divider(
|
||||
height: 16,
|
||||
height16,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
tr('moveNonInstalledAppsToBottom'))),
|
||||
Switch(
|
||||
value: settingsProvider.buryNonInstalled,
|
||||
onChanged: (value) {
|
||||
settingsProvider.buryNonInstalled = value;
|
||||
})
|
||||
],
|
||||
),
|
||||
height16,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(child: Text(tr('groupByCategory'))),
|
||||
Switch(
|
||||
value: settingsProvider.groupByCategory,
|
||||
onChanged: (value) {
|
||||
settingsProvider.groupByCategory = value;
|
||||
})
|
||||
],
|
||||
),
|
||||
height16,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child:
|
||||
Text(tr('dontShowTrackOnlyWarnings'))),
|
||||
Switch(
|
||||
value:
|
||||
settingsProvider.hideTrackOnlyWarning,
|
||||
onChanged: (value) {
|
||||
settingsProvider.hideTrackOnlyWarning =
|
||||
value;
|
||||
})
|
||||
],
|
||||
),
|
||||
height16,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child:
|
||||
Text(tr('dontShowAPKOriginWarnings'))),
|
||||
Switch(
|
||||
value:
|
||||
settingsProvider.hideAPKOriginWarning,
|
||||
onChanged: (value) {
|
||||
settingsProvider.hideAPKOriginWarning =
|
||||
value;
|
||||
})
|
||||
],
|
||||
),
|
||||
height32,
|
||||
Text(
|
||||
tr('updates'),
|
||||
tr('categories'),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
intervalDropdown,
|
||||
const Divider(
|
||||
height: 48,
|
||||
),
|
||||
Text(
|
||||
tr('sourceSpecific'),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
...sourceSpecificFields,
|
||||
height16,
|
||||
const CategoryEditorSelector(
|
||||
showLabelWhenNotEmpty: false,
|
||||
)
|
||||
],
|
||||
))),
|
||||
SliverToBoxAdapter(
|
||||
@ -351,3 +490,64 @@ class _LogsDialogState extends State<LogsDialog> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CategoryEditorSelector extends StatefulWidget {
|
||||
final void Function(List<String> categories)? onSelected;
|
||||
final bool singleSelect;
|
||||
final Set<String> preselected;
|
||||
final WrapAlignment alignment;
|
||||
final bool showLabelWhenNotEmpty;
|
||||
const CategoryEditorSelector(
|
||||
{super.key,
|
||||
this.onSelected,
|
||||
this.singleSelect = false,
|
||||
this.preselected = const {},
|
||||
this.alignment = WrapAlignment.start,
|
||||
this.showLabelWhenNotEmpty = true});
|
||||
|
||||
@override
|
||||
State<CategoryEditorSelector> createState() => _CategoryEditorSelectorState();
|
||||
}
|
||||
|
||||
class _CategoryEditorSelectorState extends State<CategoryEditorSelector> {
|
||||
Map<String, MapEntry<int, bool>> storedValues = {};
|
||||
|
||||
@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,
|
||||
storedValues[key]?.value ?? widget.preselected.contains(key))));
|
||||
return GeneratedForm(
|
||||
items: [
|
||||
[
|
||||
GeneratedFormTagInput('categories',
|
||||
label: tr('categories'),
|
||||
emptyMessage: tr('noCategories'),
|
||||
defaultValue: storedValues,
|
||||
alignment: widget.alignment,
|
||||
deleteConfirmationMessage: MapEntry(
|
||||
tr('deleteCategoriesQuestion'),
|
||||
tr('categoryDeleteWarning')),
|
||||
singleSelect: widget.singleSelect,
|
||||
showLabelWhenNotEmpty: widget.showLabelWhenNotEmpty)
|
||||
]
|
||||
],
|
||||
onValueChanges: ((values, valid, isBuilding) {
|
||||
if (!isBuilding) {
|
||||
storedValues =
|
||||
values['categories'] as Map<String, MapEntry<int, bool>>;
|
||||
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)
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
@ -13,11 +13,12 @@ class ObtainiumNotification {
|
||||
late String channelName;
|
||||
late String channelDescription;
|
||||
Importance importance;
|
||||
int? progPercent;
|
||||
bool onlyAlertOnce;
|
||||
|
||||
ObtainiumNotification(this.id, this.title, this.message, this.channelCode,
|
||||
this.channelName, this.channelDescription, this.importance,
|
||||
{this.onlyAlertOnce = false});
|
||||
{this.onlyAlertOnce = false, this.progPercent});
|
||||
}
|
||||
|
||||
class UpdateNotification extends ObtainiumNotification {
|
||||
@ -33,9 +34,9 @@ class UpdateNotification extends ObtainiumNotification {
|
||||
message = updates.isEmpty
|
||||
? tr('noNewUpdates')
|
||||
: updates.length == 1
|
||||
? tr('xHasAnUpdate', args: [updates[0].name])
|
||||
? tr('xHasAnUpdate', args: [updates[0].finalName])
|
||||
: plural('xAndNMoreUpdatesAvailable', updates.length - 1,
|
||||
args: [updates[0].name]);
|
||||
args: [updates[0].finalName, (updates.length - 1).toString()]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,9 +46,9 @@ class SilentUpdateNotification extends ObtainiumNotification {
|
||||
tr('appsUpdatedNotifDescription'), Importance.defaultImportance) {
|
||||
message = updates.length == 1
|
||||
? tr('xWasUpdatedToY',
|
||||
args: [updates[0].name, updates[0].latestVersion])
|
||||
args: [updates[0].finalName, updates[0].latestVersion])
|
||||
: plural('xAndNMoreUpdatesInstalled', updates.length - 1,
|
||||
args: [updates[0].name]);
|
||||
args: [updates[0].finalName, (updates.length - 1).toString()]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,15 +80,14 @@ class DownloadNotification extends ObtainiumNotification {
|
||||
DownloadNotification(String appName, int progPercent)
|
||||
: super(
|
||||
appName.hashCode,
|
||||
'Downloading $appName',
|
||||
'$progPercent%',
|
||||
tr('downloadingX', args: [appName]),
|
||||
'',
|
||||
'APP_DOWNLOADING',
|
||||
'Downloading App',
|
||||
'Notifies the user of the progress in downloading an App',
|
||||
Importance.defaultImportance,
|
||||
onlyAlertOnce: true) {
|
||||
message = tr('percentProgress', args: [progPercent.toString()]);
|
||||
}
|
||||
tr('downloadingX', args: [tr('app')]),
|
||||
tr('downloadNotifDescription'),
|
||||
Importance.low,
|
||||
onlyAlertOnce: true,
|
||||
progPercent: progPercent);
|
||||
}
|
||||
|
||||
final completeInstallationNotification = ObtainiumNotification(
|
||||
@ -167,12 +167,15 @@ class NotificationsProvider {
|
||||
progress: progPercent ?? 0,
|
||||
maxProgress: 100,
|
||||
showProgress: progPercent != null,
|
||||
onlyAlertOnce: onlyAlertOnce)));
|
||||
onlyAlertOnce: onlyAlertOnce,
|
||||
indeterminate: progPercent != null && progPercent < 0)));
|
||||
}
|
||||
|
||||
Future<void> notify(ObtainiumNotification notif,
|
||||
{bool cancelExisting = false}) =>
|
||||
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode,
|
||||
notif.channelName, notif.channelDescription, notif.importance,
|
||||
cancelExisting: cancelExisting, onlyAlertOnce: notif.onlyAlertOnce);
|
||||
cancelExisting: cancelExisting,
|
||||
onlyAlertOnce: notif.onlyAlertOnce,
|
||||
progPercent: notif.progPercent);
|
||||
}
|
||||
|
@ -1,9 +1,14 @@
|
||||
// Exposes functions used to save/load app settings
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
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/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';
|
||||
|
||||
@ -14,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 }
|
||||
|
||||
@ -30,6 +35,7 @@ List<int> updateIntervals = [15, 30, 60, 120, 180, 360, 720, 1440, 4320, 0]
|
||||
|
||||
class SettingsProvider with ChangeNotifier {
|
||||
SharedPreferences? prefs;
|
||||
bool justStarted = true;
|
||||
|
||||
String sourceUrl = 'https://github.com/ImranR98/Obtainium';
|
||||
|
||||
@ -59,6 +65,15 @@ class SettingsProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get useBlackTheme {
|
||||
return prefs?.getBool('useBlackTheme') ?? false;
|
||||
}
|
||||
|
||||
set useBlackTheme(bool useBlackTheme) {
|
||||
prefs?.setBool('useBlackTheme', useBlackTheme);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int get updateInterval {
|
||||
var min = prefs?.getInt('updateInterval') ?? 360;
|
||||
if (!updateIntervals.contains(min)) {
|
||||
@ -78,6 +93,15 @@ class SettingsProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get checkOnStart {
|
||||
return prefs?.getBool('checkOnStart') ?? false;
|
||||
}
|
||||
|
||||
set checkOnStart(bool checkOnStart) {
|
||||
prefs?.setBool('checkOnStart', checkOnStart);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
SortColumnSettings get sortColumn {
|
||||
return SortColumnSettings.values[
|
||||
prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index];
|
||||
@ -106,16 +130,28 @@ class SettingsProvider with ChangeNotifier {
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> getInstallPermission() async {
|
||||
bool checkJustStarted() {
|
||||
if (justStarted) {
|
||||
justStarted = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> getInstallPermission({bool enforce = false}) async {
|
||||
while (!(await Permission.requestInstallPackages.isGranted)) {
|
||||
// Explicit request as InstallPlugin request sometimes bugged
|
||||
Fluttertoast.showToast(
|
||||
msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG);
|
||||
if ((await Permission.requestInstallPackages.request()) ==
|
||||
PermissionStatus.granted) {
|
||||
break;
|
||||
return true;
|
||||
}
|
||||
if (!enforce) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool get showAppWebpage {
|
||||
@ -136,6 +172,42 @@ class SettingsProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get buryNonInstalled {
|
||||
return prefs?.getBool('buryNonInstalled') ?? false;
|
||||
}
|
||||
|
||||
set buryNonInstalled(bool show) {
|
||||
prefs?.setBool('buryNonInstalled', show);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get groupByCategory {
|
||||
return prefs?.getBool('groupByCategory') ?? false;
|
||||
}
|
||||
|
||||
set groupByCategory(bool show) {
|
||||
prefs?.setBool('groupByCategory', show);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get hideTrackOnlyWarning {
|
||||
return prefs?.getBool('hideTrackOnlyWarning') ?? false;
|
||||
}
|
||||
|
||||
set hideTrackOnlyWarning(bool show) {
|
||||
prefs?.setBool('hideTrackOnlyWarning', show);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get hideAPKOriginWarning {
|
||||
return prefs?.getBool('hideAPKOriginWarning') ?? false;
|
||||
}
|
||||
|
||||
set hideAPKOriginWarning(bool show) {
|
||||
prefs?.setBool('hideAPKOriginWarning', show);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String? getSettingString(String settingId) {
|
||||
return prefs?.getString(settingId);
|
||||
}
|
||||
@ -144,4 +216,61 @@ class SettingsProvider with ChangeNotifier {
|
||||
prefs?.setString(settingId, value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Map<String, int> get categories =>
|
||||
Map<String, int>.from(jsonDecode(prefs?.getString('categories') ?? '{}'));
|
||||
|
||||
void setCategories(Map<String, int> cats, {AppsProvider? appsProvider}) {
|
||||
if (appsProvider != null) {
|
||||
List<App> changedApps = appsProvider
|
||||
.getAppValues()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
prefs?.setString('categories', jsonEncode(cats));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String? get forcedLocale {
|
||||
var fl = prefs?.getString('forcedLocale');
|
||||
return supportedLocales
|
||||
.where((element) => element.key.toLanguageTag() == fl)
|
||||
.isNotEmpty
|
||||
? fl
|
||||
: null;
|
||||
}
|
||||
|
||||
set forcedLocale(String? fl) {
|
||||
if (fl == null) {
|
||||
prefs?.remove('forcedLocale');
|
||||
} else if (supportedLocales
|
||||
.where((element) => element.key.toLanguageTag() == fl)
|
||||
.isNotEmpty) {
|
||||
prefs?.setString('forcedLocale', fl);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,17 +3,28 @@
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/apkmirror.dart';
|
||||
import 'package:obtainium/app_sources/apkpure.dart';
|
||||
import 'package:obtainium/app_sources/codeberg.dart';
|
||||
import 'package:obtainium/app_sources/fdroid.dart';
|
||||
import 'package:obtainium/app_sources/fdroidrepo.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
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/jenkins.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/sourcehut.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';
|
||||
@ -27,15 +38,113 @@ class AppNames {
|
||||
|
||||
class APKDetails {
|
||||
late String version;
|
||||
late String versionFromSource;
|
||||
late bool isStandardVersion;
|
||||
late List<String> apkUrls;
|
||||
late List<MapEntry<String, String>> apkUrls;
|
||||
late AppNames names;
|
||||
late DateTime? releaseDate;
|
||||
late String? changeLog;
|
||||
|
||||
APKDetails(this.versionFromSource, this.apkUrls) {
|
||||
var temp = extractStandardVersionName(versionFromSource);
|
||||
this.isStandardVersion = temp != null;
|
||||
this.version = temp ?? versionFromSource;
|
||||
APKDetails(this.version, this.apkUrls, this.names,
|
||||
{this.releaseDate, this.changeLog});
|
||||
}
|
||||
|
||||
stringMapListTo2DList(List<MapEntry<String, String>> mapList) =>
|
||||
mapList.map((e) => [e.key, e.value]).toList();
|
||||
|
||||
assumed2DlistToStringMapList(List<dynamic> arr) =>
|
||||
arr.map((e) => MapEntry(e[0] as String, e[1] as String)).toList();
|
||||
|
||||
// App JSON schema has changed multiple times over the many versions of Obtainium
|
||||
// This function takes an App JSON and modifies it if needed to conform to the latest (current) version
|
||||
appJSONCompatibilityModifiers(Map<String, dynamic> json) {
|
||||
var source = SourceProvider()
|
||||
.getSource(json['url'], overrideSource: json['overrideSource']);
|
||||
var formItems = source.combinedAppSpecificSettingFormItems
|
||||
.reduce((value, element) => [...value, ...element]);
|
||||
Map<String, dynamic> additionalSettings =
|
||||
getDefaultValuesFromFormItems([formItems]);
|
||||
if (json['additionalSettings'] != null) {
|
||||
additionalSettings.addEntries(
|
||||
Map<String, dynamic>.from(jsonDecode(json['additionalSettings']))
|
||||
.entries);
|
||||
}
|
||||
// If needed, migrate old-style additionalData to newer-style additionalSettings (V1)
|
||||
if (json['additionalData'] != null) {
|
||||
List<String> temp = List<String>.from(jsonDecode(json['additionalData']));
|
||||
temp.asMap().forEach((i, value) {
|
||||
if (i < formItems.length) {
|
||||
if (formItems[i] is GeneratedFormSwitch) {
|
||||
additionalSettings[formItems[i].key] = value == 'true';
|
||||
} else {
|
||||
additionalSettings[formItems[i].key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
additionalSettings['trackOnly'] =
|
||||
json['trackOnly'] == 'true' || json['trackOnly'] == true;
|
||||
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) {
|
||||
additionalSettings[item.key] =
|
||||
item.ensureType(additionalSettings[item.key]);
|
||||
}
|
||||
}
|
||||
int preferredApkIndex =
|
||||
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int;
|
||||
if (preferredApkIndex < 0) {
|
||||
preferredApkIndex = 0;
|
||||
}
|
||||
json['preferredApkIndex'] = preferredApkIndex;
|
||||
// apkUrls can either be old list or new named list apkUrls
|
||||
List<MapEntry<String, String>> apkUrls = [];
|
||||
if (json['apkUrls'] != null) {
|
||||
var apkUrlJson = jsonDecode(json['apkUrls']);
|
||||
try {
|
||||
apkUrls = getApkUrlsFromUrls(List<String>.from(apkUrlJson));
|
||||
} catch (e) {
|
||||
apkUrls = assumed2DlistToStringMapList(List<dynamic>.from(apkUrlJson));
|
||||
apkUrls = List<dynamic>.from(apkUrlJson)
|
||||
.map((e) => MapEntry(e[0] as String, e[1] as String))
|
||||
.toList();
|
||||
}
|
||||
json['apkUrls'] = jsonEncode(stringMapListTo2DList(apkUrls));
|
||||
}
|
||||
// Arch based APK filter option should be disabled if it previously did not exist
|
||||
if (additionalSettings['autoApkFilterByArch'] == null) {
|
||||
additionalSettings['autoApkFilterByArch'] = false;
|
||||
}
|
||||
json['additionalSettings'] = jsonEncode(additionalSettings);
|
||||
// F-Droid no longer needs cloudflare exception since override can be used - migrate apps appropriately
|
||||
// This allows us to reverse the changes made for issue #418 (support cloudflare.f-droid)
|
||||
// While not causing problems for existing apps from that source that were added in a previous version
|
||||
var overrideSourceWasUndefined = !json.keys.contains('overrideSource');
|
||||
if ((json['url'] as String).startsWith('https://cloudflare.f-droid.org')) {
|
||||
json['overrideSource'] = FDroid().runtimeType.toString();
|
||||
} else if (overrideSourceWasUndefined) {
|
||||
// Similar to above, but for third-party F-Droid repos
|
||||
RegExpMatch? match = RegExp('^https?://.+/fdroid/([^/]+(/|\\?)|[^/]+\$)')
|
||||
.firstMatch(json['url'] as String);
|
||||
if (match != null) {
|
||||
json['overrideSource'] = FDroidRepo().runtimeType.toString();
|
||||
}
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
class App {
|
||||
@ -45,13 +154,15 @@ class App {
|
||||
late String name;
|
||||
String? installedVersion;
|
||||
late String latestVersion;
|
||||
List<String> apkUrls = [];
|
||||
List<MapEntry<String, String>> apkUrls = [];
|
||||
late int preferredApkIndex;
|
||||
late List<String> additionalData;
|
||||
late Map<String, dynamic> additionalSettings;
|
||||
late DateTime? lastUpdateCheck;
|
||||
bool pinned = false;
|
||||
bool trackOnly = false;
|
||||
bool enhancedVersionDetection = false;
|
||||
List<String> categories;
|
||||
late DateTime? releaseDate;
|
||||
late String? changeLog;
|
||||
late String? overrideSource;
|
||||
App(
|
||||
this.id,
|
||||
this.url,
|
||||
@ -61,41 +172,77 @@ class App {
|
||||
this.latestVersion,
|
||||
this.apkUrls,
|
||||
this.preferredApkIndex,
|
||||
this.additionalData,
|
||||
this.additionalSettings,
|
||||
this.lastUpdateCheck,
|
||||
this.pinned,
|
||||
this.trackOnly,
|
||||
this.enhancedVersionDetection);
|
||||
{this.categories = const [],
|
||||
this.releaseDate,
|
||||
this.changeLog,
|
||||
this.overrideSource});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned';
|
||||
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALSETTINGS: ${additionalSettings.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned';
|
||||
}
|
||||
|
||||
factory App.fromJson(Map<String, dynamic> json) => App(
|
||||
json['id'] as String,
|
||||
json['url'] as String,
|
||||
json['author'] as String,
|
||||
json['name'] as String,
|
||||
json['installedVersion'] == null
|
||||
? null
|
||||
: json['installedVersion'] as String,
|
||||
json['latestVersion'] as String,
|
||||
json['apkUrls'] == null
|
||||
? []
|
||||
: List<String>.from(jsonDecode(json['apkUrls'])),
|
||||
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
|
||||
json['additionalData'] == null
|
||||
? SourceProvider()
|
||||
.getSource(json['url'])
|
||||
.additionalSourceAppSpecificDefaults
|
||||
: List<String>.from(jsonDecode(json['additionalData'])),
|
||||
json['lastUpdateCheck'] == null
|
||||
? null
|
||||
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
|
||||
json['pinned'] ?? false,
|
||||
json['trackOnly'] ?? false,
|
||||
json['enhancedVersionDetection'] ?? false);
|
||||
String? get overrideName =>
|
||||
additionalSettings['appName']?.toString().trim().isNotEmpty == true
|
||||
? additionalSettings['appName']
|
||||
: null;
|
||||
|
||||
String get finalName {
|
||||
return overrideName ?? name;
|
||||
}
|
||||
|
||||
App deepCopy() => App(
|
||||
id,
|
||||
url,
|
||||
author,
|
||||
name,
|
||||
installedVersion,
|
||||
latestVersion,
|
||||
apkUrls,
|
||||
preferredApkIndex,
|
||||
Map.from(additionalSettings),
|
||||
lastUpdateCheck,
|
||||
pinned,
|
||||
categories: categories,
|
||||
changeLog: changeLog,
|
||||
releaseDate: releaseDate,
|
||||
overrideSource: overrideSource);
|
||||
|
||||
factory App.fromJson(Map<String, dynamic> json) {
|
||||
json = appJSONCompatibilityModifiers(json);
|
||||
return App(
|
||||
json['id'] as String,
|
||||
json['url'] as String,
|
||||
json['author'] as String,
|
||||
json['name'] as String,
|
||||
json['installedVersion'] == null
|
||||
? null
|
||||
: json['installedVersion'] as String,
|
||||
json['latestVersion'] as String,
|
||||
assumed2DlistToStringMapList(jsonDecode(json['apkUrls'])),
|
||||
json['preferredApkIndex'] as int,
|
||||
jsonDecode(json['additionalSettings']) as Map<String, dynamic>,
|
||||
json['lastUpdateCheck'] == null
|
||||
? null
|
||||
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
|
||||
json['pinned'] ?? false,
|
||||
categories: json['categories'] != null
|
||||
? (json['categories'] as List<dynamic>)
|
||||
.map((e) => e.toString())
|
||||
.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,
|
||||
overrideSource: json['overrideSource']);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
@ -104,18 +251,24 @@ class App {
|
||||
'name': name,
|
||||
'installedVersion': installedVersion,
|
||||
'latestVersion': latestVersion,
|
||||
'apkUrls': jsonEncode(apkUrls),
|
||||
'apkUrls': jsonEncode(stringMapListTo2DList(apkUrls)),
|
||||
'preferredApkIndex': preferredApkIndex,
|
||||
'additionalData': jsonEncode(additionalData),
|
||||
'additionalSettings': jsonEncode(additionalSettings),
|
||||
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
|
||||
'pinned': pinned,
|
||||
'trackOnly': trackOnly,
|
||||
'enhancedVersionDetection': enhancedVersionDetection
|
||||
'categories': categories,
|
||||
'releaseDate': releaseDate?.microsecondsSinceEpoch,
|
||||
'changeLog': changeLog,
|
||||
'overrideSource': overrideSource
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure the input is starts with HTTPS and has no WWW
|
||||
preStandardizeUrl(String url) {
|
||||
var firstDotIndex = url.indexOf('.');
|
||||
if (!(firstDotIndex >= 0 && firstDotIndex != url.length - 1)) {
|
||||
throw UnsupportedURLError();
|
||||
}
|
||||
if (url.toLowerCase().indexOf('http://') != 0 &&
|
||||
url.toLowerCase().indexOf('https://') != 0) {
|
||||
url = 'https://$url';
|
||||
@ -131,7 +284,7 @@ preStandardizeUrl(String url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const String noAPKFound = 'No APK found';
|
||||
String noAPKFound = tr('noAPKFound');
|
||||
|
||||
List<String> getLinksFromParsedHTML(
|
||||
Document dom, RegExp hrefPattern, String prependToLinks) =>
|
||||
@ -144,53 +297,146 @@ List<String> getLinksFromParsedHTML(
|
||||
.map((e) => '$prependToLinks${e.attributes['href']!}')
|
||||
.toList();
|
||||
|
||||
class AppSource {
|
||||
late String host;
|
||||
Map<String, dynamic> getDefaultValuesFromFormItems(
|
||||
List<List<GeneratedFormItem>> items) {
|
||||
return Map.fromEntries(items
|
||||
.map((row) => row.map((el) => MapEntry(el.key, el.defaultValue ?? '')))
|
||||
.reduce((value, element) => [...value, ...element]));
|
||||
}
|
||||
|
||||
List<MapEntry<String, String>> getApkUrlsFromUrls(List<String> urls) =>
|
||||
urls.map((e) {
|
||||
var segments = e.split('/').where((el) => el.trim().isNotEmpty);
|
||||
var apkSegs = segments.where((s) => s.toLowerCase().endsWith('.apk'));
|
||||
return MapEntry(apkSegs.isNotEmpty ? apkSegs.last : segments.last, e);
|
||||
}).toList();
|
||||
|
||||
abstract class AppSource {
|
||||
String? host;
|
||||
bool hostChanged = false;
|
||||
late String name;
|
||||
bool enforceTrackOnly = false;
|
||||
String standardizeURL(String url) {
|
||||
bool changeLogIfAnyIsMarkDown = true;
|
||||
bool appIdInferIsOptional = false;
|
||||
|
||||
AppSource() {
|
||||
name = runtimeType.toString();
|
||||
}
|
||||
|
||||
overrideVersionDetectionFormDefault(String vd, bool disableStandard) {
|
||||
additionalAppSpecificSourceAgnosticSettingFormItems =
|
||||
additionalAppSpecificSourceAgnosticSettingFormItems.map((e) {
|
||||
return e.map((e2) {
|
||||
if (e2.key == 'versionDetection') {
|
||||
var item = e2 as GeneratedFormDropdown;
|
||||
item.defaultValue = vd;
|
||||
if (disableStandard) {
|
||||
item.disabledOptKeys = ['standardVersionDetection'];
|
||||
}
|
||||
}
|
||||
return e2;
|
||||
}).toList();
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String standardizeUrl(String url) {
|
||||
url = preStandardizeUrl(url);
|
||||
if (!hostChanged) {
|
||||
url = sourceSpecificStandardizeURL(url);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
Map<String, String>? get requestHeaders => null;
|
||||
|
||||
Future<Response> sourceRequest(String url) async {
|
||||
if (requestHeaders != null) {
|
||||
var req = Request('GET', Uri.parse(url));
|
||||
req.headers.addAll(requestHeaders!);
|
||||
return Response.fromStream(await Client().send(req));
|
||||
} else {
|
||||
return get(Uri.parse(url));
|
||||
}
|
||||
}
|
||||
|
||||
String sourceSpecificStandardizeURL(String url) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
String standardUrl, Map<String, dynamic> additionalSettings) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
// Different Sources may need different kinds of additional data for Apps
|
||||
List<List<GeneratedFormItem>> additionalSourceAppSpecificFormItems = [];
|
||||
List<String> additionalSourceAppSpecificDefaults = [];
|
||||
List<List<GeneratedFormItem>> additionalSourceAppSpecificSettingFormItems =
|
||||
[];
|
||||
|
||||
// Some additional data may be needed for Apps regardless of Source
|
||||
final List<GeneratedFormItem> additionalAppSpecificSourceAgnosticFormItems = [
|
||||
GeneratedFormItem(
|
||||
List<List<GeneratedFormItem>>
|
||||
additionalAppSpecificSourceAgnosticSettingFormItems = [
|
||||
[
|
||||
GeneratedFormSwitch(
|
||||
'trackOnly',
|
||||
label: tr('trackOnly'),
|
||||
type: FormItemType.bool,
|
||||
key: 'trackOnlyFormItemKey')
|
||||
)
|
||||
],
|
||||
[
|
||||
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);
|
||||
}
|
||||
])
|
||||
],
|
||||
[
|
||||
GeneratedFormSwitch('autoApkFilterByArch',
|
||||
label: tr('autoApkFilterByArch'), defaultValue: true)
|
||||
],
|
||||
[GeneratedFormTextField('appName', label: tr('appName'), required: false)]
|
||||
];
|
||||
final List<String> additionalAppSpecificSourceAgnosticDefaults = [''];
|
||||
|
||||
// Previous 2 variables combined into one at runtime for convenient usage
|
||||
List<List<GeneratedFormItem>> get combinedAppSpecificSettingFormItems {
|
||||
return [
|
||||
...additionalSourceAppSpecificSettingFormItems,
|
||||
...additionalAppSpecificSourceAgnosticSettingFormItems
|
||||
];
|
||||
}
|
||||
|
||||
// Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider
|
||||
List<GeneratedFormItem> additionalSourceSpecificSettingFormItems = [];
|
||||
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) {
|
||||
throw NotImplementedError();
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
|
||||
Future<String> apkUrlPrefetchModifier(
|
||||
String apkUrl, String standardUrl) async {
|
||||
return apkUrl;
|
||||
}
|
||||
|
||||
bool canSearch = false;
|
||||
Future<Map<String, String>> search(String query) {
|
||||
Future<Map<String, List<String>>> search(String query) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
String? tryInferringAppId(String standardUrl) {
|
||||
Future<String?> tryInferringAppId(String standardUrl,
|
||||
{Map<String, dynamic> additionalSettings = const {}}) async {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -200,54 +446,93 @@ ObtainiumError getObtainiumHttpError(Response res) {
|
||||
tr('errorWithHttpStatusCode', args: [res.statusCode.toString()]));
|
||||
}
|
||||
|
||||
String? extractStandardVersionName(String version, {bool strict = false}) {
|
||||
var match = RegExp(
|
||||
'${strict ? '^' : ''}[0-9]+(\\.[0-9]+)+(-(alpha|beta|ocs)([0-9]+|\\+[0-9]+)?)?${strict ? '\$' : ''}')
|
||||
.firstMatch(version);
|
||||
return match != null ? version.substring(match.start, match.end) : null;
|
||||
}
|
||||
|
||||
abstract class MassAppUrlSource {
|
||||
late String name;
|
||||
late List<String> requiredArgs;
|
||||
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
|
||||
Future<Map<String, List<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 = [
|
||||
GitHub(),
|
||||
GitLab(),
|
||||
FDroid(),
|
||||
IzzyOnDroid(),
|
||||
Mullvad(),
|
||||
Signal(),
|
||||
SourceForge(),
|
||||
APKMirror()
|
||||
];
|
||||
List<AppSource> get sources => [
|
||||
GitHub(),
|
||||
GitLab(),
|
||||
Codeberg(),
|
||||
FDroid(),
|
||||
IzzyOnDroid(),
|
||||
FDroidRepo(),
|
||||
Jenkins(),
|
||||
SourceForge(),
|
||||
SourceHut(),
|
||||
APKMirror(),
|
||||
APKPure(),
|
||||
// APKCombo(), // Can't get past their scraping blocking yet (get 403 Forbidden)
|
||||
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
|
||||
];
|
||||
|
||||
// Add more mass url source classes here so they are available via the service
|
||||
List<MassAppUrlSource> massUrlSources = [GitHubStars()];
|
||||
|
||||
AppSource getSource(String url) {
|
||||
AppSource getSource(String url, {String? overrideSource}) {
|
||||
url = preStandardizeUrl(url);
|
||||
if (overrideSource != null) {
|
||||
var srcs =
|
||||
sources.where((e) => e.runtimeType.toString() == overrideSource);
|
||||
if (srcs.isEmpty) {
|
||||
throw UnsupportedURLError();
|
||||
}
|
||||
var res = srcs.first;
|
||||
res.host = Uri.parse(url).host;
|
||||
res.hostChanged = true;
|
||||
return srcs.first;
|
||||
}
|
||||
AppSource? source;
|
||||
for (var s in sources) {
|
||||
if (url.toLowerCase().contains('://${s.host}')) {
|
||||
for (var s in sources.where((element) => element.host != null)) {
|
||||
if (RegExp('://${s.host}(/|\\z)?').hasMatch(url)) {
|
||||
source = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (source == null) {
|
||||
for (var s in sources.where((element) => element.host == null)) {
|
||||
try {
|
||||
s.sourceSpecificStandardizeURL(url);
|
||||
source = s;
|
||||
break;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
}
|
||||
if (source == null) {
|
||||
throw UnsupportedURLError();
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
bool ifSourceAppsRequireAdditionalData(AppSource source) {
|
||||
for (var row in source.additionalSourceAppSpecificFormItems) {
|
||||
bool ifRequiredAppSpecificSettingsExist(AppSource source) {
|
||||
for (var row in source.combinedAppSpecificSettingFormItems) {
|
||||
for (var element in row) {
|
||||
if (element.required) {
|
||||
if (element is GeneratedFormTextField && element.required) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -255,73 +540,98 @@ 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: RegEx won't work for non-eng chars
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return sources.map((e) => e.host).contains(parts.last);
|
||||
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, List<String> additionalData,
|
||||
{String name = '',
|
||||
String? id,
|
||||
bool pinned = false,
|
||||
bool trackOnly = false,
|
||||
String? installedVersion}) async {
|
||||
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
||||
AppNames names = source.getAppNames(standardUrl);
|
||||
APKDetails apk = await source
|
||||
.getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly);
|
||||
Future<App> getApp(
|
||||
AppSource source, String url, Map<String, dynamic> additionalSettings,
|
||||
{App? currentApp,
|
||||
bool trackOnlyOverride = false,
|
||||
String? overrideSource,
|
||||
bool inferAppIdIfOptional = false}) async {
|
||||
if (trackOnlyOverride || source.enforceTrackOnly) {
|
||||
additionalSettings['trackOnly'] = true;
|
||||
}
|
||||
var trackOnly = additionalSettings['trackOnly'] == true;
|
||||
String standardUrl = source.standardizeUrl(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.key)).toList();
|
||||
}
|
||||
if (apk.apkUrls.isEmpty && !trackOnly) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
bool enhancedVersionDetection = apk.isStandardVersion &&
|
||||
installedVersion != null &&
|
||||
extractStandardVersionName(installedVersion, strict: true) != null;
|
||||
if (!enhancedVersionDetection) {
|
||||
apk.version = apk.versionFromSource;
|
||||
if (apk.apkUrls.length > 1 &&
|
||||
additionalSettings['autoApkFilterByArch'] == true) {
|
||||
var abis = (await DeviceInfoPlugin().androidInfo).supportedAbis;
|
||||
for (var abi in abis) {
|
||||
var urls2 = apk.apkUrls
|
||||
.where((element) => RegExp('.*$abi.*').hasMatch(element.key))
|
||||
.toList();
|
||||
if (urls2.isNotEmpty && urls2.length < apk.apkUrls.length) {
|
||||
apk.apkUrls = urls2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
String apkVersion = apk.version.replaceAll('/', '-');
|
||||
var name = currentApp != null ? currentApp.name.trim() : '';
|
||||
name = name.isNotEmpty
|
||||
? name
|
||||
: apk.names.name[0].toUpperCase() + apk.names.name.substring(1);
|
||||
return App(
|
||||
id ??
|
||||
source.tryInferringAppId(standardUrl) ??
|
||||
generateTempID(names, source),
|
||||
currentApp?.id ??
|
||||
((!source.appIdInferIsOptional ||
|
||||
(source.appIdInferIsOptional && inferAppIdIfOptional))
|
||||
? await source.tryInferringAppId(standardUrl,
|
||||
additionalSettings: additionalSettings)
|
||||
: null) ??
|
||||
generateTempID(standardUrl, additionalSettings),
|
||||
standardUrl,
|
||||
names.author[0].toUpperCase() + names.author.substring(1),
|
||||
name.trim().isNotEmpty
|
||||
? name
|
||||
: names.name[0].toUpperCase() + names.name.substring(1),
|
||||
installedVersion,
|
||||
apk.names.author[0].toUpperCase() + apk.names.author.substring(1),
|
||||
name,
|
||||
currentApp?.installedVersion,
|
||||
apkVersion,
|
||||
apk.apkUrls,
|
||||
apk.apkUrls.length - 1,
|
||||
additionalData,
|
||||
apk.apkUrls.length - 1 >= 0 ? apk.apkUrls.length - 1 : 0,
|
||||
additionalSettings,
|
||||
DateTime.now(),
|
||||
pinned,
|
||||
trackOnly,
|
||||
enhancedVersionDetection);
|
||||
currentApp?.pinned ?? false,
|
||||
categories: currentApp?.categories ?? const [],
|
||||
releaseDate: apk.releaseDate,
|
||||
changeLog: apk.changeLog,
|
||||
overrideSource: overrideSource ?? currentApp?.overrideSource);
|
||||
}
|
||||
|
||||
// Returns errors in [results, errors] instead of throwing them
|
||||
Future<List<dynamic>> getAppsByURLNaive(List<String> urls,
|
||||
{List<String> ignoreUrls = const []}) async {
|
||||
{List<String> alreadyAddedUrls = const []}) async {
|
||||
List<App> apps = [];
|
||||
Map<String, dynamic> errors = {};
|
||||
for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
|
||||
for (var url in urls) {
|
||||
try {
|
||||
if (alreadyAddedUrls.contains(url)) {
|
||||
throw ObtainiumError(tr('appAlreadyAdded'));
|
||||
}
|
||||
var source = getSource(url);
|
||||
apps.add(await getApp(
|
||||
source, url, source.additionalSourceAppSpecificDefaults));
|
||||
source,
|
||||
url,
|
||||
getDefaultValuesFromFormItems(
|
||||
source.combinedAppSpecificSettingFormItems)));
|
||||
} catch (e) {
|
||||
errors.addAll(<String, dynamic>{url: e});
|
||||
}
|
||||
|
560
pubspec.lock
34
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.8.3+66 # When changing this, update the tag in main() accordingly
|
||||
version: 0.13.7+171 # When changing this, update the tag in main() accordingly
|
||||
|
||||
environment:
|
||||
sdk: '>=2.18.2 <3.0.0'
|
||||
@ -38,32 +38,38 @@ dependencies:
|
||||
cupertino_icons: ^1.0.5
|
||||
path_provider: ^2.0.11
|
||||
flutter_fgbg: ^0.2.0 # Try removing reliance on this
|
||||
flutter_local_notifications: ^12.0.0
|
||||
flutter_local_notifications: ^14.0.0+1
|
||||
provider: ^6.0.3
|
||||
http: ^0.13.5
|
||||
webview_flutter: ^3.0.4
|
||||
http: ^1.0.0
|
||||
webview_flutter: ^4.0.0
|
||||
dynamic_color: ^1.5.4
|
||||
html: ^0.15.0
|
||||
shared_preferences: ^2.0.15
|
||||
url_launcher: ^6.1.5
|
||||
permission_handler: ^10.0.0
|
||||
fluttertoast: ^8.0.9
|
||||
device_info_plus: ^8.0.0
|
||||
file_picker: ^5.1.0
|
||||
device_info_plus: ^9.0.0
|
||||
file_picker: ^5.2.10
|
||||
animations: ^2.0.4
|
||||
install_plugin_v2: ^1.0.0
|
||||
share_plus: ^6.0.1
|
||||
android_package_installer:
|
||||
git:
|
||||
url: https://github.com/ImranR98/android_package_installer
|
||||
ref: main
|
||||
share_plus: ^7.0.0
|
||||
installed_apps: ^1.3.1
|
||||
package_archive_info: ^0.1.0
|
||||
android_alarm_manager_plus: ^2.1.0
|
||||
android_alarm_manager_plus: ^3.0.0
|
||||
sqflite: ^2.2.0+3
|
||||
easy_localization: ^3.0.1
|
||||
android_intent_plus: ^4.0.0
|
||||
flutter_markdown: ^0.6.14
|
||||
archive: ^3.3.7
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_launcher_icons: ^0.11.0
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
|
||||
# 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,11 +78,9 @@ dev_dependencies:
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^2.0.1
|
||||
|
||||
flutter_icons:
|
||||
android: true
|
||||
flutter_launcher_icons:
|
||||
android: "ic_launcher"
|
||||
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 +100,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
|
||||
|