Compare commits

...

26 Commits

Author SHA1 Message Date
07f6d4ad2c Fixed custom App name issue 2022-10-08 12:26:08 -04:00
dfbb4e19a5 Added more Mass App Actions 2022-10-07 21:15:19 -04:00
f5fda2ca90 Updated some plugins 2022-10-07 19:23:25 -04:00
661dc1626c Increment version 2022-10-07 19:08:24 -04:00
dde3fc20fb Back to old install plugin (dealbreaker in new one) 2022-10-07 19:06:02 -04:00
017b867d8d Added APKMirror (Phew!) 2022-10-07 17:24:45 -04:00
1cb1c124eb UI Tweak 2022-10-07 13:02:25 -04:00
fdeb852c7b More changelog urls added 2022-10-07 12:58:10 -04:00
67f50ba776 Added 'See Changes' button in app list (GitHub only) 2022-10-07 12:51:53 -04:00
a0968caa5c Tweaked update checking, fixed an issue on App page 2022-10-07 12:22:16 -04:00
e3e945d13b Bugfix - Obtainium doesn't update with other Apps 2022-10-01 00:29:15 -04:00
61f7f171b1 Upgraded a package 2022-09-30 23:23:23 -04:00
de07583161 Fixed issue with backgorund task not starting 2022-09-30 23:21:35 -04:00
49b9a65053 Updated version 2022-09-30 15:37:32 -04:00
aebc8aed76 Clearer GitHub PAT instructions 2022-09-30 15:33:24 -04:00
3958425c22 Removed outdated comment 2022-09-29 23:28:49 -04:00
0a560871cb Fixed update checking on App page 2022-09-29 23:20:57 -04:00
fbe4f0b49e Added GitHub PAT support 2022-09-29 21:27:54 -04:00
e2440a38c4 App name now editable on App page 2022-09-29 16:45:24 -04:00
496a10a444 Added pull-to-refresh on App page when no webpage shown 2022-09-29 16:35:16 -04:00
b8bb8d1f4b Bugfix for F-Droid URL parsing 2022-09-29 10:15:57 -04:00
af033f42cb Updated modules 2022-09-28 22:43:24 -04:00
e706661062 Added URL selection menu for mass imports 2022-09-28 22:33:55 -04:00
1a68b8abe6 Improved GitHub starred import + other tweaks 2022-09-28 21:36:21 -04:00
15c0ed04d1 BG Updates *should* work now 2022-09-28 21:17:42 -04:00
dd193d62f2 Update checking improvements (#38)
Still no auto retry for rate-limit. Instead, rate-limit errors are ignored and the unchecked Apps have to wait until the next cycle. Even this needs more testing before release.
2022-09-27 23:20:39 -04:00
25 changed files with 1031 additions and 298 deletions

View File

@ -13,6 +13,7 @@ Currently supported App sources:
- [IzzyOnDroid](https://android.izzysoft.de/)
- [Mullvad](https://mullvad.net/en/)
- [Signal](https://signal.org/)
- [APKMirror](https://apkmirror.com/)
## Limitations
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.

View File

@ -30,16 +30,6 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

View File

@ -0,0 +1,112 @@
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

@ -9,14 +9,26 @@ class FDroid implements AppSource {
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
RegExp standardUrlRegExB =
RegExp('^https?://$host/+[^/]+/+packages/+[^/]+');
RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase());
if (match != null) {
url = 'https://$host/packages/${Uri.parse(url).pathSegments.last}';
}
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
match = standardUrlRegExA.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
@ -54,4 +66,7 @@ class FDroid implements AppSource {
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
}

View File

@ -1,7 +1,11 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
class GitHub implements AppSource {
@override
@ -17,6 +21,21 @@ class GitHub implements AppSource {
return url.substring(0, match.end);
}
Future<String> getCredentialPrefixIfAny() async {
SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings();
String? creds =
settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id);
return creds != null && creds.isNotEmpty ? '$creds@' : '';
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/releases';
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
@ -28,7 +47,7 @@ class GitHub implements AppSource {
? additionalData[2]
: null;
Response res = await get(Uri.parse(
'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
if (res.statusCode == 200) {
var releases = jsonDecode(res.body) as List<dynamic>;
@ -76,7 +95,10 @@ class GitHub implements AppSource {
return APKDetails(version, targetRelease['apkUrls']);
} else {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
throw RateLimitError(
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
}
throw couldNotFindReleases;
@ -120,4 +142,43 @@ class GitHub implements AppSource {
@override
List<String> additionalDataDefaults = ['true', 'true', ''];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [
GeneratedFormItem(
label: 'GitHub Personal Access Token (Increases Rate Limit)',
id: 'github-creds',
required: false,
additionalValidators: [
(value) {
if (value != null && value.trim().isNotEmpty) {
if (value
.split(':')
.where((element) => element.trim().isNotEmpty)
.length !=
2) {
return 'PAT must be in this format: username:token';
}
}
return null;
}
],
hint: 'username:token',
belowWidgets: [
const SizedBox(
height: 8,
),
GestureDetector(
onTap: () {
launchUrlString(
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
mode: LaunchMode.externalApplication);
},
child: const Text(
'About GitHub PATs',
style: TextStyle(
decoration: TextDecoration.underline, fontSize: 12),
))
])
];
}

View File

@ -18,6 +18,13 @@ class GitLab implements AppSource {
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/-/releases';
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
@ -68,4 +75,7 @@ class GitLab implements AppSource {
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
}

View File

@ -17,6 +17,12 @@ class IzzyOnDroid implements AppSource {
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
@ -62,4 +68,7 @@ class IzzyOnDroid implements AppSource {
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
}

View File

@ -17,6 +17,13 @@ class Mullvad implements AppSource {
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md';
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
@ -48,4 +55,7 @@ class Mullvad implements AppSource {
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
}

View File

@ -12,6 +12,12 @@ class Signal implements AppSource {
return 'https://$host';
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
@ -41,4 +47,7 @@ class Signal implements AppSource {
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
}

View File

@ -17,6 +17,12 @@ class SourceForge implements AppSource {
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
@ -65,4 +71,7 @@ class SourceForge implements AppSource {
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
}

View File

@ -10,13 +10,19 @@ class GeneratedFormItem {
late bool required;
late int max;
late List<String? Function(String? value)> additionalValidators;
late String id;
late List<Widget> belowWidgets;
late String? hint;
GeneratedFormItem(
{this.label = 'Input',
this.type = FormItemType.string,
this.required = true,
this.max = 1,
this.additionalValidators = const []});
this.additionalValidators = const [],
this.id = 'input',
this.belowWidgets = const [],
this.hint});
}
class GeneratedForm extends StatefulWidget {
@ -89,7 +95,8 @@ class _GeneratedFormState extends State<GeneratedForm> {
});
},
decoration: InputDecoration(
helperText: e.value.label + (e.value.required ? ' *' : '')),
helperText: e.value.label + (e.value.required ? ' *' : ''),
hintText: e.value.hint),
minLines: e.value.max <= 1 ? null : e.value.max,
maxLines: e.value.max <= 1 ? 1 : e.value.max,
validator: (value) {
@ -155,7 +162,13 @@ class _GeneratedFormState extends State<GeneratedForm> {
width: 20,
));
}
rowItems.add(Expanded(child: rowInput.value));
rowItems.add(Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
rowInput.value,
...widget.items[rowInputs.key][rowInput.key].belowWidgets
])));
});
rows.add(rowItems);
});

8
lib/custom_errors.dart Normal file
View File

@ -0,0 +1,8 @@
class RateLimitError {
late int remainingMinutes;
RateLimitError(this.remainingMinutes);
@override
String toString() =>
'Too many requests (rate limited) - try again in $remainingMinutes minutes';
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/pages/home.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/notifications_provider.dart';
@ -13,47 +14,76 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.dart';
const String currentReleaseTag =
'v0.4.1-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
'v0.5.7-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
const String bgUpdateCheckTaskName = 'bg-update-check';
bgUpdateCheck(int? ignoreAfterMicroseconds) async {
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
: null;
var notificationsProvider = NotificationsProvider();
await notificationsProvider.notify(checkingUpdatesNotification);
try {
var appsProvider = AppsProvider();
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps();
List<String> existingUpdateIds =
appsProvider.getExistingUpdates(installedOnly: true);
DateTime nextIgnoreAfter = DateTime.now();
try {
await appsProvider.checkUpdates(ignoreAfter: ignoreAfter);
} catch (e) {
if (e is RateLimitError) {
String nextTaskName =
'$bgUpdateCheckTaskName-${nextIgnoreAfter.microsecondsSinceEpoch.toString()}';
Workmanager().registerOneOffTask(nextTaskName, nextTaskName,
constraints: Constraints(networkType: NetworkType.connected),
initialDelay: Duration(minutes: e.remainingMinutes),
inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch});
} else {
rethrow;
}
}
List<App> newUpdates = appsProvider
.getExistingUpdates(installedOnly: true)
.where((id) => !existingUpdateIds.contains(id))
.map((e) => appsProvider.apps[e]!.app)
.toList();
// TODO: This silent update code doesn't work yet
// List<String> silentlyUpdated = await appsProvider
// .downloadAndInstallLatestApp(
// [...newUpdates.map((e) => e.id), ...existingUpdateIds], null);
// if (silentlyUpdated.isNotEmpty) {
// newUpdates = newUpdates
// .where((element) => !silentlyUpdated.contains(element.id))
// .toList();
// notificationsProvider.notify(
// SilentUpdateNotification(
// silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()),
// cancelExisting: true);
// }
if (newUpdates.isNotEmpty) {
notificationsProvider.notify(UpdateNotification(newUpdates),
cancelExisting: true);
}
return Future.value(true);
} catch (e) {
notificationsProvider.notify(ErrorCheckingUpdatesNotification(e.toString()),
cancelExisting: true);
return Future.error(false);
} finally {
await notificationsProvider.cancel(checkingUpdatesNotification.id);
}
}
@pragma('vm:entry-point')
void bgTaskCallback() {
// Background update checking process
Workmanager().executeTask((task, taskName) async {
var notificationsProvider = NotificationsProvider();
await notificationsProvider.notify(checkingUpdatesNotification);
try {
var appsProvider = AppsProvider();
await notificationsProvider
.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps();
// List<String> existingUpdateIds = // TODO: Uncomment this and below when it works
// appsProvider.getExistingUpdates(installedOnly: true);
List<App> newUpdates = await appsProvider.checkUpdates();
// List<String> silentlyUpdated = await appsProvider
// .downloadAndInstallLatestApp(
// [...newUpdates.map((e) => e.id), ...existingUpdateIds], null);
// if (silentlyUpdated.isNotEmpty) {
// newUpdates
// .where((element) => !silentlyUpdated.contains(element.id))
// .toList();
// notificationsProvider.notify(
// SilentUpdateNotification(
// silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()),
// cancelExisting: true);
// }
if (newUpdates.isNotEmpty) {
notificationsProvider.notify(UpdateNotification(newUpdates),
cancelExisting: true);
}
return Future.value(true);
} catch (e) {
notificationsProvider.notify(
ErrorCheckingUpdatesNotification(e.toString()),
cancelExisting: true);
return Future.value(false);
} finally {
await notificationsProvider.cancel(checkingUpdatesNotification.id);
}
// Background process callback
Workmanager().executeTask((task, inputData) async {
return await bgUpdateCheck(inputData?['ignoreAfter']);
});
}
@ -78,14 +108,21 @@ void main() async {
ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => NotificationsProvider())
],
child: const MyApp(),
child: const Obtainium(),
));
}
var defaultThemeColour = Colors.deepPurple;
class MyApp extends StatelessWidget {
const MyApp({super.key});
class Obtainium extends StatefulWidget {
const Obtainium({super.key});
@override
State<Obtainium> createState() => _ObtainiumState();
}
class _ObtainiumState extends State<Obtainium> {
var existingUpdateInterval = -1;
@override
Widget build(BuildContext context) {
@ -95,16 +132,6 @@ class MyApp extends StatelessWidget {
if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings();
} else {
// Register the background update task according to the user's setting
if (settingsProvider.updateInterval > 0) {
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
frequency: Duration(minutes: settingsProvider.updateInterval),
initialDelay: Duration(minutes: settingsProvider.updateInterval),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.replace);
} else {
Workmanager().cancelByUniqueName('bg-update-check');
}
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
if (isFirstRun) {
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
@ -119,9 +146,27 @@ class MyApp extends StatelessWidget {
currentReleaseTag,
[],
0,
['true'])
['true'],
null)
]);
}
// Register the background update task according to the user's setting
if (existingUpdateInterval != settingsProvider.updateInterval) {
existingUpdateInterval = settingsProvider.updateInterval;
if (existingUpdateInterval == 0) {
Workmanager().cancelByUniqueName(bgUpdateCheckTaskName);
} 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));
}
}
}
return DynamicColorBuilder(

View File

@ -1,6 +1,8 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class GitHubStars implements MassAppSource {
@ -10,23 +12,40 @@ class GitHubStars implements MassAppSource {
@override
late List<String> requiredArgs = ['Username'];
@override
Future<List<String>> getUrls(List<String> args) async {
if (args.length != requiredArgs.length) {
throw 'Wrong number of arguments provided';
}
Response res =
await get(Uri.parse('https://api.github.com/users/${args[0]}/starred'));
Future<List<String>> getOnePageOfUserStarredUrls(
String username, int page) async {
Response res = await get(Uri.parse(
'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page'));
if (res.statusCode == 200) {
return (jsonDecode(res.body) as List<dynamic>)
.map((e) => e['html_url'] as String)
.toList();
} else {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
throw RateLimitError(
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
}
throw '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';
}
List<String> urls = [];
var page = 1;
while (true) {
var pageUrls = await getOnePageOfUserStarredUrls(args[0], page++);
urls.addAll(pageUrls);
if (pageUrls.length < 100) {
break;
}
}
return urls;
}
}

View File

@ -52,7 +52,7 @@ class _AddAppPageState extends State<AddAppPage> {
sourceProvider
.getSource(value ?? '')
.standardizeURL(
makeUrlHttps(
preStandardizeUrl(
value ?? ''));
} catch (e) {
return e is String

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
@ -18,75 +19,102 @@ class AppPage extends StatefulWidget {
}
class _AppPageState extends State<AppPage> {
AppInMemory? prevApp;
@override
Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>();
var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId];
var source = app != null ? sourceProvider.getSource(app.app.url) : null;
if (!appsProvider.areDownloadsRunning() && app != null) {
appsProvider.getUpdate(app.app.id).catchError((e) {
getUpdate(String id) {
appsProvider.getUpdate(id).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
});
}
var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId];
var source = app != null ? sourceProvider.getSource(app.app.url) : null;
if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) {
prevApp = app;
getUpdate(app.app.id);
}
return Scaffold(
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
backgroundColor: Theme.of(context).colorScheme.surface,
body: settingsProvider.showAppWebpage
? WebView(
initialUrl: app?.app.url,
javascriptMode: JavascriptMode.unrestricted,
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
app?.app.name ?? 'App',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayLarge,
),
Text(
'By ${app?.app.author ?? 'Unknown'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(
height: 32,
),
GestureDetector(
onTap: () {
if (app?.app.url != null) {
launchUrlString(app?.app.url ?? '',
mode: LaunchMode.externalApplication);
}
},
child: Text(
app?.app.url ?? '',
textAlign: TextAlign.center,
style: const TextStyle(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic,
fontSize: 12),
body: RefreshIndicator(
child: settingsProvider.showAppWebpage
? WebView(
initialUrl: app?.app.url,
javascriptMode: JavascriptMode.unrestricted,
)
: CustomScrollView(
slivers: [
SliverFillRemaining(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
app?.app.name ?? 'App',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayLarge,
),
Text(
'By ${app?.app.author ?? 'Unknown'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(
height: 32,
),
GestureDetector(
onTap: () {
if (app?.app.url != null) {
launchUrlString(app?.app.url ?? '',
mode: LaunchMode.externalApplication);
}
},
child: Text(
app?.app.url ?? '',
textAlign: TextAlign.center,
style: const TextStyle(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic,
fontSize: 12),
)),
const SizedBox(
height: 32,
),
Text(
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(
height: 32,
),
Text(
'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}',
textAlign: TextAlign.center,
style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12),
)
],
)),
const SizedBox(
height: 32,
],
),
Text(
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
onRefresh: () async {
if (app != null) {
getUpdate(app.app.id);
}
}),
bottomSheet: Padding(
padding: EdgeInsets.fromLTRB(
0, 0, 0, MediaQuery.of(context).padding.bottom),
@ -187,22 +215,40 @@ class _AppPageState extends State<AppPage> {
onPressed: app?.downloadProgress != null
? null
: () {
showDialog(
showDialog<List<String>>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Additional Options',
items: source
.additionalDataFormItems,
items: [
...source
.additionalDataFormItems,
[
GeneratedFormItem(
label: 'App Name',
required: true)
]
],
defaultValues: app != null
? app.app.additionalData
: source
.additionalDataDefaults);
? [
...app
.app.additionalData,
app.app.name
]
: [
...source
.additionalDataDefaults
]);
}).then((values) {
if (app != null && values != null) {
var changedApp = app.app;
var name = values.removeLast();
changedApp.name = name;
changedApp.additionalData = values;
appsProvider.saveApps([changedApp]);
appsProvider.saveApps(
[changedApp]).then((value) {
getUpdate(changedApp.id);
});
}
});
},

View File

@ -6,8 +6,10 @@ import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher_string.dart';
class AppsPage extends StatefulWidget {
const AppsPage({super.key});
@ -172,7 +174,37 @@ class AppsPageState extends State<AppsPage> {
: (sortedApps[index].app.installedVersion != null &&
sortedApps[index].app.installedVersion !=
sortedApps[index].app.latestVersion
? const Text('Update Available')
? Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Text('Update Available'),
SourceProvider()
.getSource(sortedApps[index].app.url)
.changeLogPageFromStandardUrl(
sortedApps[index].app.url) ==
null
? const SizedBox()
: GestureDetector(
onTap: () {
launchUrlString(
SourceProvider()
.getSource(
sortedApps[index].app.url)
.changeLogPageFromStandardUrl(
sortedApps[index].app.url)!,
mode:
LaunchMode.externalApplication);
},
child: const Text(
'See Changes',
style: TextStyle(
fontStyle: FontStyle.italic,
decoration:
TextDecoration.underline),
)),
],
)
: Text(sortedApps[index].app.installedVersion ??
'Not Installed')),
onTap: () {
@ -308,16 +340,139 @@ class AppsPageState extends State<AppsPage> {
: IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
String urls = '';
for (var id in selectedIds) {
urls += '${appsProvider.apps[id]!.app.url}\n';
}
urls = urls.substring(0, urls.length - 1);
Share.share(urls,
subject: 'Selected App URLs from Obtainium');
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
scrollable: true,
content: Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed:
appsProvider
.areDownloadsRunning()
? null
: () {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return AlertDialog(
title: Text(
'Mark ${selectedIds.length} Selected Apps as Not Installed?'),
actions: [
TextButton(
onPressed:
() {
Navigator.of(context)
.pop();
},
child: const Text(
'No')),
TextButton(
onPressed:
() {
HapticFeedback
.selectionClick();
appsProvider
.saveApps(selectedIds.map((e) {
var a =
appsProvider.apps[e]!.app;
a.installedVersion =
null;
return a;
}).toList());
Navigator.of(context)
.pop();
},
child: const Text(
'Yes'))
],
);
});
},
tooltip:
'Mark Selected Apps as Not Installed',
icon: const Icon(
Icons.no_cell_outlined)),
IconButton(
onPressed:
appsProvider
.areDownloadsRunning()
? null
: () {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return AlertDialog(
title: Text(
'Mark ${selectedIds.length} Selected Apps as Installed/Updated?'),
actions: [
TextButton(
onPressed:
() {
Navigator.of(context)
.pop();
},
child: const Text(
'No')),
TextButton(
onPressed:
() {
HapticFeedback
.selectionClick();
appsProvider
.saveApps(selectedIds.map((e) {
var a =
appsProvider.apps[e]!.app;
a.installedVersion =
a.latestVersion;
return a;
}).toList());
Navigator.of(context)
.pop();
},
child: const Text(
'Yes'))
],
);
});
},
tooltip:
'Mark Selected Apps as Installed/Updated',
icon: const Icon(Icons.done)),
IconButton(
onPressed: () {
String urls = '';
for (var id in selectedIds) {
urls +=
'${appsProvider.apps[id]!.app.url}\n';
}
urls = urls.substring(
0, urls.length - 1);
Share.share(urls,
subject:
'${selectedIds.length} Selected App URLs from Obtainium');
},
tooltip: 'Share Selected App URLs',
icon: const Icon(Icons.share),
),
]),
),
);
});
},
tooltip: 'Share Selected App URLs',
icon: const Icon(Icons.share),
tooltip: 'More',
icon: const Icon(Icons.more_horiz),
),
],
)),

View File

@ -40,7 +40,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
Future<List<List<String>>> addApps(List<String> urls) async {
await settingsProvider.getInstallPermission();
List<dynamic> results = await sourceProvider.getApps(urls);
List<dynamic> results = await sourceProvider.getApps(urls,
ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList());
List<App> apps = results[0];
Map<String, dynamic> errorsMap = results[1];
for (var app in apps) {
@ -266,42 +267,67 @@ class _ImportExportPageState extends State<ImportExportPage> {
);
}).then((values) {
if (values != null) {
setState(() {
importInProgress = true;
});
source
.getUrls(values)
.then((urls) {
setState(() {
importInProgress = true;
});
addApps(urls)
.then((errors) {
if (errors.isEmpty) {
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: Text(
'Imported ${urls.length} Apps')),
);
} else {
showDialog(
showDialog<List<String>?>(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength: urls
.length,
errors:
errors);
});
return UrlSelectionModal(
urls: urls);
})
.then((selectedUrls) {
if (selectedUrls !=
null) {
addApps(selectedUrls)
.then((errors) {
if (errors
.isEmpty) {
ScaffoldMessenger
.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Imported ${selectedUrls.length} Apps')),
);
} else {
showDialog(
context:
context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}).whenComplete(() {
setState(() {
importInProgress =
false;
});
});
} else {
setState(() {
importInProgress =
false;
});
}
}).whenComplete(() {
setState(() {
importInProgress =
false;
});
});
}).catchError((e) {
setState(() {
importInProgress =
false;
});
ScaffoldMessenger.of(
context)
.showSnackBar(
@ -375,3 +401,67 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
);
}
}
// ignore: must_be_immutable
class UrlSelectionModal extends StatefulWidget {
UrlSelectionModal({super.key, required this.urls});
List<String> urls;
@override
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
}
class _UrlSelectionModalState extends State<UrlSelectionModal> {
Map<String, bool> urlSelections = {};
@override
void initState() {
super.initState();
for (var url in widget.urls) {
urlSelections.putIfAbsent(url, () => true);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: const Text('Select URLs to Import'),
content: Column(children: [
...urlSelections.keys.map((url) {
return Row(children: [
Checkbox(
value: urlSelections[url],
onChanged: (value) {
setState(() {
urlSelections[url] = value ?? false;
});
}),
const SizedBox(
width: 8,
),
Expanded(
child: Text(
Uri.parse(url).path.substring(1),
))
]);
})
]),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
Navigator.of(context).pop(urlSelections.keys
.where((url) => urlSelections[url] ?? false)
.toList());
},
child: Text(
'Import ${urlSelections.values.where((b) => b).length} URLs'))
],
);
}
}

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
@ -15,6 +17,7 @@ class _SettingsPageState extends State<SettingsPage> {
@override
Widget build(BuildContext context) {
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
SourceProvider sourceProvider = SourceProvider();
if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings();
}
@ -22,8 +25,7 @@ class _SettingsPageState extends State<SettingsPage> {
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Settings'),
SliverFillRemaining(
hasScrollBody: true,
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: settingsProvider.prefs == null
@ -160,7 +162,7 @@ class _SettingsPageState extends State<SettingsPage> {
height: 16,
),
Text(
'More',
'Updates',
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
@ -169,68 +171,108 @@ class _SettingsPageState extends State<SettingsPage> {
labelText:
'Background Update Checking Interval'),
value: settingsProvider.updateInterval,
items: const [
DropdownMenuItem(
value: 15,
child: Text('15 Minutes'),
),
DropdownMenuItem(
value: 30,
child: Text('30 Minutes'),
),
DropdownMenuItem(
value: 60,
child: Text('1 Hour'),
),
DropdownMenuItem(
value: 360,
child: Text('6 Hours'),
),
DropdownMenuItem(
value: 720,
child: Text('12 Hours'),
),
DropdownMenuItem(
value: 1440,
child: Text('1 Day'),
),
DropdownMenuItem(
value: 0,
child: Text('Never - Manual Only'),
),
],
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 Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton.icon(
style: ButtonStyle(
foregroundColor:
MaterialStateProperty.resolveWith<
Color>((Set<MaterialState> states) {
return Colors.grey;
}),
),
onPressed: () {
launchUrlString(settingsProvider.sourceUrl,
mode: LaunchMode.externalApplication);
},
icon: const Icon(Icons.code),
label: Text(
'Source',
style:
Theme.of(context).textTheme.bodySmall,
),
)
],
const SizedBox(
height: 8,
),
Text(
'Longer intervals recommended for large App collections',
style: Theme.of(context)
.textTheme
.labelMedium!
.merge(const TextStyle(
fontStyle: FontStyle.italic)),
),
const Divider(
height: 48,
),
Text(
'Source-Specific',
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();
}
}),
],
)))
))),
SliverToBoxAdapter(
child: Column(
children: [
const SizedBox(
height: 16,
),
TextButton.icon(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
return Colors.grey;
}),
),
onPressed: () {
launchUrlString(settingsProvider.sourceUrl,
mode: LaunchMode.externalApplication);
},
icon: const Icon(Icons.code),
label: Text(
'Source',
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(
height: 16,
),
],
),
)
]));
}
}

