Compare commits

...

275 Commits

Author SHA1 Message Date
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
d068db2a57 Increment version 2022-12-19 16:19:38 -05:00
dd5c5fd2bc Merge pull request #169 from markus-gitdev/main
German translation
2022-12-19 16:12:32 -05:00
ac9dadd9d0 Merge pull request #168 from gidano/main
HU Text correction
2022-12-19 16:11:50 -05:00
bb0540b644 German translation
Initial version of german translation
2022-12-19 10:05:21 +01:00
819334021a HU Text correction 2022-12-19 08:06:59 +01:00
8ece0bbef9 Increment version 2022-12-18 13:13:44 -05:00
6a41283e74 Merge pull request #167 from bluefly000/main
Fix Japanese translations
2022-12-18 13:06:20 -05:00
e6d5c7db3e Add Japanese translation of untranslated sections 2022-12-18 19:30:11 +09:00
d4c016d8ee Fix Japanese translation (corrections to translations of notifications) 2022-12-18 18:58:06 +09:00
63034dd3f9 Added 'no version detection' option 2022-12-18 02:46:25 -05:00
67b986de93 Merge pull request #164 from gidano/Editing
HU text length adjustment
2022-12-18 01:31:54 -05:00
aafe4bc515 Increment version 2022-12-18 01:31:40 -05:00
e524335900 Add App bugfix 2022-12-18 01:21:14 -05:00
77751fa03f HU text length adjustment 2022-12-17 20:22:22 +01:00
b4e06ffb8e Increment version 2022-12-17 13:35:20 -05:00
af511deeca Merge pull request #162 from gidano/main
Hungarian translate
2022-12-17 13:33:31 -05:00
71c6db9510 Hungarian translate 2022-12-17 09:56:52 +01:00
8fac67c9e9 Fix Japanese translation 2022-12-17 17:56:42 +09:00
c317f23741 Increment version 2022-12-17 00:22:17 -05:00
12c0dd8489 Merge pull request #161 from HRTK92/main
fix translation japanese
2022-12-17 00:21:41 -05:00
1c7385ab56 fix translation japanese 2022-12-17 13:38:57 +09:00
b46347a6e3 Increment version 2022-12-16 22:47:21 -05:00
a7104c89dc Merge pull request #160 from HRTK92/main
add Japanese translation
2022-12-16 22:46:42 -05:00
347d2c2738 unified indentation 2022-12-17 12:04:19 +09:00
cc17260e54 add japanese translation 2022-12-17 12:01:54 +09:00
1985dcec3a Fixed bug for FDroid repos with uppercase in AppID 2022-12-16 19:48:48 -05:00
d435481f0b Increment version 2022-12-16 19:37:22 -05:00
a68d49c71c Added Steam as a Source (#159) + Bugfixes 2022-12-16 19:26:07 -05:00
2b6a16637e Merge branch 'main' of github.com:ImranR98/Obtainium 2022-12-16 18:56:06 -05:00
e46e4e5dbc Merge pull request #157 from atilluF/Italian-TL
Update it.json
2022-12-16 18:54:18 -05:00
848c8eaf5e Merge pull request #156 from RanTranslations/main
assets: Update Simplified Chinese translations
2022-12-16 18:54:07 -05:00
ebc48169a1 Bugfix #158 2022-12-16 18:25:51 -05:00
54c37641d5 Update it.json 2022-12-16 08:33:08 +01:00
05ad01bf85 assets: Update Simplified Chinese translations 2022-12-16 13:02:40 +08:00
049b023e01 Adding from custom fdroid repos is easier (name based) 2022-12-15 21:39:05 -05:00
f6ca5d42e8 Initial third party F-Droid repo support
Plus various bugfixes
And version increment
2022-12-15 21:22:03 -05:00
6d0cac5894 Bugfix for switching pages while downloading #150 2022-12-15 18:57:06 -05:00
bfa661c8e0 Enabled italian translations, increment version 2022-12-15 12:15:35 -05:00
e5825fe1d3 Merge pull request #153 from atilluF/Italian-TL
Italian translation
2022-12-15 12:12:00 -05:00
9e09aba444 Merge pull request #152 from atilluF/README
Added SourceForge to README.md
2022-12-15 12:11:55 -05:00
8f5e07a5ca Added Italian translation 2022-12-15 18:01:58 +01:00
e7f3cdafe5 Added SourceForge to README.md 2022-12-15 17:55:02 +01:00
14ae43de92 Internationalized more strings
Added ZH to supported language codes
Increment version
2022-12-15 11:09:03 -05:00
a8f0d784a2 Merge pull request #151 from RanTranslations/main
assets: Add Simplified Chinese translations
2022-12-15 10:32:29 -05:00
b1fb06e90b assets: Add Simplified Chinese translations 2022-12-15 18:53:33 +08:00
481204665c Workaround for version detection error in BG 2022-12-14 19:10:05 -05:00
317b5ac83a Added a log for prev. commit 2022-12-12 20:56:14 -05:00
f3b1ca4541 Attempt to disable ver. det. in bg if needed 2022-12-12 20:53:42 -05:00
a00cfa2ba6 Fixed some strings 2022-12-11 11:46:00 -05:00
f81f6374bb Enhanced Version Detection (Again) (#144)
* Simpler approach to EVD

* Download notifs now have progress bars

* Removed unused import, changed some comments

* Re-added "Please Wait" on Apps list (accidentally removed)

* Updated README.md
2022-12-11 01:59:45 -05:00
da8695834e Re-added APKMirror to README 2022-12-08 19:09:14 -05:00
c4ba1e9dbc Increment version 2022-12-08 19:01:00 -05:00
49862ad2a6 Reduced download notification importance 2022-12-08 18:57:53 -05:00
1b892f4e0d Avoid overflow for long version strings on Apps page 2022-12-08 18:54:40 -05:00
a4555f07f9 Fixed typo 2022-12-08 18:33:36 -05:00
73fbdd84f0 Updated version 2022-12-07 20:46:12 -05:00
a1518480db Updated build number 2022-12-07 20:43:35 -05:00
fd3ee02e52 Completely removed enhanced version detection 2022-12-07 20:36:14 -05:00
609366675d Fix translation error in BG check task 2022-12-07 19:48:59 -05:00
fbff498ae1 Addresses #139 2022-12-05 20:10:42 -05:00
bb4e470760 Slight tweaks 2022-12-05 20:09:16 -05:00
15183c3a95 Simplified EVD (only xx.yy.zz) 2022-12-05 16:31:43 -05:00
b496a416ff Increment version 2022-12-05 15:56:43 -05:00
6ac7ba204f EVD bugfix 2022-12-05 15:46:47 -05:00
0951c007d1 Bugfix for enhanced version detection 2022-12-05 15:39:36 -05:00
d835beec76 Bugfix for localization error in BG 2022-12-05 14:57:38 -05:00
2654bf12d3 Removed unused import 2022-12-04 17:15:08 -05:00
3951108bc9 Refactor - removed duplicate code 2022-12-04 17:12:10 -05:00
d934ce2e13 Enhanced detect bugfix + outdated apps show curr. ver. 2022-12-04 17:08:11 -05:00
66cc7f059f Disable mark as updated for enhanced detect apps 2022-12-04 16:58:04 -05:00
098428dac9 Typo 2022-12-04 14:35:49 -05:00
9e7c21b408 Enhanced ver. detection fix for track only apps 2022-12-04 14:18:02 -05:00
31c2c6b7c1 Enhanced ver. detection bugfix 2022-12-04 14:15:15 -05:00
f70049aded Changed a default (enhanced version detect bugfix) 2022-12-04 13:51:44 -05:00
60c28bf912 Attempting to add enhanced version detection #132 2022-12-04 13:40:58 -05:00
a6ed1e7c98 Increment version, upgrade packages 2022-12-04 12:49:16 -05:00
963f51dc53 Added download notifications
(removed toast during add app)
2022-12-04 12:48:12 -05:00
17b1f6e5b0 Internationalization (#131)
Replaced hardcoded English strings with locale-based variables based on the [easy_localization](https://pub.dev/packages/easy_localization) Flutter plugin.
2022-11-26 23:53:11 -05:00
086b2b949f Fixed bugfix with GitHub track-only Apps with no APK 2022-11-25 23:12:15 -05:00
9b5b212e96 APKMirror version extraction bugfix 2022-11-25 23:04:37 -05:00
6c8f9ebcbf Ran flutter upgrade 2022-11-25 20:45:49 -05:00
4d5773bdcc Slight UI changes on Add App page 2022-11-25 20:41:57 -05:00
f81ef6a416 Search results now interleaved on Add App page 2022-11-25 20:35:51 -05:00
47324fcb49 Added search bar on Add App page 2022-11-25 20:31:52 -05:00
377e0e07bd Removed unused imports 2022-11-25 19:17:08 -05:00
b5aae70274 Bugfix from prev. commit 2022-11-25 19:13:29 -05:00
42475fa42a Only ask for install perm. for non-track-only apps 2022-11-25 19:07:05 -05:00
d29534ef2e Increment version 2022-11-25 18:56:43 -05:00
25953399ac Re-added APKMirror as a Track-Only source 2022-11-25 18:55:17 -05:00
b04d2fad5c Adds Track-Only App Support (Addresses #119 and Sets Groundwork for #44) (#123)
- All Sources now have a "Track-Only" option that will prevent Obtainium from looking for APKs (though the App must still have a release of some kind so that a version string can be grabbed).
    - These Apps cannot be installed through Obtainium, but update notifications will still be sent.
    - The user needs to manually mark them as updated when appropriate.
    - This addresses issue #119.
    - It also partially addresses #44 by allowing some sources to be configured as "Track-Only"-only. The first such source (APKMirror) will be added later.
- Includes various UI changes to accommodate the above change.
- Also makes App loading a bit more responsive (sending Obtainium to the background then returning will now cause App re-load to pick up changes in App versioning that may have been made in the meantime, for instance through update checking).
2022-11-24 21:12:46 -05:00
868ba84c9a Tiny bugfix 2022-11-20 11:05:33 -05:00
602f0c3bb2 Increment version 2022-11-19 16:26:44 -05:00
00721e8ac4 Added days filter to logs dialog (#117) 2022-11-19 16:20:42 -05:00
d19f9101d6 Added dropdown support to generated form 2022-11-19 15:42:20 -05:00
a4bc278e4c Increment version 2022-11-17 19:02:44 -05:00
b04986622b IzzyOnDroid now uses API (+ other minor tweaks) 2022-11-17 19:00:27 -05:00
2059e4fd44 Switched to F-Droid API 2022-11-17 18:36:05 -05:00
618a1523cf Add app only downloads APK if needed 2022-11-16 20:57:58 -05:00
ba1cdc2c73 Increment version 2022-11-14 21:10:30 -05:00
aa2a25fffe Better GitHub error messages (#112) 2022-11-14 20:56:04 -05:00
c8ec67aef3 Existing APKs now reused again
With partial APKs avoided (#113)
2022-11-14 20:38:02 -05:00
9576a99a4e More efficient loading (addresses #110) 2022-11-14 12:56:52 -05:00
0202224fa6 Merge pull request #108 from ImranR98/logging
Added basic logging - mainly just for the BG task and errors right now.
2022-11-12 19:18:12 -05:00
631ffd5c34 Added basic logging + increment version
Logging is mainly just for the BG task and errors right now.
2022-11-12 19:17:05 -05:00
feed7ffc0b Increment version 2022-11-12 11:27:15 -05:00
296485de8a Added "Reset Install Status" button 2022-11-12 11:21:46 -05:00
d2f226d442 Slight refactoring 2022-11-12 10:44:59 -05:00
cbdb449e35 Bugfix in moveObtainiumToStart 2022-11-12 10:43:12 -05:00
3100a3a08c Added descriptions to GitHub starred imports 2022-11-12 10:40:54 -05:00
18951d6461 Added descriptions to search results 2022-11-12 10:35:59 -05:00
0e0a39a40f Allow App downgrades if com.berdik.letmedowngrade installed 2022-11-12 10:05:46 -05:00
55cae0620b Updated packages 2022-11-12 09:46:23 -05:00
ba6cea3ae6 Slightly changed icon 2022-11-12 09:42:42 -05:00
4be33374c2 Merge pull request #106 from ImranR98/search
GitHub Search and Pinned Apps
2022-11-12 03:30:36 -05:00
e2bf834981 Increment version 2022-11-12 02:15:23 -05:00
9bd7ddb21b Added App pinning 2022-11-12 02:14:45 -05:00
905a807ee9 GitHub search added 2022-11-12 01:25:32 -05:00
ab57b97875 Ready to implement GitHub search (UI done) 2022-11-12 01:05:16 -05:00
5db2c5f0b1 Initial changes to support search 2022-11-11 21:44:20 -05:00
e158c23cca Added a note about imported apps 'not installed' 2022-11-10 13:17:51 -05:00
208f125e12 Increment version 2022-11-10 13:02:37 -05:00
b7ccf3fa49 Fixed App import and legacy Apps upgrade (#103) 2022-11-10 12:55:04 -05:00
c746e89052 Fixed error reporting in add app box 2022-11-10 10:29:00 -05:00
ee758e8470 Fixed bug from previous commit 2022-11-10 10:26:36 -05:00
68d903e092 Increment version 2022-11-09 20:57:03 -05:00
c47b752344 Cancel update notifications on new install (#101)
Can't get more granular due to flutter_local_notifications/issues/1700
2022-11-09 20:56:40 -05:00
62a05996cf Fixed regression for #20 from last cleanup 2022-11-09 19:25:41 -05:00
1cda941fbe Removed APKMirror code (previously unused but present) 2022-11-07 16:05:07 -05:00
49cb908d04 Increment version 2022-11-07 15:33:02 -05:00
139f44d31d UI improvement in APKPicker 2022-11-07 15:32:42 -05:00
ed955ac6a2 Add arch info (#100) 2022-11-07 15:14:00 -05:00
f3ead6caf1 Fixed breaking bug from previous commit 2022-11-06 14:21:00 -05:00
97ab723d04 Cleanup (#98) 2022-11-05 23:29:12 -04:00
ed4a26d348 Switch to AlarmManager plugin from WorkManager for more reliable update checking (for #87) (#97) 2022-11-05 23:25:19 -04:00
bd5f21984e Shorter default interval (see #87) 2022-11-04 19:10:20 -04:00
5037d77b14 Increment version 2022-11-04 18:57:06 -04:00
c9711c7734 Addresses #76 and #93 2022-11-04 18:53:25 -04:00
76e98feeb7 Increment version 2022-11-02 20:24:12 -04:00
03da23f77a Addresses #90 2022-11-02 20:23:40 -04:00
9b99e2b302 Addresses #88, #89 2022-11-02 20:07:46 -04:00
e746ca890a Obtainium now installs last (#84) 2022-10-31 17:41:25 -04:00
9c00a7da14 Increment version 2022-10-30 13:09:56 -04:00
4df0dd64ad Addresses #77 (version string overflow) 2022-10-30 13:09:36 -04:00
7cf7ffe0de Fixed icon size on App page (#78) 2022-10-30 12:48:26 -04:00
b1953435af Added progress toasts when adding Apps 2022-10-30 12:44:30 -04:00
fc7d7d11d6 Addresses #79 + other GitHub bugfix 2022-10-30 12:22:32 -04:00
9ef26b3a4a F-Droid bugfixes (#73, #74, #75) + UI tweak 2022-10-29 22:57:21 -04:00
27ee6b9e88 Bugfix: Mass install not working 2022-10-29 18:59:27 -04:00
d1a3529036 Switched to package names as app ids (#69)
* Fixes #14 (although detection is disabled in background processes due to the bug described in #60)
    * Added App icons and basic installed detection
    * Real Package Names Used as IDs + App Icons (INCONVENIENT FOR PREVIOUS VERSION USERS)
    * Switch to using extracted names (no custom names)

* Fixes #57

* Fixes #67

* Fixes #64

* Fixes #61

* Commented out APKMirror and added code to remove their Apps

* Updated README

* Switched to Flutter stable (causes some UI elements to switch back to the old material design style, but this will be fixed in later Flutter releases)

* BG task silently retries on network errors

* Updated screenshots
2022-10-29 13:13:28 -04:00
a954a627fd Fixed 2 issues:
- Rate-limit regression in previous release
- Update notifications not sent when >1 apps have errors
2022-10-11 18:39:53 -04:00
52ce5b19c4 More informative errors for mass update checking 2022-10-11 11:53:20 -04:00
03f0b6cf05 Fixed sort order (was reversed asc/desc)
Also changed default sort to nameAuthor ascending
2022-10-09 15:26:51 -04:00
5d8d0de8de Slightly more efficient JSON importing (tiny difference) 2022-10-08 17:31:08 -04:00
07f6d4ad2c Fixed custom App name issue 2022-10-08 12:26:08 -04:00
dfbb4e19a5 Added more Mass App Actions 2022-10-07 21:15:19 -04:00
f5fda2ca90 Updated some plugins 2022-10-07 19:23:25 -04:00
661dc1626c Increment version 2022-10-07 19:08:24 -04:00
dde3fc20fb Back to old install plugin (dealbreaker in new one) 2022-10-07 19:06:02 -04:00
017b867d8d Added APKMirror (Phew!) 2022-10-07 17:24:45 -04:00
1cb1c124eb UI Tweak 2022-10-07 13:02:25 -04:00
fdeb852c7b More changelog urls added 2022-10-07 12:58:10 -04:00
67f50ba776 Added 'See Changes' button in app list (GitHub only) 2022-10-07 12:51:53 -04:00
a0968caa5c Tweaked update checking, fixed an issue on App page 2022-10-07 12:22:16 -04:00
e3e945d13b Bugfix - Obtainium doesn't update with other Apps 2022-10-01 00:29:15 -04:00
61f7f171b1 Upgraded a package 2022-09-30 23:23:23 -04:00
de07583161 Fixed issue with backgorund task not starting 2022-09-30 23:21:35 -04:00
49b9a65053 Updated version 2022-09-30 15:37:32 -04:00
aebc8aed76 Clearer GitHub PAT instructions 2022-09-30 15:33:24 -04:00
3958425c22 Removed outdated comment 2022-09-29 23:28:49 -04:00
0a560871cb Fixed update checking on App page 2022-09-29 23:20:57 -04:00
fbe4f0b49e Added GitHub PAT support 2022-09-29 21:27:54 -04:00
e2440a38c4 App name now editable on App page 2022-09-29 16:45:24 -04:00
496a10a444 Added pull-to-refresh on App page when no webpage shown 2022-09-29 16:35:16 -04:00
b8bb8d1f4b Bugfix for F-Droid URL parsing 2022-09-29 10:15:57 -04:00
af033f42cb Updated modules 2022-09-28 22:43:24 -04:00
e706661062 Added URL selection menu for mass imports 2022-09-28 22:33:55 -04:00
1a68b8abe6 Improved GitHub starred import + other tweaks 2022-09-28 21:36:21 -04:00
15c0ed04d1 BG Updates *should* work now 2022-09-28 21:17:42 -04:00
dd193d62f2 Update checking improvements (#38)
Still no auto retry for rate-limit. Instead, rate-limit errors are ignored and the unchecked Apps have to wait until the next cycle. Even this needs more testing before release.
2022-09-27 23:20:39 -04:00
77e1768f3b Bugfix 2022-09-25 11:46:25 -04:00
da9e5aed5e Apps page UI improvements 2022-09-25 11:32:57 -04:00
136628c9e6 Removed an unused import 2022-09-25 03:22:22 -04:00
a916167be3 Added basic SourceForge support 2022-09-25 03:21:57 -04:00
420cf487d4 Basic custom App name support (only when adding) 2022-09-25 02:39:41 -04:00
12855370b0 Merge pull request #31 from ImranR98/apps-list-improvements
Added
- Multi select on the Apps page with share, delete, and install actions - #23 
- (Related to above) Ability to filter and update all out of date Apps - #27 
- Notifying users to return to the App to complete installs is less buggy thanks to the new installer plugin - #24
2022-09-25 02:01:51 -04:00
33fed1cb2f Reduced dependece on fgbg thanks to new install plugin 2022-09-25 01:56:24 -04:00
33238b56a9 Added IconButton tootlips 2022-09-25 01:43:51 -04:00
428c208de4 Added share option, saveApp -> saveApps 2022-09-25 01:41:50 -04:00
9a4b0301be Updated version, standardized quotes, deleted test_page 2022-09-25 00:21:41 -04:00
f58d26524c Done w/ filter and multi select stuff 2022-09-25 00:12:02 -04:00
45e5544c5b Added apps list selection (actions incomplete) 2022-09-24 21:10:29 -04:00
0a9373e65a More work on silent updates (not working in BG) 2022-09-24 18:43:05 -04:00
b65c6e1d41 Bugfixes + started work on silent udates 2022-09-24 15:00:47 -04:00
22dd8253a9 Tiny bugfix with setting visual persistance 2022-09-24 02:49:37 -04:00
18198bbdfe Tiny bugfix in default source-specific options 2022-09-24 02:39:04 -04:00
cf3c86abb8 Updated version 2022-09-24 02:16:58 -04:00
570e376742 Tiny UI tweak 2022-09-24 02:15:08 -04:00
32ae5e8175 Added error reporting on forgeround update check 2022-09-24 02:10:56 -04:00
cbf5057c17 Changed App tile layout 2022-09-24 02:08:21 -04:00
2cfe62142a Added Apps search 2022-09-24 01:57:45 -04:00
d03486fc5d Adds Source-specific options + other changes (#26)
* Started work on dynamic forms

* dynamic form progress (switch doesn't work)

* dynamic forms work

* Gen. form improvements, source specific data (untested)

* Gen form bugfix

* Removed redundant generated modal code

* Added custom validators to gen. forms

* Progress on source options (incomplete), gen form bugfixes

* Tweaks, more

* More

* Progress

* Changed a default

* Additional options done!
2022-09-24 00:36:32 -04:00
224e435bbb Moved App Sources into separate files 2022-09-22 19:35:15 -04:00
90fa0e06ce Fixed App webpage scrolling issue 2022-09-18 13:59:26 -04:00
6c1ad94b4f Fixed build number 2022-09-17 19:09:32 -04:00
7d7986f8bf FIxed a typo 2022-09-17 19:05:55 -04:00
3ddf9ea736 Fixed incorrect background colours 2022-09-17 18:57:14 -04:00
2272f8b4e6 Merge pull request #15 from ImranR98/ui-improvements
UI improvements
2022-09-17 18:42:05 -04:00
9514062a3a Updated version 2022-09-17 18:40:01 -04:00
da57018b90 Added "not installed" button 2022-09-17 18:39:11 -04:00
87e31c37aa 'Already Installed' button also takes 'Already Updated' 2022-09-17 18:11:00 -04:00
cb4dfff1b9 Added nav animation 2022-09-17 18:06:05 -04:00
911b06bfb6 Slight tweak to import/export buttons 2022-09-17 17:54:50 -04:00
53513bfdd1 Added sections to settings page 2022-09-17 17:19:58 -04:00
681092d895 Colour, alignment fixes 2022-09-17 17:00:08 -04:00
0f6b6253de Reduced haptic feedback (consequential actions only) 2022-09-17 16:48:42 -04:00
c724b276ab Added strechy appbars to all pages 2022-09-17 16:15:30 -04:00
35369273bd Changed source order, started adding strechy titlebars 2022-09-17 14:39:38 -04:00
0b1863a227 Update README.md 2022-09-17 02:34:14 -04:00
9e21f2d6e6 Updated version 2022-09-17 02:16:11 -04:00
6f11f850e0 Import now uses file picker 2022-09-17 02:12:17 -04:00
5e96b91029 Updated version 2022-09-17 01:43:54 -04:00
5fc79af960 Added App sorting 2022-09-17 01:41:38 -04:00
05f5590e7d Updated modules, removed unneeded imports 2022-09-17 01:10:34 -04:00
50f8caeb47 Added "Already Installed" button 2022-09-17 00:59:15 -04:00
f966a9e626 Finished import/export changes 2022-09-17 00:39:56 -04:00
02a5749ba7 Removed redundant code 2022-09-17 00:09:46 -04:00
4ccf7cbc92 Added GitHub starred import (+ general import/export changes) 2022-09-16 23:52:58 -04:00
ab4efd85ce Added IzzyOnDroid App Source
+ Bugfix for third party APK URL support
+ F-Droid apps have F-Droid as Author now
2022-09-16 20:24:47 -04:00
42bba0f64c Added option to disable background update checking 2022-09-16 19:53:57 -04:00
294327bde4 FIXED GITHUB ISSUE 2022-09-13 21:42:06 -04:00
52b97662c6 Updated plugins, incremented app version, ui tweaks 2022-09-03 17:31:19 -04:00
f63da4b538 Added option to not show App webpage + wording tweak 2022-09-03 17:06:46 -04:00
c30c692d87 Added external APK support (GitLab only for now) 2022-09-03 16:12:25 -04:00
d643d5a474 Fixed invisible nav buttons on pre Android Q 2022-09-03 15:30:00 -04:00
f8101a5d9f Updated version 2022-08-28 19:26:13 -04:00
c2a7e4a0d2 Bugfix - update checking on app load was broken 2022-08-28 18:17:03 -04:00
285da7545b Slight offset on ic_notification 2022-08-27 23:56:44 -04:00
a5230acc11 Added store icon 2022-08-27 23:43:54 -04:00
53019818a6 Rearranged some folders, added graphics 2022-08-27 23:01:29 -04:00
1a04d39144 Updated README.md with new App Sources 2022-08-27 22:34:56 -04:00
68 changed files with 7084 additions and 1015 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@
.history
.svn/
migrate_working_dir/
.vscode/
# IntelliJ related
*.iml

View File

@ -4,19 +4,27 @@ Get Android App Updates Directly From the Source.
Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available.
Currently supported App sources:
- GitHub
- GitLab
Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0)
Currently supported App sources:
- [GitHub](https://github.com/)
- [GitLab](https://gitlab.com/)
- [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`)
- [Steam](https://store.steampowered.com/mobile)
## Limitations
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
- App installs happen asynchronously and the success/failure of an install cannot be determined directly. This results in install statuses and versions sometimes being out of sync with the OS until the next launch or until the problem is manually corrected.
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
- For GitHub, data is gathered using Web scraping and can easily break due to changes in website design. More reliable methods are either insufficient (GitHub RSS) or subject to rate limits (GitHub API). This may also apply to new sources added in the future.
- For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable.
## Screenshots
| <img src="./screenshots/1.apps.png" alt="Apps Page" /> | <img src="./screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./screenshots/3.material_you.png" alt="Material You" /> |
| <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="./screenshots/4.app.png" alt="App Page" /> | <img src="./screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./screenshots/6.apk_install.png" alt="App Installation" /> |
| <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" /> |

View File

@ -30,7 +30,25 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false"/>
<receiver
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmBroadcastReceiver"
android:exported="false"/>
<receiver
android:name="dev.fluttercommunity.plus.androidalarmmanager.RebootBroadcastReceiver"
android:enabled="false"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<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"/>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path path="Android/data/dev.imranr.obtainium/" name="files_root" />
<external-path path="." name="external_storage_root" />
</paths>

BIN
assets/graphics/banner.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
assets/graphics/icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
assets/graphics/icon.psd Executable file

Binary file not shown.

BIN
assets/graphics/obtainium.psd Executable file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

254
assets/translations/de.json Normal file
View File

@ -0,0 +1,254 @@
{
"invalidURLForSource": "Keine gültige {} App-URL",
"noReleaseFound": "Keine passende Version gefunden",
"noVersionFound": "Release-Version nicht ermittelbar",
"urlMatchesNoSource": "URL stimmt mit keiner bekannten Quelle überein",
"cantInstallOlderVersion": "Installation einer älteren App-Version nicht möglich",
"appIdMismatch": "Die heruntergeladene Paket-ID stimmt nicht mit der vorhandenen App-ID überein",
"functionNotImplemented": "Diese Klasse hat diese Funktion nicht implementiert",
"placeholder": "Platzhalter",
"someErrors": "Es traten einige Fehler auf",
"unexpectedError": "Unerwarteter Fehler",
"ok": "Okay",
"and": "und",
"startedBgUpdateTask": "Hintergrundaktualisierungsprüfung gestartet",
"bgUpdateIgnoreAfterIs": "Hintergrundaktualisierung 'ignoreAfter' ist {}",
"startedActualBGUpdateCheck": "Überprüfung der Hintergrundaktualisierung gestartet",
"bgUpdateTaskFinished": "Hintergrundaktualisierungsprüfung abgeschlossen",
"firstRun": "Dies ist der erste Start von Obtainium überhaupt",
"settingUpdateCheckIntervalTo": "Aktualisierungsintervall auf {} stellen",
"githubPATLabel": "GitHub Personal Access Token (Erhöht das Ratenlimit)",
"githubPATHint": "PAT muss in diesem Format sein: Benutzername:Token",
"githubPATFormat": "Benutzername:Token",
"githubPATLinkText": "Über GitHub PATs",
"includePrereleases": "Vorabversionen einbeziehen",
"fallbackToOlderReleases": "Fallback auf ältere Versionen",
"filterReleaseTitlesByRegEx": "Release-Titel nach regulärem Ausdruck\nfiltern",
"invalidRegEx": "Ungültiger regulärer Ausdruck",
"noDescription": "Keine Beschreibung",
"cancel": "Abbrechen",
"continue": "Weiter",
"requiredInBrackets": "(Benötigt)",
"dropdownNoOptsError": "FEHLER: DROPDOWN MUSS MINDESTENS EINE OPTION HABEN",
"colour": "Farbe",
"githubStarredRepos": "GitHub Starred Repos",
"uname": "Benutzername",
"wrongArgNum": "Falsche Anzahl von Argumenten übermittelt",
"xIsTrackOnly": "{} ist nur zur Nachverfolgung",
"source": "Quelle",
"app": "App",
"appsFromSourceAreTrackOnly": "Apps aus dieser Quelle sind 'Nur Nachverfolgen'.",
"youPickedTrackOnly": "Sie haben die Option 'Nur Nachverfolgen' gewählt.",
"trackOnlyAppDescription": "Die App wird auf Updates überwacht, aber Obtainium wird sie nicht herunterladen oder installieren.",
"cancelled": "Abgebrochen",
"appAlreadyAdded": "App bereits hinzugefügt",
"alreadyUpToDateQuestion": "App bereits auf dem neuesten Stand?",
"addApp": "App hinzufügen",
"appSourceURL": "Quell-URL der App",
"error": "Fehler",
"add": "Hinzufügen",
"searchSomeSourcesLabel": "Suche (nur bestimmte Quellen)",
"search": "Suchen",
"additionalOptsFor": "Zusatzoptionen für {}",
"supportedSourcesBelow": "Unterstützte Quellen:",
"trackOnlyInBrackets": "(Nur Nachverfolgen)",
"searchableInBrackets": "(Durchsuchbar)",
"appsString": "Apps",
"noApps": "Keine Apps",
"noAppsForFilter": "Keine Apps für ausgewählten Filter",
"byX": "Von {}",
"percentProgress": "Fortschritt: {}%",
"pleaseWait": "Bitte warten",
"updateAvailable": "Aktualisierung verfügbar",
"estimateInBracketsShort": "(ca.)",
"notInstalled": "Nicht installiert",
"estimateInBrackets": "(Ungefähr)",
"selectAll": "Alle auswählen",
"deselectN": "{} abgewählt",
"xWillBeRemovedButRemainInstalled": "{} wird aus Obtainium entfernt, bleibt aber auf dem Gerät installiert.",
"removeSelectedAppsQuestion": "Ausgewählte Apps entfernen?",
"removeSelectedApps": "Ausgewählte Apps entfernen",
"updateX": "Aktualisiere {}",
"installX": "Installiere {}",
"markXTrackOnlyAsUpdated": "Markiere {}\n(Nur Nachverfolgen)\nals aktualisiert",
"changeX": "Ä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",
"markSelectedAppsUpdated": "Markiere ausgewählte Apps als aktuell",
"pinToTop": "Oben anheften",
"unpinFromTop": "'Oben anheften' aufheben",
"resetInstallStatusForSelectedAppsQuestion": "Installationsstatus für ausgewählte Apps zurücksetzen?",
"installStatusOfXWillBeResetExplanation": "Der Installationsstatus der ausgewählten Apps wird zurückgesetzt. Dies kann hilfreich sein, wenn die in Obtainium angezeigte App-Version aufgrund fehlgeschlagener Aktualisierungen oder anderer Probleme falsch ist.",
"shareSelectedAppURLs": "Ausgewählte App-URLs teilen",
"resetInstallStatus": "Installationsstatus zurücksetzen",
"more": "Mehr",
"removeOutdatedFilter": "App-Filter 'Nicht aktuell' entfernen",
"showOutdatedOnly": "Nur nicht aktuelle Apps anzeigen",
"filter": "Filter",
"filterActive": "Filter *",
"filterApps": "Apps filtern",
"appName": "App Name",
"author": "Autor",
"upToDateApps": "Apps mit aktueller Version",
"nonInstalledApps": "Nicht installierte Apps",
"importExport": "Import/Export",
"settings": "Einstellungen",
"exportedTo": "Exportiert zu {}",
"obtainiumExport": "Obtainium Export",
"invalidInput": "Ungültige Eingabe",
"importedX": "Importiert {}",
"obtainiumImport": "Obtainium Import",
"importFromURLList": "Importieren aus URL-Liste",
"searchQuery": "Suchanfrage",
"appURLList": "App URL-Liste",
"line": "Linie",
"searchX": "Suche {}",
"noResults": "Keine Ergebnisse gefunden",
"importX": "Import {}",
"importedAppsIdDisclaimer": "Importierte Apps werden möglicherweise fälschlicherweise als \"Nicht installiert\" angezeigt. Um dies zu beheben, installieren Sie sie erneut über Obtainium. Dies hat keine Auswirkungen auf App-Daten. Es betrifft nur URL- und Drittanbieter-Importmethoden.",
"importErrors": "Importfehler",
"importedXOfYApps": "{} von {} Apps importiert.",
"followingURLsHadErrors": "Bei folgenden URLs traten Fehler auf:",
"okay": "Okay",
"selectURL": "URL auswählen",
"selectURLs": "URLs auswählen",
"pick": "Auswählen",
"theme": "Theme",
"dark": "Dunkel",
"light": "Hell",
"followSystem": "System folgen",
"obtainium": "Obtainium",
"materialYou": "Material You",
"appSortBy": "App sortieren nach",
"authorName": "Autor/Name",
"nameAuthor": "Name/Autor",
"asAdded": "Wie hinzugefügt",
"appSortOrder": "App Sortierung nach",
"ascending": "Aufsteigend",
"descending": "Absteigend",
"bgUpdateCheckInterval": "Prüfintervall für Hintergrundaktualisierung",
"neverManualOnly": "Nie - nur manuell",
"appearance": "Aussehen",
"showWebInAppView": "Quellwebseite in der App-Ansicht anzeigen",
"pinUpdates": "Apps mit Aktualisierungen oben anheften",
"updates": "Aktualisierungen",
"sourceSpecific": "Quellenspezifisch",
"appSource": "App-Quelle",
"noLogs": "Keine Protokolle",
"appLogs": "App Protokolle",
"close": "Schließen",
"share": "Teilen",
"appNotFound": "App nicht gefunden",
"obtainiumExportHyphenatedLowercase": "obtainium-export",
"pickAnAPK": "APK auswählen",
"appHasMoreThanOnePackage": "{} verfügt über mehr als ein Paket:",
"deviceSupportsXArch": "Ihr Gerät unterstützt die CPU-Architektur {}.",
"deviceSupportsFollowingArchs": "Ihr Gerät unterstützt die folgenden CPU-Architekturen:",
"warning": "Warnung",
"sourceIsXButPackageFromYPrompt": "Die App-Quelle ist '{}', aber das Release-Paket stammt von '{}'. Fortfahren?",
"updatesAvailable": "Aktualisierungen verfügbar",
"updatesAvailableNotifDescription": "Benachrichtigt den Nutzer, dass Aktualisierungen für eine oder mehrere von Obtainium verfolgte Apps verfügbar sind",
"noNewUpdates": "Keine neuen Aktualisierungen.",
"xHasAnUpdate": "{} hat eine Aktualisierung.",
"appsUpdated": "Apps aktualisiert",
"appsUpdatedNotifDescription": "Benachrichtigt den Benutzer, dass Aktualisierungen für eine oder mehrere Apps im Hintergrund durchgeführt wurden",
"xWasUpdatedToY": "{} wurde auf {} aktualisiert.",
"errorCheckingUpdates": "Fehler beim Prüfen auf Aktualisierungen",
"errorCheckingUpdatesNotifDescription": "Eine Benachrichtigung, die angezeigt wird, wenn die Prüfung der Hintergrundaktualisierung fehlschlägt",
"appsRemoved": "Apps entfernt",
"appsRemovedNotifDescription": "Benachrichtigt den Benutzer, dass eine oder mehrere Apps aufgrund von Fehlern beim Laden entfernt wurden",
"xWasRemovedDueToErrorY": "{} wurde aufgrund des folgenden Fehlers entfernt: {}",
"completeAppInstallation": "App Installation abschließen",
"obtainiumMustBeOpenToInstallApps": "Obtainium muss geöffnet sein, um Apps zu installieren",
"completeAppInstallationNotifDescription": "Aufforderung an den Benutzer, zu Obtainium zurückzukehren, um die Installation einer App abzuschließen",
"checkingForUpdates": "Nach Aktualisierungen suchen",
"checkingForUpdatesNotifDescription": "Vorübergehende Benachrichtigung, die bei der Suche nach Aktualisierungen angezeigt wird",
"pleaseAllowInstallPerm": "Bitte erlauben Sie Obtainium die Installation von Apps",
"trackOnly": "Nur Nachverfolgen",
"errorWithHttpStatusCode": "Fehler {}",
"versionCorrectionDisabled": "Versionskorrektur deaktiviert (Plugin scheint nicht zu funktionieren)",
"unknown": "Unbekannt",
"none": "Keine",
"never": "Nie",
"latestVersionX": "Neueste Version: {}",
"installedVersionX": "Installierte Version: {}",
"lastUpdateCheckX": "Letzte Aktualisierungsprüfung: {}",
"remove": "Entfernen",
"removeAppQuestion": "App entfernen?",
"yesMarkUpdated": "Ja, als aktualisiert markieren",
"fdroid": "F-Droid",
"appIdOrName": "App ID oder Name",
"appWithIdOrNameNotFound": "Es wurde keine App mit dieser ID oder diesem Namen gefunden",
"reposHaveMultipleApps": "Repos können mehrere Apps enthalten",
"fdroidThirdPartyRepo": "F-Droid Third-Party Repo",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"install": "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",
"deleteCategoryQuestion": "Delete Category?",
"categoryDeleteWarning": "All Apps in {} will be set to uncategorized.",
"addCategory": "Add Category",
"label": "Label",
"tooManyRequestsTryAgainInMinutes": {
"one": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minute erneut",
"other": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minuten erneut"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "Bei der Aktualisierungsprüfung im Hintergrund wurde ein {} festgestellt, eine erneute Prüfung wird in {} Minute geplant",
"other": "Bei der Aktualisierungsprüfung im Hintergrund wurde ein {} festgestellt, eine erneute Prüfung wird in {} Minuten geplant"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "Hintergrundaktualisierungsprüfung fand {} Aktualisierung - benachrichtigt den Benutzer, falls erforderlich",
"other": "Hintergrundaktualisierungsprüfung fand {} Aktualisierungen - benachrichtigt den Benutzer, falls erforderlich"
},
"apps": {
"one": "{} App",
"other": "{} Apps"
},
"url": {
"one": "{} URL",
"other": "{} URLs"
},
"minute": {
"one": "{} Minute",
"other": "{} Minutes"
},
"hour": {
"one": "{} Stunde",
"other": "{} Stunden"
},
"day": {
"one": "{} Tag",
"other": "{} Tage"
},
"clearedNLogsBeforeXAfterY": {
"one": "{n} Protokoll gelöscht (vorher = {vorher}, nachher = {nachher})",
"other": "{n} Protokolle gelöscht (vorher = {vorher}, nachher = {nachher})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} und 1 weitere App haben Aktualisierungen.",
"other": "{} und {} weitere Apps haben Aktualisierungen."
},
"xAndNMoreUpdatesInstalled": {
"one": "{} und 1 weitere Anwendung wurden aktualisiert.",
"other": "{} und {} weitere Anwendungen wurden aktualisiert."
}
}

254
assets/translations/en.json Normal file
View File

@ -0,0 +1,254 @@
{
"invalidURLForSource": "Not a valid {} App URL",
"noReleaseFound": "Could not find a suitable release",
"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",
"unexpectedError": "Unexpected Error",
"ok": "Okay",
"and": "and",
"startedBgUpdateTask": "Started BG update check task",
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
"startedActualBGUpdateCheck": "Started actual BG update checking",
"bgUpdateTaskFinished": "Finished BG update check task",
"firstRun": "This is the first ever run of Obtainium",
"settingUpdateCheckIntervalTo": "Setting update interval to {}",
"githubPATLabel": "GitHub Personal Access Token (Increases Rate Limit)",
"githubPATHint": "PAT must be in this format: username:token",
"githubPATFormat": "username:token",
"githubPATLinkText": "About GitHub PATs",
"includePrereleases": "Include prereleases",
"fallbackToOlderReleases": "Fallback to older releases",
"filterReleaseTitlesByRegEx": "Filter Release Titles by Regular Expression",
"invalidRegEx": "Invalid regular expression",
"noDescription": "No description",
"cancel": "Cancel",
"continue": "Continue",
"requiredInBrackets": "(Required)",
"dropdownNoOptsError": "ERROR: DROPDOWN MUST HAVE AT LEAST ONE OPT",
"colour": "Colour",
"githubStarredRepos": "GitHub Starred Repos",
"uname": "Username",
"wrongArgNum": "Wrong number of arguments provided",
"xIsTrackOnly": "{} is Track-Only",
"source": "Source",
"app": "App",
"appsFromSourceAreTrackOnly": "Apps from this source are 'Track-Only'.",
"youPickedTrackOnly": "You have selected the 'Track-Only' option.",
"trackOnlyAppDescription": "The App will be tracked for updates, but Obtainium will not be able to download or install it.",
"cancelled": "Cancelled",
"appAlreadyAdded": "App already added",
"alreadyUpToDateQuestion": "App Already up to Date?",
"addApp": "Add App",
"appSourceURL": "App Source URL",
"error": "Error",
"add": "Add",
"searchSomeSourcesLabel": "Search (Some Sources Only)",
"search": "Search",
"additionalOptsFor": "Additional Options for {}",
"supportedSourcesBelow": "Supported Sources:",
"trackOnlyInBrackets": "(Track-Only)",
"searchableInBrackets": "(Searchable)",
"appsString": "Apps",
"noApps": "No Apps",
"noAppsForFilter": "No Apps for Filter",
"byX": "By {}",
"percentProgress": "Progress: {}%",
"pleaseWait": "Please Wait",
"updateAvailable": "Update Available",
"estimateInBracketsShort": "(Est.)",
"notInstalled": "Not Installed",
"estimateInBrackets": "(Estimate)",
"selectAll": "Select All",
"deselectN": "Deselect {}",
"xWillBeRemovedButRemainInstalled": "{} will be removed from Obtainium but remain installed on device.",
"removeSelectedAppsQuestion": "Remove Selected Apps?",
"removeSelectedApps": "Remove Selected Apps",
"updateX": "Update {}",
"installX": "Install {}",
"markXTrackOnlyAsUpdated": "Mark {}\n(Track-Only)\nas Updated",
"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",
"markSelectedAppsUpdated": "Mark Selected Apps as Updated",
"pinToTop": "Pin to top",
"unpinFromTop": "Unpin from top",
"resetInstallStatusForSelectedAppsQuestion": "Reset Install Status for Selected Apps?",
"installStatusOfXWillBeResetExplanation": "The install status of any selected Apps will be reset.\n\nThis can help when the App version shown in Obtainium is incorrect due to failed updates or other issues.",
"shareSelectedAppURLs": "Share Selected App URLs",
"resetInstallStatus": "Reset Install Status",
"more": "More",
"removeOutdatedFilter": "Remove Out-of-Date App Filter",
"showOutdatedOnly": "Show Out-of-Date Apps Only",
"filter": "Filter",
"filterActive": "Filter *",
"filterApps": "Filter Apps",
"appName": "App Name",
"author": "Author",
"upToDateApps": "Up to Date Apps",
"nonInstalledApps": "Non-Installed Apps",
"importExport": "Import/Export",
"settings": "Settings",
"exportedTo": "Exported to {}",
"obtainiumExport": "Obtainium Export",
"invalidInput": "Invalid input",
"importedX": "Imported {}",
"obtainiumImport": "Obtainium Import",
"importFromURLList": "Import from URL List",
"searchQuery": "Search Query",
"appURLList": "App URL List",
"line": "Line",
"searchX": "Search {}",
"noResults": "No results found",
"importX": "Import {}",
"importedAppsIdDisclaimer": "Imported Apps may incorrectly show as \"Not Installed\".\nTo fix this, re-install them through Obtainium.\nThis should not affect App data.\n\nOnly affects URL and third-party import methods.",
"importErrors": "Import Errors",
"importedXOfYApps": "{} of {} Apps imported.",
"followingURLsHadErrors": "The following URLs had errors:",
"okay": "Okay",
"selectURL": "Select URL",
"selectURLs": "Select URLs",
"pick": "Pick",
"theme": "Theme",
"dark": "Dark",
"light": "Light",
"followSystem": "Follow System",
"obtainium": "Obtainium",
"materialYou": "Material You",
"appSortBy": "App Sort By",
"authorName": "Author/Name",
"nameAuthor": "Name/Author",
"asAdded": "As Added",
"appSortOrder": "App Sort Order",
"ascending": "Ascending",
"descending": "Descending",
"bgUpdateCheckInterval": "Background Update Checking Interval",
"neverManualOnly": "Never - Manual Only",
"appearance": "Appearance",
"showWebInAppView": "Show Source Webpage in App View",
"pinUpdates": "Pin Updates to Top of Apps View",
"updates": "Updates",
"sourceSpecific": "Source-Specific",
"appSource": "App Source",
"noLogs": "No Logs",
"appLogs": "App Logs",
"close": "Close",
"share": "Share",
"appNotFound": "App not found",
"obtainiumExportHyphenatedLowercase": "obtainium-export",
"pickAnAPK": "Pick an APK",
"appHasMoreThanOnePackage": "{} has more than one package:",
"deviceSupportsXArch": "Your device supports the {} CPU architecture.",
"deviceSupportsFollowingArchs": "Your device supports the following CPU architectures:",
"warning": "Warning",
"sourceIsXButPackageFromYPrompt": "The App source is '{}' but the release package comes from '{}'. Continue?",
"updatesAvailable": "Updates Available",
"updatesAvailableNotifDescription": "Notifies the user that updates are available for one or more Apps tracked by Obtainium",
"noNewUpdates": "No new updates.",
"xHasAnUpdate": "{} has an update.",
"appsUpdated": "Apps Updated",
"appsUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were applied in the background",
"xWasUpdatedToY": "{} was updated to {}.",
"errorCheckingUpdates": "Error Checking for Updates",
"errorCheckingUpdatesNotifDescription": "A notification that shows when background update checking fails",
"appsRemoved": "Apps Removed",
"appsRemovedNotifDescription": "Notifies the user that one or more Apps were removed due to errors while loading them",
"xWasRemovedDueToErrorY": "{} was removed due to this error: {}",
"completeAppInstallation": "Complete App Installation",
"obtainiumMustBeOpenToInstallApps": "Obtainium must be open to install Apps",
"completeAppInstallationNotifDescription": "Asks the user to return to Obtainium to finish installing an App",
"checkingForUpdates": "Checking for Updates",
"checkingForUpdatesNotifDescription": "Transient notification that appears when checking for updates",
"pleaseAllowInstallPerm": "Please allow Obtainium to install Apps",
"trackOnly": "Track-Only",
"errorWithHttpStatusCode": "Error {}",
"versionCorrectionDisabled": "Version correction disabled (plugin doesn't seem to work)",
"unknown": "Unknown",
"none": "None",
"never": "Never",
"latestVersionX": "Latest Version: {}",
"installedVersionX": "Installed Version: {}",
"lastUpdateCheckX": "Last Update Check: {}",
"remove": "Remove",
"removeAppQuestion": "Remove App?",
"yesMarkUpdated": "Yes, Mark as Updated",
"fdroid": "F-Droid",
"appIdOrName": "App ID or Name",
"appWithIdOrNameNotFound": "No App was found with that ID or Name",
"reposHaveMultipleApps": "Repos may contain multiple Apps",
"fdroidThirdPartyRepo": "F-Droid Third-Party Repo",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"install": "Install",
"markInstalled": "Mark Installed",
"update": "Update",
"markUpdated": "Mark Updated",
"additionalOptions": "Additional Options",
"disableVersionDetection": "Disable Version Detection",
"noVersionDetectionExplanation": "This option should only be used for Apps where version detection does not work correctly.",
"downloadingX": "Downloading {}",
"downloadNotifDescription": "Notifies the user of the progress in downloading an App",
"noAPKFound": "No APK found",
"noVersionDetection": "No version detection",
"categorize": "Categorize",
"categories": "Categories",
"category": "Category",
"noCategory": "No Category",
"deleteCategoryQuestion": "Delete Category?",
"categoryDeleteWarning": "All Apps in {} will be set to uncategorized.",
"addCategory": "Add Category",
"label": "Label",
"tooManyRequestsTryAgainInMinutes": {
"one": "Too many requests (rate limited) - try again in {} minute",
"other": "Too many requests (rate limited) - try again in {} minutes"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "BG update checking encountered a {}, will schedule a retry check in {} minute",
"other": "BG update checking encountered a {}, will schedule a retry check in {} minutes"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "BG update checking found {} update - will notify user if needed",
"other": "BG update checking found {} updates - will notify user if needed"
},
"apps": {
"one": "{} App",
"other": "{} Apps"
},
"url": {
"one": "{} URL",
"other": "{} URLs"
},
"minute": {
"one": "{} Minute",
"other": "{} Minutes"
},
"hour": {
"one": "{} Hour",
"other": "{} Hours"
},
"day": {
"one": "{} Day",
"other": "{} Days"
},
"clearedNLogsBeforeXAfterY": {
"one": "Cleared {n} log (before = {before}, after = {after})",
"other": "Cleared {n} logs (before = {before}, after = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} and 1 more app have updates.",
"other": "{} and {} more apps have updates."
},
"xAndNMoreUpdatesInstalled": {
"one": "{} and 1 more app were updated.",
"other": "{} and {} more apps were updated."
}
}

254
assets/translations/hu.json Normal file
View File

@ -0,0 +1,254 @@
{
"invalidURLForSource": "Érvénytelen a(z) {} app URL-je",
"noReleaseFound": "Nem található megfelelő kiadás",
"noVersionFound": "Nem sikerült meghatározni a kiadás verzióját",
"urlMatchesNoSource": "Az URL nem egyezik ismert forrással",
"cantInstallOlderVersion": "Nem telepíthető egy app régebbi verziója",
"appIdMismatch": "A letöltött csomagazonosító nem egyezik a meglévő app azonosítóval",
"functionNotImplemented": "Ez az osztály nem valósította meg ezt a függvényt",
"placeholder": "Helykitöltő",
"someErrors": "Néhány hiba történt",
"unexpectedError": "Váratlan hiba",
"ok": "Oké",
"and": "és",
"startedBgUpdateTask": "Háttérfrissítés ellenőrzési feladat elindítva",
"bgUpdateIgnoreAfterIs": "Háttérfrissítés ignoreAfter a következő: {}",
"startedActualBGUpdateCheck": "Elkezdődött a tényleges BG frissí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: {}",
"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",
"githubPATLinkText": "A GitHub PAT-okról",
"includePrereleases": "Tartalmazza az előzetes kiadásokat",
"fallbackToOlderReleases": "Visszatérés a régebbi kiadásokhoz",
"filterReleaseTitlesByRegEx": "A kiadás címeinek szűrése reguláris kifejezéssel",
"invalidRegEx": "Érvénytelen reguláris kifejezés",
"noDescription": "Nincs leírás",
"cancel": "Mégse",
"continue": "Tovább",
"requiredInBrackets": "(Kötlező)",
"dropdownNoOptsError": "HIBA: A LEDOBÁST LEGALÁBB EGY OPCIÓBAN KELL RENDELNI",
"colour": "Szín",
"githubStarredRepos": "GitHub Csillagozott Repo-k",
"uname": "Felh.név",
"wrongArgNum": "Rossz számú argumentumot adott meg",
"xIsTrackOnly": "{} csak nyomon követhető",
"source": "Forrás",
"app": "App",
"appsFromSourceAreTrackOnly": "Az ebből a forrásból származó alkalmazások 'Csak nyomon követhetőek'.",
"youPickedTrackOnly": "A 'Csak követés' opciót választotta.",
"trackOnlyAppDescription": "Az alkalmazás frissítéseit nyomon követi, de az Obtainium nem tudja letölteni vagy telepíteni.",
"cancelled": "Törölve",
"appAlreadyAdded": "Az app már hozzáadva",
"alreadyUpToDateQuestion": "Az app már naprakész?",
"addApp": "App hozzáadás",
"appSourceURL": "App forrás URL",
"error": "Hiba",
"add": "Hozzáadás",
"searchSomeSourcesLabel": "Keresés (csak egyes források)",
"search": "Keresés",
"additionalOptsFor": "További lehetőségek a következőhöz: {}",
"supportedSourcesBelow": "Támogatott források:",
"trackOnlyInBrackets": "(Csak nyomonkövetés)",
"searchableInBrackets": "(Kereshető)",
"appsString": "Appok",
"noApps": "Nincs App",
"noAppsForFilter": "Nincsenek appok a szűrőhöz",
"byX": "By {}",
"percentProgress": "Folyamat: {}%",
"pleaseWait": "Kis türelmet",
"updateAvailable": "Frissítés elérhető",
"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.",
"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",
"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",
"markSelectedAppsUpdated": "Jelölje meg a kiválasztott appokat frissítettként",
"pinToTop": "Rögzítés a felülre",
"unpinFromTop": "Eltávolít felülről",
"resetInstallStatusForSelectedAppsQuestion": "Visszaállítja a kiválasztott appok telepítési állapotát?",
"installStatusOfXWillBeResetExplanation": "A kiválasztott appok telepítési állapota visszaáll.\n\nEz akkor segíthet, ha az Obtainiumban megjelenített app verzió hibás, frissítések vagy egyéb problémák miatt.",
"shareSelectedAppURLs": "Ossza meg a kiválasztott app URL címeit",
"resetInstallStatus": "Telepítési állapot visszaállítása",
"more": "További",
"removeOutdatedFilter": "Távolítsa el az elavult alkalmazásszűrőt",
"showOutdatedOnly": "Csak az elavult alkalmazások megjelenítése",
"filter": "Szűrő",
"filterActive": "Szűrő *",
"filterApps": "Appok szűrése",
"appName": "App név",
"author": "Szerző",
"upToDateApps": "Naprakész appok",
"nonInstalledApps": "Nem telepített appok",
"importExport": "Import/Export",
"settings": "Beállítások",
"exportedTo": "Exportálva ide {}",
"obtainiumExport": "Obtainium Export",
"invalidInput": "Hibás bemenet",
"importedX": "Importálva innen {}",
"obtainiumImport": "Obtainium Import",
"importFromURLList": "Importálás URL listából",
"searchQuery": "Keresési lekérdezés",
"appURLList": "App URL lista",
"line": "Sor",
"searchX": "Keresés {}",
"noResults": "Nincs találat",
"importX": "Import {}",
"importedAppsIdDisclaimer": "Előfordulhat, hogy az importált appok helytelenül \"Nincs telepítve\" jelzéssel jelennek meg.\nA probléma megoldásához telepítse újra őket az Obtainiumon keresztül.\nEz nem érinti az alkalmazásadatokat.\n\nCsak az URL-ekre és a harmadik féltől származó importálási módszerekre vonatkozik..",
"importErrors": "Importálási hibák",
"importedXOfYApps": "{}/{} app importálva.",
"followingURLsHadErrors": "A következő URL-ek hibákat tartalmaztak:",
"okay": "Oké",
"selectURL": "Válassza ki az URL-t",
"selectURLs": "Kiválasztott URL-ek",
"pick": "Válasszon",
"theme": "Téma",
"dark": "Söét",
"light": "Világos",
"followSystem": "Rendszer szerint",
"obtainium": "Obtainium",
"materialYou": "Material You",
"appSortBy": "App rendezés...",
"authorName": "Szerző/Név",
"nameAuthor": "Név/Szerző",
"asAdded": "Mint hozzáadott",
"appSortOrder": "Appok rendezése",
"ascending": "Emelkedő",
"descending": "Csökkenő",
"bgUpdateCheckInterval": "Háttérfrissítés ellenőrzési időköz",
"neverManualOnly": "Soha csak manuális",
"appearance": "Megjelenés",
"showWebInAppView": "Forrás megjelenítése az Appok nézetben",
"pinUpdates": "Frissítések kitűzése az App nézet tetejére",
"updates": "Frissítések",
"sourceSpecific": "Forrás-specifikus",
"appSource": "App forrás",
"noLogs": "Nincsenek naplók",
"appLogs": "App naplók",
"close": "Bezár",
"share": "Megoszt",
"appNotFound": "App nem található",
"obtainiumExportHyphenatedLowercase": "obtainium-export",
"pickAnAPK": "Válasszon egy APK-t",
"appHasMoreThanOnePackage": "{} egynél több csomaggal rendelkezik:",
"deviceSupportsXArch": "Eszköze támogatja a {} CPU architektúrát.",
"deviceSupportsFollowingArchs": "Az eszköze a következő CPU architektúrákat támogatja:",
"warning": "Figyelem",
"sourceIsXButPackageFromYPrompt": "Az alkalmazás forrása „{}”, de a kiadási csomag innen származik: „{}”. Folytatja?",
"updatesAvailable": "Frissítések érhetők el",
"updatesAvailableNotifDescription": "Értesíti a felhasználót, hogy frissítések állnak rendelkezésre egy vagy több, az Obtainium által nyomon követett alkalmazáshoz",
"noNewUpdates": "Nincsenek új frissítések.",
"xHasAnUpdate": "{} 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",
"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",
"obtainiumMustBeOpenToInstallApps": "Az Obtainiumnak megnyitva kell lennie az alkalmazások telepítéséhez",
"completeAppInstallationNotifDescription": "Megkéri a felhasználót, hogy térjen vissza az Obtainiumhoz, hogy befejezze az alkalmazás telepítését",
"checkingForUpdates": "Frissítések keresése",
"checkingForUpdatesNotifDescription": "Átmeneti értesítés, amely a frissítések keresésekor jelenik meg",
"pleaseAllowInstallPerm": "Kérjük, engedélyezze az Obtainiumnak az alkalmazások telepítését",
"trackOnly": "Csak követés",
"errorWithHttpStatusCode": "Hiba {}",
"versionCorrectionDisabled": "Verzió korrekció letiltva (úgy tűnik, a beépülő modul nem működik)",
"unknown": "Ismeretlen",
"none": "Egyik sem",
"never": "Soha",
"latestVersionX": "Legújabb verzió: {}",
"installedVersionX": "Telepített verzió: {}",
"lastUpdateCheckX": "Frissítés ellenőrizve: {}",
"remove": "Eltávolítás",
"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",
"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",
"deleteCategoryQuestion": "Delete Category?",
"categoryDeleteWarning": "All Apps in {} will be set to uncategorized.",
"addCategory": "Add Category",
"label": "Label",
"tooManyRequestsTryAgainInMinutes": {
"one": "Túl sok kérés (korlátozott arány) próbálja újra {} perc múlva",
"other": "Túl sok kérés (korlátozott arány) próbálja újra {} perc múlva"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "A háttérfrissítések ellenőrzése {}-t észlelt, {} perc múlva ütemezi az újrapróbálkozást",
"other": "A háttérfrissítések ellenőrzése {}-t észlelt, {} perc múlva ütemezi az újrapróbálkozást"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "A háttérfrissítés ellenőrzése {} frissítést talált szükség esetén értesíti a felhasználót",
"other": "A háttérfrissítés ellenőrzése {} frissítést talált szükség esetén értesíti a felhasználót"
},
"apps": {
"one": "{} app",
"other": "{} app"
},
"url": {
"one": "{} URL",
"other": "{} URL"
},
"minute": {
"one": "{} perc",
"other": "{} perc"
},
"hour": {
"one": "{} óra",
"other": "{} óra"
},
"day": {
"one": "{} nap",
"other": "{} nap"
},
"clearedNLogsBeforeXAfterY": {
"one": "{n} napló törölve (előtte = {előtte}, utána = {utána})",
"other": "{n} napló törölve (előtte = {előtte}, utána = {utána})"
},
"xAndNMoreUpdatesAvailable": {
"one": "A(z) {} és 1 további alkalmazás frissítéseket kapott.",
"other": "{} és további {} alkalmazás frissítéseket kapott."
},
"xAndNMoreUpdatesInstalled": {
"one": "A(z) {} és 1 további alkalmazás frissítve.",
"other": "{} és további {} alkalmazás frissítve."
}
}

254
assets/translations/it.json Normal file
View File

@ -0,0 +1,254 @@
{
"invalidURLForSource": "URL dell'App da {} non valido",
"noReleaseFound": "Impossibile trovare una release adatta",
"noVersionFound": "Impossibile determinare la versione della release",
"urlMatchesNoSource": "L'URL non corrisponde ad alcuna fonte conosciuta",
"cantInstallOlderVersion": "Impossibile installare una versione precedente di un'App",
"appIdMismatch": "L'ID del pacchetto scaricato non corrisponde all'ID dell'App esistente",
"functionNotImplemented": "Questa classe non ha implementato questa funzione",
"placeholder": "Segnaposto",
"someErrors": "Si sono verificati degli errori",
"unexpectedError": "Errore imprevisto",
"ok": "Va bene",
"and": "e",
"startedBgUpdateTask": "Avviata l'attività di controllo degli aggiornamenti in background",
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
"startedActualBGUpdateCheck": "Avviato il controllo effettivo degli aggiornamenti in background",
"bgUpdateTaskFinished": "Terminata l'attività di controllo degli aggiornamenti in background",
"firstRun": "Questo è il primo avvio di sempre di Obtainium",
"settingUpdateCheckIntervalTo": "Imposta l'intervallo di aggiornamento a {}",
"githubPATLabel": "GitHub Personal Access Token (aumenta il 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 precedenti",
"filterReleaseTitlesByRegEx": "Filtra release con espressioni regolari",
"invalidRegEx": "Espressione regolare non valida",
"noDescription": "Descrizione assente",
"cancel": "Annulla",
"continue": "Continua",
"requiredInBrackets": "(richiesto)",
"dropdownNoOptsError": "ERRORE: LA TENDINA DEVE AVERE ALMENO UN'OPZIONE",
"colour": "Colore",
"githubStarredRepos": "i repository stellati da GitHub",
"uname": "Username",
"wrongArgNum": "Numero di argomenti forniti errato",
"xIsTrackOnly": "{} è Solo-Monitoraggio",
"source": "Fonte",
"app": "App",
"appsFromSourceAreTrackOnly": "Le App da questa fonte sono in modalità 'Solo-Monitoraggio'.",
"youPickedTrackOnly": "Hai selezionato 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?",
"addApp": "Aggiungi App",
"appSourceURL": "URL della fonte dell'App",
"error": "Errore",
"add": "Aggiungi",
"searchSomeSourcesLabel": "Cerca (solo per alcune fonti)",
"search": "Cerca",
"additionalOptsFor": "Opzioni aggiuntive per {}",
"supportedSourcesBelow": "Fonti supportate:",
"trackOnlyInBrackets": "(Solo-Monitoraggio)",
"searchableInBrackets": "(ricercabile)",
"appsString": "App",
"noApps": "Nessuna App",
"noAppsForFilter": "Nessuna App per i filtri selezionati",
"byX": "Da {}",
"percentProgress": "Progresso: {}%",
"pleaseWait": "Attendere prego",
"updateAvailable": "Aggiornamento disponibile",
"estimateInBracketsShort": "(Prev.)",
"notInstalled": "Non installato",
"estimateInBrackets": "(Previsto)",
"selectAll": "Seleziona tutto",
"deselectN": "Deseleziona {}",
"xWillBeRemovedButRemainInstalled": "{} sarà rimosso da Obtainium ma resterà installato sul dispositivo.",
"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).",
"markXSelectedAppsAsUpdated": "Contrassegnare le {} App selezionate come aggiornate?",
"no": "No",
"yes": "Sì",
"markSelectedAppsUpdated": "Contrassegna le App selezionate come aggiornate",
"pinToTop": "Fissa in alto",
"unpinFromTop": "Rimuovi dall'alto",
"resetInstallStatusForSelectedAppsQuestion": "Ripristinare lo stato d'installazione delle App selezionate?",
"installStatusOfXWillBeResetExplanation": "Lo stato d'installazione di ogni App selezionata sarà ripristinato.\n\nCiò può essere d'aiuto nel caso in cui la versione mostrata dell'App in Obtainium non è corretta a causa di un aggiornamento fallito o di altri problemi.",
"shareSelectedAppURLs": "Condividi gli URL delle App selezionate",
"resetInstallStatus": "Ripristina lo stato d'installazione",
"more": "Di più",
"removeOutdatedFilter": "Rimuovi il filtro per le App non aggiornate",
"showOutdatedOnly": "Mostra solo le App non aggiornate",
"filter": "Filtri",
"filterActive": "Filtri *",
"filterApps": "Filtra App",
"appName": "Nome dell'App",
"author": "Autore",
"upToDateApps": "App aggiornate",
"nonInstalledApps": "App non installate",
"importExport": "Importa/Esporta",
"settings": "Impostazioni",
"exportedTo": "Esportato in {}",
"obtainiumExport": "Esporta da Obtainium",
"invalidInput": "Inserimento non valido",
"importedX": "Importato {}",
"obtainiumImport": "Importa in Obtainium",
"importFromURLList": "Importa da lista di URL",
"searchQuery": "Stringa di ricerca",
"appURLList": "Lista di URL delle App",
"line": "Linea",
"searchX": "Cerca su {}",
"noResults": "Nessun risultato trovato",
"importX": "Importa {}",
"importedAppsIdDisclaimer": "Le App importate potrebbero essere visualizzate erroneamente come \"Non installate\".\nPer risolvere il problema, reinstallale con Obtainium.\nQuesto non dovrebbe influire sui dati delle App.\n\nRiguarda solo l'URL e i metodi di importazione di terze parti.",
"importErrors": "Errori dell'importazione",
"importedXOfYApps": "{} App di {} importate.",
"followingURLsHadErrors": "I seguenti URL contengono errori:",
"okay": "Va bene",
"selectURL": "Seleziona l'URL",
"selectURLs": "Seleziona gli URL",
"pick": "Seleziona",
"theme": "Tema",
"dark": "Scuro",
"light": "Chiaro",
"followSystem": "Segui sistema",
"obtainium": "Obtainium",
"materialYou": "Material You",
"appSortBy": "App ordinate per",
"authorName": "Autore/Nome",
"nameAuthor": "Nome/Autore",
"asAdded": "Data di aggiunta",
"appSortOrder": "Ordinamento",
"ascending": "Ascendente",
"descending": "Discendente",
"bgUpdateCheckInterval": "Intervallo di controllo degli aggiornamenti in background",
"neverManualOnly": "Mai - Solo manuale",
"appearance": "Aspetto",
"showWebInAppView": "Mostra pagina web dell'App se selezionata",
"pinUpdates": "Fissa in alto gli aggiornamenti disponibili",
"updates": "Aggiornamenti",
"sourceSpecific": "Specifiche per la fonte",
"appSource": "Sorgente dell'App",
"noLogs": "Nessun log",
"appLogs": "Log dell'App",
"close": "Chiudi",
"share": "Condividi",
"appNotFound": "App non trovata",
"obtainiumExportHyphenatedLowercase": "esportazione-obtainium",
"pickAnAPK": "Seleziona un APK",
"appHasMoreThanOnePackage": "{} offre più di un pacchetto:",
"deviceSupportsXArch": "Il tuo dispositivo supporta l'architettura {} della CPU.",
"deviceSupportsFollowingArchs": "Il tuo dispositivo 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",
"noNewUpdates": "Nessun nuovo aggiornamento.",
"xHasAnUpdate": "{} è stato aggiornato.",
"appsUpdated": "App aggiornate",
"appsUpdatedNotifDescription": "Avvisa l'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",
"xWasRemovedDueToErrorY": "{} è stata rimosso a causa di questo errore: {}",
"completeAppInstallation": "Completa l'installazione dell'App",
"obtainiumMustBeOpenToInstallApps": "Obtainium deve essere aperto per poter installare le App",
"completeAppInstallationNotifDescription": "Chiede all'utente di riaprire Obtainium per terminare l'installazione di un App",
"checkingForUpdates": "Controllo degli aggiornamenti in corso",
"checkingForUpdatesNotifDescription": "Notifica transitoria che appare durante la verifica degli aggiornamenti",
"pleaseAllowInstallPerm": "Per favore permetti a Obtainium di installare le App",
"trackOnly": "Solo-Monitoraggio",
"errorWithHttpStatusCode": "Errore {}",
"versionCorrectionDisabled": "Correzione della versione disabilitata (il plugin non pare funzionare)",
"unknown": "Sconosciuto",
"none": "Nessuno",
"never": "Mai",
"latestVersionX": "Ultima versione: {}",
"installedVersionX": "Versione installata: {}",
"lastUpdateCheckX": "Ultimo controllo degli aggiornamenti: {}",
"remove": "Rimuovi",
"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 F-Droid di terze parti",
"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",
"deleteCategoryQuestion": "Delete Category?",
"categoryDeleteWarning": "All Apps in {} will be set to uncategorized.",
"addCategory": "Add Category",
"label": "Label",
"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"
},
"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"
},
"apps": {
"one": "{} App",
"other": "{} App"
},
"url": {
"one": "{} URL",
"other": "{} URL"
},
"minute": {
"one": "{} minuto",
"other": "{} minuti"
},
"hour": {
"one": "{} ora",
"other": "{} ore"
},
"day": {
"one": "{} giorno",
"other": "{} giorni"
},
"clearedNLogsBeforeXAfterY": {
"one": "Pulito {n} log (prima = {before}, dopo = {after})",
"other": "Puliti {n} log (prima = {before}, dopo = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} e un'altra App hanno aggiornamenti disponibili.",
"other": "{} e altre {} App hanno aggiornamenti disponibili."
},
"xAndNMoreUpdatesInstalled": {
"one": "{} e un'altra App sono state aggiornate.",
"other": "{} e altre {} App sono state aggiornate."
}
}

254
assets/translations/ja.json Normal file
View File

@ -0,0 +1,254 @@
{
"invalidURLForSource": "{}は有効なソースURLではありません",
"noReleaseFound": "適切なリリースが見つかりませんでした",
"noVersionFound": "リリースバージョンを特定できませんでした",
"urlMatchesNoSource": "URLが既知のソースと一致しません",
"cantInstallOlderVersion": "旧バージョンのアプリをインストールできません",
"appIdMismatch": "ダウンロードしたパッケージのIDが既存のApp IDと一致しません",
"functionNotImplemented": "このクラスはこの機能を実装していません",
"placeholder": "プレースホルダー",
"someErrors": "いくつかのエラーが発生しました",
"unexpectedError": "予期せぬエラーが発生しました",
"ok": "OK",
"and": "と",
"startedBgUpdateTask": "バックグラウンドのアップデート確認タスクを開始",
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
"startedActualBGUpdateCheck": "実際のバックグラウンドのアップデート確認を開始",
"bgUpdateTaskFinished": "バックグラウンドのアップデート確認タスクを終了",
"firstRun": "これがObtainiumの最初の実行です",
"settingUpdateCheckIntervalTo": "確認間隔を{}に設定する",
"githubPATLabel": "GitHub パーソナルアクセストークン (レート制限の引き上げ)",
"githubPATHint": "PATは次の形式でなければなりません: ユーザー名:トークン",
"githubPATFormat": "ユーザー名:トークン",
"githubPATLinkText": "GitHub PATsについて",
"includePrereleases": "プレリリースを含む",
"fallbackToOlderReleases": "旧リリースへのフォールバック",
"filterReleaseTitlesByRegEx": "正規表現でリリースタイトルを絞り込む",
"invalidRegEx": "無効な正規表現",
"noDescription": "説明はありません",
"cancel": "キャンセル",
"continue": "続ける",
"requiredInBrackets": "(必須)",
"dropdownNoOptsError": "エラー: ドロップダウンには、少なくとも1つのオプションが必要です",
"colour": "カラー",
"githubStarredRepos": "Githubでスターしたリポジトリ",
"uname": "ユーザー名",
"wrongArgNum": "提供する引数の数が間違っています",
"xIsTrackOnly": "{} は「追跡のみ」です",
"source": "ソース",
"app": "アプリ",
"appsFromSourceAreTrackOnly": "このソースからのアプリは「追跡のみ」です。",
"youPickedTrackOnly": "「追跡のみ」を選択しています",
"trackOnlyAppDescription": "アプリのアップデートは追跡されますが、Obtainiumはアプリのダウンロードやインストールをすることはできません。",
"cancelled": "キャンセルしました",
"appAlreadyAdded": "アプリはすでに追加されています",
"alreadyUpToDateQuestion": "アプリはすでに最新ですか?",
"addApp": "アプリの追加",
"appSourceURL": "アプリのソースURL",
"error": "エラー",
"add": "追加",
"searchSomeSourcesLabel": "検索 (一部ソースのみ)",
"search": "検索",
"additionalOptsFor": "{}の追加オプション",
"supportedSourcesBelow": "対応するソース:",
"trackOnlyInBrackets": "(追跡のみ)",
"searchableInBrackets": "(検索可能)",
"appsString": "アプリ",
"noApps": "アプリはありません",
"noAppsForFilter": "フィルターに一致するアプリはありません",
"byX": "by {}",
"percentProgress": "ダウンロード中: {}%",
"pleaseWait": "しばらくお待ちください",
"updateAvailable": "アップデートが利用可能",
"estimateInBracketsShort": "(推定)",
"notInstalled": "未インストール",
"estimateInBrackets": "(推定)",
"selectAll": "すべて選択",
"deselectN": "{}件を選択解除",
"xWillBeRemovedButRemainInstalled": "{}はObtainiumから削除されますが、デバイスにはインストールされたままです。",
"removeSelectedAppsQuestion": "選択したアプリを削除しますか?",
"removeSelectedApps": "選択したアプリを削除する",
"updateX": "{}をアップデートする",
"installX": "{}をインストールする",
"markXTrackOnlyAsUpdated": "{}\n(追跡のみ)\nをアップデート済みとしてマークする",
"changeX": "{}を変更する",
"installUpdateApps": "アプリのインストール/アップデート",
"installUpdateSelectedApps": "選択したアプリのインストール/アップデート",
"onlyWorksWithNonEVDApps": "インストール状況を自動検出できないアプリ(一般的でないもの)のみ動作します。",
"markXSelectedAppsAsUpdated": "{}個の選択したアプリをアップデート済みとしてマークしますか?",
"no": "いいえ",
"yes": "はい",
"markSelectedAppsUpdated": "選択したアプリをアップデート済みとしてマークする",
"pinToTop": "トップに固定",
"unpinFromTop": "トップから固定解除",
"resetInstallStatusForSelectedAppsQuestion": "選択したアプリのインストール状態をリセットしますか?",
"installStatusOfXWillBeResetExplanation": "選択したアプリのインストール状態がリセットされます。\n\nアップデートに失敗するなどして、Obtainiumに表示されるアプリのバージョンが正しくない場合に役立ちます。",
"shareSelectedAppURLs": "選択したアプリのURLを共有する",
"resetInstallStatus": "インストール状態をリセットする",
"more": "もっと見る",
"removeOutdatedFilter": "アップデートが存在するアプリのフィルターを解除",
"showOutdatedOnly": "アップデートが存在するアプリのみ表示する",
"filter": "フィルター",
"filterActive": "フィルター *",
"filterApps": "アプリを絞り込む",
"appName": "アプリ名",
"author": "作者",
"upToDateApps": "最新のアプリ",
"nonInstalledApps": "未インストールのアプリ",
"importExport": "インポート/エクスポート",
"settings": "設定",
"exportedTo": "{}にエクスポートしました",
"obtainiumExport": "Obtainium エクスポート",
"invalidInput": "無効な入力",
"importedX": "{}をインポートしました",
"obtainiumImport": "Obtainium インポート",
"importFromURLList": "URLリストからのインポート",
"searchQuery": "検索キーワード",
"appURLList": "アプリのURLリスト",
"line": "行",
"searchX": "{}で検索",
"noResults": "結果は見つかりませんでした",
"importX": "{}をインポートする",
"importedAppsIdDisclaimer": "インポートしたアプリが「未インストール」と表示されることがあります。\nこれを解決するには、Obtainiumから再インストールしてください。\nアプリのデータには影響しません。\n\nURLとサードパーティーのインポートメソッドにのみ影響します。",
"importErrors": "インポートエラー",
"importedXOfYApps": "{} / {} アプリをインポートしました",
"followingURLsHadErrors": "以下のURLでエラーが発生しました:",
"okay": "OK",
"selectURL": "URLを選択",
"selectURLs": "URLを選択",
"pick": "選択",
"theme": "テーマ",
"dark": "ダーク",
"light": "ライト",
"followSystem": "システムに従う",
"obtainium": "Obtainium",
"materialYou": "Material You",
"appSortBy": "アプリの並び方",
"authorName": "作者名/アプリ名",
"nameAuthor": "アプリ名/作者名",
"asAdded": "追加順",
"appSortOrder": "並び順",
"ascending": "昇順",
"descending": "降順",
"bgUpdateCheckInterval": "バックグラウンドでのアップデート確認の間隔",
"neverManualOnly": "手動",
"appearance": "外観",
"showWebInAppView": "アプリビューにソースウェブページを表示する",
"pinUpdates": "アップデートがあるアプリをトップに固定する",
"updates": "更新",
"sourceSpecific": "Github アクセストークン",
"appSource": "アプリのソース",
"noLogs": "ログはありません",
"appLogs": "アプリのログ",
"close": "閉じる",
"share": "共有",
"appNotFound": "アプリが見つかりません",
"obtainiumExportHyphenatedLowercase": "obtainium-エクスポート",
"pickAnAPK": "APKを選ぶ",
"appHasMoreThanOnePackage": "{}は複数のパッケージが存在します: ",
"deviceSupportsXArch": "お使いのデバイスは{} CPUアーキテクチャに対応しています。",
"deviceSupportsFollowingArchs": "お使いのデバイスは、以下のCPUアーキテクチャをサポートしています:",
"warning": "警告",
"sourceIsXButPackageFromYPrompt": "アプリのソースは'{}'ですが、リリースパッケージは'{}'から来ています。続行しますか?",
"updatesAvailable": "アップデートが利用可能",
"updatesAvailableNotifDescription": "Obtainiumが追跡している1つまたは複数のアプリのアップデートが利用可能であることをユーザーに通知する",
"noNewUpdates": "新しいアップデートはありません",
"xHasAnUpdate": "{}のアップデートが利用可能です",
"appsUpdated": "アプリをアップデートしました",
"appsUpdatedNotifDescription": "1つまたは複数のAppのアップデートがバックグラウンドで適用されたことをユーザーに通知する",
"xWasUpdatedToY": "{}が{}にアップデートされました",
"errorCheckingUpdates": "アップデート確認中のエラー",
"errorCheckingUpdatesNotifDescription": "バックグラウンドでのアップデート確認に失敗した際に表示される通知",
"appsRemoved": "削除されたアプリ",
"appsRemovedNotifDescription": "アプリの読み込み中にエラーが発生したため、1つまたは複数のアプリが削除されたことをユーザーに通知する",
"xWasRemovedDueToErrorY": "このエラーのため、{}は削除されました: {}",
"completeAppInstallation": "アプリのインストールを完了する",
"obtainiumMustBeOpenToInstallApps": "アプリをインストールするにはObtainiumを開いている必要があります。",
"completeAppInstallationNotifDescription": "アプリのインストールを完了するために、Obtainiumに戻る必要があります。",
"checkingForUpdates": "アップデートを確認中",
"checkingForUpdatesNotifDescription": "アップデートを確認する際に表示される一時的な通知",
"pleaseAllowInstallPerm": "Obtainiumによるアプリのインストールを許可してください。",
"trackOnly": "追跡のみ",
"errorWithHttpStatusCode": "エラー {}",
"versionCorrectionDisabled": "バージョン補正無効 (プラグインが動作していません)",
"unknown": "不明",
"none": "なし",
"never": "しない",
"latestVersionX": "最新のバージョン: {}",
"installedVersionX": "インストールされたバージョン: {}",
"lastUpdateCheckX": "最終アップデート確認: {}",
"remove": "削除",
"removeAppQuestion": "アプリを削除しますか?",
"yesMarkUpdated": "はい、アップデート済みとしてマークします",
"fdroid": "F-Droid",
"appIdOrName": "アプリのIDまたは名前",
"appWithIdOrNameNotFound": "そのIDや名前を持つアプリは見つかりませんでした",
"reposHaveMultipleApps": "リポジトリには複数のアプリが含まれることがあります",
"fdroidThirdPartyRepo": "F-Droid Third-Party Repo",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"install": "インストール",
"markInstalled": "インストール済みとしてマークする",
"update": "アップデート",
"markUpdated": "アップデート済みとしてマークする",
"additionalOptions": "追加のオプション",
"disableVersionDetection": "バージョン検出を無効にする",
"noVersionDetectionExplanation": "このオプションは、バージョン検出が正しく機能しないアプリにのみ使用する必要があります。",
"downloadingX": "{} をダウンロード中",
"downloadNotifDescription": "アプリのダウンロード状況を通知する",
"noAPKFound": "APKが見つかりません",
"noVersionDetection": "バージョン検出を行わない",
"categorize": "カテゴライズ",
"categories": "カテゴリ",
"category": "カテゴリ",
"noCategory": "カテゴリなし",
"deleteCategoryQuestion": "カテゴリを削除しますか?",
"categoryDeleteWarning": "「{}」内のすべてのアプリは未分類に設定されます。",
"addCategory": "カテゴリを追加",
"label": "ラベル",
"tooManyRequestsTryAgainInMinutes": {
"one": "リクエストが多すぎます(レート制限)- {}分後に再試行してください",
"other": "リクエストが多すぎます(レート制限)- {}分後に再試行してください"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "バックグラウンドでのアップデート確認で {} の問題が発生, {} 分後に再試行します",
"other": "バックグラウンドでのアップデート確認で {} の問題が発生, {} 分後に再試行します"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "バックグラウンドでのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します",
"other": "バックグラウンドでのアップデート確認で {} 個のアップデートを発見 - 必要に応じてユーザーに通知します"
},
"apps": {
"one": "{}個のアプリ",
"other": "{}個のアプリ"
},
"url": {
"one": "{}個のURL",
"other": "{}個のURL"
},
"minute": {
"one": "{}分",
"other": "{}分"
},
"hour": {
"one": "{}時間",
"other": "{}時間"
},
"day": {
"one": "{}日",
"other": "{}日"
},
"clearedNLogsBeforeXAfterY": {
"one": "{n}個のログをクリアしました (前 = {before}, 後 = {after})",
"other": "{n}個のログをクリアしました (前 = {before}, 後 = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{}とさらに{}個のアプリのアップデートが利用可能です",
"other": "{}とさらに{}個のアプリのアップデートが利用可能です"
},
"xAndNMoreUpdatesInstalled": {
"one": "{}とさらに{}個のアプリがアップデートされました",
"other": "{}とさらに{}個のアプリがアップデートされました"
}
}

254
assets/translations/zh.json Normal file
View File

@ -0,0 +1,254 @@
{
"invalidURLForSource": "不是一个有效的 {} URL",
"noReleaseFound": "找不到合适的更新",
"noVersionFound": "无法确定更新版本",
"urlMatchesNoSource": "URL 与已知来源不符",
"cantInstallOlderVersion": "无法安装旧版应用程序",
"appIdMismatch": "下载的软件包名与现有的应用程序包名不一致",
"functionNotImplemented": "该类没有实现此功能",
"placeholder": "占位符",
"someErrors": "出现了一些错误",
"unexpectedError": "意外错误",
"ok": "好的",
"and": "和",
"startedBgUpdateTask": "开始后台检查更新任务",
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
"startedActualBGUpdateCheck": "后台检查更新已开始",
"bgUpdateTaskFinished": "后台检查更新已完成",
"firstRun": "这是你第一次运行 Obtainium",
"settingUpdateCheckIntervalTo": "设置检查更新间隔为 {}",
"githubPATLabel": "GitHub 个人访问令牌 (提高 API 限制)",
"githubPATHint": "个人访问令牌必须为: username:token 形式",
"githubPATFormat": "username:token",
"githubPATLinkText": "关于 GitHub 个人访问令牌",
"includePrereleases": "包含预发布版",
"fallbackToOlderReleases": "回退到旧版",
"filterReleaseTitlesByRegEx": "使用正则以过滤发布标题",
"invalidRegEx": "表达式无效",
"noDescription": "无描述",
"cancel": "取消",
"continue": "继续",
"requiredInBrackets": "(必须)",
"dropdownNoOptsError": "错误:下拉菜单必须至少有一个选项",
"colour": "颜色",
"githubStarredRepos": "GitHub 已星标仓库",
"uname": "用户名",
"wrongArgNum": "提供了错误的参数数量",
"xIsTrackOnly": "{} 仅追踪",
"source": "源码",
"app": "应用程序",
"appsFromSourceAreTrackOnly": "来自此来源的应用为仅追踪",
"youPickedTrackOnly": "你已选择仅追踪选项",
"trackOnlyAppDescription": "该应用程序将被跟踪更新,但 Obtainium 无法下载或安装它",
"cancelled": "已取消",
"appAlreadyAdded": "此应用程序已被添加",
"alreadyUpToDateQuestion": "应用已是最新?",
"addApp": "添加应用",
"appSourceURL": "应用来源 URL",
"error": "错误",
"add": "添加",
"searchSomeSourcesLabel": "搜索 (仅部分来源)",
"search": "搜索",
"additionalOptsFor": "{} 的更多选项",
"supportedSourcesBelow": "受支持的来源:",
"trackOnlyInBrackets": "(仅追踪)",
"searchableInBrackets": "(可被搜索)",
"appsString": "应用程序",
"noApps": "无应用程序",
"noAppsForFilter": "没有应用可被过滤",
"byX": "来自 {}",
"percentProgress": "进度: {}%",
"pleaseWait": "请等待...",
"updateAvailable": "更新可用",
"estimateInBracketsShort": "(预计.)",
"notInstalled": "未安装",
"estimateInBrackets": "(预计)",
"selectAll": "全选",
"deselectN": "取消选择 {}",
"xWillBeRemovedButRemainInstalled": "{} 将被从 Obtainium 中删除,但仍安装在设备上。",
"removeSelectedAppsQuestion": "删除已选择的应用程序吗?",
"removeSelectedApps": "删除已选择的应用程序",
"updateX": "更新 {}",
"installX": "安装 {}",
"markXTrackOnlyAsUpdated": "将仅追踪编辑为已更新",
"changeX": "更改 {}",
"installUpdateApps": "安装/更新应用程序",
"installUpdateSelectedApps": "安装/更新已选择的应用程序",
"onlyAppliesToInstalledAndOutdatedApps": "'只适用于已安装但已过时的应用程序",
"markXSelectedAppsAsUpdated": "将已选择的 {} 个应用程序标记为已更新?",
"no": "不要",
"yes": "好的",
"markSelectedAppsUpdated": "标记已选择的应用程序为已更新",
"pinToTop": "置顶",
"unpinFromTop": "取消置顶",
"resetInstallStatusForSelectedAppsQuestion": "为已选择的应用程序重置安装状态吗?",
"installStatusOfXWillBeResetExplanation": "当 Obtainium 中显示的应用程序版本由于更新失败或其他问题而不正确时,这将有助于重置任何选定应用程序的安装状态。",
"shareSelectedAppURLs": "分享已选择的应用程序 URL",
"resetInstallStatus": "重置安装状态",
"more": "更多",
"removeOutdatedFilter": "删除过时的应用程序过滤器",
"showOutdatedOnly": "只显示过时的应用程序",
"filter": "过滤器",
"filterActive": "过滤器 *",
"filterApps": "过滤应用",
"appName": "应用名称",
"author": "作者",
"upToDateApps": "已更新的应用程序",
"nonInstalledApps": "未安装的应用程序",
"importExport": "导入/导出",
"settings": "设置",
"exportedTo": "导出到 {}",
"obtainiumExport": "Obtainium 导出",
"invalidInput": "无效输入",
"importedX": "已导出到 {}",
"obtainiumImport": "Obtainium 导入",
"importFromURLList": "从 URL 列表导入",
"searchQuery": "搜索查询",
"appURLList": "应用 URL 列表",
"line": "行",
"searchX": "搜索 {}",
"noResults": "无结果",
"importX": "导入 {}",
"importedAppsIdDisclaimer": "导入的应用程序可能显示为未安装。要解决这个问题,请通过 Obtainium 重新安装它们。",
"importErrors": "导入错误",
"importedXOfYApps": "{} 中的 {} 个应用已导入",
"followingURLsHadErrors": "以下 URL 有错误:",
"okay": "好的",
"selectURL": "已选择的 URL",
"selectURLs": "已选择的 URL",
"pick": "选择",
"theme": "主题",
"dark": "深色",
"light": "浅色",
"followSystem": "跟随系统",
"obtainium": "Obtainium",
"materialYou": "Material You",
"appSortBy": "排列方式",
"authorName": "作者 / 名字",
"nameAuthor": "名字 / 作者",
"asAdded": "添加顺序",
"appSortOrder": "排列顺序",
"ascending": "升序",
"descending": "降序",
"bgUpdateCheckInterval": "后台更新检查间隔",
"neverManualOnly": "手动",
"appearance": "外观",
"showWebInAppView": "在应用来源页显示网页",
"pinUpdates": "需更新的应用置顶",
"updates": "检查间隔",
"sourceSpecific": "Github 访问令牌",
"appSource": "源代码",
"noLogs": "无日志",
"appLogs": "应用日志",
"close": "关闭",
"share": "分享",
"appNotFound": "未找到应用",
"obtainiumExportHyphenatedLowercase": "obtainium-导出",
"pickAnAPK": "选择一个安装包",
"appHasMoreThanOnePackage": "{} 有多个架构可用:",
"deviceSupportsXArch": "你的设备支持 {} 架构",
"deviceSupportsFollowingArchs": "你的设备支持以下架构:",
"warning": "警告",
"sourceIsXButPackageFromYPrompt": "此应用来源是 '{}' 但更新包来自 '{}'。 继续吗?",
"updatesAvailable": "更新可用",
"updatesAvailableNotifDescription": "通知 Obtainium 所跟踪应用程序的更新",
"noNewUpdates": "你的应用已是最新。",
"xHasAnUpdate": "{} 有更新啦",
"appsUpdated": "应用已更新",
"appsUpdatedNotifDescription": "通知在后台安装应用程序的更新",
"xWasUpdatedToY": "{} 已更新到 {}.",
"errorCheckingUpdates": "检查更新出错",
"errorCheckingUpdatesNotifDescription": "当后台更新检查失败时显示的通知",
"appsRemoved": "应用已删除",
"appsRemovedNotifDescription": "通知由于加载应用程序时出错而被删除",
"xWasRemovedDueToErrorY": "{} 已因以下错误被删除: {}",
"completeAppInstallation": "完成应用安装",
"obtainiumMustBeOpenToInstallApps": "Obtainium 需要被启动以安装更新",
"completeAppInstallationNotifDescription": "需要返回 Obtainium以完成应用程序的安装。",
"checkingForUpdates": "检查更新中",
"checkingForUpdatesNotifDescription": "检查更新时出现的瞬时通知",
"pleaseAllowInstallPerm": "请允许 Obtainium 安装应用程序",
"trackOnly": "仅追踪",
"errorWithHttpStatusCode": "错误 {}",
"versionCorrectionDisabled": "禁用版本更正(插件似乎未起作用)",
"unknown": "未知",
"none": "无",
"never": "从不",
"latestVersionX": "最新: {}",
"installedVersionX": "已安装: {}",
"lastUpdateCheckX": "最后检查: {}",
"remove": "删除",
"removeAppQuestion": "删除应用?",
"yesMarkUpdated": "'是的,标为已更新",
"fdroid": "F-Droid",
"appIdOrName": "应用 ID 或名称",
"appWithIdOrNameNotFound": "没有发现具有此 ID 或名称的应用",
"reposHaveMultipleApps": "来源可能包含多个应用",
"fdroidThirdPartyRepo": "F-Droid 第三方源",
"steam": "Steam",
"steamMobile": "Steam Mobile",
"steamChat": "Steam Chat",
"install": "安装",
"markInstalled": "标记为已安装",
"update": "更新",
"markUpdated": "标记为已更新",
"additionalOptions": "附加选项",
"disableVersionDetection": "关闭版本检测",
"noVersionDetectionExplanation": "此选项应只用于版本检测不能工作的应用程序",
"downloadingX": "下载中 {}",
"downloadNotifDescription": "通知用户下载进度",
"noAPKFound": "未找到安装包",
"noVersionDetection": "无版本检测",
"categorize": "Categorize",
"categories": "Categories",
"category": "Category",
"noCategory": "No Category",
"deleteCategoryQuestion": "Delete Category?",
"categoryDeleteWarning": "All Apps in {} will be set to uncategorized.",
"addCategory": "Add Category",
"label": "Label",
"tooManyRequestsTryAgainInMinutes": {
"one": "请求过多 (API 限制) - 在 {} 分钟后重试",
"other": "请求过多 (API 限制) - 在 {} 分钟后重试"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "后台更新检查遇到了 {} 问题, 将在 {} 分钟后重试",
"other": "后台更新检查遇到了 {} 问题, 将在 {} 分钟后重试"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "后台更新检查找到了 {} 个更新 - 将通知用户",
"other": "后台更新检查找到了 {} 个更新 - 将通知用户"
},
"apps": {
"one": "{} 个应用",
"other": "{} 个应用"
},
"url": {
"one": "{} 个 URL",
"other": "{} 个 URL"
},
"minute": {
"one": "{} 分钟",
"other": "{} 分钟"
},
"hour": {
"one": "{} 小时",
"other": "{} 小时"
},
"day": {
"one": "{} 天",
"other": "{} 天"
},
"clearedNLogsBeforeXAfterY": {
"one": "清除了 {n} 个日志 (清除前 = {before}, 清除后 = {after})",
"other": "清除了 {n} 个日志 (清除前 = {before}, 清除后 = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} 和 {} 更多应用已被更新",
"other": "{} 和 {} 更多应用已被更新"
},
"xAndNMoreUpdatesInstalled": {
"one": "{} 和 {} 更多应用已被安装",
"other": "{} 和 {} 更多应用已被安装"
}
}

View File

@ -0,0 +1,58 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class APKMirror extends AppSource {
APKMirror() {
host = 'apkmirror.com';
enforceTrackOnly = true;
}
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/#whatsnew';
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
Response res = await get(Uri.parse('$standardUrl/feed'));
if (res.statusCode == 200) {
String? titleString = parse(res.body)
.querySelector('item')
?.querySelector('title')
?.innerHtml;
String? version = titleString
?.substring(RegExp('[0-9]').firstMatch(titleString)?.start ?? 0,
RegExp(' by ').firstMatch(titleString)?.start ?? 0)
.trim();
if (version == null || version.isEmpty) {
version = titleString;
}
if (version == null || version.isEmpty) {
throw NoVersionError();
}
return APKDetails(version, [], getAppNames(standardUrl));
} else {
throw NoReleasesError();
}
}
AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
return AppNames(names[1], names[2]);
}
}

View File

@ -0,0 +1,72 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class FDroid extends AppSource {
FDroid() {
host = 'f-droid.org';
name = tr('fdroid');
}
@override
String standardizeURL(String url) {
RegExp standardUrlRegExB =
RegExp('^https?://$host/+[^/]+/+packages/+[^/]+');
RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase());
if (match != null) {
url = 'https://$host/packages/${Uri.parse(url).pathSegments.last}';
}
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
match = standardUrlRegExA.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
String? tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) {
return Uri.parse(standardUrl).pathSegments.last;
}
APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
Response res, String apkUrlPrefix, String standardUrl) {
if (res.statusCode == 200) {
List<dynamic> releases = jsonDecode(res.body)['packages'] ?? [];
if (releases.isEmpty) {
throw NoReleasesError();
}
String? latestVersion = releases[0]['versionName'];
if (latestVersion == null) {
throw NoVersionError();
}
List<String> apkUrls = releases
.where((element) => element['versionName'] == latestVersion)
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
.toList();
return APKDetails(latestVersion, apkUrls,
AppNames(name, Uri.parse(standardUrl).pathSegments.last));
} else {
throw NoReleasesError();
}
}
@override
Future<APKDetails> getLatestAPKDetails(
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')),
'https://f-droid.org/repo/$appId',
standardUrl);
}
}

View File

@ -0,0 +1,86 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class FDroidRepo extends AppSource {
FDroidRepo() {
name = tr('fdroidThirdPartyRepo');
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormTextField('appIdOrName',
label: tr('appIdOrName'),
hint: tr('reposHaveMultipleApps'),
required: true)
]
];
}
@override
String standardizeURL(String url) {
RegExp standardUrlRegExp =
RegExp('^https?://.+/fdroid/(repo(/|\\?)|repo\$)');
RegExpMatch? match = standardUrlRegExp.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
String? appIdOrName = additionalSettings['appIdOrName'];
if (appIdOrName == null) {
throw NoReleasesError();
}
var res = await get(Uri.parse('$standardUrl/index.xml'));
if (res.statusCode == 200) {
var body = parse(res.body);
var foundApps = body.querySelectorAll('application').where((element) {
return element.attributes['id'] == appIdOrName;
}).toList();
if (foundApps.isEmpty) {
foundApps = body.querySelectorAll('application').where((element) {
return element.querySelector('name')?.innerHtml.toLowerCase() ==
appIdOrName.toLowerCase();
}).toList();
}
if (foundApps.isEmpty) {
foundApps = body.querySelectorAll('application').where((element) {
return element
.querySelector('name')
?.innerHtml
.toLowerCase()
.contains(appIdOrName.toLowerCase()) ??
false;
}).toList();
}
if (foundApps.isEmpty) {
throw ObtainiumError(tr('appWithIdOrNameNotFound'));
}
var authorName = body.querySelector('repo')?.attributes['name'] ?? name;
var appName =
foundApps[0].querySelector('name')?.innerHtml ?? appIdOrName;
var releases = foundApps[0].querySelectorAll('package');
String? latestVersion = releases[0].querySelector('version')?.innerHtml;
if (latestVersion == null) {
throw NoVersionError();
}
List<String> apkUrls = releases
.where((element) =>
element.querySelector('version')?.innerHtml == latestVersion &&
element.querySelector('apkname') != null)
.map((e) => '$standardUrl/${e.querySelector('apkname')!.innerHtml}')
.toList();
return APKDetails(latestVersion, apkUrls, AppNames(authorName, appName));
} else {
throw NoReleasesError();
}
}
}

206
lib/app_sources/github.dart Normal file
View File

@ -0,0 +1,206 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
class GitHub extends AppSource {
GitHub() {
host = 'github.com';
additionalSourceSpecificSettingFormItems = [
GeneratedFormTextField('github-creds',
label: tr('githubPATLabel'),
required: false,
additionalValidators: [
(value) {
if (value != null && value.trim().isNotEmpty) {
if (value
.split(':')
.where((element) => element.trim().isNotEmpty)
.length !=
2) {
return tr('githubPATHint');
}
}
return null;
}
],
hint: tr('githubPATFormat'),
belowWidgets: [
const SizedBox(
height: 8,
),
GestureDetector(
onTap: () {
launchUrlString(
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
mode: LaunchMode.externalApplication);
},
child: Text(
tr('githubPATLinkText'),
style: const TextStyle(
decoration: TextDecoration.underline, fontSize: 12),
))
])
];
additionalSourceAppSpecificSettingFormItems = [
[
GeneratedFormSwitch('includePrereleases',
label: tr('includePrereleases'), defaultValue: false)
],
[
GeneratedFormSwitch('fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'), defaultValue: true)
],
[
GeneratedFormTextField('filterReleaseTitlesByRegEx',
label: tr('filterReleaseTitlesByRegEx'),
required: false,
additionalValidators: [
(value) {
if (value == null || value.isEmpty) {
return null;
}
try {
RegExp(value);
} catch (e) {
return tr('invalidRegEx');
}
return null;
}
])
]
];
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);
}
Future<String> getCredentialPrefixIfAny() async {
SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings();
String? creds = settingsProvider
.getSettingString(additionalSourceSpecificSettingFormItems[0].key);
return creds != null && creds.isNotEmpty ? '$creds@' : '';
}
@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://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
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['browser_download_url'] != null
? e['browser_download_url'] as String
: '';
})
.where((element) => element.toLowerCase().endsWith('.apk'))
.toList() ??
[];
dynamic targetRelease;
for (int i = 0; i < releases.length; i++) {
if (!fallbackToOlderReleases && i > 0) break;
if (!includePrereleases && releases[i]['prerelease'] == true) {
continue;
}
if (regexFilter != null &&
!RegExp(regexFilter)
.hasMatch((releases[i]['name'] as String).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 {
rateLimitErrorCheck(res);
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://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100'));
if (res.statusCode == 200) {
Map<String, String> urlsWithDescriptions = {};
for (var e in (jsonDecode(res.body)['items'] as List<dynamic>)) {
urlsWithDescriptions.addAll({
e['html_url'] as String: e['description'] != null
? e['description'] as String
: tr('noDescription')
});
}
return urlsWithDescriptions;
} else {
rateLimitErrorCheck(res);
throw getObtainiumHttpError(res);
}
}
rateLimitErrorCheck(Response res) {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw RateLimitError(
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
}
}
}

View File

@ -0,0 +1,65 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class GitLab extends AppSource {
GitLab() {
host = 'gitlab.com';
}
@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 {
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl);
var parsedHtml = parse(res.body);
var entry = parsedHtml.querySelector('entry');
var entryContent =
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrls = [
...getLinksFromParsedHTML(
entryContent,
RegExp(
'^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return '\\${x[0]}';
})}/uploads/[^/]+/[^/]+\\.apk\$',
caseSensitive: false),
standardUri.origin),
// GitLab releases may contain links to externally hosted APKs
...getLinksFromParsedHTML(entryContent,
RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
.where((element) => Uri.parse(element).host != '')
.toList()
];
var entryId = entry?.querySelector('id')?.innerHtml;
var version =
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
if (version == null) {
throw NoVersionError();
}
return APKDetails(version, apkUrls, GitHub().getAppNames(standardUrl));
} else {
throw NoReleasesError();
}
}
}

View File

@ -0,0 +1,42 @@
import 'package:http/http.dart';
import 'package:obtainium/app_sources/fdroid.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class IzzyOnDroid extends AppSource {
IzzyOnDroid() {
host = 'android.izzysoft.de';
}
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
String? tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) {
return FDroid().tryInferringAppId(standardUrl);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
String? appId = tryInferringAppId(standardUrl);
return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(
await get(
Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')),
'https://android.izzysoft.de/frepo/$appId',
standardUrl);
}
}

View File

@ -0,0 +1,49 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class Mullvad extends AppSource {
Mullvad() {
host = 'mullvad.net';
}
@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) =>
'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md';
@override
Future<APKDetails> getLatestAPKDetails(
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)
.querySelector('p.subtitle.is-6')
?.querySelector('a')
?.attributes['href']
?.split('/')
.last;
if (version == null) {
throw NoVersionError();
}
return APKDetails(
version,
['https://mullvad.net/download/app/apk/latest'],
AppNames(name, 'Mullvad-VPN'));
} else {
throw NoReleasesError();
}
}
}

View File

@ -0,0 +1,39 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class Signal extends AppSource {
Signal() {
host = 'signal.org';
}
@override
String standardizeURL(String url) {
return 'https://$host';
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
Response res =
await get(Uri.parse('https://updates.$host/android/latest.json'));
if (res.statusCode == 200) {
var json = jsonDecode(res.body);
String? apkUrl = json['url'];
List<String> apkUrls = apkUrl == null ? [] : [apkUrl];
String? version = json['versionName'];
if (version == null) {
throw NoVersionError();
}
return APKDetails(version, apkUrls, AppNames(name, 'Signal'));
} else {
throw NoReleasesError();
}
}
}

View File

@ -0,0 +1,63 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class SourceForge extends AppSource {
SourceForge() {
host = 'sourceforge.net';
}
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<APKDetails> getLatestAPKDetails(
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);
var allDownloadLinks =
parsedHtml.querySelectorAll('guid').map((e) => e.innerHtml).toList();
getVersion(String url) {
try {
var tokens = url.split('/');
return tokens[tokens.length - 3];
} catch (e) {
return null;
}
}
String? version = getVersion(allDownloadLinks[0]);
if (version == null) {
throw NoVersionError();
}
var apkUrlListAllReleases = allDownloadLinks
.where((element) => element.toLowerCase().endsWith('.apk/download'))
.toList();
var apkUrlList =
apkUrlListAllReleases // This can be used skipped for fallback support later
.where((element) => getVersion(element) == version)
.toList();
return APKDetails(
version,
apkUrlList,
AppNames(
name, standardUrl.substring(standardUrl.lastIndexOf('/') + 1)));
} else {
throw NoReleasesError();
}
}
}

View File

@ -0,0 +1,60 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class SteamMobile extends AppSource {
SteamMobile() {
host = 'store.steampowered.com';
name = tr('steam');
additionalSourceAppSpecificSettingFormItems = [
[GeneratedFormDropdown('app', apks.entries.toList(), label: tr('app'))]
];
}
final apks = {'steam': tr('steamMobile'), 'steam-chat-app': tr('steamChat')};
@override
String standardizeURL(String url) {
return 'https://$host';
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
Response res = await get(Uri.parse('https://$host/mobile'));
if (res.statusCode == 200) {
var apkNamePrefix = additionalSettings['app'] as String?;
if (apkNamePrefix == null) {
throw NoReleasesError();
}
String apkInURLRegexPattern = '/$apkNamePrefix-[^/]+\\.apk\$';
var links = parse(res.body)
.querySelectorAll('a')
.map((e) => e.attributes['href'] ?? '')
.where((e) => RegExp('https://.*$apkInURLRegexPattern').hasMatch(e))
.toList();
if (links.isEmpty) {
throw NoReleasesError();
}
var versionMatch = RegExp(apkInURLRegexPattern).firstMatch(links[0]);
if (versionMatch == null) {
throw NoVersionError();
}
var version = links[0].substring(
versionMatch.start + apkNamePrefix.length + 2, versionMatch.end - 4);
var apkUrls = [links[0]];
return APKDetails(version, apkUrls, AppNames(name, apks[apkNamePrefix]!));
} else {
throw NoReleasesError();
}
}
}

View File

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
class CustomAppBar extends StatefulWidget {
const CustomAppBar({super.key, required this.title});
final String title;
@override
State<CustomAppBar> createState() => _CustomAppBarState();
}
class _CustomAppBarState extends State<CustomAppBar> {
@override
Widget build(BuildContext context) {
return SliverAppBar(
pinned: true,
automaticallyImplyLeading: false,
expandedHeight: 100,
flexibleSpace: FlexibleSpaceBar(
titlePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
title: Text(
widget.title,
style:
TextStyle(color: Theme.of(context).textTheme.bodyMedium!.color),
),
),
);
}
}

View File

@ -0,0 +1,261 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
abstract class GeneratedFormItem {
late String key;
late String label;
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 String? hint;
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.hint})
: 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';
}
}
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});
final List<List<GeneratedFormItem>> items;
final OnValueChanges onValueChanges;
@override
State<GeneratedForm> createState() => _GeneratedFormState();
}
class _GeneratedFormState extends State<GeneratedForm> {
final _formKey = GlobalKey<FormState>();
Map<String, dynamic> values = {};
late List<List<Widget>> formInputs;
List<List<Widget>> rows = [];
// If any value changes, call this to update the parent with value and validity
void someValueChanged({bool isBuilding = false}) {
Map<String, dynamic> returnValues = values;
var valid = true;
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);
}
}
}
widget.onValueChanges(returnValues, valid, isBuilding);
}
@override
void initState() {
super.initState();
// Initialize form values as all empty
values.clear();
int j = 0;
for (var row in widget.items) {
for (var e in row) {
values[e.key] = e.defaultValue;
}
}
// Dynamically create form inputs
formInputs = widget.items.asMap().entries.map((row) {
return row.value.asMap().entries.map((e) {
var formItem = e.value;
if (formItem is GeneratedFormTextField) {
final formFieldKey = GlobalKey<FormFieldState>();
return TextFormField(
key: formFieldKey,
initialValue: values[formItem.key],
autovalidateMode: AutovalidateMode.onUserInteraction,
onChanged: (value) {
setState(() {
values[formItem.key] = value;
someValueChanged();
});
},
decoration: InputDecoration(
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 (formItem.required &&
(value == null || value.trim().isEmpty)) {
return '${formItem.label} ${tr('requiredInBrackets')}';
}
for (var validator in formItem.additionalValidators) {
String? result = validator(value);
if (result != null) {
return result;
}
}
return null;
},
);
} else if (formItem is GeneratedFormDropdown) {
if (formItem.opts!.isEmpty) {
return Text(tr('dropdownNoOptsError'));
}
return DropdownButtonFormField(
decoration: InputDecoration(labelText: formItem.label),
value: values[formItem.key],
items: formItem.opts!
.map((e2) =>
DropdownMenuItem(value: e2.key, child: Text(e2.value)))
.toList(),
onChanged: (value) {
setState(() {
values[formItem.key] = value ?? formItem.opts!.first.key;
someValueChanged();
});
});
} else {
return Container(); // Some input types added in build
}
}).toList();
}).toList();
someValueChanged(isBuilding: true);
}
@override
Widget build(BuildContext context) {
for (var r = 0; r < formInputs.length; r++) {
for (var e = 0; e < formInputs[r].length; e++) {
if (widget.items[r][e] is GeneratedFormSwitch) {
formInputs[r][e] = Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(widget.items[r][e].label),
Switch(
value: values[widget.items[r][e].key],
onChanged: (value) {
setState(() {
values[widget.items[r][e].key] = value;
someValueChanged();
});
})
],
);
}
}
}
rows.clear();
formInputs.asMap().entries.forEach((rowInputs) {
if (rowInputs.key > 0) {
rows.add([
SizedBox(
height: widget.items[rowInputs.key][0] is GeneratedFormSwitch &&
widget.items[rowInputs.key - 1][0] is! GeneratedFormSwitch
? 25
: 8,
)
]);
}
List<Widget> rowItems = [];
rowInputs.value.asMap().entries.forEach((rowInput) {
if (rowInput.key > 0) {
rowItems.add(const SizedBox(
width: 20,
));
}
rowItems.add(Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
rowInput.value,
...widget.items[rowInputs.key][rowInput.key].belowWidgets
])));
});
rows.add(rowItems);
});
return Form(
key: _formKey,
child: Column(
children: [
...rows.map((row) => Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [...row.map((e) => e)],
))
],
));
}
}

View File

@ -0,0 +1,78 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form.dart';
class GeneratedFormModal extends StatefulWidget {
const GeneratedFormModal(
{super.key,
required this.title,
required this.items,
this.initValid = false,
this.message = ''});
final String title;
final String message;
final List<List<GeneratedFormItem>> items;
final bool initValid;
@override
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
}
class _GeneratedFormModalState extends State<GeneratedFormModal> {
Map<String, dynamic> values = {};
bool valid = false;
@override
void initState() {
super.initState();
valid = widget.initValid || widget.items.isEmpty;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: Text(widget.title),
content:
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
if (widget.message.isNotEmpty) Text(widget.message),
if (widget.message.isNotEmpty)
const SizedBox(
height: 16,
),
GeneratedForm(
items: widget.items,
onValueChanges: (values, valid, isBuilding) {
if (isBuilding) {
this.values = values;
this.valid = valid;
} else {
setState(() {
this.values = values;
this.valid = valid;
});
}
})
]),
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')))
],
);
}
}

123
lib/custom_errors.dart Normal file
View File

@ -0,0 +1,123 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:provider/provider.dart';
class ObtainiumError {
late String message;
bool unexpected;
ObtainiumError(this.message, {this.unexpected = false});
@override
String toString() {
return message;
}
}
class RateLimitError {
late int remainingMinutes;
RateLimitError(this.remainingMinutes);
@override
String toString() =>
plural('tooManyRequestsTryAgainInMinutes', remainingMinutes);
}
class InvalidURLError extends ObtainiumError {
InvalidURLError(String sourceName)
: super(tr('invalidURLForSource', args: [sourceName]));
}
class NoReleasesError extends ObtainiumError {
NoReleasesError() : super(tr('noReleaseFound'));
}
class NoAPKError extends ObtainiumError {
NoAPKError() : super(tr('noReleaseFound'));
}
class NoVersionError extends ObtainiumError {
NoVersionError() : super(tr('noVersionFound'));
}
class UnsupportedURLError extends ObtainiumError {
UnsupportedURLError() : super(tr('urlMatchesNoSource'));
}
class DowngradeError extends ObtainiumError {
DowngradeError() : super(tr('cantInstallOlderVersion'));
}
class IDChangedError extends ObtainiumError {
IDChangedError() : super(tr('appIdMismatch'));
}
class NotImplementedError extends ObtainiumError {
NotImplementedError() : super(tr('functionNotImplemented'));
}
class MultiAppMultiError extends ObtainiumError {
Map<String, List<String>> content = {};
MultiAppMultiError() : super(tr('placeholder'), unexpected: true);
add(String appId, String string) {
var tempIds = content.remove(string);
tempIds ??= [];
tempIds.add(appId);
content.putIfAbsent(string, () => tempIds!);
}
@override
String toString() {
String finalString = '';
for (var e in content.keys) {
finalString += '$e: ${content[e].toString()}\n\n';
}
return finalString;
}
}
showError(dynamic e, BuildContext context) {
Provider.of<LogsProvider>(context, listen: false)
.add(e.toString(), level: LogLevels.error);
if (e is String || (e is ObtainiumError && !e.unexpected)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
scrollable: true,
title: Text(e is MultiAppMultiError
? tr('someErrors')
: tr('unexpectedError')),
content: Text(e.toString()),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: Text(tr('ok'))),
],
);
});
}
}
String list2FriendlyString(List<String> list) {
return list.length == 2
? '${list[0]} ${tr('and')} ${list[1]}'
: list
.asMap()
.entries
.map((e) =>
e.value +
(e.key == list.length - 1
? ''
: e.key == list.length - 2
? ', and '
: ', '))
.join('');
}

View File

@ -1,102 +1,227 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/pages/home.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/notifications_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:workmanager/workmanager.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
import 'package:easy_localization/easy_localization.dart';
// ignore: implementation_imports
import 'package:easy_localization/src/easy_localization_controller.dart';
// ignore: implementation_imports
import 'package:easy_localization/src/localization.dart';
const String currentVersion = '0.9.2';
const String currentReleaseTag =
'v0.1.6-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
const int bgUpdateCheckAlarmId = 666;
const supportedLocales = [
Locale('en'),
Locale('zh'),
Locale('it'),
Locale('ja'),
Locale('hu'),
Locale('de')
];
const fallbackLocale = Locale('en');
const localeDir = 'assets/translations';
final globalNavigatorKey = GlobalKey<NavigatorState>();
Future<void> loadTranslations() async {
// See easy_localization/issues/210
await EasyLocalizationController.initEasyLocation();
final controller = EasyLocalizationController(
saveLocale: true,
fallbackLocale: fallbackLocale,
supportedLocales: supportedLocales,
assetLoader: const RootBundleAssetLoader(),
useOnlyLangCode: false,
useFallbackTranslations: true,
path: localeDir,
onLoadError: (FlutterError e) {
throw e;
},
);
await controller.loadTranslations();
Localization.load(controller.locale,
translations: controller.translations,
fallbackTranslations: controller.fallbackTranslations);
}
@pragma('vm:entry-point')
void bgTaskCallback() {
// Background update checking process
Workmanager().executeTask((task, taskName) async {
var appsProvider = AppsProvider(bg: true);
var notificationsProvider = NotificationsProvider();
await notificationsProvider.notify(checkingUpdatesNotification);
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
WidgetsFlutterBinding.ensureInitialized();
await EasyLocalization.ensureInitialized();
await loadTranslations();
LogsProvider logs = LogsProvider();
logs.add(tr('startedBgUpdateTask'));
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
await AndroidAlarmManager.initialize();
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
: null;
logs.add(tr('bgUpdateIgnoreAfterIs', args: [ignoreAfter.toString()]));
var notificationsProvider = NotificationsProvider();
await notificationsProvider.notify(checkingUpdatesNotification);
try {
var appsProvider = AppsProvider();
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps();
List<String> existingUpdateIds =
appsProvider.findExistingUpdates(installedOnly: true);
DateTime nextIgnoreAfter = DateTime.now();
String? err;
try {
await notificationsProvider
.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps();
List<App> updates = await appsProvider.checkUpdates();
if (updates.isNotEmpty) {
notificationsProvider.notify(UpdateNotification(updates),
cancelExisting: true);
}
return Future.value(true);
logs.add(tr('startedActualBGUpdateCheck'));
await appsProvider.checkUpdates(
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
} catch (e) {
notificationsProvider.notify(
ErrorCheckingUpdatesNotification(e.toString()),
cancelExisting: true);
return Future.value(false);
} finally {
await notificationsProvider.cancel(checkingUpdatesNotification.id);
if (e is RateLimitError || e is SocketException) {
var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15;
logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes,
args: [e.toString(), remainingMinutes.toString()]));
AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes),
Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: {
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
});
} else {
err = e.toString();
}
}
});
List<App> newUpdates = appsProvider
.findExistingUpdates(installedOnly: true)
.where((id) => !existingUpdateIds.contains(id))
.map((e) => appsProvider.apps[e]!.app)
.toList();
// TODO: This silent update code doesn't work yet
// List<String> silentlyUpdated = await appsProvider
// .downloadAndInstallLatestApp(
// [...newUpdates.map((e) => e.id), ...existingUpdateIds], null);
// if (silentlyUpdated.isNotEmpty) {
// newUpdates = newUpdates
// .where((element) => !silentlyUpdated.contains(element.id))
// .toList();
// notificationsProvider.notify(
// SilentUpdateNotification(
// silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()),
// cancelExisting: true);
// }
logs.add(
plural('bgCheckFoundUpdatesWillNotifyIfNeeded', newUpdates.length));
if (newUpdates.isNotEmpty) {
notificationsProvider.notify(UpdateNotification(newUpdates));
}
if (err != null) {
throw err;
}
} catch (e) {
notificationsProvider
.notify(ErrorCheckingUpdatesNotification(e.toString()));
} finally {
logs.add(tr('bgUpdateTaskFinished'));
await notificationsProvider.cancel(checkingUpdatesNotification.id);
}
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
Workmanager().initialize(
bgTaskCallback,
);
await EasyLocalization.ensureInitialized();
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
await AndroidAlarmManager.initialize();
runApp(MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => AppsProvider()),
ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => NotificationsProvider())
Provider(create: (context) => NotificationsProvider()),
Provider(create: (context) => LogsProvider())
],
child: const MyApp(),
child: EasyLocalization(
supportedLocales: supportedLocales,
path: localeDir,
fallbackLocale: fallbackLocale,
child: const Obtainium()),
));
}
var defaultThemeColour = Colors.deepPurple;
class MyApp extends StatelessWidget {
const MyApp({super.key});
class Obtainium extends StatefulWidget {
const Obtainium({super.key});
@override
State<Obtainium> createState() => _ObtainiumState();
}
class _ObtainiumState extends State<Obtainium> {
var existingUpdateInterval = -1;
@override
Widget build(BuildContext context) {
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
AppsProvider appsProvider = context.read<AppsProvider>();
LogsProvider logs = context.read<LogsProvider>();
if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings().then((value) {
// Delete past downloads and check for updates every time the app is launched
// Only runs once as the settings are only initialized once (so not on every build)
appsProvider.deleteSavedAPKs();
appsProvider.checkUpdates();
});
settingsProvider.initializeSettings();
} else {
// Register the background update task according to the user's setting
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
frequency: Duration(minutes: settingsProvider.updateInterval),
initialDelay: Duration(minutes: settingsProvider.updateInterval),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.replace);
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
if (isFirstRun) {
logs.add(tr('firstRun'));
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
Permission.notification.request();
appsProvider.saveApp(App(
'imranr98_obtainium_${GitHub().host}',
'https://github.com/ImranR98/Obtainium',
'ImranR98',
'Obtainium',
currentReleaseTag,
currentReleaseTag,
[],
0));
appsProvider.saveApps([
App(
obtainiumId,
'https://github.com/ImranR98/Obtainium',
'ImranR98',
'Obtainium',
currentReleaseTag,
currentReleaseTag,
[],
0,
{'includePrereleases': true},
null,
false)
]);
}
// Register the background update task according to the user's setting
if (existingUpdateInterval != settingsProvider.updateInterval) {
if (existingUpdateInterval != -1) {
logs.add(tr('settingUpdateCheckIntervalTo',
args: [settingsProvider.updateInterval.toString()]));
}
existingUpdateInterval = settingsProvider.updateInterval;
if (existingUpdateInterval == 0) {
AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
} else {
AndroidAlarmManager.periodic(
Duration(minutes: existingUpdateInterval),
bgUpdateCheckAlarmId,
bgUpdateCheck,
rescheduleOnReboot: true,
wakeup: true);
}
}
}
@ -117,6 +242,10 @@ class MyApp extends StatelessWidget {
}
return MaterialApp(
title: 'Obtainium',
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
navigatorKey: globalNavigatorKey,
theme: ThemeData(
useMaterial3: true,
colorScheme: settingsProvider.theme == ThemeSettings.dark

View File

@ -0,0 +1,54 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class GitHubStars implements MassAppUrlSource {
@override
late String name = tr('githubStarredRepos');
@override
late List<String> requiredArgs = [tr('uname')];
Future<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions(
String username, int page) async {
Response res = await get(Uri.parse(
'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page'));
if (res.statusCode == 200) {
Map<String, String> urlsWithDescriptions = {};
for (var e in (jsonDecode(res.body) as List<dynamic>)) {
urlsWithDescriptions.addAll({
e['html_url'] as String: e['description'] != null
? e['description'] as String
: tr('noDescription')
});
}
return urlsWithDescriptions;
} else {
var gh = GitHub();
gh.rateLimitErrorCheck(res);
throw getObtainiumHttpError(res);
}
}
@override
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args) async {
if (args.length != requiredArgs.length) {
throw ObtainiumError(tr('wrongArgNum'));
}
Map<String, String> urlsWithDescriptions = {};
var page = 1;
while (true) {
var pageUrls =
await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++);
urlsWithDescriptions.addAll(pageUrls);
if (pageUrls.length < 100) {
break;
}
}
return urlsWithDescriptions;
}
}

View File

@ -1,6 +1,13 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/main.dart';
import 'package:obtainium/pages/app.dart';
import 'package:obtainium/pages/import_export.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
@ -15,120 +22,354 @@ class AddAppPage extends StatefulWidget {
}
class _AddAppPageState extends State<AddAppPage> {
final _formKey = GlobalKey<FormState>();
final urlInputController = TextEditingController();
bool gettingAppInfo = false;
String userInput = '';
String searchQuery = '';
AppSource? pickedSource;
Map<String, dynamic> additionalSettings = {};
bool additionalSettingsValid = true;
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
return Center(
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(),
Padding(
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;
additionalSettings = source != null
? getDefaultValuesFromFormItems(
source.combinedAppSpecificSettingFormItems)
: {};
additionalSettingsValid = source != null
? !sourceProvider.ifRequiredAppSpecificSettingsExist(source)
: true;
}
}
if (isBuilding) {
fn();
} else {
setState(() {
fn();
});
}
}
addApp({bool resetUserInputAfter = false}) async {
setState(() {
gettingAppInfo = true;
});
var settingsProvider = context.read<SettingsProvider>();
() async {
var userPickedTrackOnly = additionalSettings['trackOnly'] == true;
var userPickedNoVersionDetection =
additionalSettings['noVersionDetection'] == true;
var cont = true;
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
await showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('xIsTrackOnly', args: [
pickedSource!.enforceTrackOnly
? tr('source')
: tr('app')
]),
items: const [],
message:
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
);
}) ==
null) {
cont = false;
}
if (userPickedNoVersionDetection &&
await showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('disableVersionDetection'),
items: const [],
message: tr('noVersionDetectionExplanation'),
);
}) ==
null) {
cont = false;
}
if (cont) {
HapticFeedback.selectionClick();
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
App app = await sourceProvider.getApp(
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.additionalSettings['trackOnly'] != true) {
// ignore: use_build_context_synchronously
var apkUrl = await appsProvider.confirmApkUrl(app, context);
if (apkUrl == null) {
throw ObtainiumError(tr('cancelled'));
}
app.preferredApkIndex = app.apkUrls.indexOf(apkUrl);
// ignore: use_build_context_synchronously
var downloadedApk = await appsProvider.downloadApp(
app, globalNavigatorKey.currentContext);
app.id = downloadedApk.appId;
}
if (appsProvider.apps.containsKey(app.id)) {
throw ObtainiumError(tr('appAlreadyAdded'));
}
if (app.additionalSettings['trackOnly'] == true) {
app.installedVersion = app.latestVersion;
}
await appsProvider.saveApps([app]);
return app;
}
}()
.then((app) {
if (app != null) {
Navigator.push(context,
MaterialPageRoute(builder: (context) => AppPage(appId: app.id)));
}
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
gettingAppInfo = false;
if (resetUserInputAfter) {
changeUserInput('', false, true);
}
});
});
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
CustomAppBar(title: tr('addApp')),
SliverFillRemaining(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
decoration: const InputDecoration(
hintText: 'https://github.com/Author/Project',
helperText: 'Enter the App source URL'),
controller: urlInputController,
validator: (value) {
if (value == null ||
value.isEmpty ||
Uri.tryParse(value) == null) {
return 'Please enter a supported source URL';
}
return null;
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: ElevatedButton(
onPressed: gettingAppInfo
? null
: () {
HapticFeedback.mediumImpact();
if (_formKey.currentState!.validate()) {
setState(() {
gettingAppInfo = true;
});
sourceProvider
.getApp(urlInputController.value.text)
.then((app) {
var appsProvider =
context.read<AppsProvider>();
var settingsProvider =
context.read<SettingsProvider>();
if (appsProvider.apps.containsKey(app.id)) {
throw 'App already added';
}
settingsProvider
.getInstallPermission()
.then((_) {
appsProvider.saveApp(app).then((_) {
urlInputController.clear();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
AppPage(appId: app.id)));
});
});
}).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() {
setState(() {
gettingAppInfo = false;
});
});
}
},
child: const Text('Add'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: GeneratedForm(
items: [
[
GeneratedFormTextField('appSourceURL',
label: tr('appSourceURL'),
additionalValidators: [
(value) {
try {
sourceProvider
.getSource(value ?? '')
.standardizeURL(
preStandardizeUrl(
value ?? ''));
} catch (e) {
return e is String
? e
: e is ObtainiumError
? e.toString()
: tr('error');
}
return null;
}
])
]
],
onValueChanges: (values, valid, isBuilding) {
changeUserInput(values['appSourceURL']!,
valid, isBuilding);
})),
const SizedBox(
width: 16,
),
gettingAppInfo
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: gettingAppInfo ||
pickedSource == null ||
(pickedSource!
.combinedAppSpecificSettingFormItems
.isNotEmpty &&
!additionalSettingsValid)
? null
: addApp,
child: Text(tr('add')))
],
),
),
],
),
),
Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
const Text(
'Supported Sources:',
// style: TextStyle(fontWeight: FontWeight.bold),
// style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(
height: 8,
),
...sourceProvider
.getSourceHosts()
.map((e) => GestureDetector(
onTap: () {
launchUrlString('https://$e',
mode: LaunchMode.externalApplication);
},
child: Text(
e,
style: const TextStyle(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic),
)))
.toList()
]),
if (gettingAppInfo)
const LinearProgressIndicator()
else
Container(),
],
)),
);
if (sourceProvider.sources
.where((e) => e.canSearch)
.isNotEmpty &&
pickedSource == null &&
userInput.isEmpty)
const SizedBox(
height: 16,
),
if (sourceProvider.sources
.where((e) => e.canSearch)
.isNotEmpty &&
pickedSource == null &&
userInput.isEmpty)
Row(
children: [
Expanded(
child: GeneratedForm(
items: [
[
GeneratedFormTextField(
'searchSomeSources',
label: tr('searchSomeSourcesLabel'),
required: false),
]
],
onValueChanges: (values, valid, isBuilding) {
if (values.isNotEmpty && valid) {
setState(() {
searchQuery =
values['searchSomeSources']!.trim();
});
}
}),
),
const SizedBox(
width: 16,
),
ElevatedButton(
onPressed: searchQuery.isEmpty || gettingAppInfo
? null
: () {
Future.wait(sourceProvider.sources
.where((e) => e.canSearch)
.map((e) =>
e.search(searchQuery)))
.then((results) async {
// Interleave results instead of simple reduce
Map<String, String> res = {};
var si = 0;
var done = false;
while (!done) {
done = true;
for (var r in results) {
if (r.length > si) {
done = false;
res.addEntries(
[r.entries.elementAt(si)]);
}
}
si++;
}
List<String>? selectedUrls = res
.isEmpty
? []
: await showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return UrlSelectionModal(
urlsWithDescriptions: res,
selectedByDefault: false,
onlyOneSelectionAllowed:
true,
);
});
if (selectedUrls != null &&
selectedUrls.isNotEmpty) {
changeUserInput(
selectedUrls[0], true, true);
addApp(resetUserInputAfter: true);
}
}).catchError((e) {
showError(e, context);
});
},
child: Text(tr('search')))
],
),
if (pickedSource != null &&
(pickedSource!
.combinedAppSpecificSettingFormItems.isNotEmpty))
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Divider(
height: 64,
),
Text(
tr('additionalOptsFor',
args: [pickedSource?.name ?? tr('source')]),
style: TextStyle(
color:
Theme.of(context).colorScheme.primary)),
const SizedBox(
height: 16,
),
GeneratedForm(
items: pickedSource!
.combinedAppSpecificSettingFormItems,
onValueChanges: (values, valid, isBuilding) {
if (!isBuilding) {
setState(() {
additionalSettings = values;
additionalSettingsValid = valid;
});
}
}),
],
)
else
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 48,
),
Text(
tr('supportedSourcesBelow'),
),
const SizedBox(
height: 8,
),
...sourceProvider.sources
.map((e) => GestureDetector(
onTap: e.host != null
? () {
launchUrlString(
'https://${e.host}',
mode: LaunchMode
.externalApplication);
}
: null,
child: Text(
'${e.name}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
style: TextStyle(
decoration: e.host != null
? TextDecoration.underline
: TextDecoration.none,
fontStyle: FontStyle.italic),
)))
.toList()
])),
const SizedBox(
height: 8,
),
])),
)
]));
}
}

View File

@ -1,6 +1,14 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/main.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:provider/provider.dart';
@ -14,21 +22,188 @@ class AppPage extends StatefulWidget {
}
class _AppPageState extends State<AppPage> {
AppInMemory? prevApp;
@override
Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>();
AppInMemory? app = appsProvider.apps[widget.appId];
if (app?.app.installedVersion != null) {
appsProvider.getUpdate(app!.app.id);
var settingsProvider = context.watch<SettingsProvider>();
getUpdate(String id) {
appsProvider.checkUpdate(id).catchError((e) {
showError(e, context);
});
}
var categories = settingsProvider.categories;
var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId];
var source = app != null ? sourceProvider.getSource(app.app.url) : null;
if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) {
prevApp = app;
getUpdate(app.app.id);
}
var trackOnly = app?.app.additionalSettings['trackOnly'] == true;
return Scaffold(
appBar: AppBar(
title: Text('${app?.app.author}/${app?.app.name}'),
),
body: WebView(
initialUrl: app?.app.url,
javascriptMode: JavascriptMode.unrestricted,
),
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator(
child: settingsProvider.showAppWebpage
? app != null
? WebViewWidget(
controller: WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(
Theme.of(context).colorScheme.background)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onWebResourceError: (WebResourceError error) {
if (error.isForMainFrame == true) {
showError(
ObtainiumError(error.description,
unexpected: true),
context);
}
},
),
)
..loadRequest(Uri.parse(app.app.url)))
: Container()
: CustomScrollView(
slivers: [
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 ??
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,
),
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: 32,
),
app?.app.category != null
? Chip(
label: Text(app!.app.category!),
backgroundColor:
Color(categories[app.app.category!] ?? 0x0),
onDeleted: () {
app.app.category = null;
appsProvider.saveApps([app.app]);
},
visualDensity: VisualDensity.compact,
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: () {
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Pick a Category',
items: [
[
settingsProvider
.getCategoryFormItem()
]
]);
}).then((value) {
if (value != null && app != null) {
String? cat = (value['category']
?.isNotEmpty ??
false)
? value['category']
: null;
app.app.category = cat;
appsProvider.saveApps([app.app]);
}
});
},
child: Text(tr('categorize')))
])
],
)),
],
),
onRefresh: () async {
if (app != null) {
getUpdate(app.app.id);
}
}),
bottomSheet: Padding(
padding: EdgeInsets.fromLTRB(
0, 0, 0, MediaQuery.of(context).padding.bottom),
@ -40,69 +215,192 @@ class _AppPageState extends State<AppPage> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (app?.app.installedVersion != null &&
!trackOnly &&
app?.app.installedVersion != app?.app.latestVersion)
IconButton(
onPressed: app?.downloadProgress != null
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(tr(
'alreadyUpToDateQuestion')),
content: Text(
tr('onlyWorksWithNonEVDApps'),
style: const TextStyle(
fontWeight:
FontWeight.bold,
fontStyle:
FontStyle.italic)),
actions: [
TextButton(
onPressed: () {
Navigator.of(context)
.pop();
},
child: Text(tr('no'))),
TextButton(
onPressed: () {
HapticFeedback
.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp
.installedVersion =
updatedApp
.latestVersion;
appsProvider.saveApps(
[updatedApp]);
}
Navigator.of(context)
.pop();
},
child: Text(
tr('yesMarkUpdated')))
],
);
});
},
tooltip: tr('markUpdated'),
icon: const Icon(Icons.done)),
if (source != null &&
source
.combinedAppSpecificSettingFormItems.isNotEmpty)
IconButton(
onPressed: app?.downloadProgress != null
? null
: () {
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
var items = source
.combinedAppSpecificSettingFormItems
.map((row) {
row.map((e) {
if (app?.app.additionalSettings[
e.key] !=
null) {
e.defaultValue = app?.app
.additionalSettings[
e.key];
}
return e;
}).toList();
return row;
}).toList();
return GeneratedFormModal(
title: tr('additionalOptions'),
items: items);
}).then((values) {
if (app != null && values != null) {
var changedApp = app.app;
changedApp.additionalSettings =
values;
if (source.enforceTrackOnly) {
changedApp.additionalSettings[
'trackOnly'] = true;
showError(
tr('appsFromSourceAreTrackOnly'),
context);
}
appsProvider.saveApps(
[changedApp]).then((value) {
getUpdate(changedApp.id);
});
}
});
},
tooltip: tr('additionalOptions'),
icon: const Icon(Icons.settings)),
const SizedBox(width: 16.0),
Expanded(
child: ElevatedButton(
onPressed: (app?.app.installedVersion == null ||
appsProvider
.checkAppObjectForUpdate(
app!.app)) &&
app?.app.installedVersion !=
app?.app.latestVersion) &&
!appsProvider.areDownloadsRunning()
? () {
HapticFeedback.heavyImpact();
appsProvider
.downloadAndInstallLatestApp(
[app!.app.id],
context).then((res) {
if (res && mounted) {
Navigator.of(context).pop();
() async {
if (app?.app.additionalSettings[
'trackOnly'] !=
true) {
await settingsProvider
.getInstallPermission();
}
}()
.then((value) {
appsProvider
.downloadAndInstallLatestApps(
[app!.app.id],
globalNavigatorKey
.currentContext).then(
(res) {
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
}
});
}).catchError((e) {
showError(e, context);
});
}
: null,
child: Text(app?.app.installedVersion == null
? 'Install'
: 'Update'))),
? !trackOnly
? tr('install')
: tr('markInstalled')
: !trackOnly
? tr('update')
: tr('markUpdated')))),
const SizedBox(width: 16.0),
ElevatedButton(
onPressed: app?.downloadProgress != null
? null
: () {
HapticFeedback.lightImpact();
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text('Remove App?'),
content: Text(
'This will remove \'${app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'),
title: Text(tr('removeAppQuestion')),
content: Text(tr(
'xWillBeRemovedButRemainInstalled',
args: [
app?.installedInfo?.name ??
app?.app.name ??
tr('app')
])),
actions: [
TextButton(
onPressed: () {
HapticFeedback.heavyImpact();
appsProvider
.removeApp(app!.app.id)
.then((_) {
HapticFeedback
.selectionClick();
appsProvider.removeApps(
[app!.app.id]).then((_) {
int count = 0;
Navigator.of(context)
.popUntil((_) =>
count++ >= 2);
});
},
child: const Text('Remove')),
child: Text(tr('remove'))),
TextButton(
onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
child: const Text('Cancel'))
child: Text(tr('cancel')))
],
);
});
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).errorColor,
surfaceTintColor: Theme.of(context).errorColor),
child: const Text('Remove'),
foregroundColor:
Theme.of(context).colorScheme.error,
surfaceTintColor:
Theme.of(context).colorScheme.error),
child: Text(tr('remove')),
),
])),
if (app?.downloadProgress != null)

View File

@ -1,82 +1,784 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/main.dart';
import 'package:obtainium/pages/app.dart';
import 'package:obtainium/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:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher_string.dart';
class AppsPage extends StatefulWidget {
const AppsPage({super.key});
@override
State<AppsPage> createState() => _AppsPageState();
State<AppsPage> createState() => AppsPageState();
}
class _AppsPageState extends State<AppsPage> {
class AppsPageState extends State<AppsPage> {
AppsFilter? filter;
var updatesOnlyFilter =
AppsFilter(includeUptodate: false, includeNonInstalled: false);
Set<App> selectedApps = {};
DateTime? refreshingSince;
clearSelected() {
if (selectedApps.isNotEmpty) {
setState(() {
selectedApps.clear();
});
return true;
}
return false;
}
selectThese(List<App> apps) {
if (selectedApps.isEmpty) {
setState(() {
for (var a in apps) {
selectedApps.add(a);
}
});
}
}
@override
Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>();
var existingUpdateAppIds = appsProvider.getExistingUpdates();
var settingsProvider = context.watch<SettingsProvider>();
var sortedApps = appsProvider.apps.values.toList();
var currentFilterIsUpdatesOnly =
filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
selectedApps = selectedApps
.where((element) => sortedApps.map((e) => e.app).contains(element))
.toSet();
toggleAppSelected(App app) {
setState(() {
if (selectedApps.contains(app)) {
selectedApps.remove(app);
} else {
selectedApps.add(app);
}
});
}
if (filter != null) {
sortedApps = sortedApps.where((app) {
if (app.app.installedVersion == app.app.latestVersion &&
!(filter!.includeUptodate)) {
return false;
}
if (app.app.installedVersion == null &&
!(filter!.includeNonInstalled)) {
return false;
}
if (filter!.nameFilter.isNotEmpty || filter!.authorFilter.isNotEmpty) {
List<String> nameTokens = filter!.nameFilter
.split(' ')
.where((element) => element.trim().isNotEmpty)
.toList();
List<String> authorTokens = filter!.authorFilter
.split(' ')
.where((element) => element.trim().isNotEmpty)
.toList();
for (var t in nameTokens) {
var name = app.installedInfo?.name ?? app.app.name;
if (!name.toLowerCase().contains(t.toLowerCase())) {
return false;
}
}
for (var t in authorTokens) {
if (!app.app.author.toLowerCase().contains(t.toLowerCase())) {
return false;
}
}
}
if (filter!.categoryFilter.isNotEmpty &&
filter!.categoryFilter != app.app.category) {
return false;
}
return true;
}).toList();
}
sortedApps.sort((a, b) {
var nameA = a.installedInfo?.name ?? a.app.name;
var nameB = b.installedInfo?.name ?? b.app.name;
int result = 0;
if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
result = (a.app.author + nameA).compareTo(b.app.author + nameB);
} else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) {
result = (nameA + a.app.author).compareTo(nameB + b.app.author);
}
return result;
});
if (settingsProvider.sortOrder == SortOrderSettings.descending) {
sortedApps = sortedApps.reversed.toList();
}
var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
var existingUpdateIdsAllOrSelected = existingUpdates
.where((element) => selectedApps.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedApps.map((e) => e.id).contains(element))
.toList();
var newInstallIdsAllOrSelected = appsProvider
.findExistingUpdates(nonInstalledOnly: true)
.where((element) => selectedApps.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedApps.map((e) => e.id).contains(element))
.toList();
List<String> trackOnlyUpdateIdsAllOrSelected = [];
existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) {
if (appsProvider.apps[id]!.app.additionalSettings['trackOnly'] == true) {
trackOnlyUpdateIdsAllOrSelected.add(id);
return false;
}
return true;
}).toList();
newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) {
if (appsProvider.apps[id]!.app.additionalSettings['trackOnly'] == true) {
trackOnlyUpdateIdsAllOrSelected.add(id);
return false;
}
return true;
}).toList();
if (settingsProvider.pinUpdates) {
var temp = [];
sortedApps = sortedApps.where((sa) {
if (existingUpdates.contains(sa.app.id)) {
temp.add(sa);
return false;
}
return true;
}).toList();
sortedApps = [...temp, ...sortedApps];
}
var tempPinned = [];
var tempNotPinned = [];
for (var a in sortedApps) {
if (a.app.pinned) {
tempPinned.add(a);
} else {
tempNotPinned.add(a);
}
}
sortedApps = [...tempPinned, ...tempNotPinned];
return Scaffold(
floatingActionButton: existingUpdateAppIds.isEmpty
? null
: ElevatedButton.icon(
onPressed: appsProvider.areDownloadsRunning()
? null
: () {
HapticFeedback.heavyImpact();
context
.read<SettingsProvider>()
.getInstallPermission()
.then((_) {
appsProvider.downloadAndInstallLatestApp(
existingUpdateAppIds, context);
});
},
icon: const Icon(Icons.update),
label: const Text('Update All')),
body: Center(
child: appsProvider.loadingApps
? const CircularProgressIndicator()
: appsProvider.apps.isEmpty
? Text(
'No Apps',
style: Theme.of(context).textTheme.headline4,
)
: RefreshIndicator(
onRefresh: () {
HapticFeedback.lightImpact();
return appsProvider.checkUpdates();
},
child: ListView(
children: appsProvider.apps.values
.map(
(e) => ListTile(
title: Text('${e.app.author}/${e.app.name}'),
subtitle: Text(
e.app.installedVersion ?? 'Not Installed'),
trailing: e.downloadProgress != null
? Text(
'Downloading - ${e.downloadProgress?.toInt()}%')
: (e.app.installedVersion != null &&
e.app.installedVersion !=
e.app.latestVersion
? const Text('Update Available')
: null),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
AppPage(appId: e.app.id)),
);
},
),
)
.toList(),
backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator(
onRefresh: () {
HapticFeedback.lightImpact();
setState(() {
refreshingSince = DateTime.now();
});
return appsProvider.checkUpdates().catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
refreshingSince = null;
});
});
},
child: CustomScrollView(slivers: <Widget>[
CustomAppBar(title: tr('appsString')),
if (appsProvider.loadingApps || sortedApps.isEmpty)
SliverFillRemaining(
child: Center(
child: appsProvider.loadingApps
? const CircularProgressIndicator()
: Text(
appsProvider.apps.isEmpty
? tr('noApps')
: tr('noAppsForFilter'),
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
))),
if (refreshingSince != null)
SliverToBoxAdapter(
child: LinearProgressIndicator(
value: appsProvider.apps.values
.where((element) => !(element.app.lastUpdateCheck
?.isBefore(refreshingSince!) ??
true))
.length /
appsProvider.apps.length,
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
String? changesUrl = SourceProvider()
.getSource(sortedApps[index].app.url)
.changeLogPageFromStandardUrl(sortedApps[index].app.url);
return Container(
decoration: BoxDecoration(
border: Border.symmetric(
vertical: BorderSide(
width: 3,
color: Color(settingsProvider.categories[
sortedApps[index].app.category] ??
const Color.fromARGB(0, 0, 0, 0).value)))),
child: ListTile(
tileColor: sortedApps[index].app.pinned
? Colors.grey.withOpacity(0.1)
: Colors.transparent,
selectedTileColor: Theme.of(context)
.colorScheme
.primary
.withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1),
selected: selectedApps.contains(sortedApps[index].app),
onLongPress: () {
toggleAppSelected(sortedApps[index].app);
},
leading: sortedApps[index].installedInfo != null
? Image.memory(
sortedApps[index].installedInfo!.icon!,
gaplessPlayback: true,
)
: null,
title: Text(
sortedApps[index].installedInfo?.name ??
sortedApps[index].app.name,
style: TextStyle(
fontWeight: sortedApps[index].app.pinned
? FontWeight.bold
: FontWeight.normal,
),
),
));
subtitle: Text(
tr('byX', args: [sortedApps[index].app.author]),
style: TextStyle(
fontWeight: sortedApps[index].app.pinned
? FontWeight.bold
: FontWeight.normal)),
trailing: SingleChildScrollView(
reverse: true,
child: sortedApps[index].downloadProgress != null
? Text(tr('percentProgress', args: [
sortedApps[index]
.downloadProgress
?.toInt()
.toString() ??
'100'
]))
: (Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SizedBox(
width: 100,
child: Text(
'${sortedApps[index].app.installedVersion ?? tr('notInstalled')}${sortedApps[index].app.additionalSettings['trackOnly'] == true ? ' ${tr('estimateInBrackets')}' : ''}',
overflow: TextOverflow.fade,
textAlign: TextAlign.end,
)),
sortedApps[index].app.installedVersion !=
null &&
sortedApps[index]
.app
.installedVersion !=
sortedApps[index]
.app
.latestVersion
? GestureDetector(
onTap: changesUrl == null
? null
: () {
launchUrlString(changesUrl,
mode: LaunchMode
.externalApplication);
},
child: appsProvider
.areDownloadsRunning()
? Text(tr('pleaseWait'))
: Text(
'${tr('updateAvailable')}${sortedApps[index].app.additionalSettings['trackOnly'] == true ? ' ${tr('estimateInBracketsShort')}' : ''}',
style: TextStyle(
fontStyle:
FontStyle.italic,
decoration: changesUrl ==
null
? TextDecoration.none
: TextDecoration
.underline),
))
: const SizedBox(),
],
))),
onTap: () {
if (selectedApps.isNotEmpty) {
toggleAppSelected(sortedApps[index].app);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
AppPage(appId: sortedApps[index].app.id)),
);
}
},
));
}, childCount: sortedApps.length))
])),
persistentFooterButtons: [
Row(
children: [
IconButton(
onPressed: () {
selectedApps.isEmpty
? selectThese(sortedApps.map((e) => e.app).toList())
: clearSelected();
},
icon: Icon(
selectedApps.isEmpty
? Icons.select_all_outlined
: Icons.deselect_outlined,
color: Theme.of(context).colorScheme.primary,
),
tooltip: selectedApps.isEmpty
? tr('selectAll')
: tr('deselectN', args: [selectedApps.length.toString()])),
const VerticalDivider(),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
selectedApps.isEmpty
? const SizedBox()
: IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('removeSelectedAppsQuestion'),
items: const [],
initValid: true,
message: tr(
'xWillBeRemovedButRemainInstalled',
args: [
plural('apps', selectedApps.length)
]),
);
}).then((values) {
if (values != null) {
appsProvider.removeApps(
selectedApps.map((e) => e.id).toList());
}
});
},
tooltip: tr('removeSelectedApps'),
icon: const Icon(Icons.delete_outline_outlined),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: appsProvider.areDownloadsRunning() ||
(existingUpdateIdsAllOrSelected.isEmpty &&
newInstallIdsAllOrSelected.isEmpty &&
trackOnlyUpdateIdsAllOrSelected.isEmpty)
? null
: () {
HapticFeedback.heavyImpact();
List<GeneratedFormItem> formItems = [];
if (existingUpdateIdsAllOrSelected.isNotEmpty) {
formItems.add(GeneratedFormSwitch('updates',
label: tr('updateX', args: [
plural('apps',
existingUpdateIdsAllOrSelected.length)
]),
defaultValue: true));
}
if (newInstallIdsAllOrSelected.isNotEmpty) {
formItems.add(GeneratedFormSwitch('installs',
label: tr('installX', args: [
plural('apps',
newInstallIdsAllOrSelected.length)
]),
defaultValue: existingUpdateIdsAllOrSelected
.isNotEmpty));
}
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
formItems.add(GeneratedFormSwitch('trackonlies',
label: tr('markXTrackOnlyAsUpdated', args: [
plural('apps',
trackOnlyUpdateIdsAllOrSelected.length)
]),
defaultValue: existingUpdateIdsAllOrSelected
.isNotEmpty ||
newInstallIdsAllOrSelected.isNotEmpty));
}
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
var totalApps = existingUpdateIdsAllOrSelected
.length +
newInstallIdsAllOrSelected.length +
trackOnlyUpdateIdsAllOrSelected.length;
return GeneratedFormModal(
title: tr('changeX',
args: [plural('apps', totalApps)]),
items: formItems.map((e) => [e]).toList(),
initValid: true,
);
}).then((values) {
if (values != null) {
if (values.isEmpty) {
values = getDefaultValuesFromFormItems(
[formItems]);
}
bool shouldInstallUpdates =
values['updates'] == true;
bool shouldInstallNew =
values['installs'] == true;
bool shouldMarkTrackOnlies =
values['trackonlies'] == true;
(() async {
if (shouldInstallNew ||
shouldInstallUpdates) {
await settingsProvider
.getInstallPermission();
}
})()
.then((_) {
List<String> toInstall = [];
if (shouldInstallUpdates) {
toInstall
.addAll(existingUpdateIdsAllOrSelected);
}
if (shouldInstallNew) {
toInstall
.addAll(newInstallIdsAllOrSelected);
}
if (shouldMarkTrackOnlies) {
toInstall.addAll(
trackOnlyUpdateIdsAllOrSelected);
}
appsProvider
.downloadAndInstallLatestApps(toInstall,
globalNavigatorKey.currentContext)
.catchError((e) {
showError(e, context);
});
});
}
});
},
tooltip: selectedApps.isEmpty
? tr('installUpdateApps')
: tr('installUpdateSelectedApps'),
icon: const Icon(
Icons.file_download_outlined,
)),
selectedApps.isEmpty
? const SizedBox()
: IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
scrollable: true,
content: Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceAround,
children: [
IconButton(
onPressed:
appsProvider
.areDownloadsRunning()
? null
: () {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return AlertDialog(
title: Text(tr(
'markXSelectedAppsAsUpdated',
args: [
selectedApps
.length
.toString()
])),
content: Text(
tr('onlyWorksWithNonEVDApps'),
style: const TextStyle(
fontWeight:
FontWeight
.bold,
fontStyle:
FontStyle.italic),
),
actions: [
TextButton(
onPressed:
() {
Navigator.of(context)
.pop();
},
child: Text(
tr('no'))),
TextButton(
onPressed:
() {
HapticFeedback
.selectionClick();
appsProvider
.saveApps(selectedApps.map((a) {
if (a.installedVersion !=
null) {
a.installedVersion = a.latestVersion;
}
return a;
}).toList());
Navigator.of(context)
.pop();
},
child: Text(
tr('yes')))
],
);
}).whenComplete(() {
Navigator.of(
context)
.pop();
});
},
tooltip:
tr('markSelectedAppsUpdated'),
icon: const Icon(Icons.done)),
IconButton(
onPressed: () {
var pinStatus = selectedApps
.where((element) =>
element.pinned)
.isEmpty;
appsProvider.saveApps(
selectedApps.map((e) {
e.pinned = pinStatus;
return e;
}).toList());
Navigator.of(context).pop();
},
tooltip: selectedApps
.where((element) =>
element.pinned)
.isEmpty
? tr('pinToTop')
: tr('unpinFromTop'),
icon: Icon(selectedApps
.where((element) =>
element.pinned)
.isEmpty
? Icons.bookmark_outline_rounded
: Icons
.bookmark_remove_outlined),
),
IconButton(
onPressed: () {
String urls = '';
for (var a in selectedApps) {
urls += '${a.url}\n';
}
urls = urls.substring(
0, urls.length - 1);
Share.share(urls,
subject: tr(
'selectedAppURLsFromObtainium'));
Navigator.of(context).pop();
},
tooltip: tr('shareSelectedAppURLs'),
icon: const Icon(Icons.share),
),
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr(
'resetInstallStatusForSelectedAppsQuestion'),
items: const [],
initValid: true,
message: tr(
'installStatusOfXWillBeResetExplanation',
args: [
plural(
'app',
selectedApps
.length)
]),
);
}).then((values) {
if (values != null) {
appsProvider.saveApps(
selectedApps.map((e) {
e.installedVersion = null;
return e;
}).toList());
}
}).whenComplete(() {
Navigator.of(context).pop();
});
},
tooltip: tr('resetInstallStatus'),
icon: const Icon(
Icons.restore_page_outlined),
),
]),
),
);
});
},
tooltip: tr('more'),
icon: const Icon(Icons.more_horiz),
),
],
)),
const VerticalDivider(),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
setState(() {
if (currentFilterIsUpdatesOnly) {
filter = null;
} else {
filter = updatesOnlyFilter;
}
});
},
tooltip: currentFilterIsUpdatesOnly
? tr('removeOutdatedFilter')
: tr('showOutdatedOnly'),
icon: Icon(
currentFilterIsUpdatesOnly
? Icons.update_disabled_rounded
: Icons.update_rounded,
color: Theme.of(context).colorScheme.primary,
),
),
appsProvider.apps.isEmpty
? const SizedBox()
: TextButton.icon(
label: Text(
filter == null ? tr('filter') : tr('filterActive'),
style: TextStyle(
fontWeight: filter == null
? FontWeight.normal
: FontWeight.bold),
),
onPressed: () {
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
var vals = filter == null
? AppsFilter().toValuesMap()
: filter!.toValuesMap();
return GeneratedFormModal(
title: tr('filterApps'),
items: [
[
GeneratedFormTextField('appName',
label: tr('appName'),
required: false,
defaultValue: vals['appName']),
GeneratedFormTextField('author',
label: tr('author'),
required: false,
defaultValue: vals['author'])
],
[
GeneratedFormSwitch('upToDateApps',
label: tr('upToDateApps'),
defaultValue: vals['upToDateApps'])
],
[
GeneratedFormSwitch('nonInstalledApps',
label: tr('nonInstalledApps'),
defaultValue: vals['nonInstalledApps'])
],
[
settingsProvider.getCategoryFormItem(
initCategory: vals['category'] ?? '')
]
]);
}).then((values) {
if (values != null) {
setState(() {
filter = AppsFilter.fromValuesMap(values);
if (AppsFilter().isIdenticalTo(filter!)) {
filter = null;
}
});
}
});
},
icon: const Icon(Icons.filter_list_rounded))
],
),
],
);
}
}
class AppsFilter {
late String nameFilter;
late String authorFilter;
late bool includeUptodate;
late bool includeNonInstalled;
late String categoryFilter;
AppsFilter(
{this.nameFilter = '',
this.authorFilter = '',
this.includeUptodate = true,
this.includeNonInstalled = true,
this.categoryFilter = ''});
Map<String, dynamic> toValuesMap() {
return {
'appName': nameFilter,
'author': authorFilter,
'upToDateApps': includeUptodate,
'nonInstalledApps': includeNonInstalled,
'category': categoryFilter
};
}
AppsFilter.fromValuesMap(Map<String, dynamic> values) {
nameFilter = values['appName']!;
authorFilter = values['author']!;
includeUptodate = values['upToDateApps'];
includeNonInstalled = values['nonInstalledApps'];
categoryFilter = values['category']!;
}
bool isIdenticalTo(AppsFilter other) =>
authorFilter.trim() == other.authorFilter.trim() &&
nameFilter.trim() == other.nameFilter.trim() &&
includeUptodate == other.includeUptodate &&
includeNonInstalled == other.includeNonInstalled &&
categoryFilter.trim() == other.categoryFilter.trim();
}

View File

@ -1,7 +1,10 @@
import 'package:animations/animations.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/pages/add_app.dart';
import 'package:obtainium/pages/apps.dart';
import 'package:obtainium/pages/import_export.dart';
import 'package:obtainium/pages/settings.dart';
class HomePage extends StatefulWidget {
@ -11,44 +14,85 @@ class HomePage extends StatefulWidget {
State<HomePage> createState() => _HomePageState();
}
class NavigationPageItem {
late String title;
late IconData icon;
late Widget widget;
NavigationPageItem(this.title, this.icon, this.widget);
}
class _HomePageState extends State<HomePage> {
int selectedIndex = 1;
List<Widget> pages = [
const SettingsPage(),
const AppsPage(),
const AddAppPage()
List<int> selectedIndexHistory = [];
List<NavigationPageItem> pages = [
NavigationPageItem(tr('appsString'), Icons.apps,
AppsPage(key: GlobalKey<AppsPageState>())),
NavigationPageItem(tr('addApp'), Icons.add, const AddAppPage()),
NavigationPageItem(
tr('importExport'), Icons.import_export, const ImportExportPage()),
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage())
];
@override
Widget build(BuildContext context) {
return WillPopScope(
child: Scaffold(
appBar: AppBar(title: const Text('Obtainium')),
body: pages.elementAt(selectedIndex),
backgroundColor: Theme.of(context).colorScheme.surface,
body: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: pages
.elementAt(selectedIndexHistory.isEmpty
? 0
: selectedIndexHistory.last)
.widget,
),
bottomNavigationBar: NavigationBar(
destinations: const [
NavigationDestination(
icon: Icon(Icons.settings), label: 'Settings'),
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
],
destinations: pages
.map((e) =>
NavigationDestination(icon: Icon(e.icon), label: e.title))
.toList(),
onDestinationSelected: (int index) {
HapticFeedback.lightImpact();
HapticFeedback.selectionClick();
setState(() {
selectedIndex = index;
if (index == 0) {
selectedIndexHistory.clear();
} else if (selectedIndexHistory.isEmpty ||
(selectedIndexHistory.isNotEmpty &&
selectedIndexHistory.last != index)) {
int existingInd = selectedIndexHistory.indexOf(index);
if (existingInd >= 0) {
selectedIndexHistory.removeAt(existingInd);
}
selectedIndexHistory.add(index);
}
});
},
selectedIndex: selectedIndex,
selectedIndex:
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,
),
),
onWillPop: () async {
if (selectedIndex != 1) {
if (selectedIndexHistory.isNotEmpty) {
setState(() {
selectedIndex = 1;
selectedIndexHistory.removeLast();
});
return false;
}
return true;
return !(pages[0].widget.key as GlobalKey<AppsPageState>)
.currentState
?.clearSelected();
});
}
}

View File

@ -0,0 +1,622 @@
import 'dart:convert';
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ImportExportPage extends StatefulWidget {
const ImportExportPage({super.key});
@override
State<ImportExportPage> createState() => _ImportExportPageState();
}
class _ImportExportPageState extends State<ImportExportPage> {
bool importInProgress = false;
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
var appsProvider = context.read<AppsProvider>();
var outlineButtonStyle = ButtonStyle(
shape: MaterialStateProperty.all(
StadiumBorder(
side: BorderSide(
width: 1,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
CustomAppBar(title: tr('importExport')),
SliverFillRemaining(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextButton(
style: outlineButtonStyle,
onPressed: appsProvider.apps.isEmpty ||
importInProgress
? null
: () {
HapticFeedback.selectionClick();
appsProvider
.exportApps()
.then((String path) {
showError(
tr('exportedTo', args: [path]),
context);
});
},
child: Text(tr('obtainiumExport')))),
const SizedBox(
width: 16,
),
Expanded(
child: TextButton(
style: outlineButtonStyle,
onPressed: importInProgress
? null
: () {
HapticFeedback.selectionClick();
FilePicker.platform
.pickFiles()
.then((result) {
setState(() {
importInProgress = true;
});
if (result != null) {
String data = File(
result.files.single.path!)
.readAsStringSync();
try {
jsonDecode(data);
} catch (e) {
throw ObtainiumError(
tr('invalidInput'));
}
appsProvider
.importApps(data)
.then((value) {
showError(
tr('importedX', args: [
plural('apps', value)
]),
context);
});
} else {
// User canceled the picker
}
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: Text(tr('obtainiumImport'))))
],
),
if (importInProgress)
Column(
children: const [
SizedBox(
height: 14,
),
LinearProgressIndicator(),
SizedBox(
height: 14,
),
],
)
else
const Divider(
height: 32,
),
TextButton(
onPressed: importInProgress
? null
: () {
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('importFromURLList'),
items: [
[
GeneratedFormTextField(
'appURLList',
label: tr('appURLList'),
max: 7,
additionalValidators: [
(dynamic value) {
if (value != null &&
value.isNotEmpty) {
var lines = value
.trim()
.split('\n');
for (int i = 0;
i < lines.length;
i++) {
try {
sourceProvider
.getSource(
lines[i]);
} catch (e) {
return '${tr('line')} ${i + 1}: $e';
}
}
}
return null;
}
])
]
],
);
}).then((values) {
if (values != null) {
var urls =
(values['appURLList'] as String)
.split('\n');
setState(() {
importInProgress = true;
});
appsProvider
.addAppsByURL(urls)
.then((errors) {
if (errors.isEmpty) {
showError(
tr('importedX', args: [
plural('apps', urls.length)
]),
context);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return ImportErrorDialog(
urlsLength: urls.length,
errors: errors);
});
}
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
});
},
child: Text(
tr('importFromURLList'),
)),
...sourceProvider.sources
.where((element) => element.canSearch)
.map((source) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
TextButton(
onPressed: importInProgress
? null
: () {
() async {
var values = await showDialog<
Map<String,
dynamic>?>(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title: tr('searchX',
args: [
source.name
]),
items: [
[
GeneratedFormTextField(
'searchQuery',
label: tr(
'searchQuery'))
]
],
);
});
if (values != null &&
(values['searchQuery']
as String?)
?.isNotEmpty ==
true) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source.search(
values['searchQuery']
as String);
if (urlsWithDescriptions
.isNotEmpty) {
var selectedUrls =
await showDialog<
List<
String>?>(
context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urlsWithDescriptions:
urlsWithDescriptions,
selectedByDefault:
false,
);
});
if (selectedUrls !=
null &&
selectedUrls
.isNotEmpty) {
var errors =
await appsProvider
.addAppsByURL(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
tr('importedX',
args: [
plural(
'app',
selectedUrls
.length)
]),
context);
} else {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
} else {
throw ObtainiumError(
tr('noResults'));
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: Text(
tr('searchX', args: [source.name])))
]))
.toList(),
...sourceProvider.massUrlSources
.map((source) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
TextButton(
onPressed: importInProgress
? null
: () {
() async {
var values = await showDialog(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title: tr('importX',
args: [
source.name
]),
items:
source
.requiredArgs
.map(
(e) => [
GeneratedFormTextField(e,
label: e)
])
.toList(),
);
});
if (values != null) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source
.getUrlsWithDescriptions(
values);
var selectedUrls =
await showDialog<
List<String>?>(
context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urlsWithDescriptions:
urlsWithDescriptions);
});
if (selectedUrls != null) {
var errors =
await appsProvider
.addAppsByURL(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
tr('importedX',
args: [
plural(
'app',
selectedUrls
.length)
]),
context);
} else {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: Text(
tr('importX', args: [source.name])))
]))
.toList(),
const Spacer(),
const Divider(
height: 32,
),
Text(tr('importedAppsIdDisclaimer'),
textAlign: TextAlign.center,
style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12)),
const SizedBox(
height: 8,
)
],
)))
]));
}
}
class ImportErrorDialog extends StatefulWidget {
const ImportErrorDialog(
{super.key, required this.urlsLength, required this.errors});
final int urlsLength;
final List<List<String>> errors;
@override
State<ImportErrorDialog> createState() => _ImportErrorDialogState();
}
class _ImportErrorDialogState extends State<ImportErrorDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: Text(tr('importErrors')),
content:
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Text(
tr('importedXOfYApps', args: [
(widget.urlsLength - widget.errors.length).toString(),
widget.urlsLength.toString()
]),
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
Text(
tr('followingURLsHadErrors'),
style: Theme.of(context).textTheme.bodyLarge,
),
...widget.errors.map((e) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(
height: 16,
),
Text(e[0]),
Text(
e[1],
style: const TextStyle(fontStyle: FontStyle.italic),
)
]);
}).toList()
]),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: Text(tr('okay')))
],
);
}
}
// ignore: must_be_immutable
class UrlSelectionModal extends StatefulWidget {
UrlSelectionModal(
{super.key,
required this.urlsWithDescriptions,
this.selectedByDefault = true,
this.onlyOneSelectionAllowed = false});
Map<String, String> urlsWithDescriptions;
bool selectedByDefault;
bool onlyOneSelectionAllowed;
@override
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
}
class _UrlSelectionModalState extends State<UrlSelectionModal> {
Map<MapEntry<String, String>, bool> urlWithDescriptionSelections = {};
@override
void initState() {
super.initState();
for (var url in widget.urlsWithDescriptions.entries) {
urlWithDescriptionSelections.putIfAbsent(url,
() => widget.selectedByDefault && !widget.onlyOneSelectionAllowed);
}
if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) {
selectOnlyOne(widget.urlsWithDescriptions.entries.first.key);
}
}
selectOnlyOne(String url) {
for (var uwd in urlWithDescriptionSelections.keys) {
urlWithDescriptionSelections[uwd] = uwd.key == url;
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: Text(
widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')),
content: Column(children: [
...urlWithDescriptionSelections.keys.map((urlWithD) {
return Row(children: [
Checkbox(
value: urlWithDescriptionSelections[urlWithD],
onChanged: (value) {
setState(() {
value ??= false;
if (value! && widget.onlyOneSelectionAllowed) {
selectOnlyOne(urlWithD.key);
} else {
urlWithDescriptionSelections[urlWithD] = value!;
}
});
}),
const SizedBox(
width: 8,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 8,
),
GestureDetector(
onTap: () {
launchUrlString(urlWithD.key,
mode: LaunchMode.externalApplication);
},
child: Text(
Uri.parse(urlWithD.key).path.substring(1),
style:
const TextStyle(decoration: TextDecoration.underline),
textAlign: TextAlign.start,
)),
Text(
urlWithD.value.length > 128
? '${urlWithD.value.substring(0, 128)}...'
: urlWithD.value,
style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12),
),
const SizedBox(
height: 8,
)
],
))
]);
})
]),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(tr('cancel'))),
TextButton(
onPressed:
urlWithDescriptionSelections.values.where((b) => b).isEmpty
? null
: () {
Navigator.of(context).pop(urlWithDescriptionSelections
.entries
.where((entry) => entry.value)
.map((e) => e.key.key)
.toList());
},
child: Text(widget.onlyOneSelectionAllowed
? tr('pick')
: tr('importX', args: [
plural(
'url',
urlWithDescriptionSelections.values
.where((b) => b)
.length)
])))
],
);
}
}

View File

@ -1,10 +1,17 @@
import 'dart:convert';
import 'dart:math';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher_string.dart';
class SettingsPage extends StatefulWidget {
@ -14,233 +21,439 @@ 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) {
AppsProvider appsProvider = context.read<AppsProvider>();
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
SourceProvider sourceProvider = SourceProvider();
AppsProvider appsProvider = context.read<AppsProvider>();
if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings();
}
return Padding(
padding: const EdgeInsets.all(16),
child: settingsProvider.prefs == null
? Container()
: Column(
children: [
DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'Theme'),
value: settingsProvider.theme,
items: const [
DropdownMenuItem(
value: ThemeSettings.dark,
child: Text('Dark'),
),
DropdownMenuItem(
value: ThemeSettings.light,
child: Text('Light'),
),
DropdownMenuItem(
value: ThemeSettings.system,
child: Text('Follow System'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.theme = value;
}
}),
const SizedBox(
height: 16,
),
DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'Colour'),
value: settingsProvider.colour,
items: const [
DropdownMenuItem(
value: ColourSettings.basic,
child: Text('Obtainium'),
),
DropdownMenuItem(
value: ColourSettings.materialYou,
child: Text('Material You'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.colour = value;
}
}),
const SizedBox(
height: 16,
),
DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'Background Update Checking Interval'),
value: settingsProvider.updateInterval,
items: const [
DropdownMenuItem(
value: 15,
child: Text('15 Minutes'),
),
DropdownMenuItem(
value: 30,
child: Text('30 Minutes'),
),
DropdownMenuItem(
value: 60,
child: Text('1 Hour'),
),
DropdownMenuItem(
value: 360,
child: Text('6 Hours'),
),
DropdownMenuItem(
value: 720,
child: Text('12 Hours'),
),
DropdownMenuItem(
value: 1440,
child: Text('1 Day'),
),
],
onChanged: (value) {
if (value != null) {
settingsProvider.updateInterval = value;
}
}),
const SizedBox(
height: 32,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
ElevatedButton(
onPressed: appsProvider.apps.isEmpty
? null
: () {
HapticFeedback.lightImpact();
appsProvider.exportApps().then((String path) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Exported to $path')),
);
});
},
child: const Text('Export Apps')),
ElevatedButton(
onPressed: () {
HapticFeedback.lightImpact();
showDialog(
context: context,
builder: (BuildContext ctx) {
final formKey = GlobalKey<FormState>();
final jsonInputController =
TextEditingController();
return AlertDialog(
scrollable: true,
title: const Text('Import Apps'),
content: Column(children: [
const Text(
'Copy the contents of the Obtainium export file and paste them into the field below:'),
Form(
key: formKey,
child: TextFormField(
minLines: 7,
maxLines: 7,
decoration: const InputDecoration(
helperText:
'Obtainium export data'),
controller: jsonInputController,
validator: (value) {
if (value == null ||
value.isEmpty) {
return 'Please enter your Obtainium export data';
}
bool isJSON = true;
try {
jsonDecode(value);
} catch (e) {
isJSON = false;
}
if (!isJSON) {
return 'Invalid input';
}
return null;
},
),
)
]),
actions: [
TextButton(
onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
HapticFeedback.heavyImpact();
if (formKey.currentState!
.validate()) {
appsProvider
.importApps(
jsonInputController
.value.text)
.then((value) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'$value Apps Imported')),
);
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content:
Text(e.toString())),
);
}).whenComplete(() {
Navigator.of(context).pop();
var themeDropdown = DropdownButtonFormField(
decoration: InputDecoration(labelText: tr('theme')),
value: settingsProvider.theme,
items: [
DropdownMenuItem(
value: ThemeSettings.dark,
child: Text(tr('dark')),
),
DropdownMenuItem(
value: ThemeSettings.light,
child: Text(tr('light')),
),
DropdownMenuItem(
value: ThemeSettings.system,
child: Text(tr('followSystem')),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.theme = value;
}
});
var colourDropdown = DropdownButtonFormField(
decoration: InputDecoration(labelText: tr('colour')),
value: settingsProvider.colour,
items: [
DropdownMenuItem(
value: ColourSettings.basic,
child: Text(tr('obtainium')),
),
DropdownMenuItem(
value: ColourSettings.materialYou,
child: Text(tr('materialYou')),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.colour = value;
}
});
var sortDropdown = DropdownButtonFormField(
decoration: InputDecoration(labelText: tr('appSortBy')),
value: settingsProvider.sortColumn,
items: [
DropdownMenuItem(
value: SortColumnSettings.authorName,
child: Text(tr('authorName')),
),
DropdownMenuItem(
value: SortColumnSettings.nameAuthor,
child: Text(tr('nameAuthor')),
),
DropdownMenuItem(
value: SortColumnSettings.added,
child: Text(tr('asAdded')),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortColumn = value;
}
});
var orderDropdown = DropdownButtonFormField(
decoration: InputDecoration(labelText: tr('appSortOrder')),
value: settingsProvider.sortOrder,
items: [
DropdownMenuItem(
value: SortOrderSettings.ascending,
child: Text(tr('ascending')),
),
DropdownMenuItem(
value: SortOrderSettings.descending,
child: Text(tr('descending')),
),
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortOrder = value;
}
});
var intervalDropdown = DropdownButtonFormField(
decoration: InputDecoration(labelText: tr('bgUpdateCheckInterval')),
value: settingsProvider.updateInterval,
items: updateIntervals.map((e) {
int displayNum = (e < 60
? e
: e < 1440
? e / 60
: e / 1440)
.round();
String display = e == 0
? tr('neverManualOnly')
: (e < 60
? plural('minute', displayNum)
: e < 1440
? plural('hour', displayNum)
: plural('day', displayNum));
return DropdownMenuItem(value: e, child: Text(display));
}).toList(),
onChanged: (value) {
if (value != null) {
settingsProvider.updateInterval = value;
}
});
var sourceSpecificFields = sourceProvider.sources.map((e) {
if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
return GeneratedForm(
items: e.additionalSourceSpecificSettingFormItems.map((e) {
e.defaultValue = settingsProvider.getSettingString(e.key);
return [e];
}).toList(),
onValueChanges: (values, valid, isBuilding) {
if (valid) {
values.forEach((key, value) {
settingsProvider.setSettingString(key, value);
});
}
});
} else {
return Container();
}
});
const height16 = SizedBox(
height: 16,
);
var categories = settingsProvider.categories;
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
CustomAppBar(title: tr('settings')),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: settingsProvider.prefs == null
? const SizedBox()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr('appearance'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
themeDropdown,
height16,
colourDropdown,
height16,
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: sortDropdown),
const SizedBox(
width: 16,
),
Expanded(child: orderDropdown),
],
),
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(tr('showWebInAppView')),
Switch(
value: settingsProvider.showAppWebpage,
onChanged: (value) {
settingsProvider.showAppWebpage = value;
})
],
),
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(tr('pinUpdates')),
Switch(
value: settingsProvider.pinUpdates,
onChanged: (value) {
settingsProvider.pinUpdates = value;
})
],
),
const Divider(
height: 16,
),
height16,
Text(
tr('updates'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
intervalDropdown,
const Divider(
height: 48,
),
Text(
tr('sourceSpecific'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
...sourceSpecificFields,
const Divider(
height: 48,
),
Text(
tr('categories'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
height16,
Wrap(
children: [
...categories.entries.toList().map((e) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4),
child: Chip(
label: Text(e.key),
backgroundColor: Color(e.value),
visualDensity: VisualDensity.compact,
onDeleted: () {
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr(
'deleteCategoryQuestion'),
message: tr(
'categoryDeleteWarning',
args: [e.key]),
items: []);
}).then((value) {
if (value != null) {
setState(() {
categories.remove(e.key);
settingsProvider.categories =
categories;
});
appsProvider.saveApps(appsProvider
.apps.values
.where((element) =>
element.app.category ==
e.key)
.map((e) {
var a = e.app;
a.category = null;
return a;
}).toList());
}
},
child: const Text('Import')),
],
);
});
},
child: const Text('Import Apps'))
],
),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton.icon(
style: ButtonStyle(
foregroundColor:
MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
return Colors.grey;
}),
),
});
},
));
}),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4),
child: IconButton(
onPressed: () {
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('addCategory'),
items: [
[
GeneratedFormTextField(
'label',
label: tr('label'))
]
]);
}).then((value) {
String? label = value?['label'];
if (label != null) {
setState(() {
categories[label] =
generateRandomLightColor()
.value;
settingsProvider.categories =
categories;
});
}
});
},
icon: const Icon(Icons.add),
visualDensity: VisualDensity.compact,
tooltip: tr('add'),
))
],
)
],
))),
SliverToBoxAdapter(
child: Column(
children: [
const Divider(
height: 32,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
TextButton.icon(
onPressed: () {
launchUrlString(settingsProvider.sourceUrl,
mode: LaunchMode.externalApplication);
},
icon: const Icon(Icons.code),
label: Text(
tr('appSource'),
),
),
TextButton.icon(
onPressed: () {
HapticFeedback.lightImpact();
launchUrlString(settingsProvider.sourceUrl,
mode: LaunchMode.externalApplication);
context.read<LogsProvider>().get().then((logs) {
if (logs.isEmpty) {
showError(ObtainiumError(tr('noLogs')), context);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return const LogsDialog();
});
}
});
},
icon: const Icon(Icons.code),
label: Text(
'Source',
style: Theme.of(context).textTheme.caption,
),
)
],
),
],
));
icon: const Icon(Icons.bug_report_outlined),
label: Text(tr('appLogs'))),
],
),
height16,
],
),
)
]));
}
}
class LogsDialog extends StatefulWidget {
const LogsDialog({super.key});
@override
State<LogsDialog> createState() => _LogsDialogState();
}
class _LogsDialogState extends State<LogsDialog> {
String? logString;
List<int> days = [7, 5, 4, 3, 2, 1];
@override
Widget build(BuildContext context) {
var logsProvider = context.read<LogsProvider>();
void filterLogs(int days) {
logsProvider
.get(after: DateTime.now().subtract(Duration(days: days)))
.then((value) {
setState(() {
String l = value.map((e) => e.toString()).join('\n\n');
logString = l.isNotEmpty ? l : tr('noLogs');
});
});
}
if (logString == null) {
filterLogs(days.first);
}
return AlertDialog(
scrollable: true,
title: Text(tr('appLogs')),
content: Column(
children: [
DropdownButtonFormField(
value: days.first,
items: days
.map((e) => DropdownMenuItem(
value: e,
child: Text(plural('day', e)),
))
.toList(),
onChanged: (d) {
filterLogs(d ?? 7);
}),
const SizedBox(
height: 32,
),
Text(logString ?? '')
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(tr('close'))),
TextButton(
onPressed: () {
Share.share(logString ?? '', subject: tr('appLogs'));
Navigator.of(context).pop();
},
child: Text(tr('share')))
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,112 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
const String logTable = 'logs';
const String idColumn = '_id';
const String levelColumn = 'level';
const String messageColumn = 'message';
const String timestampColumn = 'timestamp';
const String dbPath = 'logs.db';
enum LogLevels { debug, info, warning, error }
class Log {
int? id;
late LogLevels level;
late String message;
DateTime timestamp = DateTime.now();
Map<String, Object?> toMap() {
var map = <String, Object?>{
idColumn: id,
levelColumn: level.index,
messageColumn: message,
timestampColumn: timestamp.millisecondsSinceEpoch
};
return map;
}
Log(this.message, this.level);
Log.fromMap(Map<String, Object?> map) {
id = map[idColumn] as int;
level = LogLevels.values.elementAt(map[levelColumn] as int);
message = map[messageColumn] as String;
timestamp =
DateTime.fromMillisecondsSinceEpoch(map[timestampColumn] as int);
}
@override
String toString() {
return '${timestamp.toString()}: ${level.name}: $message';
}
}
class LogsProvider {
LogsProvider({bool runDefaultClear = true}) {
clear(before: DateTime.now().subtract(const Duration(days: 7)));
}
Database? db;
Future<Database> getDB() async {
db ??= await openDatabase(dbPath, version: 1,
onCreate: (Database db, int version) async {
await db.execute('''
create table if not exists $logTable (
$idColumn integer primary key autoincrement,
$levelColumn integer not null,
$messageColumn text not null,
$timestampColumn integer not null)
''');
});
return db!;
}
Future<Log> add(String message, {LogLevels level = LogLevels.info}) async {
Log l = Log(message, level);
l.id = await (await getDB()).insert(logTable, l.toMap());
if (kDebugMode) {
print(l);
}
return l;
}
Future<List<Log>> get({DateTime? before, DateTime? after}) async {
var where = getWhereDates(before: before, after: after);
return (await (await getDB())
.query(logTable, where: where.key, whereArgs: where.value))
.map((e) => Log.fromMap(e))
.toList();
}
Future<int> clear({DateTime? before, DateTime? after}) async {
var where = getWhereDates(before: before, after: after);
var res = await (await getDB())
.delete(logTable, where: where.key, whereArgs: where.value);
if (res > 0) {
add(plural('clearedNLogsBeforeXAfterY', res,
namedArgs: {'before': before.toString(), 'after': after.toString()},
name: 'n'));
}
return res;
}
}
MapEntry<String?, List<int>?> getWhereDates(
{DateTime? before, DateTime? after}) {
List<String> where = [];
List<int> whereArgs = [];
if (before != null) {
where.add('$timestampColumn < ?');
whereArgs.add(before.millisecondsSinceEpoch);
}
if (after != null) {
where.add('$timestampColumn > ?');
whereArgs.add(after.millisecondsSinceEpoch);
}
return whereArgs.isEmpty
? const MapEntry(null, null)
: MapEntry(where.join(' and '), whereArgs);
}

View File

@ -1,6 +1,7 @@
// Exposes functions that can be used to send notifications to the user
// Contains a set of pre-defined ObtainiumNotification objects that should be used throughout the app
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:obtainium/providers/source_provider.dart';
@ -12,24 +13,42 @@ class ObtainiumNotification {
late String channelName;
late String channelDescription;
Importance importance;
int? progPercent;
bool onlyAlertOnce;
ObtainiumNotification(this.id, this.title, this.message, this.channelCode,
this.channelName, this.channelDescription, this.importance);
this.channelName, this.channelDescription, this.importance,
{this.onlyAlertOnce = false, this.progPercent});
}
class UpdateNotification extends ObtainiumNotification {
UpdateNotification(List<App> updates)
: super(
2,
'Updates Available',
tr('updatesAvailable'),
'',
'UPDATES_AVAILABLE',
'Updates Available',
'Notifies the user that updates are available for one or more Apps tracked by Obtainium',
tr('updatesAvailable'),
tr('updatesAvailableNotifDescription'),
Importance.max) {
message = updates.isEmpty
? tr('noNewUpdates')
: updates.length == 1
? tr('xHasAnUpdate', args: [updates[0].name])
: plural('xAndNMoreUpdatesAvailable', updates.length - 1,
args: [updates[0].name, (updates.length - 1).toString()]);
}
}
class SilentUpdateNotification extends ObtainiumNotification {
SilentUpdateNotification(List<App> updates)
: super(3, tr('appsUpdated'), '', 'APPS_UPDATED', tr('appsUpdated'),
tr('appsUpdatedNotifDescription'), Importance.defaultImportance) {
message = updates.length == 1
? '${updates[0].name} has an update.'
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.';
? tr('xWasUpdatedToY',
args: [updates[0].name, updates[0].latestVersion])
: plural('xAndNMoreUpdatesInstalled', updates.length - 1,
args: [updates[0].name, (updates.length - 1).toString()]);
}
}
@ -37,30 +56,56 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
ErrorCheckingUpdatesNotification(String error)
: super(
5,
'Error Checking for Updates',
tr('errorCheckingUpdates'),
error,
'BG_UPDATE_CHECK_ERROR',
'Error Checking for Updates',
'A notification that shows when background update checking fails',
tr('errorCheckingUpdates'),
tr('errorCheckingUpdatesNotifDescription'),
Importance.high);
}
class AppsRemovedNotification extends ObtainiumNotification {
AppsRemovedNotification(List<List<String>> namedReasons)
: super(6, tr('appsRemoved'), '', 'APPS_REMOVED', tr('appsRemoved'),
tr('appsRemovedNotifDescription'), Importance.max) {
message = '';
for (var r in namedReasons) {
message += '${tr('xWasRemovedDueToErrorY', args: [r[0], r[1]])} \n';
}
message = message.trim();
}
}
class DownloadNotification extends ObtainiumNotification {
DownloadNotification(String appName, int progPercent)
: super(
appName.hashCode,
tr('downloadingX', args: [appName]),
'',
'APP_DOWNLOADING',
tr('downloadingX', args: [tr('app')]),
tr('downloadNotifDescription'),
Importance.low,
onlyAlertOnce: true,
progPercent: progPercent);
}
final completeInstallationNotification = ObtainiumNotification(
1,
'Complete App Installation',
'Obtainium must be open to install Apps',
tr('completeAppInstallation'),
tr('obtainiumMustBeOpenToInstallApps'),
'COMPLETE_INSTALL',
'Complete App Installation',
'Asks the user to return to Obtanium to finish installing an App',
tr('completeAppInstallation'),
tr('completeAppInstallationNotifDescription'),
Importance.max);
final checkingUpdatesNotification = ObtainiumNotification(
4,
'Checking for Updates',
tr('checkingForUpdates'),
'',
'BG_UPDATE_CHECK',
'Checking for Updates',
'Transient notification that appears when checking for updates',
tr('checkingForUpdates'),
tr('checkingForUpdatesNotifDescription'),
Importance.min);
class NotificationsProvider {
@ -100,7 +145,9 @@ class NotificationsProvider {
String channelName,
String channelDescription,
Importance importance,
{bool cancelExisting = false}) async {
{bool cancelExisting = false,
int? progPercent,
bool onlyAlertOnce = false}) async {
if (cancelExisting) {
await cancel(id);
}
@ -116,12 +163,18 @@ class NotificationsProvider {
channelDescription: channelDescription,
importance: importance,
priority: importanceToPriority[importance]!,
groupKey: 'dev.imranr.obtainium.$channelCode')));
groupKey: 'dev.imranr.obtainium.$channelCode',
progress: progPercent ?? 0,
maxProgress: 100,
showProgress: progPercent != null,
onlyAlertOnce: onlyAlertOnce)));
}
Future<void> notify(ObtainiumNotification notif,
{bool cancelExisting = false}) =>
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode,
notif.channelName, notif.channelDescription, notif.importance,
cancelExisting: cancelExisting);
cancelExisting: cancelExisting,
onlyAlertOnce: notif.onlyAlertOnce,
progPercent: notif.progPercent);
}

View File

@ -1,14 +1,36 @@
// 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:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
String obtainiumTempId = 'imranr98_obtainium_${GitHub().host}';
String obtainiumId = 'dev.imranr.obtainium';
enum ThemeSettings { system, light, dark }
enum ColourSettings { basic, materialYou }
enum SortColumnSettings { added, nameAuthor, authorName }
enum SortOrderSettings { ascending, descending }
const maxAPIRateLimitMinutes = 30;
const minUpdateIntervalMinutes = maxAPIRateLimitMinutes + 30;
const maxUpdateIntervalMinutes = 4320;
List<int> updateIntervals = [15, 30, 60, 120, 180, 360, 720, 1440, 4320, 0]
.where((element) =>
(element >= minUpdateIntervalMinutes &&
element <= maxUpdateIntervalMinutes) ||
element == 0)
.toList();
class SettingsProvider with ChangeNotifier {
SharedPreferences? prefs;
@ -41,11 +63,41 @@ class SettingsProvider with ChangeNotifier {
}
int get updateInterval {
return prefs?.getInt('updateInterval') ?? 1440;
var min = prefs?.getInt('updateInterval') ?? 360;
if (!updateIntervals.contains(min)) {
var temp = updateIntervals[0];
for (var i in updateIntervals) {
if (min > i && i != 0) {
temp = i;
}
}
min = temp;
}
return min;
}
set updateInterval(int min) {
prefs?.setInt('updateInterval', min < 15 ? 15 : min);
prefs?.setInt('updateInterval', (min < 15 && min != 0) ? 15 : min);
notifyListeners();
}
SortColumnSettings get sortColumn {
return SortColumnSettings.values[
prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index];
}
set sortColumn(SortColumnSettings s) {
prefs?.setInt('sortColumn', s.index);
notifyListeners();
}
SortOrderSettings get sortOrder {
return SortOrderSettings.values[
prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index];
}
set sortOrder(SortOrderSettings s) {
prefs?.setInt('sortOrder', s.index);
notifyListeners();
}
@ -61,12 +113,54 @@ class SettingsProvider with ChangeNotifier {
while (!(await Permission.requestInstallPackages.isGranted)) {
// Explicit request as InstallPlugin request sometimes bugged
Fluttertoast.showToast(
msg: 'Please allow Obtainium to install Apps',
toastLength: Toast.LENGTH_LONG);
msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG);
if ((await Permission.requestInstallPackages.request()) ==
PermissionStatus.granted) {
break;
}
}
}
bool get showAppWebpage {
return prefs?.getBool('showAppWebpage') ?? false;
}
set showAppWebpage(bool show) {
prefs?.setBool('showAppWebpage', show);
notifyListeners();
}
bool get pinUpdates {
return prefs?.getBool('pinUpdates') ?? true;
}
set pinUpdates(bool show) {
prefs?.setBool('pinUpdates', show);
notifyListeners();
}
String? getSettingString(String settingId) {
return prefs?.getString(settingId);
}
void setSettingString(String settingId, String value) {
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));
}
getCategoryFormItem({String initCategory = ''}) => GeneratedFormDropdown(
'category',
label: tr('category'),
[
MapEntry('', tr('noCategory')),
...categories.entries.map((e) => MapEntry(e.key, e.key)).toList()
],
defaultValue: initCategory);
}

View File

@ -3,9 +3,23 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:html/dom.dart';
import 'package:http/http.dart';
import 'package:html/parser.dart';
import 'package:obtainium/app_sources/apkmirror.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/mullvad.dart';
import 'package:obtainium/app_sources/signal.dart';
import 'package:obtainium/app_sources/sourceforge.dart';
import 'package:obtainium/app_sources/steammobile.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/mass_app_sources/githubstars.dart';
import 'package:obtainium/providers/settings_provider.dart';
class AppNames {
late String author;
@ -17,8 +31,9 @@ class AppNames {
class APKDetails {
late String version;
late List<String> apkUrls;
late AppNames names;
APKDetails(this.version, this.apkUrls);
APKDetails(this.version, this.apkUrls, this.names);
}
class App {
@ -30,15 +45,65 @@ class App {
late String latestVersion;
List<String> apkUrls = [];
late int preferredApkIndex;
App(this.id, this.url, this.author, this.name, this.installedVersion,
this.latestVersion, this.apkUrls, this.preferredApkIndex);
late Map<String, dynamic> additionalSettings;
late DateTime? lastUpdateCheck;
bool pinned = false;
String? category;
App(
this.id,
this.url,
this.author,
this.name,
this.installedVersion,
this.latestVersion,
this.apkUrls,
this.preferredApkIndex,
this.additionalSettings,
this.lastUpdateCheck,
this.pinned,
{this.category});
@override
String toString() {
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls';
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(
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]);
}
}
return App(
json['id'] as String,
json['url'] as String,
json['author'] as String,
@ -47,11 +112,19 @@ class App {
? null
: json['installedVersion'] as String,
json['latestVersion'] as String,
List<String>.from(jsonDecode(json['apkUrls'])),
json['apkUrls'] == null
? []
: List<String>.from(jsonDecode(json['apkUrls'])),
json['preferredApkIndex'] == null
? 0
: json['preferredApkIndex'] as int,
);
additionalSettings,
json['lastUpdateCheck'] == null
? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false,
category: json['category']);
}
Map<String, dynamic> toJson() => {
'id': id,
@ -61,16 +134,33 @@ class App {
'installedVersion': installedVersion,
'latestVersion': latestVersion,
'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex
'preferredApkIndex': preferredApkIndex,
'additionalSettings': jsonEncode(additionalSettings),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned,
'category': category
};
}
escapeRegEx(String s) {
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return "\\${x[0]}";
});
// Ensure the input is starts with HTTPS and has no WWW
preStandardizeUrl(String url) {
if (url.toLowerCase().indexOf('http://') != 0 &&
url.toLowerCase().indexOf('https://') != 0) {
url = 'https://$url';
}
if (url.toLowerCase().indexOf('https://www.') == 0) {
url = 'https://${url.substring(12)}';
}
url = url
.split('/')
.where((e) => e.isNotEmpty)
.join('/')
.replaceFirst(':/', '://');
return url;
}
String noAPKFound = tr('noAPKFound');
List<String> getLinksFromParsedHTML(
Document dom, RegExp hrefPattern, String prependToLinks) =>
dom
@ -82,283 +172,218 @@ List<String> getLinksFromParsedHTML(
.map((e) => '$prependToLinks${e.attributes['href']!}')
.toList();
abstract class AppSource {
late String host;
String standardizeURL(String url);
Future<APKDetails> getLatestAPKDetails(String standardUrl);
AppNames getAppNames(String standardUrl);
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 GitHub implements AppSource {
@override
late String host = 'github.com';
class AppSource {
String? host;
late String name;
bool enforceTrackOnly = false;
AppSource() {
name = runtimeType.toString();
}
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
}
return url.substring(0, match.end);
throw NotImplementedError();
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse('$standardUrl/releases/latest'));
if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl);
var parsedHtml = parse(res.body);
var apkUrlList = getLinksFromParsedHTML(
parsedHtml,
RegExp(
'^${escapeRegEx(standardUri.path)}/releases/download/[^/]+/[^/]+\\.apk\$',
caseSensitive: false),
standardUri.origin);
if (apkUrlList.isEmpty) {
throw 'No APK found';
}
String getTag(String url) {
List<String> parts = url.split('/');
return parts[parts.length - 2];
}
String latestTag = getTag(apkUrlList[0]);
String? version = parsedHtml
.querySelector('.octicon-tag')
?.nextElementSibling
?.innerHtml
.trim();
if (version == null) {
throw 'Could not determine latest release version';
}
return APKDetails(version,
apkUrlList.where((element) => getTag(element) == latestTag).toList());
} else {
throw 'Unable to fetch release info';
}
Future<APKDetails> getLatestAPKDetails(
String standardUrl, Map<String, dynamic> additionalSettings) {
throw NotImplementedError();
}
@override
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]);
// Different Sources may need different kinds of additional data for Apps
List<List<GeneratedFormItem>> additionalSourceAppSpecificSettingFormItems =
[];
// Some additional data may be needed for Apps regardless of Source
final List<List<GeneratedFormItem>>
additionalAppSpecificSourceAgnosticSettingFormItems = [
[
GeneratedFormSwitch(
'trackOnly',
label: tr('trackOnly'),
)
],
[GeneratedFormSwitch('noVersionDetection', label: tr('noVersionDetection'))]
];
// Previous 2 variables combined into one at runtime for convenient usage
List<List<GeneratedFormItem>> get combinedAppSpecificSettingFormItems {
return [
...additionalSourceAppSpecificSettingFormItems,
...additionalAppSpecificSourceAgnosticSettingFormItems
];
}
// Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider
List<GeneratedFormItem> additionalSourceSpecificSettingFormItems = [];
String? changeLogPageFromStandardUrl(String standardUrl) {
return null;
}
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
return apkUrl;
}
bool canSearch = false;
Future<Map<String, String>> search(String query) {
throw NotImplementedError();
}
String? tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) {
return null;
}
}
class GitLab implements AppSource {
@override
late String host = 'gitlab.com';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl);
var parsedHtml = parse(res.body);
var entry = parsedHtml.querySelector('entry');
var entryContent =
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrlList = getLinksFromParsedHTML(
entryContent,
RegExp(
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
caseSensitive: false),
standardUri.origin);
if (apkUrlList.isEmpty) {
throw 'No APK found';
}
var entryId = entry?.querySelector('id')?.innerHtml;
var version =
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
if (version == null) {
throw 'Could not determine latest release version';
}
return APKDetails(version, apkUrlList);
} else {
throw 'Unable to fetch release info';
}
}
@override
AppNames getAppNames(String standardUrl) {
// Same as GitHub
return GitHub().getAppNames(standardUrl);
}
ObtainiumError getObtainiumHttpError(Response res) {
return ObtainiumError(res.reasonPhrase ??
tr('errorWithHttpStatusCode', args: [res.statusCode.toString()]));
}
class Signal implements AppSource {
@override
late String host = 'signal.org';
@override
String standardizeURL(String url) {
return 'https://$host';
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res =
await get(Uri.parse('https://updates.$host/android/latest.json'));
if (res.statusCode == 200) {
var json = jsonDecode(res.body);
String? apkUrl = json['url'];
if (apkUrl == null) {
throw 'No APK found';
}
String? version = json['versionName'];
if (version == null) {
throw 'Could not determine latest release version';
}
return APKDetails(version, [apkUrl]);
} else {
throw 'Unable to fetch release info';
}
}
@override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
}
class FDroid implements AppSource {
@override
late String host = 'f-droid.org';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) {
var latestReleaseDiv =
parse(res.body).querySelector('#latest.package-version');
var apkUrl = latestReleaseDiv
?.querySelector('.package-version-download a')
?.attributes['href'];
if (apkUrl == null) {
throw 'No APK found';
}
var version = latestReleaseDiv
?.querySelector('.package-version-header b')
?.innerHtml
.split(' ')
.last;
if (version == null) {
throw 'Could not determine latest release version';
}
return APKDetails(version, [apkUrl]);
} else {
throw 'Unable to fetch release info';
}
}
@override
AppNames getAppNames(String standardUrl) {
var name = Uri.parse(standardUrl).pathSegments.last;
return AppNames(name, name);
}
}
class Mullvad implements AppSource {
@override
late String host = 'mullvad.net';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
if (res.statusCode == 200) {
var version = parse(res.body)
.querySelector('p.subtitle.is-6')
?.querySelector('a')
?.attributes['href']
?.split('/')
.last;
if (version == null) {
throw 'Could not determine the latest release version';
}
return APKDetails(
version, ['https://mullvad.net/download/app/apk/latest']);
} else {
throw 'Unable to fetch release info';
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
}
abstract class MassAppUrlSource {
late String name;
late List<String> requiredArgs;
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
}
class SourceProvider {
List<AppSource> sources = [GitHub(), GitLab(), FDroid(), Mullvad(), Signal()];
// Add more source classes here so they are available via the service
List<AppSource> sources = [
GitHub(),
GitLab(),
FDroid(),
IzzyOnDroid(),
Mullvad(),
Signal(),
SourceForge(),
APKMirror(),
FDroidRepo(),
SteamMobile()
];
// Add more mass url source classes here so they are available via the service
List<MassAppUrlSource> massUrlSources = [GitHubStars()];
AppSource getSource(String url) {
url = preStandardizeUrl(url);
AppSource? source;
for (var s in sources) {
if (url.toLowerCase().contains('://${s.host}')) {
for (var s in sources.where((element) => element.host != null)) {
if (url.contains('://${s.host}')) {
source = s;
break;
}
}
if (source == null) {
throw 'URL does not match a known source';
for (var s in sources.where((element) => element.host == null)) {
try {
s.standardizeURL(url);
source = s;
break;
} catch (e) {
//
}
}
}
if (source == null) {
throw UnsupportedURLError();
}
return source;
}
Future<App> getApp(String url) async {
if (url.toLowerCase().indexOf('http://') != 0 &&
url.toLowerCase().indexOf('https://') != 0) {
url = 'https://$url';
bool ifRequiredAppSpecificSettingsExist(AppSource source) {
for (var row in source.combinedAppSpecificSettingFormItems) {
for (var element in row) {
if (element is GeneratedFormTextField && element.required) {
return true;
}
}
}
if (url.toLowerCase().indexOf('https://www.') == 0) {
url = 'https://${url.substring(12)}';
}
AppSource source = getSource(url);
String standardUrl = source.standardizeURL(url);
AppNames names = source.getAppNames(standardUrl);
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
return App(
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
standardUrl,
names.author[0].toUpperCase() + names.author.substring(1),
names.name[0].toUpperCase() + names.name.substring(1),
null,
apk.version,
apk.apkUrls,
apk.apkUrls.length - 1);
return false;
}
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
String generateTempID(AppNames names, AppSource source) =>
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}';
bool isTempId(String id) {
List<String> parts = id.split('_');
if (parts.length < 3) {
return false;
}
for (int i = 0; i < parts.length - 1; i++) {
if (RegExp('.*[A-Z].*').hasMatch(parts[i])) {
// TODO: Look into RegEx for non-Latin characters
return false;
}
}
return true;
}
Future<App> getApp(
AppSource source, String url, Map<String, dynamic> additionalSettings,
{App? currentApp,
bool trackOnlyOverride = false,
noVersionDetectionOverride = false}) async {
if (trackOnlyOverride) {
additionalSettings['trackOnly'] = true;
}
if (noVersionDetectionOverride) {
additionalSettings['noVersionDetection'] = true;
}
var trackOnly = currentApp?.additionalSettings['trackOnly'] == true;
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
APKDetails apk =
await source.getLatestAPKDetails(standardUrl, additionalSettings);
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(
currentApp?.id ??
source.tryInferringAppId(standardUrl,
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),
currentApp?.installedVersion,
apkVersion,
apk.apkUrls,
apk.apkUrls.length - 1,
additionalSettings,
DateTime.now(),
currentApp?.pinned ?? false,
category: currentApp?.category);
}
// Returns errors in [results, errors] instead of throwing them
Future<List<dynamic>> getAppsByURLNaive(List<String> urls,
{List<String> ignoreUrls = const []}) async {
List<App> apps = [];
Map<String, dynamic> errors = {};
for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
try {
var source = getSource(url);
apps.add(await getApp(
source,
url,
getDefaultValuesFromFormItems(
source.combinedAppSpecificSettingFormItems)));
} catch (e) {
errors.addAll(<String, dynamic>{url: e});
}
}
return [apps, errors];
}
}

View File

@ -1,13 +1,27 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
android_alarm_manager_plus:
dependency: "direct main"
description:
name: android_alarm_manager_plus
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
animations:
dependency: "direct main"
description:
name: animations
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7"
archive:
dependency: transitive
description:
name: archive
url: "https://pub.dartlang.org"
source: hosted
version: "3.3.1"
version: "3.3.5"
args:
dependency: transitive
description:
@ -64,6 +78,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.16.0"
convert:
dependency: transitive
description:
name: convert
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
cross_file:
dependency: transitive
description:
name: cross_file
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.3+2"
crypto:
dependency: transitive
description:
@ -92,13 +120,41 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.8"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "8.0.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "7.0.0"
dynamic_color:
dependency: "direct main"
description:
name: dynamic_color
url: "https://pub.dartlang.org"
source: hosted
version: "1.5.3"
version: "1.5.4"
easy_localization:
dependency: "direct main"
description:
name: easy_localization
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
easy_logger:
dependency: transitive
description:
name: easy_logger
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.2"
fake_async:
dependency: transitive
description:
@ -120,6 +176,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.4"
file_picker:
dependency: "direct main"
description:
name: file_picker
url: "https://pub.dartlang.org"
source: hosted
version: "5.2.4"
flutter:
dependency: "direct main"
description: flutter
@ -131,14 +194,14 @@ packages:
name: flutter_fgbg
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
version: "0.2.2"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
url: "https://pub.dartlang.org"
source: hosted
version: "0.10.0"
version: "0.11.0"
flutter_lints:
dependency: "direct dev"
description:
@ -152,21 +215,33 @@ packages:
name: flutter_local_notifications
url: "https://pub.dartlang.org"
source: hosted
version: "9.8.0+1"
version: "13.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.1"
version: "3.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.0"
version: "6.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7"
flutter_test:
dependency: "direct dev"
description: flutter
@ -183,14 +258,14 @@ packages:
name: fluttertoast
url: "https://pub.dartlang.org"
source: hosted
version: "8.0.9"
version: "8.1.2"
html:
dependency: "direct main"
description:
name: html
url: "https://pub.dartlang.org"
source: hosted
version: "0.15.0"
version: "0.15.1"
http:
dependency: "direct main"
description:
@ -204,14 +279,14 @@ packages:
name: http_parser
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.1"
version: "4.0.2"
image:
dependency: transitive
description:
name: image
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.0"
version: "3.2.2"
install_plugin_v2:
dependency: "direct main"
description:
@ -219,6 +294,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
installed_apps:
dependency: "direct main"
description:
name: installed_apps
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
intl:
dependency: transitive
description:
name: intl
url: "https://pub.dartlang.org"
source: hosted
version: "0.17.0"
js:
dependency: transitive
description:
@ -232,14 +321,14 @@ packages:
name: json_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "4.6.0"
version: "4.7.0"
lints:
dependency: transitive
description:
name: lints
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.0.1"
matcher:
dependency: transitive
description:
@ -253,7 +342,7 @@ packages:
name: material_color_utilities
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
version: "0.1.5"
meta:
dependency: transitive
description:
@ -261,6 +350,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0"
mime:
dependency: transitive
description:
name: mime
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
nested:
dependency: transitive
description:
@ -268,6 +364,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
package_archive_info:
dependency: "direct main"
description:
name: package_archive_info
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.0"
package_info:
dependency: transitive
description:
name: package_info
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
path:
dependency: transitive
description:
@ -288,7 +398,7 @@ packages:
name: path_provider_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.20"
version: "2.0.22"
path_provider_ios:
dependency: transitive
description:
@ -316,56 +426,56 @@ packages:
name: path_provider_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
version: "2.0.5"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
version: "2.1.3"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
url: "https://pub.dartlang.org"
source: hosted
version: "10.0.0"
version: "10.2.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
url: "https://pub.dartlang.org"
source: hosted
version: "10.0.0"
version: "10.2.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
url: "https://pub.dartlang.org"
source: hosted
version: "9.0.4"
version: "9.0.7"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.7.0"
version: "3.9.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.0"
version: "0.1.2"
petitparser:
dependency: transitive
description:
name: petitparser
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.0"
version: "5.1.0"
platform:
dependency: transitive
description:
@ -379,7 +489,14 @@ packages:
name: plugin_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
version: "2.1.3"
pointycastle:
dependency: transitive
description:
name: pointycastle
url: "https://pub.dartlang.org"
source: hosted
version: "3.6.2"
process:
dependency: transitive
description:
@ -393,7 +510,21 @@ packages:
name: provider
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.3"
version: "6.0.5"
share_plus:
dependency: "direct main"
description:
name: share_plus
url: "https://pub.dartlang.org"
source: hosted
version: "6.3.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.0"
shared_preferences:
dependency: "direct main"
description:
@ -407,7 +538,7 @@ packages:
name: shared_preferences_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.12"
version: "2.0.14"
shared_preferences_ios:
dependency: transitive
description:
@ -421,7 +552,7 @@ packages:
name: shared_preferences_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
version: "2.1.2"
shared_preferences_macos:
dependency: transitive
description:
@ -435,7 +566,7 @@ packages:
name: shared_preferences_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.1.0"
shared_preferences_web:
dependency: transitive
description:
@ -449,7 +580,7 @@ packages:
name: shared_preferences_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
version: "2.1.2"
sky_engine:
dependency: transitive
description: flutter
@ -461,7 +592,21 @@ packages:
name: source_span
url: "https://pub.dartlang.org"
source: hosted
version: "1.9.1"
version: "1.9.0"
sqflite:
dependency: "direct main"
description:
name: sqflite
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.2"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.0+2"
stack_trace:
dependency: transitive
description:
@ -483,6 +628,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
synchronized:
dependency: transitive
description:
name: synchronized
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0+3"
term_glyph:
dependency: transitive
description:
@ -503,7 +655,7 @@ packages:
name: timezone
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.0"
version: "0.9.0"
typed_data:
dependency: transitive
description:
@ -517,14 +669,14 @@ packages:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.5"
version: "6.1.7"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.17"
version: "6.0.22"
url_launcher_ios:
dependency: transitive
description:
@ -552,7 +704,7 @@ packages:
name: url_launcher_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
version: "2.1.1"
url_launcher_web:
dependency: transitive
description:
@ -567,6 +719,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
uuid:
dependency: transitive
description:
name: uuid
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.7"
vector_math:
dependency: transitive
description:
@ -580,42 +739,35 @@ packages:
name: webview_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.4"
version: "4.0.0"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.9.5"
version: "3.0.0"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.9.2"
version: "2.0.0"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
url: "https://pub.dartlang.org"
source: hosted
version: "2.9.3"
version: "3.0.0"
win32:
dependency: transitive
description:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.7.0"
workmanager:
dependency: "direct main"
description:
name: workmanager
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.0"
version: "3.1.3"
xdg_directories:
dependency: transitive
description:
@ -638,5 +790,5 @@ packages:
source: hosted
version: "3.1.1"
sdks:
dart: ">=2.19.0-79.0.dev <3.0.0"
flutter: ">=3.1.0-0.0.pre.1036"
dart: ">=2.18.2 <3.0.0"
flutter: ">=3.3.0"

View File

@ -17,10 +17,10 @@ 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.1.6+7 # When changing this, update the tag in main() accordingly
version: 0.9.2+90 # When changing this, update the tag in main() accordingly
environment:
sdk: '>=2.19.0-79.0.dev <3.0.0'
sdk: '>=2.18.2 <3.0.0'
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
@ -35,40 +35,48 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2
cupertino_icons: ^1.0.5
path_provider: ^2.0.11
flutter_fgbg: ^0.2.0 # Try removing reliance on this
flutter_local_notifications: ^9.8.0+1
flutter_local_notifications: ^13.0.0
provider: ^6.0.3
http: ^0.13.5
webview_flutter: ^3.0.4
workmanager: ^0.5.0
dynamic_color: ^1.5.3
install_plugin_v2: ^1.0.0 # Try replacing this
webview_flutter: ^4.0.0
dynamic_color: ^1.5.4
html: ^0.15.0
shared_preferences: ^2.0.15
url_launcher: ^6.1.5
permission_handler: ^10.0.0
fluttertoast: ^8.0.9
device_info_plus: ^8.0.0
file_picker: ^5.1.0
animations: ^2.0.4
install_plugin_v2: ^1.0.0
share_plus: ^6.0.1
installed_apps: ^1.3.1
package_archive_info: ^0.1.0
android_alarm_manager_plus: ^2.1.0
sqflite: ^2.2.0+3
easy_localization: ^3.0.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_launcher_icons: ^0.10.0
flutter_launcher_icons: ^0.11.0
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^2.0.0
flutter_lints: ^2.0.1
flutter_icons:
android: true
image_path: "assets/icon.png"
image_path: "assets/graphics/icon.png"
adaptive_icon_background: "#FFFFFF"
adaptive_icon_foreground: "assets/icon.png"
adaptive_icon_foreground: "assets/graphics/icon.png"
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@ -82,9 +90,12 @@ flutter:
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/translations/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

View File

@ -13,7 +13,7 @@ import 'package:obtainium/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
await tester.pumpWidget(const Obtainium());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);