Compare commits

...

31 Commits

Author SHA1 Message Date
6c8f9ebcbf Ran flutter upgrade 2022-11-25 20:45:49 -05:00
4d5773bdcc Slight UI changes on Add App page 2022-11-25 20:41:57 -05:00
f81ef6a416 Search results now interleaved on Add App page 2022-11-25 20:35:51 -05:00
47324fcb49 Added search bar on Add App page 2022-11-25 20:31:52 -05:00
377e0e07bd Removed unused imports 2022-11-25 19:17:08 -05:00
b5aae70274 Bugfix from prev. commit 2022-11-25 19:13:29 -05:00
42475fa42a Only ask for install perm. for non-track-only apps 2022-11-25 19:07:05 -05:00
d29534ef2e Increment version 2022-11-25 18:56:43 -05:00
25953399ac Re-added APKMirror as a Track-Only source 2022-11-25 18:55:17 -05:00
b04d2fad5c Adds Track-Only App Support (Addresses #119 and Sets Groundwork for #44) (#123)
- All Sources now have a "Track-Only" option that will prevent Obtainium from looking for APKs (though the App must still have a release of some kind so that a version string can be grabbed).
    - These Apps cannot be installed through Obtainium, but update notifications will still be sent.
    - The user needs to manually mark them as updated when appropriate.
    - This addresses issue #119.
    - It also partially addresses #44 by allowing some sources to be configured as "Track-Only"-only. The first such source (APKMirror) will be added later.
- Includes various UI changes to accommodate the above change.
- Also makes App loading a bit more responsive (sending Obtainium to the background then returning will now cause App re-load to pick up changes in App versioning that may have been made in the meantime, for instance through update checking).
2022-11-24 21:12:46 -05:00
868ba84c9a Tiny bugfix 2022-11-20 11:05:33 -05:00
602f0c3bb2 Increment version 2022-11-19 16:26:44 -05:00
00721e8ac4 Added days filter to logs dialog (#117) 2022-11-19 16:20:42 -05:00
d19f9101d6 Added dropdown support to generated form 2022-11-19 15:42:20 -05:00
a4bc278e4c Increment version 2022-11-17 19:02:44 -05:00
b04986622b IzzyOnDroid now uses API (+ other minor tweaks) 2022-11-17 19:00:27 -05:00
2059e4fd44 Switched to F-Droid API 2022-11-17 18:36:05 -05:00
618a1523cf Add app only downloads APK if needed 2022-11-16 20:57:58 -05:00
ba1cdc2c73 Increment version 2022-11-14 21:10:30 -05:00
aa2a25fffe Better GitHub error messages (#112) 2022-11-14 20:56:04 -05:00
c8ec67aef3 Existing APKs now reused again
With partial APKs avoided (#113)
2022-11-14 20:38:02 -05:00
9576a99a4e More efficient loading (addresses #110) 2022-11-14 12:56:52 -05:00
0202224fa6 Merge pull request #108 from ImranR98/logging
Added basic logging - mainly just for the BG task and errors right now.
2022-11-12 19:18:12 -05:00
631ffd5c34 Added basic logging + increment version
Logging is mainly just for the BG task and errors right now.
2022-11-12 19:17:05 -05:00
feed7ffc0b Increment version 2022-11-12 11:27:15 -05:00
296485de8a Added "Reset Install Status" button 2022-11-12 11:21:46 -05:00
d2f226d442 Slight refactoring 2022-11-12 10:44:59 -05:00
cbdb449e35 Bugfix in moveObtainiumToStart 2022-11-12 10:43:12 -05:00
3100a3a08c Added descriptions to GitHub starred imports 2022-11-12 10:40:54 -05:00
18951d6461 Added descriptions to search results 2022-11-12 10:35:59 -05:00
0e0a39a40f Allow App downgrades if com.berdik.letmedowngrade installed 2022-11-12 10:05:46 -05:00
25 changed files with 1135 additions and 443 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@ -1,4 +1,5 @@
import 'package:html/parser.dart';
import 'dart:convert';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
@ -28,51 +29,40 @@ class FDroid extends AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
String? tryInferringAppId(String standardUrl) {
return Uri.parse(standardUrl).pathSegments.last;
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse(standardUrl));
APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
Response res, String apkUrlPrefix) {
if (res.statusCode == 200) {
var releases = parse(res.body).querySelectorAll('.package-version');
List<dynamic> releases = jsonDecode(res.body)['packages'] ?? [];
if (releases.isEmpty) {
throw NoReleasesError();
}
String? latestVersion = releases[0]
.querySelector('.package-version-header b')
?.innerHtml
.split(' ')
.sublist(1)
.join(' ');
String? latestVersion = releases[0]['versionName'];
if (latestVersion == null) {
throw NoVersionError();
}
List<String> apkUrls = releases
.where((element) =>
element
.querySelector('.package-version-header b')
?.innerHtml
.split(' ')
.sublist(1)
.join(' ') ==
latestVersion)
.map((e) =>
e
.querySelector('.package-version-download a')
?.attributes['href'] ??
'')
.where((element) => element.isNotEmpty)
.where((element) => element['versionName'] == latestVersion)
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
.toList();
if (apkUrls.isEmpty) {
throw NoAPKError();
}
return APKDetails(latestVersion, apkUrls);
} else {
throw NoReleasesError();
}
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
String? appId = tryInferringAppId(standardUrl);
return getAPKUrlsFromFDroidPackagesAPIResponse(
await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')),
'https://f-droid.org/repo/$appId');
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);

View File

@ -11,9 +11,9 @@ class GitHub extends AppSource {
GitHub() {
host = 'github.com';
additionalDataDefaults = ['true', 'true', ''];
additionalSourceAppSpecificDefaults = ['true', 'true', ''];
moreSourceSettingsFormItems = [
additionalSourceSpecificSettingFormItems = [
GeneratedFormItem(
label: 'GitHub Personal Access Token (Increases Rate Limit)',
id: 'github-creds',
@ -51,7 +51,7 @@ class GitHub extends AppSource {
])
];
additionalDataFormItems = [
additionalSourceAppSpecificFormItems = [
[
GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)
],
@ -96,8 +96,8 @@ class GitHub extends AppSource {
Future<String> getCredentialPrefixIfAny() async {
SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings();
String? creds =
settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id);
String? creds = settingsProvider
.getSettingString(additionalSourceSpecificSettingFormItems[0].id);
return creds != null && creds.isNotEmpty ? '$creds@' : '';
}
@ -105,9 +105,6 @@ class GitHub extends AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/releases';
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
@ -158,23 +155,14 @@ class GitHub extends AppSource {
if (targetRelease == null) {
throw NoReleasesError();
}
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
throw NoAPKError();
}
String? version = targetRelease['tag_name'];
if (version == null) {
throw NoVersionError();
}
return APKDetails(version, targetRelease['apkUrls']);
return APKDetails(version, targetRelease['apkUrls'] as List<String>);
} else {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw RateLimitError(
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
}
throw NoReleasesError();
rateLimitErrorCheck(res);
throw getObtainiumHttpError(res);
}
}
@ -186,23 +174,31 @@ class GitHub extends AppSource {
}
@override
Future<List<String>> search(String query) async {
Future<Map<String, String>> search(String query) async {
Response res = await get(Uri.parse(
'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100'));
if (res.statusCode == 200) {
return (jsonDecode(res.body)['items'] as List<dynamic>)
.map((e) => e['html_url'] as String)
.toList();
} else {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw RateLimitError(
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
Map<String, String> urlsWithDescriptions = {};
for (var e in (jsonDecode(res.body)['items'] as List<dynamic>)) {
urlsWithDescriptions.addAll({
e['html_url'] as String: e['description'] != null
? e['description'] as String
: 'No description'
});
}
throw ObtainiumError(
res.reasonPhrase ?? 'Error ${res.statusCode.toString()}',
unexpected: true);
return urlsWithDescriptions;
} else {
rateLimitErrorCheck(res);
throw getObtainiumHttpError(res);
}
}
rateLimitErrorCheck(Response res) {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw RateLimitError(
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
}
}
}

View File

@ -23,9 +23,6 @@ class GitLab extends AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/-/releases';
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
@ -36,7 +33,7 @@ class GitLab extends AppSource {
var entry = parsedHtml.querySelector('entry');
var entryContent =
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrlList = [
var apkUrls = [
...getLinksFromParsedHTML(
entryContent,
RegExp(
@ -51,9 +48,6 @@ class GitLab extends AppSource {
.where((element) => Uri.parse(element).host != '')
.toList()
];
if (apkUrlList.isEmpty) {
throw NoAPKError();
}
var entryId = entry?.querySelector('id')?.innerHtml;
var version =
@ -61,7 +55,7 @@ class GitLab extends AppSource {
if (version == null) {
throw NoVersionError();
}
return APKDetails(version, apkUrlList);
return APKDetails(version, apkUrls);
} else {
throw NoReleasesError();
}

View File

@ -1,5 +1,5 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/fdroid.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
@ -22,41 +22,18 @@ class IzzyOnDroid extends AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
String? tryInferringAppId(String standardUrl) {
return FDroid().tryInferringAppId(standardUrl);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) {
var parsedHtml = parse(res.body);
var multipleVersionApkUrls = parsedHtml
.querySelectorAll('a')
.where((element) =>
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
false)
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
.toList();
if (multipleVersionApkUrls.isEmpty) {
throw NoAPKError();
}
var version = parsedHtml
.querySelector('#keydata')
?.querySelectorAll('b')
.where(
(element) => element.innerHtml.toLowerCase().contains('version'))
.toList()[0]
.parentNode
?.parentNode
?.children[1]
.innerHtml;
if (version == null) {
throw NoVersionError();
}
return APKDetails(version, [multipleVersionApkUrls[0]]);
} else {
throw NoReleasesError();
}
String? appId = tryInferringAppId(standardUrl);
return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(
await get(
Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')),
'https://android.izzysoft.de/frepo/$appId');
}
@override

View File

@ -22,9 +22,6 @@ class Mullvad extends AppSource {
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 {

View File

@ -16,9 +16,6 @@ class Signal extends AppSource {
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
@ -27,14 +24,12 @@ class Signal extends AppSource {
if (res.statusCode == 200) {
var json = jsonDecode(res.body);
String? apkUrl = json['url'];
if (apkUrl == null) {
throw NoAPKError();
}
List<String> apkUrls = apkUrl == null ? [] : [apkUrl];
String? version = json['versionName'];
if (version == null) {
throw NoVersionError();
}
return APKDetails(version, [apkUrl]);
return APKDetails(version, apkUrls);
} else {
throw NoReleasesError();
}

View File

@ -21,9 +21,6 @@ class SourceForge extends AppSource {
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
@ -52,9 +49,6 @@ class SourceForge extends AppSource {
apkUrlListAllReleases // This can be used skipped for fallback support later
.where((element) => getVersion(element) == version)
.toList();
if (apkUrlList.isEmpty) {
throw NoAPKError();
}
return APKDetails(version, apkUrlList);
} else {
throw NoReleasesError();

View File

@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
enum FormItemType { string, bool }
typedef OnValueChanges = void Function(List<String> values, bool valid);
typedef OnValueChanges = void Function(
List<String> values, bool valid, bool isBuilding);
class GeneratedFormItem {
late String key;
late String label;
late FormItemType type;
late bool required;
@ -13,6 +15,7 @@ class GeneratedFormItem {
late String id;
late List<Widget> belowWidgets;
late String? hint;
late List<String>? opts;
GeneratedFormItem(
{this.label = 'Input',
@ -22,7 +25,9 @@ class GeneratedFormItem {
this.additionalValidators = const [],
this.id = 'input',
this.belowWidgets = const [],
this.hint});
this.hint,
this.opts,
this.key = 'default'});
}
class GeneratedForm extends StatefulWidget {
@ -47,7 +52,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
List<List<Widget>> rows = [];
// If any value changes, call this to update the parent with value and validity
void someValueChanged() {
void someValueChanged({bool isBuilding = false}) {
List<String> returnValues = [];
var valid = true;
for (int r = 0; r < values.length; r++) {
@ -62,7 +67,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
}
}
}
widget.onValueChanges(returnValues, valid);
widget.onValueChanges(returnValues, valid, isBuilding);
}
@override
@ -75,14 +80,16 @@ class _GeneratedFormState extends State<GeneratedForm> {
.map((row) => row.map((e) {
return j < widget.defaultValues.length
? widget.defaultValues[j++]
: '';
: e.opts != null
? e.opts!.first
: '';
}).toList())
.toList();
// Dynamically create form inputs
formInputs = widget.items.asMap().entries.map((row) {
return row.value.asMap().entries.map((e) {
if (e.value.type == FormItemType.string) {
if (e.value.type == FormItemType.string && e.value.opts == null) {
final formFieldKey = GlobalKey<FormFieldState>();
return TextFormField(
key: formFieldKey,
@ -112,11 +119,29 @@ class _GeneratedFormState extends State<GeneratedForm> {
return null;
},
);
} else if (e.value.type == FormItemType.string &&
e.value.opts != null) {
if (e.value.opts!.isEmpty) {
return const Text('ERROR: DROPDOWN MUST HAVE AT LEAST ONE OPT.');
}
return DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'Colour'),
value: values[row.key][e.key],
items: e.value.opts!
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
.toList(),
onChanged: (value) {
setState(() {
values[row.key][e.key] = value ?? e.value.opts!.first;
someValueChanged();
});
});
} else {
return Container(); // Some input types added in build
}
}).toList();
}).toList();
someValueChanged(isBuilding: true);
}
@override
@ -186,3 +211,18 @@ class _GeneratedFormState extends State<GeneratedForm> {
));
}
}
String? findGeneratedFormValueByKey(
List<GeneratedFormItem> items, List<String> values, String key) {
var foundIndex = -1;
for (var i = 0; i < items.length; i++) {
if (items[i].key == key) {
foundIndex = i;
break;
}
}
if (foundIndex >= 0 && foundIndex < values.length) {
return values[foundIndex];
}
return null;
}

View File

@ -29,7 +29,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
void initState() {
super.initState();
values = widget.defaultValues;
valid = widget.initValid;
valid = widget.initValid || widget.items.isEmpty;
}
@override
@ -46,11 +46,16 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
),
GeneratedForm(
items: widget.items,
onValueChanges: (values, valid) {
setState(() {
onValueChanges: (values, valid, isBuilding) {
if (isBuilding) {
this.values = values;
this.valid = valid;
});
} else {
setState(() {
this.values = values;
this.valid = valid;
});
}
},
defaultValues: widget.defaultValues)
]),

View File

@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:provider/provider.dart';
class ObtainiumError {
late String message;
@ -75,6 +77,8 @@ class MultiAppMultiError extends ObtainiumError {
}
showError(dynamic e, BuildContext context) {
Provider.of<LogsProvider>(context, listen: false)
.add(e.toString(), level: LogLevels.error);
if (e is String || (e is ObtainiumError && !e.unexpected)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),

View File

@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/pages/home.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/notifications_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
@ -15,7 +16,7 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
const String currentVersion = '0.7.0';
const String currentVersion = '0.8.0';
const String currentReleaseTag =
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@ -23,12 +24,15 @@ const int bgUpdateCheckAlarmId = 666;
@pragma('vm:entry-point')
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
LogsProvider logs = LogsProvider();
logs.add('Started BG update check task');
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
WidgetsFlutterBinding.ensureInitialized();
await AndroidAlarmManager.initialize();
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
: null;
logs.add('Bg update ignoreAfter is $ignoreAfter');
var notificationsProvider = NotificationsProvider();
await notificationsProvider.notify(checkingUpdatesNotification);
try {
@ -40,17 +44,18 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
DateTime nextIgnoreAfter = DateTime.now();
String? err;
try {
logs.add('Started actual BG update checking');
await appsProvider.checkUpdates(
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
} catch (e) {
if (e is RateLimitError || e is SocketException) {
AndroidAlarmManager.oneShot(
Duration(minutes: e is RateLimitError ? e.remainingMinutes : 15),
Random().nextInt(pow(2, 31) as int),
bgUpdateCheck,
params: {
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
});
var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15;
logs.add(
'BG update checking encountered a ${e.runtimeType}, will schedule a retry check in $remainingMinutes minutes');
AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes),
Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: {
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
});
} else {
err = e.toString();
}
@ -74,7 +79,8 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
// silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()),
// cancelExisting: true);
// }
logs.add(
'BG update checking found ${newUpdates.length} updates - will notify user if needed');
if (newUpdates.isNotEmpty) {
notificationsProvider.notify(UpdateNotification(newUpdates));
}
@ -85,6 +91,7 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
notificationsProvider
.notify(ErrorCheckingUpdatesNotification(e.toString()));
} finally {
logs.add('Finished BG update check task');
await notificationsProvider.cancel(checkingUpdatesNotification.id);
}
}
@ -102,7 +109,8 @@ void main() async {
providers: [
ChangeNotifierProvider(create: (context) => AppsProvider()),
ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => NotificationsProvider())
Provider(create: (context) => NotificationsProvider()),
Provider(create: (context) => LogsProvider())
],
child: const Obtainium(),
));
@ -124,17 +132,19 @@ class _ObtainiumState extends State<Obtainium> {
Widget build(BuildContext context) {
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
AppsProvider appsProvider = context.read<AppsProvider>();
LogsProvider logs = context.read<LogsProvider>();
if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings();
} else {
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
if (isFirstRun) {
logs.add('This is the first ever run of Obtainium');
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
Permission.notification.request();
appsProvider.saveApps([
App(
'dev.imranr.obtainium',
obtainiumId,
'https://github.com/ImranR98/Obtainium',
'ImranR98',
'Obtainium',
@ -144,11 +154,16 @@ class _ObtainiumState extends State<Obtainium> {
0,
['true'],
null,
false,
false)
]);
}
// Register the background update task according to the user's setting
if (existingUpdateInterval != settingsProvider.updateInterval) {
if (existingUpdateInterval != -1) {
logs.add(
'Setting update interval to ${settingsProvider.updateInterval}');
}
existingUpdateInterval = settingsProvider.updateInterval;
if (existingUpdateInterval == 0) {
AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);

View File

@ -12,40 +12,42 @@ class GitHubStars implements MassAppUrlSource {
@override
late List<String> requiredArgs = ['Username'];
Future<List<String>> getOnePageOfUserStarredUrls(
Future<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions(
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 RateLimitError(
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
Map<String, String> urlsWithDescriptions = {};
for (var e in (jsonDecode(res.body) as List<dynamic>)) {
urlsWithDescriptions.addAll({
e['html_url'] as String: e['description'] != null
? e['description'] as String
: 'No description'
});
}
throw ObtainiumError('Unable to find user\'s starred repos');
return urlsWithDescriptions;
} else {
var gh = GitHub();
gh.rateLimitErrorCheck(res);
throw getObtainiumHttpError(res);
}
}
@override
Future<List<String>> getUrls(List<String> args) async {
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args) async {
if (args.length != requiredArgs.length) {
throw ObtainiumError('Wrong number of arguments provided');
}
List<String> urls = [];
Map<String, String> urlsWithDescriptions = {};
var page = 1;
while (true) {
var pageUrls = await getOnePageOfUserStarredUrls(args[0], page++);
urls.addAll(pageUrls);
var pageUrls =
await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++);
urlsWithDescriptions.addAll(pageUrls);
if (pageUrls.length < 100) {
break;
}
}
return urls;
return urlsWithDescriptions;
}
}

View File

@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/pages/app.dart';
import 'package:obtainium/pages/import_export.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
@ -21,13 +23,117 @@ class _AddAppPageState extends State<AddAppPage> {
bool gettingAppInfo = false;
String userInput = '';
String searchQuery = '';
AppSource? pickedSource;
List<String> additionalData = [];
bool validAdditionalData = true;
List<String> sourceSpecificAdditionalData = [];
bool sourceSpecificDataIsValid = true;
List<String> otherAdditionalData = [];
bool otherAdditionalDataIsValid = true;
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
AppsProvider appsProvider = context.read<AppsProvider>();
changeUserInput(String input, bool valid, bool isBuilding) {
userInput = input;
fn() {
var source = valid ? sourceProvider.getSource(userInput) : null;
if (pickedSource != source) {
pickedSource = source;
sourceSpecificAdditionalData =
source != null ? source.additionalSourceAppSpecificDefaults : [];
sourceSpecificDataIsValid = source != null
? sourceProvider.ifSourceAppsRequireAdditionalData(source)
: true;
}
}
if (isBuilding) {
fn();
} else {
setState(() {
fn();
});
}
}
addApp({bool resetUserInputAfter = false}) async {
setState(() {
gettingAppInfo = true;
});
var settingsProvider = context.read<SettingsProvider>();
() async {
var userPickedTrackOnly = findGeneratedFormValueByKey(
pickedSource!.additionalAppSpecificSourceAgnosticFormItems,
otherAdditionalData,
'trackOnlyFormItemKey') ==
'true';
var cont = true;
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
await showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title:
'${pickedSource!.enforceTrackOnly ? 'Source' : 'App'} is Track-Only',
items: const [],
defaultValues: const [],
message:
'${pickedSource!.enforceTrackOnly ? 'Apps from this source are \'Track-Only\'.' : 'You have selected the \'Track-Only\' option.'}\n\nThe App will be tracked for updates, but Obtainium will not be able to download or install it.',
);
}) ==
null) {
cont = false;
}
if (cont) {
HapticFeedback.selectionClick();
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
App app = await sourceProvider.getApp(
pickedSource!, userInput, sourceSpecificAdditionalData,
trackOnly: trackOnly);
if (!trackOnly) {
await settingsProvider.getInstallPermission();
}
// Only download the APK here if you need to for the package ID
if (sourceProvider.isTempId(app.id) && !app.trackOnly) {
// ignore: use_build_context_synchronously
var apkUrl = await appsProvider.confirmApkUrl(app, context);
if (apkUrl == null) {
throw ObtainiumError('Cancelled');
}
app.preferredApkIndex = app.apkUrls.indexOf(apkUrl);
var downloadedApk = await appsProvider.downloadApp(app);
app.id = downloadedApk.appId;
}
if (appsProvider.apps.containsKey(app.id)) {
throw ObtainiumError('App already added');
}
if (app.trackOnly) {
app.installedVersion = app.latestVersion;
}
await appsProvider.saveApps([app]);
return app;
}
}()
.then((app) {
if (app != null) {
Navigator.push(context,
MaterialPageRoute(builder: (context) => AppPage(appId: app.id)));
}
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
gettingAppInfo = false;
if (resetUserInputAfter) {
changeUserInput('', false, true);
}
});
});
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
@ -66,24 +172,9 @@ class _AddAppPageState extends State<AddAppPage> {
])
]
],
onValueChanges: (values, valid) {
setState(() {
userInput = values[0];
var source = valid
? sourceProvider.getSource(userInput)
: null;
if (pickedSource != source) {
pickedSource = source;
additionalData = source != null
? source.additionalDataDefaults
: [];
validAdditionalData = source != null
? sourceProvider
.ifSourceAppsRequireAdditionalData(
source)
: true;
}
});
onValueChanges: (values, valid, isBuilding) {
changeUserInput(
values[0], valid, isBuilding);
},
defaultValues: const [])),
const SizedBox(
@ -94,68 +185,115 @@ class _AddAppPageState extends State<AddAppPage> {
: ElevatedButton(
onPressed: gettingAppInfo ||
pickedSource == null ||
(pickedSource!.additionalDataFormItems
(pickedSource!
.additionalSourceAppSpecificFormItems
.isNotEmpty &&
!validAdditionalData)
!sourceSpecificDataIsValid) ||
(pickedSource!
.additionalAppSpecificSourceAgnosticDefaults
.isNotEmpty &&
!otherAdditionalDataIsValid)
? null
: () async {
setState(() {
gettingAppInfo = true;
});
var appsProvider =
context.read<AppsProvider>();
var settingsProvider =
context.read<SettingsProvider>();
() async {
HapticFeedback.selectionClick();
App app =
await sourceProvider.getApp(
pickedSource!,
userInput,
additionalData);
await settingsProvider
.getInstallPermission();
// ignore: use_build_context_synchronously
var apkUrl = await appsProvider
.confirmApkUrl(app, context);
if (apkUrl == null) {
throw ObtainiumError('Cancelled');
}
app.preferredApkIndex =
app.apkUrls.indexOf(apkUrl);
var downloadedApk =
await appsProvider
.downloadApp(app);
app.id = downloadedApk.appId;
if (appsProvider.apps
.containsKey(app.id)) {
throw ObtainiumError(
'App already added');
}
await appsProvider.saveApps([app]);
return app;
}()
.then((app) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
AppPage(
appId: app.id)));
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
gettingAppInfo = false;
});
});
},
: addApp,
child: const Text('Add'))
],
),
if (sourceProvider.sources
.where((e) => e.canSearch)
.isNotEmpty &&
pickedSource == null &&
userInput.isEmpty)
const SizedBox(
height: 16,
),
if (sourceProvider.sources
.where((e) => e.canSearch)
.isNotEmpty &&
pickedSource == null &&
userInput.isEmpty)
Row(
children: [
Expanded(
child: GeneratedForm(
items: [
[
GeneratedFormItem(
label: 'Search (Some Sources Only)',
required: false),
]
],
onValueChanges: (values, valid, isBuilding) {
if (values.isNotEmpty && valid) {
setState(() {
searchQuery = values[0].trim();
});
}
},
defaultValues: const ['']),
),
const SizedBox(
width: 16,
),
ElevatedButton(
onPressed: searchQuery.isEmpty || gettingAppInfo
? null
: () {
Future.wait(sourceProvider.sources
.where((e) => e.canSearch)
.map((e) =>
e.search(searchQuery)))
.then((results) async {
// Interleave results instead of simple reduce
Map<String, String> res = {};
var si = 0;
var done = false;
while (!done) {
done = true;
for (var r in results) {
if (r.length > si) {
done = false;
res.addEntries(
[r.entries.elementAt(si)]);
}
}
si++;
}
List<String>? selectedUrls = res
.isEmpty
? []
: await showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return UrlSelectionModal(
urlsWithDescriptions: res,
selectedByDefault: false,
onlyOneSelectionAllowed:
true,
);
});
if (selectedUrls != null &&
selectedUrls.isNotEmpty) {
changeUserInput(
selectedUrls[0], true, true);
addApp(resetUserInputAfter: true);
}
}).catchError((e) {
showError(e, context);
});
},
child: const Text('Search'))
],
),
if (pickedSource != null &&
pickedSource!.additionalDataDefaults.isNotEmpty)
(pickedSource!.additionalSourceAppSpecificDefaults
.isNotEmpty ||
pickedSource!
.additionalAppSpecificSourceAgnosticFormItems
.where((e) => pickedSource!.enforceTrackOnly
? e.key != 'trackOnlyFormItemKey'
: true)
.map((e) => [e])
.isNotEmpty))
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -171,22 +309,51 @@ class _AddAppPageState extends State<AddAppPage> {
height: 16,
),
if (pickedSource!
.additionalDataFormItems.isNotEmpty)
.additionalSourceAppSpecificFormItems
.isNotEmpty)
GeneratedForm(
items: pickedSource!.additionalDataFormItems,
onValueChanges: (values, valid) {
setState(() {
additionalData = values;
validAdditionalData = valid;
});
items: pickedSource!
.additionalSourceAppSpecificFormItems,
onValueChanges: (values, valid, isBuilding) {
if (isBuilding) {
sourceSpecificAdditionalData = values;
sourceSpecificDataIsValid = valid;
} else {
setState(() {
sourceSpecificAdditionalData = values;
sourceSpecificDataIsValid = valid;
});
}
},
defaultValues:
pickedSource!.additionalDataDefaults),
defaultValues: pickedSource!
.additionalSourceAppSpecificDefaults),
if (pickedSource!
.additionalDataFormItems.isNotEmpty)
.additionalAppSpecificSourceAgnosticDefaults
.isNotEmpty)
const SizedBox(
height: 8,
),
GeneratedForm(
items: pickedSource!
.additionalAppSpecificSourceAgnosticFormItems
.where((e) => pickedSource!.enforceTrackOnly
? e.key != 'trackOnlyFormItemKey'
: true)
.map((e) => [e])
.toList(),
onValueChanges: (values, valid, isBuilding) {
if (isBuilding) {
otherAdditionalData = values;
otherAdditionalDataIsValid = valid;
} else {
setState(() {
otherAdditionalData = values;
otherAdditionalDataIsValid = valid;
});
}
},
defaultValues: pickedSource!
.additionalAppSpecificSourceAgnosticDefaults),
],
)
else
@ -195,22 +362,24 @@ class _AddAppPageState extends State<AddAppPage> {
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 48,
),
const Text(
'Supported Sources:',
),
const SizedBox(
height: 8,
),
...sourceProvider
.getSourceHosts()
...sourceProvider.sources
.map((e) => GestureDetector(
onTap: () {
launchUrlString('https://$e',
launchUrlString('https://${e.host}',
mode:
LaunchMode.externalApplication);
},
child: Text(
e,
'${e.runtimeType.toString()}${e.enforceTrackOnly ? ' (Track-Only)' : ''}${e.canSearch ? ' (Searchable)' : ''}',
style: const TextStyle(
decoration:
TextDecoration.underline,
@ -218,6 +387,9 @@ class _AddAppPageState extends State<AddAppPage> {
)))
.toList()
])),
const SizedBox(
height: 8,
),
])),
)
]));

