Compare commits

...

40 Commits

Author SHA1 Message Date
602f0c3bb2 Increment version 2022-11-19 16:26:44 -05:00
00721e8ac4 Added days filter to logs dialog (#117) 2022-11-19 16:20:42 -05:00
d19f9101d6 Added dropdown support to generated form 2022-11-19 15:42:20 -05:00
a4bc278e4c Increment version 2022-11-17 19:02:44 -05:00
b04986622b IzzyOnDroid now uses API (+ other minor tweaks) 2022-11-17 19:00:27 -05:00
2059e4fd44 Switched to F-Droid API 2022-11-17 18:36:05 -05:00
618a1523cf Add app only downloads APK if needed 2022-11-16 20:57:58 -05:00
ba1cdc2c73 Increment version 2022-11-14 21:10:30 -05:00
aa2a25fffe Better GitHub error messages (#112) 2022-11-14 20:56:04 -05:00
c8ec67aef3 Existing APKs now reused again
With partial APKs avoided (#113)
2022-11-14 20:38:02 -05:00
9576a99a4e More efficient loading (addresses #110) 2022-11-14 12:56:52 -05:00
0202224fa6 Merge pull request #108 from ImranR98/logging
Added basic logging - mainly just for the BG task and errors right now.
2022-11-12 19:18:12 -05:00
631ffd5c34 Added basic logging + increment version
Logging is mainly just for the BG task and errors right now.
2022-11-12 19:17:05 -05:00
feed7ffc0b Increment version 2022-11-12 11:27:15 -05:00
296485de8a Added "Reset Install Status" button 2022-11-12 11:21:46 -05:00
d2f226d442 Slight refactoring 2022-11-12 10:44:59 -05:00
cbdb449e35 Bugfix in moveObtainiumToStart 2022-11-12 10:43:12 -05:00
3100a3a08c Added descriptions to GitHub starred imports 2022-11-12 10:40:54 -05:00
18951d6461 Added descriptions to search results 2022-11-12 10:35:59 -05:00
0e0a39a40f Allow App downgrades if com.berdik.letmedowngrade installed 2022-11-12 10:05:46 -05:00
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
c746e89052 Fixed error reporting in add app box 2022-11-10 10:29:00 -05:00
ee758e8470 Fixed bug from previous commit 2022-11-10 10:26:36 -05:00
68d903e092 Increment version 2022-11-09 20:57:03 -05:00
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
62a05996cf Fixed regression for #20 from last cleanup 2022-11-09 19:25:41 -05:00
1cda941fbe Removed APKMirror code (previously unused but present) 2022-11-07 16:05:07 -05:00
49cb908d04 Increment version 2022-11-07 15:33:02 -05:00
139f44d31d UI improvement in APKPicker 2022-11-07 15:32:42 -05:00
ed955ac6a2 Add arch info (#100) 2022-11-07 15:14:00 -05:00
38 changed files with 1076 additions and 632 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,13 @@
import 'package:html/parser.dart'; 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 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) {
@ -28,41 +29,24 @@ class FDroid implements AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) => null; String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override @override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; String? tryInferringAppId(String standardUrl) {
return Uri.parse(standardUrl).pathSegments.last;
}
@override APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
Future<APKDetails> getLatestAPKDetails( Response res, String apkUrlPrefix) {
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var releases = parse(res.body).querySelectorAll('.package-version'); List<dynamic> releases = jsonDecode(res.body)['packages'] ?? [];
if (releases.isEmpty) { if (releases.isEmpty) {
throw NoReleasesError(); throw NoReleasesError();
} }
String? latestVersion = releases[0] String? latestVersion = releases[0]['versionName'];
.querySelector('.package-version-header b')
?.innerHtml
.split(' ')
.sublist(1)
.join(' ');
if (latestVersion == null) { if (latestVersion == null) {
throw NoVersionError(); throw NoVersionError();
} }
List<String> apkUrls = releases List<String> apkUrls = releases
.where((element) => .where((element) => element['versionName'] == latestVersion)
element .map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
.querySelector('.package-version-header b')
?.innerHtml
.split(' ')
.sublist(1)
.join(' ') ==
latestVersion)
.map((e) =>
e
.querySelector('.package-version-download a')
?.attributes['href'] ??
'')
.where((element) => element.isNotEmpty)
.toList(); .toList();
if (apkUrls.isEmpty) { if (apkUrls.isEmpty) {
throw NoAPKError(); throw NoAPKError();
@ -74,16 +58,16 @@ class FDroid implements AppSource {
} }
@override @override
AppNames getAppNames(String standardUrl) { Future<APKDetails> getLatestAPKDetails(
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last); String standardUrl, List<String> additionalData) async {
String? appId = tryInferringAppId(standardUrl);
return getAPKUrlsFromFDroidPackagesAPIResponse(
await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')),
'https://f-droid.org/repo/$appId');
} }
@override @override
List<List<GeneratedFormItem>> additionalDataFormItems = []; AppNames getAppNames(String standardUrl) {
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
@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) {
@ -33,9 +105,6 @@ class GitHub implements AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) => String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/releases'; '$standardUrl/releases';
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData) async {
@ -72,7 +141,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]);
@ -95,14 +164,8 @@ class GitHub implements AppSource {
} }
return APKDetails(version, targetRelease['apkUrls']); return APKDetails(version, targetRelease['apkUrls']);
} else { } else {
if (res.headers['x-ratelimit-remaining'] == '0') { rateLimitErrorCheck(res);
throw RateLimitError( throw getObtainiumHttpError(res);
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
}
throw NoReleasesError();
} }
} }
@ -114,72 +177,31 @@ 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; rateLimitErrorCheck(res);
} throw getObtainiumHttpError(res);
try { }
RegExp(value); }
} catch (e) {
return 'Invalid regular expression';
}
return null;
}
])
]
];
@override rateLimitErrorCheck(Response res) {
List<String> additionalDataDefaults = ['true', 'true', '']; if (res.headers['x-ratelimit-remaining'] == '0') {
throw RateLimitError(
@override (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
List<GeneratedFormItem> moreSourceSettingsFormItems = [ 60000000)
GeneratedFormItem( .round());
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) {
@ -23,9 +23,6 @@ class GitLab implements AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) => String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/-/releases'; '$standardUrl/-/releases';
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData) async {
@ -72,13 +69,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:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/app_sources/fdroid.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) {
@ -22,54 +22,22 @@ class IzzyOnDroid implements AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) => null; String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override @override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; String? tryInferringAppId(String standardUrl) {
return FDroid().tryInferringAppId(standardUrl);
}
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse(standardUrl)); String? appId = tryInferringAppId(standardUrl);
if (res.statusCode == 200) { return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(
var parsedHtml = parse(res.body); await get(
var multipleVersionApkUrls = parsedHtml Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')),
.querySelectorAll('a') 'https://android.izzysoft.de/frepo/$appId');
.where((element) =>
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
false)
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
.toList();
if (multipleVersionApkUrls.isEmpty) {
throw NoAPKError();
}
var version = parsedHtml
.querySelector('#keydata')
?.querySelectorAll('b')
.where(
(element) => element.innerHtml.toLowerCase().contains('version'))
.toList()[0]
.parentNode
?.parentNode
?.children[1]
.innerHtml;
if (version == null) {
throw NoVersionError();
}
return APKDetails(version, [multipleVersionApkUrls[0]]);
} else {
throw NoReleasesError();
}
} }
@override @override
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) {
@ -22,9 +22,6 @@ class Mullvad implements AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) => String? changeLogPageFromStandardUrl(String standardUrl) =>
'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md'; 'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md';
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData) async {
@ -50,13 +47,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) {
@ -16,9 +16,6 @@ class Signal implements AppSource {
@override @override
String? changeLogPageFromStandardUrl(String standardUrl) => null; String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData) async {
@ -42,13 +39,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) {
@ -21,9 +21,6 @@ class SourceForge implements AppSource {
@override @override
String? changeLogPageFromStandardUrl(String standardUrl) => null; String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData) async {
@ -66,13 +63,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

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

View File

@ -46,11 +46,16 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
), ),
GeneratedForm( GeneratedForm(
items: widget.items, items: widget.items,
onValueChanges: (values, valid) { onValueChanges: (values, valid, isBuilding) {
setState(() { if (isBuilding) {
this.values = values; this.values = values;
this.valid = valid; this.valid = valid;
}); } else {
setState(() {
this.values = values;
this.valid = valid;
});
}
}, },
defaultValues: widget.defaultValues) defaultValues: widget.defaultValues)
]), ]),

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; 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 { 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 +50,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 +77,9 @@ class MultiAppMultiError extends ObtainiumError {
} }
showError(dynamic e, BuildContext context) { 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( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())), SnackBar(content: Text(e.toString())),
); );
@ -96,3 +104,19 @@ showError(dynamic e, BuildContext context) {
}); });
} }
} }
String list2FriendlyString(List<String> list) {
return list.length == 2
? '${list[0]} and ${list[1]}'
: list
.asMap()
.entries
.map((e) =>
e.value +
(e.key == list.length - 1
? ''
: e.key == list.length - 2
? ', and '
: ', '))
.join('');
}

View File

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

View File

@ -12,40 +12,42 @@ 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({
} else { e['html_url'] as String: e['description'] != null
if (res.headers['x-ratelimit-remaining'] == '0') { ? e['description'] as String
throw RateLimitError( : 'No description'
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / });
60000000)
.round());
} }
return urlsWithDescriptions;
throw ObtainiumError('Unable to find user\'s starred repos'); } else {
var gh = GitHub();
gh.rateLimitErrorCheck(res);
throw getObtainiumHttpError(res);
} }
} }
@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,14 +57,16 @@ 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;
} }
]) ])
] ]
], ],
onValueChanges: (values, valid) { onValueChanges: (values, valid, isBuilding) {
setState(() { setState(() {
userInput = values[0]; userInput = values[0];
var source = valid var source = valid
@ -113,18 +115,23 @@ class _AddAppPageState extends State<AddAppPage> {
additionalData); additionalData);
await settingsProvider await settingsProvider
.getInstallPermission(); .getInstallPermission();
// ignore: use_build_context_synchronously // Only download the APK here if you need to for the package ID
var apkUrl = await appsProvider if (sourceProvider
.confirmApkUrl(app, context); .isTempId(app.id)) {
if (apkUrl == null) { // ignore: use_build_context_synchronously
throw ObtainiumError('Cancelled'); var apkUrl = await appsProvider
.confirmApkUrl(app, context);
if (apkUrl == null) {
throw ObtainiumError(
'Cancelled');
}
app.preferredApkIndex =
app.apkUrls.indexOf(apkUrl);
var downloadedApk =
await appsProvider
.downloadApp(app);
app.id = downloadedApk.appId;
} }
app.preferredApkIndex =
app.apkUrls.indexOf(apkUrl);
var downloadedApk =
await appsProvider
.downloadApp(app);
app.id = downloadedApk.appId;
if (appsProvider.apps if (appsProvider.apps
.containsKey(app.id)) { .containsKey(app.id)) {
throw ObtainiumError( throw ObtainiumError(
@ -172,7 +179,7 @@ class _AddAppPageState extends State<AddAppPage> {
.additionalDataFormItems.isNotEmpty) .additionalDataFormItems.isNotEmpty)
GeneratedForm( GeneratedForm(
items: pickedSource!.additionalDataFormItems, items: pickedSource!.additionalDataFormItems,
onValueChanges: (values, valid) { onValueChanges: (values, valid, isBuilding) {
setState(() { setState(() {
additionalData = values; additionalData = values;
validAdditionalData = valid; validAdditionalData = valid;

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

@ -1,9 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class SettingsPage extends StatefulWidget { class SettingsPage extends StatefulWidget {
@ -138,7 +142,7 @@ class _SettingsPageState extends State<SettingsPage> {
if (e.moreSourceSettingsFormItems.isNotEmpty) { if (e.moreSourceSettingsFormItems.isNotEmpty) {
return GeneratedForm( return GeneratedForm(
items: e.moreSourceSettingsFormItems.map((e) => [e]).toList(), items: e.moreSourceSettingsFormItems.map((e) => [e]).toList(),
onValueChanges: (values, valid) { onValueChanges: (values, valid, isBuilding) {
if (valid) { if (valid) {
for (var i = 0; i < values.length; i++) { for (var i = 0; i < values.length; i++) {
settingsProvider.setSettingString( settingsProvider.setSettingString(
@ -238,23 +242,39 @@ class _SettingsPageState extends State<SettingsPage> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column( child: Column(
children: [ children: [
height16, const Divider(
TextButton.icon( height: 32,
style: ButtonStyle( ),
foregroundColor: MaterialStateProperty.resolveWith<Color>( Row(
(Set<MaterialState> states) { mainAxisAlignment: MainAxisAlignment.spaceAround,
return Colors.grey; children: [
}), TextButton.icon(
), onPressed: () {
onPressed: () { launchUrlString(settingsProvider.sourceUrl,
launchUrlString(settingsProvider.sourceUrl, mode: LaunchMode.externalApplication);
mode: LaunchMode.externalApplication); },
}, icon: const Icon(Icons.code),
icon: const Icon(Icons.code), label: const Text(
label: Text( 'App Source',
'Source', ),
style: Theme.of(context).textTheme.bodySmall, ),
), TextButton.icon(
onPressed: () {
context.read<LogsProvider>().get().then((logs) {
if (logs.isEmpty) {
showError(ObtainiumError('No Logs'), context);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return const LogsDialog();
});
}
});
},
icon: const Icon(Icons.bug_report_outlined),
label: const Text('App Logs')),
],
), ),
height16, height16,
], ],
@ -263,3 +283,71 @@ class _SettingsPageState extends State<SettingsPage> {
])); ]));
} }
} }
class LogsDialog extends StatefulWidget {
const LogsDialog({super.key});
@override
State<LogsDialog> createState() => _LogsDialogState();
}
class _LogsDialogState extends State<LogsDialog> {
String? logString;
List<int> days = [7, 5, 4, 3, 2, 1];
@override
Widget build(BuildContext context) {
var logsProvider = context.read<LogsProvider>();
void filterLogs(int days) {
logsProvider
.get(after: DateTime.now().subtract(Duration(days: days)))
.then((value) {
setState(() {
String l = value.map((e) => e.toString()).join('\n\n');
logString = l.isNotEmpty ? l : 'No Logs';
});
});
}
if (logString == null) {
filterLogs(days.first);
}
return AlertDialog(
scrollable: true,
title: const Text('Obtainium App Logs'),
content: Column(
children: [
DropdownButtonFormField(
value: days.first,
items: days
.map((e) => DropdownMenuItem(
value: e,
child: Text('$e Day${e == 1 ? '' : 's'}'),
))
.toList(),
onChanged: (d) {
filterLogs(d ?? 7);
}),
const SizedBox(
height: 32,
),
Text(logString ?? '')
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Close')),
TextButton(
onPressed: () {
Share.share(logString ?? '', subject: 'Obtainium App Logs');
Navigator.of(context).pop();
},
child: const Text('Share'))
],
);
}
}

View File

@ -5,15 +5,17 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:install_plugin_v2/install_plugin_v2.dart'; import 'package:install_plugin_v2/install_plugin_v2.dart';
import 'package:installed_apps/app_info.dart'; import 'package:installed_apps/app_info.dart';
import 'package:installed_apps/installed_apps.dart'; import 'package:installed_apps/installed_apps.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.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/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';
@ -41,6 +43,7 @@ class AppsProvider with ChangeNotifier {
bool loadingApps = false; bool loadingApps = false;
bool gettingUpdates = false; bool gettingUpdates = false;
bool forBGTask = false; bool forBGTask = false;
LogsProvider logs = LogsProvider();
// Variables to keep track of the app foreground status (installs can't run in the background) // Variables to keep track of the app foreground status (installs can't run in the background)
bool isForeground = true; bool isForeground = true;
@ -62,7 +65,9 @@ class AppsProvider with ChangeNotifier {
// Delete existing APKs // Delete existing APKs
(await getExternalStorageDirectory()) (await getExternalStorageDirectory())
?.listSync() ?.listSync()
.where((element) => element.path.endsWith('.apk')) .where((element) =>
element.path.endsWith('.apk') ||
element.path.endsWith('.apk.part'))
.forEach((apk) { .forEach((apk) {
apk.delete(); apk.delete();
}); });
@ -70,38 +75,39 @@ class AppsProvider with ChangeNotifier {
} }
} }
downloadFile(String url, String fileName, Function? onProgress) async { downloadFile(String url, String fileName, Function? onProgress,
{bool useExisting = true}) async {
var destDir = (await getExternalStorageDirectory())!.path; var destDir = (await getExternalStorageDirectory())!.path;
StreamedResponse response = StreamedResponse response =
await Client().send(Request('GET', Uri.parse(url))); await Client().send(Request('GET', Uri.parse(url)));
File downloadedFile = File('$destDir/$fileName'); File downloadedFile = File('$destDir/$fileName');
if (!(downloadedFile.existsSync() && useExisting)) {
if (downloadedFile.existsSync()) { File tempDownloadedFile = File('${downloadedFile.path}.part');
downloadedFile.deleteSync(); if (tempDownloadedFile.existsSync()) {
} tempDownloadedFile.deleteSync();
var length = response.contentLength; }
var received = 0; var length = response.contentLength;
double? progress; var received = 0;
var sink = downloadedFile.openWrite(); double? progress;
var sink = tempDownloadedFile.openWrite();
await response.stream.map((s) { await response.stream.map((s) {
received += s.length; received += s.length;
progress = (length != null ? received / length * 100 : 30); progress = (length != null ? received / length * 100 : 30);
if (onProgress != null) {
onProgress(progress);
}
return s;
}).pipe(sink);
await sink.close();
progress = null;
if (onProgress != null) { if (onProgress != null) {
onProgress(progress); onProgress(progress);
} }
return s; if (response.statusCode != 200) {
}).pipe(sink); tempDownloadedFile.deleteSync();
throw response.reasonPhrase ?? 'Unknown Error';
await sink.close(); }
progress = null; tempDownloadedFile.renameSync(downloadedFile.path);
if (onProgress != null) {
onProgress(progress);
}
if (response.statusCode != 200) {
downloadedFile.deleteSync();
throw response.reasonPhrase ?? 'Unknown Error';
} }
return downloadedFile; return downloadedFile;
} }
@ -138,12 +144,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);
} }
@ -176,6 +187,15 @@ class AppsProvider with ChangeNotifier {
} }
} }
Future<bool> canDowngradeApps() async {
try {
await InstalledApps.getAppInfo('com.berdik.letmedowngrade');
return true;
} catch (e) {
return false;
}
}
// Unfortunately this 'await' does not actually wait for the APK to finish installing // 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
@ -189,7 +209,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 ||
@ -206,11 +227,18 @@ class AppsProvider with ChangeNotifier {
Future<String?> confirmApkUrl(App app, BuildContext? context) async { Future<String?> confirmApkUrl(App app, BuildContext? context) async {
// If the App has more than one APK, the user should pick one (if context provided) // If the App has more than one APK, the user should pick one (if context provided)
String? apkUrl = app.apkUrls[app.preferredApkIndex]; String? apkUrl = app.apkUrls[app.preferredApkIndex];
// get device supported architecture
List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
if (app.apkUrls.length > 1 && context != null) { if (app.apkUrls.length > 1 && context != null) {
apkUrl = await showDialog( apkUrl = await showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return APKPicker(app: app, initVal: apkUrl); return APKPicker(
app: app,
initVal: apkUrl,
archs: archs,
);
}); });
} }
// If the picked APK comes from an origin different from the source, get user confirmation (if context provided) // If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
@ -287,10 +315,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;
} }
@ -327,6 +355,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();
} }
@ -397,22 +427,28 @@ class AppsProvider with ChangeNotifier {
} }
loadingApps = true; loadingApps = true;
notifyListeners(); notifyListeners();
List<FileSystemEntity> appFiles = (await getAppsDir()) List<App> newApps = (await getAppsDir())
.listSync() .listSync()
.where((item) => item.path.toLowerCase().endsWith('.json')) .where((item) => item.path.toLowerCase().endsWith('.json'))
.map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync())))
.toList(); .toList();
apps.clear(); var idsToDelete = apps.values
.map((e) => e.app.id)
.toSet()
.difference(newApps.map((e) => e.id).toSet());
for (var id in idsToDelete) {
apps.remove(id);
}
var sp = SourceProvider(); var sp = SourceProvider();
List<List<String>> errors = []; List<List<String>> errors = [];
for (int i = 0; i < appFiles.length; i++) { for (int i = 0; i < newApps.length; i++) {
App app = var info = await getInstalledInfo(newApps[i].id);
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync()));
var info = await getInstalledInfo(app.id);
try { try {
sp.getSource(app.url); sp.getSource(newApps[i].url);
apps.putIfAbsent(app.id, () => AppInMemory(app, null, info)); apps.putIfAbsent(
newApps[i].id, () => AppInMemory(newApps[i], null, info));
} catch (e) { } catch (e) {
errors.add([app.id, app.name, e.toString()]); errors.add([newApps[i].id, newApps[i].name, e.toString()]);
} }
} }
if (errors.isNotEmpty) { if (errors.isNotEmpty) {
@ -475,7 +511,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;
@ -586,10 +623,11 @@ class AppsProvider with ChangeNotifier {
} }
class APKPicker extends StatefulWidget { class APKPicker extends StatefulWidget {
const APKPicker({super.key, required this.app, this.initVal}); const APKPicker({super.key, required this.app, this.initVal, this.archs});
final App app; final App app;
final String? initVal; final String? initVal;
final List<String>? archs;
@override @override
State<APKPicker> createState() => _APKPickerState(); State<APKPicker> createState() => _APKPickerState();
@ -607,18 +645,29 @@ class _APKPickerState extends State<APKPicker> {
content: Column(children: [ content: Column(children: [
Text('${widget.app.name} has more than one package:'), Text('${widget.app.name} has more than one package:'),
const SizedBox(height: 16), const SizedBox(height: 16),
...widget.app.apkUrls.map((u) => RadioListTile<String>( ...widget.app.apkUrls.map(
title: Text(Uri.parse(u) (u) => RadioListTile<String>(
.pathSegments title: Text(Uri.parse(u)
.where((element) => element.isNotEmpty) .pathSegments
.last), .where((element) => element.isNotEmpty)
value: u, .last),
groupValue: apkUrl, value: u,
onChanged: (String? val) { groupValue: apkUrl,
setState(() { onChanged: (String? val) {
apkUrl = val; setState(() {
}); apkUrl = val;
})) });
}),
),
if (widget.archs != null)
const SizedBox(
height: 16,
),
if (widget.archs != null)
Text(
'Note:\nYour device supports the ${widget.archs!.length == 1 ? '\'${widget.archs![0]}\' CPU architecture.' : 'following CPU architectures: ${list2FriendlyString(widget.archs!.map((e) => '\'$e\'').toList())}.'}',
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
),
]), ]),
actions: [ actions: [
TextButton( TextButton(

View File

@ -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

@ -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

@ -4,6 +4,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:html/dom.dart'; import 'package:html/dom.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/fdroid.dart'; import 'package:obtainium/app_sources/fdroid.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/app_sources/gitlab.dart'; import 'package:obtainium/app_sources/gitlab.dart';
@ -40,6 +41,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 +52,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 +78,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 +91,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 +133,51 @@ 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) async {
return apkUrl;
}
bool canSearch = false;
Future<Map<String, String>> search(String query) {
throw NotImplementedError();
}
String? tryInferringAppId(String standardUrl) {
return null;
}
}
ObtainiumError getObtainiumHttpError(Response res) {
return ObtainiumError(
res.reasonPhrase ?? 'Error ${res.statusCode.toString()}');
} }
abstract class MassAppUrlSource { 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 +189,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,14 +224,29 @@ 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 =
await source.getLatestAPKDetails(standardUrl, additionalData); await source.getLatestAPKDetails(standardUrl, additionalData);
return App( return App(
id ?? generateTempID(names, source), id ??
source.tryInferringAppId(standardUrl) ??
generateTempID(names, source),
standardUrl, standardUrl,
names.author[0].toUpperCase() + names.author.substring(1), names.author[0].toUpperCase() + names.author.substring(1),
name.trim().isNotEmpty name.trim().isNotEmpty
@ -210,7 +257,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:
@ -553,6 +567,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.9.0" 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: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -574,6 +602,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
synchronized:
dependency: transitive
description:
name: synchronized
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0+3"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@ -706,7 +741,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.7+51 # When changing this, update the tag in main() accordingly version: 0.7.5+61 # 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'
@ -56,6 +56,7 @@ dependencies:
installed_apps: ^1.3.1 installed_apps: ^1.3.1
package_archive_info: ^0.1.0 package_archive_info: ^0.1.0
android_alarm_manager_plus: ^2.1.0 android_alarm_manager_plus: ^2.1.0
sqflite: ^2.2.0+3
dev_dependencies: dev_dependencies: