From ab38cc024e683518b1baeee2641e99ca47c2ea8a Mon Sep 17 00:00:00 2001 From: shrx Date: Tue, 16 Jun 2026 08:51:35 +0200 Subject: [PATCH] Added(InstallAPI): Add API to install apps via PackageInstaller session, supporting split APK bundles Add an Install API method that installs an application using the PackageInstaller session API. This works with the REQUEST_INSTALL_PACKAGES permission already held by the app and shows the system confirmation dialog, so it can install multi part packages (a base APK plus its split APKs, e.g. Android App Bundles distributed as .apkm/.apks/.xapk) which the single file install intent cannot handle. Package signatures are preserved. The "file" extra may point to a single .apk, a directory of split APKs, or a zip based bundle whose .apk entries are extracted and installed together as one session. A nested InstallStatusReceiver launches the confirmation intent on STATUS_PENDING_USER_ACTION and reports the final result. --- app/src/main/AndroidManifest.xml | 3 + .../com/termux/api/TermuxApiReceiver.java | 6 + .../java/com/termux/api/apis/InstallAPI.java | 214 ++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 app/src/main/java/com/termux/api/apis/InstallAPI.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f9d37121..d7f468e9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -162,6 +162,9 @@ + + { + if (fileExtra == null || fileExtra.isEmpty()) { + out.println("ERROR: No file specified (pass the path with --es file )"); + return; + } + + File input = new File(fileExtra); + if (!input.exists() || !input.canRead()) { + out.println("ERROR: Not a readable path: '" + input.getAbsolutePath() + "'"); + return; + } + + List apks = new ArrayList<>(); + File tempDir = null; + try { + if (input.isDirectory()) { + collectApks(input, apks); + } else if (isApk(input)) { + apks.add(input); + } else { + // Treat anything else as a zip based bundle (.apkm/.apks/.xapk/.zip). + tempDir = extractApks(context, input, apks); + } + + if (apks.isEmpty()) { + out.println("ERROR: No .apk files found in '" + input.getName() + "'"); + return; + } + + int sessionId = createAndCommitSession(context, apks); + out.println("Install session " + sessionId + " committed with " + apks.size() + + " APK(s). Confirm the system prompt to finish installation."); + } catch (IOException e) { + out.println("ERROR: " + e.getMessage()); + Logger.logStackTraceWithMessage(LOG_TAG, "Install failed", e); + } finally { + // The session keeps its own copy of the bytes after openWrite()/fsync(), so the + // extracted temp files are no longer needed once the session has been committed. + if (tempDir != null) deleteRecursive(tempDir); + } + }); + } + + private static boolean isApk(File file) { + return file.isFile() && file.getName().toLowerCase(Locale.ROOT).endsWith(".apk"); + } + + private static void collectApks(File dir, List apks) { + File[] files = dir.listFiles(); + if (files == null) return; + for (File file : files) { + if (isApk(file)) apks.add(file); + } + } + + private static File extractApks(Context context, File bundle, List apks) throws IOException { + File tempDir = new File(context.getCacheDir(), "install_" + System.currentTimeMillis()); + if (!tempDir.mkdirs()) { + throw new IOException("Could not create temporary directory for extraction"); + } + + try (ZipInputStream zis = new ZipInputStream(new BufferedInputStream(new FileInputStream(bundle)))) { + ZipEntry entry; + byte[] buffer = new byte[COPY_BUFFER_SIZE]; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory()) continue; + // Use only the basename to guard against zip path traversal ("zip slip"). + String name = new File(entry.getName()).getName(); + if (!name.toLowerCase(Locale.ROOT).endsWith(".apk")) continue; + + File outFile = new File(tempDir, name); + try (OutputStream os = new FileOutputStream(outFile)) { + int read; + while ((read = zis.read(buffer)) != -1) { + os.write(buffer, 0, read); + } + } + apks.add(outFile); + } + } + return tempDir; + } + + private static int createAndCommitSession(Context context, List apks) throws IOException { + PackageInstaller packageInstaller = context.getPackageManager().getPackageInstaller(); + PackageInstaller.SessionParams params = + new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL); + + int sessionId = packageInstaller.createSession(params); + PackageInstaller.Session session = packageInstaller.openSession(sessionId); + try { + byte[] buffer = new byte[COPY_BUFFER_SIZE]; + for (File apk : apks) { + try (InputStream in = new FileInputStream(apk); + OutputStream out = session.openWrite(apk.getName(), 0, apk.length())) { + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + session.fsync(out); + } + } + session.commit(createStatusSender(context, sessionId)); + } finally { + session.close(); + } + return sessionId; + } + + private static IntentSender createStatusSender(Context context, int sessionId) { + Intent intent = new Intent(context, InstallStatusReceiver.class).setAction(INSTALL_COMMIT_ACTION); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // The system fills in the result extras, so the PendingIntent must be mutable on 31+. + flags |= PendingIntent.FLAG_MUTABLE; + } + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, sessionId, intent, flags); + return pendingIntent.getIntentSender(); + } + + private static void deleteRecursive(File file) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) deleteRecursive(child); + } + if (!file.delete()) { + Logger.logWarn(LOG_TAG, "Could not delete temporary file: " + file.getAbsolutePath()); + } + } + + /** + * Receives the commit status. When the system needs the user to confirm the install it returns + * {@link PackageInstaller#STATUS_PENDING_USER_ACTION} together with the confirmation intent, + * which must be launched as an activity; the success/failure result arrives in a later callback. + */ + public static class InstallStatusReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE); + String message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE); + + switch (status) { + case PackageInstaller.STATUS_PENDING_USER_ACTION: + Intent confirmIntent = intent.getParcelableExtra(Intent.EXTRA_INTENT); + if (confirmIntent != null) { + confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + context.startActivity(confirmIntent); + } catch (Exception e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to launch install confirmation", e); + } + } else { + Logger.logError(LOG_TAG, "Pending user action with no confirmation intent"); + } + break; + case PackageInstaller.STATUS_SUCCESS: + String packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME); + Logger.logInfo(LOG_TAG, "Installed " + packageName); + Toast.makeText(context, "Installed " + (packageName != null ? packageName : "package"), + Toast.LENGTH_SHORT).show(); + break; + default: + Logger.logError(LOG_TAG, "Install failed, status=" + status + ", message=" + message); + Toast.makeText(context, "Install failed: " + (message != null ? message : "status " + status), + Toast.LENGTH_LONG).show(); + break; + } + } + } + +}