View File

@ -106,7 +106,7 @@ class _AppPageState extends State<AppPage> {
style: Theme.of(context).textTheme.bodyLarge,
),
Text(
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
'Installed Version: ${app?.app.installedVersion ?? 'None'}${app?.app.trackOnly == true ? ' (Estimate)\n\nApp is Track-Only' : ''}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
@ -140,6 +140,7 @@ class _AppPageState extends State<AppPage> {
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (app?.app.installedVersion != null &&
app?.app.trackOnly == false &&
app?.app.installedVersion != app?.app.latestVersion)
IconButton(
onPressed: app?.downloadProgress != null
@ -183,7 +184,8 @@ class _AppPageState extends State<AppPage> {
tooltip: 'Mark as Updated',
icon: const Icon(Icons.done)),
if (source != null &&
source.additionalDataFormItems.isNotEmpty)
source.additionalSourceAppSpecificFormItems
.isNotEmpty)
IconButton(
onPressed: app?.downloadProgress != null
? null
@ -194,11 +196,11 @@ class _AppPageState extends State<AppPage> {
return GeneratedFormModal(
title: 'Additional Options',
items: source
.additionalDataFormItems,
.additionalSourceAppSpecificFormItems,
defaultValues: app != null
? app.app.additionalData
: source
.additionalDataDefaults);
.additionalSourceAppSpecificDefaults);
}).then((values) {
if (app != null && values != null) {
var changedApp = app.app;
@ -221,21 +223,33 @@ class _AppPageState extends State<AppPage> {
!appsProvider.areDownloadsRunning()
? () {
HapticFeedback.heavyImpact();
appsProvider
.downloadAndInstallLatestApps(
[app!.app.id],
context).then((res) {
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
() async {
if (app?.app.trackOnly != true) {
await settingsProvider
.getInstallPermission();
}
}()
.then((value) {
appsProvider
.downloadAndInstallLatestApps(
[app!.app.id],
context).then((res) {
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
}
});
}).catchError((e) {
showError(e, context);
});
}
: null,
child: Text(app?.app.installedVersion == null
? 'Install'
: 'Update'))),
? app?.app.trackOnly == false
? 'Install'
: 'Mark Installed'
: app?.app.trackOnly == false
? 'Update'
: 'Mark Updated'))),
const SizedBox(width: 16.0),
ElevatedButton(
onPressed: app?.downloadProgress != null

View File

@ -135,6 +135,22 @@ class AppsPageState extends State<AppsPage> {
: selectedApps.map((e) => e.id).contains(element))
.toList();
List<String> trackOnlyUpdateIdsAllOrSelected = [];
existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) {
if (appsProvider.apps[id]!.app.trackOnly) {
trackOnlyUpdateIdsAllOrSelected.add(id);
return false;
}
return true;
}).toList();
newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) {
if (appsProvider.apps[id]!.app.trackOnly) {
trackOnlyUpdateIdsAllOrSelected.add(id);
return false;
}
return true;
}).toList();
if (settingsProvider.pinUpdates) {
var temp = [];
sortedApps = sortedApps.where((sa) {
@ -245,7 +261,7 @@ class AppsPageState extends State<AppsPage> {
children: [
Text(appsProvider.areDownloadsRunning()
? 'Please Wait...'
: 'Update Available'),
: 'Update Available${sortedApps[index].app.trackOnly ? ' (Est.)' : ''}'),
SourceProvider()
.getSource(sortedApps[index].app.url)
.changeLogPageFromStandardUrl(
@ -276,8 +292,7 @@ class AppsPageState extends State<AppsPage> {
child: SizedBox(
width: 80,
child: Text(
sortedApps[index].app.installedVersion ??
'Not Installed',
'${sortedApps[index].app.installedVersion ?? 'Not Installed'} ${sortedApps[index].app.trackOnly == true ? '(Estimate)' : ''}',
overflow: TextOverflow.fade,
textAlign: TextAlign.end,
)))),
@ -349,49 +364,77 @@ class AppsPageState extends State<AppsPage> {
visualDensity: VisualDensity.compact,
onPressed: appsProvider.areDownloadsRunning() ||
(existingUpdateIdsAllOrSelected.isEmpty &&
newInstallIdsAllOrSelected.isEmpty)
newInstallIdsAllOrSelected.isEmpty &&
trackOnlyUpdateIdsAllOrSelected.isEmpty)
? null
: () {
HapticFeedback.heavyImpact();
List<List<GeneratedFormItem>> formInputs = [];
if (existingUpdateIdsAllOrSelected.isNotEmpty &&
newInstallIdsAllOrSelected.isNotEmpty) {
formInputs.add([
GeneratedFormItem(
label:
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
type: FormItemType.bool)
]);
formInputs.add([
GeneratedFormItem(
label:
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}',
type: FormItemType.bool)
]);
List<GeneratedFormItem> formInputs = [];
List<String> defaultValues = [];
if (existingUpdateIdsAllOrSelected.isNotEmpty) {
formInputs.add(GeneratedFormItem(
label:
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
type: FormItemType.bool,
key: 'updates'));
defaultValues.add('true');
}
if (newInstallIdsAllOrSelected.isNotEmpty) {
formInputs.add(GeneratedFormItem(
label:
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}',
type: FormItemType.bool,
key: 'installs'));
defaultValues
.add(defaultValues.isEmpty ? 'true' : '');
}
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
formInputs.add(GeneratedFormItem(
label:
'Mark ${trackOnlyUpdateIdsAllOrSelected.length} Track-Only\nApp${trackOnlyUpdateIdsAllOrSelected.length == 1 ? '' : 's'} as Updated',
type: FormItemType.bool,
key: 'trackonlies'));
defaultValues
.add(defaultValues.isEmpty ? 'true' : '');
}
showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
var totalApps = existingUpdateIdsAllOrSelected
.length +
newInstallIdsAllOrSelected.length +
trackOnlyUpdateIdsAllOrSelected.length;
return GeneratedFormModal(
title:
'Install${selectedApps.isEmpty ? ' ' : ' Selected '}Apps?',
message:
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
items: formInputs,
defaultValues: [
'true',
existingUpdateIdsAllOrSelected.isEmpty
? 'true'
: ''
],
'Change $totalApps App${totalApps == 1 ? '' : 's'}',
items: formInputs.map((e) => [e]).toList(),
defaultValues: defaultValues,
initValid: true,
);
}).then((values) {
if (values != null) {
bool shouldInstallUpdates = values[0] == 'true';
bool shouldInstallNew = values[1] == 'true';
settingsProvider
.getInstallPermission()
if (values.isEmpty) {
values = defaultValues;
}
bool shouldInstallUpdates =
findGeneratedFormValueByKey(
formInputs, values, 'updates') ==
'true';
bool shouldInstallNew =
findGeneratedFormValueByKey(
formInputs, values, 'installs') ==
'true';
bool shouldMarkTrackOnlies =
findGeneratedFormValueByKey(formInputs,
values, 'trackonlies') ==
'true';
(() async {
if (shouldInstallNew ||
shouldInstallUpdates) {
await settingsProvider
.getInstallPermission();
}
})()
.then((_) {
List<String> toInstall = [];
if (shouldInstallUpdates) {
@ -402,6 +445,10 @@ class AppsPageState extends State<AppsPage> {
toInstall
.addAll(newInstallIdsAllOrSelected);
}
if (shouldMarkTrackOnlies) {
toInstall.addAll(
trackOnlyUpdateIdsAllOrSelected);
}
appsProvider
.downloadAndInstallLatestApps(
toInstall, context)
@ -528,6 +575,36 @@ class AppsPageState extends State<AppsPage> {
tooltip: 'Share Selected App URLs',
icon: const Icon(Icons.share),
),
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title:
'Reset Install Status for Selected Apps?',
items: const [],
defaultValues: const [],
initValid: true,
message:
'The install status of ${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be reset.\n\nThis can help when the App version shown in Obtainium is incorrect due to failed updates or other issues.',
);
}).then((values) {
if (values != null) {
appsProvider.saveApps(
selectedApps.map((e) {
e.installedVersion = null;
return e;
}).toList());
}
}).whenComplete(() {
Navigator.of(context).pop();
});
},
tooltip: 'Reset Install Status',
icon: const Icon(
Icons.restore_page_outlined),
),
]),
),
);

