Compare commits
122 Commits
v0.6.4-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 |
1
.gitignore
vendored
@ -9,6 +9,7 @@
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
.vscode/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
|
@ -13,9 +13,13 @@ Currently supported App sources:
|
||||
- [IzzyOnDroid](https://android.izzysoft.de/)
|
||||
- [Mullvad](https://mullvad.net/en/)
|
||||
- [Signal](https://signal.org/)
|
||||
- [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 |
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,62 +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 releases = parse(res.body).querySelectorAll('.package-version');
|
||||
List<dynamic> releases = jsonDecode(res.body)['packages'] ?? [];
|
||||
if (releases.isEmpty) {
|
||||
throw couldNotFindReleases;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
String? latestVersion = releases[0]
|
||||
.querySelector('.package-version-header b')
|
||||
?.innerHtml
|
||||
.split(' ')
|
||||
.sublist(1)
|
||||
.join(' ');
|
||||
String? latestVersion = releases[0]['versionName'];
|
||||
if (latestVersion == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
throw NoVersionError();
|
||||
}
|
||||
List<String> apkUrls = releases
|
||||
.where((element) =>
|
||||
element
|
||||
.querySelector('.package-version-header b')
|
||||
?.innerHtml
|
||||
.split(' ')
|
||||
.sublist(1)
|
||||
.join(' ') ==
|
||||
latestVersion)
|
||||
.map((e) =>
|
||||
e
|
||||
.querySelector('.package-version-download a')
|
||||
?.attributes['href'] ??
|
||||
'')
|
||||
.where((element) => element.isNotEmpty)
|
||||
.where((element) => element['versionName'] == latestVersion)
|
||||
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
|
||||
.toList();
|
||||
if (apkUrls.isEmpty) {
|
||||
throw noAPKFound;
|
||||
}
|
||||
return APKDetails(latestVersion, apkUrls);
|
||||
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 =
|
||||
@ -72,11 +144,11 @@ class GitHub implements AppSource {
|
||||
|
||||
if (regexFilter != null &&
|
||||
!RegExp(regexFilter)
|
||||
.hasMatch((releases[i]['tag_name'] as String).trim())) {
|
||||
.hasMatch((releases[i]['name'] as String).trim())) {
|
||||
continue;
|
||||
}
|
||||
var apkUrls = getReleaseAPKUrls(releases[i]);
|
||||
if (apkUrls.isEmpty) {
|
||||
if (apkUrls.isEmpty && !trackOnly) {
|
||||
continue;
|
||||
}
|
||||
targetRelease = releases[i];
|
||||
@ -84,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('/');
|
||||
@ -114,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('');
|
||||
}
|
||||
|
150
lib/main.dart
@ -1,61 +1,108 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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.6.4';
|
||||
const String currentVersion = '0.8.17';
|
||||
const String currentReleaseTag =
|
||||
'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 {
|
||||
var appsProvider = AppsProvider();
|
||||
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||
await appsProvider.loadApps(shouldCorrectInstallStatus: false);
|
||||
await appsProvider.loadApps();
|
||||
List<String> existingUpdateIds =
|
||||
appsProvider.getExistingUpdates(installedOnly: true);
|
||||
appsProvider.findExistingUpdates(installedOnly: true);
|
||||
DateTime nextIgnoreAfter = DateTime.now();
|
||||
String? err;
|
||||
try {
|
||||
logs.add(tr('startedActualBGUpdateCheck'));
|
||||
await appsProvider.checkUpdates(
|
||||
ignoreAfter: ignoreAfter,
|
||||
immediatelyThrowRateLimitError: true,
|
||||
immediatelyThrowSocketError: true,
|
||||
shouldCorrectInstallStatus: false);
|
||||
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
|
||||
} catch (e) {
|
||||
if (e is RateLimitError || e is SocketException) {
|
||||
String nextTaskName =
|
||||
'$bgUpdateCheckTaskName-${nextIgnoreAfter.microsecondsSinceEpoch.toString()}';
|
||||
Workmanager().registerOneOffTask(nextTaskName, nextTaskName,
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
initialDelay: Duration(
|
||||
minutes: e is RateLimitError ? e.remainingMinutes : 15),
|
||||
inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch});
|
||||
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 {
|
||||
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();
|
||||
@ -73,53 +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));
|
||||
}
|
||||
if (err != null) {
|
||||
throw err;
|
||||
}
|
||||
return Future.value(true);
|
||||
} catch (e) {
|
||||
notificationsProvider
|
||||
.notify(ErrorCheckingUpdatesNotification(e.toString()));
|
||||
return Future.error(false);
|
||||
} 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();
|
||||
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()),
|
||||
));
|
||||
}
|
||||
|
||||
@ -139,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(
|
||||
'dev.imranr.obtainium',
|
||||
obtainiumId,
|
||||
'https://github.com/ImranR98/Obtainium',
|
||||
'ImranR98',
|
||||
'Obtainium',
|
||||
@ -158,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -197,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,17 +25,126 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
bool gettingAppInfo = false;
|
||||
|
||||
String userInput = '';
|
||||
String searchQuery = '';
|
||||
AppSource? pickedSource;
|
||||
List<String> additionalData = [];
|
||||
bool validAdditionalData = true;
|
||||
List<String> sourceSpecificAdditionalData = [];
|
||||
bool sourceSpecificDataIsValid = true;
|
||||
List<String> otherAdditionalData = [];
|
||||
bool otherAdditionalDataIsValid = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||
|
||||
changeUserInput(String input, bool valid, bool isBuilding) {
|
||||
userInput = input;
|
||||
fn() {
|
||||
var source = valid ? sourceProvider.getSource(userInput) : null;
|
||||
if (pickedSource.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),
|
||||
@ -44,7 +158,7 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'App Source Url',
|
||||
label: tr('appSourceURL'),
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
try {
|
||||
@ -56,31 +170,18 @@ 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;
|
||||
}
|
||||
});
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
changeUserInput(
|
||||
values[0], valid, isBuilding);
|
||||
},
|
||||
defaultValues: const [])),
|
||||
const SizedBox(
|
||||
@ -91,73 +192,115 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
: ElevatedButton(
|
||||
onPressed: gettingAppInfo ||
|
||||
pickedSource == null ||
|
||||
(pickedSource!.additionalDataFormItems
|
||||
(pickedSource!
|
||||
.additionalSourceAppSpecificFormItems
|
||||
.isNotEmpty &&
|
||||
!validAdditionalData)
|
||||
!sourceSpecificDataIsValid) ||
|
||||
(pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticDefaults
|
||||
.isNotEmpty &&
|
||||
!otherAdditionalDataIsValid)
|
||||
? null
|
||||
: () async {
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
var appsProvider =
|
||||
context.read<AppsProvider>();
|
||||
var settingsProvider =
|
||||
context.read<SettingsProvider>();
|
||||
() async {
|
||||
HapticFeedback.selectionClick();
|
||||
App app =
|
||||
await sourceProvider.getApp(
|
||||
pickedSource!,
|
||||
userInput,
|
||||
additionalData);
|
||||
await settingsProvider
|
||||
.getInstallPermission();
|
||||
// ignore: use_build_context_synchronously
|
||||
var apkUrl = await appsProvider
|
||||
.selectApkUrl(app, context);
|
||||
if (apkUrl == null) {
|
||||
throw 'Cancelled';
|
||||
}
|
||||
app.preferredApkIndex =
|
||||
app.apkUrls.indexOf(apkUrl);
|
||||
var downloadedApk =
|
||||
await appsProvider.downloadApp(
|
||||
app,
|
||||
showOccasionalProgressToast:
|
||||
true);
|
||||
app.id = downloadedApk.appId;
|
||||
if (appsProvider.apps
|
||||
.containsKey(app.id)) {
|
||||
throw 'App already added';
|
||||
}
|
||||
await appsProvider.saveApps([app]);
|
||||
|
||||
return app;
|
||||
}()
|
||||
.then((app) {
|
||||
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'))
|
||||
: addApp,
|
||||
child: Text(tr('add')))
|
||||
],
|
||||
),
|
||||
if (sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.isNotEmpty &&
|
||||
pickedSource == null &&
|
||||
userInput.isEmpty)
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
if (sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.isNotEmpty &&
|
||||
pickedSource == null &&
|
||||
userInput.isEmpty)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GeneratedForm(
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr('searchSomeSourcesLabel'),
|
||||
required: false),
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (values.isNotEmpty && valid) {
|
||||
setState(() {
|
||||
searchQuery = values[0].trim();
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues: const ['']),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: searchQuery.isEmpty || gettingAppInfo
|
||||
? null
|
||||
: () {
|
||||
Future.wait(sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.map((e) =>
|
||||
e.search(searchQuery)))
|
||||
.then((results) async {
|
||||
// Interleave results instead of simple reduce
|
||||
Map<String, String> res = {};
|
||||
var si = 0;
|
||||
var done = false;
|
||||
while (!done) {
|
||||
done = true;
|
||||
for (var r in results) {
|
||||
if (r.length > si) {
|
||||
done = false;
|
||||
res.addEntries(
|
||||
[r.entries.elementAt(si)]);
|
||||
}
|
||||
}
|
||||
si++;
|
||||
}
|
||||
List<String>? selectedUrls = res
|
||||
.isEmpty
|
||||
? []
|
||||
: await showDialog<List<String>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return UrlSelectionModal(
|
||||
urlsWithDescriptions: res,
|
||||
selectedByDefault: false,
|
||||
onlyOneSelectionAllowed:
|
||||
true,
|
||||
);
|
||||
});
|
||||
if (selectedUrls != null &&
|
||||
selectedUrls.isNotEmpty) {
|
||||
changeUserInput(
|
||||
selectedUrls[0], true, true);
|
||||
addApp(resetUserInputAfter: true);
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
},
|
||||
child: Text(tr('search')))
|
||||
],
|
||||
),
|
||||
if (pickedSource != null &&
|
||||
pickedSource!.additionalDataDefaults.isNotEmpty)
|
||||
(pickedSource!.additionalSourceAppSpecificDefaults
|
||||
.isNotEmpty ||
|
||||
pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticFormItems
|
||||
.where((e) => pickedSource!.enforceTrackOnly
|
||||
? e.key != 'trackOnlyFormItemKey'
|
||||
: true)
|
||||
.map((e) => [e])
|
||||
.isNotEmpty))
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@ -165,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)),
|
||||
@ -173,22 +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,
|
||||
),
|
||||
GeneratedForm(
|
||||
items: pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticFormItems
|
||||
.where((e) => pickedSource!.enforceTrackOnly
|
||||
? e.key != 'trackOnlyFormItemKey'
|
||||
: true)
|
||||
.map((e) => [e])
|
||||
.toList(),
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (isBuilding) {
|
||||
otherAdditionalData = values;
|
||||
otherAdditionalDataIsValid = valid;
|
||||
} else {
|
||||
setState(() {
|
||||
otherAdditionalData = values;
|
||||
otherAdditionalDataIsValid = valid;
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues: pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticDefaults),
|
||||
],
|
||||
)
|
||||
else
|
||||
@ -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,6 +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_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';
|
||||
@ -25,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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -63,6 +64,7 @@ class _AppPageState extends State<AppPage> {
|
||||
Image.memory(
|
||||
app!.installedInfo!.icon!,
|
||||
height: 150,
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
])
|
||||
: Container(),
|
||||
@ -75,7 +77,7 @@ class _AppPageState extends State<AppPage> {
|
||||
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,
|
||||
),
|
||||
@ -101,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,
|
||||
),
|
||||
@ -114,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),
|
||||
@ -140,6 +151,7 @@ class _AppPageState extends State<AppPage> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (app?.app.installedVersion != null &&
|
||||
app?.app.trackOnly == false &&
|
||||
app?.app.installedVersion != app?.app.latestVersion)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
@ -149,15 +161,22 @@ class _AppPageState extends State<AppPage> {
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'App Already up to Date?'),
|
||||
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
|
||||
@ -174,8 +193,8 @@ class _AppPageState extends State<AppPage> {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Yes, Mark as Updated'))
|
||||
child: Text(
|
||||
tr('yesMarkUpdated')))
|
||||
],
|
||||
);
|
||||
});
|
||||
@ -183,7 +202,8 @@ class _AppPageState extends State<AppPage> {
|
||||
tooltip: 'Mark as Updated',
|
||||
icon: const Icon(Icons.done)),
|
||||
if (source != null &&
|
||||
source.additionalDataFormItems.isNotEmpty)
|
||||
source.additionalSourceAppSpecificFormItems
|
||||
.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
@ -194,11 +214,11 @@ class _AppPageState extends State<AppPage> {
|
||||
return GeneratedFormModal(
|
||||
title: 'Additional Options',
|
||||
items: source
|
||||
.additionalDataFormItems,
|
||||
.additionalSourceAppSpecificFormItems,
|
||||
defaultValues: app != null
|
||||
? app.app.additionalData
|
||||
: source
|
||||
.additionalDataDefaults);
|
||||
.additionalSourceAppSpecificDefaults);
|
||||
}).then((values) {
|
||||
if (app != null && values != null) {
|
||||
var changedApp = app.app;
|
||||
@ -216,31 +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
|
||||
.downloadAndInstallLatestApps(
|
||||
[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) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString())),
|
||||
);
|
||||
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
|
||||
@ -250,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?.installedInfo?.name ?? app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'),
|
||||
title: Text(tr('removeAppQuestion')),
|
||||
content: Text(tr(
|
||||
'xWillBeRemovedButRemainInstalled',
|
||||
args: [
|
||||
app?.installedInfo?.name ??
|
||||
app?.app.name ??
|
||||
tr('app')
|
||||
])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
@ -266,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')))
|
||||
],
|
||||
);
|
||||
});
|
||||
@ -281,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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -119,32 +123,77 @@ class AppsPageState extends State<AppsPage> {
|
||||
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(
|
||||
@ -152,78 +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);
|
||||
},
|
||||
leading: sortedApps[index].installedInfo != null
|
||||
? Image.memory(sortedApps[index].installedInfo!.icon!)
|
||||
? Image.memory(
|
||||
sortedApps[index].installedInfo!.icon!,
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
: null,
|
||||
title: Text(sortedApps[index].installedInfo?.name ??
|
||||
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(
|
||||
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: [
|
||||
Text(appsProvider.areDownloadsRunning()
|
||||
? 'Please Wait...'
|
||||
: '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(),
|
||||
],
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
sortedApps[index].app.installedVersion ??
|
||||
'Not Installed',
|
||||
overflow: TextOverflow.fade,
|
||||
textAlign: TextAlign.end,
|
||||
)))),
|
||||
))),
|
||||
onTap: () {
|
||||
if (selectedIds.isNotEmpty) {
|
||||
toggleAppSelected(sortedApps[index].app.id);
|
||||
if (selectedApps.isNotEmpty) {
|
||||
toggleAppSelected(sortedApps[index].app);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
@ -241,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,
|
||||
@ -268,71 +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: [
|
||||
'true',
|
||||
existingUpdateIdsAllOrSelected.isEmpty
|
||||
? '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.isEmpty || values[0] == 'true';
|
||||
bool shouldInstallNew = values.isEmpty ||
|
||||
(values.length >= 2 && values[1] == 'true');
|
||||
settingsProvider
|
||||
.getInstallPermission()
|
||||
findGeneratedFormValueByKey(
|
||||
formInputs, values, 'updates') ==
|
||||
'true';
|
||||
bool shouldInstallNew =
|
||||
findGeneratedFormValueByKey(
|
||||
formInputs, values, 'installs') ==
|
||||
'true';
|
||||
bool shouldMarkTrackOnlies =
|
||||
findGeneratedFormValueByKey(formInputs,
|
||||
values, 'trackonlies') ==
|
||||
'true';
|
||||
(() async {
|
||||
if (shouldInstallNew ||
|
||||
shouldInstallUpdates) {
|
||||
await settingsProvider
|
||||
.getInstallPermission();
|
||||
}
|
||||
})()
|
||||
.then((_) {
|
||||
List<String> toInstall = [];
|
||||
if (shouldInstallUpdates) {
|
||||
@ -343,24 +458,27 @@ class AppsPageState extends State<AppsPage> {
|
||||
toInstall
|
||||
.addAll(newInstallIdsAllOrSelected);
|
||||
}
|
||||
if (shouldMarkTrackOnlies) {
|
||||
toInstall.addAll(
|
||||
trackOnlyUpdateIdsAllOrSelected);
|
||||
}
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApps(
|
||||
toInstall, context)
|
||||
.downloadAndInstallLatestApps(toInstall,
|
||||
globalNavigatorKey.currentContext)
|
||||
.catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
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,
|
||||
@ -388,11 +506,22 @@ class AppsPageState extends State<AppsPage> {
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Mark ${selectedIds.length} Selected Apps as Updated?'),
|
||||
content:
|
||||
const Text(
|
||||
'Only applies to installed but out of date Apps.'),
|
||||
title: Text(tr(
|
||||
'markXSelectedAppsAsUpdated',
|
||||
args: [
|
||||
selectedApps
|
||||
.length
|
||||
.toString()
|
||||
])),
|
||||
content: Text(
|
||||
tr('onlyWorksWithNonEVDApps'),
|
||||
style: const TextStyle(
|
||||
fontWeight:
|
||||
FontWeight
|
||||
.bold,
|
||||
fontStyle:
|
||||
FontStyle.italic),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed:
|
||||
@ -400,17 +529,15 @@ 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;
|
||||
.saveApps(selectedApps.map((a) {
|
||||
if (a.installedVersion !=
|
||||
null) {
|
||||
a.installedVersion = a.latestVersion;
|
||||
@ -421,37 +548,104 @@ class AppsPageState extends State<AppsPage> {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Yes'))
|
||||
child: Text(
|
||||
tr('yes')))
|
||||
],
|
||||
);
|
||||
});
|
||||
}).whenComplete(() {
|
||||
Navigator.of(
|
||||
context)
|
||||
.pop();
|
||||
});
|
||||
},
|
||||
tooltip:
|
||||
'Mark Selected Apps as 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),
|
||||
),
|
||||
],
|
||||
@ -469,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
|
||||
@ -482,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
|
||||
@ -493,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')))
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
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,48 +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,
|
||||
'Apps Removed',
|
||||
'',
|
||||
'APPS_REMOVED',
|
||||
'Apps Removed',
|
||||
'Notifies the user that one or more Apps were removed due to errors while loading them',
|
||||
Importance.max) {
|
||||
: super(6, tr('appsRemoved'), '', 'APPS_REMOVED', tr('appsRemoved'),
|
||||
tr('appsRemovedNotifDescription'), Importance.max) {
|
||||
message = '';
|
||||
for (var r in namedReasons) {
|
||||
message += '${r[0]} was removed due to this error: ${r[1]}. \n';
|
||||
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 {
|
||||
@ -134,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);
|
||||
}
|
||||
@ -150,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) {
|
||||
@ -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,15 +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 {
|
||||
@ -24,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 {
|
||||
@ -39,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,
|
||||
@ -49,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 PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()}';
|
||||
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned';
|
||||
}
|
||||
|
||||
factory App.fromJson(Map<String, dynamic> json) => App(
|
||||
@ -70,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,
|
||||
@ -86,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) {
|
||||
@ -112,13 +124,6 @@ preStandardizeUrl(String url) {
|
||||
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(
|
||||
@ -132,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 {
|
||||
@ -161,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;
|
||||
}
|
||||
}
|
||||
@ -196,43 +260,67 @@ class SourceProvider {
|
||||
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 name = '', String? id}) 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(
|
||||
id ?? generateTempID(names, source),
|
||||
id ??
|
||||
source.tryInferringAppId(standardUrl,
|
||||
additionalData: additionalData) ??
|
||||
generateTempID(apk.names, source),
|
||||
standardUrl,
|
||||
names.author[0].toUpperCase() + names.author.substring(1),
|
||||
apk.names.author[0].toUpperCase() + apk.names.author.substring(1),
|
||||
name.trim().isNotEmpty
|
||||
? name
|
||||
: names.name[0].toUpperCase() + names.name.substring(1),
|
||||
null,
|
||||
apk.version.replaceAll('/', '-'),
|
||||
: 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();
|
||||
}
|
||||
|
99
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.2"
|
||||
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:
|
||||
@ -127,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:
|
||||
@ -154,7 +182,7 @@ packages:
|
||||
name: file_picker
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.2.2"
|
||||
version: "5.2.3"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -166,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:
|
||||
@ -187,7 +215,7 @@ packages:
|
||||
name: flutter_local_notifications
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "12.0.3"
|
||||
version: "12.0.4"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -202,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:
|
||||
@ -268,6 +301,13 @@ packages:
|
||||
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:
|
||||
@ -316,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:
|
||||
@ -358,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:
|
||||
@ -450,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,7 +517,7 @@ packages:
|
||||
name: share_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "6.3.0"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -546,6 +593,20 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
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:
|
||||
@ -567,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:
|
||||
@ -601,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.21"
|
||||
version: "6.0.22"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -657,7 +725,7 @@ packages:
|
||||
name: uuid
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
version: "3.0.7"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -699,14 +767,7 @@ packages:
|
||||
name: win32
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
workmanager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: workmanager
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.5.1"
|
||||
version: "3.1.2"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
13
pubspec.yaml
@ -17,7 +17,7 @@ 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.6.4+48 # 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.18.2 <3.0.0'
|
||||
@ -42,7 +42,6 @@ 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
|
||||
@ -56,12 +55,15 @@ dependencies:
|
||||
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
|
||||
@ -88,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
|
||||
|