Compare commits

...

18 Commits

Author SHA1 Message Date
49cb908d04 Increment version 2022-11-07 15:33:02 -05:00
139f44d31d UI improvement in APKPicker 2022-11-07 15:32:42 -05:00
ed955ac6a2 Add arch info (#100) 2022-11-07 15:14:00 -05:00
f3ead6caf1 Fixed breaking bug from previous commit 2022-11-06 14:21:00 -05:00
97ab723d04 Cleanup (#98) 2022-11-05 23:29:12 -04:00
ed4a26d348 Switch to AlarmManager plugin from WorkManager for more reliable update checking (for #87) (#97) 2022-11-05 23:25:19 -04:00
bd5f21984e Shorter default interval (see #87) 2022-11-04 19:10:20 -04:00
5037d77b14 Increment version 2022-11-04 18:57:06 -04:00
c9711c7734 Addresses #76 and #93 2022-11-04 18:53:25 -04:00
76e98feeb7 Increment version 2022-11-02 20:24:12 -04:00
03da23f77a Addresses #90 2022-11-02 20:23:40 -04:00
9b99e2b302 Addresses #88, #89 2022-11-02 20:07:46 -04:00
e746ca890a Obtainium now installs last (#84) 2022-10-31 17:41:25 -04:00
9c00a7da14 Increment version 2022-10-30 13:09:56 -04:00
4df0dd64ad Addresses #77 (version string overflow) 2022-10-30 13:09:36 -04:00
7cf7ffe0de Fixed icon size on App page (#78) 2022-10-30 12:48:26 -04:00
b1953435af Added progress toasts when adding Apps 2022-10-30 12:44:30 -04:00
fc7d7d11d6 Addresses #79 + other GitHub bugfix 2022-10-30 12:22:32 -04:00
24 changed files with 690 additions and 620 deletions

View File

@ -30,7 +30,25 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> 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> </application>
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <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> </manifest>

View File

@ -1,6 +1,7 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class APKMirror implements AppSource { class APKMirror implements AppSource {
@ -12,7 +13,7 @@ class APKMirror implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+'); RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw InvalidURLError(runtimeType.toString());
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@ -57,7 +58,7 @@ class APKMirror implements AppSource {
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse('$standardUrl/feed')); Response res = await get(Uri.parse('$standardUrl/feed'));
if (res.statusCode != 200) { if (res.statusCode != 200) {
throw couldNotFindReleases; throw NoReleasesError();
} }
var nextUrl = parse(res.body) var nextUrl = parse(res.body)
.querySelector('item') .querySelector('item')
@ -65,14 +66,14 @@ class APKMirror implements AppSource {
?.nextElementSibling ?.nextElementSibling
?.innerHtml; ?.innerHtml;
if (nextUrl == null) { if (nextUrl == null) {
throw couldNotFindReleases; throw NoReleasesError();
} }
Response res2 = await get(Uri.parse(nextUrl), headers: { Response res2 = await get(Uri.parse(nextUrl), headers: {
'User-Agent': 'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0' 'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
}); });
if (res2.statusCode != 200) { if (res2.statusCode != 200) {
throw couldNotFindReleases; throw NoReleasesError();
} }
var html2 = parse(res2.body); var html2 = parse(res2.body);
var origin = Uri.parse(standardUrl).origin; var origin = Uri.parse(standardUrl).origin;
@ -85,11 +86,11 @@ class APKMirror implements AppSource {
.map((e) => '$origin$e') .map((e) => '$origin$e')
.toList(); .toList();
if (apkUrls.isEmpty) { if (apkUrls.isEmpty) {
throw noAPKFound; throw NoAPKError();
} }
var version = html2.querySelector('span.active.accent_color')?.innerHtml; var version = html2.querySelector('span.active.accent_color')?.innerHtml;
if (version == null) { if (version == null) {
throw couldNotFindLatestVersion; throw NoVersionError();
} }
return APKDetails(version, apkUrls); return APKDetails(version, apkUrls);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,6 +28,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
values = widget.defaultValues;
valid = widget.initValid; valid = widget.initValid;
} }

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

View File

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

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/pages/app.dart'; import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
@ -76,7 +77,7 @@ class _AddAppPageState extends State<AddAppPage> {
: []; : [];
validAdditionalData = source != null validAdditionalData = source != null
? sourceProvider ? sourceProvider
.doesSourceHaveRequiredAdditionalData( .ifSourceAppsRequireAdditionalData(
source) source)
: true; : true;
} }
@ -114,9 +115,9 @@ class _AddAppPageState extends State<AddAppPage> {
.getInstallPermission(); .getInstallPermission();
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
var apkUrl = await appsProvider var apkUrl = await appsProvider
.selectApkUrl(app, context); .confirmApkUrl(app, context);
if (apkUrl == null) { if (apkUrl == null) {
throw 'Cancelled'; throw ObtainiumError('Cancelled');
} }
app.preferredApkIndex = app.preferredApkIndex =
app.apkUrls.indexOf(apkUrl); app.apkUrls.indexOf(apkUrl);
@ -126,7 +127,8 @@ class _AddAppPageState extends State<AddAppPage> {
app.id = downloadedApk.appId; app.id = downloadedApk.appId;
if (appsProvider.apps if (appsProvider.apps
.containsKey(app.id)) { .containsKey(app.id)) {
throw 'App already added'; throw ObtainiumError(
'App already added');
} }
await appsProvider.saveApps([app]); await appsProvider.saveApps([app]);
@ -140,11 +142,7 @@ class _AddAppPageState extends State<AddAppPage> {
AppPage( AppPage(
appId: app.id))); appId: app.id)));
}).catchError((e) { }).catchError((e) {
ScaffoldMessenger.of(context) showError(e, context);
.showSnackBar(
SnackBar(
content: Text(e.toString())),
);
}).whenComplete(() { }).whenComplete(() {
setState(() { setState(() {
gettingAppInfo = false; gettingAppInfo = false;
@ -195,9 +193,6 @@ class _AddAppPageState extends State<AddAppPage> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// const SizedBox(
// height: 48,
// ),
const Text( const Text(
'Supported Sources:', 'Supported Sources:',
), ),

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
@ -25,10 +26,8 @@ class _AppPageState extends State<AppPage> {
var appsProvider = context.watch<AppsProvider>(); var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>(); var settingsProvider = context.watch<SettingsProvider>();
getUpdate(String id) { getUpdate(String id) {
appsProvider.getUpdate(id).catchError((e) { appsProvider.checkUpdate(id).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar( showError(e, context);
SnackBar(content: Text(e.toString())),
);
}); });
} }
@ -62,10 +61,14 @@ class _AppPageState extends State<AppPage> {
children: [ children: [
Image.memory( Image.memory(
app!.installedInfo!.icon!, app!.installedInfo!.icon!,
scale: 1.5, height: 150,
gaplessPlayback: true,
) )
]) ])
: Container(), : Container(),
const SizedBox(
height: 25,
),
Text( Text(
app?.installedInfo?.name ?? app?.app.name ?? 'App', app?.installedInfo?.name ?? app?.app.name ?? 'App',
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -213,9 +216,8 @@ class _AppPageState extends State<AppPage> {
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: (app?.app.installedVersion == null || onPressed: (app?.app.installedVersion == null ||
appsProvider app?.app.installedVersion !=
.checkAppObjectForUpdate( app?.app.latestVersion) &&
app!.app)) &&
!appsProvider.areDownloadsRunning() !appsProvider.areDownloadsRunning()
? () { ? () {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
@ -227,11 +229,7 @@ class _AppPageState extends State<AppPage> {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
}).catchError((e) { }).catchError((e) {
ScaffoldMessenger.of(context) showError(e, context);
.showSnackBar(
SnackBar(
content: Text(e.toString())),
);
}); });
} }
: null, : null,

View File

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/pages/app.dart'; import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
@ -23,6 +24,7 @@ class AppsPageState extends State<AppsPage> {
var updatesOnlyFilter = var updatesOnlyFilter =
AppsFilter(includeUptodate: false, includeNonInstalled: false); AppsFilter(includeUptodate: false, includeNonInstalled: false);
Set<String> selectedIds = {}; Set<String> selectedIds = {};
DateTime? refreshingSince;
clearSelected() { clearSelected() {
if (selectedIds.isNotEmpty) { if (selectedIds.isNotEmpty) {
@ -119,28 +121,46 @@ class AppsPageState extends State<AppsPage> {
sortedApps = sortedApps.reversed.toList(); sortedApps = sortedApps.reversed.toList();
} }
var existingUpdateIdsAllOrSelected = appsProvider var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
.getExistingUpdates(installedOnly: true)
var existingUpdateIdsAllOrSelected = existingUpdates
.where((element) => selectedIds.isEmpty .where((element) => selectedIds.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty ? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element)) : selectedIds.contains(element))
.toList(); .toList();
var newInstallIdsAllOrSelected = appsProvider var newInstallIdsAllOrSelected = appsProvider
.getExistingUpdates(nonInstalledOnly: true) .findExistingUpdates(nonInstalledOnly: true)
.where((element) => selectedIds.isEmpty .where((element) => selectedIds.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty ? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element)) : selectedIds.contains(element))
.toList(); .toList();
if (settingsProvider.pinUpdates) {
var temp = [];
sortedApps = sortedApps.where((sa) {
if (existingUpdates.contains(sa.app.id)) {
temp.add(sa);
return false;
}
return true;
}).toList();
sortedApps = [...temp, ...sortedApps];
}
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: () { onRefresh: () {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
setState(() {
refreshingSince = DateTime.now();
});
return appsProvider.checkUpdates().catchError((e) { return appsProvider.checkUpdates().catchError((e) {
ScaffoldMessenger.of(context).showSnackBar( showError(e, context);
SnackBar(content: Text(e.toString())), }).whenComplete(() {
); setState(() {
refreshingSince = null;
});
}); });
}, },
child: CustomScrollView(slivers: <Widget>[ child: CustomScrollView(slivers: <Widget>[
@ -157,6 +177,17 @@ class AppsPageState extends State<AppsPage> {
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
))), ))),
if (refreshingSince != null)
SliverToBoxAdapter(
child: LinearProgressIndicator(
value: appsProvider.apps.values
.where((element) => !(element.app.lastUpdateCheck
?.isBefore(refreshingSince!) ??
true))
.length /
appsProvider.apps.length,
),
),
SliverList( SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) { (BuildContext context, int index) {
@ -168,7 +199,10 @@ class AppsPageState extends State<AppsPage> {
toggleAppSelected(sortedApps[index].app.id); toggleAppSelected(sortedApps[index].app.id);
}, },
leading: sortedApps[index].installedInfo != null leading: sortedApps[index].installedInfo != null
? Image.memory(sortedApps[index].installedInfo!.icon!) ? Image.memory(
sortedApps[index].installedInfo!.icon!,
gaplessPlayback: true,
)
: null, : null,
title: Text(sortedApps[index].installedInfo?.name ?? title: Text(sortedApps[index].installedInfo?.name ??
sortedApps[index].app.name), sortedApps[index].app.name),
@ -212,8 +246,15 @@ class AppsPageState extends State<AppsPage> {
)), )),
], ],
) )
: Text(sortedApps[index].app.installedVersion ?? : SingleChildScrollView(
'Not Installed')), child: SizedBox(
width: 80,
child: Text(
sortedApps[index].app.installedVersion ??
'Not Installed',
overflow: TextOverflow.fade,
textAlign: TextAlign.end,
)))),
onTap: () { onTap: () {
if (selectedIds.isNotEmpty) { if (selectedIds.isNotEmpty) {
toggleAppSelected(sortedApps[index].app.id); toggleAppSelected(sortedApps[index].app.id);
@ -320,10 +361,8 @@ class AppsPageState extends State<AppsPage> {
); );
}).then((values) { }).then((values) {
if (values != null) { if (values != null) {
bool shouldInstallUpdates = bool shouldInstallUpdates = values[0] == 'true';
values.isEmpty || values[0] == 'true'; bool shouldInstallNew = values[1] == 'true';
bool shouldInstallNew = values.isEmpty ||
(values.length >= 2 && values[1] == 'true');
settingsProvider settingsProvider
.getInstallPermission() .getInstallPermission()
.then((_) { .then((_) {
@ -340,9 +379,7 @@ class AppsPageState extends State<AppsPage> {
.downloadAndInstallLatestApps( .downloadAndInstallLatestApps(
toInstall, context) toInstall, context)
.catchError((e) { .catchError((e) {
ScaffoldMessenger.of(context).showSnackBar( showError(e, context);
SnackBar(content: Text(e.toString())),
);
}); });
}); });
} }

View File

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

View File

@ -21,26 +21,9 @@ class _SettingsPageState extends State<SettingsPage> {
if (settingsProvider.prefs == null) { if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings(); settingsProvider.initializeSettings();
} }
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, var themeDropdown = DropdownButtonFormField(
body: CustomScrollView(slivers: <Widget>[ decoration: const InputDecoration(labelText: 'Theme'),
const CustomAppBar(title: 'Settings'),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: settingsProvider.prefs == null
? const SizedBox()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Appearance',
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
DropdownButtonFormField(
decoration:
const InputDecoration(labelText: 'Theme'),
value: settingsProvider.theme, value: settingsProvider.theme,
items: const [ items: const [
DropdownMenuItem( DropdownMenuItem(
@ -60,13 +43,10 @@ class _SettingsPageState extends State<SettingsPage> {
if (value != null) { if (value != null) {
settingsProvider.theme = value; settingsProvider.theme = value;
} }
}), });
const SizedBox(
height: 16, var colourDropdown = DropdownButtonFormField(
), decoration: const InputDecoration(labelText: 'Colour'),
DropdownButtonFormField(
decoration:
const InputDecoration(labelText: 'Colour'),
value: settingsProvider.colour, value: settingsProvider.colour,
items: const [ items: const [
DropdownMenuItem( DropdownMenuItem(
@ -82,28 +62,18 @@ class _SettingsPageState extends State<SettingsPage> {
if (value != null) { if (value != null) {
settingsProvider.colour = value; settingsProvider.colour = value;
} }
}), });
const SizedBox(
height: 16, var sortDropdown = DropdownButtonFormField(
), decoration: const InputDecoration(labelText: 'App Sort By'),
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'App Sort By'),
value: settingsProvider.sortColumn, value: settingsProvider.sortColumn,
items: const [ items: const [
DropdownMenuItem( DropdownMenuItem(
value: value: SortColumnSettings.authorName,
SortColumnSettings.authorName,
child: Text('Author/Name'), child: Text('Author/Name'),
), ),
DropdownMenuItem( DropdownMenuItem(
value: value: SortColumnSettings.nameAuthor,
SortColumnSettings.nameAuthor,
child: Text('Name/Author'), child: Text('Name/Author'),
), ),
DropdownMenuItem( DropdownMenuItem(
@ -115,14 +85,10 @@ class _SettingsPageState extends State<SettingsPage> {
if (value != null) { if (value != null) {
settingsProvider.sortColumn = value; settingsProvider.sortColumn = value;
} }
})), });
const SizedBox(
width: 16, var orderDropdown = DropdownButtonFormField(
), decoration: const InputDecoration(labelText: 'App Sort Order'),
Expanded(
child: DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'App Sort Order'),
value: settingsProvider.sortOrder, value: settingsProvider.sortOrder,
items: const [ items: const [
DropdownMenuItem( DropdownMenuItem(
@ -138,38 +104,11 @@ class _SettingsPageState extends State<SettingsPage> {
if (value != null) { if (value != null) {
settingsProvider.sortOrder = value; settingsProvider.sortOrder = value;
} }
})), });
],
), var intervalDropdown = DropdownButtonFormField(
const SizedBox(
height: 16,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Show Source Webpage in App View'),
Switch(
value: settingsProvider.showAppWebpage,
onChanged: (value) {
settingsProvider.showAppWebpage = value;
})
],
),
const Divider(
height: 16,
),
const SizedBox(
height: 16,
),
Text(
'Updates',
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
DropdownButtonFormField(
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: labelText: 'Background Update Checking Interval'),
'Background Update Checking Interval'),
value: settingsProvider.updateInterval, value: settingsProvider.updateInterval,
items: updateIntervals.map((e) { items: updateIntervals.map((e) {
int displayNum = (e < 60 int displayNum = (e < 60
@ -187,25 +126,104 @@ class _SettingsPageState extends State<SettingsPage> {
String display = e == 0 String display = e == 0
? 'Never - Manual Only' ? 'Never - Manual Only'
: '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}'; : '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
return DropdownMenuItem( return DropdownMenuItem(value: e, child: Text(display));
value: e, child: Text(display));
}).toList(), }).toList(),
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
settingsProvider.updateInterval = value; settingsProvider.updateInterval = value;
} }
}), });
const SizedBox(
height: 8, 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>[
const CustomAppBar(title: 'Settings'),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: settingsProvider.prefs == null
? const SizedBox()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text( Text(
'Longer intervals recommended for large App collections', 'Appearance',
style: Theme.of(context) style: TextStyle(
.textTheme color: Theme.of(context).colorScheme.primary),
.labelMedium!
.merge(const TextStyle(
fontStyle: FontStyle.italic)),
), ),
themeDropdown,
height16,
colourDropdown,
height16,
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: sortDropdown),
const SizedBox(
width: 16,
),
Expanded(child: orderDropdown),
],
),
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Show Source Webpage in App View'),
Switch(
value: settingsProvider.showAppWebpage,
onChanged: (value) {
settingsProvider.showAppWebpage = value;
})
],
),
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Pin Updates to Top of Apps View'),
Switch(
value: settingsProvider.pinUpdates,
onChanged: (value) {
settingsProvider.pinUpdates = value;
})
],
),
const Divider(
height: 16,
),
height16,
Text(
'Updates',
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
intervalDropdown,
const Divider( const Divider(
height: 48, height: 48,
), ),
@ -214,42 +232,13 @@ class _SettingsPageState extends State<SettingsPage> {
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.primary), color: Theme.of(context).colorScheme.primary),
), ),
...sourceProvider.sources.map((e) { ...sourceSpecificFields,
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();
}
}),
], ],
))), ))),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column( child: Column(
children: [ children: [
const SizedBox( height16,
height: 16,
),
TextButton.icon( TextButton.icon(
style: ButtonStyle( style: ButtonStyle(
foregroundColor: MaterialStateProperty.resolveWith<Color>( foregroundColor: MaterialStateProperty.resolveWith<Color>(
@ -267,9 +256,7 @@ class _SettingsPageState extends State<SettingsPage> {
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
), ),
const SizedBox( height16,
height: 16,
),
], ],
), ),
) )

View File

@ -8,6 +8,7 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:install_plugin_v2/install_plugin_v2.dart'; import 'package:install_plugin_v2/install_plugin_v2.dart';
import 'package:installed_apps/app_info.dart'; import 'package:installed_apps/app_info.dart';
import 'package:installed_apps/installed_apps.dart'; import 'package:installed_apps/installed_apps.dart';
@ -24,15 +25,15 @@ import 'package:http/http.dart';
class AppInMemory { class AppInMemory {
late App app; late App app;
double? downloadProgress; double? downloadProgress;
AppInfo? installedInfo; // Also indicates that an App is installed AppInfo? installedInfo;
AppInMemory(this.app, this.downloadProgress, this.installedInfo); AppInMemory(this.app, this.downloadProgress, this.installedInfo);
} }
class DownloadedApp { class DownloadedApk {
String appId; String appId;
File file; File file;
DownloadedApp(this.appId, this.file); DownloadedApk(this.appId, this.file);
} }
class AppsProvider with ChangeNotifier { class AppsProvider with ChangeNotifier {
@ -40,54 +41,49 @@ class AppsProvider with ChangeNotifier {
Map<String, AppInMemory> apps = {}; Map<String, AppInMemory> apps = {};
bool loadingApps = false; bool loadingApps = false;
bool gettingUpdates = false; bool gettingUpdates = false;
bool forBGTask = false;
// Variables to keep track of the app foreground status (installs can't run in the background) // Variables to keep track of the app foreground status (installs can't run in the background)
bool isForeground = true; bool isForeground = true;
late Stream<FGBGType>? foregroundStream; late Stream<FGBGType>? foregroundStream;
late StreamSubscription<FGBGType>? foregroundSubscription; late StreamSubscription<FGBGType>? foregroundSubscription;
AppsProvider( AppsProvider({this.forBGTask = false}) {
{bool shouldLoadApps = false, // Many setup tasks should only be done in the foreground isolate
bool shouldCheckUpdatesAfterLoad = false, if (!forBGTask) {
bool shouldDeleteAPKs = false}) {
if (shouldLoadApps) {
// Subscribe to changes in the app foreground status // Subscribe to changes in the app foreground status
foregroundStream = FGBGEvents.stream.asBroadcastStream(); foregroundStream = FGBGEvents.stream.asBroadcastStream();
foregroundSubscription = foregroundStream?.listen((event) async { foregroundSubscription = foregroundStream?.listen((event) async {
isForeground = event == FGBGType.foreground; isForeground = event == FGBGType.foreground;
if (isForeground) await loadApps(); if (isForeground) await loadApps();
}); });
loadApps().then((_) { () async {
if (shouldDeleteAPKs) { // Load Apps into memory (in background, this is done later instead of in the constructor)
deleteSavedAPKs(); await loadApps();
} // Delete existing APKs
if (shouldCheckUpdatesAfterLoad) { (await getExternalStorageDirectory())
checkUpdates(); ?.listSync()
} .where((element) => element.path.endsWith('.apk'))
.forEach((apk) {
apk.delete();
}); });
}();
} }
} }
downloadApk(String apkUrl, String fileName, Function? onProgress, downloadFile(String url, String fileName, Function? onProgress) async {
Function? urlModifier,
{bool useExistingIfExists = true}) async {
var destDir = (await getExternalStorageDirectory())!.path; var destDir = (await getExternalStorageDirectory())!.path;
if (urlModifier != null) {
apkUrl = await urlModifier(apkUrl);
}
StreamedResponse response = StreamedResponse response =
await Client().send(Request('GET', Uri.parse(apkUrl))); await Client().send(Request('GET', Uri.parse(url)));
File downloadFile = File('$destDir/$fileName.apk'); File downloadedFile = File('$destDir/$fileName');
var alreadyExists = downloadFile.existsSync();
if (!alreadyExists || !useExistingIfExists) {
if (alreadyExists) {
downloadFile.deleteSync();
}
if (downloadedFile.existsSync()) {
downloadedFile.deleteSync();
}
var length = response.contentLength; var length = response.contentLength;
var received = 0; var received = 0;
double? progress; double? progress;
var sink = downloadFile.openWrite(); var sink = downloadedFile.openWrite();
await response.stream.map((s) { await response.stream.map((s) {
received += s.length; received += s.length;
@ -105,48 +101,52 @@ class AppsProvider with ChangeNotifier {
} }
if (response.statusCode != 200) { if (response.statusCode != 200) {
downloadFile.deleteSync(); downloadedFile.deleteSync();
throw response.reasonPhrase ?? 'Unknown Error'; throw response.reasonPhrase ?? 'Unknown Error';
} }
} return downloadedFile;
return downloadFile;
} }
// Downloads the App (preferred URL) and returns an ApkFile object Future<DownloadedApk> downloadApp(App app) async {
// If the app was already saved, updates it's download progress % in memory var fileName =
// But also works for Apps that are not saved '${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
Future<DownloadedApp> downloadApp(App app) async { String downloadUrl = await SourceProvider()
var fileName = '${app.id}-${app.latestVersion}-${app.preferredApkIndex}'; .getSource(app.url)
File downloadFile = await downloadApk(app.apkUrls[app.preferredApkIndex], .apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}', int? prevProg;
(double? progress) { File downloadedFile =
await downloadFile(downloadUrl, fileName, (double? progress) {
int? prog = progress?.ceil();
if (apps[app.id] != null) { if (apps[app.id] != null) {
apps[app.id]!.downloadProgress = progress; apps[app.id]!.downloadProgress = progress;
}
notifyListeners(); notifyListeners();
}, SourceProvider().getSource(app.url).apkUrlPrefetchModifier); } else if ((prog == 25 || prog == 50 || prog == 75) && prevProg != prog) {
Fluttertoast.showToast(
msg: 'Progress: $prog%', toastLength: Toast.LENGTH_SHORT);
}
prevProg = prog;
});
// Delete older versions of the APK if any // 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; var fn = file.path.split('/').last;
if (fn.startsWith('${app.id}-') && if (fn.startsWith('${app.id}-') &&
fn.endsWith('.apk') && fn.endsWith('.apk') &&
fn != '$fileName.apk') { fn != fileName) {
file.delete(); file.delete();
} }
} }
// If the ID has changed (as it should on first download), replace it // If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
var newInfo = await PackageArchiveInfo.fromPath(downloadFile.path); // The former case should be handled (give the App its real ID), the latter is a security issue
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
if (app.id != newInfo.packageName) { if (app.id != newInfo.packageName) {
var originalAppId = app.id; if (apps[app.id] != null) {
throw IDChangedError();
}
app.id = newInfo.packageName; app.id = newInfo.packageName;
downloadFile = downloadFile.renameSync( downloadedFile = downloadedFile.renameSync(
'${downloadFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk'); '${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
if (apps[originalAppId] != null) {
await removeApps([originalAppId]);
await saveApps([app]);
} }
} return DownloadedApk(app.id, downloadedFile);
return DownloadedApp(app.id, downloadFile);
} }
bool areDownloadsRunning() => apps.values bool areDownloadsRunning() => apps.values
@ -154,32 +154,34 @@ class AppsProvider with ChangeNotifier {
.isNotEmpty; .isNotEmpty;
Future<bool> canInstallSilently(App app) async { Future<bool> canInstallSilently(App app) async {
// TODO: This is unreliable - try to get from OS in the future return false;
var osInfo = await DeviceInfoPlugin().androidInfo; // TODO: Uncomment the below once silentupdates are ever figured out
return app.installedVersion != null && // // TODO: This is unreliable - try to get from OS in the future
osInfo.version.sdkInt >= 30 && // if (app.apkUrls.length > 1) {
osInfo.version.release.compareTo('12') >= 0; // 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, Future<void> waitForUserToReturnToForeground(BuildContext context) async {
{bool waitForFG = false}) async {
NotificationsProvider notificationsProvider = NotificationsProvider notificationsProvider =
context.read<NotificationsProvider>(); context.read<NotificationsProvider>();
if (!isForeground) { if (!isForeground) {
await notificationsProvider.notify(completeInstallationNotification, await notificationsProvider.notify(completeInstallationNotification,
cancelExisting: true); cancelExisting: true);
if (waitForFG) { while (await FGBGEvents.stream.first != FGBGType.foreground) {}
await FGBGEvents.stream.first == FGBGType.foreground;
await notificationsProvider.cancel(completeInstallationNotification.id); await notificationsProvider.cancel(completeInstallationNotification.id);
} }
} }
}
// Unfortunately this 'await' does not actually wait for the APK to finish installing // Unfortunately this 'await' does not actually wait for the APK to finish installing
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing // 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 // 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 // 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); var newInfo = await PackageArchiveInfo.fromPath(file.file.path);
AppInfo? appInfo; AppInfo? appInfo;
try { try {
@ -189,7 +191,7 @@ class AppsProvider with ChangeNotifier {
} }
if (appInfo != null && if (appInfo != null &&
int.parse(newInfo.buildNumber) < appInfo.versionCode!) { int.parse(newInfo.buildNumber) < appInfo.versionCode!) {
throw 'Can\'t install an older version'; throw DowngradeError();
} }
if (appInfo == null || if (appInfo == null ||
int.parse(newInfo.buildNumber) > appInfo.versionCode!) { int.parse(newInfo.buildNumber) > appInfo.versionCode!) {
@ -198,17 +200,25 @@ class AppsProvider with ChangeNotifier {
apps[file.appId]!.app.installedVersion = apps[file.appId]!.app.installedVersion =
apps[file.appId]!.app.latestVersion; apps[file.appId]!.app.latestVersion;
// Don't correct install status as installation may not be done yet // 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) // If the App has more than one APK, the user should pick one (if context provided)
String? apkUrl = app.apkUrls[app.preferredApkIndex]; String? apkUrl = app.apkUrls[app.preferredApkIndex];
// get device supported architecture
List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
if (app.apkUrls.length > 1 && context != null) { if (app.apkUrls.length > 1 && context != null) {
apkUrl = await showDialog( apkUrl = await showDialog(
context: context, context: context,
builder: (BuildContext ctx) { 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) // If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
@ -228,15 +238,6 @@ class AppsProvider with ChangeNotifier {
return apkUrl; 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 // 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 the APKs can be installed silently, they are
// If no BuildContext is provided, apps that require user interaction are ignored // If no BuildContext is provided, apps that require user interaction are ignored
@ -245,42 +246,41 @@ class AppsProvider with ChangeNotifier {
Future<List<String>> downloadAndInstallLatestApps( Future<List<String>> downloadAndInstallLatestApps(
List<String> appIds, BuildContext? context) async { List<String> appIds, BuildContext? context) async {
List<String> appsToInstall = []; List<String> appsToInstall = [];
// 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) { for (var id in appIds) {
if (apps[id] == null) { if (apps[id] == null) {
throw 'App not found'; throw ObtainiumError('App not found');
} }
String? apkUrl = await confirmApkUrl(apps[id]!.app, context);
String? apkUrl = await selectApkUrl(apps[id]!.app, context);
if (apkUrl != null) { if (apkUrl != null) {
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
if (urlInd != apps[id]!.app.preferredApkIndex) { if (urlInd != apps[id]!.app.preferredApkIndex) {
apps[id]!.app.preferredApkIndex = urlInd; apps[id]!.app.preferredApkIndex = urlInd;
await saveApps([apps[id]!.app]); await saveApps([apps[id]!.app]);
} }
if (context != null || if (context != null || await canInstallSilently(apps[id]!.app)) {
(await canInstallSilently(apps[id]!.app) &&
apps[id]!.app.apkUrls.length == 1)) {
appsToInstall.add(id); appsToInstall.add(id);
} }
} }
} }
Map<String, List<String>> errors = {}; // Download APKs for all Apps to be installed
MultiAppMultiError errors = MultiAppMultiError();
List<DownloadedApp?> downloadedFiles = List<DownloadedApk?> downloadedFiles =
await Future.wait(appsToInstall.map((id) async { await Future.wait(appsToInstall.map((id) async {
try { try {
return await downloadApp(apps[id]!.app); return await downloadApp(apps[id]!.app);
} catch (e) { } catch (e) {
addToErrorMap(errors, id, e.toString()); errors.add(id, e.toString());
} }
return null; return null;
})); }));
downloadedFiles = downloadedFiles =
downloadedFiles.where((element) => element != null).toList(); downloadedFiles.where((element) => element != null).toList();
// Separate the Apps to install into silent and regular lists
List<DownloadedApp> silentUpdates = []; List<DownloadedApk> silentUpdates = [];
List<DownloadedApp> regularInstalls = []; List<DownloadedApk> regularInstalls = [];
for (var f in downloadedFiles) { for (var f in downloadedFiles) {
bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app); bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app);
if (willBeSilent) { if (willBeSilent) {
@ -290,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 // If Obtainium is being installed, it should be the last one
List<DownloadedApp> moveObtainiumToEnd(List<DownloadedApp> items) { List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) {
String obtainiumId = 'imranr98_obtainium_${GitHub().host}'; String obtainiumId = 'imranr98_obtainium_${GitHub().host}';
DownloadedApp? temp; DownloadedApk? temp;
items.removeWhere((element) { items.removeWhere((element) {
bool res = element.appId == obtainiumId; bool res = element.appId == obtainiumId;
if (res) { if (res) {
@ -302,42 +305,34 @@ class AppsProvider with ChangeNotifier {
return res; return res;
}); });
if (temp != null) { if (temp != null) {
items.add(temp!); items = [temp!, ...items];
} }
return items; return items;
} }
// TODO: Remove below line if silentupdates are ever figured out silentUpdates = moveObtainiumToStart(silentUpdates);
regularInstalls.addAll(silentUpdates); regularInstalls = moveObtainiumToStart(regularInstalls);
silentUpdates = moveObtainiumToEnd(silentUpdates); // // Install silent updates (uncomment when it works - TODO)
regularInstalls = moveObtainiumToEnd(regularInstalls);
// TODO: Uncomment below if silentupdates are ever figured out
// for (var u in silentUpdates) { // for (var u in silentUpdates) {
// await installApk(u, silent: true); // Would need to add silent option // await installApk(u, silent: true); // Would need to add silent option
// } // }
if (context != null) { // Do regular installs
if (regularInstalls.isNotEmpty) { if (regularInstalls.isNotEmpty && context != null) {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
await askUserToReturnToForeground(context, waitForFG: true); await waitForUserToReturnToForeground(context);
}
for (var i in regularInstalls) { for (var i in regularInstalls) {
try { try {
await installApk(i); await installApk(i);
} catch (e) { } catch (e) {
addToErrorMap(errors, i.appId, e.toString()); errors.add(i.appId, e.toString());
} }
} }
} }
if (errors.isNotEmpty) {
String finalError = ''; if (errors.content.isNotEmpty) {
for (var e in errors.keys) { throw errors;
finalError +=
'$e ${errors[e]!.map((e) => apps[e]!.app.name).toString()}. ';
}
throw finalError;
} }
return downloadedFiles.map((e) => e!.appId).toList(); return downloadedFiles.map((e) => e!.appId).toList();
@ -352,40 +347,6 @@ class AppsProvider with ChangeNotifier {
return appsDir; 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 { Future<AppInfo?> getInstalledInfo(String? packageName) async {
if (packageName != null) { if (packageName != null) {
try { try {
@ -397,24 +358,37 @@ class AppsProvider with ChangeNotifier {
return null; return null;
} }
String standardizeVersionString(String versionString) { // If the App says it is installed but installedInfo is null, set it to not installed
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 is is not installed but installedInfo exists, try to set it to installed as latest version... // 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 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; var modded = false;
if (installedInfo == null && app.installedVersion != null) { if (installedInfo == null && app.installedVersion != null) {
app.installedVersion = null; app.installedVersion = null;
modded = true; modded = true;
} }
if (installedInfo != null && app.installedVersion == null) { 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) { installedInfo.versionName) {
app.installedVersion = app.latestVersion; app.installedVersion = app.latestVersion;
} else { } else {
@ -425,7 +399,7 @@ class AppsProvider with ChangeNotifier {
return modded ? app : null; return modded ? app : null;
} }
Future<void> loadApps({shouldCorrectInstallStatus = true}) async { Future<void> loadApps() async {
while (loadingApps) { while (loadingApps) {
await Future.delayed(const Duration(microseconds: 1)); await Future.delayed(const Duration(microseconds: 1));
} }
@ -456,28 +430,26 @@ class AppsProvider with ChangeNotifier {
} }
loadingApps = false; loadingApps = false;
notifyListeners(); notifyListeners();
// For any that are not installed (by ID == package name), set to not installed if needed
if (shouldCorrectInstallStatus) {
List<App> modifiedApps = []; List<App> modifiedApps = [];
for (var app in apps.values) { for (var app in apps.values) {
var moddedApp = correctInstallStatus(app.app, app.installedInfo); var moddedApp =
getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo);
if (moddedApp != null) { if (moddedApp != null) {
modifiedApps.add(moddedApp); modifiedApps.add(moddedApp);
} }
} }
if (modifiedApps.isNotEmpty) { if (modifiedApps.isNotEmpty) {
await saveApps(modifiedApps, shouldCorrectInstallStatus: false); await saveApps(modifiedApps);
}
} }
} }
Future<void> saveApps(List<App> apps, Future<void> saveApps(List<App> apps,
{bool shouldCorrectInstallStatus = true}) async { {bool attemptToCorrectInstallStatus = true}) async {
for (var app in apps) { for (var app in apps) {
AppInfo? info = await getInstalledInfo(app.id); AppInfo? info = await getInstalledInfo(app.id);
app.name = info?.name ?? app.name; app.name = info?.name ?? app.name;
if (shouldCorrectInstallStatus) { if (attemptToCorrectInstallStatus) {
app = correctInstallStatus(app, info) ?? app; app = getCorrectedInstallStatusAppIfPossible(app, info) ?? app;
} }
File('${(await getAppsDir()).path}/${app.id}.json') File('${(await getAppsDir()).path}/${app.id}.json')
.writeAsStringSync(jsonEncode(app.toJson())); .writeAsStringSync(jsonEncode(app.toJson()));
@ -503,15 +475,7 @@ class AppsProvider with ChangeNotifier {
} }
} }
bool checkAppObjectForUpdate(App app) { Future<App?> checkUpdate(String appId) async {
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 {
App? currentApp = apps[appId]!.app; App? currentApp = apps[appId]!.app;
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
App newApp = await sourceProvider.getApp( App newApp = await sourceProvider.getApp(
@ -524,51 +488,39 @@ class AppsProvider with ChangeNotifier {
if (currentApp.preferredApkIndex < newApp.apkUrls.length) { if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex; newApp.preferredApkIndex = currentApp.preferredApkIndex;
} }
await saveApps([newApp], await saveApps([newApp]);
shouldCorrectInstallStatus: shouldCorrectInstallStatus);
return newApp.latestVersion != currentApp.latestVersion ? newApp : null; return newApp.latestVersion != currentApp.latestVersion ? newApp : null;
} }
Future<List<App>> checkUpdates( Future<List<App>> checkUpdates(
{DateTime? ignoreAfter, {DateTime? ignoreAppsCheckedAfter,
bool immediatelyThrowRateLimitError = false, bool throwErrorsForRetry = false}) async {
bool shouldCorrectInstallStatus = true,
bool immediatelyThrowSocketError = false}) async {
List<App> updates = []; List<App> updates = [];
Map<String, List<String>> errors = {}; MultiAppMultiError errors = MultiAppMultiError();
if (!gettingUpdates) { if (!gettingUpdates) {
gettingUpdates = true; gettingUpdates = true;
try { try {
List<String> appIds = apps.keys.toList(); List<String> appIds = apps.values
if (ignoreAfter != null) { .where((app) =>
appIds = appIds app.app.lastUpdateCheck == null ||
.where((id) => ignoreAppsCheckedAfter == null ||
apps[id]!.app.lastUpdateCheck == null || app.app.lastUpdateCheck!.isBefore(ignoreAppsCheckedAfter))
apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter)) .map((e) => e.app.id)
.toList(); .toList();
}
appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ?? appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ??
DateTime.fromMicrosecondsSinceEpoch(0)) DateTime.fromMicrosecondsSinceEpoch(0))
.compareTo(apps[b]!.app.lastUpdateCheck ?? .compareTo(apps[b]!.app.lastUpdateCheck ??
DateTime.fromMicrosecondsSinceEpoch(0))); DateTime.fromMicrosecondsSinceEpoch(0)));
for (int i = 0; i < appIds.length; i++) { for (int i = 0; i < appIds.length; i++) {
App? newApp; App? newApp;
try { try {
newApp = await getUpdate(appIds[i], newApp = await checkUpdate(appIds[i]);
shouldCorrectInstallStatus: shouldCorrectInstallStatus);
} catch (e) { } catch (e) {
if (e is RateLimitError && immediatelyThrowRateLimitError) { if ((e is RateLimitError || e is SocketException) &&
throwErrorsForRetry) {
rethrow; rethrow;
} }
if (e is SocketException && immediatelyThrowSocketError) { errors.add(appIds[i], e.toString());
rethrow;
}
var tempIds = errors.remove(e.toString());
tempIds ??= [];
tempIds.add(appIds[i]);
errors.putIfAbsent(e.toString(), () => tempIds!);
} }
if (newApp != null) { if (newApp != null) {
updates.add(newApp); updates.add(newApp);
@ -578,18 +530,13 @@ class AppsProvider with ChangeNotifier {
gettingUpdates = false; gettingUpdates = false;
} }
} }
if (errors.isNotEmpty) { if (errors.content.isNotEmpty) {
String finalError = ''; throw errors;
for (var e in errors.keys) {
finalError +=
'$e ${errors[e]!.map((e) => apps[e]!.app.name).toString()}. ';
}
throw finalError;
} }
return updates; return updates;
} }
List<String> getExistingUpdates( List<String> findExistingUpdates(
{bool installedOnly = false, bool nonInstalledOnly = false}) { {bool installedOnly = false, bool nonInstalledOnly = false}) {
List<String> updateAppIds = []; List<String> updateAppIds = [];
List<String> appIds = apps.keys.toList(); List<String> appIds = apps.keys.toList();
@ -623,7 +570,6 @@ class AppsProvider with ChangeNotifier {
} }
Future<int> importApps(String appsJSON) async { 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>) List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
.map((e) => App.fromJson(e)) .map((e) => App.fromJson(e))
.toList(); .toList();
@ -648,10 +594,11 @@ class AppsProvider with ChangeNotifier {
} }
class APKPicker extends StatefulWidget { 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 App app;
final String? initVal; final String? initVal;
final List<String>? archs;
@override @override
State<APKPicker> createState() => _APKPickerState(); State<APKPicker> createState() => _APKPickerState();
@ -669,7 +616,8 @@ class _APKPickerState extends State<APKPicker> {
content: Column(children: [ content: Column(children: [
Text('${widget.app.name} has more than one package:'), Text('${widget.app.name} has more than one package:'),
const SizedBox(height: 16), const SizedBox(height: 16),
...widget.app.apkUrls.map((u) => RadioListTile<String>( ...widget.app.apkUrls.map(
(u) => RadioListTile<String>(
title: Text(Uri.parse(u) title: Text(Uri.parse(u)
.pathSegments .pathSegments
.where((element) => element.isNotEmpty) .where((element) => element.isNotEmpty)
@ -680,7 +628,17 @@ class _APKPickerState extends State<APKPicker> {
setState(() { setState(() {
apkUrl = val; 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: [ actions: [
TextButton( TextButton(

View File

@ -55,7 +55,7 @@ class SettingsProvider with ChangeNotifier {
} }
int get updateInterval { int get updateInterval {
var min = prefs?.getInt('updateInterval') ?? 180; var min = prefs?.getInt('updateInterval') ?? 360;
if (!updateIntervals.contains(min)) { if (!updateIntervals.contains(min)) {
var temp = updateIntervals[0]; var temp = updateIntervals[0];
for (var i in updateIntervals) { for (var i in updateIntervals) {
@ -123,6 +123,15 @@ class SettingsProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
bool get pinUpdates {
return prefs?.getBool('pinUpdates') ?? true;
}
set pinUpdates(bool show) {
prefs?.setBool('pinUpdates', show);
notifyListeners();
}
String? getSettingString(String settingId) { String? getSettingString(String settingId) {
return prefs?.getString(settingId); return prefs?.getString(settingId);
} }

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

View File

@ -1,6 +1,13 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: 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: animations:
dependency: "direct main" dependency: "direct main"
description: description:
@ -358,7 +365,7 @@ packages:
name: path_provider_android name: path_provider_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.20" version: "2.0.21"
path_provider_ios: path_provider_ios:
dependency: transitive dependency: transitive
description: description:
@ -470,7 +477,7 @@ packages:
name: share_plus name: share_plus
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.0" version: "6.2.0"
share_plus_platform_interface: share_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -700,13 +707,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.1" version: "3.0.1"
workmanager:
dependency: "direct main"
description:
name: workmanager
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.1"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: 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 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.6.2+46 # When changing this, update the tag in main() accordingly version: 0.6.8+52 # When changing this, update the tag in main() accordingly
environment: environment:
sdk: '>=2.18.2 <3.0.0' sdk: '>=2.18.2 <3.0.0'
@ -42,7 +42,6 @@ dependencies:
provider: ^6.0.3 provider: ^6.0.3
http: ^0.13.5 http: ^0.13.5
webview_flutter: ^3.0.4 webview_flutter: ^3.0.4
workmanager: ^0.5.0
dynamic_color: ^1.5.4 dynamic_color: ^1.5.4
html: ^0.15.0 html: ^0.15.0
shared_preferences: ^2.0.15 shared_preferences: ^2.0.15
@ -56,6 +55,7 @@ dependencies:
share_plus: ^6.0.1 share_plus: ^6.0.1
installed_apps: ^1.3.1 installed_apps: ^1.3.1
package_archive_info: ^0.1.0 package_archive_info: ^0.1.0
android_alarm_manager_plus: ^2.1.0
dev_dependencies: dev_dependencies: