fix(xapk): new implementation to reduce zip unpack and fix NPE (#2501)

This commit is contained in:
Skylot
2025-05-23 23:48:17 +01:00
parent 2486c981a8
commit 97d04edb01
17 changed files with 301 additions and 151 deletions
@@ -188,6 +188,7 @@ public final class JadxDecompiler implements Closeable {
closeAll(customCodeLoaders);
closeAll(customResourcesLoaders);
closeAll(closeableList);
FileUtils.deleteDirIfExists(args.getFilesGetter().getTempDir());
args.close();
FileUtils.clearTempRootDir();
}
@@ -40,7 +40,7 @@ public class JadxPluginsData implements IJadxPlugins {
return pluginManager.getResolvedPluginContexts()
.stream()
.filter(p -> p.getPluginInstance().getClass().equals(pluginCls))
.map(p -> ((P) p.getPluginInstance()))
.map(p -> (P) p.getPluginInstance())
.findFirst()
.orElseThrow(() -> new JadxRuntimeException("Plugin class '" + pluginCls + "' not found"));
}
@@ -57,6 +57,7 @@ public class FileUtils {
public static synchronized Path updateTempRootDir(Path newTempRootDir) {
try {
makeDirs(newTempRootDir);
Path dir = Files.createTempDirectory(newTempRootDir, JADX_TMP_INSTANCE_PREFIX);
tempRootDir = dir;
dir.toFile().deleteOnExit();
@@ -66,6 +66,7 @@ public class JadxWrapper {
JadxProject project = getProject();
JadxArgs jadxArgs = getSettings().toJadxArgs();
jadxArgs.setPluginLoader(new JadxExternalPluginsLoader());
jadxArgs.setFilesGetter(JadxFilesGetter.INSTANCE);
project.fillJadxArgs(jadxArgs);
JadxAppCommon.applyEnvVars(jadxArgs);
@@ -1,6 +1,5 @@
plugins {
id("jadx-library")
id("jadx-kotlin")
}
dependencies {
@@ -0,0 +1,62 @@
package jadx.plugins.input.xapk;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import jadx.api.ResourceFile;
import jadx.api.ResourcesLoader;
import jadx.api.plugins.CustomResourcesLoader;
import jadx.api.plugins.JadxPluginContext;
import jadx.api.plugins.input.ICodeLoader;
import jadx.api.plugins.input.JadxCodeInput;
import jadx.api.plugins.input.data.impl.EmptyCodeLoader;
import jadx.plugins.input.dex.DexInputPlugin;
import jadx.plugins.input.xapk.data.XApkData;
public class XApkCustomInput implements JadxCodeInput, CustomResourcesLoader {
private final JadxPluginContext context;
private final XApkLoader loader;
public XApkCustomInput(JadxPluginContext context, XApkLoader loader) {
this.context = context;
this.loader = loader;
}
@Override
public ICodeLoader loadFiles(List<Path> input) {
List<Path> apks = new ArrayList<>();
for (Path inputPath : input) {
XApkData data = loader.checkAndLoad(inputPath);
if (data != null) {
apks.addAll(data.getApks());
}
}
if (apks.isEmpty()) {
return EmptyCodeLoader.INSTANCE;
}
DexInputPlugin dexInputPlugin = context.plugins().getInstance(DexInputPlugin.class);
return dexInputPlugin.loadFiles(apks);
}
@Override
public boolean load(ResourcesLoader resLoader, List<ResourceFile> list, File file) {
XApkData xApkData = loader.checkAndLoad(file.toPath());
if (xApkData == null) {
return false;
}
for (Path apkPath : xApkData.getApks()) {
resLoader.defaultLoadFile(list, apkPath.toFile(), apkPath.getFileName() + "/");
}
for (Path filePath : xApkData.getFiles()) {
resLoader.defaultLoadFile(list, filePath.toFile(), "");
}
return true;
}
@Override
public void close() throws IOException {
}
}
@@ -0,0 +1,34 @@
package jadx.plugins.input.xapk;
import jadx.api.plugins.JadxPlugin;
import jadx.api.plugins.JadxPluginContext;
import jadx.api.plugins.JadxPluginInfo;
import jadx.api.plugins.JadxPluginInfoBuilder;
public class XApkInputPlugin implements JadxPlugin {
private XApkLoader loader;
@Override
public JadxPluginInfo getPluginInfo() {
return JadxPluginInfoBuilder.pluginId("xapk-input")
.name("XApk Input")
.description("Load .xapk files")
.build();
}
@Override
public void init(JadxPluginContext context) {
loader = new XApkLoader(context);
XApkCustomInput customInput = new XApkCustomInput(context, loader);
context.addCodeInput(customInput);
context.getDecompiler().addCustomResourcesLoader(customInput);
}
@Override
public void unload() {
if (loader != null) {
loader.unload();
}
}
}
@@ -0,0 +1,116 @@
package jadx.plugins.input.xapk;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jadx.api.plugins.JadxPluginContext;
import jadx.core.utils.GsonUtils;
import jadx.core.utils.files.FileUtils;
import jadx.plugins.input.xapk.data.SplitApk;
import jadx.plugins.input.xapk.data.XApkData;
import jadx.plugins.input.xapk.data.XApkManifest;
import jadx.zip.IZipEntry;
import jadx.zip.ZipContent;
public class XApkLoader {
private static final Logger LOG = LoggerFactory.getLogger(XApkLoader.class);
private final JadxPluginContext context;
private final Map<String, XApkData> loaded = new HashMap<>();
public XApkLoader(JadxPluginContext context) {
this.context = context;
}
public @Nullable XApkData checkAndLoad(Path inputPath) {
String fileName = inputPath.getFileName().toString();
if (!fileName.toLowerCase(Locale.ROOT).endsWith(".xapk")) {
return null;
}
try {
XApkData loadedData = getLoaded(inputPath);
if (loadedData != null) {
return loadedData;
}
File xapkFile = inputPath.toFile();
if (!FileUtils.isZipFile(xapkFile)) {
return null;
}
try (ZipContent content = context.getZipReader().open(xapkFile)) {
IZipEntry manifestEntry = content.searchEntry("manifest.json");
if (manifestEntry == null) {
return null;
}
String manifestStr = new String(manifestEntry.getBytes(), StandardCharsets.UTF_8);
XApkManifest xApkManifest = GsonUtils.buildGson().fromJson(manifestStr, XApkManifest.class);
if (xApkManifest.getVersion() != 2 || xApkManifest.getSplitApks().isEmpty()) {
return null;
}
// checks complete
// unpack all files into temp directory
XApkData xApkData = unpackXApk(xapkFile, xApkManifest, content);
saveLoaded(inputPath, xApkData);
return xApkData;
}
} catch (Exception e) {
LOG.warn("Failed to load XApk file: {}", inputPath.toAbsolutePath(), e);
return null;
}
}
private XApkData unpackXApk(File xapkFile, XApkManifest xApkManifest, ZipContent content) throws IOException {
Set<String> declaredApks = xApkManifest.getSplitApks().stream()
.map(SplitApk::getFile).collect(Collectors.toSet());
List<Path> apks = new ArrayList<>(declaredApks.size());
List<Path> files = new ArrayList<>();
String dirName = xapkFile.getName() + '_' + System.currentTimeMillis();
Path tmpDir = context.files().getPluginTempDir().resolve(dirName);
FileUtils.makeDirs(tmpDir);
for (IZipEntry entry : content.getEntries()) {
String fileName = entry.getName();
Path file = tmpDir.resolve(fileName);
Files.write(file, entry.getBytes());
if (declaredApks.contains(fileName)) {
apks.add(file);
} else {
files.add(file);
}
}
return new XApkData(xApkManifest, tmpDir, apks, files);
}
private XApkData getLoaded(Path inputPath) throws IOException {
return loaded.get(pathToKey(inputPath));
}
private void saveLoaded(Path inputPath, XApkData xApkData) throws IOException {
loaded.put(pathToKey(inputPath), xApkData);
}
private static String pathToKey(Path path) throws IOException {
return path.toRealPath(LinkOption.NOFOLLOW_LINKS).toString();
}
public synchronized void unload() {
for (XApkData data : loaded.values()) {
FileUtils.deleteDirIfExists(data.getTmpDir());
}
loaded.clear();
}
}
@@ -1,43 +0,0 @@
package jadx.plugins.input.xapk
import jadx.api.plugins.input.ICodeLoader
import jadx.api.plugins.input.JadxCodeInput
import jadx.api.plugins.utils.CommonFileUtils
import jadx.plugins.input.dex.DexInputPlugin
import jadx.zip.ZipReader
import java.io.File
import java.nio.file.Path
class XapkCustomCodeInput(
private val dexInputPlugin: DexInputPlugin,
private val zipReader: ZipReader,
) : JadxCodeInput {
override fun loadFiles(input: List<Path>): ICodeLoader {
val apkFiles = mutableListOf<File>()
for (file in input.map { it.toFile() }) {
if (!file.name.endsWith(".xapk")) continue
val manifest = XapkUtils.getManifest(file, zipReader) ?: continue
if (!XapkUtils.isSupported(manifest)) continue
zipReader.open(file).use { zip ->
for (splitApk in manifest.splitApks) {
val splitApkEntry = zip.searchEntry(splitApk.file)
if (splitApkEntry != null) {
val tmpFile = splitApkEntry.inputStream.use {
CommonFileUtils.saveToTempFile(it, ".apk").toFile()
}
apkFiles.add(tmpFile)
}
}
}
}
val codeLoader = dexInputPlugin.loadFiles(apkFiles.map { it.toPath() })
apkFiles.forEach { CommonFileUtils.safeDeleteFile(it) }
return codeLoader
}
}
@@ -1,39 +0,0 @@
package jadx.plugins.input.xapk
import jadx.api.ResourceFile
import jadx.api.ResourcesLoader
import jadx.api.plugins.CustomResourcesLoader
import jadx.api.plugins.utils.CommonFileUtils
import jadx.zip.ZipReader
import java.io.File
class XapkCustomResourcesLoader(private val zipReader: ZipReader) : CustomResourcesLoader {
private val tmpFiles = mutableListOf<File>()
override fun load(loader: ResourcesLoader, list: MutableList<ResourceFile>, file: File): Boolean {
if (!file.name.endsWith(".xapk")) return false
val manifest = XapkUtils.getManifest(file, zipReader) ?: return false
if (!XapkUtils.isSupported(manifest)) return false
val apkEntries = manifest.splitApks.map { it.file }.toHashSet()
zipReader.visitEntries(file) { entry ->
if (apkEntries.contains(entry.name)) {
val tmpFile = entry.inputStream.use {
CommonFileUtils.saveToTempFile(it, ".apk").toFile()
}
loader.defaultLoadFile(list, tmpFile, entry.name + "/")
tmpFiles += tmpFile
} else {
loader.addEntry(list, file, entry, "")
}
null
}
return true
}
override fun close() {
tmpFiles.forEach(CommonFileUtils::safeDeleteFile)
tmpFiles.clear()
}
}
@@ -1,22 +0,0 @@
package jadx.plugins.input.xapk
import jadx.api.plugins.JadxPlugin
import jadx.api.plugins.JadxPluginContext
import jadx.api.plugins.JadxPluginInfo
import jadx.api.plugins.JadxPluginInfoBuilder
import jadx.plugins.input.dex.DexInputPlugin
class XapkInputPlugin : JadxPlugin {
override fun getPluginInfo(): JadxPluginInfo =
JadxPluginInfoBuilder.pluginId("xapk-input")
.name("XAPK Input")
.description("Load .xapk files")
.build()
override fun init(context: JadxPluginContext) {
val dexInputPlugin = context.plugins().getInstance(DexInputPlugin::class.java)
context.addCodeInput(XapkCustomCodeInput(dexInputPlugin, context.zipReader))
context.decompiler.addCustomResourcesLoader(XapkCustomResourcesLoader(context.zipReader))
}
}
@@ -1,17 +0,0 @@
package jadx.plugins.input.xapk
import com.google.gson.annotations.SerializedName
data class XapkManifest(
@SerializedName("xapk_version")
var xapkVersion: Int = 0,
@SerializedName("split_apks")
var splitApks: List<SplitApk> = listOf(),
) {
data class SplitApk(
@SerializedName("file")
var file: String = "",
@SerializedName("id")
var id: String = "",
)
}
@@ -1,27 +0,0 @@
package jadx.plugins.input.xapk
import jadx.core.utils.GsonUtils.buildGson
import jadx.core.utils.files.FileUtils
import jadx.zip.ZipReader
import java.io.File
import java.io.InputStreamReader
object XapkUtils {
fun getManifest(file: File, zipReader: ZipReader): XapkManifest? {
if (!FileUtils.isZipFile(file)) return null
try {
zipReader.open(file).use { zip ->
val manifestEntry = zip.searchEntry("manifest.json") ?: return null
return InputStreamReader(manifestEntry.inputStream).use {
buildGson().fromJson(it, XapkManifest::class.java)
}
}
} catch (e: Exception) {
return null
}
}
fun isSupported(manifest: XapkManifest): Boolean {
return manifest.xapkVersion == 2 && manifest.splitApks.isNotEmpty()
}
}
@@ -0,0 +1,22 @@
package jadx.plugins.input.xapk.data;
public class SplitApk {
private String file;
private String id;
public String getFile() {
return file;
}
public void setFile(String file) {
this.file = file;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
}
@@ -0,0 +1,34 @@
package jadx.plugins.input.xapk.data;
import java.nio.file.Path;
import java.util.List;
public class XApkData {
private final XApkManifest manifest;
private final Path tmpDir;
private final List<Path> files;
private final List<Path> apks;
public XApkData(XApkManifest manifest, Path tmpDir, List<Path> apks, List<Path> files) {
this.manifest = manifest;
this.tmpDir = tmpDir;
this.apks = apks;
this.files = files;
}
public List<Path> getApks() {
return apks;
}
public List<Path> getFiles() {
return files;
}
public XApkManifest getManifest() {
return manifest;
}
public Path getTmpDir() {
return tmpDir;
}
}
@@ -0,0 +1,28 @@
package jadx.plugins.input.xapk.data;
import java.util.List;
import com.google.gson.annotations.SerializedName;
public class XApkManifest {
@SerializedName("xapk_version")
int version;
@SerializedName("split_apks")
List<SplitApk> splitApks;
public List<SplitApk> getSplitApks() {
return splitApks;
}
public void setSplitApks(List<SplitApk> splitApks) {
this.splitApks = splitApks;
}
public int getVersion() {
return version;
}
public void setVersion(int version) {
this.version = version;
}
}
@@ -1 +1 @@
jadx.plugins.input.xapk.XapkInputPlugin
jadx.plugins.input.xapk.XApkInputPlugin