View File

@ -8,10 +8,10 @@ import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ImportExportPage extends StatefulWidget {
const ImportExportPage({super.key});
@ -26,7 +26,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
var settingsProvider = context.read<SettingsProvider>();
var appsProvider = context.read<AppsProvider>();
var outlineButtonStyle = ButtonStyle(
shape: MaterialStateProperty.all(
@ -39,24 +38,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
),
);
Future<List<List<String>>> addApps(List<String> urls) async {
await settingsProvider.getInstallPermission();
List<dynamic> results = await sourceProvider.getApps(urls,
ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList());
List<App> apps = results[0];
Map<String, dynamic> errorsMap = results[1];
for (var app in apps) {
if (appsProvider.apps.containsKey(app.id)) {
errorsMap.addAll({app.id: 'App already added'});
} else {
await appsProvider.saveApps([app]);
}
}
List<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
@ -196,7 +177,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
setState(() {
importInProgress = true;
});
addApps(urls).then((errors) {
appsProvider
.addAppsByURL(urls)
.then((errors) {
if (errors.isEmpty) {
showError(
'Imported ${urls.length} Apps',
@ -258,9 +241,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
setState(() {
importInProgress = true;
});
var urls = await source
.search(values[0]);
if (urls.isNotEmpty) {
var urlsWithDescriptions =
await source
.search(values[0]);
if (urlsWithDescriptions
.isNotEmpty) {
var selectedUrls =
await showDialog<
List<
@ -270,8 +255,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
(BuildContext
ctx) {
return UrlSelectionModal(
urls: urls,
defaultSelected:
urlsWithDescriptions:
urlsWithDescriptions,
selectedByDefault:
false,
);
});
@ -280,8 +266,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
selectedUrls
.isNotEmpty) {
var errors =
await addApps(
selectedUrls);
await appsProvider
.addAppsByURL(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
@ -353,8 +340,10 @@ class _ImportExportPageState extends State<ImportExportPage> {
setState(() {
importInProgress = true;
});
var urls = await source
.getUrls(values);
var urlsWithDescriptions =
await source
.getUrlsWithDescriptions(
values);
var selectedUrls =
await showDialog<
List<String>?>(
@ -363,12 +352,14 @@ class _ImportExportPageState extends State<ImportExportPage> {
(BuildContext
ctx) {
return UrlSelectionModal(
urls: urls);
urlsWithDescriptions:
urlsWithDescriptions);
});
if (selectedUrls != null) {
var errors =
await addApps(
selectedUrls);
await appsProvider
.addAppsByURL(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
@ -477,22 +468,36 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
// ignore: must_be_immutable
class UrlSelectionModal extends StatefulWidget {
UrlSelectionModal(
{super.key, required this.urls, this.defaultSelected = true});
{super.key,
required this.urlsWithDescriptions,
this.selectedByDefault = true,
this.onlyOneSelectionAllowed = false});
List<String> urls;
bool defaultSelected;
Map<String, String> urlsWithDescriptions;
bool selectedByDefault;
bool onlyOneSelectionAllowed;
@override
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
}
class _UrlSelectionModalState extends State<UrlSelectionModal> {
Map<String, bool> urlSelections = {};
Map<MapEntry<String, String>, bool> urlWithDescriptionSelections = {};
@override
void initState() {
super.initState();
for (var url in widget.urls) {
urlSelections.putIfAbsent(url, () => widget.defaultSelected);
for (var url in widget.urlsWithDescriptions.entries) {
urlWithDescriptionSelections.putIfAbsent(url,
() => widget.selectedByDefault && !widget.onlyOneSelectionAllowed);
}
if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) {
selectOnlyOne(widget.urlsWithDescriptions.entries.first.key);
}
}
selectOnlyOne(String url) {
for (var uwd in urlWithDescriptionSelections.keys) {
urlWithDescriptionSelections[uwd] = uwd.key == url;
}
}
@ -500,23 +505,56 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: const Text('Select URLs to Import'),
title:
Text(widget.onlyOneSelectionAllowed ? 'Select URL' : 'Select URLs'),
content: Column(children: [
...urlSelections.keys.map((url) {
...urlWithDescriptionSelections.keys.map((urlWithD) {
return Row(children: [
Checkbox(
value: urlSelections[url],
value: urlWithDescriptionSelections[urlWithD],
onChanged: (value) {
setState(() {
urlSelections[url] = value ?? false;
value ??= false;
if (value! && widget.onlyOneSelectionAllowed) {
selectOnlyOne(urlWithD.key);
} else {
urlWithDescriptionSelections[urlWithD] = value!;
}
});
}),
const SizedBox(
width: 8,
),
Expanded(
child: Text(
Uri.parse(url).path.substring(1),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 8,
),
GestureDetector(
onTap: () {
launchUrlString(urlWithD.key,
mode: LaunchMode.externalApplication);
},
child: Text(
Uri.parse(urlWithD.key).path.substring(1),
style:
const TextStyle(decoration: TextDecoration.underline),
textAlign: TextAlign.start,
)),
Text(
urlWithD.value.length > 128
? '${urlWithD.value.substring(0, 128)}...'
: urlWithD.value,
style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12),
),
const SizedBox(
height: 8,
)
],
))
]);
})
@ -528,13 +566,19 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
},
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'))
onPressed:
urlWithDescriptionSelections.values.where((b) => b).isEmpty
? null
: () {
Navigator.of(context).pop(urlWithDescriptionSelections
.entries
.where((entry) => entry.value)
.map((e) => e.key.key)
.toList());
},
child: Text(widget.onlyOneSelectionAllowed
? 'Pick'
: 'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs'))
],
);
}

View File

@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/logs_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 SettingsPage extends StatefulWidget {
@ -135,18 +138,21 @@ class _SettingsPageState extends State<SettingsPage> {
});
var sourceSpecificFields = sourceProvider.sources.map((e) {
if (e.moreSourceSettingsFormItems.isNotEmpty) {
if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
return GeneratedForm(
items: e.moreSourceSettingsFormItems.map((e) => [e]).toList(),
onValueChanges: (values, valid) {
items: e.additionalSourceSpecificSettingFormItems
.map((e) => [e])
.toList(),
onValueChanges: (values, valid, isBuilding) {
if (valid) {
for (var i = 0; i < values.length; i++) {
settingsProvider.setSettingString(
e.moreSourceSettingsFormItems[i].id, values[i]);
e.additionalSourceSpecificSettingFormItems[i].id,
values[i]);
}
}
},
defaultValues: e.moreSourceSettingsFormItems.map((e) {
defaultValues: e.additionalSourceSpecificSettingFormItems.map((e) {
return settingsProvider.getSettingString(e.id) ?? '';
}).toList());
} else {
@ -238,23 +244,39 @@ class _SettingsPageState extends State<SettingsPage> {
SliverToBoxAdapter(
child: Column(
children: [
height16,
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 Divider(
height: 32,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
TextButton.icon(
onPressed: () {
launchUrlString(settingsProvider.sourceUrl,
mode: LaunchMode.externalApplication);
},
icon: const Icon(Icons.code),
label: const Text(
'App Source',
),
),
TextButton.icon(
onPressed: () {
context.read<LogsProvider>().get().then((logs) {
if (logs.isEmpty) {
showError(ObtainiumError('No Logs'), context);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return const LogsDialog();
});
}
});
},
icon: const Icon(Icons.bug_report_outlined),
label: const Text('App Logs')),
],
),
height16,
],
@ -263,3 +285,71 @@ class _SettingsPageState extends State<SettingsPage> {
]));
}
}
class LogsDialog extends StatefulWidget {
const LogsDialog({super.key});
@override
State<LogsDialog> createState() => _LogsDialogState();
}
class _LogsDialogState extends State<LogsDialog> {
String? logString;
List<int> days = [7, 5, 4, 3, 2, 1];
@override
Widget build(BuildContext context) {
var logsProvider = context.read<LogsProvider>();
void filterLogs(int days) {
logsProvider
.get(after: DateTime.now().subtract(Duration(days: days)))
.then((value) {
setState(() {
String l = value.map((e) => e.toString()).join('\n\n');
logString = l.isNotEmpty ? l : 'No Logs';
});
});
}
if (logString == null) {
filterLogs(days.first);
}
return AlertDialog(
scrollable: true,
title: const Text('Obtainium App Logs'),
content: Column(
children: [
DropdownButtonFormField(
value: days.first,
items: days
.map((e) => DropdownMenuItem(
value: e,
child: Text('$e Day${e == 1 ? '' : 's'}'),
))
.toList(),
onChanged: (d) {
filterLogs(d ?? 7);
}),
const SizedBox(
height: 32,
),
Text(logString ?? '')
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Close')),
TextButton(
onPressed: () {
Share.share(logString ?? '', subject: 'Obtainium App Logs');
Navigator.of(context).pop();
},
child: const Text('Share'))
],
);
}
}

View File

@ -12,9 +12,10 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:install_plugin_v2/install_plugin_v2.dart';
import 'package:installed_apps/app_info.dart';
import 'package:installed_apps/installed_apps.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/notifications_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:package_archive_info/package_archive_info.dart';
import 'package:provider/provider.dart';
import 'package:path_provider/path_provider.dart';
@ -42,6 +43,7 @@ class AppsProvider with ChangeNotifier {
bool loadingApps = false;
bool gettingUpdates = false;
bool forBGTask = false;
LogsProvider logs = LogsProvider();
// Variables to keep track of the app foreground status (installs can't run in the background)
bool isForeground = true;
@ -63,7 +65,9 @@ class AppsProvider with ChangeNotifier {
// Delete existing APKs
(await getExternalStorageDirectory())
?.listSync()
.where((element) => element.path.endsWith('.apk'))
.where((element) =>
element.path.endsWith('.apk') ||
element.path.endsWith('.apk.part'))
.forEach((apk) {
apk.delete();
});
@ -71,38 +75,39 @@ class AppsProvider with ChangeNotifier {
}
}
downloadFile(String url, String fileName, Function? onProgress) async {
downloadFile(String url, String fileName, Function? onProgress,
{bool useExisting = true}) async {
var destDir = (await getExternalStorageDirectory())!.path;
StreamedResponse response =
await Client().send(Request('GET', Uri.parse(url)));
File downloadedFile = File('$destDir/$fileName');
if (downloadedFile.existsSync()) {
downloadedFile.deleteSync();
}
var length = response.contentLength;
var received = 0;
double? progress;
var sink = downloadedFile.openWrite();
await response.stream.map((s) {
received += s.length;
progress = (length != null ? received / length * 100 : 30);
if (!(downloadedFile.existsSync() && useExisting)) {
File tempDownloadedFile = File('${downloadedFile.path}.part');
if (tempDownloadedFile.existsSync()) {
tempDownloadedFile.deleteSync();
}
var length = response.contentLength;
var received = 0;
double? progress;
var sink = tempDownloadedFile.openWrite();
await response.stream.map((s) {
received += s.length;
progress = (length != null ? received / length * 100 : 30);
if (onProgress != null) {
onProgress(progress);
}
return s;
}).pipe(sink);
await sink.close();
progress = null;
if (onProgress != null) {
onProgress(progress);
}
return s;
}).pipe(sink);
await sink.close();
progress = null;
if (onProgress != null) {
onProgress(progress);
}
if (response.statusCode != 200) {
downloadedFile.deleteSync();
throw response.reasonPhrase ?? 'Unknown Error';
if (response.statusCode != 200) {
tempDownloadedFile.deleteSync();
throw response.reasonPhrase ?? 'Unknown Error';
}
tempDownloadedFile.renameSync(downloadedFile.path);
}
return downloadedFile;
}
@ -182,6 +187,15 @@ class AppsProvider with ChangeNotifier {
}
}
Future<bool> canDowngradeApps() async {
try {
await InstalledApps.getAppInfo('com.berdik.letmedowngrade');
return true;
} catch (e) {
return false;
}
}
// 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
// If appropriate criteria are met, the update (never a fresh install) happens silently in the background
@ -195,7 +209,8 @@ class AppsProvider with ChangeNotifier {
// OK
}
if (appInfo != null &&
int.parse(newInfo.buildNumber) < appInfo.versionCode!) {
int.parse(newInfo.buildNumber) < appInfo.versionCode! &&
!(await canDowngradeApps())) {
throw DowngradeError();
}
if (appInfo == null ||
@ -251,6 +266,7 @@ class AppsProvider with ChangeNotifier {
Future<List<String>> downloadAndInstallLatestApps(
List<String> appIds, BuildContext? context) async {
List<String> appsToInstall = [];
List<String> trackOnlyAppsToUpdate = [];
// For all specified Apps, filter out those for which:
// 1. A URL cannot be picked
// 2. That cannot be installed silently (IF no buildContext was given for interactive install)
@ -258,7 +274,10 @@ class AppsProvider with ChangeNotifier {
if (apps[id] == null) {
throw ObtainiumError('App not found');
}
String? apkUrl = await confirmApkUrl(apps[id]!.app, context);
String? apkUrl;
if (!apps[id]!.app.trackOnly) {
apkUrl = await confirmApkUrl(apps[id]!.app, context);
}
if (apkUrl != null) {
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
if (urlInd != apps[id]!.app.preferredApkIndex) {
@ -269,7 +288,16 @@ class AppsProvider with ChangeNotifier {
appsToInstall.add(id);
}
}
if (apps[id]!.app.trackOnly) {
trackOnlyAppsToUpdate.add(id);
}
}
// Mark all specified track-only apps as latest
saveApps(trackOnlyAppsToUpdate.map((e) {
var a = apps[e]!.app;
a.installedVersion = a.latestVersion;
return a;
}).toList());
// Download APKs for all Apps to be installed
MultiAppMultiError errors = MultiAppMultiError();
List<DownloadedApk?> downloadedFiles =
@ -300,10 +328,10 @@ class AppsProvider with ChangeNotifier {
// If Obtainium is being installed, it should be the last one
List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) {
String obtainiumId = 'imranr98_obtainium_${GitHub().host}';
DownloadedApk? temp;
items.removeWhere((element) {
bool res = element.appId == obtainiumId;
bool res =
element.appId == obtainiumId || element.appId == obtainiumTempId;
if (res) {
temp = element;
}
@ -376,7 +404,9 @@ class AppsProvider with ChangeNotifier {
return null; // Can't correct in the background isolate
}
var modded = false;
if (installedInfo == null && app.installedVersion != null) {
if (installedInfo == null &&
app.installedVersion != null &&
!app.trackOnly) {
app.installedVersion = null;
modded = true;
}
@ -412,22 +442,27 @@ class AppsProvider with ChangeNotifier {
}
loadingApps = true;
notifyListeners();
List<FileSystemEntity> appFiles = (await getAppsDir())
List<App> newApps = (await getAppsDir())
.listSync()
.where((item) => item.path.toLowerCase().endsWith('.json'))
.map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync())))
.toList();
apps.clear();
var idsToDelete = apps.values
.map((e) => e.app.id)
.toSet()
.difference(newApps.map((e) => e.id).toSet());
for (var id in idsToDelete) {
apps.remove(id);
}
var sp = SourceProvider();
List<List<String>> errors = [];
for (int i = 0; i < appFiles.length; i++) {
App app =
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync()));
var info = await getInstalledInfo(app.id);
for (int i = 0; i < newApps.length; i++) {
var info = await getInstalledInfo(newApps[i].id);
try {
sp.getSource(app.url);
apps.putIfAbsent(app.id, () => AppInMemory(app, null, info));
sp.getSource(newApps[i].url);
apps[newApps[i].id] = AppInMemory(newApps[i], null, info);
} catch (e) {
errors.add([app.id, app.name, e.toString()]);
errors.add([newApps[i].id, newApps[i].name, e.toString()]);
}
}
if (errors.isNotEmpty) {
@ -491,8 +526,9 @@ class AppsProvider with ChangeNotifier {
currentApp.additionalData,
name: currentApp.name,
id: currentApp.id,
pinned: currentApp.pinned);
newApp.installedVersion = currentApp.installedVersion;
pinned: currentApp.pinned,
trackOnly: currentApp.trackOnly,
installedVersion: currentApp.installedVersion);
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex;
}
@ -599,6 +635,23 @@ class AppsProvider with ChangeNotifier {
foregroundSubscription?.cancel();
super.dispose();
}
Future<List<List<String>>> addAppsByURL(List<String> urls) async {
List<dynamic> results = await SourceProvider().getAppsByURLNaive(urls,
ignoreUrls: apps.values.map((e) => e.app.url).toList());
List<App> pps = results[0];
Map<String, dynamic> errorsMap = results[1];
for (var app in pps) {
if (apps.containsKey(app.id)) {
errorsMap.addAll({app.id: 'App already added'});
} else {
await saveApps([app]);
}
}
List<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
}
}
class APKPicker extends StatefulWidget {

View File

@ -0,0 +1,109 @@
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
const String logTable = 'logs';
const String idColumn = '_id';
const String levelColumn = 'level';
const String messageColumn = 'message';
const String timestampColumn = 'timestamp';
const String dbPath = 'logs.db';
enum LogLevels { debug, info, warning, error }
class Log {
int? id;
late LogLevels level;
late String message;
DateTime timestamp = DateTime.now();
Map<String, Object?> toMap() {
var map = <String, Object?>{
idColumn: id,
levelColumn: level.index,
messageColumn: message,
timestampColumn: timestamp.millisecondsSinceEpoch
};
return map;
}
Log(this.message, this.level);
Log.fromMap(Map<String, Object?> map) {
id = map[idColumn] as int;
level = LogLevels.values.elementAt(map[levelColumn] as int);
message = map[messageColumn] as String;
timestamp =
DateTime.fromMillisecondsSinceEpoch(map[timestampColumn] as int);
}
@override
String toString() {
return '${timestamp.toString()}: ${level.name}: $message';
}
}
class LogsProvider {
LogsProvider({bool runDefaultClear = true}) {
clear(before: DateTime.now().subtract(const Duration(days: 7)));
}
Database? db;
Future<Database> getDB() async {
db ??= await openDatabase(dbPath, version: 1,
onCreate: (Database db, int version) async {
await db.execute('''
create table if not exists $logTable (
$idColumn integer primary key autoincrement,
$levelColumn integer not null,
$messageColumn text not null,
$timestampColumn integer not null)
''');
});
return db!;
}
Future<Log> add(String message, {LogLevels level = LogLevels.info}) async {
Log l = Log(message, level);
l.id = await (await getDB()).insert(logTable, l.toMap());
if (kDebugMode) {
print(l);
}
return l;
}
Future<List<Log>> get({DateTime? before, DateTime? after}) async {
var where = getWhereDates(before: before, after: after);
return (await (await getDB())
.query(logTable, where: where.key, whereArgs: where.value))
.map((e) => Log.fromMap(e))
.toList();
}
Future<int> clear({DateTime? before, DateTime? after}) async {
var where = getWhereDates(before: before, after: after);
var res = await (await getDB())
.delete(logTable, where: where.key, whereArgs: where.value);
if (res > 0) {
add('Cleared $res logs (before = $before, after = $after)');
}
return res;
}
}
MapEntry<String?, List<int>?> getWhereDates(
{DateTime? before, DateTime? after}) {
List<String> where = [];
List<int> whereArgs = [];
if (before != null) {
where.add('$timestampColumn < ?');
whereArgs.add(before.millisecondsSinceEpoch);
}
if (after != null) {
where.add('$timestampColumn > ?');
whereArgs.add(after.millisecondsSinceEpoch);
}
return whereArgs.isEmpty
? const MapEntry(null, null)
: MapEntry(where.join(' and '), whereArgs);
}

View File

@ -2,9 +2,13 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
String obtainiumTempId = 'imranr98_obtainium_${GitHub().host}';
String obtainiumId = 'dev.imranr.obtainium';
enum ThemeSettings { system, light, dark }
enum ColourSettings { basic, materialYou }

View File

@ -4,6 +4,8 @@
import 'dart:convert';
import 'package:html/dom.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/apkmirror.dart';
import 'package:obtainium/app_sources/fdroid.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/app_sources/gitlab.dart';
@ -41,6 +43,7 @@ class App {
late List<String> additionalData;
late DateTime? lastUpdateCheck;
bool pinned = false;
bool trackOnly = false;
App(
this.id,
this.url,
@ -52,7 +55,8 @@ class App {
this.preferredApkIndex,
this.additionalData,
this.lastUpdateCheck,
this.pinned);
this.pinned,
this.trackOnly);
@override
String toString() {
@ -73,12 +77,15 @@ class App {
: List<String>.from(jsonDecode(json['apkUrls'])),
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
json['additionalData'] == null
? SourceProvider().getSource(json['url']).additionalDataDefaults
? SourceProvider()
.getSource(json['url'])
.additionalSourceAppSpecificDefaults
: List<String>.from(jsonDecode(json['additionalData'])),
json['lastUpdateCheck'] == null
? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false);
json['pinned'] ?? false,
json['trackOnly'] ?? false);
Map<String, dynamic> toJson() => {
'id': id,
@ -91,7 +98,8 @@ class App {
'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned
'pinned': pinned,
'trackOnly': trackOnly
};
}
@ -134,6 +142,7 @@ List<String> getLinksFromParsedHTML(
class AppSource {
late String host;
bool enforceTrackOnly = false;
String standardizeURL(String url) {
throw NotImplementedError();
}
@ -147,27 +156,49 @@ class AppSource {
throw NotImplementedError();
}
List<List<GeneratedFormItem>> additionalDataFormItems = [];
List<String> additionalDataDefaults = [];
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
// Different Sources may need different kinds of additional data for Apps
List<List<GeneratedFormItem>> additionalSourceAppSpecificFormItems = [];
List<String> additionalSourceAppSpecificDefaults = [];
// Some additional data may be needed for Apps regardless of Source
final List<GeneratedFormItem> additionalAppSpecificSourceAgnosticFormItems = [
GeneratedFormItem(
label: 'Track-Only',
type: FormItemType.bool,
key: 'trackOnlyFormItemKey')
];
final List<String> additionalAppSpecificSourceAgnosticDefaults = [''];
// Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider
List<GeneratedFormItem> additionalSourceSpecificSettingFormItems = [];
String? changeLogPageFromStandardUrl(String standardUrl) {
throw NotImplementedError();
}
Future<String> apkUrlPrefetchModifier(String apkUrl) {
throw NotImplementedError();
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
return apkUrl;
}
bool canSearch = false;
Future<List<String>> search(String query) {
Future<Map<String, String>> search(String query) {
throw NotImplementedError();
}
String? tryInferringAppId(String standardUrl) {
return null;
}
}
ObtainiumError getObtainiumHttpError(Response res) {
return ObtainiumError(
res.reasonPhrase ?? 'Error ${res.statusCode.toString()}');
}
abstract class MassAppUrlSource {
late String name;
late List<String> requiredArgs;
Future<List<String>> getUrls(List<String> args);
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
}
class SourceProvider {
@ -179,7 +210,8 @@ class SourceProvider {
IzzyOnDroid(),
Mullvad(),
Signal(),
SourceForge()
SourceForge(),
APKMirror()
];
// Add more mass url source classes here so they are available via the service
@ -201,7 +233,7 @@ class SourceProvider {
}
bool ifSourceAppsRequireAdditionalData(AppSource source) {
for (var row in source.additionalDataFormItems) {
for (var row in source.additionalSourceAppSpecificFormItems) {
for (var element in row) {
if (element.required) {
return true;
@ -224,46 +256,56 @@ class SourceProvider {
return false;
}
}
return getSourceHosts().contains(parts.last);
return sources.map((e) => e.host).contains(parts.last);
}
Future<App> getApp(AppSource source, String url, List<String> additionalData,
{String name = '', String? id, bool pinned = false}) async {
{String name = '',
String? id,
bool pinned = false,
bool trackOnly = false,
String? installedVersion}) async {
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
AppNames names = source.getAppNames(standardUrl);
APKDetails apk =
await source.getLatestAPKDetails(standardUrl, additionalData);
if (apk.apkUrls.isEmpty && !trackOnly) {
throw NoAPKError();
}
String apkVersion = apk.version.replaceAll('/', '-');
return App(
id ?? generateTempID(names, source),
id ??
source.tryInferringAppId(standardUrl) ??
generateTempID(names, source),
standardUrl,
names.author[0].toUpperCase() + names.author.substring(1),
name.trim().isNotEmpty
? name
: names.name[0].toUpperCase() + names.name.substring(1),
null,
apk.version.replaceAll('/', '-'),
installedVersion,
apkVersion,
apk.apkUrls,
apk.apkUrls.length - 1,
additionalData,
DateTime.now(),
pinned);
pinned,
trackOnly);
}
// Returns errors in [results, errors] instead of throwing them
Future<List<dynamic>> getApps(List<String> urls,
Future<List<dynamic>> getAppsByURLNaive(List<String> urls,
{List<String> ignoreUrls = const []}) async {
List<App> apps = [];
Map<String, dynamic> errors = {};
for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
try {
var source = getSource(url);
apps.add(await getApp(source, url, source.additionalDataDefaults));
apps.add(await getApp(
source, url, source.additionalSourceAppSpecificDefaults));
} catch (e) {
errors.addAll(<String, dynamic>{url: e});
}
}
return [apps, errors];
}
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
}

View File

@ -21,7 +21,7 @@ packages:
name: archive
url: "https://pub.dartlang.org"
source: hosted
version: "3.3.4"
version: "3.3.5"
args:
dependency: transitive
description:
@ -187,7 +187,7 @@ packages:
name: flutter_launcher_icons
url: "https://pub.dartlang.org"
source: hosted
version: "0.10.0"
version: "0.11.0"
flutter_lints:
dependency: "direct dev"
description:
@ -201,7 +201,7 @@ packages:
name: flutter_local_notifications
url: "https://pub.dartlang.org"
source: hosted
version: "12.0.3"
version: "12.0.4"
flutter_local_notifications_linux:
dependency: transitive
description:
@ -372,7 +372,7 @@ packages:
name: path_provider_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.21"
version: "2.0.22"
path_provider_ios:
dependency: transitive
description:
@ -491,7 +491,7 @@ packages:
name: share_plus
url: "https://pub.dartlang.org"
source: hosted
version: "6.2.0"
version: "6.3.0"
share_plus_platform_interface:
dependency: transitive
description:
@ -567,6 +567,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.9.0"
sqflite:
dependency: "direct main"
description:
name: sqflite
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.0+3"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.0+2"
stack_trace:
dependency: transitive
description:
@ -588,6 +602,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
synchronized:
dependency: transitive
description:
name: synchronized
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0+3"
term_glyph:
dependency: transitive
description:
@ -622,14 +643,14 @@ packages:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.6"
version: "6.1.7"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.21"
version: "6.0.22"
url_launcher_ios:
dependency: transitive
description:
@ -678,7 +699,7 @@ packages:
name: uuid
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.6"
version: "3.0.7"
vector_math:
dependency: transitive
description:
@ -720,7 +741,7 @@ packages:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
version: "3.1.2"
xdg_directories:
dependency: transitive
description:

View File

@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.7.0+56 # When changing this, update the tag in main() accordingly
version: 0.8.0+63 # When changing this, update the tag in main() accordingly
environment:
sdk: '>=2.18.2 <3.0.0'
@ -56,12 +56,13 @@ dependencies:
installed_apps: ^1.3.1
package_archive_info: ^0.1.0
android_alarm_manager_plus: ^2.1.0
sqflite: ^2.2.0+3
dev_dependencies:
flutter_test:
sdk: flutter
flutter_launcher_icons: ^0.10.0
flutter_launcher_icons: ^0.11.0
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is