Compare commits

...

157 Commits

Author SHA1 Message Date
1be38d361f Merge pull request #272 from ImranR98/dev
No longer blocking App ID changes in updates
2023-02-12 14:20:04 -05:00
32c40ae7b3 No longer blocking App ID changes in updates 2023-02-12 14:16:19 -05:00
07223d81c7 Merge pull request #268 from gidano/main
Updated hu.json
2023-02-11 12:02:41 -05:00
78baee7265 Updated hu.json 2023-02-11 11:41:52 +01:00
348c33dfe9 Merge pull request #266 from rollingmoai/patch-1
Add installation badges
2023-02-10 20:36:38 -05:00
c408d70ae6 Merge pull request #260 from atilluF/ita
Update Italian translation
2023-02-10 20:23:13 -05:00
3ae4e7cc8a Merge pull request #259 from bluefly000/japanese-translation
Update Japanese translation
2023-02-10 20:23:06 -05:00
dab0f2bb72 Add installation badges 2023-02-09 22:13:16 +08:00
4baf6bcd3b Update it.json 2023-02-05 12:10:59 +01:00
db4517aa13 Update Japanese translation 2023-02-05 12:23:30 +09:00
55d4d1f978 Merge pull request #258 from ImranR98/dev
Removed unused commented code
2023-02-04 20:06:57 -05:00
f89ac5965f Removed unused commented code 2023-02-04 20:06:22 -05:00
d5ebaa161f Merge pull request #257 from ImranR98/dev
Remove unused class
2023-02-04 20:05:39 -05:00
a4c014a8bf Remove unused class 2023-02-04 20:05:20 -05:00
bbaa42fb01 Merge pull request #256 from ImranR98/dev
Bugfixes (#254, #252), App Uninstall and Settings Feature (#257)
2023-02-04 19:46:24 -05:00
4fe311bc03 Update packages, increment version 2023-02-04 19:44:18 -05:00
ea68b97ff7 Mark updated feature more clear 2023-02-04 19:30:41 -05:00
6e0f6b528e Added App settings button 2023-02-04 19:11:28 -05:00
a2c227931e Added uninstall option 2023-02-04 18:58:14 -05:00
15ad3bb439 Removed unnecessary repetitive log 2023-02-04 17:21:39 -05:00
b03d7fba1a Fix permission error on Android 10 #252 2023-02-04 17:07:59 -05:00
31c491d7c5 Fix prev. commit. 2023-02-04 16:50:33 -05:00
71c80f11f5 'Fix' for GlobalKey error #254 2023-02-04 12:30:34 -05:00
eef4d33431 Merge pull request #246 from ImranR98/dev
Bugfixes for #242 and #245 + Various UI Improvements
2023-01-29 17:35:18 -05:00
d56342e907 Merge pull request #243 from bluefly000/japanese-translation
Update Japanese translation
2023-01-29 17:32:09 -05:00
c72c0fdb57 Increment version 2023-01-29 17:31:19 -05:00
ffe29009ed URL select modal now works when tapping text 2023-01-29 17:29:41 -05:00
60e3b68ebd Search allows option changes (no direct add) 2023-01-29 17:23:35 -05:00
ee4d0f259f Generated form bugfix (initState not running) - #245 2023-01-29 17:07:11 -05:00
0ecfbef0a0 Update Japanese translation 2023-01-29 17:28:54 +09:00
1b60e75ca7 Added delay after Obtainium install prompt 2023-01-28 20:59:17 -05:00
abcfa389e8 Merge pull request #241 from ImranR98/dev
Updated screenshots
2023-01-28 00:47:26 -05:00
a64bd67ef1 Updated screenshots 2023-01-28 00:46:54 -05:00
4252c2711b Merge pull request #240 from ImranR98/dev
APK RegEx Filter, Increased GitHub/Codeberg Release Range, UI Tweaks
Addresses #237, #238.
2023-01-28 00:17:10 -05:00
52913b0450 Slight UI tweak 2023-01-28 00:15:52 -05:00
427b0ed8d2 Changed a string 2023-01-28 00:13:03 -05:00
a85d6d4f08 Increment version, remove comment 2023-01-28 00:11:40 -05:00
05f712603c GitHub & Codeberg - get first 100 releases (not 30) 2023-01-28 00:08:17 -05:00
fa2a80e34c APK RegEx Filter + Updated Packages 2023-01-28 00:04:57 -05:00
f43e5a2ff1 Merge pull request #235 from ImranR98/dev
Increment version, update packages
2023-01-22 19:55:35 -05:00
b72aa8273e Increment version, update packages 2023-01-22 19:55:14 -05:00
520f186e4a Merge pull request #234 from p1gp1g/themed-icon
Add themed icon for Android 13
2023-01-22 19:53:43 -05:00
sim
e1e97672cf Add themed icon for Android 13 2023-01-23 01:09:14 +01:00
1494bcd013 Merge pull request #232 from ImranR98/dev
GitHub (and Codeberg) bugfix (#231)
2023-01-20 12:49:35 -05:00
3457a0a12f GitHub (and Codeberg) bugfix (#231) 2023-01-20 12:48:55 -05:00
b165400a6e Merge pull request #229 from ImranR98/dev
Increment version
2023-01-15 11:45:05 -05:00
c47bf937f1 Increment version 2023-01-15 11:44:45 -05:00
2e19a8c04c Merge pull request #228 from gidano/main
Update hu.json
2023-01-15 11:42:28 -05:00
05d4da86ec Update hu.json 2023-01-15 17:39:57 +01:00
e9d1b04d54 Merge pull request #227 from ImranR98/dev
Increment version, upgrade packages
2023-01-15 11:20:45 -05:00
cff5334c25 Increment version, upgrade packages 2023-01-15 11:20:30 -05:00
a55346fc22 Merge pull request #226 from bluefly000/japanese-translation
Update Japanese translation
2023-01-15 11:19:01 -05:00
885df678e5 Update Japanese translation 2023-01-13 13:34:57 +09:00
bf7b0c5702 Merge pull request #225 from ImranR98/dev
2 New Sources: Codeberg and HTML Fallback
2023-01-12 22:33:50 -05:00
2972da4609 Upgraded packages 2023-01-12 22:28:47 -05:00
b8567af98e Increment version 2023-01-12 22:24:52 -05:00
ea62c68b40 Added the HTML fallback Source 2023-01-12 22:23:53 -05:00
08a5af0449 Added Codeberg as a Source + search UI bugfix 2023-01-12 20:57:53 -05:00
36f327c16e Merge pull request #220 from ImranR98/dev
- Obtainium would skip installing APKs that had the same [`versionCode`](https://developer.android.com/studio/publish/versioning#versioningsettings) since this number should be different for each new build of an App.
    - However, there are enough Apps that don't do this (#149, #219) so Obtainium now installs updates even if the `versionCode` has not changed.
- The GitHub release title filter has also been improved so that it filters by `tag_name` instead of `title` for releases with empty titles (as it seems like GitHub automatically displays the tag as the title in such cases).
2023-01-07 16:58:58 -05:00
768213cb34 Increment version 2023-01-07 16:50:01 -05:00
e888fb7120 Don't skip installing same-versionCode updates 2023-01-07 16:49:38 -05:00
1fb68dd674 GitHub release filter bugfix 2023-01-07 16:18:26 -05:00
5c4bb8f84c Merge pull request #217 from ImranR98/dev
Bugfixes and UI Tweaks (#213, #215, #216)
2023-01-06 21:11:58 -05:00
1c8e759494 Increment version + updated packages 2023-01-06 21:11:13 -05:00
081c2a07d2 Categories re-added on import (#213) 2023-01-06 21:10:04 -05:00
02751fe8fa Made GitHub PATs hidden (password field) (#215) 2023-01-06 20:57:26 -05:00
95f3362a84 Apps bottom bar tweaks (#216) 2023-01-06 20:47:22 -05:00
b68cf5a1be Increment version 2023-01-02 02:05:35 -05:00
4eb7499591 Merge pull request #211 from RanTranslations/main
assets: Update Simplified Chinese
2023-01-02 02:04:34 -05:00
98fafe2aa4 assets: Update Simplified Chinese 2023-01-02 11:18:27 +08:00
9bac74aadd Icon fixed in readme 2022-12-28 06:42:42 -05:00
0a93117bf0 Merge pull request #208 from ImranR98/dev
Tiny bugfix + increment version
2022-12-28 06:31:42 -05:00
451cc41c45 Tiny bugfix + increment version 2022-12-28 06:30:58 -05:00
3b449d0982 Merge pull request #207 from ImranR98/dev
Categorization Improvements
2022-12-27 22:39:17 -05:00
1863f55372 Increment build num 2022-12-27 22:38:07 -05:00
0c4b8ac79d Made notif icon white for consistency on some OS skins 2022-12-27 22:37:49 -05:00
e287087753 Increment build number 2022-12-27 21:15:06 -05:00
82bcc46d42 Fixed search error on Add App page (#202) 2022-12-27 21:14:11 -05:00
1f26188ec6 Potential fix for rangeError for no URL Apps (#201) 2022-12-27 21:00:46 -05:00
794c3e1a81 Increment version 2022-12-27 20:42:21 -05:00
16369b4adf App page with Webview now on par with no webview
+ ratelimit error bugfix
2022-12-27 20:41:44 -05:00
8f16f745be Added categorize in multi select menu 2022-12-27 20:15:56 -05:00
8ddeb3d776 Apps now support multiple categories 2022-12-27 19:37:13 -05:00
21cf9c98d9 Merge pull request #200 from ImranR98/dev
Fixed export error on Android SDK <= 28
2022-12-25 22:30:47 -05:00
358f910d19 Increment version 2022-12-25 22:30:01 -05:00
7a3d74bd05 Fixed export error on Android SDK <= 28 2022-12-25 22:29:39 -05:00
6f27f64699 Merge pull request #199 from ImranR98/dev
UI improvements
2022-12-25 21:56:36 -05:00
3341fecb68 Increment version 2022-12-25 21:53:26 -05:00
d3bce63ca4 Updated plugins 2022-12-25 21:53:06 -05:00
8aa8b6b698 Added selection count on Apps page 2022-12-25 21:52:21 -05:00
3d6c9bbf98 Added category multi-select to Apps filter
+ UI tweaks and bugfixes
2022-12-25 21:41:51 -05:00
7af0a8628c Slightly thicker category color indicator on apps page 2022-12-25 20:31:20 -05:00
4573ce6bcf Added category select to add app page 2022-12-25 20:30:36 -05:00
e29d38fa32 Adding an existing category no longer overwrites it 2022-12-25 20:04:47 -05:00
dc82431235 App page now scrollable when categories overflow 2022-12-25 19:58:58 -05:00
424b0028bf Merge pull request #198 from gidano/main
Update hu.json
2022-12-25 15:36:26 -05:00
46fba9e0a4 Update hu.json 2022-12-25 11:14:15 +01:00
b40be7569b Bugfix (#197) 2022-12-24 23:17:03 -05:00
a173be11eb Merge pull request #193 from ImranR98/dev
Track-only source bugfix +  better http errors
2022-12-23 23:53:08 -05:00
0c97b25d99 Track-only source bugfix + better http errors
+ increment version
2022-12-23 23:52:32 -05:00
f836fd20d8 Increment version 2022-12-22 17:43:08 -05:00
2f6917592d Merge pull request #190 from atilluF/Ita-TL
Update it.json
2022-12-22 17:39:47 -05:00
b864fef3ad Update it.json
New strings + fixes
2022-12-22 22:48:53 +01:00
8e487592b3 Increment version 2022-12-22 11:58:00 -05:00
e9a44746a5 Merge pull request #184 from gidano/main
Updated hu.json
2022-12-22 11:57:16 -05:00
9123737bf3 Merge branch 'main' into main 2022-12-22 14:58:25 +01:00
12f70951c2 Merge pull request #186 from bluefly000/japanese-translation
Update Japanese translation
2022-12-22 08:03:03 -05:00
c1d56f89f0 Merge pull request #187 from markus-gitdev/main
Update DE translation
2022-12-22 08:02:57 -05:00
4dfd29f5de Merge pull request #189 from ImranR98/dev
Bugfixes
2022-12-22 08:02:21 -05:00
226cfa25e0 Increment version 2022-12-22 08:01:52 -05:00
4e0c655538 F-Droid repo URL matching made more general (#188) 2022-12-22 08:01:26 -05:00
45a23e9025 Language fix for #185 2022-12-22 07:57:21 -05:00
1e5aa0999a Update DE translation
Update german translation to match newly added localized strings
2022-12-22 10:21:04 +01:00
beeec356e5 Update Japanese translation 2022-12-22 18:03:20 +09:00
01fa9a2e96 Updated hu.json 2022-12-22 09:18:32 +01:00
0da7a36f1a Merge pull request #183 from ImranR98/dev
Better Category UI + Language Setting
2022-12-22 03:14:43 -05:00
ed2a4e674f Added language setting (mostly working) - #165 2022-12-22 03:13:55 -05:00
0f6a683faa Increment version 2022-12-22 02:26:13 -05:00
fa4d46b622 Bugfix es+ new category picker on App page 2022-12-22 02:13:21 -05:00
a3f9947f28 Finished new category editor (needs to be used) 2022-12-22 01:24:35 -05:00
6977858b99 Started work on new unified category selector/editor 2022-12-21 23:54:36 -05:00
2ff6acb701 Merge pull request #182 from ImranR98/dev
Broke `GeneratedFormItem` into sub-types + bugfix
2022-12-21 18:26:15 -05:00
0c2d6ce84d Increment version 2022-12-21 18:23:55 -05:00
9072862862 Broke GeneratedFormItem into sub-types
Prep for "chips" input type
2022-12-21 18:23:25 -05:00
3cbaac2f5d Increment version 2022-12-21 15:08:18 -05:00
0f8871efcb Merge pull request #179 from bluefly000/japanese-translation
Update Japanese translation
2022-12-21 15:07:33 -05:00
ee216cbbba Merge pull request #181 from ImranR98/dev
Bugfix (#178) + translation typos
2022-12-21 15:07:26 -05:00
ebe5b79dc5 Bugfix (#178) + translation typos 2022-12-21 15:06:54 -05:00
60014c864c Update Japanese translation 2022-12-21 21:48:32 +09:00
070b6033bd Merge pull request #177 from ImranR98/dev
Added very basic categorization support
2022-12-21 04:25:45 -05:00
626bebbe5a Localized new strings 2022-12-21 04:24:17 -05:00
118460ccb9 Added category filter 2022-12-21 04:15:39 -05:00
26f953dbb0 Category displayed on App/Apps pages
+ category save bugfix
2022-12-21 03:57:08 -05:00
99d7595f2d Added category add/remove (no recolour/rename for now) 2022-12-21 03:08:56 -05:00
e2f99c5e71 Increment version 2022-12-20 21:12:50 -05:00
1f582d239b Merge pull request #175 from RanTranslations/main
assests: Update Simplified Chinese
2022-12-20 21:10:48 -05:00
5e6b00718e Merge pull request #176 from bluefly000/main
Update Japanese translation
2022-12-20 21:10:26 -05:00
56594e6b19 Update Japanese translation 2022-12-21 10:44:18 +09:00
bbcc3ff9b3 assests: Update Simplified Chinese 2022-12-21 09:07:31 +08:00
ee66c53320 Updated plugins 2022-12-20 19:06:49 -05:00
b7d581f8b0 GitHub prereleases now not included by default 2022-12-20 18:48:54 -05:00
ead63ba21d Translation typos 2022-12-20 18:41:02 -05:00
c69404363f Increment version 2022-12-20 18:34:47 -05:00
99d0bd2461 Merge pull request #173 from atilluF/main
Fix Italian translation
2022-12-20 18:33:55 -05:00
54efda3eea Merge pull request #171 from markus-gitdev/main
Improving german translation
2022-12-20 18:33:47 -05:00
d76d68329c Merge pull request #174 from ImranR98/dev
"Additional Settings" related code changes for maintainability + other changes
2022-12-20 18:32:46 -05:00
b151eb27e1 Translations + bugfix 2022-12-20 18:19:44 -05:00
6a21045e5b Progress 2022-12-20 18:00:22 -05:00
6aedd9ce37 Update it.json (small fixes) 2022-12-20 18:48:33 +01:00
f319639a99 Merge branch 'ImranR98:main' into main 2022-12-20 08:58:43 +01:00
92e6798809 Update de.json
Abbreviating some texts to provide a better appearance.
2022-12-20 08:56:46 +01:00
9a129d41df Added migration code for additionalData (NOTHING TESTED) 2022-12-19 20:14:54 -05:00
0c2654a226 More fixes to prev commit 2022-12-19 19:58:12 -05:00
afc8e41171 Made defaultvallue part of formitem 2022-12-19 19:48:37 -05:00
1fe9e4f91e Started switching additionaldata to map 2022-12-19 19:34:43 -05:00
dbd6dec0a6 Merge remote-tracking branch 'origin/main' into dev 2022-12-19 16:19:59 -05:00
63034dd3f9 Added 'no version detection' option 2022-12-18 02:46:25 -05:00
47 changed files with 2772 additions and 1431 deletions

View File

@ -1,4 +1,4 @@
# ![Obtainium Icon](./android/app/src/main/res/drawable/ic_notification.png) Obtainium
# ![Obtainium Icon](./assets/graphics/icon_small.png) Obtainium
Get Android App Updates Directly From the Source.
@ -9,14 +9,27 @@ 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/)
- [APKMirror](https://apkmirror.com/) (Track-Only)
- Third Party F-Droid Repos (URLs ending with `/fdroid/repo`)
- Third Party F-Droid Repos
- Any URLs ending with `/fdroid/<word>`, where `<word>` can be anything - most often `repo`
- [Steam](https://store.steampowered.com/mobile)
- "HTML" (Fallback)
- Any other URL that returns an HTML page with links to APK files (if multiple, the last file alphabetically is picked)
## Installation
[<img src="https://github.com/machiav3lli/oandbackupx/blob/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png"
alt="Get it on GitHub"
height="80">](https://github.com/ImranR98/Obtainium/releases)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
alt="Get it on IzzyOnDroid"
height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.imranr.obtainium)
## Limitations
- App installs happen asynchronously and the success/failure of an install cannot be determined directly. This results in install statuses and versions sometimes being out of sync with the OS until the next launch or until the problem is manually corrected.
@ -27,4 +40,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" /> |

View File

@ -3,7 +3,8 @@
<application
android:label="Obtainium"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
android:exported="true"
@ -51,4 +52,8 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29"/>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

View File

@ -4,7 +4,6 @@
"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",
@ -23,7 +22,7 @@
"githubPATLinkText": "Über GitHub PATs",
"includePrereleases": "Vorabversionen einbeziehen",
"fallbackToOlderReleases": "Fallback auf ältere Versionen",
"filterReleaseTitlesByRegEx": "Release-Titel nach regulärem Ausdruck filtern",
"filterReleaseTitlesByRegEx": "Release-Titel nach regulärem Ausdruck\nfiltern",
"invalidRegEx": "Ungültiger regulärer Ausdruck",
"noDescription": "Keine Beschreibung",
"cancel": "Abbrechen",
@ -43,7 +42,7 @@
"cancelled": "Abgebrochen",
"appAlreadyAdded": "App bereits hinzugefügt",
"alreadyUpToDateQuestion": "App bereits auf dem neuesten Stand?",
"addApp": "App hinzugefügt",
"addApp": "App hinzufügen",
"appSourceURL": "Quell-URL der App",
"error": "Fehler",
"add": "Hinzufügen",
@ -74,7 +73,6 @@
"changeX": "Ändern {}",
"installUpdateApps": "Apps installieren/aktualisieren",
"installUpdateSelectedApps": "Ausgewählte Apps installieren/aktualisieren",
"onlyWorksWithNonEVDApps": "Funktioniert nur bei Apps, deren Installationsstatus nicht automatisch erkannt werden kann (ungewöhnlich).",
"markXSelectedAppsAsUpdated": "Markiere {} ausgewählte Apps als aktuell?",
"no": "Nein",
"yes": "Ja",
@ -92,7 +90,7 @@
"filterActive": "Filter *",
"filterApps": "Apps filtern",
"appName": "App Name",
"author": "Ersteller",
"author": "Autor",
"upToDateApps": "Apps mit aktueller Version",
"nonInstalledApps": "Nicht installierte Apps",
"importExport": "Import/Export",
@ -134,8 +132,8 @@
"neverManualOnly": "Nie - nur manuell",
"appearance": "Aussehen",
"showWebInAppView": "Quellwebseite in der App-Ansicht anzeigen",
"pinUpdates": "Aktualisierungen in der App-Ansicht oben anheften",
"updates": "Aktualisiert",
"pinUpdates": "Apps mit Aktualisierungen oben anheften",
"updates": "Aktualisierungen",
"sourceSpecific": "Quellenspezifisch",
"appSource": "App-Quelle",
"noLogs": "Keine Protokolle",
@ -178,7 +176,6 @@
"installedVersionX": "Installierte Version: {}",
"lastUpdateCheckX": "Letzte Aktualisierungsprüfung: {}",
"remove": "Entfernen",
"removeAppQuestion": "App entfernen?",
"yesMarkUpdated": "Ja, als aktualisiert markieren",
"fdroid": "F-Droid",
"appIdOrName": "App ID oder Name",
@ -188,6 +185,37 @@
"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",
"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.",
"removeAppQuestion": {
"one": "App entfernen?",
"other": "App entfernen?"
},
"tooManyRequestsTryAgainInMinutes": {
"one": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minute erneut",
"other": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minuten erneut"

View File

@ -4,7 +4,6 @@
"noVersionFound": "Could not determine release version",
"urlMatchesNoSource": "URL does not match a known source",
"cantInstallOlderVersion": "Cannot install an older version of an App",
"appIdMismatch": "Downloaded package ID does not match existing App ID",
"functionNotImplemented": "This class has not implemented this function",
"placeholder": "Placeholder",
"someErrors": "Some Errors Occurred",
@ -37,7 +36,7 @@
"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",
@ -74,7 +73,6 @@
"changeX": "Change {}",
"installUpdateApps": "Install/Update Apps",
"installUpdateSelectedApps": "Install/Update Selected Apps",
"onlyWorksWithNonEVDApps": "Only works for Apps whose install status cannot be automatically detected (uncommon).",
"markXSelectedAppsAsUpdated": "Mark {} Selected Apps as Updated?",
"no": "No",
"yes": "Yes",
@ -135,7 +133,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",
@ -178,7 +176,6 @@
"installedVersionX": "Installed Version: {}",
"lastUpdateCheckX": "Last Update Check: {}",
"remove": "Remove",
"removeAppQuestion": "Remove App?",
"yesMarkUpdated": "Yes, Mark as Updated",
"fdroid": "F-Droid",
"appIdOrName": "App ID or Name",
@ -188,6 +185,37 @@
"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",
"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.",
"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"

View File

@ -4,7 +4,6 @@
"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",
@ -13,10 +12,10 @@
"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 BG frissítés ellenőrzése",
"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 a erre: {}",
"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",
@ -28,16 +27,16 @@
"noDescription": "Nincs leírás",
"cancel": "Mégse",
"continue": "Tovább",
"requiredInBrackets": "(Kötlező)",
"dropdownNoOptsError": "HIBA: A LEDOBÁST LEGALÁBB EGY OPCIÓBAN KELL RENDELNI",
"requiredInBrackets": "(Kötelező)",
"dropdownNoOptsError": "HIBA: A LEDOBÁST LEGALÁBB EGY OPCIÓHOZ KELL RENDELNI",
"colour": "Szín",
"githubStarredRepos": "GitHub Csillagozott Repo-k",
"githubStarredRepos": "GitHub Csillagos Repo-k",
"uname": "Felh.név",
"wrongArgNum": "Rossz számú argumentumot adott meg",
"xIsTrackOnly": "{} csak nyomon követhető",
"xIsTrackOnly": "A(z) {} csak nyomkövethető",
"source": "Forrás",
"app": "App",
"appsFromSourceAreTrackOnly": "Az ebből a forrásból származó alkalmazások 'Csak nyomon követhetőek'.' ",
"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",
@ -56,25 +55,24 @@
"appsString": "Appok",
"noApps": "Nincs App",
"noAppsForFilter": "Nincsenek appok a szűrőhöz",
"byX": "By {}",
"byX": "{} által",
"percentProgress": "Folyamat: {}%",
"pleaseWait": "Kis türelmet",
"updateAvailable": "Frissítés elérhető",
"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": "{} el lesz távolítva az Obtainiumból, de továbbra is telepítve marad az eszközön.",
"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ő)\nas Frissítve",
"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",
"onlyWorksWithNonEVDApps": "Csak azoknál az alkalmazásoknál működik, amelyek telepítési állapota nem észlelhető automatikusan (nem gyakori).",
"markXSelectedAppsAsUpdated": "Megjelöl {} kiválasztott alkalmazást frissítettként?",
"no": "Nem",
"yes": "Igen",
@ -86,8 +84,8 @@
"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 alkalmazásszűrőt",
"showOutdatedOnly": "Csak az elavult alkalmazások megjelenítése",
"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",
@ -118,7 +116,7 @@
"selectURLs": "Kiválasztott URL-ek",
"pick": "Válasszon",
"theme": "Téma",
"dark": "Söét",
"dark": "Sötét",
"light": "Világos",
"followSystem": "Rendszer szerint",
"obtainium": "Obtainium",
@ -126,16 +124,16 @@
"appSortBy": "App rendezés...",
"authorName": "Szerző/Név",
"nameAuthor": "Név/Szerző",
"asAdded": "Mint hozzáadott",
"asAdded": "Mint Hozzáadott",
"appSortOrder": "Appok rendezése",
"ascending": "Emelkedő",
"descending": "Csökkenő",
"bgUpdateCheckInterval": "Háttérfrissítés ellenőrzési időköz",
"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ítve",
"updates": "Frissítések",
"sourceSpecific": "Forrás-specifikus",
"appSource": "App forrás",
"noLogs": "Nincsenek naplók",
@ -145,7 +143,7 @@
"appNotFound": "App nem található",
"obtainiumExportHyphenatedLowercase": "obtainium-export",
"pickAnAPK": "Válasszon egy APK-t",
"appHasMoreThanOnePackage": "{} egynél több csomaggal rendelkezik:",
"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",
@ -153,16 +151,16 @@
"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": "{} frissítést kapott.",
"xHasAnUpdate": "A(z) {} frissítést kapott.",
"appsUpdated": "Alkalmazások frissítve",
"appsUpdatedNotifDescription": "Értesíti a felhasználót, hogy egy vagy több app frissítése történt a háttérben",
"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 alkalmazástelepítés",
"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",
@ -178,16 +176,45 @@
"installedVersionX": "Telepített verzió: {}",
"lastUpdateCheckX": "Frissítés ellenőrizve: {}",
"remove": "Eltávolítás",
"removeAppQuestion": "Eltávolítja az alkalmazást?",
"yesMarkUpdated": "Igen, megjelölés frissítettként",
"fdroid": "F-Droid",
"appIdOrName": "App ID vagy név",
"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",
"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",
"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.",
"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"

View File

@ -4,7 +4,6 @@
"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",
@ -16,38 +15,38 @@
"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": "Imposta l'intervallo di aggiornamento a {}",
"githubPATLabel": "GitHub Personal Access Token (aumenta il limite di traffico)",
"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",
"githubPATLinkText": "Informazioni su GitHub PAT",
"includePrereleases": "Includi prerelease",
"fallbackToOlderReleases": "Ripiega su release datate",
"filterReleaseTitlesByRegEx": "Filtra le release con le espressioni regolari",
"invalidRegEx": "Espressione regolare invalida",
"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)",
"requiredInBrackets": "(richiesto)",
"dropdownNoOptsError": "ERRORE: LA TENDINA DEVE AVERE ALMENO UN'OPZIONE",
"colour": "Colore",
"githubStarredRepos": "i repository stellati da GitHub",
"githubStarredRepos": "repository stellati da GitHub",
"uname": "Username",
"wrongArgNum": "Numero di argomenti forniti errato",
"xIsTrackOnly": "{} è Solo-Monitoraggio",
"xIsTrackOnly": "{} è in modalità Solo-Monitoraggio",
"source": "Fonte",
"app": "App",
"appsFromSourceAreTrackOnly": "Le App da questa fonte sono in modalità 'Solo-Monitoraggio'.",
"youPickedTrackOnly": "Hai selezionato l'opzione '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": "App già aggiornata?",
"alreadyUpToDateQuestion": "L'App è già aggiornata?",
"addApp": "Aggiungi App",
"appSourceURL": "URL della fonte dell'App",
"error": "Errore",
"add": "Aggiungi",
"searchSomeSourcesLabel": "Cerca (disponibile solo per alcune fonti)",
"searchSomeSourcesLabel": "Cerca (solo per alcune fonti)",
"search": "Cerca",
"additionalOptsFor": "Opzioni aggiuntive per {}",
"supportedSourcesBelow": "Fonti supportate:",
@ -60,21 +59,20 @@
"percentProgress": "Progresso: {}%",
"pleaseWait": "Attendere prego",
"updateAvailable": "Aggiornamento disponibile",
"estimateInBracketsShort": "(Prev.)",
"estimateInBracketsShort": "(prev.)",
"notInstalled": "Non installato",
"estimateInBrackets": "(Previsto)",
"estimateInBrackets": "(previsto)",
"selectAll": "Seleziona tutto",
"deselectN": "Deseleziona {}",
"xWillBeRemovedButRemainInstalled": "{} sarà rimosso da Obtainium ma resterà installato sul dispositivo.",
"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 le App",
"installUpdateSelectedApps": "Installa/Aggiornale le App selezionate",
"onlyWorksWithNonEVDApps": "Funziona solo per le App il cui stato d'installazione non può essere rilevato automaticamente (inconsueto).",
"changeX": "Modifica {}",
"installUpdateApps": "Installa/Aggiorna App",
"installUpdateSelectedApps": "Installa/Aggiorna le App selezionate",
"markXSelectedAppsAsUpdated": "Contrassegnare le {} App selezionate come aggiornate?",
"no": "No",
"yes": "Sì",
@ -86,8 +84,8 @@
"shareSelectedAppURLs": "Condividi gli URL delle App selezionate",
"resetInstallStatus": "Ripristina lo stato d'installazione",
"more": "Di più",
"removeOutdatedFilter": "Rimuovi il filtro per le App datate",
"showOutdatedOnly": "Mostra solo le App datate",
"removeOutdatedFilter": "Rimuovi il filtro per le App non aggiornate",
"showOutdatedOnly": "Mostra solo le App non aggiornate",
"filter": "Filtri",
"filterActive": "Filtri *",
"filterApps": "Filtra App",
@ -95,7 +93,7 @@
"author": "Autore",
"upToDateApps": "App aggiornate",
"nonInstalledApps": "App non installate",
"importExport": "Importa/Esporta",
"importExport": "Importa - Esporta",
"settings": "Impostazioni",
"exportedTo": "Esportato in {}",
"obtainiumExport": "Esporta da Obtainium",
@ -106,7 +104,7 @@
"searchQuery": "Stringa di ricerca",
"appURLList": "Lista di URL delle App",
"line": "Linea",
"searchX": "Cerca {}",
"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.",
@ -120,7 +118,7 @@
"theme": "Tema",
"dark": "Scuro",
"light": "Chiaro",
"followSystem": "Segui il sistema",
"followSystem": "Segui sistema",
"obtainium": "Obtainium",
"materialYou": "Material You",
"appSortBy": "App ordinate per",
@ -133,10 +131,10 @@
"bgUpdateCheckInterval": "Intervallo di controllo degli aggiornamenti in background",
"neverManualOnly": "Mai - Solo manuale",
"appearance": "Aspetto",
"showWebInAppView": "Mostra la pagina web dell'App se selezionata",
"pinUpdates": "Fissa in alto gli aggiornamenti disponibili nella pagina delle App",
"updates": "Aggiornato",
"sourceSpecific": "Specifico per la fonte",
"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",
@ -146,21 +144,21 @@
"obtainiumExportHyphenatedLowercase": "esportazione-obtainium",
"pickAnAPK": "Seleziona un APK",
"appHasMoreThanOnePackage": "{} offre più di un pacchetto:",
"deviceSupportsXArch": "Il tuo dispositivo supporta l'architettura {} della CPU.",
"deviceSupportsFollowingArchs": "Il tuo dispositivo supporta le seguenti architetture della CPU:",
"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": "Avvisa l'utente che sono disponibili gli aggiornamenti di una o più App monitorate da Obtainium",
"updatesAvailableNotifDescription": "Notifica all'utente che sono disponibili gli aggiornamenti di una o più App monitorate da Obtainium",
"noNewUpdates": "Nessun nuovo aggiornamento.",
"xHasAnUpdate": "{} è stato aggiornato.",
"xHasAnUpdate": "Aggiornamento disponibile per {}",
"appsUpdated": "App aggiornate",
"appsUpdatedNotifDescription": "Avvisa l'utente che una o più App sono state aggiornate in background",
"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": "Avvisa l'utente che una o più App sono state rimosse a causa di errori durante il caricamento",
"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",
@ -178,27 +176,57 @@
"installedVersionX": "Versione installata: {}",
"lastUpdateCheckX": "Ultimo controllo degli aggiornamenti: {}",
"remove": "Rimuovi",
"removeAppQuestion": "Rimuovere App?",
"yesMarkUpdated": "Sì, contrassegna come aggiornato",
"fdroid": "F-Droid",
"appIdOrName": "ID o nome dell'App",
"appWithIdOrNameNotFound": "Non è stata trovata alcuna App con quell'ID o nome",
"reposHaveMultipleApps": "I repository possono contenere più App",
"fdroidThirdPartyRepo": "Repository di terze parti di F-Droid",
"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",
"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.",
"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 {}, ricontrollo tra {} minuto",
"other": "Il controllo degli aggiornamenti in background ha incontrato un {}, ricontrollo tra {} minuti"
"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 - avviserà l'utento se necessario",
"other": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamenti - avviserà l'utento se necessario"
"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",

View File

@ -4,19 +4,18 @@
"noVersionFound": "リリースバージョンを特定できませんでした",
"urlMatchesNoSource": "URLが既知のソースと一致しません",
"cantInstallOlderVersion": "旧バージョンのアプリをインストールできません",
"appIdMismatch": "ダウンロードしたパッケージのIDが既存のApp IDと一致しません",
"functionNotImplemented": "このクラスはこの機能を実装していません",
"placeholder": "プレースホルダー",
"someErrors": "いくつかのエラーが発生しました",
"someErrors": "何らかのエラーが発生しました",
"unexpectedError": "予期せぬエラーが発生しました",
"ok": "OK",
"and": "と",
"startedBgUpdateTask": "バックグラウンドのアップデート確認タスクを開始",
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
"startedActualBGUpdateCheck": "実際のバックグラウンドのアップデート確認を開始",
"bgUpdateTaskFinished": "バックグラウンドのアップデート確認タスクを終了",
"firstRun": "これがObtainiumの最初の実行です",
"settingUpdateCheckIntervalTo": "更新間隔を{}に設定する",
"settingUpdateCheckIntervalTo": "確認間隔を{}に設定する",
"githubPATLabel": "GitHub パーソナルアクセストークン (レート制限の引き上げ)",
"githubPATHint": "PATは次の形式でなければなりません: ユーザー名:トークン",
"githubPATFormat": "ユーザー名:トークン",
@ -27,18 +26,18 @@
"invalidRegEx": "無効な正規表現",
"noDescription": "説明はありません",
"cancel": "キャンセル",
"continue": "続ける",
"continue": "続",
"requiredInBrackets": "(必須)",
"dropdownNoOptsError": "エラー: ドロップダウンには、少なくとも1つのoptが必要です",
"dropdownNoOptsError": "エラー: ドロップダウンには、少なくとも1つのオプションが必要です",
"colour": "カラー",
"githubStarredRepos": "Githubでスターしたリポジトリ",
"uname": "ユーザー名",
"wrongArgNum": "提供する引数の数が間違っています",
"xIsTrackOnly": "{} は'追跡のみ'です",
"xIsTrackOnly": "{} は追跡のみです",
"source": "ソース",
"app": "アプリ",
"appsFromSourceAreTrackOnly": "このソースからのアプリは'追跡のみ'です'。",
"youPickedTrackOnly": "'追跡のみ'を選択しています",
"appsFromSourceAreTrackOnly": "このソースからのアプリは追跡のみです。",
"youPickedTrackOnly": "追跡のみを選択しています",
"trackOnlyAppDescription": "アプリのアップデートは追跡されますが、Obtainiumはアプリのダウンロードやインストールをすることはできません。",
"cancelled": "キャンセルしました",
"appAlreadyAdded": "アプリはすでに追加されています",
@ -64,17 +63,16 @@
"notInstalled": "未インストール",
"estimateInBrackets": "(推定)",
"selectAll": "すべて選択",
"deselectN": "{}件選択解除",
"xWillBeRemovedButRemainInstalled": "{}はObtainiumから削除されますが、デバイスにはインストールされたままです。",
"deselectN": "{}件選択解除",
"xWillBeRemovedButRemainInstalled": "{} はObtainiumから削除されますが、デバイスにはインストールされたままです。",
"removeSelectedAppsQuestion": "選択したアプリを削除しますか?",
"removeSelectedApps": "選択したアプリを削除する",
"updateX": "{}をアップデートする",
"installX": "{}をインストールする",
"updateX": "{} をアップデートする",
"installX": "{} をインストールする",
"markXTrackOnlyAsUpdated": "{}\n(追跡のみ)\nをアップデート済みとしてマークする",
"changeX": "{}を変更する",
"changeX": "{} を変更する",
"installUpdateApps": "アプリのインストール/アップデート",
"installUpdateSelectedApps": "選択したアプリのインストール/アップデート",
"onlyWorksWithNonEVDApps": "インストール状況を自動検出できないアプリ(一般的でないもの)のみ動作します。",
"markXSelectedAppsAsUpdated": "{}個の選択したアプリをアップデート済みとしてマークしますか?",
"no": "いいえ",
"yes": "はい",
@ -82,7 +80,7 @@
"pinToTop": "トップに固定",
"unpinFromTop": "トップから固定解除",
"resetInstallStatusForSelectedAppsQuestion": "選択したアプリのインストール状態をリセットしますか?",
"installStatusOfXWillBeResetExplanation": "選択したアプリのインストール状態がリセットされます。\n\nアップデートに失敗するなどして、Obtainiumに表示されるアプリのバージョンが正しくない場合に役立ちます。",
"installStatusOfXWillBeResetExplanation": "選択したアプリのインストール状態がリセットされます。\n\nアップデートに失敗した場合など、Obtainiumに表示されるアプリのバージョンが正しくない場合に有効です。",
"shareSelectedAppURLs": "選択したアプリのURLを共有する",
"resetInstallStatus": "インストール状態をリセットする",
"more": "もっと見る",
@ -109,7 +107,7 @@
"searchX": "{}で検索",
"noResults": "結果は見つかりませんでした",
"importX": "{}をインポートする",
"importedAppsIdDisclaimer": "インポートしたアプリが「未インストール」と表示されることがあります。\nこれを解決するには、Obtainiumから再インストールしてください。\nアプリのデータには影響しません。\n\nURLとサードパーティのインポートメソッドにのみ影響します。",
"importedAppsIdDisclaimer": "インポートしたアプリが「未インストール」と表示されることがあります。\nこれを解決するには、Obtainiumから再インストールしてください。\nアプリのデータには影響しません。\n\nURLとサードパーティのインポートメソッドにのみ影響します。",
"importErrors": "インポートエラー",
"importedXOfYApps": "{} / {} アプリをインポートしました",
"followingURLsHadErrors": "以下のURLでエラーが発生しました:",
@ -124,18 +122,18 @@
"obtainium": "Obtainium",
"materialYou": "Material You",
"appSortBy": "アプリの並び方",
"authorName": "作者/アプリ名",
"nameAuthor": "アプリ名/作者",
"authorName": "作者/アプリ名",
"nameAuthor": "アプリ名/作者",
"asAdded": "追加順",
"appSortOrder": "並び順",
"ascending": "昇順",
"descending": "降順",
"bgUpdateCheckInterval": "バックグラウンド更新の確認間隔",
"bgUpdateCheckInterval": "バックグラウンドでのアップデート確認間隔",
"neverManualOnly": "手動",
"appearance": "外観",
"showWebInAppView": "アプリビューにソースウェブページを表示する",
"showWebInAppView": "アプリページにソースのWebページを表示する",
"pinUpdates": "アップデートがあるアプリをトップに固定する",
"updates": "更新",
"updates": "アップデート",
"sourceSpecific": "Github アクセストークン",
"appSource": "アプリのソース",
"noLogs": "ログはありません",
@ -144,24 +142,24 @@
"share": "共有",
"appNotFound": "アプリが見つかりません",
"obtainiumExportHyphenatedLowercase": "obtainium-エクスポート",
"pickAnAPK": "APKを選",
"appHasMoreThanOnePackage": "{}は複数のパッケージが存在します: ",
"deviceSupportsXArch": "お使いのデバイスは{} CPUアーキテクチャに対応しています。",
"pickAnAPK": "APKを選",
"appHasMoreThanOnePackage": "{} は複数のパッケージが存在します: ",
"deviceSupportsXArch": "お使いのデバイスは {} CPUアーキテクチャに対応しています。",
"deviceSupportsFollowingArchs": "お使いのデバイスは、以下のCPUアーキテクチャをサポートしています:",
"warning": "警告",
"sourceIsXButPackageFromYPrompt": "アプリのソースは'{}'ですが、リリースパッケージは'{}'から来ています。続行しますか?",
"updatesAvailable": "アップデートが利用可能",
"updatesAvailableNotifDescription": "Obtainiumが追跡している1つまたは複数のアプリのアップデートが利用可能であることをユーザーに通知する",
"noNewUpdates": "新しいアップデートはありません",
"xHasAnUpdate": "{}のアップデートが利用可能です",
"noNewUpdates": "新しいアップデートはありません",
"xHasAnUpdate": "{} のアップデートが利用可能です",
"appsUpdated": "アプリをアップデートしました",
"appsUpdatedNotifDescription": "1つまたは複数のAppのアップデートがバックグラウンドで適用されたことをユーザーに通知する",
"xWasUpdatedToY": "{}{}にアップデートされました",
"xWasUpdatedToY": "{}{} にアップデートされました",
"errorCheckingUpdates": "アップデート確認中のエラー",
"errorCheckingUpdatesNotifDescription": "バックグラウンドでのアップデート確認に失敗した際に表示される通知",
"appsRemoved": "削除されたアプリ",
"appsRemovedNotifDescription": "アプリの読み込み中にエラーが発生したため、1つまたは複数のアプリが削除されたことをユーザーに通知する",
"xWasRemovedDueToErrorY": "このエラーのため、{}は削除されました: {}",
"xWasRemovedDueToErrorY": "このエラーのため、{} は削除されました: {}",
"completeAppInstallation": "アプリのインストールを完了する",
"obtainiumMustBeOpenToInstallApps": "アプリをインストールするにはObtainiumを開いている必要があります。",
"completeAppInstallationNotifDescription": "アプリのインストールを完了するために、Obtainiumに戻る必要があります。",
@ -173,32 +171,62 @@
"versionCorrectionDisabled": "バージョン補正無効 (プラグインが動作していません)",
"unknown": "不明",
"none": "なし",
"never": "Never",
"never": "しない",
"latestVersionX": "最新のバージョン: {}",
"installedVersionX": "インストールされたバージョン: {}",
"lastUpdateCheckX": "最終アップデート確認: {}",
"remove": "削除",
"removeAppQuestion": "アプリを削除しますか?",
"yesMarkUpdated": "はい、アップデート済みとしてマークします",
"fdroid": "F-Droid",
"appIdOrName": "アプリIDまたは名前",
"appIdOrName": "アプリIDまたは名前",
"appWithIdOrNameNotFound": "そのIDや名前を持つアプリは見つかりませんでした",
"reposHaveMultipleApps": "リポジトリには複数のアプリが含まれることがあります",
"fdroidThirdPartyRepo": "F-Droid Third-Party Repo",
"fdroidThirdPartyRepo": "F-Droid サードパーティリポジトリ",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"install": "インストール",
"markInstalled": "インストール済みとしてマークする",
"update": "アップデート",
"markUpdated": "アップデート済みとしてマークする",
"additionalOptions": "追加のオプション",
"disableVersionDetection": "バージョン検出を無効にする",
"noVersionDetectionExplanation": "このオプションは、バージョン検出が正しく機能しないアプリにのみ使用する必要があります。",
"downloadingX": "{} をダウンロード中",
"downloadNotifDescription": "アプリのダウンロード状況を通知する",
"noAPKFound": "APKが見つかりません",
"noVersionDetection": "バージョン検出を行わない",
"categorize": "カテゴライズ",
"categories": "カテゴリ",
"category": "カテゴリ",
"noCategory": "カテゴリなし",
"noCategories": "カテゴリなし",
"deleteCategoriesQuestion": "カテゴリを削除しますか?",
"categoryDeleteWarning": "削除されたカテゴリ内のアプリは未分類に設定されます。",
"addCategory": "カテゴリを追加",
"label": "ラベル",
"language": "言語",
"storagePermissionDenied": "ストレージ権限が拒否されました",
"selectedCategorizeWarning": "これにより、選択したアプリの既存のカテゴリ設定がすべて置き換えられます。",
"filterAPKsByRegEx": "正規表現でAPKを絞り込む",
"removeFromObtainium": "Obtainiumから削除する",
"uninstallFromDevice": "デバイスからアンインストールする",
"onlyWorksWithNonVersionDetectApps": "バージョン検出を無効にしているアプリにのみ動作します。",
"removeAppQuestion": {
"one": "アプリを削除しますか?",
"other": "アプリを削除しますか?"
},
"tooManyRequestsTryAgainInMinutes": {
"one": "リクエストが多すぎます(レート制限)- {}分後に再試行してください",
"other": "リクエストが多すぎます(レート制限)- {}分後に再試行してください"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "バックグラウンドのアップデート確認で {} の問題が発生, {} 分後に再試行します",
"other": "バックグラウンドのアップデート確認で {} の問題が発生, {} 分後に再試行します"
"one": "バックグラウンドのアップデート確認で {} の問題が発生, {} 分後に再試行します",
"other": "バックグラウンドのアップデート確認で {} の問題が発生, {} 分後に再試行します"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "バックグラウンドのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します",
"other": "バックグラウンドのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します"
"one": "バックグラウンドのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します",
"other": "バックグラウンドのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します"
},
"apps": {
"one": "{}個のアプリ",
@ -225,11 +253,11 @@
"other": "{n}個のログをクリアしました (前 = {before}, 後 = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{}とさらに{}個のアプリのアップデートが利用可能です",
"other": "{}とさらに{}個のアプリのアップデートが利用可能です"
"one": "{} とさらに {} 個のアプリのアップデートが利用可能です",
"other": "{} とさらに {} 個のアプリのアップデートが利用可能です"
},
"xAndNMoreUpdatesInstalled": {
"one": "{}とさらに{}個のアプリがアップデートされました",
"other": "{}とさらに{}個のアプリがアップデートされました"
"one": "{} とさらに {} 個のアプリがアップデートされました",
"other": "{} とさらに {} 個のアプリがアップデートされました"
}
}
}

View File

@ -4,7 +4,6 @@
"noVersionFound": "无法确定更新版本",
"urlMatchesNoSource": "URL 与已知来源不符",
"cantInstallOlderVersion": "无法安装旧版应用程序",
"appIdMismatch": "下载的软件包名与现有的应用程序包名不一致",
"functionNotImplemented": "该类没有实现此功能",
"placeholder": "占位符",
"someErrors": "出现了一些错误",
@ -12,7 +11,7 @@
"ok": "好的",
"and": "和",
"startedBgUpdateTask": "开始后台检查更新任务",
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
"bgUpdateIgnoreAfterIs": "下次后台更新检查 {}",
"startedActualBGUpdateCheck": "后台检查更新已开始",
"bgUpdateTaskFinished": "后台检查更新已完成",
"firstRun": "这是你第一次运行 Obtainium",
@ -22,9 +21,9 @@
"githubPATFormat": "username:token",
"githubPATLinkText": "关于 GitHub 个人访问令牌",
"includePrereleases": "包含预发布版",
"fallbackToOlderReleases": "回到旧版",
"filterReleaseTitlesByRegEx": "通过正则表达式过滤发布标题",
"invalidRegEx": "无效的正则表达式",
"fallbackToOlderReleases": "回退到旧版",
"filterReleaseTitlesByRegEx": "使用正则以过滤发布标题",
"invalidRegEx": "表达式无效",
"noDescription": "无描述",
"cancel": "取消",
"continue": "继续",
@ -109,7 +108,7 @@
"searchX": "搜索 {}",
"noResults": "无结果",
"importX": "导入 {}",
"importedAppsIdDisclaimer": "导入的应用程序可能不正确地显示为未安装。要解决这个问题,请通过 Obtainium 重新安装它们。",
"importedAppsIdDisclaimer": "导入的应用程序可能显示为未安装。要解决这个问题,请通过 Obtainium 重新安装它们。",
"importErrors": "导入错误",
"importedXOfYApps": "{} 中的 {} 个应用已导入",
"followingURLsHadErrors": "以下 URL 有错误:",
@ -134,7 +133,7 @@
"neverManualOnly": "手动",
"appearance": "外观",
"showWebInAppView": "在应用来源页显示网页",
"pinUpdates": "将需要更新的应用固定到顶部",
"pinUpdates": "更新的应用置顶",
"updates": "检查间隔",
"sourceSpecific": "Github 访问令牌",
"appSource": "源代码",
@ -145,9 +144,9 @@
"appNotFound": "未找到应用",
"obtainiumExportHyphenatedLowercase": "obtainium-导出",
"pickAnAPK": "选择一个安装包",
"appHasMoreThanOnePackage": "{} 有多于一个安装包",
"deviceSupportsXArch": "你的设备支持 {} CPU 架构",
"deviceSupportsFollowingArchs": "你的设备支持以下 CPU 架构:",
"appHasMoreThanOnePackage": "{} 有多个架构可用",
"deviceSupportsXArch": "你的设备支持 {} 架构",
"deviceSupportsFollowingArchs": "你的设备支持以下架构:",
"warning": "警告",
"sourceIsXButPackageFromYPrompt": "此应用来源是 '{}' 但更新包来自 '{}'。 继续吗?",
"updatesAvailable": "更新可用",
@ -178,7 +177,6 @@
"installedVersionX": "已安装: {}",
"lastUpdateCheckX": "最后检查: {}",
"remove": "删除",
"removeAppQuestion": "删除应用?",
"yesMarkUpdated": "'是的,标为已更新",
"fdroid": "F-Droid",
"appIdOrName": "应用 ID 或名称",
@ -188,6 +186,36 @@
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"install": "安装",
"markInstalled": "标记为已安装",
"update": "更新",
"markUpdated": "标记为已更新",
"additionalOptions": "附加选项",
"disableVersionDetection": "关闭版本检测",
"noVersionDetectionExplanation": "此选项应只用于版本检测不能工作的应用程序",
"downloadingX": "下载中 {}",
"downloadNotifDescription": "通知用户下载进度",
"noAPKFound": "未找到安装包",
"noVersionDetection": "无版本检测",
"categorize": "归档",
"categories": "归档",
"category": "类别",
"noCategory": "无类别",
"noCategories": "无类别",
"deleteCategoriesQuestion": "删除所有类别?",
"categoryDeleteWarning": "所有被删除类别的应用程序将被设置为无类别",
"addCategory": "添加类别",
"label": "标签",
"language": "语言",
"storagePermissionDenied": "存储权限已被拒绝",
"selectedCategorizeWarning": "这将取代所选应用程序的任何现有类别",
"filterAPKsByRegEx": "Filter APKs by Regular Expression",
"removeFromObtainium": "Remove from Obtainium",
"uninstallFromDevice": "Uninstall from Device",
"removeAppQuestion": {
"one": "删除应用?",
"other": "删除应用?"
},
"tooManyRequestsTryAgainInMinutes": {
"one": "请求过多 (API 限制) - 在 {} 分钟后重试",
"other": "请求过多 (API 限制) - 在 {} 分钟后重试"

View File

@ -25,8 +25,9 @@ class APKMirror extends AppSource {
@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('$standardUrl/feed'));
if (res.statusCode == 200) {
String? titleString = parse(res.body)
@ -45,7 +46,7 @@ class APKMirror extends AppSource {
}
return APKDetails(version, [], getAppNames(standardUrl));
} else {
throw NoReleasesError();
throw getObtainiumHttpError(res);
}
}

View File

@ -0,0 +1,149 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.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 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;
}
@override
String standardizeURL(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 {
bool includePrereleases = additionalSettings['includePrereleases'];
bool fallbackToOlderReleases =
additionalSettings['fallbackToOlderReleases'];
String? regexFilter =
(additionalSettings['filterReleaseTitlesByRegEx'] as String?)
?.isNotEmpty ==
true
? additionalSettings['filterReleaseTitlesByRegEx']
: null;
Response res = await get(Uri.parse(
'https://$host/api/v1/repos${standardUrl.substring('https://$host'.length)}/releases?per_page=100'));
if (res.statusCode == 200) {
var releases = jsonDecode(res.body) as List<dynamic>;
List<String> getReleaseAPKUrls(dynamic release) =>
(release['assets'] as List<dynamic>?)
?.map((e) {
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.key.toLowerCase().endsWith('.apk'))
.map((e) => e.value)
.toList() ??
[];
dynamic targetRelease;
for (int i = 0; i < releases.length; i++) {
if (!fallbackToOlderReleases && i > 0) break;
if (!includePrereleases && releases[i]['prerelease'] == true) {
continue;
}
if (releases[i]['draft'] == true) {
// Draft releases not supported
}
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(nameToFilter.trim())) {
continue;
}
var apkUrls = getReleaseAPKUrls(releases[i]);
if (apkUrls.isEmpty && additionalSettings['trackOnly'] != true) {
continue;
}
targetRelease = releases[i];
targetRelease['apkUrls'] = apkUrls;
break;
}
if (targetRelease == null) {
throw NoReleasesError();
}
String? version = targetRelease['tag_name'];
if (version == null) {
throw NoVersionError();
}
return APKDetails(version, targetRelease['apkUrls'] as List<String>,
getAppNames(standardUrl));
} else {
throw getObtainiumHttpError(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://$host/api/v1/repos/search?q=${Uri.encodeQueryComponent(query)}&limit=100'));
if (res.statusCode == 200) {
Map<String, String> urlsWithDescriptions = {};
for (var e in (jsonDecode(res.body)['data'] as List<dynamic>)) {
urlsWithDescriptions.addAll({
e['html_url'] as String: e['description'] != null
? e['description'] as String
: tr('noDescription')
});
}
return urlsWithDescriptions;
} else {
throw getObtainiumHttpError(res);
}
}
}

View File

@ -32,7 +32,7 @@ class FDroid extends AppSource {
@override
String? tryInferringAppId(String standardUrl,
{List<String> additionalData = const []}) {
{Map<String, dynamic> additionalSettings = const {}}) {
return Uri.parse(standardUrl).pathSegments.last;
}
@ -54,14 +54,15 @@ class FDroid extends AppSource {
return APKDetails(latestVersion, 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 standardUrl,
Map<String, dynamic> additionalSettings,
) async {
String? appId = tryInferringAppId(standardUrl);
return getAPKUrlsFromFDroidPackagesAPIResponse(
await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')),

View File

@ -9,13 +9,12 @@ class FDroidRepo extends AppSource {
FDroidRepo() {
name = tr('fdroidThirdPartyRepo');
additionalSourceAppSpecificFormItems = [
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormItem(
GeneratedFormTextField('appIdOrName',
label: tr('appIdOrName'),
hint: tr('reposHaveMultipleApps'),
required: true,
key: 'appIdOrName')
required: true)
]
];
}
@ -23,7 +22,7 @@ class FDroidRepo extends AppSource {
@override
String standardizeURL(String url) {
RegExp standardUrlRegExp =
RegExp('^https?://.+/fdroid/(repo(/|\\?)|repo\$)');
RegExp('^https?://.+/fdroid/([^/]+(/|\\?)|[^/]+\$)');
RegExpMatch? match = standardUrlRegExp.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
@ -33,13 +32,10 @@ class FDroidRepo extends AppSource {
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
String? appIdOrName = findGeneratedFormValueByKey(
additionalSourceAppSpecificFormItems
.reduce((value, element) => [...value, ...element]),
additionalData,
'appIdOrName');
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
String? appIdOrName = additionalSettings['appIdOrName'];
if (appIdOrName == null) {
throw NoReleasesError();
}
@ -84,7 +80,7 @@ class FDroidRepo extends AppSource {
.toList();
return APKDetails(latestVersion, apkUrls, AppNames(authorName, appName));
} else {
throw NoReleasesError();
throw getObtainiumHttpError(res);
}
}
}

View File

@ -12,12 +12,10 @@ class GitHub extends AppSource {
GitHub() {
host = 'github.com';
additionalSourceAppSpecificDefaults = ['true', 'true', ''];
additionalSourceSpecificSettingFormItems = [
GeneratedFormItem(
GeneratedFormTextField('github-creds',
label: tr('githubPATLabel'),
id: 'github-creds',
password: true,
required: false,
additionalValidators: [
(value) {
@ -52,31 +50,22 @@ class GitHub extends AppSource {
])
];
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);
}
])
]
@ -99,7 +88,7 @@ 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@' : '';
}
@ -109,17 +98,20 @@ class GitHub extends AppSource {
@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;
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
bool includePrereleases = additionalSettings['includePrereleases'];
bool fallbackToOlderReleases =
additionalSettings['fallbackToOlderReleases'];
String? regexFilter =
(additionalSettings['filterReleaseTitlesByRegEx'] as String?)
?.isNotEmpty ==
true
? additionalSettings['filterReleaseTitlesByRegEx']
: null;
Response res = await get(Uri.parse(
'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases?per_page=100'));
if (res.statusCode == 200) {
var releases = jsonDecode(res.body) as List<dynamic>;
@ -141,14 +133,17 @@ class GitHub extends AppSource {
if (!includePrereleases && releases[i]['prerelease'] == true) {
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];

View File

@ -25,8 +25,9 @@ class GitLab extends AppSource {
@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('$standardUrl/-/tags?format=atom'));
if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl);
@ -58,7 +59,7 @@ class GitLab extends AppSource {
}
return APKDetails(version, apkUrls, GitHub().getAppNames(standardUrl));
} else {
throw NoReleasesError();
throw getObtainiumHttpError(res);
}
}
}

47
lib/app_sources/html.dart Normal file
View File

@ -0,0 +1,47 @@
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 HTML extends AppSource {
@override
String standardizeURL(String url) {
return url;
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var uri = Uri.parse(standardUrl);
Response res = await get(uri);
if (res.statusCode == 200) {
List<String> links = parse(res.body)
.querySelectorAll('a')
.map((element) => element.attributes['href'] ?? '')
.where((element) => element.toLowerCase().endsWith('.apk'))
.toList();
links.sort((a, b) => a.split('/').last.compareTo(b.split('/').last));
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) => e.toLowerCase().startsWith('http://') ||
e.toLowerCase().startsWith('https://')
? e
: '${uri.origin}/$e')
.toList();
return APKDetails(version, apkUrls, AppNames(uri.host, tr('app')));
} else {
throw getObtainiumHttpError(res);
}
}
}

View File

@ -23,14 +23,15 @@ class IzzyOnDroid extends AppSource {
@override
String? tryInferringAppId(String standardUrl,
{List<String> additionalData = const []}) {
{Map<String, dynamic> additionalSettings = const {}}) {
return FDroid().tryInferringAppId(standardUrl);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
String? appId = tryInferringAppId(standardUrl);
return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(
await get(

View File

@ -24,8 +24,9 @@ class Mullvad extends AppSource {
@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('$standardUrl/en/download/android'));
if (res.statusCode == 200) {
var version = parse(res.body)
@ -42,7 +43,7 @@ class Mullvad extends AppSource {
['https://mullvad.net/download/app/apk/latest'],
AppNames(name, 'Mullvad-VPN'));
} else {
throw NoReleasesError();
throw getObtainiumHttpError(res);
}
}
}

View File

@ -18,8 +18,9 @@ class Signal extends AppSource {
@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'));
if (res.statusCode == 200) {
@ -32,7 +33,7 @@ class Signal extends AppSource {
}
return APKDetails(version, apkUrls, AppNames(name, 'Signal'));
} else {
throw NoReleasesError();
throw getObtainiumHttpError(res);
}
}
}

View File

@ -23,8 +23,9 @@ class SourceForge extends AppSource {
@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('$standardUrl/rss?path=/'));
if (res.statusCode == 200) {
var parsedHtml = parse(res.body);
@ -56,7 +57,7 @@ class SourceForge extends AppSource {
AppNames(
name, standardUrl.substring(standardUrl.lastIndexOf('/') + 1)));
} else {
throw NoReleasesError();
throw getObtainiumHttpError(res);
}
}
}

View File

@ -9,14 +9,8 @@ class SteamMobile extends AppSource {
SteamMobile() {
host = 'store.steampowered.com';
name = tr('steam');
additionalSourceAppSpecificFormItems = [
[
GeneratedFormItem(
label: tr('app'),
key: 'app',
required: true,
opts: apks.entries.toList())
]
additionalSourceAppSpecificSettingFormItems = [
[GeneratedFormDropdown('app', apks.entries.toList(), label: tr('app'))]
];
}
@ -32,15 +26,12 @@ class SteamMobile extends AppSource {
@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://$host/mobile'));
if (res.statusCode == 200) {
var apkNamePrefix = findGeneratedFormValueByKey(
additionalSourceAppSpecificFormItems
.reduce((value, element) => [...value, ...element]),
additionalData,
'app');
var apkNamePrefix = additionalSettings['app'] as String?;
if (apkNamePrefix == null) {
throw NoReleasesError();
}
@ -63,7 +54,7 @@ class SteamMobile extends AppSource {
var apkUrls = [links[0]];
return APKDetails(version, apkUrls, AppNames(name, apks[apkNamePrefix]!));
} else {
throw NoReleasesError();
throw getObtainiumHttpError(res);
}
}
}

View File

@ -1,121 +1,215 @@
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<MapEntry<String, 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'}) {
if (type != FormItemType.string) {
required = false;
}
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;
GeneratedFormDropdown(
String key,
this.opts, {
String label = 'Input',
List<Widget> belowWidgets = const [],
String defaultValue = '',
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.key
: '';
}).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;
@ -124,21 +218,20 @@ 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: e.value.label),
value: values[row.key][e.key],
items: e.value.opts!
.map((e) =>
DropdownMenuItem(value: e.key, child: Text(e.value)))
decoration: InputDecoration(labelText: formItem.label),
value: values[formItem.key],
items: formItem.opts!
.map((e2) =>
DropdownMenuItem(value: e2.key, child: Text(e2.value)))
.toList(),
onChanged: (value) {
setState(() {
values[row.key][e.key] = value ?? e.value.opts!.first.key;
values[formItem.key] = value ?? formItem.opts!.first.key;
someValueChanged();
});
});
@ -150,25 +243,214 @@ 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),
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'),
)),
],
)
]);
}
}
}
@ -178,9 +460,8 @@ 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
height: widget.items[rowInputs.key][0] is GeneratedFormSwitch &&
widget.items[rowInputs.key - 1][0] is! GeneratedFormSwitch
? 25
: 8,
)
@ -217,18 +498,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;
}

View File

@ -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()
],
);
}

View File

@ -13,13 +13,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 {
@ -32,7 +29,7 @@ class NoReleasesError extends ObtainiumError {
}
class NoAPKError extends ObtainiumError {
NoAPKError() : super(tr('noReleaseFound'));
NoAPKError() : super(tr('noAPKFound'));
}
class NoVersionError extends ObtainiumError {
@ -47,10 +44,6 @@ class DowngradeError extends ObtainiumError {
DowngradeError() : super(tr('cantInstallOlderVersion'));
}
class IDChangedError extends ObtainiumError {
IDChangedError() : super(tr('appIdMismatch'));
}
class NotImplementedError extends ObtainiumError {
NotImplementedError() : super(tr('functionNotImplemented'));
}

View File

@ -21,7 +21,7 @@ import 'package:easy_localization/src/easy_localization_controller.dart';
// ignore: implementation_imports
import 'package:easy_localization/src/localization.dart';
const String currentVersion = '0.8.21';
const String currentVersion = '0.10.8';
const String currentReleaseTag =
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@ -43,12 +43,16 @@ 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,
assetLoader: const RootBundleAssetLoader(),
useOnlyLangCode: false,
useOnlyLangCode: true,
useFallbackTranslations: true,
path: localeDir,
onLoadError: (FlutterError e) {
@ -160,6 +164,7 @@ void main() async {
supportedLocales: supportedLocales,
path: localeDir,
fallbackLocale: fallbackLocale,
useOnlyLangCode: true,
child: const Obtainium()),
));
}
@ -200,9 +205,8 @@ class _ObtainiumState extends State<Obtainium> {
currentReleaseTag,
[],
0,
['true'],
{'includePrereleases': true},
null,
false,
false)
]);
}

