Compare commits

...

20 Commits

Author SHA1 Message Date
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
55cae0620b Updated packages 2022-11-12 09:46:23 -05:00
ba6cea3ae6 Slightly changed icon 2022-11-12 09:42:42 -05:00
4be33374c2 Merge pull request #106 from ImranR98/search
GitHub Search and Pinned Apps
2022-11-12 03:30:36 -05:00
e2bf834981 Increment version 2022-11-12 02:15:23 -05:00
9bd7ddb21b Added App pinning 2022-11-12 02:14:45 -05:00
905a807ee9 GitHub search added 2022-11-12 01:25:32 -05:00
ab57b97875 Ready to implement GitHub search (UI done) 2022-11-12 01:05:16 -05:00
5db2c5f0b1 Initial changes to support search 2022-11-11 21:44:20 -05:00
e158c23cca Added a note about imported apps 'not installed' 2022-11-10 13:17:51 -05:00
208f125e12 Increment version 2022-11-10 13:02:37 -05:00
b7ccf3fa49 Fixed App import and legacy Apps upgrade (#103) 2022-11-10 12:55:04 -05:00
33 changed files with 814 additions and 350 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

@ -1,12 +1,12 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class FDroid implements AppSource {
@override
late String host = 'f-droid.org';
class FDroid extends AppSource {
FDroid() {
host = 'f-droid.org';
}
@override
String standardizeURL(String url) {
@ -77,13 +77,4 @@ class FDroid implements AppSource {
AppNames getAppNames(String standardUrl) {
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
}

View File

@ -7,9 +7,81 @@ 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
late String host = 'github.com';
class GitHub extends AppSource {
GitHub() {
host = 'github.com';
additionalDataDefaults = ['true', 'true', ''];
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),
))
])
];
additionalDataFormItems = [
[
GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)
],
[
GeneratedFormItem(
label: 'Fallback to older releases', type: FormItemType.bool)
],
[
GeneratedFormItem(
label: 'Filter Release Titles by Regular Expression',
type: FormItemType.string,
required: false,
additionalValidators: [
(value) {
if (value == null || value.isEmpty) {
return null;
}
try {
RegExp(value);
} catch (e) {
return 'Invalid regular expression';
}
return null;
}
])
]
];
canSearch = true;
}
@override
String standardizeURL(String url) {
@ -114,72 +186,29 @@ class GitHub implements AppSource {
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [
[GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)],
[
GeneratedFormItem(
label: 'Fallback to older releases', type: FormItemType.bool)
],
[
GeneratedFormItem(
label: 'Filter Release Titles by Regular Expression',
type: FormItemType.string,
required: false,
additionalValidators: [
(value) {
if (value == null || value.isEmpty) {
return null;
}
try {
RegExp(value);
} catch (e) {
return 'Invalid regular expression';
}
return null;
}
])
]
];
@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),
))
])
];
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) {
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'
});
}
return urlsWithDescriptions;
} else {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw RateLimitError(
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
}
throw ObtainiumError(
res.reasonPhrase ?? 'Error ${res.statusCode.toString()}',
unexpected: true);
}
}
}

View File

@ -1,13 +1,13 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class GitLab implements AppSource {
@override
late String host = 'gitlab.com';
class GitLab extends AppSource {
GitLab() {
host = 'gitlab.com';
}
@override
String standardizeURL(String url) {
@ -72,13 +72,4 @@ class GitLab implements AppSource {
// Same as GitHub
return GitHub().getAppNames(standardUrl);
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
}

View File

@ -1,12 +1,12 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class IzzyOnDroid implements AppSource {
@override
late String host = 'android.izzysoft.de';
class IzzyOnDroid extends AppSource {
IzzyOnDroid() {
host = 'android.izzysoft.de';
}
@override
String standardizeURL(String url) {
@ -63,13 +63,4 @@ class IzzyOnDroid implements AppSource {
AppNames getAppNames(String standardUrl) {
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
}

View File

@ -1,12 +1,12 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class Mullvad implements AppSource {
@override
late String host = 'mullvad.net';
class Mullvad extends AppSource {
Mullvad() {
host = 'mullvad.net';
}
@override
String standardizeURL(String url) {
@ -50,13 +50,4 @@ class Mullvad implements AppSource {
AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
}

View File

@ -1,12 +1,12 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class Signal implements AppSource {
@override
late String host = 'signal.org';
class Signal extends AppSource {
Signal() {
host = 'signal.org';
}
@override
String standardizeURL(String url) {
@ -42,13 +42,4 @@ class Signal implements AppSource {
@override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
}

View File

@ -1,12 +1,12 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class SourceForge implements AppSource {
@override
late String host = 'sourceforge.net';
class SourceForge extends AppSource {
SourceForge() {
host = 'sourceforge.net';
}
@override
String standardizeURL(String url) {
@ -66,13 +66,4 @@ class SourceForge implements AppSource {
return AppNames(runtimeType.toString(),
standardUrl.substring(standardUrl.lastIndexOf('/') + 1));
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
}

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:provider/provider.dart';
class ObtainiumError {
late String message;
ObtainiumError(this.message);
bool unexpected;
ObtainiumError(this.message, {this.unexpected = false});
@override
String toString() {
return message;
@ -48,10 +50,14 @@ class IDChangedError extends ObtainiumError {
: super('Downloaded package ID does not match existing App ID');
}
class NotImplementedError extends ObtainiumError {
NotImplementedError() : super('This class has not implemented this function');
}
class MultiAppMultiError extends ObtainiumError {
Map<String, List<String>> content = {};
MultiAppMultiError() : super('Multiple Errors Placeholder');
MultiAppMultiError() : super('Multiple Errors Placeholder', unexpected: true);
add(String appId, String string) {
var tempIds = content.remove(string);
@ -71,7 +77,9 @@ class MultiAppMultiError extends ObtainiumError {
}
showError(dynamic e, BuildContext context) {
if (e is String || (e is ObtainiumError && e is! MultiAppMultiError)) {
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.6.10';
const String currentVersion = '0.7.2';
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',
@ -143,11 +153,16 @@ class _ObtainiumState extends State<Obtainium> {
[],
0,
['true'],
null)
null,
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,14 +12,20 @@ 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();
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'
});
}
return urlsWithDescriptions;
} else {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw RateLimitError(
@ -33,19 +39,20 @@ class GitHubStars implements MassAppUrlSource {
}
@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

@ -23,24 +23,24 @@ class AppsPageState extends State<AppsPage> {
AppsFilter? filter;
var updatesOnlyFilter =
AppsFilter(includeUptodate: false, includeNonInstalled: false);
Set<String> selectedIds = {};
Set<App> selectedApps = {};
DateTime? refreshingSince;
clearSelected() {
if (selectedIds.isNotEmpty) {
if (selectedApps.isNotEmpty) {
setState(() {
selectedIds.clear();
selectedApps.clear();
});
return true;
}
return false;
}
selectThese(List<String> appIds) {
if (selectedIds.isEmpty) {
selectThese(List<App> apps) {
if (selectedApps.isEmpty) {
setState(() {
for (var a in appIds) {
selectedIds.add(a);
for (var a in apps) {
selectedApps.add(a);
}
});
}
@ -54,16 +54,16 @@ class AppsPageState extends State<AppsPage> {
var currentFilterIsUpdatesOnly =
filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
selectedIds = selectedIds
.where((element) => sortedApps.map((e) => e.app.id).contains(element))
selectedApps = selectedApps
.where((element) => sortedApps.map((e) => e.app).contains(element))
.toSet();
toggleAppSelected(String appId) {
toggleAppSelected(App app) {
setState(() {
if (selectedIds.contains(appId)) {
selectedIds.remove(appId);
if (selectedApps.contains(app)) {
selectedApps.remove(app);
} else {
selectedIds.add(appId);
selectedApps.add(app);
}
});
}
@ -124,15 +124,15 @@ class AppsPageState extends State<AppsPage> {
var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
var existingUpdateIdsAllOrSelected = existingUpdates
.where((element) => selectedIds.isEmpty
.where((element) => selectedApps.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element))
: selectedApps.map((e) => e.id).contains(element))
.toList();
var newInstallIdsAllOrSelected = appsProvider
.findExistingUpdates(nonInstalledOnly: true)
.where((element) => selectedIds.isEmpty
.where((element) => selectedApps.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element))
: selectedApps.map((e) => e.id).contains(element))
.toList();
if (settingsProvider.pinUpdates) {
@ -147,6 +147,17 @@ class AppsPageState extends State<AppsPage> {
sortedApps = [...temp, ...sortedApps];
}
var tempPinned = [];
var tempNotPinned = [];
for (var a in sortedApps) {
if (a.app.pinned) {
tempPinned.add(a);
} else {
tempNotPinned.add(a);
}
}
sortedApps = [...tempPinned, ...tempNotPinned];
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator(
@ -192,11 +203,16 @@ class AppsPageState extends State<AppsPage> {
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return ListTile(
selectedTileColor:
Theme.of(context).colorScheme.primary.withOpacity(0.1),
selected: selectedIds.contains(sortedApps[index].app.id),
tileColor: sortedApps[index].app.pinned
? Colors.grey.withOpacity(0.1)
: Colors.transparent,
selectedTileColor: Theme.of(context)
.colorScheme
.primary
.withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1),
selected: selectedApps.contains(sortedApps[index].app),
onLongPress: () {
toggleAppSelected(sortedApps[index].app.id);
toggleAppSelected(sortedApps[index].app);
},
leading: sortedApps[index].installedInfo != null
? Image.memory(
@ -204,9 +220,19 @@ class AppsPageState extends State<AppsPage> {
gaplessPlayback: true,
)
: null,
title: Text(sortedApps[index].installedInfo?.name ??
sortedApps[index].app.name),
subtitle: Text('By ${sortedApps[index].app.author}'),
title: Text(
sortedApps[index].installedInfo?.name ??
sortedApps[index].app.name,
style: TextStyle(
fontWeight: sortedApps[index].app.pinned
? FontWeight.bold
: FontWeight.normal),
),
subtitle: Text('By ${sortedApps[index].app.author}',
style: TextStyle(
fontWeight: sortedApps[index].app.pinned
? FontWeight.bold
: FontWeight.normal)),
trailing: sortedApps[index].downloadProgress != null
? Text(
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
@ -256,8 +282,8 @@ class AppsPageState extends State<AppsPage> {
textAlign: TextAlign.end,
)))),
onTap: () {
if (selectedIds.isNotEmpty) {
toggleAppSelected(sortedApps[index].app.id);
if (selectedApps.isNotEmpty) {
toggleAppSelected(sortedApps[index].app);
} else {
Navigator.push(
context,
@ -275,25 +301,25 @@ class AppsPageState extends State<AppsPage> {
children: [
IconButton(
onPressed: () {
selectedIds.isEmpty
? selectThese(sortedApps.map((e) => e.app.id).toList())
selectedApps.isEmpty
? selectThese(sortedApps.map((e) => e.app).toList())
: clearSelected();
},
icon: Icon(
selectedIds.isEmpty
selectedApps.isEmpty
? Icons.select_all_outlined
: Icons.deselect_outlined,
color: Theme.of(context).colorScheme.primary,
),
tooltip: selectedIds.isEmpty
tooltip: selectedApps.isEmpty
? 'Select All'
: 'Deselect ${selectedIds.length.toString()}'),
: 'Deselect ${selectedApps.length.toString()}'),
const VerticalDivider(),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
selectedIds.isEmpty
selectedApps.isEmpty
? const SizedBox()
: IconButton(
visualDensity: VisualDensity.compact,
@ -307,11 +333,12 @@ class AppsPageState extends State<AppsPage> {
defaultValues: const [],
initValid: true,
message:
'${selectedIds.length} App${selectedIds.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedIds.length == 1 ? 'it' : 'them'} manually.',
'${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedApps.length == 1 ? 'it' : 'them'} manually.',
);
}).then((values) {
if (values != null) {
appsProvider.removeApps(selectedIds.toList());
appsProvider.removeApps(
selectedApps.map((e) => e.id).toList());
}
});
},
@ -347,7 +374,7 @@ class AppsPageState extends State<AppsPage> {
builder: (BuildContext ctx) {
return GeneratedFormModal(
title:
'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?',
'Install${selectedApps.isEmpty ? ' ' : ' Selected '}Apps?',
message:
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
items: formInputs,
@ -386,11 +413,11 @@ class AppsPageState extends State<AppsPage> {
});
},
tooltip:
'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps',
'Install/Update${selectedApps.isEmpty ? ' ' : ' Selected '}Apps',
icon: const Icon(
Icons.file_download_outlined,
)),
selectedIds.isEmpty
selectedApps.isEmpty
? const SizedBox()
: IconButton(
visualDensity: VisualDensity.compact,
@ -419,7 +446,7 @@ class AppsPageState extends State<AppsPage> {
ctx) {
return AlertDialog(
title: Text(
'Mark ${selectedIds.length} Selected Apps as Updated?'),
'Mark ${selectedApps.length} Selected Apps as Updated?'),
content:
const Text(
'Only applies to installed but out of date Apps.'),
@ -438,9 +465,7 @@ class AppsPageState extends State<AppsPage> {
HapticFeedback
.selectionClick();
appsProvider
.saveApps(selectedIds.map((e) {
var a =
appsProvider.apps[e]!.app;
.saveApps(selectedApps.map((a) {
if (a.installedVersion !=
null) {
a.installedVersion = a.latestVersion;
@ -455,27 +480,84 @@ class AppsPageState extends State<AppsPage> {
'Yes'))
],
);
});
}).whenComplete(() {
Navigator.of(
context)
.pop();
});
},
tooltip:
'Mark Selected Apps as Updated',
icon: const Icon(Icons.done)),
IconButton(
onPressed: () {
var pinStatus = selectedApps
.where((element) =>
element.pinned)
.isEmpty;
appsProvider.saveApps(
selectedApps.map((e) {
e.pinned = pinStatus;
return e;
}).toList());
Navigator.of(context).pop();
},
tooltip:
'${selectedApps.where((element) => element.pinned).isEmpty ? 'Pin to' : 'Unpin from'} top',
icon: Icon(selectedApps
.where((element) =>
element.pinned)
.isEmpty
? Icons.bookmark_outline_rounded
: Icons
.bookmark_remove_outlined),
),
IconButton(
onPressed: () {
String urls = '';
for (var id in selectedIds) {
urls +=
'${appsProvider.apps[id]!.app.url}\n';
for (var a in selectedApps) {
urls += '${a.url}\n';
}
urls = urls.substring(
0, urls.length - 1);
Share.share(urls,
subject:
'${selectedIds.length} Selected App URLs from Obtainium');
'${selectedApps.length} Selected App URLs from Obtainium');
Navigator.of(context).pop();
},
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

@ -12,6 +12,7 @@ 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});
@ -62,7 +63,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Import/Export'),
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
@ -224,6 +224,106 @@ class _ImportExportPageState extends State<ImportExportPage> {
child: const Text(
'Import from URL List',
)),
...sourceProvider.sources
.where((element) => element.canSearch)
.map((source) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
TextButton(
onPressed: importInProgress
? null
: () {
() async {
var values = await showDialog<
List<String>>(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title:
'Search ${source.runtimeType}',
items: [
[
GeneratedFormItem(
label:
'${source.runtimeType} Search Query')
]
],
defaultValues: const [],
);
});
if (values != null &&
values[0].isNotEmpty) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source
.search(values[0]);
if (urlsWithDescriptions
.isNotEmpty) {
var selectedUrls =
await showDialog<
List<
String>?>(
context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urlsWithDescriptions:
urlsWithDescriptions,
defaultSelected:
false,
);
});
if (selectedUrls !=
null &&
selectedUrls
.isNotEmpty) {
var errors =
await addApps(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
'Imported ${selectedUrls.length} Apps',
context);
} else {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
} else {
throw ObtainiumError(
'No results found');
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: Text(
'Search ${source.runtimeType}'))
]))
.toList(),
...sourceProvider.massUrlSources
.map((source) => Column(
crossAxisAlignment:
@ -234,89 +334,93 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: importInProgress
? null
: () {
showDialog(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title:
'Import ${source.name}',
items: source
.requiredArgs
.map((e) => [
GeneratedFormItem(
label: e)
])
.toList(),
defaultValues: const [],
);
}).then((values) {
() async {
var values = await showDialog(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title:
'Import ${source.name}',
items:
source
.requiredArgs
.map(
(e) => [
GeneratedFormItem(label: e)
])
.toList(),
defaultValues: const [],
);
});
if (values != null) {
setState(() {
importInProgress = true;
});
source
.getUrls(values)
.then((urls) {
showDialog<List<String>?>(
context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urls: urls);
})
.then((selectedUrls) {
if (selectedUrls !=
null) {
addApps(selectedUrls)
.then((errors) {
if (errors
.isEmpty) {
showError(
'Imported ${selectedUrls.length} Apps',
context);
} else {
showDialog(
context:
context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}).whenComplete(() {
setState(() {
importInProgress =
false;
var urlsWithDescriptions =
await source
.getUrlsWithDescriptions(
values);
var selectedUrls =
await showDialog<
List<String>?>(
context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urlsWithDescriptions:
urlsWithDescriptions);
});
});
} else {
setState(() {
importInProgress =
false;
});
}
});
}).catchError((e) {
setState(() {
importInProgress =
false;
});
showError(e, context);
});
if (selectedUrls != null) {
var errors =
await addApps(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
'Imported ${selectedUrls.length} Apps',
context);
} else {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: Text('Import ${source.name}'))
]))
.toList()
.toList(),
const Spacer(),
const Divider(
height: 32,
),
const Text(
'Imported Apps may incorrectly show as "Not Installed".\nTo fix this, re-install them through Obtainium.\nThis should not affect App data.\n\nOnly affects URL and third-party import methods.',
textAlign: TextAlign.center,
style: TextStyle(
fontStyle: FontStyle.italic, fontSize: 12)),
const SizedBox(
height: 8,
)
],
)))
]));
@ -379,21 +483,26 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
// ignore: must_be_immutable
class UrlSelectionModal extends StatefulWidget {
UrlSelectionModal({super.key, required this.urls});
UrlSelectionModal(
{super.key,
required this.urlsWithDescriptions,
this.defaultSelected = true});
List<String> urls;
Map<String, String> urlsWithDescriptions;
bool defaultSelected;
@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, () => true);
for (var url in widget.urlsWithDescriptions.entries) {
urlWithDescriptionSelections.putIfAbsent(
url, () => widget.defaultSelected);
}
}
@ -403,21 +512,48 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
scrollable: true,
title: const Text('Select URLs to Import'),
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;
urlWithDescriptionSelections[urlWithD] = value ?? false;
});
}),
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,
)
],
))
]);
})
@ -430,12 +566,13 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
child: const Text('Cancel')),
TextButton(
onPressed: () {
Navigator.of(context).pop(urlSelections.keys
.where((url) => urlSelections[url] ?? false)
Navigator.of(context).pop(urlWithDescriptionSelections.entries
.where((entry) => entry.value)
.map((e) => e.key.key)
.toList());
},
child: Text(
'Import ${urlSelections.values.where((b) => b).length} URLs'))
'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs'))
],
);
}

View File

@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/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 {
@ -238,23 +242,55 @@ 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 {
String logString =
logs.map((e) => e.toString()).join('\n\n');
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Obtainium App Logs',
items: const [],
defaultValues: const [],
message: logString,
initValid: true,
);
}).then((value) {
if (value != null) {
Share.share(
logs
.map((e) => e.toString())
.join('\n\n'),
subject: 'Obtainium App Logs');
}
});
}
});
},
icon: const Icon(Icons.bug_report_outlined),
label: const Text('App Logs')),
],
),
height16,
],

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;
@ -139,12 +141,17 @@ class AppsProvider with ChangeNotifier {
// The former case should be handled (give the App its real ID), the latter is a security issue
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
if (app.id != newInfo.packageName) {
if (apps[app.id] != null) {
if (apps[app.id] != null && !SourceProvider().isTempId(app.id)) {
throw IDChangedError();
}
var originalAppId = app.id;
app.id = newInfo.packageName;
downloadedFile = downloadedFile.renameSync(
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
if (apps[originalAppId] != null) {
await removeApps([originalAppId]);
await saveApps([app]);
}
}
return DownloadedApk(app.id, downloadedFile);
}
@ -177,6 +184,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
@ -190,7 +206,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 ||
@ -295,10 +312,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;
}
@ -485,7 +502,8 @@ class AppsProvider with ChangeNotifier {
currentApp.url,
currentApp.additionalData,
name: currentApp.name,
id: currentApp.id);
id: currentApp.id,
pinned: currentApp.pinned);
newApp.installedVersion = currentApp.installedVersion;
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex;

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

