Compare commits

...

12 Commits

24 changed files with 612 additions and 733 deletions

View File

@ -30,7 +30,25 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false"/>
<receiver
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmBroadcastReceiver"
android:exported="false"/>
<receiver
android:name="dev.fluttercommunity.plus.androidalarmmanager.RebootBroadcastReceiver"
android:enabled="false"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
</manifest>

View File

@ -1,112 +0,0 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/source_provider.dart';
class APKMirror implements AppSource {
@override
late String host = 'apkmirror.com';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl#whatsnew';
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
var originalUri = Uri.parse(apkUrl);
var res = await get(originalUri);
if (res.statusCode != 200) {
throw false;
}
var href =
parse(res.body).querySelector('.downloadButton')?.attributes['href'];
if (href == null) {
throw false;
}
var res2 = await get(Uri.parse('${originalUri.origin}$href'), headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
});
if (res2.statusCode != 200) {
throw false;
}
var links = parse(res2.body)
.querySelectorAll('a')
.where((element) => element.innerHtml == 'here')
.map((e) => e.attributes['href'])
.where((element) => element != null)
.toList();
if (links.isEmpty) {
throw false;
}
return '${originalUri.origin}${links[0]}';
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse('$standardUrl/feed'));
if (res.statusCode != 200) {
throw couldNotFindReleases;
}
var nextUrl = parse(res.body)
.querySelector('item')
?.querySelector('link')
?.nextElementSibling
?.innerHtml;
if (nextUrl == null) {
throw couldNotFindReleases;
}
Response res2 = await get(Uri.parse(nextUrl), headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
});
if (res2.statusCode != 200) {
throw couldNotFindReleases;
}
var html2 = parse(res2.body);
var origin = Uri.parse(standardUrl).origin;
List<String> apkUrls = html2
.querySelectorAll('.apkm-badge')
.map((e) => e.innerHtml != 'APK'
? ''
: e.previousElementSibling?.attributes['href'] ?? '')
.where((element) => element.isNotEmpty)
.map((e) => '$origin$e')
.toList();
if (apkUrls.isEmpty) {
throw noAPKFound;
}
var version = html2.querySelector('span.active.accent_color')?.innerHtml;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, apkUrls);
}
@override
AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
return AppNames(names[1], names[2]);
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
}

View File

@ -1,6 +1,7 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class FDroid implements AppSource {
@ -18,7 +19,7 @@ class FDroid implements AppSource {
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
match = standardUrlRegExA.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL(runtimeType.toString());
throw InvalidURLError(runtimeType.toString());
}
return url.substring(0, match.end);
}
@ -36,7 +37,7 @@ class FDroid implements AppSource {
if (res.statusCode == 200) {
var releases = parse(res.body).querySelectorAll('.package-version');
if (releases.isEmpty) {
throw couldNotFindReleases;
throw NoReleasesError();
}
String? latestVersion = releases[0]
.querySelector('.package-version-header b')
@ -45,7 +46,7 @@ class FDroid implements AppSource {
.sublist(1)
.join(' ');
if (latestVersion == null) {
throw couldNotFindLatestVersion;
throw NoVersionError();
}
List<String> apkUrls = releases
.where((element) =>
@ -64,11 +65,11 @@ class FDroid implements AppSource {
.where((element) => element.isNotEmpty)
.toList();
if (apkUrls.isEmpty) {
throw noAPKFound;
throw NoAPKError();
}
return APKDetails(latestVersion, apkUrls);
} else {
throw couldNotFindReleases;
throw NoReleasesError();
}
}

View File

@ -16,7 +16,7 @@ class GitHub implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL(runtimeType.toString());
throw InvalidURLError(runtimeType.toString());
}
return url.substring(0, match.end);
}
@ -72,7 +72,7 @@ class GitHub implements AppSource {
if (regexFilter != null &&
!RegExp(regexFilter)
.hasMatch((releases[i]['tag_name'] as String).trim())) {
.hasMatch((releases[i]['name'] as String).trim())) {
continue;
}
var apkUrls = getReleaseAPKUrls(releases[i]);
@ -84,14 +84,14 @@ class GitHub implements AppSource {
break;
}
if (targetRelease == null) {
throw couldNotFindReleases;
throw NoReleasesError();
}
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
throw noAPKFound;
throw NoAPKError();
}
String? version = targetRelease['tag_name'];
if (version == null) {
throw couldNotFindLatestVersion;
throw NoVersionError();
}
return APKDetails(version, targetRelease['apkUrls']);
} else {
@ -102,7 +102,7 @@ class GitHub implements AppSource {
.round());
}
throw couldNotFindReleases;
throw NoReleasesError();
}
}

View File

@ -2,6 +2,7 @@ import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class GitLab implements AppSource {
@ -13,7 +14,7 @@ class GitLab implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL(runtimeType.toString());
throw InvalidURLError(runtimeType.toString());
}
return url.substring(0, match.end);
}
@ -39,7 +40,9 @@ class GitLab implements AppSource {
...getLinksFromParsedHTML(
entryContent,
RegExp(
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
'^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return '\\${x[0]}';
})}/uploads/[^/]+/[^/]+\\.apk\$',
caseSensitive: false),
standardUri.origin),
// GitLab releases may contain links to externally hosted APKs
@ -49,18 +52,18 @@ class GitLab implements AppSource {
.toList()
];
if (apkUrlList.isEmpty) {
throw noAPKFound;
throw NoAPKError();
}
var entryId = entry?.querySelector('id')?.innerHtml;
var version =
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
if (version == null) {
throw couldNotFindLatestVersion;
throw NoVersionError();
}
return APKDetails(version, apkUrlList);
} else {
throw couldNotFindReleases;
throw NoReleasesError();
}
}

View File

@ -1,6 +1,7 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class IzzyOnDroid implements AppSource {
@ -12,7 +13,7 @@ class IzzyOnDroid implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL(runtimeType.toString());
throw InvalidURLError(runtimeType.toString());
}
return url.substring(0, match.end);
}
@ -37,7 +38,7 @@ class IzzyOnDroid implements AppSource {
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
.toList();
if (multipleVersionApkUrls.isEmpty) {
throw noAPKFound;
throw NoAPKError();
}
var version = parsedHtml
.querySelector('#keydata')
@ -50,11 +51,11 @@ class IzzyOnDroid implements AppSource {
?.children[1]
.innerHtml;
if (version == null) {
throw couldNotFindLatestVersion;
throw NoVersionError();
}
return APKDetails(version, [multipleVersionApkUrls[0]]);
} else {
throw couldNotFindReleases;
throw NoReleasesError();
}
}

View File

@ -1,6 +1,7 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class Mullvad implements AppSource {
@ -12,7 +13,7 @@ class Mullvad implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL(runtimeType.toString());
throw InvalidURLError(runtimeType.toString());
}
return url.substring(0, match.end);
}
@ -36,12 +37,12 @@ class Mullvad implements AppSource {
?.split('/')
.last;
if (version == null) {
throw couldNotFindLatestVersion;
throw NoVersionError();
}
return APKDetails(
version, ['https://mullvad.net/download/app/apk/latest']);
} else {
throw couldNotFindReleases;
throw NoReleasesError();
}
}

