mirror of
https://github.com/ImranR98/Obtainium.git
synced 2025-07-30 12:30:16 +02:00
Compare commits
40 Commits
v0.9.11-be
...
v0.10.5-be
Author | SHA1 | Date | |
---|---|---|---|
|
4252c2711b | ||
|
52913b0450 | ||
|
427b0ed8d2 | ||
|
a85d6d4f08 | ||
|
05f712603c | ||
|
fa2a80e34c | ||
|
f43e5a2ff1 | ||
|
b72aa8273e | ||
|
520f186e4a | ||
|
e1e97672cf | ||
|
1494bcd013 | ||
|
3457a0a12f | ||
|
b165400a6e | ||
|
c47bf937f1 | ||
|
2e19a8c04c | ||
|
05d4da86ec | ||
|
e9d1b04d54 | ||
|
cff5334c25 | ||
|
a55346fc22 | ||
|
885df678e5 | ||
|
bf7b0c5702 | ||
|
2972da4609 | ||
|
b8567af98e | ||
|
ea62c68b40 | ||
|
08a5af0449 | ||
|
36f327c16e | ||
|
768213cb34 | ||
|
e888fb7120 | ||
|
1fb68dd674 | ||
|
5c4bb8f84c | ||
|
1c8e759494 | ||
|
081c2a07d2 | ||
|
02751fe8fa | ||
|
95f3362a84 | ||
|
b68cf5a1be | ||
|
4eb7499591 | ||
|
98fafe2aa4 | ||
|
9bac74aadd | ||
|
0a93117bf0 | ||
|
451cc41c45 |
@@ -1,4 +1,4 @@
|
||||
#  Obtainium
|
||||
#  Obtainium
|
||||
|
||||
Get Android App Updates Directly From the Source.
|
||||
|
||||
@@ -9,6 +9,7 @@ Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to
|
||||
Currently supported App sources:
|
||||
- [GitHub](https://github.com/)
|
||||
- [GitLab](https://gitlab.com/)
|
||||
- [Codeberg](https://codeberg.org/)
|
||||
- [F-Droid](https://f-droid.org/)
|
||||
- [IzzyOnDroid](https://android.izzysoft.de/)
|
||||
- [Mullvad](https://mullvad.net/en/)
|
||||
@@ -18,6 +19,8 @@ Currently supported App sources:
|
||||
- Third Party F-Droid Repos
|
||||
- Any URLs ending with `/fdroid/<word>`, where `<word>` can be anything - most often `repo`
|
||||
- [Steam](https://store.steampowered.com/mobile)
|
||||
- "HTML" (Fallback)
|
||||
- Any other URL that returns an HTML page with links to APK files (if multiple, the last file alphabetically is picked)
|
||||
|
||||
## Limitations
|
||||
- App installs happen asynchronously and the success/failure of an install cannot be determined directly. This results in install statuses and versions sometimes being out of sync with the OS until the next launch or until the problem is manually corrected.
|
||||
|
@@ -2,4 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
BIN
assets/graphics/icon_small.png
Normal file
BIN
assets/graphics/icon_small.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.6 KiB |
@@ -211,6 +211,7 @@
|
||||
"language": "Sprache",
|
||||
"storagePermissionDenied": "Storage permission denied",
|
||||
"selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.",
|
||||
"filterAPKsByRegEx": "Filter APKs by Regular Expression",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minute erneut",
|
||||
"other": "Zu viele Anfragen (Rate begrenzt) - versuchen Sie es in {} Minuten erneut"
|
||||
|
@@ -211,6 +211,7 @@
|
||||
"language": "Language",
|
||||
"storagePermissionDenied": "Storage permission denied",
|
||||
"selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.",
|
||||
"filterAPKsByRegEx": "Filter APKs by Regular Expression",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Too many requests (rate limited) - try again in {} minute",
|
||||
"other": "Too many requests (rate limited) - try again in {} minutes"
|
||||
|
@@ -207,9 +207,10 @@
|
||||
"categoryDeleteWarning": "A(z) {} összes app kategorizálatlan állapotba kerül.",
|
||||
"addCategory": "Új kategória",
|
||||
"label": "Címke",
|
||||
"language": "Language",
|
||||
"storagePermissionDenied": "Storage permission denied",
|
||||
"selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.",
|
||||
"language": "Nyelv",
|
||||
"storagePermissionDenied": "Tárhely engedély megtagadva",
|
||||
"selectedCategorizeWarning": "Ez felváltja a kiválasztott alkalmazások meglévő kategória-beállításait.",
|
||||
"filterAPKsByRegEx": "Filter APKs by Regular Expression",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Túl sok kérés (korlátozott arány) – próbálja újra {} perc múlva",
|
||||
"other": "Túl sok kérés (korlátozott arány) – próbálja újra {} perc múlva"
|
||||
|
@@ -211,6 +211,7 @@
|
||||
"language": "Lingua",
|
||||
"storagePermissionDenied": "Storage permission denied",
|
||||
"selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.",
|
||||
"filterAPKsByRegEx": "Filter APKs by Regular Expression",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "Troppe richieste (traffico limitato) - riprova tra {} minuto",
|
||||
"other": "Troppe richieste (traffico limitato) - riprova tra {} minuti"
|
||||
|
@@ -209,8 +209,9 @@
|
||||
"addCategory": "カテゴリを追加",
|
||||
"label": "ラベル",
|
||||
"language": "言語",
|
||||
"storagePermissionDenied": "Storage permission denied",
|
||||
"selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.",
|
||||
"storagePermissionDenied": "ストレージ権限が拒否されました",
|
||||
"selectedCategorizeWarning": "これにより、選択したアプリの既存のカテゴリ設定がすべて置き換えられます。",
|
||||
"filterAPKsByRegEx": "Filter APKs by Regular Expression",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "リクエストが多すぎます(レート制限)- {}分後に再試行してください",
|
||||
"other": "リクエストが多すぎます(レート制限)- {}分後に再試行してください"
|
||||
|
@@ -12,7 +12,7 @@
|
||||
"ok": "好的",
|
||||
"and": "和",
|
||||
"startedBgUpdateTask": "开始后台检查更新任务",
|
||||
"bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}",
|
||||
"bgUpdateIgnoreAfterIs": "下次后台更新检查 {}",
|
||||
"startedActualBGUpdateCheck": "后台检查更新已开始",
|
||||
"bgUpdateTaskFinished": "后台检查更新已完成",
|
||||
"firstRun": "这是你第一次运行 Obtainium",
|
||||
@@ -199,18 +199,19 @@
|
||||
"downloadNotifDescription": "通知用户下载进度",
|
||||
"noAPKFound": "未找到安装包",
|
||||
"noVersionDetection": "无版本检测",
|
||||
"categorize": "Categorize",
|
||||
"categories": "Categories",
|
||||
"category": "Category",
|
||||
"noCategory": "No Category",
|
||||
"noCategories": "No Categories",
|
||||
"deleteCategoriesQuestion": "Delete Categories?",
|
||||
"categoryDeleteWarning": "All Apps in deleted categories will be set to uncategorized.",
|
||||
"addCategory": "Add Category",
|
||||
"label": "Label",
|
||||
"language": "Language",
|
||||
"storagePermissionDenied": "Storage permission denied",
|
||||
"selectedCategorizeWarning": "This will replace any existing category settings for the selected Apps.",
|
||||
"categorize": "归档",
|
||||
"categories": "归档",
|
||||
"category": "类别",
|
||||
"noCategory": "无类别",
|
||||
"noCategories": "无类别",
|
||||
"deleteCategoriesQuestion": "删除所有类别?",
|
||||
"categoryDeleteWarning": "所有被删除类别的应用程序将被设置为无类别",
|
||||
"addCategory": "添加类别",
|
||||
"label": "标签",
|
||||
"language": "语言",
|
||||
"storagePermissionDenied": "存储权限已被拒绝",
|
||||
"selectedCategorizeWarning": "这将取代所选应用程序的任何现有类别",
|
||||
"filterAPKsByRegEx": "Filter APKs by Regular Expression",
|
||||
"tooManyRequestsTryAgainInMinutes": {
|
||||
"one": "请求过多 (API 限制) - 在 {} 分钟后重试",
|
||||
"other": "请求过多 (API 限制) - 在 {} 分钟后重试"
|
||||
|
149
lib/app_sources/codeberg.dart
Normal file
149
lib/app_sources/codeberg.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'dart:convert';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class Codeberg extends AppSource {
|
||||
Codeberg() {
|
||||
host = 'codeberg.org';
|
||||
|
||||
additionalSourceSpecificSettingFormItems = [];
|
||||
|
||||
additionalSourceAppSpecificSettingFormItems = [
|
||||
[
|
||||
GeneratedFormSwitch('includePrereleases',
|
||||
label: tr('includePrereleases'), defaultValue: false)
|
||||
],
|
||||
[
|
||||
GeneratedFormSwitch('fallbackToOlderReleases',
|
||||
label: tr('fallbackToOlderReleases'), defaultValue: true)
|
||||
],
|
||||
[
|
||||
GeneratedFormTextField('filterReleaseTitlesByRegEx',
|
||||
label: tr('filterReleaseTitlesByRegEx'),
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
return regExValidator(value);
|
||||
}
|
||||
])
|
||||
]
|
||||
];
|
||||
|
||||
canSearch = true;
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(name);
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl/releases';
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
bool includePrereleases = additionalSettings['includePrereleases'];
|
||||
bool fallbackToOlderReleases =
|
||||
additionalSettings['fallbackToOlderReleases'];
|
||||
String? regexFilter =
|
||||
(additionalSettings['filterReleaseTitlesByRegEx'] as String?)
|
||||
?.isNotEmpty ==
|
||||
true
|
||||
? additionalSettings['filterReleaseTitlesByRegEx']
|
||||
: null;
|
||||
Response res = await get(Uri.parse(
|
||||
'https://$host/api/v1/repos${standardUrl.substring('https://$host'.length)}/releases?per_page=100'));
|
||||
if (res.statusCode == 200) {
|
||||
var releases = jsonDecode(res.body) as List<dynamic>;
|
||||
|
||||
List<String> getReleaseAPKUrls(dynamic release) =>
|
||||
(release['assets'] as List<dynamic>?)
|
||||
?.map((e) {
|
||||
return e['name'] != null && e['browser_download_url'] != null
|
||||
? MapEntry(e['name'] as String,
|
||||
e['browser_download_url'] as String)
|
||||
: const MapEntry('', '');
|
||||
})
|
||||
.where((element) => element.key.toLowerCase().endsWith('.apk'))
|
||||
.map((e) => e.value)
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
dynamic targetRelease;
|
||||
|
||||
for (int i = 0; i < releases.length; i++) {
|
||||
if (!fallbackToOlderReleases && i > 0) break;
|
||||
if (!includePrereleases && releases[i]['prerelease'] == true) {
|
||||
continue;
|
||||
}
|
||||
if (releases[i]['draft'] == true) {
|
||||
// Draft releases not supported
|
||||
}
|
||||
var nameToFilter = releases[i]['name'] as String?;
|
||||
if (nameToFilter == null || nameToFilter.trim().isEmpty) {
|
||||
// Some leave titles empty so tag is used
|
||||
nameToFilter = releases[i]['tag_name'] as String;
|
||||
}
|
||||
if (regexFilter != null &&
|
||||
!RegExp(regexFilter).hasMatch(nameToFilter.trim())) {
|
||||
continue;
|
||||
}
|
||||
var apkUrls = getReleaseAPKUrls(releases[i]);
|
||||
if (apkUrls.isEmpty && additionalSettings['trackOnly'] != true) {
|
||||
continue;
|
||||
}
|
||||
targetRelease = releases[i];
|
||||
targetRelease['apkUrls'] = apkUrls;
|
||||
break;
|
||||
}
|
||||
if (targetRelease == null) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
String? version = targetRelease['tag_name'];
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, targetRelease['apkUrls'] as List<String>,
|
||||
getAppNames(standardUrl));
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
||||
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
||||
return AppNames(names[0], names[1]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> search(String query) async {
|
||||
Response res = await get(Uri.parse(
|
||||
'https://$host/api/v1/repos/search?q=${Uri.encodeQueryComponent(query)}&limit=100'));
|
||||
if (res.statusCode == 200) {
|
||||
Map<String, String> urlsWithDescriptions = {};
|
||||
for (var e in (jsonDecode(res.body)['data'] as List<dynamic>)) {
|
||||
urlsWithDescriptions.addAll({
|
||||
e['html_url'] as String: e['description'] != null
|
||||
? e['description'] as String
|
||||
: tr('noDescription')
|
||||
});
|
||||
}
|
||||
return urlsWithDescriptions;
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
@@ -15,6 +15,7 @@ class GitHub extends AppSource {
|
||||
additionalSourceSpecificSettingFormItems = [
|
||||
GeneratedFormTextField('github-creds',
|
||||
label: tr('githubPATLabel'),
|
||||
password: true,
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
@@ -64,15 +65,7 @@ class GitHub extends AppSource {
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RegExp(value);
|
||||
} catch (e) {
|
||||
return tr('invalidRegEx');
|
||||
}
|
||||
return null;
|
||||
return regExValidator(value);
|
||||
}
|
||||
])
|
||||
]
|
||||
@@ -118,7 +111,7 @@ class GitHub extends AppSource {
|
||||
? additionalSettings['filterReleaseTitlesByRegEx']
|
||||
: null;
|
||||
Response res = await get(Uri.parse(
|
||||
'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
|
||||
'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases?per_page=100'));
|
||||
if (res.statusCode == 200) {
|
||||
var releases = jsonDecode(res.body) as List<dynamic>;
|
||||
|
||||
@@ -140,10 +133,13 @@ class GitHub extends AppSource {
|
||||
if (!includePrereleases && releases[i]['prerelease'] == true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var nameToFilter = releases[i]['name'] as String?;
|
||||
if (nameToFilter == null || nameToFilter.trim().isEmpty) {
|
||||
// Some leave titles empty so tag is used
|
||||
nameToFilter = releases[i]['tag_name'] as String;
|
||||
}
|
||||
if (regexFilter != null &&
|
||||
!RegExp(regexFilter)
|
||||
.hasMatch((releases[i]['name'] as String).trim())) {
|
||||
!RegExp(regexFilter).hasMatch(nameToFilter.trim())) {
|
||||
continue;
|
||||
}
|
||||
var apkUrls = getReleaseAPKUrls(releases[i]);
|
||||
|
47
lib/app_sources/html.dart
Normal file
47
lib/app_sources/html.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class HTML extends AppSource {
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl,
|
||||
Map<String, dynamic> additionalSettings,
|
||||
) async {
|
||||
var uri = Uri.parse(standardUrl);
|
||||
Response res = await get(uri);
|
||||
if (res.statusCode == 200) {
|
||||
List<String> links = parse(res.body)
|
||||
.querySelectorAll('a')
|
||||
.map((element) => element.attributes['href'] ?? '')
|
||||
.where((element) => element.toLowerCase().endsWith('.apk'))
|
||||
.toList();
|
||||
links.sort((a, b) => a.split('/').last.compareTo(b.split('/').last));
|
||||
if (links.isEmpty) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
var rel = links.last;
|
||||
var apkName = rel.split('/').last;
|
||||
var version = apkName.substring(0, apkName.length - 4);
|
||||
List<String> apkUrls = [rel]
|
||||
.map((e) => e.toLowerCase().startsWith('http://') ||
|
||||
e.toLowerCase().startsWith('https://')
|
||||
? e
|
||||
: '${uri.origin}/$e')
|
||||
.toList();
|
||||
return APKDetails(version, apkUrls, AppNames(uri.host, tr('app')));
|
||||
} else {
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
}
|
@@ -3,7 +3,6 @@ import 'dart:math';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
|
||||
abstract class GeneratedFormItem {
|
||||
late String key;
|
||||
@@ -24,6 +23,7 @@ class GeneratedFormTextField extends GeneratedFormItem {
|
||||
late bool required;
|
||||
late int max;
|
||||
late String? hint;
|
||||
late bool password;
|
||||
|
||||
GeneratedFormTextField(String key,
|
||||
{String label = 'Input',
|
||||
@@ -32,7 +32,8 @@ class GeneratedFormTextField extends GeneratedFormItem {
|
||||
List<String? Function(String? value)> additionalValidators = const [],
|
||||
this.required = true,
|
||||
this.max = 1,
|
||||
this.hint})
|
||||
this.hint,
|
||||
this.password = false})
|
||||
: super(key,
|
||||
label: label,
|
||||
belowWidgets: belowWidgets,
|
||||
@@ -129,6 +130,21 @@ class GeneratedForm extends StatefulWidget {
|
||||
State<GeneratedForm> createState() => _GeneratedFormState();
|
||||
}
|
||||
|
||||
// Generates a random light color
|
||||
// Courtesy of ChatGPT 😭 (with a bugfix 🥳)
|
||||
Color generateRandomLightColor() {
|
||||
// Create a random number generator
|
||||
final Random random = Random();
|
||||
|
||||
// Generate random hue, saturation, and value values
|
||||
final double hue = random.nextDouble() * 360;
|
||||
final double saturation = 0.5 + random.nextDouble() * 0.5;
|
||||
final double value = 0.9 + random.nextDouble() * 0.1;
|
||||
|
||||
// Create a HSV color with the random values
|
||||
return HSVColor.fromAHSV(1.0, hue, saturation, value).toColor();
|
||||
}
|
||||
|
||||
class _GeneratedFormState extends State<GeneratedForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
Map<String, dynamic> values = {};
|
||||
@@ -153,21 +169,6 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
widget.onValueChanges(returnValues, valid, isBuilding);
|
||||
}
|
||||
|
||||
// Generates a random light color
|
||||
// Courtesy of ChatGPT 😭 (with a bugfix 🥳)
|
||||
Color generateRandomLightColor() {
|
||||
// Create a random number generator
|
||||
final Random random = Random();
|
||||
|
||||
// Generate random hue, saturation, and value values
|
||||
final double hue = random.nextDouble() * 360;
|
||||
final double saturation = 0.5 + random.nextDouble() * 0.5;
|
||||
final double value = 0.9 + random.nextDouble() * 0.1;
|
||||
|
||||
// Create a HSV color with the random values
|
||||
return HSVColor.fromAHSV(1.0, hue, saturation, value).toColor();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -188,6 +189,9 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
if (formItem is GeneratedFormTextField) {
|
||||
final formFieldKey = GlobalKey<FormFieldState>();
|
||||
return TextFormField(
|
||||
obscureText: formItem.password,
|
||||
autocorrect: !formItem.password,
|
||||
enableSuggestions: !formItem.password,
|
||||
key: formFieldKey,
|
||||
initialValue: values[formItem.key],
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
|
@@ -29,7 +29,7 @@ class NoReleasesError extends ObtainiumError {
|
||||
}
|
||||
|
||||
class NoAPKError extends ObtainiumError {
|
||||
NoAPKError() : super(tr('noReleaseFound'));
|
||||
NoAPKError() : super(tr('noAPKFound'));
|
||||
}
|
||||
|
||||
class NoVersionError extends ObtainiumError {
|
||||
|
@@ -21,7 +21,7 @@ import 'package:easy_localization/src/easy_localization_controller.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:easy_localization/src/localization.dart';
|
||||
|
||||
const String currentVersion = '0.9.11';
|
||||
const String currentVersion = '0.10.5';
|
||||
const String currentReleaseTag =
|
||||
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
|
||||
|
@@ -24,6 +24,7 @@ class AddAppPage extends StatefulWidget {
|
||||
|
||||
class _AddAppPageState extends State<AddAppPage> {
|
||||
bool gettingAppInfo = false;
|
||||
bool searching = false;
|
||||
|
||||
String userInput = '';
|
||||
String searchQuery = '';
|
||||
@@ -37,6 +38,8 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||
|
||||
bool doingSomething = gettingAppInfo || searching;
|
||||
|
||||
changeUserInput(String input, bool valid, bool isBuilding) {
|
||||
userInput = input;
|
||||
if (!isBuilding) {
|
||||
@@ -67,6 +70,7 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
additionalSettings['noVersionDetection'] == true;
|
||||
var cont = true;
|
||||
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
|
||||
// ignore: use_build_context_synchronously
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
@@ -85,6 +89,7 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
cont = false;
|
||||
}
|
||||
if (userPickedNoVersionDetection &&
|
||||
// ignore: use_build_context_synchronously
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
@@ -198,7 +203,7 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
gettingAppInfo
|
||||
? const CircularProgressIndicator()
|
||||
: ElevatedButton(
|
||||
onPressed: gettingAppInfo ||
|
||||
onPressed: doingSomething ||
|
||||
pickedSource == null ||
|
||||
(pickedSource!
|
||||
.combinedAppSpecificSettingFormItems
|
||||
@@ -249,9 +254,12 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
width: 16,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: searchQuery.isEmpty || gettingAppInfo
|
||||
onPressed: searchQuery.isEmpty || doingSomething
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
searching = true;
|
||||
});
|
||||
Future.wait(sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.map((e) =>
|
||||
@@ -293,6 +301,10 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
searching = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Text(tr('search')))
|
||||
|
@@ -317,7 +317,7 @@ class _AppPageState extends State<AppPage> {
|
||||
tooltip: tr('more')),
|
||||
const SizedBox(width: 16.0),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
child: TextButton(
|
||||
onPressed: (app?.app.installedVersion == null ||
|
||||
app?.app.installedVersion !=
|
||||
app?.app.latestVersion) &&
|
||||
@@ -356,7 +356,8 @@ class _AppPageState extends State<AppPage> {
|
||||
? tr('update')
|
||||
: tr('markUpdated')))),
|
||||
const SizedBox(width: 16.0),
|
||||
ElevatedButton(
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: () {
|
||||
@@ -401,7 +402,7 @@ class _AppPageState extends State<AppPage> {
|
||||
surfaceTintColor:
|
||||
Theme.of(context).colorScheme.error),
|
||||
child: Text(tr('remove')),
|
||||
),
|
||||
)),
|
||||
])),
|
||||
if (app?.downloadProgress != null)
|
||||
Padding(
|
||||
|
@@ -344,12 +344,15 @@ class AppsPageState extends State<AppsPage> {
|
||||
));
|
||||
}, childCount: sortedApps.length))
|
||||
])),
|
||||
persistentFooterButtons: [
|
||||
persistentFooterButtons: appsProvider.apps.isEmpty
|
||||
? null
|
||||
: [
|
||||
Row(
|
||||
children: [
|
||||
selectedApps.isEmpty
|
||||
? IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
? TextButton.icon(
|
||||
style: const ButtonStyle(
|
||||
visualDensity: VisualDensity.compact),
|
||||
onPressed: () {
|
||||
selectThese(sortedApps.map((e) => e.app).toList());
|
||||
},
|
||||
@@ -357,13 +360,14 @@ class AppsPageState extends State<AppsPage> {
|
||||
Icons.select_all_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
tooltip: tr('selectAll'))
|
||||
label: Text(sortedApps.length.toString()))
|
||||
: TextButton.icon(
|
||||
style:
|
||||
const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||
style: const ButtonStyle(
|
||||
visualDensity: VisualDensity.compact),
|
||||
onPressed: () {
|
||||
selectedApps.isEmpty
|
||||
? selectThese(sortedApps.map((e) => e.app).toList())
|
||||
? selectThese(
|
||||
sortedApps.map((e) => e.app).toList())
|
||||
: clearSelected();
|
||||
},
|
||||
icon: Icon(
|
||||
@@ -375,31 +379,36 @@ class AppsPageState extends State<AppsPage> {
|
||||
label: Text(selectedApps.length.toString())),
|
||||
const VerticalDivider(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
selectedApps.isEmpty
|
||||
? const SizedBox()
|
||||
: IconButton(
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
onPressed: selectedApps.isEmpty
|
||||
? null
|
||||
: () {
|
||||
showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('removeSelectedAppsQuestion'),
|
||||
title: tr(
|
||||
'removeSelectedAppsQuestion'),
|
||||
items: const [],
|
||||
initValid: true,
|
||||
message: tr(
|
||||
'xWillBeRemovedButRemainInstalled',
|
||||
args: [
|
||||
plural('apps', selectedApps.length)
|
||||
plural('apps',
|
||||
selectedApps.length)
|
||||
]),
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
appsProvider.removeApps(
|
||||
selectedApps.map((e) => e.id).toList());
|
||||
appsProvider.removeApps(selectedApps
|
||||
.map((e) => e.id)
|
||||
.toList());
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -408,58 +417,86 @@ class AppsPageState extends State<AppsPage> {
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: appsProvider.areDownloadsRunning() ||
|
||||
(existingUpdateIdsAllOrSelected.isEmpty &&
|
||||
newInstallIdsAllOrSelected.isEmpty &&
|
||||
trackOnlyUpdateIdsAllOrSelected.isEmpty)
|
||||
onPressed: appsProvider
|
||||
.areDownloadsRunning() ||
|
||||
(existingUpdateIdsAllOrSelected
|
||||
.isEmpty &&
|
||||
newInstallIdsAllOrSelected
|
||||
.isEmpty &&
|
||||
trackOnlyUpdateIdsAllOrSelected
|
||||
.isEmpty)
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
List<GeneratedFormItem> formItems = [];
|
||||
if (existingUpdateIdsAllOrSelected.isNotEmpty) {
|
||||
formItems.add(GeneratedFormSwitch('updates',
|
||||
List<GeneratedFormItem> formItems =
|
||||
[];
|
||||
if (existingUpdateIdsAllOrSelected
|
||||
.isNotEmpty) {
|
||||
formItems.add(GeneratedFormSwitch(
|
||||
'updates',
|
||||
label: tr('updateX', args: [
|
||||
plural('apps',
|
||||
existingUpdateIdsAllOrSelected.length)
|
||||
plural(
|
||||
'apps',
|
||||
existingUpdateIdsAllOrSelected
|
||||
.length)
|
||||
]),
|
||||
defaultValue: true));
|
||||
}
|
||||
if (newInstallIdsAllOrSelected.isNotEmpty) {
|
||||
formItems.add(GeneratedFormSwitch('installs',
|
||||
if (newInstallIdsAllOrSelected
|
||||
.isNotEmpty) {
|
||||
formItems.add(GeneratedFormSwitch(
|
||||
'installs',
|
||||
label: tr('installX', args: [
|
||||
plural('apps',
|
||||
newInstallIdsAllOrSelected.length)
|
||||
plural(
|
||||
'apps',
|
||||
newInstallIdsAllOrSelected
|
||||
.length)
|
||||
]),
|
||||
defaultValue: existingUpdateIdsAllOrSelected
|
||||
defaultValue:
|
||||
existingUpdateIdsAllOrSelected
|
||||
.isNotEmpty));
|
||||
}
|
||||
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
|
||||
formItems.add(GeneratedFormSwitch('trackonlies',
|
||||
label: tr('markXTrackOnlyAsUpdated', args: [
|
||||
plural('apps',
|
||||
trackOnlyUpdateIdsAllOrSelected.length)
|
||||
if (trackOnlyUpdateIdsAllOrSelected
|
||||
.isNotEmpty) {
|
||||
formItems.add(GeneratedFormSwitch(
|
||||
'trackonlies',
|
||||
label: tr(
|
||||
'markXTrackOnlyAsUpdated',
|
||||
args: [
|
||||
plural(
|
||||
'apps',
|
||||
trackOnlyUpdateIdsAllOrSelected
|
||||
.length)
|
||||
]),
|
||||
defaultValue: existingUpdateIdsAllOrSelected
|
||||
defaultValue:
|
||||
existingUpdateIdsAllOrSelected
|
||||
.isNotEmpty ||
|
||||
newInstallIdsAllOrSelected.isNotEmpty));
|
||||
newInstallIdsAllOrSelected
|
||||
.isNotEmpty));
|
||||
}
|
||||
showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
var totalApps = existingUpdateIdsAllOrSelected
|
||||
.length +
|
||||
newInstallIdsAllOrSelected.length +
|
||||
trackOnlyUpdateIdsAllOrSelected.length;
|
||||
newInstallIdsAllOrSelected
|
||||
.length +
|
||||
trackOnlyUpdateIdsAllOrSelected
|
||||
.length;
|
||||
return GeneratedFormModal(
|
||||
title: tr('changeX',
|
||||
args: [plural('apps', totalApps)]),
|
||||
items: formItems.map((e) => [e]).toList(),
|
||||
title: tr('changeX', args: [
|
||||
plural('apps', totalApps)
|
||||
]),
|
||||
items: formItems
|
||||
.map((e) => [e])
|
||||
.toList(),
|
||||
initValid: true,
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
if (values.isEmpty) {
|
||||
values = getDefaultValuesFromFormItems(
|
||||
values =
|
||||
getDefaultValuesFromFormItems(
|
||||
[formItems]);
|
||||
}
|
||||
bool shouldInstallUpdates =
|
||||
@@ -478,20 +515,22 @@ class AppsPageState extends State<AppsPage> {
|
||||
.then((_) {
|
||||
List<String> toInstall = [];
|
||||
if (shouldInstallUpdates) {
|
||||
toInstall
|
||||
.addAll(existingUpdateIdsAllOrSelected);
|
||||
toInstall.addAll(
|
||||
existingUpdateIdsAllOrSelected);
|
||||
}
|
||||
if (shouldInstallNew) {
|
||||
toInstall
|
||||
.addAll(newInstallIdsAllOrSelected);
|
||||
toInstall.addAll(
|
||||
newInstallIdsAllOrSelected);
|
||||
}
|
||||
if (shouldMarkTrackOnlies) {
|
||||
toInstall.addAll(
|
||||
trackOnlyUpdateIdsAllOrSelected);
|
||||
}
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApps(toInstall,
|
||||
globalNavigatorKey.currentContext)
|
||||
.downloadAndInstallLatestApps(
|
||||
toInstall,
|
||||
globalNavigatorKey
|
||||
.currentContext)
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
@@ -505,16 +544,17 @@ class AppsPageState extends State<AppsPage> {
|
||||
icon: const Icon(
|
||||
Icons.file_download_outlined,
|
||||
)),
|
||||
selectedApps.isEmpty
|
||||
? const SizedBox()
|
||||
: IconButton(
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () async {
|
||||
onPressed: selectedApps.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
try {
|
||||
Set<String>? preselected;
|
||||
var showPrompt = false;
|
||||
for (var element in selectedApps) {
|
||||
var currentCats = element.categories.toSet();
|
||||
var currentCats =
|
||||
element.categories.toSet();
|
||||
if (preselected == null) {
|
||||
preselected = currentCats;
|
||||
} else {
|
||||
@@ -527,36 +567,47 @@ class AppsPageState extends State<AppsPage> {
|
||||
}
|
||||
var cont = true;
|
||||
if (showPrompt) {
|
||||
cont = await showDialog<Map<String, dynamic>?>(
|
||||
cont = await showDialog<
|
||||
Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('categorize'),
|
||||
items: const [],
|
||||
initValid: true,
|
||||
message:
|
||||
tr('selectedCategorizeWarning'),
|
||||
message: tr(
|
||||
'selectedCategorizeWarning'),
|
||||
);
|
||||
}) !=
|
||||
null;
|
||||
}
|
||||
if (cont) {
|
||||
await showDialog<Map<String, dynamic>?>(
|
||||
// ignore: use_build_context_synchronously
|
||||
await showDialog<
|
||||
Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('categorize'),
|
||||
items: const [],
|
||||
initValid: true,
|
||||
singleNullReturnButton: tr('continue'),
|
||||
singleNullReturnButton:
|
||||
tr('continue'),
|
||||
additionalWidgets: [
|
||||
CategoryEditorSelector(
|
||||
preselected: preselected ?? {},
|
||||
showLabelWhenNotEmpty: false,
|
||||
onSelected: (categories) {
|
||||
appsProvider
|
||||
.saveApps(selectedApps.map((e) {
|
||||
e.categories = categories;
|
||||
preselected: !showPrompt
|
||||
? preselected ?? {}
|
||||
: {},
|
||||
showLabelWhenNotEmpty:
|
||||
false,
|
||||
onSelected:
|
||||
(categories) {
|
||||
appsProvider.saveApps(
|
||||
selectedApps
|
||||
.map((e) {
|
||||
e.categories =
|
||||
categories;
|
||||
return e;
|
||||
}).toList());
|
||||
},
|
||||
@@ -572,78 +623,65 @@ class AppsPageState extends State<AppsPage> {
|
||||
tooltip: tr('categorize'),
|
||||
icon: const Icon(Icons.category_outlined),
|
||||
),
|
||||
selectedApps.isEmpty
|
||||
? const SizedBox()
|
||||
: IconButton(
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
onPressed: selectedApps.isEmpty
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
content: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
padding:
|
||||
const EdgeInsets.only(
|
||||
top: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceAround,
|
||||
MainAxisAlignment
|
||||
.spaceAround,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed:
|
||||
appsProvider
|
||||
onPressed: appsProvider
|
||||
.areDownloadsRunning()
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
context:
|
||||
context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(tr(
|
||||
'markXSelectedAppsAsUpdated',
|
||||
args: [
|
||||
selectedApps
|
||||
.length
|
||||
.toString()
|
||||
title:
|
||||
Text(tr('markXSelectedAppsAsUpdated', args: [
|
||||
selectedApps.length.toString()
|
||||
])),
|
||||
content: Text(
|
||||
content:
|
||||
Text(
|
||||
tr('onlyWorksWithNonEVDApps'),
|
||||
style: const TextStyle(
|
||||
fontWeight:
|
||||
FontWeight
|
||||
.bold,
|
||||
fontStyle:
|
||||
FontStyle.italic),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontStyle: FontStyle.italic),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed:
|
||||
() {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
tr('no'))),
|
||||
child: Text(tr('no'))),
|
||||
TextButton(
|
||||
onPressed:
|
||||
() {
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
appsProvider
|
||||
.saveApps(selectedApps.map((a) {
|
||||
if (a.installedVersion !=
|
||||
null) {
|
||||
onPressed: () {
|
||||
HapticFeedback.selectionClick();
|
||||
appsProvider.saveApps(selectedApps.map((a) {
|
||||
if (a.installedVersion != null) {
|
||||
a.installedVersion = a.latestVersion;
|
||||
}
|
||||
return a;
|
||||
}).toList());
|
||||
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
tr('yes')))
|
||||
child: Text(tr('yes')))
|
||||
],
|
||||
);
|
||||
}).whenComplete(() {
|
||||
@@ -652,86 +690,117 @@ class AppsPageState extends State<AppsPage> {
|
||||
.pop();
|
||||
});
|
||||
},
|
||||
tooltip:
|
||||
tr('markSelectedAppsUpdated'),
|
||||
icon: const Icon(Icons.done)),
|
||||
tooltip: tr(
|
||||
'markSelectedAppsUpdated'),
|
||||
icon: const Icon(
|
||||
Icons.done)),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
var pinStatus = selectedApps
|
||||
.where((element) =>
|
||||
element.pinned)
|
||||
element
|
||||
.pinned)
|
||||
.isEmpty;
|
||||
appsProvider.saveApps(
|
||||
selectedApps.map((e) {
|
||||
e.pinned = pinStatus;
|
||||
appsProvider
|
||||
.saveApps(
|
||||
selectedApps
|
||||
.map(
|
||||
(e) {
|
||||
e.pinned =
|
||||
pinStatus;
|
||||
return e;
|
||||
}).toList());
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(
|
||||
context)
|
||||
.pop();
|
||||
},
|
||||
tooltip: selectedApps
|
||||
.where((element) =>
|
||||
element.pinned)
|
||||
element
|
||||
.pinned)
|
||||
.isEmpty
|
||||
? tr('pinToTop')
|
||||
: tr('unpinFromTop'),
|
||||
: tr(
|
||||
'unpinFromTop'),
|
||||
icon: Icon(selectedApps
|
||||
.where((element) =>
|
||||
element.pinned)
|
||||
element
|
||||
.pinned)
|
||||
.isEmpty
|
||||
? Icons.bookmark_outline_rounded
|
||||
? Icons
|
||||
.bookmark_outline_rounded
|
||||
: Icons
|
||||
.bookmark_remove_outlined),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
String urls = '';
|
||||
for (var a in selectedApps) {
|
||||
urls += '${a.url}\n';
|
||||
for (var a
|
||||
in selectedApps) {
|
||||
urls +=
|
||||
'${a.url}\n';
|
||||
}
|
||||
urls = urls.substring(
|
||||
0, urls.length - 1);
|
||||
urls =
|
||||
urls.substring(
|
||||
0,
|
||||
urls.length -
|
||||
1);
|
||||
Share.share(urls,
|
||||
subject: tr(
|
||||
'selectedAppURLsFromObtainium'));
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(
|
||||
context)
|
||||
.pop();
|
||||
},
|
||||
tooltip: tr('shareSelectedAppURLs'),
|
||||
icon: const Icon(Icons.share),
|
||||
tooltip: tr(
|
||||
'shareSelectedAppURLs'),
|
||||
icon: const Icon(
|
||||
Icons.share),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
context:
|
||||
context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr(
|
||||
'resetInstallStatusForSelectedAppsQuestion'),
|
||||
items: const [],
|
||||
initValid: true,
|
||||
initValid:
|
||||
true,
|
||||
message: tr(
|
||||
'installStatusOfXWillBeResetExplanation',
|
||||
args: [
|
||||
plural(
|
||||
'app',
|
||||
selectedApps
|
||||
.length)
|
||||
selectedApps.length)
|
||||
]),
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
if (values !=
|
||||
null) {
|
||||
appsProvider.saveApps(
|
||||
selectedApps.map((e) {
|
||||
e.installedVersion = null;
|
||||
selectedApps
|
||||
.map(
|
||||
(e) {
|
||||
e.installedVersion =
|
||||
null;
|
||||
return e;
|
||||
}).toList());
|
||||
}
|
||||
}).whenComplete(() {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(
|
||||
context)
|
||||
.pop();
|
||||
});
|
||||
},
|
||||
tooltip: tr('resetInstallStatus'),
|
||||
icon: const Icon(
|
||||
Icons.restore_page_outlined),
|
||||
tooltip: tr(
|
||||
'resetInstallStatus'),
|
||||
icon: const Icon(Icons
|
||||
.restore_page_outlined),
|
||||
),
|
||||
]),
|
||||
),
|
||||
@@ -742,7 +811,7 @@ class AppsPageState extends State<AppsPage> {
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
),
|
||||
],
|
||||
)),
|
||||
))),
|
||||
const VerticalDivider(),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
@@ -765,11 +834,9 @@ class AppsPageState extends State<AppsPage> {
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
appsProvider.apps.isEmpty
|
||||
? const SizedBox()
|
||||
: TextButton.icon(
|
||||
style:
|
||||
const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||
TextButton.icon(
|
||||
style: const ButtonStyle(
|
||||
visualDensity: VisualDensity.compact),
|
||||
label: Text(
|
||||
filter.isIdenticalTo(neutralFilter, settingsProvider)
|
||||
? tr('filter')
|
||||
@@ -817,7 +884,8 @@ class AppsPageState extends State<AppsPage> {
|
||||
CategoryEditorSelector(
|
||||
preselected: filter.categoryFilter,
|
||||
onSelected: (categories) {
|
||||
filter.categoryFilter = categories.toSet();
|
||||
filter.categoryFilter =
|
||||
categories.toSet();
|
||||
},
|
||||
)
|
||||
],
|
||||
|
@@ -9,6 +9,7 @@ import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
@@ -28,6 +29,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
var appsProvider = context.read<AppsProvider>();
|
||||
var settingsProvider = context.read<SettingsProvider>();
|
||||
var outlineButtonStyle = ButtonStyle(
|
||||
shape: MaterialStateProperty.all(
|
||||
StadiumBorder(
|
||||
@@ -100,6 +102,21 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
appsProvider
|
||||
.importApps(data)
|
||||
.then((value) {
|
||||
var cats =
|
||||
settingsProvider.categories;
|
||||
appsProvider.apps
|
||||
.forEach((key, value) {
|
||||
for (var c
|
||||
in value.app.categories) {
|
||||
if (!cats.containsKey(c)) {
|
||||
cats[c] =
|
||||
generateRandomLightColor()
|
||||
.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
settingsProvider.categories =
|
||||
cats;
|
||||
showError(
|
||||
tr('importedX', args: [
|
||||
plural('apps', value)
|
||||
|
@@ -4,10 +4,8 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/main.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/logs_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
@@ -247,10 +247,7 @@ class AppsProvider with ChangeNotifier {
|
||||
!(await canDowngradeApps())) {
|
||||
throw DowngradeError();
|
||||
}
|
||||
if (appInfo == null ||
|
||||
int.parse(newInfo.buildNumber) > appInfo.versionCode!) {
|
||||
await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium');
|
||||
}
|
||||
apps[file.appId]!.app.installedVersion =
|
||||
apps[file.appId]!.app.latestVersion;
|
||||
// Don't correct install status as installation may not be done yet
|
||||
@@ -265,6 +262,7 @@ class AppsProvider with ChangeNotifier {
|
||||
List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
|
||||
|
||||
if (app.apkUrls.length > 1 && context != null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
apkUrl = await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
@@ -284,6 +282,7 @@ class AppsProvider with ChangeNotifier {
|
||||
if (apkUrl != null &&
|
||||
getHost(apkUrl) != getHost(app.url) &&
|
||||
context != null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
if (await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
|
@@ -7,11 +7,13 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/apkmirror.dart';
|
||||
import 'package:obtainium/app_sources/codeberg.dart';
|
||||
import 'package:obtainium/app_sources/fdroid.dart';
|
||||
import 'package:obtainium/app_sources/fdroidrepo.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/app_sources/gitlab.dart';
|
||||
import 'package:obtainium/app_sources/izzyondroid.dart';
|
||||
import 'package:obtainium/app_sources/html.dart';
|
||||
import 'package:obtainium/app_sources/mullvad.dart';
|
||||
import 'package:obtainium/app_sources/signal.dart';
|
||||
import 'package:obtainium/app_sources/sourceforge.dart';
|
||||
@@ -19,7 +21,6 @@ import 'package:obtainium/app_sources/steammobile.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/mass_app_sources/githubstars.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
|
||||
class AppNames {
|
||||
late String author;
|
||||
@@ -154,6 +155,10 @@ class App {
|
||||
|
||||
// Ensure the input is starts with HTTPS and has no WWW
|
||||
preStandardizeUrl(String url) {
|
||||
var firstDotIndex = url.indexOf('.');
|
||||
if (!(firstDotIndex >= 0 && firstDotIndex != url.length - 1)) {
|
||||
throw UnsupportedURLError();
|
||||
}
|
||||
if (url.toLowerCase().indexOf('http://') != 0 &&
|
||||
url.toLowerCase().indexOf('https://') != 0) {
|
||||
url = 'https://$url';
|
||||
@@ -220,7 +225,19 @@ class AppSource {
|
||||
label: tr('trackOnly'),
|
||||
)
|
||||
],
|
||||
[GeneratedFormSwitch('noVersionDetection', label: tr('noVersionDetection'))]
|
||||
[
|
||||
GeneratedFormSwitch('noVersionDetection', label: tr('noVersionDetection'))
|
||||
],
|
||||
[
|
||||
GeneratedFormTextField('apkFilterRegEx',
|
||||
label: tr('filterAPKsByRegEx'),
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
return regExValidator(value);
|
||||
}
|
||||
])
|
||||
]
|
||||
];
|
||||
|
||||
// Previous 2 variables combined into one at runtime for convenient usage
|
||||
@@ -264,11 +281,24 @@ abstract class MassAppUrlSource {
|
||||
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
|
||||
}
|
||||
|
||||
regExValidator(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RegExp(value);
|
||||
} catch (e) {
|
||||
return tr('invalidRegEx');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class SourceProvider {
|
||||
// Add more source classes here so they are available via the service
|
||||
List<AppSource> sources = [
|
||||
GitHub(),
|
||||
GitLab(),
|
||||
Codeberg(),
|
||||
FDroid(),
|
||||
IzzyOnDroid(),
|
||||
Mullvad(),
|
||||
@@ -276,7 +306,8 @@ class SourceProvider {
|
||||
SourceForge(),
|
||||
APKMirror(),
|
||||
FDroidRepo(),
|
||||
SteamMobile()
|
||||
SteamMobile(),
|
||||
HTML() // This should ALWAYS be the last option as they are tried in order
|
||||
];
|
||||
|
||||
// Add more mass url source classes here so they are available via the service
|
||||
@@ -337,10 +368,13 @@ class SourceProvider {
|
||||
}
|
||||
|
||||
Future<App> getApp(
|
||||
AppSource source, String url, Map<String, dynamic> additionalSettings,
|
||||
{App? currentApp,
|
||||
AppSource source,
|
||||
String url,
|
||||
Map<String, dynamic> additionalSettings, {
|
||||
App? currentApp,
|
||||
bool trackOnlyOverride = false,
|
||||
noVersionDetectionOverride = false}) async {
|
||||
noVersionDetectionOverride = false,
|
||||
}) async {
|
||||
if (trackOnlyOverride || source.enforceTrackOnly) {
|
||||
additionalSettings['trackOnly'] = true;
|
||||
}
|
||||
@@ -351,6 +385,11 @@ class SourceProvider {
|
||||
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
||||
APKDetails apk =
|
||||
await source.getLatestAPKDetails(standardUrl, additionalSettings);
|
||||
if (additionalSettings['apkFilterRegEx'] != null) {
|
||||
var reg = RegExp(additionalSettings['apkFilterRegEx']);
|
||||
apk.apkUrls =
|
||||
apk.apkUrls.where((element) => reg.hasMatch(element)).toList();
|
||||
}
|
||||
if (apk.apkUrls.isEmpty && !trackOnly) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
|
431
pubspec.lock
431
pubspec.lock
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.9.11+101 # When changing this, update the tag in main() accordingly
|
||||
version: 0.10.5+111 # When changing this, update the tag in main() accordingly
|
||||
|
||||
environment:
|
||||
sdk: '>=2.18.2 <3.0.0'
|
||||
|
Reference in New Issue
Block a user