Compare commits
178 Commits
v0.1.7-bet
...
v0.8.6-bet
Author | SHA1 | Date | |
---|---|---|---|
a1518480db | |||
fd3ee02e52 | |||
609366675d | |||
fbff498ae1 | |||
bb4e470760 | |||
15183c3a95 | |||
b496a416ff | |||
6ac7ba204f | |||
0951c007d1 | |||
d835beec76 | |||
2654bf12d3 | |||
3951108bc9 | |||
d934ce2e13 | |||
66cc7f059f | |||
098428dac9 | |||
9e7c21b408 | |||
31c2c6b7c1 | |||
f70049aded | |||
60c28bf912 | |||
a6ed1e7c98 | |||
963f51dc53 | |||
17b1f6e5b0 | |||
086b2b949f | |||
9b5b212e96 | |||
6c8f9ebcbf | |||
4d5773bdcc | |||
f81ef6a416 | |||
47324fcb49 | |||
377e0e07bd | |||
b5aae70274 | |||
42475fa42a | |||
d29534ef2e | |||
25953399ac | |||
b04d2fad5c | |||
868ba84c9a | |||
602f0c3bb2 | |||
00721e8ac4 | |||
d19f9101d6 | |||
a4bc278e4c | |||
b04986622b | |||
2059e4fd44 | |||
618a1523cf | |||
ba1cdc2c73 | |||
aa2a25fffe | |||
c8ec67aef3 | |||
9576a99a4e | |||
0202224fa6 | |||
631ffd5c34 | |||
feed7ffc0b | |||
296485de8a | |||
d2f226d442 | |||
cbdb449e35 | |||
3100a3a08c | |||
18951d6461 | |||
0e0a39a40f | |||
55cae0620b | |||
ba6cea3ae6 | |||
4be33374c2 | |||
e2bf834981 | |||
9bd7ddb21b | |||
905a807ee9 | |||
ab57b97875 | |||
5db2c5f0b1 | |||
e158c23cca | |||
208f125e12 | |||
b7ccf3fa49 | |||
c746e89052 | |||
ee758e8470 | |||
68d903e092 | |||
c47b752344 | |||
62a05996cf | |||
1cda941fbe | |||
49cb908d04 | |||
139f44d31d | |||
ed955ac6a2 | |||
f3ead6caf1 | |||
97ab723d04 | |||
ed4a26d348 | |||
bd5f21984e | |||
5037d77b14 | |||
c9711c7734 | |||
76e98feeb7 | |||
03da23f77a | |||
9b99e2b302 | |||
e746ca890a | |||
9c00a7da14 | |||
4df0dd64ad | |||
7cf7ffe0de | |||
b1953435af | |||
fc7d7d11d6 | |||
9ef26b3a4a | |||
27ee6b9e88 | |||
d1a3529036 | |||
a954a627fd | |||
52ce5b19c4 | |||
03f0b6cf05 | |||
5d8d0de8de | |||
07f6d4ad2c | |||
dfbb4e19a5 | |||
f5fda2ca90 | |||
661dc1626c | |||
dde3fc20fb | |||
017b867d8d | |||
1cb1c124eb | |||
fdeb852c7b | |||
67f50ba776 | |||
a0968caa5c | |||
e3e945d13b | |||
61f7f171b1 | |||
de07583161 | |||
49b9a65053 | |||
aebc8aed76 | |||
3958425c22 | |||
0a560871cb | |||
fbe4f0b49e | |||
e2440a38c4 | |||
496a10a444 | |||
b8bb8d1f4b | |||
af033f42cb | |||
e706661062 | |||
1a68b8abe6 | |||
15c0ed04d1 | |||
dd193d62f2 | |||
77e1768f3b | |||
da9e5aed5e | |||
136628c9e6 | |||
a916167be3 | |||
420cf487d4 | |||
12855370b0 | |||
33fed1cb2f | |||
33238b56a9 | |||
428c208de4 | |||
9a4b0301be | |||
f58d26524c | |||
45e5544c5b | |||
0a9373e65a | |||
b65c6e1d41 | |||
22dd8253a9 | |||
18198bbdfe | |||
cf3c86abb8 | |||
570e376742 | |||
32ae5e8175 | |||
cbf5057c17 | |||
2cfe62142a | |||
d03486fc5d | |||
224e435bbb | |||
90fa0e06ce | |||
6c1ad94b4f | |||
7d7986f8bf | |||
3ddf9ea736 | |||
2272f8b4e6 | |||
9514062a3a | |||
da57018b90 | |||
87e31c37aa | |||
cb4dfff1b9 | |||
911b06bfb6 | |||
53513bfdd1 | |||
681092d895 | |||
0f6b6253de | |||
c724b276ab | |||
35369273bd | |||
0b1863a227 | |||
9e21f2d6e6 | |||
6f11f850e0 | |||
5e96b91029 | |||
5fc79af960 | |||
05f5590e7d | |||
50f8caeb47 | |||
f966a9e626 | |||
02a5749ba7 | |||
4ccf7cbc92 | |||
ab4efd85ce | |||
42bba0f64c | |||
294327bde4 | |||
52b97662c6 | |||
f63da4b538 | |||
c30c692d87 | |||
d643d5a474 |
1
.gitignore
vendored
@ -9,6 +9,7 @@
|
|||||||
.history
|
.history
|
||||||
.svn/
|
.svn/
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
*.iml
|
*.iml
|
||||||
|
@ -10,13 +10,14 @@ Currently supported App sources:
|
|||||||
- [GitHub](https://github.com/)
|
- [GitHub](https://github.com/)
|
||||||
- [GitLab](https://gitlab.com/)
|
- [GitLab](https://gitlab.com/)
|
||||||
- [F-Droid](https://f-droid.org/)
|
- [F-Droid](https://f-droid.org/)
|
||||||
|
- [IzzyOnDroid](https://android.izzysoft.de/)
|
||||||
- [Mullvad](https://mullvad.net/en/)
|
- [Mullvad](https://mullvad.net/en/)
|
||||||
- [Signal](https://signal.org/)
|
- [Signal](https://signal.org/)
|
||||||
|
|
||||||
## 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 some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods are either unavailable (e.g. Mullvad), insufficient (e.g. GitHub RSS) or subject to rate limits (e.g. GitHub API).
|
- 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
|
||||||
|
|
||||||
|
@ -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>
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.7 KiB |
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.6 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 918 B After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 6.2 KiB |
5
android/app/src/main/res/xml/file_paths.xml
Normal 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>
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 109 KiB |
Before Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 228 KiB |
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 162 KiB |
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 170 KiB |
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 146 KiB |
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 188 KiB |
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 192 KiB |
216
assets/translations/en.json
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
{
|
||||||
|
"invalidURLForSource": "Not a valid {} App URL",
|
||||||
|
"noReleaseFound": "Could not find a suitable release",
|
||||||
|
"noVersionFound": "Could not determine release version",
|
||||||
|
"urlMatchesNoSource": "URL does not match a known source",
|
||||||
|
"cantInstallOlderVersion": "Cannot install an older version of an App",
|
||||||
|
"appIdMismatch": "Downloaded package ID does not match existing App ID",
|
||||||
|
"functionNotImplemented": "This class has not implemented this function",
|
||||||
|
"placeholder": "Placeholder",
|
||||||
|
"someErrors": "Some Errors Occurred",
|
||||||
|
"unexpectedError": "Unexpected Error",
|
||||||
|
"ok": "Okay",
|
||||||
|
"and": "and",
|
||||||
|
"startedBgUpdateTask": "Started BG update check task",
|
||||||
|
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
|
||||||
|
"startedActualBGUpdateCheck": "Started actual BG update checking",
|
||||||
|
"bgUpdateTaskFinished": "Finished BG update check task",
|
||||||
|
"firstRun": "This is the first ever run of Obtainium",
|
||||||
|
"settingUpdateCheckIntervalTo": "Setting update interval to {}",
|
||||||
|
"githubPATLabel": "GitHub Personal Access Token (Increases Rate Limit)",
|
||||||
|
"githubPATHint": "PAT must be in this format: username:token",
|
||||||
|
"githubPATFormat": "username:token",
|
||||||
|
"githubPATLinkText": "'About GitHub PATs",
|
||||||
|
"includePrereleases": "Include prereleases",
|
||||||
|
"fallbackToOlderReleases": "Fallback to older releases",
|
||||||
|
"filterReleaseTitlesByRegEx": "Filter Release Titles by Regular Expression",
|
||||||
|
"invalidRegEx": "Invalid regular expression",
|
||||||
|
"noDescription": "No description",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"continue": "Continue",
|
||||||
|
"requiredInBrackets": "(Required)",
|
||||||
|
"dropdownNoOptsError": "ERROR: DROPDOWN MUST HAVE AT LEAST ONE OPT",
|
||||||
|
"colour": "Colour",
|
||||||
|
"githubStarredRepos": "GitHub Starred Repos",
|
||||||
|
"uname": "Username",
|
||||||
|
"wrongArgNum": "Wrong number of arguments provided",
|
||||||
|
"xIsTrackOnly": "{} is Track-Only",
|
||||||
|
"source": "Source",
|
||||||
|
"app": "App",
|
||||||
|
"appsFromSourceAreTrackOnly": "Apps from this source are 'Track-Only'.' ",
|
||||||
|
"youPickedTrackOnly": "You have selected the 'Track-Only' option.",
|
||||||
|
"trackOnlyAppDescription": "The App will be tracked for updates, but Obtainium will not be able to download or install it.",
|
||||||
|
"cancelled": "Cancelled",
|
||||||
|
"appAlreadyAdded": "App already added",
|
||||||
|
"addApp": "Add App",
|
||||||
|
"appSourceURL": "App Source URL",
|
||||||
|
"error": "Error",
|
||||||
|
"add": "Add",
|
||||||
|
"searchSomeSourcesLabel": "Search (Some Sources Only)",
|
||||||
|
"search": "Search",
|
||||||
|
"additionalOptsFor": "Additional Options for {}",
|
||||||
|
"supportedSourcesBelow": "Supported Sources:",
|
||||||
|
"trackOnlyInBrackets": "(Track-Only)",
|
||||||
|
"searchableInBrackets": "(Searchable)",
|
||||||
|
"appsString": "Apps",
|
||||||
|
"noApps": "No Apps",
|
||||||
|
"noAppsForFilter": "No Apps for Filter",
|
||||||
|
"byX": "By {}",
|
||||||
|
"percentProgress": "Progress: {}%",
|
||||||
|
"pleaseWait": "Please Wait...",
|
||||||
|
"updateAvailable": "Update Available",
|
||||||
|
"estimateInBracketsShort": "(Est.)",
|
||||||
|
"notInstalled": "Not Installed",
|
||||||
|
"estimateInBrackets": "(Estimate)",
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"deselectN": "Deselect {}",
|
||||||
|
"xWillBeRemovedButRemainInstalled": "{} will be removed from Obtainium but remain installed on device.",
|
||||||
|
"removeSelectedAppsQuestion": "Remove Selected Apps?",
|
||||||
|
"removeSelectedApps": "Remove Selected Apps",
|
||||||
|
"updateX": "Update {}",
|
||||||
|
"installX": "Install {}",
|
||||||
|
"markXTrackOnlyAsUpdated": "Mark {}\n(Track-Only)\nas Updated",
|
||||||
|
"changeX": "Change {}",
|
||||||
|
"installUpdateApps": "Install/Update Apps",
|
||||||
|
"installUpdateSelectedApps": "Install/Update Selected Apps",
|
||||||
|
"onlyAppliesToInstalledAndOutdatedApps": "Only applies to installed but out of date Apps whose install status cannot be automatically detected.",
|
||||||
|
"markXSelectedAppsAsUpdated": "Mark {} Selected Apps as Updated?",
|
||||||
|
"no": "No",
|
||||||
|
"yes": "Yes",
|
||||||
|
"markSelectedAppsUpdated": "Mark Selected Apps as Updated",
|
||||||
|
"pinToTop": "Pin to top",
|
||||||
|
"unpinFromTop": "Unpin from top",
|
||||||
|
"resetInstallStatusForSelectedAppsQuestion": "Reset Install Status for Selected Apps?",
|
||||||
|
"installStatusOfXWillBeResetExplanation": "The install status of any selected Apps will be reset.\n\nThis can help when the App version shown in Obtainium is incorrect due to failed updates or other issues.",
|
||||||
|
"shareSelectedAppURLs": "Share Selected App URLs",
|
||||||
|
"resetInstallStatus": "Reset Install Status",
|
||||||
|
"more": "More",
|
||||||
|
"removeOutdatedFilter": "Remove Out-of-Date App Filter",
|
||||||
|
"showOutdatedOnly": "Show Out-of-Date Apps Only",
|
||||||
|
"filter": "Filter",
|
||||||
|
"filterActive": "Filter *",
|
||||||
|
"filterApps": "Filter Apps",
|
||||||
|
"appName": "App Name",
|
||||||
|
"author": "Author",
|
||||||
|
"upToDateApps": "Up to Date Apps",
|
||||||
|
"nonInstalledApps": "Non-Installed Apps",
|
||||||
|
"importExport": "Import/Export",
|
||||||
|
"settings": "Settings",
|
||||||
|
"exportedTo": "Exported to {}",
|
||||||
|
"obtainiumExport": "Obtainium Export",
|
||||||
|
"invalidInput": "Invalid input",
|
||||||
|
"importedX": "Imported {}",
|
||||||
|
"obtainiumImport": "Obtainium Import",
|
||||||
|
"importFromURLList": "Import from URL List",
|
||||||
|
"searchQuery": "Search Query",
|
||||||
|
"appURLList": "App URL List",
|
||||||
|
"line": "Line",
|
||||||
|
"searchX": "Search {}",
|
||||||
|
"noResults": "No results found",
|
||||||
|
"importX": "Import {}",
|
||||||
|
"importedAppsIdDisclaimer": "Imported Apps may incorrectly show as \"Not Installed\".\nTo fix this, re-install them through Obtainium.\nThis should not affect App data.\n\nOnly affects URL and third-party import methods.",
|
||||||
|
"importErrors": "Import Errors",
|
||||||
|
"importedXOfYApps": "{} of {} Apps imported.",
|
||||||
|
"followingURLsHadErrors": "The following URLs had errors:",
|
||||||
|
"okay": "Okay",
|
||||||
|
"selectURL": "Select URL",
|
||||||
|
"selectURLs": "Select URLs",
|
||||||
|
"pick": "Pick",
|
||||||
|
"theme": "Theme",
|
||||||
|
"dark": "Dark",
|
||||||
|
"light": "Light",
|
||||||
|
"followSystem": "Follow System",
|
||||||
|
"obtainium": "Obtainium",
|
||||||
|
"materialYou": "Material You",
|
||||||
|
"appSortBy": "App Sort By",
|
||||||
|
"authorName": "Author/Name",
|
||||||
|
"nameAuthor": "Name/Author",
|
||||||
|
"asAdded": "As Added",
|
||||||
|
"appSortOrder": "App Sort Order",
|
||||||
|
"ascending": "Ascending",
|
||||||
|
"descending": "Descending",
|
||||||
|
"bgUpdateCheckInterval": "Background Update Checking Interval",
|
||||||
|
"neverManualOnly": "Never - Manual Only",
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"showWebInAppView": "Show Source Webpage in App View",
|
||||||
|
"pinUpdates": "Pin Updates to Top of Apps View",
|
||||||
|
"updates": "Updated",
|
||||||
|
"sourceSpecific": "Source-Specific",
|
||||||
|
"appSource": "App Source",
|
||||||
|
"noLogs": "No Logs",
|
||||||
|
"appLogs": "App Logs",
|
||||||
|
"close": "Close",
|
||||||
|
"share": "Share",
|
||||||
|
"appNotFound": "App not found",
|
||||||
|
"obtainiumExportHyphenatedLowercase": "obtainium-export",
|
||||||
|
"pickAnAPK": "Pick an APK",
|
||||||
|
"appHasMoreThanOnePackage": "{} has more than one package:",
|
||||||
|
"deviceSupportsXArch": "Your device supports the {} CPU architecture.",
|
||||||
|
"deviceSupportsFollowingArchs": "Your device supports the following CPU architectures:",
|
||||||
|
"warning": "Warning",
|
||||||
|
"sourceIsXButPackageFromYPrompt": "The App source is '{}' but the release package comes from '{}'. Continue?",
|
||||||
|
"updatesAvailable": "Updates Available",
|
||||||
|
"updatesAvailableNotifDescription": "Notifies the user that updates are available for one or more Apps tracked by Obtainium",
|
||||||
|
"noNewUpdates": "No new updates.",
|
||||||
|
"xHasAnUpdate": "{} has an update.",
|
||||||
|
"appsUpdated": "Apps Updated",
|
||||||
|
"appsUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were applied in the background",
|
||||||
|
"xWasUpdatedToY": "{} was updated to {}.",
|
||||||
|
"errorCheckingUpdates": "Error Checking for Updates",
|
||||||
|
"errorCheckingUpdatesNotifDescription": "A notification that shows when background update checking fails",
|
||||||
|
"appsRemoved": "Apps Removed",
|
||||||
|
"appsRemovedNotifDescription": "Notifies the user that one or more Apps were removed due to errors while loading them",
|
||||||
|
"xWasRemovedDueToErrorY": "{} was removed due to this error: {}",
|
||||||
|
"completeAppInstallation": "Complete App Installation",
|
||||||
|
"obtainiumMustBeOpenToInstallApps": "Obtainium must be open to install Apps",
|
||||||
|
"completeAppInstallationNotifDescription": "Asks the user to return to Obtainium to finish installing an App",
|
||||||
|
"checkingForUpdates": "Checking for Updates",
|
||||||
|
"checkingForUpdatesNotifDescription": "Transient notification that appears when checking for updates",
|
||||||
|
"pleaseAllowInstallPerm": "Please allow Obtainium to install Apps",
|
||||||
|
"trackOnly": "Track-Only",
|
||||||
|
"errorWithHttpStatusCode": "Error {}",
|
||||||
|
"tooManyRequestsTryAgainInMinutes": {
|
||||||
|
"one": "Too many requests (rate limited) - try again in {} minute",
|
||||||
|
"other": "Too many requests (rate limited) - try again in {} minutes"
|
||||||
|
},
|
||||||
|
"bgUpdateGotErrorRetryInMinutes": {
|
||||||
|
"one": "BG update checking encountered a {}, will schedule a retry check in {} minute",
|
||||||
|
"other": "BG update checking encountered a {}, will schedule a retry check in {} minutes"
|
||||||
|
},
|
||||||
|
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
|
||||||
|
"one": "BG update checking found {} update - will notify user if needed",
|
||||||
|
"other": "BG update checking found {} updates - will notify user if needed"
|
||||||
|
},
|
||||||
|
"apps": {
|
||||||
|
"one": "{} App",
|
||||||
|
"other": "{} Apps"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"one": "{} URL",
|
||||||
|
"other": "{} URLs"
|
||||||
|
},
|
||||||
|
"minute": {
|
||||||
|
"one": "{} Minute",
|
||||||
|
"other": "{} Minutes"
|
||||||
|
},
|
||||||
|
"hour": {
|
||||||
|
"one": "{} Hour",
|
||||||
|
"other": "{} Hours"
|
||||||
|
},
|
||||||
|
"day": {
|
||||||
|
"one": "{} Day",
|
||||||
|
"other": "{} Days"
|
||||||
|
},
|
||||||
|
"clearedNLogsBeforeXAfterY": {
|
||||||
|
"one": "Cleared {n} log (before = {before}, after = {after})",
|
||||||
|
"other": "Cleared {n} logs (before = {before}, after = {after})"
|
||||||
|
},
|
||||||
|
"xAndNMoreUpdatesAvailable": {
|
||||||
|
"one": "{} and {} more app have updated.",
|
||||||
|
"other": "{} and {} more apps have updates."
|
||||||
|
},
|
||||||
|
"xAndNMoreUpdatesInstalled": {
|
||||||
|
"one": "{} and {} more app were updated.",
|
||||||
|
"other": "{} and {} more apps were updated."
|
||||||
|
}
|
||||||
|
}
|
58
lib/app_sources/apkmirror.dart
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
71
lib/app_sources/fdroid.dart
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class FDroid extends AppSource {
|
||||||
|
FDroid() {
|
||||||
|
host = 'f-droid.org';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegExB =
|
||||||
|
RegExp('^https?://$host/+[^/]+/+packages/+[^/]+');
|
||||||
|
RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase());
|
||||||
|
if (match != null) {
|
||||||
|
url = 'https://$host/packages/${Uri.parse(url).pathSegments.last}';
|
||||||
|
}
|
||||||
|
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
|
||||||
|
match = standardUrlRegExA.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw InvalidURLError(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? tryInferringAppId(String standardUrl) {
|
||||||
|
return Uri.parse(standardUrl).pathSegments.last;
|
||||||
|
}
|
||||||
|
|
||||||
|
APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||||
|
Response res, String apkUrlPrefix) {
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
List<dynamic> releases = jsonDecode(res.body)['packages'] ?? [];
|
||||||
|
if (releases.isEmpty) {
|
||||||
|
throw NoReleasesError();
|
||||||
|
}
|
||||||
|
String? latestVersion = releases[0]['versionName'];
|
||||||
|
if (latestVersion == null) {
|
||||||
|
throw NoVersionError();
|
||||||
|
}
|
||||||
|
List<String> apkUrls = releases
|
||||||
|
.where((element) => element['versionName'] == latestVersion)
|
||||||
|
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
|
||||||
|
.toList();
|
||||||
|
return APKDetails(latestVersion, apkUrls);
|
||||||
|
} else {
|
||||||
|
throw NoReleasesError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData,
|
||||||
|
{bool trackOnly = false}) async {
|
||||||
|
String? appId = tryInferringAppId(standardUrl);
|
||||||
|
return getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||||
|
await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')),
|
||||||
|
'https://f-droid.org/repo/$appId');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) {
|
||||||
|
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
||||||
|
}
|
||||||
|
}
|
207
lib/app_sources/github.dart
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
|
class GitHub extends AppSource {
|
||||||
|
GitHub() {
|
||||||
|
host = 'github.com';
|
||||||
|
|
||||||
|
additionalSourceAppSpecificDefaults = ['true', 'true', ''];
|
||||||
|
|
||||||
|
additionalSourceSpecificSettingFormItems = [
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('githubPATLabel'),
|
||||||
|
id: 'github-creds',
|
||||||
|
required: false,
|
||||||
|
additionalValidators: [
|
||||||
|
(value) {
|
||||||
|
if (value != null && value.trim().isNotEmpty) {
|
||||||
|
if (value
|
||||||
|
.split(':')
|
||||||
|
.where((element) => element.trim().isNotEmpty)
|
||||||
|
.length !=
|
||||||
|
2) {
|
||||||
|
return tr('githubPATHint');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
],
|
||||||
|
hint: tr('githubPATFormat'),
|
||||||
|
belowWidgets: [
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
launchUrlString(
|
||||||
|
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
tr('githubPATLinkText'),
|
||||||
|
style: const TextStyle(
|
||||||
|
decoration: TextDecoration.underline, fontSize: 12),
|
||||||
|
))
|
||||||
|
])
|
||||||
|
];
|
||||||
|
|
||||||
|
additionalSourceAppSpecificFormItems = [
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('includePrereleases'), type: FormItemType.bool)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('fallbackToOlderReleases'), type: FormItemType.bool)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('filterReleaseTitlesByRegEx'),
|
||||||
|
type: FormItemType.string,
|
||||||
|
required: false,
|
||||||
|
additionalValidators: [
|
||||||
|
(value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
RegExp(value);
|
||||||
|
} catch (e) {
|
||||||
|
return tr('invalidRegEx');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
])
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
canSearch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||||
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw InvalidURLError(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> getCredentialPrefixIfAny() async {
|
||||||
|
SettingsProvider settingsProvider = SettingsProvider();
|
||||||
|
await settingsProvider.initializeSettings();
|
||||||
|
String? creds = settingsProvider
|
||||||
|
.getSettingString(additionalSourceSpecificSettingFormItems[0].id);
|
||||||
|
return creds != null && creds.isNotEmpty ? '$creds@' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||||
|
'$standardUrl/releases';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData,
|
||||||
|
{bool trackOnly = false}) async {
|
||||||
|
var includePrereleases =
|
||||||
|
additionalData.isNotEmpty && additionalData[0] == 'true';
|
||||||
|
var fallbackToOlderReleases =
|
||||||
|
additionalData.length >= 2 && additionalData[1] == 'true';
|
||||||
|
var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty
|
||||||
|
? additionalData[2]
|
||||||
|
: null;
|
||||||
|
Response res = await get(Uri.parse(
|
||||||
|
'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var releases = jsonDecode(res.body) as List<dynamic>;
|
||||||
|
|
||||||
|
List<String> getReleaseAPKUrls(dynamic release) =>
|
||||||
|
(release['assets'] as List<dynamic>?)
|
||||||
|
?.map((e) {
|
||||||
|
return e['browser_download_url'] != null
|
||||||
|
? e['browser_download_url'] as String
|
||||||
|
: '';
|
||||||
|
})
|
||||||
|
.where((element) => element.toLowerCase().endsWith('.apk'))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
dynamic targetRelease;
|
||||||
|
|
||||||
|
for (int i = 0; i < releases.length; i++) {
|
||||||
|
if (!fallbackToOlderReleases && i > 0) break;
|
||||||
|
if (!includePrereleases && releases[i]['prerelease'] == true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regexFilter != null &&
|
||||||
|
!RegExp(regexFilter)
|
||||||
|
.hasMatch((releases[i]['name'] as String).trim())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var apkUrls = getReleaseAPKUrls(releases[i]);
|
||||||
|
if (apkUrls.isEmpty && !trackOnly) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
targetRelease = releases[i];
|
||||||
|
targetRelease['apkUrls'] = apkUrls;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (targetRelease == null) {
|
||||||
|
throw NoReleasesError();
|
||||||
|
}
|
||||||
|
String? version = targetRelease['tag_name'];
|
||||||
|
if (version == null) {
|
||||||
|
throw NoVersionError();
|
||||||
|
}
|
||||||
|
return APKDetails(version, targetRelease['apkUrls'] as List<String>);
|
||||||
|
} else {
|
||||||
|
rateLimitErrorCheck(res);
|
||||||
|
throw getObtainiumHttpError(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) {
|
||||||
|
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
||||||
|
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
||||||
|
return AppNames(names[0], names[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> search(String query) async {
|
||||||
|
Response res = await get(Uri.parse(
|
||||||
|
'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
Map<String, String> urlsWithDescriptions = {};
|
||||||
|
for (var e in (jsonDecode(res.body)['items'] as List<dynamic>)) {
|
||||||
|
urlsWithDescriptions.addAll({
|
||||||
|
e['html_url'] as String: e['description'] != null
|
||||||
|
? e['description'] as String
|
||||||
|
: tr('noDescription')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return urlsWithDescriptions;
|
||||||
|
} else {
|
||||||
|
rateLimitErrorCheck(res);
|
||||||
|
throw getObtainiumHttpError(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimitErrorCheck(Response res) {
|
||||||
|
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||||
|
throw RateLimitError(
|
||||||
|
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||||
|
60000000)
|
||||||
|
.round());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
70
lib/app_sources/gitlab.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
44
lib/app_sources/izzyondroid.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
51
lib/app_sources/mullvad.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
41
lib/app_sources/signal.dart
Normal 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');
|
||||||
|
}
|
64
lib/app_sources/sourceforge.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
29
lib/components/custom_app_bar.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
229
lib/components/generated_form.dart
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum FormItemType { string, bool }
|
||||||
|
|
||||||
|
typedef OnValueChanges = void Function(
|
||||||
|
List<String> values, bool valid, bool isBuilding);
|
||||||
|
|
||||||
|
class GeneratedFormItem {
|
||||||
|
late String key;
|
||||||
|
late String label;
|
||||||
|
late FormItemType type;
|
||||||
|
late bool required;
|
||||||
|
late int max;
|
||||||
|
late List<String? Function(String? value)> additionalValidators;
|
||||||
|
late String id;
|
||||||
|
late List<Widget> belowWidgets;
|
||||||
|
late String? hint;
|
||||||
|
late List<String>? opts;
|
||||||
|
|
||||||
|
GeneratedFormItem(
|
||||||
|
{this.label = 'Input',
|
||||||
|
this.type = FormItemType.string,
|
||||||
|
this.required = true,
|
||||||
|
this.max = 1,
|
||||||
|
this.additionalValidators = const [],
|
||||||
|
this.id = 'input',
|
||||||
|
this.belowWidgets = const [],
|
||||||
|
this.hint,
|
||||||
|
this.opts,
|
||||||
|
this.key = 'default'});
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeneratedForm extends StatefulWidget {
|
||||||
|
const GeneratedForm(
|
||||||
|
{super.key,
|
||||||
|
required this.items,
|
||||||
|
required this.onValueChanges,
|
||||||
|
required this.defaultValues});
|
||||||
|
|
||||||
|
final List<List<GeneratedFormItem>> items;
|
||||||
|
final OnValueChanges onValueChanges;
|
||||||
|
final List<String> defaultValues;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GeneratedForm> createState() => _GeneratedFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GeneratedFormState extends State<GeneratedForm> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
late List<List<String>> values;
|
||||||
|
late List<List<Widget>> formInputs;
|
||||||
|
List<List<Widget>> rows = [];
|
||||||
|
|
||||||
|
// If any value changes, call this to update the parent with value and validity
|
||||||
|
void someValueChanged({bool isBuilding = false}) {
|
||||||
|
List<String> returnValues = [];
|
||||||
|
var valid = true;
|
||||||
|
for (int r = 0; r < values.length; r++) {
|
||||||
|
for (int i = 0; i < values[r].length; i++) {
|
||||||
|
returnValues.add(values[r][i]);
|
||||||
|
if (formInputs[r][i] is TextFormField) {
|
||||||
|
valid = valid &&
|
||||||
|
((formInputs[r][i].key as GlobalKey<FormFieldState>)
|
||||||
|
.currentState
|
||||||
|
?.isValid ??
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
widget.onValueChanges(returnValues, valid, isBuilding);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// Initialize form values as all empty
|
||||||
|
int j = 0;
|
||||||
|
values = widget.items
|
||||||
|
.map((row) => row.map((e) {
|
||||||
|
return j < widget.defaultValues.length
|
||||||
|
? widget.defaultValues[j++]
|
||||||
|
: e.opts != null
|
||||||
|
? e.opts!.first
|
||||||
|
: '';
|
||||||
|
}).toList())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Dynamically create form inputs
|
||||||
|
formInputs = widget.items.asMap().entries.map((row) {
|
||||||
|
return row.value.asMap().entries.map((e) {
|
||||||
|
if (e.value.type == FormItemType.string && e.value.opts == null) {
|
||||||
|
final formFieldKey = GlobalKey<FormFieldState>();
|
||||||
|
return TextFormField(
|
||||||
|
key: formFieldKey,
|
||||||
|
initialValue: values[row.key][e.key],
|
||||||
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
values[row.key][e.key] = value;
|
||||||
|
someValueChanged();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
helperText: e.value.label + (e.value.required ? ' *' : ''),
|
||||||
|
hintText: e.value.hint),
|
||||||
|
minLines: e.value.max <= 1 ? null : e.value.max,
|
||||||
|
maxLines: e.value.max <= 1 ? 1 : e.value.max,
|
||||||
|
validator: (value) {
|
||||||
|
if (e.value.required && (value == null || value.trim().isEmpty)) {
|
||||||
|
return '${e.value.label} ${tr('requiredInBrackets')}';
|
||||||
|
}
|
||||||
|
for (var validator in e.value.additionalValidators) {
|
||||||
|
String? result = validator(value);
|
||||||
|
if (result != null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (e.value.type == FormItemType.string &&
|
||||||
|
e.value.opts != null) {
|
||||||
|
if (e.value.opts!.isEmpty) {
|
||||||
|
return Text(tr('dropdownNoOptsError'));
|
||||||
|
}
|
||||||
|
return DropdownButtonFormField(
|
||||||
|
decoration: InputDecoration(labelText: tr('colour')),
|
||||||
|
value: values[row.key][e.key],
|
||||||
|
items: e.value.opts!
|
||||||
|
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
values[row.key][e.key] = value ?? e.value.opts!.first;
|
||||||
|
someValueChanged();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return Container(); // Some input types added in build
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
}).toList();
|
||||||
|
someValueChanged(isBuilding: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
for (var r = 0; r < formInputs.length; r++) {
|
||||||
|
for (var e = 0; e < formInputs[r].length; e++) {
|
||||||
|
if (widget.items[r][e].type == FormItemType.bool) {
|
||||||
|
formInputs[r][e] = Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(widget.items[r][e].label),
|
||||||
|
Switch(
|
||||||
|
value: values[r][e] == 'true',
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
values[r][e] = value ? 'true' : '';
|
||||||
|
someValueChanged();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.clear();
|
||||||
|
formInputs.asMap().entries.forEach((rowInputs) {
|
||||||
|
if (rowInputs.key > 0) {
|
||||||
|
rows.add([
|
||||||
|
SizedBox(
|
||||||
|
height: widget.items[rowInputs.key][0].type == FormItemType.bool &&
|
||||||
|
widget.items[rowInputs.key - 1][0].type ==
|
||||||
|
FormItemType.string
|
||||||
|
? 25
|
||||||
|
: 8,
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
List<Widget> rowItems = [];
|
||||||
|
rowInputs.value.asMap().entries.forEach((rowInput) {
|
||||||
|
if (rowInput.key > 0) {
|
||||||
|
rowItems.add(const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
rowItems.add(Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
rowInput.value,
|
||||||
|
...widget.items[rowInputs.key][rowInput.key].belowWidgets
|
||||||
|
])));
|
||||||
|
});
|
||||||
|
rows.add(rowItems);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
...rows.map((row) => Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [...row.map((e) => e)],
|
||||||
|
))
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? findGeneratedFormValueByKey(
|
||||||
|
List<GeneratedFormItem> items, List<String> values, String key) {
|
||||||
|
var foundIndex = -1;
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].key == key) {
|
||||||
|
foundIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundIndex >= 0 && foundIndex < values.length) {
|
||||||
|
return values[foundIndex];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
82
lib/components/generated_form_modal.dart
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
|
||||||
|
class GeneratedFormModal extends StatefulWidget {
|
||||||
|
const GeneratedFormModal(
|
||||||
|
{super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.items,
|
||||||
|
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: Text(tr('cancel'))),
|
||||||
|
TextButton(
|
||||||
|
onPressed: !valid
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
if (valid) {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
Navigator.of(context).pop(values);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(tr('continue')))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
123
lib/custom_errors.dart
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:obtainium/providers/logs_provider.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class ObtainiumError {
|
||||||
|
late String message;
|
||||||
|
bool unexpected;
|
||||||
|
ObtainiumError(this.message, {this.unexpected = false});
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RateLimitError {
|
||||||
|
late int remainingMinutes;
|
||||||
|
RateLimitError(this.remainingMinutes);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
plural('tooManyRequestsTryAgainInMinutes', remainingMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvalidURLError extends ObtainiumError {
|
||||||
|
InvalidURLError(String sourceName)
|
||||||
|
: super(tr('invalidURLForSource', args: [sourceName]));
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoReleasesError extends ObtainiumError {
|
||||||
|
NoReleasesError() : super(tr('noReleaseFound'));
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoAPKError extends ObtainiumError {
|
||||||
|
NoAPKError() : super(tr('noReleaseFound'));
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoVersionError extends ObtainiumError {
|
||||||
|
NoVersionError() : super(tr('noVersionFound'));
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnsupportedURLError extends ObtainiumError {
|
||||||
|
UnsupportedURLError() : super(tr('urlMatchesNoSource'));
|
||||||
|
}
|
||||||
|
|
||||||
|
class DowngradeError extends ObtainiumError {
|
||||||
|
DowngradeError() : super(tr('cantInstallOlderVersion'));
|
||||||
|
}
|
||||||
|
|
||||||
|
class IDChangedError extends ObtainiumError {
|
||||||
|
IDChangedError() : super(tr('appIdMismatch'));
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotImplementedError extends ObtainiumError {
|
||||||
|
NotImplementedError() : super(tr('functionNotImplemented'));
|
||||||
|
}
|
||||||
|
|
||||||
|
class MultiAppMultiError extends ObtainiumError {
|
||||||
|
Map<String, List<String>> content = {};
|
||||||
|
|
||||||
|
MultiAppMultiError() : super(tr('placeholder'), unexpected: true);
|
||||||
|
|
||||||
|
add(String appId, String string) {
|
||||||
|
var tempIds = content.remove(string);
|
||||||
|
tempIds ??= [];
|
||||||
|
tempIds.add(appId);
|
||||||
|
content.putIfAbsent(string, () => tempIds!);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
String finalString = '';
|
||||||
|
for (var e in content.keys) {
|
||||||
|
finalString += '$e: ${content[e].toString()}\n\n';
|
||||||
|
}
|
||||||
|
return finalString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(dynamic e, BuildContext context) {
|
||||||
|
Provider.of<LogsProvider>(context, listen: false)
|
||||||
|
.add(e.toString(), level: LogLevels.error);
|
||||||
|
if (e is String || (e is ObtainiumError && !e.unexpected)) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString())),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(e is MultiAppMultiError
|
||||||
|
? tr('someErrors')
|
||||||
|
: tr('unexpectedError')),
|
||||||
|
content: Text(e.toString()),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(null);
|
||||||
|
},
|
||||||
|
child: Text(tr('ok'))),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String list2FriendlyString(List<String> list) {
|
||||||
|
return list.length == 2
|
||||||
|
? '${list[0]} ${tr('and')} ${list[1]}'
|
||||||
|
: list
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((e) =>
|
||||||
|
e.value +
|
||||||
|
(e.key == list.length - 1
|
||||||
|
? ''
|
||||||
|
: e.key == list.length - 2
|
||||||
|
? ', and '
|
||||||
|
: ', '))
|
||||||
|
.join('');
|
||||||
|
}
|
231
lib/main.dart
@ -1,101 +1,219 @@
|
|||||||
|
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';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
// ignore: implementation_imports
|
||||||
|
import 'package:easy_localization/src/easy_localization_controller.dart';
|
||||||
|
// ignore: implementation_imports
|
||||||
|
import 'package:easy_localization/src/localization.dart';
|
||||||
|
|
||||||
|
const String currentVersion = '0.8.6';
|
||||||
const String currentReleaseTag =
|
const String currentReleaseTag =
|
||||||
'v0.1.7-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||||
|
|
||||||
|
const int bgUpdateCheckAlarmId = 666;
|
||||||
|
|
||||||
|
const supportedLocales = [Locale('en')];
|
||||||
|
const fallbackLocale = Locale('en');
|
||||||
|
const localeDir = 'assets/translations';
|
||||||
|
|
||||||
|
Future<void> loadTranslations() async {
|
||||||
|
// See easy_localization/issues/210
|
||||||
|
await EasyLocalizationController.initEasyLocation();
|
||||||
|
final controller = EasyLocalizationController(
|
||||||
|
saveLocale: true,
|
||||||
|
fallbackLocale: fallbackLocale,
|
||||||
|
supportedLocales: supportedLocales,
|
||||||
|
assetLoader: const RootBundleAssetLoader(),
|
||||||
|
useOnlyLangCode: false,
|
||||||
|
useFallbackTranslations: true,
|
||||||
|
path: localeDir,
|
||||||
|
onLoadError: (FlutterError e) {
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await controller.loadTranslations();
|
||||||
|
Localization.load(controller.locale,
|
||||||
|
translations: controller.translations,
|
||||||
|
fallbackTranslations: controller.fallbackTranslations);
|
||||||
|
}
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
void bgTaskCallback() {
|
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
||||||
// Background update checking process
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
Workmanager().executeTask((task, taskName) async {
|
await EasyLocalization.ensureInitialized();
|
||||||
var notificationsProvider = NotificationsProvider();
|
|
||||||
await notificationsProvider.notify(checkingUpdatesNotification);
|
await loadTranslations();
|
||||||
|
|
||||||
|
LogsProvider logs = LogsProvider();
|
||||||
|
logs.add(tr('startedBgUpdateTask'));
|
||||||
|
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
|
||||||
|
await AndroidAlarmManager.initialize();
|
||||||
|
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
|
||||||
|
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
|
||||||
|
: null;
|
||||||
|
logs.add(tr('bgUpdateIgnoreAfterIs', args: [ignoreAfter.toString()]));
|
||||||
|
var notificationsProvider = NotificationsProvider();
|
||||||
|
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||||
|
try {
|
||||||
|
var appsProvider = AppsProvider(forBGTask: true);
|
||||||
|
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||||
|
await appsProvider.loadApps();
|
||||||
|
List<String> existingUpdateIds =
|
||||||
|
appsProvider.findExistingUpdates(installedOnly: true);
|
||||||
|
DateTime nextIgnoreAfter = DateTime.now();
|
||||||
|
String? err;
|
||||||
try {
|
try {
|
||||||
var appsProvider = AppsProvider();
|
logs.add(tr('startedActualBGUpdateCheck'));
|
||||||
await notificationsProvider
|
await appsProvider.checkUpdates(
|
||||||
.cancel(ErrorCheckingUpdatesNotification('').id);
|
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
|
||||||
await appsProvider.loadApps();
|
|
||||||
List<App> updates = await appsProvider.checkUpdates();
|
|
||||||
if (updates.isNotEmpty) {
|
|
||||||
notificationsProvider.notify(UpdateNotification(updates),
|
|
||||||
cancelExisting: true);
|
|
||||||
}
|
|
||||||
return Future.value(true);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notificationsProvider.notify(
|
if (e is RateLimitError || e is SocketException) {
|
||||||
ErrorCheckingUpdatesNotification(e.toString()),
|
var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15;
|
||||||
cancelExisting: true);
|
logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes,
|
||||||
return Future.value(false);
|
args: [e.runtimeType.toString()]));
|
||||||
} finally {
|
AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes),
|
||||||
await notificationsProvider.cancel(checkingUpdatesNotification.id);
|
Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: {
|
||||||
|
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
err = e.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
List<App> newUpdates = appsProvider
|
||||||
|
.findExistingUpdates(installedOnly: true)
|
||||||
|
.where((id) => !existingUpdateIds.contains(id))
|
||||||
|
.map((e) => appsProvider.apps[e]!.app)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// TODO: This silent update code doesn't work yet
|
||||||
|
// List<String> silentlyUpdated = await appsProvider
|
||||||
|
// .downloadAndInstallLatestApp(
|
||||||
|
// [...newUpdates.map((e) => e.id), ...existingUpdateIds], null);
|
||||||
|
// if (silentlyUpdated.isNotEmpty) {
|
||||||
|
// newUpdates = newUpdates
|
||||||
|
// .where((element) => !silentlyUpdated.contains(element.id))
|
||||||
|
// .toList();
|
||||||
|
// notificationsProvider.notify(
|
||||||
|
// SilentUpdateNotification(
|
||||||
|
// silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()),
|
||||||
|
// cancelExisting: true);
|
||||||
|
// }
|
||||||
|
logs.add(
|
||||||
|
plural('bgCheckFoundUpdatesWillNotifyIfNeeded', newUpdates.length));
|
||||||
|
if (newUpdates.isNotEmpty) {
|
||||||
|
notificationsProvider.notify(UpdateNotification(newUpdates));
|
||||||
|
}
|
||||||
|
if (err != null) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notificationsProvider
|
||||||
|
.notify(ErrorCheckingUpdatesNotification(e.toString()));
|
||||||
|
} finally {
|
||||||
|
logs.add(tr('bgUpdateTaskFinished'));
|
||||||
|
await notificationsProvider.cancel(checkingUpdatesNotification.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
await EasyLocalization.ensureInitialized();
|
||||||
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
|
||||||
);
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
||||||
Workmanager().initialize(
|
);
|
||||||
bgTaskCallback,
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
);
|
}
|
||||||
|
await AndroidAlarmManager.initialize();
|
||||||
runApp(MultiProvider(
|
runApp(MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(create: (context) => AppsProvider()),
|
||||||
create: (context) => AppsProvider(
|
|
||||||
shouldLoadApps: true,
|
|
||||||
shouldCheckUpdatesAfterLoad: true,
|
|
||||||
shouldDeleteAPKs: true)),
|
|
||||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||||
Provider(create: (context) => NotificationsProvider())
|
Provider(create: (context) => NotificationsProvider()),
|
||||||
|
Provider(create: (context) => LogsProvider())
|
||||||
],
|
],
|
||||||
child: const MyApp(),
|
child: EasyLocalization(
|
||||||
|
supportedLocales: supportedLocales,
|
||||||
|
path: localeDir,
|
||||||
|
fallbackLocale: fallbackLocale,
|
||||||
|
child: const Obtainium()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultThemeColour = Colors.deepPurple;
|
var defaultThemeColour = Colors.deepPurple;
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class Obtainium extends StatefulWidget {
|
||||||
const MyApp({super.key});
|
const Obtainium({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<Obtainium> createState() => _ObtainiumState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ObtainiumState extends State<Obtainium> {
|
||||||
|
var existingUpdateInterval = -1;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
||||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||||
|
LogsProvider logs = context.read<LogsProvider>();
|
||||||
|
|
||||||
if (settingsProvider.prefs == null) {
|
if (settingsProvider.prefs == null) {
|
||||||
settingsProvider.initializeSettings();
|
settingsProvider.initializeSettings();
|
||||||
} else {
|
} else {
|
||||||
// Register the background update task according to the user's setting
|
|
||||||
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(tr('firstRun'));
|
||||||
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
||||||
Permission.notification.request();
|
Permission.notification.request();
|
||||||
appsProvider.saveApp(App(
|
appsProvider.saveApps([
|
||||||
'imranr98_obtainium_${GitHub().host}',
|
App(
|
||||||
'https://github.com/ImranR98/Obtainium',
|
obtainiumId,
|
||||||
'ImranR98',
|
'https://github.com/ImranR98/Obtainium',
|
||||||
'Obtainium',
|
'ImranR98',
|
||||||
currentReleaseTag,
|
'Obtainium',
|
||||||
currentReleaseTag,
|
currentReleaseTag,
|
||||||
[],
|
currentReleaseTag,
|
||||||
0));
|
[],
|
||||||
|
0,
|
||||||
|
['true'],
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
false)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// Register the background update task according to the user's setting
|
||||||
|
if (existingUpdateInterval != settingsProvider.updateInterval) {
|
||||||
|
if (existingUpdateInterval != -1) {
|
||||||
|
logs.add(tr('settingUpdateCheckIntervalTo',
|
||||||
|
args: [settingsProvider.updateInterval.toString()]));
|
||||||
|
}
|
||||||
|
existingUpdateInterval = settingsProvider.updateInterval;
|
||||||
|
if (existingUpdateInterval == 0) {
|
||||||
|
AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
|
||||||
|
} else {
|
||||||
|
AndroidAlarmManager.periodic(
|
||||||
|
Duration(minutes: existingUpdateInterval),
|
||||||
|
bgUpdateCheckAlarmId,
|
||||||
|
bgUpdateCheck,
|
||||||
|
rescheduleOnReboot: true,
|
||||||
|
wakeup: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,6 +234,9 @@ class MyApp extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Obtainium',
|
title: 'Obtainium',
|
||||||
|
localizationsDelegates: context.localizationDelegates,
|
||||||
|
supportedLocales: context.supportedLocales,
|
||||||
|
locale: context.locale,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: settingsProvider.theme == ThemeSettings.dark
|
colorScheme: settingsProvider.theme == ThemeSettings.dark
|
||||||
|
54
lib/mass_app_sources/githubstars.dart
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/app_sources/github.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class GitHubStars implements MassAppUrlSource {
|
||||||
|
@override
|
||||||
|
late String name = tr('githubStarredRepos');
|
||||||
|
|
||||||
|
@override
|
||||||
|
late List<String> requiredArgs = [tr('uname')];
|
||||||
|
|
||||||
|
Future<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions(
|
||||||
|
String username, int page) async {
|
||||||
|
Response res = await get(Uri.parse(
|
||||||
|
'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
Map<String, String> urlsWithDescriptions = {};
|
||||||
|
for (var e in (jsonDecode(res.body) as List<dynamic>)) {
|
||||||
|
urlsWithDescriptions.addAll({
|
||||||
|
e['html_url'] as String: e['description'] != null
|
||||||
|
? e['description'] as String
|
||||||
|
: tr('noDescription')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return urlsWithDescriptions;
|
||||||
|
} else {
|
||||||
|
var gh = GitHub();
|
||||||
|
gh.rateLimitErrorCheck(res);
|
||||||
|
throw getObtainiumHttpError(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args) async {
|
||||||
|
if (args.length != requiredArgs.length) {
|
||||||
|
throw ObtainiumError(tr('wrongArgNum'));
|
||||||
|
}
|
||||||
|
Map<String, String> urlsWithDescriptions = {};
|
||||||
|
var page = 1;
|
||||||
|
while (true) {
|
||||||
|
var pageUrls =
|
||||||
|
await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++);
|
||||||
|
urlsWithDescriptions.addAll(pageUrls);
|
||||||
|
if (pageUrls.length < 100) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urlsWithDescriptions;
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,12 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/pages/app.dart';
|
import 'package:obtainium/pages/app.dart';
|
||||||
|
import 'package:obtainium/pages/import_export.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
@ -15,120 +21,385 @@ class AddAppPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AddAppPageState extends State<AddAppPage> {
|
class _AddAppPageState extends State<AddAppPage> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
final urlInputController = TextEditingController();
|
|
||||||
bool gettingAppInfo = false;
|
bool gettingAppInfo = false;
|
||||||
|
|
||||||
|
String userInput = '';
|
||||||
|
String searchQuery = '';
|
||||||
|
AppSource? pickedSource;
|
||||||
|
List<String> sourceSpecificAdditionalData = [];
|
||||||
|
bool sourceSpecificDataIsValid = true;
|
||||||
|
List<String> otherAdditionalData = [];
|
||||||
|
bool otherAdditionalDataIsValid = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
SourceProvider sourceProvider = SourceProvider();
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
return Center(
|
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
changeUserInput(String input, bool valid, bool isBuilding) {
|
||||||
child: Column(
|
userInput = input;
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
fn() {
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
var source = valid ? sourceProvider.getSource(userInput) : null;
|
||||||
children: [
|
if (pickedSource != source) {
|
||||||
Container(),
|
pickedSource = source;
|
||||||
Padding(
|
sourceSpecificAdditionalData =
|
||||||
|
source != null ? source.additionalSourceAppSpecificDefaults : [];
|
||||||
|
sourceSpecificDataIsValid = source != null
|
||||||
|
? sourceProvider.ifSourceAppsRequireAdditionalData(source)
|
||||||
|
: true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBuilding) {
|
||||||
|
fn();
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
fn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addApp({bool resetUserInputAfter = false}) async {
|
||||||
|
setState(() {
|
||||||
|
gettingAppInfo = true;
|
||||||
|
});
|
||||||
|
var settingsProvider = context.read<SettingsProvider>();
|
||||||
|
() async {
|
||||||
|
var userPickedTrackOnly = findGeneratedFormValueByKey(
|
||||||
|
pickedSource!.additionalAppSpecificSourceAgnosticFormItems,
|
||||||
|
otherAdditionalData,
|
||||||
|
'trackOnlyFormItemKey') ==
|
||||||
|
'true';
|
||||||
|
var cont = true;
|
||||||
|
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr('xIsTrackOnly', args: [
|
||||||
|
pickedSource!.enforceTrackOnly
|
||||||
|
? tr('source')
|
||||||
|
: tr('app')
|
||||||
|
]),
|
||||||
|
items: const [],
|
||||||
|
defaultValues: const [],
|
||||||
|
message:
|
||||||
|
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
|
||||||
|
);
|
||||||
|
}) ==
|
||||||
|
null) {
|
||||||
|
cont = false;
|
||||||
|
}
|
||||||
|
if (cont) {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
|
||||||
|
App app = await sourceProvider.getApp(
|
||||||
|
pickedSource!, userInput, sourceSpecificAdditionalData,
|
||||||
|
trackOnly: trackOnly);
|
||||||
|
if (!trackOnly) {
|
||||||
|
await settingsProvider.getInstallPermission();
|
||||||
|
}
|
||||||
|
// Only download the APK here if you need to for the package ID
|
||||||
|
if (sourceProvider.isTempId(app.id) && !app.trackOnly) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
var apkUrl = await appsProvider.confirmApkUrl(app, context);
|
||||||
|
if (apkUrl == null) {
|
||||||
|
throw ObtainiumError(tr('cancelled'));
|
||||||
|
}
|
||||||
|
app.preferredApkIndex = app.apkUrls.indexOf(apkUrl);
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
var downloadedApk = await appsProvider.downloadApp(app, context);
|
||||||
|
app.id = downloadedApk.appId;
|
||||||
|
}
|
||||||
|
if (appsProvider.apps.containsKey(app.id)) {
|
||||||
|
throw ObtainiumError(tr('appAlreadyAdded'));
|
||||||
|
}
|
||||||
|
if (app.trackOnly) {
|
||||||
|
app.installedVersion = app.latestVersion;
|
||||||
|
}
|
||||||
|
await appsProvider.saveApps([app]);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
.then((app) {
|
||||||
|
if (app != null) {
|
||||||
|
Navigator.push(context,
|
||||||
|
MaterialPageRoute(builder: (context) => AppPage(appId: app.id)));
|
||||||
|
}
|
||||||
|
}).catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
gettingAppInfo = false;
|
||||||
|
if (resetUserInputAfter) {
|
||||||
|
changeUserInput('', false, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
|
CustomAppBar(title: tr('addApp')),
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
Row(
|
||||||
decoration: const InputDecoration(
|
children: [
|
||||||
hintText: 'https://github.com/Author/Project',
|
Expanded(
|
||||||
helperText: 'Enter the App source URL'),
|
child: GeneratedForm(
|
||||||
controller: urlInputController,
|
items: [
|
||||||
validator: (value) {
|
[
|
||||||
if (value == null ||
|
GeneratedFormItem(
|
||||||
value.isEmpty ||
|
label: tr('appSourceURL'),
|
||||||
Uri.tryParse(value) == null) {
|
additionalValidators: [
|
||||||
return 'Please enter a supported source URL';
|
(value) {
|
||||||
}
|
try {
|
||||||
return null;
|
sourceProvider
|
||||||
},
|
.getSource(value ?? '')
|
||||||
),
|
.standardizeURL(
|
||||||
Padding(
|
preStandardizeUrl(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
value ?? ''));
|
||||||
child: ElevatedButton(
|
} catch (e) {
|
||||||
onPressed: gettingAppInfo
|
return e is String
|
||||||
? null
|
? e
|
||||||
: () {
|
: e is ObtainiumError
|
||||||
HapticFeedback.mediumImpact();
|
? e.toString()
|
||||||
if (_formKey.currentState!.validate()) {
|
: tr('error');
|
||||||
setState(() {
|
}
|
||||||
gettingAppInfo = true;
|
return null;
|
||||||
});
|
}
|
||||||
sourceProvider
|
])
|
||||||
.getApp(urlInputController.value.text)
|
]
|
||||||
.then((app) {
|
],
|
||||||
var appsProvider =
|
onValueChanges: (values, valid, isBuilding) {
|
||||||
context.read<AppsProvider>();
|
changeUserInput(
|
||||||
var settingsProvider =
|
values[0], valid, isBuilding);
|
||||||
context.read<SettingsProvider>();
|
},
|
||||||
if (appsProvider.apps.containsKey(app.id)) {
|
defaultValues: const [])),
|
||||||
throw 'App already added';
|
const SizedBox(
|
||||||
}
|
width: 16,
|
||||||
settingsProvider
|
),
|
||||||
.getInstallPermission()
|
gettingAppInfo
|
||||||
.then((_) {
|
? const CircularProgressIndicator()
|
||||||
appsProvider.saveApp(app).then((_) {
|
: ElevatedButton(
|
||||||
urlInputController.clear();
|
onPressed: gettingAppInfo ||
|
||||||
Navigator.push(
|
pickedSource == null ||
|
||||||
context,
|
(pickedSource!
|
||||||
MaterialPageRoute(
|
.additionalSourceAppSpecificFormItems
|
||||||
builder: (context) =>
|
.isNotEmpty &&
|
||||||
AppPage(appId: app.id)));
|
!sourceSpecificDataIsValid) ||
|
||||||
});
|
(pickedSource!
|
||||||
});
|
.additionalAppSpecificSourceAgnosticDefaults
|
||||||
}).catchError((e) {
|
.isNotEmpty &&
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
!otherAdditionalDataIsValid)
|
||||||
SnackBar(content: Text(e.toString())),
|
? null
|
||||||
);
|
: addApp,
|
||||||
}).whenComplete(() {
|
child: Text(tr('add')))
|
||||||
setState(() {
|
],
|
||||||
gettingAppInfo = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('Add'),
|
|
||||||
),
|
),
|
||||||
),
|
if (sourceProvider.sources
|
||||||
],
|
.where((e) => e.canSearch)
|
||||||
),
|
.isNotEmpty &&
|
||||||
),
|
pickedSource == null &&
|
||||||
Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
userInput.isEmpty)
|
||||||
const Text(
|
const SizedBox(
|
||||||
'Supported Sources:',
|
height: 16,
|
||||||
// style: TextStyle(fontWeight: FontWeight.bold),
|
),
|
||||||
// style: Theme.of(context).textTheme.bodySmall,
|
if (sourceProvider.sources
|
||||||
),
|
.where((e) => e.canSearch)
|
||||||
const SizedBox(
|
.isNotEmpty &&
|
||||||
height: 8,
|
pickedSource == null &&
|
||||||
),
|
userInput.isEmpty)
|
||||||
...sourceProvider
|
Row(
|
||||||
.getSourceHosts()
|
children: [
|
||||||
.map((e) => GestureDetector(
|
Expanded(
|
||||||
onTap: () {
|
child: GeneratedForm(
|
||||||
launchUrlString('https://$e',
|
items: [
|
||||||
mode: LaunchMode.externalApplication);
|
[
|
||||||
},
|
GeneratedFormItem(
|
||||||
child: Text(
|
label: tr('searchSomeSourcesLabel'),
|
||||||
e,
|
required: false),
|
||||||
style: const TextStyle(
|
]
|
||||||
decoration: TextDecoration.underline,
|
],
|
||||||
fontStyle: FontStyle.italic),
|
onValueChanges: (values, valid, isBuilding) {
|
||||||
)))
|
if (values.isNotEmpty && valid) {
|
||||||
.toList()
|
setState(() {
|
||||||
]),
|
searchQuery = values[0].trim();
|
||||||
if (gettingAppInfo)
|
});
|
||||||
const LinearProgressIndicator()
|
}
|
||||||
else
|
},
|
||||||
Container(),
|
defaultValues: const ['']),
|
||||||
],
|
),
|
||||||
)),
|
const SizedBox(
|
||||||
);
|
width: 16,
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: searchQuery.isEmpty || gettingAppInfo
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
Future.wait(sourceProvider.sources
|
||||||
|
.where((e) => e.canSearch)
|
||||||
|
.map((e) =>
|
||||||
|
e.search(searchQuery)))
|
||||||
|
.then((results) async {
|
||||||
|
// Interleave results instead of simple reduce
|
||||||
|
Map<String, String> res = {};
|
||||||
|
var si = 0;
|
||||||
|
var done = false;
|
||||||
|
while (!done) {
|
||||||
|
done = true;
|
||||||
|
for (var r in results) {
|
||||||
|
if (r.length > si) {
|
||||||
|
done = false;
|
||||||
|
res.addEntries(
|
||||||
|
[r.entries.elementAt(si)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
si++;
|
||||||
|
}
|
||||||
|
List<String>? selectedUrls = res
|
||||||
|
.isEmpty
|
||||||
|
? []
|
||||||
|
: await showDialog<List<String>?>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return UrlSelectionModal(
|
||||||
|
urlsWithDescriptions: res,
|
||||||
|
selectedByDefault: false,
|
||||||
|
onlyOneSelectionAllowed:
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (selectedUrls != null &&
|
||||||
|
selectedUrls.isNotEmpty) {
|
||||||
|
changeUserInput(
|
||||||
|
selectedUrls[0], true, true);
|
||||||
|
addApp(resetUserInputAfter: true);
|
||||||
|
}
|
||||||
|
}).catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(tr('search')))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (pickedSource != null &&
|
||||||
|
(pickedSource!.additionalSourceAppSpecificDefaults
|
||||||
|
.isNotEmpty ||
|
||||||
|
pickedSource!
|
||||||
|
.additionalAppSpecificSourceAgnosticFormItems
|
||||||
|
.where((e) => pickedSource!.enforceTrackOnly
|
||||||
|
? e.key != 'trackOnlyFormItemKey'
|
||||||
|
: true)
|
||||||
|
.map((e) => [e])
|
||||||
|
.isNotEmpty))
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Divider(
|
||||||
|
height: 64,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
tr('additionalOptsFor', args: [
|
||||||
|
pickedSource?.runtimeType.toString() ??
|
||||||
|
tr('source')
|
||||||
|
]),
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
Theme.of(context).colorScheme.primary)),
|
||||||
|
const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
if (pickedSource!
|
||||||
|
.additionalSourceAppSpecificFormItems
|
||||||
|
.isNotEmpty)
|
||||||
|
GeneratedForm(
|
||||||
|
items: pickedSource!
|
||||||
|
.additionalSourceAppSpecificFormItems,
|
||||||
|
onValueChanges: (values, valid, isBuilding) {
|
||||||
|
if (isBuilding) {
|
||||||
|
sourceSpecificAdditionalData = values;
|
||||||
|
sourceSpecificDataIsValid = valid;
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
sourceSpecificAdditionalData = values;
|
||||||
|
sourceSpecificDataIsValid = valid;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultValues: pickedSource!
|
||||||
|
.additionalSourceAppSpecificDefaults),
|
||||||
|
if (pickedSource!
|
||||||
|
.additionalAppSpecificSourceAgnosticDefaults
|
||||||
|
.isNotEmpty)
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
|
GeneratedForm(
|
||||||
|
items: pickedSource!
|
||||||
|
.additionalAppSpecificSourceAgnosticFormItems
|
||||||
|
.where((e) => pickedSource!.enforceTrackOnly
|
||||||
|
? e.key != 'trackOnlyFormItemKey'
|
||||||
|
: true)
|
||||||
|
.map((e) => [e])
|
||||||
|
.toList(),
|
||||||
|
onValueChanges: (values, valid, isBuilding) {
|
||||||
|
if (isBuilding) {
|
||||||
|
otherAdditionalData = values;
|
||||||
|
otherAdditionalDataIsValid = valid;
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
otherAdditionalData = values;
|
||||||
|
otherAdditionalDataIsValid = valid;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultValues: pickedSource!
|
||||||
|
.additionalAppSpecificSourceAgnosticDefaults),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
height: 48,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
tr('supportedSourcesBelow'),
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
|
...sourceProvider.sources
|
||||||
|
.map((e) => GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
launchUrlString('https://${e.host}',
|
||||||
|
mode:
|
||||||
|
LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'${e.runtimeType.toString()}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
|
||||||
|
style: const TextStyle(
|
||||||
|
decoration:
|
||||||
|
TextDecoration.underline,
|
||||||
|
fontStyle: FontStyle.italic),
|
||||||
|
)))
|
||||||
|
.toList()
|
||||||
|
])),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
|
])),
|
||||||
|
)
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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'),
|
||||||
),
|
),
|
||||||
])),
|
])),
|
||||||
|
@ -1,82 +1,752 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/pages/app.dart';
|
import 'package:obtainium/pages/app.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class AppsPage extends StatefulWidget {
|
class AppsPage extends StatefulWidget {
|
||||||
const AppsPage({super.key});
|
const AppsPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AppsPage> createState() => _AppsPageState();
|
State<AppsPage> createState() => AppsPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppsPageState extends State<AppsPage> {
|
class AppsPageState extends State<AppsPage> {
|
||||||
|
AppsFilter? filter;
|
||||||
|
var updatesOnlyFilter =
|
||||||
|
AppsFilter(includeUptodate: false, includeNonInstalled: false);
|
||||||
|
Set<App> selectedApps = {};
|
||||||
|
DateTime? refreshingSince;
|
||||||
|
|
||||||
|
clearSelected() {
|
||||||
|
if (selectedApps.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
selectedApps.clear();
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectThese(List<App> apps) {
|
||||||
|
if (selectedApps.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
for (var a in apps) {
|
||||||
|
selectedApps.add(a);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var appsProvider = context.watch<AppsProvider>();
|
var appsProvider = context.watch<AppsProvider>();
|
||||||
var 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')),
|
CustomAppBar(title: tr('appsString')),
|
||||||
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
|
||||||
)
|
? tr('noApps')
|
||||||
: RefreshIndicator(
|
: tr('noAppsForFilter'),
|
||||||
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')
|
String? changesUrl = SourceProvider()
|
||||||
: null),
|
.getSource(sortedApps[index].app.url)
|
||||||
onTap: () {
|
.changeLogPageFromStandardUrl(sortedApps[index].app.url);
|
||||||
Navigator.push(
|
return ListTile(
|
||||||
context,
|
tileColor: sortedApps[index].app.pinned
|
||||||
MaterialPageRoute(
|
? Colors.grey.withOpacity(0.1)
|
||||||
builder: (context) =>
|
: Colors.transparent,
|
||||||
AppPage(appId: e.app.id)),
|
selectedTileColor: Theme.of(context)
|
||||||
);
|
.colorScheme
|
||||||
},
|
.primary
|
||||||
),
|
.withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1),
|
||||||
)
|
selected: selectedApps.contains(sortedApps[index].app),
|
||||||
.toList(),
|
onLongPress: () {
|
||||||
|
toggleAppSelected(sortedApps[index].app);
|
||||||
|
},
|
||||||
|
leading: sortedApps[index].installedInfo != null
|
||||||
|
? Image.memory(
|
||||||
|
sortedApps[index].installedInfo!.icon!,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
title: Text(
|
||||||
|
sortedApps[index].installedInfo?.name ??
|
||||||
|
sortedApps[index].app.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: sortedApps[index].app.pinned
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal),
|
||||||
|
),
|
||||||
|
subtitle: Text(tr('byX', args: [sortedApps[index].app.author]),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: sortedApps[index].app.pinned
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal)),
|
||||||
|
trailing: sortedApps[index].downloadProgress != null
|
||||||
|
? Text(tr('percentProgress', args: [
|
||||||
|
sortedApps[index]
|
||||||
|
.downloadProgress
|
||||||
|
?.toInt()
|
||||||
|
.toString() ??
|
||||||
|
'100'
|
||||||
|
]))
|
||||||
|
: (Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
SingleChildScrollView(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 80,
|
||||||
|
child: Text(
|
||||||
|
'${sortedApps[index].app.installedVersion ?? tr('notInstalled')}${sortedApps[index].app.trackOnly == true ? ' ${tr('estimateInBrackets')}' : ''}',
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
))),
|
||||||
|
sortedApps[index].app.installedVersion != null &&
|
||||||
|
sortedApps[index].app.installedVersion !=
|
||||||
|
sortedApps[index].app.latestVersion
|
||||||
|
? GestureDetector(
|
||||||
|
onTap: changesUrl == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
launchUrlString(changesUrl,
|
||||||
|
mode: LaunchMode
|
||||||
|
.externalApplication);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'${tr('updateAvailable')}${sortedApps[index].app.trackOnly ? ' ${tr('estimateInBracketsShort')}' : ''}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
decoration: changesUrl == null
|
||||||
|
? TextDecoration.none
|
||||||
|
: TextDecoration.underline),
|
||||||
|
))
|
||||||
|
: const SizedBox(),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
onTap: () {
|
||||||
|
if (selectedApps.isNotEmpty) {
|
||||||
|
toggleAppSelected(sortedApps[index].app);
|
||||||
|
} else {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
AppPage(appId: sortedApps[index].app.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, childCount: sortedApps.length))
|
||||||
|
])),
|
||||||
|
persistentFooterButtons: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
selectedApps.isEmpty
|
||||||
|
? selectThese(sortedApps.map((e) => e.app).toList())
|
||||||
|
: clearSelected();
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
selectedApps.isEmpty
|
||||||
|
? Icons.select_all_outlined
|
||||||
|
: Icons.deselect_outlined,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
tooltip: selectedApps.isEmpty
|
||||||
|
? tr('selectAll')
|
||||||
|
: tr('deselectN', args: [selectedApps.length.toString()])),
|
||||||
|
const VerticalDivider(),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
selectedApps.isEmpty
|
||||||
|
? const SizedBox()
|
||||||
|
: IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () {
|
||||||
|
showDialog<List<String>?>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr('removeSelectedAppsQuestion'),
|
||||||
|
items: const [],
|
||||||
|
defaultValues: const [],
|
||||||
|
initValid: true,
|
||||||
|
message: tr(
|
||||||
|
'xWillBeRemovedButRemainInstalled',
|
||||||
|
args: [
|
||||||
|
plural('apps', selectedApps.length)
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
appsProvider.removeApps(
|
||||||
|
selectedApps.map((e) => e.id).toList());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: tr('removeSelectedApps'),
|
||||||
|
icon: const Icon(Icons.delete_outline_outlined),
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: appsProvider.areDownloadsRunning() ||
|
||||||
|
(existingUpdateIdsAllOrSelected.isEmpty &&
|
||||||
|
newInstallIdsAllOrSelected.isEmpty &&
|
||||||
|
trackOnlyUpdateIdsAllOrSelected.isEmpty)
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
List<GeneratedFormItem> formInputs = [];
|
||||||
|
List<String> defaultValues = [];
|
||||||
|
if (existingUpdateIdsAllOrSelected.isNotEmpty) {
|
||||||
|
formInputs.add(GeneratedFormItem(
|
||||||
|
label: tr('updateX', args: [
|
||||||
|
plural('apps',
|
||||||
|
existingUpdateIdsAllOrSelected.length)
|
||||||
|
]),
|
||||||
|
type: FormItemType.bool,
|
||||||
|
key: 'updates'));
|
||||||
|
defaultValues.add('true');
|
||||||
|
}
|
||||||
|
if (newInstallIdsAllOrSelected.isNotEmpty) {
|
||||||
|
formInputs.add(GeneratedFormItem(
|
||||||
|
label: tr('installX', args: [
|
||||||
|
plural('apps',
|
||||||
|
newInstallIdsAllOrSelected.length)
|
||||||
|
]),
|
||||||
|
type: FormItemType.bool,
|
||||||
|
key: 'installs'));
|
||||||
|
defaultValues
|
||||||
|
.add(defaultValues.isEmpty ? 'true' : '');
|
||||||
|
}
|
||||||
|
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
|
||||||
|
formInputs.add(GeneratedFormItem(
|
||||||
|
label: tr('markXTrackOnlyAsUpdated', args: [
|
||||||
|
plural('apps',
|
||||||
|
trackOnlyUpdateIdsAllOrSelected.length)
|
||||||
|
]),
|
||||||
|
type: FormItemType.bool,
|
||||||
|
key: 'trackonlies'));
|
||||||
|
defaultValues
|
||||||
|
.add(defaultValues.isEmpty ? 'true' : '');
|
||||||
|
}
|
||||||
|
showDialog<List<String>?>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
var totalApps = existingUpdateIdsAllOrSelected
|
||||||
|
.length +
|
||||||
|
newInstallIdsAllOrSelected.length +
|
||||||
|
trackOnlyUpdateIdsAllOrSelected.length;
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr('changeX',
|
||||||
|
args: [plural('apps', totalApps)]),
|
||||||
|
items: formInputs.map((e) => [e]).toList(),
|
||||||
|
defaultValues: defaultValues,
|
||||||
|
initValid: true,
|
||||||
|
);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
if (values.isEmpty) {
|
||||||
|
values = defaultValues;
|
||||||
|
}
|
||||||
|
bool shouldInstallUpdates =
|
||||||
|
findGeneratedFormValueByKey(
|
||||||
|
formInputs, values, 'updates') ==
|
||||||
|
'true';
|
||||||
|
bool shouldInstallNew =
|
||||||
|
findGeneratedFormValueByKey(
|
||||||
|
formInputs, values, 'installs') ==
|
||||||
|
'true';
|
||||||
|
bool shouldMarkTrackOnlies =
|
||||||
|
findGeneratedFormValueByKey(formInputs,
|
||||||
|
values, 'trackonlies') ==
|
||||||
|
'true';
|
||||||
|
(() async {
|
||||||
|
if (shouldInstallNew ||
|
||||||
|
shouldInstallUpdates) {
|
||||||
|
await settingsProvider
|
||||||
|
.getInstallPermission();
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.then((_) {
|
||||||
|
List<String> toInstall = [];
|
||||||
|
if (shouldInstallUpdates) {
|
||||||
|
toInstall
|
||||||
|
.addAll(existingUpdateIdsAllOrSelected);
|
||||||
|
}
|
||||||
|
if (shouldInstallNew) {
|
||||||
|
toInstall
|
||||||
|
.addAll(newInstallIdsAllOrSelected);
|
||||||
|
}
|
||||||
|
if (shouldMarkTrackOnlies) {
|
||||||
|
toInstall.addAll(
|
||||||
|
trackOnlyUpdateIdsAllOrSelected);
|
||||||
|
}
|
||||||
|
appsProvider
|
||||||
|
.downloadAndInstallLatestApps(
|
||||||
|
toInstall, context)
|
||||||
|
.catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: selectedApps.isEmpty
|
||||||
|
? tr('installUpdateApps')
|
||||||
|
: tr('installUpdateSelectedApps'),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.file_download_outlined,
|
||||||
|
)),
|
||||||
|
selectedApps.isEmpty
|
||||||
|
? const SizedBox()
|
||||||
|
: IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
content: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed:
|
||||||
|
appsProvider
|
||||||
|
.areDownloadsRunning()
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(tr(
|
||||||
|
'markXSelectedAppsAsUpdated',
|
||||||
|
args: [
|
||||||
|
selectedApps
|
||||||
|
.length
|
||||||
|
.toString()
|
||||||
|
])),
|
||||||
|
content: Text(
|
||||||
|
tr('onlyAppliesToInstalledAndOutdatedApps')),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed:
|
||||||
|
() {
|
||||||
|
Navigator.of(context)
|
||||||
|
.pop();
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
tr('no'))),
|
||||||
|
TextButton(
|
||||||
|
onPressed:
|
||||||
|
() {
|
||||||
|
HapticFeedback
|
||||||
|
.selectionClick();
|
||||||
|
appsProvider
|
||||||
|
.saveApps(selectedApps.map((a) {
|
||||||
|
if (a.installedVersion !=
|
||||||
|
null) {
|
||||||
|
a.installedVersion = a.latestVersion;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}).toList());
|
||||||
|
|
||||||
|
Navigator.of(context)
|
||||||
|
.pop();
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
tr('yes')))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}).whenComplete(() {
|
||||||
|
Navigator.of(
|
||||||
|
context)
|
||||||
|
.pop();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip:
|
||||||
|
tr('markSelectedAppsUpdated'),
|
||||||
|
icon: const Icon(Icons.done)),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
var pinStatus = selectedApps
|
||||||
|
.where((element) =>
|
||||||
|
element.pinned)
|
||||||
|
.isEmpty;
|
||||||
|
appsProvider.saveApps(
|
||||||
|
selectedApps.map((e) {
|
||||||
|
e.pinned = pinStatus;
|
||||||
|
return e;
|
||||||
|
}).toList());
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
tooltip: selectedApps
|
||||||
|
.where((element) =>
|
||||||
|
element.pinned)
|
||||||
|
.isEmpty
|
||||||
|
? tr('pinToTop')
|
||||||
|
: tr('unpinFromTop'),
|
||||||
|
icon: Icon(selectedApps
|
||||||
|
.where((element) =>
|
||||||
|
element.pinned)
|
||||||
|
.isEmpty
|
||||||
|
? Icons.bookmark_outline_rounded
|
||||||
|
: Icons
|
||||||
|
.bookmark_remove_outlined),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
String urls = '';
|
||||||
|
for (var a in selectedApps) {
|
||||||
|
urls += '${a.url}\n';
|
||||||
|
}
|
||||||
|
urls = urls.substring(
|
||||||
|
0, urls.length - 1);
|
||||||
|
Share.share(urls,
|
||||||
|
subject: tr(
|
||||||
|
'selectedAppURLsFromObtainium'));
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
tooltip: tr('shareSelectedAppURLs'),
|
||||||
|
icon: const Icon(Icons.share),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr(
|
||||||
|
'resetInstallStatusForSelectedAppsQuestion'),
|
||||||
|
items: const [],
|
||||||
|
defaultValues: const [],
|
||||||
|
initValid: true,
|
||||||
|
message: tr(
|
||||||
|
'installStatusOfXWillBeResetExplanation',
|
||||||
|
args: [
|
||||||
|
plural(
|
||||||
|
'app',
|
||||||
|
selectedApps
|
||||||
|
.length)
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
appsProvider.saveApps(
|
||||||
|
selectedApps.map((e) {
|
||||||
|
e.installedVersion = null;
|
||||||
|
return e;
|
||||||
|
}).toList());
|
||||||
|
}
|
||||||
|
}).whenComplete(() {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: tr('resetInstallStatus'),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.restore_page_outlined),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: tr('more'),
|
||||||
|
icon: const Icon(Icons.more_horiz),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
const VerticalDivider(),
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (currentFilterIsUpdatesOnly) {
|
||||||
|
filter = null;
|
||||||
|
} else {
|
||||||
|
filter = updatesOnlyFilter;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: currentFilterIsUpdatesOnly
|
||||||
|
? tr('removeOutdatedFilter')
|
||||||
|
: tr('showOutdatedOnly'),
|
||||||
|
icon: Icon(
|
||||||
|
currentFilterIsUpdatesOnly
|
||||||
|
? Icons.update_disabled_rounded
|
||||||
|
: Icons.update_rounded,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
appsProvider.apps.isEmpty
|
||||||
|
? const SizedBox()
|
||||||
|
: TextButton.icon(
|
||||||
|
label: Text(
|
||||||
|
filter == null ? tr('filter') : tr('filterActive'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: filter == null
|
||||||
|
? FontWeight.normal
|
||||||
|
: FontWeight.bold),
|
||||||
),
|
),
|
||||||
));
|
onPressed: () {
|
||||||
|
showDialog<List<String>?>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr('filterApps'),
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('appName'), required: false),
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('author'), required: false)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('upToDateApps'),
|
||||||
|
type: FormItemType.bool)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('nonInstalledApps'),
|
||||||
|
type: FormItemType.bool)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
defaultValues: filter == null
|
||||||
|
? AppsFilter().toValuesArray()
|
||||||
|
: filter!.toValuesArray());
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
setState(() {
|
||||||
|
filter = AppsFilter.fromValuesArray(values);
|
||||||
|
if (AppsFilter().isIdenticalTo(filter!)) {
|
||||||
|
filter = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.filter_list_rounded))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AppsFilter {
|
||||||
|
late String nameFilter;
|
||||||
|
late String authorFilter;
|
||||||
|
late bool includeUptodate;
|
||||||
|
late bool includeNonInstalled;
|
||||||
|
|
||||||
|
AppsFilter(
|
||||||
|
{this.nameFilter = '',
|
||||||
|
this.authorFilter = '',
|
||||||
|
this.includeUptodate = true,
|
||||||
|
this.includeNonInstalled = true});
|
||||||
|
|
||||||
|
List<String> toValuesArray() {
|
||||||
|
return [
|
||||||
|
nameFilter,
|
||||||
|
authorFilter,
|
||||||
|
includeUptodate ? 'true' : '',
|
||||||
|
includeNonInstalled ? 'true' : ''
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
AppsFilter.fromValuesArray(List<String> values) {
|
||||||
|
nameFilter = values[0];
|
||||||
|
authorFilter = values[1];
|
||||||
|
includeUptodate = values[2] == 'true';
|
||||||
|
includeNonInstalled = values[3] == 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isIdenticalTo(AppsFilter other) =>
|
||||||
|
authorFilter.trim() == other.authorFilter.trim() &&
|
||||||
|
nameFilter.trim() == other.nameFilter.trim() &&
|
||||||
|
includeUptodate == other.includeUptodate &&
|
||||||
|
includeNonInstalled == other.includeNonInstalled;
|
||||||
|
}
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
|
import 'package:animations/animations.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/pages/add_app.dart';
|
import 'package:obtainium/pages/add_app.dart';
|
||||||
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 +14,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(tr('appsString'), Icons.apps,
|
||||||
const AddAppPage()
|
AppsPage(key: GlobalKey<AppsPageState>())),
|
||||||
|
NavigationPageItem(tr('addApp'), Icons.add, const AddAppPage()),
|
||||||
|
NavigationPageItem(
|
||||||
|
tr('importExport'), Icons.import_export, const ImportExportPage()),
|
||||||
|
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage())
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
619
lib/pages/import_export.dart
Normal file
@ -0,0 +1,619 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
|
class ImportExportPage extends StatefulWidget {
|
||||||
|
const ImportExportPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ImportExportPage> createState() => _ImportExportPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImportExportPageState extends State<ImportExportPage> {
|
||||||
|
bool importInProgress = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
|
var appsProvider = context.read<AppsProvider>();
|
||||||
|
var outlineButtonStyle = ButtonStyle(
|
||||||
|
shape: MaterialStateProperty.all(
|
||||||
|
StadiumBorder(
|
||||||
|
side: BorderSide(
|
||||||
|
width: 1,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
|
CustomAppBar(title: tr('importExport')),
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
style: outlineButtonStyle,
|
||||||
|
onPressed: appsProvider.apps.isEmpty ||
|
||||||
|
importInProgress
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
appsProvider
|
||||||
|
.exportApps()
|
||||||
|
.then((String path) {
|
||||||
|
showError(
|
||||||
|
tr('exportedTo', args: [path]),
|
||||||
|
context);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(tr('obtainiumExport')))),
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
style: outlineButtonStyle,
|
||||||
|
onPressed: importInProgress
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
FilePicker.platform
|
||||||
|
.pickFiles()
|
||||||
|
.then((result) {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = true;
|
||||||
|
});
|
||||||
|
if (result != null) {
|
||||||
|
String data = File(
|
||||||
|
result.files.single.path!)
|
||||||
|
.readAsStringSync();
|
||||||
|
try {
|
||||||
|
jsonDecode(data);
|
||||||
|
} catch (e) {
|
||||||
|
throw ObtainiumError(
|
||||||
|
tr('invalidInput'));
|
||||||
|
}
|
||||||
|
appsProvider
|
||||||
|
.importApps(data)
|
||||||
|
.then((value) {
|
||||||
|
showError(
|
||||||
|
tr('importedX', args: [
|
||||||
|
plural('apps', value)
|
||||||
|
]),
|
||||||
|
context);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// User canceled the picker
|
||||||
|
}
|
||||||
|
}).catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(tr('obtainiumImport'))))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (importInProgress)
|
||||||
|
Column(
|
||||||
|
children: const [
|
||||||
|
SizedBox(
|
||||||
|
height: 14,
|
||||||
|
),
|
||||||
|
LinearProgressIndicator(),
|
||||||
|
SizedBox(
|
||||||
|
height: 14,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Divider(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: importInProgress
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr('importFromURLList'),
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('appURLList'),
|
||||||
|
max: 7,
|
||||||
|
additionalValidators: [
|
||||||
|
(String? value) {
|
||||||
|
if (value != null &&
|
||||||
|
value.isNotEmpty) {
|
||||||
|
var lines = value
|
||||||
|
.trim()
|
||||||
|
.split('\n');
|
||||||
|
for (int i = 0;
|
||||||
|
i < lines.length;
|
||||||
|
i++) {
|
||||||
|
try {
|
||||||
|
sourceProvider
|
||||||
|
.getSource(
|
||||||
|
lines[i]);
|
||||||
|
} catch (e) {
|
||||||
|
return '${tr('line')} ${i + 1}: $e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
])
|
||||||
|
]
|
||||||
|
],
|
||||||
|
defaultValues: const [],
|
||||||
|
);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
var urls =
|
||||||
|
(values[0] as String).split('\n');
|
||||||
|
setState(() {
|
||||||
|
importInProgress = true;
|
||||||
|
});
|
||||||
|
appsProvider
|
||||||
|
.addAppsByURL(urls)
|
||||||
|
.then((errors) {
|
||||||
|
if (errors.isEmpty) {
|
||||||
|
showError(
|
||||||
|
tr('importedX', args: [
|
||||||
|
plural('apps', urls.length)
|
||||||
|
]),
|
||||||
|
context);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return ImportErrorDialog(
|
||||||
|
urlsLength: urls.length,
|
||||||
|
errors: errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
tr('importFromURLList'),
|
||||||
|
)),
|
||||||
|
...sourceProvider.sources
|
||||||
|
.where((element) => element.canSearch)
|
||||||
|
.map((source) => Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: importInProgress
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
() async {
|
||||||
|
var values = await showDialog<
|
||||||
|
List<String>>(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr('searchX',
|
||||||
|
args: [
|
||||||
|
source
|
||||||
|
.runtimeType
|
||||||
|
.toString()
|
||||||
|
]),
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr(
|
||||||
|
'searchQuery'))
|
||||||
|
]
|
||||||
|
],
|
||||||
|
defaultValues: const [],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (values != null &&
|
||||||
|
values[0].isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = true;
|
||||||
|
});
|
||||||
|
var urlsWithDescriptions =
|
||||||
|
await source
|
||||||
|
.search(values[0]);
|
||||||
|
if (urlsWithDescriptions
|
||||||
|
.isNotEmpty) {
|
||||||
|
var selectedUrls =
|
||||||
|
await showDialog<
|
||||||
|
List<
|
||||||
|
String>?>(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return UrlSelectionModal(
|
||||||
|
urlsWithDescriptions:
|
||||||
|
urlsWithDescriptions,
|
||||||
|
selectedByDefault:
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (selectedUrls !=
|
||||||
|
null &&
|
||||||
|
selectedUrls
|
||||||
|
.isNotEmpty) {
|
||||||
|
var errors =
|
||||||
|
await appsProvider
|
||||||
|
.addAppsByURL(
|
||||||
|
selectedUrls);
|
||||||
|
if (errors.isEmpty) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
showError(
|
||||||
|
tr('importedX',
|
||||||
|
args: [
|
||||||
|
plural(
|
||||||
|
'app',
|
||||||
|
selectedUrls
|
||||||
|
.length)
|
||||||
|
]),
|
||||||
|
context);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return ImportErrorDialog(
|
||||||
|
urlsLength:
|
||||||
|
selectedUrls
|
||||||
|
.length,
|
||||||
|
errors:
|
||||||
|
errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw ObtainiumError(
|
||||||
|
tr('noResults'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
.catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(tr('searchX', args: [
|
||||||
|
source.runtimeType.toString()
|
||||||
|
])))
|
||||||
|
]))
|
||||||
|
.toList(),
|
||||||
|
...sourceProvider.massUrlSources
|
||||||
|
.map((source) => Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: importInProgress
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
() async {
|
||||||
|
var values = await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr('importX',
|
||||||
|
args: [
|
||||||
|
source.name
|
||||||
|
]),
|
||||||
|
items:
|
||||||
|
source
|
||||||
|
.requiredArgs
|
||||||
|
.map(
|
||||||
|
(e) => [
|
||||||
|
GeneratedFormItem(label: e)
|
||||||
|
])
|
||||||
|
.toList(),
|
||||||
|
defaultValues: const [],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (values != null) {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = true;
|
||||||
|
});
|
||||||
|
var urlsWithDescriptions =
|
||||||
|
await source
|
||||||
|
.getUrlsWithDescriptions(
|
||||||
|
values);
|
||||||
|
var selectedUrls =
|
||||||
|
await showDialog<
|
||||||
|
List<String>?>(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return UrlSelectionModal(
|
||||||
|
urlsWithDescriptions:
|
||||||
|
urlsWithDescriptions);
|
||||||
|
});
|
||||||
|
if (selectedUrls != null) {
|
||||||
|
var errors =
|
||||||
|
await appsProvider
|
||||||
|
.addAppsByURL(
|
||||||
|
selectedUrls);
|
||||||
|
if (errors.isEmpty) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
showError(
|
||||||
|
tr('importedX',
|
||||||
|
args: [
|
||||||
|
plural(
|
||||||
|
'app',
|
||||||
|
selectedUrls
|
||||||
|
.length)
|
||||||
|
]),
|
||||||
|
context);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return ImportErrorDialog(
|
||||||
|
urlsLength:
|
||||||
|
selectedUrls
|
||||||
|
.length,
|
||||||
|
errors:
|
||||||
|
errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
.catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
tr('importX', args: [source.name])))
|
||||||
|
]))
|
||||||
|
.toList(),
|
||||||
|
const Spacer(),
|
||||||
|
const Divider(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
Text(tr('importedAppsIdDisclaimer'),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontStyle: FontStyle.italic, fontSize: 12)),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)))
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImportErrorDialog extends StatefulWidget {
|
||||||
|
const ImportErrorDialog(
|
||||||
|
{super.key, required this.urlsLength, required this.errors});
|
||||||
|
|
||||||
|
final int urlsLength;
|
||||||
|
final List<List<String>> errors;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ImportErrorDialog> createState() => _ImportErrorDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(tr('importErrors')),
|
||||||
|
content:
|
||||||
|
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||||
|
Text(
|
||||||
|
tr('importedXOfYApps', args: [
|
||||||
|
(widget.urlsLength - widget.errors.length).toString(),
|
||||||
|
widget.urlsLength.toString()
|
||||||
|
]),
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
tr('followingURLsHadErrors'),
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
...widget.errors.map((e) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
Text(e[0]),
|
||||||
|
Text(
|
||||||
|
e[1],
|
||||||
|
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}).toList()
|
||||||
|
]),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(null);
|
||||||
|
},
|
||||||
|
child: Text(tr('okay')))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: must_be_immutable
|
||||||
|
class UrlSelectionModal extends StatefulWidget {
|
||||||
|
UrlSelectionModal(
|
||||||
|
{super.key,
|
||||||
|
required this.urlsWithDescriptions,
|
||||||
|
this.selectedByDefault = true,
|
||||||
|
this.onlyOneSelectionAllowed = false});
|
||||||
|
|
||||||
|
Map<String, String> urlsWithDescriptions;
|
||||||
|
bool selectedByDefault;
|
||||||
|
bool onlyOneSelectionAllowed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||||
|
Map<MapEntry<String, String>, bool> urlWithDescriptionSelections = {};
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
for (var url in widget.urlsWithDescriptions.entries) {
|
||||||
|
urlWithDescriptionSelections.putIfAbsent(url,
|
||||||
|
() => widget.selectedByDefault && !widget.onlyOneSelectionAllowed);
|
||||||
|
}
|
||||||
|
if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) {
|
||||||
|
selectOnlyOne(widget.urlsWithDescriptions.entries.first.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectOnlyOne(String url) {
|
||||||
|
for (var uwd in urlWithDescriptionSelections.keys) {
|
||||||
|
urlWithDescriptionSelections[uwd] = uwd.key == url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(
|
||||||
|
widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')),
|
||||||
|
content: Column(children: [
|
||||||
|
...urlWithDescriptionSelections.keys.map((urlWithD) {
|
||||||
|
return Row(children: [
|
||||||
|
Checkbox(
|
||||||
|
value: urlWithDescriptionSelections[urlWithD],
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
value ??= false;
|
||||||
|
if (value! && widget.onlyOneSelectionAllowed) {
|
||||||
|
selectOnlyOne(urlWithD.key);
|
||||||
|
} else {
|
||||||
|
urlWithDescriptionSelections[urlWithD] = value!;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
const SizedBox(
|
||||||
|
width: 8,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
launchUrlString(urlWithD.key,
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
Uri.parse(urlWithD.key).path.substring(1),
|
||||||
|
style:
|
||||||
|
const TextStyle(decoration: TextDecoration.underline),
|
||||||
|
textAlign: TextAlign.start,
|
||||||
|
)),
|
||||||
|
Text(
|
||||||
|
urlWithD.value.length > 128
|
||||||
|
? '${urlWithD.value.substring(0, 128)}...'
|
||||||
|
: urlWithD.value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontStyle: FontStyle.italic, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
))
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(tr('cancel'))),
|
||||||
|
TextButton(
|
||||||
|
onPressed:
|
||||||
|
urlWithDescriptionSelections.values.where((b) => b).isEmpty
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
Navigator.of(context).pop(urlWithDescriptionSelections
|
||||||
|
.entries
|
||||||
|
.where((entry) => entry.value)
|
||||||
|
.map((e) => e.key.key)
|
||||||
|
.toList());
|
||||||
|
},
|
||||||
|
child: Text(widget.onlyOneSelectionAllowed
|
||||||
|
? tr('pick')
|
||||||
|
: tr('importX', args: [
|
||||||
|
plural(
|
||||||
|
'url',
|
||||||
|
urlWithDescriptionSelections.values
|
||||||
|
.where((b) => b)
|
||||||
|
.length)
|
||||||
|
])))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,10 +1,13 @@
|
|||||||
import 'dart:convert';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
import 'package:obtainium/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 +20,334 @@ 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: InputDecoration(labelText: tr('theme')),
|
||||||
title: const Text('Import Apps'),
|
value: settingsProvider.theme,
|
||||||
content: Column(children: [
|
items: [
|
||||||
const Text(
|
DropdownMenuItem(
|
||||||
'Copy the contents of the Obtainium export file and paste them into the field below:'),
|
value: ThemeSettings.dark,
|
||||||
Form(
|
child: Text(tr('dark')),
|
||||||
key: formKey,
|
),
|
||||||
child: TextFormField(
|
DropdownMenuItem(
|
||||||
minLines: 7,
|
value: ThemeSettings.light,
|
||||||
maxLines: 7,
|
child: Text(tr('light')),
|
||||||
decoration: const InputDecoration(
|
),
|
||||||
helperText:
|
DropdownMenuItem(
|
||||||
'Obtainium export data'),
|
value: ThemeSettings.system,
|
||||||
controller: jsonInputController,
|
child: Text(tr('followSystem')),
|
||||||
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: InputDecoration(labelText: tr('colour')),
|
||||||
}
|
value: settingsProvider.colour,
|
||||||
if (!isJSON) {
|
items: [
|
||||||
return 'Invalid input';
|
DropdownMenuItem(
|
||||||
}
|
value: ColourSettings.basic,
|
||||||
return null;
|
child: Text(tr('obtainium')),
|
||||||
},
|
),
|
||||||
),
|
DropdownMenuItem(
|
||||||
)
|
value: ColourSettings.materialYou,
|
||||||
]),
|
child: Text(tr('materialYou')),
|
||||||
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: InputDecoration(labelText: tr('appSortBy')),
|
||||||
if (formKey.currentState!
|
value: settingsProvider.sortColumn,
|
||||||
.validate()) {
|
items: [
|
||||||
appsProvider
|
DropdownMenuItem(
|
||||||
.importApps(
|
value: SortColumnSettings.authorName,
|
||||||
jsonInputController
|
child: Text(tr('authorName')),
|
||||||
.value.text)
|
),
|
||||||
.then((value) {
|
DropdownMenuItem(
|
||||||
ScaffoldMessenger.of(context)
|
value: SortColumnSettings.nameAuthor,
|
||||||
.showSnackBar(
|
child: Text(tr('nameAuthor')),
|
||||||
SnackBar(
|
),
|
||||||
content: Text(
|
DropdownMenuItem(
|
||||||
'$value Apps Imported')),
|
value: SortColumnSettings.added,
|
||||||
);
|
child: Text(tr('asAdded')),
|
||||||
}).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: InputDecoration(labelText: tr('appSortOrder')),
|
||||||
}
|
value: settingsProvider.sortOrder,
|
||||||
},
|
items: [
|
||||||
child: const Text('Import')),
|
DropdownMenuItem(
|
||||||
],
|
value: SortOrderSettings.ascending,
|
||||||
);
|
child: Text(tr('ascending')),
|
||||||
});
|
),
|
||||||
},
|
DropdownMenuItem(
|
||||||
child: const Text('Import Apps'))
|
value: SortOrderSettings.descending,
|
||||||
],
|
child: Text(tr('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: InputDecoration(labelText: tr('bgUpdateCheckInterval')),
|
||||||
return Colors.grey;
|
value: settingsProvider.updateInterval,
|
||||||
}),
|
items: updateIntervals.map((e) {
|
||||||
),
|
int displayNum = (e < 60
|
||||||
|
? e
|
||||||
|
: e < 1440
|
||||||
|
? e / 60
|
||||||
|
: e / 1440)
|
||||||
|
.round();
|
||||||
|
String display = e == 0
|
||||||
|
? tr('neverManualOnly')
|
||||||
|
: (e < 60
|
||||||
|
? plural('minute', displayNum)
|
||||||
|
: e < 1440
|
||||||
|
? plural('hour', displayNum)
|
||||||
|
: plural('day', displayNum));
|
||||||
|
return DropdownMenuItem(value: e, child: Text(display));
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.updateInterval = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var sourceSpecificFields = sourceProvider.sources.map((e) {
|
||||||
|
if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
|
||||||
|
return GeneratedForm(
|
||||||
|
items: e.additionalSourceSpecificSettingFormItems
|
||||||
|
.map((e) => [e])
|
||||||
|
.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>[
|
||||||
|
CustomAppBar(title: tr('settings')),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: settingsProvider.prefs == null
|
||||||
|
? const SizedBox()
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
tr('appearance'),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary),
|
||||||
|
),
|
||||||
|
themeDropdown,
|
||||||
|
height16,
|
||||||
|
colourDropdown,
|
||||||
|
height16,
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: sortDropdown),
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
),
|
||||||
|
Expanded(child: orderDropdown),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
height16,
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(tr('showWebInAppView')),
|
||||||
|
Switch(
|
||||||
|
value: settingsProvider.showAppWebpage,
|
||||||
|
onChanged: (value) {
|
||||||
|
settingsProvider.showAppWebpage = value;
|
||||||
|
})
|
||||||
|
],
|
||||||
|
),
|
||||||
|
height16,
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(tr('pinUpdates')),
|
||||||
|
Switch(
|
||||||
|
value: settingsProvider.pinUpdates,
|
||||||
|
onChanged: (value) {
|
||||||
|
settingsProvider.pinUpdates = value;
|
||||||
|
})
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
height16,
|
||||||
|
Text(
|
||||||
|
tr('updates'),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary),
|
||||||
|
),
|
||||||
|
intervalDropdown,
|
||||||
|
const Divider(
|
||||||
|
height: 48,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
tr('sourceSpecific'),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary),
|
||||||
|
),
|
||||||
|
...sourceSpecificFields,
|
||||||
|
],
|
||||||
|
))),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Divider(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
launchUrlString(settingsProvider.sourceUrl,
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.code),
|
||||||
|
label: Text(
|
||||||
|
tr('appSource'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.lightImpact();
|
context.read<LogsProvider>().get().then((logs) {
|
||||||
launchUrlString(settingsProvider.sourceUrl,
|
if (logs.isEmpty) {
|
||||||
mode: LaunchMode.externalApplication);
|
showError(ObtainiumError(tr('noLogs')), context);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return const LogsDialog();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.code),
|
icon: const Icon(Icons.bug_report_outlined),
|
||||||
label: Text(
|
label: Text(tr('appLogs'))),
|
||||||
'Source',
|
],
|
||||||
style: Theme.of(context).textTheme.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 : tr('noLogs');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logString == null) {
|
||||||
|
filterLogs(days.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(tr('appLogs')),
|
||||||
|
content: Column(
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField(
|
||||||
|
value: days.first,
|
||||||
|
items: days
|
||||||
|
.map((e) => DropdownMenuItem(
|
||||||
|
value: e,
|
||||||
|
child: Text(plural('day', e)),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (d) {
|
||||||
|
filterLogs(d ?? 7);
|
||||||
|
}),
|
||||||
|
const SizedBox(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
Text(logString ?? '')
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(tr('close'))),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Share.share(logString ?? '', subject: tr('appLogs'));
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(tr('share')))
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,27 +5,37 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package: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,122 +43,341 @@ class AppsProvider with ChangeNotifier {
|
|||||||
Map<String, AppInMemory> apps = {};
|
Map<String, AppInMemory> apps = {};
|
||||||
bool loadingApps = false;
|
bool loadingApps = false;
|
||||||
bool gettingUpdates = false;
|
bool gettingUpdates = false;
|
||||||
|
bool forBGTask = false;
|
||||||
|
LogsProvider logs = LogsProvider();
|
||||||
|
|
||||||
// Variables to keep track of the app foreground status (installs can't run in the background)
|
// Variables to keep track of the app foreground status (installs can't run in the background)
|
||||||
bool isForeground = true;
|
bool isForeground = true;
|
||||||
late Stream<FGBGType> foregroundStream;
|
late Stream<FGBGType>? foregroundStream;
|
||||||
late StreamSubscription<FGBGType> foregroundSubscription;
|
late StreamSubscription<FGBGType>? foregroundSubscription;
|
||||||
|
|
||||||
AppsProvider(
|
AppsProvider({this.forBGTask = false}) {
|
||||||
{bool shouldLoadApps = false,
|
// Many setup tasks should only be done in the foreground isolate
|
||||||
bool shouldCheckUpdatesAfterLoad = false,
|
if (!forBGTask) {
|
||||||
bool shouldDeleteAPKs = false}) {
|
// Subscribe to changes in the app foreground status
|
||||||
// Subscribe to changes in the app foreground status
|
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||||
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
foregroundSubscription = foregroundStream?.listen((event) async {
|
||||||
foregroundSubscription = foregroundStream.listen((event) async {
|
isForeground = event == FGBGType.foreground;
|
||||||
isForeground = event == FGBGType.foreground;
|
if (isForeground) await loadApps();
|
||||||
if (isForeground) await loadApps();
|
|
||||||
});
|
|
||||||
if (shouldDeleteAPKs) {
|
|
||||||
deleteSavedAPKs();
|
|
||||||
}
|
|
||||||
if (shouldLoadApps) {
|
|
||||||
loadApps().then((_) {
|
|
||||||
if (shouldCheckUpdatesAfterLoad) {
|
|
||||||
checkUpdates();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
() async {
|
||||||
|
// Load Apps into memory (in background, this is done later instead of in the constructor)
|
||||||
|
await loadApps();
|
||||||
|
// Delete existing APKs
|
||||||
|
(await getExternalStorageDirectory())
|
||||||
|
?.listSync()
|
||||||
|
.where((element) =>
|
||||||
|
element.path.endsWith('.apk') ||
|
||||||
|
element.path.endsWith('.apk.part'))
|
||||||
|
.forEach((apk) {
|
||||||
|
apk.delete();
|
||||||
|
});
|
||||||
|
}();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ApkFile> downloadApp(String apkUrl, String appId) async {
|
downloadFile(String url, String fileName, Function? onProgress,
|
||||||
|
{bool useExisting = true}) async {
|
||||||
|
var destDir = (await getExternalStorageDirectory())!.path;
|
||||||
StreamedResponse response =
|
StreamedResponse response =
|
||||||
await Client().send(Request('GET', Uri.parse(apkUrl)));
|
await Client().send(Request('GET', Uri.parse(url)));
|
||||||
File downloadFile =
|
File downloadedFile = File('$destDir/$fileName');
|
||||||
File('${(await getExternalStorageDirectory())!.path}/$appId.apk');
|
if (!(downloadedFile.existsSync() && useExisting)) {
|
||||||
if (downloadFile.existsSync()) {
|
File tempDownloadedFile = File('${downloadedFile.path}.part');
|
||||||
downloadFile.deleteSync();
|
if (tempDownloadedFile.existsSync()) {
|
||||||
|
tempDownloadedFile.deleteSync();
|
||||||
|
}
|
||||||
|
var length = response.contentLength;
|
||||||
|
var received = 0;
|
||||||
|
double? progress;
|
||||||
|
var sink = tempDownloadedFile.openWrite();
|
||||||
|
await response.stream.map((s) {
|
||||||
|
received += s.length;
|
||||||
|
progress = (length != null ? received / length * 100 : 30);
|
||||||
|
if (onProgress != null) {
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}).pipe(sink);
|
||||||
|
await sink.close();
|
||||||
|
progress = null;
|
||||||
|
if (onProgress != null) {
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
tempDownloadedFile.deleteSync();
|
||||||
|
throw response.reasonPhrase ?? tr('unexpectedError');
|
||||||
|
}
|
||||||
|
tempDownloadedFile.renameSync(downloadedFile.path);
|
||||||
}
|
}
|
||||||
var length = response.contentLength;
|
return downloadedFile;
|
||||||
var received = 0;
|
}
|
||||||
var sink = downloadFile.openWrite();
|
|
||||||
|
|
||||||
await response.stream.map((s) {
|
Future<DownloadedApk> downloadApp(App app, BuildContext? context) async {
|
||||||
received += s.length;
|
var fileName =
|
||||||
apps[appId]!.downloadProgress =
|
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
|
||||||
(length != null ? received / length * 100 : 30);
|
String downloadUrl = await SourceProvider()
|
||||||
notifyListeners();
|
.getSource(app.url)
|
||||||
return s;
|
.apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
|
||||||
}).pipe(sink);
|
NotificationsProvider? notificationsProvider =
|
||||||
|
context?.read<NotificationsProvider>();
|
||||||
await sink.close();
|
var notif = DownloadNotification(app.name, 100);
|
||||||
apps[appId]!.downloadProgress = null;
|
notificationsProvider?.cancel(notif.id);
|
||||||
notifyListeners();
|
int? prevProg;
|
||||||
|
File downloadedFile =
|
||||||
if (response.statusCode != 200) {
|
await downloadFile(downloadUrl, fileName, (double? progress) {
|
||||||
downloadFile.deleteSync();
|
int? prog = progress?.ceil();
|
||||||
throw response.reasonPhrase ?? 'Unknown Error';
|
if (apps[app.id] != null) {
|
||||||
|
apps[app.id]!.downloadProgress = progress;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
notif = DownloadNotification(app.name, prog ?? 100);
|
||||||
|
if (prog != null && prevProg != prog) {
|
||||||
|
notificationsProvider?.notify(notif);
|
||||||
|
}
|
||||||
|
prevProg = prog;
|
||||||
|
});
|
||||||
|
notificationsProvider?.cancel(notif.id);
|
||||||
|
// Delete older versions of the APK if any
|
||||||
|
for (var file in downloadedFile.parent.listSync()) {
|
||||||
|
var fn = file.path.split('/').last;
|
||||||
|
if (fn.startsWith('${app.id}-') &&
|
||||||
|
fn.endsWith('.apk') &&
|
||||||
|
fn != fileName) {
|
||||||
|
file.delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ApkFile(appId, downloadFile);
|
// If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
|
||||||
|
// The former case should be handled (give the App its real ID), the latter is a security issue
|
||||||
|
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
|
||||||
|
if (app.id != newInfo.packageName) {
|
||||||
|
if (apps[app.id] != null && !SourceProvider().isTempId(app.id)) {
|
||||||
|
throw IDChangedError();
|
||||||
|
}
|
||||||
|
var originalAppId = app.id;
|
||||||
|
app.id = newInfo.packageName;
|
||||||
|
downloadedFile = downloadedFile.renameSync(
|
||||||
|
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
|
||||||
|
if (apps[originalAppId] != null) {
|
||||||
|
await removeApps([originalAppId]);
|
||||||
|
await saveApps([app]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DownloadedApk(app.id, downloadedFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool areDownloadsRunning() => apps.values
|
bool areDownloadsRunning() => apps.values
|
||||||
.where((element) => element.downloadProgress != null)
|
.where((element) => element.downloadProgress != null)
|
||||||
.isNotEmpty;
|
.isNotEmpty;
|
||||||
|
|
||||||
// Given an AppId, uses stored info about the app to download an APK (with user input if needed) and install it
|
Future<bool> canInstallSilently(App app) async {
|
||||||
// Installs can only be done in the foreground, so a notification is sent to get the user's attention if needed
|
return false;
|
||||||
// Returns upon successful download, regardless of installation result
|
// TODO: Uncomment the below once silentupdates are ever figured out
|
||||||
Future<bool> downloadAndInstallLatestApp(
|
// // TODO: This is unreliable - try to get from OS in the future
|
||||||
List<String> appIds, BuildContext context) async {
|
// if (app.apkUrls.length > 1) {
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
|
// var osInfo = await DeviceInfoPlugin().androidInfo;
|
||||||
|
// return app.installedVersion != null &&
|
||||||
|
// osInfo.version.sdkInt >= 30 &&
|
||||||
|
// osInfo.version.release.compareTo('12') >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> waitForUserToReturnToForeground(BuildContext context) async {
|
||||||
NotificationsProvider notificationsProvider =
|
NotificationsProvider notificationsProvider =
|
||||||
context.read<NotificationsProvider>();
|
context.read<NotificationsProvider>();
|
||||||
Map<String, String> appsToInstall = {};
|
if (!isForeground) {
|
||||||
|
await notificationsProvider.notify(completeInstallationNotification,
|
||||||
|
cancelExisting: true);
|
||||||
|
while (await FGBGEvents.stream.first != FGBGType.foreground) {}
|
||||||
|
await notificationsProvider.cancel(completeInstallationNotification.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> canDowngradeApps() async {
|
||||||
|
try {
|
||||||
|
await InstalledApps.getAppInfo('com.berdik.letmedowngrade');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfortunately this 'await' does not actually wait for the APK to finish installing
|
||||||
|
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
|
||||||
|
// If appropriate criteria are met, the update (never a fresh install) happens silently in the background
|
||||||
|
// But even then, we don't know if it actually succeeded
|
||||||
|
Future<void> installApk(DownloadedApk file) async {
|
||||||
|
var newInfo = await PackageArchiveInfo.fromPath(file.file.path);
|
||||||
|
AppInfo? appInfo;
|
||||||
|
try {
|
||||||
|
appInfo = await InstalledApps.getAppInfo(apps[file.appId]!.app.id);
|
||||||
|
} catch (e) {
|
||||||
|
// OK
|
||||||
|
}
|
||||||
|
if (appInfo != null &&
|
||||||
|
int.parse(newInfo.buildNumber) < appInfo.versionCode! &&
|
||||||
|
!(await canDowngradeApps())) {
|
||||||
|
throw DowngradeError();
|
||||||
|
}
|
||||||
|
if (appInfo == null ||
|
||||||
|
int.parse(newInfo.buildNumber) > appInfo.versionCode!) {
|
||||||
|
await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium');
|
||||||
|
}
|
||||||
|
apps[file.appId]!.app.installedVersion =
|
||||||
|
apps[file.appId]!.app.latestVersion;
|
||||||
|
// Don't correct install status as installation may not be done yet
|
||||||
|
await saveApps([apps[file.appId]!.app],
|
||||||
|
attemptToCorrectInstallStatus: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> confirmApkUrl(App app, BuildContext? context) async {
|
||||||
|
// If the App has more than one APK, the user should pick one (if context provided)
|
||||||
|
String? apkUrl = app.apkUrls[app.preferredApkIndex];
|
||||||
|
// get device supported architecture
|
||||||
|
List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
|
||||||
|
|
||||||
|
if (app.apkUrls.length > 1 && context != null) {
|
||||||
|
apkUrl = await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return APKPicker(
|
||||||
|
app: app,
|
||||||
|
initVal: apkUrl,
|
||||||
|
archs: archs,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
|
||||||
|
if (apkUrl != null &&
|
||||||
|
Uri.parse(apkUrl).origin != Uri.parse(app.url).origin &&
|
||||||
|
context != null) {
|
||||||
|
if (await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return APKOriginWarningDialog(
|
||||||
|
sourceUrl: app.url, apkUrl: apkUrl!);
|
||||||
|
}) !=
|
||||||
|
true) {
|
||||||
|
apkUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return apkUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Given a list of AppIds, uses stored info about the apps to download APKs and install them
|
||||||
|
// If the APKs can be installed silently, they are
|
||||||
|
// If no BuildContext is provided, apps that require user interaction are ignored
|
||||||
|
// If user input is needed and the App is in the background, a notification is sent to get the user's attention
|
||||||
|
// Returns an array of Ids for Apps that were successfully downloaded, regardless of installation result
|
||||||
|
Future<List<String>> downloadAndInstallLatestApps(
|
||||||
|
List<String> appIds, BuildContext? context) async {
|
||||||
|
List<String> appsToInstall = [];
|
||||||
|
List<String> trackOnlyAppsToUpdate = [];
|
||||||
|
// For all specified Apps, filter out those for which:
|
||||||
|
// 1. A URL cannot be picked
|
||||||
|
// 2. That cannot be installed silently (IF no buildContext was given for interactive install)
|
||||||
for (var id in appIds) {
|
for (var id in appIds) {
|
||||||
if (apps[id] == null) {
|
if (apps[id] == null) {
|
||||||
throw 'App not found';
|
throw ObtainiumError(tr('appNotFound'));
|
||||||
}
|
}
|
||||||
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex];
|
String? apkUrl;
|
||||||
if (apps[id]!.app.apkUrls.length > 1) {
|
if (!apps[id]!.app.trackOnly) {
|
||||||
apkUrl = await showDialog(
|
apkUrl = await confirmApkUrl(apps[id]!.app, context);
|
||||||
context: context,
|
|
||||||
builder: (BuildContext ctx) {
|
|
||||||
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (apkUrl != null) {
|
if (apkUrl != null) {
|
||||||
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
||||||
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
||||||
apps[id]!.app.preferredApkIndex = urlInd;
|
apps[id]!.app.preferredApkIndex = urlInd;
|
||||||
await saveApp(apps[id]!.app);
|
await saveApps([apps[id]!.app]);
|
||||||
}
|
}
|
||||||
appsToInstall.putIfAbsent(id, () => apkUrl!);
|
if (context != null || await canInstallSilently(apps[id]!.app)) {
|
||||||
|
appsToInstall.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (apps[id]!.app.trackOnly) {
|
||||||
|
trackOnlyAppsToUpdate.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mark all specified track-only apps as latest
|
||||||
|
saveApps(trackOnlyAppsToUpdate.map((e) {
|
||||||
|
var a = apps[e]!.app;
|
||||||
|
a.installedVersion = a.latestVersion;
|
||||||
|
return a;
|
||||||
|
}).toList());
|
||||||
|
// Download APKs for all Apps to be installed
|
||||||
|
MultiAppMultiError errors = MultiAppMultiError();
|
||||||
|
List<DownloadedApk?> downloadedFiles =
|
||||||
|
await Future.wait(appsToInstall.map((id) async {
|
||||||
|
try {
|
||||||
|
return await downloadApp(apps[id]!.app, context);
|
||||||
|
} catch (e) {
|
||||||
|
errors.add(id, e.toString());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
downloadedFiles =
|
||||||
|
downloadedFiles.where((element) => element != null).toList();
|
||||||
|
// Separate the Apps to install into silent and regular lists
|
||||||
|
List<DownloadedApk> silentUpdates = [];
|
||||||
|
List<DownloadedApk> regularInstalls = [];
|
||||||
|
for (var f in downloadedFiles) {
|
||||||
|
bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app);
|
||||||
|
if (willBeSilent) {
|
||||||
|
silentUpdates.add(f);
|
||||||
|
} else {
|
||||||
|
regularInstalls.add(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries
|
// Move everything to the regular install list (since silent updates don't currently work) - TODO
|
||||||
.map((entry) => downloadApp(entry.value, entry.key)));
|
regularInstalls.addAll(silentUpdates);
|
||||||
|
|
||||||
if (!isForeground) {
|
// If Obtainium is being installed, it should be the last one
|
||||||
await notificationsProvider.notify(completeInstallationNotification,
|
List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) {
|
||||||
cancelExisting: true);
|
DownloadedApk? temp;
|
||||||
await FGBGEvents.stream.first == FGBGType.foreground;
|
items.removeWhere((element) {
|
||||||
await notificationsProvider.cancel(completeInstallationNotification.id);
|
bool res =
|
||||||
// We need to wait for the App to come to the foreground to install it
|
element.appId == obtainiumId || element.appId == obtainiumTempId;
|
||||||
// Can't try to call install plugin in a background isolate (may not have worked anyways) because of:
|
if (res) {
|
||||||
// https://github.com/flutter/flutter/issues/13937
|
temp = element;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
if (temp != null) {
|
||||||
|
items = [temp!, ...items];
|
||||||
|
}
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unfortunately this 'await' does not actually wait for the APK to finish installing
|
silentUpdates = moveObtainiumToStart(silentUpdates);
|
||||||
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
|
regularInstalls = moveObtainiumToStart(regularInstalls);
|
||||||
// This also does not use the 'session-based' installer API, so background/silent updates are impossible
|
|
||||||
for (var f in downloadedFiles) {
|
// // Install silent updates (uncomment when it works - TODO)
|
||||||
await InstallPlugin.installApk(f.file.path, 'dev.imranr.obtainium');
|
// for (var u in silentUpdates) {
|
||||||
apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion;
|
// await installApk(u, silent: true); // Would need to add silent option
|
||||||
await saveApp(apps[f.appId]!.app);
|
// }
|
||||||
|
|
||||||
|
// Do regular installs
|
||||||
|
if (regularInstalls.isNotEmpty && context != null) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
await waitForUserToReturnToForeground(context);
|
||||||
|
for (var i in regularInstalls) {
|
||||||
|
try {
|
||||||
|
await installApk(i);
|
||||||
|
} catch (e) {
|
||||||
|
errors.add(i.appId, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return downloadedFiles.isNotEmpty;
|
if (errors.content.isNotEmpty) {
|
||||||
|
throw errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationsProvider().cancel(UpdateNotification([]).id);
|
||||||
|
|
||||||
|
return downloadedFiles.map((e) => e!.appId).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Directory> getAppsDir() async {
|
Future<Directory> getAppsDir() async {
|
||||||
@ -160,96 +389,219 @@ class AppsProvider with ChangeNotifier {
|
|||||||
return appsDir;
|
return appsDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteSavedAPKs() async {
|
Future<AppInfo?> getInstalledInfo(String? packageName) async {
|
||||||
(await getExternalStorageDirectory())
|
if (packageName != null) {
|
||||||
?.listSync()
|
try {
|
||||||
.where((element) => element.path.endsWith('.apk'))
|
return await InstalledApps.getAppInfo(packageName);
|
||||||
.forEach((element) {
|
} catch (e) {
|
||||||
element.deleteSync();
|
// OK
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> loadApps() async {
|
|
||||||
loadingApps = true;
|
|
||||||
notifyListeners();
|
|
||||||
List<FileSystemEntity> appFiles = (await getAppsDir())
|
|
||||||
.listSync()
|
|
||||||
.where((item) => item.path.toLowerCase().endsWith('.json'))
|
|
||||||
.toList();
|
|
||||||
apps.clear();
|
|
||||||
for (int i = 0; i < appFiles.length; i++) {
|
|
||||||
App app =
|
|
||||||
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync()));
|
|
||||||
apps.putIfAbsent(app.id, () => AppInMemory(app, null));
|
|
||||||
}
|
|
||||||
loadingApps = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> saveApp(App app) async {
|
|
||||||
File('${(await getAppsDir()).path}/${app.id}.json')
|
|
||||||
.writeAsStringSync(jsonEncode(app.toJson()));
|
|
||||||
apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress),
|
|
||||||
ifAbsent: () => AppInMemory(app, null));
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> removeApp(String appId) async {
|
|
||||||
File file = File('${(await getAppsDir()).path}/$appId.json');
|
|
||||||
if (file.existsSync()) {
|
|
||||||
file.deleteSync();
|
|
||||||
}
|
|
||||||
if (apps.containsKey(appId)) {
|
|
||||||
apps.remove(appId);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool checkAppObjectForUpdate(App app) {
|
|
||||||
if (!apps.containsKey(app.id)) {
|
|
||||||
throw 'App not found';
|
|
||||||
}
|
|
||||||
return app.latestVersion != apps[app.id]?.app.installedVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<App?> getUpdate(String appId) async {
|
|
||||||
App? currentApp = apps[appId]!.app;
|
|
||||||
App newApp = await SourceProvider().getApp(currentApp.url);
|
|
||||||
if (newApp.latestVersion != currentApp.latestVersion) {
|
|
||||||
newApp.installedVersion = currentApp.installedVersion;
|
|
||||||
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
|
||||||
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
|
||||||
}
|
}
|
||||||
await saveApp(newApp);
|
|
||||||
return newApp;
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<App>> checkUpdates() async {
|
// If the App says it is installed but installedInfo is null, set it to not installed
|
||||||
|
// 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) => [
|
||||||
|
// TODO: Won't work for other charsets
|
||||||
|
'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;
|
||||||
@ -257,44 +609,65 @@ class AppsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<String> exportApps() async {
|
Future<String> exportApps() async {
|
||||||
Directory? exportDir = Directory('/storage/emulated/0/Download');
|
Directory? exportDir = Directory('/storage/emulated/0/Download');
|
||||||
String path = 'Downloads';
|
String path = 'Downloads'; // TODO: Is this true on non-english phones?
|
||||||
if (!exportDir.existsSync()) {
|
if (!exportDir.existsSync()) {
|
||||||
exportDir = await getExternalStorageDirectory();
|
exportDir = await getExternalStorageDirectory();
|
||||||
path = exportDir!.path;
|
path = exportDir!.path;
|
||||||
}
|
}
|
||||||
File export = File(
|
File export = File(
|
||||||
'${exportDir.path}/obtainium-export-${DateTime.now().millisecondsSinceEpoch}.json');
|
'${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json');
|
||||||
export.writeAsStringSync(
|
export.writeAsStringSync(
|
||||||
jsonEncode(apps.values.map((e) => e.app.toJson()).toList()));
|
jsonEncode(apps.values.map((e) => e.app.toJson()).toList()));
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> importApps(String appsJSON) async {
|
Future<int> importApps(String appsJSON) async {
|
||||||
// File picker does not work in android 13, so the user must paste the JSON directly into Obtainium to import Apps
|
|
||||||
List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
|
List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
|
||||||
.map((e) => App.fromJson(e))
|
.map((e) => App.fromJson(e))
|
||||||
.toList();
|
.toList();
|
||||||
for (App a in importedApps) {
|
while (loadingApps) {
|
||||||
a.installedVersion =
|
await Future.delayed(const Duration(microseconds: 1));
|
||||||
apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null;
|
|
||||||
await saveApp(a);
|
|
||||||
}
|
}
|
||||||
|
for (App a in importedApps) {
|
||||||
|
if (apps[a.id]?.app.installedVersion != null) {
|
||||||
|
a.installedVersion = apps[a.id]?.app.installedVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveApps(importedApps);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return importedApps.length;
|
return importedApps.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
foregroundSubscription.cancel();
|
foregroundSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<List<String>>> addAppsByURL(List<String> urls) async {
|
||||||
|
List<dynamic> results = await SourceProvider().getAppsByURLNaive(urls,
|
||||||
|
ignoreUrls: apps.values.map((e) => e.app.url).toList());
|
||||||
|
List<App> pps = results[0];
|
||||||
|
Map<String, dynamic> errorsMap = results[1];
|
||||||
|
for (var app in pps) {
|
||||||
|
if (apps.containsKey(app.id)) {
|
||||||
|
errorsMap.addAll({app.id: tr('appAlreadyAdded')});
|
||||||
|
} else {
|
||||||
|
await saveApps([app]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<List<String>> errors =
|
||||||
|
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class APKPicker extends StatefulWidget {
|
class APKPicker extends StatefulWidget {
|
||||||
const APKPicker({super.key, required this.app, this.initVal});
|
const APKPicker({super.key, required this.app, this.initVal, this.archs});
|
||||||
|
|
||||||
final App app;
|
final App app;
|
||||||
final String? initVal;
|
final String? initVal;
|
||||||
|
final List<String>? archs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<APKPicker> createState() => _APKPickerState();
|
State<APKPicker> createState() => _APKPickerState();
|
||||||
@ -308,33 +681,88 @@ class _APKPickerState extends State<APKPicker> {
|
|||||||
apkUrl ??= widget.initVal;
|
apkUrl ??= widget.initVal;
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: const Text('Pick an APK'),
|
title: Text(tr('pickAnAPK')),
|
||||||
content: Column(children: [
|
content: Column(children: [
|
||||||
Text('${widget.app.name} has more than one package:'),
|
Text(tr('appHasMoreThanOnePackage', args: [widget.app.name])),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
...widget.app.apkUrls.map((u) => RadioListTile<String>(
|
...widget.app.apkUrls.map(
|
||||||
title: Text(Uri.parse(u).pathSegments.last),
|
(u) => RadioListTile<String>(
|
||||||
value: u,
|
title: Text(Uri.parse(u)
|
||||||
groupValue: apkUrl,
|
.pathSegments
|
||||||
onChanged: (String? val) {
|
.where((element) => element.isNotEmpty)
|
||||||
setState(() {
|
.last),
|
||||||
apkUrl = val;
|
value: u,
|
||||||
});
|
groupValue: apkUrl,
|
||||||
}))
|
onChanged: (String? val) {
|
||||||
|
setState(() {
|
||||||
|
apkUrl = val;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
if (widget.archs != null)
|
||||||
|
const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
if (widget.archs != null)
|
||||||
|
Text(
|
||||||
|
widget.archs!.length == 1
|
||||||
|
? tr('deviceSupportsXArch', args: [widget.archs![0]])
|
||||||
|
: tr('deviceSupportsFollowingArchs') +
|
||||||
|
list2FriendlyString(
|
||||||
|
widget.archs!.map((e) => '\'$e\'').toList()),
|
||||||
|
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
Navigator.of(context).pop(null);
|
Navigator.of(context).pop(null);
|
||||||
},
|
},
|
||||||
child: const Text('Cancel')),
|
child: Text(tr('cancel'))),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.selectionClick();
|
||||||
Navigator.of(context).pop(apkUrl);
|
Navigator.of(context).pop(apkUrl);
|
||||||
},
|
},
|
||||||
child: const Text('Continue'))
|
child: Text(tr('continue')))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: Text(tr('warning')),
|
||||||
|
content: Text(tr('sourceIsXButPackageFromYPrompt', args: [
|
||||||
|
Uri.parse(widget.sourceUrl).host,
|
||||||
|
Uri.parse(widget.apkUrl).host
|
||||||
|
])),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(null);
|
||||||
|
},
|
||||||
|
child: Text(tr('cancel'))),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
},
|
||||||
|
child: Text(tr('continue')))
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
112
lib/providers/logs_provider.dart
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
||||||
|
const String logTable = 'logs';
|
||||||
|
const String idColumn = '_id';
|
||||||
|
const String levelColumn = 'level';
|
||||||
|
const String messageColumn = 'message';
|
||||||
|
const String timestampColumn = 'timestamp';
|
||||||
|
const String dbPath = 'logs.db';
|
||||||
|
|
||||||
|
enum LogLevels { debug, info, warning, error }
|
||||||
|
|
||||||
|
class Log {
|
||||||
|
int? id;
|
||||||
|
late LogLevels level;
|
||||||
|
late String message;
|
||||||
|
DateTime timestamp = DateTime.now();
|
||||||
|
|
||||||
|
Map<String, Object?> toMap() {
|
||||||
|
var map = <String, Object?>{
|
||||||
|
idColumn: id,
|
||||||
|
levelColumn: level.index,
|
||||||
|
messageColumn: message,
|
||||||
|
timestampColumn: timestamp.millisecondsSinceEpoch
|
||||||
|
};
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log(this.message, this.level);
|
||||||
|
|
||||||
|
Log.fromMap(Map<String, Object?> map) {
|
||||||
|
id = map[idColumn] as int;
|
||||||
|
level = LogLevels.values.elementAt(map[levelColumn] as int);
|
||||||
|
message = map[messageColumn] as String;
|
||||||
|
timestamp =
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(map[timestampColumn] as int);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return '${timestamp.toString()}: ${level.name}: $message';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogsProvider {
|
||||||
|
LogsProvider({bool runDefaultClear = true}) {
|
||||||
|
clear(before: DateTime.now().subtract(const Duration(days: 7)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Database? db;
|
||||||
|
|
||||||
|
Future<Database> getDB() async {
|
||||||
|
db ??= await openDatabase(dbPath, version: 1,
|
||||||
|
onCreate: (Database db, int version) async {
|
||||||
|
await db.execute('''
|
||||||
|
create table if not exists $logTable (
|
||||||
|
$idColumn integer primary key autoincrement,
|
||||||
|
$levelColumn integer not null,
|
||||||
|
$messageColumn text not null,
|
||||||
|
$timestampColumn integer not null)
|
||||||
|
''');
|
||||||
|
});
|
||||||
|
return db!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Log> add(String message, {LogLevels level = LogLevels.info}) async {
|
||||||
|
Log l = Log(message, level);
|
||||||
|
l.id = await (await getDB()).insert(logTable, l.toMap());
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(l);
|
||||||
|
}
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Log>> get({DateTime? before, DateTime? after}) async {
|
||||||
|
var where = getWhereDates(before: before, after: after);
|
||||||
|
return (await (await getDB())
|
||||||
|
.query(logTable, where: where.key, whereArgs: where.value))
|
||||||
|
.map((e) => Log.fromMap(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> clear({DateTime? before, DateTime? after}) async {
|
||||||
|
var where = getWhereDates(before: before, after: after);
|
||||||
|
var res = await (await getDB())
|
||||||
|
.delete(logTable, where: where.key, whereArgs: where.value);
|
||||||
|
if (res > 0) {
|
||||||
|
add(plural('clearedNLogsBeforeXAfterY', res,
|
||||||
|
namedArgs: {'before': before.toString(), 'after': after.toString()},
|
||||||
|
name: 'n'));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MapEntry<String?, List<int>?> getWhereDates(
|
||||||
|
{DateTime? before, DateTime? after}) {
|
||||||
|
List<String> where = [];
|
||||||
|
List<int> whereArgs = [];
|
||||||
|
if (before != null) {
|
||||||
|
where.add('$timestampColumn < ?');
|
||||||
|
whereArgs.add(before.millisecondsSinceEpoch);
|
||||||
|
}
|
||||||
|
if (after != null) {
|
||||||
|
where.add('$timestampColumn > ?');
|
||||||
|
whereArgs.add(after.millisecondsSinceEpoch);
|
||||||
|
}
|
||||||
|
return whereArgs.isEmpty
|
||||||
|
? const MapEntry(null, null)
|
||||||
|
: MapEntry(where.join(' and '), whereArgs);
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
// Exposes functions that can be used to send notifications to the user
|
// Exposes functions that can be used to send notifications to the user
|
||||||
// Contains a set of pre-defined ObtainiumNotification objects that should be used throughout the app
|
// Contains a set of pre-defined ObtainiumNotification objects that should be used throughout the app
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
@ -12,24 +13,41 @@ class ObtainiumNotification {
|
|||||||
late String channelName;
|
late String channelName;
|
||||||
late String channelDescription;
|
late String channelDescription;
|
||||||
Importance importance;
|
Importance importance;
|
||||||
|
bool onlyAlertOnce;
|
||||||
|
|
||||||
ObtainiumNotification(this.id, this.title, this.message, this.channelCode,
|
ObtainiumNotification(this.id, this.title, this.message, this.channelCode,
|
||||||
this.channelName, this.channelDescription, this.importance);
|
this.channelName, this.channelDescription, this.importance,
|
||||||
|
{this.onlyAlertOnce = false});
|
||||||
}
|
}
|
||||||
|
|
||||||
class UpdateNotification extends ObtainiumNotification {
|
class UpdateNotification extends ObtainiumNotification {
|
||||||
UpdateNotification(List<App> updates)
|
UpdateNotification(List<App> updates)
|
||||||
: super(
|
: super(
|
||||||
2,
|
2,
|
||||||
'Updates Available',
|
tr('updatesAvailable'),
|
||||||
'',
|
'',
|
||||||
'UPDATES_AVAILABLE',
|
'UPDATES_AVAILABLE',
|
||||||
'Updates Available',
|
tr('updatesAvailable'),
|
||||||
'Notifies the user that updates are available for one or more Apps tracked by Obtainium',
|
tr('updatesAvailableNotifDescription'),
|
||||||
Importance.max) {
|
Importance.max) {
|
||||||
|
message = updates.isEmpty
|
||||||
|
? tr('noNewUpdates')
|
||||||
|
: updates.length == 1
|
||||||
|
? tr('xHasAnUpdate', args: [updates[0].name])
|
||||||
|
: plural('xAndNMoreUpdatesAvailable', updates.length - 1,
|
||||||
|
args: [updates[0].name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SilentUpdateNotification extends ObtainiumNotification {
|
||||||
|
SilentUpdateNotification(List<App> updates)
|
||||||
|
: super(3, tr('appsUpdated'), '', 'APPS_UPDATED', tr('appsUpdated'),
|
||||||
|
tr('appsUpdatedNotifDescription'), Importance.defaultImportance) {
|
||||||
message = updates.length == 1
|
message = updates.length == 1
|
||||||
? '${updates[0].name} has an update.'
|
? tr('xWasUpdatedToY',
|
||||||
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.';
|
args: [updates[0].name, updates[0].latestVersion])
|
||||||
|
: plural('xAndNMoreUpdatesInstalled', updates.length - 1,
|
||||||
|
args: [updates[0].name]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,30 +55,57 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
|
|||||||
ErrorCheckingUpdatesNotification(String error)
|
ErrorCheckingUpdatesNotification(String error)
|
||||||
: super(
|
: super(
|
||||||
5,
|
5,
|
||||||
'Error Checking for Updates',
|
tr('errorCheckingUpdates'),
|
||||||
error,
|
error,
|
||||||
'BG_UPDATE_CHECK_ERROR',
|
'BG_UPDATE_CHECK_ERROR',
|
||||||
'Error Checking for Updates',
|
tr('errorCheckingUpdates'),
|
||||||
'A notification that shows when background update checking fails',
|
tr('errorCheckingUpdatesNotifDescription'),
|
||||||
Importance.high);
|
Importance.high);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AppsRemovedNotification extends ObtainiumNotification {
|
||||||
|
AppsRemovedNotification(List<List<String>> namedReasons)
|
||||||
|
: super(6, tr('appsRemoved'), '', 'APPS_REMOVED', tr('appsRemoved'),
|
||||||
|
tr('appsRemovedNotifDescription'), Importance.max) {
|
||||||
|
message = '';
|
||||||
|
for (var r in namedReasons) {
|
||||||
|
message += '${tr('xWasRemovedDueToErrorY', args: [r[0], r[1]])} \n';
|
||||||
|
}
|
||||||
|
message = message.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadNotification extends ObtainiumNotification {
|
||||||
|
DownloadNotification(String appName, int progPercent)
|
||||||
|
: super(
|
||||||
|
appName.hashCode,
|
||||||
|
'Downloading $appName',
|
||||||
|
'$progPercent%',
|
||||||
|
'APP_DOWNLOADING',
|
||||||
|
'Downloading App',
|
||||||
|
'Notifies the user of the progress in downloading an App',
|
||||||
|
Importance.defaultImportance,
|
||||||
|
onlyAlertOnce: true) {
|
||||||
|
message = tr('percentProgress', args: [progPercent.toString()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final completeInstallationNotification = ObtainiumNotification(
|
final completeInstallationNotification = ObtainiumNotification(
|
||||||
1,
|
1,
|
||||||
'Complete App Installation',
|
tr('completeAppInstallation'),
|
||||||
'Obtainium must be open to install Apps',
|
tr('obtainiumMustBeOpenToInstallApps'),
|
||||||
'COMPLETE_INSTALL',
|
'COMPLETE_INSTALL',
|
||||||
'Complete App Installation',
|
tr('completeAppInstallation'),
|
||||||
'Asks the user to return to Obtanium to finish installing an App',
|
tr('completeAppInstallationNotifDescription'),
|
||||||
Importance.max);
|
Importance.max);
|
||||||
|
|
||||||
final checkingUpdatesNotification = ObtainiumNotification(
|
final checkingUpdatesNotification = ObtainiumNotification(
|
||||||
4,
|
4,
|
||||||
'Checking for Updates',
|
tr('checkingForUpdates'),
|
||||||
'',
|
'',
|
||||||
'BG_UPDATE_CHECK',
|
'BG_UPDATE_CHECK',
|
||||||
'Checking for Updates',
|
tr('checkingForUpdates'),
|
||||||
'Transient notification that appears when checking for updates',
|
tr('checkingForUpdatesNotifDescription'),
|
||||||
Importance.min);
|
Importance.min);
|
||||||
|
|
||||||
class NotificationsProvider {
|
class NotificationsProvider {
|
||||||
@ -100,7 +145,9 @@ class NotificationsProvider {
|
|||||||
String channelName,
|
String channelName,
|
||||||
String channelDescription,
|
String channelDescription,
|
||||||
Importance importance,
|
Importance importance,
|
||||||
{bool cancelExisting = false}) async {
|
{bool cancelExisting = false,
|
||||||
|
int? progPercent,
|
||||||
|
bool onlyAlertOnce = false}) async {
|
||||||
if (cancelExisting) {
|
if (cancelExisting) {
|
||||||
await cancel(id);
|
await cancel(id);
|
||||||
}
|
}
|
||||||
@ -116,12 +163,16 @@ class NotificationsProvider {
|
|||||||
channelDescription: channelDescription,
|
channelDescription: channelDescription,
|
||||||
importance: importance,
|
importance: importance,
|
||||||
priority: importanceToPriority[importance]!,
|
priority: importanceToPriority[importance]!,
|
||||||
groupKey: 'dev.imranr.obtainium.$channelCode')));
|
groupKey: 'dev.imranr.obtainium.$channelCode',
|
||||||
|
progress: progPercent ?? 0,
|
||||||
|
maxProgress: 100,
|
||||||
|
showProgress: progPercent != null,
|
||||||
|
onlyAlertOnce: onlyAlertOnce)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> notify(ObtainiumNotification notif,
|
Future<void> notify(ObtainiumNotification notif,
|
||||||
{bool cancelExisting = false}) =>
|
{bool cancelExisting = false}) =>
|
||||||
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode,
|
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode,
|
||||||
notif.channelName, notif.channelDescription, notif.importance,
|
notif.channelName, notif.channelDescription, notif.importance,
|
||||||
cancelExisting: cancelExisting);
|
cancelExisting: cancelExisting, onlyAlertOnce: notif.onlyAlertOnce);
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,33 @@
|
|||||||
// Exposes functions used to save/load app settings
|
// Exposes functions used to save/load app settings
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
|
import 'package:obtainium/app_sources/github.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
String obtainiumTempId = 'imranr98_obtainium_${GitHub().host}';
|
||||||
|
String obtainiumId = 'dev.imranr.obtainium';
|
||||||
|
|
||||||
enum ThemeSettings { system, light, dark }
|
enum ThemeSettings { system, light, dark }
|
||||||
|
|
||||||
enum ColourSettings { basic, materialYou }
|
enum ColourSettings { basic, materialYou }
|
||||||
|
|
||||||
|
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 +60,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,12 +110,38 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
while (!(await Permission.requestInstallPackages.isGranted)) {
|
while (!(await Permission.requestInstallPackages.isGranted)) {
|
||||||
// Explicit request as InstallPlugin request sometimes bugged
|
// Explicit request as InstallPlugin request sometimes bugged
|
||||||
Fluttertoast.showToast(
|
Fluttertoast.showToast(
|
||||||
msg: 'Please allow Obtainium to install Apps',
|
msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG);
|
||||||
toastLength: Toast.LENGTH_LONG);
|
|
||||||
if ((await Permission.requestInstallPackages.request()) ==
|
if ((await Permission.requestInstallPackages.request()) ==
|
||||||
PermissionStatus.granted) {
|
PermissionStatus.granted) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,9 +3,20 @@
|
|||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:html/dom.dart';
|
import 'package:html/dom.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:html/parser.dart';
|
import 'package:obtainium/app_sources/apkmirror.dart';
|
||||||
|
import 'package:obtainium/app_sources/fdroid.dart';
|
||||||
|
import 'package:obtainium/app_sources/github.dart';
|
||||||
|
import 'package:obtainium/app_sources/gitlab.dart';
|
||||||
|
import 'package:obtainium/app_sources/izzyondroid.dart';
|
||||||
|
import 'package:obtainium/app_sources/mullvad.dart';
|
||||||
|
import 'package:obtainium/app_sources/signal.dart';
|
||||||
|
import 'package:obtainium/app_sources/sourceforge.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
|
import 'package:obtainium/mass_app_sources/githubstars.dart';
|
||||||
|
|
||||||
class AppNames {
|
class AppNames {
|
||||||
late String author;
|
late String author;
|
||||||
@ -30,28 +41,52 @@ class App {
|
|||||||
late String latestVersion;
|
late String latestVersion;
|
||||||
List<String> apkUrls = [];
|
List<String> apkUrls = [];
|
||||||
late int preferredApkIndex;
|
late int preferredApkIndex;
|
||||||
App(this.id, this.url, this.author, this.name, this.installedVersion,
|
late List<String> additionalData;
|
||||||
this.latestVersion, this.apkUrls, this.preferredApkIndex);
|
late DateTime? lastUpdateCheck;
|
||||||
|
bool pinned = false;
|
||||||
|
bool trackOnly = false;
|
||||||
|
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(
|
||||||
json['id'] as String,
|
json['id'] as String,
|
||||||
json['url'] as String,
|
json['url'] as String,
|
||||||
json['author'] as String,
|
json['author'] as String,
|
||||||
json['name'] as String,
|
json['name'] as String,
|
||||||
json['installedVersion'] == null
|
json['installedVersion'] == null
|
||||||
? null
|
? null
|
||||||
: json['installedVersion'] as String,
|
: json['installedVersion'] as String,
|
||||||
json['latestVersion'] as String,
|
json['latestVersion'] as String,
|
||||||
List<String>.from(jsonDecode(json['apkUrls'])),
|
json['apkUrls'] == null
|
||||||
json['preferredApkIndex'] == null
|
? []
|
||||||
? 0
|
: List<String>.from(jsonDecode(json['apkUrls'])),
|
||||||
: json['preferredApkIndex'] as int,
|
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
|
||||||
);
|
json['additionalData'] == null
|
||||||
|
? SourceProvider()
|
||||||
|
.getSource(json['url'])
|
||||||
|
.additionalSourceAppSpecificDefaults
|
||||||
|
: List<String>.from(jsonDecode(json['additionalData'])),
|
||||||
|
json['lastUpdateCheck'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
|
||||||
|
json['pinned'] ?? false,
|
||||||
|
json['trackOnly'] ?? false);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
@ -61,16 +96,33 @@ class App {
|
|||||||
'installedVersion': installedVersion,
|
'installedVersion': installedVersion,
|
||||||
'latestVersion': latestVersion,
|
'latestVersion': latestVersion,
|
||||||
'apkUrls': jsonEncode(apkUrls),
|
'apkUrls': jsonEncode(apkUrls),
|
||||||
'preferredApkIndex': preferredApkIndex
|
'preferredApkIndex': preferredApkIndex,
|
||||||
|
'additionalData': jsonEncode(additionalData),
|
||||||
|
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
|
||||||
|
'pinned': pinned,
|
||||||
|
'trackOnly': trackOnly
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 noAPKFound = 'No APK found';
|
||||||
|
|
||||||
List<String> getLinksFromParsedHTML(
|
List<String> getLinksFromParsedHTML(
|
||||||
Document dom, RegExp hrefPattern, String prependToLinks) =>
|
Document dom, RegExp hrefPattern, String prependToLinks) =>
|
||||||
dom
|
dom
|
||||||
@ -82,248 +134,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,
|
||||||
Response res = await get(Uri.parse('$standardUrl/releases/latest'));
|
{bool trackOnly = false}) {
|
||||||
if (res.statusCode == 200) {
|
throw NotImplementedError();
|
||||||
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: tr('trackOnly'),
|
||||||
|
type: FormItemType.bool,
|
||||||
|
key: 'trackOnlyFormItemKey')
|
||||||
|
];
|
||||||
|
final List<String> additionalAppSpecificSourceAgnosticDefaults = [''];
|
||||||
|
|
||||||
|
// Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider
|
||||||
|
List<GeneratedFormItem> additionalSourceSpecificSettingFormItems = [];
|
||||||
|
|
||||||
|
String? changeLogPageFromStandardUrl(String standardUrl) {
|
||||||
|
throw NotImplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
|
||||||
|
return apkUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canSearch = false;
|
||||||
|
Future<Map<String, String>> search(String query) {
|
||||||
|
throw NotImplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? tryInferringAppId(String standardUrl) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class GitLab implements AppSource {
|
ObtainiumError getObtainiumHttpError(Response res) {
|
||||||
@override
|
return ObtainiumError(res.reasonPhrase ??
|
||||||
late String host = 'gitlab.com';
|
tr('errorWithHttpStatusCode', args: [res.statusCode.toString()]));
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw 'Not a valid URL';
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var standardUri = Uri.parse(standardUrl);
|
|
||||||
var parsedHtml = parse(res.body);
|
|
||||||
var entry = parsedHtml.querySelector('entry');
|
|
||||||
var entryContent =
|
|
||||||
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
|
|
||||||
var apkUrlList = getLinksFromParsedHTML(
|
|
||||||
entryContent,
|
|
||||||
RegExp(
|
|
||||||
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
|
|
||||||
caseSensitive: false),
|
|
||||||
standardUri.origin);
|
|
||||||
if (apkUrlList.isEmpty) {
|
|
||||||
throw 'No APK found';
|
|
||||||
}
|
|
||||||
|
|
||||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
|
||||||
var version =
|
|
||||||
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
|
||||||
if (version == null) {
|
|
||||||
throw 'Could not determine latest release version';
|
|
||||||
}
|
|
||||||
return APKDetails(version, apkUrlList);
|
|
||||||
} else {
|
|
||||||
throw 'Unable to fetch release info';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
// Same as GitHub
|
|
||||||
return GitHub().getAppNames(standardUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 FDroid implements AppSource {
|
|
||||||
@override
|
|
||||||
late String host = 'f-droid.org';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw 'Not a valid URL';
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res = await get(Uri.parse(standardUrl));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var latestReleaseDiv =
|
|
||||||
parse(res.body).querySelector('#latest.package-version');
|
|
||||||
var apkUrl = latestReleaseDiv
|
|
||||||
?.querySelector('.package-version-download a')
|
|
||||||
?.attributes['href'];
|
|
||||||
if (apkUrl == null) {
|
|
||||||
throw 'No APK found';
|
|
||||||
}
|
|
||||||
var version = latestReleaseDiv
|
|
||||||
?.querySelector('.package-version-header b')
|
|
||||||
?.innerHtml
|
|
||||||
.split(' ')
|
|
||||||
.last;
|
|
||||||
if (version == null) {
|
|
||||||
throw 'Could not determine latest release version';
|
|
||||||
}
|
|
||||||
return APKDetails(version, [apkUrl]);
|
|
||||||
} else {
|
|
||||||
throw 'Unable to fetch release info';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
var name = Uri.parse(standardUrl).pathSegments.last;
|
|
||||||
return AppNames(name, name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Mullvad implements AppSource {
|
|
||||||
@override
|
|
||||||
late String host = 'mullvad.net';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw 'Not a valid URL';
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var version = parse(res.body)
|
|
||||||
.querySelector('p.subtitle.is-6')
|
|
||||||
?.querySelector('a')
|
|
||||||
?.attributes['href']
|
|
||||||
?.split('/')
|
|
||||||
.last;
|
|
||||||
if (version == null) {
|
|
||||||
throw 'Could not determine the latest release version';
|
|
||||||
}
|
|
||||||
return APKDetails(
|
|
||||||
version, ['https://mullvad.net/download/app/apk/latest']);
|
|
||||||
} else {
|
|
||||||
throw 'Unable to fetch release info';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class SourceProvider {
|
class SourceProvider {
|
||||||
List<AppSource> sources = [GitHub(), GitLab(), FDroid(), Mullvad(), 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}')) {
|
||||||
@ -332,33 +222,86 @@ class SourceProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (source == null) {
|
if (source == null) {
|
||||||
throw 'URL does not match a known source';
|
throw UnsupportedURLError();
|
||||||
}
|
}
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<App> getApp(String url) async {
|
bool ifSourceAppsRequireAdditionalData(AppSource source) {
|
||||||
if (url.toLowerCase().indexOf('http://') != 0 &&
|
for (var row in source.additionalSourceAppSpecificFormItems) {
|
||||||
url.toLowerCase().indexOf('https://') != 0) {
|
for (var element in row) {
|
||||||
url = 'https://$url';
|
if (element.required) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (url.toLowerCase().indexOf('https://www.') == 0) {
|
return false;
|
||||||
url = 'https://${url.substring(12)}';
|
|
||||||
}
|
|
||||||
AppSource source = getSource(url);
|
|
||||||
String standardUrl = source.standardizeURL(url);
|
|
||||||
AppNames names = source.getAppNames(standardUrl);
|
|
||||||
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
|
|
||||||
return App(
|
|
||||||
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
|
|
||||||
standardUrl,
|
|
||||||
names.author[0].toUpperCase() + names.author.substring(1),
|
|
||||||
names.name[0].toUpperCase() + names.name.substring(1),
|
|
||||||
null,
|
|
||||||
apk.version,
|
|
||||||
apk.apkUrls,
|
|
||||||
apk.apkUrls.length - 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
|
String generateTempID(AppNames names, AppSource source) =>
|
||||||
|
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}';
|
||||||
|
|
||||||
|
bool isTempId(String id) {
|
||||||
|
List<String> parts = id.split('_');
|
||||||
|
if (parts.length < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < parts.length - 1; i++) {
|
||||||
|
if (RegExp('.*[A-Z].*').hasMatch(parts[i])) {
|
||||||
|
// TODO: RegEx won't work for non-eng chars
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sources.map((e) => e.host).contains(parts.last);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<App> getApp(AppSource source, String url, List<String> additionalData,
|
||||||
|
{String name = '',
|
||||||
|
String? id,
|
||||||
|
bool pinned = false,
|
||||||
|
bool trackOnly = false,
|
||||||
|
String? installedVersion}) async {
|
||||||
|
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
||||||
|
AppNames names = source.getAppNames(standardUrl);
|
||||||
|
APKDetails apk = await source
|
||||||
|
.getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly);
|
||||||
|
if (apk.apkUrls.isEmpty && !trackOnly) {
|
||||||
|
throw NoAPKError();
|
||||||
|
}
|
||||||
|
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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
242
pubspec.lock
@ -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,41 @@ 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"
|
||||||
|
easy_localization:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: easy_localization
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
|
easy_logger:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: easy_logger
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.0.2"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -120,6 +176,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.3"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -131,14 +194,14 @@ packages:
|
|||||||
name: flutter_fgbg
|
name: flutter_fgbg
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0"
|
version: "0.2.2"
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: flutter_launcher_icons
|
name: flutter_launcher_icons
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.10.0"
|
version: "0.11.0"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@ -152,21 +215,33 @@ 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_localizations:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.7"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -183,14 +258,14 @@ packages:
|
|||||||
name: fluttertoast
|
name: fluttertoast
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.9"
|
version: "8.1.1"
|
||||||
html:
|
html:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: html
|
name: html
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.15.0"
|
version: "0.15.1"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -204,14 +279,14 @@ packages:
|
|||||||
name: http_parser
|
name: http_parser
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.1"
|
version: "4.0.2"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.0"
|
version: "3.2.2"
|
||||||
install_plugin_v2:
|
install_plugin_v2:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -219,6 +294,20 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
installed_apps:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: installed_apps
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.1"
|
||||||
|
intl:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.17.0"
|
||||||
js:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -232,14 +321,14 @@ packages:
|
|||||||
name: json_annotation
|
name: json_annotation
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.6.0"
|
version: "4.7.0"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: lints
|
name: lints
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.1"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -253,7 +342,7 @@ packages:
|
|||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0"
|
version: "0.1.5"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -261,6 +350,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.8.0"
|
version: "1.8.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -268,6 +364,20 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
package_archive_info:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: package_archive_info
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.0"
|
||||||
|
package_info:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -288,7 +398,7 @@ packages:
|
|||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.20"
|
version: "2.0.22"
|
||||||
path_provider_ios:
|
path_provider_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -316,56 +426,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 +489,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 +510,21 @@ packages:
|
|||||||
name: provider
|
name: provider
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.3"
|
version: "6.0.4"
|
||||||
|
share_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: share_plus
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.0"
|
||||||
|
share_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: share_plus_platform_interface
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -407,7 +538,7 @@ packages:
|
|||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.12"
|
version: "2.0.14"
|
||||||
shared_preferences_ios:
|
shared_preferences_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -435,7 +566,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 +592,21 @@ packages:
|
|||||||
name: source_span
|
name: source_span
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.0"
|
||||||
|
sqflite:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sqflite
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.1"
|
||||||
|
sqflite_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_common
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.0+2"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -483,6 +628,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
synchronized:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: synchronized
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0+3"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -503,7 +655,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 +669,14 @@ packages:
|
|||||||
name: url_launcher
|
name: url_launcher
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5"
|
version: "6.1.7"
|
||||||
url_launcher_android:
|
url_launcher_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.17"
|
version: "6.0.22"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -552,7 +704,7 @@ packages:
|
|||||||
name: url_launcher_platform_interface
|
name: url_launcher_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.1"
|
||||||
url_launcher_web:
|
url_launcher_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -567,6 +719,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 +746,28 @@ packages:
|
|||||||
name: webview_flutter_android
|
name: webview_flutter_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.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 +790,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.1.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=2.19.0-79.0.dev <3.0.0"
|
dart: ">=2.18.2 <3.0.0"
|
||||||
flutter: ">=3.1.0-0.0.pre.1036"
|
flutter: ">=3.3.0"
|
||||||
|
31
pubspec.yaml
@ -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.7+8 # When changing this, update the tag in main() accordingly
|
version: 0.8.5+69 # 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,34 +35,42 @@ 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
|
||||||
|
easy_localization: ^3.0.1
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_launcher_icons: ^0.10.0
|
flutter_launcher_icons: ^0.11.0
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
# 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
|
||||||
@ -82,9 +90,12 @@ flutter:
|
|||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# - assets:
|
||||||
# - images/a_dot_burr.jpeg
|
# - images/a_dot_burr.jpeg
|
||||||
# - images/a_dot_ham.jpeg
|
# - images/a_dot_ham.jpeg
|
||||||
|
|
||||||
|
assets:
|
||||||
|
- assets/translations/
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/assets-and-images/#resolution-aware
|
# https://flutter.dev/assets-and-images/#resolution-aware
|
||||||
|
@ -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);
|
||||||
|