Compare commits

...

92 Commits

Author SHA1 Message Date
086b2b949f Fixed bugfix with GitHub track-only Apps with no APK 2022-11-25 23:12:15 -05:00
9b5b212e96 APKMirror version extraction bugfix 2022-11-25 23:04:37 -05:00
6c8f9ebcbf Ran flutter upgrade 2022-11-25 20:45:49 -05:00
4d5773bdcc Slight UI changes on Add App page 2022-11-25 20:41:57 -05:00
f81ef6a416 Search results now interleaved on Add App page 2022-11-25 20:35:51 -05:00
47324fcb49 Added search bar on Add App page 2022-11-25 20:31:52 -05:00
377e0e07bd Removed unused imports 2022-11-25 19:17:08 -05:00
b5aae70274 Bugfix from prev. commit 2022-11-25 19:13:29 -05:00
42475fa42a Only ask for install perm. for non-track-only apps 2022-11-25 19:07:05 -05:00
d29534ef2e Increment version 2022-11-25 18:56:43 -05:00
25953399ac Re-added APKMirror as a Track-Only source 2022-11-25 18:55:17 -05:00
b04d2fad5c Adds Track-Only App Support (Addresses #119 and Sets Groundwork for #44) (#123)
- All Sources now have a "Track-Only" option that will prevent Obtainium from looking for APKs (though the App must still have a release of some kind so that a version string can be grabbed).
    - These Apps cannot be installed through Obtainium, but update notifications will still be sent.
    - The user needs to manually mark them as updated when appropriate.
    - This addresses issue #119.
    - It also partially addresses #44 by allowing some sources to be configured as "Track-Only"-only. The first such source (APKMirror) will be added later.
- Includes various UI changes to accommodate the above change.
- Also makes App loading a bit more responsive (sending Obtainium to the background then returning will now cause App re-load to pick up changes in App versioning that may have been made in the meantime, for instance through update checking).
2022-11-24 21:12:46 -05:00
868ba84c9a Tiny bugfix 2022-11-20 11:05:33 -05:00
602f0c3bb2 Increment version 2022-11-19 16:26:44 -05:00
00721e8ac4 Added days filter to logs dialog (#117) 2022-11-19 16:20:42 -05:00
d19f9101d6 Added dropdown support to generated form 2022-11-19 15:42:20 -05:00
a4bc278e4c Increment version 2022-11-17 19:02:44 -05:00
b04986622b IzzyOnDroid now uses API (+ other minor tweaks) 2022-11-17 19:00:27 -05:00
2059e4fd44 Switched to F-Droid API 2022-11-17 18:36:05 -05:00
618a1523cf Add app only downloads APK if needed 2022-11-16 20:57:58 -05:00
ba1cdc2c73 Increment version 2022-11-14 21:10:30 -05:00
aa2a25fffe Better GitHub error messages (#112) 2022-11-14 20:56:04 -05:00
c8ec67aef3 Existing APKs now reused again
With partial APKs avoided (#113)
2022-11-14 20:38:02 -05:00
9576a99a4e More efficient loading (addresses #110) 2022-11-14 12:56:52 -05:00
0202224fa6 Merge pull request #108 from ImranR98/logging
Added basic logging - mainly just for the BG task and errors right now.
2022-11-12 19:18:12 -05:00
631ffd5c34 Added basic logging + increment version
Logging is mainly just for the BG task and errors right now.
2022-11-12 19:17:05 -05:00
feed7ffc0b Increment version 2022-11-12 11:27:15 -05:00
296485de8a Added "Reset Install Status" button 2022-11-12 11:21:46 -05:00
d2f226d442 Slight refactoring 2022-11-12 10:44:59 -05:00
cbdb449e35 Bugfix in moveObtainiumToStart 2022-11-12 10:43:12 -05:00
3100a3a08c Added descriptions to GitHub starred imports 2022-11-12 10:40:54 -05:00
18951d6461 Added descriptions to search results 2022-11-12 10:35:59 -05:00
0e0a39a40f Allow App downgrades if com.berdik.letmedowngrade installed 2022-11-12 10:05:46 -05:00
55cae0620b Updated packages 2022-11-12 09:46:23 -05:00
ba6cea3ae6 Slightly changed icon 2022-11-12 09:42:42 -05:00
4be33374c2 Merge pull request #106 from ImranR98/search
GitHub Search and Pinned Apps
2022-11-12 03:30:36 -05:00
e2bf834981 Increment version 2022-11-12 02:15:23 -05:00
9bd7ddb21b Added App pinning 2022-11-12 02:14:45 -05:00
905a807ee9 GitHub search added 2022-11-12 01:25:32 -05:00
ab57b97875 Ready to implement GitHub search (UI done) 2022-11-12 01:05:16 -05:00
5db2c5f0b1 Initial changes to support search 2022-11-11 21:44:20 -05:00
e158c23cca Added a note about imported apps 'not installed' 2022-11-10 13:17:51 -05:00
208f125e12 Increment version 2022-11-10 13:02:37 -05:00
b7ccf3fa49 Fixed App import and legacy Apps upgrade (#103) 2022-11-10 12:55:04 -05:00
c746e89052 Fixed error reporting in add app box 2022-11-10 10:29:00 -05:00
ee758e8470 Fixed bug from previous commit 2022-11-10 10:26:36 -05:00
68d903e092 Increment version 2022-11-09 20:57:03 -05:00
c47b752344 Cancel update notifications on new install (#101)
Can't get more granular due to flutter_local_notifications/issues/1700
2022-11-09 20:56:40 -05:00
62a05996cf Fixed regression for #20 from last cleanup 2022-11-09 19:25:41 -05:00
1cda941fbe Removed APKMirror code (previously unused but present) 2022-11-07 16:05:07 -05:00
49cb908d04 Increment version 2022-11-07 15:33:02 -05:00
139f44d31d UI improvement in APKPicker 2022-11-07 15:32:42 -05:00
ed955ac6a2 Add arch info (#100) 2022-11-07 15:14:00 -05:00
f3ead6caf1 Fixed breaking bug from previous commit 2022-11-06 14:21:00 -05:00
97ab723d04 Cleanup (#98) 2022-11-05 23:29:12 -04:00
ed4a26d348 Switch to AlarmManager plugin from WorkManager for more reliable update checking (for #87) (#97) 2022-11-05 23:25:19 -04:00
bd5f21984e Shorter default interval (see #87) 2022-11-04 19:10:20 -04:00
5037d77b14 Increment version 2022-11-04 18:57:06 -04:00
c9711c7734 Addresses #76 and #93 2022-11-04 18:53:25 -04:00
76e98feeb7 Increment version 2022-11-02 20:24:12 -04:00
03da23f77a Addresses #90 2022-11-02 20:23:40 -04:00
9b99e2b302 Addresses #88, #89 2022-11-02 20:07:46 -04:00
e746ca890a Obtainium now installs last (#84) 2022-10-31 17:41:25 -04:00
9c00a7da14 Increment version 2022-10-30 13:09:56 -04:00
4df0dd64ad Addresses #77 (version string overflow) 2022-10-30 13:09:36 -04:00
7cf7ffe0de Fixed icon size on App page (#78) 2022-10-30 12:48:26 -04:00
b1953435af Added progress toasts when adding Apps 2022-10-30 12:44:30 -04:00
fc7d7d11d6 Addresses #79 + other GitHub bugfix 2022-10-30 12:22:32 -04:00
9ef26b3a4a F-Droid bugfixes (#73, #74, #75) + UI tweak 2022-10-29 22:57:21 -04:00
27ee6b9e88 Bugfix: Mass install not working 2022-10-29 18:59:27 -04:00
d1a3529036 Switched to package names as app ids (#69)
* Fixes #14 (although detection is disabled in background processes due to the bug described in #60)
    * Added App icons and basic installed detection
    * Real Package Names Used as IDs + App Icons (INCONVENIENT FOR PREVIOUS VERSION USERS)
    * Switch to using extracted names (no custom names)

* Fixes #57

* Fixes #67

* Fixes #64

* Fixes #61

* Commented out APKMirror and added code to remove their Apps

* Updated README

* Switched to Flutter stable (causes some UI elements to switch back to the old material design style, but this will be fixed in later Flutter releases)

* BG task silently retries on network errors

* Updated screenshots
2022-10-29 13:13:28 -04:00
a954a627fd Fixed 2 issues:
- Rate-limit regression in previous release
- Update notifications not sent when >1 apps have errors
2022-10-11 18:39:53 -04:00
52ce5b19c4 More informative errors for mass update checking 2022-10-11 11:53:20 -04:00
03f0b6cf05 Fixed sort order (was reversed asc/desc)
Also changed default sort to nameAuthor ascending
2022-10-09 15:26:51 -04:00
5d8d0de8de Slightly more efficient JSON importing (tiny difference) 2022-10-08 17:31:08 -04:00
07f6d4ad2c Fixed custom App name issue 2022-10-08 12:26:08 -04:00
dfbb4e19a5 Added more Mass App Actions 2022-10-07 21:15:19 -04:00
f5fda2ca90 Updated some plugins 2022-10-07 19:23:25 -04:00
661dc1626c Increment version 2022-10-07 19:08:24 -04:00
dde3fc20fb Back to old install plugin (dealbreaker in new one) 2022-10-07 19:06:02 -04:00
017b867d8d Added APKMirror (Phew!) 2022-10-07 17:24:45 -04:00
1cb1c124eb UI Tweak 2022-10-07 13:02:25 -04:00
fdeb852c7b More changelog urls added 2022-10-07 12:58:10 -04:00
67f50ba776 Added 'See Changes' button in app list (GitHub only) 2022-10-07 12:51:53 -04:00
a0968caa5c Tweaked update checking, fixed an issue on App page 2022-10-07 12:22:16 -04:00
e3e945d13b Bugfix - Obtainium doesn't update with other Apps 2022-10-01 00:29:15 -04:00
61f7f171b1 Upgraded a package 2022-09-30 23:23:23 -04:00
de07583161 Fixed issue with backgorund task not starting 2022-09-30 23:21:35 -04:00
49b9a65053 Updated version 2022-09-30 15:37:32 -04:00
aebc8aed76 Clearer GitHub PAT instructions 2022-09-30 15:33:24 -04:00
3958425c22 Removed outdated comment 2022-09-29 23:28:49 -04:00
0a560871cb Fixed update checking on App page 2022-09-29 23:20:57 -04:00
49 changed files with 2734 additions and 1300 deletions

1
.gitignore vendored
View File

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

View File

@ -30,17 +30,25 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<provider <service
android:name="androidx.core.content.FileProvider" android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmService"
android:authorities="${applicationId}.fileProvider" android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false" android:exported="false"/>
android:grantUriPermissions="true"> <receiver
<meta-data android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmBroadcastReceiver"
android:name="android.support.FILE_PROVIDER_PATHS" android:exported="false"/>
android:resource="@xml/file_paths" /> <receiver
</provider> android:name="dev.fluttercommunity.plus.androidalarmmanager.RebootBroadcastReceiver"
android:enabled="false"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application> </application>
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
</manifest> </manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View File

@ -0,0 +1,58 @@
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,
{bool trackOnly = false}) 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(RegExp('[0-9]').firstMatch(titleString)?.start ?? 0,
RegExp(' by ').firstMatch(titleString)?.start ?? 0)
.trim();
if (version == null || version.isEmpty) {
version = titleString;
}
if (version == null || version.isEmpty) {
throw NoVersionError();
}
return APKDetails(version, []);
} else {
throw NoReleasesError();
}
}
@override
AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
return AppNames(names[1], names[2]);
}
}

View File

@ -1,11 +1,13 @@
import 'package:html/parser.dart'; import 'dart:convert';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class FDroid implements AppSource { class FDroid extends AppSource {
@override FDroid() {
late String host = 'f-droid.org'; host = 'f-droid.org';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
@ -18,49 +20,52 @@ class FDroid implements AppSource {
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+'); RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
match = standardUrlRegExA.firstMatch(url.toLowerCase()); match = standardUrlRegExA.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw InvalidURLError(runtimeType.toString());
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override @override
Future<APKDetails> getLatestAPKDetails( String? changeLogPageFromStandardUrl(String standardUrl) => null;
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse(standardUrl)); @override
String? tryInferringAppId(String standardUrl) {
return Uri.parse(standardUrl).pathSegments.last;
}
APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
Response res, String apkUrlPrefix) {
if (res.statusCode == 200) { if (res.statusCode == 200) {
var latestReleaseDiv = List<dynamic> releases = jsonDecode(res.body)['packages'] ?? [];
parse(res.body).querySelector('#latest.package-version'); if (releases.isEmpty) {
var apkUrl = latestReleaseDiv throw NoReleasesError();
?.querySelector('.package-version-download a')
?.attributes['href'];
if (apkUrl == null) {
throw noAPKFound;
} }
var version = latestReleaseDiv String? latestVersion = releases[0]['versionName'];
?.querySelector('.package-version-header b') if (latestVersion == null) {
?.innerHtml throw NoVersionError();
.split(' ')
.last;
if (version == null) {
throw couldNotFindLatestVersion;
} }
return APKDetails(version, [apkUrl]); List<String> apkUrls = releases
.where((element) => element['versionName'] == latestVersion)
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
.toList();
return APKDetails(latestVersion, apkUrls);
} else { } else {
throw couldNotFindReleases; throw NoReleasesError();
} }
} }
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) 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);
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,20 +1,94 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.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:url_launcher/url_launcher_string.dart';
class GitHub implements AppSource { class GitHub extends AppSource {
@override GitHub() {
late String host = 'github.com'; host = 'github.com';
additionalSourceAppSpecificDefaults = ['true', 'true', ''];
additionalSourceSpecificSettingFormItems = [
GeneratedFormItem(
label: 'GitHub Personal Access Token (Increases Rate Limit)',
id: 'github-creds',
required: false,
additionalValidators: [
(value) {
if (value != null && value.trim().isNotEmpty) {
if (value
.split(':')
.where((element) => element.trim().isNotEmpty)
.length !=
2) {
return 'PAT must be in this format: username:token';
}
}
return null;
}
],
hint: 'username:token',
belowWidgets: [
const SizedBox(
height: 8,
),
GestureDetector(
onTap: () {
launchUrlString(
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
mode: LaunchMode.externalApplication);
},
child: const Text(
'About GitHub PATs',
style: TextStyle(
decoration: TextDecoration.underline, fontSize: 12),
))
])
];
additionalSourceAppSpecificFormItems = [
[
GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)
],
[
GeneratedFormItem(
label: 'Fallback to older releases', type: FormItemType.bool)
],
[
GeneratedFormItem(
label: 'Filter Release Titles by Regular Expression',
type: FormItemType.string,
required: false,
additionalValidators: [
(value) {
if (value == null || value.isEmpty) {
return null;
}
try {
RegExp(value);
} catch (e) {
return 'Invalid regular expression';
}
return null;
}
])
]
];
canSearch = true;
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw InvalidURLError(runtimeType.toString());
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@ -22,14 +96,19 @@ class GitHub implements AppSource {
Future<String> getCredentialPrefixIfAny() async { Future<String> getCredentialPrefixIfAny() async {
SettingsProvider settingsProvider = SettingsProvider(); SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings(); await settingsProvider.initializeSettings();
String? creds = String? creds = settingsProvider
settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id); .getSettingString(additionalSourceSpecificSettingFormItems[0].id);
return creds != null && creds.isNotEmpty ? '$creds@' : ''; return creds != null && creds.isNotEmpty ? '$creds@' : '';
} }
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/releases';
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
var includePrereleases = var includePrereleases =
additionalData.isNotEmpty && additionalData[0] == 'true'; additionalData.isNotEmpty && additionalData[0] == 'true';
var fallbackToOlderReleases = var fallbackToOlderReleases =
@ -60,13 +139,14 @@ class GitHub implements AppSource {
if (!includePrereleases && releases[i]['prerelease'] == true) { if (!includePrereleases && releases[i]['prerelease'] == true) {
continue; continue;
} }
if (regexFilter != null && if (regexFilter != null &&
!RegExp(regexFilter) !RegExp(regexFilter)
.hasMatch((releases[i]['name'] as String).trim())) { .hasMatch((releases[i]['name'] as String).trim())) {
continue; continue;
} }
var apkUrls = getReleaseAPKUrls(releases[i]); var apkUrls = getReleaseAPKUrls(releases[i]);
if (apkUrls.isEmpty) { if (apkUrls.isEmpty && !trackOnly) {
continue; continue;
} }
targetRelease = releases[i]; targetRelease = releases[i];
@ -74,25 +154,16 @@ class GitHub implements AppSource {
break; break;
} }
if (targetRelease == null) { if (targetRelease == null) {
throw couldNotFindReleases; throw NoReleasesError();
}
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
throw noAPKFound;
} }
String? version = targetRelease['tag_name']; String? version = targetRelease['tag_name'];
if (version == null) { if (version == null) {
throw couldNotFindLatestVersion; 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 couldNotFindReleases;
} }
} }
@ -104,55 +175,31 @@ class GitHub implements AppSource {
} }
@override @override
List<List<GeneratedFormItem>> additionalDataFormItems = [ Future<Map<String, String>> search(String query) async {
[GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)], Response res = await get(Uri.parse(
[ 'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100'));
GeneratedFormItem( if (res.statusCode == 200) {
label: 'Fallback to older releases', type: FormItemType.bool) Map<String, String> urlsWithDescriptions = {};
], for (var e in (jsonDecode(res.body)['items'] as List<dynamic>)) {
[ urlsWithDescriptions.addAll({
GeneratedFormItem( e['html_url'] as String: e['description'] != null
label: 'Filter Release Titles by Regular Expression', ? e['description'] as String
type: FormItemType.string, : 'No description'
required: false, });
additionalValidators: [ }
(value) { return urlsWithDescriptions;
if (value == null || value.isEmpty) { } else {
return null; rateLimitErrorCheck(res);
} throw getObtainiumHttpError(res);
try { }
RegExp(value); }
} catch (e) {
return 'Invalid regular expression';
}
return null;
}
])
]
];
@override rateLimitErrorCheck(Response res) {
List<String> additionalDataDefaults = ['true', 'true', '']; if (res.headers['x-ratelimit-remaining'] == '0') {
throw RateLimitError(
@override (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
List<GeneratedFormItem> moreSourceSettingsFormItems = [ 60000000)
GeneratedFormItem( .round());
label: 'GitHub Credentials (Increases Rate Limit)', }
id: 'github-creds', }
required: false,
additionalValidators: [
(value) {
if (value != null && value.trim().isNotEmpty) {
if (value
.split(':')
.where((element) => element.trim().isNotEmpty)
.length !=
2) {
return 'PAT must be in this format: username:token';
}
}
return null;
}
])
];
} }

View File

@ -1,26 +1,32 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class GitLab implements AppSource { class GitLab extends AppSource {
@override GitLab() {
late String host = 'gitlab.com'; host = 'gitlab.com';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw InvalidURLError(runtimeType.toString());
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/-/releases';
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom')); Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl); var standardUri = Uri.parse(standardUrl);
@ -28,11 +34,13 @@ class GitLab implements AppSource {
var entry = parsedHtml.querySelector('entry'); var entry = parsedHtml.querySelector('entry');
var entryContent = var entryContent =
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text); parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrlList = [ var apkUrls = [
...getLinksFromParsedHTML( ...getLinksFromParsedHTML(
entryContent, entryContent,
RegExp( RegExp(
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$', '^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return '\\${x[0]}';
})}/uploads/[^/]+/[^/]+\\.apk\$',
caseSensitive: false), caseSensitive: false),
standardUri.origin), standardUri.origin),
// GitLab releases may contain links to externally hosted APKs // GitLab releases may contain links to externally hosted APKs
@ -41,19 +49,16 @@ class GitLab implements AppSource {
.where((element) => Uri.parse(element).host != '') .where((element) => Uri.parse(element).host != '')
.toList() .toList()
]; ];
if (apkUrlList.isEmpty) {
throw noAPKFound;
}
var entryId = entry?.querySelector('id')?.innerHtml; var entryId = entry?.querySelector('id')?.innerHtml;
var version = var version =
entryId == null ? null : Uri.parse(entryId).pathSegments.last; entryId == null ? null : Uri.parse(entryId).pathSegments.last;
if (version == null) { if (version == null) {
throw couldNotFindLatestVersion; throw NoVersionError();
} }
return APKDetails(version, apkUrlList); return APKDetails(version, apkUrls);
} else { } else {
throw couldNotFindReleases; throw NoReleasesError();
} }
} }
@ -62,13 +67,4 @@ class GitLab implements AppSource {
// Same as GitHub // Same as GitHub
return GitHub().getAppNames(standardUrl); return GitHub().getAppNames(standardUrl);
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,68 +1,44 @@
import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/app_sources/fdroid.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class IzzyOnDroid implements AppSource { class IzzyOnDroid extends AppSource {
@override IzzyOnDroid() {
late String host = 'android.izzysoft.de'; host = 'android.izzysoft.de';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+'); RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw InvalidURLError(runtimeType.toString());
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
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,
Response res = await get(Uri.parse(standardUrl)); {bool trackOnly = false}) async {
if (res.statusCode == 200) { String? appId = tryInferringAppId(standardUrl);
var parsedHtml = parse(res.body); return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(
var multipleVersionApkUrls = parsedHtml await get(
.querySelectorAll('a') Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')),
.where((element) => 'https://android.izzysoft.de/frepo/$appId');
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
false)
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
.toList();
if (multipleVersionApkUrls.isEmpty) {
throw noAPKFound;
}
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 couldNotFindLatestVersion;
}
return APKDetails(version, [multipleVersionApkUrls[0]]);
} else {
throw couldNotFindReleases;
}
} }
@override @override
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last); return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,25 +1,31 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class Mullvad implements AppSource { class Mullvad extends AppSource {
@override Mullvad() {
late String host = 'mullvad.net'; host = 'mullvad.net';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host'); RegExp standardUrlRegEx = RegExp('^https?://$host');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw InvalidURLError(runtimeType.toString());
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md';
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
Response res = await get(Uri.parse('$standardUrl/en/download/android')); Response res = await get(Uri.parse('$standardUrl/en/download/android'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var version = parse(res.body) var version = parse(res.body)
@ -29,12 +35,12 @@ class Mullvad implements AppSource {
?.split('/') ?.split('/')
.last; .last;
if (version == null) { if (version == null) {
throw couldNotFindLatestVersion; throw NoVersionError();
} }
return APKDetails( return APKDetails(
version, ['https://mullvad.net/download/app/apk/latest']); version, ['https://mullvad.net/download/app/apk/latest']);
} else { } else {
throw couldNotFindReleases; throw NoReleasesError();
} }
} }
@ -42,13 +48,4 @@ class Mullvad implements AppSource {
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN'); return AppNames('Mullvad-VPN', 'Mullvad-VPN');
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,47 +1,41 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class Signal implements AppSource { class Signal extends AppSource {
@override Signal() {
late String host = 'signal.org'; host = 'signal.org';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
return 'https://$host'; return 'https://$host';
} }
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
Response res = Response res =
await get(Uri.parse('https://updates.$host/android/latest.json')); await get(Uri.parse('https://updates.$host/android/latest.json'));
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 noAPKFound;
}
String? version = json['versionName']; String? version = json['versionName'];
if (version == null) { if (version == null) {
throw couldNotFindLatestVersion; throw NoVersionError();
} }
return APKDetails(version, [apkUrl]); return APKDetails(version, apkUrls);
} else { } else {
throw couldNotFindReleases; throw NoReleasesError();
} }
} }
@override @override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal'); AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,25 +1,30 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class SourceForge implements AppSource { class SourceForge extends AppSource {
@override SourceForge() {
late String host = 'sourceforge.net'; host = 'sourceforge.net';
}
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+'); RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw InvalidURLError(runtimeType.toString());
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
Response res = await get(Uri.parse('$standardUrl/rss?path=/')); Response res = await get(Uri.parse('$standardUrl/rss?path=/'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var parsedHtml = parse(res.body); var parsedHtml = parse(res.body);
@ -36,7 +41,7 @@ class SourceForge implements AppSource {
String? version = getVersion(allDownloadLinks[0]); String? version = getVersion(allDownloadLinks[0]);
if (version == null) { if (version == null) {
throw couldNotFindLatestVersion; throw NoVersionError();
} }
var apkUrlListAllReleases = allDownloadLinks var apkUrlListAllReleases = allDownloadLinks
.where((element) => element.toLowerCase().endsWith('.apk/download')) .where((element) => element.toLowerCase().endsWith('.apk/download'))
@ -45,12 +50,9 @@ class SourceForge implements AppSource {
apkUrlListAllReleases // This can be used skipped for fallback support later apkUrlListAllReleases // This can be used skipped for fallback support later
.where((element) => getVersion(element) == version) .where((element) => getVersion(element) == version)
.toList(); .toList();
if (apkUrlList.isEmpty) {
throw noAPKFound;
}
return APKDetails(version, apkUrlList); return APKDetails(version, apkUrlList);
} else { } else {
throw couldNotFindReleases; throw NoReleasesError();
} }
} }
@ -59,13 +61,4 @@ class SourceForge implements AppSource {
return AppNames(runtimeType.toString(), return AppNames(runtimeType.toString(),
standardUrl.substring(standardUrl.lastIndexOf('/') + 1)); standardUrl.substring(standardUrl.lastIndexOf('/') + 1));
} }
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -2,15 +2,20 @@ 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;
late int max; late int max;
late List<String? Function(String? value)> additionalValidators; late List<String? Function(String? value)> additionalValidators;
late String id; late String id;
late List<Widget> belowWidgets;
late String? hint;
late List<String>? opts;
GeneratedFormItem( GeneratedFormItem(
{this.label = 'Input', {this.label = 'Input',
@ -18,7 +23,11 @@ class GeneratedFormItem {
this.required = true, this.required = true,
this.max = 1, this.max = 1,
this.additionalValidators = const [], this.additionalValidators = const [],
this.id = 'input'}); this.id = 'input',
this.belowWidgets = const [],
this.hint,
this.opts,
this.key = 'default'});
} }
class GeneratedForm extends StatefulWidget { class GeneratedForm extends StatefulWidget {
@ -43,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++) {
@ -58,7 +67,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
} }
} }
} }
widget.onValueChanges(returnValues, valid); widget.onValueChanges(returnValues, valid, isBuilding);
} }
@override @override
@ -71,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,
@ -91,7 +102,8 @@ class _GeneratedFormState extends State<GeneratedForm> {
}); });
}, },
decoration: InputDecoration( decoration: InputDecoration(
helperText: e.value.label + (e.value.required ? ' *' : '')), helperText: e.value.label + (e.value.required ? ' *' : ''),
hintText: e.value.hint),
minLines: e.value.max <= 1 ? null : e.value.max, minLines: e.value.max <= 1 ? null : e.value.max,
maxLines: e.value.max <= 1 ? 1 : e.value.max, maxLines: e.value.max <= 1 ? 1 : e.value.max,
validator: (value) { validator: (value) {
@ -107,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
@ -157,7 +187,13 @@ class _GeneratedFormState extends State<GeneratedForm> {
width: 20, width: 20,
)); ));
} }
rowItems.add(Expanded(child: rowInput.value)); rowItems.add(Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
rowInput.value,
...widget.items[rowInputs.key][rowInput.key].belowWidgets
])));
}); });
rows.add(rowItems); rows.add(rowItems);
}); });
@ -175,3 +211,18 @@ class _GeneratedFormState extends State<GeneratedForm> {
)); ));
} }
} }
String? findGeneratedFormValueByKey(
List<GeneratedFormItem> items, List<String> values, String key) {
var foundIndex = -1;
for (var i = 0; i < items.length; i++) {
if (items[i].key == key) {
foundIndex = i;
break;
}
}
if (foundIndex >= 0 && foundIndex < values.length) {
return values[foundIndex];
}
return null;
}