View File

@ -8,13 +8,14 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:install_plugin_v2/install_plugin_v2.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/providers/notifications_provider.dart';
import 'package:provider/provider.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_fgbg/flutter_fgbg.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:http/http.dart';
import 'package:flutter_install_app/flutter_install_app.dart';
class AppInMemory {
late App app;
@ -63,6 +64,9 @@ class AppsProvider with ChangeNotifier {
}
Future<ApkFile> downloadApp(String apkUrl, String appId) async {
apkUrl = await SourceProvider()
.getSource(apps[appId]!.app.url)
.apkUrlPrefetchModifier(apkUrl);
StreamedResponse response =
await Client().send(Request('GET', Uri.parse(apkUrl)));
File downloadFile =
@ -124,7 +128,7 @@ class AppsProvider with ChangeNotifier {
// 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(ApkFile file) async {
await AppInstaller.installApk(file.file.path, actionRequired: false);
await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium');
apps[file.appId]!.app.installedVersion =
apps[file.appId]!.app.latestVersion;
await saveApps([apps[file.appId]!.app]);
@ -194,14 +198,38 @@ class AppsProvider with ChangeNotifier {
}
}
for (var u in silentUpdates) {
await installApk(u);
// If Obtainium is being installed, it should be the last one
List<ApkFile> moveObtainiumToEnd(List<ApkFile> items) {
String obtainiumId = 'imranr98_obtainium_${GitHub().host}';
ApkFile? temp;
items.removeWhere((element) {
bool res = element.appId == obtainiumId;
if (res) {
temp = element;
}
return res;
});
if (temp != null) {
items.add(temp!);
}
return items;
}
// TODO: Remove below line if silentupdates are ever figured out
regularInstalls.addAll(silentUpdates);
silentUpdates = moveObtainiumToEnd(silentUpdates);
regularInstalls = moveObtainiumToEnd(regularInstalls);
// TODO: Uncomment below if silentupdates are ever figured out
// 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);
await askUserToReturnToForeground(context, waitForFG: true);
}
for (var i in regularInstalls) {
await installApk(i);
@ -285,24 +313,33 @@ class AppsProvider with ChangeNotifier {
App newApp = await sourceProvider.getApp(
sourceProvider.getSource(currentApp.url),
currentApp.url,
currentApp.additionalData);
if (newApp.latestVersion != currentApp.latestVersion) {
newApp.installedVersion = currentApp.installedVersion;
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex;
}
await saveApps([newApp]);
return newApp;
currentApp.additionalData,
customName: currentApp.name);
newApp.installedVersion = currentApp.installedVersion;
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex;
}
return null;
await saveApps([newApp]);
return newApp.latestVersion != currentApp.latestVersion ? newApp : null;
}
Future<List<App>> checkUpdates() async {
Future<List<App>> checkUpdates({DateTime? ignoreAfter}) async {
List<App> updates = [];
if (!gettingUpdates) {
gettingUpdates = true;
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();
}
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 = await getUpdate(appIds[i]);
if (newApp != null) {
@ -391,7 +428,10 @@ class _APKPickerState extends State<APKPicker> {
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.last),
title: Text(Uri.parse(u)
.pathSegments
.where((element) => element.isNotEmpty)
.last),
value: u,
groupValue: apkUrl,
onChanged: (String? val) {

View File

@ -13,6 +13,16 @@ enum SortColumnSettings { added, nameAuthor, authorName }
enum SortOrderSettings { ascending, descending }
const maxAPIRateLimitMinutes = 30;
const minUpdateIntervalMinutes = maxAPIRateLimitMinutes + 30;
const maxUpdateIntervalMinutes = 4320;
List<int> updateIntervals = [15, 30, 60, 120, 180, 360, 720, 1440, 4320, 0]
.where((element) =>
(element >= minUpdateIntervalMinutes &&
element <= maxUpdateIntervalMinutes) ||
element == 0)
.toList();
class SettingsProvider with ChangeNotifier {
SharedPreferences? prefs;
@ -45,7 +55,17 @@ class SettingsProvider with ChangeNotifier {
}
int get updateInterval {
return prefs?.getInt('updateInterval') ?? 1440;
var min = prefs?.getInt('updateInterval') ?? 180;
if (!updateIntervals.contains(min)) {
var temp = updateIntervals[0];
for (var i in updateIntervals) {
if (min > i && i != 0) {
temp = i;
}
}
min = temp;
}
return min;
}
set updateInterval(int min) {
@ -95,11 +115,20 @@ class SettingsProvider with ChangeNotifier {
}
bool get showAppWebpage {
return prefs?.getBool('showAppWebpage') ?? true;
return prefs?.getBool('showAppWebpage') ?? false;
}
set showAppWebpage(bool show) {
prefs?.setBool('showAppWebpage', show);
notifyListeners();
}
String? getSettingString(String settingId) {
return prefs?.getString(settingId);
}
void setSettingString(String settingId, String value) {
prefs?.setString(settingId, value);
notifyListeners();
}
}

View File

@ -4,6 +4,7 @@
import 'dart:convert';
import 'package:html/dom.dart';
import 'package:obtainium/app_sources/apkmirror.dart';
import 'package:obtainium/app_sources/fdroid.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/app_sources/gitlab.dart';
@ -38,6 +39,7 @@ class App {
List<String> apkUrls = [];
late int preferredApkIndex;
late List<String> additionalData;
late DateTime? lastUpdateCheck;
App(
this.id,
this.url,
@ -47,7 +49,8 @@ class App {
this.latestVersion,
this.apkUrls,
this.preferredApkIndex,
this.additionalData);
this.additionalData,
this.lastUpdateCheck);
@override
String toString() {
@ -69,7 +72,10 @@ class App {
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
json['additionalData'] == null
? SourceProvider().getSource(json['url']).additionalDataDefaults
: List<String>.from(jsonDecode(json['additionalData'])));
: List<String>.from(jsonDecode(json['additionalData'])),
json['lastUpdateCheck'] == null
? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']));
Map<String, dynamic> toJson() => {
'id': id,
@ -80,7 +86,8 @@ class App {
'latestVersion': latestVersion,
'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData)
'additionalData': jsonEncode(additionalData),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch
};
}
@ -90,7 +97,7 @@ escapeRegEx(String s) {
});
}
makeUrlHttps(String url) {
preStandardizeUrl(String url) {
if (url.toLowerCase().indexOf('http://') != 0 &&
url.toLowerCase().indexOf('https://') != 0) {
url = 'https://$url';
@ -129,6 +136,9 @@ abstract class AppSource {
AppNames getAppNames(String standardUrl);
late List<List<GeneratedFormItem>> additionalDataFormItems;
late List<String> additionalDataDefaults;
late List<GeneratedFormItem> moreSourceSettingsFormItems;
String? changeLogPageFromStandardUrl(String standardUrl);
Future<String> apkUrlPrefetchModifier(String apkUrl);
}
abstract class MassAppSource {
@ -146,14 +156,15 @@ class SourceProvider {
IzzyOnDroid(),
Mullvad(),
Signal(),
SourceForge()
SourceForge(),
APKMirror()
];
// Add more mass source classes here so they are available via the service
List<MassAppSource> massSources = [GitHubStars()];
AppSource getSource(String url) {
url = makeUrlHttps(url);
url = preStandardizeUrl(url);
AppSource? source;
for (var s in sources) {
if (url.toLowerCase().contains('://${s.host}')) {
@ -180,7 +191,7 @@ class SourceProvider {
Future<App> getApp(AppSource source, String url, List<String> additionalData,
{String customName = ''}) async {
String standardUrl = source.standardizeURL(makeUrlHttps(url));
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
AppNames names = source.getAppNames(standardUrl);
APKDetails apk =
await source.getLatestAPKDetails(standardUrl, additionalData);
@ -195,15 +206,17 @@ class SourceProvider {
apk.version,
apk.apkUrls,
apk.apkUrls.length - 1,
additionalData);
additionalData,
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
Future<List<dynamic>> getApps(List<String> urls) async {
Future<List<dynamic>> getApps(List<String> urls,
{List<String> ignoreUrls = const []}) async {
List<App> apps = [];
Map<String, dynamic> errors = {};
for (var url in urls) {
for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
try {
var source = getSource(url);
apps.add(await getApp(source, url, source.additionalDataDefaults));

View File

@ -7,7 +7,7 @@ packages:
name: animations
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
version: "2.0.7"
archive:
dependency: transitive
description:
@ -71,6 +71,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.16.0"
cross_file:
dependency: transitive
description:
name: cross_file
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.3+2"
crypto:
dependency: transitive
description:
@ -105,42 +112,42 @@ packages:
name: device_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.2"
version: "5.0.5"
device_info_plus_linux:
dependency: transitive
description:
name: device_info_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
version: "4.0.2"
device_info_plus_macos:
dependency: transitive
description:
name: device_info_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
version: "4.0.2"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
version: "4.0.1"
device_info_plus_web:
dependency: transitive
description:
name: device_info_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
version: "4.0.2"
device_info_plus_windows:
dependency: transitive
description:
name: device_info_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.0"
version: "5.0.2"
dynamic_color:
dependency: "direct main"
description:
@ -175,7 +182,7 @@ packages:
name: file_picker
url: "https://pub.dartlang.org"
source: hosted
version: "5.2.0"
version: "5.2.0+1"
flutter:
dependency: "direct main"
description: flutter
@ -188,13 +195,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
flutter_install_app:
dependency: "direct main"
description:
name: flutter_install_app
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
@ -215,7 +215,7 @@ packages:
name: flutter_local_notifications
url: "https://pub.dartlang.org"
source: hosted
version: "11.0.1"
version: "12.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
@ -282,6 +282,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.0"
install_plugin_v2:
dependency: "direct main"
description:
name: install_plugin_v2
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
js:
dependency: transitive
description:
@ -386,7 +393,7 @@ packages:
name: path_provider_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
version: "2.0.5"
path_provider_windows:
dependency: transitive
description:
@ -400,35 +407,35 @@ packages:
name: permission_handler
url: "https://pub.dartlang.org"
source: hosted
version: "10.0.1"
version: "10.1.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
url: "https://pub.dartlang.org"
source: hosted
version: "10.0.0"
version: "10.1.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
url: "https://pub.dartlang.org"
source: hosted
version: "9.0.4"
version: "9.0.6"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.7.1"
version: "3.9.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.0"
version: "0.1.1"
petitparser:
dependency: transitive
description:
@ -470,7 +477,7 @@ packages:
name: share_plus
url: "https://pub.dartlang.org"
source: hosted
version: "4.4.0"
version: "4.5.3"
share_plus_linux:
dependency: transitive
description:
@ -491,14 +498,14 @@ packages:
name: share_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.3"
version: "3.1.1"
share_plus_web:
dependency: transitive
description:
name: share_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
version: "3.1.0"
share_plus_windows:
dependency: transitive
description:
@ -629,7 +636,7 @@ packages:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.5"
version: "6.1.6"
url_launcher_android:
dependency: transitive
description:
@ -664,7 +671,7 @@ packages:
name: url_launcher_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
version: "2.1.1"
url_launcher_web:
dependency: transitive
description:
@ -699,21 +706,21 @@ packages:
name: webview_flutter_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.10.2"
version: "2.10.4"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.9.3"
version: "1.9.5"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
url: "https://pub.dartlang.org"
source: hosted
version: "2.9.4"
version: "2.9.5"
win32:
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.4.1+20 # When changing this, update the tag in main() accordingly
version: 0.5.7+28 # When changing this, update the tag in main() accordingly
environment:
sdk: '>=2.19.0-79.0.dev <3.0.0'
@ -38,7 +38,7 @@ dependencies:
cupertino_icons: ^1.0.5
path_provider: ^2.0.11
flutter_fgbg: ^0.2.0 # Try removing reliance on this
flutter_local_notifications: ^11.0.1
flutter_local_notifications: ^12.0.0
provider: ^6.0.3
http: ^0.13.5
webview_flutter: ^3.0.4
@ -49,10 +49,10 @@ dependencies:
url_launcher: ^6.1.5
permission_handler: ^10.0.0
fluttertoast: ^8.0.9
device_info_plus: ^4.1.2
device_info_plus: ^5.0.5
file_picker: ^5.1.0
animations: ^2.0.4
flutter_install_app: ^1.3.0
install_plugin_v2: ^1.0.0
share_plus: ^4.4.0

View File

@ -13,7 +13,7 @@ import 'package:obtainium/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
await tester.pumpWidget(const Obtainium());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);