Compare commits

...

165 Commits

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

* Fixes #57

* Fixes #67

* Fixes #64

* Fixes #61

* Commented out APKMirror and added code to remove their Apps

* Updated README

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

* BG task silently retries on network errors

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

* dynamic form progress (switch doesn't work)

* dynamic forms work

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

* Gen form bugfix

* Removed redundant generated modal code

* Added custom validators to gen. forms

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

* Tweaks, more

* More

* Progress

* Changed a default

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

@ -4,19 +4,23 @@ Get Android App Updates Directly From the Source.
Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available. Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available.
Currently supported App sources:
- GitHub
- GitLab
Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0) Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0)
Currently supported App sources:
- [GitHub](https://github.com/)
- [GitLab](https://gitlab.com/)
- [F-Droid](https://f-droid.org/)
- [IzzyOnDroid](https://android.izzysoft.de/)
- [Mullvad](https://mullvad.net/en/)
- [Signal](https://signal.org/)
## Limitations ## Limitations
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected. - App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin. - Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
- For GitHub, data is gathered using Web scraping and can easily break due to changes in website design. More reliable methods are either insufficient (GitHub RSS) or subject to rate limits (GitHub API). This may also apply to new sources added in the future. - For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable.
## Screenshots ## Screenshots
| <img src="./screenshots/1.apps.png" alt="Apps Page" /> | <img src="./screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./screenshots/3.material_you.png" alt="Material You" /> | | <img src="./assets/screenshots/1.apps.png" alt="Apps Page" /> | <img src="./assets/screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./assets/screenshots/3.material_you.png" alt="Material You" /> |
| ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- | | ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
| <img src="./screenshots/4.app.png" alt="App Page" /> | <img src="./screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./screenshots/6.apk_install.png" alt="App Installation" /> | | <img src="./assets/screenshots/4.app.png" alt="App Page" /> | <img src="./assets/screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./assets/screenshots/6.apk_install.png" alt="App Installation" /> |

View File

@ -30,7 +30,25 @@
<meta-data <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.1 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

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

BIN
assets/graphics/banner.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
assets/graphics/icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
assets/graphics/icon.psd Executable file

Binary file not shown.

BIN
assets/graphics/obtainium.psd Executable file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

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);
}
}

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

@ -0,0 +1,205 @@
import 'dart:convert';
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: 'GitHub Personal Access Token (Increases Rate Limit)',
id: 'github-creds',
required: false,
additionalValidators: [
(value) {
if (value != null && value.trim().isNotEmpty) {
if (value
.split(':')
.where((element) => element.trim().isNotEmpty)
.length !=
2) {
return 'PAT must be in this format: username:token';
}
}
return null;
}
],
hint: 'username:token',
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: const Text(
'About GitHub PATs',
style: TextStyle(
decoration: TextDecoration.underline, fontSize: 12),
))
])
];
additionalSourceAppSpecificFormItems = [
[
GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)
],
[
GeneratedFormItem(
label: 'Fallback to older releases', type: FormItemType.bool)
],
[
GeneratedFormItem(
label: 'Filter Release Titles by Regular Expression',
type: FormItemType.string,
required: false,
additionalValidators: [
(value) {
if (value == null || value.isEmpty) {
return null;
}
try {
RegExp(value);
} catch (e) {
return 'Invalid regular expression';
}
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
: 'No description'
});
}
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,228 @@
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} (required)';
}
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 const Text('ERROR: DROPDOWN MUST HAVE AT LEAST ONE OPT.');
}
return DropdownButtonFormField(
decoration: const InputDecoration(labelText: '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

@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form.dart';
class GeneratedFormModal extends StatefulWidget {
const GeneratedFormModal(
{super.key,
required this.title,
required this.items,
required this.defaultValues,
this.initValid = false,
this.message = ''});
final String title;
final String message;
final List<List<GeneratedFormItem>> items;
final List<String> defaultValues;
final bool initValid;
@override
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
}
class _GeneratedFormModalState extends State<GeneratedFormModal> {
List<String> values = [];
bool valid = false;
@override
void initState() {
super.initState();
values = widget.defaultValues;
valid = widget.initValid || widget.items.isEmpty;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: Text(widget.title),
content:
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
if (widget.message.isNotEmpty) Text(widget.message),
if (widget.message.isNotEmpty)
const SizedBox(
height: 16,
),
GeneratedForm(
items: widget.items,
onValueChanges: (values, valid, isBuilding) {
if (isBuilding) {
this.values = values;
this.valid = valid;
} else {
setState(() {
this.values = values;
this.valid = valid;
});
}
},
defaultValues: widget.defaultValues)
]),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: const Text('Cancel')),
TextButton(
onPressed: !valid
? null
: () {
if (valid) {
HapticFeedback.selectionClick();
Navigator.of(context).pop(values);
}
},
child: const Text('Continue'))
],
);
}
}

122
lib/custom_errors.dart Normal file
View File

@ -0,0 +1,122 @@
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() =>
'Too many requests (rate limited) - try again in $remainingMinutes minutes';
}
class InvalidURLError extends ObtainiumError {
InvalidURLError(String sourceName) : super('Not a valid $sourceName App URL');
}
class NoReleasesError extends ObtainiumError {
NoReleasesError() : super('Could not find a suitable release');
}
class NoAPKError extends ObtainiumError {
NoAPKError() : super('Could not find a suitable release');
}
class NoVersionError extends ObtainiumError {
NoVersionError() : super('Could not determine release version');
}
class UnsupportedURLError extends ObtainiumError {
UnsupportedURLError() : super('URL does not match a known source');
}
class DowngradeError extends ObtainiumError {
DowngradeError() : super('Cannot install an older version of an App');
}
class IDChangedError extends ObtainiumError {
IDChangedError()
: super('Downloaded package ID does not match existing App ID');
}
class NotImplementedError extends ObtainiumError {
NotImplementedError() : super('This class has not implemented this function');
}
class MultiAppMultiError extends ObtainiumError {
Map<String, List<String>> content = {};
MultiAppMultiError() : super('Multiple Errors 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
? 'Some Errors Occurred'
: 'Unexpected Error'),
content: Text(e.toString()),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: const Text('Ok')),
],
);
});
}
}
String list2FriendlyString(List<String> list) {
return list.length == 2
? '${list[0]} 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,100 +1,180 @@
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:android_alarm_manager_plus/android_alarm_manager_plus.dart';
const String currentVersion = '0.8.1';
const String currentReleaseTag = const String currentReleaseTag =
'v0.1.5-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('Started BG update check task');
var appsProvider = AppsProvider(bg: true); int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
var notificationsProvider = NotificationsProvider(); WidgetsFlutterBinding.ensureInitialized();
await notificationsProvider.notify(checkingUpdatesNotification); await AndroidAlarmManager.initialize();
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
: null;
logs.add('Bg update ignoreAfter is $ignoreAfter');
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 {
await notificationsProvider logs.add('Started actual BG update checking');
.cancel(ErrorCheckingUpdatesNotification('').id); await appsProvider.checkUpdates(
await appsProvider.loadApps(); ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
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(
return Future.value(false); 'BG update checking encountered a ${e.runtimeType}, will schedule a retry check in $remainingMinutes minutes');
} 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(
'BG update checking found ${newUpdates.length} updates - will notify user if needed');
if (newUpdates.isNotEmpty) {
notificationsProvider.notify(UpdateNotification(newUpdates));
}
if (err != null) {
throw err;
}
} catch (e) {
notificationsProvider
.notify(ErrorCheckingUpdatesNotification(e.toString()));
} finally {
logs.add('Finished BG update check task');
await notificationsProvider.cancel(checkingUpdatesNotification.id);
}
} }
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setSystemUIOverlayStyle( if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), SystemChrome.setSystemUIOverlayStyle(
); const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); );
Workmanager().initialize( SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
bgTaskCallback, }
); await AndroidAlarmManager.initialize();
runApp(MultiProvider( runApp(MultiProvider(
providers: [ providers: [
ChangeNotifierProvider(create: (context) => AppsProvider()), ChangeNotifierProvider(create: (context) => AppsProvider()),
ChangeNotifierProvider(create: (context) => SettingsProvider()), ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => NotificationsProvider()) Provider(create: (context) => NotificationsProvider()),
Provider(create: (context) => LogsProvider())
], ],
child: const MyApp(), 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().then((value) { settingsProvider.initializeSettings();
// Delete past downloads and check for updates every time the app is launched
// Only runs once as the settings are only initialized once (so not on every build)
appsProvider.deleteSavedAPKs();
appsProvider.checkUpdates();
});
} else { } else {
// Register the background update task according to the user's setting
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
frequency: Duration(minutes: settingsProvider.updateInterval),
initialDelay: Duration(minutes: settingsProvider.updateInterval),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.replace);
bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
if (isFirstRun) { if (isFirstRun) {
logs.add('This is the first ever run of Obtainium');
// 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,
['true'],
null,
false,
false)
]);
}
// Register the background update task according to the user's setting
if (existingUpdateInterval != settingsProvider.updateInterval) {
if (existingUpdateInterval != -1) {
logs.add(
'Setting update interval to ${settingsProvider.updateInterval}');
}
existingUpdateInterval = settingsProvider.updateInterval;
if (existingUpdateInterval == 0) {
AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
} else {
AndroidAlarmManager.periodic(
Duration(minutes: existingUpdateInterval),
bgUpdateCheckAlarmId,
bgUpdateCheck,
rescheduleOnReboot: true,
wakeup: true);
}
} }
} }

View File

@ -0,0 +1,53 @@
import 'dart:convert';
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 = 'GitHub Starred Repos';
@override
late List<String> requiredArgs = ['Username'];
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
: 'No description'
});
}
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('Wrong number of arguments provided');
}
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,11 @@
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 +20,378 @@ 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:
'${pickedSource!.enforceTrackOnly ? 'Source' : 'App'} is Track-Only',
items: const [],
defaultValues: const [],
message:
'${pickedSource!.enforceTrackOnly ? 'Apps from this source are \'Track-Only\'.' : 'You have selected the \'Track-Only\' option.'}\n\nThe App will be tracked for updates, but Obtainium will not be able to download or install it.',
);
}) ==
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('Cancelled');
}
app.preferredApkIndex = app.apkUrls.indexOf(apkUrl);
var downloadedApk = await appsProvider.downloadApp(app);
app.id = downloadedApk.appId;
}
if (appsProvider.apps.containsKey(app.id)) {
throw ObtainiumError('App already added');
}
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>[
const CustomAppBar(title: 'Add App'),
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: 'App Source Url',
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()) { : '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: const Text('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: 'Search (Some Sources Only)',
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: const Text('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(
'Additional Options for ${pickedSource?.runtimeType}',
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,
),
const Text(
'Supported Sources:',
),
const SizedBox(
height: 8,
),
...sourceProvider.sources
.map((e) => GestureDetector(
onTap: () {
launchUrlString('https://${e.host}',
mode:
LaunchMode.externalApplication);
},
child: Text(
'${e.runtimeType.toString()}${e.enforceTrackOnly ? ' (Track-Only)' : ''}${e.canSearch ? ' (Searchable)' : ''}',
style: const TextStyle(
decoration:
TextDecoration.underline,
fontStyle: FontStyle.italic),
)))
.toList()
])),
const SizedBox(
height: 8,
),
])),
)
]));
} }
} }

View File

@ -1,6 +1,11 @@
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/source_provider.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';
@ -14,21 +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>();
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: WebView( child: settingsProvider.showAppWebpage
initialUrl: app?.app.url, ? WebView(
javascriptMode: JavascriptMode.unrestricted, backgroundColor: Theme.of(context).colorScheme.background,
), initialUrl: app?.app.url,
javascriptMode: JavascriptMode.unrestricted,
)
: CustomScrollView(
slivers: [
SliverFillRemaining(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
app?.installedInfo != null
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.memory(
app!.installedInfo!.icon!,
height: 150,
gaplessPlayback: true,
)
])
: Container(),
const SizedBox(
height: 25,
),
Text(
app?.installedInfo?.name ?? app?.app.name ?? 'App',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayLarge,
),
Text(
'By ${app?.app.author ?? 'Unknown'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(
height: 32,
),
GestureDetector(
onTap: () {
if (app?.app.url != null) {
launchUrlString(app?.app.url ?? '',
mode: LaunchMode.externalApplication);
}
},
child: Text(
app?.app.url ?? '',
textAlign: TextAlign.center,
style: const TextStyle(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic,
fontSize: 12),
)),
const SizedBox(
height: 32,
),
Text(
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(
'Installed Version: ${app?.app.installedVersion ?? 'None'}${app?.app.trackOnly == true ? ' (Estimate)\n\nApp is Track-Only' : ''}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(
height: 32,
),
Text(
'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}',
textAlign: TextAlign.center,
style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12),
)
],
)),
],
),
onRefresh: () async {
if (app != null) {
getUpdate(app.app.id);
}
}),
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),
@ -40,48 +139,136 @@ class _AppPageState extends State<AppPage> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
if (app?.app.installedVersion != null &&
app?.app.trackOnly == false &&
app?.app.installedVersion != app?.app.latestVersion)
IconButton(
onPressed: app?.downloadProgress != null
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text(
'App Already up to Date?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context)
.pop();
},
child: const Text('No')),
TextButton(
onPressed: () {
HapticFeedback
.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp
.installedVersion =
updatedApp
.latestVersion;
appsProvider.saveApps(
[updatedApp]);
}
Navigator.of(context)
.pop();
},
child: const Text(
'Yes, Mark as Updated'))
],
);
});
},
tooltip: 'Mark as Updated',
icon: const Icon(Icons.done)),
if (source != null &&
source.additionalSourceAppSpecificFormItems
.isNotEmpty)
IconButton(
onPressed: app?.downloadProgress != null
? null
: () {
showDialog<List<String>>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Additional Options',
items: source
.additionalSourceAppSpecificFormItems,
defaultValues: app != null
? app.app.additionalData
: source
.additionalSourceAppSpecificDefaults);
}).then((values) {
if (app != null && values != null) {
var changedApp = app.app;
changedApp.additionalData = values;
appsProvider.saveApps(
[changedApp]).then((value) {
getUpdate(changedApp.id);
});
}
});
},
tooltip: 'Additional Options',
icon: const Icon(Icons.settings)),
const SizedBox(width: 16.0),
Expanded( 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((_) =>
@ -91,7 +278,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'))
@ -100,8 +286,10 @@ class _AppPageState extends State<AppPage> {
}); });
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: Theme.of(context).errorColor, foregroundColor:
surfaceTintColor: Theme.of(context).errorColor), Theme.of(context).colorScheme.error,
surfaceTintColor:
Theme.of(context).colorScheme.error),
child: const Text('Remove'), child: const Text('Remove'),
), ),
])), ])),

View File

@ -1,82 +1,729 @@
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 existingUpdateAppIds = appsProvider.getExistingUpdates(); var settingsProvider = context.watch<SettingsProvider>();
var sortedApps = appsProvider.apps.values.toList();
var currentFilterIsUpdatesOnly =
filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
selectedApps = selectedApps
.where((element) => sortedApps.map((e) => e.app).contains(element))
.toSet();
toggleAppSelected(App app) {
setState(() {
if (selectedApps.contains(app)) {
selectedApps.remove(app);
} else {
selectedApps.add(app);
}
});
}
if (filter != null) {
sortedApps = sortedApps.where((app) {
if (app.app.installedVersion == app.app.latestVersion &&
!(filter!.includeUptodate)) {
return false;
}
if (app.app.installedVersion == null &&
!(filter!.includeNonInstalled)) {
return false;
}
if (filter!.nameFilter.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) {
var nameA = a.installedInfo?.name ?? a.app.name;
var nameB = b.installedInfo?.name ?? b.app.name;
int result = 0;
if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
result = (a.app.author + nameA).compareTo(b.app.author + nameB);
} else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) {
result = (nameA + a.app.author).compareTo(nameB + b.app.author);
}
return result;
});
if (settingsProvider.sortOrder == SortOrderSettings.descending) {
sortedApps = sortedApps.reversed.toList();
}
var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
var existingUpdateIdsAllOrSelected = existingUpdates
.where((element) => selectedApps.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedApps.map((e) => e.id).contains(element))
.toList();
var newInstallIdsAllOrSelected = appsProvider
.findExistingUpdates(nonInstalledOnly: true)
.where((element) => selectedApps.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedApps.map((e) => e.id).contains(element))
.toList();
List<String> trackOnlyUpdateIdsAllOrSelected = [];
existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) {
if (appsProvider.apps[id]!.app.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(); });
context return appsProvider.checkUpdates().catchError((e) {
.read<SettingsProvider>() showError(e, context);
.getInstallPermission() }).whenComplete(() {
.then((_) { setState(() {
appsProvider.downloadAndInstallLatestApp( refreshingSince = null;
existingUpdateAppIds, context); });
}); });
}, },
icon: const Icon(Icons.update), child: CustomScrollView(slivers: <Widget>[
label: const Text('Update All')), const CustomAppBar(title: 'Apps'),
body: Center( if (appsProvider.loadingApps || sortedApps.isEmpty)
child: appsProvider.loadingApps SliverFillRemaining(
? const CircularProgressIndicator() child: Center(
: appsProvider.apps.isEmpty child: appsProvider.loadingApps
? Text( ? const CircularProgressIndicator()
'No Apps', : Text(
style: Theme.of(context).textTheme.headline4, appsProvider.apps.isEmpty
) ? 'No Apps'
: RefreshIndicator( : 'No Apps for Filter',
onRefresh: () { style: Theme.of(context).textTheme.headlineMedium,
HapticFeedback.lightImpact(); textAlign: TextAlign.center,
return appsProvider.checkUpdates(); ))),
}, if (refreshingSince != null)
child: ListView( SliverToBoxAdapter(
children: appsProvider.apps.values child: LinearProgressIndicator(
.map( value: appsProvider.apps.values
(e) => ListTile( .where((element) => !(element.app.lastUpdateCheck
title: Text('${e.app.author}/${e.app.name}'), ?.isBefore(refreshingSince!) ??
subtitle: Text( true))
e.app.installedVersion ?? 'Not Installed'), .length /
trailing: e.downloadProgress != null appsProvider.apps.length,
? Text( ),
'Downloading - ${e.downloadProgress?.toInt()}%') ),
: (e.app.installedVersion != null && SliverList(
e.app.installedVersion != delegate: SliverChildBuilderDelegate(
e.app.latestVersion (BuildContext context, int index) {
? const Text('Update Available') return ListTile(
: null), tileColor: sortedApps[index].app.pinned
onTap: () { ? Colors.grey.withOpacity(0.1)
Navigator.push( : Colors.transparent,
context, selectedTileColor: Theme.of(context)
MaterialPageRoute( .colorScheme
builder: (context) => .primary
AppPage(appId: e.app.id)), .withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1),
); selected: selectedApps.contains(sortedApps[index].app),
}, onLongPress: () {
), toggleAppSelected(sortedApps[index].app);
) },
.toList(), 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('By ${sortedApps[index].app.author}',
style: TextStyle(
fontWeight: sortedApps[index].app.pinned
? FontWeight.bold
: FontWeight.normal)),
trailing: sortedApps[index].downloadProgress != null
? Text(
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
: (sortedApps[index].app.installedVersion != null &&
sortedApps[index].app.installedVersion !=
sortedApps[index].app.latestVersion
? Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(appsProvider.areDownloadsRunning()
? 'Please Wait...'
: 'Update Available${sortedApps[index].app.trackOnly ? ' (Est.)' : ''}'),
SourceProvider()
.getSource(sortedApps[index].app.url)
.changeLogPageFromStandardUrl(
sortedApps[index].app.url) ==
null
? const SizedBox()
: GestureDetector(
onTap: () {
launchUrlString(
SourceProvider()
.getSource(
sortedApps[index].app.url)
.changeLogPageFromStandardUrl(
sortedApps[index].app.url)!,
mode:
LaunchMode.externalApplication);
},
child: const Text(
'See Changes',
style: TextStyle(
fontStyle: FontStyle.italic,
decoration:
TextDecoration.underline),
)),
],
)
: SingleChildScrollView(
child: SizedBox(
width: 80,
child: Text(
'${sortedApps[index].app.installedVersion ?? 'Not Installed'} ${sortedApps[index].app.trackOnly == true ? '(Estimate)' : ''}',
overflow: TextOverflow.fade,
textAlign: TextAlign.end,
)))),
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
? 'Select All'
: 'Deselect ${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: 'Remove Selected Apps?',
items: const [],
defaultValues: const [],
initValid: true,
message:
'${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedApps.length == 1 ? 'it' : 'them'} manually.',
);
}).then((values) {
if (values != null) {
appsProvider.removeApps(
selectedApps.map((e) => e.id).toList());
}
});
},
tooltip: 'Remove Selected Apps',
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:
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
type: FormItemType.bool,
key: 'updates'));
defaultValues.add('true');
}
if (newInstallIdsAllOrSelected.isNotEmpty) {
formInputs.add(GeneratedFormItem(
label:
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}',
type: FormItemType.bool,
key: 'installs'));
defaultValues
.add(defaultValues.isEmpty ? 'true' : '');
}
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
formInputs.add(GeneratedFormItem(
label:
'Mark ${trackOnlyUpdateIdsAllOrSelected.length} Track-Only\nApp${trackOnlyUpdateIdsAllOrSelected.length == 1 ? '' : 's'} as Updated',
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:
'Change $totalApps App${totalApps == 1 ? '' : 's'}',
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:
'Install/Update${selectedApps.isEmpty ? ' ' : ' Selected '}Apps',
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(
'Mark ${selectedApps.length} Selected Apps as Updated?'),
content:
const Text(
'Only applies to installed but out of date Apps.'),
actions: [
TextButton(
onPressed:
() {
Navigator.of(context)
.pop();
},
child: const Text(
'No')),
TextButton(
onPressed:
() {
HapticFeedback
.selectionClick();
appsProvider
.saveApps(selectedApps.map((a) {
if (a.installedVersion !=
null) {
a.installedVersion = a.latestVersion;
}
return a;
}).toList());
Navigator.of(context)
.pop();
},
child: const Text(
'Yes'))
],
);
}).whenComplete(() {
Navigator.of(
context)
.pop();
});
},
tooltip:
'Mark Selected Apps as Updated',
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 ? 'Pin to' : 'Unpin from'} top',
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:
'${selectedApps.length} Selected App URLs from Obtainium');
Navigator.of(context).pop();
},
tooltip: 'Share Selected App URLs',
icon: const Icon(Icons.share),
),
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title:
'Reset Install Status for Selected Apps?',
items: const [],
defaultValues: const [],
initValid: true,
message:
'The install status of ${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be reset.\n\nThis can help when the App version shown in Obtainium is incorrect due to failed updates or other issues.',
);
}).then((values) {
if (values != null) {
appsProvider.saveApps(
selectedApps.map((e) {
e.installedVersion = null;
return e;
}).toList());
}
}).whenComplete(() {
Navigator.of(context).pop();
});
},
tooltip: 'Reset Install Status',
icon: const Icon(
Icons.restore_page_outlined),
),
]),
),
);
});
},
tooltip: 'More',
icon: const Icon(Icons.more_horiz),
),
],
)),
const VerticalDivider(),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
setState(() {
if (currentFilterIsUpdatesOnly) {
filter = null;
} else {
filter = updatesOnlyFilter;
}
});
},
tooltip: currentFilterIsUpdatesOnly
? 'Remove Out-of-Date App Filter'
: 'Show Out-of-Date Apps Only',
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 ? 'Filter' : 'Filter *',
style: TextStyle(
fontWeight: filter == null
? FontWeight.normal
: FontWeight.bold),
), ),
)); onPressed: () {
showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Filter Apps',
items: [
[
GeneratedFormItem(
label: 'App Name', required: false),
GeneratedFormItem(
label: 'Author', required: false)
],
[
GeneratedFormItem(
label: 'Up to Date Apps',
type: FormItemType.bool)
],
[
GeneratedFormItem(
label: 'Non-Installed Apps',
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,7 +1,9 @@
import 'package:animations/animations.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';
import 'package:obtainium/pages/apps.dart'; import 'package:obtainium/pages/apps.dart';
import 'package:obtainium/pages/import_export.dart';
import 'package:obtainium/pages/settings.dart'; import 'package:obtainium/pages/settings.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
@ -11,44 +13,85 @@ 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> {
int selectedIndex = 1; List<int> selectedIndexHistory = [];
List<Widget> pages = [
const SettingsPage(), List<NavigationPageItem> pages = [
const AppsPage(), NavigationPageItem(
const AddAppPage() 'Apps', Icons.apps, AppsPage(key: GlobalKey<AppsPageState>())),
NavigationPageItem('Add App', Icons.add, const AddAppPage()),
NavigationPageItem(
'Import/Export', Icons.import_export, const ImportExportPage()),
NavigationPageItem('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(selectedIndex), body: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: pages
.elementAt(selectedIndexHistory.isEmpty
? 0
: selectedIndexHistory.last)
.widget,
),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
destinations: const [ destinations: pages
NavigationDestination( .map((e) =>
icon: Icon(Icons.settings), label: 'Settings'), NavigationDestination(icon: Icon(e.icon), label: e.title))
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'), .toList(),
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
],
onDestinationSelected: (int index) { onDestinationSelected: (int index) {
HapticFeedback.lightImpact(); HapticFeedback.selectionClick();
setState(() { setState(() {
selectedIndex = index; if (index == 0) {
selectedIndexHistory.clear();
} else if (selectedIndexHistory.isEmpty ||
(selectedIndexHistory.isNotEmpty &&
selectedIndexHistory.last != index)) {
int existingInd = selectedIndexHistory.indexOf(index);
if (existingInd >= 0) {
selectedIndexHistory.removeAt(existingInd);
}
selectedIndexHistory.add(index);
}
}); });
}, },
selectedIndex: selectedIndex, selectedIndex:
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,
), ),
), ),
onWillPop: () async { onWillPop: () async {
if (selectedIndex != 1) { if (selectedIndexHistory.isNotEmpty) {
setState(() { setState(() {
selectedIndex = 1; selectedIndexHistory.removeLast();
}); });
return false; return false;
} }
return true; return !(pages[0].widget.key as GlobalKey<AppsPageState>)
.currentState
?.clearSelected();
}); });
} }
} }

View File

@ -0,0 +1,585 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ImportExportPage extends StatefulWidget {
const ImportExportPage({super.key});
@override
State<ImportExportPage> createState() => _ImportExportPageState();
}
class _ImportExportPageState extends State<ImportExportPage> {
bool importInProgress = false;
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
var appsProvider = context.read<AppsProvider>();
var outlineButtonStyle = ButtonStyle(
shape: MaterialStateProperty.all(
StadiumBorder(
side: BorderSide(
width: 1,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Import/Export'),
SliverFillRemaining(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextButton(
style: outlineButtonStyle,
onPressed: appsProvider.apps.isEmpty ||
importInProgress
? null
: () {
HapticFeedback.selectionClick();
appsProvider
.exportApps()
.then((String path) {
showError(
'Exported to $path', context);
});
},
child: const Text('Obtainium Export'))),
const SizedBox(
width: 16,
),
Expanded(
child: TextButton(
style: outlineButtonStyle,
onPressed: importInProgress
? null
: () {
HapticFeedback.selectionClick();
FilePicker.platform
.pickFiles()
.then((result) {
setState(() {
importInProgress = true;
});
if (result != null) {
String data = File(
result.files.single.path!)
.readAsStringSync();
try {
jsonDecode(data);
} catch (e) {
throw ObtainiumError(
'Invalid input');
}
appsProvider
.importApps(data)
.then((value) {
showError(
'$value App${value == 1 ? '' : 's'} Imported',
context);
});
} else {
// User canceled the picker
}
}).catchError((e) {
showError(e, context);
}).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(
label: 'App URL List',
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 'Line ${i + 1}: $e';
}
}
}
return null;
}
])
]
],
defaultValues: const [],
);
}).then((values) {
if (values != null) {
var urls =
(values[0] as String).split('\n');
setState(() {
importInProgress = true;
});
appsProvider
.addAppsByURL(urls)
.then((errors) {
if (errors.isEmpty) {
showError(
'Imported ${urls.length} Apps',
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: const Text(
'Import from URL List',
)),
...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:
'Search ${source.runtimeType}',
items: [
[
GeneratedFormItem(
label:
'${source.runtimeType} Search Query')
]
],
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(
'Imported ${selectedUrls.length} Apps',
context);
} else {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
} else {
throw ObtainiumError(
'No results found');
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: Text(
'Search ${source.runtimeType}'))
]))
.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:
'Import ${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(
'Imported ${selectedUrls.length} Apps',
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('Import ${source.name}'))
]))
.toList(),
const Spacer(),
const Divider(
height: 32,
),
const Text(
'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.',
textAlign: TextAlign.center,
style: TextStyle(
fontStyle: FontStyle.italic, fontSize: 12)),
const SizedBox(
height: 8,
)
],
)))
]));
}
}
class ImportErrorDialog extends StatefulWidget {
const ImportErrorDialog(
{super.key, required this.urlsLength, required this.errors});
final int urlsLength;
final List<List<String>> errors;
@override
State<ImportErrorDialog> createState() => _ImportErrorDialogState();
}
class _ImportErrorDialogState extends State<ImportErrorDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: const Text('Import Errors'),
content:
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Text(
'${widget.urlsLength - widget.errors.length} of ${widget.urlsLength} Apps imported.',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
Text(
'The following URLs had errors:',
style: Theme.of(context).textTheme.bodyLarge,
),
...widget.errors.map((e) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(
height: 16,
),
Text(e[0]),
Text(
e[1],
style: const TextStyle(fontStyle: FontStyle.italic),
)
]);
}).toList()
]),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: const Text('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 ? 'Select URL' : 'Select URLs'),
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: const Text('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
? 'Pick'
: 'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs'))
],
);
}
}