View File

@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class Signal implements AppSource {
@ -27,15 +28,15 @@ class Signal implements AppSource {
var json = jsonDecode(res.body);
String? apkUrl = json['url'];
if (apkUrl == null) {
throw noAPKFound;
throw NoAPKError();
}
String? version = json['versionName'];
if (version == null) {
throw couldNotFindLatestVersion;
throw NoVersionError();
}
return APKDetails(version, [apkUrl]);
} else {
throw couldNotFindReleases;
throw NoReleasesError();
}
}

View File

@ -1,6 +1,7 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class SourceForge implements AppSource {
@ -12,7 +13,7 @@ class SourceForge implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL(runtimeType.toString());
throw InvalidURLError(runtimeType.toString());
}
return url.substring(0, match.end);
}
@ -42,7 +43,7 @@ class SourceForge implements AppSource {
String? version = getVersion(allDownloadLinks[0]);
if (version == null) {
throw couldNotFindLatestVersion;
throw NoVersionError();
}
var apkUrlListAllReleases = allDownloadLinks
.where((element) => element.toLowerCase().endsWith('.apk/download'))
@ -52,11 +53,11 @@ class SourceForge implements AppSource {
.where((element) => getVersion(element) == version)
.toList();
if (apkUrlList.isEmpty) {
throw noAPKFound;
throw NoAPKError();
}
return APKDetails(version, apkUrlList);
} else {
throw couldNotFindReleases;
throw NoReleasesError();
}
}

View File

@ -1,3 +1,15 @@
import 'package:flutter/material.dart';
import 'package:obtainium/providers/apps_provider.dart';
class ObtainiumError {
late String message;
ObtainiumError(this.message);
@override
String toString() {
return message;
}
}
class RateLimitError {
late int remainingMinutes;
RateLimitError(this.remainingMinutes);
@ -6,3 +18,97 @@ class RateLimitError {
String toString() =>
'Too many requests (rate limited) - try again in $remainingMinutes minutes';
}
class InvalidURLError extends ObtainiumError {
InvalidURLError(String sourceName) : super('Not a valid $sourceName App URL');
}
class NoReleasesError extends ObtainiumError {
NoReleasesError() : super('Could not find a suitable release');
}
class NoAPKError extends ObtainiumError {
NoAPKError() : super('Could not find a suitable release');
}
class NoVersionError extends ObtainiumError {
NoVersionError() : super('Could not determine release version');
}
class UnsupportedURLError extends ObtainiumError {
UnsupportedURLError() : super('URL does not match a known source');
}
class DowngradeError extends ObtainiumError {
DowngradeError() : super('Cannot install an older version of an App');
}
class IDChangedError extends ObtainiumError {
IDChangedError()
: super('Downloaded package ID does not match existing App ID');
}
class MultiAppMultiError extends ObtainiumError {
Map<String, List<String>> content = {};
MultiAppMultiError() : super('Multiple Errors Placeholder');
add(String appId, String string) {
var tempIds = content.remove(string);
tempIds ??= [];
tempIds.add(appId);
content.putIfAbsent(string, () => tempIds!);
}
@override
String toString() {
String finalString = '';
for (var e in content.keys) {
finalString += '$e: ${content[e].toString()}\n\n';
}
return finalString;
}
}
showError(dynamic e, BuildContext context) {
if (e is String || (e is ObtainiumError && e is! MultiAppMultiError)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
scrollable: true,
title: Text(e is MultiAppMultiError
? 'Some Errors Occurred'
: 'Unexpected Error'),
content: Text(e.toString()),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: const Text('Ok')),
],
);
});
}
}
String list2FriendlyString(List<String> list) {
return list.length == 2
? '${list[0]} and ${list[1]}'
: list
.asMap()
.entries
.map((e) =>
e.value +
(e.key == list.length - 1
? ''
: e.key == list.length - 2
? ', and '
: ', '))
.join('');
}

View File

@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -10,52 +11,52 @@ import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:workmanager/workmanager.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
const String currentVersion = '0.6.6';
const String currentVersion = '0.6.10';
const String currentReleaseTag =
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
const String bgUpdateCheckTaskName = 'bg-update-check';
const int bgUpdateCheckAlarmId = 666;
bgUpdateCheck(int? ignoreAfterMicroseconds) async {
@pragma('vm:entry-point')
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
WidgetsFlutterBinding.ensureInitialized();
await AndroidAlarmManager.initialize();
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
: null;
var notificationsProvider = NotificationsProvider();
await notificationsProvider.notify(checkingUpdatesNotification);
try {
var appsProvider = AppsProvider();
var appsProvider = AppsProvider(forBGTask: true);
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps(shouldCorrectInstallStatus: false);
await appsProvider.loadApps();
List<String> existingUpdateIds =
appsProvider.getExistingUpdates(installedOnly: true);
appsProvider.findExistingUpdates(installedOnly: true);
DateTime nextIgnoreAfter = DateTime.now();
String? err;
try {
await appsProvider.checkUpdates(
ignoreAfter: ignoreAfter,
immediatelyThrowRateLimitError: true,
immediatelyThrowSocketError: true,
shouldCorrectInstallStatus: false);
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
} catch (e) {
if (e is RateLimitError || e is SocketException) {
String nextTaskName =
'$bgUpdateCheckTaskName-${nextIgnoreAfter.microsecondsSinceEpoch.toString()}';
Workmanager().registerOneOffTask(nextTaskName, nextTaskName,
constraints: Constraints(networkType: NetworkType.connected),
initialDelay: Duration(
minutes: e is RateLimitError ? e.remainingMinutes : 15),
inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch});
AndroidAlarmManager.oneShot(
Duration(minutes: e is RateLimitError ? e.remainingMinutes : 15),
Random().nextInt(pow(2, 31) as int),
bgUpdateCheck,
params: {
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
});
} else {
err = e.toString();
}
}
List<App> newUpdates = appsProvider
.getExistingUpdates(installedOnly: true)
.findExistingUpdates(installedOnly: true)
.where((id) => !existingUpdateIds.contains(id))
.map((e) => appsProvider.apps[e]!.app)
.toList();
@ -80,24 +81,14 @@ bgUpdateCheck(int? ignoreAfterMicroseconds) async {
if (err != null) {
throw err;
}
return Future.value(true);
} catch (e) {
notificationsProvider
.notify(ErrorCheckingUpdatesNotification(e.toString()));
return Future.error(false);
} finally {
await notificationsProvider.cancel(checkingUpdatesNotification.id);
}
}
@pragma('vm:entry-point')
void bgTaskCallback() {
// Background process callback
Workmanager().executeTask((task, inputData) async {
return await bgUpdateCheck(inputData?['ignoreAfter']);
});
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
@ -106,16 +97,10 @@ void main() async {
);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
Workmanager().initialize(
bgTaskCallback,
);
await AndroidAlarmManager.initialize();
runApp(MultiProvider(
providers: [
ChangeNotifierProvider(
create: (context) => AppsProvider(
shouldLoadApps: true,
shouldCheckUpdatesAfterLoad: false,
shouldDeleteAPKs: true)),
ChangeNotifierProvider(create: (context) => AppsProvider()),
ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => NotificationsProvider())
],
@ -165,17 +150,14 @@ class _ObtainiumState extends State<Obtainium> {
if (existingUpdateInterval != settingsProvider.updateInterval) {
existingUpdateInterval = settingsProvider.updateInterval;
if (existingUpdateInterval == 0) {
Workmanager().cancelByUniqueName(bgUpdateCheckTaskName);
AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
} else {
Workmanager().registerPeriodicTask(
bgUpdateCheckTaskName, bgUpdateCheckTaskName,
frequency: Duration(minutes: existingUpdateInterval),
initialDelay: Duration(minutes: existingUpdateInterval),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.replace,
backoffPolicy: BackoffPolicy.linear,
backoffPolicyDelay:
const Duration(minutes: minUpdateIntervalMinutes));
AndroidAlarmManager.periodic(
Duration(minutes: existingUpdateInterval),
bgUpdateCheckAlarmId,
bgUpdateCheck,
rescheduleOnReboot: true,
wakeup: true);
}
}
}

View File

@ -5,7 +5,7 @@ import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class GitHubStars implements MassAppSource {
class GitHubStars implements MassAppUrlSource {
@override
late String name = 'GitHub Starred Repos';
@ -28,14 +28,14 @@ class GitHubStars implements MassAppSource {
.round());
}
throw 'Unable to find user\'s starred repos';
throw ObtainiumError('Unable to find user\'s starred repos');
}
}
@override
Future<List<String>> getUrls(List<String> args) async {
if (args.length != requiredArgs.length) {
throw 'Wrong number of arguments provided';
throw ObtainiumError('Wrong number of arguments provided');
}
List<String> urls = [];
var page = 1;

View File

@ -2,6 +2,7 @@ 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/custom_errors.dart';
import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
@ -56,7 +57,9 @@ class _AddAppPageState extends State<AddAppPage> {
} catch (e) {
return e is String
? e
: 'Error';
: e is ObtainiumError
? e.toString()
: 'Error';
}
return null;
}
@ -76,7 +79,7 @@ class _AddAppPageState extends State<AddAppPage> {
: [];
validAdditionalData = source != null
? sourceProvider
.doesSourceHaveRequiredAdditionalData(
.ifSourceAppsRequireAdditionalData(
source)
: true;
}
@ -114,21 +117,20 @@ class _AddAppPageState extends State<AddAppPage> {
.getInstallPermission();
// ignore: use_build_context_synchronously
var apkUrl = await appsProvider
.selectApkUrl(app, context);
.confirmApkUrl(app, context);
if (apkUrl == null) {
throw 'Cancelled';
throw ObtainiumError('Cancelled');
}
app.preferredApkIndex =
app.apkUrls.indexOf(apkUrl);
var downloadedApk =
await appsProvider.downloadApp(
app,
showOccasionalProgressToast:
true);
await appsProvider
.downloadApp(app);
app.id = downloadedApk.appId;
if (appsProvider.apps
.containsKey(app.id)) {
throw 'App already added';
throw ObtainiumError(
'App already added');
}
await appsProvider.saveApps([app]);
@ -142,11 +144,7 @@ class _AddAppPageState extends State<AddAppPage> {
AppPage(
appId: app.id)));
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(e.toString())),
);
showError(e, context);
}).whenComplete(() {
setState(() {
gettingAppInfo = false;
@ -197,9 +195,6 @@ class _AddAppPageState extends State<AddAppPage> {
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// const SizedBox(
// height: 48,
// ),
const Text(
'Supported Sources:',
),

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
@ -25,10 +26,8 @@ class _AppPageState extends State<AppPage> {
var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>();
getUpdate(String id) {
appsProvider.getUpdate(id).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
appsProvider.checkUpdate(id).catchError((e) {
showError(e, context);
});
}
@ -217,9 +216,8 @@ class _AppPageState extends State<AppPage> {
Expanded(
child: ElevatedButton(
onPressed: (app?.app.installedVersion == null ||
appsProvider
.checkAppObjectForUpdate(
app!.app)) &&
app?.app.installedVersion !=
app?.app.latestVersion) &&
!appsProvider.areDownloadsRunning()
? () {
HapticFeedback.heavyImpact();
@ -231,11 +229,7 @@ class _AppPageState extends State<AppPage> {
Navigator.of(context).pop();
}
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(e.toString())),
);
showError(e, context);
});
}
: null,

View File

@ -3,6 +3,7 @@ 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/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
@ -120,7 +121,7 @@ class AppsPageState extends State<AppsPage> {
sortedApps = sortedApps.reversed.toList();
}
var existingUpdates = appsProvider.getExistingUpdates(installedOnly: true);
var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
var existingUpdateIdsAllOrSelected = existingUpdates
.where((element) => selectedIds.isEmpty
@ -128,7 +129,7 @@ class AppsPageState extends State<AppsPage> {
: selectedIds.contains(element))
.toList();
var newInstallIdsAllOrSelected = appsProvider
.getExistingUpdates(nonInstalledOnly: true)
.findExistingUpdates(nonInstalledOnly: true)
.where((element) => selectedIds.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element))
@ -155,9 +156,7 @@ class AppsPageState extends State<AppsPage> {
refreshingSince = DateTime.now();
});
return appsProvider.checkUpdates().catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
showError(e, context);
}).whenComplete(() {
setState(() {
refreshingSince = null;
@ -380,9 +379,7 @@ class AppsPageState extends State<AppsPage> {
.downloadAndInstallLatestApps(
toInstall, context)
.catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
showError(e, context);
});
});
}

View File

@ -92,7 +92,6 @@ class _HomePageState extends State<HomePage> {
return !(pages[0].widget.key as GlobalKey<AppsPageState>)
.currentState
?.clearSelected();
// return !appsPageKey.currentState?.clearSelected();
});
}
}

View File

@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
@ -81,12 +82,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
appsProvider
.exportApps()
.then((String path) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Exported to $path')),
);
showError(
'Exported to $path', context);
});
},
child: const Text('Obtainium Export'))),
@ -113,27 +110,21 @@ class _ImportExportPageState extends State<ImportExportPage> {
try {
jsonDecode(data);
} catch (e) {
throw 'Invalid input';
throw ObtainiumError(
'Invalid input');
}
appsProvider
.importApps(data)
.then((value) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'$value App${value == 1 ? '' : 's'} Imported')),
);
showError(
'$value App${value == 1 ? '' : 's'} Imported',
context);
});
} else {
// User canceled the picker
}
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(e.toString())),
);
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
@ -208,12 +199,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
});
addApps(urls).then((errors) {
if (errors.isEmpty) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Imported ${urls.length} Apps')),
);
showError(
'Imported ${urls.length} Apps',
context);
} else {
showDialog(
context: context,
@ -224,10 +212,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
});
}
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(content: Text(e.toString())),
);
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
@ -239,7 +224,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
child: const Text(
'Import from URL List',
)),
...sourceProvider.massSources
...sourceProvider.massUrlSources
.map((source) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
@ -288,13 +273,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
.then((errors) {
if (errors
.isEmpty) {
ScaffoldMessenger
.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Imported ${selectedUrls.length} Apps')),
);
showError(
'Imported ${selectedUrls.length} Apps',
context);
} else {
showDialog(
context:
@ -328,13 +309,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
importInProgress =
false;
});
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: Text(
e.toString())),
);
showError(e, context);
});
}
});

