Compare commits
156 Commits
v0.2.1-bet
...
v0.8.3-bet
Author | SHA1 | Date | |
---|---|---|---|
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 |
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,6 +10,7 @@ Currently supported App sources:
|
|||||||
- [GitHub](https://github.com/)
|
- [GitHub](https://github.com/)
|
||||||
- [GitLab](https://gitlab.com/)
|
- [GitLab](https://gitlab.com/)
|
||||||
- [F-Droid](https://f-droid.org/)
|
- [F-Droid](https://f-droid.org/)
|
||||||
|
- [IzzyOnDroid](https://android.izzysoft.de/)
|
||||||
- [Mullvad](https://mullvad.net/en/)
|
- [Mullvad](https://mullvad.net/en/)
|
||||||
- [Signal](https://signal.org/)
|
- [Signal](https://signal.org/)
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
}
|
@ -1,81 +1,82 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
class GeneratedFormItem {
|
|
||||||
late String message;
|
|
||||||
late bool required;
|
|
||||||
late int lines;
|
|
||||||
|
|
||||||
GeneratedFormItem(this.message, this.required, this.lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
class GeneratedFormModal extends StatefulWidget {
|
class GeneratedFormModal extends StatefulWidget {
|
||||||
const GeneratedFormModal(
|
const GeneratedFormModal(
|
||||||
{super.key, required this.title, required this.items});
|
{super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.items,
|
||||||
|
required this.defaultValues,
|
||||||
|
this.initValid = false,
|
||||||
|
this.message = ''});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final List<GeneratedFormItem> items;
|
final String message;
|
||||||
|
final List<List<GeneratedFormItem>> items;
|
||||||
|
final List<String> defaultValues;
|
||||||
|
final bool initValid;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
|
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
List<String> values = [];
|
||||||
|
bool valid = false;
|
||||||
|
|
||||||
final urlInputController = TextEditingController();
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
values = widget.defaultValues;
|
||||||
|
valid = widget.initValid || widget.items.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final formInputs = widget.items.map((e) {
|
|
||||||
final controller = TextEditingController();
|
|
||||||
return [
|
|
||||||
controller,
|
|
||||||
TextFormField(
|
|
||||||
decoration: InputDecoration(helperText: e.message),
|
|
||||||
controller: controller,
|
|
||||||
minLines: e.lines <= 1 ? null : e.lines,
|
|
||||||
maxLines: e.lines <= 1 ? 1 : e.lines,
|
|
||||||
validator: e.required
|
|
||||||
? (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return '${e.message} (required)';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
)
|
|
||||||
];
|
|
||||||
}).toList();
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: Text(widget.title),
|
title: Text(widget.title),
|
||||||
content: Form(
|
content:
|
||||||
key: _formKey,
|
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||||
child: Column(
|
if (widget.message.isNotEmpty) Text(widget.message),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
if (widget.message.isNotEmpty)
|
||||||
children: [...formInputs.map((e) => e[1] as Widget)],
|
const SizedBox(
|
||||||
)),
|
height: 16,
|
||||||
|
),
|
||||||
|
GeneratedForm(
|
||||||
|
items: widget.items,
|
||||||
|
onValueChanges: (values, valid, isBuilding) {
|
||||||
|
if (isBuilding) {
|
||||||
|
this.values = values;
|
||||||
|
this.valid = valid;
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
this.values = values;
|
||||||
|
this.valid = valid;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultValues: widget.defaultValues)
|
||||||
|
]),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
Navigator.of(context).pop(null);
|
Navigator.of(context).pop(null);
|
||||||
},
|
},
|
||||||
child: const Text('Cancel')),
|
child: Text(tr('cancel'))),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: !valid
|
||||||
if (_formKey.currentState?.validate() == true) {
|
? null
|
||||||
HapticFeedback.heavyImpact();
|
: () {
|
||||||
Navigator.of(context).pop(formInputs
|
if (valid) {
|
||||||
.map((e) => (e[0] as TextEditingController).value.text)
|
HapticFeedback.selectionClick();
|
||||||
.toList());
|
Navigator.of(context).pop(values);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Continue'))
|
child: Text(tr('continue')))
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add support for larger textarea so this can be used for text/json imports
|
|
123
lib/custom_errors.dart
Normal file
@ -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('');
|
||||||
|
}
|
195
lib/main.dart
@ -1,108 +1,188 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/pages/home.dart';
|
import 'package:obtainium/pages/home.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
|
import 'package:obtainium/providers/logs_provider.dart';
|
||||||
import 'package:obtainium/providers/notifications_provider.dart';
|
import 'package:obtainium/providers/notifications_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:workmanager/workmanager.dart';
|
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
|
||||||
|
const String currentVersion = '0.8.3';
|
||||||
const String currentReleaseTag =
|
const String currentReleaseTag =
|
||||||
'v0.2.1-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||||
|
|
||||||
|
const int bgUpdateCheckAlarmId = 666;
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
void bgTaskCallback() {
|
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
||||||
// Background update checking process
|
LogsProvider logs = LogsProvider();
|
||||||
Workmanager().executeTask((task, taskName) async {
|
logs.add(tr('startedBgUpdateTask'));
|
||||||
var notificationsProvider = NotificationsProvider();
|
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
|
||||||
await notificationsProvider.notify(checkingUpdatesNotification);
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await EasyLocalization.ensureInitialized();
|
||||||
|
await AndroidAlarmManager.initialize();
|
||||||
|
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
|
||||||
|
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
|
||||||
|
: null;
|
||||||
|
logs.add(tr('bgUpdateIgnoreAfterIs', args: [ignoreAfter.toString()]));
|
||||||
|
var notificationsProvider = NotificationsProvider();
|
||||||
|
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||||
|
try {
|
||||||
|
var appsProvider = AppsProvider(forBGTask: true);
|
||||||
|
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||||
|
await appsProvider.loadApps();
|
||||||
|
List<String> existingUpdateIds =
|
||||||
|
appsProvider.findExistingUpdates(installedOnly: true);
|
||||||
|
DateTime nextIgnoreAfter = DateTime.now();
|
||||||
|
String? err;
|
||||||
try {
|
try {
|
||||||
var appsProvider = AppsProvider();
|
logs.add(tr('startedActualBGUpdateCheck'));
|
||||||
await notificationsProvider
|
await appsProvider.checkUpdates(
|
||||||
.cancel(ErrorCheckingUpdatesNotification('').id);
|
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
|
||||||
await appsProvider.loadApps();
|
|
||||||
List<App> updates = await appsProvider.checkUpdates();
|
|
||||||
if (updates.isNotEmpty) {
|
|
||||||
notificationsProvider.notify(UpdateNotification(updates),
|
|
||||||
cancelExisting: true);
|
|
||||||
}
|
|
||||||
return Future.value(true);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notificationsProvider.notify(
|
if (e is RateLimitError || e is SocketException) {
|
||||||
ErrorCheckingUpdatesNotification(e.toString()),
|
var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15;
|
||||||
cancelExisting: true);
|
logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes,
|
||||||
return Future.value(false);
|
args: [e.runtimeType.toString()]));
|
||||||
} finally {
|
AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes),
|
||||||
await notificationsProvider.cancel(checkingUpdatesNotification.id);
|
Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: {
|
||||||
|
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
err = e.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
List<App> newUpdates = appsProvider
|
||||||
|
.findExistingUpdates(installedOnly: true)
|
||||||
|
.where((id) => !existingUpdateIds.contains(id))
|
||||||
|
.map((e) => appsProvider.apps[e]!.app)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// TODO: This silent update code doesn't work yet
|
||||||
|
// List<String> silentlyUpdated = await appsProvider
|
||||||
|
// .downloadAndInstallLatestApp(
|
||||||
|
// [...newUpdates.map((e) => e.id), ...existingUpdateIds], null);
|
||||||
|
// if (silentlyUpdated.isNotEmpty) {
|
||||||
|
// newUpdates = newUpdates
|
||||||
|
// .where((element) => !silentlyUpdated.contains(element.id))
|
||||||
|
// .toList();
|
||||||
|
// notificationsProvider.notify(
|
||||||
|
// SilentUpdateNotification(
|
||||||
|
// silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()),
|
||||||
|
// cancelExisting: true);
|
||||||
|
// }
|
||||||
|
logs.add(
|
||||||
|
plural('bgCheckFoundUpdatesWillNotifyIfNeeded', newUpdates.length));
|
||||||
|
if (newUpdates.isNotEmpty) {
|
||||||
|
notificationsProvider.notify(UpdateNotification(newUpdates));
|
||||||
|
}
|
||||||
|
if (err != null) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notificationsProvider
|
||||||
|
.notify(ErrorCheckingUpdatesNotification(e.toString()));
|
||||||
|
} finally {
|
||||||
|
logs.add(tr('bgUpdateTaskFinished'));
|
||||||
|
await notificationsProvider.cancel(checkingUpdatesNotification.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) {
|
await EasyLocalization.ensureInitialized();
|
||||||
|
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
||||||
);
|
);
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
}
|
}
|
||||||
Workmanager().initialize(
|
await AndroidAlarmManager.initialize();
|
||||||
bgTaskCallback,
|
|
||||||
);
|
|
||||||
runApp(MultiProvider(
|
runApp(MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(create: (context) => AppsProvider()),
|
||||||
create: (context) => AppsProvider(
|
|
||||||
shouldLoadApps: true,
|
|
||||||
shouldCheckUpdatesAfterLoad: true,
|
|
||||||
shouldDeleteAPKs: true)),
|
|
||||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||||
Provider(create: (context) => NotificationsProvider())
|
Provider(create: (context) => NotificationsProvider()),
|
||||||
|
Provider(create: (context) => LogsProvider())
|
||||||
],
|
],
|
||||||
child: const MyApp(),
|
child: EasyLocalization(
|
||||||
|
supportedLocales: const [Locale('en')],
|
||||||
|
path: 'assets/translations',
|
||||||
|
fallbackLocale: const Locale('en'),
|
||||||
|
child: const Obtainium()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultThemeColour = Colors.deepPurple;
|
var defaultThemeColour = Colors.deepPurple;
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class Obtainium extends StatefulWidget {
|
||||||
const MyApp({super.key});
|
const Obtainium({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<Obtainium> createState() => _ObtainiumState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ObtainiumState extends State<Obtainium> {
|
||||||
|
var existingUpdateInterval = -1;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
||||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||||
|
LogsProvider logs = context.read<LogsProvider>();
|
||||||
|
|
||||||
if (settingsProvider.prefs == null) {
|
if (settingsProvider.prefs == null) {
|
||||||
settingsProvider.initializeSettings();
|
settingsProvider.initializeSettings();
|
||||||
} else {
|
} else {
|
||||||
// Register the background update task according to the user's setting
|
|
||||||
if (settingsProvider.updateInterval > 0) {
|
|
||||||
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
|
|
||||||
frequency: Duration(minutes: settingsProvider.updateInterval),
|
|
||||||
initialDelay: Duration(minutes: settingsProvider.updateInterval),
|
|
||||||
constraints: Constraints(networkType: NetworkType.connected),
|
|
||||||
existingWorkPolicy: ExistingWorkPolicy.replace);
|
|
||||||
} else {
|
|
||||||
Workmanager().cancelByUniqueName('bg-update-check');
|
|
||||||
}
|
|
||||||
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
|
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
|
||||||
if (isFirstRun) {
|
if (isFirstRun) {
|
||||||
|
logs.add(tr('firstRun'));
|
||||||
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
||||||
Permission.notification.request();
|
Permission.notification.request();
|
||||||
appsProvider.saveApp(App(
|
appsProvider.saveApps([
|
||||||
'imranr98_obtainium_${GitHub().host}',
|
App(
|
||||||
'https://github.com/ImranR98/Obtainium',
|
obtainiumId,
|
||||||
'ImranR98',
|
'https://github.com/ImranR98/Obtainium',
|
||||||
'Obtainium',
|
'ImranR98',
|
||||||
currentReleaseTag,
|
'Obtainium',
|
||||||
currentReleaseTag,
|
currentReleaseTag,
|
||||||
[],
|
currentReleaseTag,
|
||||||
0));
|
[],
|
||||||
|
0,
|
||||||
|
['true'],
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// Register the background update task according to the user's setting
|
||||||
|
if (existingUpdateInterval != settingsProvider.updateInterval) {
|
||||||
|
if (existingUpdateInterval != -1) {
|
||||||
|
logs.add(tr('settingUpdateCheckIntervalTo',
|
||||||
|
args: [settingsProvider.updateInterval.toString()]));
|
||||||
|
}
|
||||||
|
existingUpdateInterval = settingsProvider.updateInterval;
|
||||||
|
if (existingUpdateInterval == 0) {
|
||||||
|
AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
|
||||||
|
} else {
|
||||||
|
AndroidAlarmManager.periodic(
|
||||||
|
Duration(minutes: existingUpdateInterval),
|
||||||
|
bgUpdateCheckAlarmId,
|
||||||
|
bgUpdateCheck,
|
||||||
|
rescheduleOnReboot: true,
|
||||||
|
wakeup: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,6 +203,9 @@ class MyApp extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Obtainium',
|
title: 'Obtainium',
|
||||||
|
localizationsDelegates: context.localizationDelegates,
|
||||||
|
supportedLocales: context.supportedLocales,
|
||||||
|
locale: context.locale,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: settingsProvider.theme == ThemeSettings.dark
|
colorScheme: settingsProvider.theme == ThemeSettings.dark
|
||||||
|
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,7 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@ -16,70 +19,115 @@ class AppPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AppPageState extends State<AppPage> {
|
class _AppPageState extends State<AppPage> {
|
||||||
|
AppInMemory? prevApp;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var appsProvider = context.watch<AppsProvider>();
|
var appsProvider = context.watch<AppsProvider>();
|
||||||
var settingsProvider = context.watch<SettingsProvider>();
|
var settingsProvider = context.watch<SettingsProvider>();
|
||||||
|
getUpdate(String id) {
|
||||||
|
appsProvider.checkUpdate(id).catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceProvider = SourceProvider();
|
||||||
AppInMemory? app = appsProvider.apps[widget.appId];
|
AppInMemory? app = appsProvider.apps[widget.appId];
|
||||||
if (app?.app.installedVersion != null) {
|
var source = app != null ? sourceProvider.getSource(app.app.url) : null;
|
||||||
appsProvider.getUpdate(app!.app.id);
|
if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) {
|
||||||
|
prevApp = app;
|
||||||
|
getUpdate(app.app.id);
|
||||||
}
|
}
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
|
||||||
title: Text('${app?.app.author}/${app?.app.name}'),
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
),
|
body: RefreshIndicator(
|
||||||
body: settingsProvider.showAppWebpage
|
child: settingsProvider.showAppWebpage
|
||||||
? WebView(
|
? WebView(
|
||||||
initialUrl: app?.app.url,
|
backgroundColor: Theme.of(context).colorScheme.background,
|
||||||
javascriptMode: JavascriptMode.unrestricted,
|
initialUrl: app?.app.url,
|
||||||
)
|
javascriptMode: JavascriptMode.unrestricted,
|
||||||
: Column(
|
)
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
: CustomScrollView(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
slivers: [
|
||||||
children: [
|
SliverFillRemaining(
|
||||||
Text(
|
child: Column(
|
||||||
app?.app.name ?? 'App',
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
textAlign: TextAlign.center,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
style: Theme.of(context).textTheme.displayLarge,
|
children: [
|
||||||
),
|
app?.installedInfo != null
|
||||||
Text(
|
? Row(
|
||||||
'By ${app?.app.author ?? 'Unknown'}',
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
textAlign: TextAlign.center,
|
children: [
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
Image.memory(
|
||||||
),
|
app!.installedInfo!.icon!,
|
||||||
const SizedBox(
|
height: 150,
|
||||||
height: 32,
|
gaplessPlayback: true,
|
||||||
),
|
)
|
||||||
GestureDetector(
|
])
|
||||||
onTap: () {
|
: Container(),
|
||||||
if (app?.app.url != null) {
|
const SizedBox(
|
||||||
launchUrlString(app?.app.url ?? '',
|
height: 25,
|
||||||
mode: LaunchMode.externalApplication);
|
),
|
||||||
}
|
Text(
|
||||||
},
|
app?.installedInfo?.name ?? app?.app.name ?? 'App',
|
||||||
child: Text(
|
textAlign: TextAlign.center,
|
||||||
app?.app.url ?? '',
|
style: Theme.of(context).textTheme.displayLarge,
|
||||||
textAlign: TextAlign.center,
|
),
|
||||||
style: const TextStyle(
|
Text(
|
||||||
decoration: TextDecoration.underline,
|
'By ${app?.app.author ?? 'Unknown'}',
|
||||||
fontStyle: FontStyle.italic,
|
textAlign: TextAlign.center,
|
||||||
fontSize: 12),
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (app?.app.url != null) {
|
||||||
|
launchUrlString(app?.app.url ?? '',
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
app?.app.url ?? '',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
fontSize: 12),
|
||||||
|
)),
|
||||||
|
const SizedBox(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Installed Version: ${app?.app.installedVersion ?? 'None'}${app?.app.trackOnly == true ? ' (Estimate)\n\nApp is Track-Only' : ''}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontStyle: FontStyle.italic, fontSize: 12),
|
||||||
|
)
|
||||||
|
],
|
||||||
)),
|
)),
|
||||||
const SizedBox(
|
],
|
||||||
height: 32,
|
|
||||||
),
|
),
|
||||||
Text(
|
onRefresh: () async {
|
||||||
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
|
if (app != null) {
|
||||||
textAlign: TextAlign.center,
|
getUpdate(app.app.id);
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
}
|
||||||
),
|
}),
|
||||||
Text(
|
|
||||||
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
bottomSheet: Padding(
|
bottomSheet: Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
0, 0, 0, MediaQuery.of(context).padding.bottom),
|
0, 0, 0, MediaQuery.of(context).padding.bottom),
|
||||||
@ -91,84 +139,138 @@ class _AppPageState extends State<AppPage> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
if (app?.app.installedVersion == null)
|
if (app?.app.installedVersion != null &&
|
||||||
|
app?.app.trackOnly == false &&
|
||||||
|
app?.app.installedVersion !=
|
||||||
|
app?.app.latestVersion &&
|
||||||
|
app?.app.enhancedVersionDetection != true)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: app?.downloadProgress != null
|
||||||
showDialog(
|
? null
|
||||||
context: context,
|
: () {
|
||||||
builder: (BuildContext ctx) {
|
showDialog(
|
||||||
return AlertDialog(
|
context: context,
|
||||||
title: const Text(
|
builder: (BuildContext ctx) {
|
||||||
'App Already Installed?'),
|
return AlertDialog(
|
||||||
actions: [
|
title: const Text(
|
||||||
TextButton(
|
'App Already up to Date?'),
|
||||||
onPressed: () {
|
actions: [
|
||||||
Navigator.of(context).pop();
|
TextButton(
|
||||||
},
|
onPressed: () {
|
||||||
child: const Text('No')),
|
Navigator.of(context)
|
||||||
TextButton(
|
.pop();
|
||||||
onPressed: () {
|
},
|
||||||
var updatedApp = app?.app;
|
child: const Text('No')),
|
||||||
if (updatedApp != null) {
|
TextButton(
|
||||||
updatedApp.installedVersion =
|
onPressed: () {
|
||||||
updatedApp.latestVersion;
|
HapticFeedback
|
||||||
appsProvider
|
.selectionClick();
|
||||||
.saveApp(updatedApp);
|
var updatedApp = app?.app;
|
||||||
}
|
if (updatedApp != null) {
|
||||||
Navigator.of(context).pop();
|
updatedApp
|
||||||
},
|
.installedVersion =
|
||||||
child: const Text(
|
updatedApp
|
||||||
'Yes, Mark as Installed'))
|
.latestVersion;
|
||||||
],
|
appsProvider.saveApps(
|
||||||
);
|
[updatedApp]);
|
||||||
});
|
}
|
||||||
},
|
Navigator.of(context)
|
||||||
tooltip: 'Mark as Installed',
|
.pop();
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'Yes, Mark as Updated'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: 'Mark as Updated',
|
||||||
icon: const Icon(Icons.done)),
|
icon: const Icon(Icons.done)),
|
||||||
if (app?.app.installedVersion == null)
|
if (source != null &&
|
||||||
const SizedBox(width: 16.0),
|
source.additionalSourceAppSpecificFormItems
|
||||||
|
.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
onPressed: app?.downloadProgress != null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
showDialog<List<String>>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: 'Additional Options',
|
||||||
|
items: source
|
||||||
|
.additionalSourceAppSpecificFormItems,
|
||||||
|
defaultValues: app != null
|
||||||
|
? app.app.additionalData
|
||||||
|
: source
|
||||||
|
.additionalSourceAppSpecificDefaults);
|
||||||
|
}).then((values) {
|
||||||
|
if (app != null && values != null) {
|
||||||
|
var changedApp = app.app;
|
||||||
|
changedApp.additionalData = values;
|
||||||
|
appsProvider.saveApps(
|
||||||
|
[changedApp]).then((value) {
|
||||||
|
getUpdate(changedApp.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: 'Additional Options',
|
||||||
|
icon: const Icon(Icons.settings)),
|
||||||
|
const SizedBox(width: 16.0),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (app?.app.installedVersion == null ||
|
onPressed: (app?.app.installedVersion == null ||
|
||||||
appsProvider
|
app?.app.installedVersion !=
|
||||||
.checkAppObjectForUpdate(
|
app?.app.latestVersion) &&
|
||||||
app!.app)) &&
|
|
||||||
!appsProvider.areDownloadsRunning()
|
!appsProvider.areDownloadsRunning()
|
||||||
? () {
|
? () {
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback.heavyImpact();
|
||||||
appsProvider
|
() async {
|
||||||
.downloadAndInstallLatestApp(
|
if (app?.app.trackOnly != true) {
|
||||||
[app!.app.id],
|
await settingsProvider
|
||||||
context).then((res) {
|
.getInstallPermission();
|
||||||
if (res && mounted) {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
.then((value) {
|
||||||
|
appsProvider
|
||||||
|
.downloadAndInstallLatestApps(
|
||||||
|
[app!.app.id],
|
||||||
|
context).then((res) {
|
||||||
|
if (res.isNotEmpty && mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
child: Text(app?.app.installedVersion == null
|
child: Text(app?.app.installedVersion == null
|
||||||
? 'Install'
|
? app?.app.trackOnly == false
|
||||||
: 'Update'))),
|
? 'Install'
|
||||||
|
: 'Mark Installed'
|
||||||
|
: app?.app.trackOnly == false
|
||||||
|
? 'Update'
|
||||||
|
: 'Mark Updated'))),
|
||||||
const SizedBox(width: 16.0),
|
const SizedBox(width: 16.0),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: app?.downloadProgress != null
|
onPressed: app?.downloadProgress != null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Remove App?'),
|
title: const Text('Remove App?'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'This will remove \'${app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'),
|
'This will remove \'${app?.installedInfo?.name ?? app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback
|
||||||
appsProvider
|
.selectionClick();
|
||||||
.removeApp(app!.app.id)
|
appsProvider.removeApps(
|
||||||
.then((_) {
|
[app!.app.id]).then((_) {
|
||||||
int count = 0;
|
int count = 0;
|
||||||
Navigator.of(context)
|
Navigator.of(context)
|
||||||
.popUntil((_) =>
|
.popUntil((_) =>
|
||||||
@ -178,7 +280,6 @@ class _AppPageState extends State<AppPage> {
|
|||||||
child: const Text('Remove')),
|
child: const Text('Remove')),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: const Text('Cancel'))
|
child: const Text('Cancel'))
|
||||||
|
@ -1,95 +1,752 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/pages/app.dart';
|
import 'package:obtainium/pages/app.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class AppsPage extends StatefulWidget {
|
class AppsPage extends StatefulWidget {
|
||||||
const AppsPage({super.key});
|
const AppsPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AppsPage> createState() => _AppsPageState();
|
State<AppsPage> createState() => AppsPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppsPageState extends State<AppsPage> {
|
class AppsPageState extends State<AppsPage> {
|
||||||
|
AppsFilter? filter;
|
||||||
|
var updatesOnlyFilter =
|
||||||
|
AppsFilter(includeUptodate: false, includeNonInstalled: false);
|
||||||
|
Set<App> selectedApps = {};
|
||||||
|
DateTime? refreshingSince;
|
||||||
|
|
||||||
|
clearSelected() {
|
||||||
|
if (selectedApps.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
selectedApps.clear();
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectThese(List<App> apps) {
|
||||||
|
if (selectedApps.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
for (var a in apps) {
|
||||||
|
selectedApps.add(a);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var appsProvider = context.watch<AppsProvider>();
|
var appsProvider = context.watch<AppsProvider>();
|
||||||
var settingsProvider = context.watch<SettingsProvider>();
|
var settingsProvider = context.watch<SettingsProvider>();
|
||||||
var existingUpdateAppIds = appsProvider.getExistingUpdates();
|
|
||||||
var sortedApps = appsProvider.apps.values.toList();
|
var sortedApps = appsProvider.apps.values.toList();
|
||||||
|
var currentFilterIsUpdatesOnly =
|
||||||
|
filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
|
||||||
|
|
||||||
|
selectedApps = selectedApps
|
||||||
|
.where((element) => sortedApps.map((e) => e.app).contains(element))
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
toggleAppSelected(App app) {
|
||||||
|
setState(() {
|
||||||
|
if (selectedApps.contains(app)) {
|
||||||
|
selectedApps.remove(app);
|
||||||
|
} else {
|
||||||
|
selectedApps.add(app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter != null) {
|
||||||
|
sortedApps = sortedApps.where((app) {
|
||||||
|
if (app.app.installedVersion == app.app.latestVersion &&
|
||||||
|
!(filter!.includeUptodate)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (app.app.installedVersion == null &&
|
||||||
|
!(filter!.includeNonInstalled)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
List<String> nameTokens = filter!.nameFilter
|
||||||
|
.split(' ')
|
||||||
|
.where((element) => element.trim().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
List<String> authorTokens = filter!.authorFilter
|
||||||
|
.split(' ')
|
||||||
|
.where((element) => element.trim().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
for (var t in nameTokens) {
|
||||||
|
var name = app.installedInfo?.name ?? app.app.name;
|
||||||
|
if (!name.toLowerCase().contains(t.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var t in authorTokens) {
|
||||||
|
if (!app.app.author.toLowerCase().contains(t.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
sortedApps.sort((a, b) {
|
sortedApps.sort((a, b) {
|
||||||
|
var nameA = a.installedInfo?.name ?? a.app.name;
|
||||||
|
var nameB = b.installedInfo?.name ?? b.app.name;
|
||||||
int result = 0;
|
int result = 0;
|
||||||
if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
|
if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
|
||||||
result =
|
result = (a.app.author + nameA).compareTo(b.app.author + nameB);
|
||||||
(a.app.author + a.app.name).compareTo(b.app.author + b.app.name);
|
|
||||||
} else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) {
|
} else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) {
|
||||||
result =
|
result = (nameA + a.app.author).compareTo(nameB + b.app.author);
|
||||||
(a.app.name + a.app.author).compareTo(b.app.name + b.app.author);
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
if (settingsProvider.sortOrder == SortOrderSettings.ascending) {
|
|
||||||
|
if (settingsProvider.sortOrder == SortOrderSettings.descending) {
|
||||||
sortedApps = sortedApps.reversed.toList();
|
sortedApps = sortedApps.reversed.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
|
||||||
|
|
||||||
|
var existingUpdateIdsAllOrSelected = existingUpdates
|
||||||
|
.where((element) => selectedApps.isEmpty
|
||||||
|
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||||
|
: selectedApps.map((e) => e.id).contains(element))
|
||||||
|
.toList();
|
||||||
|
var newInstallIdsAllOrSelected = appsProvider
|
||||||
|
.findExistingUpdates(nonInstalledOnly: true)
|
||||||
|
.where((element) => selectedApps.isEmpty
|
||||||
|
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||||
|
: selectedApps.map((e) => e.id).contains(element))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<String> trackOnlyUpdateIdsAllOrSelected = [];
|
||||||
|
existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) {
|
||||||
|
if (appsProvider.apps[id]!.app.trackOnly) {
|
||||||
|
trackOnlyUpdateIdsAllOrSelected.add(id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) {
|
||||||
|
if (appsProvider.apps[id]!.app.trackOnly) {
|
||||||
|
trackOnlyUpdateIdsAllOrSelected.add(id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (settingsProvider.pinUpdates) {
|
||||||
|
var temp = [];
|
||||||
|
sortedApps = sortedApps.where((sa) {
|
||||||
|
if (existingUpdates.contains(sa.app.id)) {
|
||||||
|
temp.add(sa);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
sortedApps = [...temp, ...sortedApps];
|
||||||
|
}
|
||||||
|
|
||||||
|
var tempPinned = [];
|
||||||
|
var tempNotPinned = [];
|
||||||
|
for (var a in sortedApps) {
|
||||||
|
if (a.app.pinned) {
|
||||||
|
tempPinned.add(a);
|
||||||
|
} else {
|
||||||
|
tempNotPinned.add(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sortedApps = [...tempPinned, ...tempNotPinned];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
floatingActionButton: existingUpdateAppIds.isEmpty
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
? null
|
body: RefreshIndicator(
|
||||||
: ElevatedButton.icon(
|
onRefresh: () {
|
||||||
onPressed: appsProvider.areDownloadsRunning()
|
HapticFeedback.lightImpact();
|
||||||
? null
|
setState(() {
|
||||||
: () {
|
refreshingSince = DateTime.now();
|
||||||
HapticFeedback.heavyImpact();
|
});
|
||||||
settingsProvider.getInstallPermission().then((_) {
|
return appsProvider.checkUpdates().catchError((e) {
|
||||||
appsProvider.downloadAndInstallLatestApp(
|
showError(e, context);
|
||||||
existingUpdateAppIds, context);
|
}).whenComplete(() {
|
||||||
});
|
setState(() {
|
||||||
},
|
refreshingSince = null;
|
||||||
icon: const Icon(Icons.update),
|
});
|
||||||
label: const Text('Update All')),
|
});
|
||||||
body: Center(
|
},
|
||||||
child: appsProvider.loadingApps
|
child: CustomScrollView(slivers: <Widget>[
|
||||||
? const CircularProgressIndicator()
|
CustomAppBar(title: tr('appsString')),
|
||||||
: appsProvider.apps.isEmpty
|
if (appsProvider.loadingApps || sortedApps.isEmpty)
|
||||||
? Text(
|
SliverFillRemaining(
|
||||||
'No Apps',
|
child: Center(
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
child: appsProvider.loadingApps
|
||||||
)
|
? const CircularProgressIndicator()
|
||||||
: RefreshIndicator(
|
: Text(
|
||||||
onRefresh: () {
|
appsProvider.apps.isEmpty
|
||||||
HapticFeedback.lightImpact();
|
? tr('noApps')
|
||||||
return appsProvider.checkUpdates();
|
: tr('noAppsForFilter'),
|
||||||
},
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
child: ListView(
|
textAlign: TextAlign.center,
|
||||||
children: sortedApps
|
))),
|
||||||
.map(
|
if (refreshingSince != null)
|
||||||
(e) => ListTile(
|
SliverToBoxAdapter(
|
||||||
title: Text('${e.app.author}/${e.app.name}'),
|
child: LinearProgressIndicator(
|
||||||
subtitle: Text(
|
value: appsProvider.apps.values
|
||||||
e.app.installedVersion ?? 'Not Installed'),
|
.where((element) => !(element.app.lastUpdateCheck
|
||||||
trailing: e.downloadProgress != null
|
?.isBefore(refreshingSince!) ??
|
||||||
? Text(
|
true))
|
||||||
'Downloading - ${e.downloadProgress?.toInt()}%')
|
.length /
|
||||||
: (e.app.installedVersion != null &&
|
appsProvider.apps.length,
|
||||||
e.app.installedVersion !=
|
),
|
||||||
e.app.latestVersion
|
),
|
||||||
? const Text('Update Available')
|
SliverList(
|
||||||
: null),
|
delegate: SliverChildBuilderDelegate(
|
||||||
onTap: () {
|
(BuildContext context, int index) {
|
||||||
Navigator.push(
|
String? changesUrl = SourceProvider()
|
||||||
context,
|
.getSource(sortedApps[index].app.url)
|
||||||
MaterialPageRoute(
|
.changeLogPageFromStandardUrl(sortedApps[index].app.url);
|
||||||
builder: (context) =>
|
return ListTile(
|
||||||
AppPage(appId: e.app.id)),
|
tileColor: sortedApps[index].app.pinned
|
||||||
);
|
? Colors.grey.withOpacity(0.1)
|
||||||
},
|
: Colors.transparent,
|
||||||
),
|
selectedTileColor: Theme.of(context)
|
||||||
)
|
.colorScheme
|
||||||
.toList(),
|
.primary
|
||||||
|
.withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1),
|
||||||
|
selected: selectedApps.contains(sortedApps[index].app),
|
||||||
|
onLongPress: () {
|
||||||
|
toggleAppSelected(sortedApps[index].app);
|
||||||
|
},
|
||||||
|
leading: sortedApps[index].installedInfo != null
|
||||||
|
? Image.memory(
|
||||||
|
sortedApps[index].installedInfo!.icon!,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
title: Text(
|
||||||
|
sortedApps[index].installedInfo?.name ??
|
||||||
|
sortedApps[index].app.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: sortedApps[index].app.pinned
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal),
|
||||||
|
),
|
||||||
|
subtitle: Text(tr('byX', args: [sortedApps[index].app.author]),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: sortedApps[index].app.pinned
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal)),
|
||||||
|
trailing: sortedApps[index].downloadProgress != null
|
||||||
|
? Text(tr('percentProgress', args: [
|
||||||
|
sortedApps[index]
|
||||||
|
.downloadProgress
|
||||||
|
?.toInt()
|
||||||
|
.toString() ??
|
||||||
|
'100'
|
||||||
|
]))
|
||||||
|
: (Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
SingleChildScrollView(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 80,
|
||||||
|
child: Text(
|
||||||
|
'${sortedApps[index].app.installedVersion ?? tr('notInstalled')}${sortedApps[index].app.trackOnly == true ? ' ${tr('estimateInBrackets')}' : ''}',
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
))),
|
||||||
|
sortedApps[index].app.installedVersion != null &&
|
||||||
|
sortedApps[index].app.installedVersion !=
|
||||||
|
sortedApps[index].app.latestVersion
|
||||||
|
? GestureDetector(
|
||||||
|
onTap: changesUrl == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
launchUrlString(changesUrl,
|
||||||
|
mode: LaunchMode
|
||||||
|
.externalApplication);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'${tr('updateAvailable')}${sortedApps[index].app.trackOnly ? ' ${tr('estimateInBracketsShort')}' : ''}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
decoration: changesUrl == null
|
||||||
|
? TextDecoration.none
|
||||||
|
: TextDecoration.underline),
|
||||||
|
))
|
||||||
|
: const SizedBox(),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
onTap: () {
|
||||||
|
if (selectedApps.isNotEmpty) {
|
||||||
|
toggleAppSelected(sortedApps[index].app);
|
||||||
|
} else {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
AppPage(appId: sortedApps[index].app.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, childCount: sortedApps.length))
|
||||||
|
])),
|
||||||
|
persistentFooterButtons: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
selectedApps.isEmpty
|
||||||
|
? selectThese(sortedApps.map((e) => e.app).toList())
|
||||||
|
: clearSelected();
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
selectedApps.isEmpty
|
||||||
|
? Icons.select_all_outlined
|
||||||
|
: Icons.deselect_outlined,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
tooltip: selectedApps.isEmpty
|
||||||
|
? tr('selectAll')
|
||||||
|
: tr('deselectN', args: [selectedApps.length.toString()])),
|
||||||
|
const VerticalDivider(),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
selectedApps.isEmpty
|
||||||
|
? const SizedBox()
|
||||||
|
: IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () {
|
||||||
|
showDialog<List<String>?>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr('removeSelectedAppsQuestion'),
|
||||||
|
items: const [],
|
||||||
|
defaultValues: const [],
|
||||||
|
initValid: true,
|
||||||
|
message: tr(
|
||||||
|
'xWillBeRemovedButRemainInstalled',
|
||||||
|
args: [
|
||||||
|
plural('apps', selectedApps.length)
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
appsProvider.removeApps(
|
||||||
|
selectedApps.map((e) => e.id).toList());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: tr('removeSelectedApps'),
|
||||||
|
icon: const Icon(Icons.delete_outline_outlined),
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: appsProvider.areDownloadsRunning() ||
|
||||||
|
(existingUpdateIdsAllOrSelected.isEmpty &&
|
||||||
|
newInstallIdsAllOrSelected.isEmpty &&
|
||||||
|
trackOnlyUpdateIdsAllOrSelected.isEmpty)
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
List<GeneratedFormItem> formInputs = [];
|
||||||
|
List<String> defaultValues = [];
|
||||||
|
if (existingUpdateIdsAllOrSelected.isNotEmpty) {
|
||||||
|
formInputs.add(GeneratedFormItem(
|
||||||
|
label: tr('updateX', args: [
|
||||||
|
plural('apps',
|
||||||
|
existingUpdateIdsAllOrSelected.length)
|
||||||
|
]),
|
||||||
|
type: FormItemType.bool,
|
||||||
|
key: 'updates'));
|
||||||
|
defaultValues.add('true');
|
||||||
|
}
|
||||||
|
if (newInstallIdsAllOrSelected.isNotEmpty) {
|
||||||
|
formInputs.add(GeneratedFormItem(
|
||||||
|
label: tr('installX', args: [
|
||||||
|
plural('apps',
|
||||||
|
newInstallIdsAllOrSelected.length)
|
||||||
|
]),
|
||||||
|
type: FormItemType.bool,
|
||||||
|
key: 'installs'));
|
||||||
|
defaultValues
|
||||||
|
.add(defaultValues.isEmpty ? 'true' : '');
|
||||||
|
}
|
||||||
|
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
|
||||||
|
formInputs.add(GeneratedFormItem(
|
||||||
|
label: tr('markXTrackOnlyAsUpdated', args: [
|
||||||
|
plural('apps',
|
||||||
|
trackOnlyUpdateIdsAllOrSelected.length)
|
||||||
|
]),
|
||||||
|
type: FormItemType.bool,
|
||||||
|
key: 'trackonlies'));
|
||||||
|
defaultValues
|
||||||
|
.add(defaultValues.isEmpty ? 'true' : '');
|
||||||
|
}
|
||||||
|
showDialog<List<String>?>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
var totalApps = existingUpdateIdsAllOrSelected
|
||||||
|
.length +
|
||||||
|
newInstallIdsAllOrSelected.length +
|
||||||
|
trackOnlyUpdateIdsAllOrSelected.length;
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr('changeX',
|
||||||
|
args: [plural('apps', totalApps)]),
|
||||||
|
items: formInputs.map((e) => [e]).toList(),
|
||||||
|
defaultValues: defaultValues,
|
||||||
|
initValid: true,
|
||||||
|
);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
if (values.isEmpty) {
|
||||||
|
values = defaultValues;
|
||||||
|
}
|
||||||
|
bool shouldInstallUpdates =
|
||||||
|
findGeneratedFormValueByKey(
|
||||||
|
formInputs, values, 'updates') ==
|
||||||
|
'true';
|
||||||
|
bool shouldInstallNew =
|
||||||
|
findGeneratedFormValueByKey(
|
||||||
|
formInputs, values, 'installs') ==
|
||||||
|
'true';
|
||||||
|
bool shouldMarkTrackOnlies =
|
||||||
|
findGeneratedFormValueByKey(formInputs,
|
||||||
|
values, 'trackonlies') ==
|
||||||
|
'true';
|
||||||
|
(() async {
|
||||||
|
if (shouldInstallNew ||
|
||||||
|
shouldInstallUpdates) {
|
||||||
|
await settingsProvider
|
||||||
|
.getInstallPermission();
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.then((_) {
|
||||||
|
List<String> toInstall = [];
|
||||||
|
if (shouldInstallUpdates) {
|
||||||
|
toInstall
|
||||||
|
.addAll(existingUpdateIdsAllOrSelected);
|
||||||
|
}
|
||||||
|
if (shouldInstallNew) {
|
||||||
|
toInstall
|
||||||
|
.addAll(newInstallIdsAllOrSelected);
|
||||||
|
}
|
||||||
|
if (shouldMarkTrackOnlies) {
|
||||||
|
toInstall.addAll(
|
||||||
|
trackOnlyUpdateIdsAllOrSelected);
|
||||||
|
}
|
||||||
|
appsProvider
|
||||||
|
.downloadAndInstallLatestApps(
|
||||||
|
toInstall, context)
|
||||||
|
.catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: selectedApps.isEmpty
|
||||||
|
? tr('installUpdateApps')
|
||||||
|
: tr('installUpdateSelectedApps'),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.file_download_outlined,
|
||||||
|
)),
|
||||||
|
selectedApps.isEmpty
|
||||||
|
? const SizedBox()
|
||||||
|
: IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
content: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed:
|
||||||
|
appsProvider
|
||||||
|
.areDownloadsRunning()
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(tr(
|
||||||
|
'markXSelectedAppsAsUpdated',
|
||||||
|
args: [
|
||||||
|
selectedApps
|
||||||
|
.length
|
||||||
|
.toString()
|
||||||
|
])),
|
||||||
|
content: Text(
|
||||||
|
tr('onlyAppliesToInstalledAndOutdatedApps')),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed:
|
||||||
|
() {
|
||||||
|
Navigator.of(context)
|
||||||
|
.pop();
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
tr('no'))),
|
||||||
|
TextButton(
|
||||||
|
onPressed:
|
||||||
|
() {
|
||||||
|
HapticFeedback
|
||||||
|
.selectionClick();
|
||||||
|
appsProvider
|
||||||
|
.saveApps(selectedApps.map((a) {
|
||||||
|
if (a.installedVersion != null &&
|
||||||
|
!a.enhancedVersionDetection) {
|
||||||
|
a.installedVersion = a.latestVersion;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}).toList());
|
||||||
|
|
||||||
|
Navigator.of(context)
|
||||||
|
.pop();
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
tr('yes')))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}).whenComplete(() {
|
||||||
|
Navigator.of(
|
||||||
|
context)
|
||||||
|
.pop();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip:
|
||||||
|
tr('markSelectedAppsUpdated'),
|
||||||
|
icon: const Icon(Icons.done)),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
var pinStatus = selectedApps
|
||||||
|
.where((element) =>
|
||||||
|
element.pinned)
|
||||||
|
.isEmpty;
|
||||||
|
appsProvider.saveApps(
|
||||||
|
selectedApps.map((e) {
|
||||||
|
e.pinned = pinStatus;
|
||||||
|
return e;
|
||||||
|
}).toList());
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
tooltip: selectedApps
|
||||||
|
.where((element) =>
|
||||||
|
element.pinned)
|
||||||
|
.isEmpty
|
||||||
|
? tr('pinToTop')
|
||||||
|
: tr('unpinFromTop'),
|
||||||
|
icon: Icon(selectedApps
|
||||||
|
.where((element) =>
|
||||||
|
element.pinned)
|
||||||
|
.isEmpty
|
||||||
|
? Icons.bookmark_outline_rounded
|
||||||
|
: Icons
|
||||||
|
.bookmark_remove_outlined),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
String urls = '';
|
||||||
|
for (var a in selectedApps) {
|
||||||
|
urls += '${a.url}\n';
|
||||||
|
}
|
||||||
|
urls = urls.substring(
|
||||||
|
0, urls.length - 1);
|
||||||
|
Share.share(urls,
|
||||||
|
subject: tr(
|
||||||
|
'selectedAppURLsFromObtainium'));
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
tooltip: tr('shareSelectedAppURLs'),
|
||||||
|
icon: const Icon(Icons.share),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr(
|
||||||
|
'resetInstallStatusForSelectedAppsQuestion'),
|
||||||
|
items: const [],
|
||||||
|
defaultValues: const [],
|
||||||
|
initValid: true,
|
||||||
|
message: tr(
|
||||||
|
'installStatusOfXWillBeResetExplanation',
|
||||||
|
args: [
|
||||||
|
plural(
|
||||||
|
'app',
|
||||||
|
selectedApps
|
||||||
|
.length)
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
appsProvider.saveApps(
|
||||||
|
selectedApps.map((e) {
|
||||||
|
e.installedVersion = null;
|
||||||
|
return e;
|
||||||
|
}).toList());
|
||||||
|
}
|
||||||
|
}).whenComplete(() {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: tr('resetInstallStatus'),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.restore_page_outlined),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: tr('more'),
|
||||||
|
icon: const Icon(Icons.more_horiz),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
const VerticalDivider(),
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (currentFilterIsUpdatesOnly) {
|
||||||
|
filter = null;
|
||||||
|
} else {
|
||||||
|
filter = updatesOnlyFilter;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: currentFilterIsUpdatesOnly
|
||||||
|
? tr('removeOutdatedFilter')
|
||||||
|
: tr('showOutdatedOnly'),
|
||||||
|
icon: Icon(
|
||||||
|
currentFilterIsUpdatesOnly
|
||||||
|
? Icons.update_disabled_rounded
|
||||||
|
: Icons.update_rounded,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
appsProvider.apps.isEmpty
|
||||||
|
? const SizedBox()
|
||||||
|
: TextButton.icon(
|
||||||
|
label: Text(
|
||||||
|
filter == null ? tr('filter') : tr('filterActive'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: filter == null
|
||||||
|
? FontWeight.normal
|
||||||
|
: FontWeight.bold),
|
||||||
),
|
),
|
||||||
));
|
onPressed: () {
|
||||||
|
showDialog<List<String>?>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr('filterApps'),
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('appName'), required: false),
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('author'), required: false)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('upToDateApps'),
|
||||||
|
type: FormItemType.bool)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('nonInstalledApps'),
|
||||||
|
type: FormItemType.bool)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
defaultValues: filter == null
|
||||||
|
? AppsFilter().toValuesArray()
|
||||||
|
: filter!.toValuesArray());
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
setState(() {
|
||||||
|
filter = AppsFilter.fromValuesArray(values);
|
||||||
|
if (AppsFilter().isIdenticalTo(filter!)) {
|
||||||
|
filter = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.filter_list_rounded))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AppsFilter {
|
||||||
|
late String nameFilter;
|
||||||
|
late String authorFilter;
|
||||||
|
late bool includeUptodate;
|
||||||
|
late bool includeNonInstalled;
|
||||||
|
|
||||||
|
AppsFilter(
|
||||||
|
{this.nameFilter = '',
|
||||||
|
this.authorFilter = '',
|
||||||
|
this.includeUptodate = true,
|
||||||
|
this.includeNonInstalled = true});
|
||||||
|
|
||||||
|
List<String> toValuesArray() {
|
||||||
|
return [
|
||||||
|
nameFilter,
|
||||||
|
authorFilter,
|
||||||
|
includeUptodate ? 'true' : '',
|
||||||
|
includeNonInstalled ? 'true' : ''
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
AppsFilter.fromValuesArray(List<String> values) {
|
||||||
|
nameFilter = values[0];
|
||||||
|
authorFilter = values[1];
|
||||||
|
includeUptodate = values[2] == 'true';
|
||||||
|
includeNonInstalled = values[3] == 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isIdenticalTo(AppsFilter other) =>
|
||||||
|
authorFilter.trim() == other.authorFilter.trim() &&
|
||||||
|
nameFilter.trim() == other.nameFilter.trim() &&
|
||||||
|
includeUptodate == other.includeUptodate &&
|
||||||
|
includeNonInstalled == other.includeNonInstalled;
|
||||||
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import 'package:animations/animations.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/pages/add_app.dart';
|
import 'package:obtainium/pages/add_app.dart';
|
||||||
@ -12,33 +14,57 @@ class HomePage extends StatefulWidget {
|
|||||||
State<HomePage> createState() => _HomePageState();
|
State<HomePage> createState() => _HomePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class NavigationPageItem {
|
||||||
|
late String title;
|
||||||
|
late IconData icon;
|
||||||
|
late Widget widget;
|
||||||
|
|
||||||
|
NavigationPageItem(this.title, this.icon, this.widget);
|
||||||
|
}
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> {
|
class _HomePageState extends State<HomePage> {
|
||||||
List<int> selectedIndexHistory = [];
|
List<int> selectedIndexHistory = [];
|
||||||
List<Widget> pages = [
|
|
||||||
const AppsPage(),
|
List<NavigationPageItem> pages = [
|
||||||
const AddAppPage(),
|
NavigationPageItem(tr('appsString'), Icons.apps,
|
||||||
const ImportExportPage(),
|
AppsPage(key: GlobalKey<AppsPageState>())),
|
||||||
const SettingsPage()
|
NavigationPageItem(tr('addApp'), Icons.add, const AddAppPage()),
|
||||||
|
NavigationPageItem(
|
||||||
|
tr('importExport'), Icons.import_export, const ImportExportPage()),
|
||||||
|
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage())
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return WillPopScope(
|
return WillPopScope(
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(title: const Text('Obtainium')),
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: pages.elementAt(
|
body: PageTransitionSwitcher(
|
||||||
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last),
|
transitionBuilder: (
|
||||||
|
Widget child,
|
||||||
|
Animation<double> animation,
|
||||||
|
Animation<double> secondaryAnimation,
|
||||||
|
) {
|
||||||
|
return SharedAxisTransition(
|
||||||
|
animation: animation,
|
||||||
|
secondaryAnimation: secondaryAnimation,
|
||||||
|
transitionType: SharedAxisTransitionType.horizontal,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: pages
|
||||||
|
.elementAt(selectedIndexHistory.isEmpty
|
||||||
|
? 0
|
||||||
|
: selectedIndexHistory.last)
|
||||||
|
.widget,
|
||||||
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
destinations: const [
|
destinations: pages
|
||||||
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
|
.map((e) =>
|
||||||
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
|
NavigationDestination(icon: Icon(e.icon), label: e.title))
|
||||||
NavigationDestination(
|
.toList(),
|
||||||
icon: Icon(Icons.import_export), label: 'Import/Export'),
|
|
||||||
NavigationDestination(
|
|
||||||
icon: Icon(Icons.settings), label: 'Settings'),
|
|
||||||
],
|
|
||||||
onDestinationSelected: (int index) {
|
onDestinationSelected: (int index) {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.selectionClick();
|
||||||
setState(() {
|
setState(() {
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
selectedIndexHistory.clear();
|
selectedIndexHistory.clear();
|
||||||
@ -64,7 +90,9 @@ class _HomePageState extends State<HomePage> {
|
|||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return !(pages[0].widget.key as GlobalKey<AppsPageState>)
|
||||||
|
.currentState
|
||||||
|
?.clearSelected();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,18 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
import 'package:obtainium/components/generated_form_modal.dart';
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class ImportExportPage extends StatefulWidget {
|
class ImportExportPage extends StatefulWidget {
|
||||||
const ImportExportPage({super.key});
|
const ImportExportPage({super.key});
|
||||||
@ -23,212 +27,412 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
SourceProvider sourceProvider = SourceProvider();
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
var settingsProvider = context.read<SettingsProvider>();
|
|
||||||
var appsProvider = context.read<AppsProvider>();
|
var appsProvider = context.read<AppsProvider>();
|
||||||
|
var outlineButtonStyle = ButtonStyle(
|
||||||
|
shape: MaterialStateProperty.all(
|
||||||
|
StadiumBorder(
|
||||||
|
side: BorderSide(
|
||||||
|
width: 1,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
Future<List<List<String>>> addApps(List<String> urls) async {
|
return Scaffold(
|
||||||
await settingsProvider.getInstallPermission();
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
List<dynamic> results = await sourceProvider.getApps(urls);
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
List<App> apps = results[0];
|
CustomAppBar(title: tr('importExport')),
|
||||||
Map<String, dynamic> errorsMap = results[1];
|
SliverFillRemaining(
|
||||||
for (var app in apps) {
|
child: Padding(
|
||||||
if (appsProvider.apps.containsKey(app.id)) {
|
padding:
|
||||||
errorsMap.addAll({app.id: 'App already added'});
|
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
} else {
|
child: Column(
|
||||||
await appsProvider.saveApp(app);
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
}
|
children: [
|
||||||
}
|
Row(
|
||||||
List<List<String>> errors =
|
|
||||||
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: appsProvider.apps.isEmpty || importInProgress
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
appsProvider.exportApps().then((String path) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Exported to $path')),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: const Text('Obtainium Export')),
|
|
||||||
const SizedBox(
|
|
||||||
height: 8,
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: importInProgress
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
FilePicker.platform.pickFiles().then((result) {
|
|
||||||
setState(() {
|
|
||||||
importInProgress = true;
|
|
||||||
});
|
|
||||||
if (result != null) {
|
|
||||||
String data = File(result.files.single.path!)
|
|
||||||
.readAsStringSync();
|
|
||||||
try {
|
|
||||||
jsonDecode(data);
|
|
||||||
} catch (e) {
|
|
||||||
throw 'Invalid input';
|
|
||||||
}
|
|
||||||
appsProvider.importApps(data).then((value) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'$value App${value == 1 ? '' : 's'} Imported')),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// User canceled the picker
|
|
||||||
}
|
|
||||||
}).catchError((e) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(e.toString())),
|
|
||||||
);
|
|
||||||
}).whenComplete(() {
|
|
||||||
setState(() {
|
|
||||||
importInProgress = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: const Text('Obtainium Import')),
|
|
||||||
if (importInProgress)
|
|
||||||
Column(
|
|
||||||
children: const [
|
|
||||||
SizedBox(
|
|
||||||
height: 14,
|
|
||||||
),
|
|
||||||
LinearProgressIndicator(),
|
|
||||||
SizedBox(
|
|
||||||
height: 14,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const Divider(
|
|
||||||
height: 32,
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: importInProgress
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext ctx) {
|
|
||||||
return GeneratedFormModal(
|
|
||||||
title: 'Import from URL List',
|
|
||||||
items: [
|
|
||||||
GeneratedFormItem('App URL List', true, 7)
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}).then((values) {
|
|
||||||
if (values != null) {
|
|
||||||
var urls = (values[0] as String).split('\n');
|
|
||||||
setState(() {
|
|
||||||
importInProgress = true;
|
|
||||||
});
|
|
||||||
addApps(urls).then((errors) {
|
|
||||||
if (errors.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content:
|
|
||||||
Text('Imported ${urls.length} Apps')),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext ctx) {
|
|
||||||
return ImportErrorDialog(
|
|
||||||
urlsLength: urls.length,
|
|
||||||
errors: errors);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}).catchError((e) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(e.toString())),
|
|
||||||
);
|
|
||||||
}).whenComplete(() {
|
|
||||||
setState(() {
|
|
||||||
importInProgress = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: const Text('Import from URL List')),
|
|
||||||
...sourceProvider.massSources
|
|
||||||
.map((source) => Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 8),
|
Expanded(
|
||||||
TextButton(
|
child: TextButton(
|
||||||
onPressed: importInProgress
|
style: outlineButtonStyle,
|
||||||
? null
|
onPressed: appsProvider.apps.isEmpty ||
|
||||||
: () {
|
importInProgress
|
||||||
showDialog(
|
? null
|
||||||
context: context,
|
: () {
|
||||||
builder: (BuildContext ctx) {
|
HapticFeedback.selectionClick();
|
||||||
return GeneratedFormModal(
|
appsProvider
|
||||||
title: 'Import ${source.name}',
|
.exportApps()
|
||||||
items: source.requiredArgs
|
.then((String path) {
|
||||||
.map((e) =>
|
showError(
|
||||||
GeneratedFormItem(
|
tr('exportedTo', args: [path]),
|
||||||
e, true, 1))
|
context);
|
||||||
.toList());
|
});
|
||||||
}).then((values) {
|
},
|
||||||
if (values != null) {
|
child: Text(tr('obtainiumExport')))),
|
||||||
source.getUrls(values).then((urls) {
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
style: outlineButtonStyle,
|
||||||
|
onPressed: importInProgress
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
FilePicker.platform
|
||||||
|
.pickFiles()
|
||||||
|
.then((result) {
|
||||||
setState(() {
|
setState(() {
|
||||||
importInProgress = true;
|
importInProgress = true;
|
||||||
});
|
});
|
||||||
addApps(urls).then((errors) {
|
if (result != null) {
|
||||||
if (errors.isEmpty) {
|
String data = File(
|
||||||
ScaffoldMessenger.of(context)
|
result.files.single.path!)
|
||||||
.showSnackBar(
|
.readAsStringSync();
|
||||||
SnackBar(
|
try {
|
||||||
content: Text(
|
jsonDecode(data);
|
||||||
'Imported ${urls.length} Apps')),
|
} catch (e) {
|
||||||
);
|
throw ObtainiumError(
|
||||||
} else {
|
tr('invalidInput'));
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder:
|
|
||||||
(BuildContext ctx) {
|
|
||||||
return ImportErrorDialog(
|
|
||||||
urlsLength:
|
|
||||||
urls.length,
|
|
||||||
errors: errors);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}).whenComplete(() {
|
appsProvider
|
||||||
setState(() {
|
.importApps(data)
|
||||||
importInProgress = false;
|
.then((value) {
|
||||||
|
showError(
|
||||||
|
tr('importedX', args: [
|
||||||
|
plural('apps', value)
|
||||||
|
]),
|
||||||
|
context);
|
||||||
});
|
});
|
||||||
});
|
} else {
|
||||||
|
// User canceled the picker
|
||||||
|
}
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
ScaffoldMessenger.of(context)
|
showError(e, context);
|
||||||
.showSnackBar(
|
}).whenComplete(() {
|
||||||
SnackBar(
|
setState(() {
|
||||||
content: Text(e.toString())),
|
importInProgress = false;
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
child: Text(tr('obtainiumImport'))))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (importInProgress)
|
||||||
|
Column(
|
||||||
|
children: const [
|
||||||
|
SizedBox(
|
||||||
|
height: 14,
|
||||||
|
),
|
||||||
|
LinearProgressIndicator(),
|
||||||
|
SizedBox(
|
||||||
|
height: 14,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Divider(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: importInProgress
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr('importFromURLList'),
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('appURLList'),
|
||||||
|
max: 7,
|
||||||
|
additionalValidators: [
|
||||||
|
(String? value) {
|
||||||
|
if (value != null &&
|
||||||
|
value.isNotEmpty) {
|
||||||
|
var lines = value
|
||||||
|
.trim()
|
||||||
|
.split('\n');
|
||||||
|
for (int i = 0;
|
||||||
|
i < lines.length;
|
||||||
|
i++) {
|
||||||
|
try {
|
||||||
|
sourceProvider
|
||||||
|
.getSource(
|
||||||
|
lines[i]);
|
||||||
|
} catch (e) {
|
||||||
|
return '${tr('line')} ${i + 1}: $e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
])
|
||||||
|
]
|
||||||
|
],
|
||||||
|
defaultValues: const [],
|
||||||
|
);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
var urls =
|
||||||
|
(values[0] as String).split('\n');
|
||||||
|
setState(() {
|
||||||
|
importInProgress = true;
|
||||||
});
|
});
|
||||||
},
|
appsProvider
|
||||||
child: Text('Import ${source.name}'))
|
.addAppsByURL(urls)
|
||||||
]))
|
.then((errors) {
|
||||||
.toList()
|
if (errors.isEmpty) {
|
||||||
],
|
showError(
|
||||||
));
|
tr('importedX', args: [
|
||||||
|
plural('apps', urls.length)
|
||||||
|
]),
|
||||||
|
context);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return ImportErrorDialog(
|
||||||
|
urlsLength: urls.length,
|
||||||
|
errors: errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
tr('importFromURLList'),
|
||||||
|
)),
|
||||||
|
...sourceProvider.sources
|
||||||
|
.where((element) => element.canSearch)
|
||||||
|
.map((source) => Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: importInProgress
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
() async {
|
||||||
|
var values = await showDialog<
|
||||||
|
List<String>>(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr('searchX',
|
||||||
|
args: [
|
||||||
|
source
|
||||||
|
.runtimeType
|
||||||
|
.toString()
|
||||||
|
]),
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr(
|
||||||
|
'searchQuery'))
|
||||||
|
]
|
||||||
|
],
|
||||||
|
defaultValues: const [],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (values != null &&
|
||||||
|
values[0].isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = true;
|
||||||
|
});
|
||||||
|
var urlsWithDescriptions =
|
||||||
|
await source
|
||||||
|
.search(values[0]);
|
||||||
|
if (urlsWithDescriptions
|
||||||
|
.isNotEmpty) {
|
||||||
|
var selectedUrls =
|
||||||
|
await showDialog<
|
||||||
|
List<
|
||||||
|
String>?>(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return UrlSelectionModal(
|
||||||
|
urlsWithDescriptions:
|
||||||
|
urlsWithDescriptions,
|
||||||
|
selectedByDefault:
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (selectedUrls !=
|
||||||
|
null &&
|
||||||
|
selectedUrls
|
||||||
|
.isNotEmpty) {
|
||||||
|
var errors =
|
||||||
|
await appsProvider
|
||||||
|
.addAppsByURL(
|
||||||
|
selectedUrls);
|
||||||
|
if (errors.isEmpty) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
showError(
|
||||||
|
tr('importedX',
|
||||||
|
args: [
|
||||||
|
plural(
|
||||||
|
'app',
|
||||||
|
selectedUrls
|
||||||
|
.length)
|
||||||
|
]),
|
||||||
|
context);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return ImportErrorDialog(
|
||||||
|
urlsLength:
|
||||||
|
selectedUrls
|
||||||
|
.length,
|
||||||
|
errors:
|
||||||
|
errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw ObtainiumError(
|
||||||
|
tr('noResults'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
.catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(tr('searchX', args: [
|
||||||
|
source.runtimeType.toString()
|
||||||
|
])))
|
||||||
|
]))
|
||||||
|
.toList(),
|
||||||
|
...sourceProvider.massUrlSources
|
||||||
|
.map((source) => Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: importInProgress
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
() async {
|
||||||
|
var values = await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr('importX',
|
||||||
|
args: [
|
||||||
|
source.name
|
||||||
|
]),
|
||||||
|
items:
|
||||||
|
source
|
||||||
|
.requiredArgs
|
||||||
|
.map(
|
||||||
|
(e) => [
|
||||||
|
GeneratedFormItem(label: e)
|
||||||
|
])
|
||||||
|
.toList(),
|
||||||
|
defaultValues: const [],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (values != null) {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = true;
|
||||||
|
});
|
||||||
|
var urlsWithDescriptions =
|
||||||
|
await source
|
||||||
|
.getUrlsWithDescriptions(
|
||||||
|
values);
|
||||||
|
var selectedUrls =
|
||||||
|
await showDialog<
|
||||||
|
List<String>?>(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return UrlSelectionModal(
|
||||||
|
urlsWithDescriptions:
|
||||||
|
urlsWithDescriptions);
|
||||||
|
});
|
||||||
|
if (selectedUrls != null) {
|
||||||
|
var errors =
|
||||||
|
await appsProvider
|
||||||
|
.addAppsByURL(
|
||||||
|
selectedUrls);
|
||||||
|
if (errors.isEmpty) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
showError(
|
||||||
|
tr('importedX',
|
||||||
|
args: [
|
||||||
|
plural(
|
||||||
|
'app',
|
||||||
|
selectedUrls
|
||||||
|
.length)
|
||||||
|
]),
|
||||||
|
context);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return ImportErrorDialog(
|
||||||
|
urlsLength:
|
||||||
|
selectedUrls
|
||||||
|
.length,
|
||||||
|
errors:
|
||||||
|
errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
.catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
tr('importX', args: [source.name])))
|
||||||
|
]))
|
||||||
|
.toList(),
|
||||||
|
const Spacer(),
|
||||||
|
const Divider(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
Text(tr('importedAppsIdDisclaimer'),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontStyle: FontStyle.italic, fontSize: 12)),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)))
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -248,16 +452,19 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: const Text('Import Errors'),
|
title: Text(tr('importErrors')),
|
||||||
content:
|
content:
|
||||||
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||||
Text(
|
Text(
|
||||||
'${widget.urlsLength - widget.errors.length} of ${widget.urlsLength} Apps imported.',
|
tr('importedXOfYApps', args: [
|
||||||
|
(widget.urlsLength - widget.errors.length).toString(),
|
||||||
|
widget.urlsLength.toString()
|
||||||
|
]),
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'The following URLs had errors:',
|
tr('followingURLsHadErrors'),
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
...widget.errors.map((e) {
|
...widget.errors.map((e) {
|
||||||
@ -278,10 +485,134 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
Navigator.of(context).pop(null);
|
Navigator.of(context).pop(null);
|
||||||
},
|
},
|
||||||
child: const Text('Okay'))
|
child: Text(tr('okay')))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: must_be_immutable
|
||||||
|
class UrlSelectionModal extends StatefulWidget {
|
||||||
|
UrlSelectionModal(
|
||||||
|
{super.key,
|
||||||
|
required this.urlsWithDescriptions,
|
||||||
|
this.selectedByDefault = true,
|
||||||
|
this.onlyOneSelectionAllowed = false});
|
||||||
|
|
||||||
|
Map<String, String> urlsWithDescriptions;
|
||||||
|
bool selectedByDefault;
|
||||||
|
bool onlyOneSelectionAllowed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||||
|
Map<MapEntry<String, String>, bool> urlWithDescriptionSelections = {};
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
for (var url in widget.urlsWithDescriptions.entries) {
|
||||||
|
urlWithDescriptionSelections.putIfAbsent(url,
|
||||||
|
() => widget.selectedByDefault && !widget.onlyOneSelectionAllowed);
|
||||||
|
}
|
||||||
|
if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) {
|
||||||
|
selectOnlyOne(widget.urlsWithDescriptions.entries.first.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectOnlyOne(String url) {
|
||||||
|
for (var uwd in urlWithDescriptionSelections.keys) {
|
||||||
|
urlWithDescriptionSelections[uwd] = uwd.key == url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(
|
||||||
|
widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')),
|
||||||
|
content: Column(children: [
|
||||||
|
...urlWithDescriptionSelections.keys.map((urlWithD) {
|
||||||
|
return Row(children: [
|
||||||
|
Checkbox(
|
||||||
|
value: urlWithDescriptionSelections[urlWithD],
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
value ??= false;
|
||||||
|
if (value! && widget.onlyOneSelectionAllowed) {
|
||||||
|
selectOnlyOne(urlWithD.key);
|
||||||
|
} else {
|
||||||
|
urlWithDescriptionSelections[urlWithD] = value!;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
const SizedBox(
|
||||||
|
width: 8,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
launchUrlString(urlWithD.key,
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
Uri.parse(urlWithD.key).path.substring(1),
|
||||||
|
style:
|
||||||
|
const TextStyle(decoration: TextDecoration.underline),
|
||||||
|
textAlign: TextAlign.start,
|
||||||
|
)),
|
||||||
|
Text(
|
||||||
|
urlWithD.value.length > 128
|
||||||
|
? '${urlWithD.value.substring(0, 128)}...'
|
||||||
|
: urlWithD.value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontStyle: FontStyle.italic, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
))
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(tr('cancel'))),
|
||||||
|
TextButton(
|
||||||
|
onPressed:
|
||||||
|
urlWithDescriptionSelections.values.where((b) => b).isEmpty
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
Navigator.of(context).pop(urlWithDescriptionSelections
|
||||||
|
.entries
|
||||||
|
.where((entry) => entry.value)
|
||||||
|
.map((e) => e.key.key)
|
||||||
|
.toList());
|
||||||
|
},
|
||||||
|
child: Text(widget.onlyOneSelectionAllowed
|
||||||
|
? tr('pick')
|
||||||
|
: tr('importX', args: [
|
||||||
|
plural(
|
||||||
|
'url',
|
||||||
|
urlWithDescriptionSelections.values
|
||||||
|
.where((b) => b)
|
||||||
|
.length)
|
||||||
|
])))
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,13 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
|
import 'package:obtainium/providers/logs_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class SettingsPage extends StatefulWidget {
|
class SettingsPage extends StatefulWidget {
|
||||||
@ -15,188 +21,333 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
||||||
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
if (settingsProvider.prefs == null) {
|
if (settingsProvider.prefs == null) {
|
||||||
settingsProvider.initializeSettings();
|
settingsProvider.initializeSettings();
|
||||||
}
|
}
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
var themeDropdown = DropdownButtonFormField(
|
||||||
child: settingsProvider.prefs == null
|
decoration: InputDecoration(labelText: tr('theme')),
|
||||||
? Container()
|
value: settingsProvider.theme,
|
||||||
: Column(
|
items: [
|
||||||
children: [
|
DropdownMenuItem(
|
||||||
DropdownButtonFormField(
|
value: ThemeSettings.dark,
|
||||||
decoration: const InputDecoration(labelText: 'Theme'),
|
child: Text(tr('dark')),
|
||||||
value: settingsProvider.theme,
|
),
|
||||||
items: const [
|
DropdownMenuItem(
|
||||||
DropdownMenuItem(
|
value: ThemeSettings.light,
|
||||||
value: ThemeSettings.dark,
|
child: Text(tr('light')),
|
||||||
child: Text('Dark'),
|
),
|
||||||
),
|
DropdownMenuItem(
|
||||||
DropdownMenuItem(
|
value: ThemeSettings.system,
|
||||||
value: ThemeSettings.light,
|
child: Text(tr('followSystem')),
|
||||||
child: Text('Light'),
|
)
|
||||||
),
|
],
|
||||||
DropdownMenuItem(
|
onChanged: (value) {
|
||||||
value: ThemeSettings.system,
|
if (value != null) {
|
||||||
child: Text('Follow System'),
|
settingsProvider.theme = value;
|
||||||
)
|
}
|
||||||
],
|
});
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
var colourDropdown = DropdownButtonFormField(
|
||||||
settingsProvider.theme = value;
|
decoration: InputDecoration(labelText: tr('colour')),
|
||||||
}
|
value: settingsProvider.colour,
|
||||||
}),
|
items: [
|
||||||
const SizedBox(
|
DropdownMenuItem(
|
||||||
height: 16,
|
value: ColourSettings.basic,
|
||||||
),
|
child: Text(tr('obtainium')),
|
||||||
DropdownButtonFormField(
|
),
|
||||||
decoration: const InputDecoration(labelText: 'Colour'),
|
DropdownMenuItem(
|
||||||
value: settingsProvider.colour,
|
value: ColourSettings.materialYou,
|
||||||
items: const [
|
child: Text(tr('materialYou')),
|
||||||
DropdownMenuItem(
|
)
|
||||||
value: ColourSettings.basic,
|
],
|
||||||
child: Text('Obtainium'),
|
onChanged: (value) {
|
||||||
),
|
if (value != null) {
|
||||||
DropdownMenuItem(
|
settingsProvider.colour = value;
|
||||||
value: ColourSettings.materialYou,
|
}
|
||||||
child: Text('Material You'),
|
});
|
||||||
)
|
|
||||||
],
|
var sortDropdown = DropdownButtonFormField(
|
||||||
onChanged: (value) {
|
decoration: InputDecoration(labelText: tr('appSortBy')),
|
||||||
if (value != null) {
|
value: settingsProvider.sortColumn,
|
||||||
settingsProvider.colour = value;
|
items: [
|
||||||
}
|
DropdownMenuItem(
|
||||||
}),
|
value: SortColumnSettings.authorName,
|
||||||
const SizedBox(
|
child: Text(tr('authorName')),
|
||||||
height: 16,
|
),
|
||||||
),
|
DropdownMenuItem(
|
||||||
DropdownButtonFormField(
|
value: SortColumnSettings.nameAuthor,
|
||||||
decoration: const InputDecoration(
|
child: Text(tr('nameAuthor')),
|
||||||
labelText: 'Background Update Checking Interval'),
|
),
|
||||||
value: settingsProvider.updateInterval,
|
DropdownMenuItem(
|
||||||
items: const [
|
value: SortColumnSettings.added,
|
||||||
DropdownMenuItem(
|
child: Text(tr('asAdded')),
|
||||||
value: 15,
|
)
|
||||||
child: Text('15 Minutes'),
|
],
|
||||||
),
|
onChanged: (value) {
|
||||||
DropdownMenuItem(
|
if (value != null) {
|
||||||
value: 30,
|
settingsProvider.sortColumn = value;
|
||||||
child: Text('30 Minutes'),
|
}
|
||||||
),
|
});
|
||||||
DropdownMenuItem(
|
|
||||||
value: 60,
|
var orderDropdown = DropdownButtonFormField(
|
||||||
child: Text('1 Hour'),
|
decoration: InputDecoration(labelText: tr('appSortOrder')),
|
||||||
),
|
value: settingsProvider.sortOrder,
|
||||||
DropdownMenuItem(
|
items: [
|
||||||
value: 360,
|
DropdownMenuItem(
|
||||||
child: Text('6 Hours'),
|
value: SortOrderSettings.ascending,
|
||||||
),
|
child: Text(tr('ascending')),
|
||||||
DropdownMenuItem(
|
),
|
||||||
value: 720,
|
DropdownMenuItem(
|
||||||
child: Text('12 Hours'),
|
value: SortOrderSettings.descending,
|
||||||
),
|
child: Text(tr('descending')),
|
||||||
DropdownMenuItem(
|
),
|
||||||
value: 1440,
|
],
|
||||||
child: Text('1 Day'),
|
onChanged: (value) {
|
||||||
),
|
if (value != null) {
|
||||||
DropdownMenuItem(
|
settingsProvider.sortOrder = value;
|
||||||
value: 0,
|
}
|
||||||
child: Text('Never - Manual Only'),
|
});
|
||||||
),
|
|
||||||
],
|
var intervalDropdown = DropdownButtonFormField(
|
||||||
onChanged: (value) {
|
decoration: InputDecoration(labelText: tr('bgUpdateCheckInterval')),
|
||||||
if (value != null) {
|
value: settingsProvider.updateInterval,
|
||||||
settingsProvider.updateInterval = value;
|
items: updateIntervals.map((e) {
|
||||||
}
|
int displayNum = (e < 60
|
||||||
}),
|
? e
|
||||||
const SizedBox(
|
: e < 1440
|
||||||
height: 16,
|
? e / 60
|
||||||
),
|
: e / 1440)
|
||||||
DropdownButtonFormField(
|
.round();
|
||||||
decoration:
|
String display = e == 0
|
||||||
const InputDecoration(labelText: 'App Sort By'),
|
? tr('neverManualOnly')
|
||||||
value: settingsProvider.sortColumn,
|
: (e < 60
|
||||||
items: const [
|
? plural('minute', displayNum)
|
||||||
DropdownMenuItem(
|
: e < 1440
|
||||||
value: SortColumnSettings.authorName,
|
? plural('hour', displayNum)
|
||||||
child: Text('Author/Name'),
|
: plural('day', displayNum));
|
||||||
),
|
return DropdownMenuItem(value: e, child: Text(display));
|
||||||
DropdownMenuItem(
|
}).toList(),
|
||||||
value: SortColumnSettings.nameAuthor,
|
onChanged: (value) {
|
||||||
child: Text('Name/Author'),
|
if (value != null) {
|
||||||
),
|
settingsProvider.updateInterval = value;
|
||||||
DropdownMenuItem(
|
}
|
||||||
value: SortColumnSettings.added,
|
});
|
||||||
child: Text('As Added'),
|
|
||||||
)
|
var sourceSpecificFields = sourceProvider.sources.map((e) {
|
||||||
],
|
if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
|
||||||
onChanged: (value) {
|
return GeneratedForm(
|
||||||
if (value != null) {
|
items: e.additionalSourceSpecificSettingFormItems
|
||||||
settingsProvider.sortColumn = value;
|
.map((e) => [e])
|
||||||
}
|
.toList(),
|
||||||
}),
|
onValueChanges: (values, valid, isBuilding) {
|
||||||
const SizedBox(
|
if (valid) {
|
||||||
height: 16,
|
for (var i = 0; i < values.length; i++) {
|
||||||
),
|
settingsProvider.setSettingString(
|
||||||
DropdownButtonFormField(
|
e.additionalSourceSpecificSettingFormItems[i].id,
|
||||||
decoration:
|
values[i]);
|
||||||
const InputDecoration(labelText: 'App Sort Order'),
|
}
|
||||||
value: settingsProvider.sortOrder,
|
}
|
||||||
items: const [
|
},
|
||||||
DropdownMenuItem(
|
defaultValues: e.additionalSourceSpecificSettingFormItems.map((e) {
|
||||||
value: SortOrderSettings.ascending,
|
return settingsProvider.getSettingString(e.id) ?? '';
|
||||||
child: Text('Ascending'),
|
}).toList());
|
||||||
),
|
} else {
|
||||||
DropdownMenuItem(
|
return Container();
|
||||||
value: SortOrderSettings.descending,
|
}
|
||||||
child: Text('Descending'),
|
});
|
||||||
),
|
|
||||||
],
|
const height16 = SizedBox(
|
||||||
onChanged: (value) {
|
height: 16,
|
||||||
if (value != null) {
|
);
|
||||||
settingsProvider.sortOrder = value;
|
|
||||||
}
|
return Scaffold(
|
||||||
}),
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
const SizedBox(
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
height: 16,
|
CustomAppBar(title: tr('settings')),
|
||||||
),
|
SliverToBoxAdapter(
|
||||||
Row(
|
child: Padding(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
child: settingsProvider.prefs == null
|
||||||
const Text('Show Source Webpage in App View'),
|
? const SizedBox()
|
||||||
Switch(
|
: Column(
|
||||||
value: settingsProvider.showAppWebpage,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
onChanged: (value) {
|
children: [
|
||||||
settingsProvider.showAppWebpage = value;
|
Text(
|
||||||
})
|
tr('appearance'),
|
||||||
],
|
style: TextStyle(
|
||||||
),
|
color: Theme.of(context).colorScheme.primary),
|
||||||
const Spacer(),
|
),
|
||||||
Row(
|
themeDropdown,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
height16,
|
||||||
children: [
|
colourDropdown,
|
||||||
TextButton.icon(
|
height16,
|
||||||
style: ButtonStyle(
|
Row(
|
||||||
foregroundColor:
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
MaterialStateProperty.resolveWith<Color>(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
(Set<MaterialState> states) {
|
children: [
|
||||||
return Colors.grey;
|
Expanded(child: sortDropdown),
|
||||||
}),
|
const SizedBox(
|
||||||
),
|
width: 16,
|
||||||
|
),
|
||||||
|
Expanded(child: orderDropdown),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
height16,
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(tr('showWebInAppView')),
|
||||||
|
Switch(
|
||||||
|
value: settingsProvider.showAppWebpage,
|
||||||
|
onChanged: (value) {
|
||||||
|
settingsProvider.showAppWebpage = value;
|
||||||
|
})
|
||||||
|
],
|
||||||
|
),
|
||||||
|
height16,
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(tr('pinUpdates')),
|
||||||
|
Switch(
|
||||||
|
value: settingsProvider.pinUpdates,
|
||||||
|
onChanged: (value) {
|
||||||
|
settingsProvider.pinUpdates = value;
|
||||||
|
})
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
height16,
|
||||||
|
Text(
|
||||||
|
tr('updates'),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary),
|
||||||
|
),
|
||||||
|
intervalDropdown,
|
||||||
|
const Divider(
|
||||||
|
height: 48,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
tr('sourceSpecific'),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary),
|
||||||
|
),
|
||||||
|
...sourceSpecificFields,
|
||||||
|
],
|
||||||
|
))),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Divider(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
launchUrlString(settingsProvider.sourceUrl,
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.code),
|
||||||
|
label: Text(
|
||||||
|
tr('appSource'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.lightImpact();
|
context.read<LogsProvider>().get().then((logs) {
|
||||||
launchUrlString(settingsProvider.sourceUrl,
|
if (logs.isEmpty) {
|
||||||
mode: LaunchMode.externalApplication);
|
showError(ObtainiumError(tr('noLogs')), context);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return const LogsDialog();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.code),
|
icon: const Icon(Icons.bug_report_outlined),
|
||||||
label: Text(
|
label: Text(tr('appLogs'))),
|
||||||
'Source',
|
],
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
),
|
||||||
),
|
height16,
|
||||||
)
|
],
|
||||||
],
|
),
|
||||||
),
|
)
|
||||||
],
|
]));
|
||||||
));
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogsDialog extends StatefulWidget {
|
||||||
|
const LogsDialog({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LogsDialog> createState() => _LogsDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogsDialogState extends State<LogsDialog> {
|
||||||
|
String? logString;
|
||||||
|
List<int> days = [7, 5, 4, 3, 2, 1];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
var logsProvider = context.read<LogsProvider>();
|
||||||
|
void filterLogs(int days) {
|
||||||
|
logsProvider
|
||||||
|
.get(after: DateTime.now().subtract(Duration(days: days)))
|
||||||
|
.then((value) {
|
||||||
|
setState(() {
|
||||||
|
String l = value.map((e) => e.toString()).join('\n\n');
|
||||||
|
logString = l.isNotEmpty ? l : tr('noLogs');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logString == null) {
|
||||||
|
filterLogs(days.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(tr('appLogs')),
|
||||||
|
content: Column(
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField(
|
||||||
|
value: days.first,
|
||||||
|
items: days
|
||||||
|
.map((e) => DropdownMenuItem(
|
||||||
|
value: e,
|
||||||
|
child: Text(plural('day', e)),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (d) {
|
||||||
|
filterLogs(d ?? 7);
|
||||||
|
}),
|
||||||
|
const SizedBox(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
Text(logString ?? '')
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(tr('close'))),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Share.share(logString ?? '', subject: tr('appLogs'));
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(tr('share')))
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,27 +5,36 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:install_plugin_v2/install_plugin_v2.dart';
|
||||||
|
import 'package:installed_apps/app_info.dart';
|
||||||
|
import 'package:installed_apps/installed_apps.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
|
import 'package:obtainium/providers/logs_provider.dart';
|
||||||
import 'package:obtainium/providers/notifications_provider.dart';
|
import 'package:obtainium/providers/notifications_provider.dart';
|
||||||
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
|
import 'package:package_archive_info/package_archive_info.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:flutter_fgbg/flutter_fgbg.dart';
|
import 'package:flutter_fgbg/flutter_fgbg.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:install_plugin_v2/install_plugin_v2.dart';
|
|
||||||
|
|
||||||
class AppInMemory {
|
class AppInMemory {
|
||||||
late App app;
|
late App app;
|
||||||
double? downloadProgress;
|
double? downloadProgress;
|
||||||
|
AppInfo? installedInfo;
|
||||||
|
|
||||||
AppInMemory(this.app, this.downloadProgress);
|
AppInMemory(this.app, this.downloadProgress, this.installedInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
class ApkFile {
|
class DownloadedApk {
|
||||||
String appId;
|
String appId;
|
||||||
File file;
|
File file;
|
||||||
ApkFile(this.appId, this.file);
|
DownloadedApk(this.appId, this.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppsProvider with ChangeNotifier {
|
class AppsProvider with ChangeNotifier {
|
||||||
@ -33,136 +42,341 @@ class AppsProvider with ChangeNotifier {
|
|||||||
Map<String, AppInMemory> apps = {};
|
Map<String, AppInMemory> apps = {};
|
||||||
bool loadingApps = false;
|
bool loadingApps = false;
|
||||||
bool gettingUpdates = false;
|
bool gettingUpdates = false;
|
||||||
|
bool forBGTask = false;
|
||||||
|
LogsProvider logs = LogsProvider();
|
||||||
|
|
||||||
// Variables to keep track of the app foreground status (installs can't run in the background)
|
// Variables to keep track of the app foreground status (installs can't run in the background)
|
||||||
bool isForeground = true;
|
bool isForeground = true;
|
||||||
late Stream<FGBGType> foregroundStream;
|
late Stream<FGBGType>? foregroundStream;
|
||||||
late StreamSubscription<FGBGType> foregroundSubscription;
|
late StreamSubscription<FGBGType>? foregroundSubscription;
|
||||||
|
|
||||||
AppsProvider(
|
AppsProvider({this.forBGTask = false}) {
|
||||||
{bool shouldLoadApps = false,
|
// Many setup tasks should only be done in the foreground isolate
|
||||||
bool shouldCheckUpdatesAfterLoad = false,
|
if (!forBGTask) {
|
||||||
bool shouldDeleteAPKs = false}) {
|
// Subscribe to changes in the app foreground status
|
||||||
// Subscribe to changes in the app foreground status
|
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||||
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
foregroundSubscription = foregroundStream?.listen((event) async {
|
||||||
foregroundSubscription = foregroundStream.listen((event) async {
|
isForeground = event == FGBGType.foreground;
|
||||||
isForeground = event == FGBGType.foreground;
|
if (isForeground) await loadApps();
|
||||||
if (isForeground) await loadApps();
|
|
||||||
});
|
|
||||||
if (shouldDeleteAPKs) {
|
|
||||||
deleteSavedAPKs();
|
|
||||||
}
|
|
||||||
if (shouldLoadApps) {
|
|
||||||
loadApps().then((_) {
|
|
||||||
if (shouldCheckUpdatesAfterLoad) {
|
|
||||||
checkUpdates();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
() async {
|
||||||
|
// Load Apps into memory (in background, this is done later instead of in the constructor)
|
||||||
|
await loadApps();
|
||||||
|
// Delete existing APKs
|
||||||
|
(await getExternalStorageDirectory())
|
||||||
|
?.listSync()
|
||||||
|
.where((element) =>
|
||||||
|
element.path.endsWith('.apk') ||
|
||||||
|
element.path.endsWith('.apk.part'))
|
||||||
|
.forEach((apk) {
|
||||||
|
apk.delete();
|
||||||
|
});
|
||||||
|
}();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ApkFile> downloadApp(String apkUrl, String appId) async {
|
downloadFile(String url, String fileName, Function? onProgress,
|
||||||
|
{bool useExisting = true}) async {
|
||||||
|
var destDir = (await getExternalStorageDirectory())!.path;
|
||||||
StreamedResponse response =
|
StreamedResponse response =
|
||||||
await Client().send(Request('GET', Uri.parse(apkUrl)));
|
await Client().send(Request('GET', Uri.parse(url)));
|
||||||
File downloadFile =
|
File downloadedFile = File('$destDir/$fileName');
|
||||||
File('${(await getExternalStorageDirectory())!.path}/$appId.apk');
|
if (!(downloadedFile.existsSync() && useExisting)) {
|
||||||
if (downloadFile.existsSync()) {
|
File tempDownloadedFile = File('${downloadedFile.path}.part');
|
||||||
downloadFile.deleteSync();
|
if (tempDownloadedFile.existsSync()) {
|
||||||
|
tempDownloadedFile.deleteSync();
|
||||||
|
}
|
||||||
|
var length = response.contentLength;
|
||||||
|
var received = 0;
|
||||||
|
double? progress;
|
||||||
|
var sink = tempDownloadedFile.openWrite();
|
||||||
|
await response.stream.map((s) {
|
||||||
|
received += s.length;
|
||||||
|
progress = (length != null ? received / length * 100 : 30);
|
||||||
|
if (onProgress != null) {
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}).pipe(sink);
|
||||||
|
await sink.close();
|
||||||
|
progress = null;
|
||||||
|
if (onProgress != null) {
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
tempDownloadedFile.deleteSync();
|
||||||
|
throw response.reasonPhrase ?? tr('unexpectedError');
|
||||||
|
}
|
||||||
|
tempDownloadedFile.renameSync(downloadedFile.path);
|
||||||
}
|
}
|
||||||
var length = response.contentLength;
|
return downloadedFile;
|
||||||
var received = 0;
|
}
|
||||||
var sink = downloadFile.openWrite();
|
|
||||||
|
|
||||||
await response.stream.map((s) {
|
Future<DownloadedApk> downloadApp(App app, BuildContext? context) async {
|
||||||
received += s.length;
|
var fileName =
|
||||||
apps[appId]!.downloadProgress =
|
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
|
||||||
(length != null ? received / length * 100 : 30);
|
String downloadUrl = await SourceProvider()
|
||||||
notifyListeners();
|
.getSource(app.url)
|
||||||
return s;
|
.apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
|
||||||
}).pipe(sink);
|
NotificationsProvider? notificationsProvider =
|
||||||
|
context?.read<NotificationsProvider>();
|
||||||
await sink.close();
|
var notif = DownloadNotification(app.name, 100);
|
||||||
apps[appId]!.downloadProgress = null;
|
notificationsProvider?.cancel(notif.id);
|
||||||
notifyListeners();
|
int? prevProg;
|
||||||
|
File downloadedFile =
|
||||||
if (response.statusCode != 200) {
|
await downloadFile(downloadUrl, fileName, (double? progress) {
|
||||||
downloadFile.deleteSync();
|
int? prog = progress?.ceil();
|
||||||
throw response.reasonPhrase ?? 'Unknown Error';
|
if (apps[app.id] != null) {
|
||||||
|
apps[app.id]!.downloadProgress = progress;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
notif = DownloadNotification(app.name, prog ?? 100);
|
||||||
|
if (prog != null && prevProg != prog) {
|
||||||
|
notificationsProvider?.notify(notif);
|
||||||
|
}
|
||||||
|
prevProg = prog;
|
||||||
|
});
|
||||||
|
notificationsProvider?.cancel(notif.id);
|
||||||
|
// Delete older versions of the APK if any
|
||||||
|
for (var file in downloadedFile.parent.listSync()) {
|
||||||
|
var fn = file.path.split('/').last;
|
||||||
|
if (fn.startsWith('${app.id}-') &&
|
||||||
|
fn.endsWith('.apk') &&
|
||||||
|
fn != fileName) {
|
||||||
|
file.delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ApkFile(appId, downloadFile);
|
// If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
|
||||||
|
// The former case should be handled (give the App its real ID), the latter is a security issue
|
||||||
|
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
|
||||||
|
if (app.id != newInfo.packageName) {
|
||||||
|
if (apps[app.id] != null && !SourceProvider().isTempId(app.id)) {
|
||||||
|
throw IDChangedError();
|
||||||
|
}
|
||||||
|
var originalAppId = app.id;
|
||||||
|
app.id = newInfo.packageName;
|
||||||
|
downloadedFile = downloadedFile.renameSync(
|
||||||
|
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
|
||||||
|
if (apps[originalAppId] != null) {
|
||||||
|
await removeApps([originalAppId]);
|
||||||
|
await saveApps([app]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DownloadedApk(app.id, downloadedFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool areDownloadsRunning() => apps.values
|
bool areDownloadsRunning() => apps.values
|
||||||
.where((element) => element.downloadProgress != null)
|
.where((element) => element.downloadProgress != null)
|
||||||
.isNotEmpty;
|
.isNotEmpty;
|
||||||
|
|
||||||
// Given an AppId, uses stored info about the app to download an APK (with user input if needed) and install it
|
Future<bool> canInstallSilently(App app) async {
|
||||||
// Installs can only be done in the foreground, so a notification is sent to get the user's attention if needed
|
return false;
|
||||||
// Returns upon successful download, regardless of installation result
|
// TODO: Uncomment the below once silentupdates are ever figured out
|
||||||
Future<bool> downloadAndInstallLatestApp(
|
// // TODO: This is unreliable - try to get from OS in the future
|
||||||
List<String> appIds, BuildContext context) async {
|
// if (app.apkUrls.length > 1) {
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
|
// var osInfo = await DeviceInfoPlugin().androidInfo;
|
||||||
|
// return app.installedVersion != null &&
|
||||||
|
// osInfo.version.sdkInt >= 30 &&
|
||||||
|
// osInfo.version.release.compareTo('12') >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> waitForUserToReturnToForeground(BuildContext context) async {
|
||||||
NotificationsProvider notificationsProvider =
|
NotificationsProvider notificationsProvider =
|
||||||
context.read<NotificationsProvider>();
|
context.read<NotificationsProvider>();
|
||||||
Map<String, String> appsToInstall = {};
|
if (!isForeground) {
|
||||||
|
await notificationsProvider.notify(completeInstallationNotification,
|
||||||
|
cancelExisting: true);
|
||||||
|
while (await FGBGEvents.stream.first != FGBGType.foreground) {}
|
||||||
|
await notificationsProvider.cancel(completeInstallationNotification.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> canDowngradeApps() async {
|
||||||
|
try {
|
||||||
|
await InstalledApps.getAppInfo('com.berdik.letmedowngrade');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfortunately this 'await' does not actually wait for the APK to finish installing
|
||||||
|
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
|
||||||
|
// If appropriate criteria are met, the update (never a fresh install) happens silently in the background
|
||||||
|
// But even then, we don't know if it actually succeeded
|
||||||
|
Future<void> installApk(DownloadedApk file) async {
|
||||||
|
var newInfo = await PackageArchiveInfo.fromPath(file.file.path);
|
||||||
|
AppInfo? appInfo;
|
||||||
|
try {
|
||||||
|
appInfo = await InstalledApps.getAppInfo(apps[file.appId]!.app.id);
|
||||||
|
} catch (e) {
|
||||||
|
// OK
|
||||||
|
}
|
||||||
|
if (appInfo != null &&
|
||||||
|
int.parse(newInfo.buildNumber) < appInfo.versionCode! &&
|
||||||
|
!(await canDowngradeApps())) {
|
||||||
|
throw DowngradeError();
|
||||||
|
}
|
||||||
|
if (appInfo == null ||
|
||||||
|
int.parse(newInfo.buildNumber) > appInfo.versionCode!) {
|
||||||
|
await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium');
|
||||||
|
}
|
||||||
|
apps[file.appId]!.app.installedVersion =
|
||||||
|
apps[file.appId]!.app.latestVersion;
|
||||||
|
// Don't correct install status as installation may not be done yet
|
||||||
|
await saveApps([apps[file.appId]!.app],
|
||||||
|
attemptToCorrectInstallStatus: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> confirmApkUrl(App app, BuildContext? context) async {
|
||||||
|
// If the App has more than one APK, the user should pick one (if context provided)
|
||||||
|
String? apkUrl = app.apkUrls[app.preferredApkIndex];
|
||||||
|
// get device supported architecture
|
||||||
|
List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
|
||||||
|
|
||||||
|
if (app.apkUrls.length > 1 && context != null) {
|
||||||
|
apkUrl = await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return APKPicker(
|
||||||
|
app: app,
|
||||||
|
initVal: apkUrl,
|
||||||
|
archs: archs,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
|
||||||
|
if (apkUrl != null &&
|
||||||
|
Uri.parse(apkUrl).origin != Uri.parse(app.url).origin &&
|
||||||
|
context != null) {
|
||||||
|
if (await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return APKOriginWarningDialog(
|
||||||
|
sourceUrl: app.url, apkUrl: apkUrl!);
|
||||||
|
}) !=
|
||||||
|
true) {
|
||||||
|
apkUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return apkUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Given a list of AppIds, uses stored info about the apps to download APKs and install them
|
||||||
|
// If the APKs can be installed silently, they are
|
||||||
|
// If no BuildContext is provided, apps that require user interaction are ignored
|
||||||
|
// If user input is needed and the App is in the background, a notification is sent to get the user's attention
|
||||||
|
// Returns an array of Ids for Apps that were successfully downloaded, regardless of installation result
|
||||||
|
Future<List<String>> downloadAndInstallLatestApps(
|
||||||
|
List<String> appIds, BuildContext? context) async {
|
||||||
|
List<String> appsToInstall = [];
|
||||||
|
List<String> trackOnlyAppsToUpdate = [];
|
||||||
|
// For all specified Apps, filter out those for which:
|
||||||
|
// 1. A URL cannot be picked
|
||||||
|
// 2. That cannot be installed silently (IF no buildContext was given for interactive install)
|
||||||
for (var id in appIds) {
|
for (var id in appIds) {
|
||||||
if (apps[id] == null) {
|
if (apps[id] == null) {
|
||||||
throw 'App not found';
|
throw ObtainiumError(tr('appNotFound'));
|
||||||
}
|
}
|
||||||
// If the App has more than one APK, the user should pick one
|
String? apkUrl;
|
||||||
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex];
|
if (!apps[id]!.app.trackOnly) {
|
||||||
if (apps[id]!.app.apkUrls.length > 1) {
|
apkUrl = await confirmApkUrl(apps[id]!.app, context);
|
||||||
apkUrl = await showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext ctx) {
|
|
||||||
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// If the picked APK comes from an origin different from the source, get user confirmation
|
|
||||||
if (apkUrl != null &&
|
|
||||||
Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin) {
|
|
||||||
if (await showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext ctx) {
|
|
||||||
return APKOriginWarningDialog(
|
|
||||||
sourceUrl: apps[id]!.app.url, apkUrl: apkUrl!);
|
|
||||||
}) !=
|
|
||||||
true) {
|
|
||||||
apkUrl = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (apkUrl != null) {
|
if (apkUrl != null) {
|
||||||
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
||||||
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
||||||
apps[id]!.app.preferredApkIndex = urlInd;
|
apps[id]!.app.preferredApkIndex = urlInd;
|
||||||
await saveApp(apps[id]!.app);
|
await saveApps([apps[id]!.app]);
|
||||||
}
|
}
|
||||||
appsToInstall.putIfAbsent(id, () => apkUrl!);
|
if (context != null || await canInstallSilently(apps[id]!.app)) {
|
||||||
|
appsToInstall.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (apps[id]!.app.trackOnly) {
|
||||||
|
trackOnlyAppsToUpdate.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mark all specified track-only apps as latest
|
||||||
|
saveApps(trackOnlyAppsToUpdate.map((e) {
|
||||||
|
var a = apps[e]!.app;
|
||||||
|
a.installedVersion = a.latestVersion;
|
||||||
|
return a;
|
||||||
|
}).toList());
|
||||||
|
// Download APKs for all Apps to be installed
|
||||||
|
MultiAppMultiError errors = MultiAppMultiError();
|
||||||
|
List<DownloadedApk?> downloadedFiles =
|
||||||
|
await Future.wait(appsToInstall.map((id) async {
|
||||||
|
try {
|
||||||
|
return await downloadApp(apps[id]!.app, context);
|
||||||
|
} catch (e) {
|
||||||
|
errors.add(id, e.toString());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
downloadedFiles =
|
||||||
|
downloadedFiles.where((element) => element != null).toList();
|
||||||
|
// Separate the Apps to install into silent and regular lists
|
||||||
|
List<DownloadedApk> silentUpdates = [];
|
||||||
|
List<DownloadedApk> regularInstalls = [];
|
||||||
|
for (var f in downloadedFiles) {
|
||||||
|
bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app);
|
||||||
|
if (willBeSilent) {
|
||||||
|
silentUpdates.add(f);
|
||||||
|
} else {
|
||||||
|
regularInstalls.add(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries
|
// Move everything to the regular install list (since silent updates don't currently work) - TODO
|
||||||
.map((entry) => downloadApp(entry.value, entry.key)));
|
regularInstalls.addAll(silentUpdates);
|
||||||
|
|
||||||
if (!isForeground) {
|
// If Obtainium is being installed, it should be the last one
|
||||||
await notificationsProvider.notify(completeInstallationNotification,
|
List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) {
|
||||||
cancelExisting: true);
|
DownloadedApk? temp;
|
||||||
await FGBGEvents.stream.first == FGBGType.foreground;
|
items.removeWhere((element) {
|
||||||
await notificationsProvider.cancel(completeInstallationNotification.id);
|
bool res =
|
||||||
// We need to wait for the App to come to the foreground to install it
|
element.appId == obtainiumId || element.appId == obtainiumTempId;
|
||||||
// Can't try to call install plugin in a background isolate (may not have worked anyways) because of:
|
if (res) {
|
||||||
// https://github.com/flutter/flutter/issues/13937
|
temp = element;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
if (temp != null) {
|
||||||
|
items = [temp!, ...items];
|
||||||
|
}
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unfortunately this 'await' does not actually wait for the APK to finish installing
|
silentUpdates = moveObtainiumToStart(silentUpdates);
|
||||||
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
|
regularInstalls = moveObtainiumToStart(regularInstalls);
|
||||||
// This also does not use the 'session-based' installer API, so background/silent updates are impossible
|
|
||||||
for (var f in downloadedFiles) {
|
// // Install silent updates (uncomment when it works - TODO)
|
||||||
await InstallPlugin.installApk(f.file.path, 'dev.imranr.obtainium');
|
// for (var u in silentUpdates) {
|
||||||
apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion;
|
// await installApk(u, silent: true); // Would need to add silent option
|
||||||
await saveApp(apps[f.appId]!.app);
|
// }
|
||||||
|
|
||||||
|
// Do regular installs
|
||||||
|
if (regularInstalls.isNotEmpty && context != null) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
await waitForUserToReturnToForeground(context);
|
||||||
|
for (var i in regularInstalls) {
|
||||||
|
try {
|
||||||
|
await installApk(i);
|
||||||
|
} catch (e) {
|
||||||
|
errors.add(i.appId, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return downloadedFiles.isNotEmpty;
|
if (errors.content.isNotEmpty) {
|
||||||
|
throw errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationsProvider().cancel(UpdateNotification([]).id);
|
||||||
|
|
||||||
|
return downloadedFiles.map((e) => e!.appId).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Directory> getAppsDir() async {
|
Future<Directory> getAppsDir() async {
|
||||||
@ -174,96 +388,211 @@ class AppsProvider with ChangeNotifier {
|
|||||||
return appsDir;
|
return appsDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteSavedAPKs() async {
|
Future<AppInfo?> getInstalledInfo(String? packageName) async {
|
||||||
(await getExternalStorageDirectory())
|
if (packageName != null) {
|
||||||
?.listSync()
|
try {
|
||||||
.where((element) => element.path.endsWith('.apk'))
|
return await InstalledApps.getAppInfo(packageName);
|
||||||
.forEach((element) {
|
} catch (e) {
|
||||||
element.deleteSync();
|
// OK
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> loadApps() async {
|
|
||||||
loadingApps = true;
|
|
||||||
notifyListeners();
|
|
||||||
List<FileSystemEntity> appFiles = (await getAppsDir())
|
|
||||||
.listSync()
|
|
||||||
.where((item) => item.path.toLowerCase().endsWith('.json'))
|
|
||||||
.toList();
|
|
||||||
apps.clear();
|
|
||||||
for (int i = 0; i < appFiles.length; i++) {
|
|
||||||
App app =
|
|
||||||
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync()));
|
|
||||||
apps.putIfAbsent(app.id, () => AppInMemory(app, null));
|
|
||||||
}
|
|
||||||
loadingApps = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> saveApp(App app) async {
|
|
||||||
File('${(await getAppsDir()).path}/${app.id}.json')
|
|
||||||
.writeAsStringSync(jsonEncode(app.toJson()));
|
|
||||||
apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress),
|
|
||||||
ifAbsent: () => AppInMemory(app, null));
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> removeApp(String appId) async {
|
|
||||||
File file = File('${(await getAppsDir()).path}/$appId.json');
|
|
||||||
if (file.existsSync()) {
|
|
||||||
file.deleteSync();
|
|
||||||
}
|
|
||||||
if (apps.containsKey(appId)) {
|
|
||||||
apps.remove(appId);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool checkAppObjectForUpdate(App app) {
|
|
||||||
if (!apps.containsKey(app.id)) {
|
|
||||||
throw 'App not found';
|
|
||||||
}
|
|
||||||
return app.latestVersion != apps[app.id]?.app.installedVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<App?> getUpdate(String appId) async {
|
|
||||||
App? currentApp = apps[appId]!.app;
|
|
||||||
App newApp = await SourceProvider().getApp(currentApp.url);
|
|
||||||
if (newApp.latestVersion != currentApp.latestVersion) {
|
|
||||||
newApp.installedVersion = currentApp.installedVersion;
|
|
||||||
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
|
||||||
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
|
||||||
}
|
}
|
||||||
await saveApp(newApp);
|
|
||||||
return newApp;
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<App>> checkUpdates() async {
|
// If the App says it is installed but installedInfo is null, set it to not installed
|
||||||
List<App> updates = [];
|
// If the App says is is not installed but installedInfo exists, set it to the real installed version
|
||||||
if (!gettingUpdates) {
|
// If the internal version does not match the real one, sync them if the App supports enhanced version detection
|
||||||
gettingUpdates = true;
|
// Enhanced version detection will be true if the version extracted from source matches the standard version format
|
||||||
|
// Don't save changes, just return the object if changes were made (else null)
|
||||||
List<String> appIds = apps.keys.toList();
|
// If in a background isolate, return null straight away as the required plugin won't work anyways
|
||||||
for (int i = 0; i < appIds.length; i++) {
|
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
|
||||||
App? newApp = await getUpdate(appIds[i]);
|
if (forBGTask) {
|
||||||
if (newApp != null) {
|
return null; // Can't correct in the background isolate
|
||||||
updates.add(newApp);
|
}
|
||||||
|
var modded = false;
|
||||||
|
if (installedInfo == null &&
|
||||||
|
app.installedVersion != null &&
|
||||||
|
!app.trackOnly) {
|
||||||
|
app.installedVersion = null;
|
||||||
|
modded = true;
|
||||||
|
} else if (installedInfo != null && app.installedVersion == null) {
|
||||||
|
if (app.enhancedVersionDetection) {
|
||||||
|
app.installedVersion = installedInfo.versionName;
|
||||||
|
} else {
|
||||||
|
if (app.latestVersion.contains(installedInfo.versionName!)) {
|
||||||
|
app.installedVersion = app.latestVersion;
|
||||||
|
} else {
|
||||||
|
app.installedVersion = installedInfo.versionName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gettingUpdates = false;
|
modded = true;
|
||||||
|
} else if (installedInfo?.versionName != app.installedVersion &&
|
||||||
|
app.enhancedVersionDetection &&
|
||||||
|
!app.trackOnly) {
|
||||||
|
app.installedVersion = installedInfo?.versionName;
|
||||||
|
modded = true;
|
||||||
|
}
|
||||||
|
return modded ? app : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadApps() async {
|
||||||
|
while (loadingApps) {
|
||||||
|
await Future.delayed(const Duration(microseconds: 1));
|
||||||
|
}
|
||||||
|
loadingApps = true;
|
||||||
|
notifyListeners();
|
||||||
|
List<App> newApps = (await getAppsDir())
|
||||||
|
.listSync()
|
||||||
|
.where((item) => item.path.toLowerCase().endsWith('.json'))
|
||||||
|
.map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync())))
|
||||||
|
.toList();
|
||||||
|
var idsToDelete = apps.values
|
||||||
|
.map((e) => e.app.id)
|
||||||
|
.toSet()
|
||||||
|
.difference(newApps.map((e) => e.id).toSet());
|
||||||
|
for (var id in idsToDelete) {
|
||||||
|
apps.remove(id);
|
||||||
|
}
|
||||||
|
var sp = SourceProvider();
|
||||||
|
List<List<String>> errors = [];
|
||||||
|
for (int i = 0; i < newApps.length; i++) {
|
||||||
|
var info = await getInstalledInfo(newApps[i].id);
|
||||||
|
try {
|
||||||
|
sp.getSource(newApps[i].url);
|
||||||
|
apps[newApps[i].id] = AppInMemory(newApps[i], null, info);
|
||||||
|
} catch (e) {
|
||||||
|
errors.add([newApps[i].id, newApps[i].name, e.toString()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.isNotEmpty) {
|
||||||
|
removeApps(errors.map((e) => e[0]).toList());
|
||||||
|
NotificationsProvider().notify(
|
||||||
|
AppsRemovedNotification(errors.map((e) => [e[1], e[2]]).toList()));
|
||||||
|
}
|
||||||
|
loadingApps = false;
|
||||||
|
notifyListeners();
|
||||||
|
List<App> modifiedApps = [];
|
||||||
|
for (var app in apps.values) {
|
||||||
|
var moddedApp =
|
||||||
|
getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo);
|
||||||
|
if (moddedApp != null) {
|
||||||
|
modifiedApps.add(moddedApp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (modifiedApps.isNotEmpty) {
|
||||||
|
await saveApps(modifiedApps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveApps(List<App> apps,
|
||||||
|
{bool attemptToCorrectInstallStatus = true}) async {
|
||||||
|
for (var app in apps) {
|
||||||
|
AppInfo? info = await getInstalledInfo(app.id);
|
||||||
|
app.name = info?.name ?? app.name;
|
||||||
|
if (attemptToCorrectInstallStatus) {
|
||||||
|
app = getCorrectedInstallStatusAppIfPossible(app, info) ?? app;
|
||||||
|
}
|
||||||
|
File('${(await getAppsDir()).path}/${app.id}.json')
|
||||||
|
.writeAsStringSync(jsonEncode(app.toJson()));
|
||||||
|
this.apps.update(
|
||||||
|
app.id, (value) => AppInMemory(app, value.downloadProgress, info),
|
||||||
|
ifAbsent: () => AppInMemory(app, null, info));
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeApps(List<String> appIds) async {
|
||||||
|
for (var appId in appIds) {
|
||||||
|
File file = File('${(await getAppsDir()).path}/$appId.json');
|
||||||
|
if (file.existsSync()) {
|
||||||
|
file.deleteSync();
|
||||||
|
}
|
||||||
|
if (apps.containsKey(appId)) {
|
||||||
|
apps.remove(appId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (appIds.isNotEmpty) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<App?> checkUpdate(String appId) async {
|
||||||
|
App? currentApp = apps[appId]!.app;
|
||||||
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
|
App newApp = await sourceProvider.getApp(
|
||||||
|
sourceProvider.getSource(currentApp.url),
|
||||||
|
currentApp.url,
|
||||||
|
currentApp.additionalData,
|
||||||
|
name: currentApp.name,
|
||||||
|
id: currentApp.id,
|
||||||
|
pinned: currentApp.pinned,
|
||||||
|
trackOnly: currentApp.trackOnly,
|
||||||
|
installedVersion: currentApp.installedVersion);
|
||||||
|
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
||||||
|
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
||||||
|
}
|
||||||
|
await saveApps([newApp]);
|
||||||
|
return newApp.latestVersion != currentApp.latestVersion ? newApp : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<App>> checkUpdates(
|
||||||
|
{DateTime? ignoreAppsCheckedAfter,
|
||||||
|
bool throwErrorsForRetry = false}) async {
|
||||||
|
List<App> updates = [];
|
||||||
|
MultiAppMultiError errors = MultiAppMultiError();
|
||||||
|
if (!gettingUpdates) {
|
||||||
|
gettingUpdates = true;
|
||||||
|
try {
|
||||||
|
List<String> appIds = apps.values
|
||||||
|
.where((app) =>
|
||||||
|
app.app.lastUpdateCheck == null ||
|
||||||
|
ignoreAppsCheckedAfter == null ||
|
||||||
|
app.app.lastUpdateCheck!.isBefore(ignoreAppsCheckedAfter))
|
||||||
|
.map((e) => e.app.id)
|
||||||
|
.toList();
|
||||||
|
appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ??
|
||||||
|
DateTime.fromMicrosecondsSinceEpoch(0))
|
||||||
|
.compareTo(apps[b]!.app.lastUpdateCheck ??
|
||||||
|
DateTime.fromMicrosecondsSinceEpoch(0)));
|
||||||
|
for (int i = 0; i < appIds.length; i++) {
|
||||||
|
App? newApp;
|
||||||
|
try {
|
||||||
|
newApp = await checkUpdate(appIds[i]);
|
||||||
|
} catch (e) {
|
||||||
|
if ((e is RateLimitError || e is SocketException) &&
|
||||||
|
throwErrorsForRetry) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
errors.add(appIds[i], e.toString());
|
||||||
|
}
|
||||||
|
if (newApp != null) {
|
||||||
|
updates.add(newApp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
gettingUpdates = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.content.isNotEmpty) {
|
||||||
|
throw errors;
|
||||||
}
|
}
|
||||||
return updates;
|
return updates;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> getExistingUpdates() {
|
List<String> findExistingUpdates(
|
||||||
|
{bool installedOnly = false, bool nonInstalledOnly = false}) {
|
||||||
List<String> updateAppIds = [];
|
List<String> updateAppIds = [];
|
||||||
List<String> appIds = apps.keys.toList();
|
List<String> appIds = apps.keys.toList();
|
||||||
for (int i = 0; i < appIds.length; i++) {
|
for (int i = 0; i < appIds.length; i++) {
|
||||||
App? app = apps[appIds[i]]!.app;
|
App? app = apps[appIds[i]]!.app;
|
||||||
if (app.installedVersion != app.latestVersion) {
|
if (app.installedVersion != app.latestVersion &&
|
||||||
updateAppIds.add(app.id);
|
(!installedOnly || !nonInstalledOnly)) {
|
||||||
|
if ((app.installedVersion == null &&
|
||||||
|
(nonInstalledOnly || !installedOnly) ||
|
||||||
|
(app.installedVersion != null &&
|
||||||
|
(installedOnly || !nonInstalledOnly)))) {
|
||||||
|
updateAppIds.add(app.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return updateAppIds;
|
return updateAppIds;
|
||||||
@ -271,44 +600,65 @@ class AppsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<String> exportApps() async {
|
Future<String> exportApps() async {
|
||||||
Directory? exportDir = Directory('/storage/emulated/0/Download');
|
Directory? exportDir = Directory('/storage/emulated/0/Download');
|
||||||
String path = 'Downloads';
|
String path = 'Downloads'; // TODO: Is this true on non-english phones?
|
||||||
if (!exportDir.existsSync()) {
|
if (!exportDir.existsSync()) {
|
||||||
exportDir = await getExternalStorageDirectory();
|
exportDir = await getExternalStorageDirectory();
|
||||||
path = exportDir!.path;
|
path = exportDir!.path;
|
||||||
}
|
}
|
||||||
File export = File(
|
File export = File(
|
||||||
'${exportDir.path}/obtainium-export-${DateTime.now().millisecondsSinceEpoch}.json');
|
'${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json');
|
||||||
export.writeAsStringSync(
|
export.writeAsStringSync(
|
||||||
jsonEncode(apps.values.map((e) => e.app.toJson()).toList()));
|
jsonEncode(apps.values.map((e) => e.app.toJson()).toList()));
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> importApps(String appsJSON) async {
|
Future<int> importApps(String appsJSON) async {
|
||||||
// File picker does not work in android 13, so the user must paste the JSON directly into Obtainium to import Apps
|
|
||||||
List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
|
List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
|
||||||
.map((e) => App.fromJson(e))
|
.map((e) => App.fromJson(e))
|
||||||
.toList();
|
.toList();
|
||||||
for (App a in importedApps) {
|
while (loadingApps) {
|
||||||
a.installedVersion =
|
await Future.delayed(const Duration(microseconds: 1));
|
||||||
apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null;
|
|
||||||
await saveApp(a);
|
|
||||||
}
|
}
|
||||||
|
for (App a in importedApps) {
|
||||||
|
if (apps[a.id]?.app.installedVersion != null) {
|
||||||
|
a.installedVersion = apps[a.id]?.app.installedVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveApps(importedApps);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return importedApps.length;
|
return importedApps.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
foregroundSubscription.cancel();
|
foregroundSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<List<String>>> addAppsByURL(List<String> urls) async {
|
||||||
|
List<dynamic> results = await SourceProvider().getAppsByURLNaive(urls,
|
||||||
|
ignoreUrls: apps.values.map((e) => e.app.url).toList());
|
||||||
|
List<App> pps = results[0];
|
||||||
|
Map<String, dynamic> errorsMap = results[1];
|
||||||
|
for (var app in pps) {
|
||||||
|
if (apps.containsKey(app.id)) {
|
||||||
|
errorsMap.addAll({app.id: tr('appAlreadyAdded')});
|
||||||
|
} else {
|
||||||
|
await saveApps([app]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<List<String>> errors =
|
||||||
|
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class APKPicker extends StatefulWidget {
|
class APKPicker extends StatefulWidget {
|
||||||
const APKPicker({super.key, required this.app, this.initVal});
|
const APKPicker({super.key, required this.app, this.initVal, this.archs});
|
||||||
|
|
||||||
final App app;
|
final App app;
|
||||||
final String? initVal;
|
final String? initVal;
|
||||||
|
final List<String>? archs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<APKPicker> createState() => _APKPickerState();
|
State<APKPicker> createState() => _APKPickerState();
|
||||||
@ -322,33 +672,50 @@ class _APKPickerState extends State<APKPicker> {
|
|||||||
apkUrl ??= widget.initVal;
|
apkUrl ??= widget.initVal;
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: const Text('Pick an APK'),
|
title: Text(tr('pickAnAPK')),
|
||||||
content: Column(children: [
|
content: Column(children: [
|
||||||
Text('${widget.app.name} has more than one package:'),
|
Text(tr('appHasMoreThanOnePackage', args: [widget.app.name])),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
...widget.app.apkUrls.map((u) => RadioListTile<String>(
|
...widget.app.apkUrls.map(
|
||||||
title: Text(Uri.parse(u).pathSegments.last),
|
(u) => RadioListTile<String>(
|
||||||
value: u,
|
title: Text(Uri.parse(u)
|
||||||
groupValue: apkUrl,
|
.pathSegments
|
||||||
onChanged: (String? val) {
|
.where((element) => element.isNotEmpty)
|
||||||
setState(() {
|
.last),
|
||||||
apkUrl = val;
|
value: u,
|
||||||
});
|
groupValue: apkUrl,
|
||||||
}))
|
onChanged: (String? val) {
|
||||||
|
setState(() {
|
||||||
|
apkUrl = val;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
if (widget.archs != null)
|
||||||
|
const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
if (widget.archs != null)
|
||||||
|
Text(
|
||||||
|
widget.archs!.length == 1
|
||||||
|
? tr('deviceSupportsXArch', args: [widget.archs![0]])
|
||||||
|
: tr('deviceSupportsFollowingArchs') +
|
||||||
|
list2FriendlyString(
|
||||||
|
widget.archs!.map((e) => '\'$e\'').toList()),
|
||||||
|
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
Navigator.of(context).pop(null);
|
Navigator.of(context).pop(null);
|
||||||
},
|
},
|
||||||
child: const Text('Cancel')),
|
child: Text(tr('cancel'))),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback.selectionClick();
|
||||||
Navigator.of(context).pop(apkUrl);
|
Navigator.of(context).pop(apkUrl);
|
||||||
},
|
},
|
||||||
child: const Text('Continue'))
|
child: Text(tr('continue')))
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -370,22 +737,23 @@ class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: const Text('Warning'),
|
title: Text(tr('warning')),
|
||||||
content: Text(
|
content: Text(tr('sourceIsXButPackageFromYPrompt', args: [
|
||||||
'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'),
|
Uri.parse(widget.sourceUrl).host,
|
||||||
|
Uri.parse(widget.apkUrl).host
|
||||||
|
])),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
Navigator.of(context).pop(null);
|
Navigator.of(context).pop(null);
|
||||||
},
|
},
|
||||||
child: const Text('Cancel')),
|
child: Text(tr('cancel'))),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback.selectionClick();
|
||||||
Navigator.of(context).pop(true);
|
Navigator.of(context).pop(true);
|
||||||
},
|
},
|
||||||
child: const Text('Continue'))
|
child: Text(tr('continue')))
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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,10 +1,15 @@
|
|||||||
// Exposes functions used to save/load app settings
|
// Exposes functions used to save/load app settings
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
|
import 'package:obtainium/app_sources/github.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
String obtainiumTempId = 'imranr98_obtainium_${GitHub().host}';
|
||||||
|
String obtainiumId = 'dev.imranr.obtainium';
|
||||||
|
|
||||||
enum ThemeSettings { system, light, dark }
|
enum ThemeSettings { system, light, dark }
|
||||||
|
|
||||||
enum ColourSettings { basic, materialYou }
|
enum ColourSettings { basic, materialYou }
|
||||||
@ -13,6 +18,16 @@ enum SortColumnSettings { added, nameAuthor, authorName }
|
|||||||
|
|
||||||
enum SortOrderSettings { ascending, descending }
|
enum SortOrderSettings { ascending, descending }
|
||||||
|
|
||||||
|
const maxAPIRateLimitMinutes = 30;
|
||||||
|
const minUpdateIntervalMinutes = maxAPIRateLimitMinutes + 30;
|
||||||
|
const maxUpdateIntervalMinutes = 4320;
|
||||||
|
List<int> updateIntervals = [15, 30, 60, 120, 180, 360, 720, 1440, 4320, 0]
|
||||||
|
.where((element) =>
|
||||||
|
(element >= minUpdateIntervalMinutes &&
|
||||||
|
element <= maxUpdateIntervalMinutes) ||
|
||||||
|
element == 0)
|
||||||
|
.toList();
|
||||||
|
|
||||||
class SettingsProvider with ChangeNotifier {
|
class SettingsProvider with ChangeNotifier {
|
||||||
SharedPreferences? prefs;
|
SharedPreferences? prefs;
|
||||||
|
|
||||||
@ -45,7 +60,17 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int get updateInterval {
|
int get updateInterval {
|
||||||
return prefs?.getInt('updateInterval') ?? 1440;
|
var min = prefs?.getInt('updateInterval') ?? 360;
|
||||||
|
if (!updateIntervals.contains(min)) {
|
||||||
|
var temp = updateIntervals[0];
|
||||||
|
for (var i in updateIntervals) {
|
||||||
|
if (min > i && i != 0) {
|
||||||
|
temp = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
min = temp;
|
||||||
|
}
|
||||||
|
return min;
|
||||||
}
|
}
|
||||||
|
|
||||||
set updateInterval(int min) {
|
set updateInterval(int min) {
|
||||||
@ -54,8 +79,8 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SortColumnSettings get sortColumn {
|
SortColumnSettings get sortColumn {
|
||||||
return SortColumnSettings
|
return SortColumnSettings.values[
|
||||||
.values[prefs?.getInt('sortColumn') ?? SortColumnSettings.added.index];
|
prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index];
|
||||||
}
|
}
|
||||||
|
|
||||||
set sortColumn(SortColumnSettings s) {
|
set sortColumn(SortColumnSettings s) {
|
||||||
@ -65,7 +90,7 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
SortOrderSettings get sortOrder {
|
SortOrderSettings get sortOrder {
|
||||||
return SortOrderSettings.values[
|
return SortOrderSettings.values[
|
||||||
prefs?.getInt('sortOrder') ?? SortOrderSettings.descending.index];
|
prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index];
|
||||||
}
|
}
|
||||||
|
|
||||||
set sortOrder(SortOrderSettings s) {
|
set sortOrder(SortOrderSettings s) {
|
||||||
@ -85,8 +110,7 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
while (!(await Permission.requestInstallPackages.isGranted)) {
|
while (!(await Permission.requestInstallPackages.isGranted)) {
|
||||||
// Explicit request as InstallPlugin request sometimes bugged
|
// Explicit request as InstallPlugin request sometimes bugged
|
||||||
Fluttertoast.showToast(
|
Fluttertoast.showToast(
|
||||||
msg: 'Please allow Obtainium to install Apps',
|
msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG);
|
||||||
toastLength: Toast.LENGTH_LONG);
|
|
||||||
if ((await Permission.requestInstallPackages.request()) ==
|
if ((await Permission.requestInstallPackages.request()) ==
|
||||||
PermissionStatus.granted) {
|
PermissionStatus.granted) {
|
||||||
break;
|
break;
|
||||||
@ -95,11 +119,29 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool get showAppWebpage {
|
bool get showAppWebpage {
|
||||||
return prefs?.getBool('showAppWebpage') ?? true;
|
return prefs?.getBool('showAppWebpage') ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
set showAppWebpage(bool show) {
|
set showAppWebpage(bool show) {
|
||||||
prefs?.setBool('showAppWebpage', show);
|
prefs?.setBool('showAppWebpage', show);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get pinUpdates {
|
||||||
|
return prefs?.getBool('pinUpdates') ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
set pinUpdates(bool show) {
|
||||||
|
prefs?.setBool('pinUpdates', show);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? getSettingString(String settingId) {
|
||||||
|
return prefs?.getString(settingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSettingString(String settingId, String value) {
|
||||||
|
prefs?.setString(settingId, value);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,9 +3,20 @@
|
|||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:html/dom.dart';
|
import 'package:html/dom.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:html/parser.dart';
|
import 'package:obtainium/app_sources/apkmirror.dart';
|
||||||
|
import 'package:obtainium/app_sources/fdroid.dart';
|
||||||
|
import 'package:obtainium/app_sources/github.dart';
|
||||||
|
import 'package:obtainium/app_sources/gitlab.dart';
|
||||||
|
import 'package:obtainium/app_sources/izzyondroid.dart';
|
||||||
|
import 'package:obtainium/app_sources/mullvad.dart';
|
||||||
|
import 'package:obtainium/app_sources/signal.dart';
|
||||||
|
import 'package:obtainium/app_sources/sourceforge.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
|
import 'package:obtainium/mass_app_sources/githubstars.dart';
|
||||||
|
|
||||||
class AppNames {
|
class AppNames {
|
||||||
late String author;
|
late String author;
|
||||||
@ -16,9 +27,15 @@ class AppNames {
|
|||||||
|
|
||||||
class APKDetails {
|
class APKDetails {
|
||||||
late String version;
|
late String version;
|
||||||
|
late String versionFromSource;
|
||||||
|
late bool isStandardVersion;
|
||||||
late List<String> apkUrls;
|
late List<String> apkUrls;
|
||||||
|
|
||||||
APKDetails(this.version, this.apkUrls);
|
APKDetails(this.versionFromSource, this.apkUrls) {
|
||||||
|
var temp = extractStandardVersionName(versionFromSource);
|
||||||
|
this.isStandardVersion = temp != null;
|
||||||
|
this.version = temp ?? versionFromSource;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class App {
|
class App {
|
||||||
@ -30,28 +47,55 @@ class App {
|
|||||||
late String latestVersion;
|
late String latestVersion;
|
||||||
List<String> apkUrls = [];
|
List<String> apkUrls = [];
|
||||||
late int preferredApkIndex;
|
late int preferredApkIndex;
|
||||||
App(this.id, this.url, this.author, this.name, this.installedVersion,
|
late List<String> additionalData;
|
||||||
this.latestVersion, this.apkUrls, this.preferredApkIndex);
|
late DateTime? lastUpdateCheck;
|
||||||
|
bool pinned = false;
|
||||||
|
bool trackOnly = false;
|
||||||
|
bool enhancedVersionDetection = false;
|
||||||
|
App(
|
||||||
|
this.id,
|
||||||
|
this.url,
|
||||||
|
this.author,
|
||||||
|
this.name,
|
||||||
|
this.installedVersion,
|
||||||
|
this.latestVersion,
|
||||||
|
this.apkUrls,
|
||||||
|
this.preferredApkIndex,
|
||||||
|
this.additionalData,
|
||||||
|
this.lastUpdateCheck,
|
||||||
|
this.pinned,
|
||||||
|
this.trackOnly,
|
||||||
|
this.enhancedVersionDetection);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls';
|
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned';
|
||||||
}
|
}
|
||||||
|
|
||||||
factory App.fromJson(Map<String, dynamic> json) => App(
|
factory App.fromJson(Map<String, dynamic> json) => App(
|
||||||
json['id'] as String,
|
json['id'] as String,
|
||||||
json['url'] as String,
|
json['url'] as String,
|
||||||
json['author'] as String,
|
json['author'] as String,
|
||||||
json['name'] as String,
|
json['name'] as String,
|
||||||
json['installedVersion'] == null
|
json['installedVersion'] == null
|
||||||
? null
|
? null
|
||||||
: json['installedVersion'] as String,
|
: json['installedVersion'] as String,
|
||||||
json['latestVersion'] as String,
|
json['latestVersion'] as String,
|
||||||
List<String>.from(jsonDecode(json['apkUrls'])),
|
json['apkUrls'] == null
|
||||||
json['preferredApkIndex'] == null
|
? []
|
||||||
? 0
|
: List<String>.from(jsonDecode(json['apkUrls'])),
|
||||||
: json['preferredApkIndex'] as int,
|
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
|
||||||
);
|
json['additionalData'] == null
|
||||||
|
? SourceProvider()
|
||||||
|
.getSource(json['url'])
|
||||||
|
.additionalSourceAppSpecificDefaults
|
||||||
|
: List<String>.from(jsonDecode(json['additionalData'])),
|
||||||
|
json['lastUpdateCheck'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
|
||||||
|
json['pinned'] ?? false,
|
||||||
|
json['trackOnly'] ?? false,
|
||||||
|
json['enhancedVersionDetection'] ?? false);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
@ -61,20 +105,32 @@ class App {
|
|||||||
'installedVersion': installedVersion,
|
'installedVersion': installedVersion,
|
||||||
'latestVersion': latestVersion,
|
'latestVersion': latestVersion,
|
||||||
'apkUrls': jsonEncode(apkUrls),
|
'apkUrls': jsonEncode(apkUrls),
|
||||||
'preferredApkIndex': preferredApkIndex
|
'preferredApkIndex': preferredApkIndex,
|
||||||
|
'additionalData': jsonEncode(additionalData),
|
||||||
|
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
|
||||||
|
'pinned': pinned,
|
||||||
|
'trackOnly': trackOnly,
|
||||||
|
'enhancedVersionDetection': enhancedVersionDetection
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
escapeRegEx(String s) {
|
// Ensure the input is starts with HTTPS and has no WWW
|
||||||
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
preStandardizeUrl(String url) {
|
||||||
return "\\${x[0]}";
|
if (url.toLowerCase().indexOf('http://') != 0 &&
|
||||||
});
|
url.toLowerCase().indexOf('https://') != 0) {
|
||||||
|
url = 'https://$url';
|
||||||
|
}
|
||||||
|
if (url.toLowerCase().indexOf('https://www.') == 0) {
|
||||||
|
url = 'https://${url.substring(12)}';
|
||||||
|
}
|
||||||
|
url = url
|
||||||
|
.split('/')
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.join('/')
|
||||||
|
.replaceFirst(':/', '://');
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
const String couldNotFindReleases = 'Unable to fetch release info';
|
|
||||||
const String couldNotFindLatestVersion =
|
|
||||||
'Could not determine latest release version';
|
|
||||||
const String notValidURL = 'Not a valid URL';
|
|
||||||
const String noAPKFound = 'No APK found';
|
const String noAPKFound = 'No APK found';
|
||||||
|
|
||||||
List<String> getLinksFromParsedHTML(
|
List<String> getLinksFromParsedHTML(
|
||||||
@ -88,326 +144,93 @@ List<String> getLinksFromParsedHTML(
|
|||||||
.map((e) => '$prependToLinks${e.attributes['href']!}')
|
.map((e) => '$prependToLinks${e.attributes['href']!}')
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
abstract class AppSource {
|
class AppSource {
|
||||||
late String host;
|
late String host;
|
||||||
String standardizeURL(String url);
|
bool enforceTrackOnly = false;
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl);
|
|
||||||
AppNames getAppNames(String standardUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
class GitHub implements AppSource {
|
|
||||||
@override
|
|
||||||
late String host = 'github.com';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
String standardizeURL(String url) {
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
throw NotImplementedError();
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw notValidURL;
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
String standardUrl, List<String> additionalData,
|
||||||
Response res = await get(Uri.parse(
|
{bool trackOnly = false}) {
|
||||||
'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
|
throw NotImplementedError();
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var releases = jsonDecode(res.body) as List<dynamic>;
|
|
||||||
// Right now, the latest non-prerelease version is picked
|
|
||||||
// If none exists, the latest prerelease version is picked
|
|
||||||
// In the future, the user could be given a choice
|
|
||||||
var nonPrereleaseReleases =
|
|
||||||
releases.where((element) => element['prerelease'] != true).toList();
|
|
||||||
var latestRelease = nonPrereleaseReleases.isNotEmpty
|
|
||||||
? nonPrereleaseReleases[0]
|
|
||||||
: releases.isNotEmpty
|
|
||||||
? releases[0]
|
|
||||||
: null;
|
|
||||||
if (latestRelease == null) {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
List<dynamic>? assets = latestRelease['assets'];
|
|
||||||
List<String>? apkUrlList = assets
|
|
||||||
?.map((e) {
|
|
||||||
return e['browser_download_url'] != null
|
|
||||||
? e['browser_download_url'] as String
|
|
||||||
: '';
|
|
||||||
})
|
|
||||||
.where((element) => element.toLowerCase().endsWith('.apk'))
|
|
||||||
.toList();
|
|
||||||
if (apkUrlList == null || apkUrlList.isEmpty) {
|
|
||||||
throw noAPKFound;
|
|
||||||
}
|
|
||||||
String? version = latestRelease['tag_name'];
|
|
||||||
if (version == null) {
|
|
||||||
throw couldNotFindLatestVersion;
|
|
||||||
}
|
|
||||||
return APKDetails(version, apkUrlList);
|
|
||||||
} else {
|
|
||||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
|
||||||
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
|
|
||||||
}
|
|
||||||
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
AppNames getAppNames(String standardUrl) {
|
||||||
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
throw NotImplementedError();
|
||||||
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
}
|
||||||
return AppNames(names[0], names[1]);
|
|
||||||
|
// Different Sources may need different kinds of additional data for Apps
|
||||||
|
List<List<GeneratedFormItem>> additionalSourceAppSpecificFormItems = [];
|
||||||
|
List<String> additionalSourceAppSpecificDefaults = [];
|
||||||
|
|
||||||
|
// Some additional data may be needed for Apps regardless of Source
|
||||||
|
final List<GeneratedFormItem> additionalAppSpecificSourceAgnosticFormItems = [
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: tr('trackOnly'),
|
||||||
|
type: FormItemType.bool,
|
||||||
|
key: 'trackOnlyFormItemKey')
|
||||||
|
];
|
||||||
|
final List<String> additionalAppSpecificSourceAgnosticDefaults = [''];
|
||||||
|
|
||||||
|
// Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider
|
||||||
|
List<GeneratedFormItem> additionalSourceSpecificSettingFormItems = [];
|
||||||
|
|
||||||
|
String? changeLogPageFromStandardUrl(String standardUrl) {
|
||||||
|
throw NotImplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
|
||||||
|
return apkUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canSearch = false;
|
||||||
|
Future<Map<String, String>> search(String query) {
|
||||||
|
throw NotImplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? tryInferringAppId(String standardUrl) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class GitLab implements AppSource {
|
ObtainiumError getObtainiumHttpError(Response res) {
|
||||||
@override
|
return ObtainiumError(res.reasonPhrase ??
|
||||||
late String host = 'gitlab.com';
|
tr('errorWithHttpStatusCode', args: [res.statusCode.toString()]));
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw notValidURL;
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var standardUri = Uri.parse(standardUrl);
|
|
||||||
var parsedHtml = parse(res.body);
|
|
||||||
var entry = parsedHtml.querySelector('entry');
|
|
||||||
var entryContent =
|
|
||||||
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
|
|
||||||
var apkUrlList = [
|
|
||||||
...getLinksFromParsedHTML(
|
|
||||||
entryContent,
|
|
||||||
RegExp(
|
|
||||||
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
|
|
||||||
caseSensitive: false),
|
|
||||||
standardUri.origin),
|
|
||||||
// GitLab releases may contain links to externally hosted APKs
|
|
||||||
...getLinksFromParsedHTML(entryContent,
|
|
||||||
RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
|
|
||||||
.where((element) => Uri.parse(element).host != '')
|
|
||||||
.toList()
|
|
||||||
];
|
|
||||||
if (apkUrlList.isEmpty) {
|
|
||||||
throw noAPKFound;
|
|
||||||
}
|
|
||||||
|
|
||||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
|
||||||
var version =
|
|
||||||
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
|
||||||
if (version == null) {
|
|
||||||
throw couldNotFindLatestVersion;
|
|
||||||
}
|
|
||||||
return APKDetails(version, apkUrlList);
|
|
||||||
} else {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
// Same as GitHub
|
|
||||||
return GitHub().getAppNames(standardUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Signal implements AppSource {
|
String? extractStandardVersionName(String version, {bool strict = false}) {
|
||||||
@override
|
var match = RegExp(
|
||||||
late String host = 'signal.org';
|
'${strict ? '^' : ''}[0-9]+(\\.[0-9]+)+(-(alpha|beta|ocs)([0-9]+|\\+[0-9]+)?)?${strict ? '\$' : ''}')
|
||||||
|
.firstMatch(version);
|
||||||
@override
|
return match != null ? version.substring(match.start, match.end) : null;
|
||||||
String standardizeURL(String url) {
|
|
||||||
return 'https://$host';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res =
|
|
||||||
await get(Uri.parse('https://updates.$host/android/latest.json'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var json = jsonDecode(res.body);
|
|
||||||
String? apkUrl = json['url'];
|
|
||||||
if (apkUrl == null) {
|
|
||||||
throw noAPKFound;
|
|
||||||
}
|
|
||||||
String? version = json['versionName'];
|
|
||||||
if (version == null) {
|
|
||||||
throw couldNotFindLatestVersion;
|
|
||||||
}
|
|
||||||
return APKDetails(version, [apkUrl]);
|
|
||||||
} else {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class FDroid implements AppSource {
|
abstract class MassAppUrlSource {
|
||||||
@override
|
late String name;
|
||||||
late String host = 'f-droid.org';
|
late List<String> requiredArgs;
|
||||||
|
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw notValidURL;
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res = await get(Uri.parse(standardUrl));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var latestReleaseDiv =
|
|
||||||
parse(res.body).querySelector('#latest.package-version');
|
|
||||||
var apkUrl = latestReleaseDiv
|
|
||||||
?.querySelector('.package-version-download a')
|
|
||||||
?.attributes['href'];
|
|
||||||
if (apkUrl == null) {
|
|
||||||
throw noAPKFound;
|
|
||||||
}
|
|
||||||
var version = latestReleaseDiv
|
|
||||||
?.querySelector('.package-version-header b')
|
|
||||||
?.innerHtml
|
|
||||||
.split(' ')
|
|
||||||
.last;
|
|
||||||
if (version == null) {
|
|
||||||
throw couldNotFindLatestVersion;
|
|
||||||
}
|
|
||||||
return APKDetails(version, [apkUrl]);
|
|
||||||
} else {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Mullvad implements AppSource {
|
|
||||||
@override
|
|
||||||
late String host = 'mullvad.net';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw notValidURL;
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var version = parse(res.body)
|
|
||||||
.querySelector('p.subtitle.is-6')
|
|
||||||
?.querySelector('a')
|
|
||||||
?.attributes['href']
|
|
||||||
?.split('/')
|
|
||||||
.last;
|
|
||||||
if (version == null) {
|
|
||||||
throw couldNotFindLatestVersion;
|
|
||||||
}
|
|
||||||
return APKDetails(
|
|
||||||
version, ['https://mullvad.net/download/app/apk/latest']);
|
|
||||||
} else {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class IzzyOnDroid implements AppSource {
|
|
||||||
@override
|
|
||||||
late String host = 'android.izzysoft.de';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw notValidURL;
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res = await get(Uri.parse(standardUrl));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var parsedHtml = parse(res.body);
|
|
||||||
var multipleVersionApkUrls = parsedHtml
|
|
||||||
.querySelectorAll('a')
|
|
||||||
.where((element) =>
|
|
||||||
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
|
|
||||||
false)
|
|
||||||
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
|
|
||||||
.toList();
|
|
||||||
if (multipleVersionApkUrls.isEmpty) {
|
|
||||||
throw noAPKFound;
|
|
||||||
}
|
|
||||||
var version = parsedHtml
|
|
||||||
.querySelector('#keydata')
|
|
||||||
?.querySelectorAll('b')
|
|
||||||
.where(
|
|
||||||
(element) => element.innerHtml.toLowerCase().contains('version'))
|
|
||||||
.toList()[0]
|
|
||||||
.parentNode
|
|
||||||
?.parentNode
|
|
||||||
?.children[1]
|
|
||||||
.innerHtml;
|
|
||||||
if (version == null) {
|
|
||||||
throw couldNotFindLatestVersion;
|
|
||||||
}
|
|
||||||
return APKDetails(version, [multipleVersionApkUrls[0]]);
|
|
||||||
} else {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class SourceProvider {
|
class SourceProvider {
|
||||||
|
// Add more source classes here so they are available via the service
|
||||||
List<AppSource> sources = [
|
List<AppSource> sources = [
|
||||||
GitHub(),
|
GitHub(),
|
||||||
GitLab(),
|
GitLab(),
|
||||||
FDroid(),
|
FDroid(),
|
||||||
|
IzzyOnDroid(),
|
||||||
Mullvad(),
|
Mullvad(),
|
||||||
Signal(),
|
Signal(),
|
||||||
IzzyOnDroid()
|
SourceForge(),
|
||||||
|
APKMirror()
|
||||||
];
|
];
|
||||||
|
|
||||||
List<MassAppSource> massSources = [GitHubStars()];
|
// Add more mass url source classes here so they are available via the service
|
||||||
|
List<MassAppUrlSource> massUrlSources = [GitHubStars()];
|
||||||
|
|
||||||
// Add more source classes here so they are available via the service
|
|
||||||
AppSource getSource(String url) {
|
AppSource getSource(String url) {
|
||||||
|
url = preStandardizeUrl(url);
|
||||||
AppSource? source;
|
AppSource? source;
|
||||||
for (var s in sources) {
|
for (var s in sources) {
|
||||||
if (url.toLowerCase().contains('://${s.host}')) {
|
if (url.toLowerCase().contains('://${s.host}')) {
|
||||||
@ -416,82 +239,93 @@ class SourceProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (source == null) {
|
if (source == null) {
|
||||||
throw 'URL does not match a known source';
|
throw UnsupportedURLError();
|
||||||
}
|
}
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<App> getApp(String url) async {
|
bool ifSourceAppsRequireAdditionalData(AppSource source) {
|
||||||
if (url.toLowerCase().indexOf('http://') != 0 &&
|
for (var row in source.additionalSourceAppSpecificFormItems) {
|
||||||
url.toLowerCase().indexOf('https://') != 0) {
|
for (var element in row) {
|
||||||
url = 'https://$url';
|
if (element.required) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (url.toLowerCase().indexOf('https://www.') == 0) {
|
return false;
|
||||||
url = 'https://${url.substring(12)}';
|
|
||||||
}
|
|
||||||
AppSource source = getSource(url);
|
|
||||||
String standardUrl = source.standardizeURL(url);
|
|
||||||
AppNames names = source.getAppNames(standardUrl);
|
|
||||||
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
|
|
||||||
return App(
|
|
||||||
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
|
|
||||||
standardUrl,
|
|
||||||
names.author[0].toUpperCase() + names.author.substring(1),
|
|
||||||
names.name[0].toUpperCase() + names.name.substring(1),
|
|
||||||
null,
|
|
||||||
apk.version,
|
|
||||||
apk.apkUrls,
|
|
||||||
apk.apkUrls.length - 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a length 2 list, where the first element is a list of Apps and
|
String generateTempID(AppNames names, AppSource source) =>
|
||||||
/// the second is a Map<String, dynamic> of URLs and errors
|
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}';
|
||||||
Future<List<dynamic>> getApps(List<String> urls) async {
|
|
||||||
|
bool isTempId(String id) {
|
||||||
|
List<String> parts = id.split('_');
|
||||||
|
if (parts.length < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < parts.length - 1; i++) {
|
||||||
|
if (RegExp('.*[A-Z].*').hasMatch(parts[i])) {
|
||||||
|
// TODO: RegEx won't work for non-eng chars
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sources.map((e) => e.host).contains(parts.last);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<App> getApp(AppSource source, String url, List<String> additionalData,
|
||||||
|
{String name = '',
|
||||||
|
String? id,
|
||||||
|
bool pinned = false,
|
||||||
|
bool trackOnly = false,
|
||||||
|
String? installedVersion}) async {
|
||||||
|
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
||||||
|
AppNames names = source.getAppNames(standardUrl);
|
||||||
|
APKDetails apk = await source
|
||||||
|
.getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly);
|
||||||
|
if (apk.apkUrls.isEmpty && !trackOnly) {
|
||||||
|
throw NoAPKError();
|
||||||
|
}
|
||||||
|
bool enhancedVersionDetection = apk.isStandardVersion &&
|
||||||
|
installedVersion != null &&
|
||||||
|
extractStandardVersionName(installedVersion, strict: true) != null;
|
||||||
|
if (!enhancedVersionDetection) {
|
||||||
|
apk.version = apk.versionFromSource;
|
||||||
|
}
|
||||||
|
String apkVersion = apk.version.replaceAll('/', '-');
|
||||||
|
return App(
|
||||||
|
id ??
|
||||||
|
source.tryInferringAppId(standardUrl) ??
|
||||||
|
generateTempID(names, source),
|
||||||
|
standardUrl,
|
||||||
|
names.author[0].toUpperCase() + names.author.substring(1),
|
||||||
|
name.trim().isNotEmpty
|
||||||
|
? name
|
||||||
|
: names.name[0].toUpperCase() + names.name.substring(1),
|
||||||
|
installedVersion,
|
||||||
|
apkVersion,
|
||||||
|
apk.apkUrls,
|
||||||
|
apk.apkUrls.length - 1,
|
||||||
|
additionalData,
|
||||||
|
DateTime.now(),
|
||||||
|
pinned,
|
||||||
|
trackOnly,
|
||||||
|
enhancedVersionDetection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns errors in [results, errors] instead of throwing them
|
||||||
|
Future<List<dynamic>> getAppsByURLNaive(List<String> urls,
|
||||||
|
{List<String> ignoreUrls = const []}) async {
|
||||||
List<App> apps = [];
|
List<App> apps = [];
|
||||||
Map<String, dynamic> errors = {};
|
Map<String, dynamic> errors = {};
|
||||||
for (var url in urls) {
|
for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
|
||||||
try {
|
try {
|
||||||
apps.add(await getApp(url));
|
var source = getSource(url);
|
||||||
|
apps.add(await getApp(
|
||||||
|
source, url, source.additionalSourceAppSpecificDefaults));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errors.addAll(<String, dynamic>{url: e});
|
errors.addAll(<String, dynamic>{url: e});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [apps, errors];
|
return [apps, errors];
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class MassAppSource {
|
|
||||||
late String name;
|
|
||||||
late List<String> requiredArgs;
|
|
||||||
Future<List<String>> getUrls(List<String> args);
|
|
||||||
}
|
|
||||||
|
|
||||||
class GitHubStars implements MassAppSource {
|
|
||||||
@override
|
|
||||||
late String name = 'GitHub Starred Repos';
|
|
||||||
|
|
||||||
@override
|
|
||||||
late List<String> requiredArgs = ['Username'];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<String>> getUrls(List<String> args) async {
|
|
||||||
if (args.length != requiredArgs.length) {
|
|
||||||
throw 'Wrong number of arguments provided';
|
|
||||||
}
|
|
||||||
Response res =
|
|
||||||
await get(Uri.parse('https://api.github.com/users/${args[0]}/starred'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
return (jsonDecode(res.body) as List<dynamic>)
|
|
||||||
.map((e) => e['html_url'] as String)
|
|
||||||
.toList();
|
|
||||||
} else {
|
|
||||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
|
||||||
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
|
|
||||||
}
|
|
||||||
|
|
||||||
throw 'Unable to find user\'s starred repos';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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:
|
||||||
@ -98,42 +126,14 @@ packages:
|
|||||||
name: device_info_plus
|
name: device_info_plus
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "8.0.0"
|
||||||
device_info_plus_linux:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: device_info_plus_linux
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.0"
|
|
||||||
device_info_plus_macos:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: device_info_plus_macos
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.0"
|
|
||||||
device_info_plus_platform_interface:
|
device_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: device_info_plus_platform_interface
|
name: device_info_plus_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "7.0.0"
|
||||||
device_info_plus_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: device_info_plus_web
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.0"
|
|
||||||
device_info_plus_windows:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: device_info_plus_windows
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "4.1.0"
|
|
||||||
dynamic_color:
|
dynamic_color:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -141,6 +141,20 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.4"
|
version: "1.5.4"
|
||||||
|
easy_localization:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: easy_localization
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
|
easy_logger:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: easy_logger
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.0.2"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -168,7 +182,7 @@ packages:
|
|||||||
name: file_picker
|
name: file_picker
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.0"
|
version: "5.2.3"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -180,14 +194,14 @@ packages:
|
|||||||
name: flutter_fgbg
|
name: flutter_fgbg
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0"
|
version: "0.2.2"
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: flutter_launcher_icons
|
name: flutter_launcher_icons
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.10.0"
|
version: "0.11.0"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@ -201,21 +215,26 @@ packages:
|
|||||||
name: flutter_local_notifications
|
name: flutter_local_notifications
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.9.1"
|
version: "12.0.4"
|
||||||
flutter_local_notifications_linux:
|
flutter_local_notifications_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_linux
|
name: flutter_local_notifications_linux
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.1"
|
version: "2.0.0"
|
||||||
flutter_local_notifications_platform_interface:
|
flutter_local_notifications_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_platform_interface
|
name: flutter_local_notifications_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_localizations:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -239,14 +258,14 @@ packages:
|
|||||||
name: fluttertoast
|
name: fluttertoast
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.9"
|
version: "8.1.1"
|
||||||
html:
|
html:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: html
|
name: html
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.15.0"
|
version: "0.15.1"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -260,14 +279,14 @@ packages:
|
|||||||
name: http_parser
|
name: http_parser
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.1"
|
version: "4.0.2"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.0"
|
version: "3.2.2"
|
||||||
install_plugin_v2:
|
install_plugin_v2:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -275,6 +294,20 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
installed_apps:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: installed_apps
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.1"
|
||||||
|
intl:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.17.0"
|
||||||
js:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -288,14 +321,14 @@ packages:
|
|||||||
name: json_annotation
|
name: json_annotation
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.6.0"
|
version: "4.7.0"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: lints
|
name: lints
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.1"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -309,7 +342,7 @@ packages:
|
|||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0"
|
version: "0.1.5"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -317,6 +350,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.8.0"
|
version: "1.8.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -324,6 +364,20 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
package_archive_info:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: package_archive_info
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.0"
|
||||||
|
package_info:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -344,7 +398,7 @@ packages:
|
|||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.20"
|
version: "2.0.22"
|
||||||
path_provider_ios:
|
path_provider_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -372,7 +426,7 @@ packages:
|
|||||||
name: path_provider_platform_interface
|
name: path_provider_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.4"
|
version: "2.0.5"
|
||||||
path_provider_windows:
|
path_provider_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -386,42 +440,42 @@ packages:
|
|||||||
name: permission_handler
|
name: permission_handler
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.0"
|
version: "10.2.0"
|
||||||
permission_handler_android:
|
permission_handler_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_android
|
name: permission_handler_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.0"
|
version: "10.2.0"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_apple
|
name: permission_handler_apple
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.0.4"
|
version: "9.0.7"
|
||||||
permission_handler_platform_interface:
|
permission_handler_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_platform_interface
|
name: permission_handler_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.7.0"
|
version: "3.9.0"
|
||||||
permission_handler_windows:
|
permission_handler_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_windows
|
name: permission_handler_windows
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.0"
|
version: "0.1.2"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: petitparser
|
name: petitparser
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
version: "5.1.0"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -436,6 +490,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
version: "2.1.3"
|
||||||
|
pointycastle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pointycastle
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.6.2"
|
||||||
process:
|
process:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -449,7 +510,21 @@ packages:
|
|||||||
name: provider
|
name: provider
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.3"
|
version: "6.0.4"
|
||||||
|
share_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: share_plus
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.0"
|
||||||
|
share_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: share_plus_platform_interface
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -463,7 +538,7 @@ packages:
|
|||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.13"
|
version: "2.0.14"
|
||||||
shared_preferences_ios:
|
shared_preferences_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -517,7 +592,21 @@ packages:
|
|||||||
name: source_span
|
name: source_span
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.0"
|
||||||
|
sqflite:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sqflite
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.1"
|
||||||
|
sqflite_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_common
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.0+2"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -539,6 +628,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
synchronized:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: synchronized
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0+3"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -552,14 +648,14 @@ packages:
|
|||||||
name: test_api
|
name: test_api
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.14"
|
version: "0.4.12"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: timezone
|
name: timezone
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.8.0"
|
version: "0.9.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -573,14 +669,14 @@ packages:
|
|||||||
name: url_launcher
|
name: url_launcher
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5"
|
version: "6.1.7"
|
||||||
url_launcher_android:
|
url_launcher_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.19"
|
version: "6.0.22"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -608,7 +704,7 @@ packages:
|
|||||||
name: url_launcher_platform_interface
|
name: url_launcher_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.1"
|
||||||
url_launcher_web:
|
url_launcher_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -623,13 +719,20 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.1"
|
||||||
|
uuid:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
version: "2.1.2"
|
||||||
webview_flutter:
|
webview_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -643,35 +746,28 @@ packages:
|
|||||||
name: webview_flutter_android
|
name: webview_flutter_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.10.1"
|
version: "2.10.4"
|
||||||
webview_flutter_platform_interface:
|
webview_flutter_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_platform_interface
|
name: webview_flutter_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.3"
|
version: "1.9.5"
|
||||||
webview_flutter_wkwebview:
|
webview_flutter_wkwebview:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_wkwebview
|
name: webview_flutter_wkwebview
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.9.4"
|
version: "2.9.5"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.1.2"
|
||||||
workmanager:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: workmanager
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "0.5.0"
|
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -694,5 +790,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.1.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=2.19.0-79.0.dev <3.0.0"
|
dart: ">=2.18.2 <3.0.0"
|
||||||
flutter: ">=3.3.0"
|
flutter: ">=3.3.0"
|
||||||
|
25
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.2.1+12 # When changing this, update the tag in main() accordingly
|
version: 0.8.3+66 # When changing this, update the tag in main() accordingly
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=2.19.0-79.0.dev <3.0.0'
|
sdk: '>=2.18.2 <3.0.0'
|
||||||
|
|
||||||
# Dependencies specify other packages that your package needs in order to work.
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
# To automatically upgrade your package dependencies to the latest versions
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
@ -38,26 +38,32 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.5
|
cupertino_icons: ^1.0.5
|
||||||
path_provider: ^2.0.11
|
path_provider: ^2.0.11
|
||||||
flutter_fgbg: ^0.2.0 # Try removing reliance on this
|
flutter_fgbg: ^0.2.0 # Try removing reliance on this
|
||||||
flutter_local_notifications: ^9.9.1
|
flutter_local_notifications: ^12.0.0
|
||||||
provider: ^6.0.3
|
provider: ^6.0.3
|
||||||
http: ^0.13.5
|
http: ^0.13.5
|
||||||
webview_flutter: ^3.0.4
|
webview_flutter: ^3.0.4
|
||||||
workmanager: ^0.5.0
|
|
||||||
dynamic_color: ^1.5.4
|
dynamic_color: ^1.5.4
|
||||||
install_plugin_v2: ^1.0.0 # Try replacing this
|
|
||||||
html: ^0.15.0
|
html: ^0.15.0
|
||||||
shared_preferences: ^2.0.15
|
shared_preferences: ^2.0.15
|
||||||
url_launcher: ^6.1.5
|
url_launcher: ^6.1.5
|
||||||
permission_handler: ^10.0.0
|
permission_handler: ^10.0.0
|
||||||
fluttertoast: ^8.0.9
|
fluttertoast: ^8.0.9
|
||||||
device_info_plus: ^4.1.2
|
device_info_plus: ^8.0.0
|
||||||
file_picker: ^5.1.0
|
file_picker: ^5.1.0
|
||||||
|
animations: ^2.0.4
|
||||||
|
install_plugin_v2: ^1.0.0
|
||||||
|
share_plus: ^6.0.1
|
||||||
|
installed_apps: ^1.3.1
|
||||||
|
package_archive_info: ^0.1.0
|
||||||
|
android_alarm_manager_plus: ^2.1.0
|
||||||
|
sqflite: ^2.2.0+3
|
||||||
|
easy_localization: ^3.0.1
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_launcher_icons: ^0.10.0
|
flutter_launcher_icons: ^0.11.0
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
@ -84,9 +90,12 @@ flutter:
|
|||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# - assets:
|
||||||
# - images/a_dot_burr.jpeg
|
# - images/a_dot_burr.jpeg
|
||||||
# - images/a_dot_ham.jpeg
|
# - images/a_dot_ham.jpeg
|
||||||
|
|
||||||
|
assets:
|
||||||
|
- assets/translations/
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/assets-and-images/#resolution-aware
|
# https://flutter.dev/assets-and-images/#resolution-aware
|
||||||
|
@ -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);
|
||||||
|