diff --git a/lib/app_sources/apkpure.dart b/lib/app_sources/apkpure.dart index 9f75f7d..1ab017d 100644 --- a/lib/app_sources/apkpure.dart +++ b/lib/app_sources/apkpure.dart @@ -57,9 +57,9 @@ class APKPure extends AppSource { } catch (err) { // ignore } - + String type = html.querySelector('a.info-tag')?.text.trim() ?? 'APK'; List> apkUrls = [ - MapEntry('$appId.apk', 'https://d.$host/b/APK/$appId?version=latest') + MapEntry('$appId.apk', 'https://d.$host/b/$type/$appId?version=latest') ]; String author = html .querySelector('span.info-sdk') diff --git a/lib/pages/add_app.dart b/lib/pages/add_app.dart index ee0b2e4..bdb18e4 100644 --- a/lib/pages/add_app.dart +++ b/lib/pages/add_app.dart @@ -159,9 +159,16 @@ class _AddAppPageState extends State { app.preferredApkIndex = app.apkUrls.map((e) => e.value).toList().indexOf(apkUrl.value); // ignore: use_build_context_synchronously - var downloadedApk = await appsProvider.downloadApp( + var downloadedArtifact = await appsProvider.downloadApp( app, globalNavigatorKey.currentContext); - app.id = downloadedApk.appId; + DownloadedApk? downloadedFile; + DownloadedXApkDir? downloadedDir; + if (downloadedArtifact is DownloadedApk) { + downloadedFile = downloadedArtifact; + } else { + downloadedDir = downloadedArtifact as DownloadedXApkDir; + } + app.id = downloadedFile?.appId ?? downloadedDir!.appId; } if (appsProvider.apps.containsKey(app.id)) { throw ObtainiumError(tr('appAlreadyAdded')); diff --git a/lib/providers/apps_provider.dart b/lib/providers/apps_provider.dart index e4f611c..9691428 100644 --- a/lib/providers/apps_provider.dart +++ b/lib/providers/apps_provider.dart @@ -27,6 +27,7 @@ import 'package:flutter_fgbg/flutter_fgbg.dart'; import 'package:obtainium/providers/source_provider.dart'; import 'package:http/http.dart'; import 'package:android_intent_plus/android_intent.dart'; +import 'package:archive/archive.dart'; class AppInMemory { late App app; @@ -46,6 +47,13 @@ class DownloadedApk { DownloadedApk(this.appId, this.file); } +class DownloadedXApkDir { + String appId; + File file; + Directory extracted; + DownloadedXApkDir(this.appId, this.file, this.extracted); +} + List generateStandardVersionRegExStrings() { // TODO: Look into RegEx for non-Latin characters / non-Arabic numerals var basics = [ @@ -164,7 +172,27 @@ class AppsProvider with ChangeNotifier { return downloadedFile; } - Future downloadApp(App app, BuildContext? context) async { + handleAPKIDChange(App app, PackageArchiveInfo newInfo, File downloadedFile, + String downloadUrl) async { + // 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 + if (app.id != newInfo.packageName) { + var isTempId = SourceProvider().isTempId(app); + if (apps[app.id] != null && !isTempId) { + throw IDChangedError(); + } + var originalAppId = app.id; + app.id = newInfo.packageName; + downloadedFile = downloadedFile.renameSync( + '${downloadedFile.parent.path}/${app.id}-${downloadUrl.hashCode}.apk'); + if (apps[originalAppId] != null) { + await removeApps([originalAppId]); + await saveApps([app], onlyIfExists: !isTempId); + } + } + } + + Future downloadApp(App app, BuildContext? context) async { NotificationsProvider? notificationsProvider = context?.read(); var notifId = DownloadNotification(app.finalName, 0).id; @@ -194,33 +222,42 @@ class AppsProvider with ChangeNotifier { } prevProg = prog; }); - // 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) { - var isTempId = SourceProvider().isTempId(app); - if (apps[app.id] != null && !isTempId) { - throw IDChangedError(); - } - var originalAppId = app.id; - app.id = newInfo.packageName; - downloadedFile = downloadedFile.renameSync( - '${downloadedFile.parent.path}/${app.id}-${downloadUrl.hashCode}.apk'); - if (apps[originalAppId] != null) { - await removeApps([originalAppId]); - await saveApps([app], onlyIfExists: !isTempId); - } + PackageArchiveInfo? newInfo; + try { + newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path); + } catch (e) { + // Assume it's an XAPK + fileName = '${app.id}-${downloadUrl.hashCode}.xapk'; + String newPath = '${downloadedFile.parent.path}/$fileName'; + downloadedFile.renameSync(newPath); + downloadedFile = File(newPath); } - // Delete older versions of the APK if any + Directory? xapkDir; + if (newInfo == null) { + String xapkDirPath = '${downloadedFile.path}-dir'; + unzipFile(downloadedFile.path, '${downloadedFile.path}-dir'); + xapkDir = Directory(xapkDirPath); + var apks = xapkDir + .listSync() + .where((e) => e.path.toLowerCase().endsWith('.apk')) + .toList(); + newInfo = await PackageArchiveInfo.fromPath(apks.first.path); + } + await handleAPKIDChange(app, newInfo, downloadedFile, downloadUrl); + // Delete older versions of the file if any for (var file in downloadedFile.parent.listSync()) { var fn = file.path.split('/').last; if (fn.startsWith('${app.id}-') && - fn.endsWith('.apk') && + fn.toLowerCase().endsWith(xapkDir == null ? '.apk' : '.xapk') && fn != downloadedFile.path.split('/').last) { file.delete(); } } - return DownloadedApk(app.id, downloadedFile); + if (xapkDir != null) { + return DownloadedXApkDir(app.id, downloadedFile, xapkDir); + } else { + return DownloadedApk(app.id, downloadedFile); + } } finally { notificationsProvider?.cancel(notifId); if (apps[app.id] != null) { @@ -267,10 +304,37 @@ class AppsProvider with ChangeNotifier { } } - // Unfortunately this 'await' does not actually wait for the APK to finish installing - // So we only know that the install prompt was shown, but the user could still cancel w/o us knowing - // 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 + void unzipFile(String filePath, String destinationPath) { + final bytes = File(filePath).readAsBytesSync(); + final archive = ZipDecoder().decodeBytes(bytes); + + for (final file in archive) { + final filename = '$destinationPath/${file.name}'; + if (file.isFile) { + final data = file.content as List; + File(filename) + ..createSync(recursive: true) + ..writeAsBytesSync(data); + } else { + Directory(filename).create(recursive: true); + } + } + } + + Future installXApkDir(DownloadedXApkDir dir, + {bool silent = false}) async { + try { + for (var apk in dir.extracted + .listSync() + .where((f) => f is File && f.path.toLowerCase().endsWith('.apk'))) { + await installApk(DownloadedApk(dir.appId, apk as File), silent: silent); + } + dir.file.delete(); + } finally { + dir.extracted.delete(recursive: true); + } + } + Future installApk(DownloadedApk file, {bool silent = false}) async { // TODO: Use 'silent' when/if ever possible var newInfo = await PackageArchiveInfo.fromPath(file.file.path); @@ -420,9 +484,16 @@ class AppsProvider with ChangeNotifier { for (var id in appsToInstall) { try { // ignore: use_build_context_synchronously - var downloadedFile = await downloadApp(apps[id]!.app, context); - bool willBeSilent = - await canInstallSilently(apps[downloadedFile.appId]!.app); + var downloadedArtifact = await downloadApp(apps[id]!.app, context); + DownloadedApk? downloadedFile; + DownloadedXApkDir? downloadedDir; + if (downloadedArtifact is DownloadedApk) { + downloadedFile = downloadedArtifact; + } else { + downloadedDir = downloadedArtifact as DownloadedXApkDir; + } + bool willBeSilent = await canInstallSilently( + apps[downloadedFile?.appId ?? downloadedDir!.appId]!.app); willBeSilent = false; // TODO: Remove this when silent updates work if (!(await settingsProvider?.getInstallPermission(enforce: false) ?? true)) { @@ -432,7 +503,11 @@ class AppsProvider with ChangeNotifier { // ignore: use_build_context_synchronously await waitForUserToReturnToForeground(context); } - await installApk(downloadedFile, silent: willBeSilent); + if (downloadedFile != null) { + await installApk(downloadedFile, silent: willBeSilent); + } else { + await installXApkDir(downloadedDir!, silent: willBeSilent); + } installedIds.add(id); } catch (e) { errors.add(id, e.toString()); diff --git a/pubspec.lock b/pubspec.lock index fc6d56b..c5513ac 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -34,6 +34,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.7" + archive: + dependency: "direct main" + description: + name: archive + sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a" + url: "https://pub.dev" + source: hosted + version: "3.3.7" args: dependency: transitive description: @@ -82,6 +90,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" cross_file: dependency: transitive description: @@ -518,6 +534,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + url: "https://pub.dev" + source: hosted + version: "3.7.3" process: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d1197a8..36665bd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: easy_localization: ^3.0.1 android_intent_plus: ^3.1.5 flutter_markdown: ^0.6.14 + archive: ^3.3.7 dev_dependencies: