Compare commits

...

19 Commits

Author SHA1 Message Date
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
20 changed files with 727 additions and 356 deletions

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 {} more app have updated.",
"other": "{} and {} more apps have updates."
},
"xAndNMoreUpdatesInstalled": {
"one": "{} and {} more app were updated.",
"other": "{} and {} more apps were updated."
}
}

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';
@ -15,7 +16,7 @@ class GitHub extends AppSource {
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,9 +44,9 @@ 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),
))
])
@ -53,15 +54,16 @@ class GitHub extends AppSource {
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;
}
@ -184,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

@ -1,3 +1,4 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
enum FormItemType { string, bool }
@ -108,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);
@ -122,10 +123,10 @@ class _GeneratedFormState extends State<GeneratedForm> {
} else if (e.value.type == FormItemType.string &&
e.value.opts != null) {
if (e.value.opts!.isEmpty) {
return const Text('ERROR: DROPDOWN MUST HAVE AT LEAST ONE OPT.');
return Text(tr('dropdownNoOptsError'));
}
return DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'Colour'),
decoration: InputDecoration(labelText: tr('colour')),
value: values[row.key][e.key],
items: e.value.opts!
.map((e) => DropdownMenuItem(value: e, child: Text(e)))

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';
@ -64,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
@ -74,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,8 +15,9 @@ 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';
const String currentVersion = '0.8.1';
const String currentVersion = '0.8.4';
const String currentReleaseTag =
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@ -24,15 +25,16 @@ const int bgUpdateCheckAlarmId = 666;
@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();
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 +46,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 +82,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 +93,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 +115,11 @@ void main() async {
Provider(create: (context) => NotificationsProvider()),
Provider(create: (context) => LogsProvider())
],
child: const Obtainium(),
child: EasyLocalization(
supportedLocales: const [Locale('en')],
path: 'assets/translations',
fallbackLocale: const Locale('en'),
child: const Obtainium()),
));
}
@ -139,7 +146,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([
@ -155,14 +162,15 @@ class _ObtainiumState extends State<Obtainium> {
['true'],
null,
false,
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) {
@ -195,6 +203,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,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';
@ -75,12 +76,15 @@ class _AddAppPageState extends State<AddAppPage> {
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title:
'${pickedSource!.enforceTrackOnly ? 'Source' : 'App'} is Track-Only',
title: tr('xIsTrackOnly', args: [
pickedSource!.enforceTrackOnly
? tr('source')
: tr('app')
]),
items: const [],
defaultValues: const [],
message:
'${pickedSource!.enforceTrackOnly ? 'Apps from this source are \'Track-Only\'.' : 'You have selected the \'Track-Only\' option.'}\n\nThe App will be tracked for updates, but Obtainium will not be able to download or install it.',
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
);
}) ==
null) {
@ -100,14 +104,15 @@ class _AddAppPageState extends State<AddAppPage> {
// ignore: use_build_context_synchronously
var apkUrl = await appsProvider.confirmApkUrl(app, context);
if (apkUrl == null) {
throw ObtainiumError('Cancelled');
throw ObtainiumError(tr('cancelled'));
}
app.preferredApkIndex = app.apkUrls.indexOf(apkUrl);
var downloadedApk = await appsProvider.downloadApp(app);
// ignore: use_build_context_synchronously
var downloadedApk = await appsProvider.downloadApp(app, context);
app.id = downloadedApk.appId;
}
if (appsProvider.apps.containsKey(app.id)) {
throw ObtainiumError('App already added');
throw ObtainiumError(tr('appAlreadyAdded'));
}
if (app.trackOnly) {
app.installedVersion = app.latestVersion;
@ -137,7 +142,7 @@ class _AddAppPageState extends State<AddAppPage> {
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),
@ -151,7 +156,7 @@ class _AddAppPageState extends State<AddAppPage> {
items: [
[
GeneratedFormItem(
label: 'App Source Url',
label: tr('appSourceURL'),
additionalValidators: [
(value) {
try {
@ -165,7 +170,7 @@ class _AddAppPageState extends State<AddAppPage> {
? e
: e is ObtainiumError
? e.toString()
: 'Error';
: tr('error');
}
return null;
}
@ -195,7 +200,7 @@ class _AddAppPageState extends State<AddAppPage> {
!otherAdditionalDataIsValid)
? null
: addApp,
child: const Text('Add'))
child: Text(tr('add')))
],
),
if (sourceProvider.sources
@ -218,7 +223,7 @@ class _AddAppPageState extends State<AddAppPage> {
items: [
[
GeneratedFormItem(
label: 'Search (Some Sources Only)',
label: tr('searchSomeSourcesLabel'),
required: false),
]
],
@ -281,7 +286,7 @@ class _AddAppPageState extends State<AddAppPage> {
showError(e, context);
});
},
child: const Text('Search'))
child: Text(tr('search')))
],
),
if (pickedSource != null &&
@ -301,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)),
@ -365,8 +373,8 @@ class _AddAppPageState extends State<AddAppPage> {
const SizedBox(
height: 48,
),
const Text(
'Supported Sources:',
Text(
tr('supportedSourcesBelow'),
),
const SizedBox(
height: 8,
@ -379,7 +387,7 @@ class _AddAppPageState extends State<AddAppPage> {
LaunchMode.externalApplication);
},
child: Text(
'${e.runtimeType.toString()}${e.enforceTrackOnly ? ' (Track-Only)' : ''}${e.canSearch ? ' (Searchable)' : ''}',
'${e.runtimeType.toString()}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
style: const TextStyle(
decoration:
TextDecoration.underline,

View File

@ -114,7 +114,7 @@ class _AppPageState extends State<AppPage> {
height: 32,
),
Text(
'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}',
'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}${app?.app.enhancedVersionDetection == true ? '\n\nThis App has enhanced version detection.' : ''}',
textAlign: TextAlign.center,
style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12),
@ -141,7 +141,9 @@ class _AppPageState extends State<AppPage> {
children: [
if (app?.app.installedVersion != null &&
app?.app.trackOnly == false &&
app?.app.installedVersion != app?.app.latestVersion)
app?.app.installedVersion !=
app?.app.latestVersion &&
app?.app.enhancedVersionDetection != true)
IconButton(
onPressed: app?.downloadProgress != null
? 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';
@ -191,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(
@ -199,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,
))),
@ -218,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)
@ -244,58 +248,53 @@ 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(
? 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${sortedApps[index].app.trackOnly ? ' (Est.)' : ''}'),
SourceProvider()
.getSource(sortedApps[index].app.url)
.changeLogPageFromStandardUrl(
sortedApps[index].app.url) ==
null
? const SizedBox()
: GestureDetector(
onTap: () {
launchUrlString(
SourceProvider()
.getSource(
sortedApps[index].app.url)
.changeLogPageFromStandardUrl(
sortedApps[index].app.url)!,
mode:
LaunchMode.externalApplication);
},
child: const Text(
'See Changes',
style: TextStyle(
fontStyle: FontStyle.italic,
decoration:
TextDecoration.underline),
)),
],
)
: SingleChildScrollView(
SingleChildScrollView(
child: SizedBox(
width: 80,
child: Text(
'${sortedApps[index].app.installedVersion ?? 'Not Installed'} ${sortedApps[index].app.trackOnly == true ? '(Estimate)' : ''}',
'${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: changesUrl == null
? TextDecoration.none
: TextDecoration.underline),
))
: const SizedBox(),
],
)),
onTap: () {
if (selectedApps.isNotEmpty) {
toggleAppSelected(sortedApps[index].app);
@ -327,8 +326,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(
@ -343,12 +342,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) {
@ -357,7 +359,7 @@ class AppsPageState extends State<AppsPage> {
}
});
},
tooltip: 'Remove Selected Apps',
tooltip: tr('removeSelectedApps'),
icon: const Icon(Icons.delete_outline_outlined),
),
IconButton(
@ -373,16 +375,20 @@ class AppsPageState extends State<AppsPage> {
List<String> defaultValues = [];
if (existingUpdateIdsAllOrSelected.isNotEmpty) {
formInputs.add(GeneratedFormItem(
label:
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
label: tr('updateX', args: [
plural('apps',
existingUpdateIdsAllOrSelected.length)
]),
type: FormItemType.bool,
key: 'updates'));
defaultValues.add('true');
}
if (newInstallIdsAllOrSelected.isNotEmpty) {
formInputs.add(GeneratedFormItem(
label:
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}',
label: tr('installX', args: [
plural('apps',
newInstallIdsAllOrSelected.length)
]),
type: FormItemType.bool,
key: 'installs'));
defaultValues
@ -390,8 +396,10 @@ class AppsPageState extends State<AppsPage> {
}
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
formInputs.add(GeneratedFormItem(
label:
'Mark ${trackOnlyUpdateIdsAllOrSelected.length} Track-Only\nApp${trackOnlyUpdateIdsAllOrSelected.length == 1 ? '' : 's'} as Updated',
label: tr('markXTrackOnlyAsUpdated', args: [
plural('apps',
trackOnlyUpdateIdsAllOrSelected.length)
]),
type: FormItemType.bool,
key: 'trackonlies'));
defaultValues
@ -405,8 +413,8 @@ class AppsPageState extends State<AppsPage> {
newInstallIdsAllOrSelected.length +
trackOnlyUpdateIdsAllOrSelected.length;
return GeneratedFormModal(
title:
'Change $totalApps App${totalApps == 1 ? '' : 's'}',
title: tr('changeX',
args: [plural('apps', totalApps)]),
items: formInputs.map((e) => [e]).toList(),
defaultValues: defaultValues,
initValid: true,
@ -459,8 +467,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,
)),
@ -492,11 +501,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:
@ -504,8 +517,8 @@ class AppsPageState extends State<AppsPage> {
Navigator.of(context)
.pop();
},
child: const Text(
'No')),
child: Text(
tr('no'))),
TextButton(
onPressed:
() {
@ -513,8 +526,8 @@ class AppsPageState extends State<AppsPage> {
.selectionClick();
appsProvider
.saveApps(selectedApps.map((a) {
if (a.installedVersion !=
null) {
if (a.installedVersion != null &&
!a.enhancedVersionDetection) {
a.installedVersion = a.latestVersion;
}
return a;
@ -523,8 +536,8 @@ class AppsPageState extends State<AppsPage> {
Navigator.of(context)
.pop();
},
child: const Text(
'Yes'))
child: Text(
tr('yes')))
],
);
}).whenComplete(() {
@ -534,7 +547,7 @@ class AppsPageState extends State<AppsPage> {
});
},
tooltip:
'Mark Selected Apps as Updated',
tr('markSelectedAppsUpdated'),
icon: const Icon(Icons.done)),
IconButton(
onPressed: () {
@ -549,8 +562,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)
@ -568,11 +585,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(
@ -581,13 +598,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) {
@ -601,7 +624,7 @@ class AppsPageState extends State<AppsPage> {
Navigator.of(context).pop();
});
},
tooltip: 'Reset Install Status',
tooltip: tr('resetInstallStatus'),
icon: const Icon(
Icons.restore_page_outlined),
),
@ -610,7 +633,7 @@ class AppsPageState extends State<AppsPage> {
);
});
},
tooltip: 'More',
tooltip: tr('more'),
icon: const Icon(Icons.more_horiz),
),
],
@ -628,8 +651,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
@ -641,7 +664,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
@ -652,22 +675,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';
@ -41,7 +42,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
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:
@ -63,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,
),
@ -91,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 {
@ -111,7 +115,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
});
});
},
child: const Text('Obtainium Import')))
child: Text(tr('obtainiumImport'))))
],
),
if (importInProgress)
@ -138,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) {
@ -159,7 +163,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
.getSource(
lines[i]);
} catch (e) {
return 'Line ${i + 1}: $e';
return '${tr('line')} ${i + 1}: $e';
}
}
}
@ -182,7 +186,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
.then((errors) {
if (errors.isEmpty) {
showError(
'Imported ${urls.length} Apps',
tr('importedX', args: [
plural('apps', urls.length)
]),
context);
} else {
showDialog(
@ -203,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)
@ -224,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 [],
@ -272,7 +282,13 @@ class _ImportExportPageState extends State<ImportExportPage> {
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
'Imported ${selectedUrls.length} Apps',
tr('importedX',
args: [
plural(
'app',
selectedUrls
.length)
]),
context);
} else {
showDialog(
@ -291,7 +307,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
}
} else {
throw ObtainiumError(
'No results found');
tr('noResults'));
}
}
}()
@ -303,8 +319,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
});
});
},
child: Text(
'Search ${source.runtimeType}'))
child: Text(tr('searchX', args: [
source.runtimeType.toString()
])))
]))
.toList(),
...sourceProvider.massUrlSources
@ -323,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
@ -363,7 +382,13 @@ class _ImportExportPageState extends State<ImportExportPage> {
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
'Imported ${selectedUrls.length} Apps',
tr('importedX',
args: [
plural(
'app',
selectedUrls
.length)
]),
context);
} else {
showDialog(
@ -390,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,
@ -427,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) {
@ -459,7 +487,7 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
onPressed: () {
Navigator.of(context).pop(null);
},
child: const Text('Okay'))
child: Text(tr('okay')))
],
);
}
@ -505,8 +533,8 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title:
Text(widget.onlyOneSelectionAllowed ? 'Select URL' : 'Select URLs'),
title: Text(
widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')),
content: Column(children: [
...urlWithDescriptionSelections.keys.map((urlWithD) {
return Row(children: [
@ -564,7 +592,7 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel')),
child: Text(tr('cancel'))),
TextButton(
onPressed:
urlWithDescriptionSelections.values.where((b) => b).isEmpty
@ -577,8 +605,14 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
.toList());
},
child: Text(widget.onlyOneSelectionAllowed
? 'Pick'
: 'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs'))
? tr('pick')
: tr('importX', args: [
plural(
'url',
urlWithDescriptionSelections.values
.where((b) => b)
.length)
])))
],
);
}

View File

@ -1,3 +1,4 @@
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';
@ -26,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) {
@ -49,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) {
@ -68,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) {
@ -91,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) {
@ -110,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
@ -120,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) {
@ -167,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),
@ -177,7 +175,7 @@ class _SettingsPageState extends State<SettingsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Appearance',
tr('appearance'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
@ -200,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) {
@ -212,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) {
@ -225,7 +223,7 @@ class _SettingsPageState extends State<SettingsPage> {
),
height16,
Text(
'Updates',
tr('updates'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
@ -234,7 +232,7 @@ class _SettingsPageState extends State<SettingsPage> {
height: 48,
),
Text(
'Source-Specific',
tr('sourceSpecific'),
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
@ -256,15 +254,15 @@ 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 {
showDialog(
context: context,
@ -275,7 +273,7 @@ class _SettingsPageState extends State<SettingsPage> {
});
},
icon: const Icon(Icons.bug_report_outlined),
label: const Text('App Logs')),
label: Text(tr('appLogs'))),
],
),
height16,
@ -306,7 +304,7 @@ class _LogsDialogState extends State<LogsDialog> {
.then((value) {
setState(() {
String l = value.map((e) => e.toString()).join('\n\n');
logString = l.isNotEmpty ? l : 'No Logs';
logString = l.isNotEmpty ? l : tr('noLogs');
});
});
}
@ -317,7 +315,7 @@ class _LogsDialogState extends State<LogsDialog> {
return AlertDialog(
scrollable: true,
title: const Text('Obtainium App Logs'),
title: Text(tr('appLogs')),
content: Column(
children: [
DropdownButtonFormField(
@ -325,7 +323,7 @@ class _LogsDialogState extends State<LogsDialog> {
items: days
.map((e) => DropdownMenuItem(
value: e,
child: Text('$e Day${e == 1 ? '' : 's'}'),
child: Text(plural('day', e)),
))
.toList(),
onChanged: (d) {
@ -342,13 +340,13 @@ class _LogsDialogState extends State<LogsDialog> {
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Close')),
child: Text(tr('close'))),
TextButton(
onPressed: () {
Share.share(logString ?? '', subject: 'Obtainium App Logs');
Share.share(logString ?? '', subject: tr('appLogs'));
Navigator.of(context).pop();
},
child: const Text('Share'))
child: Text(tr('share')))
],
);
}

View File

@ -6,9 +6,9 @@ import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:install_plugin_v2/install_plugin_v2.dart';
import 'package:installed_apps/app_info.dart';
import 'package:installed_apps/installed_apps.dart';
@ -105,19 +105,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 +129,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;
@ -272,7 +278,7 @@ class AppsProvider with ChangeNotifier {
// 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) {
@ -303,7 +309,7 @@ class AppsProvider with ChangeNotifier {
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());
}
@ -394,9 +400,9 @@ class AppsProvider with ChangeNotifier {
}
// If the App says it is installed but installedInfo is null, set it to not installed
// If the App says is is not installed but installedInfo exists, try to set it to installed as latest version...
// ...if the latestVersion seems to match the version in installedInfo (not guaranteed)
// If that fails, just set it to the actual version string (all we can do at that point)
// If the App says is is not installed but installedInfo exists, set it to the real installed version
// If the internal version does not match the real one, sync them if the App supports enhanced version detection
// Enhanced version detection will be true if the version extracted from source matches the standard version format
// Don't save changes, just return the object if changes were made (else null)
// If in a background isolate, return null straight away as the required plugin won't work anyways
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
@ -409,28 +415,21 @@ class AppsProvider with ChangeNotifier {
!app.trackOnly) {
app.installedVersion = null;
modded = true;
}
if (installedInfo != null && app.installedVersion == null) {
if (app.latestVersion.characters
.where((p0) => [
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'.'
].contains(p0))
.join('') ==
installedInfo.versionName) {
} else if (installedInfo != null && app.installedVersion == null) {
if (app.enhancedVersionDetection) {
app.installedVersion = installedInfo.versionName;
} else {
if (app.latestVersion.contains(installedInfo.versionName!)) {
app.installedVersion = app.latestVersion;
} else {
app.installedVersion = installedInfo.versionName;
}
}
modded = true;
} else if (installedInfo?.versionName != app.installedVersion &&
app.enhancedVersionDetection &&
!app.trackOnly) {
app.installedVersion = installedInfo?.versionName;
modded = true;
}
return modded ? app : null;
@ -601,13 +600,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;
@ -643,7 +642,7 @@ class AppsProvider with ChangeNotifier {
Map<String, dynamic> errorsMap = results[1];
for (var app in pps) {
if (apps.containsKey(app.id)) {
errorsMap.addAll({app.id: 'App already added'});
errorsMap.addAll({app.id: tr('appAlreadyAdded')});
} else {
await saveApps([app]);
}
@ -673,9 +672,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>(
@ -697,7 +696,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),
),
]),
@ -706,13 +709,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')))
],
);
}
@ -734,21 +737,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.defaultImportance,
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,6 +3,7 @@
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';
@ -26,9 +27,15 @@ class AppNames {
class APKDetails {
late String version;
late String versionFromSource;
late bool isStandardVersion;
late List<String> apkUrls;
APKDetails(this.version, this.apkUrls);
APKDetails(this.versionFromSource, this.apkUrls) {
var temp = extractStandardVersionName(versionFromSource);
isStandardVersion = temp != null;
version = temp ?? versionFromSource;
}
}
class App {
@ -44,6 +51,7 @@ class App {
late DateTime? lastUpdateCheck;
bool pinned = false;
bool trackOnly = false;
bool enhancedVersionDetection = false;
App(
this.id,
this.url,
@ -56,7 +64,8 @@ class App {
this.additionalData,
this.lastUpdateCheck,
this.pinned,
this.trackOnly);
this.trackOnly,
this.enhancedVersionDetection);
@override
String toString() {
@ -85,7 +94,8 @@ class App {
? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false,
json['trackOnly'] ?? false);
json['trackOnly'] ?? false,
json['enhancedVersionDetection'] ?? false);
Map<String, dynamic> toJson() => {
'id': id,
@ -99,7 +109,8 @@ class App {
'additionalData': jsonEncode(additionalData),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned,
'trackOnly': trackOnly
'trackOnly': trackOnly,
'enhancedVersionDetection': enhancedVersionDetection
};
}
@ -120,13 +131,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(
@ -164,7 +168,7 @@ class AppSource {
// Some additional data may be needed for Apps regardless of Source
final List<GeneratedFormItem> additionalAppSpecificSourceAgnosticFormItems = [
GeneratedFormItem(
label: 'Track-Only',
label: tr('trackOnly'),
type: FormItemType.bool,
key: 'trackOnlyFormItemKey')
];
@ -192,8 +196,15 @@ class AppSource {
}
ObtainiumError getObtainiumHttpError(Response res) {
return ObtainiumError(
res.reasonPhrase ?? 'Error ${res.statusCode.toString()}');
return ObtainiumError(res.reasonPhrase ??
tr('errorWithHttpStatusCode', args: [res.statusCode.toString()]));
}
String? extractStandardVersionName(String version, {bool strict = false}) {
var match =
RegExp('${strict ? '^' : ''}[0-9]+(\\.[0-9]+)+${strict ? '\$' : ''}')
.firstMatch(version);
return match != null ? version.substring(match.start, match.end) : null;
}
abstract class MassAppUrlSource {
@ -254,6 +265,7 @@ 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;
}
}
@ -273,6 +285,12 @@ class SourceProvider {
if (apk.apkUrls.isEmpty && !trackOnly) {
throw NoAPKError();
}
bool enhancedVersionDetection = apk.isStandardVersion &&
installedVersion != null &&
extractStandardVersionName(installedVersion, strict: true) != null;
if (!enhancedVersionDetection) {
apk.version = apk.versionFromSource;
}
String apkVersion = apk.version.replaceAll('/', '-');
return App(
id ??
@ -290,7 +308,8 @@ class SourceProvider {
additionalData,
DateTime.now(),
pinned,
trackOnly);
trackOnly,
enhancedVersionDetection);
}
// Returns errors in [results, errors] instead of throwing them

View File

@ -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,7 +194,7 @@ 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:
@ -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:
@ -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:

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.8.1+64 # When changing this, update the tag in main() accordingly
version: 0.8.4+67 # When changing this, update the tag in main() accordingly
environment:
sdk: '>=2.18.2 <3.0.0'
@ -57,6 +57,7 @@ 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:
@ -89,10 +90,13 @@ 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