Compare commits

...

43 Commits

Author SHA1 Message Date
c4ba1e9dbc Increment version 2022-12-08 19:01:00 -05:00
49862ad2a6 Reduced download notification importance 2022-12-08 18:57:53 -05:00
1b892f4e0d Avoid overflow for long version strings on Apps page 2022-12-08 18:54:40 -05:00
a4555f07f9 Fixed typo 2022-12-08 18:33:36 -05:00
73fbdd84f0 Updated version 2022-12-07 20:46:12 -05:00
a1518480db Updated build number 2022-12-07 20:43:35 -05:00
fd3ee02e52 Completely removed enhanced version detection 2022-12-07 20:36:14 -05:00
609366675d Fix translation error in BG check task 2022-12-07 19:48:59 -05:00
fbff498ae1 Addresses #139 2022-12-05 20:10:42 -05:00
bb4e470760 Slight tweaks 2022-12-05 20:09:16 -05:00
15183c3a95 Simplified EVD (only xx.yy.zz) 2022-12-05 16:31:43 -05:00
b496a416ff Increment version 2022-12-05 15:56:43 -05:00
6ac7ba204f EVD bugfix 2022-12-05 15:46:47 -05:00
0951c007d1 Bugfix for enhanced version detection 2022-12-05 15:39:36 -05:00
d835beec76 Bugfix for localization error in BG 2022-12-05 14:57:38 -05:00
2654bf12d3 Removed unused import 2022-12-04 17:15:08 -05:00
3951108bc9 Refactor - removed duplicate code 2022-12-04 17:12:10 -05:00
d934ce2e13 Enhanced detect bugfix + outdated apps show curr. ver. 2022-12-04 17:08:11 -05:00
66cc7f059f Disable mark as updated for enhanced detect apps 2022-12-04 16:58:04 -05:00
098428dac9 Typo 2022-12-04 14:35:49 -05:00
9e7c21b408 Enhanced ver. detection fix for track only apps 2022-12-04 14:18:02 -05:00
31c2c6b7c1 Enhanced ver. detection bugfix 2022-12-04 14:15:15 -05:00
f70049aded Changed a default (enhanced version detect bugfix) 2022-12-04 13:51:44 -05:00
60c28bf912 Attempting to add enhanced version detection #132 2022-12-04 13:40:58 -05:00
a6ed1e7c98 Increment version, upgrade packages 2022-12-04 12:49:16 -05:00
963f51dc53 Added download notifications
(removed toast during add app)
2022-12-04 12:48:12 -05:00
17b1f6e5b0 Internationalization (#131)
Replaced hardcoded English strings with locale-based variables based on the [easy_localization](https://pub.dev/packages/easy_localization) Flutter plugin.
2022-11-26 23:53:11 -05:00
086b2b949f Fixed bugfix with GitHub track-only Apps with no APK 2022-11-25 23:12:15 -05:00
9b5b212e96 APKMirror version extraction bugfix 2022-11-25 23:04:37 -05:00
6c8f9ebcbf Ran flutter upgrade 2022-11-25 20:45:49 -05:00
4d5773bdcc Slight UI changes on Add App page 2022-11-25 20:41:57 -05:00
f81ef6a416 Search results now interleaved on Add App page 2022-11-25 20:35:51 -05:00
47324fcb49 Added search bar on Add App page 2022-11-25 20:31:52 -05:00
377e0e07bd Removed unused imports 2022-11-25 19:17:08 -05:00
b5aae70274 Bugfix from prev. commit 2022-11-25 19:13:29 -05:00
42475fa42a Only ask for install perm. for non-track-only apps 2022-11-25 19:07:05 -05:00
d29534ef2e Increment version 2022-11-25 18:56:43 -05:00
25953399ac Re-added APKMirror as a Track-Only source 2022-11-25 18:55:17 -05:00
b04d2fad5c Adds Track-Only App Support (Addresses #119 and Sets Groundwork for #44) (#123)
- All Sources now have a "Track-Only" option that will prevent Obtainium from looking for APKs (though the App must still have a release of some kind so that a version string can be grabbed).
    - These Apps cannot be installed through Obtainium, but update notifications will still be sent.
    - The user needs to manually mark them as updated when appropriate.
    - This addresses issue #119.
    - It also partially addresses #44 by allowing some sources to be configured as "Track-Only"-only. The first such source (APKMirror) will be added later.
- Includes various UI changes to accommodate the above change.
- Also makes App loading a bit more responsive (sending Obtainium to the background then returning will now cause App re-load to pick up changes in App versioning that may have been made in the meantime, for instance through update checking).
2022-11-24 21:12:46 -05:00
868ba84c9a Tiny bugfix 2022-11-20 11:05:33 -05:00
602f0c3bb2 Increment version 2022-11-19 16:26:44 -05:00
00721e8ac4 Added days filter to logs dialog (#117) 2022-11-19 16:20:42 -05:00
d19f9101d6 Added dropdown support to generated form 2022-11-19 15:42:20 -05:00
28 changed files with 1404 additions and 571 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@
.history
.svn/
migrate_working_dir/
.vscode/
# IntelliJ related
*.iml

216
assets/translations/en.json Normal file
View File

@ -0,0 +1,216 @@
{
"invalidURLForSource": "Not a valid {} App URL",
"noReleaseFound": "Could not find a suitable release",
"noVersionFound": "Could not determine release version",
"urlMatchesNoSource": "URL does not match a known source",
"cantInstallOlderVersion": "Cannot install an older version of an App",
"appIdMismatch": "Downloaded package ID does not match existing App ID",
"functionNotImplemented": "This class has not implemented this function",
"placeholder": "Placeholder",
"someErrors": "Some Errors Occurred",
"unexpectedError": "Unexpected Error",
"ok": "Okay",
"and": "and",
"startedBgUpdateTask": "Started BG update check task",
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
"startedActualBGUpdateCheck": "Started actual BG update checking",
"bgUpdateTaskFinished": "Finished BG update check task",
"firstRun": "This is the first ever run of Obtainium",
"settingUpdateCheckIntervalTo": "Setting update interval to {}",
"githubPATLabel": "GitHub Personal Access Token (Increases Rate Limit)",
"githubPATHint": "PAT must be in this format: username:token",
"githubPATFormat": "username:token",
"githubPATLinkText": "'About GitHub PATs",
"includePrereleases": "Include prereleases",
"fallbackToOlderReleases": "Fallback to older releases",
"filterReleaseTitlesByRegEx": "Filter Release Titles by Regular Expression",
"invalidRegEx": "Invalid regular expression",
"noDescription": "No description",
"cancel": "Cancel",
"continue": "Continue",
"requiredInBrackets": "(Required)",
"dropdownNoOptsError": "ERROR: DROPDOWN MUST HAVE AT LEAST ONE OPT",
"colour": "Colour",
"githubStarredRepos": "GitHub Starred Repos",
"uname": "Username",
"wrongArgNum": "Wrong number of arguments provided",
"xIsTrackOnly": "{} is Track-Only",
"source": "Source",
"app": "App",
"appsFromSourceAreTrackOnly": "Apps from this source are 'Track-Only'.' ",
"youPickedTrackOnly": "You have selected the 'Track-Only' option.",
"trackOnlyAppDescription": "The App will be tracked for updates, but Obtainium will not be able to download or install it.",
"cancelled": "Cancelled",
"appAlreadyAdded": "App already added",
"addApp": "Add App",
"appSourceURL": "App Source URL",
"error": "Error",
"add": "Add",
"searchSomeSourcesLabel": "Search (Some Sources Only)",
"search": "Search",
"additionalOptsFor": "Additional Options for {}",
"supportedSourcesBelow": "Supported Sources:",
"trackOnlyInBrackets": "(Track-Only)",
"searchableInBrackets": "(Searchable)",
"appsString": "Apps",
"noApps": "No Apps",
"noAppsForFilter": "No Apps for Filter",
"byX": "By {}",
"percentProgress": "Progress: {}%",
"pleaseWait": "Please Wait...",
"updateAvailable": "Update Available",
"estimateInBracketsShort": "(Est.)",
"notInstalled": "Not Installed",
"estimateInBrackets": "(Estimate)",
"selectAll": "Select All",
"deselectN": "Deselect {}",
"xWillBeRemovedButRemainInstalled": "{} will be removed from Obtainium but remain installed on device.",
"removeSelectedAppsQuestion": "Remove Selected Apps?",
"removeSelectedApps": "Remove Selected Apps",
"updateX": "Update {}",
"installX": "Install {}",
"markXTrackOnlyAsUpdated": "Mark {}\n(Track-Only)\nas Updated",
"changeX": "Change {}",
"installUpdateApps": "Install/Update Apps",
"installUpdateSelectedApps": "Install/Update Selected Apps",
"onlyAppliesToInstalledAndOutdatedApps": "Only applies to installed but out of date Apps whose install status cannot be automatically detected.",
"markXSelectedAppsAsUpdated": "Mark {} Selected Apps as Updated?",
"no": "No",
"yes": "Yes",
"markSelectedAppsUpdated": "Mark Selected Apps as Updated",
"pinToTop": "Pin to top",
"unpinFromTop": "Unpin from top",
"resetInstallStatusForSelectedAppsQuestion": "Reset Install Status for Selected Apps?",
"installStatusOfXWillBeResetExplanation": "The install status of any selected Apps will be reset.\n\nThis can help when the App version shown in Obtainium is incorrect due to failed updates or other issues.",
"shareSelectedAppURLs": "Share Selected App URLs",
"resetInstallStatus": "Reset Install Status",
"more": "More",
"removeOutdatedFilter": "Remove Out-of-Date App Filter",
"showOutdatedOnly": "Show Out-of-Date Apps Only",
"filter": "Filter",
"filterActive": "Filter *",
"filterApps": "Filter Apps",
"appName": "App Name",
"author": "Author",
"upToDateApps": "Up to Date Apps",
"nonInstalledApps": "Non-Installed Apps",
"importExport": "Import/Export",
"settings": "Settings",
"exportedTo": "Exported to {}",
"obtainiumExport": "Obtainium Export",
"invalidInput": "Invalid input",
"importedX": "Imported {}",
"obtainiumImport": "Obtainium Import",
"importFromURLList": "Import from URL List",
"searchQuery": "Search Query",
"appURLList": "App URL List",
"line": "Line",
"searchX": "Search {}",
"noResults": "No results found",
"importX": "Import {}",
"importedAppsIdDisclaimer": "Imported Apps may incorrectly show as \"Not Installed\".\nTo fix this, re-install them through Obtainium.\nThis should not affect App data.\n\nOnly affects URL and third-party import methods.",
"importErrors": "Import Errors",
"importedXOfYApps": "{} of {} Apps imported.",
"followingURLsHadErrors": "The following URLs had errors:",
"okay": "Okay",
"selectURL": "Select URL",
"selectURLs": "Select URLs",
"pick": "Pick",
"theme": "Theme",
"dark": "Dark",
"light": "Light",
"followSystem": "Follow System",
"obtainium": "Obtainium",
"materialYou": "Material You",
"appSortBy": "App Sort By",
"authorName": "Author/Name",
"nameAuthor": "Name/Author",
"asAdded": "As Added",
"appSortOrder": "App Sort Order",
"ascending": "Ascending",
"descending": "Descending",
"bgUpdateCheckInterval": "Background Update Checking Interval",
"neverManualOnly": "Never - Manual Only",
"appearance": "Appearance",
"showWebInAppView": "Show Source Webpage in App View",
"pinUpdates": "Pin Updates to Top of Apps View",
"updates": "Updated",
"sourceSpecific": "Source-Specific",
"appSource": "App Source",
"noLogs": "No Logs",
"appLogs": "App Logs",
"close": "Close",
"share": "Share",
"appNotFound": "App not found",
"obtainiumExportHyphenatedLowercase": "obtainium-export",
"pickAnAPK": "Pick an APK",
"appHasMoreThanOnePackage": "{} has more than one package:",
"deviceSupportsXArch": "Your device supports the {} CPU architecture.",
"deviceSupportsFollowingArchs": "Your device supports the following CPU architectures:",
"warning": "Warning",
"sourceIsXButPackageFromYPrompt": "The App source is '{}' but the release package comes from '{}'. Continue?",
"updatesAvailable": "Updates Available",
"updatesAvailableNotifDescription": "Notifies the user that updates are available for one or more Apps tracked by Obtainium",
"noNewUpdates": "No new updates.",
"xHasAnUpdate": "{} has an update.",
"appsUpdated": "Apps Updated",
"appsUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were applied in the background",
"xWasUpdatedToY": "{} was updated to {}.",
"errorCheckingUpdates": "Error Checking for Updates",
"errorCheckingUpdatesNotifDescription": "A notification that shows when background update checking fails",
"appsRemoved": "Apps Removed",
"appsRemovedNotifDescription": "Notifies the user that one or more Apps were removed due to errors while loading them",
"xWasRemovedDueToErrorY": "{} was removed due to this error: {}",
"completeAppInstallation": "Complete App Installation",
"obtainiumMustBeOpenToInstallApps": "Obtainium must be open to install Apps",
"completeAppInstallationNotifDescription": "Asks the user to return to Obtainium to finish installing an App",
"checkingForUpdates": "Checking for Updates",
"checkingForUpdatesNotifDescription": "Transient notification that appears when checking for updates",
"pleaseAllowInstallPerm": "Please allow Obtainium to install Apps",
"trackOnly": "Track-Only",
"errorWithHttpStatusCode": "Error {}",
"tooManyRequestsTryAgainInMinutes": {
"one": "Too many requests (rate limited) - try again in {} minute",
"other": "Too many requests (rate limited) - try again in {} minutes"
},
"bgUpdateGotErrorRetryInMinutes": {
"one": "BG update checking encountered a {}, will schedule a retry check in {} minute",
"other": "BG update checking encountered a {}, will schedule a retry check in {} minutes"
},
"bgCheckFoundUpdatesWillNotifyIfNeeded": {
"one": "BG update checking found {} update - will notify user if needed",
"other": "BG update checking found {} updates - will notify user if needed"
},
"apps": {
"one": "{} App",
"other": "{} Apps"
},
"url": {
"one": "{} URL",
"other": "{} URLs"
},
"minute": {
"one": "{} Minute",
"other": "{} Minutes"
},
"hour": {
"one": "{} Hour",
"other": "{} Hours"
},
"day": {
"one": "{} Day",
"other": "{} Days"
},
"clearedNLogsBeforeXAfterY": {
"one": "Cleared {n} log (before = {before}, after = {after})",
"other": "Cleared {n} logs (before = {before}, after = {after})"
},
"xAndNMoreUpdatesAvailable": {
"one": "{} and 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."
}
}

View File

@ -0,0 +1,58 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class APKMirror extends AppSource {
APKMirror() {
host = 'apkmirror.com';
enforceTrackOnly = true;
}
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/#whatsnew';
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
Response res = await get(Uri.parse('$standardUrl/feed'));
if (res.statusCode == 200) {
String? titleString = parse(res.body)
.querySelector('item')
?.querySelector('title')
?.innerHtml;
String? version = titleString
?.substring(RegExp('[0-9]').firstMatch(titleString)?.start ?? 0,
RegExp(' by ').firstMatch(titleString)?.start ?? 0)
.trim();
if (version == null || version.isEmpty) {
version = titleString;
}
if (version == null || version.isEmpty) {
throw NoVersionError();
}
return APKDetails(version, []);
} else {
throw NoReleasesError();
}
}
@override
AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
return AppNames(names[1], names[2]);
}
}

View File

@ -1,6 +1,5 @@
import 'dart:convert';
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
@ -49,9 +48,6 @@ class FDroid extends AppSource {
.where((element) => element['versionName'] == latestVersion)
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
.toList();
if (apkUrls.isEmpty) {
throw NoAPKError();
}
return APKDetails(latestVersion, apkUrls);
} else {
throw NoReleasesError();
@ -60,7 +56,8 @@ class FDroid extends AppSource {
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
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')),

View File

@ -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';
@ -11,11 +12,11 @@ class GitHub extends AppSource {
GitHub() {
host = 'github.com';
additionalDataDefaults = ['true', 'true', ''];
additionalSourceAppSpecificDefaults = ['true', 'true', ''];
moreSourceSettingsFormItems = [
additionalSourceSpecificSettingFormItems = [
GeneratedFormItem(
label: 'GitHub Personal Access Token (Increases Rate Limit)',
label: tr('githubPATLabel'),
id: 'github-creds',
required: false,
additionalValidators: [
@ -26,13 +27,13 @@ class GitHub extends AppSource {
.where((element) => element.trim().isNotEmpty)
.length !=
2) {
return 'PAT must be in this format: username:token';
return tr('githubPATHint');
}
}
return null;
}
],
hint: 'username:token',
hint: tr('githubPATFormat'),
belowWidgets: [
const SizedBox(
height: 8,
@ -43,25 +44,26 @@ class GitHub extends AppSource {
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
mode: LaunchMode.externalApplication);
},
child: const Text(
'About GitHub PATs',
style: TextStyle(
child: Text(
tr('githubPATLinkText'),
style: const TextStyle(
decoration: TextDecoration.underline, fontSize: 12),
))
])
];
additionalDataFormItems = [
additionalSourceAppSpecificFormItems = [
[
GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)
GeneratedFormItem(
label: tr('includePrereleases'), type: FormItemType.bool)
],
[
GeneratedFormItem(
label: 'Fallback to older releases', type: FormItemType.bool)
label: tr('fallbackToOlderReleases'), type: FormItemType.bool)
],
[
GeneratedFormItem(
label: 'Filter Release Titles by Regular Expression',
label: tr('filterReleaseTitlesByRegEx'),
type: FormItemType.string,
required: false,
additionalValidators: [
@ -72,7 +74,7 @@ class GitHub extends AppSource {
try {
RegExp(value);
} catch (e) {
return 'Invalid regular expression';
return tr('invalidRegEx');
}
return null;
}
@ -96,8 +98,8 @@ class GitHub extends 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@' : '';
}
@ -107,7 +109,8 @@ class GitHub extends AppSource {
@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 =
@ -145,7 +148,7 @@ class GitHub extends AppSource {
continue;
}
var apkUrls = getReleaseAPKUrls(releases[i]);
if (apkUrls.isEmpty) {
if (apkUrls.isEmpty && !trackOnly) {
continue;
}
targetRelease = releases[i];
@ -155,14 +158,11 @@ class GitHub extends 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 {
rateLimitErrorCheck(res);
throw getObtainiumHttpError(res);
@ -186,7 +186,7 @@ class GitHub extends AppSource {
urlsWithDescriptions.addAll({
e['html_url'] as String: e['description'] != null
? e['description'] as String
: 'No description'
: tr('noDescription')
});
}
return urlsWithDescriptions;

View File

@ -25,7 +25,8 @@ class GitLab extends AppSource {
@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);
@ -33,7 +34,7 @@ class GitLab extends AppSource {
var entry = parsedHtml.querySelector('entry');
var entryContent =
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrlList = [
var apkUrls = [
...getLinksFromParsedHTML(
entryContent,
RegExp(
@ -48,9 +49,6 @@ class GitLab extends AppSource {
.where((element) => Uri.parse(element).host != '')
.toList()
];
if (apkUrlList.isEmpty) {
throw NoAPKError();
}
var entryId = entry?.querySelector('id')?.innerHtml;
var version =
@ -58,7 +56,7 @@ class GitLab extends AppSource {
if (version == null) {
throw NoVersionError();
}
return APKDetails(version, apkUrlList);
return APKDetails(version, apkUrls);
} else {
throw NoReleasesError();
}

View File

@ -1,4 +1,3 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/fdroid.dart';
import 'package:obtainium/custom_errors.dart';
@ -29,7 +28,8 @@ class IzzyOnDroid extends AppSource {
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
String? appId = tryInferringAppId(standardUrl);
return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(
await get(

View File

@ -24,7 +24,8 @@ class Mullvad extends AppSource {
@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)

View File

@ -18,20 +18,19 @@ class Signal extends AppSource {
@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();
}

View File

@ -23,7 +23,8 @@ class SourceForge extends AppSource {
@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);
@ -49,9 +50,6 @@ class SourceForge extends 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();

View File

@ -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;
}

View File

@ -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')))
],
);
}

View File

@ -1,3 +1,4 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:provider/provider.dart';
@ -18,46 +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('This class has not implemented this function');
NotImplementedError() : super(tr('functionNotImplemented'));
}
class MultiAppMultiError extends ObtainiumError {
Map<String, List<String>> content = {};
MultiAppMultiError() : super('Multiple Errors Placeholder', unexpected: true);
MultiAppMultiError() : super(tr('placeholder'), unexpected: true);
add(String appId, String string) {
var tempIds = content.remove(string);
@ -90,15 +91,15 @@ 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'))),
],
);
});
@ -107,7 +108,7 @@ showError(dynamic e, BuildContext context) {
String list2FriendlyString(List<String> list) {
return list.length == 2
? '${list[0]} and ${list[1]}'
? '${list[0]} ${tr('and')} ${list[1]}'
: list
.asMap()
.entries

View File

@ -15,24 +15,58 @@ 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.7.4';
const String currentVersion = '0.8.7';
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 {
LogsProvider logs = LogsProvider();
logs.add('Started BG update check task');
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('Bg update ignoreAfter is $ignoreAfter');
logs.add(tr('bgUpdateIgnoreAfterIs', args: [ignoreAfter.toString()]));
var notificationsProvider = NotificationsProvider();
await notificationsProvider.notify(checkingUpdatesNotification);
try {
@ -44,14 +78,14 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
DateTime nextIgnoreAfter = DateTime.now();
String? err;
try {
logs.add('Started actual BG update checking');
logs.add(tr('startedActualBGUpdateCheck'));
await appsProvider.checkUpdates(
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
} catch (e) {
if (e is RateLimitError || e is SocketException) {
var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15;
logs.add(
'BG update checking encountered a ${e.runtimeType}, will schedule a retry check in $remainingMinutes minutes');
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
@ -80,7 +114,7 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
// cancelExisting: true);
// }
logs.add(
'BG update checking found ${newUpdates.length} updates - will notify user if needed');
plural('bgCheckFoundUpdatesWillNotifyIfNeeded', newUpdates.length));
if (newUpdates.isNotEmpty) {
notificationsProvider.notify(UpdateNotification(newUpdates));
}
@ -91,13 +125,14 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
notificationsProvider
.notify(ErrorCheckingUpdatesNotification(e.toString()));
} finally {
logs.add('Finished BG update check task');
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),
@ -112,7 +147,11 @@ void main() async {
Provider(create: (context) => NotificationsProvider()),
Provider(create: (context) => LogsProvider())
],
child: const Obtainium(),
child: EasyLocalization(
supportedLocales: supportedLocales,
path: localeDir,
fallbackLocale: fallbackLocale,
child: const Obtainium()),
));
}
@ -139,7 +178,7 @@ class _ObtainiumState extends State<Obtainium> {
} else {
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
if (isFirstRun) {
logs.add('This is the first ever run of Obtainium');
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([
@ -154,14 +193,15 @@ class _ObtainiumState extends State<Obtainium> {
0,
['true'],
null,
false,
false)
]);
}
// Register the background update task according to the user's setting
if (existingUpdateInterval != settingsProvider.updateInterval) {
if (existingUpdateInterval != -1) {
logs.add(
'Setting update interval to ${settingsProvider.updateInterval}');
logs.add(tr('settingUpdateCheckIntervalTo',
args: [settingsProvider.updateInterval.toString()]));
}
existingUpdateInterval = settingsProvider.updateInterval;
if (existingUpdateInterval == 0) {
@ -194,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

View File

@ -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,10 +8,10 @@ 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<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions(
String username, int page) async {
@ -22,7 +23,7 @@ class GitHubStars implements MassAppUrlSource {
urlsWithDescriptions.addAll({
e['html_url'] as String: e['description'] != null
? e['description'] as String
: 'No description'
: tr('noDescription')
});
}
return urlsWithDescriptions;
@ -36,7 +37,7 @@ class GitHubStars implements MassAppUrlSource {
@override
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'));
}
Map<String, String> urlsWithDescriptions = {};
var page = 1;

View File

@ -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 {
@ -59,31 +170,16 @@ class _AddAppPageState extends State<AddAppPage> {
? e
: e is ObtainiumError
? e.toString()
: 'Error';
: 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(
@ -94,73 +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();
// Only download the APK here if you need to for the package ID
if (sourceProvider
.isTempId(app.id)) {
// 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: [
@ -168,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)),
@ -176,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
@ -200,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,
@ -223,6 +395,9 @@ class _AddAppPageState extends State<AddAppPage> {
)))
.toList()
])),
const SizedBox(
height: 8,
),
])),
)
]));

View File

@ -106,7 +106,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 +140,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
@ -183,7 +184,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 +196,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 +223,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

View File

@ -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';
@ -135,6 +136,22 @@ class AppsPageState extends State<AppsPage> {
: 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) {
@ -175,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(
@ -183,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,
))),
@ -202,6 +219,9 @@ 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(
tileColor: sortedApps[index].app.pinned
? Colors.grey.withOpacity(0.1)
@ -228,59 +248,54 @@ class AppsPageState extends State<AppsPage> {
? FontWeight.bold
: FontWeight.normal),
),
subtitle: Text('By ${sortedApps[index].app.author}',
subtitle: Text(tr('byX', args: [sortedApps[index].app.author]),
style: TextStyle(
fontWeight: sortedApps[index].app.pinned
? FontWeight.bold
: FontWeight.normal)),
trailing: sortedApps[index].downloadProgress != null
? Text(
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
: (sortedApps[index].app.installedVersion != null &&
sortedApps[index].app.installedVersion !=
sortedApps[index].app.latestVersion
? Column(
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',
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: Text(
'${tr('updateAvailable')}${sortedApps[index].app.trackOnly ? ' ${tr('estimateInBracketsShort')}' : ''}',
style: TextStyle(
fontStyle: FontStyle.italic,
decoration:
TextDecoration.underline),
)),
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 (selectedApps.isNotEmpty) {
toggleAppSelected(sortedApps[index].app);
@ -312,8 +327,8 @@ class AppsPageState extends State<AppsPage> {
color: Theme.of(context).colorScheme.primary,
),
tooltip: selectedApps.isEmpty
? 'Select All'
: 'Deselect ${selectedApps.length.toString()}'),
? tr('selectAll')
: tr('deselectN', args: [selectedApps.length.toString()])),
const VerticalDivider(),
Expanded(
child: Row(
@ -328,12 +343,15 @@ 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:
'${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedApps.length == 1 ? 'it' : 'them'} manually.',
message: tr(
'xWillBeRemovedButRemainInstalled',
args: [
plural('apps', selectedApps.length)
]),
);
}).then((values) {
if (values != null) {
@ -342,56 +360,90 @@ class AppsPageState extends State<AppsPage> {
}
});
},
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${selectedApps.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) {
@ -402,6 +454,10 @@ class AppsPageState extends State<AppsPage> {
toInstall
.addAll(newInstallIdsAllOrSelected);
}
if (shouldMarkTrackOnlies) {
toInstall.addAll(
trackOnlyUpdateIdsAllOrSelected);
}
appsProvider
.downloadAndInstallLatestApps(
toInstall, context)
@ -412,8 +468,9 @@ class AppsPageState extends State<AppsPage> {
}
});
},
tooltip:
'Install/Update${selectedApps.isEmpty ? ' ' : ' Selected '}Apps',
tooltip: selectedApps.isEmpty
? tr('installUpdateApps')
: tr('installUpdateSelectedApps'),
icon: const Icon(
Icons.file_download_outlined,
)),
@ -445,11 +502,15 @@ class AppsPageState extends State<AppsPage> {
(BuildContext
ctx) {
return AlertDialog(
title: Text(
'Mark ${selectedApps.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('onlyAppliesToInstalledAndOutdatedApps')),
actions: [
TextButton(
onPressed:
@ -457,8 +518,8 @@ class AppsPageState extends State<AppsPage> {
Navigator.of(context)
.pop();
},
child: const Text(
'No')),
child: Text(
tr('no'))),
TextButton(
onPressed:
() {
@ -476,8 +537,8 @@ class AppsPageState extends State<AppsPage> {
Navigator.of(context)
.pop();
},
child: const Text(
'Yes'))
child: Text(
tr('yes')))
],
);
}).whenComplete(() {
@ -487,7 +548,7 @@ class AppsPageState extends State<AppsPage> {
});
},
tooltip:
'Mark Selected Apps as Updated',
tr('markSelectedAppsUpdated'),
icon: const Icon(Icons.done)),
IconButton(
onPressed: () {
@ -502,8 +563,12 @@ class AppsPageState extends State<AppsPage> {
}).toList());
Navigator.of(context).pop();
},
tooltip:
'${selectedApps.where((element) => element.pinned).isEmpty ? 'Pin to' : 'Unpin from'} top',
tooltip: selectedApps
.where((element) =>
element.pinned)
.isEmpty
? tr('pinToTop')
: tr('unpinFromTop'),
icon: Icon(selectedApps
.where((element) =>
element.pinned)
@ -521,11 +586,11 @@ class AppsPageState extends State<AppsPage> {
urls = urls.substring(
0, urls.length - 1);
Share.share(urls,
subject:
'${selectedApps.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(
@ -534,13 +599,19 @@ class AppsPageState extends State<AppsPage> {
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title:
'Reset Install Status for Selected Apps?',
title: tr(
'resetInstallStatusForSelectedAppsQuestion'),
items: const [],
defaultValues: const [],
initValid: true,
message:
'The install status of ${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be reset.\n\nThis can help when the App version shown in Obtainium is incorrect due to failed updates or other issues.',
message: tr(
'installStatusOfXWillBeResetExplanation',
args: [
plural(
'app',
selectedApps
.length)
]),
);
}).then((values) {
if (values != null) {
@ -554,7 +625,7 @@ class AppsPageState extends State<AppsPage> {
Navigator.of(context).pop();
});
},
tooltip: 'Reset Install Status',
tooltip: tr('resetInstallStatus'),
icon: const Icon(
Icons.restore_page_outlined),
),
@ -563,7 +634,7 @@ class AppsPageState extends State<AppsPage> {
);
});
},
tooltip: 'More',
tooltip: tr('more'),
icon: const Icon(Icons.more_horiz),
),
],
@ -581,8 +652,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
@ -594,7 +665,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
@ -605,22 +676,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)
]
],

View File

@ -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

View File

@ -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,7 +9,6 @@ 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';
@ -27,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(
@ -40,28 +39,10 @@ class _ImportExportPageState extends State<ImportExportPage> {
),
);
Future<List<List<String>>> addApps(List<String> urls) async {
await settingsProvider.getInstallPermission();
List<dynamic> results = await sourceProvider.getApps(urls,
ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList());
List<App> apps = results[0];
Map<String, dynamic> errorsMap = results[1];
for (var app in apps) {
if (appsProvider.apps.containsKey(app.id)) {
errorsMap.addAll({app.id: 'App already added'});
} else {
await appsProvider.saveApps([app]);
}
}
List<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Import/Export'),
CustomAppBar(title: tr('importExport')),
SliverFillRemaining(
child: Padding(
padding:
@ -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,8 +209,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
}
});
},
child: const Text(
'Import from URL List',
child: Text(
tr('importFromURLList'),
)),
...sourceProvider.sources
.where((element) => element.canSearch)
@ -242,13 +230,17 @@ class _ImportExportPageState extends State<ImportExportPage> {
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title:
'Search ${source.runtimeType}',
title: tr('searchX',
args: [
source
.runtimeType
.toString()
]),
items: [
[
GeneratedFormItem(
label:
'${source.runtimeType} Search Query')
label: tr(
'searchQuery'))
]
],
defaultValues: const [],
@ -275,7 +267,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
return UrlSelectionModal(
urlsWithDescriptions:
urlsWithDescriptions,
defaultSelected:
selectedByDefault:
false,
);
});
@ -284,12 +276,19 @@ class _ImportExportPageState extends State<ImportExportPage> {
selectedUrls
.isNotEmpty) {
var errors =
await addApps(
selectedUrls);
await appsProvider
.addAppsByURL(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
'Imported ${selectedUrls.length} Apps',
tr('importedX',
args: [
plural(
'app',
selectedUrls
.length)
]),
context);
} else {
showDialog(
@ -308,7 +307,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
}
} else {
throw ObtainiumError(
'No results found');
tr('noResults'));
}
}
}()
@ -320,8 +319,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
});
});
},
child: Text(
'Search ${source.runtimeType}'))
child: Text(tr('searchX', args: [
source.runtimeType.toString()
])))
]))
.toList(),
...sourceProvider.massUrlSources
@ -340,8 +340,10 @@ class _ImportExportPageState extends State<ImportExportPage> {
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title:
'Import ${source.name}',
title: tr('importX',
args: [
source.name
]),
items:
source
.requiredArgs
@ -374,12 +376,19 @@ class _ImportExportPageState extends State<ImportExportPage> {
});
if (selectedUrls != null) {
var errors =
await addApps(
selectedUrls);
await appsProvider
.addAppsByURL(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
'Imported ${selectedUrls.length} Apps',
tr('importedX',
args: [
plural(
'app',
selectedUrls
.length)
]),
context);
} else {
showDialog(
@ -406,17 +415,17 @@ class _ImportExportPageState extends State<ImportExportPage> {
});
});
},
child: Text('Import ${source.name}'))
child: Text(
tr('importX', args: [source.name])))
]))
.toList(),
const Spacer(),
const Divider(
height: 32,
),
const Text(
'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.',
Text(tr('importedAppsIdDisclaimer'),
textAlign: TextAlign.center,
style: TextStyle(
style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12)),
const SizedBox(
height: 8,
@ -443,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) {
@ -475,7 +487,7 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
onPressed: () {
Navigator.of(context).pop(null);
},
child: const Text('Okay'))
child: Text(tr('okay')))
],
);
}
@ -486,10 +498,12 @@ class UrlSelectionModal extends StatefulWidget {
UrlSelectionModal(
{super.key,
required this.urlsWithDescriptions,
this.defaultSelected = true});
this.selectedByDefault = true,
this.onlyOneSelectionAllowed = false});
Map<String, String> urlsWithDescriptions;
bool defaultSelected;
bool selectedByDefault;
bool onlyOneSelectionAllowed;
@override
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
@ -501,8 +515,17 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
void initState() {
super.initState();
for (var url in widget.urlsWithDescriptions.entries) {
urlWithDescriptionSelections.putIfAbsent(
url, () => widget.defaultSelected);
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;
}
}
@ -510,7 +533,8 @@ 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: [
...urlWithDescriptionSelections.keys.map((urlWithD) {
return Row(children: [
@ -518,7 +542,12 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
value: urlWithDescriptionSelections[urlWithD],
onChanged: (value) {
setState(() {
urlWithDescriptionSelections[urlWithD] = value ?? false;
value ??= false;
if (value! && widget.onlyOneSelectionAllowed) {
selectOnlyOne(urlWithD.key);
} else {
urlWithDescriptionSelections[urlWithD] = value!;
}
});
}),
const SizedBox(
@ -563,16 +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(urlWithDescriptionSelections.entries
.where((entry) => entry.value)
.map((e) => e.key.key)
.toList());
},
child: Text(
'Import ${urlWithDescriptionSelections.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)
])))
],
);
}