@ -40,6 +40,7 @@ class App {
late int preferredApkIndex;
late List<String> additionalData;
late DateTime? lastUpdateCheck;
bool pinned = false;
App(
this.id,
this.url,
@ -50,11 +51,12 @@ class App {
this.apkUrls,
this.preferredApkIndex,
this.additionalData,
this.lastUpdateCheck);
this.lastUpdateCheck,
this.pinned);
@override
String toString() {
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()}';
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned';
}
factory App.fromJson(Map<String, dynamic> json) => App(
@ -75,7 +77,8 @@ class App {
: List<String>.from(jsonDecode(json['additionalData'])),
json['lastUpdateCheck'] == null
? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']));
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false);
Map<String, dynamic> toJson() => {
'id': id,
@ -87,7 +90,8 @@ class App {
'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned
};
}
@ -128,23 +132,42 @@ List<String> getLinksFromParsedHTML(
.map((e) => '$prependToLinks${e.attributes['href']!}')
.toList();
abstract class AppSource {
class AppSource {
late String host;
String standardizeURL(String url);
String standardizeURL(String url) {
throw NotImplementedError();
}
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData);
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);
String standardUrl, List<String> additionalData) {
throw NotImplementedError();
}
AppNames getAppNames(String standardUrl) {
throw NotImplementedError();
}
List<List<GeneratedFormItem>> additionalDataFormItems = [];
List<String> additionalDataDefaults = [];
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
String? changeLogPageFromStandardUrl(String standardUrl) {
throw NotImplementedError();
}
Future<String> apkUrlPrefetchModifier(String apkUrl) {
throw NotImplementedError();
}
bool canSearch = false;
Future<Map<String, String>> search(String query) {
throw NotImplementedError();
}
}
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 {
@ -191,8 +214,21 @@ class SourceProvider {
String generateTempID(AppNames names, AppSource source) =>
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}';
bool isTempId(String id) {
List<String> parts = id.split('_');
if (parts.length < 3) {
return false;
}
for (int i = 0; i < parts.length - 1; i++) {
if (RegExp('.*[A-Z].*').hasMatch(parts[i])) {
return false;
}
}
return getSourceHosts().contains(parts.last);
}
Future<App> getApp(AppSource source, String url, List<String> additionalData,
{String name = '', String? id}) async {
{String name = '', String? id, bool pinned = false}) async {
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
AppNames names = source.getAppNames(standardUrl);
APKDetails apk =
@ -209,7 +245,8 @@ class SourceProvider {
apk.apkUrls,
apk.apkUrls.length - 1,
additionalData,
DateTime.now());
DateTime.now(),
pinned);
}
// Returns errors in [results, errors] instead of throwing them

View File

@ -21,7 +21,7 @@ packages:
name: archive
url: "https://pub.dartlang.org"
source: hosted
version: "3.3.2"
version: "3.3.4"
args:
dependency: transitive
description:
@ -78,6 +78,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.16.0"
convert:
dependency: transitive
description:
name: convert
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
cross_file:
dependency: transitive
description:
@ -173,7 +180,7 @@ packages:
name: flutter_fgbg
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
version: "0.2.1"
flutter_launcher_icons:
dependency: "direct dev"
description:
@ -457,6 +464,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.3"
pointycastle:
dependency: transitive
description:
name: pointycastle
url: "https://pub.dartlang.org"
source: hosted
version: "3.6.2"
process:
dependency: transitive
description:
@ -553,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:
@ -574,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:
@ -706,7 +741,7 @@ packages:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
version: "3.1.1"
xdg_directories:
dependency: transitive
description:

View File

@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.6.10+54 # When changing this, update the tag in main() accordingly
version: 0.7.2+58 # When changing this, update the tag in main() accordingly
environment:
sdk: '>=2.18.2 <3.0.0'
@ -56,6 +56,7 @@ 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: