Compare commits

...

156 Commits

Author SHA1 Message Date
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
53 changed files with 4891 additions and 1474 deletions

1
.gitignore vendored
View File

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

View File

@ -10,6 +10,7 @@ Currently supported App sources:
- [GitHub](https://github.com/) - [GitHub](https://github.com/)
- [GitLab](https://gitlab.com/) - [GitLab](https://gitlab.com/)
- [F-Droid](https://f-droid.org/) - [F-Droid](https://f-droid.org/)
- [IzzyOnDroid](https://android.izzysoft.de/)
- [Mullvad](https://mullvad.net/en/) - [Mullvad](https://mullvad.net/en/)
- [Signal](https://signal.org/) - [Signal](https://signal.org/)

View File

@ -30,7 +30,25 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> 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> </application>
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <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> </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.0 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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

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

@ -0,0 +1,216 @@
{
"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",
"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",
"onlyAppliesToInstalledAndOutdatedApps": "Only applies to installed but out of date Apps whose install status cannot be automatically detected.",
"markXSelectedAppsAsUpdated": "Mark {} Selected Apps as Updated?",
"no": "No",
"yes": "Yes",
"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": "Updated",
"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 {}",
"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 {} more app have updated.",
"other": "{} and {} more apps have updates."
},
"xAndNMoreUpdatesInstalled": {
"one": "{} and {} more app were updated.",
"other": "{} and {} more apps were updated."
}
}

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(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/#whatsnew';
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) 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, []);
} else {
throw NoReleasesError();
}
}
@override
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,71 @@
import 'dart:convert';
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';
}
@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(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
String? tryInferringAppId(String standardUrl) {
return Uri.parse(standardUrl).pathSegments.last;
}
APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
Response res, String apkUrlPrefix) {
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);
} else {
throw NoReleasesError();
}
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) 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');
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
}
}

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

@ -0,0 +1,207 @@
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';
additionalSourceAppSpecificDefaults = ['true', 'true', ''];
additionalSourceSpecificSettingFormItems = [
GeneratedFormItem(
label: tr('githubPATLabel'),
id: 'github-creds',
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),
))
])
];
additionalSourceAppSpecificFormItems = [
[
GeneratedFormItem(
label: tr('includePrereleases'), type: FormItemType.bool)
],
[
GeneratedFormItem(
label: tr('fallbackToOlderReleases'), type: FormItemType.bool)
],
[
GeneratedFormItem(
label: tr('filterReleaseTitlesByRegEx'),
type: FormItemType.string,
required: false,
additionalValidators: [
(value) {
if (value == null || value.isEmpty) {
return null;
}
try {
RegExp(value);
} catch (e) {
return tr('invalidRegEx');
}
return null;
}
])
]
];
canSearch = true;
}
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(runtimeType.toString());
}
return url.substring(0, match.end);
}
Future<String> getCredentialPrefixIfAny() async {
SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings();
String? creds = settingsProvider
.getSettingString(additionalSourceSpecificSettingFormItems[0].id);
return creds != null && creds.isNotEmpty ? '$creds@' : '';
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/releases';
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
var includePrereleases =
additionalData.isNotEmpty && additionalData[0] == 'true';
var fallbackToOlderReleases =
additionalData.length >= 2 && additionalData[1] == 'true';
var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty
? additionalData[2]
: null;
Response res = await get(Uri.parse(
'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
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 && !trackOnly) {
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>);
} else {
rateLimitErrorCheck(res);
throw getObtainiumHttpError(res);
}
}
@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]);
}
@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,70 @@
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(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/-/releases';
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
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);
} else {
throw NoReleasesError();
}
}
@override
AppNames getAppNames(String standardUrl) {
// Same as GitHub
return GitHub().getAppNames(standardUrl);
}
}

View File

@ -0,0 +1,44 @@
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(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
String? tryInferringAppId(String standardUrl) {
return FDroid().tryInferringAppId(standardUrl);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) 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');
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
}
}

View File

@ -0,0 +1,51 @@
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(runtimeType.toString());
}
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, List<String> additionalData,
{bool trackOnly = false}) 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']);
} else {
throw NoReleasesError();
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
}
}

View File

@ -0,0 +1,41 @@
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, List<String> additionalData,
{bool trackOnly = false}) 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);
} else {
throw NoReleasesError();
}
}
@override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
}

View File

@ -0,0 +1,64 @@
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(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
Response res = await get(Uri.parse('$standardUrl/rss?path=/'));
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);
} else {
throw NoReleasesError();
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames(runtimeType.toString(),
standardUrl.substring(standardUrl.lastIndexOf('/') + 1));
}
}

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,229 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
enum FormItemType { string, bool }
typedef OnValueChanges = void Function(
List<String> values, bool valid, bool isBuilding);
class GeneratedFormItem {
late String key;
late String label;
late FormItemType type;
late bool required;
late int max;
late List<String? Function(String? value)> additionalValidators;
late String id;
late List<Widget> belowWidgets;
late String? hint;
late List<String>? opts;
GeneratedFormItem(
{this.label = 'Input',
this.type = FormItemType.string,
this.required = true,
this.max = 1,
this.additionalValidators = const [],
this.id = 'input',
this.belowWidgets = const [],
this.hint,
this.opts,
this.key = 'default'});
}
class GeneratedForm extends StatefulWidget {
const GeneratedForm(
{super.key,
required this.items,
required this.onValueChanges,
required this.defaultValues});
final List<List<GeneratedFormItem>> items;
final OnValueChanges onValueChanges;
final List<String> defaultValues;
@override
State<GeneratedForm> createState() => _GeneratedFormState();
}
class _GeneratedFormState extends State<GeneratedForm> {
final _formKey = GlobalKey<FormState>();
late List<List<String>> 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}) {
List<String> returnValues = [];
var valid = true;
for (int r = 0; r < values.length; r++) {
for (int i = 0; i < values[r].length; i++) {
returnValues.add(values[r][i]);
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
int j = 0;
values = widget.items
.map((row) => row.map((e) {
return j < widget.defaultValues.length
? widget.defaultValues[j++]
: e.opts != null
? e.opts!.first
: '';
}).toList())
.toList();
// Dynamically create form inputs
formInputs = widget.items.asMap().entries.map((row) {
return row.value.asMap().entries.map((e) {
if (e.value.type == FormItemType.string && e.value.opts == null) {
final formFieldKey = GlobalKey<FormFieldState>();
return TextFormField(
key: formFieldKey,
initialValue: values[row.key][e.key],
autovalidateMode: AutovalidateMode.onUserInteraction,
onChanged: (value) {
setState(() {
values[row.key][e.key] = value;
someValueChanged();
});
},
decoration: InputDecoration(
helperText: e.value.label + (e.value.required ? ' *' : ''),
hintText: e.value.hint),
minLines: e.value.max <= 1 ? null : e.value.max,
maxLines: e.value.max <= 1 ? 1 : e.value.max,
validator: (value) {
if (e.value.required && (value == null || value.trim().isEmpty)) {
return '${e.value.label} ${tr('requiredInBrackets')}';
}
for (var validator in e.value.additionalValidators) {
String? result = validator(value);
if (result != null) {
return result;
}
}
return null;
},
);
} else if (e.value.type == FormItemType.string &&
e.value.opts != null) {
if (e.value.opts!.isEmpty) {
return Text(tr('dropdownNoOptsError'));
}
return DropdownButtonFormField(
decoration: InputDecoration(labelText: tr('colour')),
value: values[row.key][e.key],
items: e.value.opts!
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
.toList(),
onChanged: (value) {
setState(() {
values[row.key][e.key] = value ?? e.value.opts!.first;
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].type == FormItemType.bool) {
formInputs[r][e] = Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(widget.items[r][e].label),
Switch(
value: values[r][e] == 'true',
onChanged: (value) {
setState(() {
values[r][e] = value ? 'true' : '';
someValueChanged();
});
})
],
);
}
}
}
rows.clear();
formInputs.asMap().entries.forEach((rowInputs) {
if (rowInputs.key > 0) {
rows.add([
SizedBox(
height: widget.items[rowInputs.key][0].type == FormItemType.bool &&
widget.items[rowInputs.key - 1][0].type ==
FormItemType.string
? 25
: 8,
)
]);
}
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)],
))
],
));
}
}
String? findGeneratedFormValueByKey(
List<GeneratedFormItem> items, List<String> values, String key) {
var foundIndex = -1;
for (var i = 0; i < items.length; i++) {
if (items[i].key == key) {
foundIndex = i;
break;
}
}
if (foundIndex >= 0 && foundIndex < values.length) {
return values[foundIndex];
}
return null;
}

View File

@ -1,81 +1,82 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form.dart';
class GeneratedFormItem {
late String message;
late bool required;
late int lines;
GeneratedFormItem(this.message, this.required, this.lines);
}
class GeneratedFormModal extends StatefulWidget { class GeneratedFormModal extends StatefulWidget {
const GeneratedFormModal( const GeneratedFormModal(
{super.key, required this.title, required this.items}); {super.key,
required this.title,
required this.items,
required this.defaultValues,
this.initValid = false,
this.message = ''});
final String title; final String title;
final List<GeneratedFormItem> items; final String message;
final List<List<GeneratedFormItem>> items;
final List<String> defaultValues;
final bool initValid;
@override @override
State<GeneratedFormModal> createState() => _GeneratedFormModalState(); State<GeneratedFormModal> createState() => _GeneratedFormModalState();
} }
class _GeneratedFormModalState extends State<GeneratedFormModal> { class _GeneratedFormModalState extends State<GeneratedFormModal> {
final _formKey = GlobalKey<FormState>(); List<String> values = [];
bool valid = false;
final urlInputController = TextEditingController(); @override
void initState() {
super.initState();
values = widget.defaultValues;
valid = widget.initValid || widget.items.isEmpty;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final formInputs = widget.items.map((e) {
final controller = TextEditingController();
return [
controller,
TextFormField(
decoration: InputDecoration(helperText: e.message),
controller: controller,
minLines: e.lines <= 1 ? null : e.lines,
maxLines: e.lines <= 1 ? 1 : e.lines,
validator: e.required
? (value) {
if (value == null || value.isEmpty) {
return '${e.message} (required)';
}
return null;
}
: null,
)
];
}).toList();
return AlertDialog( return AlertDialog(
scrollable: true, scrollable: true,
title: Text(widget.title), title: Text(widget.title),
content: Form( content:
key: _formKey, Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
child: Column( if (widget.message.isNotEmpty) Text(widget.message),
crossAxisAlignment: CrossAxisAlignment.stretch, if (widget.message.isNotEmpty)
children: [...formInputs.map((e) => e[1] as Widget)], 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;
});
}
},
defaultValues: widget.defaultValues)
]),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: const Text('Cancel')), child: Text(tr('cancel'))),
TextButton( TextButton(
onPressed: () { onPressed: !valid
if (_formKey.currentState?.validate() == true) { ? null
HapticFeedback.heavyImpact(); : () {
Navigator.of(context).pop(formInputs if (valid) {
.map((e) => (e[0] as TextEditingController).value.text) HapticFeedback.selectionClick();
.toList()); Navigator.of(context).pop(values);
} }
}, },
child: const Text('Continue')) child: Text(tr('continue')))
], ],
); );
} }
} }
// TODO: Add support for larger textarea so this can be used for text/json imports

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,108 +1,188 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/pages/home.dart'; import 'package:obtainium/pages/home.dart';
import 'package:obtainium/providers/apps_provider.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/notifications_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:workmanager/workmanager.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.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';
const String currentVersion = '0.8.3';
const String currentReleaseTag = const String currentReleaseTag =
'v0.2.1-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES 'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
const int bgUpdateCheckAlarmId = 666;
@pragma('vm:entry-point') @pragma('vm:entry-point')
void bgTaskCallback() { Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
// Background update checking process LogsProvider logs = LogsProvider();
Workmanager().executeTask((task, taskName) async { logs.add(tr('startedBgUpdateTask'));
var notificationsProvider = NotificationsProvider(); int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
await notificationsProvider.notify(checkingUpdatesNotification); WidgetsFlutterBinding.ensureInitialized();
await EasyLocalization.ensureInitialized();
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(forBGTask: true);
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps();
List<String> existingUpdateIds =
appsProvider.findExistingUpdates(installedOnly: true);
DateTime nextIgnoreAfter = DateTime.now();
String? err;
try { try {
var appsProvider = AppsProvider(); logs.add(tr('startedActualBGUpdateCheck'));
await notificationsProvider await appsProvider.checkUpdates(
.cancel(ErrorCheckingUpdatesNotification('').id); ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
await appsProvider.loadApps();
List<App> updates = await appsProvider.checkUpdates();
if (updates.isNotEmpty) {
notificationsProvider.notify(UpdateNotification(updates),
cancelExisting: true);
}
return Future.value(true);
} catch (e) { } catch (e) {
notificationsProvider.notify( if (e is RateLimitError || e is SocketException) {
ErrorCheckingUpdatesNotification(e.toString()), var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15;
cancelExisting: true); logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes,
return Future.value(false); args: [e.runtimeType.toString()]));
} finally { AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes),
await notificationsProvider.cancel(checkingUpdatesNotification.id); 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 { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) { await EasyLocalization.ensureInitialized();
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
); );
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} }
Workmanager().initialize( await AndroidAlarmManager.initialize();
bgTaskCallback,
);
runApp(MultiProvider( runApp(MultiProvider(
providers: [ providers: [
ChangeNotifierProvider( ChangeNotifierProvider(create: (context) => AppsProvider()),
create: (context) => AppsProvider(
shouldLoadApps: true,
shouldCheckUpdatesAfterLoad: true,
shouldDeleteAPKs: true)),
ChangeNotifierProvider(create: (context) => SettingsProvider()), ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => NotificationsProvider()) Provider(create: (context) => NotificationsProvider()),
Provider(create: (context) => LogsProvider())
], ],
child: const MyApp(), child: EasyLocalization(
supportedLocales: const [Locale('en')],
path: 'assets/translations',
fallbackLocale: const Locale('en'),
child: const Obtainium()),
)); ));
} }
var defaultThemeColour = Colors.deepPurple; var defaultThemeColour = Colors.deepPurple;
class MyApp extends StatelessWidget { class Obtainium extends StatefulWidget {
const MyApp({super.key}); const Obtainium({super.key});
@override
State<Obtainium> createState() => _ObtainiumState();
}
class _ObtainiumState extends State<Obtainium> {
var existingUpdateInterval = -1;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SettingsProvider settingsProvider = context.watch<SettingsProvider>(); SettingsProvider settingsProvider = context.watch<SettingsProvider>();
AppsProvider appsProvider = context.read<AppsProvider>(); AppsProvider appsProvider = context.read<AppsProvider>();
LogsProvider logs = context.read<LogsProvider>();
if (settingsProvider.prefs == null) { if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings(); settingsProvider.initializeSettings();
} else { } else {
// Register the background update task according to the user's setting
if (settingsProvider.updateInterval > 0) {
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);
} else {
Workmanager().cancelByUniqueName('bg-update-check');
}
bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
if (isFirstRun) { if (isFirstRun) {
logs.add(tr('firstRun'));
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list // If this is the first run, ask for notification permissions and add Obtainium to the Apps list
Permission.notification.request(); Permission.notification.request();
appsProvider.saveApp(App( appsProvider.saveApps([
'imranr98_obtainium_${GitHub().host}', App(
'https://github.com/ImranR98/Obtainium', obtainiumId,
'ImranR98', 'https://github.com/ImranR98/Obtainium',
'Obtainium', 'ImranR98',
currentReleaseTag, 'Obtainium',
currentReleaseTag, currentReleaseTag,
[], currentReleaseTag,
0)); [],
0,
['true'],
null,
false,
false,
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);
}
} }
} }
@ -123,6 +203,9 @@ class MyApp extends StatelessWidget {
} }
return MaterialApp( return MaterialApp(
title: 'Obtainium', title: 'Obtainium',
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
theme: ThemeData( theme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: settingsProvider.theme == ThemeSettings.dark 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,12 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/pages/app.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/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
@ -15,120 +21,385 @@ class AddAppPage extends StatefulWidget {
} }
class _AddAppPageState extends State<AddAppPage> { class _AddAppPageState extends State<AddAppPage> {
final _formKey = GlobalKey<FormState>();
final urlInputController = TextEditingController();
bool gettingAppInfo = false; bool gettingAppInfo = false;
String userInput = '';
String searchQuery = '';
AppSource? pickedSource;
List<String> sourceSpecificAdditionalData = [];
bool sourceSpecificDataIsValid = true;
List<String> otherAdditionalData = [];
bool otherAdditionalDataIsValid = true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
return Center( AppsProvider appsProvider = context.read<AppsProvider>();
child: Form(
key: _formKey, changeUserInput(String input, bool valid, bool isBuilding) {
child: Column( userInput = input;
mainAxisAlignment: MainAxisAlignment.spaceBetween, fn() {
crossAxisAlignment: CrossAxisAlignment.stretch, var source = valid ? sourceProvider.getSource(userInput) : null;
children: [ if (pickedSource != source) {
Container(), pickedSource = source;
Padding( sourceSpecificAdditionalData =
source != null ? source.additionalSourceAppSpecificDefaults : [];
sourceSpecificDataIsValid = source != null
? sourceProvider.ifSourceAppsRequireAdditionalData(source)
: true;
}
}
if (isBuilding) {
fn();
} else {
setState(() {
fn();
});
}
}
addApp({bool resetUserInputAfter = false}) async {
setState(() {
gettingAppInfo = true;
});
var settingsProvider = context.read<SettingsProvider>();
() async {
var userPickedTrackOnly = findGeneratedFormValueByKey(
pickedSource!.additionalAppSpecificSourceAgnosticFormItems,
otherAdditionalData,
'trackOnlyFormItemKey') ==
'true';
var cont = true;
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
await showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('xIsTrackOnly', args: [
pickedSource!.enforceTrackOnly
? tr('source')
: tr('app')
]),
items: const [],
defaultValues: const [],
message:
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
);
}) ==
null) {
cont = false;
}
if (cont) {
HapticFeedback.selectionClick();
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
App app = await sourceProvider.getApp(
pickedSource!, userInput, sourceSpecificAdditionalData,
trackOnly: trackOnly);
if (!trackOnly) {
await settingsProvider.getInstallPermission();
}
// Only download the APK here if you need to for the package ID
if (sourceProvider.isTempId(app.id) && !app.trackOnly) {
// 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, context);
app.id = downloadedApk.appId;
}
if (appsProvider.apps.containsKey(app.id)) {
throw ObtainiumError(tr('appAlreadyAdded'));
}
if (app.trackOnly) {
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), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
TextFormField( Row(
decoration: const InputDecoration( children: [
hintText: 'https://github.com/Author/Project', Expanded(
helperText: 'Enter the App source URL'), child: GeneratedForm(
controller: urlInputController, items: [
validator: (value) { [
if (value == null || GeneratedFormItem(
value.isEmpty || label: tr('appSourceURL'),
Uri.tryParse(value) == null) { additionalValidators: [
return 'Please enter a supported source URL'; (value) {
} try {
return null; sourceProvider
}, .getSource(value ?? '')
), .standardizeURL(
Padding( preStandardizeUrl(
padding: const EdgeInsets.symmetric(vertical: 16.0), value ?? ''));
child: ElevatedButton( } catch (e) {
onPressed: gettingAppInfo return e is String
? null ? e
: () { : e is ObtainiumError
HapticFeedback.mediumImpact(); ? e.toString()
if (_formKey.currentState!.validate()) { : tr('error');
setState(() { }
gettingAppInfo = true; return null;
}); }
sourceProvider ])
.getApp(urlInputController.value.text) ]
.then((app) { ],
var appsProvider = onValueChanges: (values, valid, isBuilding) {
context.read<AppsProvider>(); changeUserInput(
var settingsProvider = values[0], valid, isBuilding);
context.read<SettingsProvider>(); },
if (appsProvider.apps.containsKey(app.id)) { defaultValues: const [])),
throw 'App already added'; const SizedBox(
} width: 16,
settingsProvider ),
.getInstallPermission() gettingAppInfo
.then((_) { ? const CircularProgressIndicator()
appsProvider.saveApp(app).then((_) { : ElevatedButton(
urlInputController.clear(); onPressed: gettingAppInfo ||
Navigator.push( pickedSource == null ||
context, (pickedSource!
MaterialPageRoute( .additionalSourceAppSpecificFormItems
builder: (context) => .isNotEmpty &&
AppPage(appId: app.id))); !sourceSpecificDataIsValid) ||
}); (pickedSource!
}); .additionalAppSpecificSourceAgnosticDefaults
}).catchError((e) { .isNotEmpty &&
ScaffoldMessenger.of(context).showSnackBar( !otherAdditionalDataIsValid)
SnackBar(content: Text(e.toString())), ? null
); : addApp,
}).whenComplete(() { child: Text(tr('add')))
setState(() { ],
gettingAppInfo = false;
});
});
}
},
child: const Text('Add'),
), ),
), if (sourceProvider.sources
], .where((e) => e.canSearch)
), .isNotEmpty &&
), pickedSource == null &&
Column(crossAxisAlignment: CrossAxisAlignment.center, children: [ userInput.isEmpty)
const Text( const SizedBox(
'Supported Sources:', height: 16,
// style: TextStyle(fontWeight: FontWeight.bold), ),
// style: Theme.of(context).textTheme.bodySmall, if (sourceProvider.sources
), .where((e) => e.canSearch)
const SizedBox( .isNotEmpty &&
height: 8, pickedSource == null &&
), userInput.isEmpty)
...sourceProvider Row(
.getSourceHosts() children: [
.map((e) => GestureDetector( Expanded(
onTap: () { child: GeneratedForm(
launchUrlString('https://$e', items: [
mode: LaunchMode.externalApplication); [
}, GeneratedFormItem(
child: Text( label: tr('searchSomeSourcesLabel'),
e, required: false),
style: const TextStyle( ]
decoration: TextDecoration.underline, ],
fontStyle: FontStyle.italic), onValueChanges: (values, valid, isBuilding) {
))) if (values.isNotEmpty && valid) {
.toList() setState(() {
]), searchQuery = values[0].trim();
if (gettingAppInfo) });
const LinearProgressIndicator() }
else },
Container(), defaultValues: const ['']),
], ),
)), const SizedBox(
); width: 16,
),
ElevatedButton(
onPressed: searchQuery.isEmpty || gettingAppInfo
? null
: () {
Future.wait(sourceProvider.sources
.where((e) => e.canSearch)
.map((e) =>
e.search(searchQuery)))
.then((results) async {
// Interleave results instead of simple reduce
Map<String, String> res = {};
var si = 0;
var done = false;
while (!done) {
done = true;
for (var r in results) {
if (r.length > si) {
done = false;
res.addEntries(
[r.entries.elementAt(si)]);
}
}
si++;
}
List<String>? selectedUrls = res
.isEmpty
? []
: await showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return UrlSelectionModal(
urlsWithDescriptions: res,
selectedByDefault: false,
onlyOneSelectionAllowed:
true,
);
});
if (selectedUrls != null &&
selectedUrls.isNotEmpty) {
changeUserInput(
selectedUrls[0], true, true);
addApp(resetUserInputAfter: true);
}
}).catchError((e) {
showError(e, context);
});
},
child: Text(tr('search')))
],
),
if (pickedSource != null &&
(pickedSource!.additionalSourceAppSpecificDefaults
.isNotEmpty ||
pickedSource!
.additionalAppSpecificSourceAgnosticFormItems
.where((e) => pickedSource!.enforceTrackOnly
? e.key != 'trackOnlyFormItemKey'
: true)
.map((e) => [e])
.isNotEmpty))
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Divider(
height: 64,
),
Text(
tr('additionalOptsFor', args: [
pickedSource?.runtimeType.toString() ??
tr('source')
]),
style: TextStyle(
color:
Theme.of(context).colorScheme.primary)),
const SizedBox(
height: 16,
),
if (pickedSource!
.additionalSourceAppSpecificFormItems
.isNotEmpty)
GeneratedForm(
items: pickedSource!
.additionalSourceAppSpecificFormItems,
onValueChanges: (values, valid, isBuilding) {
if (isBuilding) {
sourceSpecificAdditionalData = values;
sourceSpecificDataIsValid = valid;
} else {
setState(() {
sourceSpecificAdditionalData = values;
sourceSpecificDataIsValid = valid;
});
}
},
defaultValues: pickedSource!
.additionalSourceAppSpecificDefaults),
if (pickedSource!
.additionalAppSpecificSourceAgnosticDefaults
.isNotEmpty)
const SizedBox(
height: 8,
),
GeneratedForm(
items: pickedSource!
.additionalAppSpecificSourceAgnosticFormItems
.where((e) => pickedSource!.enforceTrackOnly
? e.key != 'trackOnlyFormItemKey'
: true)
.map((e) => [e])
.toList(),
onValueChanges: (values, valid, isBuilding) {
if (isBuilding) {
otherAdditionalData = values;
otherAdditionalDataIsValid = valid;
} else {
setState(() {
otherAdditionalData = values;
otherAdditionalDataIsValid = valid;
});
}
},
defaultValues: pickedSource!
.additionalAppSpecificSourceAgnosticDefaults),
],
)
else
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 48,
),
Text(
tr('supportedSourcesBelow'),
),
const SizedBox(
height: 8,
),
...sourceProvider.sources
.map((e) => GestureDetector(
onTap: () {
launchUrlString('https://${e.host}',
mode:
LaunchMode.externalApplication);
},
child: Text(
'${e.runtimeType.toString()}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
style: const TextStyle(
decoration:
TextDecoration.underline,
fontStyle: FontStyle.italic),
)))
.toList()
])),
const SizedBox(
height: 8,
),
])),
)
]));
} }
} }

View File

@ -1,7 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.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/apps_provider.dart';
import 'package:obtainium/providers/settings_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:url_launcher/url_launcher_string.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -16,70 +19,115 @@ class AppPage extends StatefulWidget {
} }
class _AppPageState extends State<AppPage> { class _AppPageState extends State<AppPage> {
AppInMemory? prevApp;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>(); var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>(); var settingsProvider = context.watch<SettingsProvider>();
getUpdate(String id) {
appsProvider.checkUpdate(id).catchError((e) {
showError(e, context);
});
}
var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId]; AppInMemory? app = appsProvider.apps[widget.appId];
if (app?.app.installedVersion != null) { var source = app != null ? sourceProvider.getSource(app.app.url) : null;
appsProvider.getUpdate(app!.app.id); if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) {
prevApp = app;
getUpdate(app.app.id);
} }
return Scaffold( return Scaffold(
appBar: AppBar( appBar: settingsProvider.showAppWebpage ? AppBar() : null,
title: Text('${app?.app.author}/${app?.app.name}'), backgroundColor: Theme.of(context).colorScheme.surface,
), body: RefreshIndicator(
body: settingsProvider.showAppWebpage child: settingsProvider.showAppWebpage
? WebView( ? WebView(
initialUrl: app?.app.url, backgroundColor: Theme.of(context).colorScheme.background,
javascriptMode: JavascriptMode.unrestricted, initialUrl: app?.app.url,
) javascriptMode: JavascriptMode.unrestricted,
: Column( )
mainAxisAlignment: MainAxisAlignment.center, : CustomScrollView(
crossAxisAlignment: CrossAxisAlignment.stretch, slivers: [
children: [ SliverFillRemaining(
Text( child: Column(
app?.app.name ?? 'App', mainAxisAlignment: MainAxisAlignment.center,
textAlign: TextAlign.center, crossAxisAlignment: CrossAxisAlignment.stretch,
style: Theme.of(context).textTheme.displayLarge, children: [
), app?.installedInfo != null
Text( ? Row(
'By ${app?.app.author ?? 'Unknown'}', mainAxisAlignment: MainAxisAlignment.center,
textAlign: TextAlign.center, children: [
style: Theme.of(context).textTheme.headlineMedium, Image.memory(
), app!.installedInfo!.icon!,
const SizedBox( height: 150,
height: 32, gaplessPlayback: true,
), )
GestureDetector( ])
onTap: () { : Container(),
if (app?.app.url != null) { const SizedBox(
launchUrlString(app?.app.url ?? '', height: 25,
mode: LaunchMode.externalApplication); ),
} Text(
}, app?.installedInfo?.name ?? app?.app.name ?? 'App',
child: Text( textAlign: TextAlign.center,
app?.app.url ?? '', style: Theme.of(context).textTheme.displayLarge,
textAlign: TextAlign.center, ),
style: const TextStyle( Text(
decoration: TextDecoration.underline, 'By ${app?.app.author ?? 'Unknown'}',
fontStyle: FontStyle.italic, textAlign: TextAlign.center,
fontSize: 12), style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(
height: 32,
),
GestureDetector(
onTap: () {
if (app?.app.url != null) {
launchUrlString(app?.app.url ?? '',
mode: LaunchMode.externalApplication);
}
},
child: Text(
app?.app.url ?? '',
textAlign: TextAlign.center,
style: const TextStyle(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic,
fontSize: 12),
)),
const SizedBox(
height: 32,
),
Text(
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(
'Installed Version: ${app?.app.installedVersion ?? 'None'}${app?.app.trackOnly == true ? ' (Estimate)\n\nApp is Track-Only' : ''}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(
height: 32,
),
Text(
'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}',
textAlign: TextAlign.center,
style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12),
)
],
)), )),
const SizedBox( ],
height: 32,
), ),
Text( onRefresh: () async {
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}', if (app != null) {
textAlign: TextAlign.center, getUpdate(app.app.id);
style: Theme.of(context).textTheme.bodyLarge, }
), }),
Text(
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
bottomSheet: Padding( bottomSheet: Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
0, 0, 0, MediaQuery.of(context).padding.bottom), 0, 0, 0, MediaQuery.of(context).padding.bottom),
@ -91,84 +139,138 @@ class _AppPageState extends State<AppPage> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
if (app?.app.installedVersion == null) if (app?.app.installedVersion != null &&
app?.app.trackOnly == false &&
app?.app.installedVersion !=
app?.app.latestVersion &&
app?.app.enhancedVersionDetection != true)
IconButton( IconButton(
onPressed: () { onPressed: app?.downloadProgress != null
showDialog( ? null
context: context, : () {
builder: (BuildContext ctx) { showDialog(
return AlertDialog( context: context,
title: const Text( builder: (BuildContext ctx) {
'App Already Installed?'), return AlertDialog(
actions: [ title: const Text(
TextButton( 'App Already up to Date?'),
onPressed: () { actions: [
Navigator.of(context).pop(); TextButton(
}, onPressed: () {
child: const Text('No')), Navigator.of(context)
TextButton( .pop();
onPressed: () { },
var updatedApp = app?.app; child: const Text('No')),
if (updatedApp != null) { TextButton(
updatedApp.installedVersion = onPressed: () {
updatedApp.latestVersion; HapticFeedback
appsProvider .selectionClick();
.saveApp(updatedApp); var updatedApp = app?.app;
} if (updatedApp != null) {
Navigator.of(context).pop(); updatedApp
}, .installedVersion =
child: const Text( updatedApp
'Yes, Mark as Installed')) .latestVersion;
], appsProvider.saveApps(
); [updatedApp]);
}); }
}, Navigator.of(context)
tooltip: 'Mark as Installed', .pop();
},
child: const Text(
'Yes, Mark as Updated'))
],
);
});
},
tooltip: 'Mark as Updated',
icon: const Icon(Icons.done)), icon: const Icon(Icons.done)),
if (app?.app.installedVersion == null) if (source != null &&
const SizedBox(width: 16.0), source.additionalSourceAppSpecificFormItems
.isNotEmpty)
IconButton(
onPressed: app?.downloadProgress != null
? null
: () {
showDialog<List<String>>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Additional Options',
items: source
.additionalSourceAppSpecificFormItems,
defaultValues: app != null
? app.app.additionalData
: source
.additionalSourceAppSpecificDefaults);
}).then((values) {
if (app != null && values != null) {
var changedApp = app.app;
changedApp.additionalData = values;
appsProvider.saveApps(
[changedApp]).then((value) {
getUpdate(changedApp.id);
});
}
});
},
tooltip: 'Additional Options',
icon: const Icon(Icons.settings)),
const SizedBox(width: 16.0),
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: (app?.app.installedVersion == null || onPressed: (app?.app.installedVersion == null ||
appsProvider app?.app.installedVersion !=
.checkAppObjectForUpdate( app?.app.latestVersion) &&
app!.app)) &&
!appsProvider.areDownloadsRunning() !appsProvider.areDownloadsRunning()
? () { ? () {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
appsProvider () async {
.downloadAndInstallLatestApp( if (app?.app.trackOnly != true) {
[app!.app.id], await settingsProvider
context).then((res) { .getInstallPermission();
if (res && mounted) {
Navigator.of(context).pop();
} }
}()
.then((value) {
appsProvider
.downloadAndInstallLatestApps(
[app!.app.id],
context).then((res) {
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
}
});
}).catchError((e) {
showError(e, context);
}); });
} }
: null, : null,
child: Text(app?.app.installedVersion == null child: Text(app?.app.installedVersion == null
? 'Install' ? app?.app.trackOnly == false
: 'Update'))), ? 'Install'
: 'Mark Installed'
: app?.app.trackOnly == false
? 'Update'
: 'Mark Updated'))),
const SizedBox(width: 16.0), const SizedBox(width: 16.0),
ElevatedButton( ElevatedButton(
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
? null ? null
: () { : () {
HapticFeedback.lightImpact();
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return AlertDialog( return AlertDialog(
title: const Text('Remove App?'), title: const Text('Remove App?'),
content: Text( 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.' : ''}'), 'This will remove \'${app?.installedInfo?.name ?? app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.heavyImpact(); HapticFeedback
appsProvider .selectionClick();
.removeApp(app!.app.id) appsProvider.removeApps(
.then((_) { [app!.app.id]).then((_) {
int count = 0; int count = 0;
Navigator.of(context) Navigator.of(context)
.popUntil((_) => .popUntil((_) =>
@ -178,7 +280,6 @@ class _AppPageState extends State<AppPage> {
child: const Text('Remove')), child: const Text('Remove')),
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: const Text('Cancel')) child: const Text('Cancel'))

View File

@ -1,95 +1,752 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/pages/app.dart'; import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/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 { class AppsPage extends StatefulWidget {
const AppsPage({super.key}); const AppsPage({super.key});
@override @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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>(); var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>(); var settingsProvider = context.watch<SettingsProvider>();
var existingUpdateAppIds = appsProvider.getExistingUpdates();
var sortedApps = appsProvider.apps.values.toList(); 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.isEmpty && filter!.authorFilter.isEmpty) {
return true;
}
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;
}
}
return true;
}).toList();
}
sortedApps.sort((a, b) { sortedApps.sort((a, b) {
var nameA = a.installedInfo?.name ?? a.app.name;
var nameB = b.installedInfo?.name ?? b.app.name;
int result = 0; int result = 0;
if (settingsProvider.sortColumn == SortColumnSettings.authorName) { if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
result = result = (a.app.author + nameA).compareTo(b.app.author + nameB);
(a.app.author + a.app.name).compareTo(b.app.author + b.app.name);
} else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) { } else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) {
result = result = (nameA + a.app.author).compareTo(nameB + b.app.author);
(a.app.name + a.app.author).compareTo(b.app.name + b.app.author);
} }
return result; return result;
}); });
if (settingsProvider.sortOrder == SortOrderSettings.ascending) {
if (settingsProvider.sortOrder == SortOrderSettings.descending) {
sortedApps = sortedApps.reversed.toList(); 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.trackOnly) {
trackOnlyUpdateIdsAllOrSelected.add(id);
return false;
}
return true;
}).toList();
newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) {
if (appsProvider.apps[id]!.app.trackOnly) {
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( return Scaffold(
floatingActionButton: existingUpdateAppIds.isEmpty backgroundColor: Theme.of(context).colorScheme.surface,
? null body: RefreshIndicator(
: ElevatedButton.icon( onRefresh: () {
onPressed: appsProvider.areDownloadsRunning() HapticFeedback.lightImpact();
? null setState(() {
: () { refreshingSince = DateTime.now();
HapticFeedback.heavyImpact(); });
settingsProvider.getInstallPermission().then((_) { return appsProvider.checkUpdates().catchError((e) {
appsProvider.downloadAndInstallLatestApp( showError(e, context);
existingUpdateAppIds, context); }).whenComplete(() {
}); setState(() {
}, refreshingSince = null;
icon: const Icon(Icons.update), });
label: const Text('Update All')), });
body: Center( },
child: appsProvider.loadingApps child: CustomScrollView(slivers: <Widget>[
? const CircularProgressIndicator() CustomAppBar(title: tr('appsString')),
: appsProvider.apps.isEmpty if (appsProvider.loadingApps || sortedApps.isEmpty)
? Text( SliverFillRemaining(
'No Apps', child: Center(
style: Theme.of(context).textTheme.headlineMedium, child: appsProvider.loadingApps
) ? const CircularProgressIndicator()
: RefreshIndicator( : Text(
onRefresh: () { appsProvider.apps.isEmpty
HapticFeedback.lightImpact(); ? tr('noApps')
return appsProvider.checkUpdates(); : tr('noAppsForFilter'),
}, style: Theme.of(context).textTheme.headlineMedium,
child: ListView( textAlign: TextAlign.center,
children: sortedApps ))),
.map( if (refreshingSince != null)
(e) => ListTile( SliverToBoxAdapter(
title: Text('${e.app.author}/${e.app.name}'), child: LinearProgressIndicator(
subtitle: Text( value: appsProvider.apps.values
e.app.installedVersion ?? 'Not Installed'), .where((element) => !(element.app.lastUpdateCheck
trailing: e.downloadProgress != null ?.isBefore(refreshingSince!) ??
? Text( true))
'Downloading - ${e.downloadProgress?.toInt()}%') .length /
: (e.app.installedVersion != null && appsProvider.apps.length,
e.app.installedVersion != ),
e.app.latestVersion ),
? const Text('Update Available') SliverList(
: null), delegate: SliverChildBuilderDelegate(
onTap: () { (BuildContext context, int index) {
Navigator.push( String? changesUrl = SourceProvider()
context, .getSource(sortedApps[index].app.url)
MaterialPageRoute( .changeLogPageFromStandardUrl(sortedApps[index].app.url);
builder: (context) => return ListTile(
AppPage(appId: e.app.id)), tileColor: sortedApps[index].app.pinned
); ? Colors.grey.withOpacity(0.1)
}, : Colors.transparent,
), selectedTileColor: Theme.of(context)
) .colorScheme
.toList(), .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: sortedApps[index].downloadProgress != null
? Text(tr('percentProgress', args: [
sortedApps[index]
.downloadProgress
?.toInt()
.toString() ??
'100'
]))
: (Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SingleChildScrollView(
child: SizedBox(
width: 80,
child: Text(
'${sortedApps[index].app.installedVersion ?? tr('notInstalled')}${sortedApps[index].app.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: Text(
'${tr('updateAvailable')}${sortedApps[index].app.trackOnly ? ' ${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<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('removeSelectedAppsQuestion'),
items: const [],
defaultValues: 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> formInputs = [];
List<String> defaultValues = [];
if (existingUpdateIdsAllOrSelected.isNotEmpty) {
formInputs.add(GeneratedFormItem(
label: tr('updateX', args: [
plural('apps',
existingUpdateIdsAllOrSelected.length)
]),
type: FormItemType.bool,
key: 'updates'));
defaultValues.add('true');
}
if (newInstallIdsAllOrSelected.isNotEmpty) {
formInputs.add(GeneratedFormItem(
label: tr('installX', args: [
plural('apps',
newInstallIdsAllOrSelected.length)
]),
type: FormItemType.bool,
key: 'installs'));
defaultValues
.add(defaultValues.isEmpty ? 'true' : '');
}
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
formInputs.add(GeneratedFormItem(
label: tr('markXTrackOnlyAsUpdated', args: [
plural('apps',
trackOnlyUpdateIdsAllOrSelected.length)
]),
type: FormItemType.bool,
key: 'trackonlies'));
defaultValues
.add(defaultValues.isEmpty ? 'true' : '');
}
showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
var totalApps = existingUpdateIdsAllOrSelected
.length +
newInstallIdsAllOrSelected.length +
trackOnlyUpdateIdsAllOrSelected.length;
return GeneratedFormModal(
title: tr('changeX',
args: [plural('apps', totalApps)]),
items: formInputs.map((e) => [e]).toList(),
defaultValues: defaultValues,
initValid: true,
);
}).then((values) {
if (values != null) {
if (values.isEmpty) {
values = defaultValues;
}
bool shouldInstallUpdates =
findGeneratedFormValueByKey(
formInputs, values, 'updates') ==
'true';
bool shouldInstallNew =
findGeneratedFormValueByKey(
formInputs, values, 'installs') ==
'true';
bool shouldMarkTrackOnlies =
findGeneratedFormValueByKey(formInputs,
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, context)
.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('onlyAppliesToInstalledAndOutdatedApps')),
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.enhancedVersionDetection) {
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 [],
defaultValues: 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<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('filterApps'),
items: [
[
GeneratedFormItem(
label: tr('appName'), required: false),
GeneratedFormItem(
label: tr('author'), required: false)
],
[
GeneratedFormItem(
label: tr('upToDateApps'),
type: FormItemType.bool)
],
[
GeneratedFormItem(
label: tr('nonInstalledApps'),
type: FormItemType.bool)
]
],
defaultValues: filter == null
? AppsFilter().toValuesArray()
: filter!.toValuesArray());
}).then((values) {
if (values != null) {
setState(() {
filter = AppsFilter.fromValuesArray(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;
AppsFilter(
{this.nameFilter = '',
this.authorFilter = '',
this.includeUptodate = true,
this.includeNonInstalled = true});
List<String> toValuesArray() {
return [
nameFilter,
authorFilter,
includeUptodate ? 'true' : '',
includeNonInstalled ? 'true' : ''
];
}
AppsFilter.fromValuesArray(List<String> values) {
nameFilter = values[0];
authorFilter = values[1];
includeUptodate = values[2] == 'true';
includeNonInstalled = values[3] == 'true';
}
bool isIdenticalTo(AppsFilter other) =>
authorFilter.trim() == other.authorFilter.trim() &&
nameFilter.trim() == other.nameFilter.trim() &&
includeUptodate == other.includeUptodate &&
includeNonInstalled == other.includeNonInstalled;
}

View File

@ -1,3 +1,5 @@
import 'package:animations/animations.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/pages/add_app.dart'; import 'package:obtainium/pages/add_app.dart';
@ -12,33 +14,57 @@ class HomePage extends StatefulWidget {
State<HomePage> createState() => _HomePageState(); 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> { class _HomePageState extends State<HomePage> {
List<int> selectedIndexHistory = []; List<int> selectedIndexHistory = [];
List<Widget> pages = [
const AppsPage(), List<NavigationPageItem> pages = [
const AddAppPage(), NavigationPageItem(tr('appsString'), Icons.apps,
const ImportExportPage(), AppsPage(key: GlobalKey<AppsPageState>())),
const SettingsPage() NavigationPageItem(tr('addApp'), Icons.add, const AddAppPage()),
NavigationPageItem(
tr('importExport'), Icons.import_export, const ImportExportPage()),
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage())
]; ];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return WillPopScope( return WillPopScope(
child: Scaffold( child: Scaffold(
appBar: AppBar(title: const Text('Obtainium')), backgroundColor: Theme.of(context).colorScheme.surface,
body: pages.elementAt( body: PageTransitionSwitcher(
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last), 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( bottomNavigationBar: NavigationBar(
destinations: const [ destinations: pages
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'), .map((e) =>
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'), NavigationDestination(icon: Icon(e.icon), label: e.title))
NavigationDestination( .toList(),
icon: Icon(Icons.import_export), label: 'Import/Export'),
NavigationDestination(
icon: Icon(Icons.settings), label: 'Settings'),
],
onDestinationSelected: (int index) { onDestinationSelected: (int index) {
HapticFeedback.lightImpact(); HapticFeedback.selectionClick();
setState(() { setState(() {
if (index == 0) { if (index == 0) {
selectedIndexHistory.clear(); selectedIndexHistory.clear();
@ -64,7 +90,9 @@ class _HomePageState extends State<HomePage> {
}); });
return false; return false;
} }
return true; return !(pages[0].widget.key as GlobalKey<AppsPageState>)
.currentState
?.clearSelected();
}); });
} }
} }

View File

@ -1,14 +1,18 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ImportExportPage extends StatefulWidget { class ImportExportPage extends StatefulWidget {
const ImportExportPage({super.key}); const ImportExportPage({super.key});
@ -23,212 +27,412 @@ class _ImportExportPageState extends State<ImportExportPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
var settingsProvider = context.read<SettingsProvider>();
var appsProvider = context.read<AppsProvider>(); var appsProvider = context.read<AppsProvider>();
var outlineButtonStyle = ButtonStyle(
shape: MaterialStateProperty.all(
StadiumBorder(
side: BorderSide(
width: 1,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
Future<List<List<String>>> addApps(List<String> urls) async { return Scaffold(
await settingsProvider.getInstallPermission(); backgroundColor: Theme.of(context).colorScheme.surface,
List<dynamic> results = await sourceProvider.getApps(urls); body: CustomScrollView(slivers: <Widget>[
List<App> apps = results[0]; CustomAppBar(title: tr('importExport')),
Map<String, dynamic> errorsMap = results[1]; SliverFillRemaining(
for (var app in apps) { child: Padding(
if (appsProvider.apps.containsKey(app.id)) { padding:
errorsMap.addAll({app.id: 'App already added'}); const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
} else { child: Column(
await appsProvider.saveApp(app); crossAxisAlignment: CrossAxisAlignment.stretch,
} children: [
} Row(
List<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ElevatedButton(
onPressed: appsProvider.apps.isEmpty || importInProgress
? null
: () {
HapticFeedback.lightImpact();
appsProvider.exportApps().then((String path) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Exported to $path')),
);
});
},
child: const Text('Obtainium Export')),
const SizedBox(
height: 8,
),
ElevatedButton(
onPressed: importInProgress
? null
: () {
HapticFeedback.lightImpact();
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 'Invalid input';
}
appsProvider.importApps(data).then((value) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'$value App${value == 1 ? '' : 's'} Imported')),
);
});
} else {
// User canceled the picker
}
}).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: const Text('Obtainium Import')),
if (importInProgress)
Column(
children: const [
SizedBox(
height: 14,
),
LinearProgressIndicator(),
SizedBox(
height: 14,
),
],
)
else
const Divider(
height: 32,
),
TextButton(
onPressed: importInProgress
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Import from URL List',
items: [
GeneratedFormItem('App URL List', true, 7)
],
);
}).then((values) {
if (values != null) {
var urls = (values[0] as String).split('\n');
setState(() {
importInProgress = true;
});
addApps(urls).then((errors) {
if (errors.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Imported ${urls.length} Apps')),
);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return ImportErrorDialog(
urlsLength: urls.length,
errors: errors);
});
}
}).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
});
},
child: const Text('Import from URL List')),
...sourceProvider.massSources
.map((source) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const SizedBox(height: 8), Expanded(
TextButton( child: TextButton(
onPressed: importInProgress style: outlineButtonStyle,
? null onPressed: appsProvider.apps.isEmpty ||
: () { importInProgress
showDialog( ? null
context: context, : () {
builder: (BuildContext ctx) { HapticFeedback.selectionClick();
return GeneratedFormModal( appsProvider
title: 'Import ${source.name}', .exportApps()
items: source.requiredArgs .then((String path) {
.map((e) => showError(
GeneratedFormItem( tr('exportedTo', args: [path]),
e, true, 1)) context);
.toList()); });
}).then((values) { },
if (values != null) { child: Text(tr('obtainiumExport')))),
source.getUrls(values).then((urls) { const SizedBox(
width: 16,
),
Expanded(
child: TextButton(
style: outlineButtonStyle,
onPressed: importInProgress
? null
: () {
HapticFeedback.selectionClick();
FilePicker.platform
.pickFiles()
.then((result) {
setState(() { setState(() {
importInProgress = true; importInProgress = true;
}); });
addApps(urls).then((errors) { if (result != null) {
if (errors.isEmpty) { String data = File(
ScaffoldMessenger.of(context) result.files.single.path!)
.showSnackBar( .readAsStringSync();
SnackBar( try {
content: Text( jsonDecode(data);
'Imported ${urls.length} Apps')), } catch (e) {
); throw ObtainiumError(
} else { tr('invalidInput'));
showDialog(
context: context,
builder:
(BuildContext ctx) {
return ImportErrorDialog(
urlsLength:
urls.length,
errors: errors);
});
} }
}).whenComplete(() { appsProvider
setState(() { .importApps(data)
importInProgress = false; .then((value) {
showError(
tr('importedX', args: [
plural('apps', value)
]),
context);
}); });
}); } else {
// User canceled the picker
}
}).catchError((e) { }).catchError((e) {
ScaffoldMessenger.of(context) showError(e, context);
.showSnackBar( }).whenComplete(() {
SnackBar( setState(() {
content: Text(e.toString())), 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(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('importFromURLList'),
items: [
[
GeneratedFormItem(
label: tr('appURLList'),
max: 7,
additionalValidators: [
(String? value) {
if (value != null &&
value.isNotEmpty) {
var lines = value
.trim()
.split('\n');
for (int i = 0;
i < lines.length;
i++) {
try {
sourceProvider
.getSource(
lines[i]);
} catch (e) {
return '${tr('line')} ${i + 1}: $e';
}
}
}
return null;
}
])
]
],
defaultValues: const [],
);
}).then((values) {
if (values != null) {
var urls =
(values[0] as String).split('\n');
setState(() {
importInProgress = true;
}); });
}, appsProvider
child: Text('Import ${source.name}')) .addAppsByURL(urls)
])) .then((errors) {
.toList() 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<
List<String>>(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title: tr('searchX',
args: [
source
.runtimeType
.toString()
]),
items: [
[
GeneratedFormItem(
label: tr(
'searchQuery'))
]
],
defaultValues: const [],
);
});
if (values != null &&
values[0].isNotEmpty) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source
.search(values[0]);
if (urlsWithDescriptions
.isNotEmpty) {
var selectedUrls =
await showDialog<
List<
String>?>(
context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urlsWithDescriptions:
urlsWithDescriptions,
selectedByDefault:
false,
);
});
if (selectedUrls !=
null &&
selectedUrls
.isNotEmpty) {
var errors =
await appsProvider
.addAppsByURL(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
tr('importedX',
args: [
plural(
'app',
selectedUrls
.length)
]),
context);
} else {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
} else {
throw ObtainiumError(
tr('noResults'));
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: Text(tr('searchX', args: [
source.runtimeType.toString()
])))
]))
.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) => [
GeneratedFormItem(label: e)
])
.toList(),
defaultValues: const [],
);
});
if (values != null) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source
.getUrlsWithDescriptions(
values);
var selectedUrls =
await showDialog<
List<String>?>(
context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urlsWithDescriptions:
urlsWithDescriptions);
});
if (selectedUrls != null) {
var errors =
await appsProvider
.addAppsByURL(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
tr('importedX',
args: [
plural(
'app',
selectedUrls
.length)
]),
context);
} else {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
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,
)
],
)))
]));
} }
} }
@ -248,16 +452,19 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
scrollable: true, scrollable: true,
title: const Text('Import Errors'), title: Text(tr('importErrors')),
content: content:
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Text( Text(
'${widget.urlsLength - widget.errors.length} of ${widget.urlsLength} Apps imported.', tr('importedXOfYApps', args: [
(widget.urlsLength - widget.errors.length).toString(),
widget.urlsLength.toString()
]),
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'The following URLs had errors:', tr('followingURLsHadErrors'),
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
...widget.errors.map((e) { ...widget.errors.map((e) {
@ -278,10 +485,134 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: const Text('Okay')) 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,7 +1,13 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.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/custom_errors.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class SettingsPage extends StatefulWidget { class SettingsPage extends StatefulWidget {
@ -15,188 +21,333 @@ class _SettingsPageState extends State<SettingsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SettingsProvider settingsProvider = context.watch<SettingsProvider>(); SettingsProvider settingsProvider = context.watch<SettingsProvider>();
SourceProvider sourceProvider = SourceProvider();
if (settingsProvider.prefs == null) { if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings(); settingsProvider.initializeSettings();
} }
return Padding(
padding: const EdgeInsets.all(16), var themeDropdown = DropdownButtonFormField(
child: settingsProvider.prefs == null decoration: InputDecoration(labelText: tr('theme')),
? Container() value: settingsProvider.theme,
: Column( items: [
children: [ DropdownMenuItem(
DropdownButtonFormField( value: ThemeSettings.dark,
decoration: const InputDecoration(labelText: 'Theme'), child: Text(tr('dark')),
value: settingsProvider.theme, ),
items: const [ DropdownMenuItem(
DropdownMenuItem( value: ThemeSettings.light,
value: ThemeSettings.dark, child: Text(tr('light')),
child: Text('Dark'), ),
), DropdownMenuItem(
DropdownMenuItem( value: ThemeSettings.system,
value: ThemeSettings.light, child: Text(tr('followSystem')),
child: Text('Light'), )
), ],
DropdownMenuItem( onChanged: (value) {
value: ThemeSettings.system, if (value != null) {
child: Text('Follow System'), settingsProvider.theme = value;
) }
], });
onChanged: (value) {
if (value != null) { var colourDropdown = DropdownButtonFormField(
settingsProvider.theme = value; decoration: InputDecoration(labelText: tr('colour')),
} value: settingsProvider.colour,
}), items: [
const SizedBox( DropdownMenuItem(
height: 16, value: ColourSettings.basic,
), child: Text(tr('obtainium')),
DropdownButtonFormField( ),
decoration: const InputDecoration(labelText: 'Colour'), DropdownMenuItem(
value: settingsProvider.colour, value: ColourSettings.materialYou,
items: const [ child: Text(tr('materialYou')),
DropdownMenuItem( )
value: ColourSettings.basic, ],
child: Text('Obtainium'), onChanged: (value) {
), if (value != null) {
DropdownMenuItem( settingsProvider.colour = value;
value: ColourSettings.materialYou, }
child: Text('Material You'), });
)
], var sortDropdown = DropdownButtonFormField(
onChanged: (value) { decoration: InputDecoration(labelText: tr('appSortBy')),
if (value != null) { value: settingsProvider.sortColumn,
settingsProvider.colour = value; items: [
} DropdownMenuItem(
}), value: SortColumnSettings.authorName,
const SizedBox( child: Text(tr('authorName')),
height: 16, ),
), DropdownMenuItem(
DropdownButtonFormField( value: SortColumnSettings.nameAuthor,
decoration: const InputDecoration( child: Text(tr('nameAuthor')),
labelText: 'Background Update Checking Interval'), ),
value: settingsProvider.updateInterval, DropdownMenuItem(
items: const [ value: SortColumnSettings.added,
DropdownMenuItem( child: Text(tr('asAdded')),
value: 15, )
child: Text('15 Minutes'), ],
), onChanged: (value) {
DropdownMenuItem( if (value != null) {
value: 30, settingsProvider.sortColumn = value;
child: Text('30 Minutes'), }
), });
DropdownMenuItem(
value: 60, var orderDropdown = DropdownButtonFormField(
child: Text('1 Hour'), decoration: InputDecoration(labelText: tr('appSortOrder')),
), value: settingsProvider.sortOrder,
DropdownMenuItem( items: [
value: 360, DropdownMenuItem(
child: Text('6 Hours'), value: SortOrderSettings.ascending,
), child: Text(tr('ascending')),
DropdownMenuItem( ),
value: 720, DropdownMenuItem(
child: Text('12 Hours'), value: SortOrderSettings.descending,
), child: Text(tr('descending')),
DropdownMenuItem( ),
value: 1440, ],
child: Text('1 Day'), onChanged: (value) {
), if (value != null) {
DropdownMenuItem( settingsProvider.sortOrder = value;
value: 0, }
child: Text('Never - Manual Only'), });
),
], var intervalDropdown = DropdownButtonFormField(
onChanged: (value) { decoration: InputDecoration(labelText: tr('bgUpdateCheckInterval')),
if (value != null) { value: settingsProvider.updateInterval,
settingsProvider.updateInterval = value; items: updateIntervals.map((e) {
} int displayNum = (e < 60
}), ? e
const SizedBox( : e < 1440
height: 16, ? e / 60
), : e / 1440)
DropdownButtonFormField( .round();
decoration: String display = e == 0
const InputDecoration(labelText: 'App Sort By'), ? tr('neverManualOnly')
value: settingsProvider.sortColumn, : (e < 60
items: const [ ? plural('minute', displayNum)
DropdownMenuItem( : e < 1440
value: SortColumnSettings.authorName, ? plural('hour', displayNum)
child: Text('Author/Name'), : plural('day', displayNum));
), return DropdownMenuItem(value: e, child: Text(display));
DropdownMenuItem( }).toList(),
value: SortColumnSettings.nameAuthor, onChanged: (value) {
child: Text('Name/Author'), if (value != null) {
), settingsProvider.updateInterval = value;
DropdownMenuItem( }
value: SortColumnSettings.added, });
child: Text('As Added'),
) var sourceSpecificFields = sourceProvider.sources.map((e) {
], if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
onChanged: (value) { return GeneratedForm(
if (value != null) { items: e.additionalSourceSpecificSettingFormItems
settingsProvider.sortColumn = value; .map((e) => [e])
} .toList(),
}), onValueChanges: (values, valid, isBuilding) {
const SizedBox( if (valid) {
height: 16, for (var i = 0; i < values.length; i++) {
), settingsProvider.setSettingString(
DropdownButtonFormField( e.additionalSourceSpecificSettingFormItems[i].id,
decoration: values[i]);
const InputDecoration(labelText: 'App Sort Order'), }
value: settingsProvider.sortOrder, }
items: const [ },
DropdownMenuItem( defaultValues: e.additionalSourceSpecificSettingFormItems.map((e) {
value: SortOrderSettings.ascending, return settingsProvider.getSettingString(e.id) ?? '';
child: Text('Ascending'), }).toList());
), } else {
DropdownMenuItem( return Container();
value: SortOrderSettings.descending, }
child: Text('Descending'), });
),
], const height16 = SizedBox(
onChanged: (value) { height: 16,
if (value != null) { );
settingsProvider.sortOrder = value;
} return Scaffold(
}), backgroundColor: Theme.of(context).colorScheme.surface,
const SizedBox( body: CustomScrollView(slivers: <Widget>[
height: 16, CustomAppBar(title: tr('settings')),
), SliverToBoxAdapter(
Row( child: Padding(
mainAxisAlignment: MainAxisAlignment.spaceBetween, padding: const EdgeInsets.all(16),
children: [ child: settingsProvider.prefs == null
const Text('Show Source Webpage in App View'), ? const SizedBox()
Switch( : Column(
value: settingsProvider.showAppWebpage, crossAxisAlignment: CrossAxisAlignment.start,
onChanged: (value) { children: [
settingsProvider.showAppWebpage = value; Text(
}) tr('appearance'),
], style: TextStyle(
), color: Theme.of(context).colorScheme.primary),
const Spacer(), ),
Row( themeDropdown,
mainAxisAlignment: MainAxisAlignment.center, height16,
children: [ colourDropdown,
TextButton.icon( height16,
style: ButtonStyle( Row(
foregroundColor: mainAxisAlignment: MainAxisAlignment.start,
MaterialStateProperty.resolveWith<Color>( crossAxisAlignment: CrossAxisAlignment.start,
(Set<MaterialState> states) { children: [
return Colors.grey; 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,
],
))),
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: () { onPressed: () {
HapticFeedback.lightImpact(); context.read<LogsProvider>().get().then((logs) {
launchUrlString(settingsProvider.sourceUrl, if (logs.isEmpty) {
mode: LaunchMode.externalApplication); showError(ObtainiumError(tr('noLogs')), context);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return const LogsDialog();
});
}
});
}, },
icon: const Icon(Icons.code), icon: const Icon(Icons.bug_report_outlined),
label: Text( label: Text(tr('appLogs'))),
'Source', ],
style: Theme.of(context).textTheme.bodySmall, ),
), 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')))
],
);
} }
} }

View File

@ -5,27 +5,36 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:install_plugin_v2/install_plugin_v2.dart';
import 'package:installed_apps/app_info.dart';
import 'package:installed_apps/installed_apps.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/notifications_provider.dart'; import 'package:obtainium/providers/notifications_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:package_archive_info/package_archive_info.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:flutter_fgbg/flutter_fgbg.dart'; import 'package:flutter_fgbg/flutter_fgbg.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:install_plugin_v2/install_plugin_v2.dart';
class AppInMemory { class AppInMemory {
late App app; late App app;
double? downloadProgress; double? downloadProgress;
AppInfo? installedInfo;
AppInMemory(this.app, this.downloadProgress); AppInMemory(this.app, this.downloadProgress, this.installedInfo);
} }
class ApkFile { class DownloadedApk {
String appId; String appId;
File file; File file;
ApkFile(this.appId, this.file); DownloadedApk(this.appId, this.file);
} }
class AppsProvider with ChangeNotifier { class AppsProvider with ChangeNotifier {
@ -33,136 +42,341 @@ class AppsProvider with ChangeNotifier {
Map<String, AppInMemory> apps = {}; Map<String, AppInMemory> apps = {};
bool loadingApps = false; bool loadingApps = false;
bool gettingUpdates = false; bool gettingUpdates = false;
bool forBGTask = false;
LogsProvider logs = LogsProvider();
// Variables to keep track of the app foreground status (installs can't run in the background) // Variables to keep track of the app foreground status (installs can't run in the background)
bool isForeground = true; bool isForeground = true;
late Stream<FGBGType> foregroundStream; late Stream<FGBGType>? foregroundStream;
late StreamSubscription<FGBGType> foregroundSubscription; late StreamSubscription<FGBGType>? foregroundSubscription;
AppsProvider( AppsProvider({this.forBGTask = false}) {
{bool shouldLoadApps = false, // Many setup tasks should only be done in the foreground isolate
bool shouldCheckUpdatesAfterLoad = false, if (!forBGTask) {
bool shouldDeleteAPKs = false}) { // Subscribe to changes in the app foreground status
// Subscribe to changes in the app foreground status foregroundStream = FGBGEvents.stream.asBroadcastStream();
foregroundStream = FGBGEvents.stream.asBroadcastStream(); foregroundSubscription = foregroundStream?.listen((event) async {
foregroundSubscription = foregroundStream.listen((event) async { isForeground = event == FGBGType.foreground;
isForeground = event == FGBGType.foreground; if (isForeground) await loadApps();
if (isForeground) await loadApps();
});
if (shouldDeleteAPKs) {
deleteSavedAPKs();
}
if (shouldLoadApps) {
loadApps().then((_) {
if (shouldCheckUpdatesAfterLoad) {
checkUpdates();
}
}); });
() async {
// Load Apps into memory (in background, this is done later instead of in the constructor)
await loadApps();
// Delete existing APKs
(await getExternalStorageDirectory())
?.listSync()
.where((element) =>
element.path.endsWith('.apk') ||
element.path.endsWith('.apk.part'))
.forEach((apk) {
apk.delete();
});
}();
} }
} }
Future<ApkFile> downloadApp(String apkUrl, String appId) async { downloadFile(String url, String fileName, Function? onProgress,
{bool useExisting = true}) async {
var destDir = (await getExternalStorageDirectory())!.path;
StreamedResponse response = StreamedResponse response =
await Client().send(Request('GET', Uri.parse(apkUrl))); await Client().send(Request('GET', Uri.parse(url)));
File downloadFile = File downloadedFile = File('$destDir/$fileName');
File('${(await getExternalStorageDirectory())!.path}/$appId.apk'); if (!(downloadedFile.existsSync() && useExisting)) {
if (downloadFile.existsSync()) { File tempDownloadedFile = File('${downloadedFile.path}.part');
downloadFile.deleteSync(); if (tempDownloadedFile.existsSync()) {
tempDownloadedFile.deleteSync();
}
var length = response.contentLength;
var received = 0;
double? progress;
var sink = tempDownloadedFile.openWrite();
await response.stream.map((s) {
received += s.length;
progress = (length != null ? received / length * 100 : 30);
if (onProgress != null) {
onProgress(progress);
}
return s;
}).pipe(sink);
await sink.close();
progress = null;
if (onProgress != null) {
onProgress(progress);
}
if (response.statusCode != 200) {
tempDownloadedFile.deleteSync();
throw response.reasonPhrase ?? tr('unexpectedError');
}
tempDownloadedFile.renameSync(downloadedFile.path);
} }
var length = response.contentLength; return downloadedFile;
var received = 0; }
var sink = downloadFile.openWrite();
await response.stream.map((s) { Future<DownloadedApk> downloadApp(App app, BuildContext? context) async {
received += s.length; var fileName =
apps[appId]!.downloadProgress = '${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
(length != null ? received / length * 100 : 30); String downloadUrl = await SourceProvider()
notifyListeners(); .getSource(app.url)
return s; .apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
}).pipe(sink); NotificationsProvider? notificationsProvider =
context?.read<NotificationsProvider>();
await sink.close(); var notif = DownloadNotification(app.name, 100);
apps[appId]!.downloadProgress = null; notificationsProvider?.cancel(notif.id);
notifyListeners(); int? prevProg;
File downloadedFile =
if (response.statusCode != 200) { await downloadFile(downloadUrl, fileName, (double? progress) {
downloadFile.deleteSync(); int? prog = progress?.ceil();
throw response.reasonPhrase ?? 'Unknown Error'; if (apps[app.id] != null) {
apps[app.id]!.downloadProgress = progress;
notifyListeners();
}
notif = DownloadNotification(app.name, prog ?? 100);
if (prog != null && prevProg != prog) {
notificationsProvider?.notify(notif);
}
prevProg = prog;
});
notificationsProvider?.cancel(notif.id);
// Delete older versions of the APK if any
for (var file in downloadedFile.parent.listSync()) {
var fn = file.path.split('/').last;
if (fn.startsWith('${app.id}-') &&
fn.endsWith('.apk') &&
fn != fileName) {
file.delete();
}
} }
return ApkFile(appId, downloadFile); // If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
// The former case should be handled (give the App its real ID), the latter is a security issue
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
if (app.id != newInfo.packageName) {
if (apps[app.id] != null && !SourceProvider().isTempId(app.id)) {
throw IDChangedError();
}
var originalAppId = app.id;
app.id = newInfo.packageName;
downloadedFile = downloadedFile.renameSync(
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
if (apps[originalAppId] != null) {
await removeApps([originalAppId]);
await saveApps([app]);
}
}
return DownloadedApk(app.id, downloadedFile);
} }
bool areDownloadsRunning() => apps.values bool areDownloadsRunning() => apps.values
.where((element) => element.downloadProgress != null) .where((element) => element.downloadProgress != null)
.isNotEmpty; .isNotEmpty;
// Given an AppId, uses stored info about the app to download an APK (with user input if needed) and install it Future<bool> canInstallSilently(App app) async {
// Installs can only be done in the foreground, so a notification is sent to get the user's attention if needed return false;
// Returns upon successful download, regardless of installation result // TODO: Uncomment the below once silentupdates are ever figured out
Future<bool> downloadAndInstallLatestApp( // // TODO: This is unreliable - try to get from OS in the future
List<String> appIds, BuildContext context) async { // if (app.apkUrls.length > 1) {
// return false;
// }
// var osInfo = await DeviceInfoPlugin().androidInfo;
// return app.installedVersion != null &&
// osInfo.version.sdkInt >= 30 &&
// osInfo.version.release.compareTo('12') >= 0;
}
Future<void> waitForUserToReturnToForeground(BuildContext context) async {
NotificationsProvider notificationsProvider = NotificationsProvider notificationsProvider =
context.read<NotificationsProvider>(); context.read<NotificationsProvider>();
Map<String, String> appsToInstall = {}; if (!isForeground) {
await notificationsProvider.notify(completeInstallationNotification,
cancelExisting: true);
while (await FGBGEvents.stream.first != FGBGType.foreground) {}
await notificationsProvider.cancel(completeInstallationNotification.id);
}
}
Future<bool> canDowngradeApps() async {
try {
await InstalledApps.getAppInfo('com.berdik.letmedowngrade');
return true;
} catch (e) {
return false;
}
}
// Unfortunately this 'await' does not actually wait for the APK to finish installing
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
// If appropriate criteria are met, the update (never a fresh install) happens silently in the background
// But even then, we don't know if it actually succeeded
Future<void> installApk(DownloadedApk file) async {
var newInfo = await PackageArchiveInfo.fromPath(file.file.path);
AppInfo? appInfo;
try {
appInfo = await InstalledApps.getAppInfo(apps[file.appId]!.app.id);
} catch (e) {
// OK
}
if (appInfo != null &&
int.parse(newInfo.buildNumber) < appInfo.versionCode! &&
!(await canDowngradeApps())) {
throw DowngradeError();
}
if (appInfo == null ||
int.parse(newInfo.buildNumber) > appInfo.versionCode!) {
await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium');
}
apps[file.appId]!.app.installedVersion =
apps[file.appId]!.app.latestVersion;
// Don't correct install status as installation may not be done yet
await saveApps([apps[file.appId]!.app],
attemptToCorrectInstallStatus: false);
}
Future<String?> confirmApkUrl(App app, BuildContext? context) async {
// If the App has more than one APK, the user should pick one (if context provided)
String? apkUrl = app.apkUrls[app.preferredApkIndex];
// get device supported architecture
List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
if (app.apkUrls.length > 1 && context != null) {
apkUrl = await showDialog(
context: context,
builder: (BuildContext ctx) {
return APKPicker(
app: app,
initVal: apkUrl,
archs: archs,
);
});
}
// If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
if (apkUrl != null &&
Uri.parse(apkUrl).origin != Uri.parse(app.url).origin &&
context != null) {
if (await showDialog(
context: context,
builder: (BuildContext ctx) {
return APKOriginWarningDialog(
sourceUrl: app.url, apkUrl: apkUrl!);
}) !=
true) {
apkUrl = null;
}
}
return apkUrl;
}
// Given a list of AppIds, uses stored info about the apps to download APKs and install them
// If the APKs can be installed silently, they are
// If no BuildContext is provided, apps that require user interaction are ignored
// If user input is needed and the App is in the background, a notification is sent to get the user's attention
// Returns an array of Ids for Apps that were successfully downloaded, regardless of installation result
Future<List<String>> downloadAndInstallLatestApps(
List<String> appIds, BuildContext? context) async {
List<String> appsToInstall = [];
List<String> trackOnlyAppsToUpdate = [];
// For all specified Apps, filter out those for which:
// 1. A URL cannot be picked
// 2. That cannot be installed silently (IF no buildContext was given for interactive install)
for (var id in appIds) { for (var id in appIds) {
if (apps[id] == null) { if (apps[id] == null) {
throw 'App not found'; throw ObtainiumError(tr('appNotFound'));
} }
// If the App has more than one APK, the user should pick one String? apkUrl;
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex]; if (!apps[id]!.app.trackOnly) {
if (apps[id]!.app.apkUrls.length > 1) { apkUrl = await confirmApkUrl(apps[id]!.app, context);
apkUrl = await showDialog(
context: context,
builder: (BuildContext ctx) {
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
});
}
// If the picked APK comes from an origin different from the source, get user confirmation
if (apkUrl != null &&
Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin) {
if (await showDialog(
context: context,
builder: (BuildContext ctx) {
return APKOriginWarningDialog(
sourceUrl: apps[id]!.app.url, apkUrl: apkUrl!);
}) !=
true) {
apkUrl = null;
}
} }
if (apkUrl != null) { if (apkUrl != null) {
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
if (urlInd != apps[id]!.app.preferredApkIndex) { if (urlInd != apps[id]!.app.preferredApkIndex) {
apps[id]!.app.preferredApkIndex = urlInd; apps[id]!.app.preferredApkIndex = urlInd;
await saveApp(apps[id]!.app); await saveApps([apps[id]!.app]);
} }
appsToInstall.putIfAbsent(id, () => apkUrl!); if (context != null || await canInstallSilently(apps[id]!.app)) {
appsToInstall.add(id);
}
}
if (apps[id]!.app.trackOnly) {
trackOnlyAppsToUpdate.add(id);
}
}
// Mark all specified track-only apps as latest
saveApps(trackOnlyAppsToUpdate.map((e) {
var a = apps[e]!.app;
a.installedVersion = a.latestVersion;
return a;
}).toList());
// Download APKs for all Apps to be installed
MultiAppMultiError errors = MultiAppMultiError();
List<DownloadedApk?> downloadedFiles =
await Future.wait(appsToInstall.map((id) async {
try {
return await downloadApp(apps[id]!.app, context);
} catch (e) {
errors.add(id, e.toString());
}
return null;
}));
downloadedFiles =
downloadedFiles.where((element) => element != null).toList();
// Separate the Apps to install into silent and regular lists
List<DownloadedApk> silentUpdates = [];
List<DownloadedApk> regularInstalls = [];
for (var f in downloadedFiles) {
bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app);
if (willBeSilent) {
silentUpdates.add(f);
} else {
regularInstalls.add(f);
} }
} }
List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries // Move everything to the regular install list (since silent updates don't currently work) - TODO
.map((entry) => downloadApp(entry.value, entry.key))); regularInstalls.addAll(silentUpdates);
if (!isForeground) { // If Obtainium is being installed, it should be the last one
await notificationsProvider.notify(completeInstallationNotification, List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) {
cancelExisting: true); DownloadedApk? temp;
await FGBGEvents.stream.first == FGBGType.foreground; items.removeWhere((element) {
await notificationsProvider.cancel(completeInstallationNotification.id); bool res =
// We need to wait for the App to come to the foreground to install it element.appId == obtainiumId || element.appId == obtainiumTempId;
// Can't try to call install plugin in a background isolate (may not have worked anyways) because of: if (res) {
// https://github.com/flutter/flutter/issues/13937 temp = element;
}
return res;
});
if (temp != null) {
items = [temp!, ...items];
}
return items;
} }
// Unfortunately this 'await' does not actually wait for the APK to finish installing silentUpdates = moveObtainiumToStart(silentUpdates);
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing regularInstalls = moveObtainiumToStart(regularInstalls);
// This also does not use the 'session-based' installer API, so background/silent updates are impossible
for (var f in downloadedFiles) { // // Install silent updates (uncomment when it works - TODO)
await InstallPlugin.installApk(f.file.path, 'dev.imranr.obtainium'); // for (var u in silentUpdates) {
apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion; // await installApk(u, silent: true); // Would need to add silent option
await saveApp(apps[f.appId]!.app); // }
// Do regular installs
if (regularInstalls.isNotEmpty && context != null) {
// ignore: use_build_context_synchronously
await waitForUserToReturnToForeground(context);
for (var i in regularInstalls) {
try {
await installApk(i);
} catch (e) {
errors.add(i.appId, e.toString());
}
}
} }
return downloadedFiles.isNotEmpty; if (errors.content.isNotEmpty) {
throw errors;
}
NotificationsProvider().cancel(UpdateNotification([]).id);
return downloadedFiles.map((e) => e!.appId).toList();
} }
Future<Directory> getAppsDir() async { Future<Directory> getAppsDir() async {
@ -174,96 +388,211 @@ class AppsProvider with ChangeNotifier {
return appsDir; return appsDir;
} }
Future<void> deleteSavedAPKs() async { Future<AppInfo?> getInstalledInfo(String? packageName) async {
(await getExternalStorageDirectory()) if (packageName != null) {
?.listSync() try {
.where((element) => element.path.endsWith('.apk')) return await InstalledApps.getAppInfo(packageName);
.forEach((element) { } catch (e) {
element.deleteSync(); // OK
});
}
Future<void> loadApps() async {
loadingApps = true;
notifyListeners();
List<FileSystemEntity> appFiles = (await getAppsDir())
.listSync()
.where((item) => item.path.toLowerCase().endsWith('.json'))
.toList();
apps.clear();
for (int i = 0; i < appFiles.length; i++) {
App app =
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync()));
apps.putIfAbsent(app.id, () => AppInMemory(app, null));
}
loadingApps = false;
notifyListeners();
}
Future<void> saveApp(App app) async {
File('${(await getAppsDir()).path}/${app.id}.json')
.writeAsStringSync(jsonEncode(app.toJson()));
apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress),
ifAbsent: () => AppInMemory(app, null));
notifyListeners();
}
Future<void> removeApp(String appId) async {
File file = File('${(await getAppsDir()).path}/$appId.json');
if (file.existsSync()) {
file.deleteSync();
}
if (apps.containsKey(appId)) {
apps.remove(appId);
}
notifyListeners();
}
bool checkAppObjectForUpdate(App app) {
if (!apps.containsKey(app.id)) {
throw 'App not found';
}
return app.latestVersion != apps[app.id]?.app.installedVersion;
}
Future<App?> getUpdate(String appId) async {
App? currentApp = apps[appId]!.app;
App newApp = await SourceProvider().getApp(currentApp.url);
if (newApp.latestVersion != currentApp.latestVersion) {
newApp.installedVersion = currentApp.installedVersion;
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex;
} }
await saveApp(newApp);
return newApp;
} }
return null; return null;
} }
Future<List<App>> checkUpdates() async { // If the App says it is installed but installedInfo is null, set it to not installed
List<App> updates = []; // If the App says is is not installed but installedInfo exists, set it to the real installed version
if (!gettingUpdates) { // If the internal version does not match the real one, sync them if the App supports enhanced version detection
gettingUpdates = true; // Enhanced version detection will be true if the version extracted from source matches the standard version format
// Don't save changes, just return the object if changes were made (else null)
List<String> appIds = apps.keys.toList(); // If in a background isolate, return null straight away as the required plugin won't work anyways
for (int i = 0; i < appIds.length; i++) { App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
App? newApp = await getUpdate(appIds[i]); if (forBGTask) {
if (newApp != null) { return null; // Can't correct in the background isolate
updates.add(newApp); }
var modded = false;
if (installedInfo == null &&
app.installedVersion != null &&
!app.trackOnly) {
app.installedVersion = null;
modded = true;
} else if (installedInfo != null && app.installedVersion == null) {
if (app.enhancedVersionDetection) {
app.installedVersion = installedInfo.versionName;
} else {
if (app.latestVersion.contains(installedInfo.versionName!)) {
app.installedVersion = app.latestVersion;
} else {
app.installedVersion = installedInfo.versionName;
} }
} }
gettingUpdates = false; modded = true;
} else if (installedInfo?.versionName != app.installedVersion &&
app.enhancedVersionDetection &&
!app.trackOnly) {
app.installedVersion = installedInfo?.versionName;
modded = true;
}
return modded ? app : null;
}
Future<void> loadApps() async {
while (loadingApps) {
await Future.delayed(const Duration(microseconds: 1));
}
loadingApps = true;
notifyListeners();
List<App> newApps = (await getAppsDir())
.listSync()
.where((item) => item.path.toLowerCase().endsWith('.json'))
.map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync())))
.toList();
var idsToDelete = apps.values
.map((e) => e.app.id)
.toSet()
.difference(newApps.map((e) => e.id).toSet());
for (var id in idsToDelete) {
apps.remove(id);
}
var sp = SourceProvider();
List<List<String>> errors = [];
for (int i = 0; i < newApps.length; i++) {
var info = await getInstalledInfo(newApps[i].id);
try {
sp.getSource(newApps[i].url);
apps[newApps[i].id] = AppInMemory(newApps[i], null, info);
} catch (e) {
errors.add([newApps[i].id, newApps[i].name, e.toString()]);
}
}
if (errors.isNotEmpty) {
removeApps(errors.map((e) => e[0]).toList());
NotificationsProvider().notify(
AppsRemovedNotification(errors.map((e) => [e[1], e[2]]).toList()));
}
loadingApps = false;
notifyListeners();
List<App> modifiedApps = [];
for (var app in apps.values) {
var moddedApp =
getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo);
if (moddedApp != null) {
modifiedApps.add(moddedApp);
}
}
if (modifiedApps.isNotEmpty) {
await saveApps(modifiedApps);
}
}
Future<void> saveApps(List<App> apps,
{bool attemptToCorrectInstallStatus = true}) async {
for (var app in apps) {
AppInfo? info = await getInstalledInfo(app.id);
app.name = info?.name ?? app.name;
if (attemptToCorrectInstallStatus) {
app = getCorrectedInstallStatusAppIfPossible(app, info) ?? app;
}
File('${(await getAppsDir()).path}/${app.id}.json')
.writeAsStringSync(jsonEncode(app.toJson()));
this.apps.update(
app.id, (value) => AppInMemory(app, value.downloadProgress, info),
ifAbsent: () => AppInMemory(app, null, info));
}
notifyListeners();
}
Future<void> removeApps(List<String> appIds) async {
for (var appId in appIds) {
File file = File('${(await getAppsDir()).path}/$appId.json');
if (file.existsSync()) {
file.deleteSync();
}
if (apps.containsKey(appId)) {
apps.remove(appId);
}
}
if (appIds.isNotEmpty) {
notifyListeners();
}
}
Future<App?> checkUpdate(String appId) async {
App? currentApp = apps[appId]!.app;
SourceProvider sourceProvider = SourceProvider();
App newApp = await sourceProvider.getApp(
sourceProvider.getSource(currentApp.url),
currentApp.url,
currentApp.additionalData,
name: currentApp.name,
id: currentApp.id,
pinned: currentApp.pinned,
trackOnly: currentApp.trackOnly,
installedVersion: currentApp.installedVersion);
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex;
}
await saveApps([newApp]);
return newApp.latestVersion != currentApp.latestVersion ? newApp : null;
}
Future<List<App>> checkUpdates(
{DateTime? ignoreAppsCheckedAfter,
bool throwErrorsForRetry = false}) async {
List<App> updates = [];
MultiAppMultiError errors = MultiAppMultiError();
if (!gettingUpdates) {
gettingUpdates = true;
try {
List<String> appIds = apps.values
.where((app) =>
app.app.lastUpdateCheck == null ||
ignoreAppsCheckedAfter == null ||
app.app.lastUpdateCheck!.isBefore(ignoreAppsCheckedAfter))
.map((e) => e.app.id)
.toList();
appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ??
DateTime.fromMicrosecondsSinceEpoch(0))
.compareTo(apps[b]!.app.lastUpdateCheck ??
DateTime.fromMicrosecondsSinceEpoch(0)));
for (int i = 0; i < appIds.length; i++) {
App? newApp;
try {
newApp = await checkUpdate(appIds[i]);
} catch (e) {
if ((e is RateLimitError || e is SocketException) &&
throwErrorsForRetry) {
rethrow;
}
errors.add(appIds[i], e.toString());
}
if (newApp != null) {
updates.add(newApp);
}
}
} finally {
gettingUpdates = false;
}
}
if (errors.content.isNotEmpty) {
throw errors;
} }
return updates; return updates;
} }
List<String> getExistingUpdates() { List<String> findExistingUpdates(
{bool installedOnly = false, bool nonInstalledOnly = false}) {
List<String> updateAppIds = []; List<String> updateAppIds = [];
List<String> appIds = apps.keys.toList(); List<String> appIds = apps.keys.toList();
for (int i = 0; i < appIds.length; i++) { for (int i = 0; i < appIds.length; i++) {
App? app = apps[appIds[i]]!.app; App? app = apps[appIds[i]]!.app;
if (app.installedVersion != app.latestVersion) { if (app.installedVersion != app.latestVersion &&
updateAppIds.add(app.id); (!installedOnly || !nonInstalledOnly)) {
if ((app.installedVersion == null &&
(nonInstalledOnly || !installedOnly) ||
(app.installedVersion != null &&
(installedOnly || !nonInstalledOnly)))) {
updateAppIds.add(app.id);
}
} }
} }
return updateAppIds; return updateAppIds;
@ -271,44 +600,65 @@ class AppsProvider with ChangeNotifier {
Future<String> exportApps() async { Future<String> exportApps() async {
Directory? exportDir = Directory('/storage/emulated/0/Download'); Directory? exportDir = Directory('/storage/emulated/0/Download');
String path = 'Downloads'; String path = 'Downloads'; // TODO: Is this true on non-english phones?
if (!exportDir.existsSync()) { if (!exportDir.existsSync()) {
exportDir = await getExternalStorageDirectory(); exportDir = await getExternalStorageDirectory();
path = exportDir!.path; path = exportDir!.path;
} }
File export = File( File export = File(
'${exportDir.path}/obtainium-export-${DateTime.now().millisecondsSinceEpoch}.json'); '${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json');
export.writeAsStringSync( export.writeAsStringSync(
jsonEncode(apps.values.map((e) => e.app.toJson()).toList())); jsonEncode(apps.values.map((e) => e.app.toJson()).toList()));
return path; return path;
} }
Future<int> importApps(String appsJSON) async { Future<int> importApps(String appsJSON) async {
// File picker does not work in android 13, so the user must paste the JSON directly into Obtainium to import Apps
List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>) List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
.map((e) => App.fromJson(e)) .map((e) => App.fromJson(e))
.toList(); .toList();
for (App a in importedApps) { while (loadingApps) {
a.installedVersion = await Future.delayed(const Duration(microseconds: 1));
apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null;
await saveApp(a);
} }
for (App a in importedApps) {
if (apps[a.id]?.app.installedVersion != null) {
a.installedVersion = apps[a.id]?.app.installedVersion;
}
}
await saveApps(importedApps);
notifyListeners(); notifyListeners();
return importedApps.length; return importedApps.length;
} }
@override @override
void dispose() { void dispose() {
foregroundSubscription.cancel(); foregroundSubscription?.cancel();
super.dispose(); super.dispose();
} }
Future<List<List<String>>> addAppsByURL(List<String> urls) async {
List<dynamic> results = await SourceProvider().getAppsByURLNaive(urls,
ignoreUrls: apps.values.map((e) => e.app.url).toList());
List<App> pps = results[0];
Map<String, dynamic> errorsMap = results[1];
for (var app in pps) {
if (apps.containsKey(app.id)) {
errorsMap.addAll({app.id: tr('appAlreadyAdded')});
} else {
await saveApps([app]);
}
}
List<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
}
} }
class APKPicker extends StatefulWidget { class APKPicker extends StatefulWidget {
const APKPicker({super.key, required this.app, this.initVal}); const APKPicker({super.key, required this.app, this.initVal, this.archs});
final App app; final App app;
final String? initVal; final String? initVal;
final List<String>? archs;
@override @override
State<APKPicker> createState() => _APKPickerState(); State<APKPicker> createState() => _APKPickerState();
@ -322,33 +672,50 @@ class _APKPickerState extends State<APKPicker> {
apkUrl ??= widget.initVal; apkUrl ??= widget.initVal;
return AlertDialog( return AlertDialog(
scrollable: true, scrollable: true,
title: const Text('Pick an APK'), title: Text(tr('pickAnAPK')),
content: Column(children: [ content: Column(children: [
Text('${widget.app.name} has more than one package:'), Text(tr('appHasMoreThanOnePackage', args: [widget.app.name])),
const SizedBox(height: 16), const SizedBox(height: 16),
...widget.app.apkUrls.map((u) => RadioListTile<String>( ...widget.app.apkUrls.map(
title: Text(Uri.parse(u).pathSegments.last), (u) => RadioListTile<String>(
value: u, title: Text(Uri.parse(u)
groupValue: apkUrl, .pathSegments
onChanged: (String? val) { .where((element) => element.isNotEmpty)
setState(() { .last),
apkUrl = val; value: u,
}); groupValue: apkUrl,
})) onChanged: (String? val) {
setState(() {
apkUrl = val;
});
}),
),
if (widget.archs != null)
const SizedBox(
height: 16,
),
if (widget.archs != null)
Text(
widget.archs!.length == 1
? tr('deviceSupportsXArch', args: [widget.archs![0]])
: tr('deviceSupportsFollowingArchs') +
list2FriendlyString(
widget.archs!.map((e) => '\'$e\'').toList()),
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
),
]), ]),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: const Text('Cancel')), child: Text(tr('cancel'))),
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.heavyImpact(); HapticFeedback.selectionClick();
Navigator.of(context).pop(apkUrl); Navigator.of(context).pop(apkUrl);
}, },
child: const Text('Continue')) child: Text(tr('continue')))
], ],
); );
} }
@ -370,22 +737,23 @@ class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
scrollable: true, scrollable: true,
title: const Text('Warning'), title: Text(tr('warning')),
content: Text( content: Text(tr('sourceIsXButPackageFromYPrompt', args: [
'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'), Uri.parse(widget.sourceUrl).host,
Uri.parse(widget.apkUrl).host
])),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: const Text('Cancel')), child: Text(tr('cancel'))),
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.heavyImpact(); HapticFeedback.selectionClick();
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
}, },
child: const Text('Continue')) child: Text(tr('continue')))
], ],
); );
} }

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 // 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 // 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:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
@ -12,24 +13,41 @@ class ObtainiumNotification {
late String channelName; late String channelName;
late String channelDescription; late String channelDescription;
Importance importance; Importance importance;
bool onlyAlertOnce;
ObtainiumNotification(this.id, this.title, this.message, this.channelCode, ObtainiumNotification(this.id, this.title, this.message, this.channelCode,
this.channelName, this.channelDescription, this.importance); this.channelName, this.channelDescription, this.importance,
{this.onlyAlertOnce = false});
} }
class UpdateNotification extends ObtainiumNotification { class UpdateNotification extends ObtainiumNotification {
UpdateNotification(List<App> updates) UpdateNotification(List<App> updates)
: super( : super(
2, 2,
'Updates Available', tr('updatesAvailable'),
'', '',
'UPDATES_AVAILABLE', 'UPDATES_AVAILABLE',
'Updates Available', tr('updatesAvailable'),
'Notifies the user that updates are available for one or more Apps tracked by Obtainium', tr('updatesAvailableNotifDescription'),
Importance.max) { 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]);
}
}
class SilentUpdateNotification extends ObtainiumNotification {
SilentUpdateNotification(List<App> updates)
: super(3, tr('appsUpdated'), '', 'APPS_UPDATED', tr('appsUpdated'),
tr('appsUpdatedNotifDescription'), Importance.defaultImportance) {
message = updates.length == 1 message = updates.length == 1
? '${updates[0].name} has an update.' ? tr('xWasUpdatedToY',
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.'; args: [updates[0].name, updates[0].latestVersion])
: plural('xAndNMoreUpdatesInstalled', updates.length - 1,
args: [updates[0].name]);
} }
} }
@ -37,30 +55,57 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
ErrorCheckingUpdatesNotification(String error) ErrorCheckingUpdatesNotification(String error)
: super( : super(
5, 5,
'Error Checking for Updates', tr('errorCheckingUpdates'),
error, error,
'BG_UPDATE_CHECK_ERROR', 'BG_UPDATE_CHECK_ERROR',
'Error Checking for Updates', tr('errorCheckingUpdates'),
'A notification that shows when background update checking fails', tr('errorCheckingUpdatesNotifDescription'),
Importance.high); 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,
'Downloading $appName',
'$progPercent%',
'APP_DOWNLOADING',
'Downloading App',
'Notifies the user of the progress in downloading an App',
Importance.defaultImportance,
onlyAlertOnce: true) {
message = tr('percentProgress', args: [progPercent.toString()]);
}
}
final completeInstallationNotification = ObtainiumNotification( final completeInstallationNotification = ObtainiumNotification(
1, 1,
'Complete App Installation', tr('completeAppInstallation'),
'Obtainium must be open to install Apps', tr('obtainiumMustBeOpenToInstallApps'),
'COMPLETE_INSTALL', 'COMPLETE_INSTALL',
'Complete App Installation', tr('completeAppInstallation'),
'Asks the user to return to Obtanium to finish installing an App', tr('completeAppInstallationNotifDescription'),
Importance.max); Importance.max);
final checkingUpdatesNotification = ObtainiumNotification( final checkingUpdatesNotification = ObtainiumNotification(
4, 4,
'Checking for Updates', tr('checkingForUpdates'),
'', '',
'BG_UPDATE_CHECK', 'BG_UPDATE_CHECK',
'Checking for Updates', tr('checkingForUpdates'),
'Transient notification that appears when checking for updates', tr('checkingForUpdatesNotifDescription'),
Importance.min); Importance.min);
class NotificationsProvider { class NotificationsProvider {
@ -100,7 +145,9 @@ class NotificationsProvider {
String channelName, String channelName,
String channelDescription, String channelDescription,
Importance importance, Importance importance,
{bool cancelExisting = false}) async { {bool cancelExisting = false,
int? progPercent,
bool onlyAlertOnce = false}) async {
if (cancelExisting) { if (cancelExisting) {
await cancel(id); await cancel(id);
} }
@ -116,12 +163,16 @@ class NotificationsProvider {
channelDescription: channelDescription, channelDescription: channelDescription,
importance: importance, importance: importance,
priority: importanceToPriority[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, Future<void> notify(ObtainiumNotification notif,
{bool cancelExisting = false}) => {bool cancelExisting = false}) =>
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode, notifyRaw(notif.id, notif.title, notif.message, notif.channelCode,
notif.channelName, notif.channelDescription, notif.importance, notif.channelName, notif.channelDescription, notif.importance,
cancelExisting: cancelExisting); cancelExisting: cancelExisting, onlyAlertOnce: notif.onlyAlertOnce);
} }

View File

@ -1,10 +1,15 @@
// Exposes functions used to save/load app settings // Exposes functions used to save/load app settings
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.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 ThemeSettings { system, light, dark }
enum ColourSettings { basic, materialYou } enum ColourSettings { basic, materialYou }
@ -13,6 +18,16 @@ enum SortColumnSettings { added, nameAuthor, authorName }
enum SortOrderSettings { ascending, descending } 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 { class SettingsProvider with ChangeNotifier {
SharedPreferences? prefs; SharedPreferences? prefs;
@ -45,7 +60,17 @@ class SettingsProvider with ChangeNotifier {
} }
int get updateInterval { 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) { set updateInterval(int min) {
@ -54,8 +79,8 @@ class SettingsProvider with ChangeNotifier {
} }
SortColumnSettings get sortColumn { SortColumnSettings get sortColumn {
return SortColumnSettings return SortColumnSettings.values[
.values[prefs?.getInt('sortColumn') ?? SortColumnSettings.added.index]; prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index];
} }
set sortColumn(SortColumnSettings s) { set sortColumn(SortColumnSettings s) {
@ -65,7 +90,7 @@ class SettingsProvider with ChangeNotifier {
SortOrderSettings get sortOrder { SortOrderSettings get sortOrder {
return SortOrderSettings.values[ return SortOrderSettings.values[
prefs?.getInt('sortOrder') ?? SortOrderSettings.descending.index]; prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index];
} }
set sortOrder(SortOrderSettings s) { set sortOrder(SortOrderSettings s) {
@ -85,8 +110,7 @@ class SettingsProvider with ChangeNotifier {
while (!(await Permission.requestInstallPackages.isGranted)) { while (!(await Permission.requestInstallPackages.isGranted)) {
// Explicit request as InstallPlugin request sometimes bugged // Explicit request as InstallPlugin request sometimes bugged
Fluttertoast.showToast( Fluttertoast.showToast(
msg: 'Please allow Obtainium to install Apps', msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG);
toastLength: Toast.LENGTH_LONG);
if ((await Permission.requestInstallPackages.request()) == if ((await Permission.requestInstallPackages.request()) ==
PermissionStatus.granted) { PermissionStatus.granted) {
break; break;
@ -95,11 +119,29 @@ class SettingsProvider with ChangeNotifier {
} }
bool get showAppWebpage { bool get showAppWebpage {
return prefs?.getBool('showAppWebpage') ?? true; return prefs?.getBool('showAppWebpage') ?? false;
} }
set showAppWebpage(bool show) { set showAppWebpage(bool show) {
prefs?.setBool('showAppWebpage', show); prefs?.setBool('showAppWebpage', show);
notifyListeners(); 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();
}
} }

View File

@ -3,9 +3,20 @@
import 'dart:convert'; import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:html/dom.dart'; import 'package:html/dom.dart';
import 'package:http/http.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/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/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/mass_app_sources/githubstars.dart';
class AppNames { class AppNames {
late String author; late String author;
@ -16,9 +27,15 @@ class AppNames {
class APKDetails { class APKDetails {
late String version; late String version;
late String versionFromSource;
late bool isStandardVersion;
late List<String> apkUrls; late List<String> apkUrls;
APKDetails(this.version, this.apkUrls); APKDetails(this.versionFromSource, this.apkUrls) {
var temp = extractStandardVersionName(versionFromSource);
this.isStandardVersion = temp != null;
this.version = temp ?? versionFromSource;
}
} }
class App { class App {
@ -30,28 +47,55 @@ class App {
late String latestVersion; late String latestVersion;
List<String> apkUrls = []; List<String> apkUrls = [];
late int preferredApkIndex; late int preferredApkIndex;
App(this.id, this.url, this.author, this.name, this.installedVersion, late List<String> additionalData;
this.latestVersion, this.apkUrls, this.preferredApkIndex); late DateTime? lastUpdateCheck;
bool pinned = false;
bool trackOnly = false;
bool enhancedVersionDetection = false;
App(
this.id,
this.url,
this.author,
this.name,
this.installedVersion,
this.latestVersion,
this.apkUrls,
this.preferredApkIndex,
this.additionalData,
this.lastUpdateCheck,
this.pinned,
this.trackOnly,
this.enhancedVersionDetection);
@override @override
String toString() { 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 ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned';
} }
factory App.fromJson(Map<String, dynamic> json) => App( factory App.fromJson(Map<String, dynamic> json) => App(
json['id'] as String, json['id'] as String,
json['url'] as String, json['url'] as String,
json['author'] as String, json['author'] as String,
json['name'] as String, json['name'] as String,
json['installedVersion'] == null json['installedVersion'] == null
? null ? null
: json['installedVersion'] as String, : json['installedVersion'] as String,
json['latestVersion'] as String, json['latestVersion'] as String,
List<String>.from(jsonDecode(json['apkUrls'])), json['apkUrls'] == null
json['preferredApkIndex'] == null ? []
? 0 : List<String>.from(jsonDecode(json['apkUrls'])),
: json['preferredApkIndex'] as int, json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
); json['additionalData'] == null
? SourceProvider()
.getSource(json['url'])
.additionalSourceAppSpecificDefaults
: List<String>.from(jsonDecode(json['additionalData'])),
json['lastUpdateCheck'] == null
? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false,
json['trackOnly'] ?? false,
json['enhancedVersionDetection'] ?? false);
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
@ -61,20 +105,32 @@ class App {
'installedVersion': installedVersion, 'installedVersion': installedVersion,
'latestVersion': latestVersion, 'latestVersion': latestVersion,
'apkUrls': jsonEncode(apkUrls), 'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex 'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned,
'trackOnly': trackOnly,
'enhancedVersionDetection': enhancedVersionDetection
}; };
} }
escapeRegEx(String s) { // Ensure the input is starts with HTTPS and has no WWW
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { preStandardizeUrl(String url) {
return "\\${x[0]}"; 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;
} }
const String couldNotFindReleases = 'Unable to fetch release info';
const String couldNotFindLatestVersion =
'Could not determine latest release version';
const String notValidURL = 'Not a valid URL';
const String noAPKFound = 'No APK found'; const String noAPKFound = 'No APK found';
List<String> getLinksFromParsedHTML( List<String> getLinksFromParsedHTML(
@ -88,326 +144,93 @@ List<String> getLinksFromParsedHTML(
.map((e) => '$prependToLinks${e.attributes['href']!}') .map((e) => '$prependToLinks${e.attributes['href']!}')
.toList(); .toList();
abstract class AppSource { class AppSource {
late String host; late String host;
String standardizeURL(String url); bool enforceTrackOnly = false;
Future<APKDetails> getLatestAPKDetails(String standardUrl);
AppNames getAppNames(String standardUrl);
}
class GitHub implements AppSource {
@override
late String host = 'github.com';
@override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); throw NotImplementedError();
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
return url.substring(0, match.end);
} }
@override Future<APKDetails> getLatestAPKDetails(
Future<APKDetails> getLatestAPKDetails(String standardUrl) async { String standardUrl, List<String> additionalData,
Response res = await get(Uri.parse( {bool trackOnly = false}) {
'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases')); throw NotImplementedError();
if (res.statusCode == 200) {
var releases = jsonDecode(res.body) as List<dynamic>;
// Right now, the latest non-prerelease version is picked
// If none exists, the latest prerelease version is picked
// In the future, the user could be given a choice
var nonPrereleaseReleases =
releases.where((element) => element['prerelease'] != true).toList();
var latestRelease = nonPrereleaseReleases.isNotEmpty
? nonPrereleaseReleases[0]
: releases.isNotEmpty
? releases[0]
: null;
if (latestRelease == null) {
throw couldNotFindReleases;
}
List<dynamic>? assets = latestRelease['assets'];
List<String>? apkUrlList = assets
?.map((e) {
return e['browser_download_url'] != null
? e['browser_download_url'] as String
: '';
})
.where((element) => element.toLowerCase().endsWith('.apk'))
.toList();
if (apkUrlList == null || apkUrlList.isEmpty) {
throw noAPKFound;
}
String? version = latestRelease['tag_name'];
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, apkUrlList);
} else {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
}
throw couldNotFindReleases;
}
} }
@override
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3); throw NotImplementedError();
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>> additionalSourceAppSpecificFormItems = [];
List<String> additionalSourceAppSpecificDefaults = [];
// Some additional data may be needed for Apps regardless of Source
final List<GeneratedFormItem> additionalAppSpecificSourceAgnosticFormItems = [
GeneratedFormItem(
label: tr('trackOnly'),
type: FormItemType.bool,
key: 'trackOnlyFormItemKey')
];
final List<String> additionalAppSpecificSourceAgnosticDefaults = [''];
// Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider
List<GeneratedFormItem> additionalSourceSpecificSettingFormItems = [];
String? changeLogPageFromStandardUrl(String standardUrl) {
throw NotImplementedError();
}
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
return apkUrl;
}
bool canSearch = false;
Future<Map<String, String>> search(String query) {
throw NotImplementedError();
}
String? tryInferringAppId(String standardUrl) {
return null;
} }
} }
class GitLab implements AppSource { ObtainiumError getObtainiumHttpError(Response res) {
@override return ObtainiumError(res.reasonPhrase ??
late String host = 'gitlab.com'; tr('errorWithHttpStatusCode', args: [res.statusCode.toString()]));
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
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),
// GitLab releases may contain links to externally hosted APKs
...getLinksFromParsedHTML(entryContent,
RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
.where((element) => Uri.parse(element).host != '')
.toList()
];
if (apkUrlList.isEmpty) {
throw noAPKFound;
}
var entryId = entry?.querySelector('id')?.innerHtml;
var version =
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, apkUrlList);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
// Same as GitHub
return GitHub().getAppNames(standardUrl);
}
} }
class Signal implements AppSource { String? extractStandardVersionName(String version, {bool strict = false}) {
@override var match = RegExp(
late String host = 'signal.org'; '${strict ? '^' : ''}[0-9]+(\\.[0-9]+)+(-(alpha|beta|ocs)([0-9]+|\\+[0-9]+)?)?${strict ? '\$' : ''}')
.firstMatch(version);
@override return match != null ? version.substring(match.start, match.end) : null;
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 noAPKFound;
}
String? version = json['versionName'];
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [apkUrl]);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
} }
class FDroid implements AppSource { abstract class MassAppUrlSource {
@override late String name;
late String host = 'f-droid.org'; late List<String> requiredArgs;
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
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 noAPKFound;
}
var version = latestReleaseDiv
?.querySelector('.package-version-header b')
?.innerHtml
.split(' ')
.last;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [apkUrl]);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
}
}
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 notValidURL;
}
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 couldNotFindLatestVersion;
}
return APKDetails(
version, ['https://mullvad.net/download/app/apk/latest']);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
}
}
class IzzyOnDroid implements AppSource {
@override
late String 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 notValidURL;
}
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 parsedHtml = parse(res.body);
var multipleVersionApkUrls = parsedHtml
.querySelectorAll('a')
.where((element) =>
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
false)
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
.toList();
if (multipleVersionApkUrls.isEmpty) {
throw noAPKFound;
}
var version = parsedHtml
.querySelector('#keydata')
?.querySelectorAll('b')
.where(
(element) => element.innerHtml.toLowerCase().contains('version'))
.toList()[0]
.parentNode
?.parentNode
?.children[1]
.innerHtml;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [multipleVersionApkUrls[0]]);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
}
} }
class SourceProvider { class SourceProvider {
// Add more source classes here so they are available via the service
List<AppSource> sources = [ List<AppSource> sources = [
GitHub(), GitHub(),
GitLab(), GitLab(),
FDroid(), FDroid(),
IzzyOnDroid(),
Mullvad(), Mullvad(),
Signal(), Signal(),
IzzyOnDroid() SourceForge(),
APKMirror()
]; ];
List<MassAppSource> massSources = [GitHubStars()]; // Add more mass url source classes here so they are available via the service
List<MassAppUrlSource> massUrlSources = [GitHubStars()];
// Add more source classes here so they are available via the service
AppSource getSource(String url) { AppSource getSource(String url) {
url = preStandardizeUrl(url);
AppSource? source; AppSource? source;
for (var s in sources) { for (var s in sources) {
if (url.toLowerCase().contains('://${s.host}')) { if (url.toLowerCase().contains('://${s.host}')) {
@ -416,82 +239,93 @@ class SourceProvider {
} }
} }
if (source == null) { if (source == null) {
throw 'URL does not match a known source'; throw UnsupportedURLError();
} }
return source; return source;
} }
Future<App> getApp(String url) async { bool ifSourceAppsRequireAdditionalData(AppSource source) {
if (url.toLowerCase().indexOf('http://') != 0 && for (var row in source.additionalSourceAppSpecificFormItems) {
url.toLowerCase().indexOf('https://') != 0) { for (var element in row) {
url = 'https://$url'; if (element.required) {
return true;
}
}
} }
if (url.toLowerCase().indexOf('https://www.') == 0) { return false;
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);
} }
/// Returns a length 2 list, where the first element is a list of Apps and String generateTempID(AppNames names, AppSource source) =>
/// the second is a Map<String, dynamic> of URLs and errors '${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}';
Future<List<dynamic>> getApps(List<String> urls) async {
bool isTempId(String id) {
List<String> parts = id.split('_');
if (parts.length < 3) {
return false;
}
for (int i = 0; i < parts.length - 1; i++) {
if (RegExp('.*[A-Z].*').hasMatch(parts[i])) {
// TODO: RegEx won't work for non-eng chars
return false;
}
}
return sources.map((e) => e.host).contains(parts.last);
}
Future<App> getApp(AppSource source, String url, List<String> additionalData,
{String name = '',
String? id,
bool pinned = false,
bool trackOnly = false,
String? installedVersion}) async {
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
AppNames names = source.getAppNames(standardUrl);
APKDetails apk = await source
.getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly);
if (apk.apkUrls.isEmpty && !trackOnly) {
throw NoAPKError();
}
bool enhancedVersionDetection = apk.isStandardVersion &&
installedVersion != null &&
extractStandardVersionName(installedVersion, strict: true) != null;
if (!enhancedVersionDetection) {
apk.version = apk.versionFromSource;
}
String apkVersion = apk.version.replaceAll('/', '-');
return App(
id ??
source.tryInferringAppId(standardUrl) ??
generateTempID(names, source),
standardUrl,
names.author[0].toUpperCase() + names.author.substring(1),
name.trim().isNotEmpty
? name
: names.name[0].toUpperCase() + names.name.substring(1),
installedVersion,
apkVersion,
apk.apkUrls,
apk.apkUrls.length - 1,
additionalData,
DateTime.now(),
pinned,
trackOnly,
enhancedVersionDetection);
}
// Returns errors in [results, errors] instead of throwing them
Future<List<dynamic>> getAppsByURLNaive(List<String> urls,
{List<String> ignoreUrls = const []}) async {
List<App> apps = []; List<App> apps = [];
Map<String, dynamic> errors = {}; Map<String, dynamic> errors = {};
for (var url in urls) { for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
try { try {
apps.add(await getApp(url)); var source = getSource(url);
apps.add(await getApp(
source, url, source.additionalSourceAppSpecificDefaults));
} catch (e) { } catch (e) {
errors.addAll(<String, dynamic>{url: e}); errors.addAll(<String, dynamic>{url: e});
} }
} }
return [apps, errors]; return [apps, errors];
} }
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
}
abstract class MassAppSource {
late String name;
late List<String> requiredArgs;
Future<List<String>> getUrls(List<String> args);
}
class GitHubStars implements MassAppSource {
@override
late String name = 'GitHub Starred Repos';
@override
late List<String> requiredArgs = ['Username'];
@override
Future<List<String>> getUrls(List<String> args) async {
if (args.length != requiredArgs.length) {
throw 'Wrong number of arguments provided';
}
Response res =
await get(Uri.parse('https://api.github.com/users/${args[0]}/starred'));
if (res.statusCode == 200) {
return (jsonDecode(res.body) as List<dynamic>)
.map((e) => e['html_url'] as String)
.toList();
} else {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
}
throw 'Unable to find user\'s starred repos';
}
}
} }

View File

@ -1,13 +1,27 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: 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: archive:
dependency: transitive dependency: transitive
description: description:
name: archive name: archive
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.3.1" version: "3.3.5"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -64,6 +78,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.16.0" 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: crypto:
dependency: transitive dependency: transitive
description: description:
@ -98,42 +126,14 @@ packages:
name: device_info_plus name: device_info_plus
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.1.2" version: "8.0.0"
device_info_plus_linux:
dependency: transitive
description:
name: device_info_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
device_info_plus_macos:
dependency: transitive
description:
name: device_info_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
device_info_plus_platform_interface: device_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: device_info_plus_platform_interface name: device_info_plus_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.0" version: "7.0.0"
device_info_plus_web:
dependency: transitive
description:
name: device_info_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
device_info_plus_windows:
dependency: transitive
description:
name: device_info_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.0"
dynamic_color: dynamic_color:
dependency: "direct main" dependency: "direct main"
description: description:
@ -141,6 +141,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.5.4" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -168,7 +182,7 @@ packages:
name: file_picker name: file_picker
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.1.0" version: "5.2.3"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -180,14 +194,14 @@ packages:
name: flutter_fgbg name: flutter_fgbg
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.0" version: "0.2.2"
flutter_launcher_icons: flutter_launcher_icons:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_launcher_icons name: flutter_launcher_icons
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.10.0" version: "0.11.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -201,21 +215,26 @@ packages:
name: flutter_local_notifications name: flutter_local_notifications
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "9.9.1" version: "12.0.4"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_linux name: flutter_local_notifications_linux
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.5.1" version: "2.0.0"
flutter_local_notifications_platform_interface: flutter_local_notifications_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_platform_interface name: flutter_local_notifications_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted 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: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@ -239,14 +258,14 @@ packages:
name: fluttertoast name: fluttertoast
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "8.0.9" version: "8.1.1"
html: html:
dependency: "direct main" dependency: "direct main"
description: description:
name: html name: html
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.15.0" version: "0.15.1"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -260,14 +279,14 @@ packages:
name: http_parser name: http_parser
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.1" version: "4.0.2"
image: image:
dependency: transitive dependency: transitive
description: description:
name: image name: image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.0" version: "3.2.2"
install_plugin_v2: install_plugin_v2:
dependency: "direct main" dependency: "direct main"
description: description:
@ -275,6 +294,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.0" 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: js:
dependency: transitive dependency: transitive
description: description:
@ -288,14 +321,14 @@ packages:
name: json_annotation name: json_annotation
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.6.0" version: "4.7.0"
lints: lints:
dependency: transitive dependency: transitive
description: description:
name: lints name: lints
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.0" version: "2.0.1"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -309,7 +342,7 @@ packages:
name: material_color_utilities name: material_color_utilities
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.0" version: "0.1.5"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@ -317,6 +350,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.8.0" version: "1.8.0"
mime:
dependency: transitive
description:
name: mime
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@ -324,6 +364,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@ -344,7 +398,7 @@ packages:
name: path_provider_android name: path_provider_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.20" version: "2.0.22"
path_provider_ios: path_provider_ios:
dependency: transitive dependency: transitive
description: description:
@ -372,7 +426,7 @@ packages:
name: path_provider_platform_interface name: path_provider_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.4" version: "2.0.5"
path_provider_windows: path_provider_windows:
dependency: transitive dependency: transitive
description: description:
@ -386,42 +440,42 @@ packages:
name: permission_handler name: permission_handler
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "10.0.0" version: "10.2.0"
permission_handler_android: permission_handler_android:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_android name: permission_handler_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "10.0.0" version: "10.2.0"
permission_handler_apple: permission_handler_apple:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_apple name: permission_handler_apple
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "9.0.4" version: "9.0.7"
permission_handler_platform_interface: permission_handler_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_platform_interface name: permission_handler_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.7.0" version: "3.9.0"
permission_handler_windows: permission_handler_windows:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_windows name: permission_handler_windows
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.1.0" version: "0.1.2"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
name: petitparser name: petitparser
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.0.0" version: "5.1.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -436,6 +490,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
pointycastle:
dependency: transitive
description:
name: pointycastle
url: "https://pub.dartlang.org"
source: hosted
version: "3.6.2"
process: process:
dependency: transitive dependency: transitive
description: description:
@ -449,7 +510,21 @@ packages:
name: provider name: provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.3" version: "6.0.4"
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: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@ -463,7 +538,7 @@ packages:
name: shared_preferences_android name: shared_preferences_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.13" version: "2.0.14"
shared_preferences_ios: shared_preferences_ios:
dependency: transitive dependency: transitive
description: description:
@ -517,7 +592,21 @@ packages:
name: source_span name: source_span
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted 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.1"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.0+2"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -539,6 +628,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
synchronized:
dependency: transitive
description:
name: synchronized
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0+3"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@ -552,14 +648,14 @@ packages:
name: test_api name: test_api
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.14" version: "0.4.12"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
name: timezone name: timezone
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.8.0" version: "0.9.0"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -573,14 +669,14 @@ packages:
name: url_launcher name: url_launcher
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.5" version: "6.1.7"
url_launcher_android: url_launcher_android:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.19" version: "6.0.22"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
@ -608,7 +704,7 @@ packages:
name: url_launcher_platform_interface name: url_launcher_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.0" version: "2.1.1"
url_launcher_web: url_launcher_web:
dependency: transitive dependency: transitive
description: description:
@ -623,13 +719,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.1" version: "3.0.1"
uuid:
dependency: transitive
description:
name: uuid
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.7"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.3" version: "2.1.2"
webview_flutter: webview_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@ -643,35 +746,28 @@ packages:
name: webview_flutter_android name: webview_flutter_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.10.1" version: "2.10.4"
webview_flutter_platform_interface: webview_flutter_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_platform_interface name: webview_flutter_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.9.3" version: "1.9.5"
webview_flutter_wkwebview: webview_flutter_wkwebview:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_wkwebview name: webview_flutter_wkwebview
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.9.4" version: "2.9.5"
win32: win32:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.0" version: "3.1.2"
workmanager:
dependency: "direct main"
description:
name: workmanager
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@ -694,5 +790,5 @@ packages:
source: hosted source: hosted
version: "3.1.1" version: "3.1.1"
sdks: sdks:
dart: ">=2.19.0-79.0.dev <3.0.0" dart: ">=2.18.2 <3.0.0"
flutter: ">=3.3.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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 0.2.1+12 # When changing this, update the tag in main() accordingly version: 0.8.3+66 # When changing this, update the tag in main() accordingly
environment: 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. # Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions # To automatically upgrade your package dependencies to the latest versions
@ -38,26 +38,32 @@ dependencies:
cupertino_icons: ^1.0.5 cupertino_icons: ^1.0.5
path_provider: ^2.0.11 path_provider: ^2.0.11
flutter_fgbg: ^0.2.0 # Try removing reliance on this flutter_fgbg: ^0.2.0 # Try removing reliance on this
flutter_local_notifications: ^9.9.1 flutter_local_notifications: ^12.0.0
provider: ^6.0.3 provider: ^6.0.3
http: ^0.13.5 http: ^0.13.5
webview_flutter: ^3.0.4 webview_flutter: ^3.0.4
workmanager: ^0.5.0
dynamic_color: ^1.5.4 dynamic_color: ^1.5.4
install_plugin_v2: ^1.0.0 # Try replacing this
html: ^0.15.0 html: ^0.15.0
shared_preferences: ^2.0.15 shared_preferences: ^2.0.15
url_launcher: ^6.1.5 url_launcher: ^6.1.5
permission_handler: ^10.0.0 permission_handler: ^10.0.0
fluttertoast: ^8.0.9 fluttertoast: ^8.0.9
device_info_plus: ^4.1.2 device_info_plus: ^8.0.0
file_picker: ^5.1.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: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter 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 # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # encourage good coding practices. The lint set provided by the package is
@ -84,9 +90,12 @@ flutter:
uses-material-design: true uses-material-design: true
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # - assets:
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg # - images/a_dot_ham.jpeg
assets:
- assets/translations/
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware # https://flutter.dev/assets-and-images/#resolution-aware

View File

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