Compare commits

...

24 Commits

Author SHA1 Message Date
Imran Remtulla
feed7ffc0b Increment version 2022-11-12 11:27:15 -05:00
Imran Remtulla
296485de8a Added "Reset Install Status" button 2022-11-12 11:21:46 -05:00
Imran Remtulla
d2f226d442 Slight refactoring 2022-11-12 10:44:59 -05:00
Imran Remtulla
cbdb449e35 Bugfix in moveObtainiumToStart 2022-11-12 10:43:12 -05:00
Imran Remtulla
3100a3a08c Added descriptions to GitHub starred imports 2022-11-12 10:40:54 -05:00
Imran Remtulla
18951d6461 Added descriptions to search results 2022-11-12 10:35:59 -05:00
Imran Remtulla
0e0a39a40f Allow App downgrades if com.berdik.letmedowngrade installed 2022-11-12 10:05:46 -05:00
Imran Remtulla
55cae0620b Updated packages 2022-11-12 09:46:23 -05:00
Imran Remtulla
ba6cea3ae6 Slightly changed icon 2022-11-12 09:42:42 -05:00
Imran Remtulla
4be33374c2 Merge pull request #106 from ImranR98/search
GitHub Search and Pinned Apps
2022-11-12 03:30:36 -05:00
Imran Remtulla
e2bf834981 Increment version 2022-11-12 02:15:23 -05:00
Imran Remtulla
9bd7ddb21b Added App pinning 2022-11-12 02:14:45 -05:00
Imran Remtulla
905a807ee9 GitHub search added 2022-11-12 01:25:32 -05:00
Imran Remtulla
ab57b97875 Ready to implement GitHub search (UI done) 2022-11-12 01:05:16 -05:00
Imran Remtulla
5db2c5f0b1 Initial changes to support search 2022-11-11 21:44:20 -05:00
Imran Remtulla
e158c23cca Added a note about imported apps 'not installed' 2022-11-10 13:17:51 -05:00
Imran Remtulla
208f125e12 Increment version 2022-11-10 13:02:37 -05:00
Imran Remtulla
b7ccf3fa49 Fixed App import and legacy Apps upgrade (#103) 2022-11-10 12:55:04 -05:00
Imran Remtulla
c746e89052 Fixed error reporting in add app box 2022-11-10 10:29:00 -05:00
Imran Remtulla
ee758e8470 Fixed bug from previous commit 2022-11-10 10:26:36 -05:00
Imran Remtulla
68d903e092 Increment version 2022-11-09 20:57:03 -05:00
Imran Remtulla
c47b752344 Cancel update notifications on new install (#101)
Can't get more granular due to flutter_local_notifications/issues/1700
2022-11-09 20:56:40 -05:00
Imran Remtulla
62a05996cf Fixed regression for #20 from last cleanup 2022-11-09 19:25:41 -05:00
Imran Remtulla
1cda941fbe Removed APKMirror code (previously unused but present) 2022-11-07 16:05:07 -05:00
34 changed files with 613 additions and 443 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,113 +0,0 @@
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 APKMirror implements AppSource {
@override
late String host = 'apkmirror.com';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl#whatsnew';
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
var originalUri = Uri.parse(apkUrl);
var res = await get(originalUri);
if (res.statusCode != 200) {
throw false;
}
var href =
parse(res.body).querySelector('.downloadButton')?.attributes['href'];
if (href == null) {
throw false;
}
var res2 = await get(Uri.parse('${originalUri.origin}$href'), headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
});
if (res2.statusCode != 200) {
throw false;
}
var links = parse(res2.body)
.querySelectorAll('a')
.where((element) => element.innerHtml == 'here')
.map((e) => e.attributes['href'])
.where((element) => element != null)
.toList();
if (links.isEmpty) {
throw false;
}
return '${originalUri.origin}${links[0]}';
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse('$standardUrl/feed'));
if (res.statusCode != 200) {
throw NoReleasesError();
}
var nextUrl = parse(res.body)
.querySelector('item')
?.querySelector('link')
?.nextElementSibling
?.innerHtml;
if (nextUrl == null) {
throw NoReleasesError();
}
Response res2 = await get(Uri.parse(nextUrl), headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
});
if (res2.statusCode != 200) {
throw NoReleasesError();
}
var html2 = parse(res2.body);
var origin = Uri.parse(standardUrl).origin;
List<String> apkUrls = html2
.querySelectorAll('.apkm-badge')
.map((e) => e.innerHtml != 'APK'
? ''
: e.previousElementSibling?.attributes['href'] ?? '')
.where((element) => element.isNotEmpty)
.map((e) => '$origin$e')
.toList();
if (apkUrls.isEmpty) {
throw NoAPKError();
}
var version = html2.querySelector('span.active.accent_color')?.innerHtml;
if (version == null) {
throw NoVersionError();
}
return APKDetails(version, apkUrls);
}
@override
AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
return AppNames(names[1], names[2]);
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
}

View File

@@ -1,12 +1,12 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class FDroid implements AppSource { class FDroid extends AppSource {
@override FDroid() {
late String host = 'f-droid.org'; host = 'f-droid.org';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@@ -77,13 +77,4 @@ class FDroid implements AppSource {
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last); 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:obtainium/providers/source_provider.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class GitHub implements AppSource { class GitHub extends AppSource {
@override GitHub() {
late String host = 'github.com'; 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 @override
String standardizeURL(String url) { String standardizeURL(String url) {
@@ -72,7 +144,7 @@ class GitHub implements AppSource {
if (regexFilter != null && if (regexFilter != null &&
!RegExp(regexFilter) !RegExp(regexFilter)
.hasMatch((releases[i]['tag_name'] as String).trim())) { .hasMatch((releases[i]['name'] as String).trim())) {
continue; continue;
} }
var apkUrls = getReleaseAPKUrls(releases[i]); var apkUrls = getReleaseAPKUrls(releases[i]);
@@ -114,72 +186,29 @@ class GitHub implements AppSource {
} }
@override @override
List<List<GeneratedFormItem>> additionalDataFormItems = [ Future<Map<String, String>> search(String query) async {
[GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)], Response res = await get(Uri.parse(
[ 'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100'));
GeneratedFormItem( if (res.statusCode == 200) {
label: 'Fallback to older releases', type: FormItemType.bool) Map<String, String> urlsWithDescriptions = {};
], for (var e in (jsonDecode(res.body)['items'] as List<dynamic>)) {
[ urlsWithDescriptions.addAll({
GeneratedFormItem( e['html_url'] as String: e['description'] != null
label: 'Filter Release Titles by Regular Expression', ? e['description'] as String
type: FormItemType.string, : 'No description'
required: false, });
additionalValidators: [ }
(value) { return urlsWithDescriptions;
if (value == null || value.isEmpty) { } else {
return null; if (res.headers['x-ratelimit-remaining'] == '0') {
} throw RateLimitError(
try { (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
RegExp(value); 60000000)
} catch (e) { .round());
return 'Invalid regular expression'; }
} throw ObtainiumError(
return null; res.reasonPhrase ?? 'Error ${res.statusCode.toString()}',
} unexpected: true);
]) }
] }
];
@override
List<String> additionalDataDefaults = ['true', 'true', ''];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [
GeneratedFormItem(
label: 'GitHub Personal Access Token (Increases Rate Limit)',
id: 'github-creds',
required: false,
additionalValidators: [
(value) {
if (value != null && value.trim().isNotEmpty) {
if (value
.split(':')
.where((element) => element.trim().isNotEmpty)
.length !=
2) {
return 'PAT must be in this format: username:token';
}
}
return null;
}
],
hint: 'username:token',
belowWidgets: [
const SizedBox(
height: 8,
),
GestureDetector(
onTap: () {
launchUrlString(
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
mode: LaunchMode.externalApplication);
},
child: const Text(
'About GitHub PATs',
style: TextStyle(
decoration: TextDecoration.underline, fontSize: 12),
))
])
];
} }

View File

@@ -1,13 +1,13 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class GitLab implements AppSource { class GitLab extends AppSource {
@override GitLab() {
late String host = 'gitlab.com'; host = 'gitlab.com';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@@ -72,13 +72,4 @@ class GitLab implements AppSource {
// Same as GitHub // Same as GitHub
return GitHub().getAppNames(standardUrl); 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:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class IzzyOnDroid implements AppSource { class IzzyOnDroid extends AppSource {
@override IzzyOnDroid() {
late String host = 'android.izzysoft.de'; host = 'android.izzysoft.de';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@@ -63,13 +63,4 @@ class IzzyOnDroid implements AppSource {
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last); 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:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class Mullvad implements AppSource { class Mullvad extends AppSource {
@override Mullvad() {
late String host = 'mullvad.net'; host = 'mullvad.net';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@@ -50,13 +50,4 @@ class Mullvad implements AppSource {
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN'); 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 'dart:convert';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class Signal implements AppSource { class Signal extends AppSource {
@override Signal() {
late String host = 'signal.org'; host = 'signal.org';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@@ -42,13 +42,4 @@ class Signal implements AppSource {
@override @override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal'); 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:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class SourceForge implements AppSource { class SourceForge extends AppSource {
@override SourceForge() {
late String host = 'sourceforge.net'; host = 'sourceforge.net';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@@ -66,13 +66,4 @@ class SourceForge implements AppSource {
return AppNames(runtimeType.toString(), return AppNames(runtimeType.toString(),
standardUrl.substring(standardUrl.lastIndexOf('/') + 1)); standardUrl.substring(standardUrl.lastIndexOf('/') + 1));
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:obtainium/providers/apps_provider.dart';
class ObtainiumError { class ObtainiumError {
late String message; late String message;
ObtainiumError(this.message); bool unexpected;
ObtainiumError(this.message, {this.unexpected = false});
@override @override
String toString() { String toString() {
return message; return message;
@@ -48,10 +48,14 @@ class IDChangedError extends ObtainiumError {
: super('Downloaded package ID does not match existing App ID'); : 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 { class MultiAppMultiError extends ObtainiumError {
Map<String, List<String>> content = {}; Map<String, List<String>> content = {};
MultiAppMultiError() : super('Multiple Errors Placeholder'); MultiAppMultiError() : super('Multiple Errors Placeholder', unexpected: true);
add(String appId, String string) { add(String appId, String string) {
var tempIds = content.remove(string); var tempIds = content.remove(string);
@@ -71,7 +75,7 @@ class MultiAppMultiError extends ObtainiumError {
} }
showError(dynamic e, BuildContext context) { showError(dynamic e, BuildContext context) {
if (e is String || (e is ObtainiumError && e is! MultiAppMultiError)) { if (e is String || (e is ObtainiumError && !e.unexpected)) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())), SnackBar(content: Text(e.toString())),
); );

View File

@@ -15,7 +15,7 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
const String currentVersion = '0.6.8'; const String currentVersion = '0.7.1';
const String currentReleaseTag = const String currentReleaseTag =
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES 'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@@ -134,7 +134,7 @@ class _ObtainiumState extends State<Obtainium> {
Permission.notification.request(); Permission.notification.request();
appsProvider.saveApps([ appsProvider.saveApps([
App( App(
'dev.imranr.obtainium', obtainiumId,
'https://github.com/ImranR98/Obtainium', 'https://github.com/ImranR98/Obtainium',
'ImranR98', 'ImranR98',
'Obtainium', 'Obtainium',
@@ -143,7 +143,8 @@ class _ObtainiumState extends State<Obtainium> {
[], [],
0, 0,
['true'], ['true'],
null) null,
false)
]); ]);
} }
// Register the background update task according to the user's setting // Register the background update task according to the user's setting

View File

@@ -12,14 +12,20 @@ class GitHubStars implements MassAppUrlSource {
@override @override
late List<String> requiredArgs = ['Username']; late List<String> requiredArgs = ['Username'];
Future<List<String>> getOnePageOfUserStarredUrls( Future<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions(
String username, int page) async { String username, int page) async {
Response res = await get(Uri.parse( Response res = await get(Uri.parse(
'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page')); 'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
return (jsonDecode(res.body) as List<dynamic>) Map<String, String> urlsWithDescriptions = {};
.map((e) => e['html_url'] as String) for (var e in (jsonDecode(res.body) as List<dynamic>)) {
.toList(); urlsWithDescriptions.addAll({
e['html_url'] as String: e['description'] != null
? e['description'] as String
: 'No description'
});
}
return urlsWithDescriptions;
} else { } else {
if (res.headers['x-ratelimit-remaining'] == '0') { if (res.headers['x-ratelimit-remaining'] == '0') {
throw RateLimitError( throw RateLimitError(
@@ -33,19 +39,20 @@ class GitHubStars implements MassAppUrlSource {
} }
@override @override
Future<List<String>> getUrls(List<String> args) async { Future<Map<String, String>> getUrlsWithDescriptions(List<String> args) async {
if (args.length != requiredArgs.length) { if (args.length != requiredArgs.length) {
throw ObtainiumError('Wrong number of arguments provided'); throw ObtainiumError('Wrong number of arguments provided');
} }
List<String> urls = []; Map<String, String> urlsWithDescriptions = {};
var page = 1; var page = 1;
while (true) { while (true) {
var pageUrls = await getOnePageOfUserStarredUrls(args[0], page++); var pageUrls =
urls.addAll(pageUrls); await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++);
urlsWithDescriptions.addAll(pageUrls);
if (pageUrls.length < 100) { if (pageUrls.length < 100) {
break; break;
} }
} }
return urls; return urlsWithDescriptions;
} }
} }

View File

@@ -57,7 +57,9 @@ class _AddAppPageState extends State<AddAppPage> {
} catch (e) { } catch (e) {
return e is String return e is String
? e ? e
: 'Error'; : e is ObtainiumError
? e.toString()
: 'Error';
} }
return null; return null;
} }

View File

@@ -23,24 +23,24 @@ class AppsPageState extends State<AppsPage> {
AppsFilter? filter; AppsFilter? filter;
var updatesOnlyFilter = var updatesOnlyFilter =
AppsFilter(includeUptodate: false, includeNonInstalled: false); AppsFilter(includeUptodate: false, includeNonInstalled: false);
Set<String> selectedIds = {}; Set<App> selectedApps = {};
DateTime? refreshingSince; DateTime? refreshingSince;
clearSelected() { clearSelected() {
if (selectedIds.isNotEmpty) { if (selectedApps.isNotEmpty) {
setState(() { setState(() {
selectedIds.clear(); selectedApps.clear();
}); });
return true; return true;
} }
return false; return false;
} }
selectThese(List<String> appIds) { selectThese(List<App> apps) {
if (selectedIds.isEmpty) { if (selectedApps.isEmpty) {
setState(() { setState(() {
for (var a in appIds) { for (var a in apps) {
selectedIds.add(a); selectedApps.add(a);
} }
}); });
} }
@@ -54,16 +54,16 @@ class AppsPageState extends State<AppsPage> {
var currentFilterIsUpdatesOnly = var currentFilterIsUpdatesOnly =
filter?.isIdenticalTo(updatesOnlyFilter) ?? false; filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
selectedIds = selectedIds selectedApps = selectedApps
.where((element) => sortedApps.map((e) => e.app.id).contains(element)) .where((element) => sortedApps.map((e) => e.app).contains(element))
.toSet(); .toSet();
toggleAppSelected(String appId) { toggleAppSelected(App app) {
setState(() { setState(() {
if (selectedIds.contains(appId)) { if (selectedApps.contains(app)) {
selectedIds.remove(appId); selectedApps.remove(app);
} else { } else {
selectedIds.add(appId); selectedApps.add(app);
} }
}); });
} }
@@ -124,15 +124,15 @@ class AppsPageState extends State<AppsPage> {
var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true); var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
var existingUpdateIdsAllOrSelected = existingUpdates var existingUpdateIdsAllOrSelected = existingUpdates
.where((element) => selectedIds.isEmpty .where((element) => selectedApps.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty ? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element)) : selectedApps.map((e) => e.id).contains(element))
.toList(); .toList();
var newInstallIdsAllOrSelected = appsProvider var newInstallIdsAllOrSelected = appsProvider
.findExistingUpdates(nonInstalledOnly: true) .findExistingUpdates(nonInstalledOnly: true)
.where((element) => selectedIds.isEmpty .where((element) => selectedApps.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty ? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element)) : selectedApps.map((e) => e.id).contains(element))
.toList(); .toList();
if (settingsProvider.pinUpdates) { if (settingsProvider.pinUpdates) {
@@ -147,6 +147,17 @@ class AppsPageState extends State<AppsPage> {
sortedApps = [...temp, ...sortedApps]; 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( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator( body: RefreshIndicator(
@@ -192,11 +203,16 @@ class AppsPageState extends State<AppsPage> {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) { (BuildContext context, int index) {
return ListTile( return ListTile(
selectedTileColor: tileColor: sortedApps[index].app.pinned
Theme.of(context).colorScheme.primary.withOpacity(0.1), ? Colors.grey.withOpacity(0.1)
selected: selectedIds.contains(sortedApps[index].app.id), : Colors.transparent,
selectedTileColor: Theme.of(context)
.colorScheme
.primary
.withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1),
selected: selectedApps.contains(sortedApps[index].app),
onLongPress: () { onLongPress: () {
toggleAppSelected(sortedApps[index].app.id); toggleAppSelected(sortedApps[index].app);
}, },
leading: sortedApps[index].installedInfo != null leading: sortedApps[index].installedInfo != null
? Image.memory( ? Image.memory(
@@ -204,9 +220,19 @@ class AppsPageState extends State<AppsPage> {
gaplessPlayback: true, gaplessPlayback: true,
) )
: null, : null,
title: Text(sortedApps[index].installedInfo?.name ?? title: Text(
sortedApps[index].app.name), sortedApps[index].installedInfo?.name ??
subtitle: Text('By ${sortedApps[index].app.author}'), 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 trailing: sortedApps[index].downloadProgress != null
? Text( ? Text(
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%') 'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
@@ -256,8 +282,8 @@ class AppsPageState extends State<AppsPage> {
textAlign: TextAlign.end, textAlign: TextAlign.end,
)))), )))),
onTap: () { onTap: () {
if (selectedIds.isNotEmpty) { if (selectedApps.isNotEmpty) {
toggleAppSelected(sortedApps[index].app.id); toggleAppSelected(sortedApps[index].app);
} else { } else {
Navigator.push( Navigator.push(
context, context,
@@ -275,25 +301,25 @@ class AppsPageState extends State<AppsPage> {
children: [ children: [
IconButton( IconButton(
onPressed: () { onPressed: () {
selectedIds.isEmpty selectedApps.isEmpty
? selectThese(sortedApps.map((e) => e.app.id).toList()) ? selectThese(sortedApps.map((e) => e.app).toList())
: clearSelected(); : clearSelected();
}, },
icon: Icon( icon: Icon(
selectedIds.isEmpty selectedApps.isEmpty
? Icons.select_all_outlined ? Icons.select_all_outlined
: Icons.deselect_outlined, : Icons.deselect_outlined,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
tooltip: selectedIds.isEmpty tooltip: selectedApps.isEmpty
? 'Select All' ? 'Select All'
: 'Deselect ${selectedIds.length.toString()}'), : 'Deselect ${selectedApps.length.toString()}'),
const VerticalDivider(), const VerticalDivider(),
Expanded( Expanded(
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
selectedIds.isEmpty selectedApps.isEmpty
? const SizedBox() ? const SizedBox()
: IconButton( : IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@@ -307,11 +333,12 @@ class AppsPageState extends State<AppsPage> {
defaultValues: const [], defaultValues: const [],
initValid: true, initValid: true,
message: 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) { }).then((values) {
if (values != null) { 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) { builder: (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
title: title:
'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?', 'Install${selectedApps.isEmpty ? ' ' : ' Selected '}Apps?',
message: message:
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.', '${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
items: formInputs, items: formInputs,
@@ -386,11 +413,11 @@ class AppsPageState extends State<AppsPage> {
}); });
}, },
tooltip: tooltip:
'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps', 'Install/Update${selectedApps.isEmpty ? ' ' : ' Selected '}Apps',
icon: const Icon( icon: const Icon(
Icons.file_download_outlined, Icons.file_download_outlined,
)), )),
selectedIds.isEmpty selectedApps.isEmpty
? const SizedBox() ? const SizedBox()
: IconButton( : IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@@ -419,7 +446,7 @@ class AppsPageState extends State<AppsPage> {
ctx) { ctx) {
return AlertDialog( return AlertDialog(
title: Text( title: Text(
'Mark ${selectedIds.length} Selected Apps as Updated?'), 'Mark ${selectedApps.length} Selected Apps as Updated?'),
content: content:
const Text( const Text(
'Only applies to installed but out of date Apps.'), 'Only applies to installed but out of date Apps.'),
@@ -438,9 +465,7 @@ class AppsPageState extends State<AppsPage> {
HapticFeedback HapticFeedback
.selectionClick(); .selectionClick();
appsProvider appsProvider
.saveApps(selectedIds.map((e) { .saveApps(selectedApps.map((a) {
var a =
appsProvider.apps[e]!.app;
if (a.installedVersion != if (a.installedVersion !=
null) { null) {
a.installedVersion = a.latestVersion; a.installedVersion = a.latestVersion;
@@ -455,27 +480,84 @@ class AppsPageState extends State<AppsPage> {
'Yes')) 'Yes'))
], ],
); );
}); }).whenComplete(() {
Navigator.of(
context)
.pop();
});
}, },
tooltip: tooltip:
'Mark Selected Apps as Updated', 'Mark Selected Apps as Updated',
icon: const Icon(Icons.done)), 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( IconButton(
onPressed: () { onPressed: () {
String urls = ''; String urls = '';
for (var id in selectedIds) { for (var a in selectedApps) {
urls += urls += '${a.url}\n';
'${appsProvider.apps[id]!.app.url}\n';
} }
urls = urls.substring( urls = urls.substring(
0, urls.length - 1); 0, urls.length - 1);
Share.share(urls, Share.share(urls,
subject: subject:
'${selectedIds.length} Selected App URLs from Obtainium'); '${selectedApps.length} Selected App URLs from Obtainium');
Navigator.of(context).pop();
}, },
tooltip: 'Share Selected App URLs', tooltip: 'Share Selected App URLs',
icon: const Icon(Icons.share), 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:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ImportExportPage extends StatefulWidget { class ImportExportPage extends StatefulWidget {
const ImportExportPage({super.key}); const ImportExportPage({super.key});
@@ -62,7 +63,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Import/Export'), const CustomAppBar(title: 'Import/Export'),
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false,
child: Padding( child: Padding(
padding: padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16), const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
@@ -224,6 +224,106 @@ class _ImportExportPageState extends State<ImportExportPage> {
child: const Text( child: const Text(
'Import from URL List', '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 ...sourceProvider.massUrlSources
.map((source) => Column( .map((source) => Column(
crossAxisAlignment: crossAxisAlignment:
@@ -234,89 +334,93 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: importInProgress onPressed: importInProgress
? null ? null
: () { : () {
showDialog( () async {
context: context, var values = await showDialog(
builder: context: context,
(BuildContext ctx) { builder:
return GeneratedFormModal( (BuildContext ctx) {
title: return GeneratedFormModal(
'Import ${source.name}', title:
items: source 'Import ${source.name}',
.requiredArgs items:
.map((e) => [ source
GeneratedFormItem( .requiredArgs
label: e) .map(
]) (e) => [
.toList(), GeneratedFormItem(label: e)
defaultValues: const [], ])
); .toList(),
}).then((values) { defaultValues: const [],
);
});
if (values != null) { if (values != null) {
setState(() { setState(() {
importInProgress = true; importInProgress = true;
}); });
source var urlsWithDescriptions =
.getUrls(values) await source
.then((urls) { .getUrlsWithDescriptions(
showDialog<List<String>?>( values);
context: context, var selectedUrls =
builder: await showDialog<
(BuildContext List<String>?>(
ctx) { context: context,
return UrlSelectionModal( builder:
urls: urls); (BuildContext
}) ctx) {
.then((selectedUrls) { return UrlSelectionModal(
if (selectedUrls != urlsWithDescriptions:
null) { urlsWithDescriptions);
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;
}); });
}); if (selectedUrls != null) {
} else { var errors =
setState(() { await addApps(
importInProgress = selectedUrls);
false; if (errors.isEmpty) {
}); // ignore: use_build_context_synchronously
} showError(
}); 'Imported ${selectedUrls.length} Apps',
}).catchError((e) { context);
setState(() { } else {
importInProgress = showDialog(
false; context: context,
}); builder:
showError(e, context); (BuildContext
}); ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
} }
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
}); });
}, },
child: Text('Import ${source.name}')) 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 // ignore: must_be_immutable
class UrlSelectionModal extends StatefulWidget { 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 @override
State<UrlSelectionModal> createState() => _UrlSelectionModalState(); State<UrlSelectionModal> createState() => _UrlSelectionModalState();
} }
class _UrlSelectionModalState extends State<UrlSelectionModal> { class _UrlSelectionModalState extends State<UrlSelectionModal> {
Map<String, bool> urlSelections = {}; Map<MapEntry<String, String>, bool> urlWithDescriptionSelections = {};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
for (var url in widget.urls) { for (var url in widget.urlsWithDescriptions.entries) {
urlSelections.putIfAbsent(url, () => true); urlWithDescriptionSelections.putIfAbsent(
url, () => widget.defaultSelected);
} }
} }
@@ -403,21 +512,48 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
scrollable: true, scrollable: true,
title: const Text('Select URLs to Import'), title: const Text('Select URLs to Import'),
content: Column(children: [ content: Column(children: [
...urlSelections.keys.map((url) { ...urlWithDescriptionSelections.keys.map((urlWithD) {
return Row(children: [ return Row(children: [
Checkbox( Checkbox(
value: urlSelections[url], value: urlWithDescriptionSelections[urlWithD],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
urlSelections[url] = value ?? false; urlWithDescriptionSelections[urlWithD] = value ?? false;
}); });
}), }),
const SizedBox( const SizedBox(
width: 8, width: 8,
), ),
Expanded( Expanded(
child: Text( child: Column(
Uri.parse(url).path.substring(1), 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')), child: const Text('Cancel')),
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(urlSelections.keys Navigator.of(context).pop(urlWithDescriptionSelections.entries
.where((url) => urlSelections[url] ?? false) .where((entry) => entry.value)
.map((e) => e.key.key)
.toList()); .toList());
}, },
child: Text( child: Text(
'Import ${urlSelections.values.where((b) => b).length} URLs')) 'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs'))
], ],
); );
} }

View File

@@ -15,6 +15,7 @@ import 'package:installed_apps/installed_apps.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/notifications_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:package_archive_info/package_archive_info.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -139,12 +140,17 @@ class AppsProvider with ChangeNotifier {
// The former case should be handled (give the App its real ID), the latter is a security issue // 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); var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
if (app.id != newInfo.packageName) { if (app.id != newInfo.packageName) {
if (apps[app.id] != null) { if (apps[app.id] != null && !SourceProvider().isTempId(app.id)) {
throw IDChangedError(); throw IDChangedError();
} }
var originalAppId = app.id;
app.id = newInfo.packageName; app.id = newInfo.packageName;
downloadedFile = downloadedFile.renameSync( downloadedFile = downloadedFile.renameSync(
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk'); '${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
if (apps[originalAppId] != null) {
await removeApps([originalAppId]);
await saveApps([app]);
}
} }
return DownloadedApk(app.id, downloadedFile); return DownloadedApk(app.id, downloadedFile);
} }
@@ -177,6 +183,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 // Unfortunately this 'await' does not actually wait for the APK to finish installing
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing // So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
// If appropriate criteria are met, the update (never a fresh install) happens silently in the background // If appropriate criteria are met, the update (never a fresh install) happens silently in the background
@@ -190,7 +205,8 @@ class AppsProvider with ChangeNotifier {
// OK // OK
} }
if (appInfo != null && if (appInfo != null &&
int.parse(newInfo.buildNumber) < appInfo.versionCode!) { int.parse(newInfo.buildNumber) < appInfo.versionCode! &&
!(await canDowngradeApps())) {
throw DowngradeError(); throw DowngradeError();
} }
if (appInfo == null || if (appInfo == null ||
@@ -295,10 +311,10 @@ class AppsProvider with ChangeNotifier {
// If Obtainium is being installed, it should be the last one // If Obtainium is being installed, it should be the last one
List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) { List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) {
String obtainiumId = 'imranr98_obtainium_${GitHub().host}';
DownloadedApk? temp; DownloadedApk? temp;
items.removeWhere((element) { items.removeWhere((element) {
bool res = element.appId == obtainiumId; bool res =
element.appId == obtainiumId || element.appId == obtainiumTempId;
if (res) { if (res) {
temp = element; temp = element;
} }
@@ -335,6 +351,8 @@ class AppsProvider with ChangeNotifier {
throw errors; throw errors;
} }
NotificationsProvider().cancel(UpdateNotification([]).id);
return downloadedFiles.map((e) => e!.appId).toList(); return downloadedFiles.map((e) => e!.appId).toList();
} }
@@ -483,7 +501,8 @@ class AppsProvider with ChangeNotifier {
currentApp.url, currentApp.url,
currentApp.additionalData, currentApp.additionalData,
name: currentApp.name, name: currentApp.name,
id: currentApp.id); id: currentApp.id,
pinned: currentApp.pinned);
newApp.installedVersion = currentApp.installedVersion; newApp.installedVersion = currentApp.installedVersion;
if (currentApp.preferredApkIndex < newApp.apkUrls.length) { if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex; newApp.preferredApkIndex = currentApp.preferredApkIndex;

View File

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

View File

@@ -2,9 +2,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.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 ThemeSettings { system, light, dark }
enum ColourSettings { basic, materialYou } enum ColourSettings { basic, materialYou }

View File

@@ -40,6 +40,7 @@ class App {
late int preferredApkIndex; late int preferredApkIndex;
late List<String> additionalData; late List<String> additionalData;
late DateTime? lastUpdateCheck; late DateTime? lastUpdateCheck;
bool pinned = false;
App( App(
this.id, this.id,
this.url, this.url,
@@ -50,11 +51,12 @@ class App {
this.apkUrls, this.apkUrls,
this.preferredApkIndex, this.preferredApkIndex,
this.additionalData, this.additionalData,
this.lastUpdateCheck); this.lastUpdateCheck,
this.pinned);
@override @override
String toString() { 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( factory App.fromJson(Map<String, dynamic> json) => App(
@@ -75,7 +77,8 @@ class App {
: List<String>.from(jsonDecode(json['additionalData'])), : List<String>.from(jsonDecode(json['additionalData'])),
json['lastUpdateCheck'] == null json['lastUpdateCheck'] == null
? null ? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck'])); : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false);
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
@@ -87,7 +90,8 @@ class App {
'apkUrls': jsonEncode(apkUrls), 'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex, 'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData), 'additionalData': jsonEncode(additionalData),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch 'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned
}; };
} }
@@ -128,23 +132,42 @@ List<String> getLinksFromParsedHTML(
.map((e) => '$prependToLinks${e.attributes['href']!}') .map((e) => '$prependToLinks${e.attributes['href']!}')
.toList(); .toList();
abstract class AppSource { class AppSource {
late String host; late String host;
String standardizeURL(String url); String standardizeURL(String url) {
throw NotImplementedError();
}
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData); String standardUrl, List<String> additionalData) {
AppNames getAppNames(String standardUrl); throw NotImplementedError();
late List<List<GeneratedFormItem>> additionalDataFormItems; }
late List<String> additionalDataDefaults;
late List<GeneratedFormItem> moreSourceSettingsFormItems; AppNames getAppNames(String standardUrl) {
String? changeLogPageFromStandardUrl(String standardUrl); throw NotImplementedError();
Future<String> apkUrlPrefetchModifier(String apkUrl); }
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 { abstract class MassAppUrlSource {
late String name; late String name;
late List<String> requiredArgs; late List<String> requiredArgs;
Future<List<String>> getUrls(List<String> args); Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
} }
class SourceProvider { class SourceProvider {
@@ -156,8 +179,7 @@ class SourceProvider {
IzzyOnDroid(), IzzyOnDroid(),
Mullvad(), Mullvad(),
Signal(), Signal(),
SourceForge(), SourceForge()
// APKMirror()
]; ];
// Add more mass url source classes here so they are available via the service // Add more mass url source classes here so they are available via the service
@@ -192,8 +214,21 @@ class SourceProvider {
String generateTempID(AppNames names, AppSource source) => String generateTempID(AppNames names, AppSource source) =>
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}'; '${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, 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)); String standardUrl = source.standardizeURL(preStandardizeUrl(url));
AppNames names = source.getAppNames(standardUrl); AppNames names = source.getAppNames(standardUrl);
APKDetails apk = APKDetails apk =
@@ -210,7 +245,8 @@ class SourceProvider {
apk.apkUrls, apk.apkUrls,
apk.apkUrls.length - 1, apk.apkUrls.length - 1,
additionalData, additionalData,
DateTime.now()); DateTime.now(),
pinned);
} }
// Returns errors in [results, errors] instead of throwing them // Returns errors in [results, errors] instead of throwing them

View File

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

View File

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