Compare commits
	
		
			205 Commits
		
	
	
		
			v0.1.2-bet
			...
			v0.8.5-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 609366675d | ||
|  | fbff498ae1 | ||
|  | bb4e470760 | ||
|  | 15183c3a95 | ||
|  | b496a416ff | ||
|  | 6ac7ba204f | ||
|  | 0951c007d1 | ||
|  | d835beec76 | ||
|  | 2654bf12d3 | ||
|  | 3951108bc9 | ||
|  | d934ce2e13 | ||
|  | 66cc7f059f | ||
|  | 098428dac9 | ||
|  | 9e7c21b408 | ||
|  | 31c2c6b7c1 | ||
|  | f70049aded | ||
|  | 60c28bf912 | ||
|  | a6ed1e7c98 | ||
|  | 963f51dc53 | ||
|  | 17b1f6e5b0 | ||
|  | 086b2b949f | ||
|  | 9b5b212e96 | ||
|  | 6c8f9ebcbf | ||
|  | 4d5773bdcc | ||
|  | f81ef6a416 | ||
|  | 47324fcb49 | ||
|  | 377e0e07bd | ||
|  | b5aae70274 | ||
|  | 42475fa42a | ||
|  | d29534ef2e | ||
|  | 25953399ac | ||
|  | b04d2fad5c | ||
|  | 868ba84c9a | ||
|  | 602f0c3bb2 | ||
|  | 00721e8ac4 | ||
|  | d19f9101d6 | ||
|  | a4bc278e4c | ||
|  | b04986622b | ||
|  | 2059e4fd44 | ||
|  | 618a1523cf | ||
|  | ba1cdc2c73 | ||
|  | aa2a25fffe | ||
|  | c8ec67aef3 | ||
|  | 9576a99a4e | ||
|  | 0202224fa6 | ||
|  | 631ffd5c34 | ||
|  | feed7ffc0b | ||
|  | 296485de8a | ||
|  | d2f226d442 | ||
|  | cbdb449e35 | ||
|  | 3100a3a08c | ||
|  | 18951d6461 | ||
|  | 0e0a39a40f | ||
|  | 55cae0620b | ||
|  | ba6cea3ae6 | ||
|  | 4be33374c2 | ||
|  | e2bf834981 | ||
|  | 9bd7ddb21b | ||
|  | 905a807ee9 | ||
|  | ab57b97875 | ||
|  | 5db2c5f0b1 | ||
|  | e158c23cca | ||
|  | 208f125e12 | ||
|  | b7ccf3fa49 | ||
|  | c746e89052 | ||
|  | ee758e8470 | ||
|  | 68d903e092 | ||
|  | c47b752344 | ||
|  | 62a05996cf | ||
|  | 1cda941fbe | ||
|  | 49cb908d04 | ||
|  | 139f44d31d | ||
|  | ed955ac6a2 | ||
|  | f3ead6caf1 | ||
|  | 97ab723d04 | ||
|  | ed4a26d348 | ||
|  | bd5f21984e | ||
|  | 5037d77b14 | ||
|  | c9711c7734 | ||
|  | 76e98feeb7 | ||
|  | 03da23f77a | ||
|  | 9b99e2b302 | ||
|  | e746ca890a | ||
|  | 9c00a7da14 | ||
|  | 4df0dd64ad | ||
|  | 7cf7ffe0de | ||
|  | b1953435af | ||
|  | fc7d7d11d6 | ||
|  | 9ef26b3a4a | ||
|  | 27ee6b9e88 | ||
|  | d1a3529036 | ||
|  | a954a627fd | ||
|  | 52ce5b19c4 | ||
|  | 03f0b6cf05 | ||
|  | 5d8d0de8de | ||
|  | 07f6d4ad2c | ||
|  | dfbb4e19a5 | ||
|  | f5fda2ca90 | ||
|  | 661dc1626c | ||
|  | dde3fc20fb | ||
|  | 017b867d8d | ||
|  | 1cb1c124eb | ||
|  | fdeb852c7b | ||
|  | 67f50ba776 | ||
|  | a0968caa5c | ||
|  | e3e945d13b | ||
|  | 61f7f171b1 | ||
|  | de07583161 | ||
|  | 49b9a65053 | ||
|  | aebc8aed76 | ||
|  | 3958425c22 | ||
|  | 0a560871cb | ||
|  | fbe4f0b49e | ||
|  | e2440a38c4 | ||
|  | 496a10a444 | ||
|  | b8bb8d1f4b | ||
|  | af033f42cb | ||
|  | e706661062 | ||
|  | 1a68b8abe6 | ||
|  | 15c0ed04d1 | ||
|  | dd193d62f2 | ||
|  | 77e1768f3b | ||
|  | da9e5aed5e | ||
|  | 136628c9e6 | ||
|  | a916167be3 | ||
|  | 420cf487d4 | ||
|  | 12855370b0 | ||
|  | 33fed1cb2f | ||
|  | 33238b56a9 | ||
|  | 428c208de4 | ||
|  | 9a4b0301be | ||
|  | f58d26524c | ||
|  | 45e5544c5b | ||
|  | 0a9373e65a | ||
|  | b65c6e1d41 | ||
|  | 22dd8253a9 | ||
|  | 18198bbdfe | ||
|  | cf3c86abb8 | ||
|  | 570e376742 | ||
|  | 32ae5e8175 | ||
|  | cbf5057c17 | ||
|  | 2cfe62142a | ||
|  | d03486fc5d | ||
|  | 224e435bbb | ||
|  | 90fa0e06ce | ||
|  | 6c1ad94b4f | ||
|  | 7d7986f8bf | ||
|  | 3ddf9ea736 | ||
|  | 2272f8b4e6 | ||
|  | 9514062a3a | ||
|  | da57018b90 | ||
|  | 87e31c37aa | ||
|  | cb4dfff1b9 | ||
|  | 911b06bfb6 | ||
|  | 53513bfdd1 | ||
|  | 681092d895 | ||
|  | 0f6b6253de | ||
|  | c724b276ab | ||
|  | 35369273bd | ||
|  | 0b1863a227 | ||
|  | 9e21f2d6e6 | ||
|  | 6f11f850e0 | ||
|  | 5e96b91029 | ||
|  | 5fc79af960 | ||
|  | 05f5590e7d | ||
|  | 50f8caeb47 | ||
|  | f966a9e626 | ||
|  | 02a5749ba7 | ||
|  | 4ccf7cbc92 | ||
|  | ab4efd85ce | ||
|  | 42bba0f64c | ||
|  | 294327bde4 | ||
|  | 52b97662c6 | ||
|  | f63da4b538 | ||
|  | c30c692d87 | ||
|  | d643d5a474 | ||
|  | f8101a5d9f | ||
|  | c2a7e4a0d2 | ||
|  | 285da7545b | ||
|  | a5230acc11 | ||
|  | 53019818a6 | ||
|  | 1a04d39144 | ||
|  | 96c1ed612d | ||
|  | 4d75a6a361 | ||
|  | 30075add1c | ||
|  | 52b4e1fb96 | ||
|  | f9044e20f1 | ||
|  | 7e5affe1b8 | ||
|  | 5bdab1b1e4 | ||
|  | c14c4d2f14 | ||
|  | 5e785ae1d5 | ||
|  | 6c076751ab | ||
|  | 4253203dca | ||
|  | 7f1fd3c6c0 | ||
|  | 209f7ea516 | ||
|  | 09791979d5 | ||
|  | e7170aca48 | ||
|  | 7932b909c0 | ||
|  | 4c4a9093e4 | ||
|  | a6f290eb59 | ||
|  | ecb1e7d367 | ||
|  | 10f1c3abe5 | ||
|  | 9459c96d48 | ||
|  | 2aca9d680b | ||
|  | bd205dadc5 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -9,6 +9,7 @@ | |||||||
| .history | .history | ||||||
| .svn/ | .svn/ | ||||||
| migrate_working_dir/ | migrate_working_dir/ | ||||||
|  | .vscode/ | ||||||
|  |  | ||||||
| # IntelliJ related | # IntelliJ related | ||||||
| *.iml | *.iml | ||||||
|   | |||||||
							
								
								
									
										23
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -1,17 +1,26 @@ | |||||||
| # Obtainium | #  Obtainium | ||||||
|  |  | ||||||
| Get Android App Updates Directly From the Source. | Get Android App Updates Directly From the Source. | ||||||
|  |  | ||||||
| Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available. | Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available. | ||||||
|  |  | ||||||
| Currently supported App sources: |  | ||||||
| - GitHub |  | ||||||
|  |  | ||||||
| Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0) | Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0) | ||||||
|  |  | ||||||
| ***Work In Progress - Far from ready.*** | Currently supported App sources: | ||||||
|  | - [GitHub](https://github.com/) | ||||||
|  | - [GitLab](https://gitlab.com/) | ||||||
|  | - [F-Droid](https://f-droid.org/) | ||||||
|  | - [IzzyOnDroid](https://android.izzysoft.de/) | ||||||
|  | - [Mullvad](https://mullvad.net/en/) | ||||||
|  | - [Signal](https://signal.org/) | ||||||
|  |  | ||||||
| ## Limitations | ## Limitations | ||||||
| - App installs are assumed to have succeeded; failures and cancelled installs cannot be detected. | - App installs are assumed to have succeeded; failures and cancelled installs cannot be detected. | ||||||
| - Already installed apps are not detected, for the above reason along with the fact that App sources do not provide App IDs (like `org.example.app`) to allow for comparisons. | - Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin. | ||||||
| - Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin. | - For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable. | ||||||
|  |  | ||||||
|  | ## Screenshots | ||||||
|  |  | ||||||
|  | | <img src="./assets/screenshots/1.apps.png" alt="Apps Page" /> | <img src="./assets/screenshots/2.dark_theme.png" alt="Dark Theme" />           | <img src="./assets/screenshots/3.material_you.png" alt="Material You" />    | | ||||||
|  | | ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- | | ||||||
|  | | <img src="./assets/screenshots/4.app.png" alt="App Page" />   | <img src="./assets/screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./assets/screenshots/6.apk_install.png" alt="App Installation" /> | | ||||||
|   | |||||||
| @@ -30,7 +30,25 @@ | |||||||
|         <meta-data |         <meta-data | ||||||
|             android:name="flutterEmbedding" |             android:name="flutterEmbedding" | ||||||
|             android:value="2" /> |             android:value="2" /> | ||||||
|  |         <service | ||||||
|  |             android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmService" | ||||||
|  |             android:permission="android.permission.BIND_JOB_SERVICE" | ||||||
|  |             android:exported="false"/> | ||||||
|  |         <receiver | ||||||
|  |             android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmBroadcastReceiver" | ||||||
|  |             android:exported="false"/> | ||||||
|  |         <receiver | ||||||
|  |             android:name="dev.fluttercommunity.plus.androidalarmmanager.RebootBroadcastReceiver" | ||||||
|  |             android:enabled="false" | ||||||
|  |             android:exported="false"> | ||||||
|  |             <intent-filter> | ||||||
|  |                 <action android:name="android.intent.action.BOOT_COMPLETED" /> | ||||||
|  |             </intent-filter> | ||||||
|  |         </receiver> | ||||||
|     </application> |     </application> | ||||||
|     <uses-permission android:name="android.permission.INTERNET" /> |     <uses-permission android:name="android.permission.INTERNET" /> | ||||||
|     <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> |     <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> | ||||||
|  |     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> | ||||||
|  |     <uses-permission android:name="android.permission.WAKE_LOCK"/> | ||||||
|  |     <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> | ||||||
| </manifest> | </manifest> | ||||||
| Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.8 KiB | 
| Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.8 KiB | 
| Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.7 KiB | 
| Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 15 KiB | 
| Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 25 KiB | 
| Before Width: | Height: | Size: 4.1 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> | ||||||
							
								
								
									
										
											BIN
										
									
								
								assets/graphics/banner.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 66 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/graphics/icon.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 109 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/graphics/icon.psd
									
									
									
									
									
										Executable file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								assets/graphics/obtainium.psd
									
									
									
									
									
										Executable file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								assets/icon.png
									
									
									
									
									
								
							
							
						
						| Before Width: | Height: | Size: 21 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/1.apps.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 228 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/2.dark_theme.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 162 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/3.material_you.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 170 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/4.app.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 146 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/5.apk_picker.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 188 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/6.apk_install.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 192 KiB | 
							
								
								
									
										216
									
								
								assets/translations/en.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,216 @@ | |||||||
|  | { | ||||||
|  |     "invalidURLForSource": "Not a valid {} App URL", | ||||||
|  |     "noReleaseFound": "Could not find a suitable release", | ||||||
|  |     "noVersionFound": "Could not determine release version", | ||||||
|  |     "urlMatchesNoSource": "URL does not match a known source", | ||||||
|  |     "cantInstallOlderVersion": "Cannot install an older version of an App", | ||||||
|  |     "appIdMismatch": "Downloaded package ID does not match existing App ID", | ||||||
|  |     "functionNotImplemented": "This class has not implemented this function", | ||||||
|  |     "placeholder": "Placeholder", | ||||||
|  |     "someErrors": "Some Errors Occurred", | ||||||
|  |     "unexpectedError": "Unexpected Error", | ||||||
|  |     "ok": "Okay", | ||||||
|  |     "and": "and", | ||||||
|  |     "startedBgUpdateTask": "Started BG update check task", | ||||||
|  |     "bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is  {}", | ||||||
|  |     "startedActualBGUpdateCheck": "Started actual BG update checking", | ||||||
|  |     "bgUpdateTaskFinished": "Finished BG update check task", | ||||||
|  |     "firstRun": "This is the first ever run of Obtainium", | ||||||
|  |     "settingUpdateCheckIntervalTo": "Setting update interval to {}", | ||||||
|  |     "githubPATLabel": "GitHub Personal Access Token (Increases Rate Limit)", | ||||||
|  |     "githubPATHint": "PAT must be in this format: username:token", | ||||||
|  |     "githubPATFormat": "username:token", | ||||||
|  |     "githubPATLinkText": "'About GitHub PATs", | ||||||
|  |     "includePrereleases": "Include prereleases", | ||||||
|  |     "fallbackToOlderReleases": "Fallback to older releases", | ||||||
|  |     "filterReleaseTitlesByRegEx": "Filter Release Titles by Regular Expression", | ||||||
|  |     "invalidRegEx": "Invalid regular expression", | ||||||
|  |     "noDescription": "No description", | ||||||
|  |     "cancel": "Cancel", | ||||||
|  |     "continue": "Continue", | ||||||
|  |     "requiredInBrackets": "(Required)", | ||||||
|  |     "dropdownNoOptsError": "ERROR: DROPDOWN MUST HAVE AT LEAST ONE OPT", | ||||||
|  |     "colour": "Colour", | ||||||
|  |     "githubStarredRepos": "GitHub Starred Repos", | ||||||
|  |     "uname": "Username", | ||||||
|  |     "wrongArgNum": "Wrong number of arguments provided", | ||||||
|  |     "xIsTrackOnly": "{} is Track-Only", | ||||||
|  |     "source": "Source", | ||||||
|  |     "app": "App", | ||||||
|  |     "appsFromSourceAreTrackOnly": "Apps from this source are 'Track-Only'.' ", | ||||||
|  |     "youPickedTrackOnly": "You have selected the 'Track-Only' option.", | ||||||
|  |     "trackOnlyAppDescription": "The App will be tracked for updates, but Obtainium will not be able to download or install it.", | ||||||
|  |     "cancelled": "Cancelled", | ||||||
|  |     "appAlreadyAdded": "App already added", | ||||||
|  |     "addApp": "Add App", | ||||||
|  |     "appSourceURL": "App Source URL", | ||||||
|  |     "error": "Error", | ||||||
|  |     "add": "Add", | ||||||
|  |     "searchSomeSourcesLabel": "Search (Some Sources Only)", | ||||||
|  |     "search": "Search", | ||||||
|  |     "additionalOptsFor": "Additional Options for {}", | ||||||
|  |     "supportedSourcesBelow": "Supported Sources:", | ||||||
|  |     "trackOnlyInBrackets": "(Track-Only)", | ||||||
|  |     "searchableInBrackets": "(Searchable)", | ||||||
|  |     "appsString": "Apps", | ||||||
|  |     "noApps": "No Apps", | ||||||
|  |     "noAppsForFilter": "No Apps for Filter", | ||||||
|  |     "byX": "By {}", | ||||||
|  |     "percentProgress": "Progress: {}%", | ||||||
|  |     "pleaseWait": "Please Wait...", | ||||||
|  |     "updateAvailable": "Update Available", | ||||||
|  |     "estimateInBracketsShort": "(Est.)", | ||||||
|  |     "notInstalled": "Not Installed", | ||||||
|  |     "estimateInBrackets": "(Estimate)", | ||||||
|  |     "selectAll": "Select All", | ||||||
|  |     "deselectN": "Deselect {}", | ||||||
|  |     "xWillBeRemovedButRemainInstalled": "{} will be removed from Obtainium but remain installed on device.", | ||||||
|  |     "removeSelectedAppsQuestion": "Remove Selected Apps?", | ||||||
|  |     "removeSelectedApps": "Remove Selected Apps", | ||||||
|  |     "updateX": "Update {}", | ||||||
|  |     "installX": "Install {}", | ||||||
|  |     "markXTrackOnlyAsUpdated": "Mark {}\n(Track-Only)\nas Updated", | ||||||
|  |     "changeX": "Change {}", | ||||||
|  |     "installUpdateApps": "Install/Update Apps", | ||||||
|  |     "installUpdateSelectedApps": "Install/Update Selected Apps", | ||||||
|  |     "onlyAppliesToInstalledAndOutdatedApps": "Only applies to installed but out of date Apps whose install status cannot be automatically detected.", | ||||||
|  |     "markXSelectedAppsAsUpdated": "Mark {} Selected Apps as Updated?", | ||||||
|  |     "no": "No", | ||||||
|  |     "yes": "Yes", | ||||||
|  |     "markSelectedAppsUpdated": "Mark Selected Apps as Updated", | ||||||
|  |     "pinToTop": "Pin to top", | ||||||
|  |     "unpinFromTop": "Unpin from top", | ||||||
|  |     "resetInstallStatusForSelectedAppsQuestion": "Reset Install Status for Selected Apps?", | ||||||
|  |     "installStatusOfXWillBeResetExplanation": "The install status of any selected Apps will be reset.\n\nThis can help when the App version shown in Obtainium is incorrect due to failed updates or other issues.", | ||||||
|  |     "shareSelectedAppURLs": "Share Selected App URLs", | ||||||
|  |     "resetInstallStatus": "Reset Install Status", | ||||||
|  |     "more": "More", | ||||||
|  |     "removeOutdatedFilter": "Remove Out-of-Date App Filter", | ||||||
|  |     "showOutdatedOnly": "Show Out-of-Date Apps Only", | ||||||
|  |     "filter": "Filter", | ||||||
|  |     "filterActive": "Filter *", | ||||||
|  |     "filterApps": "Filter Apps", | ||||||
|  |     "appName": "App Name", | ||||||
|  |     "author": "Author", | ||||||
|  |     "upToDateApps": "Up to Date Apps", | ||||||
|  |     "nonInstalledApps": "Non-Installed Apps", | ||||||
|  |     "importExport": "Import/Export", | ||||||
|  |     "settings": "Settings", | ||||||
|  |     "exportedTo": "Exported to {}", | ||||||
|  |     "obtainiumExport": "Obtainium Export", | ||||||
|  |     "invalidInput": "Invalid input", | ||||||
|  |     "importedX": "Imported {}", | ||||||
|  |     "obtainiumImport": "Obtainium Import", | ||||||
|  |     "importFromURLList": "Import from URL List", | ||||||
|  |     "searchQuery": "Search Query", | ||||||
|  |     "appURLList": "App URL List", | ||||||
|  |     "line": "Line", | ||||||
|  |     "searchX": "Search {}", | ||||||
|  |     "noResults": "No results found", | ||||||
|  |     "importX": "Import {}", | ||||||
|  |     "importedAppsIdDisclaimer": "Imported Apps may incorrectly show as \"Not Installed\".\nTo fix this, re-install them through Obtainium.\nThis should not affect App data.\n\nOnly affects URL and third-party import methods.", | ||||||
|  |     "importErrors": "Import Errors", | ||||||
|  |     "importedXOfYApps": "{} of {} Apps imported.", | ||||||
|  |     "followingURLsHadErrors": "The following URLs had errors:", | ||||||
|  |     "okay": "Okay", | ||||||
|  |     "selectURL": "Select URL", | ||||||
|  |     "selectURLs": "Select URLs", | ||||||
|  |     "pick": "Pick", | ||||||
|  |     "theme": "Theme", | ||||||
|  |     "dark": "Dark", | ||||||
|  |     "light": "Light", | ||||||
|  |     "followSystem": "Follow System", | ||||||
|  |     "obtainium": "Obtainium", | ||||||
|  |     "materialYou": "Material You", | ||||||
|  |     "appSortBy": "App Sort By", | ||||||
|  |     "authorName": "Author/Name", | ||||||
|  |     "nameAuthor": "Name/Author", | ||||||
|  |     "asAdded": "As Added", | ||||||
|  |     "appSortOrder": "App Sort Order", | ||||||
|  |     "ascending": "Ascending", | ||||||
|  |     "descending": "Descending", | ||||||
|  |     "bgUpdateCheckInterval": "Background Update Checking Interval", | ||||||
|  |     "neverManualOnly": "Never - Manual Only", | ||||||
|  |     "appearance": "Appearance", | ||||||
|  |     "showWebInAppView": "Show Source Webpage in App View", | ||||||
|  |     "pinUpdates": "Pin Updates to Top of Apps View", | ||||||
|  |     "updates": "Updated", | ||||||
|  |     "sourceSpecific": "Source-Specific", | ||||||
|  |     "appSource": "App Source", | ||||||
|  |     "noLogs": "No Logs", | ||||||
|  |     "appLogs": "App Logs", | ||||||
|  |     "close": "Close", | ||||||
|  |     "share": "Share", | ||||||
|  |     "appNotFound": "App not found", | ||||||
|  |     "obtainiumExportHyphenatedLowercase": "obtainium-export", | ||||||
|  |     "pickAnAPK": "Pick an APK", | ||||||
|  |     "appHasMoreThanOnePackage": "{} has more than one package:", | ||||||
|  |     "deviceSupportsXArch": "Your device supports the {} CPU architecture.", | ||||||
|  |     "deviceSupportsFollowingArchs": "Your device supports the following CPU architectures:", | ||||||
|  |     "warning": "Warning", | ||||||
|  |     "sourceIsXButPackageFromYPrompt": "The App source is '{}' but the release package comes from '{}'. Continue?", | ||||||
|  |     "updatesAvailable": "Updates Available", | ||||||
|  |     "updatesAvailableNotifDescription": "Notifies the user that updates are available for one or more Apps tracked by Obtainium", | ||||||
|  |     "noNewUpdates": "No new updates.", | ||||||
|  |     "xHasAnUpdate": "{} has an update.", | ||||||
|  |     "appsUpdated": "Apps Updated", | ||||||
|  |     "appsUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were applied in the background", | ||||||
|  |     "xWasUpdatedToY": "{} was updated to {}.", | ||||||
|  |     "errorCheckingUpdates": "Error Checking for Updates", | ||||||
|  |     "errorCheckingUpdatesNotifDescription": "A notification that shows when background update checking fails", | ||||||
|  |     "appsRemoved": "Apps Removed", | ||||||
|  |     "appsRemovedNotifDescription": "Notifies the user that one or more Apps were removed due to errors while loading them", | ||||||
|  |     "xWasRemovedDueToErrorY": "{} was removed due to this error: {}", | ||||||
|  |     "completeAppInstallation": "Complete App Installation", | ||||||
|  |     "obtainiumMustBeOpenToInstallApps": "Obtainium must be open to install Apps", | ||||||
|  |     "completeAppInstallationNotifDescription": "Asks the user to return to Obtainium to finish installing an App", | ||||||
|  |     "checkingForUpdates": "Checking for Updates", | ||||||
|  |     "checkingForUpdatesNotifDescription": "Transient notification that appears when checking for updates", | ||||||
|  |     "pleaseAllowInstallPerm": "Please allow Obtainium to install Apps", | ||||||
|  |     "trackOnly": "Track-Only", | ||||||
|  |     "errorWithHttpStatusCode": "Error {}", | ||||||
|  |     "tooManyRequestsTryAgainInMinutes": { | ||||||
|  |         "one": "Too many requests (rate limited) - try again in {} minute", | ||||||
|  |         "other": "Too many requests (rate limited) - try again in {} minutes" | ||||||
|  |     }, | ||||||
|  |     "bgUpdateGotErrorRetryInMinutes": { | ||||||
|  |         "one": "BG update checking encountered a {}, will schedule a retry check in {} minute", | ||||||
|  |         "other": "BG update checking encountered a {}, will schedule a retry check in {} minutes" | ||||||
|  |     }, | ||||||
|  |     "bgCheckFoundUpdatesWillNotifyIfNeeded": { | ||||||
|  |         "one": "BG update checking found {} update - will notify user if needed", | ||||||
|  |         "other": "BG update checking found {} updates - will notify user if needed" | ||||||
|  |     }, | ||||||
|  |     "apps": { | ||||||
|  |         "one": "{} App", | ||||||
|  |         "other": "{} Apps" | ||||||
|  |     }, | ||||||
|  |     "url": { | ||||||
|  |         "one": "{} URL", | ||||||
|  |         "other": "{} URLs" | ||||||
|  |     }, | ||||||
|  |     "minute": { | ||||||
|  |         "one": "{} Minute", | ||||||
|  |         "other": "{} Minutes" | ||||||
|  |     }, | ||||||
|  |     "hour": { | ||||||
|  |         "one": "{} Hour", | ||||||
|  |         "other": "{} Hours" | ||||||
|  |     }, | ||||||
|  |     "day": { | ||||||
|  |         "one": "{} Day", | ||||||
|  |         "other": "{} Days" | ||||||
|  |     }, | ||||||
|  |     "clearedNLogsBeforeXAfterY": { | ||||||
|  |         "one": "Cleared {n} log (before = {before}, after = {after})", | ||||||
|  |         "other": "Cleared {n} logs (before = {before}, after = {after})" | ||||||
|  |     }, | ||||||
|  |     "xAndNMoreUpdatesAvailable": { | ||||||
|  |         "one": "{} and {} more app have updated.", | ||||||
|  |         "other": "{} and {} more apps have updates." | ||||||
|  |     }, | ||||||
|  |     "xAndNMoreUpdatesInstalled": { | ||||||
|  |         "one": "{} and {} more app were updated.", | ||||||
|  |         "other": "{} and {} more apps were updated." | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										58
									
								
								lib/app_sources/apkmirror.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,58 @@ | |||||||
|  | import 'package:html/parser.dart'; | ||||||
|  | import 'package:http/http.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
|  | class APKMirror extends AppSource { | ||||||
|  |   APKMirror() { | ||||||
|  |     host = 'apkmirror.com'; | ||||||
|  |     enforceTrackOnly = true; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+'); | ||||||
|  |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|  |     if (match == null) { | ||||||
|  |       throw InvalidURLError(runtimeType.toString()); | ||||||
|  |     } | ||||||
|  |     return url.substring(0, match.end); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||||
|  |       '$standardUrl/#whatsnew'; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<APKDetails> getLatestAPKDetails( | ||||||
|  |       String standardUrl, List<String> additionalData, | ||||||
|  |       {bool trackOnly = false}) async { | ||||||
|  |     Response res = await get(Uri.parse('$standardUrl/feed')); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       String? titleString = parse(res.body) | ||||||
|  |           .querySelector('item') | ||||||
|  |           ?.querySelector('title') | ||||||
|  |           ?.innerHtml; | ||||||
|  |       String? version = titleString | ||||||
|  |           ?.substring(RegExp('[0-9]').firstMatch(titleString)?.start ?? 0, | ||||||
|  |               RegExp(' by ').firstMatch(titleString)?.start ?? 0) | ||||||
|  |           .trim(); | ||||||
|  |       if (version == null || version.isEmpty) { | ||||||
|  |         version = titleString; | ||||||
|  |       } | ||||||
|  |       if (version == null || version.isEmpty) { | ||||||
|  |         throw NoVersionError(); | ||||||
|  |       } | ||||||
|  |       return APKDetails(version, []); | ||||||
|  |     } else { | ||||||
|  |       throw NoReleasesError(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) { | ||||||
|  |     String temp = standardUrl.substring(standardUrl.indexOf('://') + 3); | ||||||
|  |     List<String> names = temp.substring(temp.indexOf('/') + 1).split('/'); | ||||||
|  |     return AppNames(names[1], names[2]); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										71
									
								
								lib/app_sources/fdroid.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,71 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:http/http.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
|  | class FDroid extends AppSource { | ||||||
|  |   FDroid() { | ||||||
|  |     host = 'f-droid.org'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     RegExp standardUrlRegExB = | ||||||
|  |         RegExp('^https?://$host/+[^/]+/+packages/+[^/]+'); | ||||||
|  |     RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase()); | ||||||
|  |     if (match != null) { | ||||||
|  |       url = 'https://$host/packages/${Uri.parse(url).pathSegments.last}'; | ||||||
|  |     } | ||||||
|  |     RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+'); | ||||||
|  |     match = standardUrlRegExA.firstMatch(url.toLowerCase()); | ||||||
|  |     if (match == null) { | ||||||
|  |       throw InvalidURLError(runtimeType.toString()); | ||||||
|  |     } | ||||||
|  |     return url.substring(0, match.end); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? tryInferringAppId(String standardUrl) { | ||||||
|  |     return Uri.parse(standardUrl).pathSegments.last; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   APKDetails getAPKUrlsFromFDroidPackagesAPIResponse( | ||||||
|  |       Response res, String apkUrlPrefix) { | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       List<dynamic> releases = jsonDecode(res.body)['packages'] ?? []; | ||||||
|  |       if (releases.isEmpty) { | ||||||
|  |         throw NoReleasesError(); | ||||||
|  |       } | ||||||
|  |       String? latestVersion = releases[0]['versionName']; | ||||||
|  |       if (latestVersion == null) { | ||||||
|  |         throw NoVersionError(); | ||||||
|  |       } | ||||||
|  |       List<String> apkUrls = releases | ||||||
|  |           .where((element) => element['versionName'] == latestVersion) | ||||||
|  |           .map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk') | ||||||
|  |           .toList(); | ||||||
|  |       return APKDetails(latestVersion, apkUrls); | ||||||
|  |     } else { | ||||||
|  |       throw NoReleasesError(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<APKDetails> getLatestAPKDetails( | ||||||
|  |       String standardUrl, List<String> additionalData, | ||||||
|  |       {bool trackOnly = false}) async { | ||||||
|  |     String? appId = tryInferringAppId(standardUrl); | ||||||
|  |     return getAPKUrlsFromFDroidPackagesAPIResponse( | ||||||
|  |         await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')), | ||||||
|  |         'https://f-droid.org/repo/$appId'); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) { | ||||||
|  |     return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										207
									
								
								lib/app_sources/github.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,207 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:http/http.dart'; | ||||||
|  | import 'package:obtainium/components/generated_form.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
|  | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
|  | class GitHub extends AppSource { | ||||||
|  |   GitHub() { | ||||||
|  |     host = 'github.com'; | ||||||
|  |  | ||||||
|  |     additionalSourceAppSpecificDefaults = ['true', 'true', '']; | ||||||
|  |  | ||||||
|  |     additionalSourceSpecificSettingFormItems = [ | ||||||
|  |       GeneratedFormItem( | ||||||
|  |           label: tr('githubPATLabel'), | ||||||
|  |           id: 'github-creds', | ||||||
|  |           required: false, | ||||||
|  |           additionalValidators: [ | ||||||
|  |             (value) { | ||||||
|  |               if (value != null && value.trim().isNotEmpty) { | ||||||
|  |                 if (value | ||||||
|  |                         .split(':') | ||||||
|  |                         .where((element) => element.trim().isNotEmpty) | ||||||
|  |                         .length != | ||||||
|  |                     2) { | ||||||
|  |                   return tr('githubPATHint'); | ||||||
|  |                 } | ||||||
|  |               } | ||||||
|  |               return null; | ||||||
|  |             } | ||||||
|  |           ], | ||||||
|  |           hint: tr('githubPATFormat'), | ||||||
|  |           belowWidgets: [ | ||||||
|  |             const SizedBox( | ||||||
|  |               height: 8, | ||||||
|  |             ), | ||||||
|  |             GestureDetector( | ||||||
|  |                 onTap: () { | ||||||
|  |                   launchUrlString( | ||||||
|  |                       'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token', | ||||||
|  |                       mode: LaunchMode.externalApplication); | ||||||
|  |                 }, | ||||||
|  |                 child: Text( | ||||||
|  |                   tr('githubPATLinkText'), | ||||||
|  |                   style: const TextStyle( | ||||||
|  |                       decoration: TextDecoration.underline, fontSize: 12), | ||||||
|  |                 )) | ||||||
|  |           ]) | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     additionalSourceAppSpecificFormItems = [ | ||||||
|  |       [ | ||||||
|  |         GeneratedFormItem( | ||||||
|  |             label: tr('includePrereleases'), type: FormItemType.bool) | ||||||
|  |       ], | ||||||
|  |       [ | ||||||
|  |         GeneratedFormItem( | ||||||
|  |             label: tr('fallbackToOlderReleases'), type: FormItemType.bool) | ||||||
|  |       ], | ||||||
|  |       [ | ||||||
|  |         GeneratedFormItem( | ||||||
|  |             label: tr('filterReleaseTitlesByRegEx'), | ||||||
|  |             type: FormItemType.string, | ||||||
|  |             required: false, | ||||||
|  |             additionalValidators: [ | ||||||
|  |               (value) { | ||||||
|  |                 if (value == null || value.isEmpty) { | ||||||
|  |                   return null; | ||||||
|  |                 } | ||||||
|  |                 try { | ||||||
|  |                   RegExp(value); | ||||||
|  |                 } catch (e) { | ||||||
|  |                   return tr('invalidRegEx'); | ||||||
|  |                 } | ||||||
|  |                 return null; | ||||||
|  |               } | ||||||
|  |             ]) | ||||||
|  |       ] | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     canSearch = true; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); | ||||||
|  |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|  |     if (match == null) { | ||||||
|  |       throw InvalidURLError(runtimeType.toString()); | ||||||
|  |     } | ||||||
|  |     return url.substring(0, match.end); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<String> getCredentialPrefixIfAny() async { | ||||||
|  |     SettingsProvider settingsProvider = SettingsProvider(); | ||||||
|  |     await settingsProvider.initializeSettings(); | ||||||
|  |     String? creds = settingsProvider | ||||||
|  |         .getSettingString(additionalSourceSpecificSettingFormItems[0].id); | ||||||
|  |     return creds != null && creds.isNotEmpty ? '$creds@' : ''; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||||
|  |       '$standardUrl/releases'; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<APKDetails> getLatestAPKDetails( | ||||||
|  |       String standardUrl, List<String> additionalData, | ||||||
|  |       {bool trackOnly = false}) async { | ||||||
|  |     var includePrereleases = | ||||||
|  |         additionalData.isNotEmpty && additionalData[0] == 'true'; | ||||||
|  |     var fallbackToOlderReleases = | ||||||
|  |         additionalData.length >= 2 && additionalData[1] == 'true'; | ||||||
|  |     var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty | ||||||
|  |         ? additionalData[2] | ||||||
|  |         : null; | ||||||
|  |     Response res = await get(Uri.parse( | ||||||
|  |         'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases')); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       var releases = jsonDecode(res.body) as List<dynamic>; | ||||||
|  |  | ||||||
|  |       List<String> getReleaseAPKUrls(dynamic release) => | ||||||
|  |           (release['assets'] as List<dynamic>?) | ||||||
|  |               ?.map((e) { | ||||||
|  |                 return e['browser_download_url'] != null | ||||||
|  |                     ? e['browser_download_url'] as String | ||||||
|  |                     : ''; | ||||||
|  |               }) | ||||||
|  |               .where((element) => element.toLowerCase().endsWith('.apk')) | ||||||
|  |               .toList() ?? | ||||||
|  |           []; | ||||||
|  |  | ||||||
|  |       dynamic targetRelease; | ||||||
|  |  | ||||||
|  |       for (int i = 0; i < releases.length; i++) { | ||||||
|  |         if (!fallbackToOlderReleases && i > 0) break; | ||||||
|  |         if (!includePrereleases && releases[i]['prerelease'] == true) { | ||||||
|  |           continue; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (regexFilter != null && | ||||||
|  |             !RegExp(regexFilter) | ||||||
|  |                 .hasMatch((releases[i]['name'] as String).trim())) { | ||||||
|  |           continue; | ||||||
|  |         } | ||||||
|  |         var apkUrls = getReleaseAPKUrls(releases[i]); | ||||||
|  |         if (apkUrls.isEmpty && !trackOnly) { | ||||||
|  |           continue; | ||||||
|  |         } | ||||||
|  |         targetRelease = releases[i]; | ||||||
|  |         targetRelease['apkUrls'] = apkUrls; | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |       if (targetRelease == null) { | ||||||
|  |         throw NoReleasesError(); | ||||||
|  |       } | ||||||
|  |       String? version = targetRelease['tag_name']; | ||||||
|  |       if (version == null) { | ||||||
|  |         throw NoVersionError(); | ||||||
|  |       } | ||||||
|  |       return APKDetails(version, targetRelease['apkUrls'] as List<String>); | ||||||
|  |     } else { | ||||||
|  |       rateLimitErrorCheck(res); | ||||||
|  |       throw getObtainiumHttpError(res); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) { | ||||||
|  |     String temp = standardUrl.substring(standardUrl.indexOf('://') + 3); | ||||||
|  |     List<String> names = temp.substring(temp.indexOf('/') + 1).split('/'); | ||||||
|  |     return AppNames(names[0], names[1]); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<Map<String, String>> search(String query) async { | ||||||
|  |     Response res = await get(Uri.parse( | ||||||
|  |         'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100')); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       Map<String, String> urlsWithDescriptions = {}; | ||||||
|  |       for (var e in (jsonDecode(res.body)['items'] as List<dynamic>)) { | ||||||
|  |         urlsWithDescriptions.addAll({ | ||||||
|  |           e['html_url'] as String: e['description'] != null | ||||||
|  |               ? e['description'] as String | ||||||
|  |               : tr('noDescription') | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |       return urlsWithDescriptions; | ||||||
|  |     } else { | ||||||
|  |       rateLimitErrorCheck(res); | ||||||
|  |       throw getObtainiumHttpError(res); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   rateLimitErrorCheck(Response res) { | ||||||
|  |     if (res.headers['x-ratelimit-remaining'] == '0') { | ||||||
|  |       throw RateLimitError( | ||||||
|  |           (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / | ||||||
|  |                   60000000) | ||||||
|  |               .round()); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										70
									
								
								lib/app_sources/gitlab.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,70 @@ | |||||||
|  | import 'package:html/parser.dart'; | ||||||
|  | import 'package:http/http.dart'; | ||||||
|  | import 'package:obtainium/app_sources/github.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
|  | class GitLab extends AppSource { | ||||||
|  |   GitLab() { | ||||||
|  |     host = 'gitlab.com'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); | ||||||
|  |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|  |     if (match == null) { | ||||||
|  |       throw InvalidURLError(runtimeType.toString()); | ||||||
|  |     } | ||||||
|  |     return url.substring(0, match.end); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||||
|  |       '$standardUrl/-/releases'; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<APKDetails> getLatestAPKDetails( | ||||||
|  |       String standardUrl, List<String> additionalData, | ||||||
|  |       {bool trackOnly = false}) async { | ||||||
|  |     Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom')); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       var standardUri = Uri.parse(standardUrl); | ||||||
|  |       var parsedHtml = parse(res.body); | ||||||
|  |       var entry = parsedHtml.querySelector('entry'); | ||||||
|  |       var entryContent = | ||||||
|  |           parse(parseFragment(entry?.querySelector('content')!.innerHtml).text); | ||||||
|  |       var apkUrls = [ | ||||||
|  |         ...getLinksFromParsedHTML( | ||||||
|  |             entryContent, | ||||||
|  |             RegExp( | ||||||
|  |                 '^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { | ||||||
|  |                   return '\\${x[0]}'; | ||||||
|  |                 })}/uploads/[^/]+/[^/]+\\.apk\$', | ||||||
|  |                 caseSensitive: false), | ||||||
|  |             standardUri.origin), | ||||||
|  |         // GitLab releases may contain links to externally hosted APKs | ||||||
|  |         ...getLinksFromParsedHTML(entryContent, | ||||||
|  |                 RegExp('/[^/]+\\.apk\$', caseSensitive: false), '') | ||||||
|  |             .where((element) => Uri.parse(element).host != '') | ||||||
|  |             .toList() | ||||||
|  |       ]; | ||||||
|  |  | ||||||
|  |       var entryId = entry?.querySelector('id')?.innerHtml; | ||||||
|  |       var version = | ||||||
|  |           entryId == null ? null : Uri.parse(entryId).pathSegments.last; | ||||||
|  |       if (version == null) { | ||||||
|  |         throw NoVersionError(); | ||||||
|  |       } | ||||||
|  |       return APKDetails(version, apkUrls); | ||||||
|  |     } else { | ||||||
|  |       throw NoReleasesError(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) { | ||||||
|  |     // Same as GitHub | ||||||
|  |     return GitHub().getAppNames(standardUrl); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										44
									
								
								lib/app_sources/izzyondroid.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,44 @@ | |||||||
|  | import 'package:http/http.dart'; | ||||||
|  | import 'package:obtainium/app_sources/fdroid.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
|  | class IzzyOnDroid extends AppSource { | ||||||
|  |   IzzyOnDroid() { | ||||||
|  |     host = 'android.izzysoft.de'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+'); | ||||||
|  |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|  |     if (match == null) { | ||||||
|  |       throw InvalidURLError(runtimeType.toString()); | ||||||
|  |     } | ||||||
|  |     return url.substring(0, match.end); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? tryInferringAppId(String standardUrl) { | ||||||
|  |     return FDroid().tryInferringAppId(standardUrl); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<APKDetails> getLatestAPKDetails( | ||||||
|  |       String standardUrl, List<String> additionalData, | ||||||
|  |       {bool trackOnly = false}) async { | ||||||
|  |     String? appId = tryInferringAppId(standardUrl); | ||||||
|  |     return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse( | ||||||
|  |         await get( | ||||||
|  |             Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')), | ||||||
|  |         'https://android.izzysoft.de/frepo/$appId'); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) { | ||||||
|  |     return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										51
									
								
								lib/app_sources/mullvad.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,51 @@ | |||||||
|  | import 'package:html/parser.dart'; | ||||||
|  | import 'package:http/http.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
|  | class Mullvad extends AppSource { | ||||||
|  |   Mullvad() { | ||||||
|  |     host = 'mullvad.net'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     RegExp standardUrlRegEx = RegExp('^https?://$host'); | ||||||
|  |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|  |     if (match == null) { | ||||||
|  |       throw InvalidURLError(runtimeType.toString()); | ||||||
|  |     } | ||||||
|  |     return url.substring(0, match.end); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||||
|  |       'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md'; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<APKDetails> getLatestAPKDetails( | ||||||
|  |       String standardUrl, List<String> additionalData, | ||||||
|  |       {bool trackOnly = false}) async { | ||||||
|  |     Response res = await get(Uri.parse('$standardUrl/en/download/android')); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       var version = parse(res.body) | ||||||
|  |           .querySelector('p.subtitle.is-6') | ||||||
|  |           ?.querySelector('a') | ||||||
|  |           ?.attributes['href'] | ||||||
|  |           ?.split('/') | ||||||
|  |           .last; | ||||||
|  |       if (version == null) { | ||||||
|  |         throw NoVersionError(); | ||||||
|  |       } | ||||||
|  |       return APKDetails( | ||||||
|  |           version, ['https://mullvad.net/download/app/apk/latest']); | ||||||
|  |     } else { | ||||||
|  |       throw NoReleasesError(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) { | ||||||
|  |     return AppNames('Mullvad-VPN', 'Mullvad-VPN'); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										41
									
								
								lib/app_sources/signal.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,41 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  | import 'package:http/http.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
|  | class Signal extends AppSource { | ||||||
|  |   Signal() { | ||||||
|  |     host = 'signal.org'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     return 'https://$host'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<APKDetails> getLatestAPKDetails( | ||||||
|  |       String standardUrl, List<String> additionalData, | ||||||
|  |       {bool trackOnly = false}) async { | ||||||
|  |     Response res = | ||||||
|  |         await get(Uri.parse('https://updates.$host/android/latest.json')); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       var json = jsonDecode(res.body); | ||||||
|  |       String? apkUrl = json['url']; | ||||||
|  |       List<String> apkUrls = apkUrl == null ? [] : [apkUrl]; | ||||||
|  |       String? version = json['versionName']; | ||||||
|  |       if (version == null) { | ||||||
|  |         throw NoVersionError(); | ||||||
|  |       } | ||||||
|  |       return APKDetails(version, apkUrls); | ||||||
|  |     } else { | ||||||
|  |       throw NoReleasesError(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal'); | ||||||
|  | } | ||||||
							
								
								
									
										64
									
								
								lib/app_sources/sourceforge.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,64 @@ | |||||||
|  | import 'package:html/parser.dart'; | ||||||
|  | import 'package:http/http.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
|  | class SourceForge extends AppSource { | ||||||
|  |   SourceForge() { | ||||||
|  |     host = 'sourceforge.net'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+'); | ||||||
|  |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|  |     if (match == null) { | ||||||
|  |       throw InvalidURLError(runtimeType.toString()); | ||||||
|  |     } | ||||||
|  |     return url.substring(0, match.end); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<APKDetails> getLatestAPKDetails( | ||||||
|  |       String standardUrl, List<String> additionalData, | ||||||
|  |       {bool trackOnly = false}) async { | ||||||
|  |     Response res = await get(Uri.parse('$standardUrl/rss?path=/')); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       var parsedHtml = parse(res.body); | ||||||
|  |       var allDownloadLinks = | ||||||
|  |           parsedHtml.querySelectorAll('guid').map((e) => e.innerHtml).toList(); | ||||||
|  |       getVersion(String url) { | ||||||
|  |         try { | ||||||
|  |           var tokens = url.split('/'); | ||||||
|  |           return tokens[tokens.length - 3]; | ||||||
|  |         } catch (e) { | ||||||
|  |           return null; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       String? version = getVersion(allDownloadLinks[0]); | ||||||
|  |       if (version == null) { | ||||||
|  |         throw NoVersionError(); | ||||||
|  |       } | ||||||
|  |       var apkUrlListAllReleases = allDownloadLinks | ||||||
|  |           .where((element) => element.toLowerCase().endsWith('.apk/download')) | ||||||
|  |           .toList(); | ||||||
|  |       var apkUrlList = | ||||||
|  |           apkUrlListAllReleases // This can be used skipped for fallback support later | ||||||
|  |               .where((element) => getVersion(element) == version) | ||||||
|  |               .toList(); | ||||||
|  |       return APKDetails(version, apkUrlList); | ||||||
|  |     } else { | ||||||
|  |       throw NoReleasesError(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) { | ||||||
|  |     return AppNames(runtimeType.toString(), | ||||||
|  |         standardUrl.substring(standardUrl.lastIndexOf('/') + 1)); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										29
									
								
								lib/components/custom_app_bar.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,29 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  |  | ||||||
|  | class CustomAppBar extends StatefulWidget { | ||||||
|  |   const CustomAppBar({super.key, required this.title}); | ||||||
|  |  | ||||||
|  |   final String title; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<CustomAppBar> createState() => _CustomAppBarState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _CustomAppBarState extends State<CustomAppBar> { | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return SliverAppBar( | ||||||
|  |       pinned: true, | ||||||
|  |       automaticallyImplyLeading: false, | ||||||
|  |       expandedHeight: 100, | ||||||
|  |       flexibleSpace: FlexibleSpaceBar( | ||||||
|  |         titlePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), | ||||||
|  |         title: Text( | ||||||
|  |           widget.title, | ||||||
|  |           style: | ||||||
|  |               TextStyle(color: Theme.of(context).textTheme.bodyMedium!.color), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										229
									
								
								lib/components/generated_form.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,229 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  |  | ||||||
|  | enum FormItemType { string, bool } | ||||||
|  |  | ||||||
|  | typedef OnValueChanges = void Function( | ||||||
|  |     List<String> values, bool valid, bool isBuilding); | ||||||
|  |  | ||||||
|  | class GeneratedFormItem { | ||||||
|  |   late String key; | ||||||
|  |   late String label; | ||||||
|  |   late FormItemType type; | ||||||
|  |   late bool required; | ||||||
|  |   late int max; | ||||||
|  |   late List<String? Function(String? value)> additionalValidators; | ||||||
|  |   late String id; | ||||||
|  |   late List<Widget> belowWidgets; | ||||||
|  |   late String? hint; | ||||||
|  |   late List<String>? opts; | ||||||
|  |  | ||||||
|  |   GeneratedFormItem( | ||||||
|  |       {this.label = 'Input', | ||||||
|  |       this.type = FormItemType.string, | ||||||
|  |       this.required = true, | ||||||
|  |       this.max = 1, | ||||||
|  |       this.additionalValidators = const [], | ||||||
|  |       this.id = 'input', | ||||||
|  |       this.belowWidgets = const [], | ||||||
|  |       this.hint, | ||||||
|  |       this.opts, | ||||||
|  |       this.key = 'default'}); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class GeneratedForm extends StatefulWidget { | ||||||
|  |   const GeneratedForm( | ||||||
|  |       {super.key, | ||||||
|  |       required this.items, | ||||||
|  |       required this.onValueChanges, | ||||||
|  |       required this.defaultValues}); | ||||||
|  |  | ||||||
|  |   final List<List<GeneratedFormItem>> items; | ||||||
|  |   final OnValueChanges onValueChanges; | ||||||
|  |   final List<String> defaultValues; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<GeneratedForm> createState() => _GeneratedFormState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _GeneratedFormState extends State<GeneratedForm> { | ||||||
|  |   final _formKey = GlobalKey<FormState>(); | ||||||
|  |   late List<List<String>> values; | ||||||
|  |   late List<List<Widget>> formInputs; | ||||||
|  |   List<List<Widget>> rows = []; | ||||||
|  |  | ||||||
|  |   // If any value changes, call this to update the parent with value and validity | ||||||
|  |   void someValueChanged({bool isBuilding = false}) { | ||||||
|  |     List<String> returnValues = []; | ||||||
|  |     var valid = true; | ||||||
|  |     for (int r = 0; r < values.length; r++) { | ||||||
|  |       for (int i = 0; i < values[r].length; i++) { | ||||||
|  |         returnValues.add(values[r][i]); | ||||||
|  |         if (formInputs[r][i] is TextFormField) { | ||||||
|  |           valid = valid && | ||||||
|  |               ((formInputs[r][i].key as GlobalKey<FormFieldState>) | ||||||
|  |                       .currentState | ||||||
|  |                       ?.isValid ?? | ||||||
|  |                   false); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     widget.onValueChanges(returnValues, valid, isBuilding); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |  | ||||||
|  |     // Initialize form values as all empty | ||||||
|  |     int j = 0; | ||||||
|  |     values = widget.items | ||||||
|  |         .map((row) => row.map((e) { | ||||||
|  |               return j < widget.defaultValues.length | ||||||
|  |                   ? widget.defaultValues[j++] | ||||||
|  |                   : e.opts != null | ||||||
|  |                       ? e.opts!.first | ||||||
|  |                       : ''; | ||||||
|  |             }).toList()) | ||||||
|  |         .toList(); | ||||||
|  |  | ||||||
|  |     // Dynamically create form inputs | ||||||
|  |     formInputs = widget.items.asMap().entries.map((row) { | ||||||
|  |       return row.value.asMap().entries.map((e) { | ||||||
|  |         if (e.value.type == FormItemType.string && e.value.opts == null) { | ||||||
|  |           final formFieldKey = GlobalKey<FormFieldState>(); | ||||||
|  |           return TextFormField( | ||||||
|  |             key: formFieldKey, | ||||||
|  |             initialValue: values[row.key][e.key], | ||||||
|  |             autovalidateMode: AutovalidateMode.onUserInteraction, | ||||||
|  |             onChanged: (value) { | ||||||
|  |               setState(() { | ||||||
|  |                 values[row.key][e.key] = value; | ||||||
|  |                 someValueChanged(); | ||||||
|  |               }); | ||||||
|  |             }, | ||||||
|  |             decoration: InputDecoration( | ||||||
|  |                 helperText: e.value.label + (e.value.required ? ' *' : ''), | ||||||
|  |                 hintText: e.value.hint), | ||||||
|  |             minLines: e.value.max <= 1 ? null : e.value.max, | ||||||
|  |             maxLines: e.value.max <= 1 ? 1 : e.value.max, | ||||||
|  |             validator: (value) { | ||||||
|  |               if (e.value.required && (value == null || value.trim().isEmpty)) { | ||||||
|  |                 return '${e.value.label} ${tr('requiredInBrackets')}'; | ||||||
|  |               } | ||||||
|  |               for (var validator in e.value.additionalValidators) { | ||||||
|  |                 String? result = validator(value); | ||||||
|  |                 if (result != null) { | ||||||
|  |                   return result; | ||||||
|  |                 } | ||||||
|  |               } | ||||||
|  |               return null; | ||||||
|  |             }, | ||||||
|  |           ); | ||||||
|  |         } else if (e.value.type == FormItemType.string && | ||||||
|  |             e.value.opts != null) { | ||||||
|  |           if (e.value.opts!.isEmpty) { | ||||||
|  |             return Text(tr('dropdownNoOptsError')); | ||||||
|  |           } | ||||||
|  |           return DropdownButtonFormField( | ||||||
|  |               decoration: InputDecoration(labelText: tr('colour')), | ||||||
|  |               value: values[row.key][e.key], | ||||||
|  |               items: e.value.opts! | ||||||
|  |                   .map((e) => DropdownMenuItem(value: e, child: Text(e))) | ||||||
|  |                   .toList(), | ||||||
|  |               onChanged: (value) { | ||||||
|  |                 setState(() { | ||||||
|  |                   values[row.key][e.key] = value ?? e.value.opts!.first; | ||||||
|  |                   someValueChanged(); | ||||||
|  |                 }); | ||||||
|  |               }); | ||||||
|  |         } else { | ||||||
|  |           return Container(); // Some input types added in build | ||||||
|  |         } | ||||||
|  |       }).toList(); | ||||||
|  |     }).toList(); | ||||||
|  |     someValueChanged(isBuilding: true); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     for (var r = 0; r < formInputs.length; r++) { | ||||||
|  |       for (var e = 0; e < formInputs[r].length; e++) { | ||||||
|  |         if (widget.items[r][e].type == FormItemType.bool) { | ||||||
|  |           formInputs[r][e] = Row( | ||||||
|  |             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||||
|  |             children: [ | ||||||
|  |               Text(widget.items[r][e].label), | ||||||
|  |               Switch( | ||||||
|  |                   value: values[r][e] == 'true', | ||||||
|  |                   onChanged: (value) { | ||||||
|  |                     setState(() { | ||||||
|  |                       values[r][e] = value ? 'true' : ''; | ||||||
|  |                       someValueChanged(); | ||||||
|  |                     }); | ||||||
|  |                   }) | ||||||
|  |             ], | ||||||
|  |           ); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     rows.clear(); | ||||||
|  |     formInputs.asMap().entries.forEach((rowInputs) { | ||||||
|  |       if (rowInputs.key > 0) { | ||||||
|  |         rows.add([ | ||||||
|  |           SizedBox( | ||||||
|  |             height: widget.items[rowInputs.key][0].type == FormItemType.bool && | ||||||
|  |                     widget.items[rowInputs.key - 1][0].type == | ||||||
|  |                         FormItemType.string | ||||||
|  |                 ? 25 | ||||||
|  |                 : 8, | ||||||
|  |           ) | ||||||
|  |         ]); | ||||||
|  |       } | ||||||
|  |       List<Widget> rowItems = []; | ||||||
|  |       rowInputs.value.asMap().entries.forEach((rowInput) { | ||||||
|  |         if (rowInput.key > 0) { | ||||||
|  |           rowItems.add(const SizedBox( | ||||||
|  |             width: 20, | ||||||
|  |           )); | ||||||
|  |         } | ||||||
|  |         rowItems.add(Expanded( | ||||||
|  |             child: Column( | ||||||
|  |                 crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |                 children: [ | ||||||
|  |               rowInput.value, | ||||||
|  |               ...widget.items[rowInputs.key][rowInput.key].belowWidgets | ||||||
|  |             ]))); | ||||||
|  |       }); | ||||||
|  |       rows.add(rowItems); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     return Form( | ||||||
|  |         key: _formKey, | ||||||
|  |         child: Column( | ||||||
|  |           children: [ | ||||||
|  |             ...rows.map((row) => Row( | ||||||
|  |                   mainAxisAlignment: MainAxisAlignment.start, | ||||||
|  |                   crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                   children: [...row.map((e) => e)], | ||||||
|  |                 )) | ||||||
|  |           ], | ||||||
|  |         )); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | String? findGeneratedFormValueByKey( | ||||||
|  |     List<GeneratedFormItem> items, List<String> values, String key) { | ||||||
|  |   var foundIndex = -1; | ||||||
|  |   for (var i = 0; i < items.length; i++) { | ||||||
|  |     if (items[i].key == key) { | ||||||
|  |       foundIndex = i; | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   if (foundIndex >= 0 && foundIndex < values.length) { | ||||||
|  |     return values[foundIndex]; | ||||||
|  |   } | ||||||
|  |   return null; | ||||||
|  | } | ||||||
							
								
								
									
										82
									
								
								lib/components/generated_form_modal.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,82 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:obtainium/components/generated_form.dart'; | ||||||
|  |  | ||||||
|  | class GeneratedFormModal extends StatefulWidget { | ||||||
|  |   const GeneratedFormModal( | ||||||
|  |       {super.key, | ||||||
|  |       required this.title, | ||||||
|  |       required this.items, | ||||||
|  |       required this.defaultValues, | ||||||
|  |       this.initValid = false, | ||||||
|  |       this.message = ''}); | ||||||
|  |  | ||||||
|  |   final String title; | ||||||
|  |   final String message; | ||||||
|  |   final List<List<GeneratedFormItem>> items; | ||||||
|  |   final List<String> defaultValues; | ||||||
|  |   final bool initValid; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<GeneratedFormModal> createState() => _GeneratedFormModalState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _GeneratedFormModalState extends State<GeneratedFormModal> { | ||||||
|  |   List<String> values = []; | ||||||
|  |   bool valid = false; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     values = widget.defaultValues; | ||||||
|  |     valid = widget.initValid || widget.items.isEmpty; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AlertDialog( | ||||||
|  |       scrollable: true, | ||||||
|  |       title: Text(widget.title), | ||||||
|  |       content: | ||||||
|  |           Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ | ||||||
|  |         if (widget.message.isNotEmpty) Text(widget.message), | ||||||
|  |         if (widget.message.isNotEmpty) | ||||||
|  |           const SizedBox( | ||||||
|  |             height: 16, | ||||||
|  |           ), | ||||||
|  |         GeneratedForm( | ||||||
|  |             items: widget.items, | ||||||
|  |             onValueChanges: (values, valid, isBuilding) { | ||||||
|  |               if (isBuilding) { | ||||||
|  |                 this.values = values; | ||||||
|  |                 this.valid = valid; | ||||||
|  |               } else { | ||||||
|  |                 setState(() { | ||||||
|  |                   this.values = values; | ||||||
|  |                   this.valid = valid; | ||||||
|  |                 }); | ||||||
|  |               } | ||||||
|  |             }, | ||||||
|  |             defaultValues: widget.defaultValues) | ||||||
|  |       ]), | ||||||
|  |       actions: [ | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               Navigator.of(context).pop(null); | ||||||
|  |             }, | ||||||
|  |             child: Text(tr('cancel'))), | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: !valid | ||||||
|  |                 ? null | ||||||
|  |                 : () { | ||||||
|  |                     if (valid) { | ||||||
|  |                       HapticFeedback.selectionClick(); | ||||||
|  |                       Navigator.of(context).pop(values); | ||||||
|  |                     } | ||||||
|  |                   }, | ||||||
|  |             child: Text(tr('continue'))) | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										123
									
								
								lib/custom_errors.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,123 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:obtainium/providers/logs_provider.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  |  | ||||||
|  | class ObtainiumError { | ||||||
|  |   late String message; | ||||||
|  |   bool unexpected; | ||||||
|  |   ObtainiumError(this.message, {this.unexpected = false}); | ||||||
|  |   @override | ||||||
|  |   String toString() { | ||||||
|  |     return message; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class RateLimitError { | ||||||
|  |   late int remainingMinutes; | ||||||
|  |   RateLimitError(this.remainingMinutes); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toString() => | ||||||
|  |       plural('tooManyRequestsTryAgainInMinutes', remainingMinutes); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class InvalidURLError extends ObtainiumError { | ||||||
|  |   InvalidURLError(String sourceName) | ||||||
|  |       : super(tr('invalidURLForSource', args: [sourceName])); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class NoReleasesError extends ObtainiumError { | ||||||
|  |   NoReleasesError() : super(tr('noReleaseFound')); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class NoAPKError extends ObtainiumError { | ||||||
|  |   NoAPKError() : super(tr('noReleaseFound')); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class NoVersionError extends ObtainiumError { | ||||||
|  |   NoVersionError() : super(tr('noVersionFound')); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class UnsupportedURLError extends ObtainiumError { | ||||||
|  |   UnsupportedURLError() : super(tr('urlMatchesNoSource')); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class DowngradeError extends ObtainiumError { | ||||||
|  |   DowngradeError() : super(tr('cantInstallOlderVersion')); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class IDChangedError extends ObtainiumError { | ||||||
|  |   IDChangedError() : super(tr('appIdMismatch')); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class NotImplementedError extends ObtainiumError { | ||||||
|  |   NotImplementedError() : super(tr('functionNotImplemented')); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class MultiAppMultiError extends ObtainiumError { | ||||||
|  |   Map<String, List<String>> content = {}; | ||||||
|  |  | ||||||
|  |   MultiAppMultiError() : super(tr('placeholder'), unexpected: true); | ||||||
|  |  | ||||||
|  |   add(String appId, String string) { | ||||||
|  |     var tempIds = content.remove(string); | ||||||
|  |     tempIds ??= []; | ||||||
|  |     tempIds.add(appId); | ||||||
|  |     content.putIfAbsent(string, () => tempIds!); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toString() { | ||||||
|  |     String finalString = ''; | ||||||
|  |     for (var e in content.keys) { | ||||||
|  |       finalString += '$e: ${content[e].toString()}\n\n'; | ||||||
|  |     } | ||||||
|  |     return finalString; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | showError(dynamic e, BuildContext context) { | ||||||
|  |   Provider.of<LogsProvider>(context, listen: false) | ||||||
|  |       .add(e.toString(), level: LogLevels.error); | ||||||
|  |   if (e is String || (e is ObtainiumError && !e.unexpected)) { | ||||||
|  |     ScaffoldMessenger.of(context).showSnackBar( | ||||||
|  |       SnackBar(content: Text(e.toString())), | ||||||
|  |     ); | ||||||
|  |   } else { | ||||||
|  |     showDialog( | ||||||
|  |         context: context, | ||||||
|  |         builder: (BuildContext ctx) { | ||||||
|  |           return AlertDialog( | ||||||
|  |             scrollable: true, | ||||||
|  |             title: Text(e is MultiAppMultiError | ||||||
|  |                 ? tr('someErrors') | ||||||
|  |                 : tr('unexpectedError')), | ||||||
|  |             content: Text(e.toString()), | ||||||
|  |             actions: [ | ||||||
|  |               TextButton( | ||||||
|  |                   onPressed: () { | ||||||
|  |                     Navigator.of(context).pop(null); | ||||||
|  |                   }, | ||||||
|  |                   child: Text(tr('ok'))), | ||||||
|  |             ], | ||||||
|  |           ); | ||||||
|  |         }); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | String list2FriendlyString(List<String> list) { | ||||||
|  |   return list.length == 2 | ||||||
|  |       ? '${list[0]} ${tr('and')} ${list[1]}' | ||||||
|  |       : list | ||||||
|  |           .asMap() | ||||||
|  |           .entries | ||||||
|  |           .map((e) => | ||||||
|  |               e.value + | ||||||
|  |               (e.key == list.length - 1 | ||||||
|  |                   ? '' | ||||||
|  |                   : e.key == list.length - 2 | ||||||
|  |                       ? ', and ' | ||||||
|  |                       : ', ')) | ||||||
|  |           .join(''); | ||||||
|  | } | ||||||
							
								
								
									
										288
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						| @@ -1,117 +1,226 @@ | |||||||
|  | import 'dart:io'; | ||||||
|  | import 'dart:math'; | ||||||
|  |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
| import 'package:flutter_local_notifications/flutter_local_notifications.dart'; | import 'package:obtainium/custom_errors.dart'; | ||||||
| import 'package:obtainium/pages/home.dart'; | import 'package:obtainium/pages/home.dart'; | ||||||
| import 'package:obtainium/services/apps_provider.dart'; | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
| import 'package:obtainium/services/settings_provider.dart'; | import 'package:obtainium/providers/logs_provider.dart'; | ||||||
| import 'package:obtainium/services/source_service.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:provider/provider.dart'; | ||||||
| import 'package:workmanager/workmanager.dart'; |  | ||||||
| import 'package:dynamic_color/dynamic_color.dart'; | import 'package:dynamic_color/dynamic_color.dart'; | ||||||
|  | import 'package:device_info_plus/device_info_plus.dart'; | ||||||
|  | import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; | ||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | // ignore: implementation_imports | ||||||
|  | import 'package:easy_localization/src/easy_localization_controller.dart'; | ||||||
|  | // ignore: implementation_imports | ||||||
|  | import 'package:easy_localization/src/localization.dart'; | ||||||
|  |  | ||||||
|  | const String currentVersion = '0.8.5'; | ||||||
|  | const String currentReleaseTag = | ||||||
|  |     'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||||
|  |  | ||||||
|  | const int bgUpdateCheckAlarmId = 666; | ||||||
|  |  | ||||||
|  | const supportedLocales = [Locale('en')]; | ||||||
|  | const fallbackLocale = Locale('en'); | ||||||
|  | const localeDir = 'assets/translations'; | ||||||
|  |  | ||||||
|  | Future<void> loadTranslations() async { | ||||||
|  |   // See easy_localization/issues/210 | ||||||
|  |   await EasyLocalizationController.initEasyLocation(); | ||||||
|  |   final controller = EasyLocalizationController( | ||||||
|  |     saveLocale: true, | ||||||
|  |     fallbackLocale: fallbackLocale, | ||||||
|  |     supportedLocales: supportedLocales, | ||||||
|  |     assetLoader: const RootBundleAssetLoader(), | ||||||
|  |     useOnlyLangCode: false, | ||||||
|  |     useFallbackTranslations: true, | ||||||
|  |     path: localeDir, | ||||||
|  |     onLoadError: (FlutterError e) { | ||||||
|  |       throw e; | ||||||
|  |     }, | ||||||
|  |   ); | ||||||
|  |   await controller.loadTranslations(); | ||||||
|  |   Localization.load(controller.locale, | ||||||
|  |       translations: controller.translations, | ||||||
|  |       fallbackTranslations: controller.fallbackTranslations); | ||||||
|  | } | ||||||
|  |  | ||||||
| @pragma('vm:entry-point') | @pragma('vm:entry-point') | ||||||
| void backgroundUpdateCheck() { | Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||||
|   Workmanager().executeTask((task, inputData) async { |   WidgetsFlutterBinding.ensureInitialized(); | ||||||
|     var appsProvider = AppsProvider(bg: true); |   await EasyLocalization.ensureInitialized(); | ||||||
|     await appsProvider.notify( |  | ||||||
|         4, |   await loadTranslations(); | ||||||
|         'Checking for Updates', |  | ||||||
|         '', |   LogsProvider logs = LogsProvider(); | ||||||
|         'BG_UPDATE_CHECK', |   logs.add(tr('startedBgUpdateTask')); | ||||||
|         'Checking for Updates', |   int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds']; | ||||||
|         'Transient notification that appears when checking for updates', |   await AndroidAlarmManager.initialize(); | ||||||
|         important: false); |   DateTime? ignoreAfter = ignoreAfterMicroseconds != null | ||||||
|  |       ? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds) | ||||||
|  |       : null; | ||||||
|  |   logs.add(tr('bgUpdateIgnoreAfterIs', args: [ignoreAfter.toString()])); | ||||||
|  |   var notificationsProvider = NotificationsProvider(); | ||||||
|  |   await notificationsProvider.notify(checkingUpdatesNotification); | ||||||
|  |   try { | ||||||
|  |     var appsProvider = AppsProvider(forBGTask: true); | ||||||
|  |     await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id); | ||||||
|  |     await appsProvider.loadApps(); | ||||||
|  |     List<String> existingUpdateIds = | ||||||
|  |         appsProvider.findExistingUpdates(installedOnly: true); | ||||||
|  |     DateTime nextIgnoreAfter = DateTime.now(); | ||||||
|  |     String? err; | ||||||
|     try { |     try { | ||||||
|       await appsProvider.loadApps(); |       logs.add(tr('startedActualBGUpdateCheck')); | ||||||
|       List<App> updates = await appsProvider.checkUpdates(); |       await appsProvider.checkUpdates( | ||||||
|       if (updates.isNotEmpty) { |           ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true); | ||||||
|         String 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.'; |  | ||||||
|         await appsProvider.downloaderNotifications.cancel(2); |  | ||||||
|         await appsProvider.notify( |  | ||||||
|             2, |  | ||||||
|             'Updates Available', |  | ||||||
|             message, |  | ||||||
|             'UPDATES_AVAILABLE', |  | ||||||
|             'Updates Available', |  | ||||||
|             'Notifies the user that updates are available for one or more Apps tracked by Obtainium'); |  | ||||||
|       } |  | ||||||
|       return Future.value(true); |  | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       await appsProvider.downloaderNotifications.cancel(5); |       if (e is RateLimitError || e is SocketException) { | ||||||
|       await appsProvider.notify( |         var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15; | ||||||
|           5, |         logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes, | ||||||
|           'Error Checking for Updates', |             args: [e.runtimeType.toString()])); | ||||||
|           e.toString(), |         AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes), | ||||||
|           'BG_UPDATE_CHECK_ERROR', |             Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: { | ||||||
|           'Error Checking for Updates', |           'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch | ||||||
|           'A notification that shows when background update checking fails', |         }); | ||||||
|           important: false); |       } else { | ||||||
|       return Future.value(false); |         err = e.toString(); | ||||||
|     } finally { |       } | ||||||
|       await appsProvider.downloaderNotifications.cancel(4); |  | ||||||
|     } |     } | ||||||
|   }); |     List<App> newUpdates = appsProvider | ||||||
|  |         .findExistingUpdates(installedOnly: true) | ||||||
|  |         .where((id) => !existingUpdateIds.contains(id)) | ||||||
|  |         .map((e) => appsProvider.apps[e]!.app) | ||||||
|  |         .toList(); | ||||||
|  |  | ||||||
|  |     // TODO: This silent update code doesn't work yet | ||||||
|  |     // List<String> silentlyUpdated = await appsProvider | ||||||
|  |     //     .downloadAndInstallLatestApp( | ||||||
|  |     //         [...newUpdates.map((e) => e.id), ...existingUpdateIds], null); | ||||||
|  |     // if (silentlyUpdated.isNotEmpty) { | ||||||
|  |     //   newUpdates = newUpdates | ||||||
|  |     //       .where((element) => !silentlyUpdated.contains(element.id)) | ||||||
|  |     //       .toList(); | ||||||
|  |     //   notificationsProvider.notify( | ||||||
|  |     //       SilentUpdateNotification( | ||||||
|  |     //           silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()), | ||||||
|  |     //       cancelExisting: true); | ||||||
|  |     // } | ||||||
|  |     logs.add( | ||||||
|  |         plural('bgCheckFoundUpdatesWillNotifyIfNeeded', newUpdates.length)); | ||||||
|  |     if (newUpdates.isNotEmpty) { | ||||||
|  |       notificationsProvider.notify(UpdateNotification(newUpdates)); | ||||||
|  |     } | ||||||
|  |     if (err != null) { | ||||||
|  |       throw err; | ||||||
|  |     } | ||||||
|  |   } catch (e) { | ||||||
|  |     notificationsProvider | ||||||
|  |         .notify(ErrorCheckingUpdatesNotification(e.toString())); | ||||||
|  |   } finally { | ||||||
|  |     logs.add(tr('bgUpdateTaskFinished')); | ||||||
|  |     await notificationsProvider.cancel(checkingUpdatesNotification.id); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| void main() async { | void main() async { | ||||||
|   WidgetsFlutterBinding.ensureInitialized(); |   WidgetsFlutterBinding.ensureInitialized(); | ||||||
|   SystemChrome.setSystemUIOverlayStyle( |   await EasyLocalization.ensureInitialized(); | ||||||
|     const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), |   if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) { | ||||||
|   ); |     SystemChrome.setSystemUIOverlayStyle( | ||||||
|   SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); |       const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), | ||||||
|   Workmanager().initialize( |     ); | ||||||
|     backgroundUpdateCheck, |     SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); | ||||||
|   ); |   } | ||||||
|   await Workmanager().cancelByUniqueName('update-apps-task'); |   await AndroidAlarmManager.initialize(); | ||||||
|   await Workmanager().registerPeriodicTask( |  | ||||||
|       'update-apps-task', 'backgroundUpdateCheck', |  | ||||||
|       frequency: const Duration(minutes: 15), |  | ||||||
|       initialDelay: const Duration(minutes: 15), |  | ||||||
|       constraints: Constraints(networkType: NetworkType.connected)); |  | ||||||
|   runApp(MultiProvider( |   runApp(MultiProvider( | ||||||
|     providers: [ |     providers: [ | ||||||
|       ChangeNotifierProvider(create: (context) => AppsProvider()), |       ChangeNotifierProvider(create: (context) => AppsProvider()), | ||||||
|       ChangeNotifierProvider(create: (context) => SettingsProvider()) |       ChangeNotifierProvider(create: (context) => SettingsProvider()), | ||||||
|  |       Provider(create: (context) => NotificationsProvider()), | ||||||
|  |       Provider(create: (context) => LogsProvider()) | ||||||
|     ], |     ], | ||||||
|     child: const MyApp(), |     child: EasyLocalization( | ||||||
|  |         supportedLocales: supportedLocales, | ||||||
|  |         path: localeDir, | ||||||
|  |         fallbackLocale: fallbackLocale, | ||||||
|  |         child: const Obtainium()), | ||||||
|   )); |   )); | ||||||
| } | } | ||||||
|  |  | ||||||
| var defaultThemeColour = Colors.deepPurple; | var defaultThemeColour = Colors.deepPurple; | ||||||
|  |  | ||||||
| class MyApp extends StatelessWidget { | class Obtainium extends StatefulWidget { | ||||||
|   const MyApp({super.key}); |   const Obtainium({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<Obtainium> createState() => _ObtainiumState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ObtainiumState extends State<Obtainium> { | ||||||
|  |   var existingUpdateInterval = -1; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|  |     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); | ||||||
|  |     AppsProvider appsProvider = context.read<AppsProvider>(); | ||||||
|  |     LogsProvider logs = context.read<LogsProvider>(); | ||||||
|  |  | ||||||
|  |     if (settingsProvider.prefs == null) { | ||||||
|  |       settingsProvider.initializeSettings(); | ||||||
|  |     } else { | ||||||
|  |       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.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); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return DynamicColorBuilder( |     return DynamicColorBuilder( | ||||||
|         builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { |         builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { | ||||||
|       AppsProvider appsProvider = context.read<AppsProvider>(); |       // Decide on a colour/brightness scheme based on OS and user settings | ||||||
|       appsProvider.deleteSavedAPKs(); |  | ||||||
|       // Initialize the settings provider (if needed) and perform first-run actions if needed |  | ||||||
|       SettingsProvider settingsProvider = context.watch<SettingsProvider>(); |  | ||||||
|       if (settingsProvider.prefs == null) { |  | ||||||
|         settingsProvider.initializeSettings().then((_) { |  | ||||||
|           bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); |  | ||||||
|           if (isFirstRun) { |  | ||||||
|             appsProvider.downloaderNotifications |  | ||||||
|                 .resolvePlatformSpecificImplementation< |  | ||||||
|                     AndroidFlutterLocalNotificationsPlugin>()! |  | ||||||
|                 .requestPermission(); |  | ||||||
|             appsProvider.saveApp(App( |  | ||||||
|                 'imranr98_obtainium_github', |  | ||||||
|                 'https://github.com/ImranR98/Obtainium', |  | ||||||
|                 'ImranR98', |  | ||||||
|                 'Obtainium', |  | ||||||
|                 'v0.1.2-beta', // KEEP THIS IN SYNC WITH GITHUB RELEASES |  | ||||||
|                 'v0.1.2-beta', |  | ||||||
|                 '')); |  | ||||||
|           } |  | ||||||
|         }); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       ColorScheme lightColorScheme; |       ColorScheme lightColorScheme; | ||||||
|       ColorScheme darkColorScheme; |       ColorScheme darkColorScheme; | ||||||
|       if (lightDynamic != null && |       if (lightDynamic != null && | ||||||
| @@ -124,9 +233,11 @@ class MyApp extends StatelessWidget { | |||||||
|         darkColorScheme = ColorScheme.fromSeed( |         darkColorScheme = ColorScheme.fromSeed( | ||||||
|             seedColor: defaultThemeColour, brightness: Brightness.dark); |             seedColor: defaultThemeColour, brightness: Brightness.dark); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       return MaterialApp( |       return MaterialApp( | ||||||
|           title: 'Obtainium', |           title: 'Obtainium', | ||||||
|  |           localizationsDelegates: context.localizationDelegates, | ||||||
|  |           supportedLocales: context.supportedLocales, | ||||||
|  |           locale: context.locale, | ||||||
|           theme: ThemeData( |           theme: ThemeData( | ||||||
|               useMaterial3: true, |               useMaterial3: true, | ||||||
|               colorScheme: settingsProvider.theme == ThemeSettings.dark |               colorScheme: settingsProvider.theme == ThemeSettings.dark | ||||||
| @@ -137,7 +248,8 @@ class MyApp extends StatelessWidget { | |||||||
|               useMaterial3: true, |               useMaterial3: true, | ||||||
|               colorScheme: settingsProvider.theme == ThemeSettings.light |               colorScheme: settingsProvider.theme == ThemeSettings.light | ||||||
|                   ? lightColorScheme |                   ? lightColorScheme | ||||||
|                   : darkColorScheme), |                   : darkColorScheme, | ||||||
|  |               fontFamily: 'Metropolis'), | ||||||
|           home: const HomePage()); |           home: const HomePage()); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|   | |||||||
							
								
								
									
										54
									
								
								lib/mass_app_sources/githubstars.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,54 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:http/http.dart'; | ||||||
|  | import 'package:obtainium/app_sources/github.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
|  | class GitHubStars implements MassAppUrlSource { | ||||||
|  |   @override | ||||||
|  |   late String name = tr('githubStarredRepos'); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   late List<String> requiredArgs = [tr('uname')]; | ||||||
|  |  | ||||||
|  |   Future<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions( | ||||||
|  |       String username, int page) async { | ||||||
|  |     Response res = await get(Uri.parse( | ||||||
|  |         'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page')); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       Map<String, String> urlsWithDescriptions = {}; | ||||||
|  |       for (var e in (jsonDecode(res.body) as List<dynamic>)) { | ||||||
|  |         urlsWithDescriptions.addAll({ | ||||||
|  |           e['html_url'] as String: e['description'] != null | ||||||
|  |               ? e['description'] as String | ||||||
|  |               : tr('noDescription') | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |       return urlsWithDescriptions; | ||||||
|  |     } else { | ||||||
|  |       var gh = GitHub(); | ||||||
|  |       gh.rateLimitErrorCheck(res); | ||||||
|  |       throw getObtainiumHttpError(res); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<Map<String, String>> getUrlsWithDescriptions(List<String> args) async { | ||||||
|  |     if (args.length != requiredArgs.length) { | ||||||
|  |       throw ObtainiumError(tr('wrongArgNum')); | ||||||
|  |     } | ||||||
|  |     Map<String, String> urlsWithDescriptions = {}; | ||||||
|  |     var page = 1; | ||||||
|  |     while (true) { | ||||||
|  |       var pageUrls = | ||||||
|  |           await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++); | ||||||
|  |       urlsWithDescriptions.addAll(pageUrls); | ||||||
|  |       if (pageUrls.length < 100) { | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return urlsWithDescriptions; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,8 +1,17 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:obtainium/components/custom_app_bar.dart'; | ||||||
|  | import 'package:obtainium/components/generated_form.dart'; | ||||||
|  | import 'package:obtainium/components/generated_form_modal.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
| import 'package:obtainium/pages/app.dart'; | import 'package:obtainium/pages/app.dart'; | ||||||
| import 'package:obtainium/services/apps_provider.dart'; | import 'package:obtainium/pages/import_export.dart'; | ||||||
| import 'package:obtainium/services/source_service.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:provider/provider.dart'; | ||||||
|  | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| class AddAppPage extends StatefulWidget { | class AddAppPage extends StatefulWidget { | ||||||
|   const AddAppPage({super.key}); |   const AddAppPage({super.key}); | ||||||
| @@ -12,79 +21,385 @@ class AddAppPage extends StatefulWidget { | |||||||
| } | } | ||||||
|  |  | ||||||
| class _AddAppPageState extends State<AddAppPage> { | class _AddAppPageState extends State<AddAppPage> { | ||||||
|   final _formKey = GlobalKey<FormState>(); |  | ||||||
|   final urlInputController = TextEditingController(); |  | ||||||
|   bool gettingAppInfo = false; |   bool gettingAppInfo = false; | ||||||
|  |  | ||||||
|  |   String userInput = ''; | ||||||
|  |   String searchQuery = ''; | ||||||
|  |   AppSource? pickedSource; | ||||||
|  |   List<String> sourceSpecificAdditionalData = []; | ||||||
|  |   bool sourceSpecificDataIsValid = true; | ||||||
|  |   List<String> otherAdditionalData = []; | ||||||
|  |   bool otherAdditionalDataIsValid = true; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Center( |     SourceProvider sourceProvider = SourceProvider(); | ||||||
|         child: Form( |     AppsProvider appsProvider = context.read<AppsProvider>(); | ||||||
|       key: _formKey, |  | ||||||
|       child: Column( |     changeUserInput(String input, bool valid, bool isBuilding) { | ||||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, |       userInput = input; | ||||||
|         children: [ |       fn() { | ||||||
|           const Spacer(), |         var source = valid ? sourceProvider.getSource(userInput) : null; | ||||||
|           Padding( |         if (pickedSource != source) { | ||||||
|               padding: const EdgeInsets.symmetric(horizontal: 16.0), |           pickedSource = source; | ||||||
|               child: TextFormField( |           sourceSpecificAdditionalData = | ||||||
|                 decoration: const InputDecoration( |               source != null ? source.additionalSourceAppSpecificDefaults : []; | ||||||
|                     hintText: 'https://github.com/Author/Project', |           sourceSpecificDataIsValid = source != null | ||||||
|                     helperText: 'Enter the App source URL'), |               ? sourceProvider.ifSourceAppsRequireAdditionalData(source) | ||||||
|                 controller: urlInputController, |               : true; | ||||||
|                 validator: (value) { |         } | ||||||
|                   if (value == null || |       } | ||||||
|                       value.isEmpty || |  | ||||||
|                       Uri.tryParse(value) == null) { |       if (isBuilding) { | ||||||
|                     return 'Please enter a supported source URL'; |         fn(); | ||||||
|                   } |       } else { | ||||||
|                   return null; |         setState(() { | ||||||
|                 }, |           fn(); | ||||||
|               )), |         }); | ||||||
|           Padding( |       } | ||||||
|             padding: |     } | ||||||
|                 const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0), |  | ||||||
|             child: ElevatedButton( |     addApp({bool resetUserInputAfter = false}) async { | ||||||
|               onPressed: gettingAppInfo |       setState(() { | ||||||
|                   ? null |         gettingAppInfo = true; | ||||||
|                   : () { |       }); | ||||||
|                       if (_formKey.currentState!.validate()) { |       var settingsProvider = context.read<SettingsProvider>(); | ||||||
|                         setState(() { |       () async { | ||||||
|                           gettingAppInfo = true; |         var userPickedTrackOnly = findGeneratedFormValueByKey( | ||||||
|                         }); |                 pickedSource!.additionalAppSpecificSourceAgnosticFormItems, | ||||||
|                         SourceService() |                 otherAdditionalData, | ||||||
|                             .getApp(urlInputController.value.text) |                 'trackOnlyFormItemKey') == | ||||||
|                             .then((app) { |             'true'; | ||||||
|                           var appsProvider = context.read<AppsProvider>(); |         var cont = true; | ||||||
|                           if (appsProvider.apps.containsKey(app.id)) { |         if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) && | ||||||
|                             throw 'App already added'; |             await showDialog( | ||||||
|                           } |                     context: context, | ||||||
|                           appsProvider.saveApp(app).then((_) { |                     builder: (BuildContext ctx) { | ||||||
|                             urlInputController.clear(); |                       return GeneratedFormModal( | ||||||
|                             Navigator.push( |                         title: tr('xIsTrackOnly', args: [ | ||||||
|                                 context, |                           pickedSource!.enforceTrackOnly | ||||||
|                                 MaterialPageRoute( |                               ? tr('source') | ||||||
|                                     builder: (context) => |                               : tr('app') | ||||||
|                                         AppPage(appId: app.id))); |                         ]), | ||||||
|                           }); |                         items: const [], | ||||||
|                         }).catchError((e) { |                         defaultValues: const [], | ||||||
|                           ScaffoldMessenger.of(context).showSnackBar( |                         message: | ||||||
|                             SnackBar(content: Text(e.toString())), |                             '${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}', | ||||||
|                           ); |                       ); | ||||||
|                         }).whenComplete(() { |                     }) == | ||||||
|                           setState(() { |                 null) { | ||||||
|                             gettingAppInfo = false; |           cont = false; | ||||||
|                           }); |         } | ||||||
|                         }); |         if (cont) { | ||||||
|                       } |           HapticFeedback.selectionClick(); | ||||||
|                     }, |           var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly; | ||||||
|               child: const Text('Add'), |           App app = await sourceProvider.getApp( | ||||||
|             ), |               pickedSource!, userInput, sourceSpecificAdditionalData, | ||||||
|           ), |               trackOnly: trackOnly); | ||||||
|           const Spacer(), |           if (!trackOnly) { | ||||||
|           if (gettingAppInfo) const LinearProgressIndicator(), |             await settingsProvider.getInstallPermission(); | ||||||
|         ], |           } | ||||||
|       ), |           // Only download the APK here if you need to for the package ID | ||||||
|     )); |           if (sourceProvider.isTempId(app.id) && !app.trackOnly) { | ||||||
|  |             // ignore: use_build_context_synchronously | ||||||
|  |             var apkUrl = await appsProvider.confirmApkUrl(app, context); | ||||||
|  |             if (apkUrl == null) { | ||||||
|  |               throw ObtainiumError(tr('cancelled')); | ||||||
|  |             } | ||||||
|  |             app.preferredApkIndex = app.apkUrls.indexOf(apkUrl); | ||||||
|  |             // ignore: use_build_context_synchronously | ||||||
|  |             var downloadedApk = await appsProvider.downloadApp(app, context); | ||||||
|  |             app.id = downloadedApk.appId; | ||||||
|  |           } | ||||||
|  |           if (appsProvider.apps.containsKey(app.id)) { | ||||||
|  |             throw ObtainiumError(tr('appAlreadyAdded')); | ||||||
|  |           } | ||||||
|  |           if (app.trackOnly) { | ||||||
|  |             app.installedVersion = app.latestVersion; | ||||||
|  |           } | ||||||
|  |           await appsProvider.saveApps([app]); | ||||||
|  |  | ||||||
|  |           return app; | ||||||
|  |         } | ||||||
|  |       }() | ||||||
|  |           .then((app) { | ||||||
|  |         if (app != null) { | ||||||
|  |           Navigator.push(context, | ||||||
|  |               MaterialPageRoute(builder: (context) => AppPage(appId: app.id))); | ||||||
|  |         } | ||||||
|  |       }).catchError((e) { | ||||||
|  |         showError(e, context); | ||||||
|  |       }).whenComplete(() { | ||||||
|  |         setState(() { | ||||||
|  |           gettingAppInfo = false; | ||||||
|  |           if (resetUserInputAfter) { | ||||||
|  |             changeUserInput('', false, true); | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return Scaffold( | ||||||
|  |         backgroundColor: Theme.of(context).colorScheme.surface, | ||||||
|  |         body: CustomScrollView(slivers: <Widget>[ | ||||||
|  |           CustomAppBar(title: tr('addApp')), | ||||||
|  |           SliverFillRemaining( | ||||||
|  |             child: Padding( | ||||||
|  |                 padding: const EdgeInsets.all(16), | ||||||
|  |                 child: Column( | ||||||
|  |                     crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |                     children: [ | ||||||
|  |                       Row( | ||||||
|  |                         children: [ | ||||||
|  |                           Expanded( | ||||||
|  |                               child: GeneratedForm( | ||||||
|  |                                   items: [ | ||||||
|  |                                     [ | ||||||
|  |                                       GeneratedFormItem( | ||||||
|  |                                           label: tr('appSourceURL'), | ||||||
|  |                                           additionalValidators: [ | ||||||
|  |                                             (value) { | ||||||
|  |                                               try { | ||||||
|  |                                                 sourceProvider | ||||||
|  |                                                     .getSource(value ?? '') | ||||||
|  |                                                     .standardizeURL( | ||||||
|  |                                                         preStandardizeUrl( | ||||||
|  |                                                             value ?? '')); | ||||||
|  |                                               } catch (e) { | ||||||
|  |                                                 return e is String | ||||||
|  |                                                     ? e | ||||||
|  |                                                     : e is ObtainiumError | ||||||
|  |                                                         ? e.toString() | ||||||
|  |                                                         : tr('error'); | ||||||
|  |                                               } | ||||||
|  |                                               return null; | ||||||
|  |                                             } | ||||||
|  |                                           ]) | ||||||
|  |                                     ] | ||||||
|  |                                   ], | ||||||
|  |                                   onValueChanges: (values, valid, isBuilding) { | ||||||
|  |                                     changeUserInput( | ||||||
|  |                                         values[0], valid, isBuilding); | ||||||
|  |                                   }, | ||||||
|  |                                   defaultValues: const [])), | ||||||
|  |                           const SizedBox( | ||||||
|  |                             width: 16, | ||||||
|  |                           ), | ||||||
|  |                           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!.additionalSourceAppSpecificDefaults | ||||||
|  |                                   .isNotEmpty || | ||||||
|  |                               pickedSource! | ||||||
|  |                                   .additionalAppSpecificSourceAgnosticFormItems | ||||||
|  |                                   .where((e) => pickedSource!.enforceTrackOnly | ||||||
|  |                                       ? e.key != 'trackOnlyFormItemKey' | ||||||
|  |                                       : true) | ||||||
|  |                                   .map((e) => [e]) | ||||||
|  |                                   .isNotEmpty)) | ||||||
|  |                         Column( | ||||||
|  |                           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |                           children: [ | ||||||
|  |                             const Divider( | ||||||
|  |                               height: 64, | ||||||
|  |                             ), | ||||||
|  |                             Text( | ||||||
|  |                                 tr('additionalOptsFor', args: [ | ||||||
|  |                                   pickedSource?.runtimeType.toString() ?? | ||||||
|  |                                       tr('source') | ||||||
|  |                                 ]), | ||||||
|  |                                 style: TextStyle( | ||||||
|  |                                     color: | ||||||
|  |                                         Theme.of(context).colorScheme.primary)), | ||||||
|  |                             const SizedBox( | ||||||
|  |                               height: 16, | ||||||
|  |                             ), | ||||||
|  |                             if (pickedSource! | ||||||
|  |                                 .additionalSourceAppSpecificFormItems | ||||||
|  |                                 .isNotEmpty) | ||||||
|  |                               GeneratedForm( | ||||||
|  |                                   items: pickedSource! | ||||||
|  |                                       .additionalSourceAppSpecificFormItems, | ||||||
|  |                                   onValueChanges: (values, valid, isBuilding) { | ||||||
|  |                                     if (isBuilding) { | ||||||
|  |                                       sourceSpecificAdditionalData = values; | ||||||
|  |                                       sourceSpecificDataIsValid = valid; | ||||||
|  |                                     } else { | ||||||
|  |                                       setState(() { | ||||||
|  |                                         sourceSpecificAdditionalData = values; | ||||||
|  |                                         sourceSpecificDataIsValid = valid; | ||||||
|  |                                       }); | ||||||
|  |                                     } | ||||||
|  |                                   }, | ||||||
|  |                                   defaultValues: pickedSource! | ||||||
|  |                                       .additionalSourceAppSpecificDefaults), | ||||||
|  |                             if (pickedSource! | ||||||
|  |                                 .additionalAppSpecificSourceAgnosticDefaults | ||||||
|  |                                 .isNotEmpty) | ||||||
|  |                               const SizedBox( | ||||||
|  |                                 height: 8, | ||||||
|  |                               ), | ||||||
|  |                             GeneratedForm( | ||||||
|  |                                 items: pickedSource! | ||||||
|  |                                     .additionalAppSpecificSourceAgnosticFormItems | ||||||
|  |                                     .where((e) => pickedSource!.enforceTrackOnly | ||||||
|  |                                         ? e.key != 'trackOnlyFormItemKey' | ||||||
|  |                                         : true) | ||||||
|  |                                     .map((e) => [e]) | ||||||
|  |                                     .toList(), | ||||||
|  |                                 onValueChanges: (values, valid, isBuilding) { | ||||||
|  |                                   if (isBuilding) { | ||||||
|  |                                     otherAdditionalData = values; | ||||||
|  |                                     otherAdditionalDataIsValid = valid; | ||||||
|  |                                   } else { | ||||||
|  |                                     setState(() { | ||||||
|  |                                       otherAdditionalData = values; | ||||||
|  |                                       otherAdditionalDataIsValid = valid; | ||||||
|  |                                     }); | ||||||
|  |                                   } | ||||||
|  |                                 }, | ||||||
|  |                                 defaultValues: pickedSource! | ||||||
|  |                                     .additionalAppSpecificSourceAgnosticDefaults), | ||||||
|  |                           ], | ||||||
|  |                         ) | ||||||
|  |                       else | ||||||
|  |                         Expanded( | ||||||
|  |                             child: Column( | ||||||
|  |                                 crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                                 mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |                                 children: [ | ||||||
|  |                               const SizedBox( | ||||||
|  |                                 height: 48, | ||||||
|  |                               ), | ||||||
|  |                               Text( | ||||||
|  |                                 tr('supportedSourcesBelow'), | ||||||
|  |                               ), | ||||||
|  |                               const SizedBox( | ||||||
|  |                                 height: 8, | ||||||
|  |                               ), | ||||||
|  |                               ...sourceProvider.sources | ||||||
|  |                                   .map((e) => GestureDetector( | ||||||
|  |                                       onTap: () { | ||||||
|  |                                         launchUrlString('https://${e.host}', | ||||||
|  |                                             mode: | ||||||
|  |                                                 LaunchMode.externalApplication); | ||||||
|  |                                       }, | ||||||
|  |                                       child: Text( | ||||||
|  |                                         '${e.runtimeType.toString()}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}', | ||||||
|  |                                         style: const TextStyle( | ||||||
|  |                                             decoration: | ||||||
|  |                                                 TextDecoration.underline, | ||||||
|  |                                             fontStyle: FontStyle.italic), | ||||||
|  |                                       ))) | ||||||
|  |                                   .toList() | ||||||
|  |                             ])), | ||||||
|  |                       const SizedBox( | ||||||
|  |                         height: 8, | ||||||
|  |                       ), | ||||||
|  |                     ])), | ||||||
|  |           ) | ||||||
|  |         ])); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,5 +1,11 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:obtainium/services/apps_provider.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'; | ||||||
|  | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
| import 'package:webview_flutter/webview_flutter.dart'; | import 'package:webview_flutter/webview_flutter.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  |  | ||||||
| @@ -13,20 +19,115 @@ class AppPage extends StatefulWidget { | |||||||
| } | } | ||||||
|  |  | ||||||
| class _AppPageState extends State<AppPage> { | class _AppPageState extends State<AppPage> { | ||||||
|  |   AppInMemory? prevApp; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     var appsProvider = context.watch<AppsProvider>(); |     var appsProvider = context.watch<AppsProvider>(); | ||||||
|  |     var settingsProvider = context.watch<SettingsProvider>(); | ||||||
|  |     getUpdate(String id) { | ||||||
|  |       appsProvider.checkUpdate(id).catchError((e) { | ||||||
|  |         showError(e, context); | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var sourceProvider = SourceProvider(); | ||||||
|     AppInMemory? app = appsProvider.apps[widget.appId]; |     AppInMemory? app = appsProvider.apps[widget.appId]; | ||||||
|     if (app?.app.installedVersion != null) { |     var source = app != null ? sourceProvider.getSource(app.app.url) : null; | ||||||
|       appsProvider.getUpdate(app!.app.id); |     if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) { | ||||||
|  |       prevApp = app; | ||||||
|  |       getUpdate(app.app.id); | ||||||
|     } |     } | ||||||
|     return Scaffold( |     return Scaffold( | ||||||
|       appBar: AppBar( |       appBar: settingsProvider.showAppWebpage ? AppBar() : null, | ||||||
|         title: Text('${app?.app.author}/${app?.app.name}'), |       backgroundColor: Theme.of(context).colorScheme.surface, | ||||||
|       ), |       body: RefreshIndicator( | ||||||
|       body: WebView( |           child: settingsProvider.showAppWebpage | ||||||
|         initialUrl: app?.app.url, |               ? WebView( | ||||||
|       ), |                   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()}'}${app?.app.enhancedVersionDetection == true ? '\n\nThis App has enhanced version detection.' : ''}', | ||||||
|  |                           textAlign: TextAlign.center, | ||||||
|  |                           style: const TextStyle( | ||||||
|  |                               fontStyle: FontStyle.italic, fontSize: 12), | ||||||
|  |                         ) | ||||||
|  |                       ], | ||||||
|  |                     )), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|  |           onRefresh: () async { | ||||||
|  |             if (app != null) { | ||||||
|  |               getUpdate(app.app.id); | ||||||
|  |             } | ||||||
|  |           }), | ||||||
|       bottomSheet: Padding( |       bottomSheet: Padding( | ||||||
|           padding: EdgeInsets.fromLTRB( |           padding: EdgeInsets.fromLTRB( | ||||||
|               0, 0, 0, MediaQuery.of(context).padding.bottom), |               0, 0, 0, MediaQuery.of(context).padding.bottom), | ||||||
| @@ -38,22 +139,119 @@ class _AppPageState extends State<AppPage> { | |||||||
|                   child: Row( |                   child: Row( | ||||||
|                       mainAxisAlignment: MainAxisAlignment.spaceEvenly, |                       mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||||
|                       children: [ |                       children: [ | ||||||
|  |                         if (app?.app.installedVersion != null && | ||||||
|  |                             app?.app.trackOnly == false && | ||||||
|  |                             app?.app.installedVersion != | ||||||
|  |                                 app?.app.latestVersion && | ||||||
|  |                             app?.app.enhancedVersionDetection != true) | ||||||
|  |                           IconButton( | ||||||
|  |                               onPressed: app?.downloadProgress != null | ||||||
|  |                                   ? null | ||||||
|  |                                   : () { | ||||||
|  |                                       showDialog( | ||||||
|  |                                           context: context, | ||||||
|  |                                           builder: (BuildContext ctx) { | ||||||
|  |                                             return AlertDialog( | ||||||
|  |                                               title: const Text( | ||||||
|  |                                                   'App Already up to Date?'), | ||||||
|  |                                               actions: [ | ||||||
|  |                                                 TextButton( | ||||||
|  |                                                     onPressed: () { | ||||||
|  |                                                       Navigator.of(context) | ||||||
|  |                                                           .pop(); | ||||||
|  |                                                     }, | ||||||
|  |                                                     child: const Text('No')), | ||||||
|  |                                                 TextButton( | ||||||
|  |                                                     onPressed: () { | ||||||
|  |                                                       HapticFeedback | ||||||
|  |                                                           .selectionClick(); | ||||||
|  |                                                       var updatedApp = app?.app; | ||||||
|  |                                                       if (updatedApp != null) { | ||||||
|  |                                                         updatedApp | ||||||
|  |                                                                 .installedVersion = | ||||||
|  |                                                             updatedApp | ||||||
|  |                                                                 .latestVersion; | ||||||
|  |                                                         appsProvider.saveApps( | ||||||
|  |                                                             [updatedApp]); | ||||||
|  |                                                       } | ||||||
|  |                                                       Navigator.of(context) | ||||||
|  |                                                           .pop(); | ||||||
|  |                                                     }, | ||||||
|  |                                                     child: const Text( | ||||||
|  |                                                         'Yes, Mark as Updated')) | ||||||
|  |                                               ], | ||||||
|  |                                             ); | ||||||
|  |                                           }); | ||||||
|  |                                     }, | ||||||
|  |                               tooltip: 'Mark as Updated', | ||||||
|  |                               icon: const Icon(Icons.done)), | ||||||
|  |                         if (source != null && | ||||||
|  |                             source.additionalSourceAppSpecificFormItems | ||||||
|  |                                 .isNotEmpty) | ||||||
|  |                           IconButton( | ||||||
|  |                               onPressed: app?.downloadProgress != null | ||||||
|  |                                   ? null | ||||||
|  |                                   : () { | ||||||
|  |                                       showDialog<List<String>>( | ||||||
|  |                                           context: context, | ||||||
|  |                                           builder: (BuildContext ctx) { | ||||||
|  |                                             return GeneratedFormModal( | ||||||
|  |                                                 title: 'Additional Options', | ||||||
|  |                                                 items: source | ||||||
|  |                                                     .additionalSourceAppSpecificFormItems, | ||||||
|  |                                                 defaultValues: app != null | ||||||
|  |                                                     ? app.app.additionalData | ||||||
|  |                                                     : source | ||||||
|  |                                                         .additionalSourceAppSpecificDefaults); | ||||||
|  |                                           }).then((values) { | ||||||
|  |                                         if (app != null && values != null) { | ||||||
|  |                                           var changedApp = app.app; | ||||||
|  |                                           changedApp.additionalData = values; | ||||||
|  |                                           appsProvider.saveApps( | ||||||
|  |                                               [changedApp]).then((value) { | ||||||
|  |                                             getUpdate(changedApp.id); | ||||||
|  |                                           }); | ||||||
|  |                                         } | ||||||
|  |                                       }); | ||||||
|  |                                     }, | ||||||
|  |                               tooltip: 'Additional Options', | ||||||
|  |                               icon: const Icon(Icons.settings)), | ||||||
|  |                         const SizedBox(width: 16.0), | ||||||
|                         Expanded( |                         Expanded( | ||||||
|                             child: ElevatedButton( |                             child: ElevatedButton( | ||||||
|                                 onPressed: (app?.app.installedVersion == null || |                                 onPressed: (app?.app.installedVersion == null || | ||||||
|                                             appsProvider |                                             app?.app.installedVersion != | ||||||
|                                                 .checkAppObjectForUpdate( |                                                 app?.app.latestVersion) && | ||||||
|                                                     app!.app)) && |                                         !appsProvider.areDownloadsRunning() | ||||||
|                                         app?.downloadProgress == null |  | ||||||
|                                     ? () { |                                     ? () { | ||||||
|                                         appsProvider |                                         HapticFeedback.heavyImpact(); | ||||||
|                                             .downloadAndInstallLatestApp( |                                         () async { | ||||||
|                                                 app!.app.id); |                                           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, |                                     : null, | ||||||
|                                 child: Text(app?.app.installedVersion == null |                                 child: Text(app?.app.installedVersion == null | ||||||
|                                     ? 'Install' |                                     ? app?.app.trackOnly == false | ||||||
|                                     : 'Update'))), |                                         ? 'Install' | ||||||
|  |                                         : 'Mark Installed' | ||||||
|  |                                     : app?.app.trackOnly == false | ||||||
|  |                                         ? 'Update' | ||||||
|  |                                         : 'Mark Updated'))), | ||||||
|                         const SizedBox(width: 16.0), |                         const SizedBox(width: 16.0), | ||||||
|                         ElevatedButton( |                         ElevatedButton( | ||||||
|                           onPressed: app?.downloadProgress != null |                           onPressed: app?.downloadProgress != null | ||||||
| @@ -65,13 +263,14 @@ class _AppPageState extends State<AppPage> { | |||||||
|                                         return AlertDialog( |                                         return AlertDialog( | ||||||
|                                           title: const Text('Remove App?'), |                                           title: const Text('Remove App?'), | ||||||
|                                           content: Text( |                                           content: Text( | ||||||
|                                               'This will remove \'${app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'), |                                               'This will remove \'${app?.installedInfo?.name ?? app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'), | ||||||
|                                           actions: [ |                                           actions: [ | ||||||
|                                             TextButton( |                                             TextButton( | ||||||
|                                                 onPressed: () { |                                                 onPressed: () { | ||||||
|                                                   appsProvider |                                                   HapticFeedback | ||||||
|                                                       .removeApp(app!.app.id) |                                                       .selectionClick(); | ||||||
|                                                       .then((_) { |                                                   appsProvider.removeApps( | ||||||
|  |                                                       [app!.app.id]).then((_) { | ||||||
|                                                     int count = 0; |                                                     int count = 0; | ||||||
|                                                     Navigator.of(context) |                                                     Navigator.of(context) | ||||||
|                                                         .popUntil((_) => |                                                         .popUntil((_) => | ||||||
| @@ -89,8 +288,10 @@ class _AppPageState extends State<AppPage> { | |||||||
|                                       }); |                                       }); | ||||||
|                                 }, |                                 }, | ||||||
|                           style: TextButton.styleFrom( |                           style: TextButton.styleFrom( | ||||||
|                               foregroundColor: Theme.of(context).errorColor, |                               foregroundColor: | ||||||
|                               surfaceTintColor: Theme.of(context).errorColor), |                                   Theme.of(context).colorScheme.error, | ||||||
|  |                               surfaceTintColor: | ||||||
|  |                                   Theme.of(context).colorScheme.error), | ||||||
|                           child: const Text('Remove'), |                           child: const Text('Remove'), | ||||||
|                         ), |                         ), | ||||||
|                       ])), |                       ])), | ||||||
|   | |||||||
| @@ -1,75 +1,752 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:obtainium/components/custom_app_bar.dart'; | ||||||
|  | import 'package:obtainium/components/generated_form.dart'; | ||||||
|  | import 'package:obtainium/components/generated_form_modal.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
| import 'package:obtainium/pages/app.dart'; | import 'package:obtainium/pages/app.dart'; | ||||||
| import 'package:obtainium/services/apps_provider.dart'; | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:share_plus/share_plus.dart'; | ||||||
|  | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| class AppsPage extends StatefulWidget { | class AppsPage extends StatefulWidget { | ||||||
|   const AppsPage({super.key}); |   const AppsPage({super.key}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<AppsPage> createState() => _AppsPageState(); |   State<AppsPage> createState() => AppsPageState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _AppsPageState extends State<AppsPage> { | class AppsPageState extends State<AppsPage> { | ||||||
|  |   AppsFilter? filter; | ||||||
|  |   var updatesOnlyFilter = | ||||||
|  |       AppsFilter(includeUptodate: false, includeNonInstalled: false); | ||||||
|  |   Set<App> selectedApps = {}; | ||||||
|  |   DateTime? refreshingSince; | ||||||
|  |  | ||||||
|  |   clearSelected() { | ||||||
|  |     if (selectedApps.isNotEmpty) { | ||||||
|  |       setState(() { | ||||||
|  |         selectedApps.clear(); | ||||||
|  |       }); | ||||||
|  |       return true; | ||||||
|  |     } | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   selectThese(List<App> apps) { | ||||||
|  |     if (selectedApps.isEmpty) { | ||||||
|  |       setState(() { | ||||||
|  |         for (var a in apps) { | ||||||
|  |           selectedApps.add(a); | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     var appsProvider = context.watch<AppsProvider>(); |     var appsProvider = context.watch<AppsProvider>(); | ||||||
|     appsProvider.checkUpdates(); |     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!.includeUptodate)) { | ||||||
|  |           return false; | ||||||
|  |         } | ||||||
|  |         if (app.app.installedVersion == null && | ||||||
|  |             !(filter!.includeNonInstalled)) { | ||||||
|  |           return false; | ||||||
|  |         } | ||||||
|  |         if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) { | ||||||
|  |           return true; | ||||||
|  |         } | ||||||
|  |         List<String> nameTokens = filter!.nameFilter | ||||||
|  |             .split(' ') | ||||||
|  |             .where((element) => element.trim().isNotEmpty) | ||||||
|  |             .toList(); | ||||||
|  |         List<String> authorTokens = filter!.authorFilter | ||||||
|  |             .split(' ') | ||||||
|  |             .where((element) => element.trim().isNotEmpty) | ||||||
|  |             .toList(); | ||||||
|  |  | ||||||
|  |         for (var t in nameTokens) { | ||||||
|  |           var name = app.installedInfo?.name ?? app.app.name; | ||||||
|  |           if (!name.toLowerCase().contains(t.toLowerCase())) { | ||||||
|  |             return false; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |         for (var t in authorTokens) { | ||||||
|  |           if (!app.app.author.toLowerCase().contains(t.toLowerCase())) { | ||||||
|  |             return false; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |         return true; | ||||||
|  |       }).toList(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     sortedApps.sort((a, b) { | ||||||
|  |       var nameA = a.installedInfo?.name ?? a.app.name; | ||||||
|  |       var nameB = b.installedInfo?.name ?? b.app.name; | ||||||
|  |       int result = 0; | ||||||
|  |       if (settingsProvider.sortColumn == SortColumnSettings.authorName) { | ||||||
|  |         result = (a.app.author + nameA).compareTo(b.app.author + nameB); | ||||||
|  |       } else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) { | ||||||
|  |         result = (nameA + a.app.author).compareTo(nameB + b.app.author); | ||||||
|  |       } | ||||||
|  |       return result; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     if (settingsProvider.sortOrder == SortOrderSettings.descending) { | ||||||
|  |       sortedApps = sortedApps.reversed.toList(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true); | ||||||
|  |  | ||||||
|  |     var existingUpdateIdsAllOrSelected = existingUpdates | ||||||
|  |         .where((element) => selectedApps.isEmpty | ||||||
|  |             ? sortedApps.where((a) => a.app.id == element).isNotEmpty | ||||||
|  |             : selectedApps.map((e) => e.id).contains(element)) | ||||||
|  |         .toList(); | ||||||
|  |     var newInstallIdsAllOrSelected = appsProvider | ||||||
|  |         .findExistingUpdates(nonInstalledOnly: true) | ||||||
|  |         .where((element) => selectedApps.isEmpty | ||||||
|  |             ? sortedApps.where((a) => a.app.id == element).isNotEmpty | ||||||
|  |             : selectedApps.map((e) => e.id).contains(element)) | ||||||
|  |         .toList(); | ||||||
|  |  | ||||||
|  |     List<String> trackOnlyUpdateIdsAllOrSelected = []; | ||||||
|  |     existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) { | ||||||
|  |       if (appsProvider.apps[id]!.app.trackOnly) { | ||||||
|  |         trackOnlyUpdateIdsAllOrSelected.add(id); | ||||||
|  |         return false; | ||||||
|  |       } | ||||||
|  |       return true; | ||||||
|  |     }).toList(); | ||||||
|  |     newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) { | ||||||
|  |       if (appsProvider.apps[id]!.app.trackOnly) { | ||||||
|  |         trackOnlyUpdateIdsAllOrSelected.add(id); | ||||||
|  |         return false; | ||||||
|  |       } | ||||||
|  |       return true; | ||||||
|  |     }).toList(); | ||||||
|  |  | ||||||
|  |     if (settingsProvider.pinUpdates) { | ||||||
|  |       var temp = []; | ||||||
|  |       sortedApps = sortedApps.where((sa) { | ||||||
|  |         if (existingUpdates.contains(sa.app.id)) { | ||||||
|  |           temp.add(sa); | ||||||
|  |           return false; | ||||||
|  |         } | ||||||
|  |         return true; | ||||||
|  |       }).toList(); | ||||||
|  |       sortedApps = [...temp, ...sortedApps]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var tempPinned = []; | ||||||
|  |     var tempNotPinned = []; | ||||||
|  |     for (var a in sortedApps) { | ||||||
|  |       if (a.app.pinned) { | ||||||
|  |         tempPinned.add(a); | ||||||
|  |       } else { | ||||||
|  |         tempNotPinned.add(a); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     sortedApps = [...tempPinned, ...tempNotPinned]; | ||||||
|  |  | ||||||
|     return Scaffold( |     return Scaffold( | ||||||
|         floatingActionButton: existingUpdateAppIds.isEmpty |       backgroundColor: Theme.of(context).colorScheme.surface, | ||||||
|             ? null |       body: RefreshIndicator( | ||||||
|             : ElevatedButton.icon( |           onRefresh: () { | ||||||
|                 onPressed: appsProvider.apps.values |             HapticFeedback.lightImpact(); | ||||||
|                         .where((element) => element.downloadProgress != null) |             setState(() { | ||||||
|                         .isNotEmpty |               refreshingSince = DateTime.now(); | ||||||
|                     ? null |             }); | ||||||
|                     : () { |             return appsProvider.checkUpdates().catchError((e) { | ||||||
|                         for (var e in existingUpdateAppIds) { |               showError(e, context); | ||||||
|                           appsProvider.downloadAndInstallLatestApp(e); |             }).whenComplete(() { | ||||||
|                         } |               setState(() { | ||||||
|                       }, |                 refreshingSince = null; | ||||||
|                 icon: const Icon(Icons.update), |               }); | ||||||
|                 label: const Text('Update All')), |             }); | ||||||
|         body: Center( |           }, | ||||||
|           child: appsProvider.loadingApps |           child: CustomScrollView(slivers: <Widget>[ | ||||||
|               ? const CircularProgressIndicator() |             CustomAppBar(title: tr('appsString')), | ||||||
|               : appsProvider.apps.isEmpty |             if (appsProvider.loadingApps || sortedApps.isEmpty) | ||||||
|                   ? Text( |               SliverFillRemaining( | ||||||
|                       'No Apps', |                   child: Center( | ||||||
|                       style: Theme.of(context).textTheme.headline4, |                       child: appsProvider.loadingApps | ||||||
|                     ) |                           ? const CircularProgressIndicator() | ||||||
|                   : RefreshIndicator( |                           : Text( | ||||||
|                       onRefresh: appsProvider.checkUpdates, |                               appsProvider.apps.isEmpty | ||||||
|                       child: ListView( |                                   ? tr('noApps') | ||||||
|                         children: appsProvider.apps.values |                                   : tr('noAppsForFilter'), | ||||||
|                             .map( |                               style: Theme.of(context).textTheme.headlineMedium, | ||||||
|                               (e) => ListTile( |                               textAlign: TextAlign.center, | ||||||
|                                 title: Text('${e.app.author}/${e.app.name}'), |                             ))), | ||||||
|                                 subtitle: Text( |             if (refreshingSince != null) | ||||||
|                                     e.app.installedVersion ?? 'Not Installed'), |               SliverToBoxAdapter( | ||||||
|                                 trailing: e.downloadProgress != null |                 child: LinearProgressIndicator( | ||||||
|                                     ? Text( |                   value: appsProvider.apps.values | ||||||
|                                         'Downloading - ${e.downloadProgress!.toInt()}%') |                           .where((element) => !(element.app.lastUpdateCheck | ||||||
|                                     : (e.app.installedVersion != null && |                                   ?.isBefore(refreshingSince!) ?? | ||||||
|                                             e.app.installedVersion != |                               true)) | ||||||
|                                                 e.app.latestVersion |                           .length / | ||||||
|                                         ? const Text('Update Available') |                       appsProvider.apps.length, | ||||||
|                                         : null), |                 ), | ||||||
|                                 onTap: () { |               ), | ||||||
|                                   Navigator.push( |             SliverList( | ||||||
|                                     context, |                 delegate: SliverChildBuilderDelegate( | ||||||
|                                     MaterialPageRoute( |                     (BuildContext context, int index) { | ||||||
|                                         builder: (context) => |               String? changesUrl = SourceProvider() | ||||||
|                                             AppPage(appId: e.app.id)), |                   .getSource(sortedApps[index].app.url) | ||||||
|                                   ); |                   .changeLogPageFromStandardUrl(sortedApps[index].app.url); | ||||||
|                                 }, |               return ListTile( | ||||||
|                               ), |                 tileColor: sortedApps[index].app.pinned | ||||||
|                             ) |                     ? Colors.grey.withOpacity(0.1) | ||||||
|                             .toList(), |                     : 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)) | ||||||
|  |           ])), | ||||||
|  |       persistentFooterButtons: [ | ||||||
|  |         Row( | ||||||
|  |           children: [ | ||||||
|  |             IconButton( | ||||||
|  |                 onPressed: () { | ||||||
|  |                   selectedApps.isEmpty | ||||||
|  |                       ? selectThese(sortedApps.map((e) => e.app).toList()) | ||||||
|  |                       : clearSelected(); | ||||||
|  |                 }, | ||||||
|  |                 icon: Icon( | ||||||
|  |                   selectedApps.isEmpty | ||||||
|  |                       ? Icons.select_all_outlined | ||||||
|  |                       : Icons.deselect_outlined, | ||||||
|  |                   color: Theme.of(context).colorScheme.primary, | ||||||
|  |                 ), | ||||||
|  |                 tooltip: selectedApps.isEmpty | ||||||
|  |                     ? tr('selectAll') | ||||||
|  |                     : tr('deselectN', args: [selectedApps.length.toString()])), | ||||||
|  |             const VerticalDivider(), | ||||||
|  |             Expanded( | ||||||
|  |                 child: Row( | ||||||
|  |               mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||||
|  |               children: [ | ||||||
|  |                 selectedApps.isEmpty | ||||||
|  |                     ? const SizedBox() | ||||||
|  |                     : IconButton( | ||||||
|  |                         visualDensity: VisualDensity.compact, | ||||||
|  |                         onPressed: () { | ||||||
|  |                           showDialog<List<String>?>( | ||||||
|  |                               context: context, | ||||||
|  |                               builder: (BuildContext ctx) { | ||||||
|  |                                 return GeneratedFormModal( | ||||||
|  |                                   title: tr('removeSelectedAppsQuestion'), | ||||||
|  |                                   items: const [], | ||||||
|  |                                   defaultValues: const [], | ||||||
|  |                                   initValid: true, | ||||||
|  |                                   message: tr( | ||||||
|  |                                       'xWillBeRemovedButRemainInstalled', | ||||||
|  |                                       args: [ | ||||||
|  |                                         plural('apps', selectedApps.length) | ||||||
|  |                                       ]), | ||||||
|  |                                 ); | ||||||
|  |                               }).then((values) { | ||||||
|  |                             if (values != null) { | ||||||
|  |                               appsProvider.removeApps( | ||||||
|  |                                   selectedApps.map((e) => e.id).toList()); | ||||||
|  |                             } | ||||||
|  |                           }); | ||||||
|  |                         }, | ||||||
|  |                         tooltip: tr('removeSelectedApps'), | ||||||
|  |                         icon: const Icon(Icons.delete_outline_outlined), | ||||||
|                       ), |                       ), | ||||||
|  |                 IconButton( | ||||||
|  |                     visualDensity: VisualDensity.compact, | ||||||
|  |                     onPressed: appsProvider.areDownloadsRunning() || | ||||||
|  |                             (existingUpdateIdsAllOrSelected.isEmpty && | ||||||
|  |                                 newInstallIdsAllOrSelected.isEmpty && | ||||||
|  |                                 trackOnlyUpdateIdsAllOrSelected.isEmpty) | ||||||
|  |                         ? null | ||||||
|  |                         : () { | ||||||
|  |                             HapticFeedback.heavyImpact(); | ||||||
|  |                             List<GeneratedFormItem> formInputs = []; | ||||||
|  |                             List<String> defaultValues = []; | ||||||
|  |                             if (existingUpdateIdsAllOrSelected.isNotEmpty) { | ||||||
|  |                               formInputs.add(GeneratedFormItem( | ||||||
|  |                                   label: tr('updateX', args: [ | ||||||
|  |                                     plural('apps', | ||||||
|  |                                         existingUpdateIdsAllOrSelected.length) | ||||||
|  |                                   ]), | ||||||
|  |                                   type: FormItemType.bool, | ||||||
|  |                                   key: 'updates')); | ||||||
|  |                               defaultValues.add('true'); | ||||||
|  |                             } | ||||||
|  |                             if (newInstallIdsAllOrSelected.isNotEmpty) { | ||||||
|  |                               formInputs.add(GeneratedFormItem( | ||||||
|  |                                   label: tr('installX', args: [ | ||||||
|  |                                     plural('apps', | ||||||
|  |                                         newInstallIdsAllOrSelected.length) | ||||||
|  |                                   ]), | ||||||
|  |                                   type: FormItemType.bool, | ||||||
|  |                                   key: 'installs')); | ||||||
|  |                               defaultValues | ||||||
|  |                                   .add(defaultValues.isEmpty ? 'true' : ''); | ||||||
|  |                             } | ||||||
|  |                             if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) { | ||||||
|  |                               formInputs.add(GeneratedFormItem( | ||||||
|  |                                   label: tr('markXTrackOnlyAsUpdated', args: [ | ||||||
|  |                                     plural('apps', | ||||||
|  |                                         trackOnlyUpdateIdsAllOrSelected.length) | ||||||
|  |                                   ]), | ||||||
|  |                                   type: FormItemType.bool, | ||||||
|  |                                   key: 'trackonlies')); | ||||||
|  |                               defaultValues | ||||||
|  |                                   .add(defaultValues.isEmpty ? 'true' : ''); | ||||||
|  |                             } | ||||||
|  |                             showDialog<List<String>?>( | ||||||
|  |                                 context: context, | ||||||
|  |                                 builder: (BuildContext ctx) { | ||||||
|  |                                   var totalApps = existingUpdateIdsAllOrSelected | ||||||
|  |                                           .length + | ||||||
|  |                                       newInstallIdsAllOrSelected.length + | ||||||
|  |                                       trackOnlyUpdateIdsAllOrSelected.length; | ||||||
|  |                                   return GeneratedFormModal( | ||||||
|  |                                     title: tr('changeX', | ||||||
|  |                                         args: [plural('apps', totalApps)]), | ||||||
|  |                                     items: formInputs.map((e) => [e]).toList(), | ||||||
|  |                                     defaultValues: defaultValues, | ||||||
|  |                                     initValid: true, | ||||||
|  |                                   ); | ||||||
|  |                                 }).then((values) { | ||||||
|  |                               if (values != null) { | ||||||
|  |                                 if (values.isEmpty) { | ||||||
|  |                                   values = defaultValues; | ||||||
|  |                                 } | ||||||
|  |                                 bool shouldInstallUpdates = | ||||||
|  |                                     findGeneratedFormValueByKey( | ||||||
|  |                                             formInputs, values, 'updates') == | ||||||
|  |                                         'true'; | ||||||
|  |                                 bool shouldInstallNew = | ||||||
|  |                                     findGeneratedFormValueByKey( | ||||||
|  |                                             formInputs, values, 'installs') == | ||||||
|  |                                         'true'; | ||||||
|  |                                 bool shouldMarkTrackOnlies = | ||||||
|  |                                     findGeneratedFormValueByKey(formInputs, | ||||||
|  |                                             values, 'trackonlies') == | ||||||
|  |                                         'true'; | ||||||
|  |                                 (() async { | ||||||
|  |                                   if (shouldInstallNew || | ||||||
|  |                                       shouldInstallUpdates) { | ||||||
|  |                                     await settingsProvider | ||||||
|  |                                         .getInstallPermission(); | ||||||
|  |                                   } | ||||||
|  |                                 })() | ||||||
|  |                                     .then((_) { | ||||||
|  |                                   List<String> toInstall = []; | ||||||
|  |                                   if (shouldInstallUpdates) { | ||||||
|  |                                     toInstall | ||||||
|  |                                         .addAll(existingUpdateIdsAllOrSelected); | ||||||
|  |                                   } | ||||||
|  |                                   if (shouldInstallNew) { | ||||||
|  |                                     toInstall | ||||||
|  |                                         .addAll(newInstallIdsAllOrSelected); | ||||||
|  |                                   } | ||||||
|  |                                   if (shouldMarkTrackOnlies) { | ||||||
|  |                                     toInstall.addAll( | ||||||
|  |                                         trackOnlyUpdateIdsAllOrSelected); | ||||||
|  |                                   } | ||||||
|  |                                   appsProvider | ||||||
|  |                                       .downloadAndInstallLatestApps( | ||||||
|  |                                           toInstall, context) | ||||||
|  |                                       .catchError((e) { | ||||||
|  |                                     showError(e, context); | ||||||
|  |                                   }); | ||||||
|  |                                 }); | ||||||
|  |                               } | ||||||
|  |                             }); | ||||||
|  |                           }, | ||||||
|  |                     tooltip: selectedApps.isEmpty | ||||||
|  |                         ? tr('installUpdateApps') | ||||||
|  |                         : tr('installUpdateSelectedApps'), | ||||||
|  |                     icon: const Icon( | ||||||
|  |                       Icons.file_download_outlined, | ||||||
|  |                     )), | ||||||
|  |                 selectedApps.isEmpty | ||||||
|  |                     ? const SizedBox() | ||||||
|  |                     : IconButton( | ||||||
|  |                         visualDensity: VisualDensity.compact, | ||||||
|  |                         onPressed: () { | ||||||
|  |                           showDialog( | ||||||
|  |                               context: context, | ||||||
|  |                               builder: (BuildContext ctx) { | ||||||
|  |                                 return AlertDialog( | ||||||
|  |                                   scrollable: true, | ||||||
|  |                                   content: Padding( | ||||||
|  |                                     padding: const EdgeInsets.only(top: 6), | ||||||
|  |                                     child: Row( | ||||||
|  |                                         mainAxisAlignment: | ||||||
|  |                                             MainAxisAlignment.spaceAround, | ||||||
|  |                                         children: [ | ||||||
|  |                                           IconButton( | ||||||
|  |                                               onPressed: | ||||||
|  |                                                   appsProvider | ||||||
|  |                                                           .areDownloadsRunning() | ||||||
|  |                                                       ? null | ||||||
|  |                                                       : () { | ||||||
|  |                                                           showDialog( | ||||||
|  |                                                               context: context, | ||||||
|  |                                                               builder: | ||||||
|  |                                                                   (BuildContext | ||||||
|  |                                                                       ctx) { | ||||||
|  |                                                                 return AlertDialog( | ||||||
|  |                                                                   title: Text(tr( | ||||||
|  |                                                                       'markXSelectedAppsAsUpdated', | ||||||
|  |                                                                       args: [ | ||||||
|  |                                                                         selectedApps | ||||||
|  |                                                                             .length | ||||||
|  |                                                                             .toString() | ||||||
|  |                                                                       ])), | ||||||
|  |                                                                   content: Text( | ||||||
|  |                                                                       tr('onlyAppliesToInstalledAndOutdatedApps')), | ||||||
|  |                                                                   actions: [ | ||||||
|  |                                                                     TextButton( | ||||||
|  |                                                                         onPressed: | ||||||
|  |                                                                             () { | ||||||
|  |                                                                           Navigator.of(context) | ||||||
|  |                                                                               .pop(); | ||||||
|  |                                                                         }, | ||||||
|  |                                                                         child: Text( | ||||||
|  |                                                                             tr('no'))), | ||||||
|  |                                                                     TextButton( | ||||||
|  |                                                                         onPressed: | ||||||
|  |                                                                             () { | ||||||
|  |                                                                           HapticFeedback | ||||||
|  |                                                                               .selectionClick(); | ||||||
|  |                                                                           appsProvider | ||||||
|  |                                                                               .saveApps(selectedApps.map((a) { | ||||||
|  |                                                                             if (a.installedVersion != null && | ||||||
|  |                                                                                 !a.enhancedVersionDetection) { | ||||||
|  |                                                                               a.installedVersion = a.latestVersion; | ||||||
|  |                                                                             } | ||||||
|  |                                                                             return a; | ||||||
|  |                                                                           }).toList()); | ||||||
|  |  | ||||||
|  |                                                                           Navigator.of(context) | ||||||
|  |                                                                               .pop(); | ||||||
|  |                                                                         }, | ||||||
|  |                                                                         child: Text( | ||||||
|  |                                                                             tr('yes'))) | ||||||
|  |                                                                   ], | ||||||
|  |                                                                 ); | ||||||
|  |                                                               }).whenComplete(() { | ||||||
|  |                                                             Navigator.of( | ||||||
|  |                                                                     context) | ||||||
|  |                                                                 .pop(); | ||||||
|  |                                                           }); | ||||||
|  |                                                         }, | ||||||
|  |                                               tooltip: | ||||||
|  |                                                   tr('markSelectedAppsUpdated'), | ||||||
|  |                                               icon: const Icon(Icons.done)), | ||||||
|  |                                           IconButton( | ||||||
|  |                                             onPressed: () { | ||||||
|  |                                               var pinStatus = selectedApps | ||||||
|  |                                                   .where((element) => | ||||||
|  |                                                       element.pinned) | ||||||
|  |                                                   .isEmpty; | ||||||
|  |                                               appsProvider.saveApps( | ||||||
|  |                                                   selectedApps.map((e) { | ||||||
|  |                                                 e.pinned = pinStatus; | ||||||
|  |                                                 return e; | ||||||
|  |                                               }).toList()); | ||||||
|  |                                               Navigator.of(context).pop(); | ||||||
|  |                                             }, | ||||||
|  |                                             tooltip: selectedApps | ||||||
|  |                                                     .where((element) => | ||||||
|  |                                                         element.pinned) | ||||||
|  |                                                     .isEmpty | ||||||
|  |                                                 ? tr('pinToTop') | ||||||
|  |                                                 : tr('unpinFromTop'), | ||||||
|  |                                             icon: Icon(selectedApps | ||||||
|  |                                                     .where((element) => | ||||||
|  |                                                         element.pinned) | ||||||
|  |                                                     .isEmpty | ||||||
|  |                                                 ? Icons.bookmark_outline_rounded | ||||||
|  |                                                 : Icons | ||||||
|  |                                                     .bookmark_remove_outlined), | ||||||
|  |                                           ), | ||||||
|  |                                           IconButton( | ||||||
|  |                                             onPressed: () { | ||||||
|  |                                               String urls = ''; | ||||||
|  |                                               for (var a in selectedApps) { | ||||||
|  |                                                 urls += '${a.url}\n'; | ||||||
|  |                                               } | ||||||
|  |                                               urls = urls.substring( | ||||||
|  |                                                   0, urls.length - 1); | ||||||
|  |                                               Share.share(urls, | ||||||
|  |                                                   subject: tr( | ||||||
|  |                                                       'selectedAppURLsFromObtainium')); | ||||||
|  |                                               Navigator.of(context).pop(); | ||||||
|  |                                             }, | ||||||
|  |                                             tooltip: tr('shareSelectedAppURLs'), | ||||||
|  |                                             icon: const Icon(Icons.share), | ||||||
|  |                                           ), | ||||||
|  |                                           IconButton( | ||||||
|  |                                             onPressed: () { | ||||||
|  |                                               showDialog( | ||||||
|  |                                                   context: context, | ||||||
|  |                                                   builder: (BuildContext ctx) { | ||||||
|  |                                                     return GeneratedFormModal( | ||||||
|  |                                                       title: tr( | ||||||
|  |                                                           'resetInstallStatusForSelectedAppsQuestion'), | ||||||
|  |                                                       items: const [], | ||||||
|  |                                                       defaultValues: const [], | ||||||
|  |                                                       initValid: true, | ||||||
|  |                                                       message: tr( | ||||||
|  |                                                           'installStatusOfXWillBeResetExplanation', | ||||||
|  |                                                           args: [ | ||||||
|  |                                                             plural( | ||||||
|  |                                                                 'app', | ||||||
|  |                                                                 selectedApps | ||||||
|  |                                                                     .length) | ||||||
|  |                                                           ]), | ||||||
|  |                                                     ); | ||||||
|  |                                                   }).then((values) { | ||||||
|  |                                                 if (values != null) { | ||||||
|  |                                                   appsProvider.saveApps( | ||||||
|  |                                                       selectedApps.map((e) { | ||||||
|  |                                                     e.installedVersion = null; | ||||||
|  |                                                     return e; | ||||||
|  |                                                   }).toList()); | ||||||
|  |                                                 } | ||||||
|  |                                               }).whenComplete(() { | ||||||
|  |                                                 Navigator.of(context).pop(); | ||||||
|  |                                               }); | ||||||
|  |                                             }, | ||||||
|  |                                             tooltip: tr('resetInstallStatus'), | ||||||
|  |                                             icon: const Icon( | ||||||
|  |                                                 Icons.restore_page_outlined), | ||||||
|  |                                           ), | ||||||
|  |                                         ]), | ||||||
|  |                                   ), | ||||||
|  |                                 ); | ||||||
|  |                               }); | ||||||
|  |                         }, | ||||||
|  |                         tooltip: tr('more'), | ||||||
|  |                         icon: const Icon(Icons.more_horiz), | ||||||
|  |                       ), | ||||||
|  |               ], | ||||||
|  |             )), | ||||||
|  |             const VerticalDivider(), | ||||||
|  |             IconButton( | ||||||
|  |               visualDensity: VisualDensity.compact, | ||||||
|  |               onPressed: () { | ||||||
|  |                 setState(() { | ||||||
|  |                   if (currentFilterIsUpdatesOnly) { | ||||||
|  |                     filter = null; | ||||||
|  |                   } else { | ||||||
|  |                     filter = updatesOnlyFilter; | ||||||
|  |                   } | ||||||
|  |                 }); | ||||||
|  |               }, | ||||||
|  |               tooltip: currentFilterIsUpdatesOnly | ||||||
|  |                   ? tr('removeOutdatedFilter') | ||||||
|  |                   : tr('showOutdatedOnly'), | ||||||
|  |               icon: Icon( | ||||||
|  |                 currentFilterIsUpdatesOnly | ||||||
|  |                     ? Icons.update_disabled_rounded | ||||||
|  |                     : Icons.update_rounded, | ||||||
|  |                 color: Theme.of(context).colorScheme.primary, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |             appsProvider.apps.isEmpty | ||||||
|  |                 ? const SizedBox() | ||||||
|  |                 : TextButton.icon( | ||||||
|  |                     label: Text( | ||||||
|  |                       filter == null ? tr('filter') : tr('filterActive'), | ||||||
|  |                       style: TextStyle( | ||||||
|  |                           fontWeight: filter == null | ||||||
|  |                               ? FontWeight.normal | ||||||
|  |                               : FontWeight.bold), | ||||||
|                     ), |                     ), | ||||||
|         )); |                     onPressed: () { | ||||||
|  |                       showDialog<List<String>?>( | ||||||
|  |                           context: context, | ||||||
|  |                           builder: (BuildContext ctx) { | ||||||
|  |                             return GeneratedFormModal( | ||||||
|  |                                 title: tr('filterApps'), | ||||||
|  |                                 items: [ | ||||||
|  |                                   [ | ||||||
|  |                                     GeneratedFormItem( | ||||||
|  |                                         label: tr('appName'), required: false), | ||||||
|  |                                     GeneratedFormItem( | ||||||
|  |                                         label: tr('author'), required: false) | ||||||
|  |                                   ], | ||||||
|  |                                   [ | ||||||
|  |                                     GeneratedFormItem( | ||||||
|  |                                         label: tr('upToDateApps'), | ||||||
|  |                                         type: FormItemType.bool) | ||||||
|  |                                   ], | ||||||
|  |                                   [ | ||||||
|  |                                     GeneratedFormItem( | ||||||
|  |                                         label: tr('nonInstalledApps'), | ||||||
|  |                                         type: FormItemType.bool) | ||||||
|  |                                   ] | ||||||
|  |                                 ], | ||||||
|  |                                 defaultValues: filter == null | ||||||
|  |                                     ? AppsFilter().toValuesArray() | ||||||
|  |                                     : filter!.toValuesArray()); | ||||||
|  |                           }).then((values) { | ||||||
|  |                         if (values != null) { | ||||||
|  |                           setState(() { | ||||||
|  |                             filter = AppsFilter.fromValuesArray(values); | ||||||
|  |                             if (AppsFilter().isIdenticalTo(filter!)) { | ||||||
|  |                               filter = null; | ||||||
|  |                             } | ||||||
|  |                           }); | ||||||
|  |                         } | ||||||
|  |                       }); | ||||||
|  |                     }, | ||||||
|  |                     icon: const Icon(Icons.filter_list_rounded)) | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class AppsFilter { | ||||||
|  |   late String nameFilter; | ||||||
|  |   late String authorFilter; | ||||||
|  |   late bool includeUptodate; | ||||||
|  |   late bool includeNonInstalled; | ||||||
|  |  | ||||||
|  |   AppsFilter( | ||||||
|  |       {this.nameFilter = '', | ||||||
|  |       this.authorFilter = '', | ||||||
|  |       this.includeUptodate = true, | ||||||
|  |       this.includeNonInstalled = true}); | ||||||
|  |  | ||||||
|  |   List<String> toValuesArray() { | ||||||
|  |     return [ | ||||||
|  |       nameFilter, | ||||||
|  |       authorFilter, | ||||||
|  |       includeUptodate ? 'true' : '', | ||||||
|  |       includeNonInstalled ? 'true' : '' | ||||||
|  |     ]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   AppsFilter.fromValuesArray(List<String> values) { | ||||||
|  |     nameFilter = values[0]; | ||||||
|  |     authorFilter = values[1]; | ||||||
|  |     includeUptodate = values[2] == 'true'; | ||||||
|  |     includeNonInstalled = values[3] == 'true'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool isIdenticalTo(AppsFilter other) => | ||||||
|  |       authorFilter.trim() == other.authorFilter.trim() && | ||||||
|  |       nameFilter.trim() == other.nameFilter.trim() && | ||||||
|  |       includeUptodate == other.includeUptodate && | ||||||
|  |       includeNonInstalled == other.includeNonInstalled; | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,6 +1,10 @@ | |||||||
|  | import 'package:animations/animations.dart'; | ||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
| import 'package:obtainium/pages/add_app.dart'; | import 'package:obtainium/pages/add_app.dart'; | ||||||
| import 'package:obtainium/pages/apps.dart'; | import 'package:obtainium/pages/apps.dart'; | ||||||
|  | import 'package:obtainium/pages/import_export.dart'; | ||||||
| import 'package:obtainium/pages/settings.dart'; | import 'package:obtainium/pages/settings.dart'; | ||||||
|  |  | ||||||
| class HomePage extends StatefulWidget { | class HomePage extends StatefulWidget { | ||||||
| @@ -10,32 +14,85 @@ class HomePage extends StatefulWidget { | |||||||
|   State<HomePage> createState() => _HomePageState(); |   State<HomePage> createState() => _HomePageState(); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class NavigationPageItem { | ||||||
|  |   late String title; | ||||||
|  |   late IconData icon; | ||||||
|  |   late Widget widget; | ||||||
|  |  | ||||||
|  |   NavigationPageItem(this.title, this.icon, this.widget); | ||||||
|  | } | ||||||
|  |  | ||||||
| class _HomePageState extends State<HomePage> { | class _HomePageState extends State<HomePage> { | ||||||
|   int selectedIndex = 1; |   List<int> selectedIndexHistory = []; | ||||||
|   List<Widget> pages = [ |  | ||||||
|     const SettingsPage(), |   List<NavigationPageItem> pages = [ | ||||||
|     const AppsPage(), |     NavigationPageItem(tr('appsString'), Icons.apps, | ||||||
|     const AddAppPage() |         AppsPage(key: GlobalKey<AppsPageState>())), | ||||||
|  |     NavigationPageItem(tr('addApp'), Icons.add, const AddAppPage()), | ||||||
|  |     NavigationPageItem( | ||||||
|  |         tr('importExport'), Icons.import_export, const ImportExportPage()), | ||||||
|  |     NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage()) | ||||||
|   ]; |   ]; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Scaffold( |     return WillPopScope( | ||||||
|       appBar: AppBar(title: const Text('Obtainium')), |         child: Scaffold( | ||||||
|       body: pages.elementAt(selectedIndex), |           backgroundColor: Theme.of(context).colorScheme.surface, | ||||||
|       bottomNavigationBar: NavigationBar( |           body: PageTransitionSwitcher( | ||||||
|         destinations: const [ |             transitionBuilder: ( | ||||||
|           NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'), |               Widget child, | ||||||
|           NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'), |               Animation<double> animation, | ||||||
|           NavigationDestination(icon: Icon(Icons.add), label: 'Add App'), |               Animation<double> secondaryAnimation, | ||||||
|         ], |             ) { | ||||||
|         onDestinationSelected: (int index) { |               return SharedAxisTransition( | ||||||
|           setState(() { |                 animation: animation, | ||||||
|             selectedIndex = index; |                 secondaryAnimation: secondaryAnimation, | ||||||
|           }); |                 transitionType: SharedAxisTransitionType.horizontal, | ||||||
|         }, |                 child: child, | ||||||
|         selectedIndex: selectedIndex, |               ); | ||||||
|       ), |             }, | ||||||
|     ); |             child: pages | ||||||
|  |                 .elementAt(selectedIndexHistory.isEmpty | ||||||
|  |                     ? 0 | ||||||
|  |                     : selectedIndexHistory.last) | ||||||
|  |                 .widget, | ||||||
|  |           ), | ||||||
|  |           bottomNavigationBar: NavigationBar( | ||||||
|  |             destinations: pages | ||||||
|  |                 .map((e) => | ||||||
|  |                     NavigationDestination(icon: Icon(e.icon), label: e.title)) | ||||||
|  |                 .toList(), | ||||||
|  |             onDestinationSelected: (int index) { | ||||||
|  |               HapticFeedback.selectionClick(); | ||||||
|  |               setState(() { | ||||||
|  |                 if (index == 0) { | ||||||
|  |                   selectedIndexHistory.clear(); | ||||||
|  |                 } else if (selectedIndexHistory.isEmpty || | ||||||
|  |                     (selectedIndexHistory.isNotEmpty && | ||||||
|  |                         selectedIndexHistory.last != index)) { | ||||||
|  |                   int existingInd = selectedIndexHistory.indexOf(index); | ||||||
|  |                   if (existingInd >= 0) { | ||||||
|  |                     selectedIndexHistory.removeAt(existingInd); | ||||||
|  |                   } | ||||||
|  |                   selectedIndexHistory.add(index); | ||||||
|  |                 } | ||||||
|  |               }); | ||||||
|  |             }, | ||||||
|  |             selectedIndex: | ||||||
|  |                 selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last, | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |         onWillPop: () async { | ||||||
|  |           if (selectedIndexHistory.isNotEmpty) { | ||||||
|  |             setState(() { | ||||||
|  |               selectedIndexHistory.removeLast(); | ||||||
|  |             }); | ||||||
|  |             return false; | ||||||
|  |           } | ||||||
|  |           return !(pages[0].widget.key as GlobalKey<AppsPageState>) | ||||||
|  |               .currentState | ||||||
|  |               ?.clearSelected(); | ||||||
|  |         }); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										619
									
								
								lib/pages/import_export.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,619 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  | import 'dart:io'; | ||||||
|  |  | ||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:obtainium/components/custom_app_bar.dart'; | ||||||
|  | import 'package:obtainium/components/generated_form.dart'; | ||||||
|  | import 'package:obtainium/components/generated_form_modal.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
|  | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:file_picker/file_picker.dart'; | ||||||
|  | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
|  | class ImportExportPage extends StatefulWidget { | ||||||
|  |   const ImportExportPage({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<ImportExportPage> createState() => _ImportExportPageState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ImportExportPageState extends State<ImportExportPage> { | ||||||
|  |   bool importInProgress = false; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     SourceProvider sourceProvider = SourceProvider(); | ||||||
|  |     var appsProvider = context.read<AppsProvider>(); | ||||||
|  |     var outlineButtonStyle = ButtonStyle( | ||||||
|  |       shape: MaterialStateProperty.all( | ||||||
|  |         StadiumBorder( | ||||||
|  |           side: BorderSide( | ||||||
|  |             width: 1, | ||||||
|  |             color: Theme.of(context).colorScheme.primary, | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     return Scaffold( | ||||||
|  |         backgroundColor: Theme.of(context).colorScheme.surface, | ||||||
|  |         body: CustomScrollView(slivers: <Widget>[ | ||||||
|  |           CustomAppBar(title: tr('importExport')), | ||||||
|  |           SliverFillRemaining( | ||||||
|  |               child: Padding( | ||||||
|  |                   padding: | ||||||
|  |                       const EdgeInsets.symmetric(vertical: 8, horizontal: 16), | ||||||
|  |                   child: Column( | ||||||
|  |                     crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |                     children: [ | ||||||
|  |                       Row( | ||||||
|  |                         children: [ | ||||||
|  |                           Expanded( | ||||||
|  |                               child: TextButton( | ||||||
|  |                                   style: outlineButtonStyle, | ||||||
|  |                                   onPressed: appsProvider.apps.isEmpty || | ||||||
|  |                                           importInProgress | ||||||
|  |                                       ? null | ||||||
|  |                                       : () { | ||||||
|  |                                           HapticFeedback.selectionClick(); | ||||||
|  |                                           appsProvider | ||||||
|  |                                               .exportApps() | ||||||
|  |                                               .then((String path) { | ||||||
|  |                                             showError( | ||||||
|  |                                                 tr('exportedTo', args: [path]), | ||||||
|  |                                                 context); | ||||||
|  |                                           }); | ||||||
|  |                                         }, | ||||||
|  |                                   child: Text(tr('obtainiumExport')))), | ||||||
|  |                           const SizedBox( | ||||||
|  |                             width: 16, | ||||||
|  |                           ), | ||||||
|  |                           Expanded( | ||||||
|  |                               child: TextButton( | ||||||
|  |                                   style: outlineButtonStyle, | ||||||
|  |                                   onPressed: importInProgress | ||||||
|  |                                       ? null | ||||||
|  |                                       : () { | ||||||
|  |                                           HapticFeedback.selectionClick(); | ||||||
|  |                                           FilePicker.platform | ||||||
|  |                                               .pickFiles() | ||||||
|  |                                               .then((result) { | ||||||
|  |                                             setState(() { | ||||||
|  |                                               importInProgress = true; | ||||||
|  |                                             }); | ||||||
|  |                                             if (result != null) { | ||||||
|  |                                               String data = File( | ||||||
|  |                                                       result.files.single.path!) | ||||||
|  |                                                   .readAsStringSync(); | ||||||
|  |                                               try { | ||||||
|  |                                                 jsonDecode(data); | ||||||
|  |                                               } catch (e) { | ||||||
|  |                                                 throw ObtainiumError( | ||||||
|  |                                                     tr('invalidInput')); | ||||||
|  |                                               } | ||||||
|  |                                               appsProvider | ||||||
|  |                                                   .importApps(data) | ||||||
|  |                                                   .then((value) { | ||||||
|  |                                                 showError( | ||||||
|  |                                                     tr('importedX', args: [ | ||||||
|  |                                                       plural('apps', value) | ||||||
|  |                                                     ]), | ||||||
|  |                                                     context); | ||||||
|  |                                               }); | ||||||
|  |                                             } else { | ||||||
|  |                                               // User canceled the picker | ||||||
|  |                                             } | ||||||
|  |                                           }).catchError((e) { | ||||||
|  |                                             showError(e, context); | ||||||
|  |                                           }).whenComplete(() { | ||||||
|  |                                             setState(() { | ||||||
|  |                                               importInProgress = false; | ||||||
|  |                                             }); | ||||||
|  |                                           }); | ||||||
|  |                                         }, | ||||||
|  |                                   child: Text(tr('obtainiumImport')))) | ||||||
|  |                         ], | ||||||
|  |                       ), | ||||||
|  |                       if (importInProgress) | ||||||
|  |                         Column( | ||||||
|  |                           children: const [ | ||||||
|  |                             SizedBox( | ||||||
|  |                               height: 14, | ||||||
|  |                             ), | ||||||
|  |                             LinearProgressIndicator(), | ||||||
|  |                             SizedBox( | ||||||
|  |                               height: 14, | ||||||
|  |                             ), | ||||||
|  |                           ], | ||||||
|  |                         ) | ||||||
|  |                       else | ||||||
|  |                         const Divider( | ||||||
|  |                           height: 32, | ||||||
|  |                         ), | ||||||
|  |                       TextButton( | ||||||
|  |                           onPressed: importInProgress | ||||||
|  |                               ? null | ||||||
|  |                               : () { | ||||||
|  |                                   showDialog( | ||||||
|  |                                       context: context, | ||||||
|  |                                       builder: (BuildContext ctx) { | ||||||
|  |                                         return GeneratedFormModal( | ||||||
|  |                                           title: tr('importFromURLList'), | ||||||
|  |                                           items: [ | ||||||
|  |                                             [ | ||||||
|  |                                               GeneratedFormItem( | ||||||
|  |                                                   label: tr('appURLList'), | ||||||
|  |                                                   max: 7, | ||||||
|  |                                                   additionalValidators: [ | ||||||
|  |                                                     (String? value) { | ||||||
|  |                                                       if (value != null && | ||||||
|  |                                                           value.isNotEmpty) { | ||||||
|  |                                                         var lines = value | ||||||
|  |                                                             .trim() | ||||||
|  |                                                             .split('\n'); | ||||||
|  |                                                         for (int i = 0; | ||||||
|  |                                                             i < lines.length; | ||||||
|  |                                                             i++) { | ||||||
|  |                                                           try { | ||||||
|  |                                                             sourceProvider | ||||||
|  |                                                                 .getSource( | ||||||
|  |                                                                     lines[i]); | ||||||
|  |                                                           } catch (e) { | ||||||
|  |                                                             return '${tr('line')} ${i + 1}: $e'; | ||||||
|  |                                                           } | ||||||
|  |                                                         } | ||||||
|  |                                                       } | ||||||
|  |                                                       return null; | ||||||
|  |                                                     } | ||||||
|  |                                                   ]) | ||||||
|  |                                             ] | ||||||
|  |                                           ], | ||||||
|  |                                           defaultValues: const [], | ||||||
|  |                                         ); | ||||||
|  |                                       }).then((values) { | ||||||
|  |                                     if (values != null) { | ||||||
|  |                                       var urls = | ||||||
|  |                                           (values[0] as String).split('\n'); | ||||||
|  |                                       setState(() { | ||||||
|  |                                         importInProgress = true; | ||||||
|  |                                       }); | ||||||
|  |                                       appsProvider | ||||||
|  |                                           .addAppsByURL(urls) | ||||||
|  |                                           .then((errors) { | ||||||
|  |                                         if (errors.isEmpty) { | ||||||
|  |                                           showError( | ||||||
|  |                                               tr('importedX', args: [ | ||||||
|  |                                                 plural('apps', urls.length) | ||||||
|  |                                               ]), | ||||||
|  |                                               context); | ||||||
|  |                                         } else { | ||||||
|  |                                           showDialog( | ||||||
|  |                                               context: context, | ||||||
|  |                                               builder: (BuildContext ctx) { | ||||||
|  |                                                 return ImportErrorDialog( | ||||||
|  |                                                     urlsLength: urls.length, | ||||||
|  |                                                     errors: errors); | ||||||
|  |                                               }); | ||||||
|  |                                         } | ||||||
|  |                                       }).catchError((e) { | ||||||
|  |                                         showError(e, context); | ||||||
|  |                                       }).whenComplete(() { | ||||||
|  |                                         setState(() { | ||||||
|  |                                           importInProgress = false; | ||||||
|  |                                         }); | ||||||
|  |                                       }); | ||||||
|  |                                     } | ||||||
|  |                                   }); | ||||||
|  |                                 }, | ||||||
|  |                           child: Text( | ||||||
|  |                             tr('importFromURLList'), | ||||||
|  |                           )), | ||||||
|  |                       ...sourceProvider.sources | ||||||
|  |                           .where((element) => element.canSearch) | ||||||
|  |                           .map((source) => Column( | ||||||
|  |                                   crossAxisAlignment: | ||||||
|  |                                       CrossAxisAlignment.stretch, | ||||||
|  |                                   children: [ | ||||||
|  |                                     const SizedBox(height: 8), | ||||||
|  |                                     TextButton( | ||||||
|  |                                         onPressed: importInProgress | ||||||
|  |                                             ? null | ||||||
|  |                                             : () { | ||||||
|  |                                                 () async { | ||||||
|  |                                                   var values = await showDialog< | ||||||
|  |                                                           List<String>>( | ||||||
|  |                                                       context: context, | ||||||
|  |                                                       builder: | ||||||
|  |                                                           (BuildContext ctx) { | ||||||
|  |                                                         return GeneratedFormModal( | ||||||
|  |                                                           title: tr('searchX', | ||||||
|  |                                                               args: [ | ||||||
|  |                                                                 source | ||||||
|  |                                                                     .runtimeType | ||||||
|  |                                                                     .toString() | ||||||
|  |                                                               ]), | ||||||
|  |                                                           items: [ | ||||||
|  |                                                             [ | ||||||
|  |                                                               GeneratedFormItem( | ||||||
|  |                                                                   label: tr( | ||||||
|  |                                                                       'searchQuery')) | ||||||
|  |                                                             ] | ||||||
|  |                                                           ], | ||||||
|  |                                                           defaultValues: const [], | ||||||
|  |                                                         ); | ||||||
|  |                                                       }); | ||||||
|  |                                                   if (values != null && | ||||||
|  |                                                       values[0].isNotEmpty) { | ||||||
|  |                                                     setState(() { | ||||||
|  |                                                       importInProgress = true; | ||||||
|  |                                                     }); | ||||||
|  |                                                     var urlsWithDescriptions = | ||||||
|  |                                                         await source | ||||||
|  |                                                             .search(values[0]); | ||||||
|  |                                                     if (urlsWithDescriptions | ||||||
|  |                                                         .isNotEmpty) { | ||||||
|  |                                                       var selectedUrls = | ||||||
|  |                                                           await showDialog< | ||||||
|  |                                                                   List< | ||||||
|  |                                                                       String>?>( | ||||||
|  |                                                               context: context, | ||||||
|  |                                                               builder: | ||||||
|  |                                                                   (BuildContext | ||||||
|  |                                                                       ctx) { | ||||||
|  |                                                                 return UrlSelectionModal( | ||||||
|  |                                                                   urlsWithDescriptions: | ||||||
|  |                                                                       urlsWithDescriptions, | ||||||
|  |                                                                   selectedByDefault: | ||||||
|  |                                                                       false, | ||||||
|  |                                                                 ); | ||||||
|  |                                                               }); | ||||||
|  |                                                       if (selectedUrls != | ||||||
|  |                                                               null && | ||||||
|  |                                                           selectedUrls | ||||||
|  |                                                               .isNotEmpty) { | ||||||
|  |                                                         var errors = | ||||||
|  |                                                             await appsProvider | ||||||
|  |                                                                 .addAppsByURL( | ||||||
|  |                                                                     selectedUrls); | ||||||
|  |                                                         if (errors.isEmpty) { | ||||||
|  |                                                           // ignore: use_build_context_synchronously | ||||||
|  |                                                           showError( | ||||||
|  |                                                               tr('importedX', | ||||||
|  |                                                                   args: [ | ||||||
|  |                                                                     plural( | ||||||
|  |                                                                         'app', | ||||||
|  |                                                                         selectedUrls | ||||||
|  |                                                                             .length) | ||||||
|  |                                                                   ]), | ||||||
|  |                                                               context); | ||||||
|  |                                                         } else { | ||||||
|  |                                                           showDialog( | ||||||
|  |                                                               context: context, | ||||||
|  |                                                               builder: | ||||||
|  |                                                                   (BuildContext | ||||||
|  |                                                                       ctx) { | ||||||
|  |                                                                 return ImportErrorDialog( | ||||||
|  |                                                                     urlsLength: | ||||||
|  |                                                                         selectedUrls | ||||||
|  |                                                                             .length, | ||||||
|  |                                                                     errors: | ||||||
|  |                                                                         errors); | ||||||
|  |                                                               }); | ||||||
|  |                                                         } | ||||||
|  |                                                       } | ||||||
|  |                                                     } else { | ||||||
|  |                                                       throw ObtainiumError( | ||||||
|  |                                                           tr('noResults')); | ||||||
|  |                                                     } | ||||||
|  |                                                   } | ||||||
|  |                                                 }() | ||||||
|  |                                                     .catchError((e) { | ||||||
|  |                                                   showError(e, context); | ||||||
|  |                                                 }).whenComplete(() { | ||||||
|  |                                                   setState(() { | ||||||
|  |                                                     importInProgress = false; | ||||||
|  |                                                   }); | ||||||
|  |                                                 }); | ||||||
|  |                                               }, | ||||||
|  |                                         child: Text(tr('searchX', args: [ | ||||||
|  |                                           source.runtimeType.toString() | ||||||
|  |                                         ]))) | ||||||
|  |                                   ])) | ||||||
|  |                           .toList(), | ||||||
|  |                       ...sourceProvider.massUrlSources | ||||||
|  |                           .map((source) => Column( | ||||||
|  |                                   crossAxisAlignment: | ||||||
|  |                                       CrossAxisAlignment.stretch, | ||||||
|  |                                   children: [ | ||||||
|  |                                     const SizedBox(height: 8), | ||||||
|  |                                     TextButton( | ||||||
|  |                                         onPressed: importInProgress | ||||||
|  |                                             ? null | ||||||
|  |                                             : () { | ||||||
|  |                                                 () async { | ||||||
|  |                                                   var values = await showDialog( | ||||||
|  |                                                       context: context, | ||||||
|  |                                                       builder: | ||||||
|  |                                                           (BuildContext ctx) { | ||||||
|  |                                                         return GeneratedFormModal( | ||||||
|  |                                                           title: tr('importX', | ||||||
|  |                                                               args: [ | ||||||
|  |                                                                 source.name | ||||||
|  |                                                               ]), | ||||||
|  |                                                           items: | ||||||
|  |                                                               source | ||||||
|  |                                                                   .requiredArgs | ||||||
|  |                                                                   .map( | ||||||
|  |                                                                       (e) => [ | ||||||
|  |                                                                             GeneratedFormItem(label: e) | ||||||
|  |                                                                           ]) | ||||||
|  |                                                                   .toList(), | ||||||
|  |                                                           defaultValues: const [], | ||||||
|  |                                                         ); | ||||||
|  |                                                       }); | ||||||
|  |                                                   if (values != null) { | ||||||
|  |                                                     setState(() { | ||||||
|  |                                                       importInProgress = true; | ||||||
|  |                                                     }); | ||||||
|  |                                                     var urlsWithDescriptions = | ||||||
|  |                                                         await source | ||||||
|  |                                                             .getUrlsWithDescriptions( | ||||||
|  |                                                                 values); | ||||||
|  |                                                     var selectedUrls = | ||||||
|  |                                                         await showDialog< | ||||||
|  |                                                                 List<String>?>( | ||||||
|  |                                                             context: context, | ||||||
|  |                                                             builder: | ||||||
|  |                                                                 (BuildContext | ||||||
|  |                                                                     ctx) { | ||||||
|  |                                                               return UrlSelectionModal( | ||||||
|  |                                                                   urlsWithDescriptions: | ||||||
|  |                                                                       urlsWithDescriptions); | ||||||
|  |                                                             }); | ||||||
|  |                                                     if (selectedUrls != null) { | ||||||
|  |                                                       var errors = | ||||||
|  |                                                           await appsProvider | ||||||
|  |                                                               .addAppsByURL( | ||||||
|  |                                                                   selectedUrls); | ||||||
|  |                                                       if (errors.isEmpty) { | ||||||
|  |                                                         // ignore: use_build_context_synchronously | ||||||
|  |                                                         showError( | ||||||
|  |                                                             tr('importedX', | ||||||
|  |                                                                 args: [ | ||||||
|  |                                                                   plural( | ||||||
|  |                                                                       'app', | ||||||
|  |                                                                       selectedUrls | ||||||
|  |                                                                           .length) | ||||||
|  |                                                                 ]), | ||||||
|  |                                                             context); | ||||||
|  |                                                       } else { | ||||||
|  |                                                         showDialog( | ||||||
|  |                                                             context: context, | ||||||
|  |                                                             builder: | ||||||
|  |                                                                 (BuildContext | ||||||
|  |                                                                     ctx) { | ||||||
|  |                                                               return ImportErrorDialog( | ||||||
|  |                                                                   urlsLength: | ||||||
|  |                                                                       selectedUrls | ||||||
|  |                                                                           .length, | ||||||
|  |                                                                   errors: | ||||||
|  |                                                                       errors); | ||||||
|  |                                                             }); | ||||||
|  |                                                       } | ||||||
|  |                                                     } | ||||||
|  |                                                   } | ||||||
|  |                                                 }() | ||||||
|  |                                                     .catchError((e) { | ||||||
|  |                                                   showError(e, context); | ||||||
|  |                                                 }).whenComplete(() { | ||||||
|  |                                                   setState(() { | ||||||
|  |                                                     importInProgress = false; | ||||||
|  |                                                   }); | ||||||
|  |                                                 }); | ||||||
|  |                                               }, | ||||||
|  |                                         child: Text( | ||||||
|  |                                             tr('importX', args: [source.name]))) | ||||||
|  |                                   ])) | ||||||
|  |                           .toList(), | ||||||
|  |                       const Spacer(), | ||||||
|  |                       const Divider( | ||||||
|  |                         height: 32, | ||||||
|  |                       ), | ||||||
|  |                       Text(tr('importedAppsIdDisclaimer'), | ||||||
|  |                           textAlign: TextAlign.center, | ||||||
|  |                           style: const TextStyle( | ||||||
|  |                               fontStyle: FontStyle.italic, fontSize: 12)), | ||||||
|  |                       const SizedBox( | ||||||
|  |                         height: 8, | ||||||
|  |                       ) | ||||||
|  |                     ], | ||||||
|  |                   ))) | ||||||
|  |         ])); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class ImportErrorDialog extends StatefulWidget { | ||||||
|  |   const ImportErrorDialog( | ||||||
|  |       {super.key, required this.urlsLength, required this.errors}); | ||||||
|  |  | ||||||
|  |   final int urlsLength; | ||||||
|  |   final List<List<String>> errors; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<ImportErrorDialog> createState() => _ImportErrorDialogState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ImportErrorDialogState extends State<ImportErrorDialog> { | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AlertDialog( | ||||||
|  |       scrollable: true, | ||||||
|  |       title: Text(tr('importErrors')), | ||||||
|  |       content: | ||||||
|  |           Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ | ||||||
|  |         Text( | ||||||
|  |           tr('importedXOfYApps', args: [ | ||||||
|  |             (widget.urlsLength - widget.errors.length).toString(), | ||||||
|  |             widget.urlsLength.toString() | ||||||
|  |           ]), | ||||||
|  |           style: Theme.of(context).textTheme.bodyLarge, | ||||||
|  |         ), | ||||||
|  |         const SizedBox(height: 16), | ||||||
|  |         Text( | ||||||
|  |           tr('followingURLsHadErrors'), | ||||||
|  |           style: Theme.of(context).textTheme.bodyLarge, | ||||||
|  |         ), | ||||||
|  |         ...widget.errors.map((e) { | ||||||
|  |           return Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |               children: [ | ||||||
|  |                 const SizedBox( | ||||||
|  |                   height: 16, | ||||||
|  |                 ), | ||||||
|  |                 Text(e[0]), | ||||||
|  |                 Text( | ||||||
|  |                   e[1], | ||||||
|  |                   style: const TextStyle(fontStyle: FontStyle.italic), | ||||||
|  |                 ) | ||||||
|  |               ]); | ||||||
|  |         }).toList() | ||||||
|  |       ]), | ||||||
|  |       actions: [ | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               Navigator.of(context).pop(null); | ||||||
|  |             }, | ||||||
|  |             child: Text(tr('okay'))) | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // ignore: must_be_immutable | ||||||
|  | class UrlSelectionModal extends StatefulWidget { | ||||||
|  |   UrlSelectionModal( | ||||||
|  |       {super.key, | ||||||
|  |       required this.urlsWithDescriptions, | ||||||
|  |       this.selectedByDefault = true, | ||||||
|  |       this.onlyOneSelectionAllowed = false}); | ||||||
|  |  | ||||||
|  |   Map<String, String> urlsWithDescriptions; | ||||||
|  |   bool selectedByDefault; | ||||||
|  |   bool onlyOneSelectionAllowed; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<UrlSelectionModal> createState() => _UrlSelectionModalState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _UrlSelectionModalState extends State<UrlSelectionModal> { | ||||||
|  |   Map<MapEntry<String, String>, bool> urlWithDescriptionSelections = {}; | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     for (var url in widget.urlsWithDescriptions.entries) { | ||||||
|  |       urlWithDescriptionSelections.putIfAbsent(url, | ||||||
|  |           () => widget.selectedByDefault && !widget.onlyOneSelectionAllowed); | ||||||
|  |     } | ||||||
|  |     if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) { | ||||||
|  |       selectOnlyOne(widget.urlsWithDescriptions.entries.first.key); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   selectOnlyOne(String url) { | ||||||
|  |     for (var uwd in urlWithDescriptionSelections.keys) { | ||||||
|  |       urlWithDescriptionSelections[uwd] = uwd.key == url; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AlertDialog( | ||||||
|  |       scrollable: true, | ||||||
|  |       title: Text( | ||||||
|  |           widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')), | ||||||
|  |       content: Column(children: [ | ||||||
|  |         ...urlWithDescriptionSelections.keys.map((urlWithD) { | ||||||
|  |           return Row(children: [ | ||||||
|  |             Checkbox( | ||||||
|  |                 value: urlWithDescriptionSelections[urlWithD], | ||||||
|  |                 onChanged: (value) { | ||||||
|  |                   setState(() { | ||||||
|  |                     value ??= false; | ||||||
|  |                     if (value! && widget.onlyOneSelectionAllowed) { | ||||||
|  |                       selectOnlyOne(urlWithD.key); | ||||||
|  |                     } else { | ||||||
|  |                       urlWithDescriptionSelections[urlWithD] = value!; | ||||||
|  |                     } | ||||||
|  |                   }); | ||||||
|  |                 }), | ||||||
|  |             const SizedBox( | ||||||
|  |               width: 8, | ||||||
|  |             ), | ||||||
|  |             Expanded( | ||||||
|  |                 child: Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |               mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |               children: [ | ||||||
|  |                 const SizedBox( | ||||||
|  |                   height: 8, | ||||||
|  |                 ), | ||||||
|  |                 GestureDetector( | ||||||
|  |                     onTap: () { | ||||||
|  |                       launchUrlString(urlWithD.key, | ||||||
|  |                           mode: LaunchMode.externalApplication); | ||||||
|  |                     }, | ||||||
|  |                     child: Text( | ||||||
|  |                       Uri.parse(urlWithD.key).path.substring(1), | ||||||
|  |                       style: | ||||||
|  |                           const TextStyle(decoration: TextDecoration.underline), | ||||||
|  |                       textAlign: TextAlign.start, | ||||||
|  |                     )), | ||||||
|  |                 Text( | ||||||
|  |                   urlWithD.value.length > 128 | ||||||
|  |                       ? '${urlWithD.value.substring(0, 128)}...' | ||||||
|  |                       : urlWithD.value, | ||||||
|  |                   style: const TextStyle( | ||||||
|  |                       fontStyle: FontStyle.italic, fontSize: 12), | ||||||
|  |                 ), | ||||||
|  |                 const SizedBox( | ||||||
|  |                   height: 8, | ||||||
|  |                 ) | ||||||
|  |               ], | ||||||
|  |             )) | ||||||
|  |           ]); | ||||||
|  |         }) | ||||||
|  |       ]), | ||||||
|  |       actions: [ | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               Navigator.of(context).pop(); | ||||||
|  |             }, | ||||||
|  |             child: Text(tr('cancel'))), | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: | ||||||
|  |                 urlWithDescriptionSelections.values.where((b) => b).isEmpty | ||||||
|  |                     ? null | ||||||
|  |                     : () { | ||||||
|  |                         Navigator.of(context).pop(urlWithDescriptionSelections | ||||||
|  |                             .entries | ||||||
|  |                             .where((entry) => entry.value) | ||||||
|  |                             .map((e) => e.key.key) | ||||||
|  |                             .toList()); | ||||||
|  |                       }, | ||||||
|  |             child: Text(widget.onlyOneSelectionAllowed | ||||||
|  |                 ? tr('pick') | ||||||
|  |                 : tr('importX', args: [ | ||||||
|  |                     plural( | ||||||
|  |                         'url', | ||||||
|  |                         urlWithDescriptionSelections.values | ||||||
|  |                             .where((b) => b) | ||||||
|  |                             .length) | ||||||
|  |                   ]))) | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,6 +1,13 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:obtainium/services/settings_provider.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:provider/provider.dart'; | ||||||
|  | import 'package:share_plus/share_plus.dart'; | ||||||
| import 'package:url_launcher/url_launcher_string.dart'; | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| class SettingsPage extends StatefulWidget { | class SettingsPage extends StatefulWidget { | ||||||
| @@ -14,73 +21,333 @@ class _SettingsPageState extends State<SettingsPage> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); |     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); | ||||||
|  |     SourceProvider sourceProvider = SourceProvider(); | ||||||
|     if (settingsProvider.prefs == null) { |     if (settingsProvider.prefs == null) { | ||||||
|       settingsProvider.initializeSettings(); |       settingsProvider.initializeSettings(); | ||||||
|     } |     } | ||||||
|     return Padding( |  | ||||||
|         padding: const EdgeInsets.all(16), |     var themeDropdown = DropdownButtonFormField( | ||||||
|         child: settingsProvider.prefs == null |         decoration: InputDecoration(labelText: tr('theme')), | ||||||
|             ? Container() |         value: settingsProvider.theme, | ||||||
|             : Column( |         items: [ | ||||||
|                 children: [ |           DropdownMenuItem( | ||||||
|                   DropdownButtonFormField( |             value: ThemeSettings.dark, | ||||||
|                       decoration: const InputDecoration(labelText: 'Theme'), |             child: Text(tr('dark')), | ||||||
|                       value: settingsProvider.theme, |           ), | ||||||
|                       items: const [ |           DropdownMenuItem( | ||||||
|                         DropdownMenuItem( |             value: ThemeSettings.light, | ||||||
|                           value: ThemeSettings.dark, |             child: Text(tr('light')), | ||||||
|                           child: Text('Dark'), |           ), | ||||||
|                         ), |           DropdownMenuItem( | ||||||
|                         DropdownMenuItem( |             value: ThemeSettings.system, | ||||||
|                           value: ThemeSettings.light, |             child: Text(tr('followSystem')), | ||||||
|                           child: Text('Light'), |           ) | ||||||
|                         ), |         ], | ||||||
|                         DropdownMenuItem( |         onChanged: (value) { | ||||||
|                           value: ThemeSettings.system, |           if (value != null) { | ||||||
|                           child: Text('Follow System'), |             settingsProvider.theme = value; | ||||||
|                         ) |           } | ||||||
|                       ], |         }); | ||||||
|                       onChanged: (value) { |  | ||||||
|                         if (value != null) { |     var colourDropdown = DropdownButtonFormField( | ||||||
|                           settingsProvider.theme = value; |         decoration: InputDecoration(labelText: tr('colour')), | ||||||
|                         } |         value: settingsProvider.colour, | ||||||
|                       }), |         items: [ | ||||||
|                   const SizedBox( |           DropdownMenuItem( | ||||||
|                     height: 16, |             value: ColourSettings.basic, | ||||||
|                   ), |             child: Text(tr('obtainium')), | ||||||
|                   DropdownButtonFormField( |           ), | ||||||
|                       decoration: const InputDecoration(labelText: 'Colour'), |           DropdownMenuItem( | ||||||
|                       value: settingsProvider.colour, |             value: ColourSettings.materialYou, | ||||||
|                       items: const [ |             child: Text(tr('materialYou')), | ||||||
|                         DropdownMenuItem( |           ) | ||||||
|                           value: ColourSettings.basic, |         ], | ||||||
|                           child: Text('Obtainium'), |         onChanged: (value) { | ||||||
|                         ), |           if (value != null) { | ||||||
|                         DropdownMenuItem( |             settingsProvider.colour = value; | ||||||
|                           value: ColourSettings.materialYou, |           } | ||||||
|                           child: Text('Material You'), |         }); | ||||||
|                         ) |  | ||||||
|                       ], |     var sortDropdown = DropdownButtonFormField( | ||||||
|                       onChanged: (value) { |         decoration: InputDecoration(labelText: tr('appSortBy')), | ||||||
|                         if (value != null) { |         value: settingsProvider.sortColumn, | ||||||
|                           settingsProvider.colour = value; |         items: [ | ||||||
|                         } |           DropdownMenuItem( | ||||||
|                       }), |             value: SortColumnSettings.authorName, | ||||||
|                   const Spacer(), |             child: Text(tr('authorName')), | ||||||
|                   Row( |           ), | ||||||
|                     mainAxisAlignment: MainAxisAlignment.end, |           DropdownMenuItem( | ||||||
|                     children: [ |             value: SortColumnSettings.nameAuthor, | ||||||
|                       ElevatedButton.icon( |             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>[ | ||||||
|  |           CustomAppBar(title: tr('settings')), | ||||||
|  |           SliverToBoxAdapter( | ||||||
|  |               child: Padding( | ||||||
|  |                   padding: const EdgeInsets.all(16), | ||||||
|  |                   child: settingsProvider.prefs == null | ||||||
|  |                       ? const SizedBox() | ||||||
|  |                       : Column( | ||||||
|  |                           crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                           children: [ | ||||||
|  |                             Text( | ||||||
|  |                               tr('appearance'), | ||||||
|  |                               style: TextStyle( | ||||||
|  |                                   color: Theme.of(context).colorScheme.primary), | ||||||
|  |                             ), | ||||||
|  |                             themeDropdown, | ||||||
|  |                             height16, | ||||||
|  |                             colourDropdown, | ||||||
|  |                             height16, | ||||||
|  |                             Row( | ||||||
|  |                               mainAxisAlignment: MainAxisAlignment.start, | ||||||
|  |                               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                               children: [ | ||||||
|  |                                 Expanded(child: sortDropdown), | ||||||
|  |                                 const SizedBox( | ||||||
|  |                                   width: 16, | ||||||
|  |                                 ), | ||||||
|  |                                 Expanded(child: orderDropdown), | ||||||
|  |                               ], | ||||||
|  |                             ), | ||||||
|  |                             height16, | ||||||
|  |                             Row( | ||||||
|  |                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||||
|  |                               children: [ | ||||||
|  |                                 Text(tr('showWebInAppView')), | ||||||
|  |                                 Switch( | ||||||
|  |                                     value: settingsProvider.showAppWebpage, | ||||||
|  |                                     onChanged: (value) { | ||||||
|  |                                       settingsProvider.showAppWebpage = value; | ||||||
|  |                                     }) | ||||||
|  |                               ], | ||||||
|  |                             ), | ||||||
|  |                             height16, | ||||||
|  |                             Row( | ||||||
|  |                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||||
|  |                               children: [ | ||||||
|  |                                 Text(tr('pinUpdates')), | ||||||
|  |                                 Switch( | ||||||
|  |                                     value: settingsProvider.pinUpdates, | ||||||
|  |                                     onChanged: (value) { | ||||||
|  |                                       settingsProvider.pinUpdates = value; | ||||||
|  |                                     }) | ||||||
|  |                               ], | ||||||
|  |                             ), | ||||||
|  |                             const Divider( | ||||||
|  |                               height: 16, | ||||||
|  |                             ), | ||||||
|  |                             height16, | ||||||
|  |                             Text( | ||||||
|  |                               tr('updates'), | ||||||
|  |                               style: TextStyle( | ||||||
|  |                                   color: Theme.of(context).colorScheme.primary), | ||||||
|  |                             ), | ||||||
|  |                             intervalDropdown, | ||||||
|  |                             const Divider( | ||||||
|  |                               height: 48, | ||||||
|  |                             ), | ||||||
|  |                             Text( | ||||||
|  |                               tr('sourceSpecific'), | ||||||
|  |                               style: TextStyle( | ||||||
|  |                                   color: Theme.of(context).colorScheme.primary), | ||||||
|  |                             ), | ||||||
|  |                             ...sourceSpecificFields, | ||||||
|  |                           ], | ||||||
|  |                         ))), | ||||||
|  |           SliverToBoxAdapter( | ||||||
|  |             child: Column( | ||||||
|  |               children: [ | ||||||
|  |                 const Divider( | ||||||
|  |                   height: 32, | ||||||
|  |                 ), | ||||||
|  |                 Row( | ||||||
|  |                   mainAxisAlignment: MainAxisAlignment.spaceAround, | ||||||
|  |                   children: [ | ||||||
|  |                     TextButton.icon( | ||||||
|  |                       onPressed: () { | ||||||
|  |                         launchUrlString(settingsProvider.sourceUrl, | ||||||
|  |                             mode: LaunchMode.externalApplication); | ||||||
|  |                       }, | ||||||
|  |                       icon: const Icon(Icons.code), | ||||||
|  |                       label: Text( | ||||||
|  |                         tr('appSource'), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                     TextButton.icon( | ||||||
|                         onPressed: () { |                         onPressed: () { | ||||||
|                           launchUrlString(settingsProvider.sourceUrl, |                           context.read<LogsProvider>().get().then((logs) { | ||||||
|                               mode: LaunchMode.externalApplication); |                             if (logs.isEmpty) { | ||||||
|  |                               showError(ObtainiumError(tr('noLogs')), context); | ||||||
|  |                             } else { | ||||||
|  |                               showDialog( | ||||||
|  |                                   context: context, | ||||||
|  |                                   builder: (BuildContext ctx) { | ||||||
|  |                                     return const LogsDialog(); | ||||||
|  |                                   }); | ||||||
|  |                             } | ||||||
|  |                           }); | ||||||
|                         }, |                         }, | ||||||
|                         icon: const Icon(Icons.code), |                         icon: const Icon(Icons.bug_report_outlined), | ||||||
|                         label: const Text('Source'), |                         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'))) | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										760
									
								
								lib/providers/apps_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,760 @@ | |||||||
|  | // Manages state related to the list of Apps tracked by Obtainium, | ||||||
|  | // Exposes related functions such as those used to add, remove, download, and install Apps. | ||||||
|  |  | ||||||
|  | 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'; | ||||||
|  |  | ||||||
|  | class AppInMemory { | ||||||
|  |   late App app; | ||||||
|  |   double? downloadProgress; | ||||||
|  |   AppInfo? installedInfo; | ||||||
|  |  | ||||||
|  |   AppInMemory(this.app, this.downloadProgress, this.installedInfo); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class DownloadedApk { | ||||||
|  |   String appId; | ||||||
|  |   File file; | ||||||
|  |   DownloadedApk(this.appId, this.file); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class AppsProvider with ChangeNotifier { | ||||||
|  |   // In memory App state (should always be kept in sync with local storage versions) | ||||||
|  |   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; | ||||||
|  |  | ||||||
|  |   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(); | ||||||
|  |         }); | ||||||
|  |       }(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   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(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); | ||||||
|  |     } | ||||||
|  |     return downloadedFile; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   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(); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     // 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; | ||||||
|  |  | ||||||
|  |   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>(); | ||||||
|  |     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 ObtainiumError(tr('appNotFound')); | ||||||
|  |       } | ||||||
|  |       String? apkUrl; | ||||||
|  |       if (!apps[id]!.app.trackOnly) { | ||||||
|  |         apkUrl = await confirmApkUrl(apps[id]!.app, context); | ||||||
|  |       } | ||||||
|  |       if (apkUrl != null) { | ||||||
|  |         int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); | ||||||
|  |         if (urlInd != apps[id]!.app.preferredApkIndex) { | ||||||
|  |           apps[id]!.app.preferredApkIndex = urlInd; | ||||||
|  |           await saveApps([apps[id]!.app]); | ||||||
|  |         } | ||||||
|  |         if (context != null || await canInstallSilently(apps[id]!.app)) { | ||||||
|  |           appsToInstall.add(id); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       if (apps[id]!.app.trackOnly) { | ||||||
|  |         trackOnlyAppsToUpdate.add(id); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     // Mark all specified track-only apps as latest | ||||||
|  |     saveApps(trackOnlyAppsToUpdate.map((e) { | ||||||
|  |       var a = apps[e]!.app; | ||||||
|  |       a.installedVersion = a.latestVersion; | ||||||
|  |       return a; | ||||||
|  |     }).toList()); | ||||||
|  |     // Download APKs for all Apps to be installed | ||||||
|  |     MultiAppMultiError errors = MultiAppMultiError(); | ||||||
|  |     List<DownloadedApk?> downloadedFiles = | ||||||
|  |         await Future.wait(appsToInstall.map((id) async { | ||||||
|  |       try { | ||||||
|  |         return await downloadApp(apps[id]!.app, 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); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Move everything to the regular install list (since silent updates don't currently work) - TODO | ||||||
|  |     regularInstalls.addAll(silentUpdates); | ||||||
|  |  | ||||||
|  |     // If Obtainium is being installed, it should be the last one | ||||||
|  |     List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) { | ||||||
|  |       DownloadedApk? temp; | ||||||
|  |       items.removeWhere((element) { | ||||||
|  |         bool res = | ||||||
|  |             element.appId == obtainiumId || element.appId == obtainiumTempId; | ||||||
|  |         if (res) { | ||||||
|  |           temp = element; | ||||||
|  |         } | ||||||
|  |         return res; | ||||||
|  |       }); | ||||||
|  |       if (temp != null) { | ||||||
|  |         items = [temp!, ...items]; | ||||||
|  |       } | ||||||
|  |       return items; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     silentUpdates = moveObtainiumToStart(silentUpdates); | ||||||
|  |     regularInstalls = moveObtainiumToStart(regularInstalls); | ||||||
|  |  | ||||||
|  |     // // Install silent updates (uncomment when it works - TODO) | ||||||
|  |     // for (var u in silentUpdates) { | ||||||
|  |     //   await installApk(u, silent: true); // Would need to add silent option | ||||||
|  |     // } | ||||||
|  |  | ||||||
|  |     // Do regular installs | ||||||
|  |     if (regularInstalls.isNotEmpty && context != null) { | ||||||
|  |       // ignore: use_build_context_synchronously | ||||||
|  |       await waitForUserToReturnToForeground(context); | ||||||
|  |       for (var i in regularInstalls) { | ||||||
|  |         try { | ||||||
|  |           await installApk(i); | ||||||
|  |         } catch (e) { | ||||||
|  |           errors.add(i.appId, e.toString()); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (errors.content.isNotEmpty) { | ||||||
|  |       throw errors; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     NotificationsProvider().cancel(UpdateNotification([]).id); | ||||||
|  |  | ||||||
|  |     return downloadedFiles.map((e) => e!.appId).toList(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<Directory> getAppsDir() async { | ||||||
|  |     Directory appsDir = Directory( | ||||||
|  |         '${(await getExternalStorageDirectory())?.path as String}/app_data'); | ||||||
|  |     if (!appsDir.existsSync()) { | ||||||
|  |       appsDir.createSync(); | ||||||
|  |     } | ||||||
|  |     return appsDir; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   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) { | ||||||
|  |       if (app.enhancedVersionDetection) { | ||||||
|  |         app.installedVersion = installedInfo.versionName; | ||||||
|  |       } else { | ||||||
|  |         if (app.latestVersion.contains(installedInfo.versionName!)) { | ||||||
|  |           app.installedVersion = app.latestVersion; | ||||||
|  |         } else { | ||||||
|  |           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<App> newApps = (await getAppsDir()) | ||||||
|  |         .listSync() | ||||||
|  |         .where((item) => item.path.toLowerCase().endsWith('.json')) | ||||||
|  |         .map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync()))) | ||||||
|  |         .toList(); | ||||||
|  |     var idsToDelete = apps.values | ||||||
|  |         .map((e) => e.app.id) | ||||||
|  |         .toSet() | ||||||
|  |         .difference(newApps.map((e) => e.id).toSet()); | ||||||
|  |     for (var id in idsToDelete) { | ||||||
|  |       apps.remove(id); | ||||||
|  |     } | ||||||
|  |     var sp = SourceProvider(); | ||||||
|  |     List<List<String>> errors = []; | ||||||
|  |     for (int i = 0; i < newApps.length; i++) { | ||||||
|  |       var info = await getInstalledInfo(newApps[i].id); | ||||||
|  |       try { | ||||||
|  |         sp.getSource(newApps[i].url); | ||||||
|  |         apps[newApps[i].id] = AppInMemory(newApps[i], null, info); | ||||||
|  |       } catch (e) { | ||||||
|  |         errors.add([newApps[i].id, newApps[i].name, e.toString()]); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if (errors.isNotEmpty) { | ||||||
|  |       removeApps(errors.map((e) => e[0]).toList()); | ||||||
|  |       NotificationsProvider().notify( | ||||||
|  |           AppsRemovedNotification(errors.map((e) => [e[1], e[2]]).toList())); | ||||||
|  |     } | ||||||
|  |     loadingApps = false; | ||||||
|  |     notifyListeners(); | ||||||
|  |     List<App> modifiedApps = []; | ||||||
|  |     for (var app in apps.values) { | ||||||
|  |       var moddedApp = | ||||||
|  |           getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo); | ||||||
|  |       if (moddedApp != null) { | ||||||
|  |         modifiedApps.add(moddedApp); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if (modifiedApps.isNotEmpty) { | ||||||
|  |       await saveApps(modifiedApps); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> saveApps(List<App> apps, | ||||||
|  |       {bool attemptToCorrectInstallStatus = true}) async { | ||||||
|  |     for (var app in apps) { | ||||||
|  |       AppInfo? info = await getInstalledInfo(app.id); | ||||||
|  |       app.name = info?.name ?? app.name; | ||||||
|  |       if (attemptToCorrectInstallStatus) { | ||||||
|  |         app = getCorrectedInstallStatusAppIfPossible(app, info) ?? app; | ||||||
|  |       } | ||||||
|  |       File('${(await getAppsDir()).path}/${app.id}.json') | ||||||
|  |           .writeAsStringSync(jsonEncode(app.toJson())); | ||||||
|  |       this.apps.update( | ||||||
|  |           app.id, (value) => AppInMemory(app, value.downloadProgress, info), | ||||||
|  |           ifAbsent: () => AppInMemory(app, null, info)); | ||||||
|  |     } | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> removeApps(List<String> appIds) async { | ||||||
|  |     for (var appId in appIds) { | ||||||
|  |       File file = File('${(await getAppsDir()).path}/$appId.json'); | ||||||
|  |       if (file.existsSync()) { | ||||||
|  |         file.deleteSync(); | ||||||
|  |       } | ||||||
|  |       if (apps.containsKey(appId)) { | ||||||
|  |         apps.remove(appId); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if (appIds.isNotEmpty) { | ||||||
|  |       notifyListeners(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<App?> checkUpdate(String appId) async { | ||||||
|  |     App? currentApp = apps[appId]!.app; | ||||||
|  |     SourceProvider sourceProvider = SourceProvider(); | ||||||
|  |     App newApp = await sourceProvider.getApp( | ||||||
|  |         sourceProvider.getSource(currentApp.url), | ||||||
|  |         currentApp.url, | ||||||
|  |         currentApp.additionalData, | ||||||
|  |         name: currentApp.name, | ||||||
|  |         id: currentApp.id, | ||||||
|  |         pinned: currentApp.pinned, | ||||||
|  |         trackOnly: currentApp.trackOnly, | ||||||
|  |         installedVersion: currentApp.installedVersion); | ||||||
|  |     if (currentApp.preferredApkIndex < newApp.apkUrls.length) { | ||||||
|  |       newApp.preferredApkIndex = currentApp.preferredApkIndex; | ||||||
|  |     } | ||||||
|  |     await saveApps([newApp]); | ||||||
|  |     return newApp.latestVersion != currentApp.latestVersion ? newApp : null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<List<App>> checkUpdates( | ||||||
|  |       {DateTime? ignoreAppsCheckedAfter, | ||||||
|  |       bool throwErrorsForRetry = false}) async { | ||||||
|  |     List<App> updates = []; | ||||||
|  |     MultiAppMultiError errors = MultiAppMultiError(); | ||||||
|  |     if (!gettingUpdates) { | ||||||
|  |       gettingUpdates = true; | ||||||
|  |       try { | ||||||
|  |         List<String> appIds = apps.values | ||||||
|  |             .where((app) => | ||||||
|  |                 app.app.lastUpdateCheck == null || | ||||||
|  |                 ignoreAppsCheckedAfter == null || | ||||||
|  |                 app.app.lastUpdateCheck!.isBefore(ignoreAppsCheckedAfter)) | ||||||
|  |             .map((e) => e.app.id) | ||||||
|  |             .toList(); | ||||||
|  |         appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ?? | ||||||
|  |                 DateTime.fromMicrosecondsSinceEpoch(0)) | ||||||
|  |             .compareTo(apps[b]!.app.lastUpdateCheck ?? | ||||||
|  |                 DateTime.fromMicrosecondsSinceEpoch(0))); | ||||||
|  |         for (int i = 0; i < appIds.length; i++) { | ||||||
|  |           App? newApp; | ||||||
|  |           try { | ||||||
|  |             newApp = await checkUpdate(appIds[i]); | ||||||
|  |           } catch (e) { | ||||||
|  |             if ((e is RateLimitError || e is SocketException) && | ||||||
|  |                 throwErrorsForRetry) { | ||||||
|  |               rethrow; | ||||||
|  |             } | ||||||
|  |             errors.add(appIds[i], e.toString()); | ||||||
|  |           } | ||||||
|  |           if (newApp != null) { | ||||||
|  |             updates.add(newApp); | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } finally { | ||||||
|  |         gettingUpdates = false; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if (errors.content.isNotEmpty) { | ||||||
|  |       throw errors; | ||||||
|  |     } | ||||||
|  |     return updates; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   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 && | ||||||
|  |           (!installedOnly || !nonInstalledOnly)) { | ||||||
|  |         if ((app.installedVersion == null && | ||||||
|  |                 (nonInstalledOnly || !installedOnly) || | ||||||
|  |             (app.installedVersion != null && | ||||||
|  |                 (installedOnly || !nonInstalledOnly)))) { | ||||||
|  |           updateAppIds.add(app.id); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return updateAppIds; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<String> exportApps() async { | ||||||
|  |     Directory? exportDir = Directory('/storage/emulated/0/Download'); | ||||||
|  |     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}/${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 { | ||||||
|  |     List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>) | ||||||
|  |         .map((e) => App.fromJson(e)) | ||||||
|  |         .toList(); | ||||||
|  |     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(); | ||||||
|  |     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, this.archs}); | ||||||
|  |  | ||||||
|  |   final App app; | ||||||
|  |   final String? initVal; | ||||||
|  |   final List<String>? archs; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<APKPicker> createState() => _APKPickerState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _APKPickerState extends State<APKPicker> { | ||||||
|  |   String? apkUrl; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     apkUrl ??= widget.initVal; | ||||||
|  |     return AlertDialog( | ||||||
|  |       scrollable: true, | ||||||
|  |       title: Text(tr('pickAnAPK')), | ||||||
|  |       content: Column(children: [ | ||||||
|  |         Text(tr('appHasMoreThanOnePackage', args: [widget.app.name])), | ||||||
|  |         const SizedBox(height: 16), | ||||||
|  |         ...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: Text(tr('cancel'))), | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               HapticFeedback.selectionClick(); | ||||||
|  |               Navigator.of(context).pop(apkUrl); | ||||||
|  |             }, | ||||||
|  |             child: Text(tr('continue'))) | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class APKOriginWarningDialog extends StatefulWidget { | ||||||
|  |   const APKOriginWarningDialog( | ||||||
|  |       {super.key, required this.sourceUrl, required this.apkUrl}); | ||||||
|  |  | ||||||
|  |   final String sourceUrl; | ||||||
|  |   final String apkUrl; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<APKOriginWarningDialog> createState() => _APKOriginWarningDialogState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> { | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AlertDialog( | ||||||
|  |       scrollable: true, | ||||||
|  |       title: Text(tr('warning')), | ||||||
|  |       content: Text(tr('sourceIsXButPackageFromYPrompt', args: [ | ||||||
|  |         Uri.parse(widget.sourceUrl).host, | ||||||
|  |         Uri.parse(widget.apkUrl).host | ||||||
|  |       ])), | ||||||
|  |       actions: [ | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               Navigator.of(context).pop(null); | ||||||
|  |             }, | ||||||
|  |             child: Text(tr('cancel'))), | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               HapticFeedback.selectionClick(); | ||||||
|  |               Navigator.of(context).pop(true); | ||||||
|  |             }, | ||||||
|  |             child: Text(tr('continue'))) | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										112
									
								
								lib/providers/logs_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,112 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/foundation.dart'; | ||||||
|  | import 'package:sqflite/sqflite.dart'; | ||||||
|  |  | ||||||
|  | const String logTable = 'logs'; | ||||||
|  | const String idColumn = '_id'; | ||||||
|  | const String levelColumn = 'level'; | ||||||
|  | const String messageColumn = 'message'; | ||||||
|  | const String timestampColumn = 'timestamp'; | ||||||
|  | const String dbPath = 'logs.db'; | ||||||
|  |  | ||||||
|  | enum LogLevels { debug, info, warning, error } | ||||||
|  |  | ||||||
|  | class Log { | ||||||
|  |   int? id; | ||||||
|  |   late LogLevels level; | ||||||
|  |   late String message; | ||||||
|  |   DateTime timestamp = DateTime.now(); | ||||||
|  |  | ||||||
|  |   Map<String, Object?> toMap() { | ||||||
|  |     var map = <String, Object?>{ | ||||||
|  |       idColumn: id, | ||||||
|  |       levelColumn: level.index, | ||||||
|  |       messageColumn: message, | ||||||
|  |       timestampColumn: timestamp.millisecondsSinceEpoch | ||||||
|  |     }; | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Log(this.message, this.level); | ||||||
|  |  | ||||||
|  |   Log.fromMap(Map<String, Object?> map) { | ||||||
|  |     id = map[idColumn] as int; | ||||||
|  |     level = LogLevels.values.elementAt(map[levelColumn] as int); | ||||||
|  |     message = map[messageColumn] as String; | ||||||
|  |     timestamp = | ||||||
|  |         DateTime.fromMillisecondsSinceEpoch(map[timestampColumn] as int); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toString() { | ||||||
|  |     return '${timestamp.toString()}: ${level.name}: $message'; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class LogsProvider { | ||||||
|  |   LogsProvider({bool runDefaultClear = true}) { | ||||||
|  |     clear(before: DateTime.now().subtract(const Duration(days: 7))); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Database? db; | ||||||
|  |  | ||||||
|  |   Future<Database> getDB() async { | ||||||
|  |     db ??= await openDatabase(dbPath, version: 1, | ||||||
|  |         onCreate: (Database db, int version) async { | ||||||
|  |       await db.execute(''' | ||||||
|  | create table if not exists $logTable (  | ||||||
|  |   $idColumn integer primary key autoincrement,  | ||||||
|  |   $levelColumn integer not null, | ||||||
|  |   $messageColumn text not null, | ||||||
|  |   $timestampColumn integer not null) | ||||||
|  | '''); | ||||||
|  |     }); | ||||||
|  |     return db!; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<Log> add(String message, {LogLevels level = LogLevels.info}) async { | ||||||
|  |     Log l = Log(message, level); | ||||||
|  |     l.id = await (await getDB()).insert(logTable, l.toMap()); | ||||||
|  |     if (kDebugMode) { | ||||||
|  |       print(l); | ||||||
|  |     } | ||||||
|  |     return l; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<List<Log>> get({DateTime? before, DateTime? after}) async { | ||||||
|  |     var where = getWhereDates(before: before, after: after); | ||||||
|  |     return (await (await getDB()) | ||||||
|  |             .query(logTable, where: where.key, whereArgs: where.value)) | ||||||
|  |         .map((e) => Log.fromMap(e)) | ||||||
|  |         .toList(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<int> clear({DateTime? before, DateTime? after}) async { | ||||||
|  |     var where = getWhereDates(before: before, after: after); | ||||||
|  |     var res = await (await getDB()) | ||||||
|  |         .delete(logTable, where: where.key, whereArgs: where.value); | ||||||
|  |     if (res > 0) { | ||||||
|  |       add(plural('clearedNLogsBeforeXAfterY', res, | ||||||
|  |           namedArgs: {'before': before.toString(), 'after': after.toString()}, | ||||||
|  |           name: 'n')); | ||||||
|  |     } | ||||||
|  |     return res; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | MapEntry<String?, List<int>?> getWhereDates( | ||||||
|  |     {DateTime? before, DateTime? after}) { | ||||||
|  |   List<String> where = []; | ||||||
|  |   List<int> whereArgs = []; | ||||||
|  |   if (before != null) { | ||||||
|  |     where.add('$timestampColumn < ?'); | ||||||
|  |     whereArgs.add(before.millisecondsSinceEpoch); | ||||||
|  |   } | ||||||
|  |   if (after != null) { | ||||||
|  |     where.add('$timestampColumn > ?'); | ||||||
|  |     whereArgs.add(after.millisecondsSinceEpoch); | ||||||
|  |   } | ||||||
|  |   return whereArgs.isEmpty | ||||||
|  |       ? const MapEntry(null, null) | ||||||
|  |       : MapEntry(where.join(' and '), whereArgs); | ||||||
|  | } | ||||||
							
								
								
									
										178
									
								
								lib/providers/notifications_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,178 @@ | |||||||
|  | // 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'; | ||||||
|  |  | ||||||
|  | class ObtainiumNotification { | ||||||
|  |   late int id; | ||||||
|  |   late String title; | ||||||
|  |   late String message; | ||||||
|  |   late String channelCode; | ||||||
|  |   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.onlyAlertOnce = false}); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class UpdateNotification extends ObtainiumNotification { | ||||||
|  |   UpdateNotification(List<App> updates) | ||||||
|  |       : super( | ||||||
|  |             2, | ||||||
|  |             tr('updatesAvailable'), | ||||||
|  |             '', | ||||||
|  |             'UPDATES_AVAILABLE', | ||||||
|  |             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 | ||||||
|  |         ? tr('xWasUpdatedToY', | ||||||
|  |             args: [updates[0].name, updates[0].latestVersion]) | ||||||
|  |         : plural('xAndNMoreUpdatesInstalled', updates.length - 1, | ||||||
|  |             args: [updates[0].name]); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class ErrorCheckingUpdatesNotification extends ObtainiumNotification { | ||||||
|  |   ErrorCheckingUpdatesNotification(String error) | ||||||
|  |       : super( | ||||||
|  |             5, | ||||||
|  |             tr('errorCheckingUpdates'), | ||||||
|  |             error, | ||||||
|  |             'BG_UPDATE_CHECK_ERROR', | ||||||
|  |             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, | ||||||
|  |     tr('completeAppInstallation'), | ||||||
|  |     tr('obtainiumMustBeOpenToInstallApps'), | ||||||
|  |     'COMPLETE_INSTALL', | ||||||
|  |     tr('completeAppInstallation'), | ||||||
|  |     tr('completeAppInstallationNotifDescription'), | ||||||
|  |     Importance.max); | ||||||
|  |  | ||||||
|  | final checkingUpdatesNotification = ObtainiumNotification( | ||||||
|  |     4, | ||||||
|  |     tr('checkingForUpdates'), | ||||||
|  |     '', | ||||||
|  |     'BG_UPDATE_CHECK', | ||||||
|  |     tr('checkingForUpdates'), | ||||||
|  |     tr('checkingForUpdatesNotifDescription'), | ||||||
|  |     Importance.min); | ||||||
|  |  | ||||||
|  | class NotificationsProvider { | ||||||
|  |   FlutterLocalNotificationsPlugin notifications = | ||||||
|  |       FlutterLocalNotificationsPlugin(); | ||||||
|  |  | ||||||
|  |   bool isInitialized = false; | ||||||
|  |  | ||||||
|  |   Map<Importance, Priority> importanceToPriority = { | ||||||
|  |     Importance.defaultImportance: Priority.defaultPriority, | ||||||
|  |     Importance.high: Priority.high, | ||||||
|  |     Importance.low: Priority.low, | ||||||
|  |     Importance.max: Priority.max, | ||||||
|  |     Importance.min: Priority.min, | ||||||
|  |     Importance.none: Priority.min, | ||||||
|  |     Importance.unspecified: Priority.defaultPriority | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   Future<void> initialize() async { | ||||||
|  |     isInitialized = await notifications.initialize(const InitializationSettings( | ||||||
|  |             android: AndroidInitializationSettings('ic_notification'))) ?? | ||||||
|  |         false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> cancel(int id) async { | ||||||
|  |     if (!isInitialized) { | ||||||
|  |       await initialize(); | ||||||
|  |     } | ||||||
|  |     await notifications.cancel(id); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> notifyRaw( | ||||||
|  |       int id, | ||||||
|  |       String title, | ||||||
|  |       String message, | ||||||
|  |       String channelCode, | ||||||
|  |       String channelName, | ||||||
|  |       String channelDescription, | ||||||
|  |       Importance importance, | ||||||
|  |       {bool cancelExisting = false, | ||||||
|  |       int? progPercent, | ||||||
|  |       bool onlyAlertOnce = false}) async { | ||||||
|  |     if (cancelExisting) { | ||||||
|  |       await cancel(id); | ||||||
|  |     } | ||||||
|  |     if (!isInitialized) { | ||||||
|  |       await initialize(); | ||||||
|  |     } | ||||||
|  |     await notifications.show( | ||||||
|  |         id, | ||||||
|  |         title, | ||||||
|  |         message, | ||||||
|  |         NotificationDetails( | ||||||
|  |             android: AndroidNotificationDetails(channelCode, channelName, | ||||||
|  |                 channelDescription: channelDescription, | ||||||
|  |                 importance: importance, | ||||||
|  |                 priority: importanceToPriority[importance]!, | ||||||
|  |                 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, onlyAlertOnce: notif.onlyAlertOnce); | ||||||
|  | } | ||||||
							
								
								
									
										147
									
								
								lib/providers/settings_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,147 @@ | |||||||
|  | // 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 } | ||||||
|  |  | ||||||
|  | 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; | ||||||
|  |  | ||||||
|  |   String sourceUrl = 'https://github.com/ImranR98/Obtainium'; | ||||||
|  |  | ||||||
|  |   // Not done in constructor as we want to be able to await it | ||||||
|  |   Future<void> initializeSettings() async { | ||||||
|  |     prefs = await SharedPreferences.getInstance(); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   ThemeSettings get theme { | ||||||
|  |     return ThemeSettings | ||||||
|  |         .values[prefs?.getInt('theme') ?? ThemeSettings.system.index]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set theme(ThemeSettings t) { | ||||||
|  |     prefs?.setInt('theme', t.index); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   ColourSettings get colour { | ||||||
|  |     return ColourSettings | ||||||
|  |         .values[prefs?.getInt('colour') ?? ColourSettings.basic.index]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set colour(ColourSettings t) { | ||||||
|  |     prefs?.setInt('colour', t.index); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   int get updateInterval { | ||||||
|  |     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) { | ||||||
|  |     prefs?.setInt('updateInterval', (min < 15 && min != 0) ? 15 : min); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   SortColumnSettings get sortColumn { | ||||||
|  |     return SortColumnSettings.values[ | ||||||
|  |         prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set sortColumn(SortColumnSettings s) { | ||||||
|  |     prefs?.setInt('sortColumn', s.index); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   SortOrderSettings get sortOrder { | ||||||
|  |     return SortOrderSettings.values[ | ||||||
|  |         prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set sortOrder(SortOrderSettings s) { | ||||||
|  |     prefs?.setInt('sortOrder', s.index); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool checkAndFlipFirstRun() { | ||||||
|  |     bool result = prefs?.getBool('firstRun') ?? true; | ||||||
|  |     if (result) { | ||||||
|  |       prefs?.setBool('firstRun', false); | ||||||
|  |     } | ||||||
|  |     return result; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> getInstallPermission() async { | ||||||
|  |     while (!(await Permission.requestInstallPackages.isGranted)) { | ||||||
|  |       // Explicit request as InstallPlugin request sometimes bugged | ||||||
|  |       Fluttertoast.showToast( | ||||||
|  |           msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG); | ||||||
|  |       if ((await Permission.requestInstallPackages.request()) == | ||||||
|  |           PermissionStatus.granted) { | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool get showAppWebpage { | ||||||
|  |     return prefs?.getBool('showAppWebpage') ?? false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set showAppWebpage(bool show) { | ||||||
|  |     prefs?.setBool('showAppWebpage', show); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool get pinUpdates { | ||||||
|  |     return prefs?.getBool('pinUpdates') ?? true; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set pinUpdates(bool show) { | ||||||
|  |     prefs?.setBool('pinUpdates', show); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   String? getSettingString(String settingId) { | ||||||
|  |     return prefs?.getString(settingId); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void setSettingString(String settingId, String value) { | ||||||
|  |     prefs?.setString(settingId, value); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										331
									
								
								lib/providers/source_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,331 @@ | |||||||
|  | // Defines App sources and provides functions used to interact with them | ||||||
|  | // AppSource is an abstract class with a concrete implementation for each source | ||||||
|  |  | ||||||
|  | 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 { | ||||||
|  |   late String author; | ||||||
|  |   late String name; | ||||||
|  |  | ||||||
|  |   AppNames(this.author, this.name); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class APKDetails { | ||||||
|  |   late String version; | ||||||
|  |   late String versionFromSource; | ||||||
|  |   late bool isStandardVersion; | ||||||
|  |   late List<String> apkUrls; | ||||||
|  |  | ||||||
|  |   APKDetails(this.versionFromSource, this.apkUrls) { | ||||||
|  |     var temp = extractStandardVersionName(versionFromSource); | ||||||
|  |     isStandardVersion = temp != null; | ||||||
|  |     version = temp ?? versionFromSource; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class App { | ||||||
|  |   late String id; | ||||||
|  |   late String url; | ||||||
|  |   late String author; | ||||||
|  |   late String name; | ||||||
|  |   String? installedVersion; | ||||||
|  |   late String latestVersion; | ||||||
|  |   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, | ||||||
|  |       this.author, | ||||||
|  |       this.name, | ||||||
|  |       this.installedVersion, | ||||||
|  |       this.latestVersion, | ||||||
|  |       this.apkUrls, | ||||||
|  |       this.preferredApkIndex, | ||||||
|  |       this.additionalData, | ||||||
|  |       this.lastUpdateCheck, | ||||||
|  |       this.pinned, | ||||||
|  |       this.trackOnly, | ||||||
|  |       this.enhancedVersionDetection); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toString() { | ||||||
|  |     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( | ||||||
|  |       json['id'] as String, | ||||||
|  |       json['url'] as String, | ||||||
|  |       json['author'] as String, | ||||||
|  |       json['name'] as String, | ||||||
|  |       json['installedVersion'] == null | ||||||
|  |           ? null | ||||||
|  |           : json['installedVersion'] as String, | ||||||
|  |       json['latestVersion'] as String, | ||||||
|  |       json['apkUrls'] == null | ||||||
|  |           ? [] | ||||||
|  |           : List<String>.from(jsonDecode(json['apkUrls'])), | ||||||
|  |       json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, | ||||||
|  |       json['additionalData'] == null | ||||||
|  |           ? SourceProvider() | ||||||
|  |               .getSource(json['url']) | ||||||
|  |               .additionalSourceAppSpecificDefaults | ||||||
|  |           : List<String>.from(jsonDecode(json['additionalData'])), | ||||||
|  |       json['lastUpdateCheck'] == null | ||||||
|  |           ? null | ||||||
|  |           : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']), | ||||||
|  |       json['pinned'] ?? false, | ||||||
|  |       json['trackOnly'] ?? false, | ||||||
|  |       json['enhancedVersionDetection'] ?? false); | ||||||
|  |  | ||||||
|  |   Map<String, dynamic> toJson() => { | ||||||
|  |         'id': id, | ||||||
|  |         'url': url, | ||||||
|  |         'author': author, | ||||||
|  |         'name': name, | ||||||
|  |         'installedVersion': installedVersion, | ||||||
|  |         'latestVersion': latestVersion, | ||||||
|  |         'apkUrls': jsonEncode(apkUrls), | ||||||
|  |         'preferredApkIndex': preferredApkIndex, | ||||||
|  |         'additionalData': jsonEncode(additionalData), | ||||||
|  |         'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch, | ||||||
|  |         'pinned': pinned, | ||||||
|  |         'trackOnly': trackOnly, | ||||||
|  |         'enhancedVersionDetection': enhancedVersionDetection | ||||||
|  |       }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 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'; | ||||||
|  |   } | ||||||
|  |   if (url.toLowerCase().indexOf('https://www.') == 0) { | ||||||
|  |     url = 'https://${url.substring(12)}'; | ||||||
|  |   } | ||||||
|  |   url = url | ||||||
|  |       .split('/') | ||||||
|  |       .where((e) => e.isNotEmpty) | ||||||
|  |       .join('/') | ||||||
|  |       .replaceFirst(':/', '://'); | ||||||
|  |   return url; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const String noAPKFound = 'No APK found'; | ||||||
|  |  | ||||||
|  | List<String> getLinksFromParsedHTML( | ||||||
|  |         Document dom, RegExp hrefPattern, String prependToLinks) => | ||||||
|  |     dom | ||||||
|  |         .querySelectorAll('a') | ||||||
|  |         .where((element) { | ||||||
|  |           if (element.attributes['href'] == null) return false; | ||||||
|  |           return hrefPattern.hasMatch(element.attributes['href']!); | ||||||
|  |         }) | ||||||
|  |         .map((e) => '$prependToLinks${e.attributes['href']!}') | ||||||
|  |         .toList(); | ||||||
|  |  | ||||||
|  | class AppSource { | ||||||
|  |   late String host; | ||||||
|  |   bool enforceTrackOnly = false; | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     throw NotImplementedError(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<APKDetails> getLatestAPKDetails( | ||||||
|  |       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; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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]+)+${strict ? '\$' : ''}') | ||||||
|  |           .firstMatch(version); | ||||||
|  |   return match != null ? version.substring(match.start, match.end) : null; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | abstract class MassAppUrlSource { | ||||||
|  |   late String name; | ||||||
|  |   late List<String> requiredArgs; | ||||||
|  |   Future<Map<String, String>> getUrlsWithDescriptions(List<String> args); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SourceProvider { | ||||||
|  |   // Add more source classes here so they are available via the service | ||||||
|  |   List<AppSource> sources = [ | ||||||
|  |     GitHub(), | ||||||
|  |     GitLab(), | ||||||
|  |     FDroid(), | ||||||
|  |     IzzyOnDroid(), | ||||||
|  |     Mullvad(), | ||||||
|  |     Signal(), | ||||||
|  |     SourceForge(), | ||||||
|  |     APKMirror() | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   // Add more mass url source classes here so they are available via the service | ||||||
|  |   List<MassAppUrlSource> massUrlSources = [GitHubStars()]; | ||||||
|  |  | ||||||
|  |   AppSource getSource(String url) { | ||||||
|  |     url = preStandardizeUrl(url); | ||||||
|  |     AppSource? source; | ||||||
|  |     for (var s in sources) { | ||||||
|  |       if (url.toLowerCase().contains('://${s.host}')) { | ||||||
|  |         source = s; | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if (source == null) { | ||||||
|  |       throw UnsupportedURLError(); | ||||||
|  |     } | ||||||
|  |     return source; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool ifSourceAppsRequireAdditionalData(AppSource source) { | ||||||
|  |     for (var row in source.additionalSourceAppSpecificFormItems) { | ||||||
|  |       for (var element in row) { | ||||||
|  |         if (element.required) { | ||||||
|  |           return true; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   String generateTempID(AppNames names, AppSource source) => | ||||||
|  |       '${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}'; | ||||||
|  |  | ||||||
|  |   bool isTempId(String id) { | ||||||
|  |     List<String> parts = id.split('_'); | ||||||
|  |     if (parts.length < 3) { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  |     for (int i = 0; i < parts.length - 1; i++) { | ||||||
|  |       if (RegExp('.*[A-Z].*').hasMatch(parts[i])) { | ||||||
|  |         // TODO: RegEx won't work for non-eng chars | ||||||
|  |         return false; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return sources.map((e) => e.host).contains(parts.last); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<App> getApp(AppSource source, String url, List<String> additionalData, | ||||||
|  |       {String name = '', | ||||||
|  |       String? id, | ||||||
|  |       bool pinned = false, | ||||||
|  |       bool trackOnly = false, | ||||||
|  |       String? installedVersion}) async { | ||||||
|  |     String standardUrl = source.standardizeURL(preStandardizeUrl(url)); | ||||||
|  |     AppNames names = source.getAppNames(standardUrl); | ||||||
|  |     APKDetails apk = await source | ||||||
|  |         .getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly); | ||||||
|  |     if (apk.apkUrls.isEmpty && !trackOnly) { | ||||||
|  |       throw NoAPKError(); | ||||||
|  |     } | ||||||
|  |     bool enhancedVersionDetection = apk.isStandardVersion && | ||||||
|  |         installedVersion != null && | ||||||
|  |         extractStandardVersionName(installedVersion, strict: true) != null; | ||||||
|  |     if (!enhancedVersionDetection) { | ||||||
|  |       apk.version = apk.versionFromSource; | ||||||
|  |     } | ||||||
|  |     String apkVersion = apk.version.replaceAll('/', '-'); | ||||||
|  |     return App( | ||||||
|  |         id ?? | ||||||
|  |             source.tryInferringAppId(standardUrl) ?? | ||||||
|  |             generateTempID(names, source), | ||||||
|  |         standardUrl, | ||||||
|  |         names.author[0].toUpperCase() + names.author.substring(1), | ||||||
|  |         name.trim().isNotEmpty | ||||||
|  |             ? name | ||||||
|  |             : names.name[0].toUpperCase() + names.name.substring(1), | ||||||
|  |         installedVersion, | ||||||
|  |         apkVersion, | ||||||
|  |         apk.apkUrls, | ||||||
|  |         apk.apkUrls.length - 1, | ||||||
|  |         additionalData, | ||||||
|  |         DateTime.now(), | ||||||
|  |         pinned, | ||||||
|  |         trackOnly, | ||||||
|  |         enhancedVersionDetection); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Returns errors in [results, errors] instead of throwing them | ||||||
|  |   Future<List<dynamic>> getAppsByURLNaive(List<String> urls, | ||||||
|  |       {List<String> ignoreUrls = const []}) async { | ||||||
|  |     List<App> apps = []; | ||||||
|  |     Map<String, dynamic> errors = {}; | ||||||
|  |     for (var url in urls.where((element) => !ignoreUrls.contains(element))) { | ||||||
|  |       try { | ||||||
|  |         var source = getSource(url); | ||||||
|  |         apps.add(await getApp( | ||||||
|  |             source, url, source.additionalSourceAppSpecificDefaults)); | ||||||
|  |       } catch (e) { | ||||||
|  |         errors.addAll(<String, dynamic>{url: e}); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return [apps, errors]; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,233 +0,0 @@ | |||||||
| // Provider that manages App-related state and provides functions to retrieve App info download/install Apps |  | ||||||
|  |  | ||||||
| import 'dart:async'; |  | ||||||
| import 'dart:convert'; |  | ||||||
| import 'dart:io'; |  | ||||||
| import 'dart:ui'; |  | ||||||
|  |  | ||||||
| import 'package:flutter/material.dart'; |  | ||||||
| import 'package:path_provider/path_provider.dart'; |  | ||||||
| import 'package:flutter_fgbg/flutter_fgbg.dart'; |  | ||||||
| import 'package:flutter_local_notifications/flutter_local_notifications.dart'; |  | ||||||
| import 'package:obtainium/services/source_service.dart'; |  | ||||||
| import 'package:http/http.dart'; |  | ||||||
| import 'package:install_plugin_v2/install_plugin_v2.dart'; |  | ||||||
|  |  | ||||||
| class AppInMemory { |  | ||||||
|   late App app; |  | ||||||
|   double? downloadProgress; |  | ||||||
|  |  | ||||||
|   AppInMemory(this.app, this.downloadProgress); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class AppsProvider with ChangeNotifier { |  | ||||||
|   // In memory App state (should always be kept in sync with local storage versions) |  | ||||||
|   Map<String, AppInMemory> apps = {}; |  | ||||||
|   bool loadingApps = false; |  | ||||||
|   bool gettingUpdates = false; |  | ||||||
|  |  | ||||||
|   // Notifications plugin for downloads |  | ||||||
|   FlutterLocalNotificationsPlugin downloaderNotifications = |  | ||||||
|       FlutterLocalNotificationsPlugin(); |  | ||||||
|  |  | ||||||
|   // Variables to keep track of the app foreground status (installs can't run in the background) |  | ||||||
|   bool isForeground = true; |  | ||||||
|   StreamSubscription<FGBGType>? foregroundSubscription; |  | ||||||
|  |  | ||||||
|   AppsProvider({bool bg = false}) { |  | ||||||
|     initializeNotifs(); |  | ||||||
|     // Subscribe to changes in the app foreground status |  | ||||||
|     foregroundSubscription = FGBGEvents.stream.listen((event) async { |  | ||||||
|       isForeground = event == FGBGType.foreground; |  | ||||||
|       if (isForeground) await loadApps(); |  | ||||||
|     }); |  | ||||||
|     loadApps(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> initializeNotifs() async { |  | ||||||
|     // Initialize the notifications service |  | ||||||
|     await downloaderNotifications.initialize(const InitializationSettings( |  | ||||||
|         android: AndroidInitializationSettings('ic_notification'))); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> notify(int id, String title, String message, String channelCode, |  | ||||||
|       String channelName, String channelDescription, |  | ||||||
|       {bool important = true}) { |  | ||||||
|     return downloaderNotifications.show( |  | ||||||
|         id, |  | ||||||
|         title, |  | ||||||
|         message, |  | ||||||
|         NotificationDetails( |  | ||||||
|             android: AndroidNotificationDetails(channelCode, channelName, |  | ||||||
|                 channelDescription: channelDescription, |  | ||||||
|                 importance: important ? Importance.max : Importance.min, |  | ||||||
|                 priority: important ? Priority.max : Priority.min, |  | ||||||
|                 groupKey: 'dev.imranr.obtainium.$channelCode'))); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   // Given a App (assumed valid), initiate an APK download (will trigger install callback when complete) |  | ||||||
|   Future<void> downloadAndInstallLatestApp(String appId) async { |  | ||||||
|     if (apps[appId] == null) { |  | ||||||
|       throw 'App not found'; |  | ||||||
|     } |  | ||||||
|     StreamedResponse response = |  | ||||||
|         await Client().send(Request('GET', Uri.parse(apps[appId]!.app.apkUrl))); |  | ||||||
|     File downloadFile = |  | ||||||
|         File('${(await getExternalStorageDirectory())!.path}/$appId.apk'); |  | ||||||
|     if (downloadFile.existsSync()) { |  | ||||||
|       downloadFile.deleteSync(); |  | ||||||
|     } |  | ||||||
|     var length = response.contentLength; |  | ||||||
|     var received = 0; |  | ||||||
|     var sink = downloadFile.openWrite(); |  | ||||||
|  |  | ||||||
|     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'; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (!isForeground) { |  | ||||||
|       await downloaderNotifications.cancel(1); |  | ||||||
|       await notify( |  | ||||||
|           1, |  | ||||||
|           'Complete App Installation', |  | ||||||
|           'Obtainium must be open to install Apps', |  | ||||||
|           'COMPLETE_INSTALL', |  | ||||||
|           'Complete App Installation', |  | ||||||
|           'Asks the user to return to Obtanium to finish installing an App'); |  | ||||||
|       while (await FGBGEvents.stream.first != FGBGType.foreground) { |  | ||||||
|         // We need to wait for the App to come to the foreground to install it |  | ||||||
|         // Can't try to call install plugin in a background isolate (may not have worked anyways) because of: |  | ||||||
|         // https://github.com/flutter/flutter/issues/13937 |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Unfortunately this 'await' does not actually wait for the APK to finish installing |  | ||||||
|     // 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 |  | ||||||
|     await InstallPlugin.installApk(downloadFile.path, 'dev.imranr.obtainium'); |  | ||||||
|  |  | ||||||
|     apps[appId]!.app.installedVersion = apps[appId]!.app.latestVersion; |  | ||||||
|     saveApp(apps[appId]!.app); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<Directory> getAppsDir() async { |  | ||||||
|     Directory appsDir = Directory( |  | ||||||
|         '${(await getExternalStorageDirectory())?.path as String}/app_data'); |  | ||||||
|     if (!appsDir.existsSync()) { |  | ||||||
|       appsDir.createSync(); |  | ||||||
|     } |  | ||||||
|     return appsDir; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> deleteSavedAPKs() async { |  | ||||||
|     (await getExternalStorageDirectory()) |  | ||||||
|         ?.listSync() |  | ||||||
|         .where((element) => element.path.endsWith('.apk')) |  | ||||||
|         .forEach((element) { |  | ||||||
|       element.deleteSync(); |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> loadApps() async { |  | ||||||
|     loadingApps = true; |  | ||||||
|     notifyListeners(); |  | ||||||
|     List<FileSystemEntity> appFiles = (await getAppsDir()) |  | ||||||
|         .listSync() |  | ||||||
|         .where((item) => item.path.toLowerCase().endsWith('.json')) |  | ||||||
|         .toList(); |  | ||||||
|     apps.clear(); |  | ||||||
|     for (int i = 0; i < appFiles.length; i++) { |  | ||||||
|       App app = |  | ||||||
|           App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync())); |  | ||||||
|       apps.putIfAbsent(app.id, () => AppInMemory(app, null)); |  | ||||||
|     } |  | ||||||
|     loadingApps = false; |  | ||||||
|     notifyListeners(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> saveApp(App app) async { |  | ||||||
|     File('${(await getAppsDir()).path}/${app.id}.json') |  | ||||||
|         .writeAsStringSync(jsonEncode(app.toJson())); |  | ||||||
|     apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress), |  | ||||||
|         ifAbsent: () => AppInMemory(app, null)); |  | ||||||
|     notifyListeners(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> removeApp(String appId) async { |  | ||||||
|     File file = File('${(await getAppsDir()).path}/$appId.json'); |  | ||||||
|     if (file.existsSync()) { |  | ||||||
|       file.deleteSync(); |  | ||||||
|     } |  | ||||||
|     if (apps.containsKey(appId)) { |  | ||||||
|       apps.remove(appId); |  | ||||||
|     } |  | ||||||
|     notifyListeners(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   bool checkAppObjectForUpdate(App app) { |  | ||||||
|     if (!apps.containsKey(app.id)) { |  | ||||||
|       throw 'App not found'; |  | ||||||
|     } |  | ||||||
|     return app.latestVersion != apps[app.id]?.app.installedVersion; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<App?> getUpdate(String appId) async { |  | ||||||
|     App? currentApp = apps[appId]!.app; |  | ||||||
|     App newApp = await SourceService().getApp(currentApp.url); |  | ||||||
|     if (newApp.latestVersion != currentApp.latestVersion) { |  | ||||||
|       newApp.installedVersion = currentApp.installedVersion; |  | ||||||
|       await saveApp(newApp); |  | ||||||
|       return newApp; |  | ||||||
|     } |  | ||||||
|     return null; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<List<App>> checkUpdates() async { |  | ||||||
|     List<App> updates = []; |  | ||||||
|     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); |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|       gettingUpdates = false; |  | ||||||
|     } |  | ||||||
|     return updates; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   List<String> getExistingUpdates() { |  | ||||||
|     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); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|     return updateAppIds; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   void dispose() { |  | ||||||
|     IsolateNameServer.removePortNameMapping('downloader_send_port'); |  | ||||||
|     foregroundSubscription?.cancel(); |  | ||||||
|     super.dispose(); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -1,47 +0,0 @@ | |||||||
| import 'package:flutter/material.dart'; |  | ||||||
| import 'package:shared_preferences/shared_preferences.dart'; |  | ||||||
|  |  | ||||||
| enum ThemeSettings { system, light, dark } |  | ||||||
|  |  | ||||||
| enum ColourSettings { basic, materialYou } |  | ||||||
|  |  | ||||||
| class SettingsProvider with ChangeNotifier { |  | ||||||
|   SharedPreferences? prefs; |  | ||||||
|  |  | ||||||
|   String sourceUrl = 'https://github.com/ImranR98/Obtainium'; |  | ||||||
|  |  | ||||||
|   // Not done in constructor as we want to be able to await it |  | ||||||
|   Future<void> initializeSettings() async { |  | ||||||
|     prefs = await SharedPreferences.getInstance(); |  | ||||||
|     notifyListeners(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   ThemeSettings get theme { |  | ||||||
|     return ThemeSettings |  | ||||||
|         .values[prefs?.getInt('theme') ?? ThemeSettings.system.index]; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   set theme(ThemeSettings t) { |  | ||||||
|     print(t); |  | ||||||
|     prefs?.setInt('theme', t.index); |  | ||||||
|     notifyListeners(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   ColourSettings get colour { |  | ||||||
|     return ColourSettings |  | ||||||
|         .values[prefs?.getInt('colour') ?? ColourSettings.basic.index]; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   set colour(ColourSettings t) { |  | ||||||
|     prefs?.setInt('colour', t.index); |  | ||||||
|     notifyListeners(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   checkAndFlipFirstRun() { |  | ||||||
|     bool result = prefs?.getBool('firstRun') ?? true; |  | ||||||
|     if (result) { |  | ||||||
|       prefs?.setBool('firstRun', false); |  | ||||||
|     } |  | ||||||
|     return result; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -1,166 +0,0 @@ | |||||||
| // Exposes functions related to interacting with App sources and retrieving App info |  | ||||||
| // Stateless - not a provider |  | ||||||
|  |  | ||||||
| import 'package:http/http.dart'; |  | ||||||
| import 'package:html/parser.dart'; |  | ||||||
|  |  | ||||||
| // Sub-classes used in App Source |  | ||||||
|  |  | ||||||
| class AppNames { |  | ||||||
|   late String author; |  | ||||||
|   late String name; |  | ||||||
|  |  | ||||||
|   AppNames(this.author, this.name); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class APKDetails { |  | ||||||
|   late String version; |  | ||||||
|   late String downloadUrl; |  | ||||||
|  |  | ||||||
|   APKDetails(this.version, this.downloadUrl); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // App Source abstract class (diff. implementations for GitHub, GitLab, etc.) |  | ||||||
|  |  | ||||||
| abstract class AppSource { |  | ||||||
|   late String sourceId; |  | ||||||
|   String standardizeURL(String url); |  | ||||||
|   Future<APKDetails> getLatestAPKDetails(String standardUrl); |  | ||||||
|   AppNames getAppNames(String standardUrl); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| escapeRegEx(String s) { |  | ||||||
|   return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { |  | ||||||
|     return "\\${x[0]}"; |  | ||||||
|   }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // App class |  | ||||||
|  |  | ||||||
| class App { |  | ||||||
|   late String id; |  | ||||||
|   late String url; |  | ||||||
|   late String author; |  | ||||||
|   late String name; |  | ||||||
|   String? installedVersion; |  | ||||||
|   late String latestVersion; |  | ||||||
|   late String apkUrl; |  | ||||||
|   App(this.id, this.url, this.author, this.name, this.installedVersion, |  | ||||||
|       this.latestVersion, this.apkUrl); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   String toString() { |  | ||||||
|     return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrl'; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   factory App.fromJson(Map<String, dynamic> json) => App( |  | ||||||
|       json['id'] as String, |  | ||||||
|       json['url'] as String, |  | ||||||
|       json['author'] as String, |  | ||||||
|       json['name'] as String, |  | ||||||
|       json['installedVersion'] == null |  | ||||||
|           ? null |  | ||||||
|           : json['installedVersion'] as String, |  | ||||||
|       json['latestVersion'] as String, |  | ||||||
|       json['apkUrl'] as String); |  | ||||||
|  |  | ||||||
|   Map<String, dynamic> toJson() => { |  | ||||||
|         'id': id, |  | ||||||
|         'url': url, |  | ||||||
|         'author': author, |  | ||||||
|         'name': name, |  | ||||||
|         'installedVersion': installedVersion, |  | ||||||
|         'latestVersion': latestVersion, |  | ||||||
|         'apkUrl': apkUrl, |  | ||||||
|       }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // Specific App Source classes |  | ||||||
|  |  | ||||||
| class GitHub implements AppSource { |  | ||||||
|   @override |  | ||||||
|   String sourceId = 'github'; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   String standardizeURL(String url) { |  | ||||||
|     RegExp standardUrlRegEx = RegExp(r'^https?://github.com/[^/]*/[^/]*'); |  | ||||||
|     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); |  | ||||||
|     if (match == null) { |  | ||||||
|       throw 'Not a valid URL'; |  | ||||||
|     } |  | ||||||
|     return url.substring(0, match.end); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   String convertURL(String url, String replaceText) { |  | ||||||
|     int tempInd1 = url.indexOf('://') + 3; |  | ||||||
|     int tempInd2 = url.substring(tempInd1).indexOf('/') + tempInd1; |  | ||||||
|     return '${url.substring(0, tempInd1)}$replaceText${url.substring(tempInd2)}'; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Future<APKDetails> getLatestAPKDetails(String standardUrl) async { |  | ||||||
|     Response res = await get(Uri.parse('$standardUrl/releases/latest')); |  | ||||||
|     if (res.statusCode == 200) { |  | ||||||
|       var standardUri = Uri.parse(standardUrl); |  | ||||||
|       var parsedHtml = parse(res.body); |  | ||||||
|       var apkUrlList = parsedHtml.querySelectorAll('a').where((element) { |  | ||||||
|         return RegExp( |  | ||||||
|                 '^${escapeRegEx(standardUri.path)}/releases/download/*/(?!/).*.apk\$', |  | ||||||
|                 caseSensitive: false) |  | ||||||
|             .hasMatch(element.attributes['href']!); |  | ||||||
|       }).toList(); |  | ||||||
|       String? version = parsedHtml |  | ||||||
|           .querySelector('.octicon-tag') |  | ||||||
|           ?.nextElementSibling |  | ||||||
|           ?.innerHtml |  | ||||||
|           .trim(); |  | ||||||
|       if (apkUrlList.isEmpty || version == null) { |  | ||||||
|         throw 'No APK found'; |  | ||||||
|       } |  | ||||||
|       return APKDetails( |  | ||||||
|           version, '${standardUri.origin}${apkUrlList[0].attributes['href']!}'); |  | ||||||
|     } else { |  | ||||||
|       throw 'Unable to fetch release info'; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   AppNames getAppNames(String standardUrl) { |  | ||||||
|     String temp = standardUrl.substring(standardUrl.indexOf('://') + 3); |  | ||||||
|     List<String> names = temp.substring(temp.indexOf('/') + 1).split('/'); |  | ||||||
|     return AppNames(names[0], names[1]); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class SourceService { |  | ||||||
|   // Add more source classes here so they are available via the service |  | ||||||
|   AppSource github = GitHub(); |  | ||||||
|   AppSource getSource(String url) { |  | ||||||
|     if (url.toLowerCase().contains('://github.com')) { |  | ||||||
|       return github; |  | ||||||
|     } |  | ||||||
|     throw 'URL does not match a known source'; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<App> getApp(String url) async { |  | ||||||
|     if (url.toLowerCase().indexOf('http://') != 0 && |  | ||||||
|         url.toLowerCase().indexOf('https://') != 0) { |  | ||||||
|       url = 'https://$url'; |  | ||||||
|     } |  | ||||||
|     if (url.toLowerCase().indexOf('https://www.') == 0) { |  | ||||||
|       url = 'https://${url.substring(12)}'; |  | ||||||
|     } |  | ||||||
|     AppSource source = getSource(url); |  | ||||||
|     String standardUrl = source.standardizeURL(url); |  | ||||||
|     AppNames names = source.getAppNames(standardUrl); |  | ||||||
|     APKDetails apk = await source.getLatestAPKDetails(standardUrl); |  | ||||||
|     return App( |  | ||||||
|         '${names.author}_${names.name}_${source.sourceId}', |  | ||||||
|         standardUrl, |  | ||||||
|         names.author[0].toUpperCase() + names.author.substring(1), |  | ||||||
|         names.name[0].toUpperCase() + names.name.substring(1), |  | ||||||
|         null, |  | ||||||
|         apk.version, |  | ||||||
|         apk.downloadUrl); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
							
								
								
									
										272
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						| @@ -1,13 +1,27 @@ | |||||||
| # Generated by pub | # Generated by pub | ||||||
| # See https://dart.dev/tools/pub/glossary#lockfile | # See https://dart.dev/tools/pub/glossary#lockfile | ||||||
| packages: | packages: | ||||||
|  |   android_alarm_manager_plus: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: android_alarm_manager_plus | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "2.1.0" | ||||||
|  |   animations: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: animations | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "2.0.7" | ||||||
|   archive: |   archive: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: archive |       name: archive | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.3.1" |     version: "3.3.5" | ||||||
|   args: |   args: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -64,6 +78,20 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.16.0" |     version: "1.16.0" | ||||||
|  |   convert: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: convert | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.1.1" | ||||||
|  |   cross_file: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: cross_file | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "0.3.3+2" | ||||||
|   crypto: |   crypto: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -92,13 +120,41 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.7.8" |     version: "0.7.8" | ||||||
|  |   device_info_plus: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: device_info_plus | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "8.0.0" | ||||||
|  |   device_info_plus_platform_interface: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: device_info_plus_platform_interface | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "7.0.0" | ||||||
|   dynamic_color: |   dynamic_color: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
|       name: dynamic_color |       name: dynamic_color | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.5.3" |     version: "1.5.4" | ||||||
|  |   easy_localization: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: easy_localization | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.1" | ||||||
|  |   easy_logger: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: easy_logger | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "0.0.2" | ||||||
|   fake_async: |   fake_async: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -120,6 +176,13 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "6.1.4" |     version: "6.1.4" | ||||||
|  |   file_picker: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: file_picker | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "5.2.3" | ||||||
|   flutter: |   flutter: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: flutter |     description: flutter | ||||||
| @@ -131,14 +194,14 @@ packages: | |||||||
|       name: flutter_fgbg |       name: flutter_fgbg | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.2.0" |     version: "0.2.2" | ||||||
|   flutter_launcher_icons: |   flutter_launcher_icons: | ||||||
|     dependency: "direct dev" |     dependency: "direct dev" | ||||||
|     description: |     description: | ||||||
|       name: flutter_launcher_icons |       name: flutter_launcher_icons | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.10.0" |     version: "0.11.0" | ||||||
|   flutter_lints: |   flutter_lints: | ||||||
|     dependency: "direct dev" |     dependency: "direct dev" | ||||||
|     description: |     description: | ||||||
| @@ -152,21 +215,33 @@ packages: | |||||||
|       name: flutter_local_notifications |       name: flutter_local_notifications | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "9.8.0+1" |     version: "12.0.4" | ||||||
|   flutter_local_notifications_linux: |   flutter_local_notifications_linux: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: flutter_local_notifications_linux |       name: flutter_local_notifications_linux | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.5.1" |     version: "2.0.0" | ||||||
|   flutter_local_notifications_platform_interface: |   flutter_local_notifications_platform_interface: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: flutter_local_notifications_platform_interface |       name: flutter_local_notifications_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "5.0.0" |     version: "6.0.0" | ||||||
|  |   flutter_localizations: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: flutter | ||||||
|  |     source: sdk | ||||||
|  |     version: "0.0.0" | ||||||
|  |   flutter_plugin_android_lifecycle: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: flutter_plugin_android_lifecycle | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "2.0.7" | ||||||
|   flutter_test: |   flutter_test: | ||||||
|     dependency: "direct dev" |     dependency: "direct dev" | ||||||
|     description: flutter |     description: flutter | ||||||
| @@ -177,13 +252,20 @@ packages: | |||||||
|     description: flutter |     description: flutter | ||||||
|     source: sdk |     source: sdk | ||||||
|     version: "0.0.0" |     version: "0.0.0" | ||||||
|  |   fluttertoast: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: fluttertoast | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "8.1.1" | ||||||
|   html: |   html: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
|       name: html |       name: html | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.15.0" |     version: "0.15.1" | ||||||
|   http: |   http: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -197,14 +279,14 @@ packages: | |||||||
|       name: http_parser |       name: http_parser | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "4.0.1" |     version: "4.0.2" | ||||||
|   image: |   image: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: image |       name: image | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.2.0" |     version: "3.2.2" | ||||||
|   install_plugin_v2: |   install_plugin_v2: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -212,6 +294,20 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.0.0" |     version: "1.0.0" | ||||||
|  |   installed_apps: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: installed_apps | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "1.3.1" | ||||||
|  |   intl: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: intl | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "0.17.0" | ||||||
|   js: |   js: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -225,14 +321,14 @@ packages: | |||||||
|       name: json_annotation |       name: json_annotation | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "4.6.0" |     version: "4.7.0" | ||||||
|   lints: |   lints: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: lints |       name: lints | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.0" |     version: "2.0.1" | ||||||
|   matcher: |   matcher: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -246,7 +342,7 @@ packages: | |||||||
|       name: material_color_utilities |       name: material_color_utilities | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.2.0" |     version: "0.1.5" | ||||||
|   meta: |   meta: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -254,6 +350,13 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.8.0" |     version: "1.8.0" | ||||||
|  |   mime: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: mime | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "1.0.3" | ||||||
|   nested: |   nested: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -261,6 +364,20 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.0.0" |     version: "1.0.0" | ||||||
|  |   package_archive_info: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: package_archive_info | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "0.1.0" | ||||||
|  |   package_info: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: package_info | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "2.0.2" | ||||||
|   path: |   path: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -281,7 +398,7 @@ packages: | |||||||
|       name: path_provider_android |       name: path_provider_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.20" |     version: "2.0.22" | ||||||
|   path_provider_ios: |   path_provider_ios: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -309,21 +426,56 @@ packages: | |||||||
|       name: path_provider_platform_interface |       name: path_provider_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.4" |     version: "2.0.5" | ||||||
|   path_provider_windows: |   path_provider_windows: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: path_provider_windows |       name: path_provider_windows | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.2" |     version: "2.1.3" | ||||||
|  |   permission_handler: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: permission_handler | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "10.2.0" | ||||||
|  |   permission_handler_android: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: permission_handler_android | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "10.2.0" | ||||||
|  |   permission_handler_apple: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: permission_handler_apple | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     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.9.0" | ||||||
|  |   permission_handler_windows: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: permission_handler_windows | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "0.1.2" | ||||||
|   petitparser: |   petitparser: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: petitparser |       name: petitparser | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "5.0.0" |     version: "5.1.0" | ||||||
|   platform: |   platform: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -337,7 +489,14 @@ packages: | |||||||
|       name: plugin_platform_interface |       name: plugin_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.2" |     version: "2.1.3" | ||||||
|  |   pointycastle: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: pointycastle | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.6.2" | ||||||
|   process: |   process: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -351,7 +510,21 @@ packages: | |||||||
|       name: provider |       name: provider | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "6.0.3" |     version: "6.0.4" | ||||||
|  |   share_plus: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: share_plus | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "6.3.0" | ||||||
|  |   share_plus_platform_interface: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: share_plus_platform_interface | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.2.0" | ||||||
|   shared_preferences: |   shared_preferences: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -365,7 +538,7 @@ packages: | |||||||
|       name: shared_preferences_android |       name: shared_preferences_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.12" |     version: "2.0.14" | ||||||
|   shared_preferences_ios: |   shared_preferences_ios: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -393,7 +566,7 @@ packages: | |||||||
|       name: shared_preferences_platform_interface |       name: shared_preferences_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.0" |     version: "2.1.0" | ||||||
|   shared_preferences_web: |   shared_preferences_web: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -419,7 +592,21 @@ packages: | |||||||
|       name: source_span |       name: source_span | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.9.1" |     version: "1.9.0" | ||||||
|  |   sqflite: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: sqflite | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "2.2.1" | ||||||
|  |   sqflite_common: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: sqflite_common | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "2.4.0+2" | ||||||
|   stack_trace: |   stack_trace: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -441,6 +628,13 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.1.1" |     version: "1.1.1" | ||||||
|  |   synchronized: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: synchronized | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.0+3" | ||||||
|   term_glyph: |   term_glyph: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -461,7 +655,7 @@ packages: | |||||||
|       name: timezone |       name: timezone | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.8.0" |     version: "0.9.0" | ||||||
|   typed_data: |   typed_data: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -475,14 +669,14 @@ packages: | |||||||
|       name: url_launcher |       name: url_launcher | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "6.1.5" |     version: "6.1.7" | ||||||
|   url_launcher_android: |   url_launcher_android: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: url_launcher_android |       name: url_launcher_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "6.0.17" |     version: "6.0.22" | ||||||
|   url_launcher_ios: |   url_launcher_ios: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -510,7 +704,7 @@ packages: | |||||||
|       name: url_launcher_platform_interface |       name: url_launcher_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.0" |     version: "2.1.1" | ||||||
|   url_launcher_web: |   url_launcher_web: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -525,6 +719,13 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.0.1" |     version: "3.0.1" | ||||||
|  |   uuid: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: uuid | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.7" | ||||||
|   vector_math: |   vector_math: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -545,35 +746,28 @@ packages: | |||||||
|       name: webview_flutter_android |       name: webview_flutter_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.9.5" |     version: "2.10.4" | ||||||
|   webview_flutter_platform_interface: |   webview_flutter_platform_interface: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: webview_flutter_platform_interface |       name: webview_flutter_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.9.2" |     version: "1.9.5" | ||||||
|   webview_flutter_wkwebview: |   webview_flutter_wkwebview: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: webview_flutter_wkwebview |       name: webview_flutter_wkwebview | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.9.3" |     version: "2.9.5" | ||||||
|   win32: |   win32: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: win32 |       name: win32 | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.7.0" |     version: "3.1.2" | ||||||
|   workmanager: |  | ||||||
|     dependency: "direct main" |  | ||||||
|     description: |  | ||||||
|       name: workmanager |  | ||||||
|       url: "https://pub.dartlang.org" |  | ||||||
|     source: hosted |  | ||||||
|     version: "0.5.0" |  | ||||||
|   xdg_directories: |   xdg_directories: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -596,5 +790,5 @@ packages: | |||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.1.1" |     version: "3.1.1" | ||||||
| sdks: | sdks: | ||||||
|   dart: ">=2.19.0-79.0.dev <3.0.0" |   dart: ">=2.18.2 <3.0.0" | ||||||
|   flutter: ">=3.1.0-0.0.pre.1036" |   flutter: ">=3.3.0" | ||||||
|   | |||||||
							
								
								
									
										37
									
								
								pubspec.yaml
									
									
									
									
									
								
							
							
						
						| @@ -17,10 +17,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev | |||||||
| # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||||
| # In Windows, build-name is used as the major, minor, and patch parts | # In Windows, build-name is used as the major, minor, and patch parts | ||||||
| # of the product and file versions while build-number is used as the build suffix. | # of the product and file versions while build-number is used as the build suffix. | ||||||
| version: 0.1.2+3 # When changing this, update the tag in main() accordingly | version: 0.8.5+68 # When changing this, update the tag in main() accordingly | ||||||
|  |  | ||||||
| environment: | environment: | ||||||
|   sdk: '>=2.19.0-79.0.dev <3.0.0' |   sdk: '>=2.18.2 <3.0.0' | ||||||
|  |  | ||||||
| # Dependencies specify other packages that your package needs in order to work. | # Dependencies specify other packages that your package needs in order to work. | ||||||
| # To automatically upgrade your package dependencies to the latest versions | # To automatically upgrade your package dependencies to the latest versions | ||||||
| @@ -35,38 +35,48 @@ dependencies: | |||||||
|  |  | ||||||
|   # The following adds the Cupertino Icons font to your application. |   # The following adds the Cupertino Icons font to your application. | ||||||
|   # Use with the CupertinoIcons class for iOS style icons. |   # Use with the CupertinoIcons class for iOS style icons. | ||||||
|   cupertino_icons: ^1.0.2 |   cupertino_icons: ^1.0.5 | ||||||
|   path_provider: ^2.0.11 |   path_provider: ^2.0.11 | ||||||
|   flutter_fgbg: ^0.2.0 # Try removing reliance on this |   flutter_fgbg: ^0.2.0 # Try removing reliance on this | ||||||
|   flutter_local_notifications: ^9.8.0+1 |   flutter_local_notifications: ^12.0.0 | ||||||
|   provider: ^6.0.3 |   provider: ^6.0.3 | ||||||
|   http: ^0.13.5 |   http: ^0.13.5 | ||||||
|   webview_flutter: ^3.0.4 |   webview_flutter: ^3.0.4 | ||||||
|   workmanager: ^0.5.0 |   dynamic_color: ^1.5.4 | ||||||
|   dynamic_color: ^1.5.3 |  | ||||||
|   install_plugin_v2: ^1.0.0 # Try replacing this |  | ||||||
|   html: ^0.15.0 |   html: ^0.15.0 | ||||||
|   shared_preferences: ^2.0.15 |   shared_preferences: ^2.0.15 | ||||||
|   url_launcher: ^6.1.5 |   url_launcher: ^6.1.5 | ||||||
|  |   permission_handler: ^10.0.0 | ||||||
|  |   fluttertoast: ^8.0.9 | ||||||
|  |   device_info_plus: ^8.0.0 | ||||||
|  |   file_picker: ^5.1.0 | ||||||
|  |   animations: ^2.0.4 | ||||||
|  |   install_plugin_v2: ^1.0.0 | ||||||
|  |   share_plus: ^6.0.1 | ||||||
|  |   installed_apps: ^1.3.1 | ||||||
|  |   package_archive_info: ^0.1.0 | ||||||
|  |   android_alarm_manager_plus: ^2.1.0 | ||||||
|  |   sqflite: ^2.2.0+3 | ||||||
|  |   easy_localization: ^3.0.1 | ||||||
|  |  | ||||||
|  |  | ||||||
| dev_dependencies: | dev_dependencies: | ||||||
|   flutter_test: |   flutter_test: | ||||||
|     sdk: flutter |     sdk: flutter | ||||||
|   flutter_launcher_icons: ^0.10.0 |   flutter_launcher_icons: ^0.11.0 | ||||||
|  |  | ||||||
|   # The "flutter_lints" package below contains a set of recommended lints to |   # The "flutter_lints" package below contains a set of recommended lints to | ||||||
|   # encourage good coding practices. The lint set provided by the package is |   # encourage good coding practices. The lint set provided by the package is | ||||||
|   # activated in the `analysis_options.yaml` file located at the root of your |   # activated in the `analysis_options.yaml` file located at the root of your | ||||||
|   # package. See that file for information about deactivating specific lint |   # package. See that file for information about deactivating specific lint | ||||||
|   # rules and activating additional ones. |   # rules and activating additional ones. | ||||||
|   flutter_lints: ^2.0.0 |   flutter_lints: ^2.0.1 | ||||||
|  |  | ||||||
| flutter_icons: | flutter_icons: | ||||||
|   android: true |   android: true | ||||||
|   image_path: "assets/icon.png" |   image_path: "assets/graphics/icon.png" | ||||||
|   adaptive_icon_background: "#FFFFFF" |   adaptive_icon_background: "#FFFFFF" | ||||||
|   adaptive_icon_foreground: "assets/icon.png" |   adaptive_icon_foreground: "assets/graphics/icon.png" | ||||||
|  |  | ||||||
| # For information on the generic Dart part of this file, see the | # For information on the generic Dart part of this file, see the | ||||||
| # following page: https://dart.dev/tools/pub/pubspec | # following page: https://dart.dev/tools/pub/pubspec | ||||||
| @@ -80,9 +90,12 @@ flutter: | |||||||
|   uses-material-design: true |   uses-material-design: true | ||||||
|  |  | ||||||
|   # To add assets to your application, add an assets section, like this: |   # To add assets to your application, add an assets section, like this: | ||||||
|   # assets: |   # - assets: | ||||||
|   #   - images/a_dot_burr.jpeg |   #   - images/a_dot_burr.jpeg | ||||||
|   #   - images/a_dot_ham.jpeg |   #   - images/a_dot_ham.jpeg | ||||||
|  |    | ||||||
|  |   assets: | ||||||
|  |     - assets/translations/ | ||||||
|  |  | ||||||
|   # An image asset can refer to one or more resolution-specific "variants", see |   # An image asset can refer to one or more resolution-specific "variants", see | ||||||
|   # https://flutter.dev/assets-and-images/#resolution-aware |   # https://flutter.dev/assets-and-images/#resolution-aware | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ import 'package:obtainium/main.dart'; | |||||||
| void main() { | void main() { | ||||||
|   testWidgets('Counter increments smoke test', (WidgetTester tester) async { |   testWidgets('Counter increments smoke test', (WidgetTester tester) async { | ||||||
|     // Build our app and trigger a frame. |     // Build our app and trigger a frame. | ||||||
|     await tester.pumpWidget(const MyApp()); |     await tester.pumpWidget(const Obtainium()); | ||||||
|  |  | ||||||
|     // Verify that our counter starts at 0. |     // Verify that our counter starts at 0. | ||||||
|     expect(find.text('0'), findsOneWidget); |     expect(find.text('0'), findsOneWidget); | ||||||
|   | |||||||