View File

@ -8,6 +8,7 @@ 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';
@ -23,39 +24,42 @@ class AddAppPage extends StatefulWidget {
class _AddAppPageState extends State<AddAppPage> {
bool gettingAppInfo = false;
bool searching = false;
String userInput = '';
String searchQuery = '';
AppSource? pickedSource;
List<String> sourceSpecificAdditionalData = [];
bool sourceSpecificDataIsValid = true;
List<String> otherAdditionalData = [];
bool otherAdditionalDataIsValid = true;
Map<String, dynamic> additionalSettings = {};
bool additionalSettingsValid = true;
List<String> pickedCategories = [];
int searchnum = 0;
@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.runtimeType != source.runtimeType) {
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 source = valid ? sourceProvider.getSource(userInput) : null;
if (pickedSource.runtimeType != source.runtimeType) {
pickedSource = source;
additionalSettings = source != null
? getDefaultValuesFromFormItems(
source.combinedAppSpecificSettingFormItems)
: {};
additionalSettingsValid = source != null
? !sourceProvider.ifRequiredAppSpecificSettingsExist(source)
: true;
}
});
}
}
@ -66,13 +70,12 @@ class _AddAppPageState extends State<AddAppPage> {
});
var settingsProvider = context.read<SettingsProvider>();
() async {
var userPickedTrackOnly = findGeneratedFormValueByKey(
pickedSource!.additionalAppSpecificSourceAgnosticFormItems,
otherAdditionalData,
'trackOnlyFormItemKey') ==
'true';
var userPickedTrackOnly = additionalSettings['trackOnly'] == true;
var userPickedNoVersionDetection =
additionalSettings['noVersionDetection'] == true;
var cont = true;
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
// ignore: use_build_context_synchronously
await showDialog(
context: context,
builder: (BuildContext ctx) {
@ -83,7 +86,6 @@ class _AddAppPageState extends State<AddAppPage> {
: tr('app')
]),
items: const [],
defaultValues: const [],
message:
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
);
@ -91,17 +93,33 @@ class _AddAppPageState extends State<AddAppPage> {
null) {
cont = false;
}
if (userPickedNoVersionDetection &&
// ignore: use_build_context_synchronously
await showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('disableVersionDetection'),
items: const [],
message: tr('noVersionDetectionExplanation'),
);
}) ==
null) {
cont = false;
}
if (cont) {
HapticFeedback.selectionClick();
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
App app = await sourceProvider.getApp(
pickedSource!, userInput, sourceSpecificAdditionalData,
trackOnly: trackOnly);
pickedSource!, userInput, additionalSettings,
trackOnlyOverride: trackOnly,
noVersionDetectionOverride: userPickedNoVersionDetection);
if (!trackOnly) {
await settingsProvider.getInstallPermission();
}
// Only download the APK here if you need to for the package ID
if (sourceProvider.isTempId(app.id) && !app.trackOnly) {
if (sourceProvider.isTempId(app.id) &&
app.additionalSettings['trackOnly'] != true) {
// ignore: use_build_context_synchronously
var apkUrl = await appsProvider.confirmApkUrl(app, context);
if (apkUrl == null) {
@ -116,9 +134,10 @@ class _AddAppPageState extends State<AddAppPage> {
if (appsProvider.apps.containsKey(app.id)) {
throw ObtainiumError(tr('appAlreadyAdded'));
}
if (app.trackOnly) {
if (app.additionalSettings['trackOnly'] == true) {
app.installedVersion = app.latestVersion;
}
app.categories = pickedCategories;
await appsProvider.saveApps([app]);
return app;
@ -155,10 +174,12 @@ class _AddAppPageState extends State<AddAppPage> {
children: [
Expanded(
child: GeneratedForm(
key: Key(searchnum.toString()),
items: [
[
GeneratedFormItem(
GeneratedFormTextField('appSourceURL',
label: tr('appSourceURL'),
defaultValue: userInput,
additionalValidators: [
(value) {
try {
@ -180,26 +201,21 @@ class _AddAppPageState extends State<AddAppPage> {
]
],
onValueChanges: (values, valid, isBuilding) {
changeUserInput(
values[0], valid, isBuilding);
},
defaultValues: const [])),
changeUserInput(values['appSourceURL']!,
valid, isBuilding);
})),
const SizedBox(
width: 16,
),
gettingAppInfo
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: gettingAppInfo ||
onPressed: doingSomething ||
pickedSource == null ||
(pickedSource!
.additionalSourceAppSpecificFormItems
.combinedAppSpecificSettingFormItems
.isNotEmpty &&
!sourceSpecificDataIsValid) ||
(pickedSource!
.additionalAppSpecificSourceAgnosticDefaults
.isNotEmpty &&
!otherAdditionalDataIsValid)
!additionalSettingsValid)
? null
: addApp,
child: Text(tr('add')))
@ -224,27 +240,33 @@ class _AddAppPageState extends State<AddAppPage> {
child: GeneratedForm(
items: [
[
GeneratedFormItem(
GeneratedFormTextField(
'searchSomeSources',
label: tr('searchSomeSourcesLabel'),
required: false),
]
],
onValueChanges: (values, valid, isBuilding) {
if (values.isNotEmpty && valid) {
if (values.isNotEmpty &&
valid &&
!isBuilding) {
setState(() {
searchQuery = values[0].trim();
searchQuery =
values['searchSomeSources']!.trim();
});
}
},
defaultValues: const ['']),
}),
),
const SizedBox(
width: 16,
),
ElevatedButton(
onPressed: searchQuery.isEmpty || gettingAppInfo
onPressed: searchQuery.isEmpty || doingSomething
? null
: () {
setState(() {
searching = true;
});
Future.wait(sourceProvider.sources
.where((e) => e.canSearch)
.map((e) =>
@ -281,26 +303,21 @@ class _AddAppPageState extends State<AddAppPage> {
if (selectedUrls != null &&
selectedUrls.isNotEmpty) {
changeUserInput(
selectedUrls[0], true, true);
addApp(resetUserInputAfter: true);
selectedUrls[0], true, false,
isSearch: true);
}
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
searching = false;
});
});
},
child: Text(tr('search')))
],
),
if (pickedSource != null &&
(pickedSource!.additionalSourceAppSpecificDefaults
.isNotEmpty ||
pickedSource!
.additionalAppSpecificSourceAgnosticFormItems
.where((e) => pickedSource!.enforceTrackOnly
? e.key != 'trackOnlyFormItemKey'
: true)
.map((e) => [e])
.isNotEmpty))
if (pickedSource != null)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -316,46 +333,30 @@ class _AddAppPageState extends State<AddAppPage> {
const SizedBox(
height: 16,
),
if (pickedSource!
.additionalSourceAppSpecificFormItems
.isNotEmpty)
GeneratedForm(
items: pickedSource!
.additionalSourceAppSpecificFormItems,
onValueChanges: (values, valid, isBuilding) {
if (!isBuilding) {
setState(() {
sourceSpecificAdditionalData = values;
sourceSpecificDataIsValid = valid;
});
}
},
defaultValues: pickedSource!
.additionalSourceAppSpecificDefaults),
if (pickedSource!
.additionalAppSpecificSourceAgnosticDefaults
.isNotEmpty)
const SizedBox(
height: 8,
),
GeneratedForm(
key: Key(pickedSource.runtimeType.toString()),
items: pickedSource!
.additionalAppSpecificSourceAgnosticFormItems
.where((e) => pickedSource!.enforceTrackOnly
? e.key != 'trackOnlyFormItemKey'
: true)
.map((e) => [e])
.toList(),
.combinedAppSpecificSettingFormItems,
onValueChanges: (values, valid, isBuilding) {
if (!isBuilding) {
setState(() {
otherAdditionalData = values;
otherAdditionalDataIsValid = valid;
additionalSettings = values;
additionalSettingsValid = valid;
});
}
},
defaultValues: pickedSource!
.additionalAppSpecificSourceAgnosticDefaults),
}),
Column(
children: [
const SizedBox(
height: 16,
),
CategoryEditorSelector(
alignment: WrapAlignment.start,
onSelected: (categories) {
pickedCategories = categories;
}),
],
),
],
)
else

View File

@ -4,6 +4,7 @@ import 'package:flutter/services.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';
@ -40,98 +41,139 @@ class _AppPageState extends State<AppPage> {
prevApp = app;
getUpdate(app.app.id);
}
var trackOnly = app?.app.additionalSettings['trackOnly'] == true;
var noVersionDetection =
app?.app.additionalSettings['noVersionDetection'] == true;
var infoColumn = Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
GestureDetector(
onTap: () {
if (app?.app.url != null) {
launchUrlString(app?.app.url ?? '',
mode: LaunchMode.externalApplication);
}
},
child: Text(
app?.app.url ?? '',
textAlign: TextAlign.center,
style: const TextStyle(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic,
fontSize: 12),
)),
const SizedBox(
height: 32,
),
Text(
tr('latestVersionX', args: [app?.app.latestVersion ?? tr('unknown')]),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(
'${tr('installedVersionX', args: [
app?.app.installedVersion ?? tr('none')
])}${trackOnly ? ' ${tr('estimateInBrackets')}\n\n${tr('xIsTrackOnly', args: [
tr('app')
])}' : ''}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(
height: 32,
),
Text(
tr('lastUpdateCheckX', args: [
app?.app.lastUpdateCheck == null
? tr('never')
: '\n${app?.app.lastUpdateCheck?.toLocal()}'
]),
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
),
const SizedBox(
height: 48,
),
CategoryEditorSelector(
alignment: WrapAlignment.center,
preselected:
app?.app.categories != null ? app!.app.categories.toSet() : {},
onSelected: (categories) {
if (app != null) {
app.app.categories = categories;
appsProvider.saveApps([app.app]);
}
}),
],
);
var fullInfoColumn = Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 150),
app?.installedInfo != null
? Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Image.memory(
app!.installedInfo!.icon!,
height: 150,
gaplessPlayback: true,
)
])
: Container(),
const SizedBox(
height: 25,
),
Text(
app?.installedInfo?.name ?? app?.app.name ?? tr('app'),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayLarge,
),
Text(
tr('byX', args: [app?.app.author ?? tr('unknown')]),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(
height: 32,
),
infoColumn,
const SizedBox(height: 150)
],
);
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(
tr('byX', args: [app?.app.author ?? tr('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);
? 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);
}
},
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')
])}${app?.app.trackOnly == true ? ' ${tr('estimateInBrackets')}\n\n${tr('xIsTrackOnly', args: [
tr('app')
])}' : ''}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(
height: 32,
),
Text(
tr('lastUpdateCheckX', args: [
app?.app.lastUpdateCheck == null
? tr('never')
: '\n${app?.app.lastUpdateCheck?.toLocal()}'
]),
textAlign: TextAlign.center,
style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12),
),
)
],
)),
..loadRequest(Uri.parse(app.app.url)))
: Container()
: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Column(children: [fullInfoColumn])),
],
),
onRefresh: () async {
@ -150,8 +192,9 @@ class _AppPageState extends State<AppPage> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (app?.app.installedVersion != null &&
app?.app.trackOnly == false &&
if (noVersionDetection &&
!trackOnly &&
app?.app.installedVersion != null &&
app?.app.installedVersion != app?.app.latestVersion)
IconButton(
onPressed: app?.downloadProgress != null
@ -163,13 +206,6 @@ class _AppPageState extends State<AppPage> {
return AlertDialog(
title: Text(tr(
'alreadyUpToDateQuestion')),
content: Text(
tr('onlyWorksWithNonEVDApps'),
style: const TextStyle(
fontWeight:
FontWeight.bold,
fontStyle:
FontStyle.italic)),
actions: [
TextButton(
onPressed: () {
@ -199,30 +235,49 @@ class _AppPageState extends State<AppPage> {
);
});
},
tooltip: 'Mark as Updated',
tooltip: tr('markUpdated'),
icon: const Icon(Icons.done)),
if (source != null &&
source.additionalSourceAppSpecificFormItems
.isNotEmpty)
source
.combinedAppSpecificSettingFormItems.isNotEmpty)
IconButton(
onPressed: app?.downloadProgress != null
? null
: () {
showDialog<List<String>>(
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
var items = source
.combinedAppSpecificSettingFormItems
.map((row) {
row.map((e) {
if (app?.app.additionalSettings[
e.key] !=
null) {
e.defaultValue = app?.app
.additionalSettings[
e.key];
}
return e;
}).toList();
return row;
}).toList();
return GeneratedFormModal(
title: 'Additional Options',
items: source
.additionalSourceAppSpecificFormItems,
defaultValues: app != null
? app.app.additionalData
: source
.additionalSourceAppSpecificDefaults);
title: tr('additionalOptions'),
items: items,
);
}).then((values) {
if (app != null && values != null) {
var changedApp = app.app;
changedApp.additionalData = values;
changedApp.additionalSettings =
values;
if (source.enforceTrackOnly) {
changedApp.additionalSettings[
'trackOnly'] = true;
showError(
tr('appsFromSourceAreTrackOnly'),
context);
}
appsProvider.saveApps(
[changedApp]).then((value) {
getUpdate(changedApp.id);
@ -230,11 +285,44 @@ class _AppPageState extends State<AppPage> {
}
});
},
tooltip: 'Additional Options',
icon: const Icon(Icons.settings)),
tooltip: tr('additionalOptions'),
icon: const Icon(Icons.edit)),
if (app != null && app.installedInfo != null)
IconButton(
onPressed: () {
appsProvider.openAppSettings(app.app.id);
},
icon: const Icon(Icons.settings),
tooltip: tr('settings'),
),
if (app != null && settingsProvider.showAppWebpage)
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
scrollable: true,
content: infoColumn,
title: Text(
'${app.app.name} ${tr('byX', args: [
app.app.author
])}'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(tr('continue')))
],
);
});
},
icon: const Icon(Icons.more_horiz),
tooltip: tr('more')),
const SizedBox(width: 16.0),
Expanded(
child: ElevatedButton(
child: TextButton(
onPressed: (app?.app.installedVersion == null ||
app?.app.installedVersion !=
app?.app.latestVersion) &&
@ -242,7 +330,9 @@ class _AppPageState extends State<AppPage> {
? () {
HapticFeedback.heavyImpact();
() async {
if (app?.app.trackOnly != true) {
if (app?.app.additionalSettings[
'trackOnly'] !=
true) {
await settingsProvider
.getInstallPermission();
}
@ -257,6 +347,8 @@ class _AppPageState extends State<AppPage> {
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
}
}).catchError((e) {
showError(e, context);
});
}).catchError((e) {
showError(e, context);
@ -264,51 +356,24 @@ class _AppPageState extends State<AppPage> {
}
: null,
child: Text(app?.app.installedVersion == null
? app?.app.trackOnly == false
? 'Install'
: 'Mark Installed'
: app?.app.trackOnly == false
? 'Update'
: 'Mark Updated'))),
? !trackOnly
? tr('install')
: tr('markInstalled')
: !trackOnly
? tr('update')
: tr('markUpdated')))),
const SizedBox(width: 16.0),
ElevatedButton(
Expanded(
child: TextButton(
onPressed: app?.downloadProgress != null
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(tr('removeAppQuestion')),
content: Text(tr(
'xWillBeRemovedButRemainInstalled',
args: [
app?.installedInfo?.name ??
app?.app.name ??
tr('app')
])),
actions: [
TextButton(
onPressed: () {
HapticFeedback
.selectionClick();
appsProvider.removeApps(
[app!.app.id]).then((_) {
int count = 0;
Navigator.of(context)
.popUntil((_) =>
count++ >= 2);
});
},
child: Text(tr('remove'))),
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(tr('cancel')))
],
);
});
appsProvider.removeAppsWithModal(
context, [app!.app]).then((value) {
if (value == true) {
Navigator.of(context).pop();
}
});
},
style: TextButton.styleFrom(
foregroundColor:
@ -316,7 +381,7 @@ class _AppPageState extends State<AppPage> {
surfaceTintColor:
Theme.of(context).colorScheme.error),
child: Text(tr('remove')),
),
)),
])),
if (app?.downloadProgress != null)
Padding(

File diff suppressed because it is too large Load Diff

View File

@ -63,21 +63,29 @@ class _HomePageState extends State<HomePage> {
.map((e) =>
NavigationDestination(icon: Icon(e.icon), label: e.title))
.toList(),
onDestinationSelected: (int index) {
onDestinationSelected: (int index) async {
HapticFeedback.selectionClick();
setState(() {
if (index == 0) {
if (index == 0) {
while ((pages[0].widget.key as GlobalKey<AppsPageState>)
.currentState !=
null) {
// Avoid duplicate GlobalKey error
await Future.delayed(const Duration(microseconds: 1));
}
setState(() {
selectedIndexHistory.clear();
} else if (selectedIndexHistory.isEmpty ||
(selectedIndexHistory.isNotEmpty &&
selectedIndexHistory.last != index)) {
});
} else if (selectedIndexHistory.isEmpty ||
(selectedIndexHistory.isNotEmpty &&
selectedIndexHistory.last != index)) {
setState(() {
int existingInd = selectedIndexHistory.indexOf(index);
if (existingInd >= 0) {
selectedIndexHistory.removeAt(existingInd);
}
selectedIndexHistory.add(index);
}
});
});
}
},
selectedIndex:
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,

View File

@ -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,7 @@ 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(
@ -66,6 +68,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
showError(
tr('exportedTo', args: [path]),
context);
}).catchError((e) {
showError(e, context);
});
},
child: Text(tr('obtainiumExport')))),
@ -98,6 +102,21 @@ class _ImportExportPageState extends State<ImportExportPage> {
appsProvider
.importApps(data)
.then((value) {
var cats =
settingsProvider.categories;
appsProvider.apps
.forEach((key, value) {
for (var c
in value.app.categories) {
if (!cats.containsKey(c)) {
cats[c] =
generateRandomLightColor()
.value;
}
}
});
settingsProvider.categories =
cats;
showError(
tr('importedX', args: [
plural('apps', value)
@ -138,18 +157,19 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: importInProgress
? null
: () {
showDialog(
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('importFromURLList'),
items: [
[
GeneratedFormItem(
GeneratedFormTextField(
'appURLList',
label: tr('appURLList'),
max: 7,
additionalValidators: [
(String? value) {
(dynamic value) {
if (value != null &&
value.isNotEmpty) {
var lines = value
@ -172,12 +192,12 @@ class _ImportExportPageState extends State<ImportExportPage> {
])
]
],
defaultValues: const [],
);
}).then((values) {
if (values != null) {
var urls =
(values[0] as String).split('\n');
(values['appURLList'] as String)
.split('\n');
setState(() {
importInProgress = true;
});
@ -225,7 +245,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
: () {
() async {
var values = await showDialog<
List<String>>(
Map<String,
dynamic>?>(
context: context,
builder:
(BuildContext ctx) {
@ -236,22 +257,26 @@ class _ImportExportPageState extends State<ImportExportPage> {
]),
items: [
[
GeneratedFormItem(
GeneratedFormTextField(
'searchQuery',
label: tr(
'searchQuery'))
]
],
defaultValues: const [],
);
});
if (values != null &&
values[0].isNotEmpty) {
(values['searchQuery']
as String?)
?.isNotEmpty ==
true) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source
.search(values[0]);
await source.search(
values['searchQuery']
as String);
if (urlsWithDescriptions
.isNotEmpty) {
var selectedUrls =
@ -332,7 +357,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
? null
: () {
() async {
var values = await showDialog(
var values = await showDialog<
Map<String,
dynamic>?>(
context: context,
builder:
(BuildContext ctx) {
@ -346,10 +373,10 @@ class _ImportExportPageState extends State<ImportExportPage> {
.requiredArgs
.map(
(e) => [
GeneratedFormItem(label: e)
GeneratedFormTextField(e,
label: e)
])
.toList(),
defaultValues: const [],
);
});
if (values != null) {
@ -359,7 +386,10 @@ class _ImportExportPageState extends State<ImportExportPage> {
var urlsWithDescriptions =
await source
.getUrlsWithDescriptions(
values);
values.values
.map((e) =>
e.toString())
.toList());
var selectedUrls =
await showDialog<
List<String>?>(
@ -534,18 +564,22 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')),
content: Column(children: [
...urlWithDescriptionSelections.keys.map((urlWithD) {
select(bool? value) {
setState(() {
value ??= false;
if (value! && widget.onlyOneSelectionAllowed) {
selectOnlyOne(urlWithD.key);
} else {
urlWithDescriptionSelections[urlWithD] = value!;
}
});
}
return Row(children: [
Checkbox(
value: urlWithDescriptionSelections[urlWithD],
onChanged: (value) {
setState(() {
value ??= false;
if (value! && widget.onlyOneSelectionAllowed) {
selectOnlyOne(urlWithD.key);
} else {
urlWithDescriptionSelections[urlWithD] = value!;
}
});
select(value);
}),
const SizedBox(
width: 8,
@ -569,12 +603,17 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
const TextStyle(decoration: TextDecoration.underline),
textAlign: TextAlign.start,
)),
Text(
urlWithD.value.length > 128
? '${urlWithD.value.substring(0, 128)}...'
: urlWithD.value,
style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12),
GestureDetector(
onTap: () {
select(!(urlWithDescriptionSelections[urlWithD] ?? false));
},
child: Text(
urlWithD.value.length > 128
? '${urlWithD.value.substring(0, 128)}...'
: urlWithD.value,
style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12),
),
),
const SizedBox(
height: 8,

View File

@ -1,8 +1,11 @@
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/logs_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
@ -17,6 +20,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) {
@ -110,6 +128,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.toLanguageTag(),
child: Text(e.toLanguageTag().toUpperCase()),
))
],
onChanged: (value) {
settingsProvider.forcedLocale = value;
if (value != null) {
context.setLocale(Locale(value));
} else {
context.resetLocale();
}
});
var intervalDropdown = DropdownButtonFormField(
decoration: InputDecoration(labelText: tr('bgUpdateCheckInterval')),
value: settingsProvider.updateInterval,
@ -138,21 +178,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();
}
@ -195,6 +231,8 @@ class _SettingsPageState extends State<SettingsPage> {
],
),
height16,
localeDropdown,
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -237,6 +275,18 @@ class _SettingsPageState extends State<SettingsPage> {
color: Theme.of(context).colorScheme.primary),
),
...sourceSpecificFields,
const Divider(
height: 48,
),
Text(
tr('categories'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
height16,
const CategoryEditorSelector(
showLabelWhenNotEmpty: false,
)
],
))),
SliverToBoxAdapter(
@ -351,3 +401,62 @@ 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>();
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.categories =
storedValues.map((key, value) => MapEntry(key, value.key));
if (widget.onSelected != null) {
widget.onSelected!(storedValues.keys
.where((k) => storedValues[k]!.value)
.toList());
}
}
}));
}
}

View File

@ -5,6 +5,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:android_intent_plus/flag.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@ -12,16 +13,20 @@ import 'package:flutter/services.dart';
import 'package:install_plugin_v2/install_plugin_v2.dart';
import 'package:installed_apps/app_info.dart';
import 'package:installed_apps/installed_apps.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/notifications_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:package_archive_info/package_archive_info.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_fgbg/flutter_fgbg.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:http/http.dart';
import 'package:android_intent_plus/android_intent.dart';
class AppInMemory {
late App app;
@ -174,12 +179,9 @@ class AppsProvider with ChangeNotifier {
}
}
// If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
// The former case should be handled (give the App its real ID), the latter is a security issue
// In either case, the app should be given the new ID
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
if (app.id != newInfo.packageName) {
if (apps[app.id] != null && !SourceProvider().isTempId(app.id)) {
throw IDChangedError();
}
var originalAppId = app.id;
app.id = newInfo.packageName;
downloadedFile = downloadedFile.renameSync(
@ -246,9 +248,10 @@ class AppsProvider with ChangeNotifier {
!(await canDowngradeApps())) {
throw DowngradeError();
}
if (appInfo == null ||
int.parse(newInfo.buildNumber) > appInfo.versionCode!) {
await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium');
await InstallPlugin.installApk(file.file.path, obtainiumId);
if (file.appId == obtainiumId) {
// Obtainium prompt should be lowest
await Future.delayed(const Duration(milliseconds: 500));
}
apps[file.appId]!.app.installedVersion =
apps[file.appId]!.app.latestVersion;
@ -257,6 +260,15 @@ class AppsProvider with ChangeNotifier {
attemptToCorrectInstallStatus: false);
}
void uninstallApp(String appId) async {
var intent = AndroidIntent(
action: 'android.intent.action.DELETE',
data: 'package:$appId',
flags: <int>[Flag.FLAG_ACTIVITY_NEW_TASK],
package: 'vnd.android.package-archive');
await intent.launch();
}
Future<String?> confirmApkUrl(App app, BuildContext? context) async {
// If the App has more than one APK, the user should pick one (if context provided)
String? apkUrl = app.apkUrls[app.preferredApkIndex];
@ -264,6 +276,7 @@ class AppsProvider with ChangeNotifier {
List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
if (app.apkUrls.length > 1 && context != null) {
// ignore: use_build_context_synchronously
apkUrl = await showDialog(
context: context,
builder: (BuildContext ctx) {
@ -283,6 +296,7 @@ class AppsProvider with ChangeNotifier {
if (apkUrl != null &&
getHost(apkUrl) != getHost(app.url) &&
context != null) {
// ignore: use_build_context_synchronously
if (await showDialog(
context: context,
builder: (BuildContext ctx) {
@ -313,7 +327,8 @@ class AppsProvider with ChangeNotifier {
throw ObtainiumError(tr('appNotFound'));
}
String? apkUrl;
if (!apps[id]!.app.trackOnly) {
var trackOnly = apps[id]!.app.additionalSettings['trackOnly'] == true;
if (!trackOnly) {
apkUrl = await confirmApkUrl(apps[id]!.app, context);
}
if (apkUrl != null) {
@ -326,7 +341,7 @@ class AppsProvider with ChangeNotifier {
appsToInstall.add(id);
}
}
if (apps[id]!.app.trackOnly) {
if (trackOnly) {
trackOnlyAppsToUpdate.add(id);
}
}
@ -439,9 +454,6 @@ class AppsProvider with ChangeNotifier {
} catch (e) {
//
}
if (!res) {
logs.add(tr('versionCorrectionDisabled'));
}
return res;
}
@ -451,9 +463,10 @@ class AppsProvider with ChangeNotifier {
// Don't save changes, just return the object if changes were made (else null)
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
var modded = false;
if (installedInfo == null &&
app.installedVersion != null &&
!app.trackOnly) {
var trackOnly = app.additionalSettings['trackOnly'] == true;
var noVersionDetection =
app.additionalSettings['noVersionDetection'] == true;
if (installedInfo == null && app.installedVersion != null && !trackOnly) {
app.installedVersion = null;
modded = true;
} else if (installedInfo?.versionName != null &&
@ -461,7 +474,8 @@ class AppsProvider with ChangeNotifier {
app.installedVersion = installedInfo!.versionName;
modded = true;
} else if (installedInfo?.versionName != null &&
installedInfo!.versionName != app.installedVersion) {
installedInfo!.versionName != app.installedVersion &&
!noVersionDetection) {
String? correctedInstalledVersion = reconcileRealAndInternalVersions(
installedInfo.versionName!, app.installedVersion!);
if (correctedInstalledVersion != null) {
@ -470,7 +484,8 @@ class AppsProvider with ChangeNotifier {
}
}
if (app.installedVersion != null &&
app.installedVersion != app.latestVersion) {
app.installedVersion != app.latestVersion &&
!noVersionDetection) {
app.installedVersion = reconcileRealAndInternalVersions(
app.installedVersion!, app.latestVersion,
matchMode: true) ??
@ -617,18 +632,65 @@ class AppsProvider with ChangeNotifier {
}
}
Future<bool> removeAppsWithModal(BuildContext context, List<App> apps) async {
var showUninstallOption =
apps.where((a) => a.installedVersion != null).isNotEmpty;
var values = await showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: plural('removeAppQuestion', apps.length),
items: !showUninstallOption
? []
: [
[
GeneratedFormSwitch('rmAppEntry',
label: tr('removeFromObtainium'), defaultValue: true)
],
[
GeneratedFormSwitch('uninstallApp',
label: tr('uninstallFromDevice'))
]
],
initValid: true,
);
});
if (values != null) {
bool uninstall = values['uninstallApp'] == true && showUninstallOption;
bool remove = values['rmAppEntry'] == true || !showUninstallOption;
if (uninstall) {
for (var i = 0; i < apps.length; i++) {
if (apps[i].installedVersion != null) {
uninstallApp(apps[i].id);
apps[i].installedVersion = null;
}
}
await saveApps(apps, attemptToCorrectInstallStatus: false);
}
if (remove) {
await removeApps(apps.map((e) => e.id).toList());
}
return uninstall || remove;
}
return false;
}
Future<void> openAppSettings(String appId) async {
final AndroidIntent intent = AndroidIntent(
action: 'action_application_details_settings',
data: 'package:$appId',
);
await intent.launch();
}
Future<App?> checkUpdate(String appId) async {
App? currentApp = apps[appId]!.app;
SourceProvider sourceProvider = SourceProvider();
App newApp = await sourceProvider.getApp(
sourceProvider.getSource(currentApp.url),
currentApp.url,
currentApp.additionalData,
name: currentApp.name,
id: currentApp.id,
pinned: currentApp.pinned,
trackOnly: currentApp.trackOnly,
installedVersion: currentApp.installedVersion);
currentApp.additionalSettings,
currentApp: currentApp);
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex;
}
@ -706,6 +768,14 @@ class AppsProvider with ChangeNotifier {
exportDir = await getExternalStorageDirectory();
path = exportDir!.path;
}
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt <= 29) {
if (await Permission.storage.isDenied) {
await Permission.storage.request();
}
if (await Permission.storage.isDenied) {
throw ObtainiumError(tr('storagePermissionDenied'));
}
}
File export = File(
'${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json');
export.writeAsStringSync(

View File

@ -80,11 +80,11 @@ class DownloadNotification extends ObtainiumNotification {
DownloadNotification(String appName, int progPercent)
: super(
appName.hashCode,
'Downloading $appName',
tr('downloadingX', args: [appName]),
'',
'APP_DOWNLOADING',
'Downloading App',
'Notifies the user of the progress in downloading an App',
tr('downloadingX', args: [tr('app')]),
tr('downloadNotifDescription'),
Importance.low,
onlyAlertOnce: true,
progPercent: progPercent);

View File

@ -1,9 +1,13 @@
// 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/components/generated_form.dart';
import 'package:obtainium/main.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -144,4 +148,35 @@ class SettingsProvider with ChangeNotifier {
prefs?.setString(settingId, value);
notifyListeners();
}
Map<String, int> get categories =>
Map<String, int>.from(jsonDecode(prefs?.getString('categories') ?? '{}'));
set categories(Map<String, int> cats) {
prefs?.setString('categories', jsonEncode(cats));
notifyListeners();
}
String? get forcedLocale {
var fl = prefs?.getString('forcedLocale');
return supportedLocales
.where((element) => element.toLanguageTag() == fl)
.isNotEmpty
? fl
: null;
}
set forcedLocale(String? fl) {
if (fl == null) {
prefs?.remove('forcedLocale');
} else if (supportedLocales
.where((element) => element.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;
}

View File

@ -7,11 +7,13 @@ 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/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/mullvad.dart';
import 'package:obtainium/app_sources/signal.dart';
import 'package:obtainium/app_sources/sourceforge.dart';
@ -44,10 +46,10 @@ class App {
late String latestVersion;
List<String> apkUrls = [];
late int preferredApkIndex;
late List<String> additionalData;
late Map<String, dynamic> additionalSettings;
late DateTime? lastUpdateCheck;
bool pinned = false;
bool trackOnly = false;
List<String> categories;
App(
this.id,
this.url,
@ -57,39 +59,83 @@ class App {
this.latestVersion,
this.apkUrls,
this.preferredApkIndex,
this.additionalData,
this.additionalSettings,
this.lastUpdateCheck,
this.pinned,
this.trackOnly);
{this.categories = const []});
@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);
factory App.fromJson(Map<String, dynamic> json) {
var source = SourceProvider().getSource(json['url']);
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;
}
// 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;
}
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,
json['apkUrls'] == null
? []
: List<String>.from(jsonDecode(json['apkUrls'])),
preferredApkIndex,
additionalSettings,
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]
: []);
}
Map<String, dynamic> toJson() => {
'id': id,
@ -100,15 +146,19 @@ class App {
'latestVersion': latestVersion,
'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData),
'additionalSettings': jsonEncode(additionalSettings),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned,
'trackOnly': trackOnly
'categories': categories
};
}
// 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';
@ -124,7 +174,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) =>
@ -137,6 +187,13 @@ List<String> getLinksFromParsedHTML(
.map((e) => '$prependToLinks${e.attributes['href']!}')
.toList();
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]));
}
class AppSource {
String? host;
late String name;
@ -151,23 +208,45 @@ class AppSource {
}
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) {
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(
final List<List<GeneratedFormItem>>
additionalAppSpecificSourceAgnosticSettingFormItems = [
[
GeneratedFormSwitch(
'trackOnly',
label: tr('trackOnly'),
type: FormItemType.bool,
key: 'trackOnlyFormItemKey')
)
],
[
GeneratedFormSwitch('noVersionDetection', label: tr('noVersionDetection'))
],
[
GeneratedFormTextField('apkFilterRegEx',
label: tr('filterAPKsByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
}
])
]
];
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 = [];
@ -186,7 +265,7 @@ class AppSource {
}
String? tryInferringAppId(String standardUrl,
{List<String> additionalData = const []}) {
{Map<String, dynamic> additionalSettings = const {}}) {
return null;
}
}
@ -202,11 +281,24 @@ abstract class MassAppUrlSource {
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
}
regExValidator(String? value) {
if (value == null || value.isEmpty) {
return null;
}
try {
RegExp(value);
} catch (e) {
return tr('invalidRegEx');
}
return null;
}
class SourceProvider {
// Add more source classes here so they are available via the service
List<AppSource> sources = [
GitHub(),
GitLab(),
Codeberg(),
FDroid(),
IzzyOnDroid(),
Mullvad(),
@ -214,7 +306,8 @@ class SourceProvider {
SourceForge(),
APKMirror(),
FDroidRepo(),
SteamMobile()
SteamMobile(),
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
@ -246,10 +339,10 @@ class SourceProvider {
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 && element.opts == null) {
if (element is GeneratedFormTextField && element.required) {
return true;
}
}
@ -274,37 +367,53 @@ class SourceProvider {
return true;
}
Future<App> getApp(AppSource source, String url, List<String> additionalData,
{String name = '',
String? id,
bool pinned = false,
bool trackOnly = false,
String? installedVersion}) async {
Future<App> getApp(
AppSource source,
String url,
Map<String, dynamic> additionalSettings, {
App? currentApp,
bool trackOnlyOverride = false,
noVersionDetectionOverride = false,
}) async {
if (trackOnlyOverride || source.enforceTrackOnly) {
additionalSettings['trackOnly'] = true;
}
if (noVersionDetectionOverride) {
additionalSettings['noVersionDetection'] = true;
}
var trackOnly = additionalSettings['trackOnly'] == true;
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
APKDetails apk = await source
.getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly);
APKDetails apk =
await source.getLatestAPKDetails(standardUrl, additionalSettings);
if (additionalSettings['apkFilterRegEx'] != null) {
var reg = RegExp(additionalSettings['apkFilterRegEx']);
apk.apkUrls =
apk.apkUrls.where((element) => reg.hasMatch(element)).toList();
}
if (apk.apkUrls.isEmpty && !trackOnly) {
throw NoAPKError();
}
String apkVersion = apk.version.replaceAll('/', '-');
var name = currentApp?.name.trim() ??
apk.names.name[0].toUpperCase() + apk.names.name.substring(1);
return App(
id ??
currentApp?.id ??
source.tryInferringAppId(standardUrl,
additionalData: additionalData) ??
additionalSettings: additionalSettings) ??
generateTempID(apk.names, source),
standardUrl,
apk.names.author[0].toUpperCase() + apk.names.author.substring(1),
name.trim().isNotEmpty
? name
: apk.names.name[0].toUpperCase() + apk.names.name.substring(1),
installedVersion,
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);
currentApp?.pinned ?? false,
categories: currentApp?.categories ?? const []);
}
// Returns errors in [results, errors] instead of throwing them
@ -316,7 +425,10 @@ class SourceProvider {
try {
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});
}

File diff suppressed because it is too large Load Diff

View File

@ -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.21+85 # When changing this, update the tag in main() accordingly
version: 0.10.8+114 # When changing this, update the tag in main() accordingly
environment:
sdk: '>=2.18.2 <3.0.0'
@ -38,10 +38,10 @@ 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: ^13.0.0
provider: ^6.0.3
http: ^0.13.5
webview_flutter: ^3.0.4
webview_flutter: ^4.0.0
dynamic_color: ^1.5.4
html: ^0.15.0
shared_preferences: ^2.0.15
@ -58,6 +58,7 @@ dependencies:
android_alarm_manager_plus: ^2.1.0
sqflite: ^2.2.0+3
easy_localization: ^3.0.1
android_intent_plus: ^3.1.5
dev_dependencies: