Compare commits
135 Commits
v0.5.7-bet
...
v0.8.17-be
Author | SHA1 | Date | |
---|---|---|---|
c317f23741 | |||
12c0dd8489 | |||
1c7385ab56 | |||
b46347a6e3 | |||
a7104c89dc | |||
347d2c2738 | |||
cc17260e54 | |||
1985dcec3a | |||
d435481f0b | |||
a68d49c71c | |||
2b6a16637e | |||
e46e4e5dbc | |||
848c8eaf5e | |||
ebc48169a1 | |||
54c37641d5 | |||
05ad01bf85 | |||
049b023e01 | |||
f6ca5d42e8 | |||
6d0cac5894 | |||
bfa661c8e0 | |||
e5825fe1d3 | |||
9e09aba444 | |||
8f5e07a5ca | |||
e7f3cdafe5 | |||
14ae43de92 | |||
a8f0d784a2 | |||
b1fb06e90b | |||
481204665c | |||
317b5ac83a | |||
f3b1ca4541 | |||
a00cfa2ba6 | |||
f81f6374bb | |||
da8695834e | |||
c4ba1e9dbc | |||
49862ad2a6 | |||
1b892f4e0d | |||
a4555f07f9 | |||
73fbdd84f0 | |||
a1518480db | |||
fd3ee02e52 | |||
609366675d | |||
fbff498ae1 | |||
bb4e470760 | |||
15183c3a95 | |||
b496a416ff | |||
6ac7ba204f | |||
0951c007d1 | |||
d835beec76 | |||
2654bf12d3 | |||
3951108bc9 | |||
d934ce2e13 | |||
66cc7f059f | |||
098428dac9 | |||
9e7c21b408 | |||
31c2c6b7c1 | |||
f70049aded | |||
60c28bf912 | |||
a6ed1e7c98 | |||
963f51dc53 | |||
17b1f6e5b0 | |||
086b2b949f | |||
9b5b212e96 | |||
6c8f9ebcbf | |||
4d5773bdcc | |||
f81ef6a416 | |||
47324fcb49 | |||
377e0e07bd | |||
b5aae70274 | |||
42475fa42a | |||
d29534ef2e | |||
25953399ac | |||
b04d2fad5c | |||
868ba84c9a | |||
602f0c3bb2 | |||
00721e8ac4 | |||
d19f9101d6 | |||
a4bc278e4c | |||
b04986622b | |||
2059e4fd44 | |||
618a1523cf | |||
ba1cdc2c73 | |||
aa2a25fffe | |||
c8ec67aef3 | |||
9576a99a4e | |||
0202224fa6 | |||
631ffd5c34 | |||
feed7ffc0b | |||
296485de8a | |||
d2f226d442 | |||
cbdb449e35 | |||
3100a3a08c | |||
18951d6461 | |||
0e0a39a40f | |||
55cae0620b | |||
ba6cea3ae6 | |||
4be33374c2 | |||
e2bf834981 | |||
9bd7ddb21b | |||
905a807ee9 | |||
ab57b97875 | |||
5db2c5f0b1 | |||
e158c23cca | |||
208f125e12 | |||
b7ccf3fa49 | |||
c746e89052 | |||
ee758e8470 | |||
68d903e092 | |||
c47b752344 | |||
62a05996cf | |||
1cda941fbe | |||
49cb908d04 | |||
139f44d31d | |||
ed955ac6a2 | |||
f3ead6caf1 | |||
97ab723d04 | |||
ed4a26d348 | |||
bd5f21984e | |||
5037d77b14 | |||
c9711c7734 | |||
76e98feeb7 | |||
03da23f77a | |||
9b99e2b302 | |||
e746ca890a | |||
9c00a7da14 | |||
4df0dd64ad | |||
7cf7ffe0de | |||
b1953435af | |||
fc7d7d11d6 | |||
9ef26b3a4a | |||
27ee6b9e88 | |||
d1a3529036 | |||
a954a627fd | |||
52ce5b19c4 | |||
03f0b6cf05 | |||
5d8d0de8de |
1
.gitignore
vendored
@ -9,6 +9,7 @@
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
.vscode/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
|
@ -13,10 +13,13 @@ Currently supported App sources:
|
||||
- [IzzyOnDroid](https://android.izzysoft.de/)
|
||||
- [Mullvad](https://mullvad.net/en/)
|
||||
- [Signal](https://signal.org/)
|
||||
- [APKMirror](https://apkmirror.com/)
|
||||
- [SourceForge](https://sourceforge.net/)
|
||||
- [APKMirror](https://apkmirror.com/) (Track-Only)
|
||||
- Third Party F-Droid Repos (URLs ending with `/fdroid/repo`)
|
||||
- [Steam](https://store.steampowered.com/mobile)
|
||||
|
||||
## Limitations
|
||||
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
|
||||
- App installs happen asynchronously and the success/failure of an install cannot be determined directly. This results in install statuses and versions sometimes being out of sync with the OS until the next launch or until the problem is manually corrected.
|
||||
- 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.
|
||||
|
||||
|
@ -30,7 +30,25 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<service
|
||||
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmService"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE"
|
||||
android:exported="false"/>
|
||||
<receiver
|
||||
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmBroadcastReceiver"
|
||||
android:exported="false"/>
|
||||
<receiver
|
||||
android:name="dev.fluttercommunity.plus.androidalarmmanager.RebootBroadcastReceiver"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||
</manifest>
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.7 KiB |
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.6 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 918 B After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 6.2 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 109 KiB |
Before Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 228 KiB |
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 162 KiB |
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 170 KiB |
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 146 KiB |
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 188 KiB |
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 192 KiB |
235
assets/translations/en.json
Normal file
@ -0,0 +1,235 @@
|
||||
{
|
||||
"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",
|
||||
"alreadyUpToDateQuestion": "App Already up to Date?",
|
||||
"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",
|
||||
"onlyWorksWithNonEVDApps": "Only works for Apps whose install status cannot be automatically detected (uncommon).",
|
||||
"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 {}",
|
||||
"versionCorrectionDisabled": "Version correction disabled (plugin doesn't seem to work)",
|
||||
"unknown": "Unknown",
|
||||
"none": "None",
|
||||
"never": "Never",
|
||||
"latestVersionX": "Latest Version: {}",
|
||||
"installedVersionX": "Installed Version: {}",
|
||||
"lastUpdateCheckX": "Last Update Check: {}",
|
||||
"remove": "Remove",
|
||||
"removeAppQuestion": "Remove App?",
|
||||
"yesMarkUpdated": "Yes, Mark as Updated",
|
||||
"fdroid": "F-Droid",
|
||||
"appIdOrName": "App ID or Name",
|
||||
"appWithIdOrNameNotFound": "No App was found with that ID or Name",
|
||||
"reposHaveMultipleApps": "Repos may contain multiple Apps",
|
||||
"fdroidThirdPartyRepo": "F-Droid Third-Party Repo",
|
||||
"steam": "Steam",
|
||||
"steamMobile": "Steam Mobile",
|
||||
"steamChat": "Steam Chat",
|
||||
"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 1 more app have updates.",
|
||||
"other": "{} and {} more apps have updates."
|
||||
},
|
||||
"xAndNMoreUpdatesInstalled": {
|
||||
"one": "{} and 1 more app were updated.",
|
||||
"other": "{} and {} more apps were updated."
|
||||
}
|
||||
}
|
235
assets/translations/it.json
Normal file
@ -0,0 +1,235 @@
|
||||
{
|
||||
"invalidURLForSource": "URL dell'App da {} non valido",
|
||||
"noReleaseFound": "Impossibile trovare una release adatta",
|
||||
"noVersionFound": "Impossibile determinare la versione della release",
|
||||
"urlMatchesNoSource": "L'URL non corrisponde ad alcuna fonte conosciuta",
|
||||
"cantInstallOlderVersion": "Impossibile installare una versione precedente di un'App",
|
||||
"appIdMismatch": "L'ID del pacchetto scaricato non corrisponde all'ID dell'App esistente",
|
||||
"functionNotImplemented": "Questa classe non ha implementato questa funzione",
|
||||
"placeholder": "Segnaposto",
|
||||
"someErrors": "Si sono verificati degli errori",
|
||||
"unexpectedError": "Errore imprevisto",
|
||||
"ok": "Va bene",
|
||||
"and": "e",
|
||||
"startedBgUpdateTask": "Avviata l'attività di controllo degli aggiornamenti in background",
|
||||
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
|
||||
"startedActualBGUpdateCheck": "Avviato il controllo effettivo degli aggiornamenti in background",
|
||||
"bgUpdateTaskFinished": "Terminata l'attività di controllo degli aggiornamenti in background",
|
||||
"firstRun": "Questo è il primo avvio di sempre di Obtainium",
|
||||
"settingUpdateCheckIntervalTo": "Imposta l'intervallo di aggiornamento a {}",
|
||||
"githubPATLabel": "GitHub Personal Access Token (aumenta il limite di traffico)",
|
||||
"githubPATHint": "PAT deve seguire questo formato: username:token",
|
||||
"githubPATFormat": "username:token",
|
||||
"githubPATLinkText": "Informazioni su GitHub PAT",
|
||||
"includePrereleases": "Includi prerelease",
|
||||
"fallbackToOlderReleases": "Ripiega su release datate",
|
||||
"filterReleaseTitlesByRegEx": "Filtra le release con le espressioni regolari",
|
||||
"invalidRegEx": "Espressione regolare invalida",
|
||||
"noDescription": "Descrizione assente",
|
||||
"cancel": "Annulla",
|
||||
"continue": "Continua",
|
||||
"requiredInBrackets": "(Richiesto)",
|
||||
"dropdownNoOptsError": "ERRORE: LA TENDINA DEVE AVERE ALMENO UN'OPZIONE",
|
||||
"colour": "Colore",
|
||||
"githubStarredRepos": "i repository stellati da GitHub",
|
||||
"uname": "Username",
|
||||
"wrongArgNum": "Numero di argomenti forniti errato",
|
||||
"xIsTrackOnly": "{} è Solo-Monitoraggio",
|
||||
"source": "Fonte",
|
||||
"app": "App",
|
||||
"appsFromSourceAreTrackOnly": "Le App da questa fonte sono in modalità 'Solo-Monitoraggio'.",
|
||||
"youPickedTrackOnly": "Hai selezionato l'opzione 'Solo-Monitoraggio'.",
|
||||
"trackOnlyAppDescription": "L'App sarà monitorata per gli aggiornamenti, ma Obtainium non sarà in grado di scaricarli o di installarli.",
|
||||
"cancelled": "Annullato",
|
||||
"appAlreadyAdded": "App già aggiunta",
|
||||
"alreadyUpToDateQuestion": "App già aggiornata?",
|
||||
"addApp": "Aggiungi App",
|
||||
"appSourceURL": "URL della fonte dell'App",
|
||||
"error": "Errore",
|
||||
"add": "Aggiungi",
|
||||
"searchSomeSourcesLabel": "Cerca (disponibile solo per alcune fonti)",
|
||||
"search": "Cerca",
|
||||
"additionalOptsFor": "Opzioni aggiuntive per {}",
|
||||
"supportedSourcesBelow": "Fonti supportate:",
|
||||
"trackOnlyInBrackets": "(Solo-Monitoraggio)",
|
||||
"searchableInBrackets": "(ricercabile)",
|
||||
"appsString": "App",
|
||||
"noApps": "Nessuna App",
|
||||
"noAppsForFilter": "Nessuna App per i filtri selezionati",
|
||||
"byX": "Da {}",
|
||||
"percentProgress": "Progresso: {}%",
|
||||
"pleaseWait": "Attendere prego",
|
||||
"updateAvailable": "Aggiornamento disponibile",
|
||||
"estimateInBracketsShort": "(Prev.)",
|
||||
"notInstalled": "Non installato",
|
||||
"estimateInBrackets": "(Previsto)",
|
||||
"selectAll": "Seleziona tutto",
|
||||
"deselectN": "Deseleziona {}",
|
||||
"xWillBeRemovedButRemainInstalled": "{} sarà rimosso da Obtainium ma resterà installato sul dispositivo.",
|
||||
"removeSelectedAppsQuestion": "Rimuovere le App selezionate?",
|
||||
"removeSelectedApps": "Rimuovi le App selezionate",
|
||||
"updateX": "Aggiorna {}",
|
||||
"installX": "Installa {}",
|
||||
"markXTrackOnlyAsUpdated": "Contrassegna {}\n(Solo-Monitoraggio)\ncome aggiornato",
|
||||
"changeX": "modifica {}",
|
||||
"installUpdateApps": "Installa/Aggiorna le App",
|
||||
"installUpdateSelectedApps": "Installa/Aggiornale le App selezionate",
|
||||
"onlyWorksWithNonEVDApps": "Funziona solo per le App il cui stato d'installazione non può essere rilevato automaticamente (inconsueto).",
|
||||
"markXSelectedAppsAsUpdated": "Contrassegnare le {} App selezionate come aggiornate?",
|
||||
"no": "No",
|
||||
"yes": "Sì",
|
||||
"markSelectedAppsUpdated": "Contrassegna le App selezionate come aggiornate",
|
||||
"pinToTop": "Fissa in alto",
|
||||
"unpinFromTop": "Rimuovi dall'alto",
|
||||
"resetInstallStatusForSelectedAppsQuestion": "Ripristinare lo stato d'installazione delle App selezionate?",
|
||||
"installStatusOfXWillBeResetExplanation": "Lo stato d'installazione di ogni App selezionata sarà ripristinato.\n\nCiò può essere d'aiuto nel caso in cui la versione mostrata dell'App in Obtainium non è corretta a causa di un aggiornamento fallito o di altri problemi.",
|
||||
"shareSelectedAppURLs": "Condividi gli URL delle App selezionate",
|
||||
"resetInstallStatus": "Ripristina lo stato d'installazione",
|
||||
"more": "Di più",
|
||||
"removeOutdatedFilter": "Rimuovi il filtro per le App datate",
|
||||
"showOutdatedOnly": "Mostra solo le App datate",
|
||||
"filter": "Filtri",
|
||||
"filterActive": "Filtri *",
|
||||
"filterApps": "Filtra App",
|
||||
"appName": "Nome dell'App",
|
||||
"author": "Autore",
|
||||
"upToDateApps": "App aggiornate",
|
||||
"nonInstalledApps": "App non installate",
|
||||
"importExport": "Importa/Esporta",
|
||||
"settings": "Impostazioni",
|
||||
"exportedTo": "Esportato in {}",
|
||||
"obtainiumExport": "Esporta da Obtainium",
|
||||
"invalidInput": "Inserimento non valido",
|
||||
"importedX": "Importato {}",
|
||||
"obtainiumImport": "Importa in Obtainium",
|
||||
"importFromURLList": "Importa da lista di URL",
|
||||
"searchQuery": "Stringa di ricerca",
|
||||
"appURLList": "Lista di URL delle App",
|
||||
"line": "Linea",
|
||||
"searchX": "Cerca {}",
|
||||
"noResults": "Nessun risultato trovato",
|
||||
"importX": "Importa {}",
|
||||
"importedAppsIdDisclaimer": "Le App importate potrebbero essere visualizzate erroneamente come \"Non installate\".\nPer risolvere il problema, reinstallale con Obtainium.\nQuesto non dovrebbe influire sui dati delle App.\n\nRiguarda solo l'URL e i metodi di importazione di terze parti.",
|
||||
"importErrors": "Errori dell'importazione",
|
||||
"importedXOfYApps": "{} App di {} importate.",
|
||||
"followingURLsHadErrors": "I seguenti URL contengono errori:",
|
||||
"okay": "Va bene",
|
||||
"selectURL": "Seleziona l'URL",
|
||||
"selectURLs": "Seleziona gli URL",
|
||||
"pick": "Seleziona",
|
||||
"theme": "Tema",
|
||||
"dark": "Scuro",
|
||||
"light": "Chiaro",
|
||||
"followSystem": "Segui il sistema",
|
||||
"obtainium": "Obtainium",
|
||||
"materialYou": "Material You",
|
||||
"appSortBy": "App ordinate per",
|
||||
"authorName": "Autore/Nome",
|
||||
"nameAuthor": "Nome/Autore",
|
||||
"asAdded": "Data di aggiunta",
|
||||
"appSortOrder": "Ordinamento",
|
||||
"ascending": "Ascendente",
|
||||
"descending": "Discendente",
|
||||
"bgUpdateCheckInterval": "Intervallo di controllo degli aggiornamenti in background",
|
||||
"neverManualOnly": "Mai - Solo manuale",
|
||||
"appearance": "Aspetto",
|
||||
"showWebInAppView": "Mostra la pagina web dell'App se selezionata",
|
||||
"pinUpdates": "Fissa in alto gli aggiornamenti disponibili nella pagina delle App",
|
||||
"updates": "Aggiornato",
|
||||
"sourceSpecific": "Specifico per la fonte",
|
||||
"appSource": "Sorgente dell'App",
|
||||
"noLogs": "Nessun log",
|
||||
"appLogs": "Log dell'App",
|
||||
"close": "Chiudi",
|
||||
"share": "Condividi",
|
||||
"appNotFound": "App non trovata",
|
||||
"obtainiumExportHyphenatedLowercase": "esportazione-obtainium",
|
||||
"pickAnAPK": "Seleziona un APK",
|
||||
"appHasMoreThanOnePackage": "{} offre più di un pacchetto:",
|
||||
"deviceSupportsXArch": "Il tuo dispositivo supporta l'architettura {} della CPU.",
|
||||
"deviceSupportsFollowingArchs": "Il tuo dispositivo supporta le seguenti architetture della CPU:",
|
||||
"warning": "Attenzione",
|
||||
"sourceIsXButPackageFromYPrompt": "L'origine dell'App è '{}' ma il pacchetto della release proviene da '{}'. Continuare?",
|
||||
"updatesAvailable": "Aggiornamenti disponibili",
|
||||
"updatesAvailableNotifDescription": "Avvisa l'utente che sono disponibili gli aggiornamenti di una o più App monitorate da Obtainium",
|
||||
"noNewUpdates": "Nessun nuovo aggiornamento.",
|
||||
"xHasAnUpdate": "{} è stato aggiornato.",
|
||||
"appsUpdated": "App aggiornate",
|
||||
"appsUpdatedNotifDescription": "Avvisa l'utente che una o più App sono state aggiornate in background",
|
||||
"xWasUpdatedToY": "{} è stato aggiornato a {}.",
|
||||
"errorCheckingUpdates": "Controllo degli errori per gli aggiornamenti",
|
||||
"errorCheckingUpdatesNotifDescription": "Una notifica che mostra quando il controllo degli aggiornamenti in background fallisce",
|
||||
"appsRemoved": "App rimosse",
|
||||
"appsRemovedNotifDescription": "Avvisa l'utente che una o più App sono state rimosse a causa di errori durante il caricamento",
|
||||
"xWasRemovedDueToErrorY": "{} è stata rimosso a causa di questo errore: {}",
|
||||
"completeAppInstallation": "Completa l'installazione dell'App",
|
||||
"obtainiumMustBeOpenToInstallApps": "Obtainium deve essere aperto per poter installare le App",
|
||||
"completeAppInstallationNotifDescription": "Chiede all'utente di riaprire Obtainium per terminare l'installazione di un App",
|
||||
"checkingForUpdates": "Controllo degli aggiornamenti in corso",
|
||||
"checkingForUpdatesNotifDescription": "Notifica transitoria che appare durante la verifica degli aggiornamenti",
|
||||
"pleaseAllowInstallPerm": "Per favore permetti a Obtainium di installare le App",
|
||||
"trackOnly": "Solo-Monitoraggio",
|
||||
"errorWithHttpStatusCode": "Errore {}",
|
||||
"versionCorrectionDisabled": "Correzione della versione disabilitata (il plugin non pare funzionare)",
|
||||
"unknown": "Sconosciuto",
|
||||
"none": "Nessuno",
|
||||
"never": "Mai",
|
||||
"latestVersionX": "Ultima versione: {}",
|
||||
"installedVersionX": "Versione installata: {}",
|
||||
"lastUpdateCheckX": "Ultimo controllo degli aggiornamenti: {}",
|
||||
"remove": "Rimuovi",
|
||||
"removeAppQuestion": "Rimuovere App?",
|
||||
"yesMarkUpdated": "Sì, contrassegna come aggiornato",
|
||||
"fdroid": "F-Droid",
|
||||
"appIdOrName": "ID o nome dell'App",
|
||||
"appWithIdOrNameNotFound": "Non è stata trovata alcuna App con quell'ID o nome",
|
||||
"reposHaveMultipleApps": "I repository possono contenere più App",
|
||||
"fdroidThirdPartyRepo": "Repository di terze parti di F-Droid",
|
||||
"steam": "Steam",
|
||||
"steamMobile": "Steam Mobile",
|
||||
"steamChat": "Steam Chat",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Troppe richieste (traffico limitato) - riprova tra {} minuto",
|
||||
"other": "Troppe richieste (traffico limitato) - riprova tra {} minuti"
|
||||
},
|
||||
"bgUpdateGotErrorRetryInMinutes": {
|
||||
"one": "Il controllo degli aggiornamenti in background ha incontrato un {}, ricontrollo tra {} minuto",
|
||||
"other": "Il controllo degli aggiornamenti in background ha incontrato un {}, ricontrollo tra {} minuti"
|
||||
},
|
||||
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
|
||||
"one": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamento - avviserà l'utento se necessario",
|
||||
"other": "Il controllo degli aggiornamenti in background ha trovato {} aggiornamenti - avviserà l'utento se necessario"
|
||||
},
|
||||
"apps": {
|
||||
"one": "{} App",
|
||||
"other": "{} App"
|
||||
},
|
||||
"url": {
|
||||
"one": "{} URL",
|
||||
"other": "{} URL"
|
||||
},
|
||||
"minute": {
|
||||
"one": "{} minuto",
|
||||
"other": "{} minuti"
|
||||
},
|
||||
"hour": {
|
||||
"one": "{} ora",
|
||||
"other": "{} ore"
|
||||
},
|
||||
"day": {
|
||||
"one": "{} giorno",
|
||||
"other": "{} giorni"
|
||||
},
|
||||
"clearedNLogsBeforeXAfterY": {
|
||||
"one": "Pulito {n} log (prima = {before}, dopo = {after})",
|
||||
"other": "Puliti {n} log (prima = {before}, dopo = {after})"
|
||||
},
|
||||
"xAndNMoreUpdatesAvailable": {
|
||||
"one": "{} e un'altra App hanno aggiornamenti disponibili.",
|
||||
"other": "{} e altre {} App hanno aggiornamenti disponibili."
|
||||
},
|
||||
"xAndNMoreUpdatesInstalled": {
|
||||
"one": "{} e un'altra App sono state aggiornate.",
|
||||
"other": "{} e altre {} App sono state aggiornate."
|
||||
}
|
||||
}
|
235
assets/translations/ja.json
Normal file
@ -0,0 +1,235 @@
|
||||
{
|
||||
"invalidURLForSource": "{}は有効なソースURLではありません",
|
||||
"noReleaseFound": "適切なリリースが見つかりませんでした",
|
||||
"noVersionFound": "リリースバージョンを特定できませんでした",
|
||||
"urlMatchesNoSource": "URLが既知のソースと一致しません",
|
||||
"cantInstallOlderVersion": "旧バージョンのアプリをインストールできません",
|
||||
"appIdMismatch": "ダウンロードしたパッケージのIDが既存のApp IDと一致しません",
|
||||
"functionNotImplemented": "このクラスはこの機能を実装していません",
|
||||
"placeholder": "プレースホルダー",
|
||||
"someErrors": "いくつかのエラーが発生しました",
|
||||
"unexpectedError": "予期せぬエラーが発生しました",
|
||||
"ok": "OK",
|
||||
"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": "更新間隔を{}に設定する",
|
||||
"githubPATLabel": "GitHub パーソナルアクセストークン (レートリミットの引き上げ)",
|
||||
"githubPATHint": "PATは次の形式でなければなりません: ユーザー名:トークン",
|
||||
"githubPATFormat": "ユーザー名:トークン",
|
||||
"githubPATLinkText": "GitHub PATsについて",
|
||||
"includePrereleases": "プレリリースを含む",
|
||||
"fallbackToOlderReleases": "旧リリースへのフォールバック",
|
||||
"filterReleaseTitlesByRegEx": "正規表現でリリースタイトルを絞り込む",
|
||||
"invalidRegEx": "無効な正規表現",
|
||||
"noDescription": "説明はありません",
|
||||
"cancel": "キャンセル",
|
||||
"continue": "続ける",
|
||||
"requiredInBrackets": "(必須)",
|
||||
"dropdownNoOptsError": "エラー: ドロップダウンには、少なくとも1つのoptが必要です。",
|
||||
"colour": "カラー",
|
||||
"githubStarredRepos": "Githubでスターしたリポジトリ",
|
||||
"uname": "ユーザー名",
|
||||
"wrongArgNum": "提供する引数の数が間違っています",
|
||||
"xIsTrackOnly": "{} は'Track-Only'です",
|
||||
"source": "ソース",
|
||||
"app": "アプリ",
|
||||
"appsFromSourceAreTrackOnly": "このソースからのアプリは'Track-Only'です'。",
|
||||
"youPickedTrackOnly": "'Track-Only'を選択しています",
|
||||
"trackOnlyAppDescription": "アプリのアップデートは追跡されますが、Obtainiumはアプリのダウンロードやインストールをすることはできません。",
|
||||
"cancelled": "キャンセルしました",
|
||||
"appAlreadyAdded": "アプリはすでに追加されています",
|
||||
"alreadyUpToDateQuestion": "アプリはすでに最新ですか?",
|
||||
"addApp": "アプリ追加",
|
||||
"appSourceURL": "アプリのソースURL",
|
||||
"error": "エラー",
|
||||
"add": "追加",
|
||||
"searchSomeSourcesLabel": "検索 (一部ソースのみ)",
|
||||
"search": "検索",
|
||||
"additionalOptsFor": "{}の追加オプション",
|
||||
"supportedSourcesBelow": "対応するソース:",
|
||||
"trackOnlyInBrackets": "(Track-Only)",
|
||||
"searchableInBrackets": "(検索可能)",
|
||||
"appsString": "アプリ",
|
||||
"noApps": "アプリはありません",
|
||||
"noAppsForFilter": "フィルターに一致するアプリはありません",
|
||||
"byX": "by {}",
|
||||
"percentProgress": "ダウンロード中: {}%",
|
||||
"pleaseWait": "しばらくお待ちください",
|
||||
"updateAvailable": "アップデートを利用可能",
|
||||
"estimateInBracketsShort": "(推定)",
|
||||
"notInstalled": "未インストール",
|
||||
"estimateInBrackets": "(推定)",
|
||||
"selectAll": "すべて選択",
|
||||
"deselectN": "{}件を選択解除",
|
||||
"xWillBeRemovedButRemainInstalled": "{}はObtainiumから削除されますが、デバイスにはインストールされたままです。",
|
||||
"removeSelectedAppsQuestion": "選択したアプリを削除しますか?",
|
||||
"removeSelectedApps": "選択したアプリを削除する",
|
||||
"updateX": "{}を更新する",
|
||||
"installX": "{}をインストールする",
|
||||
"markXTrackOnlyAsUpdated": "Mark {}\n(Track-Only)\nas Updated",
|
||||
"changeX": "{}を変更する",
|
||||
"installUpdateApps": "アプリのインストール/アップデート",
|
||||
"installUpdateSelectedApps": "選択したアプリのインストール/アップデート",
|
||||
"onlyWorksWithNonEVDApps": "インストール状況を自動検出できないアプリ(一般的でないもの)のみ動作します。",
|
||||
"markXSelectedAppsAsUpdated": "{}個の選択したアプリをアップデート済みとしてマークしますか?",
|
||||
"no": "いいえ",
|
||||
"yes": "はい",
|
||||
"markSelectedAppsUpdated": "選択したアプリをアップデート済みとしてマークする",
|
||||
"pinToTop": "トップに固定",
|
||||
"unpinFromTop": "トップから固定解除",
|
||||
"resetInstallStatusForSelectedAppsQuestion": "選択したアプリのインストール状態をリセットしますか?",
|
||||
"installStatusOfXWillBeResetExplanation": "選択したアプリのインストール状態がリセットされます。\n\nアップデートに失敗するなどして、Obtainiumに表示されるアプリのバージョンが正しくない場合に役立ちます。",
|
||||
"shareSelectedAppURLs": "選択したアプリのURLを共有する",
|
||||
"resetInstallStatus": "インストール状態をリセットする",
|
||||
"more": "もっと見る",
|
||||
"removeOutdatedFilter": "期限切れのアプリフィルターを削除",
|
||||
"showOutdatedOnly": "期限切れのアプリのみ表示する",
|
||||
"filter": "フィルター",
|
||||
"filterActive": "フィルター *",
|
||||
"filterApps": "アプリを絞り込む",
|
||||
"appName": "アプリ名",
|
||||
"author": "作者",
|
||||
"upToDateApps": "最新のアプリ",
|
||||
"nonInstalledApps": "未インストールのアプリ",
|
||||
"importExport": "インポート/エクスポート",
|
||||
"settings": "設定",
|
||||
"exportedTo": "{}にエクスポートしました",
|
||||
"obtainiumExport": "Obtainium Export",
|
||||
"invalidInput": "無効な入力",
|
||||
"importedX": "{}をインポートしました",
|
||||
"obtainiumImport": "Obtainium Import",
|
||||
"importFromURLList": "URLリストからのインポート",
|
||||
"searchQuery": "検索キーワード",
|
||||
"appURLList": "アプリのURLリスト",
|
||||
"line": "行",
|
||||
"searchX": "{}で検索",
|
||||
"noResults": "結果は見つかりませんでした",
|
||||
"importX": "{}をインポートする",
|
||||
"importedAppsIdDisclaimer": "インポートしたアプリが「Not Installed」と表示されることがあります。\nこれを解決するには、Obtainiumから再インストールしてください。\nアプリのデータには影響ありません。\n\nURLとサードパーティーのインポートメソッドにのみ影響します。",
|
||||
"importErrors": "インポートエラー",
|
||||
"importedXOfYApps": "{} / {} アプリをインポートしました",
|
||||
"followingURLsHadErrors": "以下のURLでエラーが発生しました。:",
|
||||
"okay": "OK",
|
||||
"selectURL": "URLを選択",
|
||||
"selectURLs": "URLを選択",
|
||||
"pick": "選択",
|
||||
"theme": "テーマ",
|
||||
"dark": "ダーク",
|
||||
"light": "ライト",
|
||||
"followSystem": "システムに従う",
|
||||
"obtainium": "Obtainium",
|
||||
"materialYou": "Material You",
|
||||
"appSortBy": "アプリの並び方",
|
||||
"authorName": "作者/名前",
|
||||
"nameAuthor": "名前/作者",
|
||||
"asAdded": "追加順",
|
||||
"appSortOrder": "並び順",
|
||||
"ascending": "昇順",
|
||||
"descending": "下降",
|
||||
"bgUpdateCheckInterval": "バックグラウンド更新の確認間隔",
|
||||
"neverManualOnly": "OFF - 手動のみ",
|
||||
"appearance": "外観",
|
||||
"showWebInAppView": "アプリビューにソースウェブページを表示する",
|
||||
"pinUpdates": "更新があるアプリをトップに固定する",
|
||||
"updates": "更新",
|
||||
"sourceSpecific": "Github アクセストークン",
|
||||
"appSource": "アプリのソース",
|
||||
"noLogs": "ログはありません",
|
||||
"appLogs": "アプリのログ",
|
||||
"close": "閉じる",
|
||||
"share": "共有",
|
||||
"appNotFound": "アプリが見つかりません",
|
||||
"obtainiumExportHyphenatedLowercase": "obtainium-export",
|
||||
"pickAnAPK": "APKを選ぶ",
|
||||
"appHasMoreThanOnePackage": "{}は複数のパッケージが存在します: ",
|
||||
"deviceSupportsXArch": "お使いのデバイスは{} CPUアーキテクチャに対応しています。",
|
||||
"deviceSupportsFollowingArchs": "お使いのデバイスは、以下のCPUアーキテクチャをサポートしています。:",
|
||||
"warning": "警告",
|
||||
"sourceIsXButPackageFromYPrompt": "アプリのソースは'{}'ですが、リリースパッケージは'{}'から来ています。続行しますか?",
|
||||
"updatesAvailable": "更新があります",
|
||||
"updatesAvailableNotifDescription": "Obtainiumが追跡している1つまたは複数のアプリのアップデートが利用可能であることをユーザーに通知します",
|
||||
"noNewUpdates": "新しいアップデートはありません。",
|
||||
"xHasAnUpdate": "{}は更新があります。",
|
||||
"appsUpdated": "アプリを更新しました",
|
||||
"appsUpdatedNotifDescription": "1つまたは複数のAppのアップデートがバックグラウンドで適用されたことをユーザーに通知する",
|
||||
"xWasUpdatedToY": "{}が{}に更新されました。",
|
||||
"errorCheckingUpdates": "アップデート時のエラーチェック",
|
||||
"errorCheckingUpdatesNotifDescription": "バックグラウンドでのアップデートチェックに失敗した際に表示される通知する",
|
||||
"appsRemoved": "削除されたアプリ",
|
||||
"appsRemovedNotifDescription": "アプリの読み込み中にエラーが発生したため、1つまたは複数のアプリが削除されたことをユーザーに通知する",
|
||||
"xWasRemovedDueToErrorY": "このエラーのため、{}は削除されました: {}",
|
||||
"completeAppInstallation": "アプリのインストールを完了する",
|
||||
"obtainiumMustBeOpenToInstallApps": "アプリをインストールするにはObtainiumが開いている必要があります。",
|
||||
"completeAppInstallationNotifDescription": "アプリのインストールを完了するために、Obtainiumに戻る必要があります。",
|
||||
"checkingForUpdates": "アップデートの確認",
|
||||
"checkingForUpdatesNotifDescription": "Transient notification that appears when checking for updates",
|
||||
"pleaseAllowInstallPerm": "Obtainiumによるアプリのインストールを許可してください。",
|
||||
"trackOnly": "Track-Only",
|
||||
"errorWithHttpStatusCode": "エラー {}",
|
||||
"versionCorrectionDisabled": "バージョン補正無効 (プラグインが動作していません)",
|
||||
"unknown": "不明",
|
||||
"none": "なし",
|
||||
"never": "Never",
|
||||
"latestVersionX": "最新版: {}",
|
||||
"installedVersionX": "インストールされたバージョン: {}",
|
||||
"lastUpdateCheckX": "最終アップデート確認: {}",
|
||||
"remove": "削除",
|
||||
"removeAppQuestion": "アプリを削除しますか?",
|
||||
"yesMarkUpdated": "はい、更新済みとしてマーク",
|
||||
"fdroid": "F-Droid",
|
||||
"appIdOrName": "アプリIDまたは名前",
|
||||
"appWithIdOrNameNotFound": "そのIDや名前を持つアプリは見つかりませんでした",
|
||||
"reposHaveMultipleApps": "レポには複数のAppが含まれることがあります",
|
||||
"fdroidThirdPartyRepo": "F-Droid Third-Party Repo",
|
||||
"steam": "Steam",
|
||||
"steamMobile": "Steam Mobile",
|
||||
"steamChat": "Steam Chat",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "リクエストが多すぎます(レート制限あり)- {}分後に再試行してください。",
|
||||
"other": "リクエストが多すぎます(レート制限あり)- {}分後に再試行してください。"
|
||||
},
|
||||
"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": "{}個のアプリ",
|
||||
"other": "{}個のアプリ"
|
||||
},
|
||||
"url": {
|
||||
"one": "{}個のURL",
|
||||
"other": "{}個のURL"
|
||||
},
|
||||
"minute": {
|
||||
"one": "{}分",
|
||||
"other": "{}分"
|
||||
},
|
||||
"hour": {
|
||||
"one": "{}時間",
|
||||
"other": "{}時間"
|
||||
},
|
||||
"day": {
|
||||
"one": "{}日",
|
||||
"other": "{}日"
|
||||
},
|
||||
"clearedNLogsBeforeXAfterY": {
|
||||
"one": "{n}個のログをクリアしました (前 = {before}, 後 = {after})",
|
||||
"other": "{n}個のログをクリアしました (前 = {before}, 後 = {after})"
|
||||
},
|
||||
"xAndNMoreUpdatesAvailable": {
|
||||
"one": "{}と{}のアプリが更新があります。",
|
||||
"other": "{}と{}のアプリが更新があります。"
|
||||
},
|
||||
"xAndNMoreUpdatesInstalled": {
|
||||
"one": "{}と{}のアプリが更新しました。",
|
||||
"other": "{}と{}のアプリが更新しました。"
|
||||
}
|
||||
}
|
235
assets/translations/zh.json
Normal file
@ -0,0 +1,235 @@
|
||||
{
|
||||
"invalidURLForSource": "不是一个有效的 {} URL",
|
||||
"noReleaseFound": "找不到合适的更新",
|
||||
"noVersionFound": "无法确定更新版本",
|
||||
"urlMatchesNoSource": "URL 与已知来源不符",
|
||||
"cantInstallOlderVersion": "无法安装旧版应用程序",
|
||||
"appIdMismatch": "下载的软件包名与现有的应用程序包名不一致",
|
||||
"functionNotImplemented": "该类没有实现此功能",
|
||||
"placeholder": "占位符",
|
||||
"someErrors": "出现了一些错误",
|
||||
"unexpectedError": "意外错误",
|
||||
"ok": "好的",
|
||||
"and": "和",
|
||||
"startedBgUpdateTask": "开始后台检查更新任务",
|
||||
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
|
||||
"startedActualBGUpdateCheck": "后台检查更新已开始",
|
||||
"bgUpdateTaskFinished": "后台检查更新已完成",
|
||||
"firstRun": "这是你第一次运行 Obtainium",
|
||||
"settingUpdateCheckIntervalTo": "设置检查更新间隔为 {}",
|
||||
"githubPATLabel": "GitHub 个人访问令牌 (提高 API 限制)",
|
||||
"githubPATHint": "个人访问令牌必须为: username:token 形式",
|
||||
"githubPATFormat": "username:token",
|
||||
"githubPATLinkText": "关于 GitHub 个人访问令牌",
|
||||
"includePrereleases": "包含预发布版",
|
||||
"fallbackToOlderReleases": "回落到旧版",
|
||||
"filterReleaseTitlesByRegEx": "通过正则表达式过滤发布标题",
|
||||
"invalidRegEx": "无效的正则表达式",
|
||||
"noDescription": "无描述",
|
||||
"cancel": "取消",
|
||||
"continue": "继续",
|
||||
"requiredInBrackets": "(必须)",
|
||||
"dropdownNoOptsError": "错误:下拉菜单必须至少有一个选项",
|
||||
"colour": "颜色",
|
||||
"githubStarredRepos": "GitHub 已星标仓库",
|
||||
"uname": "用户名",
|
||||
"wrongArgNum": "提供了错误的参数数量",
|
||||
"xIsTrackOnly": "{} 仅追踪",
|
||||
"source": "源码",
|
||||
"app": "应用程序",
|
||||
"appsFromSourceAreTrackOnly": "来自此来源的应用为仅追踪",
|
||||
"youPickedTrackOnly": "你已选择仅追踪选项",
|
||||
"trackOnlyAppDescription": "该应用程序将被跟踪更新,但 Obtainium 无法下载或安装它",
|
||||
"cancelled": "已取消",
|
||||
"appAlreadyAdded": "此应用程序已被添加",
|
||||
"alreadyUpToDateQuestion": "应用已是最新?",
|
||||
"addApp": "添加应用",
|
||||
"appSourceURL": "应用来源 URL",
|
||||
"error": "错误",
|
||||
"add": "添加",
|
||||
"searchSomeSourcesLabel": "搜索 (仅部分来源)",
|
||||
"search": "搜索",
|
||||
"additionalOptsFor": "{} 的更多选项",
|
||||
"supportedSourcesBelow": "受支持的来源:",
|
||||
"trackOnlyInBrackets": "(仅追踪)",
|
||||
"searchableInBrackets": "(可被搜索)",
|
||||
"appsString": "应用程序",
|
||||
"noApps": "无应用程序",
|
||||
"noAppsForFilter": "没有应用可被过滤",
|
||||
"byX": "来自 {}",
|
||||
"percentProgress": "进度: {}%",
|
||||
"pleaseWait": "请等待...",
|
||||
"updateAvailable": "更新可用",
|
||||
"estimateInBracketsShort": "(预计.)",
|
||||
"notInstalled": "未安装",
|
||||
"estimateInBrackets": "(预计)",
|
||||
"selectAll": "全选",
|
||||
"deselectN": "取消选择 {}",
|
||||
"xWillBeRemovedButRemainInstalled": "{} 将被从 Obtainium 中删除,但仍安装在设备上。",
|
||||
"removeSelectedAppsQuestion": "删除已选择的应用程序吗?",
|
||||
"removeSelectedApps": "删除已选择的应用程序",
|
||||
"updateX": "更新 {}",
|
||||
"installX": "安装 {}",
|
||||
"markXTrackOnlyAsUpdated": "将仅追踪编辑为已更新",
|
||||
"changeX": "更改 {}",
|
||||
"installUpdateApps": "安装/更新应用程序",
|
||||
"installUpdateSelectedApps": "安装/更新已选择的应用程序",
|
||||
"onlyAppliesToInstalledAndOutdatedApps": "'只适用于已安装但已过时的应用程序",
|
||||
"markXSelectedAppsAsUpdated": "将已选择的 {} 个应用程序标记为已更新?",
|
||||
"no": "不要",
|
||||
"yes": "好的",
|
||||
"markSelectedAppsUpdated": "标记已选择的应用程序为已更新",
|
||||
"pinToTop": "置顶",
|
||||
"unpinFromTop": "取消置顶",
|
||||
"resetInstallStatusForSelectedAppsQuestion": "为已选择的应用程序重置安装状态吗?",
|
||||
"installStatusOfXWillBeResetExplanation": "当 Obtainium 中显示的应用程序版本由于更新失败或其他问题而不正确时,这将有助于重置任何选定应用程序的安装状态。",
|
||||
"shareSelectedAppURLs": "分享已选择的应用程序 URL",
|
||||
"resetInstallStatus": "重置安装状态",
|
||||
"more": "更多",
|
||||
"removeOutdatedFilter": "删除过时的应用程序过滤器",
|
||||
"showOutdatedOnly": "只显示过时的应用程序",
|
||||
"filter": "过滤器",
|
||||
"filterActive": "过滤器 *",
|
||||
"filterApps": "过滤应用",
|
||||
"appName": "应用名称",
|
||||
"author": "作者",
|
||||
"upToDateApps": "已更新的应用程序",
|
||||
"nonInstalledApps": "未安装的应用程序",
|
||||
"importExport": "导入/导出",
|
||||
"settings": "设置",
|
||||
"exportedTo": "导出到 {}",
|
||||
"obtainiumExport": "Obtainium 导出",
|
||||
"invalidInput": "无效输入",
|
||||
"importedX": "已导出到 {}",
|
||||
"obtainiumImport": "Obtainium 导入",
|
||||
"importFromURLList": "从 URL 列表导入",
|
||||
"searchQuery": "搜索查询",
|
||||
"appURLList": "应用 URL 列表",
|
||||
"line": "行",
|
||||
"searchX": "搜索 {}",
|
||||
"noResults": "无结果",
|
||||
"importX": "导入 {}",
|
||||
"importedAppsIdDisclaimer": "导入的应用程序可能不正确地显示为未安装。要解决这个问题,请通过 Obtainium 重新安装它们。",
|
||||
"importErrors": "导入错误",
|
||||
"importedXOfYApps": "{} 中的 {} 个应用已导入",
|
||||
"followingURLsHadErrors": "以下 URL 有错误:",
|
||||
"okay": "好的",
|
||||
"selectURL": "已选择的 URL",
|
||||
"selectURLs": "已选择的 URL",
|
||||
"pick": "选择",
|
||||
"theme": "主题",
|
||||
"dark": "深色",
|
||||
"light": "浅色",
|
||||
"followSystem": "跟随系统",
|
||||
"obtainium": "Obtainium",
|
||||
"materialYou": "Material You",
|
||||
"appSortBy": "排列方式",
|
||||
"authorName": "作者 / 名字",
|
||||
"nameAuthor": "名字 / 作者",
|
||||
"asAdded": "添加顺序",
|
||||
"appSortOrder": "排列顺序",
|
||||
"ascending": "升序",
|
||||
"descending": "降序",
|
||||
"bgUpdateCheckInterval": "后台更新检查间隔",
|
||||
"neverManualOnly": "手动",
|
||||
"appearance": "外观",
|
||||
"showWebInAppView": "在应用来源页显示网页",
|
||||
"pinUpdates": "将需要更新的应用固定到顶部",
|
||||
"updates": "检查间隔",
|
||||
"sourceSpecific": "Github 访问令牌",
|
||||
"appSource": "源代码",
|
||||
"noLogs": "无日志",
|
||||
"appLogs": "应用日志",
|
||||
"close": "关闭",
|
||||
"share": "分享",
|
||||
"appNotFound": "未找到应用",
|
||||
"obtainiumExportHyphenatedLowercase": "obtainium-导出",
|
||||
"pickAnAPK": "选择一个安装包",
|
||||
"appHasMoreThanOnePackage": "{} 有多于一个安装包:",
|
||||
"deviceSupportsXArch": "你的设备支持 {} CPU 架构",
|
||||
"deviceSupportsFollowingArchs": "你的设备支持以下 CPU 架构:",
|
||||
"warning": "警告",
|
||||
"sourceIsXButPackageFromYPrompt": "此应用来源是 '{}' 但更新包来自 '{}'。 继续吗?",
|
||||
"updatesAvailable": "更新可用",
|
||||
"updatesAvailableNotifDescription": "通知 Obtainium 所跟踪应用程序的更新",
|
||||
"noNewUpdates": "你的应用已是最新。",
|
||||
"xHasAnUpdate": "{} 有更新啦",
|
||||
"appsUpdated": "应用已更新",
|
||||
"appsUpdatedNotifDescription": "通知在后台安装应用程序的更新",
|
||||
"xWasUpdatedToY": "{} 已更新到 {}.",
|
||||
"errorCheckingUpdates": "检查更新出错",
|
||||
"errorCheckingUpdatesNotifDescription": "当后台更新检查失败时显示的通知",
|
||||
"appsRemoved": "应用已删除",
|
||||
"appsRemovedNotifDescription": "通知由于加载应用程序时出错而被删除",
|
||||
"xWasRemovedDueToErrorY": "{} 已因以下错误被删除: {}",
|
||||
"completeAppInstallation": "完成应用安装",
|
||||
"obtainiumMustBeOpenToInstallApps": "Obtainium 需要被启动以安装更新",
|
||||
"completeAppInstallationNotifDescription": "需要返回 Obtainium,以完成应用程序的安装。",
|
||||
"checkingForUpdates": "检查更新中",
|
||||
"checkingForUpdatesNotifDescription": "检查更新时出现的瞬时通知",
|
||||
"pleaseAllowInstallPerm": "请允许 Obtainium 安装应用程序",
|
||||
"trackOnly": "仅追踪",
|
||||
"errorWithHttpStatusCode": "错误 {}",
|
||||
"versionCorrectionDisabled": "禁用版本更正(插件似乎未起作用)",
|
||||
"unknown": "未知",
|
||||
"none": "无",
|
||||
"never": "从不",
|
||||
"latestVersionX": "最新: {}",
|
||||
"installedVersionX": "已安装: {}",
|
||||
"lastUpdateCheckX": "最后检查: {}",
|
||||
"remove": "删除",
|
||||
"removeAppQuestion": "删除应用?",
|
||||
"yesMarkUpdated": "'是的,标为已更新",
|
||||
"fdroid": "F-Droid",
|
||||
"appIdOrName": "应用 ID 或名称",
|
||||
"appWithIdOrNameNotFound": "没有发现具有此 ID 或名称的应用",
|
||||
"reposHaveMultipleApps": "来源可能包含多个应用",
|
||||
"fdroidThirdPartyRepo": "F-Droid 第三方源",
|
||||
"steam": "Steam",
|
||||
"steamMobile": "Steam Mobile",
|
||||
"steamChat": "Steam Chat",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "请求过多 (API 限制) - 在 {} 分钟后重试",
|
||||
"other": "请求过多 (API 限制) - 在 {} 分钟后重试"
|
||||
},
|
||||
"bgUpdateGotErrorRetryInMinutes": {
|
||||
"one": "后台更新检查遇到了 {} 问题, 将在 {} 分钟后重试",
|
||||
"other": "后台更新检查遇到了 {} 问题, 将在 {} 分钟后重试"
|
||||
},
|
||||
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
|
||||
"one": "后台更新检查找到了 {} 个更新 - 将通知用户",
|
||||
"other": "后台更新检查找到了 {} 个更新 - 将通知用户"
|
||||
},
|
||||
"apps": {
|
||||
"one": "{} 个应用",
|
||||
"other": "{} 个应用"
|
||||
},
|
||||
"url": {
|
||||
"one": "{} 个 URL",
|
||||
"other": "{} 个 URL"
|
||||
},
|
||||
"minute": {
|
||||
"one": "{} 分钟",
|
||||
"other": "{} 分钟"
|
||||
},
|
||||
"hour": {
|
||||
"one": "{} 小时",
|
||||
"other": "{} 小时"
|
||||
},
|
||||
"day": {
|
||||
"one": "{} 天",
|
||||
"other": "{} 天"
|
||||
},
|
||||
"clearedNLogsBeforeXAfterY": {
|
||||
"one": "清除了 {n} 个日志 (清除前 = {before}, 清除后 = {after})",
|
||||
"other": "清除了 {n} 个日志 (清除前 = {before}, 清除后 = {after})"
|
||||
},
|
||||
"xAndNMoreUpdatesAvailable": {
|
||||
"one": "{} 和 {} 更多应用已被更新",
|
||||
"other": "{} 和 {} 更多应用已被更新"
|
||||
},
|
||||
"xAndNMoreUpdatesInstalled": {
|
||||
"one": "{} 和 {} 更多应用已被安装",
|
||||
"other": "{} 和 {} 更多应用已被安装"
|
||||
}
|
||||
}
|
@ -1,112 +1,57 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class APKMirror implements AppSource {
|
||||
@override
|
||||
late String host = 'apkmirror.com';
|
||||
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 notValidURL(runtimeType.toString());
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl#whatsnew';
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
|
||||
var originalUri = Uri.parse(apkUrl);
|
||||
var res = await get(originalUri);
|
||||
if (res.statusCode != 200) {
|
||||
throw false;
|
||||
}
|
||||
var href =
|
||||
parse(res.body).querySelector('.downloadButton')?.attributes['href'];
|
||||
if (href == null) {
|
||||
throw false;
|
||||
}
|
||||
var res2 = await get(Uri.parse('${originalUri.origin}$href'), headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
|
||||
});
|
||||
if (res2.statusCode != 200) {
|
||||
throw false;
|
||||
}
|
||||
var links = parse(res2.body)
|
||||
.querySelectorAll('a')
|
||||
.where((element) => element.innerHtml == 'here')
|
||||
.map((e) => e.attributes['href'])
|
||||
.where((element) => element != null)
|
||||
.toList();
|
||||
if (links.isEmpty) {
|
||||
throw false;
|
||||
}
|
||||
return '${originalUri.origin}${links[0]}';
|
||||
}
|
||||
'$standardUrl/#whatsnew';
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/feed'));
|
||||
if (res.statusCode != 200) {
|
||||
throw couldNotFindReleases;
|
||||
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, [], getAppNames(standardUrl));
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
var nextUrl = parse(res.body)
|
||||
.querySelector('item')
|
||||
?.querySelector('link')
|
||||
?.nextElementSibling
|
||||
?.innerHtml;
|
||||
if (nextUrl == null) {
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
Response res2 = await get(Uri.parse(nextUrl), headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
|
||||
});
|
||||
if (res2.statusCode != 200) {
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
var html2 = parse(res2.body);
|
||||
var origin = Uri.parse(standardUrl).origin;
|
||||
List<String> apkUrls = html2
|
||||
.querySelectorAll('.apkm-badge')
|
||||
.map((e) => e.innerHtml != 'APK'
|
||||
? ''
|
||||
: e.previousElementSibling?.attributes['href'] ?? '')
|
||||
.where((element) => element.isNotEmpty)
|
||||
.map((e) => '$origin$e')
|
||||
.toList();
|
||||
if (apkUrls.isEmpty) {
|
||||
throw noAPKFound;
|
||||
}
|
||||
var version = html2.querySelector('span.active.accent_color')?.innerHtml;
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
}
|
||||
return APKDetails(version, apkUrls);
|
||||
}
|
||||
|
||||
@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]);
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,11 +1,15 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class FDroid implements AppSource {
|
||||
@override
|
||||
late String host = 'f-droid.org';
|
||||
class FDroid extends AppSource {
|
||||
FDroid() {
|
||||
host = 'f-droid.org';
|
||||
name = tr('fdroid');
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
@ -18,7 +22,7 @@ class FDroid implements AppSource {
|
||||
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
|
||||
match = standardUrlRegExA.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw notValidURL(runtimeType.toString());
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -27,46 +31,41 @@ class FDroid implements AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
String? tryInferringAppId(String standardUrl,
|
||||
{List<String> additionalData = const []}) {
|
||||
return Uri.parse(standardUrl).pathSegments.last;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
Response res = await get(Uri.parse(standardUrl));
|
||||
APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||
Response res, String apkUrlPrefix, String standardUrl) {
|
||||
if (res.statusCode == 200) {
|
||||
var latestReleaseDiv =
|
||||
parse(res.body).querySelector('#latest.package-version');
|
||||
var apkUrl = latestReleaseDiv
|
||||
?.querySelector('.package-version-download a')
|
||||
?.attributes['href'];
|
||||
if (apkUrl == null) {
|
||||
throw noAPKFound;
|
||||
List<dynamic> releases = jsonDecode(res.body)['packages'] ?? [];
|
||||
if (releases.isEmpty) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
var version = latestReleaseDiv
|
||||
?.querySelector('.package-version-header b')
|
||||
?.innerHtml
|
||||
.split(' ')
|
||||
.last;
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
String? latestVersion = releases[0]['versionName'];
|
||||
if (latestVersion == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, [apkUrl]);
|
||||
List<String> apkUrls = releases
|
||||
.where((element) => element['versionName'] == latestVersion)
|
||||
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
|
||||
.toList();
|
||||
return APKDetails(latestVersion, apkUrls,
|
||||
AppNames(name, Uri.parse(standardUrl).pathSegments.last));
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
||||
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',
|
||||
standardUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
90
lib/app_sources/fdroidrepo.dart
Normal file
@ -0,0 +1,90 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class FDroidRepo extends AppSource {
|
||||
FDroidRepo() {
|
||||
name = tr('fdroidThirdPartyRepo');
|
||||
|
||||
additionalSourceAppSpecificFormItems = [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr('appIdOrName'),
|
||||
hint: tr('reposHaveMultipleApps'),
|
||||
required: true,
|
||||
key: 'appIdOrName')
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegExp =
|
||||
RegExp('^https?://.+/fdroid/(repo(/|\\?)|repo\$)');
|
||||
RegExpMatch? match = standardUrlRegExp.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
String? appIdOrName = findGeneratedFormValueByKey(
|
||||
additionalSourceAppSpecificFormItems
|
||||
.reduce((value, element) => [...value, ...element]),
|
||||
additionalData,
|
||||
'appIdOrName');
|
||||
if (appIdOrName == null) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
var res = await get(Uri.parse('$standardUrl/index.xml'));
|
||||
if (res.statusCode == 200) {
|
||||
var body = parse(res.body);
|
||||
var foundApps = body.querySelectorAll('application').where((element) {
|
||||
return element.attributes['id'] == appIdOrName;
|
||||
}).toList();
|
||||
if (foundApps.isEmpty) {
|
||||
foundApps = body.querySelectorAll('application').where((element) {
|
||||
return element.querySelector('name')?.innerHtml.toLowerCase() ==
|
||||
appIdOrName.toLowerCase();
|
||||
}).toList();
|
||||
}
|
||||
if (foundApps.isEmpty) {
|
||||
foundApps = body.querySelectorAll('application').where((element) {
|
||||
return element
|
||||
.querySelector('name')
|
||||
?.innerHtml
|
||||
.toLowerCase()
|
||||
.contains(appIdOrName.toLowerCase()) ??
|
||||
false;
|
||||
}).toList();
|
||||
}
|
||||
if (foundApps.isEmpty) {
|
||||
throw ObtainiumError(tr('appWithIdOrNameNotFound'));
|
||||
}
|
||||
var authorName = body.querySelector('repo')?.attributes['name'] ?? name;
|
||||
var appName =
|
||||
foundApps[0].querySelector('name')?.innerHtml ?? appIdOrName;
|
||||
var releases = foundApps[0].querySelectorAll('package');
|
||||
String? latestVersion = releases[0].querySelector('version')?.innerHtml;
|
||||
if (latestVersion == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
List<String> apkUrls = releases
|
||||
.where((element) =>
|
||||
element.querySelector('version')?.innerHtml == latestVersion &&
|
||||
element.querySelector('apkname') != null)
|
||||
.map((e) => '$standardUrl/${e.querySelector('apkname')!.innerHtml}')
|
||||
.toList();
|
||||
return APKDetails(latestVersion, apkUrls, AppNames(authorName, appName));
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
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';
|
||||
@ -7,16 +8,89 @@ import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class GitHub implements AppSource {
|
||||
@override
|
||||
late String host = 'github.com';
|
||||
class GitHub extends AppSource {
|
||||
GitHub() {
|
||||
host = 'github.com';
|
||||
|
||||
additionalSourceAppSpecificDefaults = ['true', 'true', ''];
|
||||
|
||||
additionalSourceSpecificSettingFormItems = [
|
||||
GeneratedFormItem(
|
||||
label: tr('githubPATLabel'),
|
||||
id: 'github-creds',
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value != null && value.trim().isNotEmpty) {
|
||||
if (value
|
||||
.split(':')
|
||||
.where((element) => element.trim().isNotEmpty)
|
||||
.length !=
|
||||
2) {
|
||||
return tr('githubPATHint');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
],
|
||||
hint: tr('githubPATFormat'),
|
||||
belowWidgets: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text(
|
||||
tr('githubPATLinkText'),
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline, fontSize: 12),
|
||||
))
|
||||
])
|
||||
];
|
||||
|
||||
additionalSourceAppSpecificFormItems = [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr('includePrereleases'), type: FormItemType.bool)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr('fallbackToOlderReleases'), type: FormItemType.bool)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr('filterReleaseTitlesByRegEx'),
|
||||
type: FormItemType.string,
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RegExp(value);
|
||||
} catch (e) {
|
||||
return tr('invalidRegEx');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
])
|
||||
]
|
||||
];
|
||||
|
||||
canSearch = true;
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw notValidURL(runtimeType.toString());
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -24,8 +98,8 @@ class GitHub implements AppSource {
|
||||
Future<String> getCredentialPrefixIfAny() async {
|
||||
SettingsProvider settingsProvider = SettingsProvider();
|
||||
await settingsProvider.initializeSettings();
|
||||
String? creds =
|
||||
settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id);
|
||||
String? creds = settingsProvider
|
||||
.getSettingString(additionalSourceSpecificSettingFormItems[0].id);
|
||||
return creds != null && creds.isNotEmpty ? '$creds@' : '';
|
||||
}
|
||||
|
||||
@ -33,12 +107,10 @@ class GitHub implements AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl/releases';
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
var includePrereleases =
|
||||
additionalData.isNotEmpty && additionalData[0] == 'true';
|
||||
var fallbackToOlderReleases =
|
||||
@ -69,13 +141,14 @@ class GitHub implements AppSource {
|
||||
if (!includePrereleases && releases[i]['prerelease'] == true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (regexFilter != null &&
|
||||
!RegExp(regexFilter)
|
||||
.hasMatch((releases[i]['name'] as String).trim())) {
|
||||
continue;
|
||||
}
|
||||
var apkUrls = getReleaseAPKUrls(releases[i]);
|
||||
if (apkUrls.isEmpty) {
|
||||
if (apkUrls.isEmpty && !trackOnly) {
|
||||
continue;
|
||||
}
|
||||
targetRelease = releases[i];
|
||||
@ -83,29 +156,20 @@ class GitHub implements AppSource {
|
||||
break;
|
||||
}
|
||||
if (targetRelease == null) {
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
|
||||
throw noAPKFound;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
String? version = targetRelease['tag_name'];
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, targetRelease['apkUrls']);
|
||||
return APKDetails(version, targetRelease['apkUrls'] as List<String>,
|
||||
getAppNames(standardUrl));
|
||||
} else {
|
||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||
throw RateLimitError(
|
||||
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||
60000000)
|
||||
.round());
|
||||
}
|
||||
|
||||
throw couldNotFindReleases;
|
||||
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('/');
|
||||
@ -113,72 +177,31 @@ class GitHub implements AppSource {
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [
|
||||
[GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Fallback to older releases', type: FormItemType.bool)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Filter Release Titles by Regular Expression',
|
||||
type: FormItemType.string,
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RegExp(value);
|
||||
} catch (e) {
|
||||
return 'Invalid regular expression';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
])
|
||||
]
|
||||
];
|
||||
Future<Map<String, String>> search(String query) async {
|
||||
Response res = await get(Uri.parse(
|
||||
'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100'));
|
||||
if (res.statusCode == 200) {
|
||||
Map<String, String> urlsWithDescriptions = {};
|
||||
for (var e in (jsonDecode(res.body)['items'] as List<dynamic>)) {
|
||||
urlsWithDescriptions.addAll({
|
||||
e['html_url'] as String: e['description'] != null
|
||||
? e['description'] as String
|
||||
: tr('noDescription')
|
||||
});
|
||||
}
|
||||
return urlsWithDescriptions;
|
||||
} else {
|
||||
rateLimitErrorCheck(res);
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = ['true', 'true', ''];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [
|
||||
GeneratedFormItem(
|
||||
label: 'GitHub Personal Access Token (Increases Rate Limit)',
|
||||
id: 'github-creds',
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value != null && value.trim().isNotEmpty) {
|
||||
if (value
|
||||
.split(':')
|
||||
.where((element) => element.trim().isNotEmpty)
|
||||
.length !=
|
||||
2) {
|
||||
return 'PAT must be in this format: username:token';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
],
|
||||
hint: 'username:token',
|
||||
belowWidgets: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: const Text(
|
||||
'About GitHub PATs',
|
||||
style: TextStyle(
|
||||
decoration: TextDecoration.underline, fontSize: 12),
|
||||
))
|
||||
])
|
||||
];
|
||||
rateLimitErrorCheck(Response res) {
|
||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||
throw RateLimitError(
|
||||
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||
60000000)
|
||||
.round());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,19 +1,20 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class GitLab implements AppSource {
|
||||
@override
|
||||
late String host = 'gitlab.com';
|
||||
class GitLab extends AppSource {
|
||||
GitLab() {
|
||||
host = 'gitlab.com';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw notValidURL(runtimeType.toString());
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -22,12 +23,10 @@ class GitLab implements AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl/-/releases';
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
|
||||
if (res.statusCode == 200) {
|
||||
var standardUri = Uri.parse(standardUrl);
|
||||
@ -35,11 +34,13 @@ class GitLab implements AppSource {
|
||||
var entry = parsedHtml.querySelector('entry');
|
||||
var entryContent =
|
||||
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
|
||||
var apkUrlList = [
|
||||
var apkUrls = [
|
||||
...getLinksFromParsedHTML(
|
||||
entryContent,
|
||||
RegExp(
|
||||
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
|
||||
'^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
||||
return '\\${x[0]}';
|
||||
})}/uploads/[^/]+/[^/]+\\.apk\$',
|
||||
caseSensitive: false),
|
||||
standardUri.origin),
|
||||
// GitLab releases may contain links to externally hosted APKs
|
||||
@ -48,34 +49,16 @@ class GitLab implements AppSource {
|
||||
.where((element) => Uri.parse(element).host != '')
|
||||
.toList()
|
||||
];
|
||||
if (apkUrlList.isEmpty) {
|
||||
throw noAPKFound;
|
||||
}
|
||||
|
||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||
var version =
|
||||
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, apkUrlList);
|
||||
return APKDetails(version, apkUrls, GitHub().getAppNames(standardUrl));
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
// Same as GitHub
|
||||
return GitHub().getAppNames(standardUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,18 +1,19 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/app_sources/fdroid.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class IzzyOnDroid implements AppSource {
|
||||
@override
|
||||
late String host = 'android.izzysoft.de';
|
||||
class IzzyOnDroid extends AppSource {
|
||||
IzzyOnDroid() {
|
||||
host = 'android.izzysoft.de';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw notValidURL(runtimeType.toString());
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -21,54 +22,20 @@ class IzzyOnDroid implements AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
String? tryInferringAppId(String standardUrl,
|
||||
{List<String> additionalData = const []}) {
|
||||
return FDroid().tryInferringAppId(standardUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
Response res = await get(Uri.parse(standardUrl));
|
||||
if (res.statusCode == 200) {
|
||||
var parsedHtml = parse(res.body);
|
||||
var multipleVersionApkUrls = parsedHtml
|
||||
.querySelectorAll('a')
|
||||
.where((element) =>
|
||||
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
|
||||
false)
|
||||
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
|
||||
.toList();
|
||||
if (multipleVersionApkUrls.isEmpty) {
|
||||
throw noAPKFound;
|
||||
}
|
||||
var version = parsedHtml
|
||||
.querySelector('#keydata')
|
||||
?.querySelectorAll('b')
|
||||
.where(
|
||||
(element) => element.innerHtml.toLowerCase().contains('version'))
|
||||
.toList()[0]
|
||||
.parentNode
|
||||
?.parentNode
|
||||
?.children[1]
|
||||
.innerHtml;
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
}
|
||||
return APKDetails(version, [multipleVersionApkUrls[0]]);
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
String? appId = tryInferringAppId(standardUrl);
|
||||
return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||
await get(
|
||||
Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')),
|
||||
'https://android.izzysoft.de/frepo/$appId',
|
||||
standardUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,18 +1,19 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class Mullvad implements AppSource {
|
||||
@override
|
||||
late String host = 'mullvad.net';
|
||||
class Mullvad extends AppSource {
|
||||
Mullvad() {
|
||||
host = 'mullvad.net';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw notValidURL(runtimeType.toString());
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -21,12 +22,10 @@ class Mullvad implements AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md';
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
|
||||
if (res.statusCode == 200) {
|
||||
var version = parse(res.body)
|
||||
@ -36,26 +35,14 @@ class Mullvad implements AppSource {
|
||||
?.split('/')
|
||||
.last;
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(
|
||||
version, ['https://mullvad.net/download/app/apk/latest']);
|
||||
version,
|
||||
['https://mullvad.net/download/app/apk/latest'],
|
||||
AppNames(name, 'Mullvad-VPN'));
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class Signal implements AppSource {
|
||||
@override
|
||||
late String host = 'signal.org';
|
||||
class Signal extends AppSource {
|
||||
Signal() {
|
||||
host = 'signal.org';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
@ -15,39 +16,23 @@ class Signal implements AppSource {
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res =
|
||||
await get(Uri.parse('https://updates.$host/android/latest.json'));
|
||||
if (res.statusCode == 200) {
|
||||
var json = jsonDecode(res.body);
|
||||
String? apkUrl = json['url'];
|
||||
if (apkUrl == null) {
|
||||
throw noAPKFound;
|
||||
}
|
||||
List<String> apkUrls = apkUrl == null ? [] : [apkUrl];
|
||||
String? version = json['versionName'];
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, [apkUrl]);
|
||||
return APKDetails(version, apkUrls, AppNames(name, 'Signal'));
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,18 +1,19 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class SourceForge implements AppSource {
|
||||
@override
|
||||
late String host = 'sourceforge.net';
|
||||
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 notValidURL(runtimeType.toString());
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -20,12 +21,10 @@ class SourceForge implements AppSource {
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/rss?path=/'));
|
||||
if (res.statusCode == 200) {
|
||||
var parsedHtml = parse(res.body);
|
||||
@ -42,7 +41,7 @@ class SourceForge implements AppSource {
|
||||
|
||||
String? version = getVersion(allDownloadLinks[0]);
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
throw NoVersionError();
|
||||
}
|
||||
var apkUrlListAllReleases = allDownloadLinks
|
||||
.where((element) => element.toLowerCase().endsWith('.apk/download'))
|
||||
@ -51,27 +50,13 @@ class SourceForge implements AppSource {
|
||||
apkUrlListAllReleases // This can be used skipped for fallback support later
|
||||
.where((element) => getVersion(element) == version)
|
||||
.toList();
|
||||
if (apkUrlList.isEmpty) {
|
||||
throw noAPKFound;
|
||||
}
|
||||
return APKDetails(version, apkUrlList);
|
||||
return APKDetails(
|
||||
version,
|
||||
apkUrlList,
|
||||
AppNames(
|
||||
name, standardUrl.substring(standardUrl.lastIndexOf('/') + 1)));
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames(runtimeType.toString(),
|
||||
standardUrl.substring(standardUrl.lastIndexOf('/') + 1));
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
69
lib/app_sources/steammobile.dart
Normal file
@ -0,0 +1,69 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class SteamMobile extends AppSource {
|
||||
SteamMobile() {
|
||||
host = 'store.steampowered.com';
|
||||
name = tr('steam');
|
||||
additionalSourceAppSpecificFormItems = [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr('app'),
|
||||
key: 'app',
|
||||
required: true,
|
||||
opts: apks.entries.toList())
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
final apks = {'steam': tr('steamMobile'), 'steam-chat-app': tr('steamChat')};
|
||||
|
||||
@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://$host/mobile'));
|
||||
if (res.statusCode == 200) {
|
||||
var apkNamePrefix = findGeneratedFormValueByKey(
|
||||
additionalSourceAppSpecificFormItems
|
||||
.reduce((value, element) => [...value, ...element]),
|
||||
additionalData,
|
||||
'app');
|
||||
if (apkNamePrefix == null) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
String apkInURLRegexPattern = '/$apkNamePrefix-[^/]+\\.apk\$';
|
||||
var links = parse(res.body)
|
||||
.querySelectorAll('a')
|
||||
.map((e) => e.attributes['href'] ?? '')
|
||||
.where((e) => RegExp('https://.*$apkInURLRegexPattern').hasMatch(e))
|
||||
.toList();
|
||||
|
||||
if (links.isEmpty) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
var versionMatch = RegExp(apkInURLRegexPattern).firstMatch(links[0]);
|
||||
if (versionMatch == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
var version = links[0].substring(
|
||||
versionMatch.start + apkNamePrefix.length + 2, versionMatch.end - 4);
|
||||
var apkUrls = [links[0]];
|
||||
return APKDetails(version, apkUrls, AppNames(name, apks[apkNamePrefix]!));
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +1,13 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum FormItemType { string, bool }
|
||||
|
||||
typedef OnValueChanges = void Function(List<String> values, bool valid);
|
||||
typedef OnValueChanges = void Function(
|
||||
List<String> values, bool valid, bool isBuilding);
|
||||
|
||||
class GeneratedFormItem {
|
||||
late String key;
|
||||
late String label;
|
||||
late FormItemType type;
|
||||
late bool required;
|
||||
@ -13,6 +16,7 @@ class GeneratedFormItem {
|
||||
late String id;
|
||||
late List<Widget> belowWidgets;
|
||||
late String? hint;
|
||||
late List<MapEntry<String, String>>? opts;
|
||||
|
||||
GeneratedFormItem(
|
||||
{this.label = 'Input',
|
||||
@ -22,7 +26,13 @@ class GeneratedFormItem {
|
||||
this.additionalValidators = const [],
|
||||
this.id = 'input',
|
||||
this.belowWidgets = const [],
|
||||
this.hint});
|
||||
this.hint,
|
||||
this.opts,
|
||||
this.key = 'default'}) {
|
||||
if (type != FormItemType.string) {
|
||||
required = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GeneratedForm extends StatefulWidget {
|
||||
@ -47,7 +57,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
List<List<Widget>> rows = [];
|
||||
|
||||
// If any value changes, call this to update the parent with value and validity
|
||||
void someValueChanged() {
|
||||
void someValueChanged({bool isBuilding = false}) {
|
||||
List<String> returnValues = [];
|
||||
var valid = true;
|
||||
for (int r = 0; r < values.length; r++) {
|
||||
@ -62,7 +72,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
}
|
||||
}
|
||||
}
|
||||
widget.onValueChanges(returnValues, valid);
|
||||
widget.onValueChanges(returnValues, valid, isBuilding);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -75,14 +85,16 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
.map((row) => row.map((e) {
|
||||
return j < widget.defaultValues.length
|
||||
? widget.defaultValues[j++]
|
||||
: '';
|
||||
: e.opts != null
|
||||
? e.opts!.first.key
|
||||
: '';
|
||||
}).toList())
|
||||
.toList();
|
||||
|
||||
// Dynamically create form inputs
|
||||
formInputs = widget.items.asMap().entries.map((row) {
|
||||
return row.value.asMap().entries.map((e) {
|
||||
if (e.value.type == FormItemType.string) {
|
||||
if (e.value.type == FormItemType.string && e.value.opts == null) {
|
||||
final formFieldKey = GlobalKey<FormFieldState>();
|
||||
return TextFormField(
|
||||
key: formFieldKey,
|
||||
@ -101,7 +113,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
maxLines: e.value.max <= 1 ? 1 : e.value.max,
|
||||
validator: (value) {
|
||||
if (e.value.required && (value == null || value.trim().isEmpty)) {
|
||||
return '${e.value.label} (required)';
|
||||
return '${e.value.label} ${tr('requiredInBrackets')}';
|
||||
}
|
||||
for (var validator in e.value.additionalValidators) {
|
||||
String? result = validator(value);
|
||||
@ -112,11 +124,30 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
return null;
|
||||
},
|
||||
);
|
||||
} else if (e.value.type == FormItemType.string &&
|
||||
e.value.opts != null) {
|
||||
if (e.value.opts!.isEmpty) {
|
||||
return Text(tr('dropdownNoOptsError'));
|
||||
}
|
||||
return DropdownButtonFormField(
|
||||
decoration: InputDecoration(labelText: e.value.label),
|
||||
value: values[row.key][e.key],
|
||||
items: e.value.opts!
|
||||
.map((e) =>
|
||||
DropdownMenuItem(value: e.key, child: Text(e.value)))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
values[row.key][e.key] = value ?? e.value.opts!.first.key;
|
||||
someValueChanged();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return Container(); // Some input types added in build
|
||||
}
|
||||
}).toList();
|
||||
}).toList();
|
||||
someValueChanged(isBuilding: true);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -186,3 +217,18 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
String? findGeneratedFormValueByKey(
|
||||
List<GeneratedFormItem> items, List<String> values, String key) {
|
||||
var foundIndex = -1;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].key == key) {
|
||||
foundIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundIndex >= 0 && foundIndex < values.length) {
|
||||
return values[foundIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
@ -28,7 +29,8 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
valid = widget.initValid;
|
||||
values = widget.defaultValues;
|
||||
valid = widget.initValid || widget.items.isEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -45,11 +47,16 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
),
|
||||
GeneratedForm(
|
||||
items: widget.items,
|
||||
onValueChanges: (values, valid) {
|
||||
setState(() {
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (isBuilding) {
|
||||
this.values = values;
|
||||
this.valid = valid;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
this.values = values;
|
||||
this.valid = valid;
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues: widget.defaultValues)
|
||||
]),
|
||||
@ -58,7 +65,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: const Text('Cancel')),
|
||||
child: Text(tr('cancel'))),
|
||||
TextButton(
|
||||
onPressed: !valid
|
||||
? null
|
||||
@ -68,7 +75,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
Navigator.of(context).pop(values);
|
||||
}
|
||||
},
|
||||
child: const Text('Continue'))
|
||||
child: Text(tr('continue')))
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -1,8 +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() =>
|
||||
'Too many requests (rate limited) - try again in $remainingMinutes minutes';
|
||||
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('');
|
||||
}
|
||||
|
167
lib/main.dart
@ -1,27 +1,79 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/pages/home.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/logs_provider.dart';
|
||||
import 'package:obtainium/providers/notifications_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
// 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.17';
|
||||
const String currentReleaseTag =
|
||||
'v0.5.7-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
|
||||
const String bgUpdateCheckTaskName = 'bg-update-check';
|
||||
const int bgUpdateCheckAlarmId = 666;
|
||||
|
||||
bgUpdateCheck(int? ignoreAfterMicroseconds) async {
|
||||
const supportedLocales = [
|
||||
Locale('en'),
|
||||
Locale('zh'),
|
||||
Locale('it'),
|
||||
Locale('ja')
|
||||
];
|
||||
const fallbackLocale = Locale('en');
|
||||
const localeDir = 'assets/translations';
|
||||
|
||||
final globalNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
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')
|
||||
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await EasyLocalization.ensureInitialized();
|
||||
|
||||
await loadTranslations();
|
||||
|
||||
LogsProvider logs = LogsProvider();
|
||||
logs.add(tr('startedBgUpdateTask'));
|
||||
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
|
||||
await AndroidAlarmManager.initialize();
|
||||
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
|
||||
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
|
||||
: null;
|
||||
logs.add(tr('bgUpdateIgnoreAfterIs', args: [ignoreAfter.toString()]));
|
||||
var notificationsProvider = NotificationsProvider();
|
||||
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||
try {
|
||||
@ -29,24 +81,28 @@ bgUpdateCheck(int? ignoreAfterMicroseconds) async {
|
||||
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||
await appsProvider.loadApps();
|
||||
List<String> existingUpdateIds =
|
||||
appsProvider.getExistingUpdates(installedOnly: true);
|
||||
appsProvider.findExistingUpdates(installedOnly: true);
|
||||
DateTime nextIgnoreAfter = DateTime.now();
|
||||
String? err;
|
||||
try {
|
||||
await appsProvider.checkUpdates(ignoreAfter: ignoreAfter);
|
||||
logs.add(tr('startedActualBGUpdateCheck'));
|
||||
await appsProvider.checkUpdates(
|
||||
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
|
||||
} catch (e) {
|
||||
if (e is RateLimitError) {
|
||||
String nextTaskName =
|
||||
'$bgUpdateCheckTaskName-${nextIgnoreAfter.microsecondsSinceEpoch.toString()}';
|
||||
Workmanager().registerOneOffTask(nextTaskName, nextTaskName,
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
initialDelay: Duration(minutes: e.remainingMinutes),
|
||||
inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch});
|
||||
if (e is RateLimitError || e is SocketException) {
|
||||
var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15;
|
||||
logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes,
|
||||
args: [e.toString(), remainingMinutes.toString()]));
|
||||
AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes),
|
||||
Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: {
|
||||
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
|
||||
});
|
||||
} else {
|
||||
rethrow;
|
||||
err = e.toString();
|
||||
}
|
||||
}
|
||||
List<App> newUpdates = appsProvider
|
||||
.getExistingUpdates(installedOnly: true)
|
||||
.findExistingUpdates(installedOnly: true)
|
||||
.where((id) => !existingUpdateIds.contains(id))
|
||||
.map((e) => appsProvider.apps[e]!.app)
|
||||
.toList();
|
||||
@ -64,51 +120,45 @@ bgUpdateCheck(int? ignoreAfterMicroseconds) async {
|
||||
// silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()),
|
||||
// cancelExisting: true);
|
||||
// }
|
||||
|
||||
logs.add(
|
||||
plural('bgCheckFoundUpdatesWillNotifyIfNeeded', newUpdates.length));
|
||||
if (newUpdates.isNotEmpty) {
|
||||
notificationsProvider.notify(UpdateNotification(newUpdates),
|
||||
cancelExisting: true);
|
||||
notificationsProvider.notify(UpdateNotification(newUpdates));
|
||||
}
|
||||
if (err != null) {
|
||||
throw err;
|
||||
}
|
||||
return Future.value(true);
|
||||
} catch (e) {
|
||||
notificationsProvider.notify(ErrorCheckingUpdatesNotification(e.toString()),
|
||||
cancelExisting: true);
|
||||
return Future.error(false);
|
||||
notificationsProvider
|
||||
.notify(ErrorCheckingUpdatesNotification(e.toString()));
|
||||
} finally {
|
||||
logs.add(tr('bgUpdateTaskFinished'));
|
||||
await notificationsProvider.cancel(checkingUpdatesNotification.id);
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void bgTaskCallback() {
|
||||
// Background process callback
|
||||
Workmanager().executeTask((task, inputData) async {
|
||||
return await bgUpdateCheck(inputData?['ignoreAfter']);
|
||||
});
|
||||
}
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) {
|
||||
await EasyLocalization.ensureInitialized();
|
||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
||||
);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
}
|
||||
Workmanager().initialize(
|
||||
bgTaskCallback,
|
||||
);
|
||||
await AndroidAlarmManager.initialize();
|
||||
runApp(MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => AppsProvider(
|
||||
shouldLoadApps: true,
|
||||
shouldCheckUpdatesAfterLoad: false,
|
||||
shouldDeleteAPKs: true)),
|
||||
ChangeNotifierProvider(create: (context) => AppsProvider()),
|
||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||
Provider(create: (context) => NotificationsProvider())
|
||||
Provider(create: (context) => NotificationsProvider()),
|
||||
Provider(create: (context) => LogsProvider())
|
||||
],
|
||||
child: const Obtainium(),
|
||||
child: EasyLocalization(
|
||||
supportedLocales: supportedLocales,
|
||||
path: localeDir,
|
||||
fallbackLocale: fallbackLocale,
|
||||
child: const Obtainium()),
|
||||
));
|
||||
}
|
||||
|
||||
@ -128,17 +178,19 @@ class _ObtainiumState extends State<Obtainium> {
|
||||
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(
|
||||
'imranr98_obtainium_${GitHub().host}',
|
||||
obtainiumId,
|
||||
'https://github.com/ImranR98/Obtainium',
|
||||
'ImranR98',
|
||||
'Obtainium',
|
||||
@ -147,24 +199,27 @@ class _ObtainiumState extends State<Obtainium> {
|
||||
[],
|
||||
0,
|
||||
['true'],
|
||||
null)
|
||||
null,
|
||||
false,
|
||||
false)
|
||||
]);
|
||||
}
|
||||
// Register the background update task according to the user's setting
|
||||
if (existingUpdateInterval != settingsProvider.updateInterval) {
|
||||
if (existingUpdateInterval != -1) {
|
||||
logs.add(tr('settingUpdateCheckIntervalTo',
|
||||
args: [settingsProvider.updateInterval.toString()]));
|
||||
}
|
||||
existingUpdateInterval = settingsProvider.updateInterval;
|
||||
if (existingUpdateInterval == 0) {
|
||||
Workmanager().cancelByUniqueName(bgUpdateCheckTaskName);
|
||||
AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
|
||||
} else {
|
||||
Workmanager().registerPeriodicTask(
|
||||
bgUpdateCheckTaskName, bgUpdateCheckTaskName,
|
||||
frequency: Duration(minutes: existingUpdateInterval),
|
||||
initialDelay: Duration(minutes: existingUpdateInterval),
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingWorkPolicy.replace,
|
||||
backoffPolicy: BackoffPolicy.linear,
|
||||
backoffPolicyDelay:
|
||||
const Duration(minutes: minUpdateIntervalMinutes));
|
||||
AndroidAlarmManager.periodic(
|
||||
Duration(minutes: existingUpdateInterval),
|
||||
bgUpdateCheckAlarmId,
|
||||
bgUpdateCheck,
|
||||
rescheduleOnReboot: true,
|
||||
wakeup: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -186,6 +241,10 @@ class _ObtainiumState extends State<Obtainium> {
|
||||
}
|
||||
return MaterialApp(
|
||||
title: 'Obtainium',
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: context.locale,
|
||||
navigatorKey: globalNavigatorKey,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: settingsProvider.theme == ThemeSettings.dark
|
||||
|
@ -1,51 +1,54 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class GitHubStars implements MassAppSource {
|
||||
class GitHubStars implements MassAppUrlSource {
|
||||
@override
|
||||
late String name = 'GitHub Starred Repos';
|
||||
late String name = tr('githubStarredRepos');
|
||||
|
||||
@override
|
||||
late List<String> requiredArgs = ['Username'];
|
||||
late List<String> requiredArgs = [tr('uname')];
|
||||
|
||||
Future<List<String>> getOnePageOfUserStarredUrls(
|
||||
Future<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions(
|
||||
String username, int page) async {
|
||||
Response res = await get(Uri.parse(
|
||||
'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page'));
|
||||
if (res.statusCode == 200) {
|
||||
return (jsonDecode(res.body) as List<dynamic>)
|
||||
.map((e) => e['html_url'] as String)
|
||||
.toList();
|
||||
} else {
|
||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||
throw RateLimitError(
|
||||
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||
60000000)
|
||||
.round());
|
||||
Map<String, String> urlsWithDescriptions = {};
|
||||
for (var e in (jsonDecode(res.body) as List<dynamic>)) {
|
||||
urlsWithDescriptions.addAll({
|
||||
e['html_url'] as String: e['description'] != null
|
||||
? e['description'] as String
|
||||
: tr('noDescription')
|
||||
});
|
||||
}
|
||||
|
||||
throw 'Unable to find user\'s starred repos';
|
||||
return urlsWithDescriptions;
|
||||
} else {
|
||||
var gh = GitHub();
|
||||
gh.rateLimitErrorCheck(res);
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> getUrls(List<String> args) async {
|
||||
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args) async {
|
||||
if (args.length != requiredArgs.length) {
|
||||
throw 'Wrong number of arguments provided';
|
||||
throw ObtainiumError(tr('wrongArgNum'));
|
||||
}
|
||||
List<String> urls = [];
|
||||
Map<String, String> urlsWithDescriptions = {};
|
||||
var page = 1;
|
||||
while (true) {
|
||||
var pageUrls = await getOnePageOfUserStarredUrls(args[0], page++);
|
||||
urls.addAll(pageUrls);
|
||||
var pageUrls =
|
||||
await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++);
|
||||
urlsWithDescriptions.addAll(pageUrls);
|
||||
if (pageUrls.length < 100) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
return urlsWithDescriptions;
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,13 @@
|
||||
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/main.dart';
|
||||
import 'package:obtainium/pages/app.dart';
|
||||
import 'package:obtainium/pages/import_export.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
@ -20,18 +25,126 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
bool gettingAppInfo = false;
|
||||
|
||||
String userInput = '';
|
||||
String searchQuery = '';
|
||||
AppSource? pickedSource;
|
||||
List<String> additionalData = [];
|
||||
String customName = '';
|
||||
bool validAdditionalData = true;
|
||||
List<String> sourceSpecificAdditionalData = [];
|
||||
bool sourceSpecificDataIsValid = true;
|
||||
List<String> otherAdditionalData = [];
|
||||
bool otherAdditionalDataIsValid = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||
|
||||
changeUserInput(String input, bool valid, bool isBuilding) {
|
||||
userInput = input;
|
||||
fn() {
|
||||
var source = valid ? sourceProvider.getSource(userInput) : null;
|
||||
if (pickedSource.runtimeType != source.runtimeType) {
|
||||
pickedSource = source;
|
||||
sourceSpecificAdditionalData =
|
||||
source != null ? source.additionalSourceAppSpecificDefaults : [];
|
||||
sourceSpecificDataIsValid = source != null
|
||||
? !sourceProvider.ifSourceAppsRequireAdditionalData(source)
|
||||
: true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isBuilding) {
|
||||
fn();
|
||||
} else {
|
||||
setState(() {
|
||||
fn();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addApp({bool resetUserInputAfter = false}) async {
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
var settingsProvider = context.read<SettingsProvider>();
|
||||
() async {
|
||||
var userPickedTrackOnly = findGeneratedFormValueByKey(
|
||||
pickedSource!.additionalAppSpecificSourceAgnosticFormItems,
|
||||
otherAdditionalData,
|
||||
'trackOnlyFormItemKey') ==
|
||||
'true';
|
||||
var cont = true;
|
||||
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('xIsTrackOnly', args: [
|
||||
pickedSource!.enforceTrackOnly
|
||||
? tr('source')
|
||||
: tr('app')
|
||||
]),
|
||||
items: const [],
|
||||
defaultValues: const [],
|
||||
message:
|
||||
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
|
||||
);
|
||||
}) ==
|
||||
null) {
|
||||
cont = false;
|
||||
}
|
||||
if (cont) {
|
||||
HapticFeedback.selectionClick();
|
||||
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
|
||||
App app = await sourceProvider.getApp(
|
||||
pickedSource!, userInput, sourceSpecificAdditionalData,
|
||||
trackOnly: trackOnly);
|
||||
if (!trackOnly) {
|
||||
await settingsProvider.getInstallPermission();
|
||||
}
|
||||
// Only download the APK here if you need to for the package ID
|
||||
if (sourceProvider.isTempId(app.id) && !app.trackOnly) {
|
||||
// ignore: use_build_context_synchronously
|
||||
var apkUrl = await appsProvider.confirmApkUrl(app, context);
|
||||
if (apkUrl == null) {
|
||||
throw ObtainiumError(tr('cancelled'));
|
||||
}
|
||||
app.preferredApkIndex = app.apkUrls.indexOf(apkUrl);
|
||||
// ignore: use_build_context_synchronously
|
||||
var downloadedApk = await appsProvider.downloadApp(
|
||||
app, globalNavigatorKey.currentContext);
|
||||
app.id = downloadedApk.appId;
|
||||
}
|
||||
if (appsProvider.apps.containsKey(app.id)) {
|
||||
throw ObtainiumError(tr('appAlreadyAdded'));
|
||||
}
|
||||
if (app.trackOnly) {
|
||||
app.installedVersion = app.latestVersion;
|
||||
}
|
||||
await appsProvider.saveApps([app]);
|
||||
|
||||
return app;
|
||||
}
|
||||
}()
|
||||
.then((app) {
|
||||
if (app != null) {
|
||||
Navigator.push(context,
|
||||
MaterialPageRoute(builder: (context) => AppPage(appId: app.id)));
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
gettingAppInfo = false;
|
||||
if (resetUserInputAfter) {
|
||||
changeUserInput('', false, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
const CustomAppBar(title: 'Add App'),
|
||||
CustomAppBar(title: tr('addApp')),
|
||||
SliverFillRemaining(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@ -45,7 +158,7 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'App Source Url',
|
||||
label: tr('appSourceURL'),
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
try {
|
||||
@ -57,92 +170,137 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
} catch (e) {
|
||||
return e is String
|
||||
? e
|
||||
: 'Error';
|
||||
: e is ObtainiumError
|
||||
? e.toString()
|
||||
: tr('error');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
])
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid) {
|
||||
setState(() {
|
||||
userInput = values[0];
|
||||
var source = valid
|
||||
? sourceProvider.getSource(userInput)
|
||||
: null;
|
||||
if (pickedSource != source) {
|
||||
pickedSource = source;
|
||||
additionalData = source != null
|
||||
? source.additionalDataDefaults
|
||||
: [];
|
||||
validAdditionalData = source != null
|
||||
? sourceProvider
|
||||
.doesSourceHaveRequiredAdditionalData(
|
||||
source)
|
||||
: true;
|
||||
if (source == null) {
|
||||
customName = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
changeUserInput(
|
||||
values[0], valid, isBuilding);
|
||||
},
|
||||
defaultValues: const [])),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: gettingAppInfo ||
|
||||
pickedSource == null ||
|
||||
(pickedSource!.additionalDataFormItems
|
||||
.isNotEmpty &&
|
||||
!validAdditionalData)
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
sourceProvider
|
||||
.getApp(pickedSource!, userInput,
|
||||
additionalData,
|
||||
customName: customName)
|
||||
.then((app) {
|
||||
var appsProvider =
|
||||
context.read<AppsProvider>();
|
||||
var settingsProvider =
|
||||
context.read<SettingsProvider>();
|
||||
if (appsProvider.apps
|
||||
.containsKey(app.id)) {
|
||||
throw 'App already added';
|
||||
}
|
||||
settingsProvider
|
||||
.getInstallPermission()
|
||||
.then((_) {
|
||||
appsProvider
|
||||
.saveApps([app]).then((_) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
AppPage(
|
||||
appId: app.id)));
|
||||
});
|
||||
});
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
gettingAppInfo = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: const Text('Add'))
|
||||
gettingAppInfo
|
||||
? const CircularProgressIndicator()
|
||||
: ElevatedButton(
|
||||
onPressed: gettingAppInfo ||
|
||||
pickedSource == null ||
|
||||
(pickedSource!
|
||||
.additionalSourceAppSpecificFormItems
|
||||
.isNotEmpty &&
|
||||
!sourceSpecificDataIsValid) ||
|
||||
(pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticDefaults
|
||||
.isNotEmpty &&
|
||||
!otherAdditionalDataIsValid)
|
||||
? null
|
||||
: addApp,
|
||||
child: Text(tr('add')))
|
||||
],
|
||||
),
|
||||
if (pickedSource != null)
|
||||
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: [
|
||||
@ -150,7 +308,8 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
height: 64,
|
||||
),
|
||||
Text(
|
||||
'Additional Options for ${pickedSource?.runtimeType}',
|
||||
tr('additionalOptsFor',
|
||||
args: [pickedSource?.name ?? tr('source')]),
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary)),
|
||||
@ -158,37 +317,51 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
height: 16,
|
||||
),
|
||||
if (pickedSource!
|
||||
.additionalDataFormItems.isNotEmpty)
|
||||
.additionalSourceAppSpecificFormItems
|
||||
.isNotEmpty)
|
||||
GeneratedForm(
|
||||
items: pickedSource!.additionalDataFormItems,
|
||||
onValueChanges: (values, valid) {
|
||||
setState(() {
|
||||
additionalData = values;
|
||||
validAdditionalData = valid;
|
||||
});
|
||||
items: pickedSource!
|
||||
.additionalSourceAppSpecificFormItems,
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (isBuilding) {
|
||||
sourceSpecificAdditionalData = values;
|
||||
sourceSpecificDataIsValid = valid;
|
||||
} else {
|
||||
setState(() {
|
||||
sourceSpecificAdditionalData = values;
|
||||
sourceSpecificDataIsValid = valid;
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues:
|
||||
pickedSource!.additionalDataDefaults),
|
||||
defaultValues: pickedSource!
|
||||
.additionalSourceAppSpecificDefaults),
|
||||
if (pickedSource!
|
||||
.additionalDataFormItems.isNotEmpty)
|
||||
.additionalAppSpecificSourceAgnosticDefaults
|
||||
.isNotEmpty)
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
if (pickedSource != null)
|
||||
GeneratedForm(
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Custom App Name',
|
||||
required: false)
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid) {
|
||||
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(() {
|
||||
customName = values[0];
|
||||
otherAdditionalData = values;
|
||||
otherAdditionalDataIsValid = valid;
|
||||
});
|
||||
},
|
||||
defaultValues: [customName])
|
||||
}
|
||||
},
|
||||
defaultValues: pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticDefaults),
|
||||
],
|
||||
)
|
||||
else
|
||||
@ -197,32 +370,38 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// const SizedBox(
|
||||
// height: 48,
|
||||
// ),
|
||||
const Text(
|
||||
'Supported Sources:',
|
||||
const SizedBox(
|
||||
height: 48,
|
||||
),
|
||||
Text(
|
||||
tr('supportedSourcesBelow'),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
...sourceProvider
|
||||
.getSourceHosts()
|
||||
...sourceProvider.sources
|
||||
.map((e) => GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString('https://$e',
|
||||
mode:
|
||||
LaunchMode.externalApplication);
|
||||
},
|
||||
onTap: e.host != null
|
||||
? () {
|
||||
launchUrlString(
|
||||
'https://${e.host}',
|
||||
mode: LaunchMode
|
||||
.externalApplication);
|
||||
}
|
||||
: null,
|
||||
child: Text(
|
||||
e,
|
||||
style: const TextStyle(
|
||||
decoration:
|
||||
TextDecoration.underline,
|
||||
'${e.name}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
|
||||
style: TextStyle(
|
||||
decoration: e.host != null
|
||||
? TextDecoration.underline
|
||||
: TextDecoration.none,
|
||||
fontStyle: FontStyle.italic),
|
||||
)))
|
||||
.toList()
|
||||
])),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
])),
|
||||
)
|
||||
]));
|
||||
|
@ -1,7 +1,9 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/main.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
@ -26,10 +28,8 @@ class _AppPageState extends State<AppPage> {
|
||||
var appsProvider = context.watch<AppsProvider>();
|
||||
var settingsProvider = context.watch<SettingsProvider>();
|
||||
getUpdate(String id) {
|
||||
appsProvider.getUpdate(id).catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
appsProvider.checkUpdate(id).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
}
|
||||
|
||||
@ -46,6 +46,7 @@ class _AppPageState extends State<AppPage> {
|
||||
body: RefreshIndicator(
|
||||
child: settingsProvider.showAppWebpage
|
||||
? WebView(
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
initialUrl: app?.app.url,
|
||||
javascriptMode: JavascriptMode.unrestricted,
|
||||
)
|
||||
@ -56,13 +57,27 @@ class _AppPageState extends State<AppPage> {
|
||||
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?.app.name ?? 'App',
|
||||
app?.installedInfo?.name ?? app?.app.name ?? 'App',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
Text(
|
||||
'By ${app?.app.author ?? 'Unknown'}',
|
||||
tr('byX', args: [app?.app.author ?? tr('unknown')]),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
@ -88,12 +103,17 @@ class _AppPageState extends State<AppPage> {
|
||||
height: 32,
|
||||
),
|
||||
Text(
|
||||
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
|
||||
tr('latestVersionX',
|
||||
args: [app?.app.latestVersion ?? tr('unknown')]),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
|
||||
'${tr('installedVersionX', args: [
|
||||
app?.app.installedVersion ?? tr('none')
|
||||
])}${app?.app.trackOnly == true ? ' ${tr('estimateInBrackets')}\n\n${tr('xIsTrackOnly', args: [
|
||||
tr('app')
|
||||
])}' : ''}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
@ -101,7 +121,11 @@ class _AppPageState extends State<AppPage> {
|
||||
height: 32,
|
||||
),
|
||||
Text(
|
||||
'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}',
|
||||
tr('lastUpdateCheckX', args: [
|
||||
app?.app.lastUpdateCheck == null
|
||||
? tr('never')
|
||||
: '\n${app?.app.lastUpdateCheck?.toLocal()}'
|
||||
]),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic, fontSize: 12),
|
||||
@ -126,7 +150,9 @@ class _AppPageState extends State<AppPage> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (app?.app.installedVersion != app?.app.latestVersion)
|
||||
if (app?.app.installedVersion != null &&
|
||||
app?.app.trackOnly == false &&
|
||||
app?.app.installedVersion != app?.app.latestVersion)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
@ -135,15 +161,22 @@ class _AppPageState extends State<AppPage> {
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'),
|
||||
title: Text(tr(
|
||||
'alreadyUpToDateQuestion')),
|
||||
content: Text(
|
||||
tr('onlyWorksWithNonEVDApps'),
|
||||
style: const TextStyle(
|
||||
fontWeight:
|
||||
FontWeight.bold,
|
||||
fontStyle:
|
||||
FontStyle.italic)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text('No')),
|
||||
child: Text(tr('no'))),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback
|
||||
@ -160,57 +193,17 @@ class _AppPageState extends State<AppPage> {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Yes, Mark as Installed'))
|
||||
child: Text(
|
||||
tr('yesMarkUpdated')))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
tooltip: 'Mark as Installed',
|
||||
icon: const Icon(Icons.done))
|
||||
else
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'App Not Installed?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text('No')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
var updatedApp = app?.app;
|
||||
if (updatedApp != null) {
|
||||
updatedApp
|
||||
.installedVersion =
|
||||
null;
|
||||
appsProvider.saveApps(
|
||||
[updatedApp]);
|
||||
}
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Yes, Mark as Not Installed'))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
tooltip: 'Mark as Not Installed',
|
||||
icon: const Icon(Icons.no_cell_outlined)),
|
||||
tooltip: 'Mark as Updated',
|
||||
icon: const Icon(Icons.done)),
|
||||
if (source != null &&
|
||||
source.additionalDataFormItems.isNotEmpty)
|
||||
source.additionalSourceAppSpecificFormItems
|
||||
.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
@ -220,30 +213,15 @@ class _AppPageState extends State<AppPage> {
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: 'Additional Options',
|
||||
items: [
|
||||
...source
|
||||
.additionalDataFormItems,
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'App Name',
|
||||
required: true)
|
||||
]
|
||||
],
|
||||
items: source
|
||||
.additionalSourceAppSpecificFormItems,
|
||||
defaultValues: app != null
|
||||
? [
|
||||
...app
|
||||
.app.additionalData,
|
||||
app.app.name
|
||||
]
|
||||
: [
|
||||
...source
|
||||
.additionalDataDefaults
|
||||
]);
|
||||
? app.app.additionalData
|
||||
: source
|
||||
.additionalSourceAppSpecificDefaults);
|
||||
}).then((values) {
|
||||
if (app != null && values != null) {
|
||||
var changedApp = app.app;
|
||||
var name = values.removeLast();
|
||||
changedApp.name = name;
|
||||
changedApp.additionalData = values;
|
||||
appsProvider.saveApps(
|
||||
[changedApp]).then((value) {
|
||||
@ -258,25 +236,40 @@ class _AppPageState extends State<AppPage> {
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: (app?.app.installedVersion == null ||
|
||||
appsProvider
|
||||
.checkAppObjectForUpdate(
|
||||
app!.app)) &&
|
||||
app?.app.installedVersion !=
|
||||
app?.app.latestVersion) &&
|
||||
!appsProvider.areDownloadsRunning()
|
||||
? () {
|
||||
HapticFeedback.heavyImpact();
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApp(
|
||||
[app!.app.id],
|
||||
context).then((res) {
|
||||
if (res.isNotEmpty && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
() async {
|
||||
if (app?.app.trackOnly != true) {
|
||||
await settingsProvider
|
||||
.getInstallPermission();
|
||||
}
|
||||
}()
|
||||
.then((value) {
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApps(
|
||||
[app!.app.id],
|
||||
globalNavigatorKey
|
||||
.currentContext).then(
|
||||
(res) {
|
||||
if (res.isNotEmpty && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
}
|
||||
: null,
|
||||
child: Text(app?.app.installedVersion == null
|
||||
? 'Install'
|
||||
: 'Update'))),
|
||||
? app?.app.trackOnly == false
|
||||
? 'Install'
|
||||
: 'Mark Installed'
|
||||
: app?.app.trackOnly == false
|
||||
? 'Update'
|
||||
: 'Mark Updated'))),
|
||||
const SizedBox(width: 16.0),
|
||||
ElevatedButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
@ -286,9 +279,14 @@ class _AppPageState extends State<AppPage> {
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text('Remove App?'),
|
||||
content: Text(
|
||||
'This will remove \'${app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'),
|
||||
title: Text(tr('removeAppQuestion')),
|
||||
content: Text(tr(
|
||||
'xWillBeRemovedButRemainInstalled',
|
||||
args: [
|
||||
app?.installedInfo?.name ??
|
||||
app?.app.name ??
|
||||
tr('app')
|
||||
])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
@ -302,12 +300,12 @@ class _AppPageState extends State<AppPage> {
|
||||
count++ >= 2);
|
||||
});
|
||||
},
|
||||
child: const Text('Remove')),
|
||||
child: Text(tr('remove'))),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'))
|
||||
child: Text(tr('cancel')))
|
||||
],
|
||||
);
|
||||
});
|
||||
@ -317,7 +315,7 @@ class _AppPageState extends State<AppPage> {
|
||||
Theme.of(context).colorScheme.error,
|
||||
surfaceTintColor:
|
||||
Theme.of(context).colorScheme.error),
|
||||
child: const Text('Remove'),
|
||||
child: Text(tr('remove')),
|
||||
),
|
||||
])),
|
||||
if (app?.downloadProgress != null)
|
||||
|
@ -1,8 +1,11 @@
|
||||
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/main.dart';
|
||||
import 'package:obtainium/pages/app.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
@ -22,23 +25,24 @@ class AppsPageState extends State<AppsPage> {
|
||||
AppsFilter? filter;
|
||||
var updatesOnlyFilter =
|
||||
AppsFilter(includeUptodate: false, includeNonInstalled: false);
|
||||
Set<String> selectedIds = {};
|
||||
Set<App> selectedApps = {};
|
||||
DateTime? refreshingSince;
|
||||
|
||||
clearSelected() {
|
||||
if (selectedIds.isNotEmpty) {
|
||||
if (selectedApps.isNotEmpty) {
|
||||
setState(() {
|
||||
selectedIds.clear();
|
||||
selectedApps.clear();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
selectThese(List<String> appIds) {
|
||||
if (selectedIds.isEmpty) {
|
||||
selectThese(List<App> apps) {
|
||||
if (selectedApps.isEmpty) {
|
||||
setState(() {
|
||||
for (var a in appIds) {
|
||||
selectedIds.add(a);
|
||||
for (var a in apps) {
|
||||
selectedApps.add(a);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -52,16 +56,16 @@ class AppsPageState extends State<AppsPage> {
|
||||
var currentFilterIsUpdatesOnly =
|
||||
filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
|
||||
|
||||
selectedIds = selectedIds
|
||||
.where((element) => sortedApps.map((e) => e.app.id).contains(element))
|
||||
selectedApps = selectedApps
|
||||
.where((element) => sortedApps.map((e) => e.app).contains(element))
|
||||
.toSet();
|
||||
|
||||
toggleAppSelected(String appId) {
|
||||
toggleAppSelected(App app) {
|
||||
setState(() {
|
||||
if (selectedIds.contains(appId)) {
|
||||
selectedIds.remove(appId);
|
||||
if (selectedApps.contains(app)) {
|
||||
selectedApps.remove(app);
|
||||
} else {
|
||||
selectedIds.add(appId);
|
||||
selectedApps.add(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -89,7 +93,8 @@ class AppsPageState extends State<AppsPage> {
|
||||
.toList();
|
||||
|
||||
for (var t in nameTokens) {
|
||||
if (!app.app.name.toLowerCase().contains(t.toLowerCase())) {
|
||||
var name = app.installedInfo?.name ?? app.app.name;
|
||||
if (!name.toLowerCase().contains(t.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -103,47 +108,92 @@ class AppsPageState extends State<AppsPage> {
|
||||
}
|
||||
|
||||
sortedApps.sort((a, b) {
|
||||
var nameA = a.installedInfo?.name ?? a.app.name;
|
||||
var nameB = b.installedInfo?.name ?? b.app.name;
|
||||
int result = 0;
|
||||
if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
|
||||
result =
|
||||
(a.app.author + a.app.name).compareTo(b.app.author + b.app.name);
|
||||
result = (a.app.author + nameA).compareTo(b.app.author + nameB);
|
||||
} else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) {
|
||||
result =
|
||||
(a.app.name + a.app.author).compareTo(b.app.name + b.app.author);
|
||||
result = (nameA + a.app.author).compareTo(nameB + b.app.author);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
if (settingsProvider.sortOrder == SortOrderSettings.ascending) {
|
||||
if (settingsProvider.sortOrder == SortOrderSettings.descending) {
|
||||
sortedApps = sortedApps.reversed.toList();
|
||||
}
|
||||
|
||||
var existingUpdateIdsAllOrSelected = appsProvider
|
||||
.getExistingUpdates(installedOnly: true)
|
||||
.where((element) => selectedIds.isEmpty
|
||||
var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
|
||||
|
||||
var existingUpdateIdsAllOrSelected = existingUpdates
|
||||
.where((element) => selectedApps.isEmpty
|
||||
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||
: selectedIds.contains(element))
|
||||
: selectedApps.map((e) => e.id).contains(element))
|
||||
.toList();
|
||||
var newInstallIdsAllOrSelected = appsProvider
|
||||
.getExistingUpdates(nonInstalledOnly: true)
|
||||
.where((element) => selectedIds.isEmpty
|
||||
.findExistingUpdates(nonInstalledOnly: true)
|
||||
.where((element) => selectedApps.isEmpty
|
||||
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||
: selectedIds.contains(element))
|
||||
: selectedApps.map((e) => e.id).contains(element))
|
||||
.toList();
|
||||
|
||||
List<String> trackOnlyUpdateIdsAllOrSelected = [];
|
||||
existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) {
|
||||
if (appsProvider.apps[id]!.app.trackOnly) {
|
||||
trackOnlyUpdateIdsAllOrSelected.add(id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) {
|
||||
if (appsProvider.apps[id]!.app.trackOnly) {
|
||||
trackOnlyUpdateIdsAllOrSelected.add(id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
if (settingsProvider.pinUpdates) {
|
||||
var temp = [];
|
||||
sortedApps = sortedApps.where((sa) {
|
||||
if (existingUpdates.contains(sa.app.id)) {
|
||||
temp.add(sa);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
sortedApps = [...temp, ...sortedApps];
|
||||
}
|
||||
|
||||
var tempPinned = [];
|
||||
var tempNotPinned = [];
|
||||
for (var a in sortedApps) {
|
||||
if (a.app.pinned) {
|
||||
tempPinned.add(a);
|
||||
} else {
|
||||
tempNotPinned.add(a);
|
||||
}
|
||||
}
|
||||
sortedApps = [...tempPinned, ...tempNotPinned];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
setState(() {
|
||||
refreshingSince = DateTime.now();
|
||||
});
|
||||
return appsProvider.checkUpdates().catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
refreshingSince = null;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: CustomScrollView(slivers: <Widget>[
|
||||
const CustomAppBar(title: 'Apps'),
|
||||
CustomAppBar(title: tr('appsString')),
|
||||
if (appsProvider.loadingApps || sortedApps.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
@ -151,65 +201,108 @@ class AppsPageState extends State<AppsPage> {
|
||||
? const CircularProgressIndicator()
|
||||
: Text(
|
||||
appsProvider.apps.isEmpty
|
||||
? 'No Apps'
|
||||
: 'No Apps for Filter',
|
||||
? tr('noApps')
|
||||
: tr('noAppsForFilter'),
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
))),
|
||||
if (refreshingSince != null)
|
||||
SliverToBoxAdapter(
|
||||
child: LinearProgressIndicator(
|
||||
value: appsProvider.apps.values
|
||||
.where((element) => !(element.app.lastUpdateCheck
|
||||
?.isBefore(refreshingSince!) ??
|
||||
true))
|
||||
.length /
|
||||
appsProvider.apps.length,
|
||||
),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
String? changesUrl = SourceProvider()
|
||||
.getSource(sortedApps[index].app.url)
|
||||
.changeLogPageFromStandardUrl(sortedApps[index].app.url);
|
||||
return ListTile(
|
||||
selectedTileColor:
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
selected: selectedIds.contains(sortedApps[index].app.id),
|
||||
tileColor: sortedApps[index].app.pinned
|
||||
? Colors.grey.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
selectedTileColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1),
|
||||
selected: selectedApps.contains(sortedApps[index].app),
|
||||
onLongPress: () {
|
||||
toggleAppSelected(sortedApps[index].app.id);
|
||||
toggleAppSelected(sortedApps[index].app);
|
||||
},
|
||||
title: Text(sortedApps[index].app.name),
|
||||
subtitle: Text('By ${sortedApps[index].app.author}'),
|
||||
trailing: sortedApps[index].downloadProgress != null
|
||||
? Text(
|
||||
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
|
||||
: (sortedApps[index].app.installedVersion != null &&
|
||||
sortedApps[index].app.installedVersion !=
|
||||
sortedApps[index].app.latestVersion
|
||||
? Column(
|
||||
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: SingleChildScrollView(
|
||||
reverse: true,
|
||||
child: sortedApps[index].downloadProgress != null
|
||||
? Text(tr('percentProgress', args: [
|
||||
sortedApps[index]
|
||||
.downloadProgress
|
||||
?.toInt()
|
||||
.toString() ??
|
||||
'100'
|
||||
]))
|
||||
: (Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const Text('Update Available'),
|
||||
SourceProvider()
|
||||
.getSource(sortedApps[index].app.url)
|
||||
.changeLogPageFromStandardUrl(
|
||||
sortedApps[index].app.url) ==
|
||||
null
|
||||
? const SizedBox()
|
||||
: GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
SourceProvider()
|
||||
.getSource(
|
||||
sortedApps[index].app.url)
|
||||
.changeLogPageFromStandardUrl(
|
||||
sortedApps[index].app.url)!,
|
||||
mode:
|
||||
LaunchMode.externalApplication);
|
||||
},
|
||||
child: const Text(
|
||||
'See Changes',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
decoration:
|
||||
TextDecoration.underline),
|
||||
)),
|
||||
SizedBox(
|
||||
width: 100,
|
||||
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: appsProvider.areDownloadsRunning()
|
||||
? Text(tr('pleaseWait'))
|
||||
: Text(
|
||||
'${tr('updateAvailable')}${sortedApps[index].app.trackOnly ? ' ${tr('estimateInBracketsShort')}' : ''}',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
decoration: changesUrl == null
|
||||
? TextDecoration.none
|
||||
: TextDecoration
|
||||
.underline),
|
||||
))
|
||||
: const SizedBox(),
|
||||
],
|
||||
)
|
||||
: Text(sortedApps[index].app.installedVersion ??
|
||||
'Not Installed')),
|
||||
))),
|
||||
onTap: () {
|
||||
if (selectedIds.isNotEmpty) {
|
||||
toggleAppSelected(sortedApps[index].app.id);
|
||||
if (selectedApps.isNotEmpty) {
|
||||
toggleAppSelected(sortedApps[index].app);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
@ -227,25 +320,25 @@ class AppsPageState extends State<AppsPage> {
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
selectedIds.isEmpty
|
||||
? selectThese(sortedApps.map((e) => e.app.id).toList())
|
||||
selectedApps.isEmpty
|
||||
? selectThese(sortedApps.map((e) => e.app).toList())
|
||||
: clearSelected();
|
||||
},
|
||||
icon: Icon(
|
||||
selectedIds.isEmpty
|
||||
selectedApps.isEmpty
|
||||
? Icons.select_all_outlined
|
||||
: Icons.deselect_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
tooltip: selectedIds.isEmpty
|
||||
? 'Select All'
|
||||
: 'Deselect ${selectedIds.length.toString()}'),
|
||||
tooltip: selectedApps.isEmpty
|
||||
? tr('selectAll')
|
||||
: tr('deselectN', args: [selectedApps.length.toString()])),
|
||||
const VerticalDivider(),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
selectedIds.isEmpty
|
||||
selectedApps.isEmpty
|
||||
? const SizedBox()
|
||||
: IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
@ -254,66 +347,107 @@ class AppsPageState extends State<AppsPage> {
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: 'Remove Selected Apps?',
|
||||
title: tr('removeSelectedAppsQuestion'),
|
||||
items: const [],
|
||||
defaultValues: const [],
|
||||
initValid: true,
|
||||
message:
|
||||
'${selectedIds.length} App${selectedIds.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedIds.length == 1 ? 'it' : 'them'} manually.',
|
||||
message: tr(
|
||||
'xWillBeRemovedButRemainInstalled',
|
||||
args: [
|
||||
plural('apps', selectedApps.length)
|
||||
]),
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
appsProvider.removeApps(selectedIds.toList());
|
||||
appsProvider.removeApps(
|
||||
selectedApps.map((e) => e.id).toList());
|
||||
}
|
||||
});
|
||||
},
|
||||
tooltip: 'Remove Selected Apps',
|
||||
tooltip: tr('removeSelectedApps'),
|
||||
icon: const Icon(Icons.delete_outline_outlined),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: appsProvider.areDownloadsRunning() ||
|
||||
(existingUpdateIdsAllOrSelected.isEmpty &&
|
||||
newInstallIdsAllOrSelected.isEmpty)
|
||||
newInstallIdsAllOrSelected.isEmpty &&
|
||||
trackOnlyUpdateIdsAllOrSelected.isEmpty)
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
List<List<GeneratedFormItem>> formInputs = [];
|
||||
if (existingUpdateIdsAllOrSelected.isNotEmpty &&
|
||||
newInstallIdsAllOrSelected.isNotEmpty) {
|
||||
formInputs.add([
|
||||
GeneratedFormItem(
|
||||
label:
|
||||
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
|
||||
type: FormItemType.bool)
|
||||
]);
|
||||
formInputs.add([
|
||||
GeneratedFormItem(
|
||||
label:
|
||||
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}',
|
||||
type: FormItemType.bool)
|
||||
]);
|
||||
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:
|
||||
'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?',
|
||||
message:
|
||||
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
|
||||
items: formInputs,
|
||||
defaultValues: const ['true', 'true'],
|
||||
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 =
|
||||
values.length < 2 || values[0] == 'true';
|
||||
findGeneratedFormValueByKey(
|
||||
formInputs, values, 'updates') ==
|
||||
'true';
|
||||
bool shouldInstallNew =
|
||||
values.length < 2 || values[1] == 'true';
|
||||
settingsProvider
|
||||
.getInstallPermission()
|
||||
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) {
|
||||
@ -324,18 +458,27 @@ class AppsPageState extends State<AppsPage> {
|
||||
toInstall
|
||||
.addAll(newInstallIdsAllOrSelected);
|
||||
}
|
||||
appsProvider.downloadAndInstallLatestApp(
|
||||
toInstall, context);
|
||||
if (shouldMarkTrackOnlies) {
|
||||
toInstall.addAll(
|
||||
trackOnlyUpdateIdsAllOrSelected);
|
||||
}
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApps(toInstall,
|
||||
globalNavigatorKey.currentContext)
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
tooltip:
|
||||
'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps',
|
||||
tooltip: selectedApps.isEmpty
|
||||
? tr('installUpdateApps')
|
||||
: tr('installUpdateSelectedApps'),
|
||||
icon: const Icon(
|
||||
Icons.file_download_outlined,
|
||||
)),
|
||||
selectedIds.isEmpty
|
||||
selectedApps.isEmpty
|
||||
? const SizedBox()
|
||||
: IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
@ -349,7 +492,7 @@ class AppsPageState extends State<AppsPage> {
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed:
|
||||
@ -363,8 +506,22 @@ class AppsPageState extends State<AppsPage> {
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Mark ${selectedIds.length} Selected Apps as Not Installed?'),
|
||||
title: Text(tr(
|
||||
'markXSelectedAppsAsUpdated',
|
||||
args: [
|
||||
selectedApps
|
||||
.length
|
||||
.toString()
|
||||
])),
|
||||
content: Text(
|
||||
tr('onlyWorksWithNonEVDApps'),
|
||||
style: const TextStyle(
|
||||
fontWeight:
|
||||
FontWeight
|
||||
.bold,
|
||||
fontStyle:
|
||||
FontStyle.italic),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed:
|
||||
@ -372,106 +529,123 @@ class AppsPageState extends State<AppsPage> {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'No')),
|
||||
child: Text(
|
||||
tr('no'))),
|
||||
TextButton(
|
||||
onPressed:
|
||||
() {
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
appsProvider
|
||||
.saveApps(selectedIds.map((e) {
|
||||
var a =
|
||||
appsProvider.apps[e]!.app;
|
||||
a.installedVersion =
|
||||
null;
|
||||
.saveApps(selectedApps.map((a) {
|
||||
if (a.installedVersion !=
|
||||
null) {
|
||||
a.installedVersion = a.latestVersion;
|
||||
}
|
||||
return a;
|
||||
}).toList());
|
||||
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Yes'))
|
||||
child: Text(
|
||||
tr('yes')))
|
||||
],
|
||||
);
|
||||
});
|
||||
}).whenComplete(() {
|
||||
Navigator.of(
|
||||
context)
|
||||
.pop();
|
||||
});
|
||||
},
|
||||
tooltip:
|
||||
'Mark Selected Apps as Not Installed',
|
||||
icon: const Icon(
|
||||
Icons.no_cell_outlined)),
|
||||
IconButton(
|
||||
onPressed:
|
||||
appsProvider
|
||||
.areDownloadsRunning()
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Mark ${selectedIds.length} Selected Apps as Installed/Updated?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed:
|
||||
() {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'No')),
|
||||
TextButton(
|
||||
onPressed:
|
||||
() {
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
appsProvider
|
||||
.saveApps(selectedIds.map((e) {
|
||||
var a =
|
||||
appsProvider.apps[e]!.app;
|
||||
a.installedVersion =
|
||||
a.latestVersion;
|
||||
return a;
|
||||
}).toList());
|
||||
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Yes'))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
tooltip:
|
||||
'Mark Selected Apps as Installed/Updated',
|
||||
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 id in selectedIds) {
|
||||
urls +=
|
||||
'${appsProvider.apps[id]!.app.url}\n';
|
||||
for (var a in selectedApps) {
|
||||
urls += '${a.url}\n';
|
||||
}
|
||||
urls = urls.substring(
|
||||
0, urls.length - 1);
|
||||
Share.share(urls,
|
||||
subject:
|
||||
'${selectedIds.length} Selected App URLs from Obtainium');
|
||||
subject: tr(
|
||||
'selectedAppURLsFromObtainium'));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
tooltip: 'Share Selected App URLs',
|
||||
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: 'More',
|
||||
tooltip: tr('more'),
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
),
|
||||
],
|
||||
@ -489,8 +663,8 @@ class AppsPageState extends State<AppsPage> {
|
||||
});
|
||||
},
|
||||
tooltip: currentFilterIsUpdatesOnly
|
||||
? 'Remove Out-of-Date App Filter'
|
||||
: 'Show Out-of-Date Apps Only',
|
||||
? tr('removeOutdatedFilter')
|
||||
: tr('showOutdatedOnly'),
|
||||
icon: Icon(
|
||||
currentFilterIsUpdatesOnly
|
||||
? Icons.update_disabled_rounded
|
||||
@ -502,7 +676,7 @@ class AppsPageState extends State<AppsPage> {
|
||||
? const SizedBox()
|
||||
: TextButton.icon(
|
||||
label: Text(
|
||||
filter == null ? 'Filter' : 'Filter *',
|
||||
filter == null ? tr('filter') : tr('filterActive'),
|
||||
style: TextStyle(
|
||||
fontWeight: filter == null
|
||||
? FontWeight.normal
|
||||
@ -513,22 +687,22 @@ class AppsPageState extends State<AppsPage> {
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: 'Filter Apps',
|
||||
title: tr('filterApps'),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'App Name', required: false),
|
||||
label: tr('appName'), required: false),
|
||||
GeneratedFormItem(
|
||||
label: 'Author', required: false)
|
||||
label: tr('author'), required: false)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Up to Date Apps',
|
||||
label: tr('upToDateApps'),
|
||||
type: FormItemType.bool)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Non-Installed Apps',
|
||||
label: tr('nonInstalledApps'),
|
||||
type: FormItemType.bool)
|
||||
]
|
||||
],
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/pages/add_app.dart';
|
||||
@ -25,12 +26,12 @@ class _HomePageState extends State<HomePage> {
|
||||
List<int> selectedIndexHistory = [];
|
||||
|
||||
List<NavigationPageItem> pages = [
|
||||
NavigationPageItem(tr('appsString'), Icons.apps,
|
||||
AppsPage(key: GlobalKey<AppsPageState>())),
|
||||
NavigationPageItem(tr('addApp'), Icons.add, const AddAppPage()),
|
||||
NavigationPageItem(
|
||||
'Apps', Icons.apps, AppsPage(key: GlobalKey<AppsPageState>())),
|
||||
NavigationPageItem('Add App', Icons.add, const AddAppPage()),
|
||||
NavigationPageItem(
|
||||
'Import/Export', Icons.import_export, const ImportExportPage()),
|
||||
NavigationPageItem('Settings', Icons.settings, const SettingsPage())
|
||||
tr('importExport'), Icons.import_export, const ImportExportPage()),
|
||||
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage())
|
||||
];
|
||||
|
||||
@override
|
||||
@ -92,7 +93,6 @@ class _HomePageState extends State<HomePage> {
|
||||
return !(pages[0].widget.key as GlobalKey<AppsPageState>)
|
||||
.currentState
|
||||
?.clearSelected();
|
||||
// return !appsPageKey.currentState?.clearSelected();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,18 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class ImportExportPage extends StatefulWidget {
|
||||
const ImportExportPage({super.key});
|
||||
@ -25,7 +27,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
var settingsProvider = context.read<SettingsProvider>();
|
||||
var appsProvider = context.read<AppsProvider>();
|
||||
var outlineButtonStyle = ButtonStyle(
|
||||
shape: MaterialStateProperty.all(
|
||||
@ -38,30 +39,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
),
|
||||
);
|
||||
|
||||
Future<List<List<String>>> addApps(List<String> urls) async {
|
||||
await settingsProvider.getInstallPermission();
|
||||
List<dynamic> results = await sourceProvider.getApps(urls,
|
||||
ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList());
|
||||
List<App> apps = results[0];
|
||||
Map<String, dynamic> errorsMap = results[1];
|
||||
for (var app in apps) {
|
||||
if (appsProvider.apps.containsKey(app.id)) {
|
||||
errorsMap.addAll({app.id: 'App already added'});
|
||||
} else {
|
||||
await appsProvider.saveApps([app]);
|
||||
}
|
||||
}
|
||||
List<List<String>> errors =
|
||||
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
|
||||
return errors;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
const CustomAppBar(title: 'Import/Export'),
|
||||
CustomAppBar(title: tr('importExport')),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
@ -81,15 +63,12 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
appsProvider
|
||||
.exportApps()
|
||||
.then((String path) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Exported to $path')),
|
||||
);
|
||||
showError(
|
||||
tr('exportedTo', args: [path]),
|
||||
context);
|
||||
});
|
||||
},
|
||||
child: const Text('Obtainium Export'))),
|
||||
child: Text(tr('obtainiumExport')))),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
@ -113,34 +92,30 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
try {
|
||||
jsonDecode(data);
|
||||
} catch (e) {
|
||||
throw 'Invalid input';
|
||||
throw ObtainiumError(
|
||||
tr('invalidInput'));
|
||||
}
|
||||
appsProvider
|
||||
.importApps(data)
|
||||
.then((value) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'$value App${value == 1 ? '' : 's'} Imported')),
|
||||
);
|
||||
showError(
|
||||
tr('importedX', args: [
|
||||
plural('apps', value)
|
||||
]),
|
||||
context);
|
||||
});
|
||||
} else {
|
||||
// User canceled the picker
|
||||
}
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString())),
|
||||
);
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: const Text('Obtainium Import')))
|
||||
child: Text(tr('obtainiumImport'))))
|
||||
],
|
||||
),
|
||||
if (importInProgress)
|
||||
@ -167,11 +142,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: 'Import from URL List',
|
||||
title: tr('importFromURLList'),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'App URL List',
|
||||
label: tr('appURLList'),
|
||||
max: 7,
|
||||
additionalValidators: [
|
||||
(String? value) {
|
||||
@ -188,7 +163,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
.getSource(
|
||||
lines[i]);
|
||||
} catch (e) {
|
||||
return 'Line ${i + 1}: $e';
|
||||
return '${tr('line')} ${i + 1}: $e';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -206,14 +181,15 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
addApps(urls).then((errors) {
|
||||
appsProvider
|
||||
.addAppsByURL(urls)
|
||||
.then((errors) {
|
||||
if (errors.isEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Imported ${urls.length} Apps')),
|
||||
);
|
||||
showError(
|
||||
tr('importedX', args: [
|
||||
plural('apps', urls.length)
|
||||
]),
|
||||
context);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
@ -224,10 +200,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
});
|
||||
}
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
@ -236,10 +209,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
}
|
||||
});
|
||||
},
|
||||
child: const Text(
|
||||
'Import from URL List',
|
||||
child: Text(
|
||||
tr('importFromURLList'),
|
||||
)),
|
||||
...sourceProvider.massSources
|
||||
...sourceProvider.sources
|
||||
.where((element) => element.canSearch)
|
||||
.map((source) => Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
@ -249,99 +223,210 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title:
|
||||
'Import ${source.name}',
|
||||
items: source
|
||||
.requiredArgs
|
||||
.map((e) => [
|
||||
GeneratedFormItem(
|
||||
label: e)
|
||||
])
|
||||
.toList(),
|
||||
defaultValues: const [],
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
() async {
|
||||
var values = await showDialog<
|
||||
List<String>>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('searchX',
|
||||
args: [
|
||||
source.name
|
||||
]),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr(
|
||||
'searchQuery'))
|
||||
]
|
||||
],
|
||||
defaultValues: const [],
|
||||
);
|
||||
});
|
||||
if (values != null &&
|
||||
values[0].isNotEmpty) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
source
|
||||
.getUrls(values)
|
||||
.then((urls) {
|
||||
showDialog<List<String>?>(
|
||||
var urlsWithDescriptions =
|
||||
await source
|
||||
.search(values[0]);
|
||||
if (urlsWithDescriptions
|
||||
.isNotEmpty) {
|
||||
var selectedUrls =
|
||||
await showDialog<
|
||||
List<
|
||||
String>?>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return UrlSelectionModal(
|
||||
urls: urls);
|
||||
})
|
||||
.then((selectedUrls) {
|
||||
if (selectedUrls !=
|
||||
null) {
|
||||
addApps(selectedUrls)
|
||||
.then((errors) {
|
||||
if (errors
|
||||
.isEmpty) {
|
||||
ScaffoldMessenger
|
||||
.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Imported ${selectedUrls.length} Apps')),
|
||||
);
|
||||
} else {
|
||||
showDialog(
|
||||
context:
|
||||
context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength:
|
||||
selectedUrls
|
||||
.length,
|
||||
errors:
|
||||
errors);
|
||||
});
|
||||
}
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress =
|
||||
false;
|
||||
});
|
||||
});
|
||||
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 {
|
||||
setState(() {
|
||||
importInProgress =
|
||||
false;
|
||||
});
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength:
|
||||
selectedUrls
|
||||
.length,
|
||||
errors:
|
||||
errors);
|
||||
});
|
||||
}
|
||||
});
|
||||
}).catchError((e) {
|
||||
setState(() {
|
||||
importInProgress =
|
||||
false;
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e.toString())),
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw ObtainiumError(
|
||||
tr('noResults'));
|
||||
}
|
||||
}
|
||||
}()
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Text('Import ${source.name}'))
|
||||
child: Text(
|
||||
tr('searchX', args: [source.name])))
|
||||
]))
|
||||
.toList()
|
||||
.toList(),
|
||||
...sourceProvider.massUrlSources
|
||||
.map((source) => Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
() async {
|
||||
var values = await showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('importX',
|
||||
args: [
|
||||
source.name
|
||||
]),
|
||||
items:
|
||||
source
|
||||
.requiredArgs
|
||||
.map(
|
||||
(e) => [
|
||||
GeneratedFormItem(label: e)
|
||||
])
|
||||
.toList(),
|
||||
defaultValues: const [],
|
||||
);
|
||||
});
|
||||
if (values != null) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
var urlsWithDescriptions =
|
||||
await source
|
||||
.getUrlsWithDescriptions(
|
||||
values);
|
||||
var selectedUrls =
|
||||
await showDialog<
|
||||
List<String>?>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return UrlSelectionModal(
|
||||
urlsWithDescriptions:
|
||||
urlsWithDescriptions);
|
||||
});
|
||||
if (selectedUrls != null) {
|
||||
var errors =
|
||||
await appsProvider
|
||||
.addAppsByURL(
|
||||
selectedUrls);
|
||||
if (errors.isEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showError(
|
||||
tr('importedX',
|
||||
args: [
|
||||
plural(
|
||||
'app',
|
||||
selectedUrls
|
||||
.length)
|
||||
]),
|
||||
context);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength:
|
||||
selectedUrls
|
||||
.length,
|
||||
errors:
|
||||
errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
tr('importX', args: [source.name])))
|
||||
]))
|
||||
.toList(),
|
||||
const Spacer(),
|
||||
const Divider(
|
||||
height: 32,
|
||||
),
|
||||
Text(tr('importedAppsIdDisclaimer'),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic, fontSize: 12)),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
)
|
||||
],
|
||||
)))
|
||||
]));
|
||||
@ -364,16 +449,19 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Text('Import Errors'),
|
||||
title: Text(tr('importErrors')),
|
||||
content:
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
Text(
|
||||
'${widget.urlsLength - widget.errors.length} of ${widget.urlsLength} Apps imported.',
|
||||
tr('importedXOfYApps', args: [
|
||||
(widget.urlsLength - widget.errors.length).toString(),
|
||||
widget.urlsLength.toString()
|
||||
]),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'The following URLs had errors:',
|
||||
tr('followingURLsHadErrors'),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
...widget.errors.map((e) {
|
||||
@ -396,7 +484,7 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: const Text('Okay'))
|
||||
child: Text(tr('okay')))
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -404,21 +492,37 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class UrlSelectionModal extends StatefulWidget {
|
||||
UrlSelectionModal({super.key, required this.urls});
|
||||
UrlSelectionModal(
|
||||
{super.key,
|
||||
required this.urlsWithDescriptions,
|
||||
this.selectedByDefault = true,
|
||||
this.onlyOneSelectionAllowed = false});
|
||||
|
||||
List<String> urls;
|
||||
Map<String, String> urlsWithDescriptions;
|
||||
bool selectedByDefault;
|
||||
bool onlyOneSelectionAllowed;
|
||||
|
||||
@override
|
||||
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
|
||||
}
|
||||
|
||||
class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
Map<String, bool> urlSelections = {};
|
||||
Map<MapEntry<String, String>, bool> urlWithDescriptionSelections = {};
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (var url in widget.urls) {
|
||||
urlSelections.putIfAbsent(url, () => true);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -426,23 +530,56 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Text('Select URLs to Import'),
|
||||
title: Text(
|
||||
widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')),
|
||||
content: Column(children: [
|
||||
...urlSelections.keys.map((url) {
|
||||
...urlWithDescriptionSelections.keys.map((urlWithD) {
|
||||
return Row(children: [
|
||||
Checkbox(
|
||||
value: urlSelections[url],
|
||||
value: urlWithDescriptionSelections[urlWithD],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
urlSelections[url] = value ?? false;
|
||||
value ??= false;
|
||||
if (value! && widget.onlyOneSelectionAllowed) {
|
||||
selectOnlyOne(urlWithD.key);
|
||||
} else {
|
||||
urlWithDescriptionSelections[urlWithD] = value!;
|
||||
}
|
||||
});
|
||||
}),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
Uri.parse(url).path.substring(1),
|
||||
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,
|
||||
)
|
||||
],
|
||||
))
|
||||
]);
|
||||
})
|
||||
@ -452,15 +589,27 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel')),
|
||||
child: Text(tr('cancel'))),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(urlSelections.keys
|
||||
.where((url) => urlSelections[url] ?? false)
|
||||
.toList());
|
||||
},
|
||||
child: Text(
|
||||
'Import ${urlSelections.values.where((b) => b).length} URLs'))
|
||||
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,9 +1,13 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/logs_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
@ -21,10 +25,147 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
if (settingsProvider.prefs == null) {
|
||||
settingsProvider.initializeSettings();
|
||||
}
|
||||
|
||||
var themeDropdown = DropdownButtonFormField(
|
||||
decoration: InputDecoration(labelText: tr('theme')),
|
||||
value: settingsProvider.theme,
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.dark,
|
||||
child: Text(tr('dark')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.light,
|
||||
child: Text(tr('light')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.system,
|
||||
child: Text(tr('followSystem')),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.theme = value;
|
||||
}
|
||||
});
|
||||
|
||||
var colourDropdown = DropdownButtonFormField(
|
||||
decoration: InputDecoration(labelText: tr('colour')),
|
||||
value: settingsProvider.colour,
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: ColourSettings.basic,
|
||||
child: Text(tr('obtainium')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ColourSettings.materialYou,
|
||||
child: Text(tr('materialYou')),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.colour = value;
|
||||
}
|
||||
});
|
||||
|
||||
var sortDropdown = DropdownButtonFormField(
|
||||
decoration: InputDecoration(labelText: tr('appSortBy')),
|
||||
value: settingsProvider.sortColumn,
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: SortColumnSettings.authorName,
|
||||
child: Text(tr('authorName')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SortColumnSettings.nameAuthor,
|
||||
child: Text(tr('nameAuthor')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SortColumnSettings.added,
|
||||
child: Text(tr('asAdded')),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.sortColumn = value;
|
||||
}
|
||||
});
|
||||
|
||||
var orderDropdown = DropdownButtonFormField(
|
||||
decoration: InputDecoration(labelText: tr('appSortOrder')),
|
||||
value: settingsProvider.sortOrder,
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: SortOrderSettings.ascending,
|
||||
child: Text(tr('ascending')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SortOrderSettings.descending,
|
||||
child: Text(tr('descending')),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.sortOrder = value;
|
||||
}
|
||||
});
|
||||
|
||||
var intervalDropdown = DropdownButtonFormField(
|
||||
decoration: InputDecoration(labelText: tr('bgUpdateCheckInterval')),
|
||||
value: settingsProvider.updateInterval,
|
||||
items: updateIntervals.map((e) {
|
||||
int displayNum = (e < 60
|
||||
? e
|
||||
: e < 1440
|
||||
? e / 60
|
||||
: e / 1440)
|
||||
.round();
|
||||
String display = e == 0
|
||||
? tr('neverManualOnly')
|
||||
: (e < 60
|
||||
? plural('minute', displayNum)
|
||||
: e < 1440
|
||||
? plural('hour', displayNum)
|
||||
: plural('day', displayNum));
|
||||
return DropdownMenuItem(value: e, child: Text(display));
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.updateInterval = value;
|
||||
}
|
||||
});
|
||||
|
||||
var sourceSpecificFields = sourceProvider.sources.map((e) {
|
||||
if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
|
||||
return GeneratedForm(
|
||||
items: e.additionalSourceSpecificSettingFormItems
|
||||
.map((e) => [e])
|
||||
.toList(),
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (valid) {
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
settingsProvider.setSettingString(
|
||||
e.additionalSourceSpecificSettingFormItems[i].id,
|
||||
values[i]);
|
||||
}
|
||||
}
|
||||
},
|
||||
defaultValues: e.additionalSourceSpecificSettingFormItems.map((e) {
|
||||
return settingsProvider.getSettingString(e.id) ?? '';
|
||||
}).toList());
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
});
|
||||
|
||||
const height16 = SizedBox(
|
||||
height: 16,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
const CustomAppBar(title: 'Settings'),
|
||||
CustomAppBar(title: tr('settings')),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@ -34,120 +175,30 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Appearance',
|
||||
tr('appearance'),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
DropdownButtonFormField(
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Theme'),
|
||||
value: settingsProvider.theme,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.dark,
|
||||
child: Text('Dark'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.light,
|
||||
child: Text('Light'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.system,
|
||||
child: Text('Follow System'),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.theme = value;
|
||||
}
|
||||
}),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
DropdownButtonFormField(
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Colour'),
|
||||
value: settingsProvider.colour,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: ColourSettings.basic,
|
||||
child: Text('Obtainium'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ColourSettings.materialYou,
|
||||
child: Text('Material You'),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.colour = value;
|
||||
}
|
||||
}),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
themeDropdown,
|
||||
height16,
|
||||
colourDropdown,
|
||||
height16,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'App Sort By'),
|
||||
value: settingsProvider.sortColumn,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value:
|
||||
SortColumnSettings.authorName,
|
||||
child: Text('Author/Name'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value:
|
||||
SortColumnSettings.nameAuthor,
|
||||
child: Text('Name/Author'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SortColumnSettings.added,
|
||||
child: Text('As Added'),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.sortColumn = value;
|
||||
}
|
||||
})),
|
||||
Expanded(child: sortDropdown),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'App Sort Order'),
|
||||
value: settingsProvider.sortOrder,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: SortOrderSettings.ascending,
|
||||
child: Text('Ascending'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SortOrderSettings.descending,
|
||||
child: Text('Descending'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.sortOrder = value;
|
||||
}
|
||||
})),
|
||||
Expanded(child: orderDropdown),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
height16,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Show Source Webpage in App View'),
|
||||
Text(tr('showWebInAppView')),
|
||||
Switch(
|
||||
value: settingsProvider.showAppWebpage,
|
||||
onChanged: (value) {
|
||||
@ -155,124 +206,148 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
})
|
||||
],
|
||||
),
|
||||
height16,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(tr('pinUpdates')),
|
||||
Switch(
|
||||
value: settingsProvider.pinUpdates,
|
||||
onChanged: (value) {
|
||||
settingsProvider.pinUpdates = value;
|
||||
})
|
||||
],
|
||||
),
|
||||
const Divider(
|
||||
height: 16,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
height16,
|
||||
Text(
|
||||
'Updates',
|
||||
tr('updates'),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
DropdownButtonFormField(
|
||||
decoration: const InputDecoration(
|
||||
labelText:
|
||||
'Background Update Checking Interval'),
|
||||
value: settingsProvider.updateInterval,
|
||||
items: updateIntervals.map((e) {
|
||||
int displayNum = (e < 60
|
||||
? e
|
||||
: e < 1440
|
||||
? e / 60
|
||||
: e / 1440)
|
||||
.round();
|
||||
var displayUnit = (e < 60
|
||||
? 'Minute'
|
||||
: e < 1440
|
||||
? 'Hour'
|
||||
: 'Day');
|
||||
|
||||
String display = e == 0
|
||||
? 'Never - Manual Only'
|
||||
: '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
|
||||
return DropdownMenuItem(
|
||||
value: e, child: Text(display));
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.updateInterval = value;
|
||||
}
|
||||
}),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
Text(
|
||||
'Longer intervals recommended for large App collections',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium!
|
||||
.merge(const TextStyle(
|
||||
fontStyle: FontStyle.italic)),
|
||||
),
|
||||
intervalDropdown,
|
||||
const Divider(
|
||||
height: 48,
|
||||
),
|
||||
Text(
|
||||
'Source-Specific',
|
||||
tr('sourceSpecific'),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
...sourceProvider.sources.map((e) {
|
||||
if (e.moreSourceSettingsFormItems.isNotEmpty) {
|
||||
return GeneratedForm(
|
||||
items: e.moreSourceSettingsFormItems
|
||||
.map((e) => [e])
|
||||
.toList(),
|
||||
onValueChanges: (values, valid) {
|
||||
if (valid) {
|
||||
for (var i = 0;
|
||||
i < values.length;
|
||||
i++) {
|
||||
settingsProvider.setSettingString(
|
||||
e.moreSourceSettingsFormItems[i]
|
||||
.id,
|
||||
values[i]);
|
||||
}
|
||||
}
|
||||
},
|
||||
defaultValues:
|
||||
e.moreSourceSettingsFormItems.map((e) {
|
||||
return settingsProvider
|
||||
.getSettingString(e.id) ??
|
||||
'';
|
||||
}).toList());
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
}),
|
||||
...sourceSpecificFields,
|
||||
],
|
||||
))),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
const Divider(
|
||||
height: 32,
|
||||
),
|
||||
TextButton.icon(
|
||||
style: ButtonStyle(
|
||||
foregroundColor: MaterialStateProperty.resolveWith<Color>(
|
||||
(Set<MaterialState> states) {
|
||||
return Colors.grey;
|
||||
}),
|
||||
),
|
||||
onPressed: () {
|
||||
launchUrlString(settingsProvider.sourceUrl,
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
icon: const Icon(Icons.code),
|
||||
label: Text(
|
||||
'Source',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
launchUrlString(settingsProvider.sourceUrl,
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
icon: const Icon(Icons.code),
|
||||
label: Text(
|
||||
tr('appSource'),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
context.read<LogsProvider>().get().then((logs) {
|
||||
if (logs.isEmpty) {
|
||||
showError(ObtainiumError(tr('noLogs')), context);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return const LogsDialog();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.bug_report_outlined),
|
||||
label: Text(tr('appLogs'))),
|
||||
],
|
||||
),
|
||||
height16,
|
||||
],
|
||||
),
|
||||
)
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
class LogsDialog extends StatefulWidget {
|
||||
const LogsDialog({super.key});
|
||||
|
||||
@override
|
||||
State<LogsDialog> createState() => _LogsDialogState();
|
||||
}
|
||||
|
||||
class _LogsDialogState extends State<LogsDialog> {
|
||||
String? logString;
|
||||
List<int> days = [7, 5, 4, 3, 2, 1];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var logsProvider = context.read<LogsProvider>();
|
||||
void filterLogs(int days) {
|
||||
logsProvider
|
||||
.get(after: DateTime.now().subtract(Duration(days: days)))
|
||||
.then((value) {
|
||||
setState(() {
|
||||
String l = value.map((e) => e.toString()).join('\n\n');
|
||||
logString = l.isNotEmpty ? l : tr('noLogs');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (logString == null) {
|
||||
filterLogs(days.first);
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(tr('appLogs')),
|
||||
content: Column(
|
||||
children: [
|
||||
DropdownButtonFormField(
|
||||
value: days.first,
|
||||
items: days
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(plural('day', e)),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (d) {
|
||||
filterLogs(d ?? 7);
|
||||
}),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
Text(logString ?? '')
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(tr('close'))),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Share.share(logString ?? '', subject: tr('appLogs'));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(tr('share')))
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -6,11 +6,17 @@ 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:obtainium/app_sources/github.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';
|
||||
@ -20,81 +26,170 @@ import 'package:http/http.dart';
|
||||
class AppInMemory {
|
||||
late App app;
|
||||
double? downloadProgress;
|
||||
AppInfo? installedInfo;
|
||||
|
||||
AppInMemory(this.app, this.downloadProgress);
|
||||
AppInMemory(this.app, this.downloadProgress, this.installedInfo);
|
||||
}
|
||||
|
||||
class ApkFile {
|
||||
class DownloadedApk {
|
||||
String appId;
|
||||
File file;
|
||||
ApkFile(this.appId, this.file);
|
||||
DownloadedApk(this.appId, this.file);
|
||||
}
|
||||
|
||||
List<String> generateStandardVersionRegExStrings() {
|
||||
// TODO: Look into RegEx for non-Latin characters / non-Arabic numerals
|
||||
var basics = [
|
||||
'[0-9]+',
|
||||
'[0-9]+\\.[0-9]+',
|
||||
'[0-9]+\\.[0-9]+\\.[0-9]+',
|
||||
'[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+'
|
||||
];
|
||||
var preSuffixes = ['-', '\\+'];
|
||||
var suffixes = ['alpha', 'beta', 'ose'];
|
||||
var finals = ['\\+[0-9]+', '[0-9]+'];
|
||||
List<String> results = [];
|
||||
for (var b in basics) {
|
||||
results.add(b);
|
||||
for (var p in preSuffixes) {
|
||||
for (var s in suffixes) {
|
||||
results.add('$b$s');
|
||||
results.add('$b$p$s');
|
||||
for (var f in finals) {
|
||||
results.add('$b$s$f');
|
||||
results.add('$b$p$s$f');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
List<String> standardVersionRegExStrings =
|
||||
generateStandardVersionRegExStrings();
|
||||
|
||||
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;
|
||||
LogsProvider logs = LogsProvider();
|
||||
|
||||
// Variables to keep track of the app foreground status (installs can't run in the background)
|
||||
bool isForeground = true;
|
||||
late Stream<FGBGType> foregroundStream;
|
||||
late StreamSubscription<FGBGType> foregroundSubscription;
|
||||
late Stream<FGBGType>? foregroundStream;
|
||||
late StreamSubscription<FGBGType>? foregroundSubscription;
|
||||
|
||||
AppsProvider(
|
||||
{bool shouldLoadApps = false,
|
||||
bool shouldCheckUpdatesAfterLoad = false,
|
||||
bool shouldDeleteAPKs = false}) {
|
||||
AppsProvider() {
|
||||
// Subscribe to changes in the app foreground status
|
||||
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||
foregroundSubscription = foregroundStream.listen((event) async {
|
||||
foregroundSubscription = foregroundStream?.listen((event) async {
|
||||
isForeground = event == FGBGType.foreground;
|
||||
if (isForeground) await loadApps();
|
||||
});
|
||||
if (shouldDeleteAPKs) {
|
||||
deleteSavedAPKs();
|
||||
}
|
||||
if (shouldLoadApps) {
|
||||
loadApps().then((_) {
|
||||
if (shouldCheckUpdatesAfterLoad) {
|
||||
checkUpdates();
|
||||
}
|
||||
() async {
|
||||
// Load Apps into memory (in background, this is done later instead of in the constructor)
|
||||
await loadApps();
|
||||
// Delete existing APKs
|
||||
(await getExternalStorageDirectory())
|
||||
?.listSync()
|
||||
.where((element) =>
|
||||
element.path.endsWith('.apk') ||
|
||||
element.path.endsWith('.apk.part'))
|
||||
.forEach((apk) {
|
||||
apk.delete();
|
||||
});
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
Future<ApkFile> downloadApp(String apkUrl, String appId) async {
|
||||
apkUrl = await SourceProvider()
|
||||
.getSource(apps[appId]!.app.url)
|
||||
.apkUrlPrefetchModifier(apkUrl);
|
||||
downloadFile(String url, String fileName, Function? onProgress,
|
||||
{bool useExisting = true}) async {
|
||||
var destDir = (await getExternalStorageDirectory())!.path;
|
||||
StreamedResponse response =
|
||||
await Client().send(Request('GET', Uri.parse(apkUrl)));
|
||||
File downloadFile =
|
||||
File('${(await getExternalStorageDirectory())!.path}/$appId.apk');
|
||||
if (downloadFile.existsSync()) {
|
||||
downloadFile.deleteSync();
|
||||
await Client().send(Request('GET', Uri.parse(url)));
|
||||
File downloadedFile = File('$destDir/$fileName');
|
||||
if (!(downloadedFile.existsSync() && useExisting)) {
|
||||
File tempDownloadedFile = File('${downloadedFile.path}.part');
|
||||
if (tempDownloadedFile.existsSync()) {
|
||||
tempDownloadedFile.deleteSync();
|
||||
}
|
||||
var length = response.contentLength;
|
||||
var received = 0;
|
||||
double? progress;
|
||||
var sink = tempDownloadedFile.openWrite();
|
||||
await response.stream.map((s) {
|
||||
received += s.length;
|
||||
progress = (length != null ? received / length * 100 : 30);
|
||||
if (onProgress != null) {
|
||||
onProgress(progress);
|
||||
}
|
||||
return s;
|
||||
}).pipe(sink);
|
||||
await sink.close();
|
||||
progress = null;
|
||||
if (onProgress != null) {
|
||||
onProgress(progress);
|
||||
}
|
||||
if (response.statusCode != 200) {
|
||||
tempDownloadedFile.deleteSync();
|
||||
throw response.reasonPhrase ?? tr('unexpectedError');
|
||||
}
|
||||
tempDownloadedFile.renameSync(downloadedFile.path);
|
||||
}
|
||||
var length = response.contentLength;
|
||||
var received = 0;
|
||||
var sink = downloadFile.openWrite();
|
||||
return downloadedFile;
|
||||
}
|
||||
|
||||
await response.stream.map((s) {
|
||||
received += s.length;
|
||||
apps[appId]!.downloadProgress =
|
||||
(length != null ? received / length * 100 : 30);
|
||||
notifyListeners();
|
||||
return s;
|
||||
}).pipe(sink);
|
||||
|
||||
await sink.close();
|
||||
apps[appId]!.downloadProgress = null;
|
||||
notifyListeners();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
downloadFile.deleteSync();
|
||||
throw response.reasonPhrase ?? 'Unknown Error';
|
||||
Future<DownloadedApk> downloadApp(App app, BuildContext? context) async {
|
||||
var fileName =
|
||||
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
|
||||
String downloadUrl = await SourceProvider()
|
||||
.getSource(app.url)
|
||||
.apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
|
||||
NotificationsProvider? notificationsProvider =
|
||||
context?.read<NotificationsProvider>();
|
||||
var notif = DownloadNotification(app.name, 100);
|
||||
notificationsProvider?.cancel(notif.id);
|
||||
int? prevProg;
|
||||
File downloadedFile =
|
||||
await downloadFile(downloadUrl, fileName, (double? progress) {
|
||||
int? prog = progress?.ceil();
|
||||
if (apps[app.id] != null) {
|
||||
apps[app.id]!.downloadProgress = progress;
|
||||
notifyListeners();
|
||||
}
|
||||
notif = DownloadNotification(app.name, prog ?? 100);
|
||||
if (prog != null && prevProg != prog) {
|
||||
notificationsProvider?.notify(notif);
|
||||
}
|
||||
prevProg = prog;
|
||||
});
|
||||
notificationsProvider?.cancel(notif.id);
|
||||
// Delete older versions of the APK if any
|
||||
for (var file in downloadedFile.parent.listSync()) {
|
||||
var fn = file.path.split('/').last;
|
||||
if (fn.startsWith('${app.id}-') &&
|
||||
fn.endsWith('.apk') &&
|
||||
fn != fileName) {
|
||||
file.delete();
|
||||
}
|
||||
}
|
||||
return ApkFile(appId, downloadFile);
|
||||
// If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
|
||||
// The former case should be handled (give the App its real ID), the latter is a security issue
|
||||
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
|
||||
if (app.id != newInfo.packageName) {
|
||||
if (apps[app.id] != null && !SourceProvider().isTempId(app.id)) {
|
||||
throw IDChangedError();
|
||||
}
|
||||
var originalAppId = app.id;
|
||||
app.id = newInfo.packageName;
|
||||
downloadedFile = downloadedFile.renameSync(
|
||||
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
|
||||
if (apps[originalAppId] != null) {
|
||||
await removeApps([originalAppId]);
|
||||
await saveApps([app]);
|
||||
}
|
||||
}
|
||||
return DownloadedApk(app.id, downloadedFile);
|
||||
}
|
||||
|
||||
bool areDownloadsRunning() => apps.values
|
||||
@ -102,24 +197,35 @@ class AppsProvider with ChangeNotifier {
|
||||
.isNotEmpty;
|
||||
|
||||
Future<bool> canInstallSilently(App app) async {
|
||||
// TODO: This is unreliable - try to get from OS in the future
|
||||
var osInfo = await DeviceInfoPlugin().androidInfo;
|
||||
return app.installedVersion != null &&
|
||||
osInfo.version.sdkInt! >= 30 &&
|
||||
osInfo.version.release!.compareTo('12') >= 0;
|
||||
return false;
|
||||
// TODO: Uncomment the below if silent updates are ever figured out
|
||||
// // NOTE: 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> askUserToReturnToForeground(BuildContext context,
|
||||
{bool waitForFG = false}) async {
|
||||
Future<void> waitForUserToReturnToForeground(BuildContext context) async {
|
||||
NotificationsProvider notificationsProvider =
|
||||
context.read<NotificationsProvider>();
|
||||
if (!isForeground) {
|
||||
await notificationsProvider.notify(completeInstallationNotification,
|
||||
cancelExisting: true);
|
||||
if (waitForFG) {
|
||||
await FGBGEvents.stream.first == FGBGType.foreground;
|
||||
await notificationsProvider.cancel(completeInstallationNotification.id);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,11 +233,67 @@ class AppsProvider with ChangeNotifier {
|
||||
// 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(ApkFile file) async {
|
||||
await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium');
|
||||
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;
|
||||
await saveApps([apps[file.appId]!.app]);
|
||||
// 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
getHost(String url) {
|
||||
var temp = Uri.parse(url).host.split('.');
|
||||
return temp.sublist(temp.length - 2).join('.');
|
||||
}
|
||||
|
||||
// If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
|
||||
if (apkUrl != null &&
|
||||
getHost(apkUrl) != getHost(app.url) &&
|
||||
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
|
||||
@ -139,36 +301,20 @@ class AppsProvider with ChangeNotifier {
|
||||
// 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>> downloadAndInstallLatestApp(
|
||||
Future<List<String>> downloadAndInstallLatestApps(
|
||||
List<String> appIds, BuildContext? context) async {
|
||||
Map<String, String> appsToInstall = {};
|
||||
List<String> appsToInstall = [];
|
||||
List<String> trackOnlyAppsToUpdate = [];
|
||||
// For all specified Apps, filter out those for which:
|
||||
// 1. A URL cannot be picked
|
||||
// 2. That cannot be installed silently (IF no buildContext was given for interactive install)
|
||||
for (var id in appIds) {
|
||||
if (apps[id] == null) {
|
||||
throw 'App not found';
|
||||
throw ObtainiumError(tr('appNotFound'));
|
||||
}
|
||||
|
||||
// If the App has more than one APK, the user should pick one (if context provided)
|
||||
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex];
|
||||
if (apps[id]!.app.apkUrls.length > 1 && context != null) {
|
||||
apkUrl = await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
|
||||
});
|
||||
}
|
||||
// If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
|
||||
if (apkUrl != null &&
|
||||
Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin &&
|
||||
context != null) {
|
||||
if (await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return APKOriginWarningDialog(
|
||||
sourceUrl: apps[id]!.app.url, apkUrl: apkUrl!);
|
||||
}) !=
|
||||
true) {
|
||||
apkUrl = null;
|
||||
}
|
||||
String? apkUrl;
|
||||
if (!apps[id]!.app.trackOnly) {
|
||||
apkUrl = await confirmApkUrl(apps[id]!.app, context);
|
||||
}
|
||||
if (apkUrl != null) {
|
||||
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
||||
@ -176,21 +322,38 @@ class AppsProvider with ChangeNotifier {
|
||||
apps[id]!.app.preferredApkIndex = urlInd;
|
||||
await saveApps([apps[id]!.app]);
|
||||
}
|
||||
if (context != null ||
|
||||
(await canInstallSilently(apps[id]!.app) &&
|
||||
apps[id]!.app.apkUrls.length == 1)) {
|
||||
appsToInstall.putIfAbsent(id, () => apkUrl!);
|
||||
if (context != null || await canInstallSilently(apps[id]!.app)) {
|
||||
appsToInstall.add(id);
|
||||
}
|
||||
}
|
||||
if (apps[id]!.app.trackOnly) {
|
||||
trackOnlyAppsToUpdate.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries
|
||||
.map((entry) => downloadApp(entry.value, entry.key)));
|
||||
|
||||
List<ApkFile> silentUpdates = [];
|
||||
List<ApkFile> regularInstalls = [];
|
||||
// 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);
|
||||
bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app);
|
||||
if (willBeSilent) {
|
||||
silentUpdates.add(f);
|
||||
} else {
|
||||
@ -198,45 +361,55 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Move everything to the regular install list (since silent updates don't currently work)
|
||||
// TODO: Remove this when silent updates work
|
||||
regularInstalls.addAll(silentUpdates);
|
||||
|
||||
// If Obtainium is being installed, it should be the last one
|
||||
List<ApkFile> moveObtainiumToEnd(List<ApkFile> items) {
|
||||
String obtainiumId = 'imranr98_obtainium_${GitHub().host}';
|
||||
ApkFile? temp;
|
||||
List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) {
|
||||
DownloadedApk? temp;
|
||||
items.removeWhere((element) {
|
||||
bool res = element.appId == obtainiumId;
|
||||
bool res =
|
||||
element.appId == obtainiumId || element.appId == obtainiumTempId;
|
||||
if (res) {
|
||||
temp = element;
|
||||
}
|
||||
return res;
|
||||
});
|
||||
if (temp != null) {
|
||||
items.add(temp!);
|
||||
items = [temp!, ...items];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// TODO: Remove below line if silentupdates are ever figured out
|
||||
regularInstalls.addAll(silentUpdates);
|
||||
silentUpdates = moveObtainiumToStart(silentUpdates);
|
||||
regularInstalls = moveObtainiumToStart(regularInstalls);
|
||||
|
||||
silentUpdates = moveObtainiumToEnd(silentUpdates);
|
||||
regularInstalls = moveObtainiumToEnd(regularInstalls);
|
||||
|
||||
// TODO: Uncomment below if silentupdates are ever figured out
|
||||
// // Install silent updates (uncomment when it works - TODO)
|
||||
// for (var u in silentUpdates) {
|
||||
// await installApk(u, silent: true); // Would need to add silent option
|
||||
// }
|
||||
|
||||
if (context != null) {
|
||||
if (regularInstalls.isNotEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
await askUserToReturnToForeground(context, waitForFG: true);
|
||||
}
|
||||
// Do regular installs
|
||||
if (regularInstalls.isNotEmpty && context != null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
await waitForUserToReturnToForeground(context);
|
||||
for (var i in regularInstalls) {
|
||||
await installApk(i);
|
||||
try {
|
||||
await installApk(i);
|
||||
} catch (e) {
|
||||
errors.add(i.appId, e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedFiles.map((e) => e.appId).toList();
|
||||
if (errors.content.isNotEmpty) {
|
||||
throw errors;
|
||||
}
|
||||
|
||||
NotificationsProvider().cancel(UpdateNotification([]).id);
|
||||
|
||||
return downloadedFiles.map((e) => e!.appId).toList();
|
||||
}
|
||||
|
||||
Future<Directory> getAppsDir() async {
|
||||
@ -248,39 +421,183 @@ class AppsProvider with ChangeNotifier {
|
||||
return appsDir;
|
||||
}
|
||||
|
||||
Future<void> deleteSavedAPKs() async {
|
||||
(await getExternalStorageDirectory())
|
||||
?.listSync()
|
||||
.where((element) => element.path.endsWith('.apk'))
|
||||
.forEach((element) {
|
||||
element.deleteSync();
|
||||
});
|
||||
Future<AppInfo?> getInstalledInfo(String? packageName) async {
|
||||
if (packageName != null) {
|
||||
try {
|
||||
return await InstalledApps.getAppInfo(packageName);
|
||||
} catch (e) {
|
||||
// OK
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> doesInstalledAppsPluginWork() async {
|
||||
bool res = false;
|
||||
try {
|
||||
res = (await InstalledApps.getAppInfo(obtainiumId)).versionName != null;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
if (!res) {
|
||||
logs.add(tr('versionCorrectionDisabled'));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// If the App says it is installed but installedInfo is null, set it to not installed
|
||||
// If there is any other mismatch between installedInfo and installedVersion, try reconciling them intelligently
|
||||
// If that fails, just set it to the actual version string (all we can do at that point)
|
||||
// Don't save changes, just return the object if changes were made (else null)
|
||||
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
|
||||
var modded = false;
|
||||
if (installedInfo == null &&
|
||||
app.installedVersion != null &&
|
||||
!app.trackOnly) {
|
||||
app.installedVersion = null;
|
||||
modded = true;
|
||||
} else if (installedInfo?.versionName != null &&
|
||||
app.installedVersion == null) {
|
||||
app.installedVersion = installedInfo!.versionName;
|
||||
modded = true;
|
||||
} else if (installedInfo?.versionName != null &&
|
||||
installedInfo!.versionName != app.installedVersion) {
|
||||
String? correctedInstalledVersion = reconcileRealAndInternalVersions(
|
||||
installedInfo.versionName!, app.installedVersion!);
|
||||
if (correctedInstalledVersion != null) {
|
||||
app.installedVersion = correctedInstalledVersion;
|
||||
modded = true;
|
||||
}
|
||||
}
|
||||
if (app.installedVersion != null &&
|
||||
app.installedVersion != app.latestVersion) {
|
||||
app.installedVersion = reconcileRealAndInternalVersions(
|
||||
app.installedVersion!, app.latestVersion,
|
||||
matchMode: true) ??
|
||||
app.installedVersion;
|
||||
modded = true;
|
||||
}
|
||||
return modded ? app : null;
|
||||
}
|
||||
|
||||
String? reconcileRealAndInternalVersions(
|
||||
String realVersion, String internalVersion,
|
||||
{bool matchMode = false}) {
|
||||
// 1. If one or both of these can't be converted to a "standard" format, return null (leave as is)
|
||||
// 2. If both have a "standard" format under which they are equal, return null (leave as is)
|
||||
// 3. If both have a "standard" format in common but are unequal, return realVersion (this means it was changed externally)
|
||||
// If in matchMode, the outcomes of rules 2 and 3 are reversed, and the "real" version is not matched strictly
|
||||
// Matchmode to be used when comparing internal install version and internal latest version
|
||||
|
||||
bool doStringsMatchUnderRegEx(
|
||||
String pattern, String value1, String value2) {
|
||||
var r = RegExp(pattern);
|
||||
var m1 = r.firstMatch(value1);
|
||||
var m2 = r.firstMatch(value2);
|
||||
return m1 != null && m2 != null
|
||||
? value1.substring(m1.start, m1.end) ==
|
||||
value2.substring(m2.start, m2.end)
|
||||
: false;
|
||||
}
|
||||
|
||||
Set<String> findStandardFormatsForVersion(String version, bool strict) {
|
||||
Set<String> results = {};
|
||||
for (var pattern in standardVersionRegExStrings) {
|
||||
if (RegExp('${strict ? '^' : ''}$pattern${strict ? '\$' : ''}')
|
||||
.hasMatch(version)) {
|
||||
results.add(pattern);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
var realStandardVersionFormats =
|
||||
findStandardFormatsForVersion(realVersion, true);
|
||||
var internalStandardVersionFormats =
|
||||
findStandardFormatsForVersion(internalVersion, false);
|
||||
var commonStandardFormats =
|
||||
realStandardVersionFormats.intersection(internalStandardVersionFormats);
|
||||
if (commonStandardFormats.isEmpty) {
|
||||
return null; // Incompatible; no "enhanced detection"
|
||||
}
|
||||
for (String pattern in commonStandardFormats) {
|
||||
if (doStringsMatchUnderRegEx(pattern, internalVersion, realVersion)) {
|
||||
return matchMode
|
||||
? internalVersion
|
||||
: null; // Enhanced detection says no change
|
||||
}
|
||||
}
|
||||
return matchMode
|
||||
? null
|
||||
: realVersion; // Enhanced detection says something changed
|
||||
}
|
||||
|
||||
Future<void> loadApps() async {
|
||||
while (loadingApps) {
|
||||
await Future.delayed(const Duration(microseconds: 1));
|
||||
}
|
||||
loadingApps = true;
|
||||
notifyListeners();
|
||||
List<FileSystemEntity> appFiles = (await getAppsDir())
|
||||
List<App> newApps = (await getAppsDir())
|
||||
.listSync()
|
||||
.where((item) => item.path.toLowerCase().endsWith('.json'))
|
||||
.map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync())))
|
||||
.toList();
|
||||
apps.clear();
|
||||
for (int i = 0; i < appFiles.length; i++) {
|
||||
App app =
|
||||
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync()));
|
||||
apps.putIfAbsent(app.id, () => AppInMemory(app, null));
|
||||
var idsToDelete = apps.values
|
||||
.map((e) => e.app.id)
|
||||
.toSet()
|
||||
.difference(newApps.map((e) => e.id).toSet());
|
||||
for (var id in idsToDelete) {
|
||||
apps.remove(id);
|
||||
}
|
||||
var sp = SourceProvider();
|
||||
List<List<String>> errors = [];
|
||||
for (int i = 0; i < newApps.length; i++) {
|
||||
var info = await getInstalledInfo(newApps[i].id);
|
||||
try {
|
||||
sp.getSource(newApps[i].url);
|
||||
apps[newApps[i].id] = AppInMemory(newApps[i], null, info);
|
||||
} catch (e) {
|
||||
errors.add([newApps[i].id, newApps[i].name, e.toString()]);
|
||||
}
|
||||
}
|
||||
if (errors.isNotEmpty) {
|
||||
removeApps(errors.map((e) => e[0]).toList());
|
||||
NotificationsProvider().notify(
|
||||
AppsRemovedNotification(errors.map((e) => [e[1], e[2]]).toList()));
|
||||
}
|
||||
loadingApps = false;
|
||||
notifyListeners();
|
||||
if (await doesInstalledAppsPluginWork()) {
|
||||
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, attemptToCorrectInstallStatus: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveApps(List<App> apps) async {
|
||||
Future<void> saveApps(List<App> apps,
|
||||
{bool attemptToCorrectInstallStatus = true}) async {
|
||||
attemptToCorrectInstallStatus =
|
||||
attemptToCorrectInstallStatus && (await doesInstalledAppsPluginWork());
|
||||
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),
|
||||
ifAbsent: () => AppInMemory(app, null));
|
||||
app.id, (value) => AppInMemory(app, value.downloadProgress, info),
|
||||
ifAbsent: () => AppInMemory(app, null, info));
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
@ -300,22 +617,18 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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,
|
||||
customName: currentApp.name);
|
||||
newApp.installedVersion = currentApp.installedVersion;
|
||||
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;
|
||||
}
|
||||
@ -323,35 +636,51 @@ class AppsProvider with ChangeNotifier {
|
||||
return newApp.latestVersion != currentApp.latestVersion ? newApp : null;
|
||||
}
|
||||
|
||||
Future<List<App>> checkUpdates({DateTime? ignoreAfter}) async {
|
||||
Future<List<App>> checkUpdates(
|
||||
{DateTime? ignoreAppsCheckedAfter,
|
||||
bool throwErrorsForRetry = false}) async {
|
||||
List<App> updates = [];
|
||||
MultiAppMultiError errors = MultiAppMultiError();
|
||||
if (!gettingUpdates) {
|
||||
gettingUpdates = true;
|
||||
|
||||
List<String> appIds = apps.keys.toList();
|
||||
if (ignoreAfter != null) {
|
||||
appIds = appIds
|
||||
.where((id) =>
|
||||
apps[id]!.app.lastUpdateCheck == null ||
|
||||
apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter))
|
||||
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 = await getUpdate(appIds[i]);
|
||||
if (newApp != null) {
|
||||
updates.add(newApp);
|
||||
appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ??
|
||||
DateTime.fromMicrosecondsSinceEpoch(0))
|
||||
.compareTo(apps[b]!.app.lastUpdateCheck ??
|
||||
DateTime.fromMicrosecondsSinceEpoch(0)));
|
||||
for (int i = 0; i < appIds.length; i++) {
|
||||
App? newApp;
|
||||
try {
|
||||
newApp = await checkUpdate(appIds[i]);
|
||||
} catch (e) {
|
||||
if ((e is RateLimitError || e is SocketException) &&
|
||||
throwErrorsForRetry) {
|
||||
rethrow;
|
||||
}
|
||||
errors.add(appIds[i], e.toString());
|
||||
}
|
||||
if (newApp != null) {
|
||||
updates.add(newApp);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
gettingUpdates = false;
|
||||
}
|
||||
gettingUpdates = false;
|
||||
}
|
||||
if (errors.content.isNotEmpty) {
|
||||
throw errors;
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
List<String> getExistingUpdates(
|
||||
List<String> findExistingUpdates(
|
||||
{bool installedOnly = false, bool nonInstalledOnly = false}) {
|
||||
List<String> updateAppIds = [];
|
||||
List<String> appIds = apps.keys.toList();
|
||||
@ -372,44 +701,65 @@ class AppsProvider with ChangeNotifier {
|
||||
|
||||
Future<String> exportApps() async {
|
||||
Directory? exportDir = Directory('/storage/emulated/0/Download');
|
||||
String path = 'Downloads';
|
||||
String path = 'Downloads'; // TODO: See if hardcoding this can be avoided
|
||||
if (!exportDir.existsSync()) {
|
||||
exportDir = await getExternalStorageDirectory();
|
||||
path = exportDir!.path;
|
||||
}
|
||||
File export = File(
|
||||
'${exportDir.path}/obtainium-export-${DateTime.now().millisecondsSinceEpoch}.json');
|
||||
'${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json');
|
||||
export.writeAsStringSync(
|
||||
jsonEncode(apps.values.map((e) => e.app.toJson()).toList()));
|
||||
return path;
|
||||
}
|
||||
|
||||
Future<int> importApps(String appsJSON) async {
|
||||
// File picker does not work in android 13, so the user must paste the JSON directly into Obtainium to import Apps
|
||||
List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
|
||||
.map((e) => App.fromJson(e))
|
||||
.toList();
|
||||
for (App a in importedApps) {
|
||||
a.installedVersion =
|
||||
apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null;
|
||||
await saveApps([a]);
|
||||
while (loadingApps) {
|
||||
await Future.delayed(const Duration(microseconds: 1));
|
||||
}
|
||||
for (App a in importedApps) {
|
||||
if (apps[a.id]?.app.installedVersion != null) {
|
||||
a.installedVersion = apps[a.id]?.app.installedVersion;
|
||||
}
|
||||
}
|
||||
await saveApps(importedApps);
|
||||
notifyListeners();
|
||||
return importedApps.length;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
foregroundSubscription.cancel();
|
||||
foregroundSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<List<List<String>>> addAppsByURL(List<String> urls) async {
|
||||
List<dynamic> results = await SourceProvider().getAppsByURLNaive(urls,
|
||||
ignoreUrls: apps.values.map((e) => e.app.url).toList());
|
||||
List<App> pps = results[0];
|
||||
Map<String, dynamic> errorsMap = results[1];
|
||||
for (var app in pps) {
|
||||
if (apps.containsKey(app.id)) {
|
||||
errorsMap.addAll({app.id: tr('appAlreadyAdded')});
|
||||
} else {
|
||||
await saveApps([app]);
|
||||
}
|
||||
}
|
||||
List<List<String>> errors =
|
||||
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
class APKPicker extends StatefulWidget {
|
||||
const APKPicker({super.key, required this.app, this.initVal});
|
||||
const APKPicker({super.key, required this.app, this.initVal, this.archs});
|
||||
|
||||
final App app;
|
||||
final String? initVal;
|
||||
final List<String>? archs;
|
||||
|
||||
@override
|
||||
State<APKPicker> createState() => _APKPickerState();
|
||||
@ -423,35 +773,50 @@ class _APKPickerState extends State<APKPicker> {
|
||||
apkUrl ??= widget.initVal;
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Text('Pick an APK'),
|
||||
title: Text(tr('pickAnAPK')),
|
||||
content: Column(children: [
|
||||
Text('${widget.app.name} has more than one package:'),
|
||||
Text(tr('appHasMoreThanOnePackage', args: [widget.app.name])),
|
||||
const SizedBox(height: 16),
|
||||
...widget.app.apkUrls.map((u) => RadioListTile<String>(
|
||||
title: Text(Uri.parse(u)
|
||||
.pathSegments
|
||||
.where((element) => element.isNotEmpty)
|
||||
.last),
|
||||
value: u,
|
||||
groupValue: apkUrl,
|
||||
onChanged: (String? val) {
|
||||
setState(() {
|
||||
apkUrl = val;
|
||||
});
|
||||
}))
|
||||
...widget.app.apkUrls.map(
|
||||
(u) => RadioListTile<String>(
|
||||
title: Text(Uri.parse(u)
|
||||
.pathSegments
|
||||
.where((element) => element.isNotEmpty)
|
||||
.last),
|
||||
value: u,
|
||||
groupValue: apkUrl,
|
||||
onChanged: (String? val) {
|
||||
setState(() {
|
||||
apkUrl = val;
|
||||
});
|
||||
}),
|
||||
),
|
||||
if (widget.archs != null)
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
if (widget.archs != null)
|
||||
Text(
|
||||
widget.archs!.length == 1
|
||||
? tr('deviceSupportsXArch', args: [widget.archs![0]])
|
||||
: tr('deviceSupportsFollowingArchs') +
|
||||
list2FriendlyString(
|
||||
widget.archs!.map((e) => '\'$e\'').toList()),
|
||||
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
|
||||
),
|
||||
]),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: const Text('Cancel')),
|
||||
child: Text(tr('cancel'))),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.selectionClick();
|
||||
Navigator.of(context).pop(apkUrl);
|
||||
},
|
||||
child: const Text('Continue'))
|
||||
child: Text(tr('continue')))
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -473,21 +838,23 @@ class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Text('Warning'),
|
||||
content: Text(
|
||||
'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'),
|
||||
title: Text(tr('warning')),
|
||||
content: Text(tr('sourceIsXButPackageFromYPrompt', args: [
|
||||
Uri.parse(widget.sourceUrl).host,
|
||||
Uri.parse(widget.apkUrl).host
|
||||
])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: const Text('Cancel')),
|
||||
child: Text(tr('cancel'))),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.selectionClick();
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: const Text('Continue'))
|
||||
child: Text(tr('continue')))
|
||||
],
|
||||
);
|
||||
}
|
||||
|
112
lib/providers/logs_provider.dart
Normal file
@ -0,0 +1,112 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
const String logTable = 'logs';
|
||||
const String idColumn = '_id';
|
||||
const String levelColumn = 'level';
|
||||
const String messageColumn = 'message';
|
||||
const String timestampColumn = 'timestamp';
|
||||
const String dbPath = 'logs.db';
|
||||
|
||||
enum LogLevels { debug, info, warning, error }
|
||||
|
||||
class Log {
|
||||
int? id;
|
||||
late LogLevels level;
|
||||
late String message;
|
||||
DateTime timestamp = DateTime.now();
|
||||
|
||||
Map<String, Object?> toMap() {
|
||||
var map = <String, Object?>{
|
||||
idColumn: id,
|
||||
levelColumn: level.index,
|
||||
messageColumn: message,
|
||||
timestampColumn: timestamp.millisecondsSinceEpoch
|
||||
};
|
||||
return map;
|
||||
}
|
||||
|
||||
Log(this.message, this.level);
|
||||
|
||||
Log.fromMap(Map<String, Object?> map) {
|
||||
id = map[idColumn] as int;
|
||||
level = LogLevels.values.elementAt(map[levelColumn] as int);
|
||||
message = map[messageColumn] as String;
|
||||
timestamp =
|
||||
DateTime.fromMillisecondsSinceEpoch(map[timestampColumn] as int);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '${timestamp.toString()}: ${level.name}: $message';
|
||||
}
|
||||
}
|
||||
|
||||
class LogsProvider {
|
||||
LogsProvider({bool runDefaultClear = true}) {
|
||||
clear(before: DateTime.now().subtract(const Duration(days: 7)));
|
||||
}
|
||||
|
||||
Database? db;
|
||||
|
||||
Future<Database> getDB() async {
|
||||
db ??= await openDatabase(dbPath, version: 1,
|
||||
onCreate: (Database db, int version) async {
|
||||
await db.execute('''
|
||||
create table if not exists $logTable (
|
||||
$idColumn integer primary key autoincrement,
|
||||
$levelColumn integer not null,
|
||||
$messageColumn text not null,
|
||||
$timestampColumn integer not null)
|
||||
''');
|
||||
});
|
||||
return db!;
|
||||
}
|
||||
|
||||
Future<Log> add(String message, {LogLevels level = LogLevels.info}) async {
|
||||
Log l = Log(message, level);
|
||||
l.id = await (await getDB()).insert(logTable, l.toMap());
|
||||
if (kDebugMode) {
|
||||
print(l);
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
Future<List<Log>> get({DateTime? before, DateTime? after}) async {
|
||||
var where = getWhereDates(before: before, after: after);
|
||||
return (await (await getDB())
|
||||
.query(logTable, where: where.key, whereArgs: where.value))
|
||||
.map((e) => Log.fromMap(e))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<int> clear({DateTime? before, DateTime? after}) async {
|
||||
var where = getWhereDates(before: before, after: after);
|
||||
var res = await (await getDB())
|
||||
.delete(logTable, where: where.key, whereArgs: where.value);
|
||||
if (res > 0) {
|
||||
add(plural('clearedNLogsBeforeXAfterY', res,
|
||||
namedArgs: {'before': before.toString(), 'after': after.toString()},
|
||||
name: 'n'));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
MapEntry<String?, List<int>?> getWhereDates(
|
||||
{DateTime? before, DateTime? after}) {
|
||||
List<String> where = [];
|
||||
List<int> whereArgs = [];
|
||||
if (before != null) {
|
||||
where.add('$timestampColumn < ?');
|
||||
whereArgs.add(before.millisecondsSinceEpoch);
|
||||
}
|
||||
if (after != null) {
|
||||
where.add('$timestampColumn > ?');
|
||||
whereArgs.add(after.millisecondsSinceEpoch);
|
||||
}
|
||||
return whereArgs.isEmpty
|
||||
? const MapEntry(null, null)
|
||||
: MapEntry(where.join(' and '), whereArgs);
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
// Exposes functions that can be used to send notifications to the user
|
||||
// Contains a set of pre-defined ObtainiumNotification objects that should be used throughout the app
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
@ -12,40 +13,42 @@ class ObtainiumNotification {
|
||||
late String channelName;
|
||||
late String channelDescription;
|
||||
Importance importance;
|
||||
int? progPercent;
|
||||
bool onlyAlertOnce;
|
||||
|
||||
ObtainiumNotification(this.id, this.title, this.message, this.channelCode,
|
||||
this.channelName, this.channelDescription, this.importance);
|
||||
this.channelName, this.channelDescription, this.importance,
|
||||
{this.onlyAlertOnce = false, this.progPercent});
|
||||
}
|
||||
|
||||
class UpdateNotification extends ObtainiumNotification {
|
||||
UpdateNotification(List<App> updates)
|
||||
: super(
|
||||
2,
|
||||
'Updates Available',
|
||||
tr('updatesAvailable'),
|
||||
'',
|
||||
'UPDATES_AVAILABLE',
|
||||
'Updates Available',
|
||||
'Notifies the user that updates are available for one or more Apps tracked by Obtainium',
|
||||
tr('updatesAvailable'),
|
||||
tr('updatesAvailableNotifDescription'),
|
||||
Importance.max) {
|
||||
message = updates.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.';
|
||||
message = updates.isEmpty
|
||||
? tr('noNewUpdates')
|
||||
: updates.length == 1
|
||||
? tr('xHasAnUpdate', args: [updates[0].name])
|
||||
: plural('xAndNMoreUpdatesAvailable', updates.length - 1,
|
||||
args: [updates[0].name, (updates.length - 1).toString()]);
|
||||
}
|
||||
}
|
||||
|
||||
class SilentUpdateNotification extends ObtainiumNotification {
|
||||
SilentUpdateNotification(List<App> updates)
|
||||
: super(
|
||||
3,
|
||||
'Apps Updated',
|
||||
'',
|
||||
'APPS_UPDATED',
|
||||
'Apps Updated',
|
||||
'Notifies the user that updates to one or more Apps were applied in the background',
|
||||
Importance.defaultImportance) {
|
||||
: super(3, tr('appsUpdated'), '', 'APPS_UPDATED', tr('appsUpdated'),
|
||||
tr('appsUpdatedNotifDescription'), Importance.defaultImportance) {
|
||||
message = updates.length == 1
|
||||
? '${updates[0].name} was updated to ${updates[0].latestVersion}.'
|
||||
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} were updated.';
|
||||
? tr('xWasUpdatedToY',
|
||||
args: [updates[0].name, updates[0].latestVersion])
|
||||
: plural('xAndNMoreUpdatesInstalled', updates.length - 1,
|
||||
args: [updates[0].name, (updates.length - 1).toString()]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,30 +56,56 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
|
||||
ErrorCheckingUpdatesNotification(String error)
|
||||
: super(
|
||||
5,
|
||||
'Error Checking for Updates',
|
||||
tr('errorCheckingUpdates'),
|
||||
error,
|
||||
'BG_UPDATE_CHECK_ERROR',
|
||||
'Error Checking for Updates',
|
||||
'A notification that shows when background update checking fails',
|
||||
tr('errorCheckingUpdates'),
|
||||
tr('errorCheckingUpdatesNotifDescription'),
|
||||
Importance.high);
|
||||
}
|
||||
|
||||
class AppsRemovedNotification extends ObtainiumNotification {
|
||||
AppsRemovedNotification(List<List<String>> namedReasons)
|
||||
: super(6, tr('appsRemoved'), '', 'APPS_REMOVED', tr('appsRemoved'),
|
||||
tr('appsRemovedNotifDescription'), Importance.max) {
|
||||
message = '';
|
||||
for (var r in namedReasons) {
|
||||
message += '${tr('xWasRemovedDueToErrorY', args: [r[0], r[1]])} \n';
|
||||
}
|
||||
message = message.trim();
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadNotification extends ObtainiumNotification {
|
||||
DownloadNotification(String appName, int progPercent)
|
||||
: super(
|
||||
appName.hashCode,
|
||||
'Downloading $appName',
|
||||
'',
|
||||
'APP_DOWNLOADING',
|
||||
'Downloading App',
|
||||
'Notifies the user of the progress in downloading an App',
|
||||
Importance.low,
|
||||
onlyAlertOnce: true,
|
||||
progPercent: progPercent);
|
||||
}
|
||||
|
||||
final completeInstallationNotification = ObtainiumNotification(
|
||||
1,
|
||||
'Complete App Installation',
|
||||
'Obtainium must be open to install Apps',
|
||||
tr('completeAppInstallation'),
|
||||
tr('obtainiumMustBeOpenToInstallApps'),
|
||||
'COMPLETE_INSTALL',
|
||||
'Complete App Installation',
|
||||
'Asks the user to return to Obtanium to finish installing an App',
|
||||
tr('completeAppInstallation'),
|
||||
tr('completeAppInstallationNotifDescription'),
|
||||
Importance.max);
|
||||
|
||||
final checkingUpdatesNotification = ObtainiumNotification(
|
||||
4,
|
||||
'Checking for Updates',
|
||||
tr('checkingForUpdates'),
|
||||
'',
|
||||
'BG_UPDATE_CHECK',
|
||||
'Checking for Updates',
|
||||
'Transient notification that appears when checking for updates',
|
||||
tr('checkingForUpdates'),
|
||||
tr('checkingForUpdatesNotifDescription'),
|
||||
Importance.min);
|
||||
|
||||
class NotificationsProvider {
|
||||
@ -116,7 +145,9 @@ class NotificationsProvider {
|
||||
String channelName,
|
||||
String channelDescription,
|
||||
Importance importance,
|
||||
{bool cancelExisting = false}) async {
|
||||
{bool cancelExisting = false,
|
||||
int? progPercent,
|
||||
bool onlyAlertOnce = false}) async {
|
||||
if (cancelExisting) {
|
||||
await cancel(id);
|
||||
}
|
||||
@ -132,12 +163,18 @@ class NotificationsProvider {
|
||||
channelDescription: channelDescription,
|
||||
importance: importance,
|
||||
priority: importanceToPriority[importance]!,
|
||||
groupKey: 'dev.imranr.obtainium.$channelCode')));
|
||||
groupKey: 'dev.imranr.obtainium.$channelCode',
|
||||
progress: progPercent ?? 0,
|
||||
maxProgress: 100,
|
||||
showProgress: progPercent != null,
|
||||
onlyAlertOnce: onlyAlertOnce)));
|
||||
}
|
||||
|
||||
Future<void> notify(ObtainiumNotification notif,
|
||||
{bool cancelExisting = false}) =>
|
||||
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode,
|
||||
notif.channelName, notif.channelDescription, notif.importance,
|
||||
cancelExisting: cancelExisting);
|
||||
cancelExisting: cancelExisting,
|
||||
onlyAlertOnce: notif.onlyAlertOnce,
|
||||
progPercent: notif.progPercent);
|
||||
}
|
||||
|
@ -1,10 +1,15 @@
|
||||
// Exposes functions used to save/load app settings
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
String obtainiumTempId = 'imranr98_obtainium_${GitHub().host}';
|
||||
String obtainiumId = 'dev.imranr.obtainium';
|
||||
|
||||
enum ThemeSettings { system, light, dark }
|
||||
|
||||
enum ColourSettings { basic, materialYou }
|
||||
@ -55,7 +60,7 @@ class SettingsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
int get updateInterval {
|
||||
var min = prefs?.getInt('updateInterval') ?? 180;
|
||||
var min = prefs?.getInt('updateInterval') ?? 360;
|
||||
if (!updateIntervals.contains(min)) {
|
||||
var temp = updateIntervals[0];
|
||||
for (var i in updateIntervals) {
|
||||
@ -74,8 +79,8 @@ class SettingsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
SortColumnSettings get sortColumn {
|
||||
return SortColumnSettings
|
||||
.values[prefs?.getInt('sortColumn') ?? SortColumnSettings.added.index];
|
||||
return SortColumnSettings.values[
|
||||
prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index];
|
||||
}
|
||||
|
||||
set sortColumn(SortColumnSettings s) {
|
||||
@ -85,7 +90,7 @@ class SettingsProvider with ChangeNotifier {
|
||||
|
||||
SortOrderSettings get sortOrder {
|
||||
return SortOrderSettings.values[
|
||||
prefs?.getInt('sortOrder') ?? SortOrderSettings.descending.index];
|
||||
prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index];
|
||||
}
|
||||
|
||||
set sortOrder(SortOrderSettings s) {
|
||||
@ -105,8 +110,7 @@ class SettingsProvider with ChangeNotifier {
|
||||
while (!(await Permission.requestInstallPackages.isGranted)) {
|
||||
// Explicit request as InstallPlugin request sometimes bugged
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Please allow Obtainium to install Apps',
|
||||
toastLength: Toast.LENGTH_LONG);
|
||||
msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG);
|
||||
if ((await Permission.requestInstallPackages.request()) ==
|
||||
PermissionStatus.granted) {
|
||||
break;
|
||||
@ -123,6 +127,15 @@ class SettingsProvider with ChangeNotifier {
|
||||
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);
|
||||
}
|
||||
|
@ -3,16 +3,21 @@
|
||||
|
||||
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/fdroidrepo.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/app_sources/steammobile.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/mass_app_sources/githubstars.dart';
|
||||
|
||||
class AppNames {
|
||||
@ -25,8 +30,9 @@ class AppNames {
|
||||
class APKDetails {
|
||||
late String version;
|
||||
late List<String> apkUrls;
|
||||
late AppNames names;
|
||||
|
||||
APKDetails(this.version, this.apkUrls);
|
||||
APKDetails(this.version, this.apkUrls, this.names);
|
||||
}
|
||||
|
||||
class App {
|
||||
@ -40,6 +46,8 @@ class App {
|
||||
late int preferredApkIndex;
|
||||
late List<String> additionalData;
|
||||
late DateTime? lastUpdateCheck;
|
||||
bool pinned = false;
|
||||
bool trackOnly = false;
|
||||
App(
|
||||
this.id,
|
||||
this.url,
|
||||
@ -50,11 +58,13 @@ class App {
|
||||
this.apkUrls,
|
||||
this.preferredApkIndex,
|
||||
this.additionalData,
|
||||
this.lastUpdateCheck);
|
||||
this.lastUpdateCheck,
|
||||
this.pinned,
|
||||
this.trackOnly);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls';
|
||||
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned';
|
||||
}
|
||||
|
||||
factory App.fromJson(Map<String, dynamic> json) => App(
|
||||
@ -71,11 +81,15 @@ class App {
|
||||
: List<String>.from(jsonDecode(json['apkUrls'])),
|
||||
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
|
||||
json['additionalData'] == null
|
||||
? SourceProvider().getSource(json['url']).additionalDataDefaults
|
||||
? SourceProvider()
|
||||
.getSource(json['url'])
|
||||
.additionalSourceAppSpecificDefaults
|
||||
: List<String>.from(jsonDecode(json['additionalData'])),
|
||||
json['lastUpdateCheck'] == null
|
||||
? null
|
||||
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']));
|
||||
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
|
||||
json['pinned'] ?? false,
|
||||
json['trackOnly'] ?? false);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
@ -87,16 +101,13 @@ class App {
|
||||
'apkUrls': jsonEncode(apkUrls),
|
||||
'preferredApkIndex': preferredApkIndex,
|
||||
'additionalData': jsonEncode(additionalData),
|
||||
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch
|
||||
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
|
||||
'pinned': pinned,
|
||||
'trackOnly': trackOnly
|
||||
};
|
||||
}
|
||||
|
||||
escapeRegEx(String s) {
|
||||
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
||||
return '\\${x[0]}';
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@ -105,16 +116,14 @@ preStandardizeUrl(String url) {
|
||||
if (url.toLowerCase().indexOf('https://www.') == 0) {
|
||||
url = 'https://${url.substring(12)}';
|
||||
}
|
||||
url = url
|
||||
.split('/')
|
||||
.where((e) => e.isNotEmpty)
|
||||
.join('/')
|
||||
.replaceFirst(':/', '://');
|
||||
return url;
|
||||
}
|
||||
|
||||
const String couldNotFindReleases = 'Could not find a suitable release';
|
||||
const String couldNotFindLatestVersion =
|
||||
'Could not determine latest release version';
|
||||
String notValidURL(String sourceName) {
|
||||
return 'Not a valid $sourceName App URL';
|
||||
}
|
||||
|
||||
const String noAPKFound = 'No APK found';
|
||||
|
||||
List<String> getLinksFromParsedHTML(
|
||||
@ -128,23 +137,69 @@ List<String> getLinksFromParsedHTML(
|
||||
.map((e) => '$prependToLinks${e.attributes['href']!}')
|
||||
.toList();
|
||||
|
||||
abstract class AppSource {
|
||||
late String host;
|
||||
String standardizeURL(String url);
|
||||
class AppSource {
|
||||
String? host;
|
||||
late String name;
|
||||
bool enforceTrackOnly = false;
|
||||
|
||||
AppSource() {
|
||||
name = runtimeType.toString();
|
||||
}
|
||||
|
||||
String standardizeURL(String url) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData);
|
||||
AppNames getAppNames(String standardUrl);
|
||||
late List<List<GeneratedFormItem>> additionalDataFormItems;
|
||||
late List<String> additionalDataDefaults;
|
||||
late List<GeneratedFormItem> moreSourceSettingsFormItems;
|
||||
String? changeLogPageFromStandardUrl(String standardUrl);
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl);
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
|
||||
return apkUrl;
|
||||
}
|
||||
|
||||
bool canSearch = false;
|
||||
Future<Map<String, String>> search(String query) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
String? tryInferringAppId(String standardUrl,
|
||||
{List<String> additionalData = const []}) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class MassAppSource {
|
||||
ObtainiumError getObtainiumHttpError(Response res) {
|
||||
return ObtainiumError(res.reasonPhrase ??
|
||||
tr('errorWithHttpStatusCode', args: [res.statusCode.toString()]));
|
||||
}
|
||||
|
||||
abstract class MassAppUrlSource {
|
||||
late String name;
|
||||
late List<String> requiredArgs;
|
||||
Future<List<String>> getUrls(List<String> args);
|
||||
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
|
||||
}
|
||||
|
||||
class SourceProvider {
|
||||
@ -157,31 +212,44 @@ class SourceProvider {
|
||||
Mullvad(),
|
||||
Signal(),
|
||||
SourceForge(),
|
||||
APKMirror()
|
||||
APKMirror(),
|
||||
FDroidRepo(),
|
||||
SteamMobile()
|
||||
];
|
||||
|
||||
// Add more mass source classes here so they are available via the service
|
||||
List<MassAppSource> massSources = [GitHubStars()];
|
||||
// Add more mass url source classes here so they are available via the service
|
||||
List<MassAppUrlSource> massUrlSources = [GitHubStars()];
|
||||
|
||||
AppSource getSource(String url) {
|
||||
url = preStandardizeUrl(url);
|
||||
AppSource? source;
|
||||
for (var s in sources) {
|
||||
if (url.toLowerCase().contains('://${s.host}')) {
|
||||
for (var s in sources.where((element) => element.host != null)) {
|
||||
if (url.contains('://${s.host}')) {
|
||||
source = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (source == null) {
|
||||
throw 'URL does not match a known source';
|
||||
for (var s in sources.where((element) => element.host == null)) {
|
||||
try {
|
||||
s.standardizeURL(url);
|
||||
source = s;
|
||||
break;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
}
|
||||
if (source == null) {
|
||||
throw UnsupportedURLError();
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
bool doesSourceHaveRequiredAdditionalData(AppSource source) {
|
||||
for (var row in source.additionalDataFormItems) {
|
||||
bool ifSourceAppsRequireAdditionalData(AppSource source) {
|
||||
for (var row in source.additionalSourceAppSpecificFormItems) {
|
||||
for (var element in row) {
|
||||
if (element.required) {
|
||||
if (element.required && element.opts == null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -189,43 +257,70 @@ class SourceProvider {
|
||||
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: Look into RegEx for non-Latin characters
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<App> getApp(AppSource source, String url, List<String> additionalData,
|
||||
{String customName = ''}) async {
|
||||
{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);
|
||||
APKDetails apk = await source
|
||||
.getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly);
|
||||
if (apk.apkUrls.isEmpty && !trackOnly) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
String apkVersion = apk.version.replaceAll('/', '-');
|
||||
return App(
|
||||
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
|
||||
id ??
|
||||
source.tryInferringAppId(standardUrl,
|
||||
additionalData: additionalData) ??
|
||||
generateTempID(apk.names, source),
|
||||
standardUrl,
|
||||
names.author[0].toUpperCase() + names.author.substring(1),
|
||||
customName.trim().isNotEmpty
|
||||
? customName
|
||||
: names.name[0].toUpperCase() + names.name.substring(1),
|
||||
null,
|
||||
apk.version,
|
||||
apk.names.author[0].toUpperCase() + apk.names.author.substring(1),
|
||||
name.trim().isNotEmpty
|
||||
? name
|
||||
: apk.names.name[0].toUpperCase() + apk.names.name.substring(1),
|
||||
installedVersion,
|
||||
apkVersion,
|
||||
apk.apkUrls,
|
||||
apk.apkUrls.length - 1,
|
||||
additionalData,
|
||||
DateTime.now());
|
||||
DateTime.now(),
|
||||
pinned,
|
||||
trackOnly);
|
||||
}
|
||||
|
||||
/// Returns a length 2 list, where the first element is a list of Apps and
|
||||
/// the second is a Map<String, dynamic> of URLs and errors
|
||||
Future<List<dynamic>> getApps(List<String> urls,
|
||||
// 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.additionalDataDefaults));
|
||||
apps.add(await getApp(
|
||||
source, url, source.additionalSourceAppSpecificDefaults));
|
||||
} catch (e) {
|
||||
errors.addAll(<String, dynamic>{url: e});
|
||||
}
|
||||
}
|
||||
return [apps, errors];
|
||||
}
|
||||
|
||||
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
|
||||
}
|
||||
|
225
pubspec.lock
@ -1,6 +1,13 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
android_alarm_manager_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: android_alarm_manager_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
animations:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -14,7 +21,7 @@ packages:
|
||||
name: archive
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.3.1"
|
||||
version: "3.3.5"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -71,6 +78,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -112,42 +126,14 @@ packages:
|
||||
name: device_info_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.0.5"
|
||||
device_info_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
device_info_plus_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
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: "4.0.1"
|
||||
device_info_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
device_info_plus_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
version: "7.0.0"
|
||||
dynamic_color:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -155,6 +141,20 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.5.4"
|
||||
easy_localization:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: easy_localization
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
easy_logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: easy_logger
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.2"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -182,7 +182,7 @@ packages:
|
||||
name: file_picker
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.2.0+1"
|
||||
version: "5.2.3"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -194,14 +194,14 @@ packages:
|
||||
name: flutter_fgbg
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "0.2.2"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.10.0"
|
||||
version: "0.11.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -215,14 +215,14 @@ packages:
|
||||
name: flutter_local_notifications
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "12.0.0"
|
||||
version: "12.0.4"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "2.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -230,6 +230,11 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_localizations:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -253,14 +258,14 @@ packages:
|
||||
name: fluttertoast
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "8.0.9"
|
||||
version: "8.1.1"
|
||||
html:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: html
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.15.0"
|
||||
version: "0.15.1"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -274,14 +279,14 @@ packages:
|
||||
name: http_parser
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
version: "4.0.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
version: "3.2.2"
|
||||
install_plugin_v2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -289,6 +294,20 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
installed_apps:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: installed_apps
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: intl
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.17.0"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -309,7 +328,7 @@ packages:
|
||||
name: lints
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.0.1"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -323,7 +342,7 @@ packages:
|
||||
name: material_color_utilities
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "0.1.5"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -337,7 +356,7 @@ packages:
|
||||
name: mime
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
version: "1.0.3"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -345,6 +364,20 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
package_archive_info:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_archive_info
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
package_info:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -365,7 +398,7 @@ packages:
|
||||
name: path_provider_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.20"
|
||||
version: "2.0.22"
|
||||
path_provider_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -407,21 +440,21 @@ packages:
|
||||
name: permission_handler
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "10.1.0"
|
||||
version: "10.2.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "10.1.0"
|
||||
version: "10.2.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "9.0.6"
|
||||
version: "9.0.7"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -435,14 +468,14 @@ packages:
|
||||
name: permission_handler_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.1"
|
||||
version: "0.1.2"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
version: "5.1.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -457,6 +490,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
pointycastle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointycastle
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.6.2"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -470,49 +510,21 @@ packages:
|
||||
name: provider
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
version: "6.0.4"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
share_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
share_plus_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
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.1.1"
|
||||
share_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
share_plus_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.2.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -526,7 +538,7 @@ packages:
|
||||
name: shared_preferences_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.13"
|
||||
version: "2.0.14"
|
||||
shared_preferences_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -580,7 +592,21 @@ packages:
|
||||
name: source_span
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
version: "1.9.0"
|
||||
sqflite:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.4.0+2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -594,7 +620,7 @@ packages:
|
||||
name: stream_channel
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
version: "2.1.0"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -602,6 +628,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0+3"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -615,7 +648,7 @@ packages:
|
||||
name: test_api
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.14"
|
||||
version: "0.4.12"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -636,14 +669,14 @@ packages:
|
||||
name: url_launcher
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.1.6"
|
||||
version: "6.1.7"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.0.19"
|
||||
version: "6.0.22"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -686,13 +719,20 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.1.2"
|
||||
webview_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -727,14 +767,7 @@ packages:
|
||||
name: win32
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
workmanager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: workmanager
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
version: "3.1.2"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -757,5 +790,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
sdks:
|
||||
dart: ">=2.19.0-79.0.dev <3.0.0"
|
||||
dart: ">=2.18.2 <3.0.0"
|
||||
flutter: ">=3.3.0"
|
||||
|
21
pubspec.yaml
@ -17,10 +17,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.5.7+28 # When changing this, update the tag in main() accordingly
|
||||
version: 0.8.17+81 # When changing this, update the tag in main() accordingly
|
||||
|
||||
environment:
|
||||
sdk: '>=2.19.0-79.0.dev <3.0.0'
|
||||
sdk: '>=2.18.2 <3.0.0'
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
@ -42,24 +42,28 @@ dependencies:
|
||||
provider: ^6.0.3
|
||||
http: ^0.13.5
|
||||
webview_flutter: ^3.0.4
|
||||
workmanager: ^0.5.0
|
||||
dynamic_color: ^1.5.4
|
||||
html: ^0.15.0
|
||||
shared_preferences: ^2.0.15
|
||||
url_launcher: ^6.1.5
|
||||
permission_handler: ^10.0.0
|
||||
fluttertoast: ^8.0.9
|
||||
device_info_plus: ^5.0.5
|
||||
device_info_plus: ^8.0.0
|
||||
file_picker: ^5.1.0
|
||||
animations: ^2.0.4
|
||||
install_plugin_v2: ^1.0.0
|
||||
share_plus: ^4.4.0
|
||||
share_plus: ^6.0.1
|
||||
installed_apps: ^1.3.1
|
||||
package_archive_info: ^0.1.0
|
||||
android_alarm_manager_plus: ^2.1.0
|
||||
sqflite: ^2.2.0+3
|
||||
easy_localization: ^3.0.1
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_launcher_icons: ^0.10.0
|
||||
flutter_launcher_icons: ^0.11.0
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
@ -86,9 +90,12 @@ flutter:
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
assets:
|
||||
- assets/translations/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/assets-and-images/#resolution-aware
|
||||
|