Is it possible to merge/install split APK files (AKA "app bundle"), on Android device itself, without root?

时间:2019-03-17 22:59:24

标签: android apk

Background

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 ...

The problem

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.

What I've found

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:

enter image description here

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:

  • "META-INF"
  • "resources.arsc"
  • "AndroidManifest.xml"
  • in the case of the one with the "xxxhdpi", I also get "res" folder.

Thing is, since those all exist in multiple places, I don't get how could I merge them.

The questions

  1. What is the way to merge those all into one APK file?

  2. 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).

8 个答案:

答案 0 :(得分:2)

可以将拆分的APK手动和自动合并到单个APK中,但是被迫使用虚构的签名密钥意味着该APK无法安装为正版应用的更新,并且在可能的情况下检查自身是否回火

有关如何手动合并拆分的APK的详细指南:https://platinmods.com/threads/how-to-turn-a-split-apk-into-a-normal-non-split-apk.76683/

可自动合并拆分的APK的PC软件:https://www.andnixsh.com/2020/06/sap-split-apks-packer-by-kirlif-windows.html

答案 1 :(得分:1)

请检查此内容。 当我们发送

adb install-multiple apk1 apk2 ...

它调用此代码 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]);
    }

依次调用Pm.java或执行PackageManagerService代码的新方式,两者都很相似

我试图将代码集成到我的应用程序中,我所遇到的问题是apk安装无法完成,这是由于该应用程序需要的原因。

<uses-permission android:name="android.permission.INSTALL_PACKAGES"/>

但是它仅提供给系统专用的应用程序。当我从adb shell执行apk时成功安装了这些步骤,并且在创建我的应用程序时,系统priv-app apk安装成功了。

代码以调用PackageManager的新api,通常是从Pm.java复制而来 安装拆分式APK的步骤

  1. 使用参数-S创建会话,返回会话ID。

    (安装创建,-S,52488426) 52488426-APK的总大小。

  2. 在该会话中使用大小,名称和路径写入拆分的APK

    (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,-)

  3. 使用会话ID提交会话

    (install-commit,824704264)

我已将airbnb apk放入我的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

并调用函数来安装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;
}

这是我们执行adb install-multiple时Pm.java中实际收到的命令的列表。

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]]

因此对于不是系统priv-app的应用程序,我不知道它们如何安装拆分式APK。 作为系统私人应用程序的Play商店可以使用这些API并安装拆分的APK,而不会出现任何问题。

答案 2 :(得分:0)

从Android应用程序捆绑包中,您可以使用带有--mode=universal标志的bundletool build-apks命令来生成“通用APK”。这样会生成一个与所有设备(您的应用支持)兼容的“胖” APK。

我知道这并不是严格地回答您的问题,但是尝试合并APK不仅是一项复杂的任务,而且还会导致很多情况下出现某些不正确的情况。

答案 3 :(得分:0)

如果您具有root用户,则可以使用此代码。

在执行此代码之前,请获取读/写sdcard权限(通过运行时权限或设置应用程序授予的权限)。 运行此代码后,airbnb apk已成功安装。

使用args“ / split-apks /”调用此函数,我将airbnb拆分apk放置在/ sdcard / split-apks /中的目录中。

System.out.println(table.findElement(By.xpath("//div[@class='cb-col cb-col-8 text-right text-bold']")).get(i).getText());

编辑:相同的代码,但是在Kotlin中,因为它更短:

示例用法:

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;

}

示例:

Foo.installApk(context,fullPathToSplitApksFolder)

代码:

        AsyncTask.execute {
            Foo.installApk(this@MainActivity,"/storage/emulated/0/Download/split")
        }

答案 4 :(得分:0)

无需root实施 检查以下git hub链接:https://github.com/nkalra0123/splitapkinstall

我们必须创建一个服务并在session.commit()中传递该句柄

 Intent callbackIntent = new Intent(getApplicationContext(), APKInstallService.class);
 PendingIntent pendingIntent = PendingIntent.getService(getApplicationContext(), 0, callbackIntent, 0);
 session.commit(pendingIntent.getIntentSender());

编辑:由于该解决方案有效,但未在此处实际发布,因此我决定在将其标记为正确的解决方案之前将其编写。这是代码:

清单

<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()
        }
    }
}

答案 5 :(得分:0)

  1. 使用--mode = universal
  2. 运行bundletool
  3. 这将生成一个APKS文件,将扩展名重命名为zip
  4. 解压缩
  5. 您将找到一个胖的Universal.apk文件,该文件可以像以前一样安装。

答案 6 :(得分:0)

因为我还没有学过android,所以我对编码的部分并不了解,但是我可以建议您尝试一下。 如果任务只是将apka拆分为一个,我要做的就是

  1. 使用SAI或Lucky patcher安装应用程序(因为它开始创建apk而不是apk,因此能够安装它们)
  2. 使用apk提取器将其提取为单个apk(第一次出现在搜索结果中,由Meher)
  3. 可选-如果只需要apk,请卸载应用

因此,您可以查看它们的源代码(如果它们是开源的,否则类似的东西),然后尝试使单个应用程序执行所有这些过程(如果您知道android)。

希望它会有所帮助,如果您设法创建一个,请提供您的应用链接。

感谢和欢呼

答案 7 :(得分:-1)

  

将这些全部合并为一个APK文件的方式是什么?

安装后(请参阅问题2),使用例如TotalCommander从以下位置复制apk  “已安装的应用”

  

是否可以在没有root用户且没有PC的情况下安装拆分的APK文件?

使用任何终端应用,然后:

 pm install <split1> <split2> ...