Compare commits
82 Commits
v0.6.7-bet
...
v0.8.8-bet
Author | SHA1 | Date | |
---|---|---|---|
f81f6374bb | |||
da8695834e | |||
c4ba1e9dbc | |||
49862ad2a6 | |||
1b892f4e0d | |||
a4555f07f9 | |||
73fbdd84f0 | |||
a1518480db | |||
fd3ee02e52 | |||
609366675d | |||
fbff498ae1 | |||
bb4e470760 | |||
15183c3a95 | |||
b496a416ff | |||
6ac7ba204f | |||
0951c007d1 | |||
d835beec76 | |||
2654bf12d3 | |||
3951108bc9 | |||
d934ce2e13 | |||
66cc7f059f | |||
098428dac9 | |||
9e7c21b408 | |||
31c2c6b7c1 | |||
f70049aded | |||
60c28bf912 | |||
a6ed1e7c98 | |||
963f51dc53 | |||
17b1f6e5b0 | |||
086b2b949f | |||
9b5b212e96 | |||
6c8f9ebcbf | |||
4d5773bdcc | |||
f81ef6a416 | |||
47324fcb49 | |||
377e0e07bd | |||
b5aae70274 | |||
42475fa42a | |||
d29534ef2e | |||
25953399ac | |||
b04d2fad5c | |||
868ba84c9a | |||
602f0c3bb2 | |||
00721e8ac4 | |||
d19f9101d6 | |||
a4bc278e4c | |||
b04986622b | |||
2059e4fd44 | |||
618a1523cf | |||
ba1cdc2c73 | |||
aa2a25fffe | |||
c8ec67aef3 | |||
9576a99a4e | |||
0202224fa6 | |||
631ffd5c34 | |||
feed7ffc0b | |||
296485de8a | |||
d2f226d442 | |||
cbdb449e35 | |||
3100a3a08c | |||
18951d6461 | |||
0e0a39a40f | |||
55cae0620b | |||
ba6cea3ae6 | |||
4be33374c2 | |||
e2bf834981 | |||
9bd7ddb21b | |||
905a807ee9 | |||
ab57b97875 | |||
5db2c5f0b1 | |||
e158c23cca | |||
208f125e12 | |||
b7ccf3fa49 | |||
c746e89052 | |||
ee758e8470 | |||
68d903e092 | |||
c47b752344 | |||
62a05996cf | |||
1cda941fbe | |||
49cb908d04 | |||
139f44d31d | |||
ed955ac6a2 |
1
.gitignore
vendored
@ -9,6 +9,7 @@
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
.vscode/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
|
@ -13,9 +13,10 @@ Currently supported App sources:
|
||||
- [IzzyOnDroid](https://android.izzysoft.de/)
|
||||
- [Mullvad](https://mullvad.net/en/)
|
||||
- [Signal](https://signal.org/)
|
||||
- [APKMirror](https://apkmirror.com/) (Track-Only)
|
||||
|
||||
## Limitations
|
||||
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
|
||||
- App installs happen asynchronously and the success/failure of an install cannot be determined directly. This results in install statuses and versions sometimes being out of sync with the OS until the next launch or until the problem is manually corrected.
|
||||
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
|
||||
- For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable.
|
||||
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.7 KiB |
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.6 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 918 B After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 6.2 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 109 KiB |
Before Width: | Height: | Size: 21 KiB |
217
assets/translations/en.json
Normal file
@ -0,0 +1,217 @@
|
||||
{
|
||||
"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 {}",
|
||||
"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."
|
||||
}
|
||||
}
|
@ -1,12 +1,13 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class APKMirror implements AppSource {
|
||||
@override
|
||||
late String host = 'apkmirror.com';
|
||||
class APKMirror extends AppSource {
|
||||
APKMirror() {
|
||||
host = 'apkmirror.com';
|
||||
enforceTrackOnly = true;
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
@ -20,79 +21,32 @@ class APKMirror implements AppSource {
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl#whatsnew';
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
|
||||
var originalUri = Uri.parse(apkUrl);
|
||||
var res = await get(originalUri);
|
||||
if (res.statusCode != 200) {
|
||||
throw false;
|
||||
}
|
||||
var href =
|
||||
parse(res.body).querySelector('.downloadButton')?.attributes['href'];
|
||||
if (href == null) {
|
||||
throw false;
|
||||
}
|
||||
var res2 = await get(Uri.parse('${originalUri.origin}$href'), headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
|
||||
});
|
||||
if (res2.statusCode != 200) {
|
||||
throw false;
|
||||
}
|
||||
var links = parse(res2.body)
|
||||
.querySelectorAll('a')
|
||||
.where((element) => element.innerHtml == 'here')
|
||||
.map((e) => e.attributes['href'])
|
||||
.where((element) => element != null)
|
||||
.toList();
|
||||
if (links.isEmpty) {
|
||||
throw false;
|
||||
}
|
||||
return '${originalUri.origin}${links[0]}';
|
||||
}
|
||||
'$standardUrl/#whatsnew';
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/feed'));
|
||||
if (res.statusCode != 200) {
|
||||
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();
|
||||
}
|
||||
var nextUrl = parse(res.body)
|
||||
.querySelector('item')
|
||||
?.querySelector('link')
|
||||
?.nextElementSibling
|
||||
?.innerHtml;
|
||||
if (nextUrl == null) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
Response res2 = await get(Uri.parse(nextUrl), headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
|
||||
});
|
||||
if (res2.statusCode != 200) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
var html2 = parse(res2.body);
|
||||
var origin = Uri.parse(standardUrl).origin;
|
||||
List<String> apkUrls = html2
|
||||
.querySelectorAll('.apkm-badge')
|
||||
.map((e) => e.innerHtml != 'APK'
|
||||
? ''
|
||||
: e.previousElementSibling?.attributes['href'] ?? '')
|
||||
.where((element) => element.isNotEmpty)
|
||||
.map((e) => '$origin$e')
|
||||
.toList();
|
||||
if (apkUrls.isEmpty) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
var version = html2.querySelector('span.active.accent_color')?.innerHtml;
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, apkUrls);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -101,13 +55,4 @@ class APKMirror implements AppSource {
|
||||
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
||||
return AppNames(names[1], names[2]);
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,12 +1,13 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class FDroid implements AppSource {
|
||||
@override
|
||||
late String host = 'f-droid.org';
|
||||
class FDroid extends AppSource {
|
||||
FDroid() {
|
||||
host = 'f-droid.org';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
@ -28,45 +29,25 @@ class FDroid implements AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
String? tryInferringAppId(String standardUrl) {
|
||||
return Uri.parse(standardUrl).pathSegments.last;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
Response res = await get(Uri.parse(standardUrl));
|
||||
APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||
Response res, String apkUrlPrefix) {
|
||||
if (res.statusCode == 200) {
|
||||
var releases = parse(res.body).querySelectorAll('.package-version');
|
||||
List<dynamic> releases = jsonDecode(res.body)['packages'] ?? [];
|
||||
if (releases.isEmpty) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
String? latestVersion = releases[0]
|
||||
.querySelector('.package-version-header b')
|
||||
?.innerHtml
|
||||
.split(' ')
|
||||
.sublist(1)
|
||||
.join(' ');
|
||||
String? latestVersion = releases[0]['versionName'];
|
||||
if (latestVersion == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
List<String> apkUrls = releases
|
||||
.where((element) =>
|
||||
element
|
||||
.querySelector('.package-version-header b')
|
||||
?.innerHtml
|
||||
.split(' ')
|
||||
.sublist(1)
|
||||
.join(' ') ==
|
||||
latestVersion)
|
||||
.map((e) =>
|
||||
e
|
||||
.querySelector('.package-version-download a')
|
||||
?.attributes['href'] ??
|
||||
'')
|
||||
.where((element) => element.isNotEmpty)
|
||||
.where((element) => element['versionName'] == latestVersion)
|
||||
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
|
||||
.toList();
|
||||
if (apkUrls.isEmpty) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
return APKDetails(latestVersion, apkUrls);
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
@ -74,16 +55,17 @@ class FDroid implements AppSource {
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
String? appId = tryInferringAppId(standardUrl);
|
||||
return getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||
await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')),
|
||||
'https://f-droid.org/repo/$appId');
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
@ -7,9 +8,82 @@ import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class GitHub implements AppSource {
|
||||
@override
|
||||
late String host = 'github.com';
|
||||
class GitHub extends AppSource {
|
||||
GitHub() {
|
||||
host = 'github.com';
|
||||
|
||||
additionalSourceAppSpecificDefaults = ['true', 'true', ''];
|
||||
|
||||
additionalSourceSpecificSettingFormItems = [
|
||||
GeneratedFormItem(
|
||||
label: tr('githubPATLabel'),
|
||||
id: 'github-creds',
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value != null && value.trim().isNotEmpty) {
|
||||
if (value
|
||||
.split(':')
|
||||
.where((element) => element.trim().isNotEmpty)
|
||||
.length !=
|
||||
2) {
|
||||
return tr('githubPATHint');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
],
|
||||
hint: tr('githubPATFormat'),
|
||||
belowWidgets: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text(
|
||||
tr('githubPATLinkText'),
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline, fontSize: 12),
|
||||
))
|
||||
])
|
||||
];
|
||||
|
||||
additionalSourceAppSpecificFormItems = [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr('includePrereleases'), type: FormItemType.bool)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr('fallbackToOlderReleases'), type: FormItemType.bool)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr('filterReleaseTitlesByRegEx'),
|
||||
type: FormItemType.string,
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RegExp(value);
|
||||
} catch (e) {
|
||||
return tr('invalidRegEx');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
])
|
||||
]
|
||||
];
|
||||
|
||||
canSearch = true;
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
@ -24,8 +98,8 @@ class GitHub implements AppSource {
|
||||
Future<String> getCredentialPrefixIfAny() async {
|
||||
SettingsProvider settingsProvider = SettingsProvider();
|
||||
await settingsProvider.initializeSettings();
|
||||
String? creds =
|
||||
settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id);
|
||||
String? creds = settingsProvider
|
||||
.getSettingString(additionalSourceSpecificSettingFormItems[0].id);
|
||||
return creds != null && creds.isNotEmpty ? '$creds@' : '';
|
||||
}
|
||||
|
||||
@ -33,12 +107,10 @@ class GitHub implements AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl/releases';
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
var includePrereleases =
|
||||
additionalData.isNotEmpty && additionalData[0] == 'true';
|
||||
var fallbackToOlderReleases =
|
||||
@ -72,11 +144,11 @@ class GitHub implements AppSource {
|
||||
|
||||
if (regexFilter != null &&
|
||||
!RegExp(regexFilter)
|
||||
.hasMatch((releases[i]['tag_name'] as String).trim())) {
|
||||
.hasMatch((releases[i]['name'] as String).trim())) {
|
||||
continue;
|
||||
}
|
||||
var apkUrls = getReleaseAPKUrls(releases[i]);
|
||||
if (apkUrls.isEmpty) {
|
||||
if (apkUrls.isEmpty && !trackOnly) {
|
||||
continue;
|
||||
}
|
||||
targetRelease = releases[i];
|
||||
@ -86,23 +158,14 @@ class GitHub implements AppSource {
|
||||
if (targetRelease == null) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
String? version = targetRelease['tag_name'];
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, targetRelease['apkUrls']);
|
||||
return APKDetails(version, targetRelease['apkUrls'] as List<String>);
|
||||
} else {
|
||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||
throw RateLimitError(
|
||||
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||
60000000)
|
||||
.round());
|
||||
}
|
||||
|
||||
throw NoReleasesError();
|
||||
rateLimitErrorCheck(res);
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,72 +177,31 @@ class GitHub implements AppSource {
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [
|
||||
[GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Fallback to older releases', type: FormItemType.bool)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Filter Release Titles by Regular Expression',
|
||||
type: FormItemType.string,
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RegExp(value);
|
||||
} catch (e) {
|
||||
return 'Invalid regular expression';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
])
|
||||
]
|
||||
];
|
||||
Future<Map<String, String>> search(String query) async {
|
||||
Response res = await get(Uri.parse(
|
||||
'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100'));
|
||||
if (res.statusCode == 200) {
|
||||
Map<String, String> urlsWithDescriptions = {};
|
||||
for (var e in (jsonDecode(res.body)['items'] as List<dynamic>)) {
|
||||
urlsWithDescriptions.addAll({
|
||||
e['html_url'] as String: e['description'] != null
|
||||
? e['description'] as String
|
||||
: tr('noDescription')
|
||||
});
|
||||
}
|
||||
return urlsWithDescriptions;
|
||||
} else {
|
||||
rateLimitErrorCheck(res);
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = ['true', 'true', ''];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [
|
||||
GeneratedFormItem(
|
||||
label: 'GitHub Personal Access Token (Increases Rate Limit)',
|
||||
id: 'github-creds',
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value != null && value.trim().isNotEmpty) {
|
||||
if (value
|
||||
.split(':')
|
||||
.where((element) => element.trim().isNotEmpty)
|
||||
.length !=
|
||||
2) {
|
||||
return 'PAT must be in this format: username:token';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
],
|
||||
hint: 'username:token',
|
||||
belowWidgets: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: const Text(
|
||||
'About GitHub PATs',
|
||||
style: TextStyle(
|
||||
decoration: TextDecoration.underline, fontSize: 12),
|
||||
))
|
||||
])
|
||||
];
|
||||
rateLimitErrorCheck(Response res) {
|
||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||
throw RateLimitError(
|
||||
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||
60000000)
|
||||
.round());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class GitLab implements AppSource {
|
||||
@override
|
||||
late String host = 'gitlab.com';
|
||||
class GitLab extends AppSource {
|
||||
GitLab() {
|
||||
host = 'gitlab.com';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
@ -23,12 +23,10 @@ class GitLab implements AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl/-/releases';
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
|
||||
if (res.statusCode == 200) {
|
||||
var standardUri = Uri.parse(standardUrl);
|
||||
@ -36,7 +34,7 @@ class GitLab implements AppSource {
|
||||
var entry = parsedHtml.querySelector('entry');
|
||||
var entryContent =
|
||||
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
|
||||
var apkUrlList = [
|
||||
var apkUrls = [
|
||||
...getLinksFromParsedHTML(
|
||||
entryContent,
|
||||
RegExp(
|
||||
@ -51,9 +49,6 @@ class GitLab implements AppSource {
|
||||
.where((element) => Uri.parse(element).host != '')
|
||||
.toList()
|
||||
];
|
||||
if (apkUrlList.isEmpty) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
|
||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||
var version =
|
||||
@ -61,7 +56,7 @@ class GitLab implements AppSource {
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, apkUrlList);
|
||||
return APKDetails(version, apkUrls);
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
@ -72,13 +67,4 @@ class GitLab implements AppSource {
|
||||
// Same as GitHub
|
||||
return GitHub().getAppNames(standardUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/app_sources/fdroid.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class IzzyOnDroid implements AppSource {
|
||||
@override
|
||||
late String host = 'android.izzysoft.de';
|
||||
class IzzyOnDroid extends AppSource {
|
||||
IzzyOnDroid() {
|
||||
host = 'android.izzysoft.de';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
@ -22,54 +22,23 @@ class IzzyOnDroid implements AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
String? tryInferringAppId(String standardUrl) {
|
||||
return FDroid().tryInferringAppId(standardUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
Response res = await get(Uri.parse(standardUrl));
|
||||
if (res.statusCode == 200) {
|
||||
var parsedHtml = parse(res.body);
|
||||
var multipleVersionApkUrls = parsedHtml
|
||||
.querySelectorAll('a')
|
||||
.where((element) =>
|
||||
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
|
||||
false)
|
||||
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
|
||||
.toList();
|
||||
if (multipleVersionApkUrls.isEmpty) {
|
||||
throw 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();
|
||||
}
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
String? appId = tryInferringAppId(standardUrl);
|
||||
return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||
await get(
|
||||
Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')),
|
||||
'https://android.izzysoft.de/frepo/$appId');
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class Mullvad implements AppSource {
|
||||
@override
|
||||
late String host = 'mullvad.net';
|
||||
class Mullvad extends AppSource {
|
||||
Mullvad() {
|
||||
host = 'mullvad.net';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
@ -22,12 +22,10 @@ class Mullvad implements AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md';
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
|
||||
if (res.statusCode == 200) {
|
||||
var version = parse(res.body)
|
||||
@ -50,13 +48,4 @@ class Mullvad implements AppSource {
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class Signal implements AppSource {
|
||||
@override
|
||||
late String host = 'signal.org';
|
||||
class Signal extends AppSource {
|
||||
Signal() {
|
||||
host = 'signal.org';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
@ -16,25 +16,21 @@ class Signal implements AppSource {
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res =
|
||||
await get(Uri.parse('https://updates.$host/android/latest.json'));
|
||||
if (res.statusCode == 200) {
|
||||
var json = jsonDecode(res.body);
|
||||
String? apkUrl = json['url'];
|
||||
if (apkUrl == null) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
List<String> apkUrls = apkUrl == null ? [] : [apkUrl];
|
||||
String? version = json['versionName'];
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, [apkUrl]);
|
||||
return APKDetails(version, apkUrls);
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
@ -42,13 +38,4 @@ class Signal implements AppSource {
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class SourceForge implements AppSource {
|
||||
@override
|
||||
late String host = 'sourceforge.net';
|
||||
class SourceForge extends AppSource {
|
||||
SourceForge() {
|
||||
host = 'sourceforge.net';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
@ -21,12 +21,10 @@ class SourceForge implements AppSource {
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/rss?path=/'));
|
||||
if (res.statusCode == 200) {
|
||||
var parsedHtml = parse(res.body);
|
||||
@ -52,9 +50,6 @@ class SourceForge implements AppSource {
|
||||
apkUrlListAllReleases // This can be used skipped for fallback support later
|
||||
.where((element) => getVersion(element) == version)
|
||||
.toList();
|
||||
if (apkUrlList.isEmpty) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
return APKDetails(version, apkUrlList);
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
@ -66,13 +61,4 @@ class SourceForge implements AppSource {
|
||||
return AppNames(runtimeType.toString(),
|
||||
standardUrl.substring(standardUrl.lastIndexOf('/') + 1));
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,10 +1,13 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum FormItemType { string, bool }
|
||||
|
||||
typedef OnValueChanges = void Function(List<String> values, bool valid);
|
||||
typedef OnValueChanges = void Function(
|
||||
List<String> values, bool valid, bool isBuilding);
|
||||
|
||||
class GeneratedFormItem {
|
||||
late String key;
|
||||
late String label;
|
||||
late FormItemType type;
|
||||
late bool required;
|
||||
@ -13,6 +16,7 @@ class GeneratedFormItem {
|
||||
late String id;
|
||||
late List<Widget> belowWidgets;
|
||||
late String? hint;
|
||||
late List<String>? opts;
|
||||
|
||||
GeneratedFormItem(
|
||||
{this.label = 'Input',
|
||||
@ -22,7 +26,9 @@ class GeneratedFormItem {
|
||||
this.additionalValidators = const [],
|
||||
this.id = 'input',
|
||||
this.belowWidgets = const [],
|
||||
this.hint});
|
||||
this.hint,
|
||||
this.opts,
|
||||
this.key = 'default'});
|
||||
}
|
||||
|
||||
class GeneratedForm extends StatefulWidget {
|
||||
@ -47,7 +53,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
List<List<Widget>> rows = [];
|
||||
|
||||
// If any value changes, call this to update the parent with value and validity
|
||||
void someValueChanged() {
|
||||
void someValueChanged({bool isBuilding = false}) {
|
||||
List<String> returnValues = [];
|
||||
var valid = true;
|
||||
for (int r = 0; r < values.length; r++) {
|
||||
@ -62,7 +68,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
}
|
||||
}
|
||||
}
|
||||
widget.onValueChanges(returnValues, valid);
|
||||
widget.onValueChanges(returnValues, valid, isBuilding);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -75,14 +81,16 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
.map((row) => row.map((e) {
|
||||
return j < widget.defaultValues.length
|
||||
? widget.defaultValues[j++]
|
||||
: '';
|
||||
: e.opts != null
|
||||
? e.opts!.first
|
||||
: '';
|
||||
}).toList())
|
||||
.toList();
|
||||
|
||||
// Dynamically create form inputs
|
||||
formInputs = widget.items.asMap().entries.map((row) {
|
||||
return row.value.asMap().entries.map((e) {
|
||||
if (e.value.type == FormItemType.string) {
|
||||
if (e.value.type == FormItemType.string && e.value.opts == null) {
|
||||
final formFieldKey = GlobalKey<FormFieldState>();
|
||||
return TextFormField(
|
||||
key: formFieldKey,
|
||||
@ -101,7 +109,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
maxLines: e.value.max <= 1 ? 1 : e.value.max,
|
||||
validator: (value) {
|
||||
if (e.value.required && (value == null || value.trim().isEmpty)) {
|
||||
return '${e.value.label} (required)';
|
||||
return '${e.value.label} ${tr('requiredInBrackets')}';
|
||||
}
|
||||
for (var validator in e.value.additionalValidators) {
|
||||
String? result = validator(value);
|
||||
@ -112,11 +120,29 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
return null;
|
||||
},
|
||||
);
|
||||
} else if (e.value.type == FormItemType.string &&
|
||||
e.value.opts != null) {
|
||||
if (e.value.opts!.isEmpty) {
|
||||
return Text(tr('dropdownNoOptsError'));
|
||||
}
|
||||
return DropdownButtonFormField(
|
||||
decoration: InputDecoration(labelText: tr('colour')),
|
||||
value: values[row.key][e.key],
|
||||
items: e.value.opts!
|
||||
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
values[row.key][e.key] = value ?? e.value.opts!.first;
|
||||
someValueChanged();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return Container(); // Some input types added in build
|
||||
}
|
||||
}).toList();
|
||||
}).toList();
|
||||
someValueChanged(isBuilding: true);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -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/services.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
@ -29,7 +30,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
values = widget.defaultValues;
|
||||
valid = widget.initValid;
|
||||
valid = widget.initValid || widget.items.isEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -46,11 +47,16 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
),
|
||||
GeneratedForm(
|
||||
items: widget.items,
|
||||
onValueChanges: (values, valid) {
|
||||
setState(() {
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (isBuilding) {
|
||||
this.values = values;
|
||||
this.valid = valid;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
this.values = values;
|
||||
this.valid = valid;
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues: widget.defaultValues)
|
||||
]),
|
||||
@ -59,7 +65,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: const Text('Cancel')),
|
||||
child: Text(tr('cancel'))),
|
||||
TextButton(
|
||||
onPressed: !valid
|
||||
? null
|
||||
@ -69,7 +75,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
Navigator.of(context).pop(values);
|
||||
}
|
||||
},
|
||||
child: const Text('Continue'))
|
||||
child: Text(tr('continue')))
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -1,9 +1,12 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/logs_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class ObtainiumError {
|
||||
late String message;
|
||||
ObtainiumError(this.message);
|
||||
bool unexpected;
|
||||
ObtainiumError(this.message, {this.unexpected = false});
|
||||
@override
|
||||
String toString() {
|
||||
return message;
|
||||
@ -16,42 +19,46 @@ class RateLimitError {
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'Too many requests (rate limited) - try again in $remainingMinutes minutes';
|
||||
plural('tooManyRequestsTryAgainInMinutes', remainingMinutes);
|
||||
}
|
||||
|
||||
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 {
|
||||
NoReleasesError() : super('Could not find a suitable release');
|
||||
NoReleasesError() : super(tr('noReleaseFound'));
|
||||
}
|
||||
|
||||
class NoAPKError extends ObtainiumError {
|
||||
NoAPKError() : super('Could not find a suitable release');
|
||||
NoAPKError() : super(tr('noReleaseFound'));
|
||||
}
|
||||
|
||||
class NoVersionError extends ObtainiumError {
|
||||
NoVersionError() : super('Could not determine release version');
|
||||
NoVersionError() : super(tr('noVersionFound'));
|
||||
}
|
||||
|
||||
class UnsupportedURLError extends ObtainiumError {
|
||||
UnsupportedURLError() : super('URL does not match a known source');
|
||||
UnsupportedURLError() : super(tr('urlMatchesNoSource'));
|
||||
}
|
||||
|
||||
class DowngradeError extends ObtainiumError {
|
||||
DowngradeError() : super('Cannot install an older version of an App');
|
||||
DowngradeError() : super(tr('cantInstallOlderVersion'));
|
||||
}
|
||||
|
||||
class IDChangedError extends ObtainiumError {
|
||||
IDChangedError()
|
||||
: super('Downloaded package ID does not match existing App ID');
|
||||
IDChangedError() : super(tr('appIdMismatch'));
|
||||
}
|
||||
|
||||
class NotImplementedError extends ObtainiumError {
|
||||
NotImplementedError() : super(tr('functionNotImplemented'));
|
||||
}
|
||||
|
||||
class MultiAppMultiError extends ObtainiumError {
|
||||
Map<String, List<String>> content = {};
|
||||
|
||||
MultiAppMultiError() : super('Multiple Errors Placeholder');
|
||||
MultiAppMultiError() : super(tr('placeholder'), unexpected: true);
|
||||
|
||||
add(String appId, String string) {
|
||||
var tempIds = content.remove(string);
|
||||
@ -71,7 +78,9 @@ class MultiAppMultiError extends ObtainiumError {
|
||||
}
|
||||
|
||||
showError(dynamic e, BuildContext context) {
|
||||
if (e is String || (e is ObtainiumError && e is! MultiAppMultiError)) {
|
||||
Provider.of<LogsProvider>(context, listen: false)
|
||||
.add(e.toString(), level: LogLevels.error);
|
||||
if (e is String || (e is ObtainiumError && !e.unexpected)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
@ -82,17 +91,33 @@ showError(dynamic e, BuildContext context) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(e is MultiAppMultiError
|
||||
? 'Some Errors Occurred'
|
||||
: 'Unexpected Error'),
|
||||
? tr('someErrors')
|
||||
: tr('unexpectedError')),
|
||||
content: Text(e.toString()),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: const Text('Ok')),
|
||||
child: Text(tr('ok'))),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String list2FriendlyString(List<String> list) {
|
||||
return list.length == 2
|
||||
? '${list[0]} ${tr('and')} ${list[1]}'
|
||||
: list
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) =>
|
||||
e.value +
|
||||
(e.key == list.length - 1
|
||||
? ''
|
||||
: e.key == list.length - 2
|
||||
? ', and '
|
||||
: ', '))
|
||||
.join('');
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/pages/home.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/logs_provider.dart';
|
||||
import 'package:obtainium/providers/notifications_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
@ -14,25 +15,62 @@ import 'package:provider/provider.dart';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:easy_localization/src/easy_localization_controller.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:easy_localization/src/localization.dart';
|
||||
|
||||
const String currentVersion = '0.6.7';
|
||||
const String currentVersion = '0.8.8';
|
||||
const String currentReleaseTag =
|
||||
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
|
||||
const int bgUpdateCheckAlarmId = 666;
|
||||
|
||||
const supportedLocales = [Locale('en')];
|
||||
const fallbackLocale = Locale('en');
|
||||
const localeDir = 'assets/translations';
|
||||
|
||||
Future<void> loadTranslations() async {
|
||||
// See easy_localization/issues/210
|
||||
await EasyLocalizationController.initEasyLocation();
|
||||
final controller = EasyLocalizationController(
|
||||
saveLocale: true,
|
||||
fallbackLocale: fallbackLocale,
|
||||
supportedLocales: supportedLocales,
|
||||
assetLoader: const RootBundleAssetLoader(),
|
||||
useOnlyLangCode: false,
|
||||
useFallbackTranslations: true,
|
||||
path: localeDir,
|
||||
onLoadError: (FlutterError e) {
|
||||
throw e;
|
||||
},
|
||||
);
|
||||
await controller.loadTranslations();
|
||||
Localization.load(controller.locale,
|
||||
translations: controller.translations,
|
||||
fallbackTranslations: controller.fallbackTranslations);
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
||||
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await EasyLocalization.ensureInitialized();
|
||||
|
||||
await loadTranslations();
|
||||
|
||||
LogsProvider logs = LogsProvider();
|
||||
logs.add(tr('startedBgUpdateTask'));
|
||||
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
|
||||
await AndroidAlarmManager.initialize();
|
||||
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
|
||||
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
|
||||
: null;
|
||||
logs.add(tr('bgUpdateIgnoreAfterIs', args: [ignoreAfter.toString()]));
|
||||
var notificationsProvider = NotificationsProvider();
|
||||
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||
try {
|
||||
var appsProvider = AppsProvider(forBGTask: true);
|
||||
var appsProvider = AppsProvider();
|
||||
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||
await appsProvider.loadApps();
|
||||
List<String> existingUpdateIds =
|
||||
@ -40,17 +78,18 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
||||
DateTime nextIgnoreAfter = DateTime.now();
|
||||
String? err;
|
||||
try {
|
||||
logs.add(tr('startedActualBGUpdateCheck'));
|
||||
await appsProvider.checkUpdates(
|
||||
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
|
||||
} catch (e) {
|
||||
if (e is RateLimitError || e is SocketException) {
|
||||
AndroidAlarmManager.oneShot(
|
||||
Duration(minutes: e is RateLimitError ? e.remainingMinutes : 15),
|
||||
Random().nextInt(pow(2, 31) as int),
|
||||
bgUpdateCheck,
|
||||
params: {
|
||||
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
|
||||
});
|
||||
var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15;
|
||||
logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes,
|
||||
args: [e.runtimeType.toString()]));
|
||||
AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes),
|
||||
Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: {
|
||||
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
|
||||
});
|
||||
} else {
|
||||
err = e.toString();
|
||||
}
|
||||
@ -74,7 +113,8 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
||||
// silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()),
|
||||
// cancelExisting: true);
|
||||
// }
|
||||
|
||||
logs.add(
|
||||
plural('bgCheckFoundUpdatesWillNotifyIfNeeded', newUpdates.length));
|
||||
if (newUpdates.isNotEmpty) {
|
||||
notificationsProvider.notify(UpdateNotification(newUpdates));
|
||||
}
|
||||
@ -85,12 +125,14 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
||||
notificationsProvider
|
||||
.notify(ErrorCheckingUpdatesNotification(e.toString()));
|
||||
} finally {
|
||||
logs.add(tr('bgUpdateTaskFinished'));
|
||||
await notificationsProvider.cancel(checkingUpdatesNotification.id);
|
||||
}
|
||||
}
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await EasyLocalization.ensureInitialized();
|
||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
||||
@ -102,9 +144,14 @@ void main() async {
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (context) => AppsProvider()),
|
||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||
Provider(create: (context) => NotificationsProvider())
|
||||
Provider(create: (context) => NotificationsProvider()),
|
||||
Provider(create: (context) => LogsProvider())
|
||||
],
|
||||
child: const Obtainium(),
|
||||
child: EasyLocalization(
|
||||
supportedLocales: supportedLocales,
|
||||
path: localeDir,
|
||||
fallbackLocale: fallbackLocale,
|
||||
child: const Obtainium()),
|
||||
));
|
||||
}
|
||||
|
||||
@ -124,17 +171,19 @@ class _ObtainiumState extends State<Obtainium> {
|
||||
Widget build(BuildContext context) {
|
||||
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||
LogsProvider logs = context.read<LogsProvider>();
|
||||
|
||||
if (settingsProvider.prefs == null) {
|
||||
settingsProvider.initializeSettings();
|
||||
} else {
|
||||
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
|
||||
if (isFirstRun) {
|
||||
logs.add(tr('firstRun'));
|
||||
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
||||
Permission.notification.request();
|
||||
appsProvider.saveApps([
|
||||
App(
|
||||
'dev.imranr.obtainium',
|
||||
obtainiumId,
|
||||
'https://github.com/ImranR98/Obtainium',
|
||||
'ImranR98',
|
||||
'Obtainium',
|
||||
@ -143,11 +192,17 @@ class _ObtainiumState extends State<Obtainium> {
|
||||
[],
|
||||
0,
|
||||
['true'],
|
||||
null)
|
||||
null,
|
||||
false,
|
||||
false)
|
||||
]);
|
||||
}
|
||||
// Register the background update task according to the user's setting
|
||||
if (existingUpdateInterval != settingsProvider.updateInterval) {
|
||||
if (existingUpdateInterval != -1) {
|
||||
logs.add(tr('settingUpdateCheckIntervalTo',
|
||||
args: [settingsProvider.updateInterval.toString()]));
|
||||
}
|
||||
existingUpdateInterval = settingsProvider.updateInterval;
|
||||
if (existingUpdateInterval == 0) {
|
||||
AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
|
||||
@ -179,6 +234,9 @@ class _ObtainiumState extends State<Obtainium> {
|
||||
}
|
||||
return MaterialApp(
|
||||
title: 'Obtainium',
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: context.locale,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: settingsProvider.theme == ThemeSettings.dark
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
@ -7,45 +8,47 @@ import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class GitHubStars implements MassAppUrlSource {
|
||||
@override
|
||||
late String name = 'GitHub Starred Repos';
|
||||
late String name = tr('githubStarredRepos');
|
||||
|
||||
@override
|
||||
late List<String> requiredArgs = ['Username'];
|
||||
late List<String> requiredArgs = [tr('uname')];
|
||||
|
||||
Future<List<String>> getOnePageOfUserStarredUrls(
|
||||
Future<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions(
|
||||
String username, int page) async {
|
||||
Response res = await get(Uri.parse(
|
||||
'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page'));
|
||||
if (res.statusCode == 200) {
|
||||
return (jsonDecode(res.body) as List<dynamic>)
|
||||
.map((e) => e['html_url'] as String)
|
||||
.toList();
|
||||
} else {
|
||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||
throw RateLimitError(
|
||||
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||
60000000)
|
||||
.round());
|
||||
Map<String, String> urlsWithDescriptions = {};
|
||||
for (var e in (jsonDecode(res.body) as List<dynamic>)) {
|
||||
urlsWithDescriptions.addAll({
|
||||
e['html_url'] as String: e['description'] != null
|
||||
? e['description'] as String
|
||||
: tr('noDescription')
|
||||
});
|
||||
}
|
||||
|
||||
throw ObtainiumError('Unable to find user\'s starred repos');
|
||||
return urlsWithDescriptions;
|
||||
} else {
|
||||
var gh = GitHub();
|
||||
gh.rateLimitErrorCheck(res);
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> getUrls(List<String> args) async {
|
||||
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args) async {
|
||||
if (args.length != requiredArgs.length) {
|
||||
throw ObtainiumError('Wrong number of arguments provided');
|
||||
throw ObtainiumError(tr('wrongArgNum'));
|
||||
}
|
||||
List<String> urls = [];
|
||||
Map<String, String> urlsWithDescriptions = {};
|
||||
var page = 1;
|
||||
while (true) {
|
||||
var pageUrls = await getOnePageOfUserStarredUrls(args[0], page++);
|
||||
urls.addAll(pageUrls);
|
||||
var pageUrls =
|
||||
await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++);
|
||||
urlsWithDescriptions.addAll(pageUrls);
|
||||
if (pageUrls.length < 100) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
return urlsWithDescriptions;
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,12 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/pages/app.dart';
|
||||
import 'package:obtainium/pages/import_export.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
@ -21,17 +24,125 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
bool gettingAppInfo = false;
|
||||
|
||||
String userInput = '';
|
||||
String searchQuery = '';
|
||||
AppSource? pickedSource;
|
||||
List<String> additionalData = [];
|
||||
bool validAdditionalData = true;
|
||||
List<String> sourceSpecificAdditionalData = [];
|
||||
bool sourceSpecificDataIsValid = true;
|
||||
List<String> otherAdditionalData = [];
|
||||
bool otherAdditionalDataIsValid = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||
|
||||
changeUserInput(String input, bool valid, bool isBuilding) {
|
||||
userInput = input;
|
||||
fn() {
|
||||
var source = valid ? sourceProvider.getSource(userInput) : null;
|
||||
if (pickedSource != source) {
|
||||
pickedSource = source;
|
||||
sourceSpecificAdditionalData =
|
||||
source != null ? source.additionalSourceAppSpecificDefaults : [];
|
||||
sourceSpecificDataIsValid = source != null
|
||||
? sourceProvider.ifSourceAppsRequireAdditionalData(source)
|
||||
: true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isBuilding) {
|
||||
fn();
|
||||
} else {
|
||||
setState(() {
|
||||
fn();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addApp({bool resetUserInputAfter = false}) async {
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
var settingsProvider = context.read<SettingsProvider>();
|
||||
() async {
|
||||
var userPickedTrackOnly = findGeneratedFormValueByKey(
|
||||
pickedSource!.additionalAppSpecificSourceAgnosticFormItems,
|
||||
otherAdditionalData,
|
||||
'trackOnlyFormItemKey') ==
|
||||
'true';
|
||||
var cont = true;
|
||||
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('xIsTrackOnly', args: [
|
||||
pickedSource!.enforceTrackOnly
|
||||
? tr('source')
|
||||
: tr('app')
|
||||
]),
|
||||
items: const [],
|
||||
defaultValues: const [],
|
||||
message:
|
||||
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
|
||||
);
|
||||
}) ==
|
||||
null) {
|
||||
cont = false;
|
||||
}
|
||||
if (cont) {
|
||||
HapticFeedback.selectionClick();
|
||||
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
|
||||
App app = await sourceProvider.getApp(
|
||||
pickedSource!, userInput, sourceSpecificAdditionalData,
|
||||
trackOnly: trackOnly);
|
||||
if (!trackOnly) {
|
||||
await settingsProvider.getInstallPermission();
|
||||
}
|
||||
// Only download the APK here if you need to for the package ID
|
||||
if (sourceProvider.isTempId(app.id) && !app.trackOnly) {
|
||||
// ignore: use_build_context_synchronously
|
||||
var apkUrl = await appsProvider.confirmApkUrl(app, context);
|
||||
if (apkUrl == null) {
|
||||
throw ObtainiumError(tr('cancelled'));
|
||||
}
|
||||
app.preferredApkIndex = app.apkUrls.indexOf(apkUrl);
|
||||
// ignore: use_build_context_synchronously
|
||||
var downloadedApk = await appsProvider.downloadApp(app, context);
|
||||
app.id = downloadedApk.appId;
|
||||
}
|
||||
if (appsProvider.apps.containsKey(app.id)) {
|
||||
throw ObtainiumError(tr('appAlreadyAdded'));
|
||||
}
|
||||
if (app.trackOnly) {
|
||||
app.installedVersion = app.latestVersion;
|
||||
}
|
||||
await appsProvider.saveApps([app]);
|
||||
|
||||
return app;
|
||||
}
|
||||
}()
|
||||
.then((app) {
|
||||
if (app != null) {
|
||||
Navigator.push(context,
|
||||
MaterialPageRoute(builder: (context) => AppPage(appId: app.id)));
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
gettingAppInfo = false;
|
||||
if (resetUserInputAfter) {
|
||||
changeUserInput('', false, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
const CustomAppBar(title: 'Add App'),
|
||||
CustomAppBar(title: tr('addApp')),
|
||||
SliverFillRemaining(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@ -45,7 +156,7 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'App Source Url',
|
||||
label: tr('appSourceURL'),
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
try {
|
||||
@ -57,31 +168,18 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
} catch (e) {
|
||||
return e is String
|
||||
? e
|
||||
: 'Error';
|
||||
: e is ObtainiumError
|
||||
? e.toString()
|
||||
: tr('error');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
])
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid) {
|
||||
setState(() {
|
||||
userInput = values[0];
|
||||
var source = valid
|
||||
? sourceProvider.getSource(userInput)
|
||||
: null;
|
||||
if (pickedSource != source) {
|
||||
pickedSource = source;
|
||||
additionalData = source != null
|
||||
? source.additionalDataDefaults
|
||||
: [];
|
||||
validAdditionalData = source != null
|
||||
? sourceProvider
|
||||
.ifSourceAppsRequireAdditionalData(
|
||||
source)
|
||||
: true;
|
||||
}
|
||||
});
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
changeUserInput(
|
||||
values[0], valid, isBuilding);
|
||||
},
|
||||
defaultValues: const [])),
|
||||
const SizedBox(
|
||||
@ -92,68 +190,115 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
: ElevatedButton(
|
||||
onPressed: gettingAppInfo ||
|
||||
pickedSource == null ||
|
||||
(pickedSource!.additionalDataFormItems
|
||||
(pickedSource!
|
||||
.additionalSourceAppSpecificFormItems
|
||||
.isNotEmpty &&
|
||||
!validAdditionalData)
|
||||
!sourceSpecificDataIsValid) ||
|
||||
(pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticDefaults
|
||||
.isNotEmpty &&
|
||||
!otherAdditionalDataIsValid)
|
||||
? null
|
||||
: () async {
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
var appsProvider =
|
||||
context.read<AppsProvider>();
|
||||
var settingsProvider =
|
||||
context.read<SettingsProvider>();
|
||||
() async {
|
||||
HapticFeedback.selectionClick();
|
||||
App app =
|
||||
await sourceProvider.getApp(
|
||||
pickedSource!,
|
||||
userInput,
|
||||
additionalData);
|
||||
await settingsProvider
|
||||
.getInstallPermission();
|
||||
// ignore: use_build_context_synchronously
|
||||
var apkUrl = await appsProvider
|
||||
.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'))
|
||||
: addApp,
|
||||
child: Text(tr('add')))
|
||||
],
|
||||
),
|
||||
if (sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.isNotEmpty &&
|
||||
pickedSource == null &&
|
||||
userInput.isEmpty)
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
if (sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.isNotEmpty &&
|
||||
pickedSource == null &&
|
||||
userInput.isEmpty)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GeneratedForm(
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr('searchSomeSourcesLabel'),
|
||||
required: false),
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (values.isNotEmpty && valid) {
|
||||
setState(() {
|
||||
searchQuery = values[0].trim();
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues: const ['']),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: searchQuery.isEmpty || gettingAppInfo
|
||||
? null
|
||||
: () {
|
||||
Future.wait(sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.map((e) =>
|
||||
e.search(searchQuery)))
|
||||
.then((results) async {
|
||||
// Interleave results instead of simple reduce
|
||||
Map<String, String> res = {};
|
||||
var si = 0;
|
||||
var done = false;
|
||||
while (!done) {
|
||||
done = true;
|
||||
for (var r in results) {
|
||||
if (r.length > si) {
|
||||
done = false;
|
||||
res.addEntries(
|
||||
[r.entries.elementAt(si)]);
|
||||
}
|
||||
}
|
||||
si++;
|
||||
}
|
||||
List<String>? selectedUrls = res
|
||||
.isEmpty
|
||||
? []
|
||||
: await showDialog<List<String>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return UrlSelectionModal(
|
||||
urlsWithDescriptions: res,
|
||||
selectedByDefault: false,
|
||||
onlyOneSelectionAllowed:
|
||||
true,
|
||||
);
|
||||
});
|
||||
if (selectedUrls != null &&
|
||||
selectedUrls.isNotEmpty) {
|
||||
changeUserInput(
|
||||
selectedUrls[0], true, true);
|
||||
addApp(resetUserInputAfter: true);
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
},
|
||||
child: Text(tr('search')))
|
||||
],
|
||||
),
|
||||
if (pickedSource != null &&
|
||||
pickedSource!.additionalDataDefaults.isNotEmpty)
|
||||
(pickedSource!.additionalSourceAppSpecificDefaults
|
||||
.isNotEmpty ||
|
||||
pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticFormItems
|
||||
.where((e) => pickedSource!.enforceTrackOnly
|
||||
? e.key != 'trackOnlyFormItemKey'
|
||||
: true)
|
||||
.map((e) => [e])
|
||||
.isNotEmpty))
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@ -161,7 +306,10 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
height: 64,
|
||||
),
|
||||
Text(
|
||||
'Additional Options for ${pickedSource?.runtimeType}',
|
||||
tr('additionalOptsFor', args: [
|
||||
pickedSource?.runtimeType.toString() ??
|
||||
tr('source')
|
||||
]),
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary)),
|
||||
@ -169,22 +317,51 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
height: 16,
|
||||
),
|
||||
if (pickedSource!
|
||||
.additionalDataFormItems.isNotEmpty)
|
||||
.additionalSourceAppSpecificFormItems
|
||||
.isNotEmpty)
|
||||
GeneratedForm(
|
||||
items: pickedSource!.additionalDataFormItems,
|
||||
onValueChanges: (values, valid) {
|
||||
setState(() {
|
||||
additionalData = values;
|
||||
validAdditionalData = valid;
|
||||
});
|
||||
items: pickedSource!
|
||||
.additionalSourceAppSpecificFormItems,
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (isBuilding) {
|
||||
sourceSpecificAdditionalData = values;
|
||||
sourceSpecificDataIsValid = valid;
|
||||
} else {
|
||||
setState(() {
|
||||
sourceSpecificAdditionalData = values;
|
||||
sourceSpecificDataIsValid = valid;
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues:
|
||||
pickedSource!.additionalDataDefaults),
|
||||
defaultValues: pickedSource!
|
||||
.additionalSourceAppSpecificDefaults),
|
||||
if (pickedSource!
|
||||
.additionalDataFormItems.isNotEmpty)
|
||||
.additionalAppSpecificSourceAgnosticDefaults
|
||||
.isNotEmpty)
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
GeneratedForm(
|
||||
items: pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticFormItems
|
||||
.where((e) => pickedSource!.enforceTrackOnly
|
||||
? e.key != 'trackOnlyFormItemKey'
|
||||
: true)
|
||||
.map((e) => [e])
|
||||
.toList(),
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (isBuilding) {
|
||||
otherAdditionalData = values;
|
||||
otherAdditionalDataIsValid = valid;
|
||||
} else {
|
||||
setState(() {
|
||||
otherAdditionalData = values;
|
||||
otherAdditionalDataIsValid = valid;
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues: pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticDefaults),
|
||||
],
|
||||
)
|
||||
else
|
||||
@ -193,22 +370,24 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Supported Sources:',
|
||||
const SizedBox(
|
||||
height: 48,
|
||||
),
|
||||
Text(
|
||||
tr('supportedSourcesBelow'),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
...sourceProvider
|
||||
.getSourceHosts()
|
||||
...sourceProvider.sources
|
||||
.map((e) => GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString('https://$e',
|
||||
launchUrlString('https://${e.host}',
|
||||
mode:
|
||||
LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text(
|
||||
e,
|
||||
'${e.runtimeType.toString()}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
|
||||
style: const TextStyle(
|
||||
decoration:
|
||||
TextDecoration.underline,
|
||||
@ -216,6 +395,9 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
)))
|
||||
.toList()
|
||||
])),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
])),
|
||||
)
|
||||
]));
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
@ -106,7 +107,7 @@ class _AppPageState extends State<AppPage> {
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
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,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
@ -140,6 +141,7 @@ class _AppPageState extends State<AppPage> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (app?.app.installedVersion != null &&
|
||||
app?.app.trackOnly == false &&
|
||||
app?.app.installedVersion != app?.app.latestVersion)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
@ -149,8 +151,15 @@ class _AppPageState extends State<AppPage> {
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'App Already up to Date?'),
|
||||
title: Text(tr(
|
||||
'alreadyUpToDateQuestion')),
|
||||
content: Text(
|
||||
tr('onlyWorksWithNonEVDApps'),
|
||||
style: const TextStyle(
|
||||
fontWeight:
|
||||
FontWeight.bold,
|
||||
fontStyle:
|
||||
FontStyle.italic)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
@ -183,7 +192,8 @@ class _AppPageState extends State<AppPage> {
|
||||
tooltip: 'Mark as Updated',
|
||||
icon: const Icon(Icons.done)),
|
||||
if (source != null &&
|
||||
source.additionalDataFormItems.isNotEmpty)
|
||||
source.additionalSourceAppSpecificFormItems
|
||||
.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
@ -194,11 +204,11 @@ class _AppPageState extends State<AppPage> {
|
||||
return GeneratedFormModal(
|
||||
title: 'Additional Options',
|
||||
items: source
|
||||
.additionalDataFormItems,
|
||||
.additionalSourceAppSpecificFormItems,
|
||||
defaultValues: app != null
|
||||
? app.app.additionalData
|
||||
: source
|
||||
.additionalDataDefaults);
|
||||
.additionalSourceAppSpecificDefaults);
|
||||
}).then((values) {
|
||||
if (app != null && values != null) {
|
||||
var changedApp = app.app;
|
||||
@ -221,21 +231,33 @@ class _AppPageState extends State<AppPage> {
|
||||
!appsProvider.areDownloadsRunning()
|
||||
? () {
|
||||
HapticFeedback.heavyImpact();
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApps(
|
||||
[app!.app.id],
|
||||
context).then((res) {
|
||||
if (res.isNotEmpty && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
() async {
|
||||
if (app?.app.trackOnly != true) {
|
||||
await settingsProvider
|
||||
.getInstallPermission();
|
||||
}
|
||||
}()
|
||||
.then((value) {
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApps(
|
||||
[app!.app.id],
|
||||
context).then((res) {
|
||||
if (res.isNotEmpty && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
}
|
||||
: null,
|
||||
child: Text(app?.app.installedVersion == null
|
||||
? 'Install'
|
||||
: 'Update'))),
|
||||
? app?.app.trackOnly == false
|
||||
? 'Install'
|
||||
: 'Mark Installed'
|
||||
: app?.app.trackOnly == false
|
||||
? 'Update'
|
||||
: 'Mark Updated'))),
|
||||
const SizedBox(width: 16.0),
|
||||
ElevatedButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
@ -23,24 +24,24 @@ class AppsPageState extends State<AppsPage> {
|
||||
AppsFilter? filter;
|
||||
var updatesOnlyFilter =
|
||||
AppsFilter(includeUptodate: false, includeNonInstalled: false);
|
||||
Set<String> selectedIds = {};
|
||||
Set<App> selectedApps = {};
|
||||
DateTime? refreshingSince;
|
||||
|
||||
clearSelected() {
|
||||
if (selectedIds.isNotEmpty) {
|
||||
if (selectedApps.isNotEmpty) {
|
||||
setState(() {
|
||||
selectedIds.clear();
|
||||
selectedApps.clear();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
selectThese(List<String> appIds) {
|
||||
if (selectedIds.isEmpty) {
|
||||
selectThese(List<App> apps) {
|
||||
if (selectedApps.isEmpty) {
|
||||
setState(() {
|
||||
for (var a in appIds) {
|
||||
selectedIds.add(a);
|
||||
for (var a in apps) {
|
||||
selectedApps.add(a);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -54,16 +55,16 @@ class AppsPageState extends State<AppsPage> {
|
||||
var currentFilterIsUpdatesOnly =
|
||||
filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
|
||||
|
||||
selectedIds = selectedIds
|
||||
.where((element) => sortedApps.map((e) => e.app.id).contains(element))
|
||||
selectedApps = selectedApps
|
||||
.where((element) => sortedApps.map((e) => e.app).contains(element))
|
||||
.toSet();
|
||||
|
||||
toggleAppSelected(String appId) {
|
||||
toggleAppSelected(App app) {
|
||||
setState(() {
|
||||
if (selectedIds.contains(appId)) {
|
||||
selectedIds.remove(appId);
|
||||
if (selectedApps.contains(app)) {
|
||||
selectedApps.remove(app);
|
||||
} else {
|
||||
selectedIds.add(appId);
|
||||
selectedApps.add(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -124,17 +125,33 @@ class AppsPageState extends State<AppsPage> {
|
||||
var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
|
||||
|
||||
var existingUpdateIdsAllOrSelected = existingUpdates
|
||||
.where((element) => selectedIds.isEmpty
|
||||
.where((element) => selectedApps.isEmpty
|
||||
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||
: selectedIds.contains(element))
|
||||
: selectedApps.map((e) => e.id).contains(element))
|
||||
.toList();
|
||||
var newInstallIdsAllOrSelected = appsProvider
|
||||
.findExistingUpdates(nonInstalledOnly: true)
|
||||
.where((element) => selectedIds.isEmpty
|
||||
.where((element) => selectedApps.isEmpty
|
||||
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||
: selectedIds.contains(element))
|
||||
: selectedApps.map((e) => e.id).contains(element))
|
||||
.toList();
|
||||
|
||||
List<String> trackOnlyUpdateIdsAllOrSelected = [];
|
||||
existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) {
|
||||
if (appsProvider.apps[id]!.app.trackOnly) {
|
||||
trackOnlyUpdateIdsAllOrSelected.add(id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) {
|
||||
if (appsProvider.apps[id]!.app.trackOnly) {
|
||||
trackOnlyUpdateIdsAllOrSelected.add(id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
if (settingsProvider.pinUpdates) {
|
||||
var temp = [];
|
||||
sortedApps = sortedApps.where((sa) {
|
||||
@ -147,6 +164,17 @@ class AppsPageState extends State<AppsPage> {
|
||||
sortedApps = [...temp, ...sortedApps];
|
||||
}
|
||||
|
||||
var tempPinned = [];
|
||||
var tempNotPinned = [];
|
||||
for (var a in sortedApps) {
|
||||
if (a.app.pinned) {
|
||||
tempPinned.add(a);
|
||||
} else {
|
||||
tempNotPinned.add(a);
|
||||
}
|
||||
}
|
||||
sortedApps = [...tempPinned, ...tempNotPinned];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: RefreshIndicator(
|
||||
@ -164,7 +192,7 @@ class AppsPageState extends State<AppsPage> {
|
||||
});
|
||||
},
|
||||
child: CustomScrollView(slivers: <Widget>[
|
||||
const CustomAppBar(title: 'Apps'),
|
||||
CustomAppBar(title: tr('appsString')),
|
||||
if (appsProvider.loadingApps || sortedApps.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
@ -172,8 +200,8 @@ class AppsPageState extends State<AppsPage> {
|
||||
? const CircularProgressIndicator()
|
||||
: Text(
|
||||
appsProvider.apps.isEmpty
|
||||
? 'No Apps'
|
||||
: 'No Apps for Filter',
|
||||
? tr('noApps')
|
||||
: tr('noAppsForFilter'),
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
))),
|
||||
@ -191,12 +219,20 @@ class AppsPageState extends State<AppsPage> {
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
String? changesUrl = SourceProvider()
|
||||
.getSource(sortedApps[index].app.url)
|
||||
.changeLogPageFromStandardUrl(sortedApps[index].app.url);
|
||||
return ListTile(
|
||||
selectedTileColor:
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
selected: selectedIds.contains(sortedApps[index].app.id),
|
||||
tileColor: sortedApps[index].app.pinned
|
||||
? Colors.grey.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
selectedTileColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1),
|
||||
selected: selectedApps.contains(sortedApps[index].app),
|
||||
onLongPress: () {
|
||||
toggleAppSelected(sortedApps[index].app.id);
|
||||
toggleAppSelected(sortedApps[index].app);
|
||||
},
|
||||
leading: sortedApps[index].installedInfo != null
|
||||
? Image.memory(
|
||||
@ -204,60 +240,68 @@ class AppsPageState extends State<AppsPage> {
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
: null,
|
||||
title: Text(sortedApps[index].installedInfo?.name ??
|
||||
sortedApps[index].app.name),
|
||||
subtitle: Text('By ${sortedApps[index].app.author}'),
|
||||
trailing: sortedApps[index].downloadProgress != null
|
||||
? Text(
|
||||
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
|
||||
: (sortedApps[index].app.installedVersion != null &&
|
||||
sortedApps[index].app.installedVersion !=
|
||||
sortedApps[index].app.latestVersion
|
||||
? Column(
|
||||
title: Text(
|
||||
sortedApps[index].installedInfo?.name ??
|
||||
sortedApps[index].app.name,
|
||||
style: TextStyle(
|
||||
fontWeight: sortedApps[index].app.pinned
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal),
|
||||
),
|
||||
subtitle: Text(tr('byX', args: [sortedApps[index].app.author]),
|
||||
style: TextStyle(
|
||||
fontWeight: sortedApps[index].app.pinned
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal)),
|
||||
trailing: SingleChildScrollView(
|
||||
reverse: true,
|
||||
child: sortedApps[index].downloadProgress != null
|
||||
? Text(tr('percentProgress', args: [
|
||||
sortedApps[index]
|
||||
.downloadProgress
|
||||
?.toInt()
|
||||
.toString() ??
|
||||
'100'
|
||||
]))
|
||||
: (Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(appsProvider.areDownloadsRunning()
|
||||
? 'Please Wait...'
|
||||
: 'Update Available'),
|
||||
SourceProvider()
|
||||
.getSource(sortedApps[index].app.url)
|
||||
.changeLogPageFromStandardUrl(
|
||||
sortedApps[index].app.url) ==
|
||||
null
|
||||
? const SizedBox()
|
||||
: GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
SourceProvider()
|
||||
.getSource(
|
||||
sortedApps[index].app.url)
|
||||
.changeLogPageFromStandardUrl(
|
||||
sortedApps[index].app.url)!,
|
||||
mode:
|
||||
LaunchMode.externalApplication);
|
||||
},
|
||||
child: const Text(
|
||||
'See Changes',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
decoration:
|
||||
TextDecoration.underline),
|
||||
)),
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
'${sortedApps[index].app.installedVersion ?? tr('notInstalled')}${sortedApps[index].app.trackOnly == true ? ' ${tr('estimateInBrackets')}' : ''}',
|
||||
overflow: TextOverflow.fade,
|
||||
textAlign: TextAlign.end,
|
||||
)),
|
||||
sortedApps[index].app.installedVersion != null &&
|
||||
sortedApps[index].app.installedVersion !=
|
||||
sortedApps[index].app.latestVersion
|
||||
? GestureDetector(
|
||||
onTap: changesUrl == null
|
||||
? null
|
||||
: () {
|
||||
launchUrlString(changesUrl,
|
||||
mode: LaunchMode
|
||||
.externalApplication);
|
||||
},
|
||||
child: appsProvider.areDownloadsRunning()
|
||||
? Text(tr('pleaseWait'))
|
||||
: Text(
|
||||
'${tr('updateAvailable')}${sortedApps[index].app.trackOnly ? ' ${tr('estimateInBracketsShort')}' : ''}',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
decoration: changesUrl == null
|
||||
? TextDecoration.none
|
||||
: TextDecoration
|
||||
.underline),
|
||||
))
|
||||
: const SizedBox(),
|
||||
],
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
sortedApps[index].app.installedVersion ??
|
||||
'Not Installed',
|
||||
overflow: TextOverflow.fade,
|
||||
textAlign: TextAlign.end,
|
||||
)))),
|
||||
))),
|
||||
onTap: () {
|
||||
if (selectedIds.isNotEmpty) {
|
||||
toggleAppSelected(sortedApps[index].app.id);
|
||||
if (selectedApps.isNotEmpty) {
|
||||
toggleAppSelected(sortedApps[index].app);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
@ -275,25 +319,25 @@ class AppsPageState extends State<AppsPage> {
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
selectedIds.isEmpty
|
||||
? selectThese(sortedApps.map((e) => e.app.id).toList())
|
||||
selectedApps.isEmpty
|
||||
? selectThese(sortedApps.map((e) => e.app).toList())
|
||||
: clearSelected();
|
||||
},
|
||||
icon: Icon(
|
||||
selectedIds.isEmpty
|
||||
selectedApps.isEmpty
|
||||
? Icons.select_all_outlined
|
||||
: Icons.deselect_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
tooltip: selectedIds.isEmpty
|
||||
? 'Select All'
|
||||
: 'Deselect ${selectedIds.length.toString()}'),
|
||||
tooltip: selectedApps.isEmpty
|
||||
? tr('selectAll')
|
||||
: tr('deselectN', args: [selectedApps.length.toString()])),
|
||||
const VerticalDivider(),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
selectedIds.isEmpty
|
||||
selectedApps.isEmpty
|
||||
? const SizedBox()
|
||||
: IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
@ -302,69 +346,107 @@ class AppsPageState extends State<AppsPage> {
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: 'Remove Selected Apps?',
|
||||
title: tr('removeSelectedAppsQuestion'),
|
||||
items: const [],
|
||||
defaultValues: const [],
|
||||
initValid: true,
|
||||
message:
|
||||
'${selectedIds.length} App${selectedIds.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedIds.length == 1 ? 'it' : 'them'} manually.',
|
||||
message: tr(
|
||||
'xWillBeRemovedButRemainInstalled',
|
||||
args: [
|
||||
plural('apps', selectedApps.length)
|
||||
]),
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
appsProvider.removeApps(selectedIds.toList());
|
||||
appsProvider.removeApps(
|
||||
selectedApps.map((e) => e.id).toList());
|
||||
}
|
||||
});
|
||||
},
|
||||
tooltip: 'Remove Selected Apps',
|
||||
tooltip: tr('removeSelectedApps'),
|
||||
icon: const Icon(Icons.delete_outline_outlined),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: appsProvider.areDownloadsRunning() ||
|
||||
(existingUpdateIdsAllOrSelected.isEmpty &&
|
||||
newInstallIdsAllOrSelected.isEmpty)
|
||||
newInstallIdsAllOrSelected.isEmpty &&
|
||||
trackOnlyUpdateIdsAllOrSelected.isEmpty)
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
List<List<GeneratedFormItem>> formInputs = [];
|
||||
if (existingUpdateIdsAllOrSelected.isNotEmpty &&
|
||||
newInstallIdsAllOrSelected.isNotEmpty) {
|
||||
formInputs.add([
|
||||
GeneratedFormItem(
|
||||
label:
|
||||
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
|
||||
type: FormItemType.bool)
|
||||
]);
|
||||
formInputs.add([
|
||||
GeneratedFormItem(
|
||||
label:
|
||||
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}',
|
||||
type: FormItemType.bool)
|
||||
]);
|
||||
List<GeneratedFormItem> formInputs = [];
|
||||
List<String> defaultValues = [];
|
||||
if (existingUpdateIdsAllOrSelected.isNotEmpty) {
|
||||
formInputs.add(GeneratedFormItem(
|
||||
label: tr('updateX', args: [
|
||||
plural('apps',
|
||||
existingUpdateIdsAllOrSelected.length)
|
||||
]),
|
||||
type: FormItemType.bool,
|
||||
key: 'updates'));
|
||||
defaultValues.add('true');
|
||||
}
|
||||
if (newInstallIdsAllOrSelected.isNotEmpty) {
|
||||
formInputs.add(GeneratedFormItem(
|
||||
label: tr('installX', args: [
|
||||
plural('apps',
|
||||
newInstallIdsAllOrSelected.length)
|
||||
]),
|
||||
type: FormItemType.bool,
|
||||
key: 'installs'));
|
||||
defaultValues
|
||||
.add(defaultValues.isEmpty ? 'true' : '');
|
||||
}
|
||||
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
|
||||
formInputs.add(GeneratedFormItem(
|
||||
label: tr('markXTrackOnlyAsUpdated', args: [
|
||||
plural('apps',
|
||||
trackOnlyUpdateIdsAllOrSelected.length)
|
||||
]),
|
||||
type: FormItemType.bool,
|
||||
key: 'trackonlies'));
|
||||
defaultValues
|
||||
.add(defaultValues.isEmpty ? 'true' : '');
|
||||
}
|
||||
showDialog<List<String>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
var totalApps = existingUpdateIdsAllOrSelected
|
||||
.length +
|
||||
newInstallIdsAllOrSelected.length +
|
||||
trackOnlyUpdateIdsAllOrSelected.length;
|
||||
return GeneratedFormModal(
|
||||
title:
|
||||
'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?',
|
||||
message:
|
||||
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
|
||||
items: formInputs,
|
||||
defaultValues: [
|
||||
'true',
|
||||
existingUpdateIdsAllOrSelected.isEmpty
|
||||
? 'true'
|
||||
: ''
|
||||
],
|
||||
title: tr('changeX',
|
||||
args: [plural('apps', totalApps)]),
|
||||
items: formInputs.map((e) => [e]).toList(),
|
||||
defaultValues: defaultValues,
|
||||
initValid: true,
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
bool shouldInstallUpdates = values[0] == 'true';
|
||||
bool shouldInstallNew = values[1] == 'true';
|
||||
settingsProvider
|
||||
.getInstallPermission()
|
||||
if (values.isEmpty) {
|
||||
values = defaultValues;
|
||||
}
|
||||
bool shouldInstallUpdates =
|
||||
findGeneratedFormValueByKey(
|
||||
formInputs, values, 'updates') ==
|
||||
'true';
|
||||
bool shouldInstallNew =
|
||||
findGeneratedFormValueByKey(
|
||||
formInputs, values, 'installs') ==
|
||||
'true';
|
||||
bool shouldMarkTrackOnlies =
|
||||
findGeneratedFormValueByKey(formInputs,
|
||||
values, 'trackonlies') ==
|
||||
'true';
|
||||
(() async {
|
||||
if (shouldInstallNew ||
|
||||
shouldInstallUpdates) {
|
||||
await settingsProvider
|
||||
.getInstallPermission();
|
||||
}
|
||||
})()
|
||||
.then((_) {
|
||||
List<String> toInstall = [];
|
||||
if (shouldInstallUpdates) {
|
||||
@ -375,6 +457,10 @@ class AppsPageState extends State<AppsPage> {
|
||||
toInstall
|
||||
.addAll(newInstallIdsAllOrSelected);
|
||||
}
|
||||
if (shouldMarkTrackOnlies) {
|
||||
toInstall.addAll(
|
||||
trackOnlyUpdateIdsAllOrSelected);
|
||||
}
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApps(
|
||||
toInstall, context)
|
||||
@ -385,12 +471,13 @@ class AppsPageState extends State<AppsPage> {
|
||||
}
|
||||
});
|
||||
},
|
||||
tooltip:
|
||||
'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps',
|
||||
tooltip: selectedApps.isEmpty
|
||||
? tr('installUpdateApps')
|
||||
: tr('installUpdateSelectedApps'),
|
||||
icon: const Icon(
|
||||
Icons.file_download_outlined,
|
||||
)),
|
||||
selectedIds.isEmpty
|
||||
selectedApps.isEmpty
|
||||
? const SizedBox()
|
||||
: IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
@ -418,11 +505,22 @@ class AppsPageState extends State<AppsPage> {
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Mark ${selectedIds.length} Selected Apps as Updated?'),
|
||||
content:
|
||||
const Text(
|
||||
'Only applies to installed but out of date Apps.'),
|
||||
title: Text(tr(
|
||||
'markXSelectedAppsAsUpdated',
|
||||
args: [
|
||||
selectedApps
|
||||
.length
|
||||
.toString()
|
||||
])),
|
||||
content: Text(
|
||||
tr('onlyWorksWithNonEVDApps'),
|
||||
style: const TextStyle(
|
||||
fontWeight:
|
||||
FontWeight
|
||||
.bold,
|
||||
fontStyle:
|
||||
FontStyle.italic),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed:
|
||||
@ -430,17 +528,15 @@ class AppsPageState extends State<AppsPage> {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'No')),
|
||||
child: Text(
|
||||
tr('no'))),
|
||||
TextButton(
|
||||
onPressed:
|
||||
() {
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
appsProvider
|
||||
.saveApps(selectedIds.map((e) {
|
||||
var a =
|
||||
appsProvider.apps[e]!.app;
|
||||
.saveApps(selectedApps.map((a) {
|
||||
if (a.installedVersion !=
|
||||
null) {
|
||||
a.installedVersion = a.latestVersion;
|
||||
@ -451,37 +547,104 @@ class AppsPageState extends State<AppsPage> {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Yes'))
|
||||
child: Text(
|
||||
tr('yes')))
|
||||
],
|
||||
);
|
||||
});
|
||||
}).whenComplete(() {
|
||||
Navigator.of(
|
||||
context)
|
||||
.pop();
|
||||
});
|
||||
},
|
||||
tooltip:
|
||||
'Mark Selected Apps as Updated',
|
||||
tr('markSelectedAppsUpdated'),
|
||||
icon: const Icon(Icons.done)),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
var pinStatus = selectedApps
|
||||
.where((element) =>
|
||||
element.pinned)
|
||||
.isEmpty;
|
||||
appsProvider.saveApps(
|
||||
selectedApps.map((e) {
|
||||
e.pinned = pinStatus;
|
||||
return e;
|
||||
}).toList());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
tooltip: selectedApps
|
||||
.where((element) =>
|
||||
element.pinned)
|
||||
.isEmpty
|
||||
? tr('pinToTop')
|
||||
: tr('unpinFromTop'),
|
||||
icon: Icon(selectedApps
|
||||
.where((element) =>
|
||||
element.pinned)
|
||||
.isEmpty
|
||||
? Icons.bookmark_outline_rounded
|
||||
: Icons
|
||||
.bookmark_remove_outlined),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
String urls = '';
|
||||
for (var id in selectedIds) {
|
||||
urls +=
|
||||
'${appsProvider.apps[id]!.app.url}\n';
|
||||
for (var a in selectedApps) {
|
||||
urls += '${a.url}\n';
|
||||
}
|
||||
urls = urls.substring(
|
||||
0, urls.length - 1);
|
||||
Share.share(urls,
|
||||
subject:
|
||||
'${selectedIds.length} Selected App URLs from Obtainium');
|
||||
subject: tr(
|
||||
'selectedAppURLsFromObtainium'));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
tooltip: 'Share Selected App URLs',
|
||||
tooltip: tr('shareSelectedAppURLs'),
|
||||
icon: const Icon(Icons.share),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr(
|
||||
'resetInstallStatusForSelectedAppsQuestion'),
|
||||
items: const [],
|
||||
defaultValues: const [],
|
||||
initValid: true,
|
||||
message: tr(
|
||||
'installStatusOfXWillBeResetExplanation',
|
||||
args: [
|
||||
plural(
|
||||
'app',
|
||||
selectedApps
|
||||
.length)
|
||||
]),
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
appsProvider.saveApps(
|
||||
selectedApps.map((e) {
|
||||
e.installedVersion = null;
|
||||
return e;
|
||||
}).toList());
|
||||
}
|
||||
}).whenComplete(() {
|
||||
Navigator.of(context).pop();
|
||||
});
|
||||
},
|
||||
tooltip: tr('resetInstallStatus'),
|
||||
icon: const Icon(
|
||||
Icons.restore_page_outlined),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
tooltip: 'More',
|
||||
tooltip: tr('more'),
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
),
|
||||
],
|
||||
@ -499,8 +662,8 @@ class AppsPageState extends State<AppsPage> {
|
||||
});
|
||||
},
|
||||
tooltip: currentFilterIsUpdatesOnly
|
||||
? 'Remove Out-of-Date App Filter'
|
||||
: 'Show Out-of-Date Apps Only',
|
||||
? tr('removeOutdatedFilter')
|
||||
: tr('showOutdatedOnly'),
|
||||
icon: Icon(
|
||||
currentFilterIsUpdatesOnly
|
||||
? Icons.update_disabled_rounded
|
||||
@ -512,7 +675,7 @@ class AppsPageState extends State<AppsPage> {
|
||||
? const SizedBox()
|
||||
: TextButton.icon(
|
||||
label: Text(
|
||||
filter == null ? 'Filter' : 'Filter *',
|
||||
filter == null ? tr('filter') : tr('filterActive'),
|
||||
style: TextStyle(
|
||||
fontWeight: filter == null
|
||||
? FontWeight.normal
|
||||
@ -523,22 +686,22 @@ class AppsPageState extends State<AppsPage> {
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: 'Filter Apps',
|
||||
title: tr('filterApps'),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'App Name', required: false),
|
||||
label: tr('appName'), required: false),
|
||||
GeneratedFormItem(
|
||||
label: 'Author', required: false)
|
||||
label: tr('author'), required: false)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Up to Date Apps',
|
||||
label: tr('upToDateApps'),
|
||||
type: FormItemType.bool)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Non-Installed Apps',
|
||||
label: tr('nonInstalledApps'),
|
||||
type: FormItemType.bool)
|
||||
]
|
||||
],
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/pages/add_app.dart';
|
||||
@ -25,12 +26,12 @@ class _HomePageState extends State<HomePage> {
|
||||
List<int> selectedIndexHistory = [];
|
||||
|
||||
List<NavigationPageItem> pages = [
|
||||
NavigationPageItem(tr('appsString'), Icons.apps,
|
||||
AppsPage(key: GlobalKey<AppsPageState>())),
|
||||
NavigationPageItem(tr('addApp'), Icons.add, const AddAppPage()),
|
||||
NavigationPageItem(
|
||||
'Apps', Icons.apps, AppsPage(key: GlobalKey<AppsPageState>())),
|
||||
NavigationPageItem('Add App', Icons.add, const AddAppPage()),
|
||||
NavigationPageItem(
|
||||
'Import/Export', Icons.import_export, const ImportExportPage()),
|
||||
NavigationPageItem('Settings', Icons.settings, const SettingsPage())
|
||||
tr('importExport'), Icons.import_export, const ImportExportPage()),
|
||||
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage())
|
||||
];
|
||||
|
||||
@override
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
@ -8,10 +9,10 @@ import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class ImportExportPage extends StatefulWidget {
|
||||
const ImportExportPage({super.key});
|
||||
@ -26,7 +27,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
var settingsProvider = context.read<SettingsProvider>();
|
||||
var appsProvider = context.read<AppsProvider>();
|
||||
var outlineButtonStyle = ButtonStyle(
|
||||
shape: MaterialStateProperty.all(
|
||||
@ -39,30 +39,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
),
|
||||
);
|
||||
|
||||
Future<List<List<String>>> addApps(List<String> urls) async {
|
||||
await settingsProvider.getInstallPermission();
|
||||
List<dynamic> results = await sourceProvider.getApps(urls,
|
||||
ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList());
|
||||
List<App> apps = results[0];
|
||||
Map<String, dynamic> errorsMap = results[1];
|
||||
for (var app in apps) {
|
||||
if (appsProvider.apps.containsKey(app.id)) {
|
||||
errorsMap.addAll({app.id: 'App already added'});
|
||||
} else {
|
||||
await appsProvider.saveApps([app]);
|
||||
}
|
||||
}
|
||||
List<List<String>> errors =
|
||||
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
|
||||
return errors;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
const CustomAppBar(title: 'Import/Export'),
|
||||
CustomAppBar(title: tr('importExport')),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
@ -83,10 +64,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
.exportApps()
|
||||
.then((String path) {
|
||||
showError(
|
||||
'Exported to $path', context);
|
||||
tr('exportedTo', args: [path]),
|
||||
context);
|
||||
});
|
||||
},
|
||||
child: const Text('Obtainium Export'))),
|
||||
child: Text(tr('obtainiumExport')))),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
@ -111,13 +93,15 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
jsonDecode(data);
|
||||
} catch (e) {
|
||||
throw ObtainiumError(
|
||||
'Invalid input');
|
||||
tr('invalidInput'));
|
||||
}
|
||||
appsProvider
|
||||
.importApps(data)
|
||||
.then((value) {
|
||||
showError(
|
||||
'$value App${value == 1 ? '' : 's'} Imported',
|
||||
tr('importedX', args: [
|
||||
plural('apps', value)
|
||||
]),
|
||||
context);
|
||||
});
|
||||
} else {
|
||||
@ -131,7 +115,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
});
|
||||
});
|
||||
},
|
||||
child: const Text('Obtainium Import')))
|
||||
child: Text(tr('obtainiumImport'))))
|
||||
],
|
||||
),
|
||||
if (importInProgress)
|
||||
@ -158,11 +142,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: 'Import from URL List',
|
||||
title: tr('importFromURLList'),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'App URL List',
|
||||
label: tr('appURLList'),
|
||||
max: 7,
|
||||
additionalValidators: [
|
||||
(String? value) {
|
||||
@ -179,7 +163,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
.getSource(
|
||||
lines[i]);
|
||||
} catch (e) {
|
||||
return 'Line ${i + 1}: $e';
|
||||
return '${tr('line')} ${i + 1}: $e';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -197,10 +181,14 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
addApps(urls).then((errors) {
|
||||
appsProvider
|
||||
.addAppsByURL(urls)
|
||||
.then((errors) {
|
||||
if (errors.isEmpty) {
|
||||
showError(
|
||||
'Imported ${urls.length} Apps',
|
||||
tr('importedX', args: [
|
||||
plural('apps', urls.length)
|
||||
]),
|
||||
context);
|
||||
} else {
|
||||
showDialog(
|
||||
@ -221,9 +209,121 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
}
|
||||
});
|
||||
},
|
||||
child: const Text(
|
||||
'Import from URL List',
|
||||
child: Text(
|
||||
tr('importFromURLList'),
|
||||
)),
|
||||
...sourceProvider.sources
|
||||
.where((element) => element.canSearch)
|
||||
.map((source) => Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
() async {
|
||||
var values = await showDialog<
|
||||
List<String>>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('searchX',
|
||||
args: [
|
||||
source
|
||||
.runtimeType
|
||||
.toString()
|
||||
]),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: tr(
|
||||
'searchQuery'))
|
||||
]
|
||||
],
|
||||
defaultValues: const [],
|
||||
);
|
||||
});
|
||||
if (values != null &&
|
||||
values[0].isNotEmpty) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
var urlsWithDescriptions =
|
||||
await source
|
||||
.search(values[0]);
|
||||
if (urlsWithDescriptions
|
||||
.isNotEmpty) {
|
||||
var selectedUrls =
|
||||
await showDialog<
|
||||
List<
|
||||
String>?>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return UrlSelectionModal(
|
||||
urlsWithDescriptions:
|
||||
urlsWithDescriptions,
|
||||
selectedByDefault:
|
||||
false,
|
||||
);
|
||||
});
|
||||
if (selectedUrls !=
|
||||
null &&
|
||||
selectedUrls
|
||||
.isNotEmpty) {
|
||||
var errors =
|
||||
await appsProvider
|
||||
.addAppsByURL(
|
||||
selectedUrls);
|
||||
if (errors.isEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showError(
|
||||
tr('importedX',
|
||||
args: [
|
||||
plural(
|
||||
'app',
|
||||
selectedUrls
|
||||
.length)
|
||||
]),
|
||||
context);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength:
|
||||
selectedUrls
|
||||
.length,
|
||||
errors:
|
||||
errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw ObtainiumError(
|
||||
tr('noResults'));
|
||||
}
|
||||
}
|
||||
}()
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Text(tr('searchX', args: [
|
||||
source.runtimeType.toString()
|
||||
])))
|
||||
]))
|
||||
.toList(),
|
||||
...sourceProvider.massUrlSources
|
||||
.map((source) => Column(
|
||||
crossAxisAlignment:
|
||||
@ -234,89 +334,102 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title:
|
||||
'Import ${source.name}',
|
||||
items: source
|
||||
.requiredArgs
|
||||
.map((e) => [
|
||||
GeneratedFormItem(
|
||||
label: e)
|
||||
])
|
||||
.toList(),
|
||||
defaultValues: const [],
|
||||
);
|
||||
}).then((values) {
|
||||
() async {
|
||||
var values = await showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('importX',
|
||||
args: [
|
||||
source.name
|
||||
]),
|
||||
items:
|
||||
source
|
||||
.requiredArgs
|
||||
.map(
|
||||
(e) => [
|
||||
GeneratedFormItem(label: e)
|
||||
])
|
||||
.toList(),
|
||||
defaultValues: const [],
|
||||
);
|
||||
});
|
||||
if (values != null) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
source
|
||||
.getUrls(values)
|
||||
.then((urls) {
|
||||
showDialog<List<String>?>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return UrlSelectionModal(
|
||||
urls: urls);
|
||||
})
|
||||
.then((selectedUrls) {
|
||||
if (selectedUrls !=
|
||||
null) {
|
||||
addApps(selectedUrls)
|
||||
.then((errors) {
|
||||
if (errors
|
||||
.isEmpty) {
|
||||
showError(
|
||||
'Imported ${selectedUrls.length} Apps',
|
||||
context);
|
||||
} else {
|
||||
showDialog(
|
||||
context:
|
||||
context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength:
|
||||
selectedUrls
|
||||
.length,
|
||||
errors:
|
||||
errors);
|
||||
});
|
||||
}
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress =
|
||||
false;
|
||||
var urlsWithDescriptions =
|
||||
await source
|
||||
.getUrlsWithDescriptions(
|
||||
values);
|
||||
var selectedUrls =
|
||||
await showDialog<
|
||||
List<String>?>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return UrlSelectionModal(
|
||||
urlsWithDescriptions:
|
||||
urlsWithDescriptions);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
importInProgress =
|
||||
false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}).catchError((e) {
|
||||
setState(() {
|
||||
importInProgress =
|
||||
false;
|
||||
});
|
||||
showError(e, context);
|
||||
});
|
||||
if (selectedUrls != null) {
|
||||
var errors =
|
||||
await appsProvider
|
||||
.addAppsByURL(
|
||||
selectedUrls);
|
||||
if (errors.isEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showError(
|
||||
tr('importedX',
|
||||
args: [
|
||||
plural(
|
||||
'app',
|
||||
selectedUrls
|
||||
.length)
|
||||
]),
|
||||
context);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength:
|
||||
selectedUrls
|
||||
.length,
|
||||
errors:
|
||||
errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Text('Import ${source.name}'))
|
||||
child: Text(
|
||||
tr('importX', args: [source.name])))
|
||||
]))
|
||||
.toList()
|
||||
.toList(),
|
||||
const Spacer(),
|
||||
const Divider(
|
||||
height: 32,
|
||||
),
|
||||
Text(tr('importedAppsIdDisclaimer'),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic, fontSize: 12)),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
)
|
||||
],
|
||||
)))
|
||||
]));
|
||||
@ -339,16 +452,19 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Text('Import Errors'),
|
||||
title: Text(tr('importErrors')),
|
||||
content:
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
Text(
|
||||
'${widget.urlsLength - widget.errors.length} of ${widget.urlsLength} Apps imported.',
|
||||
tr('importedXOfYApps', args: [
|
||||
(widget.urlsLength - widget.errors.length).toString(),
|
||||
widget.urlsLength.toString()
|
||||
]),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'The following URLs had errors:',
|
||||
tr('followingURLsHadErrors'),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
...widget.errors.map((e) {
|
||||
@ -371,7 +487,7 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: const Text('Okay'))
|
||||
child: Text(tr('okay')))
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -379,21 +495,37 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class UrlSelectionModal extends StatefulWidget {
|
||||
UrlSelectionModal({super.key, required this.urls});
|
||||
UrlSelectionModal(
|
||||
{super.key,
|
||||
required this.urlsWithDescriptions,
|
||||
this.selectedByDefault = true,
|
||||
this.onlyOneSelectionAllowed = false});
|
||||
|
||||
List<String> urls;
|
||||
Map<String, String> urlsWithDescriptions;
|
||||
bool selectedByDefault;
|
||||
bool onlyOneSelectionAllowed;
|
||||
|
||||
@override
|
||||
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
|
||||
}
|
||||
|
||||
class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
Map<String, bool> urlSelections = {};
|
||||
Map<MapEntry<String, String>, bool> urlWithDescriptionSelections = {};
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (var url in widget.urls) {
|
||||
urlSelections.putIfAbsent(url, () => true);
|
||||
for (var url in widget.urlsWithDescriptions.entries) {
|
||||
urlWithDescriptionSelections.putIfAbsent(url,
|
||||
() => widget.selectedByDefault && !widget.onlyOneSelectionAllowed);
|
||||
}
|
||||
if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) {
|
||||
selectOnlyOne(widget.urlsWithDescriptions.entries.first.key);
|
||||
}
|
||||
}
|
||||
|
||||
selectOnlyOne(String url) {
|
||||
for (var uwd in urlWithDescriptionSelections.keys) {
|
||||
urlWithDescriptionSelections[uwd] = uwd.key == url;
|
||||
}
|
||||
}
|
||||
|
||||
@ -401,23 +533,56 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Text('Select URLs to Import'),
|
||||
title: Text(
|
||||
widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')),
|
||||
content: Column(children: [
|
||||
...urlSelections.keys.map((url) {
|
||||
...urlWithDescriptionSelections.keys.map((urlWithD) {
|
||||
return Row(children: [
|
||||
Checkbox(
|
||||
value: urlSelections[url],
|
||||
value: urlWithDescriptionSelections[urlWithD],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
urlSelections[url] = value ?? false;
|
||||
value ??= false;
|
||||
if (value! && widget.onlyOneSelectionAllowed) {
|
||||
selectOnlyOne(urlWithD.key);
|
||||
} else {
|
||||
urlWithDescriptionSelections[urlWithD] = value!;
|
||||
}
|
||||
});
|
||||
}),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
Uri.parse(url).path.substring(1),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString(urlWithD.key,
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text(
|
||||
Uri.parse(urlWithD.key).path.substring(1),
|
||||
style:
|
||||
const TextStyle(decoration: TextDecoration.underline),
|
||||
textAlign: TextAlign.start,
|
||||
)),
|
||||
Text(
|
||||
urlWithD.value.length > 128
|
||||
? '${urlWithD.value.substring(0, 128)}...'
|
||||
: urlWithD.value,
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic, fontSize: 12),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
)
|
||||
],
|
||||
))
|
||||
]);
|
||||
})
|
||||
@ -427,15 +592,27 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel')),
|
||||
child: Text(tr('cancel'))),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(urlSelections.keys
|
||||
.where((url) => urlSelections[url] ?? false)
|
||||
.toList());
|
||||
},
|
||||
child: Text(
|
||||
'Import ${urlSelections.values.where((b) => b).length} URLs'))
|
||||
onPressed:
|
||||
urlWithDescriptionSelections.values.where((b) => b).isEmpty
|
||||
? null
|
||||
: () {
|
||||
Navigator.of(context).pop(urlWithDescriptionSelections
|
||||
.entries
|
||||
.where((entry) => entry.value)
|
||||
.map((e) => e.key.key)
|
||||
.toList());
|
||||
},
|
||||
child: Text(widget.onlyOneSelectionAllowed
|
||||
? tr('pick')
|
||||
: tr('importX', args: [
|
||||
plural(
|
||||
'url',
|
||||
urlWithDescriptionSelections.values
|
||||
.where((b) => b)
|
||||
.length)
|
||||
])))
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -1,9 +1,13 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/logs_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
@ -23,20 +27,20 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
|
||||
var themeDropdown = DropdownButtonFormField(
|
||||
decoration: const InputDecoration(labelText: 'Theme'),
|
||||
decoration: InputDecoration(labelText: tr('theme')),
|
||||
value: settingsProvider.theme,
|
||||
items: const [
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.dark,
|
||||
child: Text('Dark'),
|
||||
child: Text(tr('dark')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.light,
|
||||
child: Text('Light'),
|
||||
child: Text(tr('light')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.system,
|
||||
child: Text('Follow System'),
|
||||
child: Text(tr('followSystem')),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
@ -46,16 +50,16 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
});
|
||||
|
||||
var colourDropdown = DropdownButtonFormField(
|
||||
decoration: const InputDecoration(labelText: 'Colour'),
|
||||
decoration: InputDecoration(labelText: tr('colour')),
|
||||
value: settingsProvider.colour,
|
||||
items: const [
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: ColourSettings.basic,
|
||||
child: Text('Obtainium'),
|
||||
child: Text(tr('obtainium')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ColourSettings.materialYou,
|
||||
child: Text('Material You'),
|
||||
child: Text(tr('materialYou')),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
@ -65,20 +69,20 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
});
|
||||
|
||||
var sortDropdown = DropdownButtonFormField(
|
||||
decoration: const InputDecoration(labelText: 'App Sort By'),
|
||||
decoration: InputDecoration(labelText: tr('appSortBy')),
|
||||
value: settingsProvider.sortColumn,
|
||||
items: const [
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: SortColumnSettings.authorName,
|
||||
child: Text('Author/Name'),
|
||||
child: Text(tr('authorName')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SortColumnSettings.nameAuthor,
|
||||
child: Text('Name/Author'),
|
||||
child: Text(tr('nameAuthor')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SortColumnSettings.added,
|
||||
child: Text('As Added'),
|
||||
child: Text(tr('asAdded')),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
@ -88,16 +92,16 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
});
|
||||
|
||||
var orderDropdown = DropdownButtonFormField(
|
||||
decoration: const InputDecoration(labelText: 'App Sort Order'),
|
||||
decoration: InputDecoration(labelText: tr('appSortOrder')),
|
||||
value: settingsProvider.sortOrder,
|
||||
items: const [
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: SortOrderSettings.ascending,
|
||||
child: Text('Ascending'),
|
||||
child: Text(tr('ascending')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SortOrderSettings.descending,
|
||||
child: Text('Descending'),
|
||||
child: Text(tr('descending')),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
@ -107,8 +111,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
});
|
||||
|
||||
var intervalDropdown = DropdownButtonFormField(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Background Update Checking Interval'),
|
||||
decoration: InputDecoration(labelText: tr('bgUpdateCheckInterval')),
|
||||
value: settingsProvider.updateInterval,
|
||||
items: updateIntervals.map((e) {
|
||||
int displayNum = (e < 60
|
||||
@ -117,15 +120,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
? e / 60
|
||||
: e / 1440)
|
||||
.round();
|
||||
var displayUnit = (e < 60
|
||||
? 'Minute'
|
||||
: e < 1440
|
||||
? 'Hour'
|
||||
: 'Day');
|
||||
|
||||
String display = e == 0
|
||||
? 'Never - Manual Only'
|
||||
: '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
|
||||
? tr('neverManualOnly')
|
||||
: (e < 60
|
||||
? plural('minute', displayNum)
|
||||
: e < 1440
|
||||
? plural('hour', displayNum)
|
||||
: plural('day', displayNum));
|
||||
return DropdownMenuItem(value: e, child: Text(display));
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
@ -135,18 +136,21 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
});
|
||||
|
||||
var sourceSpecificFields = sourceProvider.sources.map((e) {
|
||||
if (e.moreSourceSettingsFormItems.isNotEmpty) {
|
||||
if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
|
||||
return GeneratedForm(
|
||||
items: e.moreSourceSettingsFormItems.map((e) => [e]).toList(),
|
||||
onValueChanges: (values, valid) {
|
||||
items: e.additionalSourceSpecificSettingFormItems
|
||||
.map((e) => [e])
|
||||
.toList(),
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (valid) {
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
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) ?? '';
|
||||
}).toList());
|
||||
} else {
|
||||
@ -161,7 +165,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
const CustomAppBar(title: 'Settings'),
|
||||
CustomAppBar(title: tr('settings')),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@ -171,7 +175,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Appearance',
|
||||
tr('appearance'),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
@ -194,7 +198,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Show Source Webpage in App View'),
|
||||
Text(tr('showWebInAppView')),
|
||||
Switch(
|
||||
value: settingsProvider.showAppWebpage,
|
||||
onChanged: (value) {
|
||||
@ -206,7 +210,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Pin Updates to Top of Apps View'),
|
||||
Text(tr('pinUpdates')),
|
||||
Switch(
|
||||
value: settingsProvider.pinUpdates,
|
||||
onChanged: (value) {
|
||||
@ -219,7 +223,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
height16,
|
||||
Text(
|
||||
'Updates',
|
||||
tr('updates'),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
@ -228,7 +232,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
height: 48,
|
||||
),
|
||||
Text(
|
||||
'Source-Specific',
|
||||
tr('sourceSpecific'),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
@ -238,23 +242,39 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
height16,
|
||||
TextButton.icon(
|
||||
style: ButtonStyle(
|
||||
foregroundColor: MaterialStateProperty.resolveWith<Color>(
|
||||
(Set<MaterialState> states) {
|
||||
return Colors.grey;
|
||||
}),
|
||||
),
|
||||
onPressed: () {
|
||||
launchUrlString(settingsProvider.sourceUrl,
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
icon: const Icon(Icons.code),
|
||||
label: Text(
|
||||
'Source',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const Divider(
|
||||
height: 32,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
launchUrlString(settingsProvider.sourceUrl,
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
icon: const Icon(Icons.code),
|
||||
label: Text(
|
||||
tr('appSource'),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
context.read<LogsProvider>().get().then((logs) {
|
||||
if (logs.isEmpty) {
|
||||
showError(ObtainiumError(tr('noLogs')), context);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return const LogsDialog();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.bug_report_outlined),
|
||||
label: Text(tr('appLogs'))),
|
||||
],
|
||||
),
|
||||
height16,
|
||||
],
|
||||
@ -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')))
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -5,15 +5,17 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:install_plugin_v2/install_plugin_v2.dart';
|
||||
import 'package:installed_apps/app_info.dart';
|
||||
import 'package:installed_apps/installed_apps.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/logs_provider.dart';
|
||||
import 'package:obtainium/providers/notifications_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:package_archive_info/package_archive_info.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@ -35,83 +37,118 @@ class DownloadedApk {
|
||||
DownloadedApk(this.appId, this.file);
|
||||
}
|
||||
|
||||
List<String> generateStandardVersionRegExStrings() {
|
||||
// TODO: Look into RegEx for non-Latin characters / non-Arabic numerals
|
||||
var basics = [
|
||||
'[0-9]+',
|
||||
'[0-9]+\\.[0-9]+',
|
||||
'[0-9]+\\.[0-9]+\\.[0-9]+',
|
||||
'[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+'
|
||||
];
|
||||
var preSuffixes = ['-', '\\+'];
|
||||
var suffixes = ['alpha', 'beta', 'ose'];
|
||||
var finals = ['\\+[0-9]+', '[0-9]+'];
|
||||
List<String> results = [];
|
||||
for (var b in basics) {
|
||||
results.add(b);
|
||||
for (var p in preSuffixes) {
|
||||
for (var s in suffixes) {
|
||||
results.add('$b$s');
|
||||
results.add('$b$p$s');
|
||||
for (var f in finals) {
|
||||
results.add('$b$s$f');
|
||||
results.add('$b$p$s$f');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
List<String> standardVersionRegExStrings =
|
||||
generateStandardVersionRegExStrings();
|
||||
|
||||
class AppsProvider with ChangeNotifier {
|
||||
// In memory App state (should always be kept in sync with local storage versions)
|
||||
Map<String, AppInMemory> apps = {};
|
||||
bool loadingApps = false;
|
||||
bool gettingUpdates = false;
|
||||
bool forBGTask = false;
|
||||
LogsProvider logs = LogsProvider();
|
||||
|
||||
// Variables to keep track of the app foreground status (installs can't run in the background)
|
||||
bool isForeground = true;
|
||||
late Stream<FGBGType>? foregroundStream;
|
||||
late StreamSubscription<FGBGType>? foregroundSubscription;
|
||||
|
||||
AppsProvider({this.forBGTask = false}) {
|
||||
// Many setup tasks should only be done in the foreground isolate
|
||||
if (!forBGTask) {
|
||||
// Subscribe to changes in the app foreground status
|
||||
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||
foregroundSubscription = foregroundStream?.listen((event) async {
|
||||
isForeground = event == FGBGType.foreground;
|
||||
if (isForeground) await loadApps();
|
||||
AppsProvider() {
|
||||
// Subscribe to changes in the app foreground status
|
||||
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||
foregroundSubscription = foregroundStream?.listen((event) async {
|
||||
isForeground = event == FGBGType.foreground;
|
||||
if (isForeground) await loadApps();
|
||||
});
|
||||
() async {
|
||||
// Load Apps into memory (in background, this is done later instead of in the constructor)
|
||||
await loadApps();
|
||||
// Delete existing APKs
|
||||
(await getExternalStorageDirectory())
|
||||
?.listSync()
|
||||
.where((element) =>
|
||||
element.path.endsWith('.apk') ||
|
||||
element.path.endsWith('.apk.part'))
|
||||
.forEach((apk) {
|
||||
apk.delete();
|
||||
});
|
||||
() 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;
|
||||
StreamedResponse response =
|
||||
await Client().send(Request('GET', Uri.parse(url)));
|
||||
File downloadedFile = File('$destDir/$fileName');
|
||||
|
||||
if (downloadedFile.existsSync()) {
|
||||
downloadedFile.deleteSync();
|
||||
}
|
||||
var length = response.contentLength;
|
||||
var received = 0;
|
||||
double? progress;
|
||||
var sink = downloadedFile.openWrite();
|
||||
|
||||
await response.stream.map((s) {
|
||||
received += s.length;
|
||||
progress = (length != null ? received / length * 100 : 30);
|
||||
if (!(downloadedFile.existsSync() && useExisting)) {
|
||||
File tempDownloadedFile = File('${downloadedFile.path}.part');
|
||||
if (tempDownloadedFile.existsSync()) {
|
||||
tempDownloadedFile.deleteSync();
|
||||
}
|
||||
var length = response.contentLength;
|
||||
var received = 0;
|
||||
double? progress;
|
||||
var sink = tempDownloadedFile.openWrite();
|
||||
await response.stream.map((s) {
|
||||
received += s.length;
|
||||
progress = (length != null ? received / length * 100 : 30);
|
||||
if (onProgress != null) {
|
||||
onProgress(progress);
|
||||
}
|
||||
return s;
|
||||
}).pipe(sink);
|
||||
await sink.close();
|
||||
progress = null;
|
||||
if (onProgress != null) {
|
||||
onProgress(progress);
|
||||
}
|
||||
return s;
|
||||
}).pipe(sink);
|
||||
|
||||
await sink.close();
|
||||
progress = null;
|
||||
if (onProgress != null) {
|
||||
onProgress(progress);
|
||||
}
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
downloadedFile.deleteSync();
|
||||
throw response.reasonPhrase ?? 'Unknown Error';
|
||||
if (response.statusCode != 200) {
|
||||
tempDownloadedFile.deleteSync();
|
||||
throw response.reasonPhrase ?? tr('unexpectedError');
|
||||
}
|
||||
tempDownloadedFile.renameSync(downloadedFile.path);
|
||||
}
|
||||
return downloadedFile;
|
||||
}
|
||||
|
||||
Future<DownloadedApk> downloadApp(App app) async {
|
||||
Future<DownloadedApk> downloadApp(App app, BuildContext? context) async {
|
||||
var fileName =
|
||||
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
|
||||
String downloadUrl = await SourceProvider()
|
||||
.getSource(app.url)
|
||||
.apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
|
||||
NotificationsProvider? notificationsProvider =
|
||||
context?.read<NotificationsProvider>();
|
||||
var notif = DownloadNotification(app.name, 100);
|
||||
notificationsProvider?.cancel(notif.id);
|
||||
int? prevProg;
|
||||
File downloadedFile =
|
||||
await downloadFile(downloadUrl, fileName, (double? progress) {
|
||||
@ -119,12 +156,14 @@ class AppsProvider with ChangeNotifier {
|
||||
if (apps[app.id] != null) {
|
||||
apps[app.id]!.downloadProgress = progress;
|
||||
notifyListeners();
|
||||
} else if ((prog == 25 || prog == 50 || prog == 75) && prevProg != prog) {
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Progress: $prog%', toastLength: Toast.LENGTH_SHORT);
|
||||
}
|
||||
notif = DownloadNotification(app.name, prog ?? 100);
|
||||
if (prog != null && prevProg != prog) {
|
||||
notificationsProvider?.notify(notif);
|
||||
}
|
||||
prevProg = prog;
|
||||
});
|
||||
notificationsProvider?.cancel(notif.id);
|
||||
// Delete older versions of the APK if any
|
||||
for (var file in downloadedFile.parent.listSync()) {
|
||||
var fn = file.path.split('/').last;
|
||||
@ -138,12 +177,17 @@ class AppsProvider with ChangeNotifier {
|
||||
// The former case should be handled (give the App its real ID), the latter is a security issue
|
||||
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
|
||||
if (app.id != newInfo.packageName) {
|
||||
if (apps[app.id] != null) {
|
||||
if (apps[app.id] != null && !SourceProvider().isTempId(app.id)) {
|
||||
throw IDChangedError();
|
||||
}
|
||||
var originalAppId = app.id;
|
||||
app.id = newInfo.packageName;
|
||||
downloadedFile = downloadedFile.renameSync(
|
||||
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
|
||||
if (apps[originalAppId] != null) {
|
||||
await removeApps([originalAppId]);
|
||||
await saveApps([app]);
|
||||
}
|
||||
}
|
||||
return DownloadedApk(app.id, downloadedFile);
|
||||
}
|
||||
@ -154,8 +198,8 @@ class AppsProvider with ChangeNotifier {
|
||||
|
||||
Future<bool> canInstallSilently(App app) async {
|
||||
return false;
|
||||
// TODO: Uncomment the below once silentupdates are ever figured out
|
||||
// // TODO: This is unreliable - try to get from OS in the future
|
||||
// TODO: Uncomment the below if silent updates are ever figured out
|
||||
// // NOTE: This is unreliable - try to get from OS in the future
|
||||
// if (app.apkUrls.length > 1) {
|
||||
// return false;
|
||||
// }
|
||||
@ -176,6 +220,15 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> canDowngradeApps() async {
|
||||
try {
|
||||
await InstalledApps.getAppInfo('com.berdik.letmedowngrade');
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Unfortunately this 'await' does not actually wait for the APK to finish installing
|
||||
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
|
||||
// If appropriate criteria are met, the update (never a fresh install) happens silently in the background
|
||||
@ -189,7 +242,8 @@ class AppsProvider with ChangeNotifier {
|
||||
// OK
|
||||
}
|
||||
if (appInfo != null &&
|
||||
int.parse(newInfo.buildNumber) < appInfo.versionCode!) {
|
||||
int.parse(newInfo.buildNumber) < appInfo.versionCode! &&
|
||||
!(await canDowngradeApps())) {
|
||||
throw DowngradeError();
|
||||
}
|
||||
if (appInfo == null ||
|
||||
@ -206,11 +260,18 @@ class AppsProvider with ChangeNotifier {
|
||||
Future<String?> confirmApkUrl(App app, BuildContext? context) async {
|
||||
// If the App has more than one APK, the user should pick one (if context provided)
|
||||
String? apkUrl = app.apkUrls[app.preferredApkIndex];
|
||||
// get device supported architecture
|
||||
List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
|
||||
|
||||
if (app.apkUrls.length > 1 && context != null) {
|
||||
apkUrl = await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return APKPicker(app: app, initVal: apkUrl);
|
||||
return APKPicker(
|
||||
app: app,
|
||||
initVal: apkUrl,
|
||||
archs: archs,
|
||||
);
|
||||
});
|
||||
}
|
||||
// If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
|
||||
@ -238,14 +299,18 @@ class AppsProvider with ChangeNotifier {
|
||||
Future<List<String>> downloadAndInstallLatestApps(
|
||||
List<String> appIds, BuildContext? context) async {
|
||||
List<String> appsToInstall = [];
|
||||
List<String> trackOnlyAppsToUpdate = [];
|
||||
// For all specified Apps, filter out those for which:
|
||||
// 1. A URL cannot be picked
|
||||
// 2. That cannot be installed silently (IF no buildContext was given for interactive install)
|
||||
for (var id in appIds) {
|
||||
if (apps[id] == null) {
|
||||
throw ObtainiumError('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) {
|
||||
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
||||
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
||||
@ -256,13 +321,22 @@ class AppsProvider with ChangeNotifier {
|
||||
appsToInstall.add(id);
|
||||
}
|
||||
}
|
||||
if (apps[id]!.app.trackOnly) {
|
||||
trackOnlyAppsToUpdate.add(id);
|
||||
}
|
||||
}
|
||||
// Mark all specified track-only apps as latest
|
||||
saveApps(trackOnlyAppsToUpdate.map((e) {
|
||||
var a = apps[e]!.app;
|
||||
a.installedVersion = a.latestVersion;
|
||||
return a;
|
||||
}).toList());
|
||||
// Download APKs for all Apps to be installed
|
||||
MultiAppMultiError errors = MultiAppMultiError();
|
||||
List<DownloadedApk?> downloadedFiles =
|
||||
await Future.wait(appsToInstall.map((id) async {
|
||||
try {
|
||||
return await downloadApp(apps[id]!.app);
|
||||
return await downloadApp(apps[id]!.app, context);
|
||||
} catch (e) {
|
||||
errors.add(id, e.toString());
|
||||
}
|
||||
@ -282,15 +356,16 @@ 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);
|
||||
|
||||
// If Obtainium is being installed, it should be the last one
|
||||
List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) {
|
||||
String obtainiumId = 'imranr98_obtainium_${GitHub().host}';
|
||||
DownloadedApk? temp;
|
||||
items.removeWhere((element) {
|
||||
bool res = element.appId == obtainiumId;
|
||||
bool res =
|
||||
element.appId == obtainiumId || element.appId == obtainiumTempId;
|
||||
if (res) {
|
||||
temp = element;
|
||||
}
|
||||
@ -327,6 +402,8 @@ class AppsProvider with ChangeNotifier {
|
||||
throw errors;
|
||||
}
|
||||
|
||||
NotificationsProvider().cancel(UpdateNotification([]).id);
|
||||
|
||||
return downloadedFiles.map((e) => e!.appId).toList();
|
||||
}
|
||||
|
||||
@ -351,68 +428,119 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
// 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 the latestVersion seems to match the version in installedInfo (not guaranteed)
|
||||
// If there is any other mismatch between installedInfo and installedVersion, try reconciling them intelligently
|
||||
// If that fails, just set it to the actual version string (all we can do at that point)
|
||||
// Don't save changes, just return the object if changes were made (else null)
|
||||
// If in a background isolate, return null straight away as the required plugin won't work anyways
|
||||
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
|
||||
if (forBGTask) {
|
||||
return null; // Can't correct in the background isolate
|
||||
}
|
||||
var modded = false;
|
||||
if (installedInfo == null && app.installedVersion != null) {
|
||||
if (installedInfo == null &&
|
||||
app.installedVersion != null &&
|
||||
!app.trackOnly) {
|
||||
app.installedVersion = null;
|
||||
modded = true;
|
||||
}
|
||||
if (installedInfo != null && app.installedVersion == null) {
|
||||
if (app.latestVersion.characters
|
||||
.where((p0) => [
|
||||
'0',
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'.'
|
||||
].contains(p0))
|
||||
.join('') ==
|
||||
installedInfo.versionName) {
|
||||
app.installedVersion = app.latestVersion;
|
||||
} else {
|
||||
app.installedVersion = installedInfo.versionName;
|
||||
} else if (installedInfo?.versionName != null &&
|
||||
app.installedVersion == null) {
|
||||
app.installedVersion = installedInfo!.versionName;
|
||||
modded = true;
|
||||
} else if (installedInfo?.versionName != null &&
|
||||
installedInfo!.versionName != app.installedVersion) {
|
||||
String? correctedInstalledVersion = reconcileRealAndInternalVersions(
|
||||
installedInfo.versionName!, app.installedVersion!);
|
||||
if (correctedInstalledVersion != null) {
|
||||
app.installedVersion = correctedInstalledVersion;
|
||||
modded = true;
|
||||
}
|
||||
}
|
||||
if (app.installedVersion != null &&
|
||||
app.installedVersion != app.latestVersion) {
|
||||
app.installedVersion = reconcileRealAndInternalVersions(
|
||||
app.installedVersion!, app.latestVersion,
|
||||
matchMode: true) ??
|
||||
app.installedVersion;
|
||||
modded = true;
|
||||
}
|
||||
return modded ? app : null;
|
||||
}
|
||||
|
||||
String? reconcileRealAndInternalVersions(
|
||||
String realVersion, String internalVersion,
|
||||
{bool matchMode = false}) {
|
||||
// 1. If one or both of these can't be converted to a "standard" format, return null (leave as is)
|
||||
// 2. If both have a "standard" format under which they are equal, return null (leave as is)
|
||||
// 3. If both have a "standard" format in common but are unequal, return realVersion (this means it was changed externally)
|
||||
// If in matchMode, the outcomes of rules 2 and 3 are reversed, and the "real" version is not matched strictly
|
||||
// Matchmode to be used when comparing internal install version and internal latest version
|
||||
|
||||
bool doStringsMatchUnderRegEx(
|
||||
String pattern, String value1, String value2) {
|
||||
var r = RegExp(pattern);
|
||||
var m1 = r.firstMatch(value1);
|
||||
var m2 = r.firstMatch(value2);
|
||||
return m1 != null && m2 != null
|
||||
? value1.substring(m1.start, m1.end) ==
|
||||
value2.substring(m2.start, m2.end)
|
||||
: false;
|
||||
}
|
||||
|
||||
Set<String> findStandardFormatsForVersion(String version, bool strict) {
|
||||
Set<String> results = {};
|
||||
for (var pattern in standardVersionRegExStrings) {
|
||||
if (RegExp('${strict ? '^' : ''}$pattern${strict ? '\$' : ''}')
|
||||
.hasMatch(version)) {
|
||||
results.add(pattern);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
var realStandardVersionFormats =
|
||||
findStandardFormatsForVersion(realVersion, true);
|
||||
var internalStandardVersionFormats =
|
||||
findStandardFormatsForVersion(internalVersion, false);
|
||||
var commonStandardFormats =
|
||||
realStandardVersionFormats.intersection(internalStandardVersionFormats);
|
||||
if (commonStandardFormats.isEmpty) {
|
||||
return null; // Incompatible; no "enhanced detection"
|
||||
}
|
||||
for (String pattern in commonStandardFormats) {
|
||||
if (doStringsMatchUnderRegEx(pattern, internalVersion, realVersion)) {
|
||||
return matchMode
|
||||
? internalVersion
|
||||
: null; // Enhanced detection says no change
|
||||
}
|
||||
}
|
||||
return matchMode
|
||||
? null
|
||||
: realVersion; // Enhanced detection says something changed
|
||||
}
|
||||
|
||||
Future<void> loadApps() async {
|
||||
while (loadingApps) {
|
||||
await Future.delayed(const Duration(microseconds: 1));
|
||||
}
|
||||
loadingApps = true;
|
||||
notifyListeners();
|
||||
List<FileSystemEntity> appFiles = (await getAppsDir())
|
||||
List<App> newApps = (await getAppsDir())
|
||||
.listSync()
|
||||
.where((item) => item.path.toLowerCase().endsWith('.json'))
|
||||
.map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync())))
|
||||
.toList();
|
||||
apps.clear();
|
||||
var idsToDelete = apps.values
|
||||
.map((e) => e.app.id)
|
||||
.toSet()
|
||||
.difference(newApps.map((e) => e.id).toSet());
|
||||
for (var id in idsToDelete) {
|
||||
apps.remove(id);
|
||||
}
|
||||
var sp = SourceProvider();
|
||||
List<List<String>> errors = [];
|
||||
for (int i = 0; i < appFiles.length; i++) {
|
||||
App app =
|
||||
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync()));
|
||||
var info = await getInstalledInfo(app.id);
|
||||
for (int i = 0; i < newApps.length; i++) {
|
||||
var info = await getInstalledInfo(newApps[i].id);
|
||||
try {
|
||||
sp.getSource(app.url);
|
||||
apps.putIfAbsent(app.id, () => AppInMemory(app, null, info));
|
||||
sp.getSource(newApps[i].url);
|
||||
apps[newApps[i].id] = AppInMemory(newApps[i], null, info);
|
||||
} catch (e) {
|
||||
errors.add([app.id, app.name, e.toString()]);
|
||||
errors.add([newApps[i].id, newApps[i].name, e.toString()]);
|
||||
}
|
||||
}
|
||||
if (errors.isNotEmpty) {
|
||||
@ -431,7 +559,7 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
if (modifiedApps.isNotEmpty) {
|
||||
await saveApps(modifiedApps);
|
||||
await saveApps(modifiedApps, attemptToCorrectInstallStatus: false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -475,8 +603,10 @@ class AppsProvider with ChangeNotifier {
|
||||
currentApp.url,
|
||||
currentApp.additionalData,
|
||||
name: currentApp.name,
|
||||
id: currentApp.id);
|
||||
newApp.installedVersion = currentApp.installedVersion;
|
||||
id: currentApp.id,
|
||||
pinned: currentApp.pinned,
|
||||
trackOnly: currentApp.trackOnly,
|
||||
installedVersion: currentApp.installedVersion);
|
||||
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
||||
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
||||
}
|
||||
@ -549,13 +679,13 @@ class AppsProvider with ChangeNotifier {
|
||||
|
||||
Future<String> exportApps() async {
|
||||
Directory? exportDir = Directory('/storage/emulated/0/Download');
|
||||
String path = 'Downloads';
|
||||
String path = 'Downloads'; // TODO: See if hardcoding this can be avoided
|
||||
if (!exportDir.existsSync()) {
|
||||
exportDir = await getExternalStorageDirectory();
|
||||
path = exportDir!.path;
|
||||
}
|
||||
File export = File(
|
||||
'${exportDir.path}/obtainium-export-${DateTime.now().millisecondsSinceEpoch}.json');
|
||||
'${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json');
|
||||
export.writeAsStringSync(
|
||||
jsonEncode(apps.values.map((e) => e.app.toJson()).toList()));
|
||||
return path;
|
||||
@ -583,13 +713,31 @@ class AppsProvider with ChangeNotifier {
|
||||
foregroundSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<List<List<String>>> addAppsByURL(List<String> urls) async {
|
||||
List<dynamic> results = await SourceProvider().getAppsByURLNaive(urls,
|
||||
ignoreUrls: apps.values.map((e) => e.app.url).toList());
|
||||
List<App> pps = results[0];
|
||||
Map<String, dynamic> errorsMap = results[1];
|
||||
for (var app in pps) {
|
||||
if (apps.containsKey(app.id)) {
|
||||
errorsMap.addAll({app.id: tr('appAlreadyAdded')});
|
||||
} else {
|
||||
await saveApps([app]);
|
||||
}
|
||||
}
|
||||
List<List<String>> errors =
|
||||
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
class APKPicker extends StatefulWidget {
|
||||
const APKPicker({super.key, required this.app, this.initVal});
|
||||
const APKPicker({super.key, required this.app, this.initVal, this.archs});
|
||||
|
||||
final App app;
|
||||
final String? initVal;
|
||||
final List<String>? archs;
|
||||
|
||||
@override
|
||||
State<APKPicker> createState() => _APKPickerState();
|
||||
@ -603,35 +751,50 @@ class _APKPickerState extends State<APKPicker> {
|
||||
apkUrl ??= widget.initVal;
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Text('Pick an APK'),
|
||||
title: Text(tr('pickAnAPK')),
|
||||
content: Column(children: [
|
||||
Text('${widget.app.name} has more than one package:'),
|
||||
Text(tr('appHasMoreThanOnePackage', args: [widget.app.name])),
|
||||
const SizedBox(height: 16),
|
||||
...widget.app.apkUrls.map((u) => RadioListTile<String>(
|
||||
title: Text(Uri.parse(u)
|
||||
.pathSegments
|
||||
.where((element) => element.isNotEmpty)
|
||||
.last),
|
||||
value: u,
|
||||
groupValue: apkUrl,
|
||||
onChanged: (String? val) {
|
||||
setState(() {
|
||||
apkUrl = val;
|
||||
});
|
||||
}))
|
||||
...widget.app.apkUrls.map(
|
||||
(u) => RadioListTile<String>(
|
||||
title: Text(Uri.parse(u)
|
||||
.pathSegments
|
||||
.where((element) => element.isNotEmpty)
|
||||
.last),
|
||||
value: u,
|
||||
groupValue: apkUrl,
|
||||
onChanged: (String? val) {
|
||||
setState(() {
|
||||
apkUrl = val;
|
||||
});
|
||||
}),
|
||||
),
|
||||
if (widget.archs != null)
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
if (widget.archs != null)
|
||||
Text(
|
||||
widget.archs!.length == 1
|
||||
? tr('deviceSupportsXArch', args: [widget.archs![0]])
|
||||
: tr('deviceSupportsFollowingArchs') +
|
||||
list2FriendlyString(
|
||||
widget.archs!.map((e) => '\'$e\'').toList()),
|
||||
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
|
||||
),
|
||||
]),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: const Text('Cancel')),
|
||||
child: Text(tr('cancel'))),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.selectionClick();
|
||||
Navigator.of(context).pop(apkUrl);
|
||||
},
|
||||
child: const Text('Continue'))
|
||||
child: Text(tr('continue')))
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -653,21 +816,23 @@ class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Text('Warning'),
|
||||
content: Text(
|
||||
'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'),
|
||||
title: Text(tr('warning')),
|
||||
content: Text(tr('sourceIsXButPackageFromYPrompt', args: [
|
||||
Uri.parse(widget.sourceUrl).host,
|
||||
Uri.parse(widget.apkUrl).host
|
||||
])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: const Text('Cancel')),
|
||||
child: Text(tr('cancel'))),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.selectionClick();
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: const Text('Continue'))
|
||||
child: Text(tr('continue')))
|
||||
],
|
||||
);
|
||||
}
|
||||
|
112
lib/providers/logs_provider.dart
Normal file
@ -0,0 +1,112 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
const String logTable = 'logs';
|
||||
const String idColumn = '_id';
|
||||
const String levelColumn = 'level';
|
||||
const String messageColumn = 'message';
|
||||
const String timestampColumn = 'timestamp';
|
||||
const String dbPath = 'logs.db';
|
||||
|
||||
enum LogLevels { debug, info, warning, error }
|
||||
|
||||
class Log {
|
||||
int? id;
|
||||
late LogLevels level;
|
||||
late String message;
|
||||
DateTime timestamp = DateTime.now();
|
||||
|
||||
Map<String, Object?> toMap() {
|
||||
var map = <String, Object?>{
|
||||
idColumn: id,
|
||||
levelColumn: level.index,
|
||||
messageColumn: message,
|
||||
timestampColumn: timestamp.millisecondsSinceEpoch
|
||||
};
|
||||
return map;
|
||||
}
|
||||
|
||||
Log(this.message, this.level);
|
||||
|
||||
Log.fromMap(Map<String, Object?> map) {
|
||||
id = map[idColumn] as int;
|
||||
level = LogLevels.values.elementAt(map[levelColumn] as int);
|
||||
message = map[messageColumn] as String;
|
||||
timestamp =
|
||||
DateTime.fromMillisecondsSinceEpoch(map[timestampColumn] as int);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '${timestamp.toString()}: ${level.name}: $message';
|
||||
}
|
||||
}
|
||||
|
||||
class LogsProvider {
|
||||
LogsProvider({bool runDefaultClear = true}) {
|
||||
clear(before: DateTime.now().subtract(const Duration(days: 7)));
|
||||
}
|
||||
|
||||
Database? db;
|
||||
|
||||
Future<Database> getDB() async {
|
||||
db ??= await openDatabase(dbPath, version: 1,
|
||||
onCreate: (Database db, int version) async {
|
||||
await db.execute('''
|
||||
create table if not exists $logTable (
|
||||
$idColumn integer primary key autoincrement,
|
||||
$levelColumn integer not null,
|
||||
$messageColumn text not null,
|
||||
$timestampColumn integer not null)
|
||||
''');
|
||||
});
|
||||
return db!;
|
||||
}
|
||||
|
||||
Future<Log> add(String message, {LogLevels level = LogLevels.info}) async {
|
||||
Log l = Log(message, level);
|
||||
l.id = await (await getDB()).insert(logTable, l.toMap());
|
||||
if (kDebugMode) {
|
||||
print(l);
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
Future<List<Log>> get({DateTime? before, DateTime? after}) async {
|
||||
var where = getWhereDates(before: before, after: after);
|
||||
return (await (await getDB())
|
||||
.query(logTable, where: where.key, whereArgs: where.value))
|
||||
.map((e) => Log.fromMap(e))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<int> clear({DateTime? before, DateTime? after}) async {
|
||||
var where = getWhereDates(before: before, after: after);
|
||||
var res = await (await getDB())
|
||||
.delete(logTable, where: where.key, whereArgs: where.value);
|
||||
if (res > 0) {
|
||||
add(plural('clearedNLogsBeforeXAfterY', res,
|
||||
namedArgs: {'before': before.toString(), 'after': after.toString()},
|
||||
name: 'n'));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
MapEntry<String?, List<int>?> getWhereDates(
|
||||
{DateTime? before, DateTime? after}) {
|
||||
List<String> where = [];
|
||||
List<int> whereArgs = [];
|
||||
if (before != null) {
|
||||
where.add('$timestampColumn < ?');
|
||||
whereArgs.add(before.millisecondsSinceEpoch);
|
||||
}
|
||||
if (after != null) {
|
||||
where.add('$timestampColumn > ?');
|
||||
whereArgs.add(after.millisecondsSinceEpoch);
|
||||
}
|
||||
return whereArgs.isEmpty
|
||||
? const MapEntry(null, null)
|
||||
: MapEntry(where.join(' and '), whereArgs);
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
// Exposes functions that can be used to send notifications to the user
|
||||
// Contains a set of pre-defined ObtainiumNotification objects that should be used throughout the app
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
@ -12,40 +13,42 @@ class ObtainiumNotification {
|
||||
late String channelName;
|
||||
late String channelDescription;
|
||||
Importance importance;
|
||||
int? progPercent;
|
||||
bool onlyAlertOnce;
|
||||
|
||||
ObtainiumNotification(this.id, this.title, this.message, this.channelCode,
|
||||
this.channelName, this.channelDescription, this.importance);
|
||||
this.channelName, this.channelDescription, this.importance,
|
||||
{this.onlyAlertOnce = false, this.progPercent});
|
||||
}
|
||||
|
||||
class UpdateNotification extends ObtainiumNotification {
|
||||
UpdateNotification(List<App> updates)
|
||||
: super(
|
||||
2,
|
||||
'Updates Available',
|
||||
tr('updatesAvailable'),
|
||||
'',
|
||||
'UPDATES_AVAILABLE',
|
||||
'Updates Available',
|
||||
'Notifies the user that updates are available for one or more Apps tracked by Obtainium',
|
||||
tr('updatesAvailable'),
|
||||
tr('updatesAvailableNotifDescription'),
|
||||
Importance.max) {
|
||||
message = updates.length == 1
|
||||
? '${updates[0].name} has an update.'
|
||||
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.';
|
||||
message = updates.isEmpty
|
||||
? tr('noNewUpdates')
|
||||
: updates.length == 1
|
||||
? tr('xHasAnUpdate', args: [updates[0].name])
|
||||
: plural('xAndNMoreUpdatesAvailable', updates.length - 1,
|
||||
args: [updates[0].name]);
|
||||
}
|
||||
}
|
||||
|
||||
class SilentUpdateNotification extends ObtainiumNotification {
|
||||
SilentUpdateNotification(List<App> updates)
|
||||
: super(
|
||||
3,
|
||||
'Apps Updated',
|
||||
'',
|
||||
'APPS_UPDATED',
|
||||
'Apps Updated',
|
||||
'Notifies the user that updates to one or more Apps were applied in the background',
|
||||
Importance.defaultImportance) {
|
||||
: super(3, tr('appsUpdated'), '', 'APPS_UPDATED', tr('appsUpdated'),
|
||||
tr('appsUpdatedNotifDescription'), Importance.defaultImportance) {
|
||||
message = updates.length == 1
|
||||
? '${updates[0].name} was updated to ${updates[0].latestVersion}.'
|
||||
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} were updated.';
|
||||
? tr('xWasUpdatedToY',
|
||||
args: [updates[0].name, updates[0].latestVersion])
|
||||
: plural('xAndNMoreUpdatesInstalled', updates.length - 1,
|
||||
args: [updates[0].name]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,48 +56,56 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
|
||||
ErrorCheckingUpdatesNotification(String error)
|
||||
: super(
|
||||
5,
|
||||
'Error Checking for Updates',
|
||||
tr('errorCheckingUpdates'),
|
||||
error,
|
||||
'BG_UPDATE_CHECK_ERROR',
|
||||
'Error Checking for Updates',
|
||||
'A notification that shows when background update checking fails',
|
||||
tr('errorCheckingUpdates'),
|
||||
tr('errorCheckingUpdatesNotifDescription'),
|
||||
Importance.high);
|
||||
}
|
||||
|
||||
class AppsRemovedNotification extends ObtainiumNotification {
|
||||
AppsRemovedNotification(List<List<String>> namedReasons)
|
||||
: super(
|
||||
6,
|
||||
'Apps Removed',
|
||||
'',
|
||||
'APPS_REMOVED',
|
||||
'Apps Removed',
|
||||
'Notifies the user that one or more Apps were removed due to errors while loading them',
|
||||
Importance.max) {
|
||||
: super(6, tr('appsRemoved'), '', 'APPS_REMOVED', tr('appsRemoved'),
|
||||
tr('appsRemovedNotifDescription'), Importance.max) {
|
||||
message = '';
|
||||
for (var r in namedReasons) {
|
||||
message += '${r[0]} was removed due to this error: ${r[1]}. \n';
|
||||
message += '${tr('xWasRemovedDueToErrorY', args: [r[0], r[1]])} \n';
|
||||
}
|
||||
message = message.trim();
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadNotification extends ObtainiumNotification {
|
||||
DownloadNotification(String appName, int progPercent)
|
||||
: super(
|
||||
appName.hashCode,
|
||||
'Downloading $appName',
|
||||
'',
|
||||
'APP_DOWNLOADING',
|
||||
'Downloading App',
|
||||
'Notifies the user of the progress in downloading an App',
|
||||
Importance.low,
|
||||
onlyAlertOnce: true,
|
||||
progPercent: progPercent);
|
||||
}
|
||||
|
||||
final completeInstallationNotification = ObtainiumNotification(
|
||||
1,
|
||||
'Complete App Installation',
|
||||
'Obtainium must be open to install Apps',
|
||||
tr('completeAppInstallation'),
|
||||
tr('obtainiumMustBeOpenToInstallApps'),
|
||||
'COMPLETE_INSTALL',
|
||||
'Complete App Installation',
|
||||
'Asks the user to return to Obtanium to finish installing an App',
|
||||
tr('completeAppInstallation'),
|
||||
tr('completeAppInstallationNotifDescription'),
|
||||
Importance.max);
|
||||
|
||||
final checkingUpdatesNotification = ObtainiumNotification(
|
||||
4,
|
||||
'Checking for Updates',
|
||||
tr('checkingForUpdates'),
|
||||
'',
|
||||
'BG_UPDATE_CHECK',
|
||||
'Checking for Updates',
|
||||
'Transient notification that appears when checking for updates',
|
||||
tr('checkingForUpdates'),
|
||||
tr('checkingForUpdatesNotifDescription'),
|
||||
Importance.min);
|
||||
|
||||
class NotificationsProvider {
|
||||
@ -134,7 +145,9 @@ class NotificationsProvider {
|
||||
String channelName,
|
||||
String channelDescription,
|
||||
Importance importance,
|
||||
{bool cancelExisting = false}) async {
|
||||
{bool cancelExisting = false,
|
||||
int? progPercent,
|
||||
bool onlyAlertOnce = false}) async {
|
||||
if (cancelExisting) {
|
||||
await cancel(id);
|
||||
}
|
||||
@ -150,12 +163,18 @@ class NotificationsProvider {
|
||||
channelDescription: channelDescription,
|
||||
importance: importance,
|
||||
priority: importanceToPriority[importance]!,
|
||||
groupKey: 'dev.imranr.obtainium.$channelCode')));
|
||||
groupKey: 'dev.imranr.obtainium.$channelCode',
|
||||
progress: progPercent ?? 0,
|
||||
maxProgress: 100,
|
||||
showProgress: progPercent != null,
|
||||
onlyAlertOnce: onlyAlertOnce)));
|
||||
}
|
||||
|
||||
Future<void> notify(ObtainiumNotification notif,
|
||||
{bool cancelExisting = false}) =>
|
||||
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode,
|
||||
notif.channelName, notif.channelDescription, notif.importance,
|
||||
cancelExisting: cancelExisting);
|
||||
cancelExisting: cancelExisting,
|
||||
onlyAlertOnce: notif.onlyAlertOnce,
|
||||
progPercent: notif.progPercent);
|
||||
}
|
||||
|
@ -1,10 +1,15 @@
|
||||
// Exposes functions used to save/load app settings
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
String obtainiumTempId = 'imranr98_obtainium_${GitHub().host}';
|
||||
String obtainiumId = 'dev.imranr.obtainium';
|
||||
|
||||
enum ThemeSettings { system, light, dark }
|
||||
|
||||
enum ColourSettings { basic, materialYou }
|
||||
@ -105,8 +110,7 @@ class SettingsProvider with ChangeNotifier {
|
||||
while (!(await Permission.requestInstallPackages.isGranted)) {
|
||||
// Explicit request as InstallPlugin request sometimes bugged
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Please allow Obtainium to install Apps',
|
||||
toastLength: Toast.LENGTH_LONG);
|
||||
msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG);
|
||||
if ((await Permission.requestInstallPackages.request()) ==
|
||||
PermissionStatus.granted) {
|
||||
break;
|
||||
|
@ -3,7 +3,10 @@
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/apkmirror.dart';
|
||||
import 'package:obtainium/app_sources/fdroid.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/app_sources/gitlab.dart';
|
||||
@ -40,6 +43,8 @@ class App {
|
||||
late int preferredApkIndex;
|
||||
late List<String> additionalData;
|
||||
late DateTime? lastUpdateCheck;
|
||||
bool pinned = false;
|
||||
bool trackOnly = false;
|
||||
App(
|
||||
this.id,
|
||||
this.url,
|
||||
@ -50,11 +55,13 @@ class App {
|
||||
this.apkUrls,
|
||||
this.preferredApkIndex,
|
||||
this.additionalData,
|
||||
this.lastUpdateCheck);
|
||||
this.lastUpdateCheck,
|
||||
this.pinned,
|
||||
this.trackOnly);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()}';
|
||||
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned';
|
||||
}
|
||||
|
||||
factory App.fromJson(Map<String, dynamic> json) => App(
|
||||
@ -71,11 +78,15 @@ class App {
|
||||
: List<String>.from(jsonDecode(json['apkUrls'])),
|
||||
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
|
||||
json['additionalData'] == null
|
||||
? SourceProvider().getSource(json['url']).additionalDataDefaults
|
||||
? SourceProvider()
|
||||
.getSource(json['url'])
|
||||
.additionalSourceAppSpecificDefaults
|
||||
: List<String>.from(jsonDecode(json['additionalData'])),
|
||||
json['lastUpdateCheck'] == null
|
||||
? null
|
||||
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']));
|
||||
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
|
||||
json['pinned'] ?? false,
|
||||
json['trackOnly'] ?? false);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
@ -87,7 +98,9 @@ class App {
|
||||
'apkUrls': jsonEncode(apkUrls),
|
||||
'preferredApkIndex': preferredApkIndex,
|
||||
'additionalData': jsonEncode(additionalData),
|
||||
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch
|
||||
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
|
||||
'pinned': pinned,
|
||||
'trackOnly': trackOnly
|
||||
};
|
||||
}
|
||||
|
||||
@ -108,13 +121,6 @@ preStandardizeUrl(String url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const String couldNotFindReleases = 'Could not find a suitable release';
|
||||
const String couldNotFindLatestVersion =
|
||||
'Could not determine latest release version';
|
||||
String notValidURL(String sourceName) {
|
||||
return 'Not a valid $sourceName App URL';
|
||||
}
|
||||
|
||||
const String noAPKFound = 'No APK found';
|
||||
|
||||
List<String> getLinksFromParsedHTML(
|
||||
@ -128,23 +134,66 @@ List<String> getLinksFromParsedHTML(
|
||||
.map((e) => '$prependToLinks${e.attributes['href']!}')
|
||||
.toList();
|
||||
|
||||
abstract class AppSource {
|
||||
class AppSource {
|
||||
late String host;
|
||||
String standardizeURL(String url);
|
||||
bool enforceTrackOnly = false;
|
||||
String standardizeURL(String url) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData);
|
||||
AppNames getAppNames(String standardUrl);
|
||||
late List<List<GeneratedFormItem>> additionalDataFormItems;
|
||||
late List<String> additionalDataDefaults;
|
||||
late List<GeneratedFormItem> moreSourceSettingsFormItems;
|
||||
String? changeLogPageFromStandardUrl(String standardUrl);
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl);
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
// Different Sources may need different kinds of additional data for Apps
|
||||
List<List<GeneratedFormItem>> additionalSourceAppSpecificFormItems = [];
|
||||
List<String> additionalSourceAppSpecificDefaults = [];
|
||||
|
||||
// Some additional data may be needed for Apps regardless of Source
|
||||
final List<GeneratedFormItem> additionalAppSpecificSourceAgnosticFormItems = [
|
||||
GeneratedFormItem(
|
||||
label: tr('trackOnly'),
|
||||
type: FormItemType.bool,
|
||||
key: 'trackOnlyFormItemKey')
|
||||
];
|
||||
final List<String> additionalAppSpecificSourceAgnosticDefaults = [''];
|
||||
|
||||
// Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider
|
||||
List<GeneratedFormItem> additionalSourceSpecificSettingFormItems = [];
|
||||
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
|
||||
return apkUrl;
|
||||
}
|
||||
|
||||
bool canSearch = false;
|
||||
Future<Map<String, String>> search(String query) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
String? tryInferringAppId(String standardUrl) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ObtainiumError getObtainiumHttpError(Response res) {
|
||||
return ObtainiumError(res.reasonPhrase ??
|
||||
tr('errorWithHttpStatusCode', args: [res.statusCode.toString()]));
|
||||
}
|
||||
|
||||
abstract class MassAppUrlSource {
|
||||
late String name;
|
||||
late List<String> requiredArgs;
|
||||
Future<List<String>> getUrls(List<String> args);
|
||||
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
|
||||
}
|
||||
|
||||
class SourceProvider {
|
||||
@ -157,7 +206,7 @@ class SourceProvider {
|
||||
Mullvad(),
|
||||
Signal(),
|
||||
SourceForge(),
|
||||
// APKMirror()
|
||||
APKMirror()
|
||||
];
|
||||
|
||||
// Add more mass url source classes here so they are available via the service
|
||||
@ -179,7 +228,7 @@ class SourceProvider {
|
||||
}
|
||||
|
||||
bool ifSourceAppsRequireAdditionalData(AppSource source) {
|
||||
for (var row in source.additionalDataFormItems) {
|
||||
for (var row in source.additionalSourceAppSpecificFormItems) {
|
||||
for (var element in row) {
|
||||
if (element.required) {
|
||||
return true;
|
||||
@ -192,42 +241,67 @@ class SourceProvider {
|
||||
String generateTempID(AppNames names, AppSource source) =>
|
||||
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}';
|
||||
|
||||
bool isTempId(String id) {
|
||||
List<String> parts = id.split('_');
|
||||
if (parts.length < 3) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < parts.length - 1; i++) {
|
||||
if (RegExp('.*[A-Z].*').hasMatch(parts[i])) {
|
||||
// TODO: Look into RegEx for non-Latin characters
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return sources.map((e) => e.host).contains(parts.last);
|
||||
}
|
||||
|
||||
Future<App> getApp(AppSource source, String url, List<String> additionalData,
|
||||
{String name = '', String? id}) async {
|
||||
{String name = '',
|
||||
String? id,
|
||||
bool pinned = false,
|
||||
bool trackOnly = false,
|
||||
String? installedVersion}) async {
|
||||
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
||||
AppNames names = source.getAppNames(standardUrl);
|
||||
APKDetails apk =
|
||||
await source.getLatestAPKDetails(standardUrl, additionalData);
|
||||
APKDetails apk = await source
|
||||
.getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly);
|
||||
if (apk.apkUrls.isEmpty && !trackOnly) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
String apkVersion = apk.version.replaceAll('/', '-');
|
||||
return App(
|
||||
id ?? generateTempID(names, source),
|
||||
id ??
|
||||
source.tryInferringAppId(standardUrl) ??
|
||||
generateTempID(names, source),
|
||||
standardUrl,
|
||||
names.author[0].toUpperCase() + names.author.substring(1),
|
||||
name.trim().isNotEmpty
|
||||
? name
|
||||
: names.name[0].toUpperCase() + names.name.substring(1),
|
||||
null,
|
||||
apk.version.replaceAll('/', '-'),
|
||||
installedVersion,
|
||||
apkVersion,
|
||||
apk.apkUrls,
|
||||
apk.apkUrls.length - 1,
|
||||
additionalData,
|
||||
DateTime.now());
|
||||
DateTime.now(),
|
||||
pinned,
|
||||
trackOnly);
|
||||
}
|
||||
|
||||
// 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<App> apps = [];
|
||||
Map<String, dynamic> errors = {};
|
||||
for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
|
||||
try {
|
||||
var source = getSource(url);
|
||||
apps.add(await getApp(source, url, source.additionalDataDefaults));
|
||||
apps.add(await getApp(
|
||||
source, url, source.additionalSourceAppSpecificDefaults));
|
||||
} catch (e) {
|
||||
errors.addAll(<String, dynamic>{url: e});
|
||||
}
|
||||
}
|
||||
return [apps, errors];
|
||||
}
|
||||
|
||||
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
|
||||
}
|
||||
|
85
pubspec.lock
@ -21,7 +21,7 @@ packages:
|
||||
name: archive
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.3.2"
|
||||
version: "3.3.5"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -78,6 +78,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -134,6 +141,20 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.5.4"
|
||||
easy_localization:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: easy_localization
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
easy_logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: easy_logger
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.2"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -161,7 +182,7 @@ packages:
|
||||
name: file_picker
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.2.2"
|
||||
version: "5.2.3"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -173,14 +194,14 @@ packages:
|
||||
name: flutter_fgbg
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "0.2.2"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.10.0"
|
||||
version: "0.11.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -194,7 +215,7 @@ packages:
|
||||
name: flutter_local_notifications
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "12.0.3"
|
||||
version: "12.0.4"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -209,6 +230,11 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_localizations:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -275,6 +301,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: intl
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.17.0"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -323,7 +356,7 @@ packages:
|
||||
name: mime
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
version: "1.0.3"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -365,7 +398,7 @@ packages:
|
||||
name: path_provider_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.21"
|
||||
version: "2.0.22"
|
||||
path_provider_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -457,6 +490,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
pointycastle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointycastle
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.6.2"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -477,7 +517,7 @@ packages:
|
||||
name: share_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.2.0"
|
||||
version: "6.3.0"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -553,6 +593,20 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.9.0"
|
||||
sqflite:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.4.0+2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -574,6 +628,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0+3"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -608,14 +669,14 @@ packages:
|
||||
name: url_launcher
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.1.6"
|
||||
version: "6.1.7"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.0.21"
|
||||
version: "6.0.22"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -664,7 +725,7 @@ packages:
|
||||
name: uuid
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
version: "3.0.7"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -706,7 +767,7 @@ packages:
|
||||
name: win32
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.1.2"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
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
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.6.7+51 # When changing this, update the tag in main() accordingly
|
||||
version: 0.8.8+71 # When changing this, update the tag in main() accordingly
|
||||
|
||||
environment:
|
||||
sdk: '>=2.18.2 <3.0.0'
|
||||
@ -56,12 +56,14 @@ dependencies:
|
||||
installed_apps: ^1.3.1
|
||||
package_archive_info: ^0.1.0
|
||||
android_alarm_manager_plus: ^2.1.0
|
||||
sqflite: ^2.2.0+3
|
||||
easy_localization: ^3.0.1
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_launcher_icons: ^0.10.0
|
||||
flutter_launcher_icons: ^0.11.0
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
@ -88,9 +90,12 @@ flutter:
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
assets:
|
||||
- assets/translations/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/assets-and-images/#resolution-aware
|
||||
|