mirror of
https://github.com/ImranR98/Obtainium.git
synced 2025-07-13 13:26:43 +02:00
Compare commits
31 Commits
v0.7.0-bet
...
v0.8.0-bet
Author | SHA1 | Date | |
---|---|---|---|
6c8f9ebcbf | |||
4d5773bdcc | |||
f81ef6a416 | |||
47324fcb49 | |||
377e0e07bd | |||
b5aae70274 | |||
42475fa42a | |||
d29534ef2e | |||
25953399ac | |||
b04d2fad5c | |||
868ba84c9a | |||
602f0c3bb2 | |||
00721e8ac4 | |||
d19f9101d6 | |||
a4bc278e4c | |||
b04986622b | |||
2059e4fd44 | |||
618a1523cf | |||
ba1cdc2c73 | |||
aa2a25fffe | |||
c8ec67aef3 | |||
9576a99a4e | |||
0202224fa6 | |||
631ffd5c34 | |||
feed7ffc0b | |||
296485de8a | |||
d2f226d442 | |||
cbdb449e35 | |||
3100a3a08c | |||
18951d6461 | |||
0e0a39a40f |
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,6 +9,7 @@
|
|||||||
.history
|
.history
|
||||||
.svn/
|
.svn/
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
*.iml
|
*.iml
|
||||||
|
55
lib/app_sources/apkmirror.dart
Normal file
55
lib/app_sources/apkmirror.dart
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import 'package:html/parser.dart';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.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';
|
||||||
@ -28,51 +29,40 @@ class FDroid extends 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
String? appId = tryInferringAppId(standardUrl);
|
||||||
|
return getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||||
|
await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')),
|
||||||
|
'https://f-droid.org/repo/$appId');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AppNames getAppNames(String standardUrl) {
|
AppNames getAppNames(String standardUrl) {
|
||||||
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
||||||
|
@ -11,9 +11,9 @@ class GitHub extends AppSource {
|
|||||||
GitHub() {
|
GitHub() {
|
||||||
host = 'github.com';
|
host = 'github.com';
|
||||||
|
|
||||||
additionalDataDefaults = ['true', 'true', ''];
|
additionalSourceAppSpecificDefaults = ['true', 'true', ''];
|
||||||
|
|
||||||
moreSourceSettingsFormItems = [
|
additionalSourceSpecificSettingFormItems = [
|
||||||
GeneratedFormItem(
|
GeneratedFormItem(
|
||||||
label: 'GitHub Personal Access Token (Increases Rate Limit)',
|
label: 'GitHub Personal Access Token (Increases Rate Limit)',
|
||||||
id: 'github-creds',
|
id: 'github-creds',
|
||||||
@ -51,7 +51,7 @@ class GitHub extends AppSource {
|
|||||||
])
|
])
|
||||||
];
|
];
|
||||||
|
|
||||||
additionalDataFormItems = [
|
additionalSourceAppSpecificFormItems = [
|
||||||
[
|
[
|
||||||
GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)
|
GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)
|
||||||
],
|
],
|
||||||
@ -96,8 +96,8 @@ class GitHub extends 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@' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,9 +105,6 @@ class GitHub extends 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 {
|
||||||
@ -158,23 +155,14 @@ class GitHub extends 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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,23 +174,31 @@ class GitHub extends AppSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<String>> search(String query) async {
|
Future<Map<String, String>> search(String query) async {
|
||||||
Response res = await get(Uri.parse(
|
Response res = await get(Uri.parse(
|
||||||
'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100'));
|
'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100'));
|
||||||
if (res.statusCode == 200) {
|
if (res.statusCode == 200) {
|
||||||
return (jsonDecode(res.body)['items'] as List<dynamic>)
|
Map<String, String> urlsWithDescriptions = {};
|
||||||
.map((e) => e['html_url'] as String)
|
for (var e in (jsonDecode(res.body)['items'] 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());
|
|
||||||
}
|
}
|
||||||
throw ObtainiumError(
|
return urlsWithDescriptions;
|
||||||
res.reasonPhrase ?? 'Error ${res.statusCode.toString()}',
|
} else {
|
||||||
unexpected: true);
|
rateLimitErrorCheck(res);
|
||||||
|
throw getObtainiumHttpError(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimitErrorCheck(Response res) {
|
||||||
|
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||||
|
throw RateLimitError(
|
||||||
|
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||||
|
60000000)
|
||||||
|
.round());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,9 +23,6 @@ class GitLab extends 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 extends 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 extends 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 extends 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();
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import 'package:html/parser.dart';
|
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.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';
|
||||||
|
|
||||||
@ -22,41 +22,18 @@ class IzzyOnDroid extends 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
|
||||||
|
@ -22,9 +22,6 @@ class Mullvad extends 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 {
|
||||||
|
@ -16,9 +16,6 @@ class Signal extends 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 extends 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();
|
||||||
}
|
}
|
||||||
|
@ -21,9 +21,6 @@ class SourceForge extends 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 extends 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();
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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)
|
||||||
]),
|
]),
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:obtainium/providers/logs_provider.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class ObtainiumError {
|
class ObtainiumError {
|
||||||
late String message;
|
late String message;
|
||||||
@ -75,6 +77,8 @@ class MultiAppMultiError extends ObtainiumError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showError(dynamic e, BuildContext context) {
|
showError(dynamic e, BuildContext context) {
|
||||||
|
Provider.of<LogsProvider>(context, listen: false)
|
||||||
|
.add(e.toString(), level: LogLevels.error);
|
||||||
if (e is String || (e is ObtainiumError && !e.unexpected)) {
|
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())),
|
||||||
|
@ -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.7.0';
|
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',
|
||||||
@ -144,11 +154,16 @@ class _ObtainiumState extends State<Obtainium> {
|
|||||||
0,
|
0,
|
||||||
['true'],
|
['true'],
|
||||||
null,
|
null,
|
||||||
|
false,
|
||||||
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);
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>[
|
||||||
@ -66,24 +172,9 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
])
|
])
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
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(
|
||||||
@ -94,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: [
|
||||||
@ -171,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
|
||||||
@ -195,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,
|
||||||
@ -218,6 +387,9 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
)))
|
)))
|
||||||
.toList()
|
.toList()
|
||||||
])),
|
])),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
])),
|
])),
|
||||||
)
|
)
|
||||||
]));
|
]));
|
||||||
|
@ -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
|
||||||
|
@ -135,6 +135,22 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
: selectedApps.map((e) => e.id).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) {
|
||||||
@ -245,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(
|
||||||
@ -276,8 +292,7 @@ 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,
|
||||||
)))),
|
)))),
|
||||||
@ -349,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${selectedApps.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) {
|
||||||
@ -402,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)
|
||||||
@ -528,6 +575,36 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
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),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -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,24 +38,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<List<List<String>>> addApps(List<String> urls) async {
|
|
||||||
await settingsProvider.getInstallPermission();
|
|
||||||
List<dynamic> results = await sourceProvider.getApps(urls,
|
|
||||||
ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList());
|
|
||||||
List<App> apps = results[0];
|
|
||||||
Map<String, dynamic> errorsMap = results[1];
|
|
||||||
for (var app in apps) {
|
|
||||||
if (appsProvider.apps.containsKey(app.id)) {
|
|
||||||
errorsMap.addAll({app.id: 'App already added'});
|
|
||||||
} else {
|
|
||||||
await appsProvider.saveApps([app]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
List<List<String>> errors =
|
|
||||||
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: CustomScrollView(slivers: <Widget>[
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
@ -196,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',
|
||||||
@ -258,9 +241,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
importInProgress = true;
|
importInProgress = true;
|
||||||
});
|
});
|
||||||
var urls = await source
|
var urlsWithDescriptions =
|
||||||
.search(values[0]);
|
await source
|
||||||
if (urls.isNotEmpty) {
|
.search(values[0]);
|
||||||
|
if (urlsWithDescriptions
|
||||||
|
.isNotEmpty) {
|
||||||
var selectedUrls =
|
var selectedUrls =
|
||||||
await showDialog<
|
await showDialog<
|
||||||
List<
|
List<
|
||||||
@ -270,8 +255,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
(BuildContext
|
(BuildContext
|
||||||
ctx) {
|
ctx) {
|
||||||
return UrlSelectionModal(
|
return UrlSelectionModal(
|
||||||
urls: urls,
|
urlsWithDescriptions:
|
||||||
defaultSelected:
|
urlsWithDescriptions,
|
||||||
|
selectedByDefault:
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -280,8 +266,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
selectedUrls
|
selectedUrls
|
||||||
.isNotEmpty) {
|
.isNotEmpty) {
|
||||||
var errors =
|
var errors =
|
||||||
await addApps(
|
await appsProvider
|
||||||
selectedUrls);
|
.addAppsByURL(
|
||||||
|
selectedUrls);
|
||||||
if (errors.isEmpty) {
|
if (errors.isEmpty) {
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
showError(
|
showError(
|
||||||
@ -353,8 +340,10 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
importInProgress = true;
|
importInProgress = true;
|
||||||
});
|
});
|
||||||
var urls = await source
|
var urlsWithDescriptions =
|
||||||
.getUrls(values);
|
await source
|
||||||
|
.getUrlsWithDescriptions(
|
||||||
|
values);
|
||||||
var selectedUrls =
|
var selectedUrls =
|
||||||
await showDialog<
|
await showDialog<
|
||||||
List<String>?>(
|
List<String>?>(
|
||||||
@ -363,12 +352,14 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
(BuildContext
|
(BuildContext
|
||||||
ctx) {
|
ctx) {
|
||||||
return UrlSelectionModal(
|
return UrlSelectionModal(
|
||||||
urls: urls);
|
urlsWithDescriptions:
|
||||||
|
urlsWithDescriptions);
|
||||||
});
|
});
|
||||||
if (selectedUrls != null) {
|
if (selectedUrls != null) {
|
||||||
var errors =
|
var errors =
|
||||||
await addApps(
|
await appsProvider
|
||||||
selectedUrls);
|
.addAppsByURL(
|
||||||
|
selectedUrls);
|
||||||
if (errors.isEmpty) {
|
if (errors.isEmpty) {
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
showError(
|
showError(
|
||||||
@ -477,22 +468,36 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
|||||||
// ignore: must_be_immutable
|
// ignore: must_be_immutable
|
||||||
class UrlSelectionModal extends StatefulWidget {
|
class UrlSelectionModal extends StatefulWidget {
|
||||||
UrlSelectionModal(
|
UrlSelectionModal(
|
||||||
{super.key, required this.urls, this.defaultSelected = true});
|
{super.key,
|
||||||
|
required this.urlsWithDescriptions,
|
||||||
|
this.selectedByDefault = true,
|
||||||
|
this.onlyOneSelectionAllowed = false});
|
||||||
|
|
||||||
List<String> urls;
|
Map<String, String> urlsWithDescriptions;
|
||||||
bool defaultSelected;
|
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, () => widget.defaultSelected);
|
urlWithDescriptionSelections.putIfAbsent(url,
|
||||||
|
() => widget.selectedByDefault && !widget.onlyOneSelectionAllowed);
|
||||||
|
}
|
||||||
|
if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) {
|
||||||
|
selectOnlyOne(widget.urlsWithDescriptions.entries.first.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectOnlyOne(String url) {
|
||||||
|
for (var uwd in urlWithDescriptionSelections.keys) {
|
||||||
|
urlWithDescriptionSelections[uwd] = uwd.key == url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -500,23 +505,56 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
|||||||
Widget build(BuildContext context) {
|
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,
|
||||||
|
)
|
||||||
|
],
|
||||||
))
|
))
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
@ -528,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'))
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
@ -182,6 +187,15 @@ class AppsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> canDowngradeApps() async {
|
||||||
|
try {
|
||||||
|
await InstalledApps.getAppInfo('com.berdik.letmedowngrade');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Unfortunately this 'await' does not actually wait for the APK to finish installing
|
// 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
|
||||||
@ -195,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 ||
|
||||||
@ -251,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)
|
||||||
@ -258,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) {
|
||||||
@ -269,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 =
|
||||||
@ -300,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;
|
||||||
}
|
}
|
||||||
@ -376,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;
|
||||||
}
|
}
|
||||||
@ -412,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) {
|
||||||
@ -491,8 +526,9 @@ class AppsProvider with ChangeNotifier {
|
|||||||
currentApp.additionalData,
|
currentApp.additionalData,
|
||||||
name: currentApp.name,
|
name: currentApp.name,
|
||||||
id: currentApp.id,
|
id: currentApp.id,
|
||||||
pinned: currentApp.pinned);
|
pinned: currentApp.pinned,
|
||||||
newApp.installedVersion = currentApp.installedVersion;
|
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;
|
||||||
}
|
}
|
||||||
@ -599,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 {
|
||||||
|
109
lib/providers/logs_provider.dart
Normal file
109
lib/providers/logs_provider.dart
Normal 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);
|
||||||
|
}
|
@ -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 }
|
||||||
|
@ -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';
|
||||||
@ -41,6 +43,7 @@ class App {
|
|||||||
late List<String> additionalData;
|
late List<String> additionalData;
|
||||||
late DateTime? lastUpdateCheck;
|
late DateTime? lastUpdateCheck;
|
||||||
bool pinned = false;
|
bool pinned = false;
|
||||||
|
bool trackOnly = false;
|
||||||
App(
|
App(
|
||||||
this.id,
|
this.id,
|
||||||
this.url,
|
this.url,
|
||||||
@ -52,7 +55,8 @@ class App {
|
|||||||
this.preferredApkIndex,
|
this.preferredApkIndex,
|
||||||
this.additionalData,
|
this.additionalData,
|
||||||
this.lastUpdateCheck,
|
this.lastUpdateCheck,
|
||||||
this.pinned);
|
this.pinned,
|
||||||
|
this.trackOnly);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
@ -73,12 +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['pinned'] ?? false,
|
||||||
|
json['trackOnly'] ?? false);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
@ -91,7 +98,8 @@ class App {
|
|||||||
'preferredApkIndex': preferredApkIndex,
|
'preferredApkIndex': preferredApkIndex,
|
||||||
'additionalData': jsonEncode(additionalData),
|
'additionalData': jsonEncode(additionalData),
|
||||||
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
|
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
|
||||||
'pinned': pinned
|
'pinned': pinned,
|
||||||
|
'trackOnly': trackOnly
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,6 +142,7 @@ List<String> getLinksFromParsedHTML(
|
|||||||
|
|
||||||
class AppSource {
|
class AppSource {
|
||||||
late String host;
|
late String host;
|
||||||
|
bool enforceTrackOnly = false;
|
||||||
String standardizeURL(String url) {
|
String standardizeURL(String url) {
|
||||||
throw NotImplementedError();
|
throw NotImplementedError();
|
||||||
}
|
}
|
||||||
@ -147,27 +156,49 @@ class AppSource {
|
|||||||
throw NotImplementedError();
|
throw NotImplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
// Different Sources may need different kinds of additional data for Apps
|
||||||
List<String> additionalDataDefaults = [];
|
List<List<GeneratedFormItem>> additionalSourceAppSpecificFormItems = [];
|
||||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
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) {
|
String? changeLogPageFromStandardUrl(String standardUrl) {
|
||||||
throw NotImplementedError();
|
throw NotImplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> apkUrlPrefetchModifier(String apkUrl) {
|
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
|
||||||
throw NotImplementedError();
|
return apkUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool canSearch = false;
|
bool canSearch = false;
|
||||||
Future<List<String>> search(String query) {
|
Future<Map<String, String>> search(String query) {
|
||||||
throw NotImplementedError();
|
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 {
|
||||||
@ -179,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
|
||||||
@ -201,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;
|
||||||
@ -224,46 +256,56 @@ class SourceProvider {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return getSourceHosts().contains(parts.last);
|
return sources.map((e) => e.host).contains(parts.last);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<App> getApp(AppSource source, String url, List<String> additionalData,
|
Future<App> getApp(AppSource source, String url, List<String> additionalData,
|
||||||
{String name = '', String? id, bool pinned = false}) async {
|
{String name = '',
|
||||||
|
String? id,
|
||||||
|
bool pinned = false,
|
||||||
|
bool trackOnly = false,
|
||||||
|
String? installedVersion}) async {
|
||||||
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
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);
|
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();
|
|
||||||
}
|
}
|
||||||
|
39
pubspec.lock
39
pubspec.lock
@ -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.4"
|
version: "3.3.5"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -187,7 +187,7 @@ packages:
|
|||||||
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:
|
||||||
@ -201,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:
|
||||||
@ -372,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:
|
||||||
@ -491,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:
|
||||||
@ -567,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:
|
||||||
@ -588,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:
|
||||||
@ -622,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:
|
||||||
@ -678,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:
|
||||||
@ -720,7 +741,7 @@ packages:
|
|||||||
name: win32
|
name: win32
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.1.2"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -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.7.0+56 # When changing this, update the tag in main() accordingly
|
version: 0.8.0+63 # When changing this, update the tag in main() accordingly
|
||||||
|
|
||||||
environment:
|
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
|
||||||
|
Reference in New Issue
Block a user