Compare commits

...

44 Commits

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

1
.gitignore vendored
View File

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

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

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

View File

@ -1,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,45 +29,25 @@ 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) {
throw NoAPKError();
}
return APKDetails(latestVersion, apkUrls); return APKDetails(latestVersion, apkUrls);
} else { } else {
throw NoReleasesError(); throw NoReleasesError();
@ -74,16 +55,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';
additionalSourceAppSpecificDefaults = ['true', 'true', ''];
additionalSourceSpecificSettingFormItems = [
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),
))
])
];
additionalSourceAppSpecificFormItems = [
[
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) {
@ -24,8 +96,8 @@ class GitHub implements AppSource {
Future<String> getCredentialPrefixIfAny() async { Future<String> getCredentialPrefixIfAny() async {
SettingsProvider settingsProvider = SettingsProvider(); SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings(); await settingsProvider.initializeSettings();
String? creds = String? creds = settingsProvider
settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id); .getSettingString(additionalSourceSpecificSettingFormItems[0].id);
return creds != null && creds.isNotEmpty ? '$creds@' : ''; return creds != null && creds.isNotEmpty ? '$creds@' : '';
} }
@ -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 {
@ -86,23 +155,14 @@ class GitHub implements AppSource {
if (targetRelease == null) { if (targetRelease == null) {
throw NoReleasesError(); throw NoReleasesError();
} }
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
throw NoAPKError();
}
String? version = targetRelease['tag_name']; String? version = targetRelease['tag_name'];
if (version == null) { if (version == null) {
throw NoVersionError(); throw NoVersionError();
} }
return APKDetails(version, targetRelease['apkUrls']); return APKDetails(version, targetRelease['apkUrls'] as List<String>);
} 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 +174,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 {
@ -36,7 +33,7 @@ class GitLab implements AppSource {
var entry = parsedHtml.querySelector('entry'); var entry = parsedHtml.querySelector('entry');
var entryContent = var entryContent =
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text); parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrlList = [ var apkUrls = [
...getLinksFromParsedHTML( ...getLinksFromParsedHTML(
entryContent, entryContent,
RegExp( RegExp(
@ -51,9 +48,6 @@ class GitLab implements AppSource {
.where((element) => Uri.parse(element).host != '') .where((element) => Uri.parse(element).host != '')
.toList() .toList()
]; ];
if (apkUrlList.isEmpty) {
throw NoAPKError();
}
var entryId = entry?.querySelector('id')?.innerHtml; var entryId = entry?.querySelector('id')?.innerHtml;
var version = var version =
@ -61,7 +55,7 @@ class GitLab implements AppSource {
if (version == null) { if (version == null) {
throw NoVersionError(); throw NoVersionError();
} }
return APKDetails(version, apkUrlList); return APKDetails(version, apkUrls);
} else { } else {
throw NoReleasesError(); throw NoReleasesError();
} }
@ -72,13 +66,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 {
@ -27,14 +24,12 @@ class Signal implements AppSource {
if (res.statusCode == 200) { if (res.statusCode == 200) {
var json = jsonDecode(res.body); var json = jsonDecode(res.body);
String? apkUrl = json['url']; String? apkUrl = json['url'];
if (apkUrl == null) { List<String> apkUrls = apkUrl == null ? [] : [apkUrl];
throw NoAPKError();
}
String? version = json['versionName']; String? version = json['versionName'];
if (version == null) { if (version == null) {
throw NoVersionError(); throw NoVersionError();
} }
return APKDetails(version, [apkUrl]); return APKDetails(version, apkUrls);
} else { } else {
throw NoReleasesError(); throw NoReleasesError();
} }
@ -42,13 +37,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 {
@ -52,9 +49,6 @@ class SourceForge implements AppSource {
apkUrlListAllReleases // This can be used skipped for fallback support later apkUrlListAllReleases // This can be used skipped for fallback support later
.where((element) => getVersion(element) == version) .where((element) => getVersion(element) == version)
.toList(); .toList();
if (apkUrlList.isEmpty) {
throw NoAPKError();
}
return APKDetails(version, apkUrlList); return APKDetails(version, apkUrlList);
} else { } else {
throw NoReleasesError(); throw NoReleasesError();
@ -66,13 +60,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,9 +2,11 @@ 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 key;
late String label; late String label;
late FormItemType type; late FormItemType type;
late bool required; late bool required;
@ -13,6 +15,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 +25,9 @@ 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,
this.key = 'default'});
} }
class GeneratedForm extends StatefulWidget { class GeneratedForm extends StatefulWidget {
@ -47,7 +52,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 +67,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
} }
} }
} }
widget.onValueChanges(returnValues, valid); widget.onValueChanges(returnValues, valid, isBuilding);
} }
@override @override
@ -75,14 +80,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 +119,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
@ -186,3 +211,18 @@ class _GeneratedFormState extends State<GeneratedForm> {
)); ));
} }
} }
String? findGeneratedFormValueByKey(
List<GeneratedFormItem> items, List<String> values, String key) {
var foundIndex = -1;
for (var i = 0; i < items.length; i++) {
if (items[i].key == key) {
foundIndex = i;
break;
}
}
if (foundIndex >= 0 && foundIndex < values.length) {
return values[foundIndex];
}
return null;
}

View File

@ -29,7 +29,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
void initState() { void initState() {
super.initState(); super.initState();
values = widget.defaultValues; values = widget.defaultValues;
valid = widget.initValid; valid = widget.initValid || widget.items.isEmpty;
} }
@override @override
@ -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())),
); );

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.9'; const String currentVersion = '0.8.0';
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,17 @@ class _ObtainiumState extends State<Obtainium> {
[], [],
0, 0,
['true'], ['true'],
null) null,
false,
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

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

View File

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

View File

@ -23,24 +23,24 @@ class AppsPageState extends State<AppsPage> {
AppsFilter? filter; AppsFilter? filter;
var updatesOnlyFilter = var updatesOnlyFilter =
AppsFilter(includeUptodate: false, includeNonInstalled: false); AppsFilter(includeUptodate: false, includeNonInstalled: false);
Set<String> selectedIds = {}; Set<App> selectedApps = {};
DateTime? refreshingSince; DateTime? refreshingSince;
clearSelected() { clearSelected() {
if (selectedIds.isNotEmpty) { if (selectedApps.isNotEmpty) {
setState(() { setState(() {
selectedIds.clear(); selectedApps.clear();
}); });
return true; return true;
} }
return false; return false;
} }
selectThese(List<String> appIds) { selectThese(List<App> apps) {
if (selectedIds.isEmpty) { if (selectedApps.isEmpty) {
setState(() { setState(() {
for (var a in appIds) { for (var a in apps) {
selectedIds.add(a); selectedApps.add(a);
} }
}); });
} }
@ -54,16 +54,16 @@ class AppsPageState extends State<AppsPage> {
var currentFilterIsUpdatesOnly = var currentFilterIsUpdatesOnly =
filter?.isIdenticalTo(updatesOnlyFilter) ?? false; filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
selectedIds = selectedIds selectedApps = selectedApps
.where((element) => sortedApps.map((e) => e.app.id).contains(element)) .where((element) => sortedApps.map((e) => e.app).contains(element))
.toSet(); .toSet();
toggleAppSelected(String appId) { toggleAppSelected(App app) {
setState(() { setState(() {
if (selectedIds.contains(appId)) { if (selectedApps.contains(app)) {
selectedIds.remove(appId); selectedApps.remove(app);
} else { } else {
selectedIds.add(appId); selectedApps.add(app);
} }
}); });
} }
@ -124,17 +124,33 @@ 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();
List<String> trackOnlyUpdateIdsAllOrSelected = [];
existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) {
if (appsProvider.apps[id]!.app.trackOnly) {
trackOnlyUpdateIdsAllOrSelected.add(id);
return false;
}
return true;
}).toList();
newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) {
if (appsProvider.apps[id]!.app.trackOnly) {
trackOnlyUpdateIdsAllOrSelected.add(id);
return false;
}
return true;
}).toList();
if (settingsProvider.pinUpdates) { if (settingsProvider.pinUpdates) {
var temp = []; var temp = [];
sortedApps = sortedApps.where((sa) { sortedApps = sortedApps.where((sa) {
@ -147,6 +163,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 +219,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 +236,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()}%')
@ -219,7 +261,7 @@ class AppsPageState extends State<AppsPage> {
children: [ children: [
Text(appsProvider.areDownloadsRunning() Text(appsProvider.areDownloadsRunning()
? 'Please Wait...' ? 'Please Wait...'
: 'Update Available'), : 'Update Available${sortedApps[index].app.trackOnly ? ' (Est.)' : ''}'),
SourceProvider() SourceProvider()
.getSource(sortedApps[index].app.url) .getSource(sortedApps[index].app.url)
.changeLogPageFromStandardUrl( .changeLogPageFromStandardUrl(
@ -250,14 +292,13 @@ class AppsPageState extends State<AppsPage> {
child: SizedBox( child: SizedBox(
width: 80, width: 80,
child: Text( child: Text(
sortedApps[index].app.installedVersion ?? '${sortedApps[index].app.installedVersion ?? 'Not Installed'} ${sortedApps[index].app.trackOnly == true ? '(Estimate)' : ''}',
'Not Installed',
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
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 +316,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 +348,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());
} }
}); });
}, },
@ -322,49 +364,77 @@ class AppsPageState extends State<AppsPage> {
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
onPressed: appsProvider.areDownloadsRunning() || onPressed: appsProvider.areDownloadsRunning() ||
(existingUpdateIdsAllOrSelected.isEmpty && (existingUpdateIdsAllOrSelected.isEmpty &&
newInstallIdsAllOrSelected.isEmpty) newInstallIdsAllOrSelected.isEmpty &&
trackOnlyUpdateIdsAllOrSelected.isEmpty)
? null ? null
: () { : () {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
List<List<GeneratedFormItem>> formInputs = []; List<GeneratedFormItem> formInputs = [];
if (existingUpdateIdsAllOrSelected.isNotEmpty && List<String> defaultValues = [];
newInstallIdsAllOrSelected.isNotEmpty) { if (existingUpdateIdsAllOrSelected.isNotEmpty) {
formInputs.add([ formInputs.add(GeneratedFormItem(
GeneratedFormItem( label:
label: 'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}', type: FormItemType.bool,
type: FormItemType.bool) key: 'updates'));
]); defaultValues.add('true');
formInputs.add([ }
GeneratedFormItem( if (newInstallIdsAllOrSelected.isNotEmpty) {
label: formInputs.add(GeneratedFormItem(
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}', label:
type: FormItemType.bool) 'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}',
]); type: FormItemType.bool,
key: 'installs'));
defaultValues
.add(defaultValues.isEmpty ? 'true' : '');
}
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
formInputs.add(GeneratedFormItem(
label:
'Mark ${trackOnlyUpdateIdsAllOrSelected.length} Track-Only\nApp${trackOnlyUpdateIdsAllOrSelected.length == 1 ? '' : 's'} as Updated',
type: FormItemType.bool,
key: 'trackonlies'));
defaultValues
.add(defaultValues.isEmpty ? 'true' : '');
} }
showDialog<List<String>?>( showDialog<List<String>?>(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
var totalApps = existingUpdateIdsAllOrSelected
.length +
newInstallIdsAllOrSelected.length +
trackOnlyUpdateIdsAllOrSelected.length;
return GeneratedFormModal( return GeneratedFormModal(
title: title:
'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?', 'Change $totalApps App${totalApps == 1 ? '' : 's'}',
message: items: formInputs.map((e) => [e]).toList(),
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.', defaultValues: defaultValues,
items: formInputs,
defaultValues: [
'true',
existingUpdateIdsAllOrSelected.isEmpty
? 'true'
: ''
],
initValid: true, initValid: true,
); );
}).then((values) { }).then((values) {
if (values != null) { if (values != null) {
bool shouldInstallUpdates = values[0] == 'true'; if (values.isEmpty) {
bool shouldInstallNew = values[1] == 'true'; values = defaultValues;
settingsProvider }
.getInstallPermission() bool shouldInstallUpdates =
findGeneratedFormValueByKey(
formInputs, values, 'updates') ==
'true';
bool shouldInstallNew =
findGeneratedFormValueByKey(
formInputs, values, 'installs') ==
'true';
bool shouldMarkTrackOnlies =
findGeneratedFormValueByKey(formInputs,
values, 'trackonlies') ==
'true';
(() async {
if (shouldInstallNew ||
shouldInstallUpdates) {
await settingsProvider
.getInstallPermission();
}
})()
.then((_) { .then((_) {
List<String> toInstall = []; List<String> toInstall = [];
if (shouldInstallUpdates) { if (shouldInstallUpdates) {
@ -375,6 +445,10 @@ class AppsPageState extends State<AppsPage> {
toInstall toInstall
.addAll(newInstallIdsAllOrSelected); .addAll(newInstallIdsAllOrSelected);
} }
if (shouldMarkTrackOnlies) {
toInstall.addAll(
trackOnlyUpdateIdsAllOrSelected);
}
appsProvider appsProvider
.downloadAndInstallLatestApps( .downloadAndInstallLatestApps(
toInstall, context) toInstall, context)
@ -386,11 +460,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 +493,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 +512,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 +527,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

@ -8,10 +8,10 @@ import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_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: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});
@ -26,7 +26,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
var settingsProvider = context.read<SettingsProvider>();
var appsProvider = context.read<AppsProvider>(); var appsProvider = context.read<AppsProvider>();
var outlineButtonStyle = ButtonStyle( var outlineButtonStyle = ButtonStyle(
shape: MaterialStateProperty.all( shape: MaterialStateProperty.all(
@ -39,30 +38,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
), ),
); );
Future<List<List<String>>> addApps(List<String> urls) async {
await settingsProvider.getInstallPermission();
List<dynamic> results = await sourceProvider.getApps(urls,
ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList());
List<App> apps = results[0];
Map<String, dynamic> errorsMap = results[1];
for (var app in apps) {
if (appsProvider.apps.containsKey(app.id)) {
errorsMap.addAll({app.id: 'App already added'});
} else {
await appsProvider.saveApps([app]);
}
}
List<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
}
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
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),
@ -197,7 +177,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
setState(() { setState(() {
importInProgress = true; importInProgress = true;
}); });
addApps(urls).then((errors) { appsProvider
.addAppsByURL(urls)
.then((errors) {
if (errors.isEmpty) { if (errors.isEmpty) {
showError( showError(
'Imported ${urls.length} Apps', 'Imported ${urls.length} Apps',
@ -224,6 +206,107 @@ 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,
selectedByDefault:
false,
);
});
if (selectedUrls !=
null &&
selectedUrls
.isNotEmpty) {
var errors =
await appsProvider
.addAppsByURL(
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 +317,94 @@ 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 appsProvider
importInProgress = .addAppsByURL(
false; selectedUrls);
}); if (errors.isEmpty) {
} // ignore: use_build_context_synchronously
}); showError(
}).catchError((e) { 'Imported ${selectedUrls.length} Apps',
setState(() { context);
importInProgress = } else {
false; showDialog(
}); context: context,
showError(e, context); builder:
}); (BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
} }
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
}); });
}, },
child: Text('Import ${source.name}')) 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 +467,37 @@ 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.selectedByDefault = true,
this.onlyOneSelectionAllowed = false});
List<String> urls; Map<String, String> urlsWithDescriptions;
bool selectedByDefault;
bool onlyOneSelectionAllowed;
@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.selectedByDefault && !widget.onlyOneSelectionAllowed);
}
if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) {
selectOnlyOne(widget.urlsWithDescriptions.entries.first.key);
}
}
selectOnlyOne(String url) {
for (var uwd in urlWithDescriptionSelections.keys) {
urlWithDescriptionSelections[uwd] = uwd.key == url;
} }
} }
@ -401,23 +505,56 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
scrollable: true, scrollable: true,
title: const Text('Select URLs to Import'), title:
Text(widget.onlyOneSelectionAllowed ? 'Select URL' : 'Select URLs'),
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; value ??= false;
if (value! && widget.onlyOneSelectionAllowed) {
selectOnlyOne(urlWithD.key);
} else {
urlWithDescriptionSelections[urlWithD] = value!;
}
}); });
}), }),
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,
)
],
)) ))
]); ]);
}) })
@ -429,13 +566,19 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
}, },
child: const Text('Cancel')), child: const Text('Cancel')),
TextButton( TextButton(
onPressed: () { onPressed:
Navigator.of(context).pop(urlSelections.keys urlWithDescriptionSelections.values.where((b) => b).isEmpty
.where((url) => urlSelections[url] ?? false) ? null
.toList()); : () {
}, Navigator.of(context).pop(urlWithDescriptionSelections
child: Text( .entries
'Import ${urlSelections.values.where((b) => b).length} URLs')) .where((entry) => entry.value)
.map((e) => e.key.key)
.toList());
},
child: Text(widget.onlyOneSelectionAllowed
? 'Pick'
: 'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs'))
], ],
); );
} }

View File

@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package: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/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 {
@ -135,18 +138,21 @@ class _SettingsPageState extends State<SettingsPage> {
}); });
var sourceSpecificFields = sourceProvider.sources.map((e) { var sourceSpecificFields = sourceProvider.sources.map((e) {
if (e.moreSourceSettingsFormItems.isNotEmpty) { if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
return GeneratedForm( return GeneratedForm(
items: e.moreSourceSettingsFormItems.map((e) => [e]).toList(), items: e.additionalSourceSpecificSettingFormItems
onValueChanges: (values, valid) { .map((e) => [e])
.toList(),
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(
e.moreSourceSettingsFormItems[i].id, values[i]); e.additionalSourceSpecificSettingFormItems[i].id,
values[i]);
} }
} }
}, },
defaultValues: e.moreSourceSettingsFormItems.map((e) { defaultValues: e.additionalSourceSpecificSettingFormItems.map((e) {
return settingsProvider.getSettingString(e.id) ?? ''; return settingsProvider.getSettingString(e.id) ?? '';
}).toList()); }).toList());
} else { } else {
@ -238,23 +244,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 +285,71 @@ class _SettingsPageState extends State<SettingsPage> {
])); ]));
} }
} }
class LogsDialog extends StatefulWidget {
const LogsDialog({super.key});
@override
State<LogsDialog> createState() => _LogsDialogState();
}
class _LogsDialogState extends State<LogsDialog> {
String? logString;
List<int> days = [7, 5, 4, 3, 2, 1];
@override
Widget build(BuildContext context) {
var logsProvider = context.read<LogsProvider>();
void filterLogs(int days) {
logsProvider
.get(after: DateTime.now().subtract(Duration(days: days)))
.then((value) {
setState(() {
String l = value.map((e) => e.toString()).join('\n\n');
logString = l.isNotEmpty ? l : 'No Logs';
});
});
}
if (logString == null) {
filterLogs(days.first);
}
return AlertDialog(
scrollable: true,
title: const Text('Obtainium App Logs'),
content: Column(
children: [
DropdownButtonFormField(
value: days.first,
items: days
.map((e) => DropdownMenuItem(
value: e,
child: Text('$e Day${e == 1 ? '' : 's'}'),
))
.toList(),
onChanged: (d) {
filterLogs(d ?? 7);
}),
const SizedBox(
height: 32,
),
Text(logString ?? '')
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Close')),
TextButton(
onPressed: () {
Share.share(logString ?? '', subject: 'Obtainium App Logs');
Navigator.of(context).pop();
},
child: const Text('Share'))
],
);
}
}

View File

@ -12,9 +12,10 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:install_plugin_v2/install_plugin_v2.dart'; import 'package: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';
@ -42,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;
@ -63,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();
}); });
@ -71,38 +75,39 @@ class AppsProvider with ChangeNotifier {
} }
} }
downloadFile(String url, String fileName, Function? onProgress) async { downloadFile(String url, String fileName, Function? onProgress,
{bool useExisting = true}) async {
var destDir = (await getExternalStorageDirectory())!.path; 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;
} }
@ -139,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);
} }
@ -177,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
@ -190,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 ||
@ -246,6 +266,7 @@ class AppsProvider with ChangeNotifier {
Future<List<String>> downloadAndInstallLatestApps( Future<List<String>> downloadAndInstallLatestApps(
List<String> appIds, BuildContext? context) async { List<String> appIds, BuildContext? context) async {
List<String> appsToInstall = []; List<String> appsToInstall = [];
List<String> trackOnlyAppsToUpdate = [];
// For all specified Apps, filter out those for which: // For all specified Apps, filter out those for which:
// 1. A URL cannot be picked // 1. A URL cannot be picked
// 2. That cannot be installed silently (IF no buildContext was given for interactive install) // 2. That cannot be installed silently (IF no buildContext was given for interactive install)
@ -253,7 +274,10 @@ class AppsProvider with ChangeNotifier {
if (apps[id] == null) { if (apps[id] == null) {
throw ObtainiumError('App not found'); throw ObtainiumError('App not found');
} }
String? apkUrl = await confirmApkUrl(apps[id]!.app, context); String? apkUrl;
if (!apps[id]!.app.trackOnly) {
apkUrl = await confirmApkUrl(apps[id]!.app, context);
}
if (apkUrl != null) { if (apkUrl != null) {
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
if (urlInd != apps[id]!.app.preferredApkIndex) { if (urlInd != apps[id]!.app.preferredApkIndex) {
@ -264,7 +288,16 @@ class AppsProvider with ChangeNotifier {
appsToInstall.add(id); appsToInstall.add(id);
} }
} }
if (apps[id]!.app.trackOnly) {
trackOnlyAppsToUpdate.add(id);
}
} }
// Mark all specified track-only apps as latest
saveApps(trackOnlyAppsToUpdate.map((e) {
var a = apps[e]!.app;
a.installedVersion = a.latestVersion;
return a;
}).toList());
// Download APKs for all Apps to be installed // Download APKs for all Apps to be installed
MultiAppMultiError errors = MultiAppMultiError(); MultiAppMultiError errors = MultiAppMultiError();
List<DownloadedApk?> downloadedFiles = List<DownloadedApk?> downloadedFiles =
@ -295,10 +328,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;
} }
@ -371,7 +404,9 @@ class AppsProvider with ChangeNotifier {
return null; // Can't correct in the background isolate return null; // Can't correct in the background isolate
} }
var modded = false; var modded = false;
if (installedInfo == null && app.installedVersion != null) { if (installedInfo == null &&
app.installedVersion != null &&
!app.trackOnly) {
app.installedVersion = null; app.installedVersion = null;
modded = true; modded = true;
} }
@ -407,22 +442,27 @@ 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[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) {
@ -485,8 +525,10 @@ class AppsProvider with ChangeNotifier {
currentApp.url, currentApp.url,
currentApp.additionalData, currentApp.additionalData,
name: currentApp.name, name: currentApp.name,
id: currentApp.id); id: currentApp.id,
newApp.installedVersion = currentApp.installedVersion; pinned: currentApp.pinned,
trackOnly: currentApp.trackOnly,
installedVersion: currentApp.installedVersion);
if (currentApp.preferredApkIndex < newApp.apkUrls.length) { if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex; newApp.preferredApkIndex = currentApp.preferredApkIndex;
} }
@ -593,6 +635,23 @@ class AppsProvider with ChangeNotifier {
foregroundSubscription?.cancel(); foregroundSubscription?.cancel();
super.dispose(); super.dispose();
} }
Future<List<List<String>>> addAppsByURL(List<String> urls) async {
List<dynamic> results = await SourceProvider().getAppsByURLNaive(urls,
ignoreUrls: apps.values.map((e) => e.app.url).toList());
List<App> pps = results[0];
Map<String, dynamic> errorsMap = results[1];
for (var app in pps) {
if (apps.containsKey(app.id)) {
errorsMap.addAll({app.id: 'App already added'});
} else {
await saveApps([app]);
}
}
List<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
}
} }
class APKPicker extends StatefulWidget { class APKPicker extends StatefulWidget {

View File

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

View File

@ -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,8 @@
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/apkmirror.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 +42,8 @@ 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;
bool trackOnly = false;
App( App(
this.id, this.id,
this.url, this.url,
@ -50,11 +54,13 @@ class App {
this.apkUrls, this.apkUrls,
this.preferredApkIndex, this.preferredApkIndex,
this.additionalData, this.additionalData,
this.lastUpdateCheck); this.lastUpdateCheck,
this.pinned,
this.trackOnly);
@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(
@ -71,11 +77,15 @@ class App {
: List<String>.from(jsonDecode(json['apkUrls'])), : List<String>.from(jsonDecode(json['apkUrls'])),
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
json['additionalData'] == null json['additionalData'] == null
? SourceProvider().getSource(json['url']).additionalDataDefaults ? SourceProvider()
.getSource(json['url'])
.additionalSourceAppSpecificDefaults
: 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,
json['trackOnly'] ?? false);
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
@ -87,7 +97,9 @@ 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,
'trackOnly': trackOnly
}; };
} }
@ -128,23 +140,65 @@ 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); bool enforceTrackOnly = false;
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); }
// Different Sources may need different kinds of additional data for Apps
List<List<GeneratedFormItem>> additionalSourceAppSpecificFormItems = [];
List<String> additionalSourceAppSpecificDefaults = [];
// Some additional data may be needed for Apps regardless of Source
final List<GeneratedFormItem> additionalAppSpecificSourceAgnosticFormItems = [
GeneratedFormItem(
label: 'Track-Only',
type: FormItemType.bool,
key: 'trackOnlyFormItemKey')
];
final List<String> additionalAppSpecificSourceAgnosticDefaults = [''];
// Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider
List<GeneratedFormItem> additionalSourceSpecificSettingFormItems = [];
String? changeLogPageFromStandardUrl(String standardUrl) {
throw NotImplementedError();
}
Future<String> apkUrlPrefetchModifier(String apkUrl) 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,7 +210,8 @@ 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
@ -178,7 +233,7 @@ class SourceProvider {
} }
bool ifSourceAppsRequireAdditionalData(AppSource source) { bool ifSourceAppsRequireAdditionalData(AppSource source) {
for (var row in source.additionalDataFormItems) { for (var row in source.additionalSourceAppSpecificFormItems) {
for (var element in row) { for (var element in row) {
if (element.required) { if (element.required) {
return true; return true;
@ -191,42 +246,66 @@ 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 sources.map((e) => e.host).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,
bool trackOnly = false,
String? installedVersion}) 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);
if (apk.apkUrls.isEmpty && !trackOnly) {
throw NoAPKError();
}
String apkVersion = apk.version.replaceAll('/', '-');
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
? name ? name
: names.name[0].toUpperCase() + names.name.substring(1), : names.name[0].toUpperCase() + names.name.substring(1),
null, installedVersion,
apk.version.replaceAll('/', '-'), apkVersion,
apk.apkUrls, apk.apkUrls,
apk.apkUrls.length - 1, apk.apkUrls.length - 1,
additionalData, additionalData,
DateTime.now()); DateTime.now(),
pinned,
trackOnly);
} }
// Returns errors in [results, errors] instead of throwing them // Returns errors in [results, errors] instead of throwing them
Future<List<dynamic>> getApps(List<String> urls, Future<List<dynamic>> getAppsByURLNaive(List<String> urls,
{List<String> ignoreUrls = const []}) async { {List<String> ignoreUrls = const []}) async {
List<App> apps = []; List<App> apps = [];
Map<String, dynamic> errors = {}; Map<String, dynamic> errors = {};
for (var url in urls.where((element) => !ignoreUrls.contains(element))) { for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
try { try {
var source = getSource(url); var source = getSource(url);
apps.add(await getApp(source, url, source.additionalDataDefaults)); apps.add(await getApp(
source, url, source.additionalSourceAppSpecificDefaults));
} catch (e) { } catch (e) {
errors.addAll(<String, dynamic>{url: e}); errors.addAll(<String, dynamic>{url: e});
} }
} }
return [apps, errors]; return [apps, errors];
} }
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
} }

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.5"
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,14 +180,14 @@ 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:
name: flutter_launcher_icons name: flutter_launcher_icons
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.10.0" version: "0.11.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -194,7 +201,7 @@ packages:
name: flutter_local_notifications name: flutter_local_notifications
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "12.0.3" version: "12.0.4"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
@ -365,7 +372,7 @@ packages:
name: path_provider_android name: path_provider_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.21" version: "2.0.22"
path_provider_ios: path_provider_ios:
dependency: transitive dependency: transitive
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:
@ -477,7 +491,7 @@ packages:
name: share_plus name: share_plus
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.2.0" version: "6.3.0"
share_plus_platform_interface: share_plus_platform_interface:
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:
@ -608,14 +643,14 @@ packages:
name: url_launcher name: url_launcher
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.6" version: "6.1.7"
url_launcher_android: url_launcher_android:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.21" version: "6.0.22"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
@ -664,7 +699,7 @@ packages:
name: uuid name: uuid
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.6" version: "3.0.7"
vector_math: vector_math:
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.2"
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.9+53 # When changing this, update the tag in main() accordingly version: 0.8.0+63 # 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,12 +56,13 @@ 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:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_launcher_icons: ^0.10.0 flutter_launcher_icons: ^0.11.0
# The "flutter_lints" package below contains a set of recommended lints to # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # encourage good coding practices. The lint set provided by the package is