Compare commits
	
		
			129 Commits
		
	
	
		
			v0.3.0-bet
			...
			v0.8.2-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 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 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -9,6 +9,7 @@ | ||||
| .history | ||||
| .svn/ | ||||
| migrate_working_dir/ | ||||
| .vscode/ | ||||
|  | ||||
| # IntelliJ related | ||||
| *.iml | ||||
|   | ||||
| @@ -30,7 +30,25 @@ | ||||
|         <meta-data | ||||
|             android:name="flutterEmbedding" | ||||
|             android:value="2" /> | ||||
|         <service | ||||
|             android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmService" | ||||
|             android:permission="android.permission.BIND_JOB_SERVICE" | ||||
|             android:exported="false"/> | ||||
|         <receiver | ||||
|             android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmBroadcastReceiver" | ||||
|             android:exported="false"/> | ||||
|         <receiver | ||||
|             android:name="dev.fluttercommunity.plus.androidalarmmanager.RebootBroadcastReceiver" | ||||
|             android:enabled="false" | ||||
|             android:exported="false"> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.BOOT_COMPLETED" /> | ||||
|             </intent-filter> | ||||
|         </receiver> | ||||
|     </application> | ||||
|     <uses-permission android:name="android.permission.INTERNET" /> | ||||
|     <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> | ||||
|     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> | ||||
|     <uses-permission android:name="android.permission.WAKE_LOCK"/> | ||||
|     <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> | ||||
| </manifest> | ||||
| 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]); | ||||
|   } | ||||
| } | ||||
| @@ -1,57 +1,71 @@ | ||||
| import 'package:html/parser.dart'; | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class FDroid implements AppSource { | ||||
|   @override | ||||
|   late String host = 'f-droid.org'; | ||||
| class FDroid extends AppSource { | ||||
|   FDroid() { | ||||
|     host = 'f-droid.org'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String standardizeURL(String url) { | ||||
|     RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+'); | ||||
|     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||
|     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 notValidURL(runtimeType.toString()); | ||||
|       throw InvalidURLError(runtimeType.toString()); | ||||
|     } | ||||
|     return url.substring(0, match.end); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
|     Response res = await get(Uri.parse(standardUrl)); | ||||
|   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) { | ||||
|       var latestReleaseDiv = | ||||
|           parse(res.body).querySelector('#latest.package-version'); | ||||
|       var apkUrl = latestReleaseDiv | ||||
|           ?.querySelector('.package-version-download a') | ||||
|           ?.attributes['href']; | ||||
|       if (apkUrl == null) { | ||||
|         throw noAPKFound; | ||||
|       List<dynamic> releases = jsonDecode(res.body)['packages'] ?? []; | ||||
|       if (releases.isEmpty) { | ||||
|         throw NoReleasesError(); | ||||
|       } | ||||
|       var version = latestReleaseDiv | ||||
|           ?.querySelector('.package-version-header b') | ||||
|           ?.innerHtml | ||||
|           .split(' ') | ||||
|           .last; | ||||
|       if (version == null) { | ||||
|         throw couldNotFindLatestVersion; | ||||
|       String? latestVersion = releases[0]['versionName']; | ||||
|       if (latestVersion == null) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       return APKDetails(version, [apkUrl]); | ||||
|       List<String> apkUrls = releases | ||||
|           .where((element) => element['versionName'] == latestVersion) | ||||
|           .map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk') | ||||
|           .toList(); | ||||
|       return APKDetails(latestVersion, apkUrls); | ||||
|     } else { | ||||
|       throw couldNotFindReleases; | ||||
|       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); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
| } | ||||
|   | ||||
| @@ -1,34 +1,125 @@ | ||||
| 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 implements AppSource { | ||||
|   @override | ||||
|   late String host = 'github.com'; | ||||
| 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 notValidURL(runtimeType.toString()); | ||||
|       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) async { | ||||
|       String standardUrl, List<String> additionalData, | ||||
|       {bool trackOnly = false}) async { | ||||
|     var includePrereleases = | ||||
|         additionalData.isNotEmpty && additionalData[0] == "true"; | ||||
|         additionalData.isNotEmpty && additionalData[0] == 'true'; | ||||
|     var fallbackToOlderReleases = | ||||
|         additionalData.length >= 2 && additionalData[1] == "true"; | ||||
|         additionalData.length >= 2 && additionalData[1] == 'true'; | ||||
|     var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty | ||||
|         ? additionalData[2] | ||||
|         : null; | ||||
|     Response res = await get(Uri.parse( | ||||
|         'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases')); | ||||
|         'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases')); | ||||
|     if (res.statusCode == 200) { | ||||
|       var releases = jsonDecode(res.body) as List<dynamic>; | ||||
|  | ||||
| @@ -50,13 +141,14 @@ class GitHub implements AppSource { | ||||
|         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) { | ||||
|         if (apkUrls.isEmpty && !trackOnly) { | ||||
|           continue; | ||||
|         } | ||||
|         targetRelease = releases[i]; | ||||
| @@ -64,22 +156,16 @@ class GitHub implements AppSource { | ||||
|         break; | ||||
|       } | ||||
|       if (targetRelease == null) { | ||||
|         throw couldNotFindReleases; | ||||
|       } | ||||
|       if ((targetRelease['apkUrls'] as List<String>).isEmpty) { | ||||
|         throw noAPKFound; | ||||
|         throw NoReleasesError(); | ||||
|       } | ||||
|       String? version = targetRelease['tag_name']; | ||||
|       if (version == null) { | ||||
|         throw couldNotFindLatestVersion; | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       return APKDetails(version, targetRelease['apkUrls']); | ||||
|       return APKDetails(version, targetRelease['apkUrls'] as List<String>); | ||||
|     } 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; | ||||
|       rateLimitErrorCheck(res); | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -91,33 +177,31 @@ class GitHub implements AppSource { | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = [ | ||||
|     [GeneratedFormItem(label: "Include prereleases", type: FormItemType.bool)], | ||||
|     [ | ||||
|       GeneratedFormItem( | ||||
|           label: "Fallback to older releases", type: FormItemType.bool) | ||||
|     ], | ||||
|     [ | ||||
|       GeneratedFormItem( | ||||
|           label: "Filter Release Titles by Regular Expression", | ||||
|           type: FormItemType.string, | ||||
|           required: false, | ||||
|           additionalValidators: [ | ||||
|             (value) { | ||||
|               if (value == null || value.isEmpty) { | ||||
|                 return null; | ||||
|               } | ||||
|               try { | ||||
|                 RegExp(value); | ||||
|               } catch (e) { | ||||
|                 return "Invalid regular expression"; | ||||
|               } | ||||
|               return null; | ||||
|             } | ||||
|           ]) | ||||
|     ] | ||||
|   ]; | ||||
|   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); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = ["true", "true", ""]; | ||||
|   rateLimitErrorCheck(Response res) { | ||||
|     if (res.headers['x-ratelimit-remaining'] == '0') { | ||||
|       throw RateLimitError( | ||||
|           (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / | ||||
|                   60000000) | ||||
|               .round()); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,26 +1,32 @@ | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class GitLab implements AppSource { | ||||
|   @override | ||||
|   late String host = 'gitlab.com'; | ||||
| 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 notValidURL(runtimeType.toString()); | ||||
|       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) async { | ||||
|       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); | ||||
| @@ -28,11 +34,13 @@ class GitLab implements AppSource { | ||||
|       var entry = parsedHtml.querySelector('entry'); | ||||
|       var entryContent = | ||||
|           parse(parseFragment(entry?.querySelector('content')!.innerHtml).text); | ||||
|       var apkUrlList = [ | ||||
|       var apkUrls = [ | ||||
|         ...getLinksFromParsedHTML( | ||||
|             entryContent, | ||||
|             RegExp( | ||||
|                 '^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$', | ||||
|                 '^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { | ||||
|                   return '\\${x[0]}'; | ||||
|                 })}/uploads/[^/]+/[^/]+\\.apk\$', | ||||
|                 caseSensitive: false), | ||||
|             standardUri.origin), | ||||
|         // GitLab releases may contain links to externally hosted APKs | ||||
| @@ -41,19 +49,16 @@ class GitLab implements AppSource { | ||||
|             .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; | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       return APKDetails(version, apkUrlList); | ||||
|       return APKDetails(version, apkUrls); | ||||
|     } else { | ||||
|       throw couldNotFindReleases; | ||||
|       throw NoReleasesError(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -62,10 +67,4 @@ class GitLab implements AppSource { | ||||
|     // Same as GitHub | ||||
|     return GitHub().getAppNames(standardUrl); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
| } | ||||
|   | ||||
| @@ -1,65 +1,44 @@ | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/app_sources/fdroid.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class IzzyOnDroid implements AppSource { | ||||
|   @override | ||||
|   late String host = 'android.izzysoft.de'; | ||||
| 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 notValidURL(runtimeType.toString()); | ||||
|       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) 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; | ||||
|     } | ||||
|       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); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
| } | ||||
|   | ||||
| @@ -1,25 +1,31 @@ | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class Mullvad implements AppSource { | ||||
|   @override | ||||
|   late String host = 'mullvad.net'; | ||||
| 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 notValidURL(runtimeType.toString()); | ||||
|       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) async { | ||||
|       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) | ||||
| @@ -29,12 +35,12 @@ class Mullvad implements AppSource { | ||||
|           ?.split('/') | ||||
|           .last; | ||||
|       if (version == null) { | ||||
|         throw couldNotFindLatestVersion; | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       return APKDetails( | ||||
|           version, ['https://mullvad.net/download/app/apk/latest']); | ||||
|     } else { | ||||
|       throw couldNotFindReleases; | ||||
|       throw NoReleasesError(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -42,10 +48,4 @@ class Mullvad implements AppSource { | ||||
|   AppNames getAppNames(String standardUrl) { | ||||
|     return AppNames('Mullvad-VPN', 'Mullvad-VPN'); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
| } | ||||
|   | ||||
| @@ -1,44 +1,41 @@ | ||||
| import 'dart:convert'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class Signal implements AppSource { | ||||
|   @override | ||||
|   late String host = 'signal.org'; | ||||
| 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) async { | ||||
|       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']; | ||||
|       if (apkUrl == null) { | ||||
|         throw noAPKFound; | ||||
|       } | ||||
|       List<String> apkUrls = apkUrl == null ? [] : [apkUrl]; | ||||
|       String? version = json['versionName']; | ||||
|       if (version == null) { | ||||
|         throw couldNotFindLatestVersion; | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       return APKDetails(version, [apkUrl]); | ||||
|       return APKDetails(version, apkUrls); | ||||
|     } else { | ||||
|       throw couldNotFindReleases; | ||||
|       throw NoReleasesError(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal'); | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
| } | ||||
|   | ||||
							
								
								
									
										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)); | ||||
|   } | ||||
| } | ||||
| @@ -1,22 +1,34 @@ | ||||
| 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); | ||||
| 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.label = 'Input', | ||||
|       this.type = FormItemType.string, | ||||
|       this.required = true, | ||||
|       this.max = 1, | ||||
|       this.additionalValidators = const []}); | ||||
|       this.additionalValidators = const [], | ||||
|       this.id = 'input', | ||||
|       this.belowWidgets = const [], | ||||
|       this.hint, | ||||
|       this.opts, | ||||
|       this.key = 'default'}); | ||||
| } | ||||
|  | ||||
| class GeneratedForm extends StatefulWidget { | ||||
| @@ -41,7 +53,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|   List<List<Widget>> rows = []; | ||||
|  | ||||
|   // If any value changes, call this to update the parent with value and validity | ||||
|   void someValueChanged() { | ||||
|   void someValueChanged({bool isBuilding = false}) { | ||||
|     List<String> returnValues = []; | ||||
|     var valid = true; | ||||
|     for (int r = 0; r < values.length; r++) { | ||||
| @@ -56,7 +68,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     widget.onValueChanges(returnValues, valid); | ||||
|     widget.onValueChanges(returnValues, valid, isBuilding); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -69,14 +81,16 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|         .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) { | ||||
|         if (e.value.type == FormItemType.string && e.value.opts == null) { | ||||
|           final formFieldKey = GlobalKey<FormFieldState>(); | ||||
|           return TextFormField( | ||||
|             key: formFieldKey, | ||||
| @@ -89,12 +103,13 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|               }); | ||||
|             }, | ||||
|             decoration: InputDecoration( | ||||
|                 helperText: e.value.label + (e.value.required ? " *" : "")), | ||||
|                 helperText: e.value.label + (e.value.required ? ' *' : ''), | ||||
|                 hintText: e.value.hint), | ||||
|             minLines: e.value.max <= 1 ? null : e.value.max, | ||||
|             maxLines: e.value.max <= 1 ? 1 : e.value.max, | ||||
|             validator: (value) { | ||||
|               if (e.value.required && (value == null || value.trim().isEmpty)) { | ||||
|                 return '${e.value.label} (required)'; | ||||
|                 return '${e.value.label} ${tr('requiredInBrackets')}'; | ||||
|               } | ||||
|               for (var validator in e.value.additionalValidators) { | ||||
|                 String? result = validator(value); | ||||
| @@ -105,11 +120,29 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|               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 | ||||
| @@ -122,10 +155,10 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|             children: [ | ||||
|               Text(widget.items[r][e].label), | ||||
|               Switch( | ||||
|                   value: values[r][e] == "true", | ||||
|                   value: values[r][e] == 'true', | ||||
|                   onChanged: (value) { | ||||
|                     setState(() { | ||||
|                       values[r][e] = value ? "true" : ""; | ||||
|                       values[r][e] = value ? 'true' : ''; | ||||
|                       someValueChanged(); | ||||
|                     }); | ||||
|                   }) | ||||
| @@ -155,7 +188,13 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|             width: 20, | ||||
|           )); | ||||
|         } | ||||
|         rowItems.add(Expanded(child: rowInput.value)); | ||||
|         rowItems.add(Expanded( | ||||
|             child: Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                 children: [ | ||||
|               rowInput.value, | ||||
|               ...widget.items[rowInputs.key][rowInput.key].belowWidgets | ||||
|             ]))); | ||||
|       }); | ||||
|       rows.add(rowItems); | ||||
|     }); | ||||
| @@ -173,3 +212,18 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|         )); | ||||
|   } | ||||
| } | ||||
|  | ||||
| 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,3 +1,4 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| @@ -7,11 +8,15 @@ class GeneratedFormModal extends StatefulWidget { | ||||
|       {super.key, | ||||
|       required this.title, | ||||
|       required this.items, | ||||
|       required this.defaultValues}); | ||||
|       required this.defaultValues, | ||||
|       this.initValid = false, | ||||
|       this.message = ''}); | ||||
|  | ||||
|   final String title; | ||||
|   final String message; | ||||
|   final List<List<GeneratedFormItem>> items; | ||||
|   final List<String> defaultValues; | ||||
|   final bool initValid; | ||||
|  | ||||
|   @override | ||||
|   State<GeneratedFormModal> createState() => _GeneratedFormModalState(); | ||||
| @@ -21,26 +26,46 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> { | ||||
|   List<String> values = []; | ||||
|   bool valid = false; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     values = widget.defaultValues; | ||||
|     valid = widget.initValid || widget.items.isEmpty; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AlertDialog( | ||||
|       scrollable: true, | ||||
|       title: Text(widget.title), | ||||
|       content: GeneratedForm( | ||||
|           items: widget.items, | ||||
|           onValueChanges: (values, valid) { | ||||
|             setState(() { | ||||
|               this.values = values; | ||||
|               this.valid = valid; | ||||
|             }); | ||||
|           }, | ||||
|           defaultValues: widget.defaultValues), | ||||
|       content: | ||||
|           Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ | ||||
|         if (widget.message.isNotEmpty) Text(widget.message), | ||||
|         if (widget.message.isNotEmpty) | ||||
|           const SizedBox( | ||||
|             height: 16, | ||||
|           ), | ||||
|         GeneratedForm( | ||||
|             items: widget.items, | ||||
|             onValueChanges: (values, valid, isBuilding) { | ||||
|               if (isBuilding) { | ||||
|                 this.values = values; | ||||
|                 this.valid = valid; | ||||
|               } else { | ||||
|                 setState(() { | ||||
|                   this.values = values; | ||||
|                   this.valid = valid; | ||||
|                 }); | ||||
|               } | ||||
|             }, | ||||
|             defaultValues: widget.defaultValues) | ||||
|       ]), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               Navigator.of(context).pop(null); | ||||
|             }, | ||||
|             child: const Text('Cancel')), | ||||
|             child: Text(tr('cancel'))), | ||||
|         TextButton( | ||||
|             onPressed: !valid | ||||
|                 ? null | ||||
| @@ -50,7 +75,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> { | ||||
|                       Navigator.of(context).pop(values); | ||||
|                     } | ||||
|                   }, | ||||
|             child: const Text('Continue')) | ||||
|             child: Text(tr('continue'))) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|   | ||||
							
								
								
									
										123
									
								
								lib/custom_errors.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,123 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:obtainium/providers/logs_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
|  | ||||
| class ObtainiumError { | ||||
|   late String message; | ||||
|   bool unexpected; | ||||
|   ObtainiumError(this.message, {this.unexpected = false}); | ||||
|   @override | ||||
|   String toString() { | ||||
|     return message; | ||||
|   } | ||||
| } | ||||
|  | ||||
| class RateLimitError { | ||||
|   late int remainingMinutes; | ||||
|   RateLimitError(this.remainingMinutes); | ||||
|  | ||||
|   @override | ||||
|   String toString() => | ||||
|       plural('tooManyRequestsTryAgainInMinutes', remainingMinutes); | ||||
| } | ||||
|  | ||||
| class InvalidURLError extends ObtainiumError { | ||||
|   InvalidURLError(String sourceName) | ||||
|       : super(tr('invalidURLForSource', args: [sourceName])); | ||||
| } | ||||
|  | ||||
| class NoReleasesError extends ObtainiumError { | ||||
|   NoReleasesError() : super(tr('noReleaseFound')); | ||||
| } | ||||
|  | ||||
| class NoAPKError extends ObtainiumError { | ||||
|   NoAPKError() : super(tr('noReleaseFound')); | ||||
| } | ||||
|  | ||||
| class NoVersionError extends ObtainiumError { | ||||
|   NoVersionError() : super(tr('noVersionFound')); | ||||
| } | ||||
|  | ||||
| class UnsupportedURLError extends ObtainiumError { | ||||
|   UnsupportedURLError() : super(tr('urlMatchesNoSource')); | ||||
| } | ||||
|  | ||||
| class DowngradeError extends ObtainiumError { | ||||
|   DowngradeError() : super(tr('cantInstallOlderVersion')); | ||||
| } | ||||
|  | ||||
| class IDChangedError extends ObtainiumError { | ||||
|   IDChangedError() : super(tr('appIdMismatch')); | ||||
| } | ||||
|  | ||||
| class NotImplementedError extends ObtainiumError { | ||||
|   NotImplementedError() : super(tr('functionNotImplemented')); | ||||
| } | ||||
|  | ||||
| class MultiAppMultiError extends ObtainiumError { | ||||
|   Map<String, List<String>> content = {}; | ||||
|  | ||||
|   MultiAppMultiError() : super(tr('placeholder'), unexpected: true); | ||||
|  | ||||
|   add(String appId, String string) { | ||||
|     var tempIds = content.remove(string); | ||||
|     tempIds ??= []; | ||||
|     tempIds.add(appId); | ||||
|     content.putIfAbsent(string, () => tempIds!); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     String finalString = ''; | ||||
|     for (var e in content.keys) { | ||||
|       finalString += '$e: ${content[e].toString()}\n\n'; | ||||
|     } | ||||
|     return finalString; | ||||
|   } | ||||
| } | ||||
|  | ||||
| showError(dynamic e, BuildContext context) { | ||||
|   Provider.of<LogsProvider>(context, listen: false) | ||||
|       .add(e.toString(), level: LogLevels.error); | ||||
|   if (e is String || (e is ObtainiumError && !e.unexpected)) { | ||||
|     ScaffoldMessenger.of(context).showSnackBar( | ||||
|       SnackBar(content: Text(e.toString())), | ||||
|     ); | ||||
|   } else { | ||||
|     showDialog( | ||||
|         context: context, | ||||
|         builder: (BuildContext ctx) { | ||||
|           return AlertDialog( | ||||
|             scrollable: true, | ||||
|             title: Text(e is MultiAppMultiError | ||||
|                 ? tr('someErrors') | ||||
|                 : tr('unexpectedError')), | ||||
|             content: Text(e.toString()), | ||||
|             actions: [ | ||||
|               TextButton( | ||||
|                   onPressed: () { | ||||
|                     Navigator.of(context).pop(null); | ||||
|                   }, | ||||
|                   child: Text(tr('ok'))), | ||||
|             ], | ||||
|           ); | ||||
|         }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| String list2FriendlyString(List<String> list) { | ||||
|   return list.length == 2 | ||||
|       ? '${list[0]} ${tr('and')} ${list[1]}' | ||||
|       : list | ||||
|           .asMap() | ||||
|           .entries | ||||
|           .map((e) => | ||||
|               e.value + | ||||
|               (e.key == list.length - 1 | ||||
|                   ? '' | ||||
|                   : e.key == list.length - 2 | ||||
|                       ? ', and ' | ||||
|                       : ', ')) | ||||
|           .join(''); | ||||
| } | ||||
							
								
								
									
										196
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						| @@ -1,110 +1,187 @@ | ||||
| import 'dart:io'; | ||||
| import 'dart:math'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/pages/home.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/logs_provider.dart'; | ||||
| import 'package:obtainium/providers/notifications_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:permission_handler/permission_handler.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:workmanager/workmanager.dart'; | ||||
| import 'package:dynamic_color/dynamic_color.dart'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
| import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
|  | ||||
| const String currentVersion = '0.8.2'; | ||||
| const String currentReleaseTag = | ||||
|     'v0.3.0-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') | ||||
| void bgTaskCallback() { | ||||
|   // Background update checking process | ||||
|   Workmanager().executeTask((task, taskName) async { | ||||
|     var notificationsProvider = NotificationsProvider(); | ||||
|     await notificationsProvider.notify(checkingUpdatesNotification); | ||||
| Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|   LogsProvider logs = LogsProvider(); | ||||
|   logs.add(tr('startedBgUpdateTask')); | ||||
|   int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds']; | ||||
|   WidgetsFlutterBinding.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 { | ||||
|       var appsProvider = AppsProvider(); | ||||
|       await notificationsProvider | ||||
|           .cancel(ErrorCheckingUpdatesNotification('').id); | ||||
|       await appsProvider.loadApps(); | ||||
|       List<App> updates = await appsProvider.checkUpdates(); | ||||
|       if (updates.isNotEmpty) { | ||||
|         notificationsProvider.notify(UpdateNotification(updates), | ||||
|             cancelExisting: true); | ||||
|       } | ||||
|       return Future.value(true); | ||||
|       logs.add(tr('startedActualBGUpdateCheck')); | ||||
|       await appsProvider.checkUpdates( | ||||
|           ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true); | ||||
|     } catch (e) { | ||||
|       notificationsProvider.notify( | ||||
|           ErrorCheckingUpdatesNotification(e.toString()), | ||||
|           cancelExisting: true); | ||||
|       return Future.value(false); | ||||
|     } finally { | ||||
|       await notificationsProvider.cancel(checkingUpdatesNotification.id); | ||||
|       if (e is RateLimitError || e is SocketException) { | ||||
|         var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15; | ||||
|         logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes, | ||||
|             args: [e.runtimeType.toString()])); | ||||
|         AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes), | ||||
|             Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: { | ||||
|           'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch | ||||
|         }); | ||||
|       } else { | ||||
|         err = e.toString(); | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
|     List<App> newUpdates = appsProvider | ||||
|         .findExistingUpdates(installedOnly: true) | ||||
|         .where((id) => !existingUpdateIds.contains(id)) | ||||
|         .map((e) => appsProvider.apps[e]!.app) | ||||
|         .toList(); | ||||
|  | ||||
|     // TODO: This silent update code doesn't work yet | ||||
|     // List<String> silentlyUpdated = await appsProvider | ||||
|     //     .downloadAndInstallLatestApp( | ||||
|     //         [...newUpdates.map((e) => e.id), ...existingUpdateIds], null); | ||||
|     // if (silentlyUpdated.isNotEmpty) { | ||||
|     //   newUpdates = newUpdates | ||||
|     //       .where((element) => !silentlyUpdated.contains(element.id)) | ||||
|     //       .toList(); | ||||
|     //   notificationsProvider.notify( | ||||
|     //       SilentUpdateNotification( | ||||
|     //           silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()), | ||||
|     //       cancelExisting: true); | ||||
|     // } | ||||
|     logs.add( | ||||
|         plural('bgCheckFoundUpdatesWillNotifyIfNeeded', newUpdates.length)); | ||||
|     if (newUpdates.isNotEmpty) { | ||||
|       notificationsProvider.notify(UpdateNotification(newUpdates)); | ||||
|     } | ||||
|     if (err != null) { | ||||
|       throw err; | ||||
|     } | ||||
|   } catch (e) { | ||||
|     notificationsProvider | ||||
|         .notify(ErrorCheckingUpdatesNotification(e.toString())); | ||||
|   } finally { | ||||
|     logs.add(tr('bgUpdateTaskFinished')); | ||||
|     await notificationsProvider.cancel(checkingUpdatesNotification.id); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void main() async { | ||||
|   WidgetsFlutterBinding.ensureInitialized(); | ||||
|   if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) { | ||||
|   await EasyLocalization.ensureInitialized(); | ||||
|   if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) { | ||||
|     SystemChrome.setSystemUIOverlayStyle( | ||||
|       const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), | ||||
|     ); | ||||
|     SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); | ||||
|   } | ||||
|   Workmanager().initialize( | ||||
|     bgTaskCallback, | ||||
|   ); | ||||
|   await AndroidAlarmManager.initialize(); | ||||
|   runApp(MultiProvider( | ||||
|     providers: [ | ||||
|       ChangeNotifierProvider( | ||||
|           create: (context) => AppsProvider( | ||||
|               shouldLoadApps: true, | ||||
|               shouldCheckUpdatesAfterLoad: false, | ||||
|               shouldDeleteAPKs: true)), | ||||
|       ChangeNotifierProvider(create: (context) => AppsProvider()), | ||||
|       ChangeNotifierProvider(create: (context) => SettingsProvider()), | ||||
|       Provider(create: (context) => NotificationsProvider()) | ||||
|       Provider(create: (context) => NotificationsProvider()), | ||||
|       Provider(create: (context) => LogsProvider()) | ||||
|     ], | ||||
|     child: const MyApp(), | ||||
|     child: EasyLocalization( | ||||
|         supportedLocales: const [Locale('en')], | ||||
|         path: 'assets/translations', | ||||
|         fallbackLocale: const Locale('en'), | ||||
|         child: const Obtainium()), | ||||
|   )); | ||||
| } | ||||
|  | ||||
| var defaultThemeColour = Colors.deepPurple; | ||||
|  | ||||
| class MyApp extends StatelessWidget { | ||||
|   const MyApp({super.key}); | ||||
| class Obtainium extends StatefulWidget { | ||||
|   const Obtainium({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<Obtainium> createState() => _ObtainiumState(); | ||||
| } | ||||
|  | ||||
| class _ObtainiumState extends State<Obtainium> { | ||||
|   var existingUpdateInterval = -1; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); | ||||
|     AppsProvider appsProvider = context.read<AppsProvider>(); | ||||
|     LogsProvider logs = context.read<LogsProvider>(); | ||||
|  | ||||
|     if (settingsProvider.prefs == null) { | ||||
|       settingsProvider.initializeSettings(); | ||||
|     } 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(); | ||||
|       if (isFirstRun) { | ||||
|         logs.add(tr('firstRun')); | ||||
|         // If this is the first run, ask for notification permissions and add Obtainium to the Apps list | ||||
|         Permission.notification.request(); | ||||
|         appsProvider.saveApp(App( | ||||
|             'imranr98_obtainium_${GitHub().host}', | ||||
|             'https://github.com/ImranR98/Obtainium', | ||||
|             'ImranR98', | ||||
|             'Obtainium', | ||||
|             currentReleaseTag, | ||||
|             currentReleaseTag, | ||||
|             [], | ||||
|             0, | ||||
|             ["true"])); | ||||
|         appsProvider.saveApps([ | ||||
|           App( | ||||
|               obtainiumId, | ||||
|               'https://github.com/ImranR98/Obtainium', | ||||
|               'ImranR98', | ||||
|               'Obtainium', | ||||
|               currentReleaseTag, | ||||
|               currentReleaseTag, | ||||
|               [], | ||||
|               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); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
| @@ -125,6 +202,9 @@ class MyApp extends StatelessWidget { | ||||
|       } | ||||
|       return MaterialApp( | ||||
|           title: 'Obtainium', | ||||
|           localizationsDelegates: context.localizationDelegates, | ||||
|           supportedLocales: context.supportedLocales, | ||||
|           locale: context.locale, | ||||
|           theme: ThemeData( | ||||
|               useMaterial3: true, | ||||
|               colorScheme: settingsProvider.theme == ThemeSettings.dark | ||||
|   | ||||
| @@ -1,32 +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 MassAppSource { | ||||
| class GitHubStars implements MassAppUrlSource { | ||||
|   @override | ||||
|   late String name = 'GitHub Starred Repos'; | ||||
|   late String name = tr('githubStarredRepos'); | ||||
|  | ||||
|   @override | ||||
|   late List<String> requiredArgs = ['Username']; | ||||
|   late List<String> requiredArgs = [tr('uname')]; | ||||
|  | ||||
|   @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')); | ||||
|   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) { | ||||
|       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'; | ||||
|       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') | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       throw 'Unable to find user\'s starred repos'; | ||||
|       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,8 +1,12 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/components/custom_app_bar.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/components/generated_form_modal.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/pages/app.dart'; | ||||
| import 'package:obtainium/pages/import_export.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| @@ -19,18 +23,126 @@ class AddAppPage extends StatefulWidget { | ||||
| class _AddAppPageState extends State<AddAppPage> { | ||||
|   bool gettingAppInfo = false; | ||||
|  | ||||
|   String userInput = ""; | ||||
|   String userInput = ''; | ||||
|   String searchQuery = ''; | ||||
|   AppSource? pickedSource; | ||||
|   List<String> additionalData = []; | ||||
|   bool validAdditionalData = true; | ||||
|   List<String> sourceSpecificAdditionalData = []; | ||||
|   bool sourceSpecificDataIsValid = true; | ||||
|   List<String> otherAdditionalData = []; | ||||
|   bool otherAdditionalDataIsValid = true; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     SourceProvider sourceProvider = SourceProvider(); | ||||
|     AppsProvider appsProvider = context.read<AppsProvider>(); | ||||
|  | ||||
|     changeUserInput(String input, bool valid, bool isBuilding) { | ||||
|       userInput = input; | ||||
|       fn() { | ||||
|         var source = valid ? sourceProvider.getSource(userInput) : null; | ||||
|         if (pickedSource != source) { | ||||
|           pickedSource = source; | ||||
|           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>[ | ||||
|           const CustomAppBar(title: 'Add App'), | ||||
|           CustomAppBar(title: tr('addApp')), | ||||
|           SliverFillRemaining( | ||||
|             child: Padding( | ||||
|                 padding: const EdgeInsets.all(16), | ||||
| @@ -44,98 +156,149 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                   items: [ | ||||
|                                     [ | ||||
|                                       GeneratedFormItem( | ||||
|                                           label: "App Source Url", | ||||
|                                           label: tr('appSourceURL'), | ||||
|                                           additionalValidators: [ | ||||
|                                             (value) { | ||||
|                                               try { | ||||
|                                                 sourceProvider | ||||
|                                                     .getSource(value ?? "") | ||||
|                                                     .getSource(value ?? '') | ||||
|                                                     .standardizeURL( | ||||
|                                                         makeUrlHttps( | ||||
|                                                             value ?? "")); | ||||
|                                                         preStandardizeUrl( | ||||
|                                                             value ?? '')); | ||||
|                                               } catch (e) { | ||||
|                                                 return e is String | ||||
|                                                     ? e | ||||
|                                                     : "Error"; | ||||
|                                                     : e is ObtainiumError | ||||
|                                                         ? e.toString() | ||||
|                                                         : tr('error'); | ||||
|                                               } | ||||
|                                               return null; | ||||
|                                             } | ||||
|                                           ]) | ||||
|                                     ] | ||||
|                                   ], | ||||
|                                   onValueChanges: (values, valid) { | ||||
|                                     setState(() { | ||||
|                                       userInput = values[0]; | ||||
|                                       var source = valid | ||||
|                                           ? sourceProvider.getSource(userInput) | ||||
|                                           : null; | ||||
|                                       if (pickedSource != source) { | ||||
|                                         pickedSource = source; | ||||
|                                         additionalData = []; | ||||
|                                         validAdditionalData = source != null | ||||
|                                             ? sourceProvider | ||||
|                                                 .doesSourceHaveRequiredAdditionalData( | ||||
|                                                     source) | ||||
|                                             : true; | ||||
|                                       } | ||||
|                                     }); | ||||
|                                   onValueChanges: (values, valid, isBuilding) { | ||||
|                                     changeUserInput( | ||||
|                                         values[0], valid, isBuilding); | ||||
|                                   }, | ||||
|                                   defaultValues: const [])), | ||||
|                           const SizedBox( | ||||
|                             width: 16, | ||||
|                           ), | ||||
|                           ElevatedButton( | ||||
|                               onPressed: gettingAppInfo || | ||||
|                                       pickedSource == null || | ||||
|                                       (pickedSource!.additionalDataFormItems | ||||
|                                               .isNotEmpty && | ||||
|                                           !validAdditionalData) | ||||
|                                   ? null | ||||
|                                   : () { | ||||
|                                       HapticFeedback.selectionClick(); | ||||
|                                       setState(() { | ||||
|                                         gettingAppInfo = true; | ||||
|                                       }); | ||||
|                                       sourceProvider | ||||
|                                           .getApp(pickedSource!, userInput, | ||||
|                                               additionalData) | ||||
|                                           .then((app) { | ||||
|                                         var appsProvider = | ||||
|                                             context.read<AppsProvider>(); | ||||
|                                         var settingsProvider = | ||||
|                                             context.read<SettingsProvider>(); | ||||
|                                         if (appsProvider.apps | ||||
|                                             .containsKey(app.id)) { | ||||
|                                           throw 'App already added'; | ||||
|                                         } | ||||
|                                         settingsProvider | ||||
|                                             .getInstallPermission() | ||||
|                                             .then((_) { | ||||
|                                           appsProvider.saveApp(app).then((_) { | ||||
|                                             Navigator.push( | ||||
|                                                 context, | ||||
|                                                 MaterialPageRoute( | ||||
|                                                     builder: (context) => | ||||
|                                                         AppPage( | ||||
|                                                             appId: app.id))); | ||||
|                                           }); | ||||
|                                         }); | ||||
|                                       }).catchError((e) { | ||||
|                                         ScaffoldMessenger.of(context) | ||||
|                                             .showSnackBar( | ||||
|                                           SnackBar(content: Text(e.toString())), | ||||
|                                         ); | ||||
|                                       }).whenComplete(() { | ||||
|                                         setState(() { | ||||
|                                           gettingAppInfo = false; | ||||
|                                         }); | ||||
|                                       }); | ||||
|                                     }, | ||||
|                               child: const Text('Add')) | ||||
|                           gettingAppInfo | ||||
|                               ? const CircularProgressIndicator() | ||||
|                               : ElevatedButton( | ||||
|                                   onPressed: gettingAppInfo || | ||||
|                                           pickedSource == null || | ||||
|                                           (pickedSource! | ||||
|                                                   .additionalSourceAppSpecificFormItems | ||||
|                                                   .isNotEmpty && | ||||
|                                               !sourceSpecificDataIsValid) || | ||||
|                                           (pickedSource! | ||||
|                                                   .additionalAppSpecificSourceAgnosticDefaults | ||||
|                                                   .isNotEmpty && | ||||
|                                               !otherAdditionalDataIsValid) | ||||
|                                       ? null | ||||
|                                       : addApp, | ||||
|                                   child: Text(tr('add'))) | ||||
|                         ], | ||||
|                       ), | ||||
|                       if (sourceProvider.sources | ||||
|                               .where((e) => e.canSearch) | ||||
|                               .isNotEmpty && | ||||
|                           pickedSource == null && | ||||
|                           userInput.isEmpty) | ||||
|                         const SizedBox( | ||||
|                           height: 16, | ||||
|                         ), | ||||
|                       if (sourceProvider.sources | ||||
|                               .where((e) => e.canSearch) | ||||
|                               .isNotEmpty && | ||||
|                           pickedSource == null && | ||||
|                           userInput.isEmpty) | ||||
|                         Row( | ||||
|                           children: [ | ||||
|                             Expanded( | ||||
|                               child: GeneratedForm( | ||||
|                                   items: [ | ||||
|                                     [ | ||||
|                                       GeneratedFormItem( | ||||
|                                           label: tr('searchSomeSourcesLabel'), | ||||
|                                           required: false), | ||||
|                                     ] | ||||
|                                   ], | ||||
|                                   onValueChanges: (values, valid, isBuilding) { | ||||
|                                     if (values.isNotEmpty && valid) { | ||||
|                                       setState(() { | ||||
|                                         searchQuery = values[0].trim(); | ||||
|                                       }); | ||||
|                                     } | ||||
|                                   }, | ||||
|                                   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!.additionalDataFormItems.isNotEmpty)) | ||||
|                           (pickedSource!.additionalSourceAppSpecificDefaults | ||||
|                                   .isNotEmpty || | ||||
|                               pickedSource! | ||||
|                                   .additionalAppSpecificSourceAgnosticFormItems | ||||
|                                   .where((e) => pickedSource!.enforceTrackOnly | ||||
|                                       ? e.key != 'trackOnlyFormItemKey' | ||||
|                                       : true) | ||||
|                                   .map((e) => [e]) | ||||
|                                   .isNotEmpty)) | ||||
|                         Column( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                           children: [ | ||||
| @@ -143,23 +306,62 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                               height: 64, | ||||
|                             ), | ||||
|                             Text( | ||||
|                                 'Additional Options for ${pickedSource?.runtimeType}', | ||||
|                                 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!.additionalDataFormItems, | ||||
|                                 onValueChanges: (values, valid) { | ||||
|                                   setState(() { | ||||
|                                     additionalData = values; | ||||
|                                     validAdditionalData = valid; | ||||
|                                   }); | ||||
|                                 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!.additionalDataDefaults) | ||||
|                                 defaultValues: pickedSource! | ||||
|                                     .additionalAppSpecificSourceAgnosticDefaults), | ||||
|                           ], | ||||
|                         ) | ||||
|                       else | ||||
| @@ -168,25 +370,24 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                 crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                                 mainAxisAlignment: MainAxisAlignment.center, | ||||
|                                 children: [ | ||||
|                               // const SizedBox( | ||||
|                               //   height: 48, | ||||
|                               // ), | ||||
|                               const Text( | ||||
|                                 'Supported Sources:', | ||||
|                               const SizedBox( | ||||
|                                 height: 48, | ||||
|                               ), | ||||
|                               Text( | ||||
|                                 tr('supportedSourcesBelow'), | ||||
|                               ), | ||||
|                               const SizedBox( | ||||
|                                 height: 8, | ||||
|                               ), | ||||
|                               ...sourceProvider | ||||
|                                   .getSourceHosts() | ||||
|                               ...sourceProvider.sources | ||||
|                                   .map((e) => GestureDetector( | ||||
|                                       onTap: () { | ||||
|                                         launchUrlString('https://$e', | ||||
|                                         launchUrlString('https://${e.host}', | ||||
|                                             mode: | ||||
|                                                 LaunchMode.externalApplication); | ||||
|                                       }, | ||||
|                                       child: Text( | ||||
|                                         e, | ||||
|                                         '${e.runtimeType.toString()}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}', | ||||
|                                         style: const TextStyle( | ||||
|                                             decoration: | ||||
|                                                 TextDecoration.underline, | ||||
| @@ -194,6 +395,9 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                       ))) | ||||
|                                   .toList() | ||||
|                             ])), | ||||
|                       const SizedBox( | ||||
|                         height: 8, | ||||
|                       ), | ||||
|                     ])), | ||||
|           ) | ||||
|         ])); | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import 'package:flutter/material.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/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| @@ -18,71 +19,115 @@ class AppPage extends StatefulWidget { | ||||
| } | ||||
|  | ||||
| class _AppPageState extends State<AppPage> { | ||||
|   AppInMemory? prevApp; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     var appsProvider = context.watch<AppsProvider>(); | ||||
|     var settingsProvider = context.watch<SettingsProvider>(); | ||||
|     getUpdate(String id) { | ||||
|       appsProvider.checkUpdate(id).catchError((e) { | ||||
|         showError(e, context); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     var sourceProvider = SourceProvider(); | ||||
|     AppInMemory? app = appsProvider.apps[widget.appId]; | ||||
|     var source = app != null ? sourceProvider.getSource(app.app.url) : null; | ||||
|     if (app?.app.installedVersion != null) { | ||||
|       appsProvider.getUpdate(app!.app.id); | ||||
|     if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) { | ||||
|       prevApp = app; | ||||
|       getUpdate(app.app.id); | ||||
|     } | ||||
|     return Scaffold( | ||||
|       appBar: settingsProvider.showAppWebpage ? AppBar() : null, | ||||
|       backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|       body: settingsProvider.showAppWebpage | ||||
|           ? WebView( | ||||
|               initialUrl: app?.app.url, | ||||
|               javascriptMode: JavascriptMode.unrestricted, | ||||
|             ) | ||||
|           : Column( | ||||
|               mainAxisAlignment: MainAxisAlignment.center, | ||||
|               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|               children: [ | ||||
|                 Text( | ||||
|                   app?.app.name ?? 'App', | ||||
|                   textAlign: TextAlign.center, | ||||
|                   style: Theme.of(context).textTheme.displayLarge, | ||||
|                 ), | ||||
|                 Text( | ||||
|                   'By ${app?.app.author ?? 'Unknown'}', | ||||
|                   textAlign: TextAlign.center, | ||||
|                   style: Theme.of(context).textTheme.headlineMedium, | ||||
|                 ), | ||||
|                 const SizedBox( | ||||
|                   height: 32, | ||||
|                 ), | ||||
|                 GestureDetector( | ||||
|                     onTap: () { | ||||
|                       if (app?.app.url != null) { | ||||
|                         launchUrlString(app?.app.url ?? '', | ||||
|                             mode: LaunchMode.externalApplication); | ||||
|                       } | ||||
|                     }, | ||||
|                     child: Text( | ||||
|                       app?.app.url ?? '', | ||||
|                       textAlign: TextAlign.center, | ||||
|                       style: const TextStyle( | ||||
|                           decoration: TextDecoration.underline, | ||||
|                           fontStyle: FontStyle.italic, | ||||
|                           fontSize: 12), | ||||
|       body: RefreshIndicator( | ||||
|           child: settingsProvider.showAppWebpage | ||||
|               ? WebView( | ||||
|                   backgroundColor: Theme.of(context).colorScheme.background, | ||||
|                   initialUrl: app?.app.url, | ||||
|                   javascriptMode: JavascriptMode.unrestricted, | ||||
|                 ) | ||||
|               : CustomScrollView( | ||||
|                   slivers: [ | ||||
|                     SliverFillRemaining( | ||||
|                         child: Column( | ||||
|                       mainAxisAlignment: MainAxisAlignment.center, | ||||
|                       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                       children: [ | ||||
|                         app?.installedInfo != null | ||||
|                             ? Row( | ||||
|                                 mainAxisAlignment: MainAxisAlignment.center, | ||||
|                                 children: [ | ||||
|                                     Image.memory( | ||||
|                                       app!.installedInfo!.icon!, | ||||
|                                       height: 150, | ||||
|                                       gaplessPlayback: true, | ||||
|                                     ) | ||||
|                                   ]) | ||||
|                             : Container(), | ||||
|                         const SizedBox( | ||||
|                           height: 25, | ||||
|                         ), | ||||
|                         Text( | ||||
|                           app?.installedInfo?.name ?? app?.app.name ?? 'App', | ||||
|                           textAlign: TextAlign.center, | ||||
|                           style: Theme.of(context).textTheme.displayLarge, | ||||
|                         ), | ||||
|                         Text( | ||||
|                           'By ${app?.app.author ?? 'Unknown'}', | ||||
|                           textAlign: TextAlign.center, | ||||
|                           style: Theme.of(context).textTheme.headlineMedium, | ||||
|                         ), | ||||
|                         const SizedBox( | ||||
|                           height: 32, | ||||
|                         ), | ||||
|                         GestureDetector( | ||||
|                             onTap: () { | ||||
|                               if (app?.app.url != null) { | ||||
|                                 launchUrlString(app?.app.url ?? '', | ||||
|                                     mode: LaunchMode.externalApplication); | ||||
|                               } | ||||
|                             }, | ||||
|                             child: Text( | ||||
|                               app?.app.url ?? '', | ||||
|                               textAlign: TextAlign.center, | ||||
|                               style: const TextStyle( | ||||
|                                   decoration: TextDecoration.underline, | ||||
|                                   fontStyle: FontStyle.italic, | ||||
|                                   fontSize: 12), | ||||
|                             )), | ||||
|                         const SizedBox( | ||||
|                           height: 32, | ||||
|                         ), | ||||
|                         Text( | ||||
|                           'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}', | ||||
|                           textAlign: TextAlign.center, | ||||
|                           style: Theme.of(context).textTheme.bodyLarge, | ||||
|                         ), | ||||
|                         Text( | ||||
|                           'Installed Version: ${app?.app.installedVersion ?? 'None'}${app?.app.trackOnly == true ? ' (Estimate)\n\nApp is Track-Only' : ''}', | ||||
|                           textAlign: TextAlign.center, | ||||
|                           style: Theme.of(context).textTheme.bodyLarge, | ||||
|                         ), | ||||
|                         const SizedBox( | ||||
|                           height: 32, | ||||
|                         ), | ||||
|                         Text( | ||||
|                           'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}', | ||||
|                           textAlign: TextAlign.center, | ||||
|                           style: const TextStyle( | ||||
|                               fontStyle: FontStyle.italic, fontSize: 12), | ||||
|                         ) | ||||
|                       ], | ||||
|                     )), | ||||
|                 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'}', | ||||
|                   textAlign: TextAlign.center, | ||||
|                   style: Theme.of(context).textTheme.bodyLarge, | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           onRefresh: () async { | ||||
|             if (app != null) { | ||||
|               getUpdate(app.app.id); | ||||
|             } | ||||
|           }), | ||||
|       bottomSheet: Padding( | ||||
|           padding: EdgeInsets.fromLTRB( | ||||
|               0, 0, 0, MediaQuery.of(context).padding.bottom), | ||||
| @@ -94,129 +139,119 @@ class _AppPageState extends State<AppPage> { | ||||
|                   child: Row( | ||||
|                       mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||
|                       children: [ | ||||
|                         if (app?.app.installedVersion != app?.app.latestVersion) | ||||
|                         if (app?.app.installedVersion != null && | ||||
|                             app?.app.trackOnly == false && | ||||
|                             app?.app.installedVersion != | ||||
|                                 app?.app.latestVersion && | ||||
|                             app?.app.enhancedVersionDetection != true) | ||||
|                           IconButton( | ||||
|                               onPressed: () { | ||||
|                                 showDialog( | ||||
|                                     context: context, | ||||
|                                     builder: (BuildContext ctx) { | ||||
|                                       return AlertDialog( | ||||
|                                         title: Text( | ||||
|                                             'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'), | ||||
|                                         actions: [ | ||||
|                                           TextButton( | ||||
|                                               onPressed: () { | ||||
|                                                 Navigator.of(context).pop(); | ||||
|                                               }, | ||||
|                                               child: const Text('No')), | ||||
|                                           TextButton( | ||||
|                                               onPressed: () { | ||||
|                                                 HapticFeedback.selectionClick(); | ||||
|                                                 var updatedApp = app?.app; | ||||
|                                                 if (updatedApp != null) { | ||||
|                                                   updatedApp.installedVersion = | ||||
|                                                       updatedApp.latestVersion; | ||||
|                                                   appsProvider | ||||
|                                                       .saveApp(updatedApp); | ||||
|                                                 } | ||||
|                                                 Navigator.of(context).pop(); | ||||
|                                               }, | ||||
|                                               child: const Text( | ||||
|                                                   'Yes, Mark as Installed')) | ||||
|                                         ], | ||||
|                                       ); | ||||
|                                     }); | ||||
|                               }, | ||||
|                               tooltip: 'Mark as Installed', | ||||
|                               icon: const Icon(Icons.done)) | ||||
|                         else | ||||
|                           IconButton( | ||||
|                               onPressed: () { | ||||
|                                 showDialog( | ||||
|                                     context: context, | ||||
|                                     builder: (BuildContext ctx) { | ||||
|                                       return AlertDialog( | ||||
|                                         title: const Text('App Not Installed?'), | ||||
|                                         actions: [ | ||||
|                                           TextButton( | ||||
|                                               onPressed: () { | ||||
|                                                 Navigator.of(context).pop(); | ||||
|                                               }, | ||||
|                                               child: const Text('No')), | ||||
|                                           TextButton( | ||||
|                                               onPressed: () { | ||||
|                                                 HapticFeedback.selectionClick(); | ||||
|                                                 var updatedApp = app?.app; | ||||
|                                                 if (updatedApp != null) { | ||||
|                                                   updatedApp.installedVersion = | ||||
|                                                       null; | ||||
|                                                   appsProvider | ||||
|                                                       .saveApp(updatedApp); | ||||
|                                                 } | ||||
|                                                 Navigator.of(context).pop(); | ||||
|                                               }, | ||||
|                                               child: const Text( | ||||
|                                                   'Yes, Mark as Not Installed')) | ||||
|                                         ], | ||||
|                                       ); | ||||
|                                     }); | ||||
|                               }, | ||||
|                               tooltip: 'Mark as Not Installed', | ||||
|                               icon: const Icon(Icons.no_cell_outlined)), | ||||
|                               onPressed: app?.downloadProgress != null | ||||
|                                   ? null | ||||
|                                   : () { | ||||
|                                       showDialog( | ||||
|                                           context: context, | ||||
|                                           builder: (BuildContext ctx) { | ||||
|                                             return AlertDialog( | ||||
|                                               title: const Text( | ||||
|                                                   'App Already up to Date?'), | ||||
|                                               actions: [ | ||||
|                                                 TextButton( | ||||
|                                                     onPressed: () { | ||||
|                                                       Navigator.of(context) | ||||
|                                                           .pop(); | ||||
|                                                     }, | ||||
|                                                     child: const Text('No')), | ||||
|                                                 TextButton( | ||||
|                                                     onPressed: () { | ||||
|                                                       HapticFeedback | ||||
|                                                           .selectionClick(); | ||||
|                                                       var updatedApp = app?.app; | ||||
|                                                       if (updatedApp != null) { | ||||
|                                                         updatedApp | ||||
|                                                                 .installedVersion = | ||||
|                                                             updatedApp | ||||
|                                                                 .latestVersion; | ||||
|                                                         appsProvider.saveApps( | ||||
|                                                             [updatedApp]); | ||||
|                                                       } | ||||
|                                                       Navigator.of(context) | ||||
|                                                           .pop(); | ||||
|                                                     }, | ||||
|                                                     child: const Text( | ||||
|                                                         'Yes, Mark as Updated')) | ||||
|                                               ], | ||||
|                                             ); | ||||
|                                           }); | ||||
|                                     }, | ||||
|                               tooltip: 'Mark as Updated', | ||||
|                               icon: const Icon(Icons.done)), | ||||
|                         if (source != null && | ||||
|                             source.additionalDataFormItems.isNotEmpty) | ||||
|                             source.additionalSourceAppSpecificFormItems | ||||
|                                 .isNotEmpty) | ||||
|                           IconButton( | ||||
|                               onPressed: () { | ||||
|                                 showDialog( | ||||
|                                     context: context, | ||||
|                                     builder: (BuildContext ctx) { | ||||
|                                       return GeneratedFormModal( | ||||
|                                           title: 'Additional Options', | ||||
|                                           items: source.additionalDataFormItems, | ||||
|                                           defaultValues: | ||||
|                                               source.additionalDataDefaults); | ||||
|                                     }).then((values) { | ||||
|                                   if (app != null && values != null) { | ||||
|                                     var changedApp = app.app; | ||||
|                                     changedApp.additionalData = values; | ||||
|                                     sourceProvider | ||||
|                                         .getApp(source, changedApp.url, | ||||
|                                             changedApp.additionalData) | ||||
|                                         .then((finalChangedApp) { | ||||
|                                       appsProvider.saveApp(finalChangedApp); | ||||
|                                     }).catchError((e) { | ||||
|                                       ScaffoldMessenger.of(context) | ||||
|                                           .showSnackBar( | ||||
|                                         SnackBar(content: Text(e.toString())), | ||||
|                                       ); | ||||
|                                     }); | ||||
|                                   } | ||||
|                                 }); | ||||
|                               }, | ||||
|                               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( | ||||
|                             child: ElevatedButton( | ||||
|                                 onPressed: (app?.app.installedVersion == null || | ||||
|                                             appsProvider | ||||
|                                                 .checkAppObjectForUpdate( | ||||
|                                                     app!.app)) && | ||||
|                                             app?.app.installedVersion != | ||||
|                                                 app?.app.latestVersion) && | ||||
|                                         !appsProvider.areDownloadsRunning() | ||||
|                                     ? () { | ||||
|                                         HapticFeedback.heavyImpact(); | ||||
|                                         appsProvider | ||||
|                                             .downloadAndInstallLatestApp( | ||||
|                                                 [app!.app.id], | ||||
|                                                 context).then((res) { | ||||
|                                           if (res && mounted) { | ||||
|                                             Navigator.of(context).pop(); | ||||
|                                         () async { | ||||
|                                           if (app?.app.trackOnly != true) { | ||||
|                                             await settingsProvider | ||||
|                                                 .getInstallPermission(); | ||||
|                                           } | ||||
|                                         }() | ||||
|                                             .then((value) { | ||||
|                                           appsProvider | ||||
|                                               .downloadAndInstallLatestApps( | ||||
|                                                   [app!.app.id], | ||||
|                                                   context).then((res) { | ||||
|                                             if (res.isNotEmpty && mounted) { | ||||
|                                               Navigator.of(context).pop(); | ||||
|                                             } | ||||
|                                           }); | ||||
|                                         }).catchError((e) { | ||||
|                                           showError(e, context); | ||||
|                                         }); | ||||
|                                       } | ||||
|                                     : null, | ||||
|                                 child: Text(app?.app.installedVersion == null | ||||
|                                     ? 'Install' | ||||
|                                     : 'Update'))), | ||||
|                                     ? app?.app.trackOnly == false | ||||
|                                         ? 'Install' | ||||
|                                         : 'Mark Installed' | ||||
|                                     : app?.app.trackOnly == false | ||||
|                                         ? 'Update' | ||||
|                                         : 'Mark Updated'))), | ||||
|                         const SizedBox(width: 16.0), | ||||
|                         ElevatedButton( | ||||
|                           onPressed: app?.downloadProgress != null | ||||
| @@ -228,15 +263,14 @@ class _AppPageState extends State<AppPage> { | ||||
|                                         return AlertDialog( | ||||
|                                           title: const Text('Remove App?'), | ||||
|                                           content: Text( | ||||
|                                               'This will remove \'${app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'), | ||||
|                                               '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: [ | ||||
|                                             TextButton( | ||||
|                                                 onPressed: () { | ||||
|                                                   HapticFeedback | ||||
|                                                       .selectionClick(); | ||||
|                                                   appsProvider | ||||
|                                                       .removeApp(app!.app.id) | ||||
|                                                       .then((_) { | ||||
|                                                   appsProvider.removeApps( | ||||
|                                                       [app!.app.id]).then((_) { | ||||
|                                                     int count = 0; | ||||
|                                                     Navigator.of(context) | ||||
|                                                         .popUntil((_) => | ||||
|   | ||||
| @@ -1,34 +1,82 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/components/custom_app_bar.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/components/generated_form_modal.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/pages/app.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:share_plus/share_plus.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| class AppsPage extends StatefulWidget { | ||||
|   const AppsPage({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<AppsPage> createState() => _AppsPageState(); | ||||
|   State<AppsPage> createState() => AppsPageState(); | ||||
| } | ||||
|  | ||||
| class _AppsPageState extends State<AppsPage> { | ||||
| class AppsPageState extends State<AppsPage> { | ||||
|   AppsFilter? filter; | ||||
|   var updatesOnlyFilter = | ||||
|       AppsFilter(includeUptodate: false, includeNonInstalled: false); | ||||
|   Set<App> selectedApps = {}; | ||||
|   DateTime? refreshingSince; | ||||
|  | ||||
|   clearSelected() { | ||||
|     if (selectedApps.isNotEmpty) { | ||||
|       setState(() { | ||||
|         selectedApps.clear(); | ||||
|       }); | ||||
|       return true; | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   selectThese(List<App> apps) { | ||||
|     if (selectedApps.isEmpty) { | ||||
|       setState(() { | ||||
|         for (var a in apps) { | ||||
|           selectedApps.add(a); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     var appsProvider = context.watch<AppsProvider>(); | ||||
|     var settingsProvider = context.watch<SettingsProvider>(); | ||||
|     var existingUpdateAppIds = appsProvider.getExistingUpdates(); | ||||
|     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!.onlyNonLatest) { | ||||
|             !(filter!.includeUptodate)) { | ||||
|           return false; | ||||
|         } | ||||
|         if (app.app.installedVersion == null && | ||||
|             !(filter!.includeNonInstalled)) { | ||||
|           return false; | ||||
|         } | ||||
|         if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) { | ||||
| @@ -44,7 +92,8 @@ class _AppsPageState extends State<AppsPage> { | ||||
|             .toList(); | ||||
|  | ||||
|         for (var t in nameTokens) { | ||||
|           if (!app.app.name.toLowerCase().contains(t.toLowerCase())) { | ||||
|           var name = app.installedInfo?.name ?? app.app.name; | ||||
|           if (!name.toLowerCase().contains(t.toLowerCase())) { | ||||
|             return false; | ||||
|           } | ||||
|         } | ||||
| @@ -58,152 +107,646 @@ class _AppsPageState extends State<AppsPage> { | ||||
|     } | ||||
|  | ||||
|     sortedApps.sort((a, b) { | ||||
|       var nameA = a.installedInfo?.name ?? a.app.name; | ||||
|       var nameB = b.installedInfo?.name ?? b.app.name; | ||||
|       int result = 0; | ||||
|       if (settingsProvider.sortColumn == SortColumnSettings.authorName) { | ||||
|         result = | ||||
|             (a.app.author + a.app.name).compareTo(b.app.author + b.app.name); | ||||
|         result = (a.app.author + nameA).compareTo(b.app.author + nameB); | ||||
|       } else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) { | ||||
|         result = | ||||
|             (a.app.name + a.app.author).compareTo(b.app.name + b.app.author); | ||||
|         result = (nameA + a.app.author).compareTo(nameB + b.app.author); | ||||
|       } | ||||
|       return result; | ||||
|     }); | ||||
|  | ||||
|     if (settingsProvider.sortOrder == SortOrderSettings.ascending) { | ||||
|     if (settingsProvider.sortOrder == SortOrderSettings.descending) { | ||||
|       sortedApps = sortedApps.reversed.toList(); | ||||
|     } | ||||
|  | ||||
|     var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true); | ||||
|  | ||||
|     var existingUpdateIdsAllOrSelected = existingUpdates | ||||
|         .where((element) => selectedApps.isEmpty | ||||
|             ? sortedApps.where((a) => a.app.id == element).isNotEmpty | ||||
|             : selectedApps.map((e) => e.id).contains(element)) | ||||
|         .toList(); | ||||
|     var newInstallIdsAllOrSelected = appsProvider | ||||
|         .findExistingUpdates(nonInstalledOnly: true) | ||||
|         .where((element) => selectedApps.isEmpty | ||||
|             ? sortedApps.where((a) => a.app.id == element).isNotEmpty | ||||
|             : selectedApps.map((e) => e.id).contains(element)) | ||||
|         .toList(); | ||||
|  | ||||
|     List<String> trackOnlyUpdateIdsAllOrSelected = []; | ||||
|     existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) { | ||||
|       if (appsProvider.apps[id]!.app.trackOnly) { | ||||
|         trackOnlyUpdateIdsAllOrSelected.add(id); | ||||
|         return false; | ||||
|       } | ||||
|       return true; | ||||
|     }).toList(); | ||||
|     newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) { | ||||
|       if (appsProvider.apps[id]!.app.trackOnly) { | ||||
|         trackOnlyUpdateIdsAllOrSelected.add(id); | ||||
|         return false; | ||||
|       } | ||||
|       return true; | ||||
|     }).toList(); | ||||
|  | ||||
|     if (settingsProvider.pinUpdates) { | ||||
|       var temp = []; | ||||
|       sortedApps = sortedApps.where((sa) { | ||||
|         if (existingUpdates.contains(sa.app.id)) { | ||||
|           temp.add(sa); | ||||
|           return false; | ||||
|         } | ||||
|         return true; | ||||
|       }).toList(); | ||||
|       sortedApps = [...temp, ...sortedApps]; | ||||
|     } | ||||
|  | ||||
|     var tempPinned = []; | ||||
|     var tempNotPinned = []; | ||||
|     for (var a in sortedApps) { | ||||
|       if (a.app.pinned) { | ||||
|         tempPinned.add(a); | ||||
|       } else { | ||||
|         tempNotPinned.add(a); | ||||
|       } | ||||
|     } | ||||
|     sortedApps = [...tempPinned, ...tempNotPinned]; | ||||
|  | ||||
|     return Scaffold( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         floatingActionButton: | ||||
|             Row(mainAxisAlignment: MainAxisAlignment.end, children: [ | ||||
|           existingUpdateAppIds.isEmpty || filter != null | ||||
|               ? const SizedBox() | ||||
|               : ElevatedButton.icon( | ||||
|                   onPressed: appsProvider.areDownloadsRunning() | ||||
|                       ? null | ||||
|                       : () { | ||||
|                           HapticFeedback.heavyImpact(); | ||||
|                           settingsProvider.getInstallPermission().then((_) { | ||||
|                             appsProvider.downloadAndInstallLatestApp( | ||||
|                                 existingUpdateAppIds, context); | ||||
|                           }); | ||||
|                         }, | ||||
|                   icon: const Icon(Icons.install_mobile_outlined), | ||||
|                   label: const Text('Install All')), | ||||
|           const SizedBox( | ||||
|             width: 16, | ||||
|           ), | ||||
|           appsProvider.apps.isEmpty | ||||
|               ? const SizedBox() | ||||
|               : ElevatedButton.icon( | ||||
|                   onPressed: () { | ||||
|                     showDialog<List<String>?>( | ||||
|                         context: context, | ||||
|                         builder: (BuildContext ctx) { | ||||
|                           return GeneratedFormModal( | ||||
|                               title: 'Filter Apps', | ||||
|                               items: [ | ||||
|                                 [ | ||||
|                                   GeneratedFormItem( | ||||
|                                       label: "App Name", required: false), | ||||
|                                   GeneratedFormItem( | ||||
|                                       label: "Author", required: false) | ||||
|                                 ], | ||||
|                                 [ | ||||
|                                   GeneratedFormItem( | ||||
|                                       label: "Ignore Up-to-Date Apps", | ||||
|                                       type: FormItemType.bool) | ||||
|                                 ] | ||||
|                               ], | ||||
|                               defaultValues: filter == null | ||||
|                                   ? [] | ||||
|                                   : [ | ||||
|                                       filter!.nameFilter, | ||||
|                                       filter!.authorFilter, | ||||
|                                       filter!.onlyNonLatest ? 'true' : '' | ||||
|                                     ]); | ||||
|                         }).then((values) { | ||||
|                       if (values != null && | ||||
|                           values | ||||
|                               .where((element) => element.isNotEmpty) | ||||
|                               .isNotEmpty) { | ||||
|                         setState(() { | ||||
|                           filter = AppsFilter( | ||||
|                               nameFilter: values[0], | ||||
|                               authorFilter: values[1], | ||||
|                               onlyNonLatest: values[2] == "true"); | ||||
|                         }); | ||||
|                       } else { | ||||
|                         setState(() { | ||||
|                           filter = null; | ||||
|                         }); | ||||
|                       } | ||||
|                     }); | ||||
|                   }, | ||||
|                   label: Text(filter == null ? 'Search' : 'Modify Search'), | ||||
|                   icon: Icon( | ||||
|                       filter == null ? Icons.search : Icons.manage_search)), | ||||
|         ]), | ||||
|         body: RefreshIndicator( | ||||
|             onRefresh: () { | ||||
|               HapticFeedback.lightImpact(); | ||||
|               return appsProvider.checkUpdates().catchError((e) { | ||||
|                 ScaffoldMessenger.of(context).showSnackBar( | ||||
|                   SnackBar(content: Text(e.toString())), | ||||
|                 ); | ||||
|       backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|       body: RefreshIndicator( | ||||
|           onRefresh: () { | ||||
|             HapticFeedback.lightImpact(); | ||||
|             setState(() { | ||||
|               refreshingSince = DateTime.now(); | ||||
|             }); | ||||
|             return appsProvider.checkUpdates().catchError((e) { | ||||
|               showError(e, context); | ||||
|             }).whenComplete(() { | ||||
|               setState(() { | ||||
|                 refreshingSince = null; | ||||
|               }); | ||||
|             }, | ||||
|             child: CustomScrollView(slivers: <Widget>[ | ||||
|               const CustomAppBar(title: 'Apps'), | ||||
|               if (appsProvider.loadingApps || sortedApps.isEmpty) | ||||
|                 SliverFillRemaining( | ||||
|                     child: Center( | ||||
|                         child: appsProvider.loadingApps | ||||
|                             ? const CircularProgressIndicator() | ||||
|                             : Text( | ||||
|                                 appsProvider.apps.isEmpty | ||||
|                                     ? 'No Apps' | ||||
|                                     : 'No Search Results', | ||||
|                                 style: | ||||
|                                     Theme.of(context).textTheme.headlineMedium, | ||||
|                               ))), | ||||
|               SliverList( | ||||
|                   delegate: SliverChildBuilderDelegate( | ||||
|                       (BuildContext context, int index) { | ||||
|                 return ListTile( | ||||
|                   title: Text(sortedApps[index].app.name), | ||||
|                   subtitle: Text('By ${sortedApps[index].app.author}'), | ||||
|                   trailing: sortedApps[index].downloadProgress != null | ||||
|                       ? Text( | ||||
|                           'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%') | ||||
|                       : (sortedApps[index].app.installedVersion != null && | ||||
|                               sortedApps[index].app.installedVersion != | ||||
|                                   sortedApps[index].app.latestVersion | ||||
|                           ? const Text('Update Available') | ||||
|                           : Text(sortedApps[index].app.installedVersion ?? | ||||
|                               'Not Installed')), | ||||
|                   onTap: () { | ||||
|             }); | ||||
|           }, | ||||
|           child: CustomScrollView(slivers: <Widget>[ | ||||
|             CustomAppBar(title: tr('appsString')), | ||||
|             if (appsProvider.loadingApps || sortedApps.isEmpty) | ||||
|               SliverFillRemaining( | ||||
|                   child: Center( | ||||
|                       child: appsProvider.loadingApps | ||||
|                           ? const CircularProgressIndicator() | ||||
|                           : Text( | ||||
|                               appsProvider.apps.isEmpty | ||||
|                                   ? tr('noApps') | ||||
|                                   : tr('noAppsForFilter'), | ||||
|                               style: Theme.of(context).textTheme.headlineMedium, | ||||
|                               textAlign: TextAlign.center, | ||||
|                             ))), | ||||
|             if (refreshingSince != null) | ||||
|               SliverToBoxAdapter( | ||||
|                 child: LinearProgressIndicator( | ||||
|                   value: appsProvider.apps.values | ||||
|                           .where((element) => !(element.app.lastUpdateCheck | ||||
|                                   ?.isBefore(refreshingSince!) ?? | ||||
|                               true)) | ||||
|                           .length / | ||||
|                       appsProvider.apps.length, | ||||
|                 ), | ||||
|               ), | ||||
|             SliverList( | ||||
|                 delegate: SliverChildBuilderDelegate( | ||||
|                     (BuildContext context, int index) { | ||||
|               String? changesUrl = SourceProvider() | ||||
|                   .getSource(sortedApps[index].app.url) | ||||
|                   .changeLogPageFromStandardUrl(sortedApps[index].app.url); | ||||
|               return ListTile( | ||||
|                 tileColor: sortedApps[index].app.pinned | ||||
|                     ? Colors.grey.withOpacity(0.1) | ||||
|                     : Colors.transparent, | ||||
|                 selectedTileColor: Theme.of(context) | ||||
|                     .colorScheme | ||||
|                     .primary | ||||
|                     .withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1), | ||||
|                 selected: selectedApps.contains(sortedApps[index].app), | ||||
|                 onLongPress: () { | ||||
|                   toggleAppSelected(sortedApps[index].app); | ||||
|                 }, | ||||
|                 leading: sortedApps[index].installedInfo != null | ||||
|                     ? Image.memory( | ||||
|                         sortedApps[index].installedInfo!.icon!, | ||||
|                         gaplessPlayback: true, | ||||
|                       ) | ||||
|                     : null, | ||||
|                 title: Text( | ||||
|                   sortedApps[index].installedInfo?.name ?? | ||||
|                       sortedApps[index].app.name, | ||||
|                   style: TextStyle( | ||||
|                       fontWeight: sortedApps[index].app.pinned | ||||
|                           ? FontWeight.bold | ||||
|                           : FontWeight.normal), | ||||
|                 ), | ||||
|                 subtitle: Text(tr('byX', args: [sortedApps[index].app.author]), | ||||
|                     style: TextStyle( | ||||
|                         fontWeight: sortedApps[index].app.pinned | ||||
|                             ? FontWeight.bold | ||||
|                             : FontWeight.normal)), | ||||
|                 trailing: 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)) | ||||
|             ]))); | ||||
|                   } | ||||
|                 }, | ||||
|               ); | ||||
|             }, 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 onlyNonLatest; | ||||
|   late bool includeUptodate; | ||||
|   late bool includeNonInstalled; | ||||
|  | ||||
|   AppsFilter( | ||||
|       {this.nameFilter = "", | ||||
|       this.authorFilter = "", | ||||
|       this.onlyNonLatest = false}); | ||||
|       {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,4 +1,5 @@ | ||||
| import 'package:animations/animations.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/pages/add_app.dart'; | ||||
| @@ -25,11 +26,12 @@ class _HomePageState extends State<HomePage> { | ||||
|   List<int> selectedIndexHistory = []; | ||||
|  | ||||
|   List<NavigationPageItem> pages = [ | ||||
|     NavigationPageItem('Apps', Icons.apps, const AppsPage()), | ||||
|     NavigationPageItem('Add App', Icons.add, const AddAppPage()), | ||||
|     NavigationPageItem(tr('appsString'), Icons.apps, | ||||
|         AppsPage(key: GlobalKey<AppsPageState>())), | ||||
|     NavigationPageItem(tr('addApp'), Icons.add, const AddAppPage()), | ||||
|     NavigationPageItem( | ||||
|         'Import/Export', Icons.import_export, const ImportExportPage()), | ||||
|     NavigationPageItem('Settings', Icons.settings, const SettingsPage()) | ||||
|         tr('importExport'), Icons.import_export, const ImportExportPage()), | ||||
|     NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage()) | ||||
|   ]; | ||||
|  | ||||
|   @override | ||||
| @@ -88,7 +90,9 @@ class _HomePageState extends State<HomePage> { | ||||
|             }); | ||||
|             return false; | ||||
|           } | ||||
|           return true; | ||||
|           return !(pages[0].widget.key as GlobalKey<AppsPageState>) | ||||
|               .currentState | ||||
|               ?.clearSelected(); | ||||
|         }); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,16 +1,18 @@ | ||||
| import 'dart:convert'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/components/custom_app_bar.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/components/generated_form_modal.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:file_picker/file_picker.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| class ImportExportPage extends StatefulWidget { | ||||
|   const ImportExportPage({super.key}); | ||||
| @@ -25,7 +27,6 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     SourceProvider sourceProvider = SourceProvider(); | ||||
|     var settingsProvider = context.read<SettingsProvider>(); | ||||
|     var appsProvider = context.read<AppsProvider>(); | ||||
|     var outlineButtonStyle = ButtonStyle( | ||||
|       shape: MaterialStateProperty.all( | ||||
| @@ -38,29 +39,11 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     Future<List<List<String>>> addApps(List<String> urls) async { | ||||
|       await settingsProvider.getInstallPermission(); | ||||
|       List<dynamic> results = await sourceProvider.getApps(urls); | ||||
|       List<App> apps = results[0]; | ||||
|       Map<String, dynamic> errorsMap = results[1]; | ||||
|       for (var app in apps) { | ||||
|         if (appsProvider.apps.containsKey(app.id)) { | ||||
|           errorsMap.addAll({app.id: 'App already added'}); | ||||
|         } else { | ||||
|           await appsProvider.saveApp(app); | ||||
|         } | ||||
|       } | ||||
|       List<List<String>> errors = | ||||
|           errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList(); | ||||
|       return errors; | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: CustomScrollView(slivers: <Widget>[ | ||||
|           const CustomAppBar(title: 'Import/Export'), | ||||
|           CustomAppBar(title: tr('importExport')), | ||||
|           SliverFillRemaining( | ||||
|               hasScrollBody: false, | ||||
|               child: Padding( | ||||
|                   padding: | ||||
|                       const EdgeInsets.symmetric(vertical: 8, horizontal: 16), | ||||
| @@ -80,15 +63,12 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                           appsProvider | ||||
|                                               .exportApps() | ||||
|                                               .then((String path) { | ||||
|                                             ScaffoldMessenger.of(context) | ||||
|                                                 .showSnackBar( | ||||
|                                               SnackBar( | ||||
|                                                   content: Text( | ||||
|                                                       'Exported to $path')), | ||||
|                                             ); | ||||
|                                             showError( | ||||
|                                                 tr('exportedTo', args: [path]), | ||||
|                                                 context); | ||||
|                                           }); | ||||
|                                         }, | ||||
|                                   child: const Text('Obtainium Export'))), | ||||
|                                   child: Text(tr('obtainiumExport')))), | ||||
|                           const SizedBox( | ||||
|                             width: 16, | ||||
|                           ), | ||||
| @@ -112,34 +92,30 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                               try { | ||||
|                                                 jsonDecode(data); | ||||
|                                               } catch (e) { | ||||
|                                                 throw 'Invalid input'; | ||||
|                                                 throw ObtainiumError( | ||||
|                                                     tr('invalidInput')); | ||||
|                                               } | ||||
|                                               appsProvider | ||||
|                                                   .importApps(data) | ||||
|                                                   .then((value) { | ||||
|                                                 ScaffoldMessenger.of(context) | ||||
|                                                     .showSnackBar( | ||||
|                                                   SnackBar( | ||||
|                                                       content: Text( | ||||
|                                                           '$value App${value == 1 ? '' : 's'} Imported')), | ||||
|                                                 ); | ||||
|                                                 showError( | ||||
|                                                     tr('importedX', args: [ | ||||
|                                                       plural('apps', value) | ||||
|                                                     ]), | ||||
|                                                     context); | ||||
|                                               }); | ||||
|                                             } else { | ||||
|                                               // User canceled the picker | ||||
|                                             } | ||||
|                                           }).catchError((e) { | ||||
|                                             ScaffoldMessenger.of(context) | ||||
|                                                 .showSnackBar( | ||||
|                                               SnackBar( | ||||
|                                                   content: Text(e.toString())), | ||||
|                                             ); | ||||
|                                             showError(e, context); | ||||
|                                           }).whenComplete(() { | ||||
|                                             setState(() { | ||||
|                                               importInProgress = false; | ||||
|                                             }); | ||||
|                                           }); | ||||
|                                         }, | ||||
|                                   child: const Text('Obtainium Import'))) | ||||
|                                   child: Text(tr('obtainiumImport')))) | ||||
|                         ], | ||||
|                       ), | ||||
|                       if (importInProgress) | ||||
| @@ -166,11 +142,11 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                       context: context, | ||||
|                                       builder: (BuildContext ctx) { | ||||
|                                         return GeneratedFormModal( | ||||
|                                           title: 'Import from URL List', | ||||
|                                           title: tr('importFromURLList'), | ||||
|                                           items: [ | ||||
|                                             [ | ||||
|                                               GeneratedFormItem( | ||||
|                                                   label: 'App URL List', | ||||
|                                                   label: tr('appURLList'), | ||||
|                                                   max: 7, | ||||
|                                                   additionalValidators: [ | ||||
|                                                     (String? value) { | ||||
| @@ -187,7 +163,7 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                                                 .getSource( | ||||
|                                                                     lines[i]); | ||||
|                                                           } catch (e) { | ||||
|                                                             return 'Line ${i + 1}: $e'; | ||||
|                                                             return '${tr('line')} ${i + 1}: $e'; | ||||
|                                                           } | ||||
|                                                         } | ||||
|                                                       } | ||||
| @@ -205,14 +181,15 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                       setState(() { | ||||
|                                         importInProgress = true; | ||||
|                                       }); | ||||
|                                       addApps(urls).then((errors) { | ||||
|                                       appsProvider | ||||
|                                           .addAppsByURL(urls) | ||||
|                                           .then((errors) { | ||||
|                                         if (errors.isEmpty) { | ||||
|                                           ScaffoldMessenger.of(context) | ||||
|                                               .showSnackBar( | ||||
|                                             SnackBar( | ||||
|                                                 content: Text( | ||||
|                                                     'Imported ${urls.length} Apps')), | ||||
|                                           ); | ||||
|                                           showError( | ||||
|                                               tr('importedX', args: [ | ||||
|                                                 plural('apps', urls.length) | ||||
|                                               ]), | ||||
|                                               context); | ||||
|                                         } else { | ||||
|                                           showDialog( | ||||
|                                               context: context, | ||||
| @@ -223,10 +200,7 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                               }); | ||||
|                                         } | ||||
|                                       }).catchError((e) { | ||||
|                                         ScaffoldMessenger.of(context) | ||||
|                                             .showSnackBar( | ||||
|                                           SnackBar(content: Text(e.toString())), | ||||
|                                         ); | ||||
|                                         showError(e, context); | ||||
|                                       }).whenComplete(() { | ||||
|                                         setState(() { | ||||
|                                           importInProgress = false; | ||||
| @@ -235,10 +209,11 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                     } | ||||
|                                   }); | ||||
|                                 }, | ||||
|                           child: const Text( | ||||
|                             'Import from URL List', | ||||
|                           child: Text( | ||||
|                             tr('importFromURLList'), | ||||
|                           )), | ||||
|                       ...sourceProvider.massSources | ||||
|                       ...sourceProvider.sources | ||||
|                           .where((element) => element.canSearch) | ||||
|                           .map((source) => Column( | ||||
|                                   crossAxisAlignment: | ||||
|                                       CrossAxisAlignment.stretch, | ||||
| @@ -248,40 +223,73 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                         onPressed: importInProgress | ||||
|                                             ? null | ||||
|                                             : () { | ||||
|                                                 showDialog( | ||||
|                                                     context: context, | ||||
|                                                     builder: | ||||
|                                                         (BuildContext ctx) { | ||||
|                                                       return GeneratedFormModal( | ||||
|                                                         title: | ||||
|                                                             'Import ${source.name}', | ||||
|                                                         items: source | ||||
|                                                             .requiredArgs | ||||
|                                                             .map((e) => [ | ||||
|                                                                   GeneratedFormItem( | ||||
|                                                                       label: e) | ||||
|                                                                 ]) | ||||
|                                                             .toList(), | ||||
|                                                         defaultValues: const [], | ||||
|                                                       ); | ||||
|                                                     }).then((values) { | ||||
|                                                   if (values != null) { | ||||
|                                                     source | ||||
|                                                         .getUrls(values) | ||||
|                                                         .then((urls) { | ||||
|                                                       setState(() { | ||||
|                                                         importInProgress = true; | ||||
|                                                 () 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 [], | ||||
|                                                         ); | ||||
|                                                       }); | ||||
|                                                       addApps(urls) | ||||
|                                                           .then((errors) { | ||||
|                                                   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) { | ||||
|                                                           ScaffoldMessenger.of( | ||||
|                                                                   context) | ||||
|                                                               .showSnackBar( | ||||
|                                                             SnackBar( | ||||
|                                                                 content: Text( | ||||
|                                                                     'Imported ${urls.length} Apps')), | ||||
|                                                           ); | ||||
|                                                           // ignore: use_build_context_synchronously | ||||
|                                                           showError( | ||||
|                                                               tr('importedX', | ||||
|                                                                   args: [ | ||||
|                                                                     plural( | ||||
|                                                                         'app', | ||||
|                                                                         selectedUrls | ||||
|                                                                             .length) | ||||
|                                                                   ]), | ||||
|                                                               context); | ||||
|                                                         } else { | ||||
|                                                           showDialog( | ||||
|                                                               context: context, | ||||
| @@ -289,33 +297,139 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                                                   (BuildContext | ||||
|                                                                       ctx) { | ||||
|                                                                 return ImportErrorDialog( | ||||
|                                                                     urlsLength: urls | ||||
|                                                                         .length, | ||||
|                                                                     urlsLength: | ||||
|                                                                         selectedUrls | ||||
|                                                                             .length, | ||||
|                                                                     errors: | ||||
|                                                                         errors); | ||||
|                                                               }); | ||||
|                                                         } | ||||
|                                                       }).whenComplete(() { | ||||
|                                                         setState(() { | ||||
|                                                           importInProgress = | ||||
|                                                               false; | ||||
|                                                         }); | ||||
|                                                       }); | ||||
|                                                     }).catchError((e) { | ||||
|                                                       ScaffoldMessenger.of( | ||||
|                                                               context) | ||||
|                                                           .showSnackBar( | ||||
|                                                         SnackBar( | ||||
|                                                             content: Text( | ||||
|                                                                 e.toString())), | ||||
|                                                       ); | ||||
|                                                     }); | ||||
|                                                       } | ||||
|                                                     } else { | ||||
|                                                       throw ObtainiumError( | ||||
|                                                           tr('noResults')); | ||||
|                                                     } | ||||
|                                                   } | ||||
|                                                 }() | ||||
|                                                     .catchError((e) { | ||||
|                                                   showError(e, context); | ||||
|                                                 }).whenComplete(() { | ||||
|                                                   setState(() { | ||||
|                                                     importInProgress = false; | ||||
|                                                   }); | ||||
|                                                 }); | ||||
|                                               }, | ||||
|                                         child: Text('Import ${source.name}')) | ||||
|                                         child: Text(tr('searchX', args: [ | ||||
|                                           source.runtimeType.toString() | ||||
|                                         ]))) | ||||
|                                   ])) | ||||
|                           .toList() | ||||
|                           .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, | ||||
|                       ) | ||||
|                     ], | ||||
|                   ))) | ||||
|         ])); | ||||
| @@ -338,16 +452,19 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> { | ||||
|   Widget build(BuildContext context) { | ||||
|     return AlertDialog( | ||||
|       scrollable: true, | ||||
|       title: const Text('Import Errors'), | ||||
|       title: Text(tr('importErrors')), | ||||
|       content: | ||||
|           Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ | ||||
|         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, | ||||
|         ), | ||||
|         const SizedBox(height: 16), | ||||
|         Text( | ||||
|           'The following URLs had errors:', | ||||
|           tr('followingURLsHadErrors'), | ||||
|           style: Theme.of(context).textTheme.bodyLarge, | ||||
|         ), | ||||
|         ...widget.errors.map((e) { | ||||
| @@ -370,7 +487,132 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> { | ||||
|             onPressed: () { | ||||
|               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: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/source_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:share_plus/share_plus.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| class SettingsPage extends StatefulWidget { | ||||
| @@ -15,15 +21,152 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); | ||||
|     SourceProvider sourceProvider = SourceProvider(); | ||||
|     if (settingsProvider.prefs == null) { | ||||
|       settingsProvider.initializeSettings(); | ||||
|     } | ||||
|  | ||||
|     var themeDropdown = DropdownButtonFormField( | ||||
|         decoration: InputDecoration(labelText: tr('theme')), | ||||
|         value: settingsProvider.theme, | ||||
|         items: [ | ||||
|           DropdownMenuItem( | ||||
|             value: ThemeSettings.dark, | ||||
|             child: Text(tr('dark')), | ||||
|           ), | ||||
|           DropdownMenuItem( | ||||
|             value: ThemeSettings.light, | ||||
|             child: Text(tr('light')), | ||||
|           ), | ||||
|           DropdownMenuItem( | ||||
|             value: ThemeSettings.system, | ||||
|             child: Text(tr('followSystem')), | ||||
|           ) | ||||
|         ], | ||||
|         onChanged: (value) { | ||||
|           if (value != null) { | ||||
|             settingsProvider.theme = value; | ||||
|           } | ||||
|         }); | ||||
|  | ||||
|     var colourDropdown = DropdownButtonFormField( | ||||
|         decoration: InputDecoration(labelText: tr('colour')), | ||||
|         value: settingsProvider.colour, | ||||
|         items: [ | ||||
|           DropdownMenuItem( | ||||
|             value: ColourSettings.basic, | ||||
|             child: Text(tr('obtainium')), | ||||
|           ), | ||||
|           DropdownMenuItem( | ||||
|             value: ColourSettings.materialYou, | ||||
|             child: Text(tr('materialYou')), | ||||
|           ) | ||||
|         ], | ||||
|         onChanged: (value) { | ||||
|           if (value != null) { | ||||
|             settingsProvider.colour = value; | ||||
|           } | ||||
|         }); | ||||
|  | ||||
|     var sortDropdown = DropdownButtonFormField( | ||||
|         decoration: InputDecoration(labelText: tr('appSortBy')), | ||||
|         value: settingsProvider.sortColumn, | ||||
|         items: [ | ||||
|           DropdownMenuItem( | ||||
|             value: SortColumnSettings.authorName, | ||||
|             child: Text(tr('authorName')), | ||||
|           ), | ||||
|           DropdownMenuItem( | ||||
|             value: SortColumnSettings.nameAuthor, | ||||
|             child: Text(tr('nameAuthor')), | ||||
|           ), | ||||
|           DropdownMenuItem( | ||||
|             value: SortColumnSettings.added, | ||||
|             child: Text(tr('asAdded')), | ||||
|           ) | ||||
|         ], | ||||
|         onChanged: (value) { | ||||
|           if (value != null) { | ||||
|             settingsProvider.sortColumn = value; | ||||
|           } | ||||
|         }); | ||||
|  | ||||
|     var orderDropdown = DropdownButtonFormField( | ||||
|         decoration: InputDecoration(labelText: tr('appSortOrder')), | ||||
|         value: settingsProvider.sortOrder, | ||||
|         items: [ | ||||
|           DropdownMenuItem( | ||||
|             value: SortOrderSettings.ascending, | ||||
|             child: Text(tr('ascending')), | ||||
|           ), | ||||
|           DropdownMenuItem( | ||||
|             value: SortOrderSettings.descending, | ||||
|             child: Text(tr('descending')), | ||||
|           ), | ||||
|         ], | ||||
|         onChanged: (value) { | ||||
|           if (value != null) { | ||||
|             settingsProvider.sortOrder = value; | ||||
|           } | ||||
|         }); | ||||
|  | ||||
|     var intervalDropdown = DropdownButtonFormField( | ||||
|         decoration: InputDecoration(labelText: tr('bgUpdateCheckInterval')), | ||||
|         value: settingsProvider.updateInterval, | ||||
|         items: updateIntervals.map((e) { | ||||
|           int displayNum = (e < 60 | ||||
|                   ? e | ||||
|                   : e < 1440 | ||||
|                       ? e / 60 | ||||
|                       : e / 1440) | ||||
|               .round(); | ||||
|           String display = e == 0 | ||||
|               ? tr('neverManualOnly') | ||||
|               : (e < 60 | ||||
|                   ? plural('minute', displayNum) | ||||
|                   : e < 1440 | ||||
|                       ? plural('hour', displayNum) | ||||
|                       : plural('day', displayNum)); | ||||
|           return DropdownMenuItem(value: e, child: Text(display)); | ||||
|         }).toList(), | ||||
|         onChanged: (value) { | ||||
|           if (value != null) { | ||||
|             settingsProvider.updateInterval = value; | ||||
|           } | ||||
|         }); | ||||
|  | ||||
|     var sourceSpecificFields = sourceProvider.sources.map((e) { | ||||
|       if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) { | ||||
|         return GeneratedForm( | ||||
|             items: e.additionalSourceSpecificSettingFormItems | ||||
|                 .map((e) => [e]) | ||||
|                 .toList(), | ||||
|             onValueChanges: (values, valid, isBuilding) { | ||||
|               if (valid) { | ||||
|                 for (var i = 0; i < values.length; i++) { | ||||
|                   settingsProvider.setSettingString( | ||||
|                       e.additionalSourceSpecificSettingFormItems[i].id, | ||||
|                       values[i]); | ||||
|                 } | ||||
|               } | ||||
|             }, | ||||
|             defaultValues: e.additionalSourceSpecificSettingFormItems.map((e) { | ||||
|               return settingsProvider.getSettingString(e.id) ?? ''; | ||||
|             }).toList()); | ||||
|       } else { | ||||
|         return Container(); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     const height16 = SizedBox( | ||||
|       height: 16, | ||||
|     ); | ||||
|  | ||||
|     return Scaffold( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: CustomScrollView(slivers: <Widget>[ | ||||
|           const CustomAppBar(title: 'Settings'), | ||||
|           SliverFillRemaining( | ||||
|               hasScrollBody: true, | ||||
|           CustomAppBar(title: tr('settings')), | ||||
|           SliverToBoxAdapter( | ||||
|               child: Padding( | ||||
|                   padding: const EdgeInsets.all(16), | ||||
|                   child: settingsProvider.prefs == null | ||||
| @@ -32,120 +175,30 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                           children: [ | ||||
|                             Text( | ||||
|                               'Appearance', | ||||
|                               tr('appearance'), | ||||
|                               style: TextStyle( | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
|                             DropdownButtonFormField( | ||||
|                                 decoration: | ||||
|                                     const InputDecoration(labelText: 'Theme'), | ||||
|                                 value: settingsProvider.theme, | ||||
|                                 items: const [ | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: ThemeSettings.dark, | ||||
|                                     child: Text('Dark'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: ThemeSettings.light, | ||||
|                                     child: Text('Light'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: ThemeSettings.system, | ||||
|                                     child: Text('Follow System'), | ||||
|                                   ) | ||||
|                                 ], | ||||
|                                 onChanged: (value) { | ||||
|                                   if (value != null) { | ||||
|                                     settingsProvider.theme = value; | ||||
|                                   } | ||||
|                                 }), | ||||
|                             const SizedBox( | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             DropdownButtonFormField( | ||||
|                                 decoration: | ||||
|                                     const InputDecoration(labelText: 'Colour'), | ||||
|                                 value: settingsProvider.colour, | ||||
|                                 items: const [ | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: ColourSettings.basic, | ||||
|                                     child: Text('Obtainium'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: ColourSettings.materialYou, | ||||
|                                     child: Text('Material You'), | ||||
|                                   ) | ||||
|                                 ], | ||||
|                                 onChanged: (value) { | ||||
|                                   if (value != null) { | ||||
|                                     settingsProvider.colour = value; | ||||
|                                   } | ||||
|                                 }), | ||||
|                             const SizedBox( | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             themeDropdown, | ||||
|                             height16, | ||||
|                             colourDropdown, | ||||
|                             height16, | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.start, | ||||
|                               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                               children: [ | ||||
|                                 Expanded( | ||||
|                                     child: DropdownButtonFormField( | ||||
|                                         decoration: const InputDecoration( | ||||
|                                             labelText: 'App Sort By'), | ||||
|                                         value: settingsProvider.sortColumn, | ||||
|                                         items: const [ | ||||
|                                           DropdownMenuItem( | ||||
|                                             value: | ||||
|                                                 SortColumnSettings.authorName, | ||||
|                                             child: Text('Author/Name'), | ||||
|                                           ), | ||||
|                                           DropdownMenuItem( | ||||
|                                             value: | ||||
|                                                 SortColumnSettings.nameAuthor, | ||||
|                                             child: Text('Name/Author'), | ||||
|                                           ), | ||||
|                                           DropdownMenuItem( | ||||
|                                             value: SortColumnSettings.added, | ||||
|                                             child: Text('As Added'), | ||||
|                                           ) | ||||
|                                         ], | ||||
|                                         onChanged: (value) { | ||||
|                                           if (value != null) { | ||||
|                                             settingsProvider.sortColumn = value; | ||||
|                                           } | ||||
|                                         })), | ||||
|                                 Expanded(child: sortDropdown), | ||||
|                                 const SizedBox( | ||||
|                                   width: 16, | ||||
|                                 ), | ||||
|                                 Expanded( | ||||
|                                     child: DropdownButtonFormField( | ||||
|                                         decoration: const InputDecoration( | ||||
|                                             labelText: 'App Sort Order'), | ||||
|                                         value: settingsProvider.sortOrder, | ||||
|                                         items: const [ | ||||
|                                           DropdownMenuItem( | ||||
|                                             value: SortOrderSettings.ascending, | ||||
|                                             child: Text('Ascending'), | ||||
|                                           ), | ||||
|                                           DropdownMenuItem( | ||||
|                                             value: SortOrderSettings.descending, | ||||
|                                             child: Text('Descending'), | ||||
|                                           ), | ||||
|                                         ], | ||||
|                                         onChanged: (value) { | ||||
|                                           if (value != null) { | ||||
|                                             settingsProvider.sortOrder = value; | ||||
|                                           } | ||||
|                                         })), | ||||
|                                 Expanded(child: orderDropdown), | ||||
|                               ], | ||||
|                             ), | ||||
|                             const SizedBox( | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             height16, | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 const Text('Show Source Webpage in App View'), | ||||
|                                 Text(tr('showWebInAppView')), | ||||
|                                 Switch( | ||||
|                                     value: settingsProvider.showAppWebpage, | ||||
|                                     onChanged: (value) { | ||||
| @@ -153,84 +206,148 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                                     }) | ||||
|                               ], | ||||
|                             ), | ||||
|                             height16, | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 Text(tr('pinUpdates')), | ||||
|                                 Switch( | ||||
|                                     value: settingsProvider.pinUpdates, | ||||
|                                     onChanged: (value) { | ||||
|                                       settingsProvider.pinUpdates = value; | ||||
|                                     }) | ||||
|                               ], | ||||
|                             ), | ||||
|                             const Divider( | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             const SizedBox( | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             height16, | ||||
|                             Text( | ||||
|                               'More', | ||||
|                               tr('updates'), | ||||
|                               style: TextStyle( | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
|                             DropdownButtonFormField( | ||||
|                                 decoration: const InputDecoration( | ||||
|                                     labelText: | ||||
|                                         'Background Update Checking Interval'), | ||||
|                                 value: settingsProvider.updateInterval, | ||||
|                                 items: const [ | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: 15, | ||||
|                                     child: Text('15 Minutes'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: 30, | ||||
|                                     child: Text('30 Minutes'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: 60, | ||||
|                                     child: Text('1 Hour'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: 360, | ||||
|                                     child: Text('6 Hours'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: 720, | ||||
|                                     child: Text('12 Hours'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: 1440, | ||||
|                                     child: Text('1 Day'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: 0, | ||||
|                                     child: Text('Never - Manual Only'), | ||||
|                                   ), | ||||
|                                 ], | ||||
|                                 onChanged: (value) { | ||||
|                                   if (value != null) { | ||||
|                                     settingsProvider.updateInterval = value; | ||||
|                                   } | ||||
|                                 }), | ||||
|                             const Spacer(), | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.center, | ||||
|                               children: [ | ||||
|                                 TextButton.icon( | ||||
|                                   style: ButtonStyle( | ||||
|                                     foregroundColor: | ||||
|                                         MaterialStateProperty.resolveWith< | ||||
|                                             Color>((Set<MaterialState> states) { | ||||
|                                       return Colors.grey; | ||||
|                                     }), | ||||
|                                   ), | ||||
|                                   onPressed: () { | ||||
|                                     launchUrlString(settingsProvider.sourceUrl, | ||||
|                                         mode: LaunchMode.externalApplication); | ||||
|                                   }, | ||||
|                                   icon: const Icon(Icons.code), | ||||
|                                   label: Text( | ||||
|                                     'Source', | ||||
|                                     style: | ||||
|                                         Theme.of(context).textTheme.bodySmall, | ||||
|                                   ), | ||||
|                                 ) | ||||
|                               ], | ||||
|                             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: () { | ||||
|                           context.read<LogsProvider>().get().then((logs) { | ||||
|                             if (logs.isEmpty) { | ||||
|                               showError(ObtainiumError(tr('noLogs')), context); | ||||
|                             } else { | ||||
|                               showDialog( | ||||
|                                   context: context, | ||||
|                                   builder: (BuildContext ctx) { | ||||
|                                     return const LogsDialog(); | ||||
|                                   }); | ||||
|                             } | ||||
|                           }); | ||||
|                         }, | ||||
|                         icon: const Icon(Icons.bug_report_outlined), | ||||
|                         label: Text(tr('appLogs'))), | ||||
|                   ], | ||||
|                 ), | ||||
|                 height16, | ||||
|               ], | ||||
|             ), | ||||
|           ) | ||||
|         ])); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class LogsDialog extends StatefulWidget { | ||||
|   const LogsDialog({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<LogsDialog> createState() => _LogsDialogState(); | ||||
| } | ||||
|  | ||||
| class _LogsDialogState extends State<LogsDialog> { | ||||
|   String? logString; | ||||
|   List<int> days = [7, 5, 4, 3, 2, 1]; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     var logsProvider = context.read<LogsProvider>(); | ||||
|     void filterLogs(int days) { | ||||
|       logsProvider | ||||
|           .get(after: DateTime.now().subtract(Duration(days: days))) | ||||
|           .then((value) { | ||||
|         setState(() { | ||||
|           String l = value.map((e) => e.toString()).join('\n\n'); | ||||
|           logString = l.isNotEmpty ? l : tr('noLogs'); | ||||
|         }); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     if (logString == null) { | ||||
|       filterLogs(days.first); | ||||
|     } | ||||
|  | ||||
|     return AlertDialog( | ||||
|       scrollable: true, | ||||
|       title: Text(tr('appLogs')), | ||||
|       content: Column( | ||||
|         children: [ | ||||
|           DropdownButtonFormField( | ||||
|               value: days.first, | ||||
|               items: days | ||||
|                   .map((e) => DropdownMenuItem( | ||||
|                         value: e, | ||||
|                         child: Text(plural('day', e)), | ||||
|                       )) | ||||
|                   .toList(), | ||||
|               onChanged: (d) { | ||||
|                 filterLogs(d ?? 7); | ||||
|               }), | ||||
|           const SizedBox( | ||||
|             height: 32, | ||||
|           ), | ||||
|           Text(logString ?? '') | ||||
|         ], | ||||
|       ), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               Navigator.of(context).pop(); | ||||
|             }, | ||||
|             child: Text(tr('close'))), | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               Share.share(logString ?? '', subject: tr('appLogs')); | ||||
|               Navigator.of(context).pop(); | ||||
|             }, | ||||
|             child: Text(tr('share'))) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,53 +0,0 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
|  | ||||
| class TestPage extends StatefulWidget { | ||||
|   const TestPage({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<TestPage> createState() => _TestPageState(); | ||||
| } | ||||
|  | ||||
| class _TestPageState extends State<TestPage> { | ||||
|   List<String?>? sourceSpecificData; | ||||
|   bool valid = false; | ||||
|  | ||||
|   List<List<GeneratedFormItem>> sourceSpecificInputs = [ | ||||
|     [GeneratedFormItem(label: 'Test Item 1')], | ||||
|     [ | ||||
|       GeneratedFormItem(label: 'Test Item 2', required: false), | ||||
|       GeneratedFormItem(label: 'Test Item 3') | ||||
|     ], | ||||
|     [GeneratedFormItem(label: 'Test Item 4', type: FormItemType.bool)] | ||||
|   ]; | ||||
|  | ||||
|   List<String> defaultInputValues = ["ABC"]; | ||||
|  | ||||
|   void onSourceSpecificDataChanges( | ||||
|       List<String?> valuesFromForm, bool formValid) { | ||||
|     setState(() { | ||||
|       sourceSpecificData = valuesFromForm; | ||||
|       valid = formValid; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Scaffold( | ||||
|         appBar: AppBar(title: const Text('Test Page')), | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|             child: Column(children: [ | ||||
|               GeneratedForm( | ||||
|                 items: sourceSpecificInputs, | ||||
|                 onValueChanges: onSourceSpecificDataChanges, | ||||
|                 defaultValues: defaultInputValues, | ||||
|               ), | ||||
|               ...(sourceSpecificData != null | ||||
|                   ? (sourceSpecificData as List<String?>) | ||||
|                       .map((e) => Text(e ?? "")) | ||||
|                   : [Container()]) | ||||
|             ]))); | ||||
|   } | ||||
| } | ||||
| @@ -5,27 +5,36 @@ import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
| 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/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/settings_provider.dart'; | ||||
| import 'package:package_archive_info/package_archive_info.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| import 'package:flutter_fgbg/flutter_fgbg.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:install_plugin_v2/install_plugin_v2.dart'; | ||||
|  | ||||
| class AppInMemory { | ||||
|   late App app; | ||||
|   double? downloadProgress; | ||||
|   AppInfo? installedInfo; | ||||
|  | ||||
|   AppInMemory(this.app, this.downloadProgress); | ||||
|   AppInMemory(this.app, this.downloadProgress, this.installedInfo); | ||||
| } | ||||
|  | ||||
| class ApkFile { | ||||
| class DownloadedApk { | ||||
|   String appId; | ||||
|   File file; | ||||
|   ApkFile(this.appId, this.file); | ||||
|   DownloadedApk(this.appId, this.file); | ||||
| } | ||||
|  | ||||
| class AppsProvider with ChangeNotifier { | ||||
| @@ -33,136 +42,341 @@ class AppsProvider with ChangeNotifier { | ||||
|   Map<String, AppInMemory> apps = {}; | ||||
|   bool loadingApps = 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) | ||||
|   bool isForeground = true; | ||||
|   late Stream<FGBGType> foregroundStream; | ||||
|   late StreamSubscription<FGBGType> foregroundSubscription; | ||||
|   late Stream<FGBGType>? foregroundStream; | ||||
|   late StreamSubscription<FGBGType>? foregroundSubscription; | ||||
|  | ||||
|   AppsProvider( | ||||
|       {bool shouldLoadApps = false, | ||||
|       bool shouldCheckUpdatesAfterLoad = false, | ||||
|       bool shouldDeleteAPKs = false}) { | ||||
|     // Subscribe to changes in the app foreground status | ||||
|     foregroundStream = FGBGEvents.stream.asBroadcastStream(); | ||||
|     foregroundSubscription = foregroundStream.listen((event) async { | ||||
|       isForeground = event == FGBGType.foreground; | ||||
|       if (isForeground) await loadApps(); | ||||
|     }); | ||||
|     if (shouldDeleteAPKs) { | ||||
|       deleteSavedAPKs(); | ||||
|     } | ||||
|     if (shouldLoadApps) { | ||||
|       loadApps().then((_) { | ||||
|         if (shouldCheckUpdatesAfterLoad) { | ||||
|           checkUpdates(); | ||||
|         } | ||||
|   AppsProvider({this.forBGTask = false}) { | ||||
|     // Many setup tasks should only be done in the foreground isolate | ||||
|     if (!forBGTask) { | ||||
|       // Subscribe to changes in the app foreground status | ||||
|       foregroundStream = FGBGEvents.stream.asBroadcastStream(); | ||||
|       foregroundSubscription = foregroundStream?.listen((event) async { | ||||
|         isForeground = event == FGBGType.foreground; | ||||
|         if (isForeground) await loadApps(); | ||||
|       }); | ||||
|       () async { | ||||
|         // Load Apps into memory (in background, this is done later instead of in the constructor) | ||||
|         await loadApps(); | ||||
|         // Delete existing APKs | ||||
|         (await getExternalStorageDirectory()) | ||||
|             ?.listSync() | ||||
|             .where((element) => | ||||
|                 element.path.endsWith('.apk') || | ||||
|                 element.path.endsWith('.apk.part')) | ||||
|             .forEach((apk) { | ||||
|           apk.delete(); | ||||
|         }); | ||||
|       }(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<ApkFile> downloadApp(String apkUrl, String appId) async { | ||||
|   downloadFile(String url, String fileName, Function? onProgress, | ||||
|       {bool useExisting = true}) async { | ||||
|     var destDir = (await getExternalStorageDirectory())!.path; | ||||
|     StreamedResponse response = | ||||
|         await Client().send(Request('GET', Uri.parse(apkUrl))); | ||||
|     File downloadFile = | ||||
|         File('${(await getExternalStorageDirectory())!.path}/$appId.apk'); | ||||
|     if (downloadFile.existsSync()) { | ||||
|       downloadFile.deleteSync(); | ||||
|         await Client().send(Request('GET', Uri.parse(url))); | ||||
|     File downloadedFile = File('$destDir/$fileName'); | ||||
|     if (!(downloadedFile.existsSync() && useExisting)) { | ||||
|       File tempDownloadedFile = File('${downloadedFile.path}.part'); | ||||
|       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; | ||||
|     var received = 0; | ||||
|     var sink = downloadFile.openWrite(); | ||||
|     return downloadedFile; | ||||
|   } | ||||
|  | ||||
|     await response.stream.map((s) { | ||||
|       received += s.length; | ||||
|       apps[appId]!.downloadProgress = | ||||
|           (length != null ? received / length * 100 : 30); | ||||
|       notifyListeners(); | ||||
|       return s; | ||||
|     }).pipe(sink); | ||||
|  | ||||
|     await sink.close(); | ||||
|     apps[appId]!.downloadProgress = null; | ||||
|     notifyListeners(); | ||||
|  | ||||
|     if (response.statusCode != 200) { | ||||
|       downloadFile.deleteSync(); | ||||
|       throw response.reasonPhrase ?? 'Unknown Error'; | ||||
|   Future<DownloadedApk> downloadApp(App app, BuildContext? context) async { | ||||
|     var fileName = | ||||
|         '${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk'; | ||||
|     String downloadUrl = await SourceProvider() | ||||
|         .getSource(app.url) | ||||
|         .apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]); | ||||
|     NotificationsProvider? notificationsProvider = | ||||
|         context?.read<NotificationsProvider>(); | ||||
|     var notif = DownloadNotification(app.name, 100); | ||||
|     notificationsProvider?.cancel(notif.id); | ||||
|     int? prevProg; | ||||
|     File downloadedFile = | ||||
|         await downloadFile(downloadUrl, fileName, (double? progress) { | ||||
|       int? prog = progress?.ceil(); | ||||
|       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 | ||||
|       .where((element) => element.downloadProgress != null) | ||||
|       .isNotEmpty; | ||||
|  | ||||
|   // Given an AppId, uses stored info about the app to download an APK (with user input if needed) and install it | ||||
|   // Installs can only be done in the foreground, so a notification is sent to get the user's attention if needed | ||||
|   // Returns upon successful download, regardless of installation result | ||||
|   Future<bool> downloadAndInstallLatestApp( | ||||
|       List<String> appIds, BuildContext context) async { | ||||
|   Future<bool> canInstallSilently(App app) async { | ||||
|     return false; | ||||
|     // TODO: Uncomment the below once silentupdates are ever figured out | ||||
|     // // TODO: This is unreliable - try to get from OS in the future | ||||
|     // 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 = | ||||
|         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) { | ||||
|       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 = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex]; | ||||
|       if (apps[id]!.app.apkUrls.length > 1) { | ||||
|         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; | ||||
|         } | ||||
|       String? apkUrl; | ||||
|       if (!apps[id]!.app.trackOnly) { | ||||
|         apkUrl = await confirmApkUrl(apps[id]!.app, context); | ||||
|       } | ||||
|       if (apkUrl != null) { | ||||
|         int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); | ||||
|         if (urlInd != apps[id]!.app.preferredApkIndex) { | ||||
|           apps[id]!.app.preferredApkIndex = urlInd; | ||||
|           await 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 | ||||
|         .map((entry) => downloadApp(entry.value, entry.key))); | ||||
|     // Move everything to the regular install list (since silent updates don't currently work) - TODO | ||||
|     regularInstalls.addAll(silentUpdates); | ||||
|  | ||||
|     if (!isForeground) { | ||||
|       await notificationsProvider.notify(completeInstallationNotification, | ||||
|           cancelExisting: true); | ||||
|       await FGBGEvents.stream.first == FGBGType.foreground; | ||||
|       await notificationsProvider.cancel(completeInstallationNotification.id); | ||||
|       // We need to wait for the App to come to the foreground to install it | ||||
|       // Can't try to call install plugin in a background isolate (may not have worked anyways) because of: | ||||
|       // https://github.com/flutter/flutter/issues/13937 | ||||
|     // If Obtainium is being installed, it should be the last one | ||||
|     List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) { | ||||
|       DownloadedApk? temp; | ||||
|       items.removeWhere((element) { | ||||
|         bool res = | ||||
|             element.appId == obtainiumId || element.appId == obtainiumTempId; | ||||
|         if (res) { | ||||
|           temp = element; | ||||
|         } | ||||
|         return res; | ||||
|       }); | ||||
|       if (temp != null) { | ||||
|         items = [temp!, ...items]; | ||||
|       } | ||||
|       return items; | ||||
|     } | ||||
|  | ||||
|     // 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 | ||||
|     // This also does not use the 'session-based' installer API, so background/silent updates are impossible | ||||
|     for (var f in downloadedFiles) { | ||||
|       await InstallPlugin.installApk(f.file.path, 'dev.imranr.obtainium'); | ||||
|       apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion; | ||||
|       await saveApp(apps[f.appId]!.app); | ||||
|     silentUpdates = moveObtainiumToStart(silentUpdates); | ||||
|     regularInstalls = moveObtainiumToStart(regularInstalls); | ||||
|  | ||||
|     // // Install silent updates (uncomment when it works - TODO) | ||||
|     // for (var u in silentUpdates) { | ||||
|     //   await installApk(u, silent: true); // Would need to add silent option | ||||
|     // } | ||||
|  | ||||
|     // Do regular installs | ||||
|     if (regularInstalls.isNotEmpty && context != null) { | ||||
|       // ignore: use_build_context_synchronously | ||||
|       await waitForUserToReturnToForeground(context); | ||||
|       for (var i in regularInstalls) { | ||||
|         try { | ||||
|           await installApk(i); | ||||
|         } catch (e) { | ||||
|           errors.add(i.appId, e.toString()); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return downloadedFiles.isNotEmpty; | ||||
|     if (errors.content.isNotEmpty) { | ||||
|       throw errors; | ||||
|     } | ||||
|  | ||||
|     NotificationsProvider().cancel(UpdateNotification([]).id); | ||||
|  | ||||
|     return downloadedFiles.map((e) => e!.appId).toList(); | ||||
|   } | ||||
|  | ||||
|   Future<Directory> getAppsDir() async { | ||||
| @@ -174,100 +388,203 @@ class AppsProvider with ChangeNotifier { | ||||
|     return appsDir; | ||||
|   } | ||||
|  | ||||
|   Future<void> deleteSavedAPKs() async { | ||||
|     (await getExternalStorageDirectory()) | ||||
|         ?.listSync() | ||||
|         .where((element) => element.path.endsWith('.apk')) | ||||
|         .forEach((element) { | ||||
|       element.deleteSync(); | ||||
|     }); | ||||
|   Future<AppInfo?> getInstalledInfo(String? packageName) async { | ||||
|     if (packageName != null) { | ||||
|       try { | ||||
|         return await InstalledApps.getAppInfo(packageName); | ||||
|       } catch (e) { | ||||
|         // OK | ||||
|       } | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   // If the App says it is installed but installedInfo is null, set it to not installed | ||||
|   // If the App says is is not installed but installedInfo exists, set it to the real installed version | ||||
|   // If the internal version does not match the real one, sync them if the App supports enhanced version detection | ||||
|   // 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) | ||||
|   // If in a background isolate, return null straight away as the required plugin won't work anyways | ||||
|   App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) { | ||||
|     if (forBGTask) { | ||||
|       return null; // Can't correct in the background isolate | ||||
|     } | ||||
|     var modded = false; | ||||
|     if (installedInfo == null && | ||||
|         app.installedVersion != null && | ||||
|         !app.trackOnly) { | ||||
|       app.installedVersion = null; | ||||
|       modded = true; | ||||
|     } else if (installedInfo != null && app.installedVersion == null) { | ||||
|       app.installedVersion = installedInfo.versionName; | ||||
|       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<FileSystemEntity> appFiles = (await getAppsDir()) | ||||
|     List<App> newApps = (await getAppsDir()) | ||||
|         .listSync() | ||||
|         .where((item) => item.path.toLowerCase().endsWith('.json')) | ||||
|         .map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync()))) | ||||
|         .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)); | ||||
|     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(); | ||||
|   } | ||||
|  | ||||
|   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(); | ||||
|     List<App> modifiedApps = []; | ||||
|     for (var app in apps.values) { | ||||
|       var moddedApp = | ||||
|           getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo); | ||||
|       if (moddedApp != null) { | ||||
|         modifiedApps.add(moddedApp); | ||||
|       } | ||||
|     } | ||||
|     if (apps.containsKey(appId)) { | ||||
|       apps.remove(appId); | ||||
|     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(); | ||||
|   } | ||||
|  | ||||
|   bool checkAppObjectForUpdate(App app) { | ||||
|     if (!apps.containsKey(app.id)) { | ||||
|       throw 'App not found'; | ||||
|   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(); | ||||
|     } | ||||
|     return app.latestVersion != apps[app.id]?.app.installedVersion; | ||||
|   } | ||||
|  | ||||
|   Future<App?> getUpdate(String appId) async { | ||||
|   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); | ||||
|     if (newApp.latestVersion != currentApp.latestVersion) { | ||||
|       newApp.installedVersion = currentApp.installedVersion; | ||||
|       if (currentApp.preferredApkIndex < newApp.apkUrls.length) { | ||||
|         newApp.preferredApkIndex = currentApp.preferredApkIndex; | ||||
|       } | ||||
|       await saveApp(newApp); | ||||
|       return newApp; | ||||
|         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; | ||||
|     } | ||||
|     return null; | ||||
|     await saveApps([newApp]); | ||||
|     return newApp.latestVersion != currentApp.latestVersion ? newApp : null; | ||||
|   } | ||||
|  | ||||
|   Future<List<App>> checkUpdates() async { | ||||
|   Future<List<App>> checkUpdates( | ||||
|       {DateTime? ignoreAppsCheckedAfter, | ||||
|       bool throwErrorsForRetry = false}) async { | ||||
|     List<App> updates = []; | ||||
|     MultiAppMultiError errors = MultiAppMultiError(); | ||||
|     if (!gettingUpdates) { | ||||
|       gettingUpdates = true; | ||||
|  | ||||
|       List<String> appIds = apps.keys.toList(); | ||||
|       for (int i = 0; i < appIds.length; i++) { | ||||
|         App? newApp = await getUpdate(appIds[i]); | ||||
|         if (newApp != null) { | ||||
|           updates.add(newApp); | ||||
|       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; | ||||
|       } | ||||
|       gettingUpdates = false; | ||||
|     } | ||||
|     if (errors.content.isNotEmpty) { | ||||
|       throw errors; | ||||
|     } | ||||
|     return updates; | ||||
|   } | ||||
|  | ||||
|   List<String> getExistingUpdates() { | ||||
|   List<String> findExistingUpdates( | ||||
|       {bool installedOnly = false, bool nonInstalledOnly = false}) { | ||||
|     List<String> updateAppIds = []; | ||||
|     List<String> appIds = apps.keys.toList(); | ||||
|     for (int i = 0; i < appIds.length; i++) { | ||||
|       App? app = apps[appIds[i]]!.app; | ||||
|       if (app.installedVersion != app.latestVersion) { | ||||
|         updateAppIds.add(app.id); | ||||
|       if (app.installedVersion != app.latestVersion && | ||||
|           (!installedOnly || !nonInstalledOnly)) { | ||||
|         if ((app.installedVersion == null && | ||||
|                 (nonInstalledOnly || !installedOnly) || | ||||
|             (app.installedVersion != null && | ||||
|                 (installedOnly || !nonInstalledOnly)))) { | ||||
|           updateAppIds.add(app.id); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return updateAppIds; | ||||
| @@ -275,44 +592,65 @@ class AppsProvider with ChangeNotifier { | ||||
|  | ||||
|   Future<String> exportApps() async { | ||||
|     Directory? exportDir = Directory('/storage/emulated/0/Download'); | ||||
|     String path = 'Downloads'; | ||||
|     String path = 'Downloads'; // TODO: Is this true on non-english phones? | ||||
|     if (!exportDir.existsSync()) { | ||||
|       exportDir = await getExternalStorageDirectory(); | ||||
|       path = exportDir!.path; | ||||
|     } | ||||
|     File export = File( | ||||
|         '${exportDir.path}/obtainium-export-${DateTime.now().millisecondsSinceEpoch}.json'); | ||||
|         '${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json'); | ||||
|     export.writeAsStringSync( | ||||
|         jsonEncode(apps.values.map((e) => e.app.toJson()).toList())); | ||||
|     return path; | ||||
|   } | ||||
|  | ||||
|   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>) | ||||
|         .map((e) => App.fromJson(e)) | ||||
|         .toList(); | ||||
|     for (App a in importedApps) { | ||||
|       a.installedVersion = | ||||
|           apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null; | ||||
|       await saveApp(a); | ||||
|     while (loadingApps) { | ||||
|       await Future.delayed(const Duration(microseconds: 1)); | ||||
|     } | ||||
|     for (App a in importedApps) { | ||||
|       if (apps[a.id]?.app.installedVersion != null) { | ||||
|         a.installedVersion = apps[a.id]?.app.installedVersion; | ||||
|       } | ||||
|     } | ||||
|     await saveApps(importedApps); | ||||
|     notifyListeners(); | ||||
|     return importedApps.length; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     foregroundSubscription.cancel(); | ||||
|     foregroundSubscription?.cancel(); | ||||
|     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 { | ||||
|   const APKPicker({super.key, required this.app, this.initVal}); | ||||
|   const APKPicker({super.key, required this.app, this.initVal, this.archs}); | ||||
|  | ||||
|   final App app; | ||||
|   final String? initVal; | ||||
|   final List<String>? archs; | ||||
|  | ||||
|   @override | ||||
|   State<APKPicker> createState() => _APKPickerState(); | ||||
| @@ -326,32 +664,50 @@ class _APKPickerState extends State<APKPicker> { | ||||
|     apkUrl ??= widget.initVal; | ||||
|     return AlertDialog( | ||||
|       scrollable: true, | ||||
|       title: const Text('Pick an APK'), | ||||
|       title: Text(tr('pickAnAPK')), | ||||
|       content: Column(children: [ | ||||
|         Text('${widget.app.name} has more than one package:'), | ||||
|         Text(tr('appHasMoreThanOnePackage', args: [widget.app.name])), | ||||
|         const SizedBox(height: 16), | ||||
|         ...widget.app.apkUrls.map((u) => RadioListTile<String>( | ||||
|             title: Text(Uri.parse(u).pathSegments.last), | ||||
|             value: u, | ||||
|             groupValue: apkUrl, | ||||
|             onChanged: (String? val) { | ||||
|               setState(() { | ||||
|                 apkUrl = val; | ||||
|               }); | ||||
|             })) | ||||
|         ...widget.app.apkUrls.map( | ||||
|           (u) => RadioListTile<String>( | ||||
|               title: Text(Uri.parse(u) | ||||
|                   .pathSegments | ||||
|                   .where((element) => element.isNotEmpty) | ||||
|                   .last), | ||||
|               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: [ | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               Navigator.of(context).pop(null); | ||||
|             }, | ||||
|             child: const Text('Cancel')), | ||||
|             child: Text(tr('cancel'))), | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               HapticFeedback.selectionClick(); | ||||
|               Navigator.of(context).pop(apkUrl); | ||||
|             }, | ||||
|             child: const Text('Continue')) | ||||
|             child: Text(tr('continue'))) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| @@ -373,21 +729,23 @@ class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> { | ||||
|   Widget build(BuildContext context) { | ||||
|     return AlertDialog( | ||||
|       scrollable: true, | ||||
|       title: const Text('Warning'), | ||||
|       content: Text( | ||||
|           'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'), | ||||
|       title: Text(tr('warning')), | ||||
|       content: Text(tr('sourceIsXButPackageFromYPrompt', args: [ | ||||
|         Uri.parse(widget.sourceUrl).host, | ||||
|         Uri.parse(widget.apkUrl).host | ||||
|       ])), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               Navigator.of(context).pop(null); | ||||
|             }, | ||||
|             child: const Text('Cancel')), | ||||
|             child: Text(tr('cancel'))), | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               HapticFeedback.selectionClick(); | ||||
|               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 | ||||
| // Contains a set of pre-defined ObtainiumNotification objects that should be used throughout the app | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter_local_notifications/flutter_local_notifications.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| @@ -12,24 +13,41 @@ class ObtainiumNotification { | ||||
|   late String channelName; | ||||
|   late String channelDescription; | ||||
|   Importance importance; | ||||
|   bool onlyAlertOnce; | ||||
|  | ||||
|   ObtainiumNotification(this.id, this.title, this.message, this.channelCode, | ||||
|       this.channelName, this.channelDescription, this.importance); | ||||
|       this.channelName, this.channelDescription, this.importance, | ||||
|       {this.onlyAlertOnce = false}); | ||||
| } | ||||
|  | ||||
| class UpdateNotification extends ObtainiumNotification { | ||||
|   UpdateNotification(List<App> updates) | ||||
|       : super( | ||||
|             2, | ||||
|             'Updates Available', | ||||
|             tr('updatesAvailable'), | ||||
|             '', | ||||
|             'UPDATES_AVAILABLE', | ||||
|             'Updates Available', | ||||
|             'Notifies the user that updates are available for one or more Apps tracked by Obtainium', | ||||
|             tr('updatesAvailable'), | ||||
|             tr('updatesAvailableNotifDescription'), | ||||
|             Importance.max) { | ||||
|     message = updates.isEmpty | ||||
|         ? tr('noNewUpdates') | ||||
|         : updates.length == 1 | ||||
|             ? tr('xHasAnUpdate', args: [updates[0].name]) | ||||
|             : plural('xAndNMoreUpdatesAvailable', updates.length - 1, | ||||
|                 args: [updates[0].name]); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class SilentUpdateNotification extends ObtainiumNotification { | ||||
|   SilentUpdateNotification(List<App> updates) | ||||
|       : super(3, tr('appsUpdated'), '', 'APPS_UPDATED', tr('appsUpdated'), | ||||
|             tr('appsUpdatedNotifDescription'), Importance.defaultImportance) { | ||||
|     message = updates.length == 1 | ||||
|         ? '${updates[0].name} has an update.' | ||||
|         : '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.'; | ||||
|         ? tr('xWasUpdatedToY', | ||||
|             args: [updates[0].name, updates[0].latestVersion]) | ||||
|         : plural('xAndNMoreUpdatesInstalled', updates.length - 1, | ||||
|             args: [updates[0].name]); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -37,30 +55,57 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification { | ||||
|   ErrorCheckingUpdatesNotification(String error) | ||||
|       : super( | ||||
|             5, | ||||
|             'Error Checking for Updates', | ||||
|             tr('errorCheckingUpdates'), | ||||
|             error, | ||||
|             'BG_UPDATE_CHECK_ERROR', | ||||
|             'Error Checking for Updates', | ||||
|             'A notification that shows when background update checking fails', | ||||
|             tr('errorCheckingUpdates'), | ||||
|             tr('errorCheckingUpdatesNotifDescription'), | ||||
|             Importance.high); | ||||
| } | ||||
|  | ||||
| class AppsRemovedNotification extends ObtainiumNotification { | ||||
|   AppsRemovedNotification(List<List<String>> namedReasons) | ||||
|       : super(6, tr('appsRemoved'), '', 'APPS_REMOVED', tr('appsRemoved'), | ||||
|             tr('appsRemovedNotifDescription'), Importance.max) { | ||||
|     message = ''; | ||||
|     for (var r in namedReasons) { | ||||
|       message += '${tr('xWasRemovedDueToErrorY', args: [r[0], r[1]])} \n'; | ||||
|     } | ||||
|     message = message.trim(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class DownloadNotification extends ObtainiumNotification { | ||||
|   DownloadNotification(String appName, int progPercent) | ||||
|       : super( | ||||
|             appName.hashCode, | ||||
|             '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( | ||||
|     1, | ||||
|     'Complete App Installation', | ||||
|     'Obtainium must be open to install Apps', | ||||
|     tr('completeAppInstallation'), | ||||
|     tr('obtainiumMustBeOpenToInstallApps'), | ||||
|     'COMPLETE_INSTALL', | ||||
|     'Complete App Installation', | ||||
|     'Asks the user to return to Obtanium to finish installing an App', | ||||
|     tr('completeAppInstallation'), | ||||
|     tr('completeAppInstallationNotifDescription'), | ||||
|     Importance.max); | ||||
|  | ||||
| final checkingUpdatesNotification = ObtainiumNotification( | ||||
|     4, | ||||
|     'Checking for Updates', | ||||
|     tr('checkingForUpdates'), | ||||
|     '', | ||||
|     'BG_UPDATE_CHECK', | ||||
|     'Checking for Updates', | ||||
|     'Transient notification that appears when checking for updates', | ||||
|     tr('checkingForUpdates'), | ||||
|     tr('checkingForUpdatesNotifDescription'), | ||||
|     Importance.min); | ||||
|  | ||||
| class NotificationsProvider { | ||||
| @@ -100,7 +145,9 @@ class NotificationsProvider { | ||||
|       String channelName, | ||||
|       String channelDescription, | ||||
|       Importance importance, | ||||
|       {bool cancelExisting = false}) async { | ||||
|       {bool cancelExisting = false, | ||||
|       int? progPercent, | ||||
|       bool onlyAlertOnce = false}) async { | ||||
|     if (cancelExisting) { | ||||
|       await cancel(id); | ||||
|     } | ||||
| @@ -116,12 +163,16 @@ class NotificationsProvider { | ||||
|                 channelDescription: channelDescription, | ||||
|                 importance: importance, | ||||
|                 priority: importanceToPriority[importance]!, | ||||
|                 groupKey: 'dev.imranr.obtainium.$channelCode'))); | ||||
|                 groupKey: 'dev.imranr.obtainium.$channelCode', | ||||
|                 progress: progPercent ?? 0, | ||||
|                 maxProgress: 100, | ||||
|                 showProgress: progPercent != null, | ||||
|                 onlyAlertOnce: onlyAlertOnce))); | ||||
|   } | ||||
|  | ||||
|   Future<void> notify(ObtainiumNotification notif, | ||||
|           {bool cancelExisting = false}) => | ||||
|       notifyRaw(notif.id, notif.title, notif.message, notif.channelCode, | ||||
|           notif.channelName, notif.channelDescription, notif.importance, | ||||
|           cancelExisting: cancelExisting); | ||||
|           cancelExisting: cancelExisting, onlyAlertOnce: notif.onlyAlertOnce); | ||||
| } | ||||
|   | ||||
| @@ -1,10 +1,15 @@ | ||||
| // Exposes functions used to save/load app settings | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:fluttertoast/fluttertoast.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:permission_handler/permission_handler.dart'; | ||||
| import 'package:shared_preferences/shared_preferences.dart'; | ||||
|  | ||||
| String obtainiumTempId = 'imranr98_obtainium_${GitHub().host}'; | ||||
| String obtainiumId = 'dev.imranr.obtainium'; | ||||
|  | ||||
| enum ThemeSettings { system, light, dark } | ||||
|  | ||||
| enum ColourSettings { basic, materialYou } | ||||
| @@ -13,6 +18,16 @@ enum SortColumnSettings { added, nameAuthor, authorName } | ||||
|  | ||||
| enum SortOrderSettings { ascending, descending } | ||||
|  | ||||
| const maxAPIRateLimitMinutes = 30; | ||||
| const minUpdateIntervalMinutes = maxAPIRateLimitMinutes + 30; | ||||
| const maxUpdateIntervalMinutes = 4320; | ||||
| List<int> updateIntervals = [15, 30, 60, 120, 180, 360, 720, 1440, 4320, 0] | ||||
|     .where((element) => | ||||
|         (element >= minUpdateIntervalMinutes && | ||||
|             element <= maxUpdateIntervalMinutes) || | ||||
|         element == 0) | ||||
|     .toList(); | ||||
|  | ||||
| class SettingsProvider with ChangeNotifier { | ||||
|   SharedPreferences? prefs; | ||||
|  | ||||
| @@ -45,7 +60,17 @@ class SettingsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   int get updateInterval { | ||||
|     return prefs?.getInt('updateInterval') ?? 1440; | ||||
|     var min = prefs?.getInt('updateInterval') ?? 360; | ||||
|     if (!updateIntervals.contains(min)) { | ||||
|       var temp = updateIntervals[0]; | ||||
|       for (var i in updateIntervals) { | ||||
|         if (min > i && i != 0) { | ||||
|           temp = i; | ||||
|         } | ||||
|       } | ||||
|       min = temp; | ||||
|     } | ||||
|     return min; | ||||
|   } | ||||
|  | ||||
|   set updateInterval(int min) { | ||||
| @@ -54,8 +79,8 @@ class SettingsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   SortColumnSettings get sortColumn { | ||||
|     return SortColumnSettings | ||||
|         .values[prefs?.getInt('sortColumn') ?? SortColumnSettings.added.index]; | ||||
|     return SortColumnSettings.values[ | ||||
|         prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index]; | ||||
|   } | ||||
|  | ||||
|   set sortColumn(SortColumnSettings s) { | ||||
| @@ -65,7 +90,7 @@ class SettingsProvider with ChangeNotifier { | ||||
|  | ||||
|   SortOrderSettings get sortOrder { | ||||
|     return SortOrderSettings.values[ | ||||
|         prefs?.getInt('sortOrder') ?? SortOrderSettings.descending.index]; | ||||
|         prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index]; | ||||
|   } | ||||
|  | ||||
|   set sortOrder(SortOrderSettings s) { | ||||
| @@ -85,8 +110,7 @@ class SettingsProvider with ChangeNotifier { | ||||
|     while (!(await Permission.requestInstallPackages.isGranted)) { | ||||
|       // Explicit request as InstallPlugin request sometimes bugged | ||||
|       Fluttertoast.showToast( | ||||
|           msg: 'Please allow Obtainium to install Apps', | ||||
|           toastLength: Toast.LENGTH_LONG); | ||||
|           msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG); | ||||
|       if ((await Permission.requestInstallPackages.request()) == | ||||
|           PermissionStatus.granted) { | ||||
|         break; | ||||
| @@ -95,11 +119,29 @@ class SettingsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   bool get showAppWebpage { | ||||
|     return prefs?.getBool('showAppWebpage') ?? true; | ||||
|     return prefs?.getBool('showAppWebpage') ?? false; | ||||
|   } | ||||
|  | ||||
|   set showAppWebpage(bool show) { | ||||
|     prefs?.setBool('showAppWebpage', show); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   bool get pinUpdates { | ||||
|     return prefs?.getBool('pinUpdates') ?? true; | ||||
|   } | ||||
|  | ||||
|   set pinUpdates(bool show) { | ||||
|     prefs?.setBool('pinUpdates', show); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   String? getSettingString(String settingId) { | ||||
|     return prefs?.getString(settingId); | ||||
|   } | ||||
|  | ||||
|   void setSettingString(String settingId, String value) { | ||||
|     prefs?.setString(settingId, value); | ||||
|     notifyListeners(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -3,14 +3,19 @@ | ||||
|  | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:html/dom.dart'; | ||||
| import 'package:http/http.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 { | ||||
| @@ -23,8 +28,13 @@ class AppNames { | ||||
| class APKDetails { | ||||
|   late String version; | ||||
|   late List<String> apkUrls; | ||||
|   late bool isStandardVersionName; | ||||
|  | ||||
|   APKDetails(this.version, this.apkUrls); | ||||
|   APKDetails(version, this.apkUrls) { | ||||
|     var standardVersion = extractStandardVersionName(version); | ||||
|     isStandardVersionName = standardVersion != null; | ||||
|     this.version = standardVersion ?? version; | ||||
|   } | ||||
| } | ||||
|  | ||||
| class App { | ||||
| @@ -37,6 +47,10 @@ class App { | ||||
|   List<String> apkUrls = []; | ||||
|   late int preferredApkIndex; | ||||
|   late List<String> additionalData; | ||||
|   late DateTime? lastUpdateCheck; | ||||
|   bool pinned = false; | ||||
|   bool trackOnly = false; | ||||
|   bool enhancedVersionDetection = false; | ||||
|   App( | ||||
|       this.id, | ||||
|       this.url, | ||||
| @@ -46,11 +60,15 @@ class App { | ||||
|       this.latestVersion, | ||||
|       this.apkUrls, | ||||
|       this.preferredApkIndex, | ||||
|       this.additionalData); | ||||
|       this.additionalData, | ||||
|       this.lastUpdateCheck, | ||||
|       this.pinned, | ||||
|       this.trackOnly, | ||||
|       this.enhancedVersionDetection); | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls'; | ||||
|     return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned'; | ||||
|   } | ||||
|  | ||||
|   factory App.fromJson(Map<String, dynamic> json) => App( | ||||
| @@ -67,8 +85,16 @@ class App { | ||||
|           : List<String>.from(jsonDecode(json['apkUrls'])), | ||||
|       json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, | ||||
|       json['additionalData'] == null | ||||
|           ? SourceProvider().getSource(json['url']).additionalDataDefaults | ||||
|           : List<String>.from(jsonDecode(json['additionalData']))); | ||||
|           ? 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() => { | ||||
|         'id': id, | ||||
| @@ -79,17 +105,16 @@ class App { | ||||
|         'latestVersion': latestVersion, | ||||
|         'apkUrls': jsonEncode(apkUrls), | ||||
|         'preferredApkIndex': preferredApkIndex, | ||||
|         'additionalData': jsonEncode(additionalData) | ||||
|         'additionalData': jsonEncode(additionalData), | ||||
|         'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch, | ||||
|         'pinned': pinned, | ||||
|         'trackOnly': trackOnly, | ||||
|         'enhancedVersionDetection': enhancedVersionDetection | ||||
|       }; | ||||
| } | ||||
|  | ||||
| escapeRegEx(String s) { | ||||
|   return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { | ||||
|     return "\\${x[0]}"; | ||||
|   }); | ||||
| } | ||||
|  | ||||
| makeUrlHttps(String url) { | ||||
| // Ensure the input is starts with HTTPS and has no WWW | ||||
| preStandardizeUrl(String url) { | ||||
|   if (url.toLowerCase().indexOf('http://') != 0 && | ||||
|       url.toLowerCase().indexOf('https://') != 0) { | ||||
|     url = 'https://$url'; | ||||
| @@ -97,16 +122,14 @@ makeUrlHttps(String url) { | ||||
|   if (url.toLowerCase().indexOf('https://www.') == 0) { | ||||
|     url = 'https://${url.substring(12)}'; | ||||
|   } | ||||
|   url = url | ||||
|       .split('/') | ||||
|       .where((e) => e.isNotEmpty) | ||||
|       .join('/') | ||||
|       .replaceFirst(':/', '://'); | ||||
|   return url; | ||||
| } | ||||
|  | ||||
| const String couldNotFindReleases = 'Could not find a suitable release'; | ||||
| const String couldNotFindLatestVersion = | ||||
|     'Could not determine latest release version'; | ||||
| String notValidURL(String sourceName) { | ||||
|   return 'Not a valid $sourceName App URL'; | ||||
| } | ||||
|  | ||||
| const String noAPKFound = 'No APK found'; | ||||
|  | ||||
| List<String> getLinksFromParsedHTML( | ||||
| @@ -120,20 +143,73 @@ List<String> getLinksFromParsedHTML( | ||||
|         .map((e) => '$prependToLinks${e.attributes['href']!}') | ||||
|         .toList(); | ||||
|  | ||||
| abstract class AppSource { | ||||
| class AppSource { | ||||
|   late String host; | ||||
|   String standardizeURL(String url); | ||||
|   bool enforceTrackOnly = false; | ||||
|   String standardizeURL(String url) { | ||||
|     throw NotImplementedError(); | ||||
|   } | ||||
|  | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData); | ||||
|   AppNames getAppNames(String standardUrl); | ||||
|   late List<List<GeneratedFormItem>> additionalDataFormItems; | ||||
|   late List<String> additionalDataDefaults; | ||||
|       String standardUrl, List<String> additionalData, | ||||
|       {bool trackOnly = false}) { | ||||
|     throw NotImplementedError(); | ||||
|   } | ||||
|  | ||||
|   AppNames getAppNames(String standardUrl) { | ||||
|     throw NotImplementedError(); | ||||
|   } | ||||
|  | ||||
|   // 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; | ||||
|   } | ||||
| } | ||||
|  | ||||
| abstract class MassAppSource { | ||||
| ObtainiumError getObtainiumHttpError(Response res) { | ||||
|   return ObtainiumError(res.reasonPhrase ?? | ||||
|       tr('errorWithHttpStatusCode', args: [res.statusCode.toString()])); | ||||
| } | ||||
|  | ||||
| String? extractStandardVersionName(String version, {bool strict = false}) { | ||||
|   var match = RegExp( | ||||
|           '${strict ? '^' : ''}[0-9]+(\\.[0-9]+)+(-(alpha|beta)\\+?[0-9]+)?${strict ? '\$' : ''}') | ||||
|       .firstMatch(version); | ||||
|   return match != null ? version.substring(match.start, match.end) : null; | ||||
| } | ||||
|  | ||||
| abstract class MassAppUrlSource { | ||||
|   late String name; | ||||
|   late List<String> requiredArgs; | ||||
|   Future<List<String>> getUrls(List<String> args); | ||||
|   Future<Map<String, String>> getUrlsWithDescriptions(List<String> args); | ||||
| } | ||||
|  | ||||
| class SourceProvider { | ||||
| @@ -144,14 +220,16 @@ class SourceProvider { | ||||
|     FDroid(), | ||||
|     IzzyOnDroid(), | ||||
|     Mullvad(), | ||||
|     Signal() | ||||
|     Signal(), | ||||
|     SourceForge(), | ||||
|     APKMirror() | ||||
|   ]; | ||||
|  | ||||
|   // Add more mass source classes here so they are available via the service | ||||
|   List<MassAppSource> massSources = [GitHubStars()]; | ||||
|   // Add more mass url source classes here so they are available via the service | ||||
|   List<MassAppUrlSource> massUrlSources = [GitHubStars()]; | ||||
|  | ||||
|   AppSource getSource(String url) { | ||||
|     url = makeUrlHttps(url); | ||||
|     url = preStandardizeUrl(url); | ||||
|     AppSource? source; | ||||
|     for (var s in sources) { | ||||
|       if (url.toLowerCase().contains('://${s.host}')) { | ||||
| @@ -160,13 +238,13 @@ class SourceProvider { | ||||
|       } | ||||
|     } | ||||
|     if (source == null) { | ||||
|       throw 'URL does not match a known source'; | ||||
|       throw UnsupportedURLError(); | ||||
|     } | ||||
|     return source; | ||||
|   } | ||||
|  | ||||
|   bool doesSourceHaveRequiredAdditionalData(AppSource source) { | ||||
|     for (var row in source.additionalDataFormItems) { | ||||
|   bool ifSourceAppsRequireAdditionalData(AppSource source) { | ||||
|     for (var row in source.additionalSourceAppSpecificFormItems) { | ||||
|       for (var element in row) { | ||||
|         if (element.required) { | ||||
|           return true; | ||||
| @@ -176,39 +254,74 @@ class SourceProvider { | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   Future<App> getApp( | ||||
|       AppSource source, String url, List<String> additionalData) async { | ||||
|     String standardUrl = source.standardizeURL(makeUrlHttps(url)); | ||||
|     AppNames names = source.getAppNames(standardUrl); | ||||
|     APKDetails apk = | ||||
|         await source.getLatestAPKDetails(standardUrl, additionalData); | ||||
|     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, | ||||
|         additionalData); | ||||
|   String generateTempID(AppNames names, AppSource source) => | ||||
|       '${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}'; | ||||
|  | ||||
|   bool isTempId(String id) { | ||||
|     List<String> parts = id.split('_'); | ||||
|     if (parts.length < 3) { | ||||
|       return false; | ||||
|     } | ||||
|     for (int i = 0; i < parts.length - 1; i++) { | ||||
|       if (RegExp('.*[A-Z].*').hasMatch(parts[i])) { | ||||
|         // TODO: RegEx won't work for non-eng chars | ||||
|         return false; | ||||
|       } | ||||
|     } | ||||
|     return sources.map((e) => e.host).contains(parts.last); | ||||
|   } | ||||
|  | ||||
|   /// Returns a length 2 list, where the first element is a list of Apps and | ||||
|   /// the second is a Map<String, dynamic> of URLs and errors | ||||
|   Future<List<dynamic>> getApps(List<String> urls) async { | ||||
|   Future<App> getApp(AppSource source, String url, List<String> additionalData, | ||||
|       {String name = '', | ||||
|       String? id, | ||||
|       bool pinned = false, | ||||
|       bool trackOnly = false, | ||||
|       String? installedVersion}) async { | ||||
|     String standardUrl = source.standardizeURL(preStandardizeUrl(url)); | ||||
|     AppNames names = source.getAppNames(standardUrl); | ||||
|     APKDetails apk = await source | ||||
|         .getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly); | ||||
|     if (apk.apkUrls.isEmpty && !trackOnly) { | ||||
|       throw NoAPKError(); | ||||
|     } | ||||
|     String apkVersion = apk.version.replaceAll('/', '-'); | ||||
|     return App( | ||||
|         id ?? | ||||
|             source.tryInferringAppId(standardUrl) ?? | ||||
|             generateTempID(names, source), | ||||
|         standardUrl, | ||||
|         names.author[0].toUpperCase() + names.author.substring(1), | ||||
|         name.trim().isNotEmpty | ||||
|             ? name | ||||
|             : names.name[0].toUpperCase() + names.name.substring(1), | ||||
|         installedVersion, | ||||
|         apkVersion, | ||||
|         apk.apkUrls, | ||||
|         apk.apkUrls.length - 1, | ||||
|         additionalData, | ||||
|         DateTime.now(), | ||||
|         pinned, | ||||
|         trackOnly, | ||||
|         apk.isStandardVersionName && | ||||
|             (installedVersion == null || | ||||
|                 extractStandardVersionName(installedVersion, strict: true) != | ||||
|                     null)); | ||||
|   } | ||||
|  | ||||
|   // Returns errors in [results, errors] instead of throwing them | ||||
|   Future<List<dynamic>> getAppsByURLNaive(List<String> urls, | ||||
|       {List<String> ignoreUrls = const []}) async { | ||||
|     List<App> apps = []; | ||||
|     Map<String, dynamic> errors = {}; | ||||
|     for (var url in urls) { | ||||
|     for (var url in urls.where((element) => !ignoreUrls.contains(element))) { | ||||
|       try { | ||||
|         var source = getSource(url); | ||||
|         apps.add(await getApp(source, url, source.additionalDataDefaults)); | ||||
|         apps.add(await getApp( | ||||
|             source, url, source.additionalSourceAppSpecificDefaults)); | ||||
|       } catch (e) { | ||||
|         errors.addAll(<String, dynamic>{url: e}); | ||||
|       } | ||||
|     } | ||||
|     return [apps, errors]; | ||||
|   } | ||||
|  | ||||
|   List<String> getSourceHosts() => sources.map((e) => e.host).toList(); | ||||
| } | ||||
|   | ||||
							
								
								
									
										235
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						| @@ -1,20 +1,27 @@ | ||||
| # Generated by pub | ||||
| # See https://dart.dev/tools/pub/glossary#lockfile | ||||
| packages: | ||||
|   android_alarm_manager_plus: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: android_alarm_manager_plus | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.1.0" | ||||
|   animations: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: animations | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.4" | ||||
|     version: "2.0.7" | ||||
|   archive: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: archive | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.3.1" | ||||
|     version: "3.3.5" | ||||
|   args: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -71,6 +78,20 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.16.0" | ||||
|   convert: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: convert | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.1.1" | ||||
|   cross_file: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: cross_file | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.3.3+2" | ||||
|   crypto: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -105,42 +126,14 @@ packages: | ||||
|       name: device_info_plus | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "4.1.2" | ||||
|   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" | ||||
|     version: "8.0.0" | ||||
|   device_info_plus_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: device_info_plus_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.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" | ||||
|     version: "7.0.0" | ||||
|   dynamic_color: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -148,6 +141,20 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.5.4" | ||||
|   easy_localization: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: easy_localization | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.1" | ||||
|   easy_logger: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: easy_logger | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.0.2" | ||||
|   fake_async: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -175,7 +182,7 @@ packages: | ||||
|       name: file_picker | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "5.1.0" | ||||
|     version: "5.2.3" | ||||
|   flutter: | ||||
|     dependency: "direct main" | ||||
|     description: flutter | ||||
| @@ -187,14 +194,14 @@ packages: | ||||
|       name: flutter_fgbg | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.2.0" | ||||
|     version: "0.2.2" | ||||
|   flutter_launcher_icons: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
|       name: flutter_launcher_icons | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.10.0" | ||||
|     version: "0.11.0" | ||||
|   flutter_lints: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
| @@ -208,14 +215,14 @@ packages: | ||||
|       name: flutter_local_notifications | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "10.0.0" | ||||
|     version: "12.0.4" | ||||
|   flutter_local_notifications_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: flutter_local_notifications_linux | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.0.0" | ||||
|     version: "2.0.0" | ||||
|   flutter_local_notifications_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -223,6 +230,11 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "6.0.0" | ||||
|   flutter_localizations: | ||||
|     dependency: transitive | ||||
|     description: flutter | ||||
|     source: sdk | ||||
|     version: "0.0.0" | ||||
|   flutter_plugin_android_lifecycle: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -246,14 +258,14 @@ packages: | ||||
|       name: fluttertoast | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "8.0.9" | ||||
|     version: "8.1.1" | ||||
|   html: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: html | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.15.0" | ||||
|     version: "0.15.1" | ||||
|   http: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -267,14 +279,14 @@ packages: | ||||
|       name: http_parser | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "4.0.1" | ||||
|     version: "4.0.2" | ||||
|   image: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: image | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.2.0" | ||||
|     version: "3.2.2" | ||||
|   install_plugin_v2: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -282,6 +294,20 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.0.0" | ||||
|   installed_apps: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: installed_apps | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.3.1" | ||||
|   intl: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: intl | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.17.0" | ||||
|   js: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -295,14 +321,14 @@ packages: | ||||
|       name: json_annotation | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "4.6.0" | ||||
|     version: "4.7.0" | ||||
|   lints: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: lints | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.0" | ||||
|     version: "2.0.1" | ||||
|   matcher: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -316,7 +342,7 @@ packages: | ||||
|       name: material_color_utilities | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.2.0" | ||||
|     version: "0.1.5" | ||||
|   meta: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -324,6 +350,13 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.8.0" | ||||
|   mime: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: mime | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.0.3" | ||||
|   nested: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -331,6 +364,20 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.0.0" | ||||
|   package_archive_info: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: package_archive_info | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.1.0" | ||||
|   package_info: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: package_info | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.2" | ||||
|   path: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -351,7 +398,7 @@ packages: | ||||
|       name: path_provider_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.20" | ||||
|     version: "2.0.22" | ||||
|   path_provider_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -379,7 +426,7 @@ packages: | ||||
|       name: path_provider_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.4" | ||||
|     version: "2.0.5" | ||||
|   path_provider_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -393,42 +440,42 @@ packages: | ||||
|       name: permission_handler | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "10.0.0" | ||||
|     version: "10.2.0" | ||||
|   permission_handler_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "10.0.0" | ||||
|     version: "10.2.0" | ||||
|   permission_handler_apple: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_apple | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "9.0.4" | ||||
|     version: "9.0.7" | ||||
|   permission_handler_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.7.0" | ||||
|     version: "3.9.0" | ||||
|   permission_handler_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_windows | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.1.0" | ||||
|     version: "0.1.2" | ||||
|   petitparser: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: petitparser | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "5.0.0" | ||||
|     version: "5.1.0" | ||||
|   platform: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -443,6 +490,13 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.1.3" | ||||
|   pointycastle: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: pointycastle | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.6.2" | ||||
|   process: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -456,7 +510,21 @@ packages: | ||||
|       name: provider | ||||
|       url: "https://pub.dartlang.org" | ||||
|     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: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -470,7 +538,7 @@ packages: | ||||
|       name: shared_preferences_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.13" | ||||
|     version: "2.0.14" | ||||
|   shared_preferences_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -524,7 +592,21 @@ packages: | ||||
|       name: source_span | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.9.1" | ||||
|     version: "1.9.0" | ||||
|   sqflite: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: sqflite | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.2.1" | ||||
|   sqflite_common: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: sqflite_common | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.4.0+2" | ||||
|   stack_trace: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -546,6 +628,13 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.1.1" | ||||
|   synchronized: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: synchronized | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.0+3" | ||||
|   term_glyph: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -559,14 +648,14 @@ packages: | ||||
|       name: test_api | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.4.14" | ||||
|     version: "0.4.12" | ||||
|   timezone: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: timezone | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.8.0" | ||||
|     version: "0.9.0" | ||||
|   typed_data: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -580,14 +669,14 @@ packages: | ||||
|       name: url_launcher | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "6.1.5" | ||||
|     version: "6.1.7" | ||||
|   url_launcher_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: url_launcher_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "6.0.19" | ||||
|     version: "6.0.22" | ||||
|   url_launcher_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -615,7 +704,7 @@ packages: | ||||
|       name: url_launcher_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.1.0" | ||||
|     version: "2.1.1" | ||||
|   url_launcher_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -630,13 +719,20 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.1" | ||||
|   uuid: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: uuid | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.7" | ||||
|   vector_math: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: vector_math | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.1.3" | ||||
|     version: "2.1.2" | ||||
|   webview_flutter: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -650,35 +746,28 @@ packages: | ||||
|       name: webview_flutter_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.10.1" | ||||
|     version: "2.10.4" | ||||
|   webview_flutter_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: webview_flutter_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.9.3" | ||||
|     version: "1.9.5" | ||||
|   webview_flutter_wkwebview: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: webview_flutter_wkwebview | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.9.4" | ||||
|     version: "2.9.5" | ||||
|   win32: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: win32 | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.0" | ||||
|   workmanager: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: workmanager | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.5.0" | ||||
|     version: "3.1.2" | ||||
|   xdg_directories: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -701,5 +790,5 @@ packages: | ||||
|     source: hosted | ||||
|     version: "3.1.1" | ||||
| sdks: | ||||
|   dart: ">=2.19.0-79.0.dev <3.0.0" | ||||
|   dart: ">=2.18.2 <3.0.0" | ||||
|   flutter: ">=3.3.0" | ||||
|   | ||||
							
								
								
									
										24
									
								
								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 | ||||
| # In Windows, build-name is used as the major, minor, and patch parts | ||||
| # of the product and file versions while build-number is used as the build suffix. | ||||
| version: 0.3.0+16 # When changing this, update the tag in main() accordingly | ||||
| version: 0.8.2+65 # When changing this, update the tag in main() accordingly | ||||
|  | ||||
| environment: | ||||
|   sdk: '>=2.19.0-79.0.dev <3.0.0' | ||||
|   sdk: '>=2.18.2 <3.0.0' | ||||
|  | ||||
| # Dependencies specify other packages that your package needs in order to work. | ||||
| # To automatically upgrade your package dependencies to the latest versions | ||||
| @@ -38,27 +38,32 @@ dependencies: | ||||
|   cupertino_icons: ^1.0.5 | ||||
|   path_provider: ^2.0.11 | ||||
|   flutter_fgbg: ^0.2.0 # Try removing reliance on this | ||||
|   flutter_local_notifications: ^10.0.0 | ||||
|   flutter_local_notifications: ^12.0.0 | ||||
|   provider: ^6.0.3 | ||||
|   http: ^0.13.5 | ||||
|   webview_flutter: ^3.0.4 | ||||
|   workmanager: ^0.5.0 | ||||
|   dynamic_color: ^1.5.4 | ||||
|   install_plugin_v2: ^1.0.0 # Try replacing this | ||||
|   html: ^0.15.0 | ||||
|   shared_preferences: ^2.0.15 | ||||
|   url_launcher: ^6.1.5 | ||||
|   permission_handler: ^10.0.0 | ||||
|   fluttertoast: ^8.0.9 | ||||
|   device_info_plus: ^4.1.2 | ||||
|   device_info_plus: ^8.0.0 | ||||
|   file_picker: ^5.1.0 | ||||
|   animations: ^2.0.4 | ||||
|   install_plugin_v2: ^1.0.0 | ||||
|   share_plus: ^6.0.1 | ||||
|   installed_apps: ^1.3.1 | ||||
|   package_archive_info: ^0.1.0 | ||||
|   android_alarm_manager_plus: ^2.1.0 | ||||
|   sqflite: ^2.2.0+3 | ||||
|   easy_localization: ^3.0.1 | ||||
|  | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|     sdk: flutter | ||||
|   flutter_launcher_icons: ^0.10.0 | ||||
|   flutter_launcher_icons: ^0.11.0 | ||||
|  | ||||
|   # The "flutter_lints" package below contains a set of recommended lints to | ||||
|   # encourage good coding practices. The lint set provided by the package is | ||||
| @@ -85,9 +90,12 @@ flutter: | ||||
|   uses-material-design: true | ||||
|  | ||||
|   # To add assets to your application, add an assets section, like this: | ||||
|   # assets: | ||||
|   # - assets: | ||||
|   #   - images/a_dot_burr.jpeg | ||||
|   #   - images/a_dot_ham.jpeg | ||||
|    | ||||
|   assets: | ||||
|     - assets/translations/ | ||||
|  | ||||
|   # An image asset can refer to one or more resolution-specific "variants", see | ||||
|   # https://flutter.dev/assets-and-images/#resolution-aware | ||||
|   | ||||
| @@ -13,7 +13,7 @@ import 'package:obtainium/main.dart'; | ||||
| void main() { | ||||
|   testWidgets('Counter increments smoke test', (WidgetTester tester) async { | ||||
|     // Build our app and trigger a frame. | ||||
|     await tester.pumpWidget(const MyApp()); | ||||
|     await tester.pumpWidget(const Obtainium()); | ||||
|  | ||||
|     // Verify that our counter starts at 0. | ||||
|     expect(find.text('0'), findsOneWidget); | ||||
|   | ||||