View File

@ -28,7 +28,8 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
valid = widget.initValid; values = widget.defaultValues;
valid = widget.initValid || widget.items.isEmpty;
} }
@override @override
@ -45,11 +46,16 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
), ),
GeneratedForm( GeneratedForm(
items: widget.items, items: widget.items,
onValueChanges: (values, valid) { onValueChanges: (values, valid, isBuilding) {
setState(() { if (isBuilding) {
this.values = values; this.values = values;
this.valid = valid; this.valid = valid;
}); } else {
setState(() {
this.values = values;
this.valid = valid;
});
}
}, },
defaultValues: widget.defaultValues) defaultValues: widget.defaultValues)
]), ]),

View File

@ -1,3 +1,17 @@
import 'package:flutter/material.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:provider/provider.dart';
class ObtainiumError {
late String message;
bool unexpected;
ObtainiumError(this.message, {this.unexpected = false});
@override
String toString() {
return message;
}
}
class RateLimitError { class RateLimitError {
late int remainingMinutes; late int remainingMinutes;
RateLimitError(this.remainingMinutes); RateLimitError(this.remainingMinutes);
@ -6,3 +20,103 @@ class RateLimitError {
String toString() => String toString() =>
'Too many requests (rate limited) - try again in $remainingMinutes minutes'; 'Too many requests (rate limited) - try again in $remainingMinutes minutes';
} }
class InvalidURLError extends ObtainiumError {
InvalidURLError(String sourceName) : super('Not a valid $sourceName App URL');
}
class NoReleasesError extends ObtainiumError {
NoReleasesError() : super('Could not find a suitable release');
}
class NoAPKError extends ObtainiumError {
NoAPKError() : super('Could not find a suitable release');
}
class NoVersionError extends ObtainiumError {
NoVersionError() : super('Could not determine release version');
}
class UnsupportedURLError extends ObtainiumError {
UnsupportedURLError() : super('URL does not match a known source');
}
class DowngradeError extends ObtainiumError {
DowngradeError() : super('Cannot install an older version of an App');
}
class IDChangedError extends ObtainiumError {
IDChangedError()
: super('Downloaded package ID does not match existing App ID');
}
class NotImplementedError extends ObtainiumError {
NotImplementedError() : super('This class has not implemented this function');
}
class MultiAppMultiError extends ObtainiumError {
Map<String, List<String>> content = {};
MultiAppMultiError() : super('Multiple Errors Placeholder', unexpected: true);
add(String appId, String string) {
var tempIds = content.remove(string);
tempIds ??= [];
tempIds.add(appId);
content.putIfAbsent(string, () => tempIds!);
}
@override
String toString() {
String finalString = '';
for (var e in content.keys) {
finalString += '$e: ${content[e].toString()}\n\n';
}
return finalString;
}
}
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)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
scrollable: true,
title: Text(e is MultiAppMultiError
? 'Some Errors Occurred'
: 'Unexpected Error'),
content: Text(e.toString()),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: const Text('Ok')),
],
);
});
}
}
String list2FriendlyString(List<String> list) {
return list.length == 2
? '${list[0]} and ${list[1]}'
: list
.asMap()
.entries
.map((e) =>
e.value +
(e.key == list.length - 1
? ''
: e.key == list.length - 2
? ', and '
: ', '))
.join('');
}

View File

@ -1,63 +1,77 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/app_sources/github.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';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:workmanager/workmanager.dart';
import 'package:dynamic_color/dynamic_color.dart'; 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';
const String currentVersion = '0.8.1';
const String currentReleaseTag = const String currentReleaseTag =
'v0.5.0-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES 'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
const String bgUpdateCheckTaskName = 'bg-update-check'; const int bgUpdateCheckAlarmId = 666;
bgUpdateCheck(int? ignoreAfterMicroseconds) async { @pragma('vm:entry-point')
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
LogsProvider logs = LogsProvider();
logs.add('Started BG update check task');
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
WidgetsFlutterBinding.ensureInitialized();
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 {
var appsProvider = AppsProvider(); var appsProvider = AppsProvider(forBGTask: true);
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id); await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps(); await appsProvider.loadApps();
// List<String> existingUpdateIds = // TODO: Uncomment this and below when it works
// appsProvider.getExistingUpdates(installedOnly: true);
List<String> existingUpdateIds = List<String> existingUpdateIds =
appsProvider.getExistingUpdates(installedOnly: true); appsProvider.findExistingUpdates(installedOnly: true);
DateTime nextIgnoreAfter = DateTime.now(); DateTime nextIgnoreAfter = DateTime.now();
String? err;
try { try {
await appsProvider.checkUpdates(ignoreAfter: ignoreAfter); logs.add('Started actual BG update checking');
await appsProvider.checkUpdates(
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
} catch (e) { } catch (e) {
if (e is RateLimitError) { if (e is RateLimitError || e is SocketException) {
// Ignore these (scheduling another task as below does not work) var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15;
String nextTaskName = logs.add(
'$bgUpdateCheckTaskName-${nextIgnoreAfter.microsecondsSinceEpoch.toString()}'; 'BG update checking encountered a ${e.runtimeType}, will schedule a retry check in $remainingMinutes minutes');
Workmanager().registerOneOffTask(nextTaskName, nextTaskName, AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes),
constraints: Constraints(networkType: NetworkType.connected), Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: {
initialDelay: Duration(minutes: e.remainingMinutes), 'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch}); });
} else { } else {
rethrow; err = e.toString();
} }
} }
List<App> newUpdates = appsProvider List<App> newUpdates = appsProvider
.getExistingUpdates(installedOnly: true) .findExistingUpdates(installedOnly: true)
.where((id) => !existingUpdateIds.contains(id)) .where((id) => !existingUpdateIds.contains(id))
.map((e) => appsProvider.apps[e]!.app) .map((e) => appsProvider.apps[e]!.app)
.toList(); .toList();
// TODO: This silent update code doesn't work yet
// List<String> silentlyUpdated = await appsProvider // List<String> silentlyUpdated = await appsProvider
// .downloadAndInstallLatestApp( // .downloadAndInstallLatestApp(
// [...newUpdates.map((e) => e.id), ...existingUpdateIds], null); // [...newUpdates.map((e) => e.id), ...existingUpdateIds], null);
// if (silentlyUpdated.isNotEmpty) { // if (silentlyUpdated.isNotEmpty) {
// newUpdates // newUpdates = newUpdates
// .where((element) => !silentlyUpdated.contains(element.id)) // .where((element) => !silentlyUpdated.contains(element.id))
// .toList(); // .toList();
// notificationsProvider.notify( // notificationsProvider.notify(
@ -65,73 +79,72 @@ bgUpdateCheck(int? ignoreAfterMicroseconds) 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));
cancelExisting: true); }
if (err != null) {
throw err;
} }
return Future.value(true);
} catch (e) { } catch (e) {
notificationsProvider.notify(ErrorCheckingUpdatesNotification(e.toString()), notificationsProvider
cancelExisting: true); .notify(ErrorCheckingUpdatesNotification(e.toString()));
return Future.error(false);
} finally { } finally {
logs.add('Finished BG update check task');
await notificationsProvider.cancel(checkingUpdatesNotification.id); await notificationsProvider.cancel(checkingUpdatesNotification.id);
} }
} }
@pragma('vm:entry-point')
void bgTaskCallback() {
// Background process callback
Workmanager().executeTask((task, inputData) async {
return await bgUpdateCheck(inputData?['ignoreAfter']);
});
}
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) { if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
); );
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} }
Workmanager().initialize( await AndroidAlarmManager.initialize();
bgTaskCallback,
);
runApp(MultiProvider( runApp(MultiProvider(
providers: [ providers: [
ChangeNotifierProvider( ChangeNotifierProvider(create: (context) => AppsProvider()),
create: (context) => AppsProvider(
shouldLoadApps: true,
shouldCheckUpdatesAfterLoad: false,
shouldDeleteAPKs: true)),
ChangeNotifierProvider(create: (context) => SettingsProvider()), ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => NotificationsProvider()) Provider(create: (context) => NotificationsProvider()),
Provider(create: (context) => LogsProvider())
], ],
child: const MyApp(), child: const Obtainium(),
)); ));
} }
var defaultThemeColour = Colors.deepPurple; var defaultThemeColour = Colors.deepPurple;
class MyApp extends StatelessWidget { class Obtainium extends StatefulWidget {
const MyApp({super.key}); const Obtainium({super.key});
@override
State<Obtainium> createState() => _ObtainiumState();
}
class _ObtainiumState extends State<Obtainium> {
var existingUpdateInterval = -1;
@override @override
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(
'imranr98_obtainium_${GitHub().host}', obtainiumId,
'https://github.com/ImranR98/Obtainium', 'https://github.com/ImranR98/Obtainium',
'ImranR98', 'ImranR98',
'Obtainium', 'Obtainium',
@ -140,22 +153,28 @@ class MyApp extends StatelessWidget {
[], [],
0, 0,
['true'], ['true'],
null) null,
false,
false)
]); ]);
} }
// Register the background update task according to the user's setting // Register the background update task according to the user's setting
if (settingsProvider.updateInterval == 0) { if (existingUpdateInterval != settingsProvider.updateInterval) {
Workmanager().cancelByUniqueName(bgUpdateCheckTaskName); if (existingUpdateInterval != -1) {
} else { logs.add(
Workmanager().registerPeriodicTask( 'Setting update interval to ${settingsProvider.updateInterval}');
bgUpdateCheckTaskName, bgUpdateCheckTaskName, }
frequency: Duration(minutes: settingsProvider.updateInterval), existingUpdateInterval = settingsProvider.updateInterval;
initialDelay: Duration(minutes: settingsProvider.updateInterval), if (existingUpdateInterval == 0) {
constraints: Constraints(networkType: NetworkType.connected), AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
existingWorkPolicy: ExistingWorkPolicy.keep, } else {
backoffPolicy: BackoffPolicy.linear, AndroidAlarmManager.periodic(
backoffPolicyDelay: Duration(minutes: existingUpdateInterval),
const Duration(minutes: minUpdateIntervalMinutes)); bgUpdateCheckAlarmId,
bgUpdateCheck,
rescheduleOnReboot: true,
wakeup: true);
}
} }
} }

View File

@ -5,47 +5,49 @@ import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class GitHubStars implements MassAppSource { class GitHubStars implements MassAppUrlSource {
@override @override
late String name = 'GitHub Starred Repos'; late String name = 'GitHub Starred Repos';
@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 '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 'Wrong number of arguments provided'; throw ObtainiumError('Wrong number of arguments provided');
} }
List<String> urls = []; Map<String, String> urlsWithDescriptions = {};
var page = 1; var page = 1;
while (true) { while (true) {
var pageUrls = await getOnePageOfUserStarredUrls(args[0], page++); var pageUrls =
urls.addAll(pageUrls); await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++);
urlsWithDescriptions.addAll(pageUrls);
if (pageUrls.length < 100) { if (pageUrls.length < 100) {
break; break;
} }
} }
return urls; return urlsWithDescriptions;
} }
} }

View File

@ -2,7 +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/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';
@ -20,14 +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 = [];
String customName = ''; bool sourceSpecificDataIsValid = true;
bool validAdditionalData = true; List<String> otherAdditionalData = [];
bool otherAdditionalDataIsValid = true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
AppsProvider appsProvider = context.read<AppsProvider>();
changeUserInput(String input, bool valid, bool isBuilding) {
userInput = input;
fn() {
var source = valid ? sourceProvider.getSource(userInput) : null;
if (pickedSource != source) {
pickedSource = source;
sourceSpecificAdditionalData =
source != null ? source.additionalSourceAppSpecificDefaults : [];
sourceSpecificDataIsValid = source != null
? sourceProvider.ifSourceAppsRequireAdditionalData(source)
: true;
}
}
if (isBuilding) {
fn();
} else {
setState(() {
fn();
});
}
}
addApp({bool resetUserInputAfter = false}) async {
setState(() {
gettingAppInfo = true;
});
var settingsProvider = context.read<SettingsProvider>();
() async {
var userPickedTrackOnly = findGeneratedFormValueByKey(
pickedSource!.additionalAppSpecificSourceAgnosticFormItems,
otherAdditionalData,
'trackOnlyFormItemKey') ==
'true';
var cont = true;
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
await showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title:
'${pickedSource!.enforceTrackOnly ? 'Source' : 'App'} is Track-Only',
items: const [],
defaultValues: const [],
message:
'${pickedSource!.enforceTrackOnly ? 'Apps from this source are \'Track-Only\'.' : 'You have selected the \'Track-Only\' option.'}\n\nThe App will be tracked for updates, but Obtainium will not be able to download or install it.',
);
}) ==
null) {
cont = false;
}
if (cont) {
HapticFeedback.selectionClick();
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
App app = await sourceProvider.getApp(
pickedSource!, userInput, sourceSpecificAdditionalData,
trackOnly: trackOnly);
if (!trackOnly) {
await settingsProvider.getInstallPermission();
}
// Only download the APK here if you need to for the package ID
if (sourceProvider.isTempId(app.id) && !app.trackOnly) {
// ignore: use_build_context_synchronously
var apkUrl = await appsProvider.confirmApkUrl(app, context);
if (apkUrl == null) {
throw ObtainiumError('Cancelled');
}
app.preferredApkIndex = app.apkUrls.indexOf(apkUrl);
var downloadedApk = await appsProvider.downloadApp(app);
app.id = downloadedApk.appId;
}
if (appsProvider.apps.containsKey(app.id)) {
throw ObtainiumError('App already added');
}
if (app.trackOnly) {
app.installedVersion = app.latestVersion;
}
await appsProvider.saveApps([app]);
return app;
}
}()
.then((app) {
if (app != null) {
Navigator.push(context,
MaterialPageRoute(builder: (context) => AppPage(appId: app.id)));
}
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
gettingAppInfo = false;
if (resetUserInputAfter) {
changeUserInput('', false, true);
}
});
});
}
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
@ -57,92 +163,137 @@ class _AddAppPageState extends State<AddAppPage> {
} catch (e) { } catch (e) {
return e is String return e is String
? e ? e
: 'Error'; : e is ObtainiumError
? e.toString()
: 'Error';
} }
return null; return null;
} }
]) ])
] ]
], ],
onValueChanges: (values, valid) { onValueChanges: (values, valid, isBuilding) {
setState(() { changeUserInput(
userInput = values[0]; values[0], valid, isBuilding);
var source = valid
? sourceProvider.getSource(userInput)
: null;
if (pickedSource != source) {
pickedSource = source;
additionalData = source != null
? source.additionalDataDefaults
: [];
validAdditionalData = source != null
? sourceProvider
.doesSourceHaveRequiredAdditionalData(
source)
: true;
if (source == null) {
customName = '';
}
}
});
}, },
defaultValues: const [])), defaultValues: const [])),
const SizedBox( const SizedBox(
width: 16, width: 16,
), ),
ElevatedButton( gettingAppInfo
onPressed: gettingAppInfo || ? const CircularProgressIndicator()
pickedSource == null || : ElevatedButton(
(pickedSource!.additionalDataFormItems onPressed: gettingAppInfo ||
.isNotEmpty && pickedSource == null ||
!validAdditionalData) (pickedSource!
? null .additionalSourceAppSpecificFormItems
: () { .isNotEmpty &&
HapticFeedback.selectionClick(); !sourceSpecificDataIsValid) ||
setState(() { (pickedSource!
gettingAppInfo = true; .additionalAppSpecificSourceAgnosticDefaults
}); .isNotEmpty &&
sourceProvider !otherAdditionalDataIsValid)
.getApp(pickedSource!, userInput, ? null
additionalData, : addApp,
customName: customName) child: const Text('Add'))
.then((app) {
var appsProvider =
context.read<AppsProvider>();
var settingsProvider =
context.read<SettingsProvider>();
if (appsProvider.apps
.containsKey(app.id)) {
throw 'App already added';
}
settingsProvider
.getInstallPermission()
.then((_) {
appsProvider
.saveApps([app]).then((_) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
AppPage(
appId: app.id)));
});
});
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() {
setState(() {
gettingAppInfo = false;
});
});
},
child: const Text('Add'))
], ],
), ),
if (pickedSource != null) 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 &&
(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: [
@ -158,37 +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,
), ),
if (pickedSource != null) GeneratedForm(
GeneratedForm( items: pickedSource!
items: [ .additionalAppSpecificSourceAgnosticFormItems
[ .where((e) => pickedSource!.enforceTrackOnly
GeneratedFormItem( ? e.key != 'trackOnlyFormItemKey'
label: 'Custom App Name', : true)
required: false) .map((e) => [e])
] .toList(),
], onValueChanges: (values, valid, isBuilding) {
onValueChanges: (values, valid) { if (isBuilding) {
otherAdditionalData = values;
otherAdditionalDataIsValid = valid;
} else {
setState(() { setState(() {
customName = values[0]; otherAdditionalData = values;
otherAdditionalDataIsValid = valid;
}); });
}, }
defaultValues: [customName]) },
defaultValues: pickedSource!
.additionalAppSpecificSourceAgnosticDefaults),
], ],
) )
else else
@ -197,25 +362,24 @@ class _AddAppPageState extends State<AddAppPage> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// const SizedBox( const SizedBox(
// height: 48, 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,
@ -223,6 +387,9 @@ class _AddAppPageState extends State<AddAppPage> {
))) )))
.toList() .toList()
])), ])),
const SizedBox(
height: 8,
),
])), ])),
) )
])); ]));

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
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/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';
@ -19,19 +19,24 @@ class AppPage extends StatefulWidget {
} }
class _AppPageState extends State<AppPage> { class _AppPageState extends State<AppPage> {
AppInMemory? prevApp;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>(); var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>(); var settingsProvider = context.watch<SettingsProvider>();
getUpdate(String id) {
appsProvider.checkUpdate(id).catchError((e) {
showError(e, context);
});
}
var sourceProvider = SourceProvider(); var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId]; AppInMemory? app = appsProvider.apps[widget.appId];
var source = app != null ? sourceProvider.getSource(app.app.url) : null; var source = app != null ? sourceProvider.getSource(app.app.url) : null;
if (!appsProvider.areDownloadsRunning() && app != null) { if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) {
appsProvider.getUpdate(app.app.id).catchError((e) { prevApp = app;
ScaffoldMessenger.of(context).showSnackBar( getUpdate(app.app.id);
SnackBar(content: Text(e.toString())),
);
});
} }
return Scaffold( return Scaffold(
appBar: settingsProvider.showAppWebpage ? AppBar() : null, appBar: settingsProvider.showAppWebpage ? AppBar() : null,
@ -39,6 +44,7 @@ class _AppPageState extends State<AppPage> {
body: RefreshIndicator( body: RefreshIndicator(
child: settingsProvider.showAppWebpage child: settingsProvider.showAppWebpage
? WebView( ? WebView(
backgroundColor: Theme.of(context).colorScheme.background,
initialUrl: app?.app.url, initialUrl: app?.app.url,
javascriptMode: JavascriptMode.unrestricted, javascriptMode: JavascriptMode.unrestricted,
) )
@ -49,8 +55,22 @@ class _AppPageState extends State<AppPage> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
app?.installedInfo != null
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.memory(
app!.installedInfo!.icon!,
height: 150,
gaplessPlayback: true,
)
])
: Container(),
const SizedBox(
height: 25,
),
Text( Text(
app?.app.name ?? 'App', app?.installedInfo?.name ?? app?.app.name ?? 'App',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayLarge, style: Theme.of(context).textTheme.displayLarge,
), ),
@ -86,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,
), ),
@ -105,13 +125,7 @@ class _AppPageState extends State<AppPage> {
), ),
onRefresh: () async { onRefresh: () async {
if (app != null) { if (app != null) {
try { getUpdate(app.app.id);
await appsProvider.getUpdate(app.app.id);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}
} }
}), }),
bottomSheet: Padding( bottomSheet: Padding(
@ -125,7 +139,9 @@ class _AppPageState extends State<AppPage> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
if (app?.app.installedVersion != app?.app.latestVersion) if (app?.app.installedVersion != null &&
app?.app.trackOnly == false &&
app?.app.installedVersion != app?.app.latestVersion)
IconButton( IconButton(
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
? null ? null
@ -134,8 +150,8 @@ class _AppPageState extends State<AppPage> {
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return AlertDialog( return AlertDialog(
title: Text( title: const Text(
'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'), 'App Already up to Date?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
@ -160,56 +176,16 @@ class _AppPageState extends State<AppPage> {
.pop(); .pop();
}, },
child: const Text( child: const Text(
'Yes, Mark as Installed')) 'Yes, Mark as Updated'))
], ],
); );
}); });
}, },
tooltip: 'Mark as Installed', tooltip: 'Mark as Updated',
icon: const Icon(Icons.done)) icon: const Icon(Icons.done)),
else
IconButton(
onPressed: app?.downloadProgress != null
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text(
'App Not Installed?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context)
.pop();
},
child: const Text('No')),
TextButton(
onPressed: () {
HapticFeedback
.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp
.installedVersion =
null;
appsProvider.saveApps(
[updatedApp]);
}
Navigator.of(context)
.pop();
},
child: const Text(
'Yes, Mark as Not Installed'))
],
);
});
},
tooltip: 'Mark as Not Installed',
icon: const Icon(Icons.no_cell_outlined)),
if (source != null && if (source != null &&
source.additionalDataFormItems.isNotEmpty) source.additionalSourceAppSpecificFormItems
.isNotEmpty)
IconButton( IconButton(
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
? null ? null
@ -219,32 +195,20 @@ class _AppPageState extends State<AppPage> {
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
title: 'Additional Options', title: 'Additional Options',
items: [ items: source
...source .additionalSourceAppSpecificFormItems,
.additionalDataFormItems,
[
GeneratedFormItem(
label: 'App Name',
required: true)
]
],
defaultValues: app != null defaultValues: app != null
? [ ? app.app.additionalData
...app : source
.app.additionalData, .additionalSourceAppSpecificDefaults);
app.app.name
]
: [
...source
.additionalDataDefaults
]);
}).then((values) { }).then((values) {
if (app != null && values != null) { if (app != null && values != null) {
var changedApp = app.app; var changedApp = app.app;
var name = values.removeLast();
changedApp.name = name;
changedApp.additionalData = values; changedApp.additionalData = values;
appsProvider.saveApps([changedApp]); appsProvider.saveApps(
[changedApp]).then((value) {
getUpdate(changedApp.id);
});
} }
}); });
}, },
@ -254,25 +218,38 @@ class _AppPageState extends State<AppPage> {
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: (app?.app.installedVersion == null || onPressed: (app?.app.installedVersion == null ||
appsProvider app?.app.installedVersion !=
.checkAppObjectForUpdate( app?.app.latestVersion) &&
app!.app)) &&
!appsProvider.areDownloadsRunning() !appsProvider.areDownloadsRunning()
? () { ? () {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
appsProvider () async {
.downloadAndInstallLatestApp( 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) {
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
@ -284,7 +261,7 @@ class _AppPageState extends State<AppPage> {
return AlertDialog( return AlertDialog(
title: const Text('Remove App?'), title: const Text('Remove App?'),
content: Text( content: Text(
'This will remove \'${app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'), 'This will remove \'${app?.installedInfo?.name ?? app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {

View File

@ -3,11 +3,14 @@ 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/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/pages/app.dart'; import 'package:obtainium/pages/app.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:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher_string.dart';
class AppsPage extends StatefulWidget { class AppsPage extends StatefulWidget {
const AppsPage({super.key}); const AppsPage({super.key});
@ -20,23 +23,24 @@ class AppsPageState extends State<AppsPage> {
AppsFilter? filter; AppsFilter? filter;
var updatesOnlyFilter = var updatesOnlyFilter =
AppsFilter(includeUptodate: false, includeNonInstalled: false); AppsFilter(includeUptodate: false, includeNonInstalled: false);
Set<String> selectedIds = {}; Set<App> selectedApps = {};
DateTime? refreshingSince;
clearSelected() { clearSelected() {
if (selectedIds.isNotEmpty) { if (selectedApps.isNotEmpty) {
setState(() { setState(() {
selectedIds.clear(); selectedApps.clear();
}); });
return true; return true;
} }
return false; return false;
} }
selectThese(List<String> appIds) { selectThese(List<App> apps) {
if (selectedIds.isEmpty) { if (selectedApps.isEmpty) {
setState(() { setState(() {
for (var a in appIds) { for (var a in apps) {
selectedIds.add(a); selectedApps.add(a);
} }
}); });
} }
@ -50,16 +54,16 @@ class AppsPageState extends State<AppsPage> {
var currentFilterIsUpdatesOnly = var currentFilterIsUpdatesOnly =
filter?.isIdenticalTo(updatesOnlyFilter) ?? false; filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
selectedIds = selectedIds selectedApps = selectedApps
.where((element) => sortedApps.map((e) => e.app.id).contains(element)) .where((element) => sortedApps.map((e) => e.app).contains(element))
.toSet(); .toSet();
toggleAppSelected(String appId) { toggleAppSelected(App app) {
setState(() { setState(() {
if (selectedIds.contains(appId)) { if (selectedApps.contains(app)) {
selectedIds.remove(appId); selectedApps.remove(app);
} else { } else {
selectedIds.add(appId); selectedApps.add(app);
} }
}); });
} }
@ -87,7 +91,8 @@ class AppsPageState extends State<AppsPage> {
.toList(); .toList();
for (var t in nameTokens) { for (var t in nameTokens) {
if (!app.app.name.toLowerCase().contains(t.toLowerCase())) { var name = app.installedInfo?.name ?? app.app.name;
if (!name.toLowerCase().contains(t.toLowerCase())) {
return false; return false;
} }
} }
@ -101,43 +106,88 @@ class AppsPageState extends State<AppsPage> {
} }
sortedApps.sort((a, b) { sortedApps.sort((a, b) {
var nameA = a.installedInfo?.name ?? a.app.name;
var nameB = b.installedInfo?.name ?? b.app.name;
int result = 0; int result = 0;
if (settingsProvider.sortColumn == SortColumnSettings.authorName) { if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
result = result = (a.app.author + nameA).compareTo(b.app.author + nameB);
(a.app.author + a.app.name).compareTo(b.app.author + b.app.name);
} else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) { } else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) {
result = result = (nameA + a.app.author).compareTo(nameB + b.app.author);
(a.app.name + a.app.author).compareTo(b.app.name + b.app.author);
} }
return result; return result;
}); });
if (settingsProvider.sortOrder == SortOrderSettings.ascending) { if (settingsProvider.sortOrder == SortOrderSettings.descending) {
sortedApps = sortedApps.reversed.toList(); sortedApps = sortedApps.reversed.toList();
} }
var existingUpdateIdsAllOrSelected = appsProvider var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
.getExistingUpdates(installedOnly: true)
.where((element) => selectedIds.isEmpty var existingUpdateIdsAllOrSelected = existingUpdates
.where((element) => selectedApps.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty ? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element)) : selectedApps.map((e) => e.id).contains(element))
.toList(); .toList();
var newInstallIdsAllOrSelected = appsProvider var newInstallIdsAllOrSelected = appsProvider
.getExistingUpdates(nonInstalledOnly: true) .findExistingUpdates(nonInstalledOnly: true)
.where((element) => selectedIds.isEmpty .where((element) => selectedApps.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty ? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element)) : selectedApps.map((e) => e.id).contains(element))
.toList(); .toList();
List<String> trackOnlyUpdateIdsAllOrSelected = [];
existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) {
if (appsProvider.apps[id]!.app.trackOnly) {
trackOnlyUpdateIdsAllOrSelected.add(id);
return false;
}
return true;
}).toList();
newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) {
if (appsProvider.apps[id]!.app.trackOnly) {
trackOnlyUpdateIdsAllOrSelected.add(id);
return false;
}
return true;
}).toList();
if (settingsProvider.pinUpdates) {
var temp = [];
sortedApps = sortedApps.where((sa) {
if (existingUpdates.contains(sa.app.id)) {
temp.add(sa);
return false;
}
return true;
}).toList();
sortedApps = [...temp, ...sortedApps];
}
var tempPinned = [];
var tempNotPinned = [];
for (var a in sortedApps) {
if (a.app.pinned) {
tempPinned.add(a);
} else {
tempNotPinned.add(a);
}
}
sortedApps = [...tempPinned, ...tempNotPinned];
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: () { onRefresh: () {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
setState(() {
refreshingSince = DateTime.now();
});
return appsProvider.checkUpdates().catchError((e) { return appsProvider.checkUpdates().catchError((e) {
ScaffoldMessenger.of(context).showSnackBar( showError(e, context);
SnackBar(content: Text(e.toString())), }).whenComplete(() {
); setState(() {
refreshingSince = null;
});
}); });
}, },
child: CustomScrollView(slivers: <Widget>[ child: CustomScrollView(slivers: <Widget>[
@ -154,30 +204,101 @@ class AppsPageState extends State<AppsPage> {
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
))), ))),
if (refreshingSince != null)
SliverToBoxAdapter(
child: LinearProgressIndicator(
value: appsProvider.apps.values
.where((element) => !(element.app.lastUpdateCheck
?.isBefore(refreshingSince!) ??
true))
.length /
appsProvider.apps.length,
),
),
SliverList( SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) { (BuildContext context, int index) {
return ListTile( return ListTile(
selectedTileColor: tileColor: sortedApps[index].app.pinned
Theme.of(context).colorScheme.primary.withOpacity(0.1), ? Colors.grey.withOpacity(0.1)
selected: selectedIds.contains(sortedApps[index].app.id), : Colors.transparent,
selectedTileColor: Theme.of(context)
.colorScheme
.primary
.withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1),
selected: selectedApps.contains(sortedApps[index].app),
onLongPress: () { onLongPress: () {
toggleAppSelected(sortedApps[index].app.id); toggleAppSelected(sortedApps[index].app);
}, },
title: Text(sortedApps[index].app.name), leading: sortedApps[index].installedInfo != null
subtitle: Text('By ${sortedApps[index].app.author}'), ? Image.memory(
sortedApps[index].installedInfo!.icon!,
gaplessPlayback: true,
)
: null,
title: Text(
sortedApps[index].installedInfo?.name ??
sortedApps[index].app.name,
style: TextStyle(
fontWeight: sortedApps[index].app.pinned
? FontWeight.bold
: FontWeight.normal),
),
subtitle: Text('By ${sortedApps[index].app.author}',
style: TextStyle(
fontWeight: sortedApps[index].app.pinned
? FontWeight.bold
: FontWeight.normal)),
trailing: sortedApps[index].downloadProgress != null trailing: sortedApps[index].downloadProgress != null
? Text( ? Text(
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%') 'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
: (sortedApps[index].app.installedVersion != null && : (sortedApps[index].app.installedVersion != null &&
sortedApps[index].app.installedVersion != sortedApps[index].app.installedVersion !=
sortedApps[index].app.latestVersion sortedApps[index].app.latestVersion
? const Text('Update Available') ? Column(
: Text(sortedApps[index].app.installedVersion ?? mainAxisAlignment: MainAxisAlignment.center,
'Not Installed')), crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(appsProvider.areDownloadsRunning()
? 'Please Wait...'
: 'Update Available${sortedApps[index].app.trackOnly ? ' (Est.)' : ''}'),
SourceProvider()
.getSource(sortedApps[index].app.url)
.changeLogPageFromStandardUrl(
sortedApps[index].app.url) ==
null
? const SizedBox()
: GestureDetector(
onTap: () {
launchUrlString(
SourceProvider()
.getSource(
sortedApps[index].app.url)
.changeLogPageFromStandardUrl(
sortedApps[index].app.url)!,
mode:
LaunchMode.externalApplication);
},
child: const Text(
'See Changes',
style: TextStyle(
fontStyle: FontStyle.italic,
decoration:
TextDecoration.underline),
)),
],
)
: SingleChildScrollView(
child: SizedBox(
width: 80,
child: Text(
'${sortedApps[index].app.installedVersion ?? 'Not Installed'} ${sortedApps[index].app.trackOnly == true ? '(Estimate)' : ''}',
overflow: TextOverflow.fade,
textAlign: TextAlign.end,
)))),
onTap: () { onTap: () {
if (selectedIds.isNotEmpty) { if (selectedApps.isNotEmpty) {
toggleAppSelected(sortedApps[index].app.id); toggleAppSelected(sortedApps[index].app);
} else { } else {
Navigator.push( Navigator.push(
context, context,
@ -195,25 +316,25 @@ class AppsPageState extends State<AppsPage> {
children: [ children: [
IconButton( IconButton(
onPressed: () { onPressed: () {
selectedIds.isEmpty selectedApps.isEmpty
? selectThese(sortedApps.map((e) => e.app.id).toList()) ? selectThese(sortedApps.map((e) => e.app).toList())
: clearSelected(); : clearSelected();
}, },
icon: Icon( icon: Icon(
selectedIds.isEmpty selectedApps.isEmpty
? Icons.select_all_outlined ? Icons.select_all_outlined
: Icons.deselect_outlined, : Icons.deselect_outlined,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
tooltip: selectedIds.isEmpty tooltip: selectedApps.isEmpty
? 'Select All' ? 'Select All'
: 'Deselect ${selectedIds.length.toString()}'), : 'Deselect ${selectedApps.length.toString()}'),
const VerticalDivider(), const VerticalDivider(),
Expanded( Expanded(
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
selectedIds.isEmpty selectedApps.isEmpty
? const SizedBox() ? const SizedBox()
: IconButton( : IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@ -227,11 +348,12 @@ class AppsPageState extends State<AppsPage> {
defaultValues: const [], defaultValues: const [],
initValid: true, initValid: true,
message: message:
'${selectedIds.length} App${selectedIds.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedIds.length == 1 ? 'it' : 'them'} manually.', '${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedApps.length == 1 ? 'it' : 'them'} manually.',
); );
}).then((values) { }).then((values) {
if (values != null) { if (values != null) {
appsProvider.removeApps(selectedIds.toList()); appsProvider.removeApps(
selectedApps.map((e) => e.id).toList());
} }
}); });
}, },
@ -242,46 +364,77 @@ class AppsPageState extends State<AppsPage> {
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
onPressed: appsProvider.areDownloadsRunning() || onPressed: appsProvider.areDownloadsRunning() ||
(existingUpdateIdsAllOrSelected.isEmpty && (existingUpdateIdsAllOrSelected.isEmpty &&
newInstallIdsAllOrSelected.isEmpty) newInstallIdsAllOrSelected.isEmpty &&
trackOnlyUpdateIdsAllOrSelected.isEmpty)
? null ? null
: () { : () {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
List<List<GeneratedFormItem>> formInputs = []; List<GeneratedFormItem> formInputs = [];
if (existingUpdateIdsAllOrSelected.isNotEmpty && List<String> defaultValues = [];
newInstallIdsAllOrSelected.isNotEmpty) { if (existingUpdateIdsAllOrSelected.isNotEmpty) {
formInputs.add([ formInputs.add(GeneratedFormItem(
GeneratedFormItem( label:
label: 'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}', type: FormItemType.bool,
type: FormItemType.bool) key: 'updates'));
]); defaultValues.add('true');
formInputs.add([ }
GeneratedFormItem( if (newInstallIdsAllOrSelected.isNotEmpty) {
label: formInputs.add(GeneratedFormItem(
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}', label:
type: FormItemType.bool) 'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}',
]); type: FormItemType.bool,
key: 'installs'));
defaultValues
.add(defaultValues.isEmpty ? 'true' : '');
}
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
formInputs.add(GeneratedFormItem(
label:
'Mark ${trackOnlyUpdateIdsAllOrSelected.length} Track-Only\nApp${trackOnlyUpdateIdsAllOrSelected.length == 1 ? '' : 's'} as Updated',
type: FormItemType.bool,
key: 'trackonlies'));
defaultValues
.add(defaultValues.isEmpty ? 'true' : '');
} }
showDialog<List<String>?>( showDialog<List<String>?>(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
var totalApps = existingUpdateIdsAllOrSelected
.length +
newInstallIdsAllOrSelected.length +
trackOnlyUpdateIdsAllOrSelected.length;
return GeneratedFormModal( return GeneratedFormModal(
title: title:
'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?', 'Change $totalApps App${totalApps == 1 ? '' : 's'}',
message: items: formInputs.map((e) => [e]).toList(),
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.', defaultValues: defaultValues,
items: formInputs,
defaultValues: const ['true', 'true'],
initValid: true, initValid: true,
); );
}).then((values) { }).then((values) {
if (values != null) { if (values != null) {
if (values.isEmpty) {
values = defaultValues;
}
bool shouldInstallUpdates = bool shouldInstallUpdates =
values.length < 2 || values[0] == 'true'; findGeneratedFormValueByKey(
formInputs, values, 'updates') ==
'true';
bool shouldInstallNew = bool shouldInstallNew =
values.length < 2 || values[1] == 'true'; findGeneratedFormValueByKey(
settingsProvider formInputs, values, 'installs') ==
.getInstallPermission() '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) {
@ -292,32 +445,173 @@ class AppsPageState extends State<AppsPage> {
toInstall toInstall
.addAll(newInstallIdsAllOrSelected); .addAll(newInstallIdsAllOrSelected);
} }
appsProvider.downloadAndInstallLatestApp( if (shouldMarkTrackOnlies) {
toInstall, context); toInstall.addAll(
trackOnlyUpdateIdsAllOrSelected);
}
appsProvider
.downloadAndInstallLatestApps(
toInstall, context)
.catchError((e) {
showError(e, context);
});
}); });
} }
}); });
}, },
tooltip: tooltip:
'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps', 'Install/Update${selectedApps.isEmpty ? ' ' : ' Selected '}Apps',
icon: const Icon( icon: const Icon(
Icons.file_download_outlined, Icons.file_download_outlined,
)), )),
selectedIds.isEmpty selectedApps.isEmpty
? const SizedBox() ? const SizedBox()
: IconButton( : IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
onPressed: () { onPressed: () {
String urls = ''; showDialog(
for (var id in selectedIds) { context: context,
urls += '${appsProvider.apps[id]!.app.url}\n'; builder: (BuildContext ctx) {
} return AlertDialog(
urls = urls.substring(0, urls.length - 1); scrollable: true,
Share.share(urls, content: Padding(
subject: 'Selected App URLs from Obtainium'); padding: const EdgeInsets.only(top: 6),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceAround,
children: [
IconButton(
onPressed:
appsProvider
.areDownloadsRunning()
? null
: () {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return AlertDialog(
title: Text(
'Mark ${selectedApps.length} Selected Apps as Updated?'),
content:
const Text(
'Only applies to installed but out of date Apps.'),
actions: [
TextButton(
onPressed:
() {
Navigator.of(context)
.pop();
},
child: const Text(
'No')),
TextButton(
onPressed:
() {
HapticFeedback
.selectionClick();
appsProvider
.saveApps(selectedApps.map((a) {
if (a.installedVersion !=
null) {
a.installedVersion = a.latestVersion;
}
return a;
}).toList());
Navigator.of(context)
.pop();
},
child: const Text(
'Yes'))
],
);
}).whenComplete(() {
Navigator.of(
context)
.pop();
});
},
tooltip:
'Mark Selected Apps as Updated',
icon: const Icon(Icons.done)),
IconButton(
onPressed: () {
var pinStatus = selectedApps
.where((element) =>
element.pinned)
.isEmpty;
appsProvider.saveApps(
selectedApps.map((e) {
e.pinned = pinStatus;
return e;
}).toList());
Navigator.of(context).pop();
},
tooltip:
'${selectedApps.where((element) => element.pinned).isEmpty ? 'Pin to' : 'Unpin from'} top',
icon: Icon(selectedApps
.where((element) =>
element.pinned)
.isEmpty
? Icons.bookmark_outline_rounded
: Icons
.bookmark_remove_outlined),
),
IconButton(
onPressed: () {
String urls = '';
for (var a in selectedApps) {
urls += '${a.url}\n';
}
urls = urls.substring(
0, urls.length - 1);
Share.share(urls,
subject:
'${selectedApps.length} Selected App URLs from Obtainium');
Navigator.of(context).pop();
},
tooltip: 'Share Selected App URLs',
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),
),
]),
),
);
});
}, },
tooltip: 'Share Selected App URLs', tooltip: 'More',
icon: const Icon(Icons.share), icon: const Icon(Icons.more_horiz),
), ),
], ],
)), )),

View File

@ -92,7 +92,6 @@ class _HomePageState extends State<HomePage> {
return !(pages[0].widget.key as GlobalKey<AppsPageState>) return !(pages[0].widget.key as GlobalKey<AppsPageState>)
.currentState .currentState
?.clearSelected(); ?.clearSelected();
// return !appsPageKey.currentState?.clearSelected();
}); });
} }
} }

View File

@ -6,11 +6,12 @@ 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/components/generated_form_modal.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});
@ -25,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(
@ -38,30 +38,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
), ),
); );
Future<List<List<String>>> addApps(List<String> urls) async {
await settingsProvider.getInstallPermission();
List<dynamic> results = await sourceProvider.getApps(urls,
ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList());
List<App> apps = results[0];
Map<String, dynamic> errorsMap = results[1];
for (var app in apps) {
if (appsProvider.apps.containsKey(app.id)) {
errorsMap.addAll({app.id: 'App already added'});
} else {
await appsProvider.saveApps([app]);
}
}
List<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
}
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Import/Export'), const CustomAppBar(title: 'Import/Export'),
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false,
child: Padding( child: Padding(
padding: padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16), const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
@ -81,12 +62,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
appsProvider appsProvider
.exportApps() .exportApps()
.then((String path) { .then((String path) {
ScaffoldMessenger.of(context) showError(
.showSnackBar( 'Exported to $path', context);
SnackBar(
content: Text(
'Exported to $path')),
);
}); });
}, },
child: const Text('Obtainium Export'))), child: const Text('Obtainium Export'))),
@ -113,27 +90,21 @@ class _ImportExportPageState extends State<ImportExportPage> {
try { try {
jsonDecode(data); jsonDecode(data);
} catch (e) { } catch (e) {
throw 'Invalid input'; throw ObtainiumError(
'Invalid input');
} }
appsProvider appsProvider
.importApps(data) .importApps(data)
.then((value) { .then((value) {
ScaffoldMessenger.of(context) showError(
.showSnackBar( '$value App${value == 1 ? '' : 's'} Imported',
SnackBar( context);
content: Text(
'$value App${value == 1 ? '' : 's'} Imported')),
);
}); });
} else { } else {
// User canceled the picker // User canceled the picker
} }
}).catchError((e) { }).catchError((e) {
ScaffoldMessenger.of(context) showError(e, context);
.showSnackBar(
SnackBar(
content: Text(e.toString())),
);
}).whenComplete(() { }).whenComplete(() {
setState(() { setState(() {
importInProgress = false; importInProgress = false;
@ -206,14 +177,13 @@ 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) {
ScaffoldMessenger.of(context) showError(
.showSnackBar( 'Imported ${urls.length} Apps',
SnackBar( context);
content: Text(
'Imported ${urls.length} Apps')),
);
} else { } else {
showDialog( showDialog(
context: context, context: context,
@ -224,10 +194,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
}); });
} }
}).catchError((e) { }).catchError((e) {
ScaffoldMessenger.of(context) showError(e, context);
.showSnackBar(
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() { }).whenComplete(() {
setState(() { setState(() {
importInProgress = false; importInProgress = false;
@ -239,7 +206,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
child: const Text( child: const Text(
'Import from URL List', 'Import from URL List',
)), )),
...sourceProvider.massSources ...sourceProvider.sources
.where((element) => element.canSearch)
.map((source) => Column( .map((source) => Column(
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.stretch, CrossAxisAlignment.stretch,
@ -249,99 +217,194 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: importInProgress onPressed: importInProgress
? null ? null
: () { : () {
showDialog( () async {
context: context, var values = await showDialog<
builder: List<String>>(
(BuildContext ctx) { context: context,
return GeneratedFormModal( builder:
title: (BuildContext ctx) {
'Import ${source.name}', return GeneratedFormModal(
items: source title:
.requiredArgs 'Search ${source.runtimeType}',
.map((e) => [ items: [
GeneratedFormItem( [
label: e) GeneratedFormItem(
]) label:
.toList(), '${source.runtimeType} Search Query')
defaultValues: const [], ]
); ],
}).then((values) { defaultValues: const [],
if (values != null) { );
});
if (values != null &&
values[0].isNotEmpty) {
setState(() { setState(() {
importInProgress = true; importInProgress = true;
}); });
source var urlsWithDescriptions =
.getUrls(values) await source
.then((urls) { .search(values[0]);
showDialog<List<String>?>( if (urlsWithDescriptions
.isNotEmpty) {
var selectedUrls =
await showDialog<
List<
String>?>(
context: context, context: context,
builder: builder:
(BuildContext (BuildContext
ctx) { ctx) {
return UrlSelectionModal( return UrlSelectionModal(
urls: urls); urlsWithDescriptions:
}) urlsWithDescriptions,
.then((selectedUrls) { selectedByDefault:
if (selectedUrls != false,
null) { );
addApps(selectedUrls) });
.then((errors) { if (selectedUrls !=
if (errors null &&
.isEmpty) { selectedUrls
ScaffoldMessenger .isNotEmpty) {
.of(context) var errors =
.showSnackBar( await appsProvider
SnackBar( .addAppsByURL(
content: Text( selectedUrls);
'Imported ${selectedUrls.length} Apps')), if (errors.isEmpty) {
); // ignore: use_build_context_synchronously
} else { showError(
showDialog( 'Imported ${selectedUrls.length} Apps',
context: context);
context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}).whenComplete(() {
setState(() {
importInProgress =
false;
});
});
} else { } else {
setState(() { showDialog(
importInProgress = context: context,
false; builder:
}); (BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
} }
}); }
}).catchError((e) { } else {
setState(() { throw ObtainiumError(
importInProgress = 'No results found');
false; }
});
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: Text(
e.toString())),
);
});
} }
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: Text(
'Search ${source.runtimeType}'))
]))
.toList(),
...sourceProvider.massUrlSources
.map((source) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
TextButton(
onPressed: importInProgress
? null
: () {
() async {
var values = await showDialog(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title:
'Import ${source.name}',
items:
source
.requiredArgs
.map(
(e) => [
GeneratedFormItem(label: e)
])
.toList(),
defaultValues: const [],
);
});
if (values != null) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source
.getUrlsWithDescriptions(
values);
var selectedUrls =
await showDialog<
List<String>?>(
context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urlsWithDescriptions:
urlsWithDescriptions);
});
if (selectedUrls != null) {
var errors =
await appsProvider
.addAppsByURL(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
'Imported ${selectedUrls.length} Apps',
context);
} else {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
}); });
}, },
child: Text('Import ${source.name}')) child: Text('Import ${source.name}'))
])) ]))
.toList() .toList(),
const Spacer(),
const Divider(
height: 32,
),
const Text(
'Imported Apps may incorrectly show as "Not Installed".\nTo fix this, re-install them through Obtainium.\nThis should not affect App data.\n\nOnly affects URL and third-party import methods.',
textAlign: TextAlign.center,
style: TextStyle(
fontStyle: FontStyle.italic, fontSize: 12)),
const SizedBox(
height: 8,
)
], ],
))) )))
])); ]));
@ -404,21 +467,37 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
// ignore: must_be_immutable // ignore: must_be_immutable
class UrlSelectionModal extends StatefulWidget { class UrlSelectionModal extends StatefulWidget {
UrlSelectionModal({super.key, required this.urls}); UrlSelectionModal(
{super.key,
required this.urlsWithDescriptions,
this.selectedByDefault = true,
this.onlyOneSelectionAllowed = false});
List<String> urls; Map<String, String> urlsWithDescriptions;
bool selectedByDefault;
bool onlyOneSelectionAllowed;
@override @override
State<UrlSelectionModal> createState() => _UrlSelectionModalState(); State<UrlSelectionModal> createState() => _UrlSelectionModalState();
} }
class _UrlSelectionModalState extends State<UrlSelectionModal> { class _UrlSelectionModalState extends State<UrlSelectionModal> {
Map<String, bool> urlSelections = {}; Map<MapEntry<String, String>, bool> urlWithDescriptionSelections = {};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
for (var url in widget.urls) { for (var url in widget.urlsWithDescriptions.entries) {
urlSelections.putIfAbsent(url, () => true); urlWithDescriptionSelections.putIfAbsent(url,
() => widget.selectedByDefault && !widget.onlyOneSelectionAllowed);
}
if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) {
selectOnlyOne(widget.urlsWithDescriptions.entries.first.key);
}
}
selectOnlyOne(String url) {
for (var uwd in urlWithDescriptionSelections.keys) {
urlWithDescriptionSelections[uwd] = uwd.key == url;
} }
} }
@ -426,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,
)
],
)) ))
]); ]);
}) })
@ -454,13 +566,19 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
}, },
child: const Text('Cancel')), child: const Text('Cancel')),
TextButton( TextButton(
onPressed: () { onPressed:
Navigator.of(context).pop(urlSelections.keys urlWithDescriptionSelections.values.where((b) => b).isEmpty
.where((url) => urlSelections[url] ?? false) ? null
.toList()); : () {
}, Navigator.of(context).pop(urlWithDescriptionSelections
child: Text( .entries
'Import ${urlSelections.values.where((b) => b).length} URLs')) .where((entry) => entry.value)
.map((e) => e.key.key)
.toList());
},
child: Text(widget.onlyOneSelectionAllowed
? 'Pick'
: 'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs'))
], ],
); );
} }

View File

@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class SettingsPage extends StatefulWidget { class SettingsPage extends StatefulWidget {
@ -21,6 +24,146 @@ class _SettingsPageState extends State<SettingsPage> {
if (settingsProvider.prefs == null) { if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings(); settingsProvider.initializeSettings();
} }
var themeDropdown = DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'Theme'),
value: settingsProvider.theme,
items: const [
DropdownMenuItem(
value: ThemeSettings.dark,
child: Text('Dark'),
),
DropdownMenuItem(
value: ThemeSettings.light,
child: Text('Light'),
),
DropdownMenuItem(
value: ThemeSettings.system,
child: Text('Follow System'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.theme = value;
}
});
var colourDropdown = DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'Colour'),
value: settingsProvider.colour,
items: const [
DropdownMenuItem(
value: ColourSettings.basic,
child: Text('Obtainium'),
),
DropdownMenuItem(
value: ColourSettings.materialYou,
child: Text('Material You'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.colour = value;
}
});
var sortDropdown = DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'App Sort By'),
value: settingsProvider.sortColumn,
items: const [
DropdownMenuItem(
value: SortColumnSettings.authorName,
child: Text('Author/Name'),
),
DropdownMenuItem(
value: SortColumnSettings.nameAuthor,
child: Text('Name/Author'),
),
DropdownMenuItem(
value: SortColumnSettings.added,
child: Text('As Added'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortColumn = value;
}
});
var orderDropdown = DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'App Sort Order'),
value: settingsProvider.sortOrder,
items: const [
DropdownMenuItem(
value: SortOrderSettings.ascending,
child: Text('Ascending'),
),
DropdownMenuItem(
value: SortOrderSettings.descending,
child: Text('Descending'),
),
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortOrder = value;
}
});
var intervalDropdown = DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'Background Update Checking Interval'),
value: settingsProvider.updateInterval,
items: updateIntervals.map((e) {
int displayNum = (e < 60
? e
: e < 1440
? e / 60
: e / 1440)
.round();
var displayUnit = (e < 60
? 'Minute'
: e < 1440
? 'Hour'
: 'Day');
String display = e == 0
? 'Never - Manual Only'
: '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
return DropdownMenuItem(value: e, child: Text(display));
}).toList(),
onChanged: (value) {
if (value != null) {
settingsProvider.updateInterval = value;
}
});
var sourceSpecificFields = sourceProvider.sources.map((e) {
if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
return GeneratedForm(
items: e.additionalSourceSpecificSettingFormItems
.map((e) => [e])
.toList(),
onValueChanges: (values, valid, isBuilding) {
if (valid) {
for (var i = 0; i < values.length; i++) {
settingsProvider.setSettingString(
e.additionalSourceSpecificSettingFormItems[i].id,
values[i]);
}
}
},
defaultValues: e.additionalSourceSpecificSettingFormItems.map((e) {
return settingsProvider.getSettingString(e.id) ?? '';
}).toList());
} else {
return Container();
}
});
const height16 = SizedBox(
height: 16,
);
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
@ -38,112 +181,22 @@ class _SettingsPageState extends State<SettingsPage> {
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.primary), color: Theme.of(context).colorScheme.primary),
), ),
DropdownButtonFormField( themeDropdown,
decoration: height16,
const InputDecoration(labelText: 'Theme'), colourDropdown,
value: settingsProvider.theme, height16,
items: const [
DropdownMenuItem(
value: ThemeSettings.dark,
child: Text('Dark'),
),
DropdownMenuItem(
value: ThemeSettings.light,
child: Text('Light'),
),
DropdownMenuItem(
value: ThemeSettings.system,
child: Text('Follow System'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.theme = value;
}
}),
const SizedBox(
height: 16,
),
DropdownButtonFormField(
decoration:
const InputDecoration(labelText: 'Colour'),
value: settingsProvider.colour,
items: const [
DropdownMenuItem(
value: ColourSettings.basic,
child: Text('Obtainium'),
),
DropdownMenuItem(
value: ColourSettings.materialYou,
child: Text('Material You'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.colour = value;
}
}),
const SizedBox(
height: 16,
),
Row( Row(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(child: sortDropdown),
child: DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'App Sort By'),
value: settingsProvider.sortColumn,
items: const [
DropdownMenuItem(
value:
SortColumnSettings.authorName,
child: Text('Author/Name'),
),
DropdownMenuItem(
value:
SortColumnSettings.nameAuthor,
child: Text('Name/Author'),
),
DropdownMenuItem(
value: SortColumnSettings.added,
child: Text('As Added'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortColumn = value;
}
})),
const SizedBox( const SizedBox(
width: 16, width: 16,
), ),
Expanded( Expanded(child: orderDropdown),
child: DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'App Sort Order'),
value: settingsProvider.sortOrder,
items: const [
DropdownMenuItem(
value: SortOrderSettings.ascending,
child: Text('Ascending'),
),
DropdownMenuItem(
value: SortOrderSettings.descending,
child: Text('Descending'),
),
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortOrder = value;
}
})),
], ],
), ),
const SizedBox( height16,
height: 16,
),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -155,57 +208,28 @@ class _SettingsPageState extends State<SettingsPage> {
}) })
], ],
), ),
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Pin Updates to Top of Apps View'),
Switch(
value: settingsProvider.pinUpdates,
onChanged: (value) {
settingsProvider.pinUpdates = value;
})
],
),
const Divider( const Divider(
height: 16, height: 16,
), ),
const SizedBox( height16,
height: 16,
),
Text( Text(
'Updates', 'Updates',
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.primary), color: Theme.of(context).colorScheme.primary),
), ),
DropdownButtonFormField( intervalDropdown,
decoration: const InputDecoration(
labelText:
'Background Update Checking Interval'),
value: settingsProvider.updateInterval,
items: updateIntervals.map((e) {
int displayNum = (e < 60
? e
: e < 1440
? e / 60
: e / 1440)
.round();
var displayUnit = (e < 60
? 'Minute'
: e < 1440
? 'Hour'
: 'Day');
String display = e == 0
? 'Never - Manual Only'
: '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
return DropdownMenuItem(
value: e, child: Text(display));
}).toList(),
onChanged: (value) {
if (value != null) {
settingsProvider.updateInterval = value;
}
}),
const SizedBox(
height: 8,
),
Text(
'Longer intervals recommended for large App collections',
style: Theme.of(context)
.textTheme
.labelMedium!
.merge(const TextStyle(
fontStyle: FontStyle.italic)),
),
const Divider( const Divider(
height: 48, height: 48,
), ),
@ -214,65 +238,118 @@ class _SettingsPageState extends State<SettingsPage> {
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.primary), color: Theme.of(context).colorScheme.primary),
), ),
...sourceProvider.sources.map((e) { ...sourceSpecificFields,
if (e.moreSourceSettingsFormItems.isNotEmpty) {
return GeneratedForm(
items: e.moreSourceSettingsFormItems
.map((e) => [e])
.toList(),
onValueChanges: (values, valid) {
if (valid) {
for (var i = 0;
i < values.length;
i++) {
settingsProvider.setSettingString(
e.moreSourceSettingsFormItems[i]
.id,
values[i]);
}
}
},
defaultValues:
e.moreSourceSettingsFormItems.map((e) {
return settingsProvider
.getSettingString(e.id) ??
'';
}).toList());
} else {
return Container();
}
}),
], ],
))), ))),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column( child: Column(
children: [ children: [
const SizedBox( const Divider(
height: 16, height: 32,
), ),
TextButton.icon( Row(
style: ButtonStyle( mainAxisAlignment: MainAxisAlignment.spaceAround,
foregroundColor: MaterialStateProperty.resolveWith<Color>( children: [
(Set<MaterialState> states) { TextButton.icon(
return Colors.grey; onPressed: () {
}), launchUrlString(settingsProvider.sourceUrl,
), mode: LaunchMode.externalApplication);
onPressed: () { },
launchUrlString(settingsProvider.sourceUrl, icon: const Icon(Icons.code),
mode: LaunchMode.externalApplication); label: const Text(
}, 'App Source',
icon: const Icon(Icons.code), ),
label: Text( ),
'Source', TextButton.icon(
style: Theme.of(context).textTheme.bodySmall, onPressed: () {
), context.read<LogsProvider>().get().then((logs) {
), if (logs.isEmpty) {
const SizedBox( showError(ObtainiumError('No Logs'), context);
height: 16, } else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return const LogsDialog();
});
}
});
},
icon: const Icon(Icons.bug_report_outlined),
label: const Text('App Logs')),
],
), ),
height16,
], ],
), ),
) )
])); ]));
} }
} }
class LogsDialog extends StatefulWidget {
const LogsDialog({super.key});
@override
State<LogsDialog> createState() => _LogsDialogState();
}
class _LogsDialogState extends State<LogsDialog> {
String? logString;
List<int> days = [7, 5, 4, 3, 2, 1];
@override
Widget build(BuildContext context) {
var logsProvider = context.read<LogsProvider>();
void filterLogs(int days) {
logsProvider
.get(after: DateTime.now().subtract(Duration(days: days)))
.then((value) {
setState(() {
String l = value.map((e) => e.toString()).join('\n\n');
logString = l.isNotEmpty ? l : 'No Logs';
});
});
}
if (logString == null) {
filterLogs(days.first);
}
return AlertDialog(
scrollable: true,
title: const Text('Obtainium App Logs'),
content: Column(
children: [
DropdownButtonFormField(
value: days.first,
items: days
.map((e) => DropdownMenuItem(
value: e,
child: Text('$e Day${e == 1 ? '' : 's'}'),
))
.toList(),
onChanged: (d) {
filterLogs(d ?? 7);
}),
const SizedBox(
height: 32,
),
Text(logString ?? '')
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Close')),
TextButton(
onPressed: () {
Share.share(logString ?? '', subject: 'Obtainium App Logs');
Navigator.of(context).pop();
},
child: const Text('Share'))
],
);
}
}

View File

@ -8,25 +8,33 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:install_plugin_v2/install_plugin_v2.dart';
import 'package:installed_apps/app_info.dart';
import 'package:installed_apps/installed_apps.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:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:flutter_fgbg/flutter_fgbg.dart'; import 'package:flutter_fgbg/flutter_fgbg.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:flutter_install_app/flutter_install_app.dart';
class AppInMemory { class AppInMemory {
late App app; late App app;
double? downloadProgress; double? downloadProgress;
AppInfo? installedInfo;
AppInMemory(this.app, this.downloadProgress); AppInMemory(this.app, this.downloadProgress, this.installedInfo);
} }
class ApkFile { class DownloadedApk {
String appId; String appId;
File file; File file;
ApkFile(this.appId, this.file); DownloadedApk(this.appId, this.file);
} }
class AppsProvider with ChangeNotifier { class AppsProvider with ChangeNotifier {
@ -34,63 +42,121 @@ class AppsProvider with ChangeNotifier {
Map<String, AppInMemory> apps = {}; Map<String, AppInMemory> apps = {};
bool loadingApps = false; bool loadingApps = false;
bool gettingUpdates = false; bool gettingUpdates = 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;
late Stream<FGBGType> foregroundStream; late Stream<FGBGType>? foregroundStream;
late StreamSubscription<FGBGType> foregroundSubscription; late StreamSubscription<FGBGType>? foregroundSubscription;
AppsProvider( AppsProvider({this.forBGTask = false}) {
{bool shouldLoadApps = false, // Many setup tasks should only be done in the foreground isolate
bool shouldCheckUpdatesAfterLoad = false, if (!forBGTask) {
bool shouldDeleteAPKs = false}) { // Subscribe to changes in the app foreground status
// Subscribe to changes in the app foreground status foregroundStream = FGBGEvents.stream.asBroadcastStream();
foregroundStream = FGBGEvents.stream.asBroadcastStream(); foregroundSubscription = foregroundStream?.listen((event) async {
foregroundSubscription = foregroundStream.listen((event) async { isForeground = event == FGBGType.foreground;
isForeground = event == FGBGType.foreground; if (isForeground) await loadApps();
if (isForeground) await loadApps();
});
if (shouldDeleteAPKs) {
deleteSavedAPKs();
}
if (shouldLoadApps) {
loadApps().then((_) {
if (shouldCheckUpdatesAfterLoad) {
checkUpdates();
}
}); });
() async {
// Load Apps into memory (in background, this is done later instead of in the constructor)
await loadApps();
// Delete existing APKs
(await getExternalStorageDirectory())
?.listSync()
.where((element) =>
element.path.endsWith('.apk') ||
element.path.endsWith('.apk.part'))
.forEach((apk) {
apk.delete();
});
}();
} }
} }
Future<ApkFile> downloadApp(String apkUrl, String appId) async { downloadFile(String url, String fileName, Function? onProgress,
{bool useExisting = true}) async {
var destDir = (await getExternalStorageDirectory())!.path;
StreamedResponse response = StreamedResponse response =
await Client().send(Request('GET', Uri.parse(apkUrl))); await Client().send(Request('GET', Uri.parse(url)));
File downloadFile = File downloadedFile = File('$destDir/$fileName');
File('${(await getExternalStorageDirectory())!.path}/$appId.apk'); if (!(downloadedFile.existsSync() && useExisting)) {
if (downloadFile.existsSync()) { File tempDownloadedFile = File('${downloadedFile.path}.part');
downloadFile.deleteSync(); if (tempDownloadedFile.existsSync()) {
tempDownloadedFile.deleteSync();
}
var length = response.contentLength;
var received = 0;
double? progress;
var sink = tempDownloadedFile.openWrite();
await response.stream.map((s) {
received += s.length;
progress = (length != null ? received / length * 100 : 30);
if (onProgress != null) {
onProgress(progress);
}
return s;
}).pipe(sink);
await sink.close();
progress = null;
if (onProgress != null) {
onProgress(progress);
}
if (response.statusCode != 200) {
tempDownloadedFile.deleteSync();
throw response.reasonPhrase ?? 'Unknown Error';
}
tempDownloadedFile.renameSync(downloadedFile.path);
} }
var length = response.contentLength; return downloadedFile;
var received = 0; }
var sink = downloadFile.openWrite();
await response.stream.map((s) { Future<DownloadedApk> downloadApp(App app) async {
received += s.length; var fileName =
apps[appId]!.downloadProgress = '${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
(length != null ? received / length * 100 : 30); String downloadUrl = await SourceProvider()
notifyListeners(); .getSource(app.url)
return s; .apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
}).pipe(sink); int? prevProg;
File downloadedFile =
await sink.close(); await downloadFile(downloadUrl, fileName, (double? progress) {
apps[appId]!.downloadProgress = null; int? prog = progress?.ceil();
notifyListeners(); if (apps[app.id] != null) {
apps[app.id]!.downloadProgress = progress;
if (response.statusCode != 200) { notifyListeners();
downloadFile.deleteSync(); } else if ((prog == 25 || prog == 50 || prog == 75) && prevProg != prog) {
throw response.reasonPhrase ?? 'Unknown Error'; Fluttertoast.showToast(
msg: 'Progress: $prog%', toastLength: Toast.LENGTH_SHORT);
}
prevProg = prog;
});
// Delete older versions of the APK if any
for (var file in downloadedFile.parent.listSync()) {
var fn = file.path.split('/').last;
if (fn.startsWith('${app.id}-') &&
fn.endsWith('.apk') &&
fn != fileName) {
file.delete();
}
} }
return ApkFile(appId, downloadFile); // If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
// The former case should be handled (give the App its real ID), the latter is a security issue
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
if (app.id != newInfo.packageName) {
if (apps[app.id] != null && !SourceProvider().isTempId(app.id)) {
throw IDChangedError();
}
var originalAppId = app.id;
app.id = newInfo.packageName;
downloadedFile = downloadedFile.renameSync(
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
if (apps[originalAppId] != null) {
await removeApps([originalAppId]);
await saveApps([app]);
}
}
return DownloadedApk(app.id, downloadedFile);
} }
bool areDownloadsRunning() => apps.values bool areDownloadsRunning() => apps.values
@ -98,24 +164,35 @@ class AppsProvider with ChangeNotifier {
.isNotEmpty; .isNotEmpty;
Future<bool> canInstallSilently(App app) async { Future<bool> canInstallSilently(App app) async {
// TODO: This is unreliable - try to get from OS in the future return false;
var osInfo = await DeviceInfoPlugin().androidInfo; // TODO: Uncomment the below once silentupdates are ever figured out
return app.installedVersion != null && // // TODO: This is unreliable - try to get from OS in the future
osInfo.version.sdkInt! >= 30 && // if (app.apkUrls.length > 1) {
osInfo.version.release!.compareTo('12') >= 0; // return false;
// }
// var osInfo = await DeviceInfoPlugin().androidInfo;
// return app.installedVersion != null &&
// osInfo.version.sdkInt >= 30 &&
// osInfo.version.release.compareTo('12') >= 0;
} }
Future<void> askUserToReturnToForeground(BuildContext context, Future<void> waitForUserToReturnToForeground(BuildContext context) async {
{bool waitForFG = false}) async {
NotificationsProvider notificationsProvider = NotificationsProvider notificationsProvider =
context.read<NotificationsProvider>(); context.read<NotificationsProvider>();
if (!isForeground) { if (!isForeground) {
await notificationsProvider.notify(completeInstallationNotification, await notificationsProvider.notify(completeInstallationNotification,
cancelExisting: true); cancelExisting: true);
if (waitForFG) { while (await FGBGEvents.stream.first != FGBGType.foreground) {}
await FGBGEvents.stream.first == FGBGType.foreground; await notificationsProvider.cancel(completeInstallationNotification.id);
await notificationsProvider.cancel(completeInstallationNotification.id); }
} }
Future<bool> canDowngradeApps() async {
try {
await InstalledApps.getAppInfo('com.berdik.letmedowngrade');
return true;
} catch (e) {
return false;
} }
} }
@ -123,11 +200,62 @@ class AppsProvider with ChangeNotifier {
// 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
// But even then, we don't know if it actually succeeded // But even then, we don't know if it actually succeeded
Future<void> installApk(ApkFile file) async { Future<void> installApk(DownloadedApk file) async {
await AppInstaller.installApk(file.file.path, actionRequired: false); var newInfo = await PackageArchiveInfo.fromPath(file.file.path);
AppInfo? appInfo;
try {
appInfo = await InstalledApps.getAppInfo(apps[file.appId]!.app.id);
} catch (e) {
// OK
}
if (appInfo != null &&
int.parse(newInfo.buildNumber) < appInfo.versionCode! &&
!(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.installedVersion =
apps[file.appId]!.app.latestVersion; apps[file.appId]!.app.latestVersion;
await saveApps([apps[file.appId]!.app]); // Don't correct install status as installation may not be done yet
await saveApps([apps[file.appId]!.app],
attemptToCorrectInstallStatus: false);
}
Future<String?> confirmApkUrl(App app, BuildContext? context) async {
// If the App has more than one APK, the user should pick one (if context provided)
String? apkUrl = app.apkUrls[app.preferredApkIndex];
// get device supported architecture
List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
if (app.apkUrls.length > 1 && context != null) {
apkUrl = await showDialog(
context: context,
builder: (BuildContext ctx) {
return APKPicker(
app: app,
initVal: apkUrl,
archs: archs,
);
});
}
// If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
if (apkUrl != null &&
Uri.parse(apkUrl).origin != Uri.parse(app.url).origin &&
context != null) {
if (await showDialog(
context: context,
builder: (BuildContext ctx) {
return APKOriginWarningDialog(
sourceUrl: app.url, apkUrl: apkUrl!);
}) !=
true) {
apkUrl = null;
}
}
return apkUrl;
} }
// Given a list of AppIds, uses stored info about the apps to download APKs and install them // Given a list of AppIds, uses stored info about the apps to download APKs and install them
@ -135,36 +263,20 @@ class AppsProvider with ChangeNotifier {
// If no BuildContext is provided, apps that require user interaction are ignored // If no BuildContext is provided, apps that require user interaction are ignored
// If user input is needed and the App is in the background, a notification is sent to get the user's attention // If user input is needed and the App is in the background, a notification is sent to get the user's attention
// Returns an array of Ids for Apps that were successfully downloaded, regardless of installation result // Returns an array of Ids for Apps that were successfully downloaded, regardless of installation result
Future<List<String>> downloadAndInstallLatestApp( Future<List<String>> downloadAndInstallLatestApps(
List<String> appIds, BuildContext? context) async { List<String> appIds, BuildContext? context) async {
Map<String, String> appsToInstall = {}; List<String> appsToInstall = [];
List<String> trackOnlyAppsToUpdate = [];
// For all specified Apps, filter out those for which:
// 1. A URL cannot be picked
// 2. That cannot be installed silently (IF no buildContext was given for interactive install)
for (var id in appIds) { for (var id in appIds) {
if (apps[id] == null) { if (apps[id] == null) {
throw 'App not found'; throw ObtainiumError('App not found');
} }
String? apkUrl;
// If the App has more than one APK, the user should pick one (if context provided) if (!apps[id]!.app.trackOnly) {
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex]; apkUrl = await confirmApkUrl(apps[id]!.app, context);
if (apps[id]!.app.apkUrls.length > 1 && context != null) {
apkUrl = await showDialog(
context: context,
builder: (BuildContext ctx) {
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
});
}
// If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
if (apkUrl != null &&
Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin &&
context != null) {
if (await showDialog(
context: context,
builder: (BuildContext ctx) {
return APKOriginWarningDialog(
sourceUrl: apps[id]!.app.url, apkUrl: apkUrl!);
}) !=
true) {
apkUrl = null;
}
} }
if (apkUrl != null) { if (apkUrl != null) {
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
@ -172,21 +284,38 @@ class AppsProvider with ChangeNotifier {
apps[id]!.app.preferredApkIndex = urlInd; apps[id]!.app.preferredApkIndex = urlInd;
await saveApps([apps[id]!.app]); await saveApps([apps[id]!.app]);
} }
if (context != null || if (context != null || await canInstallSilently(apps[id]!.app)) {
(await canInstallSilently(apps[id]!.app) && appsToInstall.add(id);
apps[id]!.app.apkUrls.length == 1)) {
appsToInstall.putIfAbsent(id, () => apkUrl!);
} }
} }
if (apps[id]!.app.trackOnly) {
trackOnlyAppsToUpdate.add(id);
}
} }
// Mark all specified track-only apps as latest
List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries saveApps(trackOnlyAppsToUpdate.map((e) {
.map((entry) => downloadApp(entry.value, entry.key))); var a = apps[e]!.app;
a.installedVersion = a.latestVersion;
List<ApkFile> silentUpdates = []; return a;
List<ApkFile> regularInstalls = []; }).toList());
// Download APKs for all Apps to be installed
MultiAppMultiError errors = MultiAppMultiError();
List<DownloadedApk?> downloadedFiles =
await Future.wait(appsToInstall.map((id) async {
try {
return await downloadApp(apps[id]!.app);
} catch (e) {
errors.add(id, e.toString());
}
return null;
}));
downloadedFiles =
downloadedFiles.where((element) => element != null).toList();
// Separate the Apps to install into silent and regular lists
List<DownloadedApk> silentUpdates = [];
List<DownloadedApk> regularInstalls = [];
for (var f in downloadedFiles) { for (var f in downloadedFiles) {
bool willBeSilent = await canInstallSilently(apps[f.appId]!.app); bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app);
if (willBeSilent) { if (willBeSilent) {
silentUpdates.add(f); silentUpdates.add(f);
} else { } else {
@ -194,21 +323,54 @@ class AppsProvider with ChangeNotifier {
} }
} }
for (var u in silentUpdates) { // Move everything to the regular install list (since silent updates don't currently work) - TODO
await installApk(u); regularInstalls.addAll(silentUpdates);
// If Obtainium is being installed, it should be the last one
List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) {
DownloadedApk? temp;
items.removeWhere((element) {
bool res =
element.appId == obtainiumId || element.appId == obtainiumTempId;
if (res) {
temp = element;
}
return res;
});
if (temp != null) {
items = [temp!, ...items];
}
return items;
} }
if (context != null) { silentUpdates = moveObtainiumToStart(silentUpdates);
if (regularInstalls.isNotEmpty) { regularInstalls = moveObtainiumToStart(regularInstalls);
// ignore: use_build_context_synchronously
await askUserToReturnToForeground(context); // // Install silent updates (uncomment when it works - TODO)
} // for (var u in silentUpdates) {
// await installApk(u, silent: true); // Would need to add silent option
// }
// Do regular installs
if (regularInstalls.isNotEmpty && context != null) {
// ignore: use_build_context_synchronously
await waitForUserToReturnToForeground(context);
for (var i in regularInstalls) { for (var i in regularInstalls) {
await installApk(i); try {
await installApk(i);
} catch (e) {
errors.add(i.appId, e.toString());
}
} }
} }
return downloadedFiles.map((e) => e.appId).toList(); if (errors.content.isNotEmpty) {
throw errors;
}
NotificationsProvider().cancel(UpdateNotification([]).id);
return downloadedFiles.map((e) => e!.appId).toList();
} }
Future<Directory> getAppsDir() async { Future<Directory> getAppsDir() async {
@ -220,39 +382,122 @@ class AppsProvider with ChangeNotifier {
return appsDir; return appsDir;
} }
Future<void> deleteSavedAPKs() async { Future<AppInfo?> getInstalledInfo(String? packageName) async {
(await getExternalStorageDirectory()) if (packageName != null) {
?.listSync() try {
.where((element) => element.path.endsWith('.apk')) return await InstalledApps.getAppInfo(packageName);
.forEach((element) { } catch (e) {
element.deleteSync(); // OK
}); }
}
return null;
}
// If the App says it is installed but installedInfo is null, set it to not installed
// If the App says is is not installed but installedInfo exists, try to set it to installed as latest version...
// ...if the latestVersion seems to match the version in installedInfo (not guaranteed)
// If that fails, just set it to the actual version string (all we can do at that point)
// Don't save changes, just return the object if changes were made (else null)
// If in a background isolate, return null straight away as the required plugin won't work anyways
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
if (forBGTask) {
return null; // Can't correct in the background isolate
}
var modded = false;
if (installedInfo == null &&
app.installedVersion != null &&
!app.trackOnly) {
app.installedVersion = null;
modded = true;
}
if (installedInfo != null && app.installedVersion == null) {
if (app.latestVersion.characters
.where((p0) => [
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'.'
].contains(p0))
.join('') ==
installedInfo.versionName) {
app.installedVersion = app.latestVersion;
} else {
app.installedVersion = installedInfo.versionName;
}
modded = true;
}
return modded ? app : null;
} }
Future<void> loadApps() async { Future<void> loadApps() async {
while (loadingApps) {
await Future.delayed(const Duration(microseconds: 1));
}
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
for (int i = 0; i < appFiles.length; i++) { .map((e) => e.app.id)
App app = .toSet()
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync())); .difference(newApps.map((e) => e.id).toSet());
apps.putIfAbsent(app.id, () => AppInMemory(app, null)); for (var id in idsToDelete) {
apps.remove(id);
}
var sp = SourceProvider();
List<List<String>> errors = [];
for (int i = 0; i < newApps.length; i++) {
var info = await getInstalledInfo(newApps[i].id);
try {
sp.getSource(newApps[i].url);
apps[newApps[i].id] = AppInMemory(newApps[i], null, info);
} catch (e) {
errors.add([newApps[i].id, newApps[i].name, e.toString()]);
}
}
if (errors.isNotEmpty) {
removeApps(errors.map((e) => e[0]).toList());
NotificationsProvider().notify(
AppsRemovedNotification(errors.map((e) => [e[1], e[2]]).toList()));
} }
loadingApps = false; loadingApps = false;
notifyListeners(); notifyListeners();
List<App> modifiedApps = [];
for (var app in apps.values) {
var moddedApp =
getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo);
if (moddedApp != null) {
modifiedApps.add(moddedApp);
}
}
if (modifiedApps.isNotEmpty) {
await saveApps(modifiedApps);
}
} }
Future<void> saveApps(List<App> apps) async { Future<void> saveApps(List<App> apps,
{bool attemptToCorrectInstallStatus = true}) async {
for (var app in apps) { for (var app in apps) {
AppInfo? info = await getInstalledInfo(app.id);
app.name = info?.name ?? app.name;
if (attemptToCorrectInstallStatus) {
app = getCorrectedInstallStatusAppIfPossible(app, info) ?? app;
}
File('${(await getAppsDir()).path}/${app.id}.json') File('${(await getAppsDir()).path}/${app.id}.json')
.writeAsStringSync(jsonEncode(app.toJson())); .writeAsStringSync(jsonEncode(app.toJson()));
this.apps.update( this.apps.update(
app.id, (value) => AppInMemory(app, value.downloadProgress), app.id, (value) => AppInMemory(app, value.downloadProgress, info),
ifAbsent: () => AppInMemory(app, null)); ifAbsent: () => AppInMemory(app, null, info));
} }
notifyListeners(); notifyListeners();
} }
@ -272,60 +517,70 @@ class AppsProvider with ChangeNotifier {
} }
} }
bool checkAppObjectForUpdate(App app) { Future<App?> checkUpdate(String appId) async {
if (!apps.containsKey(app.id)) {
throw 'App not found';
}
return app.latestVersion != apps[app.id]?.app.installedVersion;
}
Future<App?> getUpdate(String appId) async {
App? currentApp = apps[appId]!.app; App? currentApp = apps[appId]!.app;
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
App newApp = await sourceProvider.getApp( App newApp = await sourceProvider.getApp(
sourceProvider.getSource(currentApp.url), sourceProvider.getSource(currentApp.url),
currentApp.url, currentApp.url,
currentApp.additionalData); currentApp.additionalData,
if (newApp.latestVersion != currentApp.latestVersion) { name: currentApp.name,
newApp.installedVersion = currentApp.installedVersion; id: currentApp.id,
if (currentApp.preferredApkIndex < newApp.apkUrls.length) { pinned: currentApp.pinned,
newApp.preferredApkIndex = currentApp.preferredApkIndex; trackOnly: currentApp.trackOnly,
} installedVersion: currentApp.installedVersion);
await saveApps([newApp]); if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
return newApp; newApp.preferredApkIndex = currentApp.preferredApkIndex;
} }
return null; await saveApps([newApp]);
return newApp.latestVersion != currentApp.latestVersion ? newApp : null;
} }
Future<List<App>> checkUpdates({DateTime? ignoreAfter}) async { Future<List<App>> checkUpdates(
{DateTime? ignoreAppsCheckedAfter,
bool throwErrorsForRetry = false}) async {
List<App> updates = []; List<App> updates = [];
MultiAppMultiError errors = MultiAppMultiError();
if (!gettingUpdates) { if (!gettingUpdates) {
gettingUpdates = true; gettingUpdates = true;
try {
List<String> appIds = apps.keys.toList(); List<String> appIds = apps.values
if (ignoreAfter != null) { .where((app) =>
appIds = appIds app.app.lastUpdateCheck == null ||
.where((id) => ignoreAppsCheckedAfter == null ||
apps[id]!.app.lastUpdateCheck != null && app.app.lastUpdateCheck!.isBefore(ignoreAppsCheckedAfter))
apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter)) .map((e) => e.app.id)
.toList(); .toList();
} appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ??
appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ?? DateTime.fromMicrosecondsSinceEpoch(0))
DateTime.fromMicrosecondsSinceEpoch(0)) .compareTo(apps[b]!.app.lastUpdateCheck ??
.compareTo(apps[b]!.app.lastUpdateCheck ?? DateTime.fromMicrosecondsSinceEpoch(0)));
DateTime.fromMicrosecondsSinceEpoch(0))); for (int i = 0; i < appIds.length; i++) {
for (int i = 0; i < appIds.length; i++) { App? newApp;
App? newApp = await getUpdate(appIds[i]); try {
if (newApp != null) { newApp = await checkUpdate(appIds[i]);
updates.add(newApp); } catch (e) {
if ((e is RateLimitError || e is SocketException) &&
throwErrorsForRetry) {
rethrow;
}
errors.add(appIds[i], e.toString());
}
if (newApp != null) {
updates.add(newApp);
}
} }
} finally {
gettingUpdates = false;
} }
gettingUpdates = false; }
if (errors.content.isNotEmpty) {
throw errors;
} }
return updates; return updates;
} }
List<String> getExistingUpdates( List<String> findExistingUpdates(
{bool installedOnly = false, bool nonInstalledOnly = false}) { {bool installedOnly = false, bool nonInstalledOnly = false}) {
List<String> updateAppIds = []; List<String> updateAppIds = [];
List<String> appIds = apps.keys.toList(); List<String> appIds = apps.keys.toList();
@ -359,31 +614,52 @@ class AppsProvider with ChangeNotifier {
} }
Future<int> importApps(String appsJSON) async { Future<int> importApps(String appsJSON) async {
// File picker does not work in android 13, so the user must paste the JSON directly into Obtainium to import Apps
List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>) List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
.map((e) => App.fromJson(e)) .map((e) => App.fromJson(e))
.toList(); .toList();
for (App a in importedApps) { while (loadingApps) {
a.installedVersion = await Future.delayed(const Duration(microseconds: 1));
apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null;
await saveApps([a]);
} }
for (App a in importedApps) {
if (apps[a.id]?.app.installedVersion != null) {
a.installedVersion = apps[a.id]?.app.installedVersion;
}
}
await saveApps(importedApps);
notifyListeners(); notifyListeners();
return importedApps.length; return importedApps.length;
} }
@override @override
void dispose() { void dispose() {
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 {
const APKPicker({super.key, required this.app, this.initVal}); const APKPicker({super.key, required this.app, this.initVal, this.archs});
final App app; final App app;
final String? initVal; final String? initVal;
final List<String>? archs;
@override @override
State<APKPicker> createState() => _APKPickerState(); State<APKPicker> createState() => _APKPickerState();
@ -401,15 +677,29 @@ class _APKPickerState extends State<APKPicker> {
content: Column(children: [ content: Column(children: [
Text('${widget.app.name} has more than one package:'), Text('${widget.app.name} has more than one package:'),
const SizedBox(height: 16), const SizedBox(height: 16),
...widget.app.apkUrls.map((u) => RadioListTile<String>( ...widget.app.apkUrls.map(
title: Text(Uri.parse(u).pathSegments.last), (u) => RadioListTile<String>(
value: u, title: Text(Uri.parse(u)
groupValue: apkUrl, .pathSegments
onChanged: (String? val) { .where((element) => element.isNotEmpty)
setState(() { .last),
apkUrl = val; value: u,
}); groupValue: apkUrl,
})) onChanged: (String? val) {
setState(() {
apkUrl = val;
});
}),
),
if (widget.archs != null)
const SizedBox(
height: 16,
),
if (widget.archs != null)
Text(
'Note:\nYour device supports the ${widget.archs!.length == 1 ? '\'${widget.archs![0]}\' CPU architecture.' : 'following CPU architectures: ${list2FriendlyString(widget.archs!.map((e) => '\'$e\'').toList())}.'}',
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
),
]), ]),
actions: [ actions: [
TextButton( TextButton(

View File

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

View File

@ -27,9 +27,11 @@ class UpdateNotification extends ObtainiumNotification {
'Updates Available', 'Updates Available',
'Notifies the user that updates are available for one or more Apps tracked by Obtainium', 'Notifies the user that updates are available for one or more Apps tracked by Obtainium',
Importance.max) { Importance.max) {
message = updates.length == 1 message = updates.isEmpty
? '${updates[0].name} has an update.' ? "No new updates."
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.'; : updates.length == 1
? '${updates[0].name} has an update.'
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.';
} }
} }
@ -61,6 +63,24 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
Importance.high); Importance.high);
} }
class AppsRemovedNotification extends ObtainiumNotification {
AppsRemovedNotification(List<List<String>> namedReasons)
: super(
6,
'Apps Removed',
'',
'APPS_REMOVED',
'Apps Removed',
'Notifies the user that one or more Apps were removed due to errors while loading them',
Importance.max) {
message = '';
for (var r in namedReasons) {
message += '${r[0]} was removed due to this error: ${r[1]}. \n';
}
message = message.trim();
}
}
final completeInstallationNotification = ObtainiumNotification( final completeInstallationNotification = ObtainiumNotification(
1, 1,
'Complete App Installation', 'Complete App Installation',

View File

@ -2,9 +2,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
String obtainiumTempId = 'imranr98_obtainium_${GitHub().host}';
String obtainiumId = 'dev.imranr.obtainium';
enum ThemeSettings { system, light, dark } enum ThemeSettings { system, light, dark }
enum ColourSettings { basic, materialYou } enum ColourSettings { basic, materialYou }
@ -55,7 +59,7 @@ class SettingsProvider with ChangeNotifier {
} }
int get updateInterval { int get updateInterval {
var min = prefs?.getInt('updateInterval') ?? 180; var min = prefs?.getInt('updateInterval') ?? 360;
if (!updateIntervals.contains(min)) { if (!updateIntervals.contains(min)) {
var temp = updateIntervals[0]; var temp = updateIntervals[0];
for (var i in updateIntervals) { for (var i in updateIntervals) {
@ -74,8 +78,8 @@ class SettingsProvider with ChangeNotifier {
} }
SortColumnSettings get sortColumn { SortColumnSettings get sortColumn {
return SortColumnSettings return SortColumnSettings.values[
.values[prefs?.getInt('sortColumn') ?? SortColumnSettings.added.index]; prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index];
} }
set sortColumn(SortColumnSettings s) { set sortColumn(SortColumnSettings s) {
@ -85,7 +89,7 @@ class SettingsProvider with ChangeNotifier {
SortOrderSettings get sortOrder { SortOrderSettings get sortOrder {
return SortOrderSettings.values[ return SortOrderSettings.values[
prefs?.getInt('sortOrder') ?? SortOrderSettings.descending.index]; prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index];
} }
set sortOrder(SortOrderSettings s) { set sortOrder(SortOrderSettings s) {
@ -123,11 +127,21 @@ class SettingsProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
bool get pinUpdates {
return prefs?.getBool('pinUpdates') ?? true;
}
set pinUpdates(bool show) {
prefs?.setBool('pinUpdates', show);
notifyListeners();
}
String? getSettingString(String settingId) { String? getSettingString(String settingId) {
return prefs?.getString(settingId); return prefs?.getString(settingId);
} }
void setSettingString(String settingId, String value) { void setSettingString(String settingId, String value) {
prefs?.setString(settingId, value); prefs?.setString(settingId, value);
notifyListeners();
} }
} }

View File

@ -4,6 +4,8 @@
import 'dart:convert'; import 'dart:convert';
import 'package:html/dom.dart'; import 'package:html/dom.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/apkmirror.dart';
import 'package:obtainium/app_sources/fdroid.dart'; import 'package:obtainium/app_sources/fdroid.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/app_sources/gitlab.dart'; import 'package:obtainium/app_sources/gitlab.dart';
@ -12,6 +14,7 @@ import 'package:obtainium/app_sources/mullvad.dart';
import 'package:obtainium/app_sources/signal.dart'; import 'package:obtainium/app_sources/signal.dart';
import 'package:obtainium/app_sources/sourceforge.dart'; import 'package:obtainium/app_sources/sourceforge.dart';
import 'package:obtainium/components/generated_form.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/mass_app_sources/githubstars.dart';
class AppNames { class AppNames {
@ -39,6 +42,8 @@ class App {
late int preferredApkIndex; late int preferredApkIndex;
late List<String> additionalData; late List<String> additionalData;
late DateTime? lastUpdateCheck; late DateTime? lastUpdateCheck;
bool pinned = false;
bool trackOnly = false;
App( App(
this.id, this.id,
this.url, this.url,
@ -49,11 +54,13 @@ class App {
this.apkUrls, this.apkUrls,
this.preferredApkIndex, this.preferredApkIndex,
this.additionalData, this.additionalData,
this.lastUpdateCheck); this.lastUpdateCheck,
this.pinned,
this.trackOnly);
@override @override
String toString() { String toString() {
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls'; return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned';
} }
factory App.fromJson(Map<String, dynamic> json) => App( factory App.fromJson(Map<String, dynamic> json) => App(
@ -70,11 +77,15 @@ class App {
: List<String>.from(jsonDecode(json['apkUrls'])), : List<String>.from(jsonDecode(json['apkUrls'])),
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
json['additionalData'] == null json['additionalData'] == null
? SourceProvider().getSource(json['url']).additionalDataDefaults ? SourceProvider()
.getSource(json['url'])
.additionalSourceAppSpecificDefaults
: List<String>.from(jsonDecode(json['additionalData'])), : List<String>.from(jsonDecode(json['additionalData'])),
json['lastUpdateCheck'] == null json['lastUpdateCheck'] == null
? null ? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck'])); : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false,
json['trackOnly'] ?? false);
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
@ -86,16 +97,13 @@ class App {
'apkUrls': jsonEncode(apkUrls), 'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex, 'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData), 'additionalData': jsonEncode(additionalData),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch 'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned,
'trackOnly': trackOnly
}; };
} }
escapeRegEx(String s) { // Ensure the input is starts with HTTPS and has no WWW
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return '\\${x[0]}';
});
}
preStandardizeUrl(String url) { preStandardizeUrl(String url) {
if (url.toLowerCase().indexOf('http://') != 0 && if (url.toLowerCase().indexOf('http://') != 0 &&
url.toLowerCase().indexOf('https://') != 0) { url.toLowerCase().indexOf('https://') != 0) {
@ -104,6 +112,11 @@ preStandardizeUrl(String url) {
if (url.toLowerCase().indexOf('https://www.') == 0) { if (url.toLowerCase().indexOf('https://www.') == 0) {
url = 'https://${url.substring(12)}'; url = 'https://${url.substring(12)}';
} }
url = url
.split('/')
.where((e) => e.isNotEmpty)
.join('/')
.replaceFirst(':/', '://');
return url; return url;
} }
@ -127,21 +140,66 @@ List<String> getLinksFromParsedHTML(
.map((e) => '$prependToLinks${e.attributes['href']!}') .map((e) => '$prependToLinks${e.attributes['href']!}')
.toList(); .toList();
abstract class AppSource { class AppSource {
late String host; late String host;
String standardizeURL(String url); bool enforceTrackOnly = false;
String standardizeURL(String url) {
throw NotImplementedError();
}
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData); String standardUrl, List<String> additionalData,
AppNames getAppNames(String standardUrl); {bool trackOnly = false}) {
late List<List<GeneratedFormItem>> additionalDataFormItems; throw NotImplementedError();
late List<String> additionalDataDefaults; }
late List<GeneratedFormItem> moreSourceSettingsFormItems;
AppNames getAppNames(String standardUrl) {
throw NotImplementedError();
}
// Different Sources may need different kinds of additional data for Apps
List<List<GeneratedFormItem>> additionalSourceAppSpecificFormItems = [];
List<String> additionalSourceAppSpecificDefaults = [];
// Some additional data may be needed for Apps regardless of Source
final List<GeneratedFormItem> additionalAppSpecificSourceAgnosticFormItems = [
GeneratedFormItem(
label: 'Track-Only',
type: FormItemType.bool,
key: 'trackOnlyFormItemKey')
];
final List<String> additionalAppSpecificSourceAgnosticDefaults = [''];
// Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider
List<GeneratedFormItem> additionalSourceSpecificSettingFormItems = [];
String? changeLogPageFromStandardUrl(String standardUrl) {
throw NotImplementedError();
}
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
return apkUrl;
}
bool canSearch = false;
Future<Map<String, String>> search(String query) {
throw NotImplementedError();
}
String? tryInferringAppId(String standardUrl) {
return null;
}
} }
abstract class MassAppSource { ObtainiumError getObtainiumHttpError(Response res) {
return ObtainiumError(
res.reasonPhrase ?? 'Error ${res.statusCode.toString()}');
}
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 {
@ -153,11 +211,12 @@ class SourceProvider {
IzzyOnDroid(), IzzyOnDroid(),
Mullvad(), Mullvad(),
Signal(), Signal(),
SourceForge() SourceForge(),
APKMirror()
]; ];
// Add more mass source classes here so they are available via the service // Add more mass url source classes here so they are available via the service
List<MassAppSource> massSources = [GitHubStars()]; List<MassAppUrlSource> massUrlSources = [GitHubStars()];
AppSource getSource(String url) { AppSource getSource(String url) {
url = preStandardizeUrl(url); url = preStandardizeUrl(url);
@ -169,13 +228,13 @@ class SourceProvider {
} }
} }
if (source == null) { if (source == null) {
throw 'URL does not match a known source'; throw UnsupportedURLError();
} }
return source; return source;
} }
bool doesSourceHaveRequiredAdditionalData(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;
@ -185,43 +244,69 @@ class SourceProvider {
return false; return false;
} }
String generateTempID(AppNames names, AppSource source) =>
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}';
bool isTempId(String id) {
List<String> parts = id.split('_');
if (parts.length < 3) {
return false;
}
for (int i = 0; i < parts.length - 1; i++) {
if (RegExp('.*[A-Z].*').hasMatch(parts[i])) {
return false;
}
}
return sources.map((e) => e.host).contains(parts.last);
}
Future<App> getApp(AppSource source, String url, List<String> additionalData, Future<App> getApp(AppSource source, String url, List<String> additionalData,
{String customName = ''}) 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
await source.getLatestAPKDetails(standardUrl, additionalData); .getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly);
if (apk.apkUrls.isEmpty && !trackOnly) {
throw NoAPKError();
}
String apkVersion = apk.version.replaceAll('/', '-');
return App( return App(
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}', 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),
customName.trim().isNotEmpty name.trim().isNotEmpty
? customName ? name
: names.name[0].toUpperCase() + names.name.substring(1), : names.name[0].toUpperCase() + names.name.substring(1),
null, installedVersion,
apk.version, apkVersion,
apk.apkUrls, apk.apkUrls,
apk.apkUrls.length - 1, apk.apkUrls.length - 1,
additionalData, additionalData,
DateTime.now()); DateTime.now(),
pinned,
trackOnly);
} }
/// Returns a length 2 list, where the first element is a list of Apps and // Returns errors in [results, errors] instead of throwing them
/// the second is a Map<String, dynamic> of URLs and errors Future<List<dynamic>> getAppsByURLNaive(List<String> urls,
Future<List<dynamic>> getApps(List<String> urls,
{List<String> ignoreUrls = const []}) async { {List<String> ignoreUrls = const []}) async {
List<App> apps = []; List<App> apps = [];
Map<String, dynamic> errors = {}; Map<String, dynamic> errors = {};
for (var url in urls.where((element) => !ignoreUrls.contains(element))) { for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
try { try {
var source = getSource(url); var source = getSource(url);
apps.add(await getApp(source, url, source.additionalDataDefaults)); apps.add(await getApp(
source, url, source.additionalSourceAppSpecificDefaults));
} catch (e) { } catch (e) {
errors.addAll(<String, dynamic>{url: e}); errors.addAll(<String, dynamic>{url: e});
} }
} }
return [apps, errors]; return [apps, errors];
} }
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
} }

