Reputation: 116382
In the past, I've asked about sharing or backup of app-bundle / split apk files, here .
This seems like an almost impossible task, which I could only figure out how to install the split APK files, and even then it's only via adb:
adb install-multiple apk1 apk2 ...
I was told that it should be actually possible to merge multiple split APK files into one that I could install (here), but wasn't given of how to do it.
This could be useful for saving it for later (backup), and because currently there is no way to install split-apk files within the device.
In fact, this is such a major issue, that I don't know of any backup app that can handle split APK files (app bundle), and this include Titanium app.
I took a sample app that uses app-bundles, called "AirBnb".
Looking at the files it has, those are what the Play Store decided to download:
So I tried to enter each. The "base" is the main one, so I skipped it to look at the others. To me it seems that all have these files within:
Thing is, since those all exist in multiple places, I don't get how could I merge them.
What is the way to merge those all into one APK file?
Is it possible to install split APK files without root and without PC ? This was possible in the past on backup apps such as Titanium, but only on normal APK files, and not app bundle (split apk).
EDIT: I've set a bounty. Please, if you know of a solution, show it. Show something that you've tested to work. Either of merging split APK files, or installing them , all without root and right on the device.
EDIT: Sadly all solutions here didn't work, with or without root, and that's even though I've found an app that succeeded doing it (with and without root), called "SAI (Split APKs Installer)" (I think its repository is here, found after I've put a bounty).
I'm putting a new bounty. Please, whoever publishes a new answer, show that it works with and without root. Show on Github if needed (and here just the important stuff). I know this app is open sourced anyway, but it's important for me how to do it here, and share with others, as currently what's shown here isn't working, and requires root, even though it's not really needed.
This time I won't grant the bounty till I see something that indeed works (previously I was short on time and granted it to the answer I thought should work).
Upvotes: 45
Views: 68307
Reputation: 51
Upvotes: 3
Reputation: 46
Well I don't know the coding part much, as I have not learnt android, but I can suggest something which you can try. If the task is just to make the split apka into one, what I do is
So you can look at their source code (if they are open source, otherwise something similar), and then try to make a single app to do all these processes (if you know android).
Hope it helps, and please provide your app link, if you manage to create one.
Thanks and cheers
Upvotes: 0
Reputation:
It is possible to merge split APKs into a single APK, both manually and automatically, but forced to use a made up signing key means the APK can’t be installed as an update to the genuine app and in case the app may checks itself for tempering
A detailed guide how to merge split APKs manually: https://platinmods.com/threads/how-to-turn-a-split-apk-into-a-normal-non-split-apk.76683/
A PC software to merge split APKs automatically: https://www.andnixsh.com/2020/06/sap-split-apks-packer-by-kirlif-windows.html
Upvotes: 5
Reputation: 11
What is the way to merge those all into one APK file?
After installing (see question 2), use eg TotalCommander to copy the apk from 'installed apps'
Is it possible to install split APK files without root and without PC ?
Use any terminal app, then:
pm install <split1> <split2> ...
Upvotes: -1
Reputation: 2380
No root required implementation Check this git hub link: https://github.com/nkalra0123/splitapkinstall
We have to create a service and pass that handle in session.commit()
Intent callbackIntent = new Intent(getApplicationContext(), APKInstallService.class);
PendingIntent pendingIntent = PendingIntent.getService(getApplicationContext(), 0, callbackIntent, 0);
session.commit(pendingIntent.getIntentSender());
EDIT: Since the solution works, but not really published here, I've decided to write it before marking it as correct solution. Here's the code:
manifest
<manifest package="com.nitin.apkinstaller" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<application
android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true"
android:theme="@style/AppTheme" tools:ignore="AllowBackup,GoogleAppIndexingWarning">
<activity
android:name=".MainActivity" android:label="@string/app_name" android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service android:name=".APKInstallService"/>
</application>
</manifest>
APKInstallService
class APKInstallService : Service() {
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
when (if (intent.hasExtra(PackageInstaller.EXTRA_STATUS)) null else intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 0)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
Log.d("AppLog", "Requesting user confirmation for installation")
val confirmationIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
confirmationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
startActivity(confirmationIntent)
} catch (e: Exception) {
}
}
PackageInstaller.STATUS_SUCCESS -> Log.d("AppLog", "Installation succeed")
else -> Log.d("AppLog", "Installation failed")
}
stopSelf()
return START_NOT_STICKY
}
override fun onBind(intent: Intent): IBinder? {
return null
}
}
MainActivity
class MainActivity : AppCompatActivity() {
private lateinit var packageInstaller: PackageInstaller
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val toolbar = findViewById<Toolbar>(R.id.toolbar)
setSupportActionBar(toolbar)
val fab = findViewById<FloatingActionButton>(R.id.fab)
fab.setOnClickListener {
packageInstaller = packageManager.packageInstaller
val ret = installApk("/storage/emulated/0/Download/split/")
Log.d("AppLog", "onClick: return value is $ret")
}
}
private fun installApk(apkFolderPath: String): Int {
val nameSizeMap = HashMap<String, Long>()
var totalSize: Long = 0
var sessionId = 0
val folder = File(apkFolderPath)
val listOfFiles = folder.listFiles()
try {
for (listOfFile in listOfFiles) {
if (listOfFile.isFile) {
Log.d("AppLog", "installApk: " + listOfFile.name)
nameSizeMap[listOfFile.name] = listOfFile.length()
totalSize += listOfFile.length()
}
}
} catch (e: Exception) {
e.printStackTrace()
return -1
}
val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
installParams.setSize(totalSize)
try {
sessionId = packageInstaller.createSession(installParams)
Log.d("AppLog","Success: created install session [$sessionId]")
for ((key, value) in nameSizeMap) {
doWriteSession(sessionId, apkFolderPath + key, value, key)
}
doCommitSession(sessionId)
Log.d("AppLog","Success")
} catch (e: IOException) {
e.printStackTrace()
}
return sessionId
}
private fun doWriteSession(sessionId: Int, inPath: String?, sizeBytes: Long, splitName: String): Int {
var inPathToUse = inPath
var sizeBytesToUse = sizeBytes
if ("-" == inPathToUse) {
inPathToUse = null
} else if (inPathToUse != null) {
val file = File(inPathToUse)
if (file.isFile)
sizeBytesToUse = file.length()
}
var session: PackageInstaller.Session? = null
var inputStream: InputStream? = null
var out: OutputStream? = null
try {
session = packageInstaller.openSession(sessionId)
if (inPathToUse != null) {
inputStream = FileInputStream(inPathToUse)
}
out = session!!.openWrite(splitName, 0, sizeBytesToUse)
var total = 0
val buffer = ByteArray(65536)
var c: Int
while (true) {
c = inputStream!!.read(buffer)
if (c == -1)
break
total += c
out!!.write(buffer, 0, c)
}
session.fsync(out!!)
Log.d("AppLog", "Success: streamed $total bytes")
return PackageInstaller.STATUS_SUCCESS
} catch (e: IOException) {
Log.e("AppLog", "Error: failed to write; " + e.message)
return PackageInstaller.STATUS_FAILURE
} finally {
try {
out?.close()
inputStream?.close()
session?.close()
} catch (e: IOException) {
e.printStackTrace()
}
}
}
private fun doCommitSession(sessionId: Int) {
var session: PackageInstaller.Session? = null
try {
try {
session = packageInstaller.openSession(sessionId)
val callbackIntent = Intent(applicationContext, APKInstallService::class.java)
val pendingIntent = PendingIntent.getService(applicationContext, 0, callbackIntent, 0)
session!!.commit(pendingIntent.intentSender)
session.close()
Log.d("AppLog", "install request sent")
Log.d("AppLog", "doCommitSession: " + packageInstaller.mySessions)
Log.d("AppLog", "doCommitSession: after session commit ")
} catch (e: IOException) {
e.printStackTrace()
}
} finally {
session!!.close()
}
}
}
Upvotes: 9
Reputation: 2380
If you have root, you can use this code.
Please get the read/write sdcard permission.(via runtime permissions or permission granted from settings app) before executing this code. airbnb apk was successfully installed after running this code.
Calling this function with args "/split-apks/" , I have placed the airbnb split apks in a directory in /sdcard/split-apks/.
installApk("/split-apks/");
public void installApk(String apkFolderPath)
{
PackageInstaller packageInstaller = getPackageManager().getPackageInstaller();
HashMap<String, Long> nameSizeMap = new HashMap<>();
long totalSize = 0;
File folder = new File(Environment.getExternalStorageDirectory().getPath()+ apkFolderPath);
File[] listOfFiles = folder.listFiles();
for (int i = 0; i < listOfFiles.length; i++) {
if (listOfFiles[i].isFile()) {
System.out.println("File " + listOfFiles[i].getName());
nameSizeMap.put(listOfFiles[i].getName(),listOfFiles[i].length());
totalSize += listOfFiles[i].length();
}
}
String su = "/system/xbin/su";
final String[] pm_install_create = new String[]{su, "-c", "pm" ,"install-create", "-S", Long.toString(totalSize) };
execute(null, pm_install_create);
List<PackageInstaller.SessionInfo> sessions = packageInstaller.getAllSessions();
int sessId = sessions.get(0).getSessionId();
String sessionId = Integer.toString(sessId);
for(Map.Entry<String,Long> entry : nameSizeMap.entrySet())
{
String[] pm_install_write = new String[]{su, "-c", "pm" ,"install-write", "-S", Long.toString(entry.getValue()),sessionId, entry.getKey(), Environment.getExternalStorageDirectory().getPath()+apkFolderPath+ entry.getKey()};
execute(null,pm_install_write);
}
String[] pm_install_commit = new String[]{su, "-c", "pm" ,"install-commit", sessionId};
execute(null, pm_install_commit);
}
public String execute(Map<String, String> environvenmentVars, String[] cmd) {
boolean DEBUG = true;
if (DEBUG)
Log.d("log","command is " + Arrays.toString(cmd));
try {
Process process = Runtime.getRuntime().exec(cmd);
if (DEBUG)
Log.d("log", "process is " + process);
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
if (DEBUG)
Log.d("log", "bufferreader is " + reader);
if (DEBUG)
Log.d("log", "readline " + reader.readLine());
StringBuffer output = new StringBuffer();
char[] buffer = new char[4096];
int read;
while ((read = reader.read(buffer)) > 0) {
output.append(buffer, 0, read);
}
reader.close();
process.waitFor();
if (DEBUG)
Log.d("log", output.toString());
return output.toString();
}
catch (Exception e)
{
e.printStackTrace();
}
return null;
}
EDIT: same code, but in Kotlin, as it's shorter:
sample usage:
Foo.installApk(context,fullPathToSplitApksFolder)
Example:
AsyncTask.execute {
Foo.installApk(this@MainActivity,"/storage/emulated/0/Download/split")
}
Code:
object Foo {
@WorkerThread
@JvmStatic
fun installApk(context: Context, apkFolderPath: String) {
val packageInstaller = context.packageManager.packageInstaller
val nameSizeMap = HashMap<File, Long>()
var totalSize: Long = 0
val folder = File(apkFolderPath)
val listOfFiles = folder.listFiles().filter { it.isFile && it.name.endsWith(".apk") }
for (file in listOfFiles) {
Log.d("AppLog", "File " + file.name)
nameSizeMap[file] = file.length()
totalSize += file.length()
}
val su = "su"
val pmInstallCreate = arrayOf(su, "-c", "pm", "install-create", "-S", totalSize.toString())
execute(pmInstallCreate)
val sessions = packageInstaller.allSessions
val sessionId = Integer.toString(sessions[0].sessionId)
for ((file, value) in nameSizeMap) {
val pmInstallWrite = arrayOf(su, "-c", "pm", "install-write", "-S", value.toString(), sessionId, file.name, file.absolutePath)
execute(pmInstallWrite)
}
val pmInstallCommit = arrayOf(su, "-c", "pm", "install-commit", sessionId)
execute(pmInstallCommit)
}
@WorkerThread
@JvmStatic
private fun execute(cmd: Array<String>): String? {
Log.d("AppLog", "command is " + Arrays.toString(cmd))
try {
val process = Runtime.getRuntime().exec(cmd)
Log.d("AppLog", "process is $process")
val reader = BufferedReader(InputStreamReader(process.inputStream))
Log.d("AppLog", "bufferreader is $reader")
Log.d("AppLog", "readline " + reader.readLine())
val output = StringBuilder()
val buffer = CharArray(4096)
var read: Int
while (true) {
read = reader.read(buffer)
if (read <= 0)
break
output.append(buffer, 0, read)
}
reader.close()
process.waitFor()
Log.d("AppLog", output.toString())
return output.toString()
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
}
Upvotes: 1
Reputation: 2380
Please check this. when we send
adb install-multiple apk1 apk2 ...
it calls this code install-multiple
std::string install_cmd;
if (_use_legacy_install()) {
install_cmd = "exec:pm";
} else {
install_cmd = "exec:cmd package";
}
std::string cmd = android::base::StringPrintf("%s install-create -S %" PRIu64, install_cmd.c_str(), total_size);
for (i = 1; i < first_apk; i++) {
cmd += " " + escape_arg(argv[i]);
}
which in turn calls Pm.java or a new way of executing PackageManagerService code, both are similar
I tried to integrate that code in my app, The problem which I faced, apk installation was not able to complete, it is due to the reason that the app needs.
<uses-permission android:name="android.permission.INSTALL_PACKAGES"/>
But it is only given to system-priv apps. When I executed these steps from adb shell apk installation was successful and when I created my app a system priv-app apk install was successfull.
code to call new apis of PackageManager, mostly copied from Pm.java Steps in installing split apks
Create a session with argument -S , return session id.
(install-create, -S, 52488426) 52488426 -- total size of apks.
Write split apks in that session with size , name and path
(install-write, -S, 44334187, 824704264, 1_base.apk, -)
(install-write, -S, 1262034, 824704264, 2_split_config.en.apk, -)
(install-write, -S, 266117, 824704264, 3_split_config.hdpi.apk, -)
(install-write, -S, 6626088, 824704264, 4_split_config.x86.apk, -)
commit the session with session id
(install-commit, 824704264)
I have placed airbnb apk in my sdcard.
OnePlus5:/sdcard/com.airbnb.android-1 $ ll
total 51264
-rw-rw---- 1 root sdcard_rw 44334187 2019-04-01 14:20 base.apk
-rw-rw---- 1 root sdcard_rw 1262034 2019-04-01 14:20 split_config.en.apk
-rw-rw---- 1 root sdcard_rw 266117 2019-04-01 14:20 split_config.hdpi.apk
-rw-rw---- 1 root sdcard_rw 6626088 2019-04-01 14:20 split_config.x86.apk
and calling functions to install apk.
final InstallParams installParams = makeInstallParams(52488426l);
try {
int sessionId = runInstallCreate(installParams);
runInstallWrite(44334187,sessionId, "1_base.apk", "/sdcard/com.airbnb.android-1/base.apk");
runInstallWrite(1262034,sessionId, "2_split_config.en.apk", "/sdcard/com.airbnb.android-1/split_config.en.apk");
runInstallWrite(266117,sessionId, "3_split_config.hdpi.apk", "/sdcard/com.airbnb.android-1/split_config.hdpi.apk");
runInstallWrite(6626088,sessionId, "4_split_config.x86.apk", "/sdcard/com.airbnb.android-1/split_config.x86.apk");
if (doCommitSession(sessionId, false )
!= PackageInstaller.STATUS_SUCCESS) {
}
System.out.println("Success");
} catch (RemoteException e) {
e.printStackTrace();
}
private int runInstallCreate(InstallParams installParams) throws RemoteException {
final int sessionId = doCreateSession(installParams.sessionParams);
System.out.println("Success: created install session [" + sessionId + "]");
return sessionId;
}
private int doCreateSession(PackageInstaller.SessionParams params)
throws RemoteException {
int sessionId = 0 ;
try {
sessionId = packageInstaller.createSession(params);
} catch (IOException e) {
e.printStackTrace();
}
return sessionId;
}
private int runInstallWrite(long size, int sessionId , String splitName ,String path ) throws RemoteException {
long sizeBytes = -1;
String opt;
sizeBytes = size;
return doWriteSession(sessionId, path, sizeBytes, splitName, true /*logSuccess*/);
}
private int doWriteSession(int sessionId, String inPath, long sizeBytes, String splitName,
boolean logSuccess) throws RemoteException {
if ("-".equals(inPath)) {
inPath = null;
} else if (inPath != null) {
final File file = new File(inPath);
if (file.isFile()) {
sizeBytes = file.length();
}
}
final PackageInstaller.SessionInfo info = packageInstaller.getSessionInfo(sessionId);
PackageInstaller.Session session = null;
InputStream in = null;
OutputStream out = null;
try {
session = packageInstaller.openSession(sessionId);
if (inPath != null) {
in = new FileInputStream(inPath);
}
out = session.openWrite(splitName, 0, sizeBytes);
int total = 0;
byte[] buffer = new byte[65536];
int c;
while ((c = in.read(buffer)) != -1) {
total += c;
out.write(buffer, 0, c);
}
session.fsync(out);
if (logSuccess) {
System.out.println("Success: streamed " + total + " bytes");
}
return PackageInstaller.STATUS_SUCCESS;
} catch (IOException e) {
System.err.println("Error: failed to write; " + e.getMessage());
return PackageInstaller.STATUS_FAILURE;
} finally {
try {
out.close();
in.close();
session.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
private int doCommitSession(int sessionId, boolean logSuccess) throws RemoteException {
PackageInstaller.Session session = null;
try {
try {
session = packageInstaller.openSession(sessionId);
} catch (IOException e) {
e.printStackTrace();
}
session.commit(PendingIntent.getBroadcast(getApplicationContext(), sessionId,
new Intent("android.intent.action.MAIN"), 0).getIntentSender());
System.out.println("install request sent");
Log.d(TAG, "doCommitSession: " + packageInstaller.getMySessions());
Log.d(TAG, "doCommitSession: after session commit ");
return 1;
} finally {
session.close();
}
}
private static class InstallParams {
PackageInstaller.SessionParams sessionParams;
}
private InstallParams makeInstallParams(long totalSize ) {
final PackageInstaller.SessionParams sessionParams = new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
final InstallParams params = new InstallParams();
params.sessionParams = sessionParams;
String opt;
sessionParams.setSize(totalSize);
return params;
}
This is the list of commands that are actually received in Pm.java when we do adb install-multiple
04-01 16:04:40.626 4886 4886 D Pm : run() called with: args = [[install-create, -S, 52488426]]
04-01 16:04:41.862 4897 4897 D Pm : run() called with: args = [[install-write, -S, 44334187, 824704264, 1_base.apk, -]]
04-01 16:04:56.036 4912 4912 D Pm : run() called with: args = [[install-write, -S, 1262034, 824704264, 2_split_config.en.apk, -]]
04-01 16:04:57.584 4924 4924 D Pm : run() called with: args = [[install-write, -S, 266117, 824704264, 3_split_config.hdpi.apk, -]]
04-01 16:04:58.842 4936 4936 D Pm : run() called with: args = [[install-write, -S, 6626088, 824704264, 4_split_config.x86.apk, -]]
04-01 16:05:01.304 4948 4948 D Pm : run() called with: args = [[install-commit, 824704264]]
So for apps which are not system priv-app, I don't know how can they can install split apks. Play store being a system priv-app can use these apis and install split apks without any issues.
Upvotes: 17
Reputation: 17417
From an Android App Bundle, you can generate a "universal APK" using bundletool build-apks command with the --mode=universal
flag. This will generate a single "fat" APK that is compatible with all devices (that your app supports).
I know this isn't strictly answering your question, but trying to merge the APKs is not only a complex task, but will result in a lot of cases in something incorrect.
Upvotes: 1