View File

@ -1,10 +1,12 @@
import 'dart:convert';
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/providers/apps_provider.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 {
@ -17,230 +19,337 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> { class _SettingsPageState extends State<SettingsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
AppsProvider appsProvider = context.read<AppsProvider>();
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),
child: settingsProvider.prefs == null
? Container()
: Column(
children: [
DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'Theme'),
value: settingsProvider.theme,
items: const [
DropdownMenuItem(
value: ThemeSettings.dark,
child: Text('Dark'),
),
DropdownMenuItem(
value: ThemeSettings.light,
child: Text('Light'),
),
DropdownMenuItem(
value: ThemeSettings.system,
child: Text('Follow System'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.theme = value;
}
}),
const SizedBox(
height: 16,
),
DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'Colour'),
value: settingsProvider.colour,
items: const [
DropdownMenuItem(
value: ColourSettings.basic,
child: Text('Obtainium'),
),
DropdownMenuItem(
value: ColourSettings.materialYou,
child: Text('Material You'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.colour = value;
}
}),
const SizedBox(
height: 16,
),
DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'Background Update Checking Interval'),
value: settingsProvider.updateInterval,
items: const [
DropdownMenuItem(
value: 15,
child: Text('15 Minutes'),
),
DropdownMenuItem(
value: 30,
child: Text('30 Minutes'),
),
DropdownMenuItem(
value: 60,
child: Text('1 Hour'),
),
DropdownMenuItem(
value: 360,
child: Text('6 Hours'),
),
DropdownMenuItem(
value: 720,
child: Text('12 Hours'),
),
DropdownMenuItem(
value: 1440,
child: Text('1 Day'),
),
],
onChanged: (value) {
if (value != null) {
settingsProvider.updateInterval = value;
}
}),
const SizedBox(
height: 32,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
ElevatedButton(
onPressed: appsProvider.apps.isEmpty
? null
: () {
HapticFeedback.lightImpact();
appsProvider.exportApps().then((String path) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Exported to $path')),
);
});
},
child: const Text('Export Apps')),
ElevatedButton(
onPressed: () {
HapticFeedback.lightImpact();
showDialog(
context: context,
builder: (BuildContext ctx) {
final formKey = GlobalKey<FormState>();
final jsonInputController =
TextEditingController();
return AlertDialog( var themeDropdown = DropdownButtonFormField(
scrollable: true, decoration: const InputDecoration(labelText: 'Theme'),
title: const Text('Import Apps'), value: settingsProvider.theme,
content: Column(children: [ items: const [
const Text( DropdownMenuItem(
'Copy the contents of the Obtainium export file and paste them into the field below:'), value: ThemeSettings.dark,
Form( child: Text('Dark'),
key: formKey, ),
child: TextFormField( DropdownMenuItem(
minLines: 7, value: ThemeSettings.light,
maxLines: 7, child: Text('Light'),
decoration: const InputDecoration( ),
helperText: DropdownMenuItem(
'Obtainium export data'), value: ThemeSettings.system,
controller: jsonInputController, child: Text('Follow System'),
validator: (value) { )
if (value == null || ],
value.isEmpty) { onChanged: (value) {
return 'Please enter your Obtainium export data'; if (value != null) {
} settingsProvider.theme = value;
bool isJSON = true; }
try { });
jsonDecode(value);
} catch (e) { var colourDropdown = DropdownButtonFormField(
isJSON = false; decoration: const InputDecoration(labelText: 'Colour'),
} value: settingsProvider.colour,
if (!isJSON) { items: const [
return 'Invalid input'; DropdownMenuItem(
} value: ColourSettings.basic,
return null; child: Text('Obtainium'),
}, ),
), DropdownMenuItem(
) value: ColourSettings.materialYou,
]), child: Text('Material You'),
actions: [ )
TextButton( ],
onPressed: () { onChanged: (value) {
HapticFeedback.lightImpact(); if (value != null) {
Navigator.of(context).pop(); settingsProvider.colour = value;
}, }
child: const Text('Cancel')), });
TextButton(
onPressed: () { var sortDropdown = DropdownButtonFormField(
HapticFeedback.heavyImpact(); decoration: const InputDecoration(labelText: 'App Sort By'),
if (formKey.currentState! value: settingsProvider.sortColumn,
.validate()) { items: const [
appsProvider DropdownMenuItem(
.importApps( value: SortColumnSettings.authorName,
jsonInputController child: Text('Author/Name'),
.value.text) ),
.then((value) { DropdownMenuItem(
ScaffoldMessenger.of(context) value: SortColumnSettings.nameAuthor,
.showSnackBar( child: Text('Name/Author'),
SnackBar( ),
content: Text( DropdownMenuItem(
'$value Apps Imported')), value: SortColumnSettings.added,
); child: Text('As Added'),
}).catchError((e) { )
ScaffoldMessenger.of(context) ],
.showSnackBar( onChanged: (value) {
SnackBar( if (value != null) {
content: settingsProvider.sortColumn = value;
Text(e.toString())), }
); });
}).whenComplete(() {
Navigator.of(context).pop(); var orderDropdown = DropdownButtonFormField(
}); decoration: const InputDecoration(labelText: 'App Sort Order'),
} value: settingsProvider.sortOrder,
}, items: const [
child: const Text('Import')), DropdownMenuItem(
], value: SortOrderSettings.ascending,
); child: Text('Ascending'),
}); ),
}, DropdownMenuItem(
child: const Text('Import Apps')) value: SortOrderSettings.descending,
], child: Text('Descending'),
), ),
const Spacer(), ],
Row( onChanged: (value) {
mainAxisAlignment: MainAxisAlignment.center, if (value != null) {
children: [ settingsProvider.sortOrder = value;
TextButton.icon( }
style: ButtonStyle( });
foregroundColor:
MaterialStateProperty.resolveWith<Color>( var intervalDropdown = DropdownButtonFormField(
(Set<MaterialState> states) { decoration: const InputDecoration(
return Colors.grey; labelText: 'Background Update Checking Interval'),
}), value: settingsProvider.updateInterval,
), items: updateIntervals.map((e) {
int displayNum = (e < 60
? e
: e < 1440
? e / 60
: e / 1440)
.round();
var displayUnit = (e < 60
? 'Minute'
: e < 1440
? 'Hour'
: 'Day');
String display = e == 0
? 'Never - Manual Only'
: '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
return DropdownMenuItem(value: e, child: Text(display));
}).toList(),
onChanged: (value) {
if (value != null) {
settingsProvider.updateInterval = value;
}
});
var sourceSpecificFields = sourceProvider.sources.map((e) {
if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
return GeneratedForm(
items: e.additionalSourceSpecificSettingFormItems
.map((e) => [e])
.toList(),
onValueChanges: (values, valid, isBuilding) {
if (valid) {
for (var i = 0; i < values.length; i++) {
settingsProvider.setSettingString(
e.additionalSourceSpecificSettingFormItems[i].id,
values[i]);
}
}
},
defaultValues: e.additionalSourceSpecificSettingFormItems.map((e) {
return settingsProvider.getSettingString(e.id) ?? '';
}).toList());
} else {
return Container();
}
});
const height16 = SizedBox(
height: 16,
);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Settings'),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: settingsProvider.prefs == null
? const SizedBox()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Appearance',
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
themeDropdown,
height16,
colourDropdown,
height16,
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: sortDropdown),
const SizedBox(
width: 16,
),
Expanded(child: orderDropdown),
],
),
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Show Source Webpage in App View'),
Switch(
value: settingsProvider.showAppWebpage,
onChanged: (value) {
settingsProvider.showAppWebpage = value;
})
],
),
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Pin Updates to Top of Apps View'),
Switch(
value: settingsProvider.pinUpdates,
onChanged: (value) {
settingsProvider.pinUpdates = value;
})
],
),
const Divider(
height: 16,
),
height16,
Text(
'Updates',
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
intervalDropdown,
const Divider(
height: 48,
),
Text(
'Source-Specific',
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: const Text(
'App Source',
),
),
TextButton.icon(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact(); context.read<LogsProvider>().get().then((logs) {
launchUrlString(settingsProvider.sourceUrl, if (logs.isEmpty) {
mode: LaunchMode.externalApplication); showError(ObtainiumError('No Logs'), 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: const Text('App Logs')),
'Source', ],
style: Theme.of(context).textTheme.caption, ),
), 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 : 'No Logs';
});
});
}
if (logString == null) {
filterLogs(days.first);
}
return AlertDialog(
scrollable: true,
title: const Text('Obtainium App Logs'),
content: Column(
children: [
DropdownButtonFormField(
value: days.first,
items: days
.map((e) => DropdownMenuItem(
value: e,
child: Text('$e Day${e == 1 ? '' : 's'}'),
))
.toList(),
onChanged: (d) {
filterLogs(d ?? 7);
}),
const SizedBox(
height: 32,
),
Text(logString ?? '')
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Close')),
TextButton(
onPressed: () {
Share.share(logString ?? '', subject: 'Obtainium App Logs');
Navigator.of(context).pop();
},
child: const Text('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:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.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,105 +42,335 @@ 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({bool bg = false}) { AppsProvider({this.forBGTask = false}) {
// Subscribe to changes in the app foreground status // Many setup tasks should only be done in the foreground isolate
foregroundStream = FGBGEvents.stream.asBroadcastStream(); if (!forBGTask) {
foregroundSubscription = foregroundStream.listen((event) async { // Subscribe to changes in the app foreground status
isForeground = event == FGBGType.foreground; foregroundStream = FGBGEvents.stream.asBroadcastStream();
if (isForeground) await loadApps(); foregroundSubscription = foregroundStream?.listen((event) async {
}); isForeground = event == FGBGType.foreground;
loadApps(); if (isForeground) await loadApps();
});
() 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 ?? 'Unknown Error';
}
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) 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); int? prevProg;
File downloadedFile =
await sink.close(); await downloadFile(downloadUrl, fileName, (double? progress) {
apps[appId]!.downloadProgress = null; int? prog = progress?.ceil();
notifyListeners(); if (apps[app.id] != null) {
apps[app.id]!.downloadProgress = progress;
if (response.statusCode != 200) { notifyListeners();
downloadFile.deleteSync(); } else if ((prog == 25 || prog == 50 || prog == 75) && prevProg != prog) {
throw response.reasonPhrase ?? 'Unknown Error'; Fluttertoast.showToast(
msg: 'Progress: $prog%', toastLength: Toast.LENGTH_SHORT);
}
prevProg = prog;
});
// Delete older versions of the APK if any
for (var file in downloadedFile.parent.listSync()) {
var fn = file.path.split('/').last;
if (fn.startsWith('${app.id}-') &&
fn.endsWith('.apk') &&
fn != fileName) {
file.delete();
}
} }
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 = {};
for (var id in appIds) {
if (apps[id] == null) {
throw 'App not found';
}
String? apkUrl = apps[id]!.app.apkUrls.last;
if (apps[id]!.app.apkUrls.length > 1) {
apkUrl = await showDialog(
context: context,
builder: (BuildContext ctx) {
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
});
}
if (apkUrl != null) {
appsToInstall.putIfAbsent(id, () => apkUrl!);
}
}
List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries
.map((entry) => downloadApp(entry.value, entry.key)));
if (!isForeground) { if (!isForeground) {
await notificationsProvider.notify(completeInstallationNotification, await notificationsProvider.notify(completeInstallationNotification,
cancelExisting: true); cancelExisting: true);
await FGBGEvents.stream.first == FGBGType.foreground; while (await FGBGEvents.stream.first != FGBGType.foreground) {}
await notificationsProvider.cancel(completeInstallationNotification.id); await notificationsProvider.cancel(completeInstallationNotification.id);
// We need to wait for the App to come to the foreground to install it
// Can't try to call install plugin in a background isolate (may not have worked anyways) because of:
// https://github.com/flutter/flutter/issues/13937
} }
}
// Unfortunately this 'await' does not actually wait for the APK to finish installing Future<bool> canDowngradeApps() async {
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing try {
// This also does not use the 'session-based' installer API, so background/silent updates are impossible 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) {
if (apps[id] == null) {
throw ObtainiumError('App not found');
}
String? apkUrl;
if (!apps[id]!.app.trackOnly) {
apkUrl = await confirmApkUrl(apps[id]!.app, context);
}
if (apkUrl != null) {
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
if (urlInd != apps[id]!.app.preferredApkIndex) {
apps[id]!.app.preferredApkIndex = urlInd;
await saveApps([apps[id]!.app]);
}
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);
} 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) { for (var f in downloadedFiles) {
await InstallPlugin.installApk(f.file.path, 'dev.imranr.obtainium'); bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app);
apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion; if (willBeSilent) {
await saveApp(apps[f.appId]!.app); silentUpdates.add(f);
} else {
regularInstalls.add(f);
}
} }
return downloadedFiles.isNotEmpty; // Move everything to the regular install list (since silent updates don't currently work) - TODO
regularInstalls.addAll(silentUpdates);
// If Obtainium is being installed, it should be the last one
List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) {
DownloadedApk? temp;
items.removeWhere((element) {
bool res =
element.appId == obtainiumId || element.appId == obtainiumTempId;
if (res) {
temp = element;
}
return res;
});
if (temp != null) {
items = [temp!, ...items];
}
return items;
}
silentUpdates = moveObtainiumToStart(silentUpdates);
regularInstalls = moveObtainiumToStart(regularInstalls);
// // Install silent updates (uncomment when it works - TODO)
// for (var u in silentUpdates) {
// await installApk(u, silent: true); // Would need to add silent option
// }
// 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());
}
}
}
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 {
@ -143,93 +382,218 @@ 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;
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
// If the App says is is not installed but installedInfo exists, try to set it to installed as latest version...
// ...if the latestVersion seems to match the version in installedInfo (not guaranteed)
// If that fails, just set it to the actual version string (all we can do at that point)
// Don't save changes, just return the object if changes were made (else null)
// If in a background isolate, return null straight away as the required plugin won't work anyways
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
if (forBGTask) {
return null; // Can't correct in the background isolate
}
var modded = false;
if (installedInfo == null &&
app.installedVersion != null &&
!app.trackOnly) {
app.installedVersion = null;
modded = true;
}
if (installedInfo != null && app.installedVersion == null) {
if (app.latestVersion.characters
.where((p0) => [
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'.'
].contains(p0))
.join('') ==
installedInfo.versionName) {
app.installedVersion = app.latestVersion;
} else {
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 = []; List<App> updates = [];
MultiAppMultiError errors = MultiAppMultiError();
if (!gettingUpdates) { if (!gettingUpdates) {
gettingUpdates = true; gettingUpdates = true;
try {
List<String> appIds = apps.keys.toList(); List<String> appIds = apps.values
for (int i = 0; i < appIds.length; i++) { .where((app) =>
App? newApp = await getUpdate(appIds[i]); app.app.lastUpdateCheck == null ||
if (newApp != null) { ignoreAppsCheckedAfter == null ||
updates.add(newApp); 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;
} }
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;
@ -250,31 +614,52 @@ class AppsProvider with ChangeNotifier {
} }
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: 'App already added'});
} 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();
@ -290,28 +675,41 @@ class _APKPickerState extends State<APKPicker> {
scrollable: true, scrollable: true,
title: const Text('Pick an APK'), title: const Text('Pick an APK'),
content: Column(children: [ content: Column(children: [
Text('${widget.app.name} has more than one package - pick one.'), Text('${widget.app.name} has more than one package:'),
...widget.app.apkUrls.map((u) => ListTile( const SizedBox(height: 16),
title: Text(Uri.parse(u).pathSegments.last), ...widget.app.apkUrls.map(
leading: Radio<String>( (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(
'Note:\nYour device supports the ${widget.archs!.length == 1 ? '\'${widget.archs![0]}\' CPU architecture.' : 'following CPU architectures: ${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: const Text('Cancel')),
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.mediumImpact(); HapticFeedback.selectionClick();
Navigator.of(context).pop(apkUrl); Navigator.of(context).pop(apkUrl);
}, },
child: const Text('Continue')) child: const Text('Continue'))
@ -319,3 +717,39 @@ class _APKPickerState extends State<APKPicker> {
); );
} }
} }
class APKOriginWarningDialog extends StatefulWidget {
const APKOriginWarningDialog(
{super.key, required this.sourceUrl, required this.apkUrl});
final String sourceUrl;
final String apkUrl;
@override
State<APKOriginWarningDialog> createState() => _APKOriginWarningDialogState();
}
class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: const Text('Warning'),
content: Text(
'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
Navigator.of(context).pop(true);
},
child: const Text('Continue'))
],
);
}
}

View File

@ -0,0 +1,109 @@
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('Cleared $res logs (before = $before, after = $after)');
}
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

@ -27,9 +27,27 @@ class UpdateNotification extends ObtainiumNotification {
'Updates Available', 'Updates Available',
'Notifies the user that updates are available for one or more Apps tracked by Obtainium', 'Notifies the user that updates are available for one or more Apps tracked by Obtainium',
Importance.max) { Importance.max) {
message = updates.isEmpty
? "No new updates."
: updates.length == 1
? '${updates[0].name} has an update.'
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.';
}
}
class SilentUpdateNotification extends ObtainiumNotification {
SilentUpdateNotification(List<App> updates)
: super(
3,
'Apps Updated',
'',
'APPS_UPDATED',
'Apps Updated',
'Notifies the user that updates to one or more Apps were applied in the background',
Importance.defaultImportance) {
message = updates.length == 1 message = updates.length == 1
? '${updates[0].name} has an update.' ? '${updates[0].name} was updated to ${updates[0].latestVersion}.'
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.'; : '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} were updated.';
} }
} }
@ -45,6 +63,24 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
Importance.high); Importance.high);
} }
class AppsRemovedNotification extends ObtainiumNotification {
AppsRemovedNotification(List<List<String>> namedReasons)
: super(
6,
'Apps Removed',
'',
'APPS_REMOVED',
'Apps Removed',
'Notifies the user that one or more Apps were removed due to errors while loading them',
Importance.max) {
message = '';
for (var r in namedReasons) {
message += '${r[0]} was removed due to this error: ${r[1]}. \n';
}
message = message.trim();
}
}
final completeInstallationNotification = ObtainiumNotification( final completeInstallationNotification = ObtainiumNotification(
1, 1,
'Complete App Installation', 'Complete App Installation',

View File

@ -2,13 +2,31 @@
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 }
enum SortColumnSettings { added, nameAuthor, authorName }
enum SortOrderSettings { ascending, descending }
const maxAPIRateLimitMinutes = 30;
const minUpdateIntervalMinutes = maxAPIRateLimitMinutes + 30;
const maxUpdateIntervalMinutes = 4320;
List<int> updateIntervals = [15, 30, 60, 120, 180, 360, 720, 1440, 4320, 0]
.where((element) =>
(element >= minUpdateIntervalMinutes &&
element <= maxUpdateIntervalMinutes) ||
element == 0)
.toList();
class SettingsProvider with ChangeNotifier { class SettingsProvider with ChangeNotifier {
SharedPreferences? prefs; SharedPreferences? prefs;
@ -41,11 +59,41 @@ 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) {
prefs?.setInt('updateInterval', min < 15 ? 15 : min); prefs?.setInt('updateInterval', (min < 15 && min != 0) ? 15 : min);
notifyListeners();
}
SortColumnSettings get sortColumn {
return SortColumnSettings.values[
prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index];
}
set sortColumn(SortColumnSettings s) {
prefs?.setInt('sortColumn', s.index);
notifyListeners();
}
SortOrderSettings get sortOrder {
return SortOrderSettings.values[
prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index];
}
set sortOrder(SortOrderSettings s) {
prefs?.setInt('sortOrder', s.index);
notifyListeners(); notifyListeners();
} }
@ -69,4 +117,31 @@ class SettingsProvider with ChangeNotifier {
} }
} }
} }
bool get showAppWebpage {
return prefs?.getBool('showAppWebpage') ?? false;
}
set showAppWebpage(bool show) {
prefs?.setBool('showAppWebpage', show);
notifyListeners();
}
bool get pinUpdates {
return prefs?.getBool('pinUpdates') ?? true;
}
set pinUpdates(bool show) {
prefs?.setBool('pinUpdates', show);
notifyListeners();
}
String? getSettingString(String settingId) {
return prefs?.getString(settingId);
}
void setSettingString(String settingId, String value) {
prefs?.setString(settingId, value);
notifyListeners();
}
} }