View File

@ -1,7 +1,7 @@
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/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
@ -27,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) {
@ -50,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) {
@ -69,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) {
@ -92,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) {
@ -111,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
@ -121,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) {
@ -139,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 {
@ -165,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),
@ -175,7 +175,7 @@ class _SettingsPageState extends State<SettingsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Appearance',
tr('appearance'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
@ -198,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) {
@ -210,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) {
@ -223,7 +223,7 @@ class _SettingsPageState extends State<SettingsPage> {
),
height16,
Text(
'Updates',
tr('updates'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
@ -232,7 +232,7 @@ class _SettingsPageState extends State<SettingsPage> {
height: 48,
),
Text(
'Source-Specific',
tr('sourceSpecific'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
@ -254,42 +254,26 @@ class _SettingsPageState extends State<SettingsPage> {
mode: LaunchMode.externalApplication);
},
icon: const Icon(Icons.code),
label: const Text(
'App Source',
label: Text(
tr('appSource'),
),
),
TextButton.icon(
onPressed: () {
context.read<LogsProvider>().get().then((logs) {
if (logs.isEmpty) {
showError(ObtainiumError('No Logs'), context);
showError(ObtainiumError(tr('noLogs')), context);
} else {
String logString =
logs.map((e) => e.toString()).join('\n\n');
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Obtainium App Logs',
items: const [],
defaultValues: const [],
message: logString,
initValid: true,
);
}).then((value) {
if (value != null) {
Share.share(
logs
.map((e) => e.toString())
.join('\n\n'),
subject: 'Obtainium App Logs');
}
});
return const LogsDialog();
});
}
});
},
icon: const Icon(Icons.bug_report_outlined),
label: const Text('App Logs')),
label: Text(tr('appLogs'))),
],
),
height16,
@ -299,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')))
],
);
}
}

View File

@ -6,6 +6,7 @@ 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';
@ -105,19 +106,23 @@ class AppsProvider with ChangeNotifier {
}
if (response.statusCode != 200) {
tempDownloadedFile.deleteSync();
throw response.reasonPhrase ?? 'Unknown Error';
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) {
@ -125,12 +130,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;
@ -266,14 +273,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) {
@ -284,13 +295,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());
}
@ -391,13 +411,16 @@ class AppsProvider with ChangeNotifier {
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) => [
// TODO: Won't work for other charsets
'0',
'1',
'2',
@ -445,8 +468,7 @@ class AppsProvider with ChangeNotifier {
var info = await getInstalledInfo(newApps[i].id);
try {
sp.getSource(newApps[i].url);
apps.putIfAbsent(
newApps[i].id, () => AppInMemory(newApps[i], null, info));
apps[newApps[i].id] = AppInMemory(newApps[i], null, info);
} catch (e) {
errors.add([newApps[i].id, newApps[i].name, e.toString()]);
}
@ -512,8 +534,9 @@ class AppsProvider with ChangeNotifier {
currentApp.additionalData,
name: currentApp.name,
id: currentApp.id,
pinned: currentApp.pinned);
newApp.installedVersion = currentApp.installedVersion;
pinned: currentApp.pinned,
trackOnly: currentApp.trackOnly,
installedVersion: currentApp.installedVersion);
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex;
}
@ -586,13 +609,13 @@ class AppsProvider with ChangeNotifier {
Future<String> exportApps() async {
Directory? exportDir = Directory('/storage/emulated/0/Download');
String path = 'Downloads';
String path = 'Downloads'; // TODO: Is this true on non-english phones?
if (!exportDir.existsSync()) {
exportDir = await getExternalStorageDirectory();
path = exportDir!.path;
}
File export = File(
'${exportDir.path}/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;
@ -620,6 +643,23 @@ 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 {
@ -641,9 +681,9 @@ 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>(
@ -665,7 +705,11 @@ class _APKPickerState extends State<APKPicker> {
),
if (widget.archs != null)
Text(
'Note:\nYour device supports the ${widget.archs!.length == 1 ? '\'${widget.archs![0]}\' CPU architecture.' : 'following CPU architectures: ${list2FriendlyString(widget.archs!.map((e) => '\'$e\'').toList())}.'}',
widget.archs!.length == 1
? tr('deviceSupportsXArch', args: [widget.archs![0]])
: tr('deviceSupportsFollowingArchs') +
list2FriendlyString(
widget.archs!.map((e) => '\'$e\'').toList()),
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
),
]),
@ -674,13 +718,13 @@ class _APKPickerState extends State<APKPicker> {
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')))
],
);
}
@ -702,21 +746,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')))
],
);
}

View File

@ -1,3 +1,4 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
@ -85,7 +86,9 @@ create table if not exists $logTable (
var res = await (await getDB())
.delete(logTable, where: where.key, whereArgs: where.value);
if (res > 0) {
add('Cleared $res logs (before = $before, after = $after)');
add(plural('clearedNLogsBeforeXAfterY', res,
namedArgs: {'before': before.toString(), 'after': after.toString()},
name: 'n'));
}
return res;
}

View File

@ -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,42 +13,41 @@ class ObtainiumNotification {
late String channelName;
late String channelDescription;
Importance importance;
bool onlyAlertOnce;
ObtainiumNotification(this.id, this.title, this.message, this.channelCode,
this.channelName, this.channelDescription, this.importance);
this.channelName, this.channelDescription, this.importance,
{this.onlyAlertOnce = false});
}
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.isEmpty
? "No new updates."
? tr('noNewUpdates')
: 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.';
? 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]);
}
}
@ -55,48 +55,57 @@ 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',
'$progPercent%',
'APP_DOWNLOADING',
'Downloading App',
'Notifies the user of the progress in downloading an App',
Importance.low,
onlyAlertOnce: true) {
message = tr('percentProgress', args: [progPercent.toString()]);
}
}
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 {
@ -136,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);
}
@ -152,12 +163,16 @@ 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);
}

View File

@ -1,5 +1,6 @@
// 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';
@ -109,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;

View File

@ -3,8 +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';
@ -42,6 +44,7 @@ class App {
late List<String> additionalData;
late DateTime? lastUpdateCheck;
bool pinned = false;
bool trackOnly = false;
App(
this.id,
this.url,
@ -53,7 +56,8 @@ class App {
this.preferredApkIndex,
this.additionalData,
this.lastUpdateCheck,
this.pinned);
this.pinned,
this.trackOnly);
@override
String toString() {
@ -74,12 +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']),
json['pinned'] ?? false);
json['pinned'] ?? false,
json['trackOnly'] ?? false);
Map<String, dynamic> toJson() => {
'id': id,
@ -92,7 +99,8 @@ class App {
'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned
'pinned': pinned,
'trackOnly': trackOnly
};
}
@ -113,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(
@ -135,12 +136,14 @@ List<String> getLinksFromParsedHTML(
class AppSource {
late String host;
bool enforceTrackOnly = false;
String standardizeURL(String url) {
throw NotImplementedError();
}
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) {
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) {
throw NotImplementedError();
}
@ -148,9 +151,22 @@ class AppSource {
throw NotImplementedError();
}
List<List<GeneratedFormItem>> additionalDataFormItems = [];
List<String> additionalDataDefaults = [];
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
// 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();
}
@ -170,8 +186,8 @@ class AppSource {
}
ObtainiumError getObtainiumHttpError(Response res) {
return ObtainiumError(
res.reasonPhrase ?? 'Error ${res.statusCode.toString()}');
return ObtainiumError(res.reasonPhrase ??
tr('errorWithHttpStatusCode', args: [res.statusCode.toString()]));
}
abstract class MassAppUrlSource {
@ -189,7 +205,8 @@ class SourceProvider {
IzzyOnDroid(),
Mullvad(),
Signal(),
SourceForge()
SourceForge(),
APKMirror()
];
// Add more mass url source classes here so they are available via the service
@ -211,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;
@ -231,18 +248,27 @@ class SourceProvider {
}
for (int i = 0; i < parts.length - 1; i++) {
if (RegExp('.*[A-Z].*').hasMatch(parts[i])) {
// TODO: RegEx won't work for non-eng chars
return false;
}
}
return getSourceHosts().contains(parts.last);
return sources.map((e) => e.host).contains(parts.last);
}
Future<App> getApp(AppSource source, String url, List<String> additionalData,
{String name = '', String? id, bool pinned = false}) 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 ??
source.tryInferringAppId(standardUrl) ??
@ -252,30 +278,30 @@ class SourceProvider {
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(),
pinned);
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();
}

View File

@ -21,7 +21,7 @@ packages:
name: archive
url: "https://pub.dartlang.org"
source: hosted
version: "3.3.4"
version: "3.3.5"
args:
dependency: transitive
description:
@ -141,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:
@ -168,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
@ -180,14 +194,14 @@ packages:
name: flutter_fgbg
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.1"
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:
@ -201,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:
@ -216,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:
@ -282,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:
@ -330,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:
@ -372,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:
@ -491,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:
@ -573,7 +599,7 @@ packages:
name: sqflite
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.0+3"
version: "2.2.1"
sqflite_common:
dependency: transitive
description:
@ -643,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:
@ -699,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:
@ -741,7 +767,7 @@ packages:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
version: "3.1.2"
xdg_directories:
dependency: transitive
description:

View File

@ -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.7.4+60 # When changing this, update the tag in main() accordingly
version: 0.8.7+70 # When changing this, update the tag in main() accordingly
environment:
sdk: '>=2.18.2 <3.0.0'
@ -57,12 +57,13 @@ dependencies:
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
@ -89,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