View File

@ -1,20 +1,27 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
android_alarm_manager_plus:
dependency: "direct main"
description:
name: android_alarm_manager_plus
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
animations: animations:
dependency: "direct main" dependency: "direct main"
description: description:
name: animations name: animations
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.5" version: "2.0.7"
archive: archive:
dependency: transitive dependency: transitive
description: description:
name: archive name: archive
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.3.1" version: "3.3.5"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -71,6 +78,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.16.0" version: "1.16.0"
convert:
dependency: transitive
description:
name: convert
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
cross_file:
dependency: transitive
description:
name: cross_file
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.3+2"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@ -105,42 +126,14 @@ packages:
name: device_info_plus name: device_info_plus
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.1.2" version: "8.0.0"
device_info_plus_linux:
dependency: transitive
description:
name: device_info_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
device_info_plus_macos:
dependency: transitive
description:
name: device_info_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
device_info_plus_platform_interface: device_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: device_info_plus_platform_interface name: device_info_plus_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.0" version: "7.0.0"
device_info_plus_web:
dependency: transitive
description:
name: device_info_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
device_info_plus_windows:
dependency: transitive
description:
name: device_info_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.0"
dynamic_color: dynamic_color:
dependency: "direct main" dependency: "direct main"
description: description:
@ -175,7 +168,7 @@ packages:
name: file_picker name: file_picker
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.2.0+1" version: "5.2.2"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -187,21 +180,14 @@ packages:
name: flutter_fgbg name: flutter_fgbg
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.0" version: "0.2.1"
flutter_install_app:
dependency: "direct main"
description:
name: flutter_install_app
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
flutter_launcher_icons: flutter_launcher_icons:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_launcher_icons name: flutter_launcher_icons
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.10.0" version: "0.11.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -215,14 +201,14 @@ packages:
name: flutter_local_notifications name: flutter_local_notifications
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "11.0.1" version: "12.0.4"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_linux name: flutter_local_notifications_linux
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.0" version: "2.0.0"
flutter_local_notifications_platform_interface: flutter_local_notifications_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -253,14 +239,14 @@ packages:
name: fluttertoast name: fluttertoast
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "8.0.9" version: "8.1.1"
html: html:
dependency: "direct main" dependency: "direct main"
description: description:
name: html name: html
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.15.0" version: "0.15.1"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -274,14 +260,28 @@ packages:
name: http_parser name: http_parser
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.1" version: "4.0.2"
image: image:
dependency: transitive dependency: transitive
description: description:
name: image name: image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.0" version: "3.2.2"
install_plugin_v2:
dependency: "direct main"
description:
name: install_plugin_v2
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
installed_apps:
dependency: "direct main"
description:
name: installed_apps
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
js: js:
dependency: transitive dependency: transitive
description: description:
@ -302,7 +302,7 @@ packages:
name: lints name: lints
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.0" version: "2.0.1"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -316,7 +316,7 @@ packages:
name: material_color_utilities name: material_color_utilities
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.0" version: "0.1.5"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@ -338,6 +338,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
package_archive_info:
dependency: "direct main"
description:
name: package_archive_info
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.0"
package_info:
dependency: transitive
description:
name: package_info
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -358,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.20" version: "2.0.22"
path_provider_ios: path_provider_ios:
dependency: transitive dependency: transitive
description: description:
@ -400,42 +414,42 @@ packages:
name: permission_handler name: permission_handler
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "10.0.2" version: "10.2.0"
permission_handler_android: permission_handler_android:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_android name: permission_handler_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "10.0.0" version: "10.2.0"
permission_handler_apple: permission_handler_apple:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_apple name: permission_handler_apple
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "9.0.4" version: "9.0.7"
permission_handler_platform_interface: permission_handler_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_platform_interface name: permission_handler_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.8.0" version: "3.9.0"
permission_handler_windows: permission_handler_windows:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_windows name: permission_handler_windows
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.1.0" version: "0.1.2"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
name: petitparser name: petitparser
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.0.0" version: "5.1.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -450,6 +464,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
pointycastle:
dependency: transitive
description:
name: pointycastle
url: "https://pub.dartlang.org"
source: hosted
version: "3.6.2"
process: process:
dependency: transitive dependency: transitive
description: description:
@ -463,49 +484,21 @@ packages:
name: provider name: provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.3" version: "6.0.4"
share_plus: share_plus:
dependency: "direct main" dependency: "direct main"
description: description:
name: share_plus name: share_plus
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.4.0" version: "6.3.0"
share_plus_linux:
dependency: transitive
description:
name: share_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
share_plus_macos:
dependency: transitive
description:
name: share_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
share_plus_platform_interface: share_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: share_plus_platform_interface name: share_plus_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.3" version: "3.2.0"
share_plus_web:
dependency: transitive
description:
name: share_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
share_plus_windows:
dependency: transitive
description:
name: share_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@ -519,7 +512,7 @@ packages:
name: shared_preferences_android name: shared_preferences_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.13" version: "2.0.14"
shared_preferences_ios: shared_preferences_ios:
dependency: transitive dependency: transitive
description: description:
@ -573,7 +566,21 @@ packages:
name: source_span name: source_span
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.9.1" 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:
@ -587,7 +594,7 @@ packages:
name: stream_channel name: stream_channel
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.1" version: "2.1.0"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
@ -595,6 +602,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
synchronized:
dependency: transitive
description:
name: synchronized
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0+3"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@ -608,7 +622,7 @@ packages:
name: test_api name: test_api
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.14" version: "0.4.12"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
@ -629,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.19" version: "6.0.22"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
@ -679,13 +693,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.1" version: "3.0.1"
uuid:
dependency: transitive
description:
name: uuid
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.7"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.4" version: "2.1.2"
webview_flutter: webview_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@ -699,7 +720,7 @@ packages:
name: webview_flutter_android name: webview_flutter_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.10.3" version: "2.10.4"
webview_flutter_platform_interface: webview_flutter_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -720,14 +741,7 @@ packages:
name: win32 name: win32
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.0" version: "3.1.2"
workmanager:
dependency: "direct main"
description:
name: workmanager
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@ -750,5 +764,5 @@ packages:
source: hosted source: hosted
version: "3.1.1" version: "3.1.1"
sdks: sdks:
dart: ">=2.19.0-79.0.dev <3.0.0" dart: ">=2.18.2 <3.0.0"
flutter: ">=3.3.0" flutter: ">=3.3.0"