View File

@ -5,7 +5,17 @@ import 'dart:convert';
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;
@ -29,12 +39,28 @@ class App {
String? installedVersion; String? installedVersion;
late String latestVersion; late String latestVersion;
List<String> apkUrls = []; List<String> apkUrls = [];
App(this.id, this.url, this.author, this.name, this.installedVersion, late int preferredApkIndex;
this.latestVersion, this.apkUrls); late List<String> additionalData;
late DateTime? lastUpdateCheck;
bool pinned = false;
bool trackOnly = 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);
@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(
@ -46,7 +72,20 @@ class App {
? 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
? []
: List<String>.from(jsonDecode(json['apkUrls'])),
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
json['additionalData'] == null
? SourceProvider()
.getSource(json['url'])
.additionalSourceAppSpecificDefaults
: List<String>.from(jsonDecode(json['additionalData'])),
json['lastUpdateCheck'] == null
? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false,
json['trackOnly'] ?? false);
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
@ -56,15 +95,40 @@ class App {
'installedVersion': installedVersion, 'installedVersion': installedVersion,
'latestVersion': latestVersion, 'latestVersion': latestVersion,
'apkUrls': jsonEncode(apkUrls), 'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned,
'trackOnly': trackOnly
}; };
} }
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 = 'Could not find a suitable release';
const String couldNotFindLatestVersion =
'Could not determine latest release version';
String notValidURL(String sourceName) {
return 'Not a valid $sourceName App URL';
}
const String noAPKFound = 'No APK found';
List<String> getLinksFromParsedHTML( List<String> getLinksFromParsedHTML(
Document dom, RegExp hrefPattern, String prependToLinks) => Document dom, RegExp hrefPattern, String prependToLinks) =>
dom dom
@ -76,163 +140,86 @@ 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 'Not a valid URL';
}
return url.substring(0, match.end);
} }
@override Future<APKDetails> getLatestAPKDetails(
Future<APKDetails> getLatestAPKDetails(String standardUrl) async { String standardUrl, List<String> additionalData,
// The GitHub RSS feed does not contain asset download details, so we use web scraping (avoid API due to rate limits) {bool trackOnly = false}) {
Response res = await get(Uri.parse('$standardUrl/releases/latest')); throw NotImplementedError();
if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl);
var parsedHtml = parse(res.body);
var apkUrlList = getLinksFromParsedHTML(
parsedHtml,
RegExp(
'^${escapeRegEx(standardUri.path)}/releases/download/[^/]+/[^/]+\\.apk\$',
caseSensitive: false),
standardUri.origin);
if (apkUrlList.isEmpty) {
throw 'No APK found';
}
String getTag(String url) {
List<String> parts = url.split('/');
return parts[parts.length - 2];
}
String latestTag = getTag(apkUrlList[0]);
String? version = parsedHtml
.querySelector('.octicon-tag')
?.nextElementSibling
?.innerHtml
.trim();
if (version == null) {
throw 'Could not determine latest release version';
}
return APKDetails(version,
apkUrlList.where((element) => getTag(element) == latestTag).toList());
} else {
throw 'Unable to fetch release info';
}
} }
@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: 'Track-Only',
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(
late String host = 'gitlab.com'; res.reasonPhrase ?? 'Error ${res.statusCode.toString()}');
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]*/[^/]*');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
// GitLab provides an RSS feed with all the details we need
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl);
var parsedHtml = parse(res.body);
var entry = parsedHtml.querySelector('entry');
var entryContent =
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrlList = getLinksFromParsedHTML(
entryContent,
RegExp(
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
caseSensitive: false),
standardUri.origin);
if (apkUrlList.isEmpty) {
throw 'No APK found';
}
var entryId = entry?.querySelector('id')?.innerHtml;
var version =
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
if (version == null) {
throw 'Could not determine latest release version';
}
return APKDetails(version, apkUrlList);
} else {
throw 'Unable to fetch release info';
}
}
@override
AppNames getAppNames(String standardUrl) {
// Same as GitHub
return GitHub().getAppNames(standardUrl);
}
} }
class Signal implements AppSource { abstract class MassAppUrlSource {
@override late String name;
late String host = 'signal.org'; late List<String> requiredArgs;
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
@override
String standardizeURL(String url) {
return 'https://$host';
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res =
await get(Uri.parse('https://updates.$host/android/latest.json'));
if (res.statusCode == 200) {
var json = jsonDecode(res.body);
String? apkUrl = json['url'];
if (apkUrl == null) {
throw 'No APK found';
}
String? version = json['versionName'];
if (version == null) {
throw 'Could not determine latest release version';
}
return APKDetails(version, [apkUrl]);
} else {
throw 'Unable to fetch release info';
}
}
@override
AppNames getAppNames(String standardUrl) => AppNames('signal', 'signal');
} }
class SourceProvider { class SourceProvider {
List<AppSource> sources = [GitHub(), GitLab(), Signal()];
// Add more source classes here so they are available via the service // Add more source classes here so they are available via the service
List<AppSource> sources = [
GitHub(),
GitLab(),
FDroid(),
IzzyOnDroid(),
Mullvad(),
Signal(),
SourceForge(),
APKMirror()
];
// Add more mass url source classes here so they are available via the service
List<MassAppUrlSource> massUrlSources = [GitHubStars()];
AppSource getSource(String url) { AppSource getSource(String url) {
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}')) {
@ -241,32 +228,85 @@ 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);
} }
List<String> getSourceHosts() => sources.map((e) => e.host).toList(); String generateTempID(AppNames names, AppSource source) =>
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}';
bool isTempId(String id) {
List<String> parts = id.split('_');
if (parts.length < 3) {
return false;
}
for (int i = 0; i < parts.length - 1; i++) {
if (RegExp('.*[A-Z].*').hasMatch(parts[i])) {
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();
}
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);
}
// Returns errors in [results, errors] instead of throwing them
Future<List<dynamic>> getAppsByURLNaive(List<String> urls,
{List<String> ignoreUrls = const []}) async {
List<App> apps = [];
Map<String, dynamic> errors = {};
for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
try {
var source = getSource(url);
apps.add(await getApp(
source, url, source.additionalSourceAppSpecificDefaults));
} catch (e) {
errors.addAll(<String, dynamic>{url: e});
}
}
return [apps, errors];
}
} }

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:
@ -92,13 +120,27 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.7.8" version: "0.7.8"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "8.0.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "7.0.0"
dynamic_color: dynamic_color:
dependency: "direct main" dependency: "direct main"
description: description:
name: dynamic_color name: dynamic_color
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.5.3" version: "1.5.4"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -120,6 +162,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.4" version: "6.1.4"
file_picker:
dependency: "direct main"
description:
name: file_picker
url: "https://pub.dartlang.org"
source: hosted
version: "5.2.2"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -131,14 +180,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.1"
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:
@ -152,21 +201,28 @@ 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.8.0+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_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -183,14 +239,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:
@ -204,14 +260,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:
@ -219,6 +275,13 @@ 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"
js: js:
dependency: transitive dependency: transitive
description: description:
@ -232,14 +295,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:
@ -253,7 +316,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:
@ -261,6 +324,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.2"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@ -268,6 +338,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:
@ -288,7 +372,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:
@ -316,56 +400,56 @@ 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:
name: path_provider_windows name: path_provider_windows
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.2" version: "2.1.3"
permission_handler: permission_handler:
dependency: "direct main" dependency: "direct main"
description: description:
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:
@ -379,7 +463,14 @@ packages:
name: plugin_platform_interface name: plugin_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.2" version: "2.1.3"
pointycastle:
dependency: transitive
description:
name: pointycastle
url: "https://pub.dartlang.org"
source: hosted
version: "3.6.2"
process: process:
dependency: transitive dependency: transitive
description: description:
@ -393,7 +484,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:
@ -407,7 +512,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.12" version: "2.0.14"
shared_preferences_ios: shared_preferences_ios:
dependency: transitive dependency: transitive
description: description:
@ -435,7 +540,7 @@ packages:
name: shared_preferences_platform_interface name: shared_preferences_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.0" version: "2.1.0"
shared_preferences_web: shared_preferences_web:
dependency: transitive dependency: transitive
description: description:
@ -461,7 +566,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.0+3"
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:
@ -483,6 +602,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:
@ -503,7 +629,7 @@ packages:
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:
@ -517,14 +643,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.17" version: "6.0.22"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
@ -552,7 +678,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:
@ -567,6 +693,13 @@ 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:
@ -587,35 +720,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.9.5" 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.2" 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.3" 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: "2.7.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:
@ -638,5 +764,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.1.0-0.0.pre.1036" 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.1.5+6 # When changing this, update the tag in main() accordingly version: 0.8.1+64 # 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
@ -35,40 +35,47 @@ dependencies:
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2 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.8.0+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.3
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: ^8.0.0
file_picker: ^5.1.0
animations: ^2.0.4
install_plugin_v2: ^1.0.0
share_plus: ^6.0.1
installed_apps: ^1.3.1
package_archive_info: ^0.1.0
android_alarm_manager_plus: ^2.1.0
sqflite: ^2.2.0+3
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
# activated in the `analysis_options.yaml` file located at the root of your # activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint # package. See that file for information about deactivating specific lint
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^2.0.0 flutter_lints: ^2.0.1
flutter_icons: flutter_icons:
android: true android: true
image_path: "assets/icon.png" image_path: "assets/graphics/icon.png"
adaptive_icon_background: "#FFFFFF" adaptive_icon_background: "#FFFFFF"
adaptive_icon_foreground: "assets/icon.png" adaptive_icon_foreground: "assets/graphics/icon.png"
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

View File

@ -13,7 +13,7 @@ import 'package:obtainium/main.dart';
void main() { 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);