mirror of
https://github.com/ImranR98/Obtainium.git
synced 2025-07-15 14:16:43 +02:00
Compare commits
59 Commits
v0.7.1-bet
...
v0.8.10-be
Author | SHA1 | Date | |
---|---|---|---|
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,6 +9,7 @@
|
|||||||
.history
|
.history
|
||||||
.svn/
|
.svn/
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
*.iml
|
*.iml
|
||||||
|
@ -13,9 +13,10 @@ Currently supported App sources:
|
|||||||
- [IzzyOnDroid](https://android.izzysoft.de/)
|
- [IzzyOnDroid](https://android.izzysoft.de/)
|
||||||
- [Mullvad](https://mullvad.net/en/)
|
- [Mullvad](https://mullvad.net/en/)
|
||||||
- [Signal](https://signal.org/)
|
- [Signal](https://signal.org/)
|
||||||
|
- [APKMirror](https://apkmirror.com/) (Track-Only)
|
||||||
|
|
||||||
## Limitations
|
## 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
218
assets/translations/en.json
Normal file
218
assets/translations/en.json
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
{
|
||||||
|
"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)",
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
58
lib/app_sources/apkmirror.dart
Normal file
58
lib/app_sources/apkmirror.dart
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import 'package:html/parser.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class APKMirror extends AppSource {
|
||||||
|
APKMirror() {
|
||||||
|
host = 'apkmirror.com';
|
||||||
|
enforceTrackOnly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+');
|
||||||
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw InvalidURLError(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||||
|
'$standardUrl/#whatsnew';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData,
|
||||||
|
{bool trackOnly = false}) async {
|
||||||
|
Response res = await get(Uri.parse('$standardUrl/feed'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
String? titleString = parse(res.body)
|
||||||
|
.querySelector('item')
|
||||||
|
?.querySelector('title')
|
||||||
|
?.innerHtml;
|
||||||
|
String? version = titleString
|
||||||
|
?.substring(RegExp('[0-9]').firstMatch(titleString)?.start ?? 0,
|
||||||
|
RegExp(' by ').firstMatch(titleString)?.start ?? 0)
|
||||||
|
.trim();
|
||||||
|
if (version == null || version.isEmpty) {
|
||||||
|
version = titleString;
|
||||||
|
}
|
||||||
|
if (version == null || version.isEmpty) {
|
||||||
|
throw NoVersionError();
|
||||||
|
}
|
||||||
|
return APKDetails(version, []);
|
||||||
|
} else {
|
||||||
|
throw NoReleasesError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) {
|
||||||
|
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
||||||
|
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
||||||
|
return AppNames(names[1], names[2]);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import 'package:html/parser.dart';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:obtainium/custom_errors.dart';
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
@ -28,51 +29,41 @@ class FDroid extends AppSource {
|
|||||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
String? tryInferringAppId(String standardUrl) {
|
||||||
|
return Uri.parse(standardUrl).pathSegments.last;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||||
Future<APKDetails> getLatestAPKDetails(
|
Response res, String apkUrlPrefix) {
|
||||||
String standardUrl, List<String> additionalData) async {
|
|
||||||
Response res = await get(Uri.parse(standardUrl));
|
|
||||||
if (res.statusCode == 200) {
|
if (res.statusCode == 200) {
|
||||||
var releases = parse(res.body).querySelectorAll('.package-version');
|
List<dynamic> releases = jsonDecode(res.body)['packages'] ?? [];
|
||||||
if (releases.isEmpty) {
|
if (releases.isEmpty) {
|
||||||
throw NoReleasesError();
|
throw NoReleasesError();
|
||||||
}
|
}
|
||||||
String? latestVersion = releases[0]
|
String? latestVersion = releases[0]['versionName'];
|
||||||
.querySelector('.package-version-header b')
|
|
||||||
?.innerHtml
|
|
||||||
.split(' ')
|
|
||||||
.sublist(1)
|
|
||||||
.join(' ');
|
|
||||||
if (latestVersion == null) {
|
if (latestVersion == null) {
|
||||||
throw NoVersionError();
|
throw NoVersionError();
|
||||||
}
|
}
|
||||||
List<String> apkUrls = releases
|
List<String> apkUrls = releases
|
||||||
.where((element) =>
|
.where((element) => element['versionName'] == latestVersion)
|
||||||
element
|
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
|
||||||
.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)
|
|
||||||
.toList();
|
.toList();
|
||||||
if (apkUrls.isEmpty) {
|
|
||||||
throw NoAPKError();
|
|
||||||
}
|
|
||||||
return APKDetails(latestVersion, apkUrls);
|
return APKDetails(latestVersion, apkUrls);
|
||||||
} else {
|
} else {
|
||||||
throw NoReleasesError();
|
throw NoReleasesError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData,
|
||||||
|
{bool trackOnly = false}) async {
|
||||||
|
String? appId = tryInferringAppId(standardUrl);
|
||||||
|
return getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||||
|
await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')),
|
||||||
|
'https://f-droid.org/repo/$appId');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AppNames getAppNames(String standardUrl) {
|
AppNames getAppNames(String standardUrl) {
|
||||||
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:obtainium/components/generated_form.dart';
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
@ -11,11 +12,11 @@ class GitHub extends AppSource {
|
|||||||
GitHub() {
|
GitHub() {
|
||||||
host = 'github.com';
|
host = 'github.com';
|
||||||
|
|
||||||
additionalDataDefaults = ['true', 'true', ''];
|
additionalSourceAppSpecificDefaults = ['true', 'true', ''];
|
||||||
|
|
||||||
moreSourceSettingsFormItems = [
|
additionalSourceSpecificSettingFormItems = [
|
||||||
GeneratedFormItem(
|
GeneratedFormItem(
|
||||||
label: 'GitHub Personal Access Token (Increases Rate Limit)',
|
label: tr('githubPATLabel'),
|
||||||
id: 'github-creds',
|
id: 'github-creds',
|
||||||
required: false,
|
required: false,
|
||||||
additionalValidators: [
|
additionalValidators: [
|
||||||
@ -26,13 +27,13 @@ class GitHub extends AppSource {
|
|||||||
.where((element) => element.trim().isNotEmpty)
|
.where((element) => element.trim().isNotEmpty)
|
||||||
.length !=
|
.length !=
|
||||||
2) {
|
2) {
|
||||||
return 'PAT must be in this format: username:token';
|
return tr('githubPATHint');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
hint: 'username:token',
|
hint: tr('githubPATFormat'),
|
||||||
belowWidgets: [
|
belowWidgets: [
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
height: 8,
|
height: 8,
|
||||||
@ -43,25 +44,26 @@ class GitHub extends AppSource {
|
|||||||
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
|
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
|
||||||
mode: LaunchMode.externalApplication);
|
mode: LaunchMode.externalApplication);
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: Text(
|
||||||
'About GitHub PATs',
|
tr('githubPATLinkText'),
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
decoration: TextDecoration.underline, fontSize: 12),
|
decoration: TextDecoration.underline, fontSize: 12),
|
||||||
))
|
))
|
||||||
])
|
])
|
||||||
];
|
];
|
||||||
|
|
||||||
additionalDataFormItems = [
|
additionalSourceAppSpecificFormItems = [
|
||||||
[
|
[
|
||||||
GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)
|
GeneratedFormItem(
|
||||||
|
label: tr('includePrereleases'), type: FormItemType.bool)
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
GeneratedFormItem(
|
GeneratedFormItem(
|
||||||
label: 'Fallback to older releases', type: FormItemType.bool)
|
label: tr('fallbackToOlderReleases'), type: FormItemType.bool)
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
GeneratedFormItem(
|
GeneratedFormItem(
|
||||||
label: 'Filter Release Titles by Regular Expression',
|
label: tr('filterReleaseTitlesByRegEx'),
|
||||||
type: FormItemType.string,
|
type: FormItemType.string,
|
||||||
required: false,
|
required: false,
|
||||||
additionalValidators: [
|
additionalValidators: [
|
||||||
@ -72,7 +74,7 @@ class GitHub extends AppSource {
|
|||||||
try {
|
try {
|
||||||
RegExp(value);
|
RegExp(value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return 'Invalid regular expression';
|
return tr('invalidRegEx');
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -96,8 +98,8 @@ class GitHub extends AppSource {
|
|||||||
Future<String> getCredentialPrefixIfAny() async {
|
Future<String> getCredentialPrefixIfAny() async {
|
||||||
SettingsProvider settingsProvider = SettingsProvider();
|
SettingsProvider settingsProvider = SettingsProvider();
|
||||||
await settingsProvider.initializeSettings();
|
await settingsProvider.initializeSettings();
|
||||||
String? creds =
|
String? creds = settingsProvider
|
||||||
settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id);
|
.getSettingString(additionalSourceSpecificSettingFormItems[0].id);
|
||||||
return creds != null && creds.isNotEmpty ? '$creds@' : '';
|
return creds != null && creds.isNotEmpty ? '$creds@' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,12 +107,10 @@ class GitHub extends AppSource {
|
|||||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||||
'$standardUrl/releases';
|
'$standardUrl/releases';
|
||||||
|
|
||||||
@override
|
|
||||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<APKDetails> getLatestAPKDetails(
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
String standardUrl, List<String> additionalData) async {
|
String standardUrl, List<String> additionalData,
|
||||||
|
{bool trackOnly = false}) async {
|
||||||
var includePrereleases =
|
var includePrereleases =
|
||||||
additionalData.isNotEmpty && additionalData[0] == 'true';
|
additionalData.isNotEmpty && additionalData[0] == 'true';
|
||||||
var fallbackToOlderReleases =
|
var fallbackToOlderReleases =
|
||||||
@ -148,7 +148,7 @@ class GitHub extends AppSource {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var apkUrls = getReleaseAPKUrls(releases[i]);
|
var apkUrls = getReleaseAPKUrls(releases[i]);
|
||||||
if (apkUrls.isEmpty) {
|
if (apkUrls.isEmpty && !trackOnly) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
targetRelease = releases[i];
|
targetRelease = releases[i];
|
||||||
@ -158,23 +158,14 @@ class GitHub extends AppSource {
|
|||||||
if (targetRelease == null) {
|
if (targetRelease == null) {
|
||||||
throw NoReleasesError();
|
throw NoReleasesError();
|
||||||
}
|
}
|
||||||
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
|
|
||||||
throw NoAPKError();
|
|
||||||
}
|
|
||||||
String? version = targetRelease['tag_name'];
|
String? version = targetRelease['tag_name'];
|
||||||
if (version == null) {
|
if (version == null) {
|
||||||
throw NoVersionError();
|
throw NoVersionError();
|
||||||
}
|
}
|
||||||
return APKDetails(version, targetRelease['apkUrls']);
|
return APKDetails(version, targetRelease['apkUrls'] as List<String>);
|
||||||
} else {
|
} else {
|
||||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
rateLimitErrorCheck(res);
|
||||||
throw RateLimitError(
|
throw getObtainiumHttpError(res);
|
||||||
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
|
||||||
60000000)
|
|
||||||
.round());
|
|
||||||
}
|
|
||||||
|
|
||||||
throw NoReleasesError();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,20 +186,22 @@ class GitHub extends AppSource {
|
|||||||
urlsWithDescriptions.addAll({
|
urlsWithDescriptions.addAll({
|
||||||
e['html_url'] as String: e['description'] != null
|
e['html_url'] as String: e['description'] != null
|
||||||
? e['description'] as String
|
? e['description'] as String
|
||||||
: 'No description'
|
: tr('noDescription')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return urlsWithDescriptions;
|
return urlsWithDescriptions;
|
||||||
} else {
|
} else {
|
||||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
rateLimitErrorCheck(res);
|
||||||
throw RateLimitError(
|
throw getObtainiumHttpError(res);
|
||||||
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
}
|
||||||
60000000)
|
}
|
||||||
.round());
|
|
||||||
}
|
rateLimitErrorCheck(Response res) {
|
||||||
throw ObtainiumError(
|
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||||
res.reasonPhrase ?? 'Error ${res.statusCode.toString()}',
|
throw RateLimitError(
|
||||||
unexpected: true);
|
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||||
|
60000000)
|
||||||
|
.round());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,12 +23,10 @@ class GitLab extends AppSource {
|
|||||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||||
'$standardUrl/-/releases';
|
'$standardUrl/-/releases';
|
||||||
|
|
||||||
@override
|
|
||||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<APKDetails> getLatestAPKDetails(
|
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'));
|
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
|
||||||
if (res.statusCode == 200) {
|
if (res.statusCode == 200) {
|
||||||
var standardUri = Uri.parse(standardUrl);
|
var standardUri = Uri.parse(standardUrl);
|
||||||
@ -36,7 +34,7 @@ class GitLab extends AppSource {
|
|||||||
var entry = parsedHtml.querySelector('entry');
|
var entry = parsedHtml.querySelector('entry');
|
||||||
var entryContent =
|
var entryContent =
|
||||||
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
|
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
|
||||||
var apkUrlList = [
|
var apkUrls = [
|
||||||
...getLinksFromParsedHTML(
|
...getLinksFromParsedHTML(
|
||||||
entryContent,
|
entryContent,
|
||||||
RegExp(
|
RegExp(
|
||||||
@ -51,9 +49,6 @@ class GitLab extends AppSource {
|
|||||||
.where((element) => Uri.parse(element).host != '')
|
.where((element) => Uri.parse(element).host != '')
|
||||||
.toList()
|
.toList()
|
||||||
];
|
];
|
||||||
if (apkUrlList.isEmpty) {
|
|
||||||
throw NoAPKError();
|
|
||||||
}
|
|
||||||
|
|
||||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||||
var version =
|
var version =
|
||||||
@ -61,7 +56,7 @@ class GitLab extends AppSource {
|
|||||||
if (version == null) {
|
if (version == null) {
|
||||||
throw NoVersionError();
|
throw NoVersionError();
|
||||||
}
|
}
|
||||||
return APKDetails(version, apkUrlList);
|
return APKDetails(version, apkUrls);
|
||||||
} else {
|
} else {
|
||||||
throw NoReleasesError();
|
throw NoReleasesError();
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import 'package:html/parser.dart';
|
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/app_sources/fdroid.dart';
|
||||||
import 'package:obtainium/custom_errors.dart';
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
@ -22,41 +22,19 @@ class IzzyOnDroid extends AppSource {
|
|||||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
String? tryInferringAppId(String standardUrl) {
|
||||||
|
return FDroid().tryInferringAppId(standardUrl);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<APKDetails> getLatestAPKDetails(
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
String standardUrl, List<String> additionalData) async {
|
String standardUrl, List<String> additionalData,
|
||||||
Response res = await get(Uri.parse(standardUrl));
|
{bool trackOnly = false}) async {
|
||||||
if (res.statusCode == 200) {
|
String? appId = tryInferringAppId(standardUrl);
|
||||||
var parsedHtml = parse(res.body);
|
return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||||
var multipleVersionApkUrls = parsedHtml
|
await get(
|
||||||
.querySelectorAll('a')
|
Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')),
|
||||||
.where((element) =>
|
'https://android.izzysoft.de/frepo/$appId');
|
||||||
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
|
|
||||||
false)
|
|
||||||
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
|
|
||||||
.toList();
|
|
||||||
if (multipleVersionApkUrls.isEmpty) {
|
|
||||||
throw NoAPKError();
|
|
||||||
}
|
|
||||||
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 NoVersionError();
|
|
||||||
}
|
|
||||||
return APKDetails(version, [multipleVersionApkUrls[0]]);
|
|
||||||
} else {
|
|
||||||
throw NoReleasesError();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -22,12 +22,10 @@ class Mullvad extends AppSource {
|
|||||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||||
'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md';
|
'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md';
|
||||||
|
|
||||||
@override
|
|
||||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<APKDetails> getLatestAPKDetails(
|
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'));
|
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
|
||||||
if (res.statusCode == 200) {
|
if (res.statusCode == 200) {
|
||||||
var version = parse(res.body)
|
var version = parse(res.body)
|
||||||
|
@ -16,25 +16,21 @@ class Signal extends AppSource {
|
|||||||
@override
|
@override
|
||||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||||
|
|
||||||
@override
|
|
||||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<APKDetails> getLatestAPKDetails(
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
String standardUrl, List<String> additionalData) async {
|
String standardUrl, List<String> additionalData,
|
||||||
|
{bool trackOnly = false}) async {
|
||||||
Response res =
|
Response res =
|
||||||
await get(Uri.parse('https://updates.$host/android/latest.json'));
|
await get(Uri.parse('https://updates.$host/android/latest.json'));
|
||||||
if (res.statusCode == 200) {
|
if (res.statusCode == 200) {
|
||||||
var json = jsonDecode(res.body);
|
var json = jsonDecode(res.body);
|
||||||
String? apkUrl = json['url'];
|
String? apkUrl = json['url'];
|
||||||
if (apkUrl == null) {
|
List<String> apkUrls = apkUrl == null ? [] : [apkUrl];
|
||||||
throw NoAPKError();
|
|
||||||
}
|
|
||||||
String? version = json['versionName'];
|
String? version = json['versionName'];
|
||||||
if (version == null) {
|
if (version == null) {
|
||||||
throw NoVersionError();
|
throw NoVersionError();
|
||||||
}
|
}
|
||||||
return APKDetails(version, [apkUrl]);
|
return APKDetails(version, apkUrls);
|
||||||
} else {
|
} else {
|
||||||
throw NoReleasesError();
|
throw NoReleasesError();
|
||||||
}
|
}
|
||||||
|
@ -21,12 +21,10 @@ class SourceForge extends AppSource {
|
|||||||
@override
|
@override
|
||||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||||
|
|
||||||
@override
|
|
||||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<APKDetails> getLatestAPKDetails(
|
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=/'));
|
Response res = await get(Uri.parse('$standardUrl/rss?path=/'));
|
||||||
if (res.statusCode == 200) {
|
if (res.statusCode == 200) {
|
||||||
var parsedHtml = parse(res.body);
|
var parsedHtml = parse(res.body);
|
||||||
@ -52,9 +50,6 @@ class SourceForge extends AppSource {
|
|||||||
apkUrlListAllReleases // This can be used skipped for fallback support later
|
apkUrlListAllReleases // This can be used skipped for fallback support later
|
||||||
.where((element) => getVersion(element) == version)
|
.where((element) => getVersion(element) == version)
|
||||||
.toList();
|
.toList();
|
||||||
if (apkUrlList.isEmpty) {
|
|
||||||
throw NoAPKError();
|
|
||||||
}
|
|
||||||
return APKDetails(version, apkUrlList);
|
return APKDetails(version, apkUrlList);
|
||||||
} else {
|
} else {
|
||||||
throw NoReleasesError();
|
throw NoReleasesError();
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
enum FormItemType { string, bool }
|
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 {
|
class GeneratedFormItem {
|
||||||
|
late String key;
|
||||||
late String label;
|
late String label;
|
||||||
late FormItemType type;
|
late FormItemType type;
|
||||||
late bool required;
|
late bool required;
|
||||||
@ -13,6 +16,7 @@ class GeneratedFormItem {
|
|||||||
late String id;
|
late String id;
|
||||||
late List<Widget> belowWidgets;
|
late List<Widget> belowWidgets;
|
||||||
late String? hint;
|
late String? hint;
|
||||||
|
late List<String>? opts;
|
||||||
|
|
||||||
GeneratedFormItem(
|
GeneratedFormItem(
|
||||||
{this.label = 'Input',
|
{this.label = 'Input',
|
||||||
@ -22,7 +26,9 @@ class GeneratedFormItem {
|
|||||||
this.additionalValidators = const [],
|
this.additionalValidators = const [],
|
||||||
this.id = 'input',
|
this.id = 'input',
|
||||||
this.belowWidgets = const [],
|
this.belowWidgets = const [],
|
||||||
this.hint});
|
this.hint,
|
||||||
|
this.opts,
|
||||||
|
this.key = 'default'});
|
||||||
}
|
}
|
||||||
|
|
||||||
class GeneratedForm extends StatefulWidget {
|
class GeneratedForm extends StatefulWidget {
|
||||||
@ -47,7 +53,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
|||||||
List<List<Widget>> rows = [];
|
List<List<Widget>> rows = [];
|
||||||
|
|
||||||
// If any value changes, call this to update the parent with value and validity
|
// If any value changes, call this to update the parent with value and validity
|
||||||
void someValueChanged() {
|
void someValueChanged({bool isBuilding = false}) {
|
||||||
List<String> returnValues = [];
|
List<String> returnValues = [];
|
||||||
var valid = true;
|
var valid = true;
|
||||||
for (int r = 0; r < values.length; r++) {
|
for (int r = 0; r < values.length; r++) {
|
||||||
@ -62,7 +68,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
widget.onValueChanges(returnValues, valid);
|
widget.onValueChanges(returnValues, valid, isBuilding);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -75,14 +81,16 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
|||||||
.map((row) => row.map((e) {
|
.map((row) => row.map((e) {
|
||||||
return j < widget.defaultValues.length
|
return j < widget.defaultValues.length
|
||||||
? widget.defaultValues[j++]
|
? widget.defaultValues[j++]
|
||||||
: '';
|
: e.opts != null
|
||||||
|
? e.opts!.first
|
||||||
|
: '';
|
||||||
}).toList())
|
}).toList())
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Dynamically create form inputs
|
// Dynamically create form inputs
|
||||||
formInputs = widget.items.asMap().entries.map((row) {
|
formInputs = widget.items.asMap().entries.map((row) {
|
||||||
return row.value.asMap().entries.map((e) {
|
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>();
|
final formFieldKey = GlobalKey<FormFieldState>();
|
||||||
return TextFormField(
|
return TextFormField(
|
||||||
key: formFieldKey,
|
key: formFieldKey,
|
||||||
@ -101,7 +109,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
|||||||
maxLines: e.value.max <= 1 ? 1 : e.value.max,
|
maxLines: e.value.max <= 1 ? 1 : e.value.max,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (e.value.required && (value == null || value.trim().isEmpty)) {
|
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) {
|
for (var validator in e.value.additionalValidators) {
|
||||||
String? result = validator(value);
|
String? result = validator(value);
|
||||||
@ -112,11 +120,29 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} else if (e.value.type == FormItemType.string &&
|
||||||
|
e.value.opts != null) {
|
||||||
|
if (e.value.opts!.isEmpty) {
|
||||||
|
return Text(tr('dropdownNoOptsError'));
|
||||||
|
}
|
||||||
|
return DropdownButtonFormField(
|
||||||
|
decoration: InputDecoration(labelText: tr('colour')),
|
||||||
|
value: values[row.key][e.key],
|
||||||
|
items: e.value.opts!
|
||||||
|
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
values[row.key][e.key] = value ?? e.value.opts!.first;
|
||||||
|
someValueChanged();
|
||||||
|
});
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
return Container(); // Some input types added in build
|
return Container(); // Some input types added in build
|
||||||
}
|
}
|
||||||
}).toList();
|
}).toList();
|
||||||
}).toList();
|
}).toList();
|
||||||
|
someValueChanged(isBuilding: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -186,3 +212,18 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? findGeneratedFormValueByKey(
|
||||||
|
List<GeneratedFormItem> items, List<String> values, String key) {
|
||||||
|
var foundIndex = -1;
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].key == key) {
|
||||||
|
foundIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundIndex >= 0 && foundIndex < values.length) {
|
||||||
|
return values[foundIndex];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/components/generated_form.dart';
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
@ -29,7 +30,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
values = widget.defaultValues;
|
values = widget.defaultValues;
|
||||||
valid = widget.initValid;
|
valid = widget.initValid || widget.items.isEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -46,11 +47,16 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
|||||||
),
|
),
|
||||||
GeneratedForm(
|
GeneratedForm(
|
||||||
items: widget.items,
|
items: widget.items,
|
||||||
onValueChanges: (values, valid) {
|
onValueChanges: (values, valid, isBuilding) {
|
||||||
setState(() {
|
if (isBuilding) {
|
||||||
this.values = values;
|
this.values = values;
|
||||||
this.valid = valid;
|
this.valid = valid;
|
||||||
});
|
} else {
|
||||||
|
setState(() {
|
||||||
|
this.values = values;
|
||||||
|
this.valid = valid;
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
defaultValues: widget.defaultValues)
|
defaultValues: widget.defaultValues)
|
||||||
]),
|
]),
|
||||||
@ -59,7 +65,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop(null);
|
Navigator.of(context).pop(null);
|
||||||
},
|
},
|
||||||
child: const Text('Cancel')),
|
child: Text(tr('cancel'))),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: !valid
|
onPressed: !valid
|
||||||
? null
|
? null
|
||||||
@ -69,7 +75,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
|||||||
Navigator.of(context).pop(values);
|
Navigator.of(context).pop(values);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Continue'))
|
child: Text(tr('continue')))
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:obtainium/providers/logs_provider.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class ObtainiumError {
|
class ObtainiumError {
|
||||||
late String message;
|
late String message;
|
||||||
@ -16,46 +19,46 @@ class RateLimitError {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'Too many requests (rate limited) - try again in $remainingMinutes minutes';
|
plural('tooManyRequestsTryAgainInMinutes', remainingMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
class InvalidURLError extends ObtainiumError {
|
class InvalidURLError extends ObtainiumError {
|
||||||
InvalidURLError(String sourceName) : super('Not a valid $sourceName App URL');
|
InvalidURLError(String sourceName)
|
||||||
|
: super(tr('invalidURLForSource', args: [sourceName]));
|
||||||
}
|
}
|
||||||
|
|
||||||
class NoReleasesError extends ObtainiumError {
|
class NoReleasesError extends ObtainiumError {
|
||||||
NoReleasesError() : super('Could not find a suitable release');
|
NoReleasesError() : super(tr('noReleaseFound'));
|
||||||
}
|
}
|
||||||
|
|
||||||
class NoAPKError extends ObtainiumError {
|
class NoAPKError extends ObtainiumError {
|
||||||
NoAPKError() : super('Could not find a suitable release');
|
NoAPKError() : super(tr('noReleaseFound'));
|
||||||
}
|
}
|
||||||
|
|
||||||
class NoVersionError extends ObtainiumError {
|
class NoVersionError extends ObtainiumError {
|
||||||
NoVersionError() : super('Could not determine release version');
|
NoVersionError() : super(tr('noVersionFound'));
|
||||||
}
|
}
|
||||||
|
|
||||||
class UnsupportedURLError extends ObtainiumError {
|
class UnsupportedURLError extends ObtainiumError {
|
||||||
UnsupportedURLError() : super('URL does not match a known source');
|
UnsupportedURLError() : super(tr('urlMatchesNoSource'));
|
||||||
}
|
}
|
||||||
|
|
||||||
class DowngradeError extends ObtainiumError {
|
class DowngradeError extends ObtainiumError {
|
||||||
DowngradeError() : super('Cannot install an older version of an App');
|
DowngradeError() : super(tr('cantInstallOlderVersion'));
|
||||||
}
|
}
|
||||||
|
|
||||||
class IDChangedError extends ObtainiumError {
|
class IDChangedError extends ObtainiumError {
|
||||||
IDChangedError()
|
IDChangedError() : super(tr('appIdMismatch'));
|
||||||
: super('Downloaded package ID does not match existing App ID');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class NotImplementedError extends ObtainiumError {
|
class NotImplementedError extends ObtainiumError {
|
||||||
NotImplementedError() : super('This class has not implemented this function');
|
NotImplementedError() : super(tr('functionNotImplemented'));
|
||||||
}
|
}
|
||||||
|
|
||||||
class MultiAppMultiError extends ObtainiumError {
|
class MultiAppMultiError extends ObtainiumError {
|
||||||
Map<String, List<String>> content = {};
|
Map<String, List<String>> content = {};
|
||||||
|
|
||||||
MultiAppMultiError() : super('Multiple Errors Placeholder', unexpected: true);
|
MultiAppMultiError() : super(tr('placeholder'), unexpected: true);
|
||||||
|
|
||||||
add(String appId, String string) {
|
add(String appId, String string) {
|
||||||
var tempIds = content.remove(string);
|
var tempIds = content.remove(string);
|
||||||
@ -75,6 +78,8 @@ class MultiAppMultiError extends ObtainiumError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showError(dynamic e, BuildContext context) {
|
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)) {
|
if (e is String || (e is ObtainiumError && !e.unexpected)) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(e.toString())),
|
SnackBar(content: Text(e.toString())),
|
||||||
@ -86,15 +91,15 @@ showError(dynamic e, BuildContext context) {
|
|||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: Text(e is MultiAppMultiError
|
title: Text(e is MultiAppMultiError
|
||||||
? 'Some Errors Occurred'
|
? tr('someErrors')
|
||||||
: 'Unexpected Error'),
|
: tr('unexpectedError')),
|
||||||
content: Text(e.toString()),
|
content: Text(e.toString()),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop(null);
|
Navigator.of(context).pop(null);
|
||||||
},
|
},
|
||||||
child: const Text('Ok')),
|
child: Text(tr('ok'))),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -103,7 +108,7 @@ showError(dynamic e, BuildContext context) {
|
|||||||
|
|
||||||
String list2FriendlyString(List<String> list) {
|
String list2FriendlyString(List<String> list) {
|
||||||
return list.length == 2
|
return list.length == 2
|
||||||
? '${list[0]} and ${list[1]}'
|
? '${list[0]} ${tr('and')} ${list[1]}'
|
||||||
: list
|
: list
|
||||||
.asMap()
|
.asMap()
|
||||||
.entries
|
.entries
|
||||||
|
@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:obtainium/custom_errors.dart';
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/pages/home.dart';
|
import 'package:obtainium/pages/home.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
|
import 'package:obtainium/providers/logs_provider.dart';
|
||||||
import 'package:obtainium/providers/notifications_provider.dart';
|
import 'package:obtainium/providers/notifications_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
@ -14,25 +15,62 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
|
import 'package: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.7.1';
|
const String currentVersion = '0.8.10';
|
||||||
const String currentReleaseTag =
|
const String currentReleaseTag =
|
||||||
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||||
|
|
||||||
const int bgUpdateCheckAlarmId = 666;
|
const int bgUpdateCheckAlarmId = 666;
|
||||||
|
|
||||||
|
const supportedLocales = [Locale('en')];
|
||||||
|
const fallbackLocale = Locale('en');
|
||||||
|
const localeDir = 'assets/translations';
|
||||||
|
|
||||||
|
Future<void> loadTranslations() async {
|
||||||
|
// See easy_localization/issues/210
|
||||||
|
await EasyLocalizationController.initEasyLocation();
|
||||||
|
final controller = EasyLocalizationController(
|
||||||
|
saveLocale: true,
|
||||||
|
fallbackLocale: fallbackLocale,
|
||||||
|
supportedLocales: supportedLocales,
|
||||||
|
assetLoader: const RootBundleAssetLoader(),
|
||||||
|
useOnlyLangCode: false,
|
||||||
|
useFallbackTranslations: true,
|
||||||
|
path: localeDir,
|
||||||
|
onLoadError: (FlutterError e) {
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await controller.loadTranslations();
|
||||||
|
Localization.load(controller.locale,
|
||||||
|
translations: controller.translations,
|
||||||
|
fallbackTranslations: controller.fallbackTranslations);
|
||||||
|
}
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
||||||
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
|
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await EasyLocalization.ensureInitialized();
|
||||||
|
|
||||||
|
await loadTranslations();
|
||||||
|
|
||||||
|
LogsProvider logs = LogsProvider();
|
||||||
|
logs.add(tr('startedBgUpdateTask'));
|
||||||
|
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
|
||||||
await AndroidAlarmManager.initialize();
|
await AndroidAlarmManager.initialize();
|
||||||
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
|
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
|
||||||
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
|
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
|
||||||
: null;
|
: null;
|
||||||
|
logs.add(tr('bgUpdateIgnoreAfterIs', args: [ignoreAfter.toString()]));
|
||||||
var notificationsProvider = NotificationsProvider();
|
var notificationsProvider = NotificationsProvider();
|
||||||
await notificationsProvider.notify(checkingUpdatesNotification);
|
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||||
try {
|
try {
|
||||||
var appsProvider = AppsProvider(forBGTask: true);
|
var appsProvider = AppsProvider();
|
||||||
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
|
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||||
await appsProvider.loadApps();
|
await appsProvider.loadApps();
|
||||||
List<String> existingUpdateIds =
|
List<String> existingUpdateIds =
|
||||||
@ -40,17 +78,18 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
|||||||
DateTime nextIgnoreAfter = DateTime.now();
|
DateTime nextIgnoreAfter = DateTime.now();
|
||||||
String? err;
|
String? err;
|
||||||
try {
|
try {
|
||||||
|
logs.add(tr('startedActualBGUpdateCheck'));
|
||||||
await appsProvider.checkUpdates(
|
await appsProvider.checkUpdates(
|
||||||
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
|
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is RateLimitError || e is SocketException) {
|
if (e is RateLimitError || e is SocketException) {
|
||||||
AndroidAlarmManager.oneShot(
|
var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15;
|
||||||
Duration(minutes: e is RateLimitError ? e.remainingMinutes : 15),
|
logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes,
|
||||||
Random().nextInt(pow(2, 31) as int),
|
args: [e.runtimeType.toString(), remainingMinutes.toString()]));
|
||||||
bgUpdateCheck,
|
AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes),
|
||||||
params: {
|
Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: {
|
||||||
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
|
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
err = e.toString();
|
err = e.toString();
|
||||||
}
|
}
|
||||||
@ -74,7 +113,8 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
|||||||
// silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()),
|
// silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()),
|
||||||
// cancelExisting: true);
|
// cancelExisting: true);
|
||||||
// }
|
// }
|
||||||
|
logs.add(
|
||||||
|
plural('bgCheckFoundUpdatesWillNotifyIfNeeded', newUpdates.length));
|
||||||
if (newUpdates.isNotEmpty) {
|
if (newUpdates.isNotEmpty) {
|
||||||
notificationsProvider.notify(UpdateNotification(newUpdates));
|
notificationsProvider.notify(UpdateNotification(newUpdates));
|
||||||
}
|
}
|
||||||
@ -85,12 +125,14 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
|||||||
notificationsProvider
|
notificationsProvider
|
||||||
.notify(ErrorCheckingUpdatesNotification(e.toString()));
|
.notify(ErrorCheckingUpdatesNotification(e.toString()));
|
||||||
} finally {
|
} finally {
|
||||||
|
logs.add(tr('bgUpdateTaskFinished'));
|
||||||
await notificationsProvider.cancel(checkingUpdatesNotification.id);
|
await notificationsProvider.cancel(checkingUpdatesNotification.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await EasyLocalization.ensureInitialized();
|
||||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
|
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
||||||
@ -102,9 +144,14 @@ void main() async {
|
|||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(create: (context) => AppsProvider()),
|
ChangeNotifierProvider(create: (context) => AppsProvider()),
|
||||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||||
Provider(create: (context) => NotificationsProvider())
|
Provider(create: (context) => NotificationsProvider()),
|
||||||
|
Provider(create: (context) => LogsProvider())
|
||||||
],
|
],
|
||||||
child: const Obtainium(),
|
child: EasyLocalization(
|
||||||
|
supportedLocales: supportedLocales,
|
||||||
|
path: localeDir,
|
||||||
|
fallbackLocale: fallbackLocale,
|
||||||
|
child: const Obtainium()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,12 +171,14 @@ class _ObtainiumState extends State<Obtainium> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
||||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||||
|
LogsProvider logs = context.read<LogsProvider>();
|
||||||
|
|
||||||
if (settingsProvider.prefs == null) {
|
if (settingsProvider.prefs == null) {
|
||||||
settingsProvider.initializeSettings();
|
settingsProvider.initializeSettings();
|
||||||
} else {
|
} else {
|
||||||
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
|
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
|
||||||
if (isFirstRun) {
|
if (isFirstRun) {
|
||||||
|
logs.add(tr('firstRun'));
|
||||||
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
||||||
Permission.notification.request();
|
Permission.notification.request();
|
||||||
appsProvider.saveApps([
|
appsProvider.saveApps([
|
||||||
@ -144,11 +193,16 @@ class _ObtainiumState extends State<Obtainium> {
|
|||||||
0,
|
0,
|
||||||
['true'],
|
['true'],
|
||||||
null,
|
null,
|
||||||
|
false,
|
||||||
false)
|
false)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
// Register the background update task according to the user's setting
|
// Register the background update task according to the user's setting
|
||||||
if (existingUpdateInterval != settingsProvider.updateInterval) {
|
if (existingUpdateInterval != settingsProvider.updateInterval) {
|
||||||
|
if (existingUpdateInterval != -1) {
|
||||||
|
logs.add(tr('settingUpdateCheckIntervalTo',
|
||||||
|
args: [settingsProvider.updateInterval.toString()]));
|
||||||
|
}
|
||||||
existingUpdateInterval = settingsProvider.updateInterval;
|
existingUpdateInterval = settingsProvider.updateInterval;
|
||||||
if (existingUpdateInterval == 0) {
|
if (existingUpdateInterval == 0) {
|
||||||
AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
|
AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
|
||||||
@ -180,6 +234,9 @@ class _ObtainiumState extends State<Obtainium> {
|
|||||||
}
|
}
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Obtainium',
|
title: 'Obtainium',
|
||||||
|
localizationsDelegates: context.localizationDelegates,
|
||||||
|
supportedLocales: context.supportedLocales,
|
||||||
|
locale: context.locale,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: settingsProvider.theme == ThemeSettings.dark
|
colorScheme: settingsProvider.theme == ThemeSettings.dark
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:obtainium/app_sources/github.dart';
|
import 'package:obtainium/app_sources/github.dart';
|
||||||
import 'package:obtainium/custom_errors.dart';
|
import 'package:obtainium/custom_errors.dart';
|
||||||
@ -7,10 +8,10 @@ import 'package:obtainium/providers/source_provider.dart';
|
|||||||
|
|
||||||
class GitHubStars implements MassAppUrlSource {
|
class GitHubStars implements MassAppUrlSource {
|
||||||
@override
|
@override
|
||||||
late String name = 'GitHub Starred Repos';
|
late String name = tr('githubStarredRepos');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
late List<String> requiredArgs = ['Username'];
|
late List<String> requiredArgs = [tr('uname')];
|
||||||
|
|
||||||
Future<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions(
|
Future<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions(
|
||||||
String username, int page) async {
|
String username, int page) async {
|
||||||
@ -22,26 +23,21 @@ class GitHubStars implements MassAppUrlSource {
|
|||||||
urlsWithDescriptions.addAll({
|
urlsWithDescriptions.addAll({
|
||||||
e['html_url'] as String: e['description'] != null
|
e['html_url'] as String: e['description'] != null
|
||||||
? e['description'] as String
|
? e['description'] as String
|
||||||
: 'No description'
|
: tr('noDescription')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return urlsWithDescriptions;
|
return urlsWithDescriptions;
|
||||||
} else {
|
} else {
|
||||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
var gh = GitHub();
|
||||||
throw RateLimitError(
|
gh.rateLimitErrorCheck(res);
|
||||||
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
throw getObtainiumHttpError(res);
|
||||||
60000000)
|
|
||||||
.round());
|
|
||||||
}
|
|
||||||
|
|
||||||
throw ObtainiumError('Unable to find user\'s starred repos');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args) async {
|
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args) async {
|
||||||
if (args.length != requiredArgs.length) {
|
if (args.length != requiredArgs.length) {
|
||||||
throw ObtainiumError('Wrong number of arguments provided');
|
throw ObtainiumError(tr('wrongArgNum'));
|
||||||
}
|
}
|
||||||
Map<String, String> urlsWithDescriptions = {};
|
Map<String, String> urlsWithDescriptions = {};
|
||||||
var page = 1;
|
var page = 1;
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/components/custom_app_bar.dart';
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
import 'package:obtainium/components/generated_form.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/custom_errors.dart';
|
||||||
import 'package:obtainium/pages/app.dart';
|
import 'package:obtainium/pages/app.dart';
|
||||||
|
import 'package:obtainium/pages/import_export.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
@ -21,17 +24,125 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
bool gettingAppInfo = false;
|
bool gettingAppInfo = false;
|
||||||
|
|
||||||
String userInput = '';
|
String userInput = '';
|
||||||
|
String searchQuery = '';
|
||||||
AppSource? pickedSource;
|
AppSource? pickedSource;
|
||||||
List<String> additionalData = [];
|
List<String> sourceSpecificAdditionalData = [];
|
||||||
bool validAdditionalData = true;
|
bool sourceSpecificDataIsValid = true;
|
||||||
|
List<String> otherAdditionalData = [];
|
||||||
|
bool otherAdditionalDataIsValid = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
SourceProvider sourceProvider = SourceProvider();
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
|
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||||
|
|
||||||
|
changeUserInput(String input, bool valid, bool isBuilding) {
|
||||||
|
userInput = input;
|
||||||
|
fn() {
|
||||||
|
var source = valid ? sourceProvider.getSource(userInput) : null;
|
||||||
|
if (pickedSource != source) {
|
||||||
|
pickedSource = source;
|
||||||
|
sourceSpecificAdditionalData =
|
||||||
|
source != null ? source.additionalSourceAppSpecificDefaults : [];
|
||||||
|
sourceSpecificDataIsValid = source != null
|
||||||
|
? sourceProvider.ifSourceAppsRequireAdditionalData(source)
|
||||||
|
: true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBuilding) {
|
||||||
|
fn();
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
fn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addApp({bool resetUserInputAfter = false}) async {
|
||||||
|
setState(() {
|
||||||
|
gettingAppInfo = true;
|
||||||
|
});
|
||||||
|
var settingsProvider = context.read<SettingsProvider>();
|
||||||
|
() async {
|
||||||
|
var userPickedTrackOnly = findGeneratedFormValueByKey(
|
||||||
|
pickedSource!.additionalAppSpecificSourceAgnosticFormItems,
|
||||||
|
otherAdditionalData,
|
||||||
|
'trackOnlyFormItemKey') ==
|
||||||
|
'true';
|
||||||
|
var cont = true;
|
||||||
|
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: tr('xIsTrackOnly', args: [
|
||||||
|
pickedSource!.enforceTrackOnly
|
||||||
|
? tr('source')
|
||||||
|
: tr('app')
|
||||||
|
]),
|
||||||
|
items: const [],
|
||||||
|
defaultValues: const [],
|
||||||
|
message:
|
||||||
|
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
|
||||||
|
);
|
||||||
|
}) ==
|
||||||
|
null) {
|
||||||
|
cont = false;
|
||||||
|
}
|
||||||
|
if (cont) {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
|
||||||
|
App app = await sourceProvider.getApp(
|
||||||
|
pickedSource!, userInput, sourceSpecificAdditionalData,
|
||||||
|
trackOnly: trackOnly);
|
||||||
|
if (!trackOnly) {
|
||||||
|
await settingsProvider.getInstallPermission();
|
||||||
|
}
|
||||||
|
// Only download the APK here if you need to for the package ID
|
||||||
|
if (sourceProvider.isTempId(app.id) && !app.trackOnly) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
var apkUrl = await appsProvider.confirmApkUrl(app, context);
|
||||||
|
if (apkUrl == null) {
|
||||||
|
throw ObtainiumError(tr('cancelled'));
|
||||||
|
}
|
||||||
|
app.preferredApkIndex = app.apkUrls.indexOf(apkUrl);
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
var downloadedApk = await appsProvider.downloadApp(app, context);
|
||||||
|
app.id = downloadedApk.appId;
|
||||||
|
}
|
||||||
|
if (appsProvider.apps.containsKey(app.id)) {
|
||||||
|
throw ObtainiumError(tr('appAlreadyAdded'));
|
||||||
|
}
|
||||||
|
if (app.trackOnly) {
|
||||||
|
app.installedVersion = app.latestVersion;
|
||||||
|
}
|
||||||
|
await appsProvider.saveApps([app]);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
.then((app) {
|
||||||
|
if (app != null) {
|
||||||
|
Navigator.push(context,
|
||||||
|
MaterialPageRoute(builder: (context) => AppPage(appId: app.id)));
|
||||||
|
}
|
||||||
|
}).catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
gettingAppInfo = false;
|
||||||
|
if (resetUserInputAfter) {
|
||||||
|
changeUserInput('', false, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: CustomScrollView(slivers: <Widget>[
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
const CustomAppBar(title: 'Add App'),
|
CustomAppBar(title: tr('addApp')),
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@ -45,7 +156,7 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
items: [
|
items: [
|
||||||
[
|
[
|
||||||
GeneratedFormItem(
|
GeneratedFormItem(
|
||||||
label: 'App Source Url',
|
label: tr('appSourceURL'),
|
||||||
additionalValidators: [
|
additionalValidators: [
|
||||||
(value) {
|
(value) {
|
||||||
try {
|
try {
|
||||||
@ -59,31 +170,16 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
? e
|
? e
|
||||||
: e is ObtainiumError
|
: e is ObtainiumError
|
||||||
? e.toString()
|
? e.toString()
|
||||||
: 'Error';
|
: tr('error');
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
onValueChanges: (values, valid) {
|
onValueChanges: (values, valid, isBuilding) {
|
||||||
setState(() {
|
changeUserInput(
|
||||||
userInput = values[0];
|
values[0], valid, isBuilding);
|
||||||
var source = valid
|
|
||||||
? sourceProvider.getSource(userInput)
|
|
||||||
: null;
|
|
||||||
if (pickedSource != source) {
|
|
||||||
pickedSource = source;
|
|
||||||
additionalData = source != null
|
|
||||||
? source.additionalDataDefaults
|
|
||||||
: [];
|
|
||||||
validAdditionalData = source != null
|
|
||||||
? sourceProvider
|
|
||||||
.ifSourceAppsRequireAdditionalData(
|
|
||||||
source)
|
|
||||||
: true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
defaultValues: const [])),
|
defaultValues: const [])),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
@ -94,68 +190,115 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
: ElevatedButton(
|
: ElevatedButton(
|
||||||
onPressed: gettingAppInfo ||
|
onPressed: gettingAppInfo ||
|
||||||
pickedSource == null ||
|
pickedSource == null ||
|
||||||
(pickedSource!.additionalDataFormItems
|
(pickedSource!
|
||||||
|
.additionalSourceAppSpecificFormItems
|
||||||
.isNotEmpty &&
|
.isNotEmpty &&
|
||||||
!validAdditionalData)
|
!sourceSpecificDataIsValid) ||
|
||||||
|
(pickedSource!
|
||||||
|
.additionalAppSpecificSourceAgnosticDefaults
|
||||||
|
.isNotEmpty &&
|
||||||
|
!otherAdditionalDataIsValid)
|
||||||
? null
|
? null
|
||||||
: () async {
|
: addApp,
|
||||||
setState(() {
|
child: Text(tr('add')))
|
||||||
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
|
|
||||||
.confirmApkUrl(app, context);
|
|
||||||
if (apkUrl == null) {
|
|
||||||
throw ObtainiumError('Cancelled');
|
|
||||||
}
|
|
||||||
app.preferredApkIndex =
|
|
||||||
app.apkUrls.indexOf(apkUrl);
|
|
||||||
var downloadedApk =
|
|
||||||
await appsProvider
|
|
||||||
.downloadApp(app);
|
|
||||||
app.id = downloadedApk.appId;
|
|
||||||
if (appsProvider.apps
|
|
||||||
.containsKey(app.id)) {
|
|
||||||
throw ObtainiumError(
|
|
||||||
'App already added');
|
|
||||||
}
|
|
||||||
await appsProvider.saveApps([app]);
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}()
|
|
||||||
.then((app) {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) =>
|
|
||||||
AppPage(
|
|
||||||
appId: app.id)));
|
|
||||||
}).catchError((e) {
|
|
||||||
showError(e, context);
|
|
||||||
}).whenComplete(() {
|
|
||||||
setState(() {
|
|
||||||
gettingAppInfo = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: const Text('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 &&
|
if (pickedSource != null &&
|
||||||
pickedSource!.additionalDataDefaults.isNotEmpty)
|
(pickedSource!.additionalSourceAppSpecificDefaults
|
||||||
|
.isNotEmpty ||
|
||||||
|
pickedSource!
|
||||||
|
.additionalAppSpecificSourceAgnosticFormItems
|
||||||
|
.where((e) => pickedSource!.enforceTrackOnly
|
||||||
|
? e.key != 'trackOnlyFormItemKey'
|
||||||
|
: true)
|
||||||
|
.map((e) => [e])
|
||||||
|
.isNotEmpty))
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@ -163,7 +306,10 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
height: 64,
|
height: 64,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Additional Options for ${pickedSource?.runtimeType}',
|
tr('additionalOptsFor', args: [
|
||||||
|
pickedSource?.runtimeType.toString() ??
|
||||||
|
tr('source')
|
||||||
|
]),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color:
|
color:
|
||||||
Theme.of(context).colorScheme.primary)),
|
Theme.of(context).colorScheme.primary)),
|
||||||
@ -171,22 +317,51 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
height: 16,
|
height: 16,
|
||||||
),
|
),
|
||||||
if (pickedSource!
|
if (pickedSource!
|
||||||
.additionalDataFormItems.isNotEmpty)
|
.additionalSourceAppSpecificFormItems
|
||||||
|
.isNotEmpty)
|
||||||
GeneratedForm(
|
GeneratedForm(
|
||||||
items: pickedSource!.additionalDataFormItems,
|
items: pickedSource!
|
||||||
onValueChanges: (values, valid) {
|
.additionalSourceAppSpecificFormItems,
|
||||||
setState(() {
|
onValueChanges: (values, valid, isBuilding) {
|
||||||
additionalData = values;
|
if (isBuilding) {
|
||||||
validAdditionalData = valid;
|
sourceSpecificAdditionalData = values;
|
||||||
});
|
sourceSpecificDataIsValid = valid;
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
sourceSpecificAdditionalData = values;
|
||||||
|
sourceSpecificDataIsValid = valid;
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
defaultValues:
|
defaultValues: pickedSource!
|
||||||
pickedSource!.additionalDataDefaults),
|
.additionalSourceAppSpecificDefaults),
|
||||||
if (pickedSource!
|
if (pickedSource!
|
||||||
.additionalDataFormItems.isNotEmpty)
|
.additionalAppSpecificSourceAgnosticDefaults
|
||||||
|
.isNotEmpty)
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
height: 8,
|
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
|
else
|
||||||
@ -195,22 +370,24 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const SizedBox(
|
||||||
'Supported Sources:',
|
height: 48,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
tr('supportedSourcesBelow'),
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
height: 8,
|
height: 8,
|
||||||
),
|
),
|
||||||
...sourceProvider
|
...sourceProvider.sources
|
||||||
.getSourceHosts()
|
|
||||||
.map((e) => GestureDetector(
|
.map((e) => GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
launchUrlString('https://$e',
|
launchUrlString('https://${e.host}',
|
||||||
mode:
|
mode:
|
||||||
LaunchMode.externalApplication);
|
LaunchMode.externalApplication);
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
e,
|
'${e.runtimeType.toString()}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
decoration:
|
decoration:
|
||||||
TextDecoration.underline,
|
TextDecoration.underline,
|
||||||
@ -218,6 +395,9 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
)))
|
)))
|
||||||
.toList()
|
.toList()
|
||||||
])),
|
])),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
])),
|
])),
|
||||||
)
|
)
|
||||||
]));
|
]));
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/components/generated_form_modal.dart';
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
@ -106,7 +107,7 @@ class _AppPageState extends State<AppPage> {
|
|||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
|
'Installed Version: ${app?.app.installedVersion ?? 'None'}${app?.app.trackOnly == true ? ' (Estimate)\n\nApp is Track-Only' : ''}',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
@ -140,6 +141,7 @@ class _AppPageState extends State<AppPage> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
if (app?.app.installedVersion != null &&
|
if (app?.app.installedVersion != null &&
|
||||||
|
app?.app.trackOnly == false &&
|
||||||
app?.app.installedVersion != app?.app.latestVersion)
|
app?.app.installedVersion != app?.app.latestVersion)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: app?.downloadProgress != null
|
onPressed: app?.downloadProgress != null
|
||||||
@ -149,8 +151,15 @@ class _AppPageState extends State<AppPage> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text(
|
title: Text(tr(
|
||||||
'App Already up to Date?'),
|
'alreadyUpToDateQuestion')),
|
||||||
|
content: Text(
|
||||||
|
tr('onlyWorksWithNonEVDApps'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight:
|
||||||
|
FontWeight.bold,
|
||||||
|
fontStyle:
|
||||||
|
FontStyle.italic)),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@ -183,7 +192,8 @@ class _AppPageState extends State<AppPage> {
|
|||||||
tooltip: 'Mark as Updated',
|
tooltip: 'Mark as Updated',
|
||||||
icon: const Icon(Icons.done)),
|
icon: const Icon(Icons.done)),
|
||||||
if (source != null &&
|
if (source != null &&
|
||||||
source.additionalDataFormItems.isNotEmpty)
|
source.additionalSourceAppSpecificFormItems
|
||||||
|
.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: app?.downloadProgress != null
|
onPressed: app?.downloadProgress != null
|
||||||
? null
|
? null
|
||||||
@ -194,11 +204,11 @@ class _AppPageState extends State<AppPage> {
|
|||||||
return GeneratedFormModal(
|
return GeneratedFormModal(
|
||||||
title: 'Additional Options',
|
title: 'Additional Options',
|
||||||
items: source
|
items: source
|
||||||
.additionalDataFormItems,
|
.additionalSourceAppSpecificFormItems,
|
||||||
defaultValues: app != null
|
defaultValues: app != null
|
||||||
? app.app.additionalData
|
? app.app.additionalData
|
||||||
: source
|
: source
|
||||||
.additionalDataDefaults);
|
.additionalSourceAppSpecificDefaults);
|
||||||
}).then((values) {
|
}).then((values) {
|
||||||
if (app != null && values != null) {
|
if (app != null && values != null) {
|
||||||
var changedApp = app.app;
|
var changedApp = app.app;
|
||||||
@ -221,21 +231,33 @@ class _AppPageState extends State<AppPage> {
|
|||||||
!appsProvider.areDownloadsRunning()
|
!appsProvider.areDownloadsRunning()
|
||||||
? () {
|
? () {
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback.heavyImpact();
|
||||||
appsProvider
|
() async {
|
||||||
.downloadAndInstallLatestApps(
|
if (app?.app.trackOnly != true) {
|
||||||
[app!.app.id],
|
await settingsProvider
|
||||||
context).then((res) {
|
.getInstallPermission();
|
||||||
if (res.isNotEmpty && mounted) {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
.then((value) {
|
||||||
|
appsProvider
|
||||||
|
.downloadAndInstallLatestApps(
|
||||||
|
[app!.app.id],
|
||||||
|
context).then((res) {
|
||||||
|
if (res.isNotEmpty && mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
});
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
showError(e, context);
|
showError(e, context);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
child: Text(app?.app.installedVersion == null
|
child: Text(app?.app.installedVersion == null
|
||||||
? 'Install'
|
? app?.app.trackOnly == false
|
||||||
: 'Update'))),
|
? 'Install'
|
||||||
|
: 'Mark Installed'
|
||||||
|
: app?.app.trackOnly == false
|
||||||
|
? 'Update'
|
||||||
|
: 'Mark Updated'))),
|
||||||
const SizedBox(width: 16.0),
|
const SizedBox(width: 16.0),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: app?.downloadProgress != null
|
onPressed: app?.downloadProgress != null
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/components/custom_app_bar.dart';
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
@ -135,6 +136,22 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
: selectedApps.map((e) => e.id).contains(element))
|
: selectedApps.map((e) => e.id).contains(element))
|
||||||
.toList();
|
.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) {
|
if (settingsProvider.pinUpdates) {
|
||||||
var temp = [];
|
var temp = [];
|
||||||
sortedApps = sortedApps.where((sa) {
|
sortedApps = sortedApps.where((sa) {
|
||||||
@ -175,7 +192,7 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: CustomScrollView(slivers: <Widget>[
|
child: CustomScrollView(slivers: <Widget>[
|
||||||
const CustomAppBar(title: 'Apps'),
|
CustomAppBar(title: tr('appsString')),
|
||||||
if (appsProvider.loadingApps || sortedApps.isEmpty)
|
if (appsProvider.loadingApps || sortedApps.isEmpty)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
@ -183,8 +200,8 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
? const CircularProgressIndicator()
|
? const CircularProgressIndicator()
|
||||||
: Text(
|
: Text(
|
||||||
appsProvider.apps.isEmpty
|
appsProvider.apps.isEmpty
|
||||||
? 'No Apps'
|
? tr('noApps')
|
||||||
: 'No Apps for Filter',
|
: tr('noAppsForFilter'),
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
))),
|
))),
|
||||||
@ -202,6 +219,9 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(BuildContext context, int index) {
|
(BuildContext context, int index) {
|
||||||
|
String? changesUrl = SourceProvider()
|
||||||
|
.getSource(sortedApps[index].app.url)
|
||||||
|
.changeLogPageFromStandardUrl(sortedApps[index].app.url);
|
||||||
return ListTile(
|
return ListTile(
|
||||||
tileColor: sortedApps[index].app.pinned
|
tileColor: sortedApps[index].app.pinned
|
||||||
? Colors.grey.withOpacity(0.1)
|
? Colors.grey.withOpacity(0.1)
|
||||||
@ -228,59 +248,57 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
? FontWeight.bold
|
? FontWeight.bold
|
||||||
: FontWeight.normal),
|
: FontWeight.normal),
|
||||||
),
|
),
|
||||||
subtitle: Text('By ${sortedApps[index].app.author}',
|
subtitle: Text(tr('byX', args: [sortedApps[index].app.author]),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: sortedApps[index].app.pinned
|
fontWeight: sortedApps[index].app.pinned
|
||||||
? FontWeight.bold
|
? FontWeight.bold
|
||||||
: FontWeight.normal)),
|
: FontWeight.normal)),
|
||||||
trailing: sortedApps[index].downloadProgress != null
|
trailing: SingleChildScrollView(
|
||||||
? Text(
|
reverse: true,
|
||||||
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
|
child: sortedApps[index].downloadProgress != null
|
||||||
: (sortedApps[index].app.installedVersion != null &&
|
? Text(tr('percentProgress', args: [
|
||||||
sortedApps[index].app.installedVersion !=
|
sortedApps[index]
|
||||||
sortedApps[index].app.latestVersion
|
.downloadProgress
|
||||||
? Column(
|
?.toInt()
|
||||||
|
.toString() ??
|
||||||
|
'100'
|
||||||
|
]))
|
||||||
|
: (Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Text(appsProvider.areDownloadsRunning()
|
SizedBox(
|
||||||
? 'Please Wait...'
|
width: 100,
|
||||||
: 'Update Available'),
|
child: Text(
|
||||||
SourceProvider()
|
'${sortedApps[index].app.installedVersion ?? tr('notInstalled')}${sortedApps[index].app.trackOnly == true ? ' ${tr('estimateInBrackets')}' : ''}',
|
||||||
.getSource(sortedApps[index].app.url)
|
overflow: TextOverflow.fade,
|
||||||
.changeLogPageFromStandardUrl(
|
textAlign: TextAlign.end,
|
||||||
sortedApps[index].app.url) ==
|
)),
|
||||||
null
|
sortedApps[index].app.installedVersion != null &&
|
||||||
? const SizedBox()
|
sortedApps[index].app.installedVersion !=
|
||||||
: GestureDetector(
|
sortedApps[index].app.latestVersion
|
||||||
onTap: () {
|
? GestureDetector(
|
||||||
launchUrlString(
|
onTap: changesUrl == null
|
||||||
SourceProvider()
|
? null
|
||||||
.getSource(
|
: () {
|
||||||
sortedApps[index].app.url)
|
launchUrlString(changesUrl,
|
||||||
.changeLogPageFromStandardUrl(
|
mode: LaunchMode
|
||||||
sortedApps[index].app.url)!,
|
.externalApplication);
|
||||||
mode:
|
},
|
||||||
LaunchMode.externalApplication);
|
child: appsProvider.areDownloadsRunning()
|
||||||
},
|
? Text(tr('pleaseWait'))
|
||||||
child: const Text(
|
: Text(
|
||||||
'See Changes',
|
'${tr('updateAvailable')}${sortedApps[index].app.trackOnly ? ' ${tr('estimateInBracketsShort')}' : ''}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
decoration:
|
decoration: changesUrl == null
|
||||||
TextDecoration.underline),
|
? 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: () {
|
onTap: () {
|
||||||
if (selectedApps.isNotEmpty) {
|
if (selectedApps.isNotEmpty) {
|
||||||
toggleAppSelected(sortedApps[index].app);
|
toggleAppSelected(sortedApps[index].app);
|
||||||
@ -312,8 +330,8 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
),
|
),
|
||||||
tooltip: selectedApps.isEmpty
|
tooltip: selectedApps.isEmpty
|
||||||
? 'Select All'
|
? tr('selectAll')
|
||||||
: 'Deselect ${selectedApps.length.toString()}'),
|
: tr('deselectN', args: [selectedApps.length.toString()])),
|
||||||
const VerticalDivider(),
|
const VerticalDivider(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
@ -328,12 +346,15 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
return GeneratedFormModal(
|
return GeneratedFormModal(
|
||||||
title: 'Remove Selected Apps?',
|
title: tr('removeSelectedAppsQuestion'),
|
||||||
items: const [],
|
items: const [],
|
||||||
defaultValues: const [],
|
defaultValues: const [],
|
||||||
initValid: true,
|
initValid: true,
|
||||||
message:
|
message: tr(
|
||||||
'${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedApps.length == 1 ? 'it' : 'them'} manually.',
|
'xWillBeRemovedButRemainInstalled',
|
||||||
|
args: [
|
||||||
|
plural('apps', selectedApps.length)
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
}).then((values) {
|
}).then((values) {
|
||||||
if (values != null) {
|
if (values != null) {
|
||||||
@ -342,56 +363,90 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
tooltip: 'Remove Selected Apps',
|
tooltip: tr('removeSelectedApps'),
|
||||||
icon: const Icon(Icons.delete_outline_outlined),
|
icon: const Icon(Icons.delete_outline_outlined),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
onPressed: appsProvider.areDownloadsRunning() ||
|
onPressed: appsProvider.areDownloadsRunning() ||
|
||||||
(existingUpdateIdsAllOrSelected.isEmpty &&
|
(existingUpdateIdsAllOrSelected.isEmpty &&
|
||||||
newInstallIdsAllOrSelected.isEmpty)
|
newInstallIdsAllOrSelected.isEmpty &&
|
||||||
|
trackOnlyUpdateIdsAllOrSelected.isEmpty)
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback.heavyImpact();
|
||||||
List<List<GeneratedFormItem>> formInputs = [];
|
List<GeneratedFormItem> formInputs = [];
|
||||||
if (existingUpdateIdsAllOrSelected.isNotEmpty &&
|
List<String> defaultValues = [];
|
||||||
newInstallIdsAllOrSelected.isNotEmpty) {
|
if (existingUpdateIdsAllOrSelected.isNotEmpty) {
|
||||||
formInputs.add([
|
formInputs.add(GeneratedFormItem(
|
||||||
GeneratedFormItem(
|
label: tr('updateX', args: [
|
||||||
label:
|
plural('apps',
|
||||||
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
|
existingUpdateIdsAllOrSelected.length)
|
||||||
type: FormItemType.bool)
|
]),
|
||||||
]);
|
type: FormItemType.bool,
|
||||||
formInputs.add([
|
key: 'updates'));
|
||||||
GeneratedFormItem(
|
defaultValues.add('true');
|
||||||
label:
|
}
|
||||||
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}',
|
if (newInstallIdsAllOrSelected.isNotEmpty) {
|
||||||
type: FormItemType.bool)
|
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>?>(
|
showDialog<List<String>?>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
|
var totalApps = existingUpdateIdsAllOrSelected
|
||||||
|
.length +
|
||||||
|
newInstallIdsAllOrSelected.length +
|
||||||
|
trackOnlyUpdateIdsAllOrSelected.length;
|
||||||
return GeneratedFormModal(
|
return GeneratedFormModal(
|
||||||
title:
|
title: tr('changeX',
|
||||||
'Install${selectedApps.isEmpty ? ' ' : ' Selected '}Apps?',
|
args: [plural('apps', totalApps)]),
|
||||||
message:
|
items: formInputs.map((e) => [e]).toList(),
|
||||||
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
|
defaultValues: defaultValues,
|
||||||
items: formInputs,
|
|
||||||
defaultValues: [
|
|
||||||
'true',
|
|
||||||
existingUpdateIdsAllOrSelected.isEmpty
|
|
||||||
? 'true'
|
|
||||||
: ''
|
|
||||||
],
|
|
||||||
initValid: true,
|
initValid: true,
|
||||||
);
|
);
|
||||||
}).then((values) {
|
}).then((values) {
|
||||||
if (values != null) {
|
if (values != null) {
|
||||||
bool shouldInstallUpdates = values[0] == 'true';
|
if (values.isEmpty) {
|
||||||
bool shouldInstallNew = values[1] == 'true';
|
values = defaultValues;
|
||||||
settingsProvider
|
}
|
||||||
.getInstallPermission()
|
bool shouldInstallUpdates =
|
||||||
|
findGeneratedFormValueByKey(
|
||||||
|
formInputs, values, 'updates') ==
|
||||||
|
'true';
|
||||||
|
bool shouldInstallNew =
|
||||||
|
findGeneratedFormValueByKey(
|
||||||
|
formInputs, values, 'installs') ==
|
||||||
|
'true';
|
||||||
|
bool shouldMarkTrackOnlies =
|
||||||
|
findGeneratedFormValueByKey(formInputs,
|
||||||
|
values, 'trackonlies') ==
|
||||||
|
'true';
|
||||||
|
(() async {
|
||||||
|
if (shouldInstallNew ||
|
||||||
|
shouldInstallUpdates) {
|
||||||
|
await settingsProvider
|
||||||
|
.getInstallPermission();
|
||||||
|
}
|
||||||
|
})()
|
||||||
.then((_) {
|
.then((_) {
|
||||||
List<String> toInstall = [];
|
List<String> toInstall = [];
|
||||||
if (shouldInstallUpdates) {
|
if (shouldInstallUpdates) {
|
||||||
@ -402,6 +457,10 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
toInstall
|
toInstall
|
||||||
.addAll(newInstallIdsAllOrSelected);
|
.addAll(newInstallIdsAllOrSelected);
|
||||||
}
|
}
|
||||||
|
if (shouldMarkTrackOnlies) {
|
||||||
|
toInstall.addAll(
|
||||||
|
trackOnlyUpdateIdsAllOrSelected);
|
||||||
|
}
|
||||||
appsProvider
|
appsProvider
|
||||||
.downloadAndInstallLatestApps(
|
.downloadAndInstallLatestApps(
|
||||||
toInstall, context)
|
toInstall, context)
|
||||||
@ -412,8 +471,9 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
tooltip:
|
tooltip: selectedApps.isEmpty
|
||||||
'Install/Update${selectedApps.isEmpty ? ' ' : ' Selected '}Apps',
|
? tr('installUpdateApps')
|
||||||
|
: tr('installUpdateSelectedApps'),
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.file_download_outlined,
|
Icons.file_download_outlined,
|
||||||
)),
|
)),
|
||||||
@ -445,11 +505,22 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
(BuildContext
|
(BuildContext
|
||||||
ctx) {
|
ctx) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(
|
title: Text(tr(
|
||||||
'Mark ${selectedApps.length} Selected Apps as Updated?'),
|
'markXSelectedAppsAsUpdated',
|
||||||
content:
|
args: [
|
||||||
const Text(
|
selectedApps
|
||||||
'Only applies to installed but out of date Apps.'),
|
.length
|
||||||
|
.toString()
|
||||||
|
])),
|
||||||
|
content: Text(
|
||||||
|
tr('onlyWorksWithNonEVDApps'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight:
|
||||||
|
FontWeight
|
||||||
|
.bold,
|
||||||
|
fontStyle:
|
||||||
|
FontStyle.italic),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed:
|
onPressed:
|
||||||
@ -457,8 +528,8 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
Navigator.of(context)
|
Navigator.of(context)
|
||||||
.pop();
|
.pop();
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: Text(
|
||||||
'No')),
|
tr('no'))),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed:
|
onPressed:
|
||||||
() {
|
() {
|
||||||
@ -476,8 +547,8 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
Navigator.of(context)
|
Navigator.of(context)
|
||||||
.pop();
|
.pop();
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Yes'))
|
tr('yes')))
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}).whenComplete(() {
|
}).whenComplete(() {
|
||||||
@ -487,7 +558,7 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
tooltip:
|
tooltip:
|
||||||
'Mark Selected Apps as Updated',
|
tr('markSelectedAppsUpdated'),
|
||||||
icon: const Icon(Icons.done)),
|
icon: const Icon(Icons.done)),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@ -502,8 +573,12 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
}).toList());
|
}).toList());
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
tooltip:
|
tooltip: selectedApps
|
||||||
'${selectedApps.where((element) => element.pinned).isEmpty ? 'Pin to' : 'Unpin from'} top',
|
.where((element) =>
|
||||||
|
element.pinned)
|
||||||
|
.isEmpty
|
||||||
|
? tr('pinToTop')
|
||||||
|
: tr('unpinFromTop'),
|
||||||
icon: Icon(selectedApps
|
icon: Icon(selectedApps
|
||||||
.where((element) =>
|
.where((element) =>
|
||||||
element.pinned)
|
element.pinned)
|
||||||
@ -521,11 +596,11 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
urls = urls.substring(
|
urls = urls.substring(
|
||||||
0, urls.length - 1);
|
0, urls.length - 1);
|
||||||
Share.share(urls,
|
Share.share(urls,
|
||||||
subject:
|
subject: tr(
|
||||||
'${selectedApps.length} Selected App URLs from Obtainium');
|
'selectedAppURLsFromObtainium'));
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
tooltip: 'Share Selected App URLs',
|
tooltip: tr('shareSelectedAppURLs'),
|
||||||
icon: const Icon(Icons.share),
|
icon: const Icon(Icons.share),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@ -534,13 +609,19 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
return GeneratedFormModal(
|
return GeneratedFormModal(
|
||||||
title:
|
title: tr(
|
||||||
'Reset Install Status for Selected Apps?',
|
'resetInstallStatusForSelectedAppsQuestion'),
|
||||||
items: const [],
|
items: const [],
|
||||||
defaultValues: const [],
|
defaultValues: const [],
|
||||||
initValid: true,
|
initValid: true,
|
||||||
message:
|
message: tr(
|
||||||
'The install status of ${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be reset.\n\nThis can help when the App version shown in Obtainium is incorrect due to failed updates or other issues.',
|
'installStatusOfXWillBeResetExplanation',
|
||||||
|
args: [
|
||||||
|
plural(
|
||||||
|
'app',
|
||||||
|
selectedApps
|
||||||
|
.length)
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
}).then((values) {
|
}).then((values) {
|
||||||
if (values != null) {
|
if (values != null) {
|
||||||
@ -554,7 +635,7 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
tooltip: 'Reset Install Status',
|
tooltip: tr('resetInstallStatus'),
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.restore_page_outlined),
|
Icons.restore_page_outlined),
|
||||||
),
|
),
|
||||||
@ -563,7 +644,7 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
tooltip: 'More',
|
tooltip: tr('more'),
|
||||||
icon: const Icon(Icons.more_horiz),
|
icon: const Icon(Icons.more_horiz),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -581,8 +662,8 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
tooltip: currentFilterIsUpdatesOnly
|
tooltip: currentFilterIsUpdatesOnly
|
||||||
? 'Remove Out-of-Date App Filter'
|
? tr('removeOutdatedFilter')
|
||||||
: 'Show Out-of-Date Apps Only',
|
: tr('showOutdatedOnly'),
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
currentFilterIsUpdatesOnly
|
currentFilterIsUpdatesOnly
|
||||||
? Icons.update_disabled_rounded
|
? Icons.update_disabled_rounded
|
||||||
@ -594,7 +675,7 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
? const SizedBox()
|
? const SizedBox()
|
||||||
: TextButton.icon(
|
: TextButton.icon(
|
||||||
label: Text(
|
label: Text(
|
||||||
filter == null ? 'Filter' : 'Filter *',
|
filter == null ? tr('filter') : tr('filterActive'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: filter == null
|
fontWeight: filter == null
|
||||||
? FontWeight.normal
|
? FontWeight.normal
|
||||||
@ -605,22 +686,22 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
return GeneratedFormModal(
|
return GeneratedFormModal(
|
||||||
title: 'Filter Apps',
|
title: tr('filterApps'),
|
||||||
items: [
|
items: [
|
||||||
[
|
[
|
||||||
GeneratedFormItem(
|
GeneratedFormItem(
|
||||||
label: 'App Name', required: false),
|
label: tr('appName'), required: false),
|
||||||
GeneratedFormItem(
|
GeneratedFormItem(
|
||||||
label: 'Author', required: false)
|
label: tr('author'), required: false)
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
GeneratedFormItem(
|
GeneratedFormItem(
|
||||||
label: 'Up to Date Apps',
|
label: tr('upToDateApps'),
|
||||||
type: FormItemType.bool)
|
type: FormItemType.bool)
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
GeneratedFormItem(
|
GeneratedFormItem(
|
||||||
label: 'Non-Installed Apps',
|
label: tr('nonInstalledApps'),
|
||||||
type: FormItemType.bool)
|
type: FormItemType.bool)
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'package:animations/animations.dart';
|
import 'package:animations/animations.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/pages/add_app.dart';
|
import 'package:obtainium/pages/add_app.dart';
|
||||||
@ -25,12 +26,12 @@ class _HomePageState extends State<HomePage> {
|
|||||||
List<int> selectedIndexHistory = [];
|
List<int> selectedIndexHistory = [];
|
||||||
|
|
||||||
List<NavigationPageItem> pages = [
|
List<NavigationPageItem> pages = [
|
||||||
|
NavigationPageItem(tr('appsString'), Icons.apps,
|
||||||
|
AppsPage(key: GlobalKey<AppsPageState>())),
|
||||||
|
NavigationPageItem(tr('addApp'), Icons.add, const AddAppPage()),
|
||||||
NavigationPageItem(
|
NavigationPageItem(
|
||||||
'Apps', Icons.apps, AppsPage(key: GlobalKey<AppsPageState>())),
|
tr('importExport'), Icons.import_export, const ImportExportPage()),
|
||||||
NavigationPageItem('Add App', Icons.add, const AddAppPage()),
|
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage())
|
||||||
NavigationPageItem(
|
|
||||||
'Import/Export', Icons.import_export, const ImportExportPage()),
|
|
||||||
NavigationPageItem('Settings', Icons.settings, const SettingsPage())
|
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/components/custom_app_bar.dart';
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
@ -8,7 +9,6 @@ import 'package:obtainium/components/generated_form.dart';
|
|||||||
import 'package:obtainium/components/generated_form_modal.dart';
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
import 'package:obtainium/custom_errors.dart';
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
@ -27,7 +27,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
SourceProvider sourceProvider = SourceProvider();
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
var settingsProvider = context.read<SettingsProvider>();
|
|
||||||
var appsProvider = context.read<AppsProvider>();
|
var appsProvider = context.read<AppsProvider>();
|
||||||
var outlineButtonStyle = ButtonStyle(
|
var outlineButtonStyle = ButtonStyle(
|
||||||
shape: MaterialStateProperty.all(
|
shape: MaterialStateProperty.all(
|
||||||
@ -40,28 +39,10 @@ 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(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: CustomScrollView(slivers: <Widget>[
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
const CustomAppBar(title: 'Import/Export'),
|
CustomAppBar(title: tr('importExport')),
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding:
|
padding:
|
||||||
@ -83,10 +64,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
.exportApps()
|
.exportApps()
|
||||||
.then((String path) {
|
.then((String path) {
|
||||||
showError(
|
showError(
|
||||||
'Exported to $path', context);
|
tr('exportedTo', args: [path]),
|
||||||
|
context);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: const Text('Obtainium Export'))),
|
child: Text(tr('obtainiumExport')))),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
),
|
),
|
||||||
@ -111,13 +93,15 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
jsonDecode(data);
|
jsonDecode(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw ObtainiumError(
|
throw ObtainiumError(
|
||||||
'Invalid input');
|
tr('invalidInput'));
|
||||||
}
|
}
|
||||||
appsProvider
|
appsProvider
|
||||||
.importApps(data)
|
.importApps(data)
|
||||||
.then((value) {
|
.then((value) {
|
||||||
showError(
|
showError(
|
||||||
'$value App${value == 1 ? '' : 's'} Imported',
|
tr('importedX', args: [
|
||||||
|
plural('apps', value)
|
||||||
|
]),
|
||||||
context);
|
context);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -131,7 +115,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: const Text('Obtainium Import')))
|
child: Text(tr('obtainiumImport'))))
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (importInProgress)
|
if (importInProgress)
|
||||||
@ -158,11 +142,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
return GeneratedFormModal(
|
return GeneratedFormModal(
|
||||||
title: 'Import from URL List',
|
title: tr('importFromURLList'),
|
||||||
items: [
|
items: [
|
||||||
[
|
[
|
||||||
GeneratedFormItem(
|
GeneratedFormItem(
|
||||||
label: 'App URL List',
|
label: tr('appURLList'),
|
||||||
max: 7,
|
max: 7,
|
||||||
additionalValidators: [
|
additionalValidators: [
|
||||||
(String? value) {
|
(String? value) {
|
||||||
@ -179,7 +163,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
.getSource(
|
.getSource(
|
||||||
lines[i]);
|
lines[i]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return 'Line ${i + 1}: $e';
|
return '${tr('line')} ${i + 1}: $e';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -197,10 +181,14 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
importInProgress = true;
|
importInProgress = true;
|
||||||
});
|
});
|
||||||
addApps(urls).then((errors) {
|
appsProvider
|
||||||
|
.addAppsByURL(urls)
|
||||||
|
.then((errors) {
|
||||||
if (errors.isEmpty) {
|
if (errors.isEmpty) {
|
||||||
showError(
|
showError(
|
||||||
'Imported ${urls.length} Apps',
|
tr('importedX', args: [
|
||||||
|
plural('apps', urls.length)
|
||||||
|
]),
|
||||||
context);
|
context);
|
||||||
} else {
|
} else {
|
||||||
showDialog(
|
showDialog(
|
||||||
@ -221,8 +209,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Import from URL List',
|
tr('importFromURLList'),
|
||||||
)),
|
)),
|
||||||
...sourceProvider.sources
|
...sourceProvider.sources
|
||||||
.where((element) => element.canSearch)
|
.where((element) => element.canSearch)
|
||||||
@ -242,13 +230,17 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
builder:
|
builder:
|
||||||
(BuildContext ctx) {
|
(BuildContext ctx) {
|
||||||
return GeneratedFormModal(
|
return GeneratedFormModal(
|
||||||
title:
|
title: tr('searchX',
|
||||||
'Search ${source.runtimeType}',
|
args: [
|
||||||
|
source
|
||||||
|
.runtimeType
|
||||||
|
.toString()
|
||||||
|
]),
|
||||||
items: [
|
items: [
|
||||||
[
|
[
|
||||||
GeneratedFormItem(
|
GeneratedFormItem(
|
||||||
label:
|
label: tr(
|
||||||
'${source.runtimeType} Search Query')
|
'searchQuery'))
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
defaultValues: const [],
|
defaultValues: const [],
|
||||||
@ -275,7 +267,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
return UrlSelectionModal(
|
return UrlSelectionModal(
|
||||||
urlsWithDescriptions:
|
urlsWithDescriptions:
|
||||||
urlsWithDescriptions,
|
urlsWithDescriptions,
|
||||||
defaultSelected:
|
selectedByDefault:
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -284,12 +276,19 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
selectedUrls
|
selectedUrls
|
||||||
.isNotEmpty) {
|
.isNotEmpty) {
|
||||||
var errors =
|
var errors =
|
||||||
await addApps(
|
await appsProvider
|
||||||
selectedUrls);
|
.addAppsByURL(
|
||||||
|
selectedUrls);
|
||||||
if (errors.isEmpty) {
|
if (errors.isEmpty) {
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
showError(
|
showError(
|
||||||
'Imported ${selectedUrls.length} Apps',
|
tr('importedX',
|
||||||
|
args: [
|
||||||
|
plural(
|
||||||
|
'app',
|
||||||
|
selectedUrls
|
||||||
|
.length)
|
||||||
|
]),
|
||||||
context);
|
context);
|
||||||
} else {
|
} else {
|
||||||
showDialog(
|
showDialog(
|
||||||
@ -308,7 +307,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw ObtainiumError(
|
throw ObtainiumError(
|
||||||
'No results found');
|
tr('noResults'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@ -320,8 +319,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(tr('searchX', args: [
|
||||||
'Search ${source.runtimeType}'))
|
source.runtimeType.toString()
|
||||||
|
])))
|
||||||
]))
|
]))
|
||||||
.toList(),
|
.toList(),
|
||||||
...sourceProvider.massUrlSources
|
...sourceProvider.massUrlSources
|
||||||
@ -340,8 +340,10 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
builder:
|
builder:
|
||||||
(BuildContext ctx) {
|
(BuildContext ctx) {
|
||||||
return GeneratedFormModal(
|
return GeneratedFormModal(
|
||||||
title:
|
title: tr('importX',
|
||||||
'Import ${source.name}',
|
args: [
|
||||||
|
source.name
|
||||||
|
]),
|
||||||
items:
|
items:
|
||||||
source
|
source
|
||||||
.requiredArgs
|
.requiredArgs
|
||||||
@ -374,12 +376,19 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
});
|
});
|
||||||
if (selectedUrls != null) {
|
if (selectedUrls != null) {
|
||||||
var errors =
|
var errors =
|
||||||
await addApps(
|
await appsProvider
|
||||||
selectedUrls);
|
.addAppsByURL(
|
||||||
|
selectedUrls);
|
||||||
if (errors.isEmpty) {
|
if (errors.isEmpty) {
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
showError(
|
showError(
|
||||||
'Imported ${selectedUrls.length} Apps',
|
tr('importedX',
|
||||||
|
args: [
|
||||||
|
plural(
|
||||||
|
'app',
|
||||||
|
selectedUrls
|
||||||
|
.length)
|
||||||
|
]),
|
||||||
context);
|
context);
|
||||||
} else {
|
} else {
|
||||||
showDialog(
|
showDialog(
|
||||||
@ -406,17 +415,17 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: Text('Import ${source.name}'))
|
child: Text(
|
||||||
|
tr('importX', args: [source.name])))
|
||||||
]))
|
]))
|
||||||
.toList(),
|
.toList(),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
const Divider(
|
const Divider(
|
||||||
height: 32,
|
height: 32,
|
||||||
),
|
),
|
||||||
const Text(
|
Text(tr('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.',
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontStyle: FontStyle.italic, fontSize: 12)),
|
fontStyle: FontStyle.italic, fontSize: 12)),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
height: 8,
|
height: 8,
|
||||||
@ -443,16 +452,19 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: const Text('Import Errors'),
|
title: Text(tr('importErrors')),
|
||||||
content:
|
content:
|
||||||
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||||
Text(
|
Text(
|
||||||
'${widget.urlsLength - widget.errors.length} of ${widget.urlsLength} Apps imported.',
|
tr('importedXOfYApps', args: [
|
||||||
|
(widget.urlsLength - widget.errors.length).toString(),
|
||||||
|
widget.urlsLength.toString()
|
||||||
|
]),
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'The following URLs had errors:',
|
tr('followingURLsHadErrors'),
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
...widget.errors.map((e) {
|
...widget.errors.map((e) {
|
||||||
@ -475,7 +487,7 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop(null);
|
Navigator.of(context).pop(null);
|
||||||
},
|
},
|
||||||
child: const Text('Okay'))
|
child: Text(tr('okay')))
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -486,10 +498,12 @@ class UrlSelectionModal extends StatefulWidget {
|
|||||||
UrlSelectionModal(
|
UrlSelectionModal(
|
||||||
{super.key,
|
{super.key,
|
||||||
required this.urlsWithDescriptions,
|
required this.urlsWithDescriptions,
|
||||||
this.defaultSelected = true});
|
this.selectedByDefault = true,
|
||||||
|
this.onlyOneSelectionAllowed = false});
|
||||||
|
|
||||||
Map<String, String> urlsWithDescriptions;
|
Map<String, String> urlsWithDescriptions;
|
||||||
bool defaultSelected;
|
bool selectedByDefault;
|
||||||
|
bool onlyOneSelectionAllowed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
|
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
|
||||||
@ -501,8 +515,17 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
for (var url in widget.urlsWithDescriptions.entries) {
|
for (var url in widget.urlsWithDescriptions.entries) {
|
||||||
urlWithDescriptionSelections.putIfAbsent(
|
urlWithDescriptionSelections.putIfAbsent(url,
|
||||||
url, () => widget.defaultSelected);
|
() => 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -510,7 +533,8 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: const Text('Select URLs to Import'),
|
title: Text(
|
||||||
|
widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')),
|
||||||
content: Column(children: [
|
content: Column(children: [
|
||||||
...urlWithDescriptionSelections.keys.map((urlWithD) {
|
...urlWithDescriptionSelections.keys.map((urlWithD) {
|
||||||
return Row(children: [
|
return Row(children: [
|
||||||
@ -518,7 +542,12 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
|||||||
value: urlWithDescriptionSelections[urlWithD],
|
value: urlWithDescriptionSelections[urlWithD],
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
urlWithDescriptionSelections[urlWithD] = value ?? false;
|
value ??= false;
|
||||||
|
if (value! && widget.onlyOneSelectionAllowed) {
|
||||||
|
selectOnlyOne(urlWithD.key);
|
||||||
|
} else {
|
||||||
|
urlWithDescriptionSelections[urlWithD] = value!;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
@ -563,16 +592,27 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: const Text('Cancel')),
|
child: Text(tr('cancel'))),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed:
|
||||||
Navigator.of(context).pop(urlWithDescriptionSelections.entries
|
urlWithDescriptionSelections.values.where((b) => b).isEmpty
|
||||||
.where((entry) => entry.value)
|
? null
|
||||||
.map((e) => e.key.key)
|
: () {
|
||||||
.toList());
|
Navigator.of(context).pop(urlWithDescriptionSelections
|
||||||
},
|
.entries
|
||||||
child: Text(
|
.where((entry) => entry.value)
|
||||||
'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs'))
|
.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:flutter/material.dart';
|
||||||
import 'package:obtainium/components/custom_app_bar.dart';
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
import 'package:obtainium/components/generated_form.dart';
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
|
import 'package:obtainium/providers/logs_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class SettingsPage extends StatefulWidget {
|
class SettingsPage extends StatefulWidget {
|
||||||
@ -23,20 +27,20 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var themeDropdown = DropdownButtonFormField(
|
var themeDropdown = DropdownButtonFormField(
|
||||||
decoration: const InputDecoration(labelText: 'Theme'),
|
decoration: InputDecoration(labelText: tr('theme')),
|
||||||
value: settingsProvider.theme,
|
value: settingsProvider.theme,
|
||||||
items: const [
|
items: [
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: ThemeSettings.dark,
|
value: ThemeSettings.dark,
|
||||||
child: Text('Dark'),
|
child: Text(tr('dark')),
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: ThemeSettings.light,
|
value: ThemeSettings.light,
|
||||||
child: Text('Light'),
|
child: Text(tr('light')),
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: ThemeSettings.system,
|
value: ThemeSettings.system,
|
||||||
child: Text('Follow System'),
|
child: Text(tr('followSystem')),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
@ -46,16 +50,16 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
var colourDropdown = DropdownButtonFormField(
|
var colourDropdown = DropdownButtonFormField(
|
||||||
decoration: const InputDecoration(labelText: 'Colour'),
|
decoration: InputDecoration(labelText: tr('colour')),
|
||||||
value: settingsProvider.colour,
|
value: settingsProvider.colour,
|
||||||
items: const [
|
items: [
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: ColourSettings.basic,
|
value: ColourSettings.basic,
|
||||||
child: Text('Obtainium'),
|
child: Text(tr('obtainium')),
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: ColourSettings.materialYou,
|
value: ColourSettings.materialYou,
|
||||||
child: Text('Material You'),
|
child: Text(tr('materialYou')),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
@ -65,20 +69,20 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
var sortDropdown = DropdownButtonFormField(
|
var sortDropdown = DropdownButtonFormField(
|
||||||
decoration: const InputDecoration(labelText: 'App Sort By'),
|
decoration: InputDecoration(labelText: tr('appSortBy')),
|
||||||
value: settingsProvider.sortColumn,
|
value: settingsProvider.sortColumn,
|
||||||
items: const [
|
items: [
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: SortColumnSettings.authorName,
|
value: SortColumnSettings.authorName,
|
||||||
child: Text('Author/Name'),
|
child: Text(tr('authorName')),
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: SortColumnSettings.nameAuthor,
|
value: SortColumnSettings.nameAuthor,
|
||||||
child: Text('Name/Author'),
|
child: Text(tr('nameAuthor')),
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: SortColumnSettings.added,
|
value: SortColumnSettings.added,
|
||||||
child: Text('As Added'),
|
child: Text(tr('asAdded')),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
@ -88,16 +92,16 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
var orderDropdown = DropdownButtonFormField(
|
var orderDropdown = DropdownButtonFormField(
|
||||||
decoration: const InputDecoration(labelText: 'App Sort Order'),
|
decoration: InputDecoration(labelText: tr('appSortOrder')),
|
||||||
value: settingsProvider.sortOrder,
|
value: settingsProvider.sortOrder,
|
||||||
items: const [
|
items: [
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: SortOrderSettings.ascending,
|
value: SortOrderSettings.ascending,
|
||||||
child: Text('Ascending'),
|
child: Text(tr('ascending')),
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: SortOrderSettings.descending,
|
value: SortOrderSettings.descending,
|
||||||
child: Text('Descending'),
|
child: Text(tr('descending')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
@ -107,8 +111,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
var intervalDropdown = DropdownButtonFormField(
|
var intervalDropdown = DropdownButtonFormField(
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(labelText: tr('bgUpdateCheckInterval')),
|
||||||
labelText: 'Background Update Checking Interval'),
|
|
||||||
value: settingsProvider.updateInterval,
|
value: settingsProvider.updateInterval,
|
||||||
items: updateIntervals.map((e) {
|
items: updateIntervals.map((e) {
|
||||||
int displayNum = (e < 60
|
int displayNum = (e < 60
|
||||||
@ -117,15 +120,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
? e / 60
|
? e / 60
|
||||||
: e / 1440)
|
: e / 1440)
|
||||||
.round();
|
.round();
|
||||||
var displayUnit = (e < 60
|
|
||||||
? 'Minute'
|
|
||||||
: e < 1440
|
|
||||||
? 'Hour'
|
|
||||||
: 'Day');
|
|
||||||
|
|
||||||
String display = e == 0
|
String display = e == 0
|
||||||
? 'Never - Manual Only'
|
? tr('neverManualOnly')
|
||||||
: '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
|
: (e < 60
|
||||||
|
? plural('minute', displayNum)
|
||||||
|
: e < 1440
|
||||||
|
? plural('hour', displayNum)
|
||||||
|
: plural('day', displayNum));
|
||||||
return DropdownMenuItem(value: e, child: Text(display));
|
return DropdownMenuItem(value: e, child: Text(display));
|
||||||
}).toList(),
|
}).toList(),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
@ -135,18 +136,21 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
var sourceSpecificFields = sourceProvider.sources.map((e) {
|
var sourceSpecificFields = sourceProvider.sources.map((e) {
|
||||||
if (e.moreSourceSettingsFormItems.isNotEmpty) {
|
if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
|
||||||
return GeneratedForm(
|
return GeneratedForm(
|
||||||
items: e.moreSourceSettingsFormItems.map((e) => [e]).toList(),
|
items: e.additionalSourceSpecificSettingFormItems
|
||||||
onValueChanges: (values, valid) {
|
.map((e) => [e])
|
||||||
|
.toList(),
|
||||||
|
onValueChanges: (values, valid, isBuilding) {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
for (var i = 0; i < values.length; i++) {
|
for (var i = 0; i < values.length; i++) {
|
||||||
settingsProvider.setSettingString(
|
settingsProvider.setSettingString(
|
||||||
e.moreSourceSettingsFormItems[i].id, values[i]);
|
e.additionalSourceSpecificSettingFormItems[i].id,
|
||||||
|
values[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
defaultValues: e.moreSourceSettingsFormItems.map((e) {
|
defaultValues: e.additionalSourceSpecificSettingFormItems.map((e) {
|
||||||
return settingsProvider.getSettingString(e.id) ?? '';
|
return settingsProvider.getSettingString(e.id) ?? '';
|
||||||
}).toList());
|
}).toList());
|
||||||
} else {
|
} else {
|
||||||
@ -161,7 +165,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: CustomScrollView(slivers: <Widget>[
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
const CustomAppBar(title: 'Settings'),
|
CustomAppBar(title: tr('settings')),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@ -171,7 +175,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Appearance',
|
tr('appearance'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.primary),
|
color: Theme.of(context).colorScheme.primary),
|
||||||
),
|
),
|
||||||
@ -194,7 +198,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Text('Show Source Webpage in App View'),
|
Text(tr('showWebInAppView')),
|
||||||
Switch(
|
Switch(
|
||||||
value: settingsProvider.showAppWebpage,
|
value: settingsProvider.showAppWebpage,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
@ -206,7 +210,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Text('Pin Updates to Top of Apps View'),
|
Text(tr('pinUpdates')),
|
||||||
Switch(
|
Switch(
|
||||||
value: settingsProvider.pinUpdates,
|
value: settingsProvider.pinUpdates,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
@ -219,7 +223,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
),
|
),
|
||||||
height16,
|
height16,
|
||||||
Text(
|
Text(
|
||||||
'Updates',
|
tr('updates'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.primary),
|
color: Theme.of(context).colorScheme.primary),
|
||||||
),
|
),
|
||||||
@ -228,7 +232,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
height: 48,
|
height: 48,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Source-Specific',
|
tr('sourceSpecific'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.primary),
|
color: Theme.of(context).colorScheme.primary),
|
||||||
),
|
),
|
||||||
@ -238,23 +242,39 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
height16,
|
const Divider(
|
||||||
TextButton.icon(
|
height: 32,
|
||||||
style: ButtonStyle(
|
),
|
||||||
foregroundColor: MaterialStateProperty.resolveWith<Color>(
|
Row(
|
||||||
(Set<MaterialState> states) {
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
return Colors.grey;
|
children: [
|
||||||
}),
|
TextButton.icon(
|
||||||
),
|
onPressed: () {
|
||||||
onPressed: () {
|
launchUrlString(settingsProvider.sourceUrl,
|
||||||
launchUrlString(settingsProvider.sourceUrl,
|
mode: LaunchMode.externalApplication);
|
||||||
mode: LaunchMode.externalApplication);
|
},
|
||||||
},
|
icon: const Icon(Icons.code),
|
||||||
icon: const Icon(Icons.code),
|
label: Text(
|
||||||
label: Text(
|
tr('appSource'),
|
||||||
'Source',
|
),
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
),
|
||||||
),
|
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,
|
height16,
|
||||||
],
|
],
|
||||||
@ -263,3 +283,71 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class LogsDialog extends StatefulWidget {
|
||||||
|
const LogsDialog({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LogsDialog> createState() => _LogsDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogsDialogState extends State<LogsDialog> {
|
||||||
|
String? logString;
|
||||||
|
List<int> days = [7, 5, 4, 3, 2, 1];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
var logsProvider = context.read<LogsProvider>();
|
||||||
|
void filterLogs(int days) {
|
||||||
|
logsProvider
|
||||||
|
.get(after: DateTime.now().subtract(Duration(days: days)))
|
||||||
|
.then((value) {
|
||||||
|
setState(() {
|
||||||
|
String l = value.map((e) => e.toString()).join('\n\n');
|
||||||
|
logString = l.isNotEmpty ? l : tr('noLogs');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logString == null) {
|
||||||
|
filterLogs(days.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(tr('appLogs')),
|
||||||
|
content: Column(
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField(
|
||||||
|
value: days.first,
|
||||||
|
items: days
|
||||||
|
.map((e) => DropdownMenuItem(
|
||||||
|
value: e,
|
||||||
|
child: Text(plural('day', e)),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (d) {
|
||||||
|
filterLogs(d ?? 7);
|
||||||
|
}),
|
||||||
|
const SizedBox(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
Text(logString ?? '')
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(tr('close'))),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Share.share(logString ?? '', subject: tr('appLogs'));
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(tr('share')))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -6,14 +6,14 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:install_plugin_v2/install_plugin_v2.dart';
|
import 'package:install_plugin_v2/install_plugin_v2.dart';
|
||||||
import 'package:installed_apps/app_info.dart';
|
import 'package:installed_apps/app_info.dart';
|
||||||
import 'package:installed_apps/installed_apps.dart';
|
import 'package:installed_apps/installed_apps.dart';
|
||||||
import 'package:obtainium/app_sources/github.dart';
|
|
||||||
import 'package:obtainium/custom_errors.dart';
|
import 'package:obtainium/custom_errors.dart';
|
||||||
|
import 'package:obtainium/providers/logs_provider.dart';
|
||||||
import 'package:obtainium/providers/notifications_provider.dart';
|
import 'package:obtainium/providers/notifications_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
import 'package:package_archive_info/package_archive_info.dart';
|
import 'package:package_archive_info/package_archive_info.dart';
|
||||||
@ -37,83 +37,118 @@ class DownloadedApk {
|
|||||||
DownloadedApk(this.appId, this.file);
|
DownloadedApk(this.appId, this.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> generateStandardVersionRegExStrings() {
|
||||||
|
// TODO: Look into RegEx for non-Latin characters / non-Arabic numerals
|
||||||
|
var basics = [
|
||||||
|
'[0-9]+',
|
||||||
|
'[0-9]+\\.[0-9]+',
|
||||||
|
'[0-9]+\\.[0-9]+\\.[0-9]+',
|
||||||
|
'[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+'
|
||||||
|
];
|
||||||
|
var preSuffixes = ['-', '\\+'];
|
||||||
|
var suffixes = ['alpha', 'beta', 'ose'];
|
||||||
|
var finals = ['\\+[0-9]+', '[0-9]+'];
|
||||||
|
List<String> results = [];
|
||||||
|
for (var b in basics) {
|
||||||
|
results.add(b);
|
||||||
|
for (var p in preSuffixes) {
|
||||||
|
for (var s in suffixes) {
|
||||||
|
results.add('$b$s');
|
||||||
|
results.add('$b$p$s');
|
||||||
|
for (var f in finals) {
|
||||||
|
results.add('$b$s$f');
|
||||||
|
results.add('$b$p$s$f');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> standardVersionRegExStrings =
|
||||||
|
generateStandardVersionRegExStrings();
|
||||||
|
|
||||||
class AppsProvider with ChangeNotifier {
|
class AppsProvider with ChangeNotifier {
|
||||||
// In memory App state (should always be kept in sync with local storage versions)
|
// In memory App state (should always be kept in sync with local storage versions)
|
||||||
Map<String, AppInMemory> apps = {};
|
Map<String, AppInMemory> apps = {};
|
||||||
bool loadingApps = false;
|
bool loadingApps = false;
|
||||||
bool gettingUpdates = false;
|
bool gettingUpdates = false;
|
||||||
bool forBGTask = false;
|
LogsProvider logs = LogsProvider();
|
||||||
|
|
||||||
// Variables to keep track of the app foreground status (installs can't run in the background)
|
// Variables to keep track of the app foreground status (installs can't run in the background)
|
||||||
bool isForeground = true;
|
bool isForeground = true;
|
||||||
late Stream<FGBGType>? foregroundStream;
|
late Stream<FGBGType>? foregroundStream;
|
||||||
late StreamSubscription<FGBGType>? foregroundSubscription;
|
late StreamSubscription<FGBGType>? foregroundSubscription;
|
||||||
|
|
||||||
AppsProvider({this.forBGTask = false}) {
|
AppsProvider() {
|
||||||
// Many setup tasks should only be done in the foreground isolate
|
// Subscribe to changes in the app foreground status
|
||||||
if (!forBGTask) {
|
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||||
// Subscribe to changes in the app foreground status
|
foregroundSubscription = foregroundStream?.listen((event) async {
|
||||||
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
isForeground = event == FGBGType.foreground;
|
||||||
foregroundSubscription = foregroundStream?.listen((event) async {
|
if (isForeground) await loadApps();
|
||||||
isForeground = event == FGBGType.foreground;
|
});
|
||||||
if (isForeground) await loadApps();
|
() async {
|
||||||
|
// Load Apps into memory (in background, this is done later instead of in the constructor)
|
||||||
|
await loadApps();
|
||||||
|
// Delete existing APKs
|
||||||
|
(await getExternalStorageDirectory())
|
||||||
|
?.listSync()
|
||||||
|
.where((element) =>
|
||||||
|
element.path.endsWith('.apk') ||
|
||||||
|
element.path.endsWith('.apk.part'))
|
||||||
|
.forEach((apk) {
|
||||||
|
apk.delete();
|
||||||
});
|
});
|
||||||
() async {
|
}();
|
||||||
// Load Apps into memory (in background, this is done later instead of in the constructor)
|
|
||||||
await loadApps();
|
|
||||||
// Delete existing APKs
|
|
||||||
(await getExternalStorageDirectory())
|
|
||||||
?.listSync()
|
|
||||||
.where((element) => element.path.endsWith('.apk'))
|
|
||||||
.forEach((apk) {
|
|
||||||
apk.delete();
|
|
||||||
});
|
|
||||||
}();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadFile(String url, String fileName, Function? onProgress) async {
|
downloadFile(String url, String fileName, Function? onProgress,
|
||||||
|
{bool useExisting = true}) async {
|
||||||
var destDir = (await getExternalStorageDirectory())!.path;
|
var destDir = (await getExternalStorageDirectory())!.path;
|
||||||
StreamedResponse response =
|
StreamedResponse response =
|
||||||
await Client().send(Request('GET', Uri.parse(url)));
|
await Client().send(Request('GET', Uri.parse(url)));
|
||||||
File downloadedFile = File('$destDir/$fileName');
|
File downloadedFile = File('$destDir/$fileName');
|
||||||
|
if (!(downloadedFile.existsSync() && useExisting)) {
|
||||||
if (downloadedFile.existsSync()) {
|
File tempDownloadedFile = File('${downloadedFile.path}.part');
|
||||||
downloadedFile.deleteSync();
|
if (tempDownloadedFile.existsSync()) {
|
||||||
}
|
tempDownloadedFile.deleteSync();
|
||||||
var length = response.contentLength;
|
}
|
||||||
var received = 0;
|
var length = response.contentLength;
|
||||||
double? progress;
|
var received = 0;
|
||||||
var sink = downloadedFile.openWrite();
|
double? progress;
|
||||||
|
var sink = tempDownloadedFile.openWrite();
|
||||||
await response.stream.map((s) {
|
await response.stream.map((s) {
|
||||||
received += s.length;
|
received += s.length;
|
||||||
progress = (length != null ? received / length * 100 : 30);
|
progress = (length != null ? received / length * 100 : 30);
|
||||||
|
if (onProgress != null) {
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}).pipe(sink);
|
||||||
|
await sink.close();
|
||||||
|
progress = null;
|
||||||
if (onProgress != null) {
|
if (onProgress != null) {
|
||||||
onProgress(progress);
|
onProgress(progress);
|
||||||
}
|
}
|
||||||
return s;
|
if (response.statusCode != 200) {
|
||||||
}).pipe(sink);
|
tempDownloadedFile.deleteSync();
|
||||||
|
throw response.reasonPhrase ?? tr('unexpectedError');
|
||||||
await sink.close();
|
}
|
||||||
progress = null;
|
tempDownloadedFile.renameSync(downloadedFile.path);
|
||||||
if (onProgress != null) {
|
|
||||||
onProgress(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
downloadedFile.deleteSync();
|
|
||||||
throw response.reasonPhrase ?? 'Unknown Error';
|
|
||||||
}
|
}
|
||||||
return downloadedFile;
|
return downloadedFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DownloadedApk> downloadApp(App app) async {
|
Future<DownloadedApk> downloadApp(App app, BuildContext? context) async {
|
||||||
var fileName =
|
var fileName =
|
||||||
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
|
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
|
||||||
String downloadUrl = await SourceProvider()
|
String downloadUrl = await SourceProvider()
|
||||||
.getSource(app.url)
|
.getSource(app.url)
|
||||||
.apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
|
.apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
|
||||||
|
NotificationsProvider? notificationsProvider =
|
||||||
|
context?.read<NotificationsProvider>();
|
||||||
|
var notif = DownloadNotification(app.name, 100);
|
||||||
|
notificationsProvider?.cancel(notif.id);
|
||||||
int? prevProg;
|
int? prevProg;
|
||||||
File downloadedFile =
|
File downloadedFile =
|
||||||
await downloadFile(downloadUrl, fileName, (double? progress) {
|
await downloadFile(downloadUrl, fileName, (double? progress) {
|
||||||
@ -121,12 +156,14 @@ class AppsProvider with ChangeNotifier {
|
|||||||
if (apps[app.id] != null) {
|
if (apps[app.id] != null) {
|
||||||
apps[app.id]!.downloadProgress = progress;
|
apps[app.id]!.downloadProgress = progress;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} else if ((prog == 25 || prog == 50 || prog == 75) && prevProg != prog) {
|
}
|
||||||
Fluttertoast.showToast(
|
notif = DownloadNotification(app.name, prog ?? 100);
|
||||||
msg: 'Progress: $prog%', toastLength: Toast.LENGTH_SHORT);
|
if (prog != null && prevProg != prog) {
|
||||||
|
notificationsProvider?.notify(notif);
|
||||||
}
|
}
|
||||||
prevProg = prog;
|
prevProg = prog;
|
||||||
});
|
});
|
||||||
|
notificationsProvider?.cancel(notif.id);
|
||||||
// Delete older versions of the APK if any
|
// Delete older versions of the APK if any
|
||||||
for (var file in downloadedFile.parent.listSync()) {
|
for (var file in downloadedFile.parent.listSync()) {
|
||||||
var fn = file.path.split('/').last;
|
var fn = file.path.split('/').last;
|
||||||
@ -161,8 +198,8 @@ class AppsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<bool> canInstallSilently(App app) async {
|
Future<bool> canInstallSilently(App app) async {
|
||||||
return false;
|
return false;
|
||||||
// TODO: Uncomment the below once silentupdates are ever figured out
|
// TODO: Uncomment the below if silent updates are ever figured out
|
||||||
// // TODO: This is unreliable - try to get from OS in the future
|
// // NOTE: This is unreliable - try to get from OS in the future
|
||||||
// if (app.apkUrls.length > 1) {
|
// if (app.apkUrls.length > 1) {
|
||||||
// return false;
|
// return false;
|
||||||
// }
|
// }
|
||||||
@ -262,14 +299,18 @@ class AppsProvider with ChangeNotifier {
|
|||||||
Future<List<String>> downloadAndInstallLatestApps(
|
Future<List<String>> downloadAndInstallLatestApps(
|
||||||
List<String> appIds, BuildContext? context) async {
|
List<String> appIds, BuildContext? context) async {
|
||||||
List<String> appsToInstall = [];
|
List<String> appsToInstall = [];
|
||||||
|
List<String> trackOnlyAppsToUpdate = [];
|
||||||
// For all specified Apps, filter out those for which:
|
// For all specified Apps, filter out those for which:
|
||||||
// 1. A URL cannot be picked
|
// 1. A URL cannot be picked
|
||||||
// 2. That cannot be installed silently (IF no buildContext was given for interactive install)
|
// 2. That cannot be installed silently (IF no buildContext was given for interactive install)
|
||||||
for (var id in appIds) {
|
for (var id in appIds) {
|
||||||
if (apps[id] == null) {
|
if (apps[id] == null) {
|
||||||
throw ObtainiumError('App not found');
|
throw ObtainiumError(tr('appNotFound'));
|
||||||
|
}
|
||||||
|
String? apkUrl;
|
||||||
|
if (!apps[id]!.app.trackOnly) {
|
||||||
|
apkUrl = await confirmApkUrl(apps[id]!.app, context);
|
||||||
}
|
}
|
||||||
String? apkUrl = await confirmApkUrl(apps[id]!.app, context);
|
|
||||||
if (apkUrl != null) {
|
if (apkUrl != null) {
|
||||||
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
||||||
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
||||||
@ -280,13 +321,22 @@ class AppsProvider with ChangeNotifier {
|
|||||||
appsToInstall.add(id);
|
appsToInstall.add(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (apps[id]!.app.trackOnly) {
|
||||||
|
trackOnlyAppsToUpdate.add(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Mark all specified track-only apps as latest
|
||||||
|
saveApps(trackOnlyAppsToUpdate.map((e) {
|
||||||
|
var a = apps[e]!.app;
|
||||||
|
a.installedVersion = a.latestVersion;
|
||||||
|
return a;
|
||||||
|
}).toList());
|
||||||
// Download APKs for all Apps to be installed
|
// Download APKs for all Apps to be installed
|
||||||
MultiAppMultiError errors = MultiAppMultiError();
|
MultiAppMultiError errors = MultiAppMultiError();
|
||||||
List<DownloadedApk?> downloadedFiles =
|
List<DownloadedApk?> downloadedFiles =
|
||||||
await Future.wait(appsToInstall.map((id) async {
|
await Future.wait(appsToInstall.map((id) async {
|
||||||
try {
|
try {
|
||||||
return await downloadApp(apps[id]!.app);
|
return await downloadApp(apps[id]!.app, context);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errors.add(id, e.toString());
|
errors.add(id, e.toString());
|
||||||
}
|
}
|
||||||
@ -306,7 +356,8 @@ class AppsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move everything to the regular install list (since silent updates don't currently work) - TODO
|
// Move everything to the regular install list (since silent updates don't currently work)
|
||||||
|
// TODO: Remove this when silent updates work
|
||||||
regularInstalls.addAll(silentUpdates);
|
regularInstalls.addAll(silentUpdates);
|
||||||
|
|
||||||
// If Obtainium is being installed, it should be the last one
|
// If Obtainium is being installed, it should be the last one
|
||||||
@ -376,69 +427,133 @@ class AppsProvider with ChangeNotifier {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> doesInstalledAppsPluginWork() async {
|
||||||
|
bool res = false;
|
||||||
|
try {
|
||||||
|
res = (await InstalledApps.getAppInfo(obtainiumId)).versionName != null;
|
||||||
|
} catch (e) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
if (!res) {
|
||||||
|
logs.add(tr('versionCorrectionDisabled'));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
// If the App says it is installed but installedInfo is null, set it to not installed
|
// If the App says it is installed but installedInfo is null, set it to not installed
|
||||||
// If the App says is is not installed but installedInfo exists, try to set it to installed as latest version...
|
// If there is any other mismatch between installedInfo and installedVersion, try reconciling them intelligently
|
||||||
// ...if the latestVersion seems to match the version in installedInfo (not guaranteed)
|
|
||||||
// If that fails, just set it to the actual version string (all we can do at that point)
|
// If that fails, just set it to the actual version string (all we can do at that point)
|
||||||
// Don't save changes, just return the object if changes were made (else null)
|
// Don't save changes, just return the object if changes were made (else null)
|
||||||
// If in a background isolate, return null straight away as the required plugin won't work anyways
|
|
||||||
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
|
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
|
||||||
if (forBGTask) {
|
|
||||||
return null; // Can't correct in the background isolate
|
|
||||||
}
|
|
||||||
var modded = false;
|
var modded = false;
|
||||||
if (installedInfo == null && app.installedVersion != null) {
|
if (installedInfo == null &&
|
||||||
|
app.installedVersion != null &&
|
||||||
|
!app.trackOnly) {
|
||||||
app.installedVersion = null;
|
app.installedVersion = null;
|
||||||
modded = true;
|
modded = true;
|
||||||
}
|
} else if (installedInfo?.versionName != null &&
|
||||||
if (installedInfo != null && app.installedVersion == null) {
|
app.installedVersion == null) {
|
||||||
if (app.latestVersion.characters
|
app.installedVersion = installedInfo!.versionName;
|
||||||
.where((p0) => [
|
modded = true;
|
||||||
'0',
|
} else if (installedInfo?.versionName != null &&
|
||||||
'1',
|
installedInfo!.versionName != app.installedVersion) {
|
||||||
'2',
|
String? correctedInstalledVersion = reconcileRealAndInternalVersions(
|
||||||
'3',
|
installedInfo.versionName!, app.installedVersion!);
|
||||||
'4',
|
if (correctedInstalledVersion != null) {
|
||||||
'5',
|
app.installedVersion = correctedInstalledVersion;
|
||||||
'6',
|
modded = true;
|
||||||
'7',
|
|
||||||
'8',
|
|
||||||
'9',
|
|
||||||
'.'
|
|
||||||
].contains(p0))
|
|
||||||
.join('') ==
|
|
||||||
installedInfo.versionName) {
|
|
||||||
app.installedVersion = app.latestVersion;
|
|
||||||
} else {
|
|
||||||
app.installedVersion = installedInfo.versionName;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (app.installedVersion != null &&
|
||||||
|
app.installedVersion != app.latestVersion) {
|
||||||
|
app.installedVersion = reconcileRealAndInternalVersions(
|
||||||
|
app.installedVersion!, app.latestVersion,
|
||||||
|
matchMode: true) ??
|
||||||
|
app.installedVersion;
|
||||||
modded = true;
|
modded = true;
|
||||||
}
|
}
|
||||||
return modded ? app : null;
|
return modded ? app : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? reconcileRealAndInternalVersions(
|
||||||
|
String realVersion, String internalVersion,
|
||||||
|
{bool matchMode = false}) {
|
||||||
|
// 1. If one or both of these can't be converted to a "standard" format, return null (leave as is)
|
||||||
|
// 2. If both have a "standard" format under which they are equal, return null (leave as is)
|
||||||
|
// 3. If both have a "standard" format in common but are unequal, return realVersion (this means it was changed externally)
|
||||||
|
// If in matchMode, the outcomes of rules 2 and 3 are reversed, and the "real" version is not matched strictly
|
||||||
|
// Matchmode to be used when comparing internal install version and internal latest version
|
||||||
|
|
||||||
|
bool doStringsMatchUnderRegEx(
|
||||||
|
String pattern, String value1, String value2) {
|
||||||
|
var r = RegExp(pattern);
|
||||||
|
var m1 = r.firstMatch(value1);
|
||||||
|
var m2 = r.firstMatch(value2);
|
||||||
|
return m1 != null && m2 != null
|
||||||
|
? value1.substring(m1.start, m1.end) ==
|
||||||
|
value2.substring(m2.start, m2.end)
|
||||||
|
: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> findStandardFormatsForVersion(String version, bool strict) {
|
||||||
|
Set<String> results = {};
|
||||||
|
for (var pattern in standardVersionRegExStrings) {
|
||||||
|
if (RegExp('${strict ? '^' : ''}$pattern${strict ? '\$' : ''}')
|
||||||
|
.hasMatch(version)) {
|
||||||
|
results.add(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
var realStandardVersionFormats =
|
||||||
|
findStandardFormatsForVersion(realVersion, true);
|
||||||
|
var internalStandardVersionFormats =
|
||||||
|
findStandardFormatsForVersion(internalVersion, false);
|
||||||
|
var commonStandardFormats =
|
||||||
|
realStandardVersionFormats.intersection(internalStandardVersionFormats);
|
||||||
|
if (commonStandardFormats.isEmpty) {
|
||||||
|
return null; // Incompatible; no "enhanced detection"
|
||||||
|
}
|
||||||
|
for (String pattern in commonStandardFormats) {
|
||||||
|
if (doStringsMatchUnderRegEx(pattern, internalVersion, realVersion)) {
|
||||||
|
return matchMode
|
||||||
|
? internalVersion
|
||||||
|
: null; // Enhanced detection says no change
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matchMode
|
||||||
|
? null
|
||||||
|
: realVersion; // Enhanced detection says something changed
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> loadApps() async {
|
Future<void> loadApps() async {
|
||||||
while (loadingApps) {
|
while (loadingApps) {
|
||||||
await Future.delayed(const Duration(microseconds: 1));
|
await Future.delayed(const Duration(microseconds: 1));
|
||||||
}
|
}
|
||||||
loadingApps = true;
|
loadingApps = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
List<FileSystemEntity> appFiles = (await getAppsDir())
|
List<App> newApps = (await getAppsDir())
|
||||||
.listSync()
|
.listSync()
|
||||||
.where((item) => item.path.toLowerCase().endsWith('.json'))
|
.where((item) => item.path.toLowerCase().endsWith('.json'))
|
||||||
|
.map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync())))
|
||||||
.toList();
|
.toList();
|
||||||
apps.clear();
|
var idsToDelete = apps.values
|
||||||
|
.map((e) => e.app.id)
|
||||||
|
.toSet()
|
||||||
|
.difference(newApps.map((e) => e.id).toSet());
|
||||||
|
for (var id in idsToDelete) {
|
||||||
|
apps.remove(id);
|
||||||
|
}
|
||||||
var sp = SourceProvider();
|
var sp = SourceProvider();
|
||||||
List<List<String>> errors = [];
|
List<List<String>> errors = [];
|
||||||
for (int i = 0; i < appFiles.length; i++) {
|
for (int i = 0; i < newApps.length; i++) {
|
||||||
App app =
|
var info = await getInstalledInfo(newApps[i].id);
|
||||||
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync()));
|
|
||||||
var info = await getInstalledInfo(app.id);
|
|
||||||
try {
|
try {
|
||||||
sp.getSource(app.url);
|
sp.getSource(newApps[i].url);
|
||||||
apps.putIfAbsent(app.id, () => AppInMemory(app, null, info));
|
apps[newApps[i].id] = AppInMemory(newApps[i], null, info);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errors.add([app.id, app.name, e.toString()]);
|
errors.add([newApps[i].id, newApps[i].name, e.toString()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (errors.isNotEmpty) {
|
if (errors.isNotEmpty) {
|
||||||
@ -448,21 +563,25 @@ class AppsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
loadingApps = false;
|
loadingApps = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
List<App> modifiedApps = [];
|
if (await doesInstalledAppsPluginWork()) {
|
||||||
for (var app in apps.values) {
|
List<App> modifiedApps = [];
|
||||||
var moddedApp =
|
for (var app in apps.values) {
|
||||||
getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo);
|
var moddedApp =
|
||||||
if (moddedApp != null) {
|
getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo);
|
||||||
modifiedApps.add(moddedApp);
|
if (moddedApp != null) {
|
||||||
|
modifiedApps.add(moddedApp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (modifiedApps.isNotEmpty) {
|
||||||
|
await saveApps(modifiedApps, attemptToCorrectInstallStatus: false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (modifiedApps.isNotEmpty) {
|
|
||||||
await saveApps(modifiedApps);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveApps(List<App> apps,
|
Future<void> saveApps(List<App> apps,
|
||||||
{bool attemptToCorrectInstallStatus = true}) async {
|
{bool attemptToCorrectInstallStatus = true}) async {
|
||||||
|
attemptToCorrectInstallStatus =
|
||||||
|
attemptToCorrectInstallStatus && (await doesInstalledAppsPluginWork());
|
||||||
for (var app in apps) {
|
for (var app in apps) {
|
||||||
AppInfo? info = await getInstalledInfo(app.id);
|
AppInfo? info = await getInstalledInfo(app.id);
|
||||||
app.name = info?.name ?? app.name;
|
app.name = info?.name ?? app.name;
|
||||||
@ -502,8 +621,9 @@ class AppsProvider with ChangeNotifier {
|
|||||||
currentApp.additionalData,
|
currentApp.additionalData,
|
||||||
name: currentApp.name,
|
name: currentApp.name,
|
||||||
id: currentApp.id,
|
id: currentApp.id,
|
||||||
pinned: currentApp.pinned);
|
pinned: currentApp.pinned,
|
||||||
newApp.installedVersion = currentApp.installedVersion;
|
trackOnly: currentApp.trackOnly,
|
||||||
|
installedVersion: currentApp.installedVersion);
|
||||||
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
||||||
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
||||||
}
|
}
|
||||||
@ -576,13 +696,13 @@ class AppsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<String> exportApps() async {
|
Future<String> exportApps() async {
|
||||||
Directory? exportDir = Directory('/storage/emulated/0/Download');
|
Directory? exportDir = Directory('/storage/emulated/0/Download');
|
||||||
String path = 'Downloads';
|
String path = 'Downloads'; // TODO: See if hardcoding this can be avoided
|
||||||
if (!exportDir.existsSync()) {
|
if (!exportDir.existsSync()) {
|
||||||
exportDir = await getExternalStorageDirectory();
|
exportDir = await getExternalStorageDirectory();
|
||||||
path = exportDir!.path;
|
path = exportDir!.path;
|
||||||
}
|
}
|
||||||
File export = File(
|
File export = File(
|
||||||
'${exportDir.path}/obtainium-export-${DateTime.now().millisecondsSinceEpoch}.json');
|
'${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json');
|
||||||
export.writeAsStringSync(
|
export.writeAsStringSync(
|
||||||
jsonEncode(apps.values.map((e) => e.app.toJson()).toList()));
|
jsonEncode(apps.values.map((e) => e.app.toJson()).toList()));
|
||||||
return path;
|
return path;
|
||||||
@ -610,6 +730,23 @@ class AppsProvider with ChangeNotifier {
|
|||||||
foregroundSubscription?.cancel();
|
foregroundSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<List<String>>> addAppsByURL(List<String> urls) async {
|
||||||
|
List<dynamic> results = await SourceProvider().getAppsByURLNaive(urls,
|
||||||
|
ignoreUrls: apps.values.map((e) => e.app.url).toList());
|
||||||
|
List<App> pps = results[0];
|
||||||
|
Map<String, dynamic> errorsMap = results[1];
|
||||||
|
for (var app in pps) {
|
||||||
|
if (apps.containsKey(app.id)) {
|
||||||
|
errorsMap.addAll({app.id: tr('appAlreadyAdded')});
|
||||||
|
} else {
|
||||||
|
await saveApps([app]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<List<String>> errors =
|
||||||
|
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class APKPicker extends StatefulWidget {
|
class APKPicker extends StatefulWidget {
|
||||||
@ -631,9 +768,9 @@ class _APKPickerState extends State<APKPicker> {
|
|||||||
apkUrl ??= widget.initVal;
|
apkUrl ??= widget.initVal;
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: const Text('Pick an APK'),
|
title: Text(tr('pickAnAPK')),
|
||||||
content: Column(children: [
|
content: Column(children: [
|
||||||
Text('${widget.app.name} has more than one package:'),
|
Text(tr('appHasMoreThanOnePackage', args: [widget.app.name])),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
...widget.app.apkUrls.map(
|
...widget.app.apkUrls.map(
|
||||||
(u) => RadioListTile<String>(
|
(u) => RadioListTile<String>(
|
||||||
@ -655,7 +792,11 @@ class _APKPickerState extends State<APKPicker> {
|
|||||||
),
|
),
|
||||||
if (widget.archs != null)
|
if (widget.archs != null)
|
||||||
Text(
|
Text(
|
||||||
'Note:\nYour device supports the ${widget.archs!.length == 1 ? '\'${widget.archs![0]}\' CPU architecture.' : 'following CPU architectures: ${list2FriendlyString(widget.archs!.map((e) => '\'$e\'').toList())}.'}',
|
widget.archs!.length == 1
|
||||||
|
? tr('deviceSupportsXArch', args: [widget.archs![0]])
|
||||||
|
: tr('deviceSupportsFollowingArchs') +
|
||||||
|
list2FriendlyString(
|
||||||
|
widget.archs!.map((e) => '\'$e\'').toList()),
|
||||||
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
|
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
@ -664,13 +805,13 @@ class _APKPickerState extends State<APKPicker> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop(null);
|
Navigator.of(context).pop(null);
|
||||||
},
|
},
|
||||||
child: const Text('Cancel')),
|
child: Text(tr('cancel'))),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
Navigator.of(context).pop(apkUrl);
|
Navigator.of(context).pop(apkUrl);
|
||||||
},
|
},
|
||||||
child: const Text('Continue'))
|
child: Text(tr('continue')))
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -692,21 +833,23 @@ class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: const Text('Warning'),
|
title: Text(tr('warning')),
|
||||||
content: Text(
|
content: Text(tr('sourceIsXButPackageFromYPrompt', args: [
|
||||||
'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'),
|
Uri.parse(widget.sourceUrl).host,
|
||||||
|
Uri.parse(widget.apkUrl).host
|
||||||
|
])),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop(null);
|
Navigator.of(context).pop(null);
|
||||||
},
|
},
|
||||||
child: const Text('Cancel')),
|
child: Text(tr('cancel'))),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
Navigator.of(context).pop(true);
|
Navigator.of(context).pop(true);
|
||||||
},
|
},
|
||||||
child: const Text('Continue'))
|
child: Text(tr('continue')))
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
112
lib/providers/logs_provider.dart
Normal file
112
lib/providers/logs_provider.dart
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
||||||
|
const String logTable = 'logs';
|
||||||
|
const String idColumn = '_id';
|
||||||
|
const String levelColumn = 'level';
|
||||||
|
const String messageColumn = 'message';
|
||||||
|
const String timestampColumn = 'timestamp';
|
||||||
|
const String dbPath = 'logs.db';
|
||||||
|
|
||||||
|
enum LogLevels { debug, info, warning, error }
|
||||||
|
|
||||||
|
class Log {
|
||||||
|
int? id;
|
||||||
|
late LogLevels level;
|
||||||
|
late String message;
|
||||||
|
DateTime timestamp = DateTime.now();
|
||||||
|
|
||||||
|
Map<String, Object?> toMap() {
|
||||||
|
var map = <String, Object?>{
|
||||||
|
idColumn: id,
|
||||||
|
levelColumn: level.index,
|
||||||
|
messageColumn: message,
|
||||||
|
timestampColumn: timestamp.millisecondsSinceEpoch
|
||||||
|
};
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log(this.message, this.level);
|
||||||
|
|
||||||
|
Log.fromMap(Map<String, Object?> map) {
|
||||||
|
id = map[idColumn] as int;
|
||||||
|
level = LogLevels.values.elementAt(map[levelColumn] as int);
|
||||||
|
message = map[messageColumn] as String;
|
||||||
|
timestamp =
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(map[timestampColumn] as int);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return '${timestamp.toString()}: ${level.name}: $message';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogsProvider {
|
||||||
|
LogsProvider({bool runDefaultClear = true}) {
|
||||||
|
clear(before: DateTime.now().subtract(const Duration(days: 7)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Database? db;
|
||||||
|
|
||||||
|
Future<Database> getDB() async {
|
||||||
|
db ??= await openDatabase(dbPath, version: 1,
|
||||||
|
onCreate: (Database db, int version) async {
|
||||||
|
await db.execute('''
|
||||||
|
create table if not exists $logTable (
|
||||||
|
$idColumn integer primary key autoincrement,
|
||||||
|
$levelColumn integer not null,
|
||||||
|
$messageColumn text not null,
|
||||||
|
$timestampColumn integer not null)
|
||||||
|
''');
|
||||||
|
});
|
||||||
|
return db!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Log> add(String message, {LogLevels level = LogLevels.info}) async {
|
||||||
|
Log l = Log(message, level);
|
||||||
|
l.id = await (await getDB()).insert(logTable, l.toMap());
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(l);
|
||||||
|
}
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Log>> get({DateTime? before, DateTime? after}) async {
|
||||||
|
var where = getWhereDates(before: before, after: after);
|
||||||
|
return (await (await getDB())
|
||||||
|
.query(logTable, where: where.key, whereArgs: where.value))
|
||||||
|
.map((e) => Log.fromMap(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> clear({DateTime? before, DateTime? after}) async {
|
||||||
|
var where = getWhereDates(before: before, after: after);
|
||||||
|
var res = await (await getDB())
|
||||||
|
.delete(logTable, where: where.key, whereArgs: where.value);
|
||||||
|
if (res > 0) {
|
||||||
|
add(plural('clearedNLogsBeforeXAfterY', res,
|
||||||
|
namedArgs: {'before': before.toString(), 'after': after.toString()},
|
||||||
|
name: 'n'));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MapEntry<String?, List<int>?> getWhereDates(
|
||||||
|
{DateTime? before, DateTime? after}) {
|
||||||
|
List<String> where = [];
|
||||||
|
List<int> whereArgs = [];
|
||||||
|
if (before != null) {
|
||||||
|
where.add('$timestampColumn < ?');
|
||||||
|
whereArgs.add(before.millisecondsSinceEpoch);
|
||||||
|
}
|
||||||
|
if (after != null) {
|
||||||
|
where.add('$timestampColumn > ?');
|
||||||
|
whereArgs.add(after.millisecondsSinceEpoch);
|
||||||
|
}
|
||||||
|
return whereArgs.isEmpty
|
||||||
|
? const MapEntry(null, null)
|
||||||
|
: MapEntry(where.join(' and '), whereArgs);
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
// Exposes functions that can be used to send notifications to the user
|
// Exposes functions that can be used to send notifications to the user
|
||||||
// Contains a set of pre-defined ObtainiumNotification objects that should be used throughout the app
|
// Contains a set of pre-defined ObtainiumNotification objects that should be used throughout the app
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
@ -12,42 +13,42 @@ class ObtainiumNotification {
|
|||||||
late String channelName;
|
late String channelName;
|
||||||
late String channelDescription;
|
late String channelDescription;
|
||||||
Importance importance;
|
Importance importance;
|
||||||
|
int? progPercent;
|
||||||
|
bool onlyAlertOnce;
|
||||||
|
|
||||||
ObtainiumNotification(this.id, this.title, this.message, this.channelCode,
|
ObtainiumNotification(this.id, this.title, this.message, this.channelCode,
|
||||||
this.channelName, this.channelDescription, this.importance);
|
this.channelName, this.channelDescription, this.importance,
|
||||||
|
{this.onlyAlertOnce = false, this.progPercent});
|
||||||
}
|
}
|
||||||
|
|
||||||
class UpdateNotification extends ObtainiumNotification {
|
class UpdateNotification extends ObtainiumNotification {
|
||||||
UpdateNotification(List<App> updates)
|
UpdateNotification(List<App> updates)
|
||||||
: super(
|
: super(
|
||||||
2,
|
2,
|
||||||
'Updates Available',
|
tr('updatesAvailable'),
|
||||||
'',
|
'',
|
||||||
'UPDATES_AVAILABLE',
|
'UPDATES_AVAILABLE',
|
||||||
'Updates Available',
|
tr('updatesAvailable'),
|
||||||
'Notifies the user that updates are available for one or more Apps tracked by Obtainium',
|
tr('updatesAvailableNotifDescription'),
|
||||||
Importance.max) {
|
Importance.max) {
|
||||||
message = updates.isEmpty
|
message = updates.isEmpty
|
||||||
? "No new updates."
|
? tr('noNewUpdates')
|
||||||
: updates.length == 1
|
: updates.length == 1
|
||||||
? '${updates[0].name} has an update.'
|
? tr('xHasAnUpdate', args: [updates[0].name])
|
||||||
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.';
|
: plural('xAndNMoreUpdatesAvailable', updates.length - 1,
|
||||||
|
args: [updates[0].name, (updates.length - 1).toString()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SilentUpdateNotification extends ObtainiumNotification {
|
class SilentUpdateNotification extends ObtainiumNotification {
|
||||||
SilentUpdateNotification(List<App> updates)
|
SilentUpdateNotification(List<App> updates)
|
||||||
: super(
|
: super(3, tr('appsUpdated'), '', 'APPS_UPDATED', tr('appsUpdated'),
|
||||||
3,
|
tr('appsUpdatedNotifDescription'), Importance.defaultImportance) {
|
||||||
'Apps Updated',
|
|
||||||
'',
|
|
||||||
'APPS_UPDATED',
|
|
||||||
'Apps Updated',
|
|
||||||
'Notifies the user that updates to one or more Apps were applied in the background',
|
|
||||||
Importance.defaultImportance) {
|
|
||||||
message = updates.length == 1
|
message = updates.length == 1
|
||||||
? '${updates[0].name} was updated to ${updates[0].latestVersion}.'
|
? tr('xWasUpdatedToY',
|
||||||
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} were updated.';
|
args: [updates[0].name, updates[0].latestVersion])
|
||||||
|
: plural('xAndNMoreUpdatesInstalled', updates.length - 1,
|
||||||
|
args: [updates[0].name, (updates.length - 1).toString()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,48 +56,56 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
|
|||||||
ErrorCheckingUpdatesNotification(String error)
|
ErrorCheckingUpdatesNotification(String error)
|
||||||
: super(
|
: super(
|
||||||
5,
|
5,
|
||||||
'Error Checking for Updates',
|
tr('errorCheckingUpdates'),
|
||||||
error,
|
error,
|
||||||
'BG_UPDATE_CHECK_ERROR',
|
'BG_UPDATE_CHECK_ERROR',
|
||||||
'Error Checking for Updates',
|
tr('errorCheckingUpdates'),
|
||||||
'A notification that shows when background update checking fails',
|
tr('errorCheckingUpdatesNotifDescription'),
|
||||||
Importance.high);
|
Importance.high);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppsRemovedNotification extends ObtainiumNotification {
|
class AppsRemovedNotification extends ObtainiumNotification {
|
||||||
AppsRemovedNotification(List<List<String>> namedReasons)
|
AppsRemovedNotification(List<List<String>> namedReasons)
|
||||||
: super(
|
: super(6, tr('appsRemoved'), '', 'APPS_REMOVED', tr('appsRemoved'),
|
||||||
6,
|
tr('appsRemovedNotifDescription'), Importance.max) {
|
||||||
'Apps Removed',
|
|
||||||
'',
|
|
||||||
'APPS_REMOVED',
|
|
||||||
'Apps Removed',
|
|
||||||
'Notifies the user that one or more Apps were removed due to errors while loading them',
|
|
||||||
Importance.max) {
|
|
||||||
message = '';
|
message = '';
|
||||||
for (var r in namedReasons) {
|
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();
|
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(
|
final completeInstallationNotification = ObtainiumNotification(
|
||||||
1,
|
1,
|
||||||
'Complete App Installation',
|
tr('completeAppInstallation'),
|
||||||
'Obtainium must be open to install Apps',
|
tr('obtainiumMustBeOpenToInstallApps'),
|
||||||
'COMPLETE_INSTALL',
|
'COMPLETE_INSTALL',
|
||||||
'Complete App Installation',
|
tr('completeAppInstallation'),
|
||||||
'Asks the user to return to Obtanium to finish installing an App',
|
tr('completeAppInstallationNotifDescription'),
|
||||||
Importance.max);
|
Importance.max);
|
||||||
|
|
||||||
final checkingUpdatesNotification = ObtainiumNotification(
|
final checkingUpdatesNotification = ObtainiumNotification(
|
||||||
4,
|
4,
|
||||||
'Checking for Updates',
|
tr('checkingForUpdates'),
|
||||||
'',
|
'',
|
||||||
'BG_UPDATE_CHECK',
|
'BG_UPDATE_CHECK',
|
||||||
'Checking for Updates',
|
tr('checkingForUpdates'),
|
||||||
'Transient notification that appears when checking for updates',
|
tr('checkingForUpdatesNotifDescription'),
|
||||||
Importance.min);
|
Importance.min);
|
||||||
|
|
||||||
class NotificationsProvider {
|
class NotificationsProvider {
|
||||||
@ -136,7 +145,9 @@ class NotificationsProvider {
|
|||||||
String channelName,
|
String channelName,
|
||||||
String channelDescription,
|
String channelDescription,
|
||||||
Importance importance,
|
Importance importance,
|
||||||
{bool cancelExisting = false}) async {
|
{bool cancelExisting = false,
|
||||||
|
int? progPercent,
|
||||||
|
bool onlyAlertOnce = false}) async {
|
||||||
if (cancelExisting) {
|
if (cancelExisting) {
|
||||||
await cancel(id);
|
await cancel(id);
|
||||||
}
|
}
|
||||||
@ -152,12 +163,18 @@ class NotificationsProvider {
|
|||||||
channelDescription: channelDescription,
|
channelDescription: channelDescription,
|
||||||
importance: importance,
|
importance: importance,
|
||||||
priority: importanceToPriority[importance]!,
|
priority: importanceToPriority[importance]!,
|
||||||
groupKey: 'dev.imranr.obtainium.$channelCode')));
|
groupKey: 'dev.imranr.obtainium.$channelCode',
|
||||||
|
progress: progPercent ?? 0,
|
||||||
|
maxProgress: 100,
|
||||||
|
showProgress: progPercent != null,
|
||||||
|
onlyAlertOnce: onlyAlertOnce)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> notify(ObtainiumNotification notif,
|
Future<void> notify(ObtainiumNotification notif,
|
||||||
{bool cancelExisting = false}) =>
|
{bool cancelExisting = false}) =>
|
||||||
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode,
|
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode,
|
||||||
notif.channelName, notif.channelDescription, notif.importance,
|
notif.channelName, notif.channelDescription, notif.importance,
|
||||||
cancelExisting: cancelExisting);
|
cancelExisting: cancelExisting,
|
||||||
|
onlyAlertOnce: notif.onlyAlertOnce,
|
||||||
|
progPercent: notif.progPercent);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
// Exposes functions used to save/load app settings
|
// Exposes functions used to save/load app settings
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:obtainium/app_sources/github.dart';
|
import 'package:obtainium/app_sources/github.dart';
|
||||||
@ -109,8 +110,7 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
while (!(await Permission.requestInstallPackages.isGranted)) {
|
while (!(await Permission.requestInstallPackages.isGranted)) {
|
||||||
// Explicit request as InstallPlugin request sometimes bugged
|
// Explicit request as InstallPlugin request sometimes bugged
|
||||||
Fluttertoast.showToast(
|
Fluttertoast.showToast(
|
||||||
msg: 'Please allow Obtainium to install Apps',
|
msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG);
|
||||||
toastLength: Toast.LENGTH_LONG);
|
|
||||||
if ((await Permission.requestInstallPackages.request()) ==
|
if ((await Permission.requestInstallPackages.request()) ==
|
||||||
PermissionStatus.granted) {
|
PermissionStatus.granted) {
|
||||||
break;
|
break;
|
||||||
|
@ -3,7 +3,10 @@
|
|||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:html/dom.dart';
|
import 'package:html/dom.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/app_sources/apkmirror.dart';
|
||||||
import 'package:obtainium/app_sources/fdroid.dart';
|
import 'package:obtainium/app_sources/fdroid.dart';
|
||||||
import 'package:obtainium/app_sources/github.dart';
|
import 'package:obtainium/app_sources/github.dart';
|
||||||
import 'package:obtainium/app_sources/gitlab.dart';
|
import 'package:obtainium/app_sources/gitlab.dart';
|
||||||
@ -41,6 +44,7 @@ class App {
|
|||||||
late List<String> additionalData;
|
late List<String> additionalData;
|
||||||
late DateTime? lastUpdateCheck;
|
late DateTime? lastUpdateCheck;
|
||||||
bool pinned = false;
|
bool pinned = false;
|
||||||
|
bool trackOnly = false;
|
||||||
App(
|
App(
|
||||||
this.id,
|
this.id,
|
||||||
this.url,
|
this.url,
|
||||||
@ -52,7 +56,8 @@ class App {
|
|||||||
this.preferredApkIndex,
|
this.preferredApkIndex,
|
||||||
this.additionalData,
|
this.additionalData,
|
||||||
this.lastUpdateCheck,
|
this.lastUpdateCheck,
|
||||||
this.pinned);
|
this.pinned,
|
||||||
|
this.trackOnly);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
@ -73,12 +78,15 @@ class App {
|
|||||||
: List<String>.from(jsonDecode(json['apkUrls'])),
|
: List<String>.from(jsonDecode(json['apkUrls'])),
|
||||||
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
|
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
|
||||||
json['additionalData'] == null
|
json['additionalData'] == null
|
||||||
? SourceProvider().getSource(json['url']).additionalDataDefaults
|
? SourceProvider()
|
||||||
|
.getSource(json['url'])
|
||||||
|
.additionalSourceAppSpecificDefaults
|
||||||
: List<String>.from(jsonDecode(json['additionalData'])),
|
: List<String>.from(jsonDecode(json['additionalData'])),
|
||||||
json['lastUpdateCheck'] == null
|
json['lastUpdateCheck'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
|
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
|
||||||
json['pinned'] ?? false);
|
json['pinned'] ?? false,
|
||||||
|
json['trackOnly'] ?? false);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
@ -91,7 +99,8 @@ class App {
|
|||||||
'preferredApkIndex': preferredApkIndex,
|
'preferredApkIndex': preferredApkIndex,
|
||||||
'additionalData': jsonEncode(additionalData),
|
'additionalData': jsonEncode(additionalData),
|
||||||
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
|
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
|
||||||
'pinned': pinned
|
'pinned': pinned,
|
||||||
|
'trackOnly': trackOnly
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,13 +121,6 @@ preStandardizeUrl(String url) {
|
|||||||
return 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';
|
const String noAPKFound = 'No APK found';
|
||||||
|
|
||||||
List<String> getLinksFromParsedHTML(
|
List<String> getLinksFromParsedHTML(
|
||||||
@ -134,12 +136,14 @@ List<String> getLinksFromParsedHTML(
|
|||||||
|
|
||||||
class AppSource {
|
class AppSource {
|
||||||
late String host;
|
late String host;
|
||||||
|
bool enforceTrackOnly = false;
|
||||||
String standardizeURL(String url) {
|
String standardizeURL(String url) {
|
||||||
throw NotImplementedError();
|
throw NotImplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<APKDetails> getLatestAPKDetails(
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
String standardUrl, List<String> additionalData) {
|
String standardUrl, List<String> additionalData,
|
||||||
|
{bool trackOnly = false}) {
|
||||||
throw NotImplementedError();
|
throw NotImplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,21 +151,43 @@ class AppSource {
|
|||||||
throw NotImplementedError();
|
throw NotImplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
// Different Sources may need different kinds of additional data for Apps
|
||||||
List<String> additionalDataDefaults = [];
|
List<List<GeneratedFormItem>> additionalSourceAppSpecificFormItems = [];
|
||||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
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) {
|
String? changeLogPageFromStandardUrl(String standardUrl) {
|
||||||
throw NotImplementedError();
|
throw NotImplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> apkUrlPrefetchModifier(String apkUrl) {
|
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
|
||||||
throw NotImplementedError();
|
return apkUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool canSearch = false;
|
bool canSearch = false;
|
||||||
Future<Map<String, String>> search(String query) {
|
Future<Map<String, String>> search(String query) {
|
||||||
throw NotImplementedError();
|
throw NotImplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? tryInferringAppId(String standardUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ObtainiumError getObtainiumHttpError(Response res) {
|
||||||
|
return ObtainiumError(res.reasonPhrase ??
|
||||||
|
tr('errorWithHttpStatusCode', args: [res.statusCode.toString()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class MassAppUrlSource {
|
abstract class MassAppUrlSource {
|
||||||
@ -179,7 +205,8 @@ class SourceProvider {
|
|||||||
IzzyOnDroid(),
|
IzzyOnDroid(),
|
||||||
Mullvad(),
|
Mullvad(),
|
||||||
Signal(),
|
Signal(),
|
||||||
SourceForge()
|
SourceForge(),
|
||||||
|
APKMirror()
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add more mass url source classes here so they are available via the service
|
// Add more mass url source classes here so they are available via the service
|
||||||
@ -201,7 +228,7 @@ class SourceProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool ifSourceAppsRequireAdditionalData(AppSource source) {
|
bool ifSourceAppsRequireAdditionalData(AppSource source) {
|
||||||
for (var row in source.additionalDataFormItems) {
|
for (var row in source.additionalSourceAppSpecificFormItems) {
|
||||||
for (var element in row) {
|
for (var element in row) {
|
||||||
if (element.required) {
|
if (element.required) {
|
||||||
return true;
|
return true;
|
||||||
@ -221,49 +248,60 @@ class SourceProvider {
|
|||||||
}
|
}
|
||||||
for (int i = 0; i < parts.length - 1; i++) {
|
for (int i = 0; i < parts.length - 1; i++) {
|
||||||
if (RegExp('.*[A-Z].*').hasMatch(parts[i])) {
|
if (RegExp('.*[A-Z].*').hasMatch(parts[i])) {
|
||||||
|
// TODO: Look into RegEx for non-Latin characters
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return getSourceHosts().contains(parts.last);
|
return sources.map((e) => e.host).contains(parts.last);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<App> getApp(AppSource source, String url, List<String> additionalData,
|
Future<App> getApp(AppSource source, String url, List<String> additionalData,
|
||||||
{String name = '', String? id, bool pinned = false}) async {
|
{String name = '',
|
||||||
|
String? id,
|
||||||
|
bool pinned = false,
|
||||||
|
bool trackOnly = false,
|
||||||
|
String? installedVersion}) async {
|
||||||
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
||||||
AppNames names = source.getAppNames(standardUrl);
|
AppNames names = source.getAppNames(standardUrl);
|
||||||
APKDetails apk =
|
APKDetails apk = await source
|
||||||
await source.getLatestAPKDetails(standardUrl, additionalData);
|
.getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly);
|
||||||
|
if (apk.apkUrls.isEmpty && !trackOnly) {
|
||||||
|
throw NoAPKError();
|
||||||
|
}
|
||||||
|
String apkVersion = apk.version.replaceAll('/', '-');
|
||||||
return App(
|
return App(
|
||||||
id ?? generateTempID(names, source),
|
id ??
|
||||||
|
source.tryInferringAppId(standardUrl) ??
|
||||||
|
generateTempID(names, source),
|
||||||
standardUrl,
|
standardUrl,
|
||||||
names.author[0].toUpperCase() + names.author.substring(1),
|
names.author[0].toUpperCase() + names.author.substring(1),
|
||||||
name.trim().isNotEmpty
|
name.trim().isNotEmpty
|
||||||
? name
|
? name
|
||||||
: names.name[0].toUpperCase() + names.name.substring(1),
|
: names.name[0].toUpperCase() + names.name.substring(1),
|
||||||
null,
|
installedVersion,
|
||||||
apk.version.replaceAll('/', '-'),
|
apkVersion,
|
||||||
apk.apkUrls,
|
apk.apkUrls,
|
||||||
apk.apkUrls.length - 1,
|
apk.apkUrls.length - 1,
|
||||||
additionalData,
|
additionalData,
|
||||||
DateTime.now(),
|
DateTime.now(),
|
||||||
pinned);
|
pinned,
|
||||||
|
trackOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns errors in [results, errors] instead of throwing them
|
// Returns errors in [results, errors] instead of throwing them
|
||||||
Future<List<dynamic>> getApps(List<String> urls,
|
Future<List<dynamic>> getAppsByURLNaive(List<String> urls,
|
||||||
{List<String> ignoreUrls = const []}) async {
|
{List<String> ignoreUrls = const []}) async {
|
||||||
List<App> apps = [];
|
List<App> apps = [];
|
||||||
Map<String, dynamic> errors = {};
|
Map<String, dynamic> errors = {};
|
||||||
for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
|
for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
|
||||||
try {
|
try {
|
||||||
var source = getSource(url);
|
var source = getSource(url);
|
||||||
apps.add(await getApp(source, url, source.additionalDataDefaults));
|
apps.add(await getApp(
|
||||||
|
source, url, source.additionalSourceAppSpecificDefaults));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errors.addAll(<String, dynamic>{url: e});
|
errors.addAll(<String, dynamic>{url: e});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [apps, errors];
|
return [apps, errors];
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
|
|
||||||
}
|
}
|
||||||
|
71
pubspec.lock
71
pubspec.lock
@ -21,7 +21,7 @@ packages:
|
|||||||
name: archive
|
name: archive
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.4"
|
version: "3.3.5"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -141,6 +141,20 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.4"
|
version: "1.5.4"
|
||||||
|
easy_localization:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: easy_localization
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
|
easy_logger:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: easy_logger
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.0.2"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -168,7 +182,7 @@ packages:
|
|||||||
name: file_picker
|
name: file_picker
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.2.2"
|
version: "5.2.3"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -180,14 +194,14 @@ packages:
|
|||||||
name: flutter_fgbg
|
name: flutter_fgbg
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.1"
|
version: "0.2.2"
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: flutter_launcher_icons
|
name: flutter_launcher_icons
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.10.0"
|
version: "0.11.0"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@ -201,7 +215,7 @@ packages:
|
|||||||
name: flutter_local_notifications
|
name: flutter_local_notifications
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "12.0.3"
|
version: "12.0.4"
|
||||||
flutter_local_notifications_linux:
|
flutter_local_notifications_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -216,6 +230,11 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_localizations:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -282,6 +301,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.1"
|
version: "1.3.1"
|
||||||
|
intl:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.17.0"
|
||||||
js:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -330,7 +356,7 @@ packages:
|
|||||||
name: mime
|
name: mime
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
version: "1.0.3"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -372,7 +398,7 @@ packages:
|
|||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.21"
|
version: "2.0.22"
|
||||||
path_provider_ios:
|
path_provider_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -491,7 +517,7 @@ packages:
|
|||||||
name: share_plus
|
name: share_plus
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.2.0"
|
version: "6.3.0"
|
||||||
share_plus_platform_interface:
|
share_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -567,6 +593,20 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.0"
|
version: "1.9.0"
|
||||||
|
sqflite:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sqflite
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.1"
|
||||||
|
sqflite_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_common
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.0+2"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -588,6 +628,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
synchronized:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: synchronized
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0+3"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -622,14 +669,14 @@ packages:
|
|||||||
name: url_launcher
|
name: url_launcher
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.6"
|
version: "6.1.7"
|
||||||
url_launcher_android:
|
url_launcher_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.21"
|
version: "6.0.22"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -678,7 +725,7 @@ packages:
|
|||||||
name: uuid
|
name: uuid
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.6"
|
version: "3.0.7"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -720,7 +767,7 @@ packages:
|
|||||||
name: win32
|
name: win32
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.1.2"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
11
pubspec.yaml
11
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
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.7.1+57 # When changing this, update the tag in main() accordingly
|
version: 0.8.10+74 # When changing this, update the tag in main() accordingly
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=2.18.2 <3.0.0'
|
sdk: '>=2.18.2 <3.0.0'
|
||||||
@ -56,12 +56,14 @@ dependencies:
|
|||||||
installed_apps: ^1.3.1
|
installed_apps: ^1.3.1
|
||||||
package_archive_info: ^0.1.0
|
package_archive_info: ^0.1.0
|
||||||
android_alarm_manager_plus: ^2.1.0
|
android_alarm_manager_plus: ^2.1.0
|
||||||
|
sqflite: ^2.2.0+3
|
||||||
|
easy_localization: ^3.0.1
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_launcher_icons: ^0.10.0
|
flutter_launcher_icons: ^0.11.0
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
@ -88,9 +90,12 @@ flutter:
|
|||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# - assets:
|
||||||
# - images/a_dot_burr.jpeg
|
# - images/a_dot_burr.jpeg
|
||||||
# - images/a_dot_ham.jpeg
|
# - images/a_dot_ham.jpeg
|
||||||
|
|
||||||
|
assets:
|
||||||
|
- assets/translations/
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/assets-and-images/#resolution-aware
|
# https://flutter.dev/assets-and-images/#resolution-aware
|
||||||
|
Reference in New Issue
Block a user