View File

@ -17,10 +17,10 @@ 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.5.0+21 # When changing this, update the tag in main() accordingly version: 0.8.1+64 # When changing this, update the tag in main() accordingly
environment: environment:
sdk: '>=2.19.0-79.0.dev <3.0.0' sdk: '>=2.18.2 <3.0.0'
# Dependencies specify other packages that your package needs in order to work. # Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions # To automatically upgrade your package dependencies to the latest versions
@ -38,28 +38,31 @@ dependencies:
cupertino_icons: ^1.0.5 cupertino_icons: ^1.0.5
path_provider: ^2.0.11 path_provider: ^2.0.11
flutter_fgbg: ^0.2.0 # Try removing reliance on this flutter_fgbg: ^0.2.0 # Try removing reliance on this
flutter_local_notifications: ^11.0.1 flutter_local_notifications: ^12.0.0
provider: ^6.0.3 provider: ^6.0.3
http: ^0.13.5 http: ^0.13.5
webview_flutter: ^3.0.4 webview_flutter: ^3.0.4
workmanager: ^0.5.0
dynamic_color: ^1.5.4 dynamic_color: ^1.5.4
html: ^0.15.0 html: ^0.15.0
shared_preferences: ^2.0.15 shared_preferences: ^2.0.15
url_launcher: ^6.1.5 url_launcher: ^6.1.5
permission_handler: ^10.0.0 permission_handler: ^10.0.0
fluttertoast: ^8.0.9 fluttertoast: ^8.0.9
device_info_plus: ^4.1.2 device_info_plus: ^8.0.0
file_picker: ^5.1.0 file_picker: ^5.1.0
animations: ^2.0.4 animations: ^2.0.4
flutter_install_app: ^1.3.0 install_plugin_v2: ^1.0.0
share_plus: ^4.4.0 share_plus: ^6.0.1
installed_apps: ^1.3.1
package_archive_info: ^0.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

View File

@ -13,7 +13,7 @@ import 'package:obtainium/main.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame. // Build our app and trigger a frame.
await tester.pumpWidget(const MyApp()); await tester.pumpWidget(const Obtainium());
// Verify that our counter starts at 0. // Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget); expect(find.text('0'), findsOneWidget);