diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5014f48..ccee49f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -30,15 +30,6 @@ - - - diff --git a/lib/pages/app.dart b/lib/pages/app.dart index 0586257..795b34a 100644 --- a/lib/pages/app.dart +++ b/lib/pages/app.dart @@ -17,16 +17,16 @@ class _AppPageState extends State { @override Widget build(BuildContext context) { var appsProvider = context.watch(); - App? app = appsProvider.apps[widget.appId]; - if (app?.installedVersion != null) { - appsProvider.getUpdate(app!.id); + AppInMemory? app = appsProvider.apps[widget.appId]; + if (app?.app.installedVersion != null) { + appsProvider.getUpdate(app!.app.id); } return Scaffold( appBar: AppBar( - title: Text('${app?.author}/${app?.name}'), + title: Text('${app!.app.author}/${app.app.name}'), ), body: WebView( - initialUrl: app?.url, + initialUrl: app.app.url, ), bottomSheet: Column( mainAxisSize: MainAxisSize.min, @@ -39,21 +39,21 @@ class _AppPageState extends State { children: [ Expanded( child: ElevatedButton( - onPressed: (app?.installedVersion == null || - appsProvider - .checkAppObjectForUpdate(app!)) && - app?.currentDownloadId == null + onPressed: (app.app.installedVersion == null || + appsProvider.checkAppObjectForUpdate( + app.app)) && + app.downloadProgress == null ? () { - appsProvider - .backgroundDownloadAndInstallApp(app!); + appsProvider.downloadAndInstallLatestApp( + app.app.id); } : null, - child: Text(app?.installedVersion == null + child: Text(app.app.installedVersion == null ? 'Install' : 'Update'))), const SizedBox(width: 16.0), ElevatedButton( - onPressed: app?.currentDownloadId != null + onPressed: app.downloadProgress != null ? null : () { showDialog( @@ -62,12 +62,12 @@ class _AppPageState extends State { return AlertDialog( title: const Text('Remove App?'), content: Text( - 'This will remove \'${app?.name}\' from Obtainium.${app?.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'), + '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.' : ''}'), actions: [ TextButton( onPressed: () { appsProvider - .removeApp(app!.id) + .removeApp(app.app.id) .then((_) { int count = 0; Navigator.of(context).popUntil( @@ -90,7 +90,8 @@ class _AppPageState extends State { child: const Text('Remove'), ), ])), - if (app?.currentDownloadId != null) const LinearProgressIndicator() + if (app.downloadProgress != null) + LinearProgressIndicator(value: app.downloadProgress) ], ), ); diff --git a/lib/pages/apps.dart b/lib/pages/apps.dart index af6eee1..2884ed6 100644 --- a/lib/pages/apps.dart +++ b/lib/pages/apps.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:obtainium/pages/add_app.dart'; import 'package:obtainium/pages/app.dart'; import 'package:obtainium/services/apps_provider.dart'; import 'package:provider/provider.dart'; @@ -31,18 +30,23 @@ class _AppsPageState extends State { children: appsProvider.apps.values .map( (e) => ListTile( - title: Text('${e.author}/${e.name}'), + title: Text('${e.app.author}/${e.app.name}'), subtitle: - Text(e.installedVersion ?? 'Not Installed'), - trailing: e.installedVersion != null && - e.installedVersion != e.latestVersion - ? const Text('Update Available') - : null, + Text(e.app.installedVersion ?? 'Not Installed'), + trailing: e.downloadProgress != null + ? Text( + 'Downloading - ${e.downloadProgress!.toInt()}%') + : (e.app.installedVersion != null && + e.app.installedVersion != + e.app.latestVersion + ? const Text('Update Available') + : null), onTap: () { Navigator.push( context, MaterialPageRoute( - builder: (context) => AppPage(appId: e.id)), + builder: (context) => + AppPage(appId: e.app.id)), ); }, ), diff --git a/lib/services/apps_provider.dart b/lib/services/apps_provider.dart index c8359f3..02c3eeb 100644 --- a/lib/services/apps_provider.dart +++ b/lib/services/apps_provider.dart @@ -3,64 +3,45 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:isolate'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:flutter_fgbg/flutter_fgbg.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:obtainium/services/source_service.dart'; +import 'package:http/http.dart'; +import 'package:install_plugin_v2/install_plugin_v2.dart'; + +class AppInMemory { + late App app; + double? downloadProgress; + + AppInMemory(this.app, this.downloadProgress); +} class AppsProvider with ChangeNotifier { // In memory App state (should always be kept in sync with local storage versions) - Map apps = {}; + Map apps = {}; bool loadingApps = false; bool gettingUpdates = false; - AppsProvider({bool bg = false}) { - initializeNotifs(); - loadApps().then((_) { - clearDownloadStates(); - }); - if (!bg) { - initializeDownloader(); - } - } - // Notifications plugin for downloads FlutterLocalNotificationsPlugin downloaderNotifications = FlutterLocalNotificationsPlugin(); - // Port for FlutterDownloader background/foreground communication - final ReceivePort _port = ReceivePort(); - // Variables to keep track of the app foreground status (installs can't run in the background) bool isForeground = true; StreamSubscription? foregroundSubscription; - // Setup the FlutterDownloader plugin (call only once) - Future initializeDownloader() async { - // Make sure FlutterDownloader can be used - await FlutterDownloader.initialize(); - // Set up the status update callback for FlutterDownloader - FlutterDownloader.registerCallback(downloadCallbackBackground); - // The actual callback is in the background isolate - // So setup a port to pass the data to a foreground callback - IsolateNameServer.registerPortWithName( - _port.sendPort, 'downloader_send_port'); - _port.listen((dynamic data) { - String id = data[0]; - DownloadTaskStatus status = data[1]; - int progress = data[2]; - downloadCallbackForeground(id, status, progress); - }); + AppsProvider({bool bg = false}) { + initializeNotifs(); // Subscribe to changes in the app foreground status foregroundSubscription = FGBGEvents.stream.listen((event) async { isForeground = event == FGBGType.foreground; if (isForeground) await loadApps(); }); + loadApps(); } Future initializeNotifs() async { @@ -69,15 +50,6 @@ class AppsProvider with ChangeNotifier { android: AndroidInitializationSettings('ic_notification'))); } - // Callback that receives FlutterDownloader status and forwards to a foreground function - @pragma('vm:entry-point') - static void downloadCallbackBackground( - String id, DownloadTaskStatus status, int progress) { - final SendPort? send = - IsolateNameServer.lookupPortByName('downloader_send_port'); - send!.send([id, status, progress]); - } - Future notify(int id, String title, String message, String channelCode, String channelName, String channelDescription) { return downloaderNotifications.show( @@ -92,63 +64,44 @@ class AppsProvider with ChangeNotifier { groupKey: 'dev.imranr.obtainium.$channelCode'))); } - // Foreground function to act on FlutterDownloader status updates (install downloaded APK) - void downloadCallbackForeground( - String id, DownloadTaskStatus status, int progress) async { - if (status == DownloadTaskStatus.complete) { - // Wait for app to come to the foreground if not already, and notify the user - while (!isForeground) { - await notify( - 1, - 'Complete App Installation', - 'Obtainium must be open to install Apps', - 'COMPLETE_INSTALL', - 'Complete App Installation', - 'Asks the user to return to Obtanium to finish installing an App'); - if (await FGBGEvents.stream.first == FGBGType.foreground) { - break; - } - } - // Install the App (and remove warning notification if any) - FlutterDownloader.open(taskId: id); - downloaderNotifications.cancel(1); - } - // Change App status based on result (we assume user accepts install - no way to tell programatically) - if (status == DownloadTaskStatus.complete || - status == DownloadTaskStatus.failed || - status == DownloadTaskStatus.canceled) { - App? foundApp; - apps.forEach((appId, app) { - if (app.currentDownloadId == id) { - foundApp = apps[appId]; - } - }); - foundApp!.currentDownloadId = null; - if (status == DownloadTaskStatus.complete) { - foundApp!.installedVersion = foundApp!.latestVersion; - } - saveApp(foundApp!); - } - } - // Given a App (assumed valid), initiate an APK download (will trigger install callback when complete) - Future backgroundDownloadAndInstallApp(App app) async { - Directory apkDir = Directory( - '${(await getExternalStorageDirectory())?.path as String}/apks/${app.id}'); - if (apkDir.existsSync()) apkDir.deleteSync(recursive: true); - apkDir.createSync(recursive: true); - String? downloadId = await FlutterDownloader.enqueue( - url: app.apkUrl, - savedDir: apkDir.path, - showNotification: true, - openFileFromNotification: false, - ); - if (downloadId != null) { - app.currentDownloadId = downloadId; - saveApp(app); - } else { - throw "Could not start download"; + Future downloadAndInstallLatestApp(String appId) async { + if (apps[appId] == null) { + throw 'App not found'; } + StreamedResponse response = + await Client().send(Request('GET', Uri.parse(apps[appId]!.app.apkUrl))); + File downloadFile = + File('${(await getTemporaryDirectory()).path}/$appId.apk'); + var length = response.contentLength; + var received = 0; + var sink = downloadFile.openWrite(); + + await response.stream.map((s) { + received += s.length; + apps[appId]!.downloadProgress = + (length != null ? received / length * 100 : 30); + notifyListeners(); + return s; + }).pipe(sink); + + await sink.close(); + apps[appId]!.downloadProgress = null; + notifyListeners(); + + if (response.statusCode != 200) { + downloadFile.deleteSync(); + throw response.reasonPhrase ?? 'Unknown Error'; + } + + var res = await InstallPlugin.installApk( + downloadFile.path, 'dev.imranr.obtainium'); + print(res); + + apps[appId]!.app.installedVersion = apps[appId]!.app.latestVersion; + saveApp(apps[appId]!.app); + + downloadFile.deleteSync(); } Future getAppsDir() async { @@ -171,7 +124,7 @@ class AppsProvider with ChangeNotifier { for (int i = 0; i < appFiles.length; i++) { App app = App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync())); - apps.putIfAbsent(app.id, () => app); + apps.putIfAbsent(app.id, () => AppInMemory(app, null)); } loadingApps = false; notifyListeners(); @@ -180,25 +133,11 @@ class AppsProvider with ChangeNotifier { Future saveApp(App app) async { File('${(await getAppsDir()).path}/${app.id}.json') .writeAsStringSync(jsonEncode(app.toJson())); - apps.update(app.id, (value) => app, ifAbsent: () => app); + apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress), + ifAbsent: () => AppInMemory(app, null)); notifyListeners(); } - Future clearDownloadStates() async { - var appList = apps.values.toList(); - int count = 0; - for (int i = 0; i < appList.length; i++) { - if (appList[i].currentDownloadId != null) { - apps[appList[i].id]?.currentDownloadId = null; - await saveApp(apps[appList[i].id]!); - count++; - } - } - if (count > 0) { - notifyListeners(); - } - } - Future removeApp(String appId) async { File file = File('${(await getAppsDir()).path}/$appId.json'); if (file.existsSync()) { @@ -214,11 +153,11 @@ class AppsProvider with ChangeNotifier { if (!apps.containsKey(app.id)) { throw 'App not found'; } - return app.latestVersion != apps[app.id]?.installedVersion; + return app.latestVersion != apps[app.id]?.app.installedVersion; } Future getUpdate(String appId) async { - App? currentApp = apps[appId]; + App? currentApp = apps[appId]!.app; App newApp = await SourceService().getApp(currentApp!.url); if (newApp.latestVersion != currentApp.latestVersion) { newApp.installedVersion = currentApp.installedVersion; @@ -248,9 +187,9 @@ class AppsProvider with ChangeNotifier { Future installUpdates() async { List appIds = apps.keys.toList(); for (int i = 0; i < appIds.length; i++) { - App? app = apps[appIds[i]]; + App? app = apps[appIds[i]]!.app; if (app!.installedVersion != app.latestVersion) { - await backgroundDownloadAndInstallApp(app); + await downloadAndInstallLatestApp(app.id); } } } diff --git a/lib/services/source_service.dart b/lib/services/source_service.dart index f32336c..7c1dce2 100644 --- a/lib/services/source_service.dart +++ b/lib/services/source_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:http/http.dart'; +import 'package:html/parser.dart'; // Sub-classes used in App Source @@ -28,6 +29,12 @@ abstract class AppSource { AppNames getAppNames(String standardUrl); } +escapeRegEx(String s) { + return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { + return "\\${x[0]}"; + }); +} + // App class class App { @@ -38,9 +45,8 @@ class App { String? installedVersion; late String latestVersion; late String apkUrl; - String? currentDownloadId; App(this.id, this.url, this.author, this.name, this.installedVersion, - this.latestVersion, this.apkUrl, this.currentDownloadId); + this.latestVersion, this.apkUrl); @override String toString() { @@ -56,10 +62,7 @@ class App { ? null : json['installedVersion'] as String, json['latestVersion'] as String, - json['apkUrl'] as String, - json['currentDownloadId'] == null - ? null - : json['currentDownloadId'] as String); + json['apkUrl'] as String); Map toJson() => { 'id': id, @@ -69,7 +72,6 @@ class App { 'installedVersion': installedVersion, 'latestVersion': latestVersion, 'apkUrl': apkUrl, - 'currentDownloadId': currentDownloadId }; } @@ -94,20 +96,22 @@ class GitHub implements AppSource { @override Future getLatestAPKUrl(String standardUrl) async { - Response res = await get(Uri.parse( - '${convertURL(standardUrl, 'api.github.com/repos')}/releases/latest')); + Response res = await get(Uri.parse('$standardUrl/releases/latest')); if (res.statusCode == 200) { - dynamic release = jsonDecode(res.body); - for (int i = 0; i < release['assets'].length; i++) { - if (release['assets'][i]['name'] - .toString() - .toLowerCase() - .endsWith('.apk')) { - return APKDetails(release['tag_name'], - release['assets'][i]['browser_download_url']); - } + var standardUri = Uri.parse(standardUrl); + var parsedHtml = parse(res.body); + var apkUrlList = parsedHtml.querySelectorAll('a').where((element) { + return RegExp( + '^${escapeRegEx(standardUri.path)}/releases/download/*/(?!/).*.apk\$', + caseSensitive: false) + .hasMatch(element.attributes['href']!); + }).toList(); + String? version = parsedHtml.querySelector('h1')?.innerHtml; + if (apkUrlList.isEmpty || version == null) { + throw 'No APK found'; } - throw 'No APK found'; + return APKDetails( + version, '${standardUri.origin}${apkUrlList[0].attributes['href']!}'); } else { throw 'Unable to fetch release info'; } @@ -147,7 +151,6 @@ class SourceService { names.name[0].toUpperCase() + names.name.substring(1), null, apk.version, - apk.downloadUrl, - null); + apk.downloadUrl); } } diff --git a/pubspec.lock b/pubspec.lock index 5faf770..83ed880 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -71,6 +71,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.2" + csslib: + dependency: transitive + description: + name: csslib + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.2" cupertino_icons: dependency: "direct main" description: @@ -118,13 +125,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_downloader: - dependency: "direct main" - description: - name: flutter_downloader - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.1" flutter_fgbg: dependency: "direct main" description: @@ -172,11 +172,13 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" + html: + dependency: "direct main" + description: + name: html + url: "https://pub.dartlang.org" + source: hosted + version: "0.15.0" http: dependency: "direct main" description: @@ -198,13 +200,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.2.0" - js: - dependency: transitive + install_plugin_v2: + dependency: "direct main" description: - name: js + name: install_plugin_v2 url: "https://pub.dartlang.org" source: hosted - version: "0.6.4" + version: "1.0.0" json_annotation: dependency: transitive description: @@ -392,13 +394,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.8.0" - toast: - dependency: "direct main" - description: - name: toast - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.0" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 06f9557..3b283f7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,15 +37,15 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 path_provider: ^2.0.11 - flutter_downloader: ^1.8.1 flutter_fgbg: ^0.2.0 flutter_local_notifications: ^9.7.0 provider: ^6.0.3 http: ^0.13.5 - toast: ^0.3.0 webview_flutter: ^3.0.4 workmanager: ^0.5.0 dynamic_color: ^1.5.3 + install_plugin_v2: ^1.0.0 + html: ^0.15.0 dev_dependencies: