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; + } + } + } + +}