View File

@ -21,6 +21,143 @@ class _SettingsPageState extends State<SettingsPage> {
if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings();
}
var themeDropdown = DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'Theme'),
value: settingsProvider.theme,
items: const [
DropdownMenuItem(
value: ThemeSettings.dark,
child: Text('Dark'),
),
DropdownMenuItem(
value: ThemeSettings.light,
child: Text('Light'),
),
DropdownMenuItem(
value: ThemeSettings.system,
child: Text('Follow System'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.theme = value;
}
});
var colourDropdown = DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'Colour'),
value: settingsProvider.colour,
items: const [
DropdownMenuItem(
value: ColourSettings.basic,
child: Text('Obtainium'),
),
DropdownMenuItem(
value: ColourSettings.materialYou,
child: Text('Material You'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.colour = value;
}
});
var sortDropdown = DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'App Sort By'),
value: settingsProvider.sortColumn,
items: const [
DropdownMenuItem(
value: SortColumnSettings.authorName,
child: Text('Author/Name'),
),
DropdownMenuItem(
value: SortColumnSettings.nameAuthor,
child: Text('Name/Author'),
),
DropdownMenuItem(
value: SortColumnSettings.added,
child: Text('As Added'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortColumn = value;
}
});
var orderDropdown = DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'App Sort Order'),
value: settingsProvider.sortOrder,
items: const [
DropdownMenuItem(
value: SortOrderSettings.ascending,
child: Text('Ascending'),
),
DropdownMenuItem(
value: SortOrderSettings.descending,
child: Text('Descending'),
),
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortOrder = value;
}
});
var intervalDropdown = DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'Background Update Checking Interval'),
value: settingsProvider.updateInterval,
items: updateIntervals.map((e) {
int displayNum = (e < 60
? e
: e < 1440
? e / 60
: e / 1440)
.round();
var displayUnit = (e < 60
? 'Minute'
: e < 1440
? 'Hour'
: 'Day');
String display = e == 0
? 'Never - Manual Only'
: '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
return DropdownMenuItem(value: e, child: Text(display));
}).toList(),
onChanged: (value) {
if (value != null) {
settingsProvider.updateInterval = value;
}
});
var sourceSpecificFields = sourceProvider.sources.map((e) {
if (e.moreSourceSettingsFormItems.isNotEmpty) {
return GeneratedForm(
items: e.moreSourceSettingsFormItems.map((e) => [e]).toList(),
onValueChanges: (values, valid) {
if (valid) {
for (var i = 0; i < values.length; i++) {
settingsProvider.setSettingString(
e.moreSourceSettingsFormItems[i].id, values[i]);
}
}
},
defaultValues: e.moreSourceSettingsFormItems.map((e) {
return settingsProvider.getSettingString(e.id) ?? '';
}).toList());
} else {
return Container();
}
});
const height16 = SizedBox(
height: 16,
);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
@ -38,112 +175,22 @@ class _SettingsPageState extends State<SettingsPage> {
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
DropdownButtonFormField(
decoration:
const InputDecoration(labelText: 'Theme'),
value: settingsProvider.theme,
items: const [
DropdownMenuItem(
value: ThemeSettings.dark,
child: Text('Dark'),
),
DropdownMenuItem(
value: ThemeSettings.light,
child: Text('Light'),
),
DropdownMenuItem(
value: ThemeSettings.system,
child: Text('Follow System'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.theme = value;
}
}),
const SizedBox(
height: 16,
),
DropdownButtonFormField(
decoration:
const InputDecoration(labelText: 'Colour'),
value: settingsProvider.colour,
items: const [
DropdownMenuItem(
value: ColourSettings.basic,
child: Text('Obtainium'),
),
DropdownMenuItem(
value: ColourSettings.materialYou,
child: Text('Material You'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.colour = value;
}
}),
const SizedBox(
height: 16,
),
themeDropdown,
height16,
colourDropdown,
height16,
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'App Sort By'),
value: settingsProvider.sortColumn,
items: const [
DropdownMenuItem(
value:
SortColumnSettings.authorName,
child: Text('Author/Name'),
),
DropdownMenuItem(
value:
SortColumnSettings.nameAuthor,
child: Text('Name/Author'),
),
DropdownMenuItem(
value: SortColumnSettings.added,
child: Text('As Added'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortColumn = value;
}
})),
Expanded(child: sortDropdown),
const SizedBox(
width: 16,
),
Expanded(
child: DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'App Sort Order'),
value: settingsProvider.sortOrder,
items: const [
DropdownMenuItem(
value: SortOrderSettings.ascending,
child: Text('Ascending'),
),
DropdownMenuItem(
value: SortOrderSettings.descending,
child: Text('Descending'),
),
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortOrder = value;
}
})),
Expanded(child: orderDropdown),
],
),
const SizedBox(
height: 16,
),
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -155,9 +202,7 @@ class _SettingsPageState extends State<SettingsPage> {
})
],
),
const SizedBox(
height: 16,
),
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -172,54 +217,13 @@ class _SettingsPageState extends State<SettingsPage> {
const Divider(
height: 16,
),
const SizedBox(
height: 16,
),
height16,
Text(
'Updates',
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
DropdownButtonFormField(
decoration: const InputDecoration(
labelText:
'Background Update Checking Interval'),
value: settingsProvider.updateInterval,
items: updateIntervals.map((e) {
int displayNum = (e < 60
? e
: e < 1440
? e / 60
: e / 1440)
.round();
var displayUnit = (e < 60
? 'Minute'
: e < 1440
? 'Hour'
: 'Day');
String display = e == 0
? 'Never - Manual Only'
: '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
return DropdownMenuItem(
value: e, child: Text(display));
}).toList(),
onChanged: (value) {
if (value != null) {
settingsProvider.updateInterval = value;
}
}),
const SizedBox(
height: 8,
),
Text(
'Longer intervals may result in less reliable behaviour',
style: Theme.of(context)
.textTheme
.labelMedium!
.merge(const TextStyle(
fontStyle: FontStyle.italic)),
),
intervalDropdown,
const Divider(
height: 48,
),
@ -228,42 +232,13 @@ class _SettingsPageState extends State<SettingsPage> {
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
...sourceProvider.sources.map((e) {
if (e.moreSourceSettingsFormItems.isNotEmpty) {
return GeneratedForm(
items: e.moreSourceSettingsFormItems
.map((e) => [e])
.toList(),
onValueChanges: (values, valid) {
if (valid) {
for (var i = 0;
i < values.length;
i++) {
settingsProvider.setSettingString(
e.moreSourceSettingsFormItems[i]
.id,
values[i]);
}
}
},
defaultValues:
e.moreSourceSettingsFormItems.map((e) {
return settingsProvider
.getSettingString(e.id) ??
'';
}).toList());
} else {
return Container();
}
}),
...sourceSpecificFields,
],
))),
SliverToBoxAdapter(
child: Column(
children: [
const SizedBox(
height: 16,
),
height16,
TextButton.icon(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.resolveWith<Color>(
@ -281,9 +256,7 @@ class _SettingsPageState extends State<SettingsPage> {
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(
height: 16,
),
height16,
],
),
)

View File

@ -25,15 +25,15 @@ import 'package:http/http.dart';
class AppInMemory {
late App app;
double? downloadProgress;
AppInfo? installedInfo; // Also indicates that an App is installed
AppInfo? installedInfo;
AppInMemory(this.app, this.downloadProgress, this.installedInfo);
}
class DownloadedApp {
class DownloadedApk {
String appId;
File file;
DownloadedApp(this.appId, this.file);
DownloadedApk(this.appId, this.file);
}
class AppsProvider with ChangeNotifier {
@ -41,124 +41,112 @@ class AppsProvider with ChangeNotifier {
Map<String, AppInMemory> apps = {};
bool loadingApps = false;
bool gettingUpdates = false;
bool forBGTask = false;
// Variables to keep track of the app foreground status (installs can't run in the background)
bool isForeground = true;
late Stream<FGBGType>? foregroundStream;
late StreamSubscription<FGBGType>? foregroundSubscription;
AppsProvider(
{bool shouldLoadApps = false,
bool shouldCheckUpdatesAfterLoad = false,
bool shouldDeleteAPKs = false}) {
if (shouldLoadApps) {
AppsProvider({this.forBGTask = false}) {
// Many setup tasks should only be done in the foreground isolate
if (!forBGTask) {
// Subscribe to changes in the app foreground status
foregroundStream = FGBGEvents.stream.asBroadcastStream();
foregroundSubscription = foregroundStream?.listen((event) async {
isForeground = event == FGBGType.foreground;
if (isForeground) await loadApps();
});
loadApps().then((_) {
if (shouldDeleteAPKs) {
deleteSavedAPKs();
}
if (shouldCheckUpdatesAfterLoad) {
checkUpdates();
}
});
() async {
// Load Apps into memory (in background, this is done later instead of in the constructor)
await loadApps();
// Delete existing APKs
(await getExternalStorageDirectory())
?.listSync()
.where((element) => element.path.endsWith('.apk'))
.forEach((apk) {
apk.delete();
});
}();
}
}
downloadApk(String apkUrl, String fileName, Function? onProgress,
Function? urlModifier) async {
bool useExistingIfExists =
false; // This should be an function argument, but isn't because of the partially downloaded APK issue
downloadFile(String url, String fileName, Function? onProgress) async {
var destDir = (await getExternalStorageDirectory())!.path;
if (urlModifier != null) {
apkUrl = await urlModifier(apkUrl);
}
StreamedResponse response =
await Client().send(Request('GET', Uri.parse(apkUrl)));
File downloadFile = File('$destDir/$fileName.apk');
var alreadyExists = downloadFile.existsSync();
if (!alreadyExists || !useExistingIfExists) {
if (alreadyExists) {
downloadFile.deleteSync();
}
await Client().send(Request('GET', Uri.parse(url)));
File downloadedFile = File('$destDir/$fileName');
var length = response.contentLength;
var received = 0;
double? progress;
var sink = downloadFile.openWrite();
if (downloadedFile.existsSync()) {
downloadedFile.deleteSync();
}
var length = response.contentLength;
var received = 0;
double? progress;
var sink = downloadedFile.openWrite();
await response.stream.map((s) {
received += s.length;
progress = (length != null ? received / length * 100 : 30);
if (onProgress != null) {
onProgress(progress);
}
return s;
}).pipe(sink);
await sink.close();
progress = null;
await response.stream.map((s) {
received += s.length;
progress = (length != null ? received / length * 100 : 30);
if (onProgress != null) {
onProgress(progress);
}
return s;
}).pipe(sink);
if (response.statusCode != 200) {
downloadFile.deleteSync();
throw response.reasonPhrase ?? 'Unknown Error';
}
await sink.close();
progress = null;
if (onProgress != null) {
onProgress(progress);
}
return downloadFile;
if (response.statusCode != 200) {
downloadedFile.deleteSync();
throw response.reasonPhrase ?? 'Unknown Error';
}
return downloadedFile;
}
// Downloads the App (preferred URL) and returns an ApkFile object
// If the app was already saved, updates it's download progress % in memory
// But also works for Apps that are not saved
Future<DownloadedApp> downloadApp(App app,
{bool showOccasionalProgressToast = false}) async {
Future<DownloadedApk> downloadApp(App app) async {
var fileName =
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
String downloadUrl = await SourceProvider()
.getSource(app.url)
.apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
int? prevProg;
var fileName = '${app.id}-${app.latestVersion}-${app.preferredApkIndex}';
File downloadFile = await downloadApk(app.apkUrls[app.preferredApkIndex],
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}',
(double? progress) {
File downloadedFile =
await downloadFile(downloadUrl, fileName, (double? progress) {
int? prog = progress?.ceil();
if (apps[app.id] != null) {
apps[app.id]!.downloadProgress = progress;
}
int? prog = progress?.ceil();
if (showOccasionalProgressToast &&
(prog == 25 || prog == 50 || prog == 75) &&
prevProg != prog) {
notifyListeners();
} else if ((prog == 25 || prog == 50 || prog == 75) && prevProg != prog) {
Fluttertoast.showToast(
msg: 'Progress: $prog%', toastLength: Toast.LENGTH_SHORT);
}
prevProg = prog;
notifyListeners();
}, SourceProvider().getSource(app.url).apkUrlPrefetchModifier);
});
// Delete older versions of the APK if any
for (var file in downloadFile.parent.listSync()) {
for (var file in downloadedFile.parent.listSync()) {
var fn = file.path.split('/').last;
if (fn.startsWith('${app.id}-') &&
fn.endsWith('.apk') &&
fn != '$fileName.apk') {
fn != fileName) {
file.delete();
}
}
// If the ID has changed (as it should on first download), replace it
var newInfo = await PackageArchiveInfo.fromPath(downloadFile.path);
// If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
// The former case should be handled (give the App its real ID), the latter is a security issue
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
if (app.id != newInfo.packageName) {
var originalAppId = app.id;
app.id = newInfo.packageName;
downloadFile = downloadFile.renameSync(
'${downloadFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
if (apps[originalAppId] != null) {
await removeApps([originalAppId]);
await saveApps([app]);
if (apps[app.id] != null) {
throw IDChangedError();
}
app.id = newInfo.packageName;
downloadedFile = downloadedFile.renameSync(
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
}
return DownloadedApp(app.id, downloadFile);
return DownloadedApk(app.id, downloadedFile);
}
bool areDownloadsRunning() => apps.values
@ -166,24 +154,26 @@ class AppsProvider with ChangeNotifier {
.isNotEmpty;
Future<bool> canInstallSilently(App app) async {
// TODO: This is unreliable - try to get from OS in the future
var osInfo = await DeviceInfoPlugin().androidInfo;
return app.installedVersion != null &&
osInfo.version.sdkInt >= 30 &&
osInfo.version.release.compareTo('12') >= 0;
return false;
// TODO: Uncomment the below once silentupdates are ever figured out
// // TODO: This is unreliable - try to get from OS in the future
// if (app.apkUrls.length > 1) {
// return false;
// }
// var osInfo = await DeviceInfoPlugin().androidInfo;
// return app.installedVersion != null &&
// osInfo.version.sdkInt >= 30 &&
// osInfo.version.release.compareTo('12') >= 0;
}
Future<void> askUserToReturnToForeground(BuildContext context,
{bool waitForFG = false}) async {
Future<void> waitForUserToReturnToForeground(BuildContext context) async {
NotificationsProvider notificationsProvider =
context.read<NotificationsProvider>();
if (!isForeground) {
await notificationsProvider.notify(completeInstallationNotification,
cancelExisting: true);
if (waitForFG) {
await FGBGEvents.stream.first == FGBGType.foreground;
await notificationsProvider.cancel(completeInstallationNotification.id);
}
while (await FGBGEvents.stream.first != FGBGType.foreground) {}
await notificationsProvider.cancel(completeInstallationNotification.id);
}
}
@ -191,7 +181,7 @@ class AppsProvider with ChangeNotifier {
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
// If appropriate criteria are met, the update (never a fresh install) happens silently in the background
// But even then, we don't know if it actually succeeded
Future<void> installApk(DownloadedApp file) async {
Future<void> installApk(DownloadedApk file) async {
var newInfo = await PackageArchiveInfo.fromPath(file.file.path);
AppInfo? appInfo;
try {
@ -201,7 +191,7 @@ class AppsProvider with ChangeNotifier {
}
if (appInfo != null &&
int.parse(newInfo.buildNumber) < appInfo.versionCode!) {
throw 'Can\'t install an older version';
throw DowngradeError();
}
if (appInfo == null ||
int.parse(newInfo.buildNumber) > appInfo.versionCode!) {
@ -210,17 +200,25 @@ class AppsProvider with ChangeNotifier {
apps[file.appId]!.app.installedVersion =
apps[file.appId]!.app.latestVersion;
// Don't correct install status as installation may not be done yet
await saveApps([apps[file.appId]!.app], shouldCorrectInstallStatus: false);
await saveApps([apps[file.appId]!.app],
attemptToCorrectInstallStatus: false);
}
Future<String?> selectApkUrl(App app, BuildContext? context) async {
Future<String?> confirmApkUrl(App app, BuildContext? context) async {
// If the App has more than one APK, the user should pick one (if context provided)
String? apkUrl = app.apkUrls[app.preferredApkIndex];
// get device supported architecture
List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
if (app.apkUrls.length > 1 && context != null) {
apkUrl = await showDialog(
context: context,
builder: (BuildContext ctx) {
return APKPicker(app: app, initVal: apkUrl);
return APKPicker(
app: app,
initVal: apkUrl,
archs: archs,
);
});
}
// If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
@ -240,15 +238,6 @@ class AppsProvider with ChangeNotifier {
return apkUrl;
}
Map<String, List<String>> addToErrorMap(
Map<String, List<String>> errors, String appId, String error) {
var tempIds = errors.remove(error);
tempIds ??= [];
tempIds.add(appId);
errors.putIfAbsent(error, () => tempIds!);
return errors;
}
// Given a list of AppIds, uses stored info about the apps to download APKs and install them
// If the APKs can be installed silently, they are
// If no BuildContext is provided, apps that require user interaction are ignored
@ -257,42 +246,41 @@ class AppsProvider with ChangeNotifier {
Future<List<String>> downloadAndInstallLatestApps(
List<String> appIds, BuildContext? context) async {
List<String> appsToInstall = [];
// 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 'App not found';
throw ObtainiumError('App not found');
}
String? apkUrl = await selectApkUrl(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) {
apps[id]!.app.preferredApkIndex = urlInd;
await saveApps([apps[id]!.app]);
}
if (context != null ||
(await canInstallSilently(apps[id]!.app) &&
apps[id]!.app.apkUrls.length == 1)) {
if (context != null || await canInstallSilently(apps[id]!.app)) {
appsToInstall.add(id);
}
}
}
Map<String, List<String>> errors = {};
List<DownloadedApp?> downloadedFiles =
// 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);
} catch (e) {
addToErrorMap(errors, id, e.toString());
errors.add(id, e.toString());
}
return null;
}));
downloadedFiles =
downloadedFiles.where((element) => element != null).toList();
List<DownloadedApp> silentUpdates = [];
List<DownloadedApp> regularInstalls = [];
// Separate the Apps to install into silent and regular lists
List<DownloadedApk> silentUpdates = [];
List<DownloadedApk> regularInstalls = [];
for (var f in downloadedFiles) {
bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app);
if (willBeSilent) {
@ -302,10 +290,13 @@ class AppsProvider with ChangeNotifier {
}
}
// Move everything to the regular install list (since silent updates don't currently work) - TODO
regularInstalls.addAll(silentUpdates);
// If Obtainium is being installed, it should be the last one
List<DownloadedApp> moveObtainiumToStart(List<DownloadedApp> items) {
List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) {
String obtainiumId = 'imranr98_obtainium_${GitHub().host}';
DownloadedApp? temp;
DownloadedApk? temp;
items.removeWhere((element) {
bool res = element.appId == obtainiumId;
if (res) {
@ -319,39 +310,33 @@ class AppsProvider with ChangeNotifier {
return items;
}
// TODO: Remove below line if silentupdates are ever figured out
regularInstalls.addAll(silentUpdates);
silentUpdates = moveObtainiumToStart(silentUpdates);
regularInstalls = moveObtainiumToStart(regularInstalls);
// TODO: Uncomment below if silentupdates are ever figured out
// // Install silent updates (uncomment when it works - TODO)
// for (var u in silentUpdates) {
// await installApk(u, silent: true); // Would need to add silent option
// }
if (context != null) {
if (regularInstalls.isNotEmpty) {
// ignore: use_build_context_synchronously
await askUserToReturnToForeground(context, waitForFG: true);
}
// Do regular installs
if (regularInstalls.isNotEmpty && context != null) {
// ignore: use_build_context_synchronously
await waitForUserToReturnToForeground(context);
for (var i in regularInstalls) {
try {
await installApk(i);
} catch (e) {
addToErrorMap(errors, i.appId, e.toString());
errors.add(i.appId, e.toString());
}
}
}
if (errors.isNotEmpty) {
String finalError = '';
for (var e in errors.keys) {
finalError +=
'$e ${errors[e]!.map((e) => apps[e]!.app.name).toString()}. ';
}
throw finalError;
if (errors.content.isNotEmpty) {
throw errors;
}
NotificationsProvider().cancel(UpdateNotification([]).id);
return downloadedFiles.map((e) => e!.appId).toList();
}
@ -364,40 +349,6 @@ class AppsProvider with ChangeNotifier {
return appsDir;
}
// Delete all stored APKs except those likely to still be needed
Future<void> deleteSavedAPKs() async {
List<FileSystemEntity>? apks = (await getExternalStorageDirectory())
?.listSync()
.where((element) => element.path.endsWith('.apk'))
.toList();
if (apks != null && apks.isNotEmpty) {
for (var apk in apks) {
var shouldDelete = true;
var temp = apk.path.split('/').last;
temp = temp.substring(0, temp.length - 4);
var fn = temp.split('-');
if (fn.length == 3) {
var possibleId = fn[0];
var possibleVersion = fn[1];
var possibleApkUrlIndex = fn[2];
if (apps[possibleId] != null) {
if (apps[possibleId] != null &&
apps[possibleId]?.app != null &&
apps[possibleId]!.app.installedVersion !=
apps[possibleId]!.app.latestVersion &&
apps[possibleId]!.app.latestVersion == possibleVersion &&
apps[possibleId]!.app.preferredApkIndex.toString() ==
possibleApkUrlIndex) {
shouldDelete = false;
}
}
}
if (shouldDelete) apk.delete();
}
}
}
Future<AppInfo?> getInstalledInfo(String? packageName) async {
if (packageName != null) {
try {
@ -409,24 +360,37 @@ class AppsProvider with ChangeNotifier {
return null;
}
String standardizeVersionString(String versionString) {
return versionString.characters
.where((p0) => ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.']
.contains(p0))
.join('');
}
// If the App says it is installed by installedInfo is null, set it to not installed
// If the App says it is installed but installedInfo is null, set it to not installed
// If the App says is is not installed but installedInfo exists, try to set it to installed as latest version...
// ...if the latestVersion seems to match the version in installedInfo (not guaranteed)
App? correctInstallStatus(App app, AppInfo? installedInfo) {
// If that fails, just set it to the actual version string (all we can do at that point)
// Don't save changes, just return the object if changes were made (else null)
// If in a background isolate, return null straight away as the required plugin won't work anyways
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
if (forBGTask) {
return null; // Can't correct in the background isolate
}
var modded = false;
if (installedInfo == null && app.installedVersion != null) {
app.installedVersion = null;
modded = true;
}
if (installedInfo != null && app.installedVersion == null) {
if (standardizeVersionString(app.latestVersion) ==
if (app.latestVersion.characters
.where((p0) => [
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'.'
].contains(p0))
.join('') ==
installedInfo.versionName) {
app.installedVersion = app.latestVersion;
} else {
@ -437,7 +401,7 @@ class AppsProvider with ChangeNotifier {
return modded ? app : null;
}
Future<void> loadApps({shouldCorrectInstallStatus = true}) async {
Future<void> loadApps() async {
while (loadingApps) {
await Future.delayed(const Duration(microseconds: 1));
}
@ -468,28 +432,26 @@ class AppsProvider with ChangeNotifier {
}
loadingApps = false;
notifyListeners();
// For any that are not installed (by ID == package name), set to not installed if needed
if (shouldCorrectInstallStatus) {
List<App> modifiedApps = [];
for (var app in apps.values) {
var moddedApp = correctInstallStatus(app.app, app.installedInfo);
if (moddedApp != null) {
modifiedApps.add(moddedApp);
}
}
if (modifiedApps.isNotEmpty) {
await saveApps(modifiedApps, shouldCorrectInstallStatus: false);
List<App> modifiedApps = [];
for (var app in apps.values) {
var moddedApp =
getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo);
if (moddedApp != null) {
modifiedApps.add(moddedApp);
}
}
if (modifiedApps.isNotEmpty) {
await saveApps(modifiedApps);
}
}
Future<void> saveApps(List<App> apps,
{bool shouldCorrectInstallStatus = true}) async {
{bool attemptToCorrectInstallStatus = true}) async {
for (var app in apps) {
AppInfo? info = await getInstalledInfo(app.id);
app.name = info?.name ?? app.name;
if (shouldCorrectInstallStatus) {
app = correctInstallStatus(app, info) ?? app;
if (attemptToCorrectInstallStatus) {
app = getCorrectedInstallStatusAppIfPossible(app, info) ?? app;
}
File('${(await getAppsDir()).path}/${app.id}.json')
.writeAsStringSync(jsonEncode(app.toJson()));
@ -515,15 +477,7 @@ class AppsProvider with ChangeNotifier {
}
}
bool checkAppObjectForUpdate(App app) {
if (!apps.containsKey(app.id)) {
throw 'App not found';
}
return app.latestVersion != apps[app.id]?.app.installedVersion;
}
Future<App?> getUpdate(String appId,
{bool shouldCorrectInstallStatus = true}) async {
Future<App?> checkUpdate(String appId) async {
App? currentApp = apps[appId]!.app;
SourceProvider sourceProvider = SourceProvider();
App newApp = await sourceProvider.getApp(
@ -536,51 +490,39 @@ class AppsProvider with ChangeNotifier {
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex;
}
await saveApps([newApp],
shouldCorrectInstallStatus: shouldCorrectInstallStatus);
await saveApps([newApp]);
return newApp.latestVersion != currentApp.latestVersion ? newApp : null;
}
Future<List<App>> checkUpdates(
{DateTime? ignoreAfter,
bool immediatelyThrowRateLimitError = false,
bool shouldCorrectInstallStatus = true,
bool immediatelyThrowSocketError = false}) async {
{DateTime? ignoreAppsCheckedAfter,
bool throwErrorsForRetry = false}) async {
List<App> updates = [];
Map<String, List<String>> errors = {};
MultiAppMultiError errors = MultiAppMultiError();
if (!gettingUpdates) {
gettingUpdates = true;
try {
List<String> appIds = apps.keys.toList();
if (ignoreAfter != null) {
appIds = appIds
.where((id) =>
apps[id]!.app.lastUpdateCheck == null ||
apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter))
.toList();
}
List<String> appIds = apps.values
.where((app) =>
app.app.lastUpdateCheck == null ||
ignoreAppsCheckedAfter == null ||
app.app.lastUpdateCheck!.isBefore(ignoreAppsCheckedAfter))
.map((e) => e.app.id)
.toList();
appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ??
DateTime.fromMicrosecondsSinceEpoch(0))
.compareTo(apps[b]!.app.lastUpdateCheck ??
DateTime.fromMicrosecondsSinceEpoch(0)));
for (int i = 0; i < appIds.length; i++) {
App? newApp;
try {
newApp = await getUpdate(appIds[i],
shouldCorrectInstallStatus: shouldCorrectInstallStatus);
newApp = await checkUpdate(appIds[i]);
} catch (e) {
if (e is RateLimitError && immediatelyThrowRateLimitError) {
if ((e is RateLimitError || e is SocketException) &&
throwErrorsForRetry) {
rethrow;
}
if (e is SocketException && immediatelyThrowSocketError) {
rethrow;
}
var tempIds = errors.remove(e.toString());
tempIds ??= [];
tempIds.add(appIds[i]);
errors.putIfAbsent(e.toString(), () => tempIds!);
errors.add(appIds[i], e.toString());
}
if (newApp != null) {
updates.add(newApp);
@ -590,18 +532,13 @@ class AppsProvider with ChangeNotifier {
gettingUpdates = false;
}
}
if (errors.isNotEmpty) {
String finalError = '';
for (var e in errors.keys) {
finalError +=
'$e ${errors[e]!.map((e) => apps[e]!.app.name).toString()}. ';
}
throw finalError;
if (errors.content.isNotEmpty) {
throw errors;
}
return updates;
}
List<String> getExistingUpdates(
List<String> findExistingUpdates(
{bool installedOnly = false, bool nonInstalledOnly = false}) {
List<String> updateAppIds = [];
List<String> appIds = apps.keys.toList();
@ -635,7 +572,6 @@ class AppsProvider with ChangeNotifier {
}
Future<int> importApps(String appsJSON) async {
// File picker does not work in android 13, so the user must paste the JSON directly into Obtainium to import Apps
List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
.map((e) => App.fromJson(e))
.toList();
@ -660,10 +596,11 @@ class AppsProvider with ChangeNotifier {
}
class APKPicker extends StatefulWidget {
const APKPicker({super.key, required this.app, this.initVal});
const APKPicker({super.key, required this.app, this.initVal, this.archs});
final App app;
final String? initVal;
final List<String>? archs;
@override
State<APKPicker> createState() => _APKPickerState();
@ -681,18 +618,29 @@ class _APKPickerState extends State<APKPicker> {
content: Column(children: [
Text('${widget.app.name} has more than one package:'),
const SizedBox(height: 16),
...widget.app.apkUrls.map((u) => RadioListTile<String>(
title: Text(Uri.parse(u)
.pathSegments
.where((element) => element.isNotEmpty)
.last),
value: u,
groupValue: apkUrl,
onChanged: (String? val) {
setState(() {
apkUrl = val;
});
}))
...widget.app.apkUrls.map(
(u) => RadioListTile<String>(
title: Text(Uri.parse(u)
.pathSegments
.where((element) => element.isNotEmpty)
.last),
value: u,
groupValue: apkUrl,
onChanged: (String? val) {
setState(() {
apkUrl = val;
});
}),
),
if (widget.archs != null)
const SizedBox(
height: 16,
),
if (widget.archs != null)
Text(
'Note:\nYour device supports the ${widget.archs!.length == 1 ? '\'${widget.archs![0]}\' CPU architecture.' : 'following CPU architectures: ${list2FriendlyString(widget.archs!.map((e) => '\'$e\'').toList())}.'}',
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
),
]),
actions: [
TextButton(

View File

@ -27,9 +27,11 @@ class UpdateNotification extends ObtainiumNotification {
'Updates Available',
'Notifies the user that updates are available for one or more Apps tracked by Obtainium',
Importance.max) {
message = updates.length == 1
? '${updates[0].name} has an update.'
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.';
message = updates.isEmpty
? "No new updates."
: 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.';
}
}

View File

@ -55,7 +55,7 @@ class SettingsProvider with ChangeNotifier {
}
int get updateInterval {
var min = prefs?.getInt('updateInterval') ?? 60;
var min = prefs?.getInt('updateInterval') ?? 360;
if (!updateIntervals.contains(min)) {
var temp = updateIntervals[0];
for (var i in updateIntervals) {

View File

@ -12,6 +12,7 @@ import 'package:obtainium/app_sources/mullvad.dart';
import 'package:obtainium/app_sources/signal.dart';
import 'package:obtainium/app_sources/sourceforge.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/mass_app_sources/githubstars.dart';
class AppNames {
@ -90,12 +91,7 @@ class App {
};
}
escapeRegEx(String s) {
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return '\\${x[0]}';
});
}
// Ensure the input is starts with HTTPS and has no WWW
preStandardizeUrl(String url) {
if (url.toLowerCase().indexOf('http://') != 0 &&
url.toLowerCase().indexOf('https://') != 0) {
@ -145,7 +141,7 @@ abstract class AppSource {
Future<String> apkUrlPrefetchModifier(String apkUrl);
}
abstract class MassAppSource {
abstract class MassAppUrlSource {
late String name;
late List<String> requiredArgs;
Future<List<String>> getUrls(List<String> args);
@ -160,12 +156,11 @@ class SourceProvider {
IzzyOnDroid(),
Mullvad(),
Signal(),
SourceForge(),
// APKMirror()
SourceForge()
];
// Add more mass source classes here so they are available via the service
List<MassAppSource> massSources = [GitHubStars()];
// Add more mass url source classes here so they are available via the service
List<MassAppUrlSource> massUrlSources = [GitHubStars()];
AppSource getSource(String url) {
url = preStandardizeUrl(url);
@ -177,12 +172,12 @@ class SourceProvider {
}
}
if (source == null) {
throw 'URL does not match a known source';
throw UnsupportedURLError();
}
return source;
}
bool doesSourceHaveRequiredAdditionalData(AppSource source) {
bool ifSourceAppsRequireAdditionalData(AppSource source) {
for (var row in source.additionalDataFormItems) {
for (var element in row) {
if (element.required) {
@ -217,8 +212,7 @@ class SourceProvider {
DateTime.now());
}
/// Returns a length 2 list, where the first element is a list of Apps and
/// the second is a Map<String, dynamic> of URLs and errors
// Returns errors in [results, errors] instead of throwing them
Future<List<dynamic>> getApps(List<String> urls,
{List<String> ignoreUrls = const []}) async {
List<App> apps = [];

View File

@ -1,6 +1,13 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
android_alarm_manager_plus:
dependency: "direct main"
description:
name: android_alarm_manager_plus
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
animations:
dependency: "direct main"
description:
@ -358,7 +365,7 @@ packages:
name: path_provider_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.20"
version: "2.0.21"
path_provider_ios:
dependency: transitive
description:
@ -470,7 +477,7 @@ packages:
name: share_plus
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.0"
version: "6.2.0"
share_plus_platform_interface:
dependency: transitive
description:
@ -700,13 +707,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
workmanager:
dependency: "direct main"
description:
name: workmanager
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.1"
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.6.6+50 # When changing this, update the tag in main() accordingly
version: 0.6.10+54 # When changing this, update the tag in main() accordingly
environment:
sdk: '>=2.18.2 <3.0.0'
@ -42,7 +42,6 @@ dependencies:
provider: ^6.0.3
http: ^0.13.5
webview_flutter: ^3.0.4
workmanager: ^0.5.0
dynamic_color: ^1.5.4
html: ^0.15.0
shared_preferences: ^2.0.15
@ -56,6 +55,7 @@ dependencies:
share_plus: ^6.0.1
installed_apps: ^1.3.1
package_archive_info: ^0.1.0
android_alarm_manager_plus: ^2.1.0
dev_dependencies: