From 72ffcbbc44e2931890a69c070220792d991319f7 Mon Sep 17 00:00:00 2001 From: oSumAtrIX Date: Fri, 11 Aug 2023 00:54:18 +0200 Subject: [PATCH] feat: decode `9patch` files on Android --- brut.apktool/apktool-lib/build.gradle.kts | 16 +++ .../brut/androlib/res/ResourcesDecoder.java | 7 +- .../Res9patchAndroidStreamDecoder.java | 135 ++++++++++++++++++ .../androlib/decode/MissingDiv9PatchTest.java | 5 +- .../src/main/java/brut/util/OSDetection.java | 9 ++ 5 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 brut.apktool/apktool-lib/src/main/java/brut/androlib/res/decoder/Res9patchAndroidStreamDecoder.java diff --git a/brut.apktool/apktool-lib/build.gradle.kts b/brut.apktool/apktool-lib/build.gradle.kts index 9e7295b4..349c5705 100644 --- a/brut.apktool/apktool-lib/build.gradle.kts +++ b/brut.apktool/apktool-lib/build.gradle.kts @@ -11,6 +11,20 @@ val xmlunitVersion: String by rootProject.extra val gitRevision: String by rootProject.extra val apktoolVersion: String by rootProject.extra +// region Determine Android SDK location + +val sdkRoot: String? = System.getenv("ANDROID_SDK_ROOT") +val androidJarPath: String = if (sdkRoot == null) { + GradleException("Missing ANDROID_SDK_ROOT").printStackTrace() + + "com.google.android:android:4.1.1.4" +} else { + val androidVersion = 33 + File("$sdkRoot/platforms/android-$androidVersion/android.jar").path +} + +// endregion + tasks { processResources { from("src/main/resources/properties") { @@ -49,4 +63,6 @@ dependencies { testImplementation("junit:junit:$junitVersion") testImplementation("org.xmlunit:xmlunit-legacy:$xmlunitVersion") + + compileOnly(files(androidJarPath)) } diff --git a/brut.apktool/apktool-lib/src/main/java/brut/androlib/res/ResourcesDecoder.java b/brut.apktool/apktool-lib/src/main/java/brut/androlib/res/ResourcesDecoder.java index 436f65cc..4cfbd085 100644 --- a/brut.apktool/apktool-lib/src/main/java/brut/androlib/res/ResourcesDecoder.java +++ b/brut.apktool/apktool-lib/src/main/java/brut/androlib/res/ResourcesDecoder.java @@ -28,6 +28,7 @@ import brut.androlib.res.xml.ResXmlPatcher; import brut.directory.Directory; import brut.directory.DirectoryException; import brut.directory.FileDirectory; +import brut.util.OSDetection; import org.xmlpull.v1.XmlSerializer; import java.io.*; @@ -148,7 +149,11 @@ public class ResourcesDecoder { ResStreamDecoderContainer decoders = new ResStreamDecoderContainer(); decoders.setDecoder("raw", new ResRawStreamDecoder()); - decoders.setDecoder("9patch", new Res9patchStreamDecoder()); + + decoders.setDecoder( + "9patch", + OSDetection.isAndroid() ? new Res9patchAndroidStreamDecoder() : new Res9patchStreamDecoder() + ); AXmlResourceParser axmlParser = new AXmlResourceParser(mResTable); decoders.setDecoder("xml", new XmlPullStreamDecoder(axmlParser, getResXmlSerializer())); diff --git a/brut.apktool/apktool-lib/src/main/java/brut/androlib/res/decoder/Res9patchAndroidStreamDecoder.java b/brut.apktool/apktool-lib/src/main/java/brut/androlib/res/decoder/Res9patchAndroidStreamDecoder.java new file mode 100644 index 00000000..801b05e7 --- /dev/null +++ b/brut.apktool/apktool-lib/src/main/java/brut/androlib/res/decoder/Res9patchAndroidStreamDecoder.java @@ -0,0 +1,135 @@ +package brut.androlib.res.decoder; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import brut.androlib.exceptions.AndrolibException; +import brut.androlib.exceptions.CantFind9PatchChunkException; +import brut.androlib.res.data.ninepatch.NinePatchData; +import brut.androlib.res.data.ninepatch.OpticalInset; +import brut.util.ExtDataInput; +import org.apache.commons.io.IOUtils; + +import java.io.*; + +public class Res9patchAndroidStreamDecoder implements ResStreamDecoder { + public void decode(InputStream in, OutputStream out) throws AndrolibException { + try { + byte[] data = IOUtils.toByteArray(in); + + if (data.length == 0) { + return; + } + Bitmap bm = BitmapFactory.decodeByteArray(data, 0, data.length); + int width = bm.getWidth(), height = bm.getHeight(); + + Bitmap outImg = Bitmap.createBitmap(width + 2, height + 2, bm.getConfig()); + + for (int w = 0; w < width; w++) + for (int h = 0; h < height; h++) outImg.setPixel(w + 1, h + 1, bm.getPixel(w, h)); + + NinePatchData np = getNinePatch(data); + drawHLineA(outImg, height + 1, np.padLeft + 1, width - np.padRight); + drawVLineA(outImg, width + 1, np.padTop + 1, height - np.padBottom); + + int[] xDivs = np.xDivs; + if (xDivs.length == 0) { + drawHLineA(outImg, 0, 1, width); + } else { + for (int i = 0; i < xDivs.length; i += 2) { + drawHLineA(outImg, 0, xDivs[i] + 1, xDivs[i + 1]); + } + } + + int[] yDivs = np.yDivs; + if (yDivs.length == 0) { + drawVLineA(outImg, 0, 1, height); + } else { + for (int i = 0; i < yDivs.length; i += 2) { + drawVLineA(outImg, 0, yDivs[i] + 1, yDivs[i + 1]); + } + } + + // Some images additionally use Optical Bounds + // https://developer.android.com/about/versions/android-4.3.html#OpticalBounds + try { + OpticalInset oi = getOpticalInset(data); + + for (int i = 0; i < oi.layoutBoundsLeft; i++) { + int x = 1 + i; + outImg.setPixel(x, height + 1, OI_COLOR); + } + + for (int i = 0; i < oi.layoutBoundsRight; i++) { + int x = width - i; + outImg.setPixel(x, height + 1, OI_COLOR); + } + + for (int i = 0; i < oi.layoutBoundsTop; i++) { + int y = 1 + i; + outImg.setPixel(width + 1, y, OI_COLOR); + } + + for (int i = 0; i < oi.layoutBoundsBottom; i++) { + int y = height - i; + outImg.setPixel(width + 1, y, OI_COLOR); + } + } catch (CantFind9PatchChunkException t) { + // This chunk might not exist + } + + outImg.compress(Bitmap.CompressFormat.PNG, 100, out); + bm.recycle(); + outImg.recycle(); + } catch (IOException ex) { + throw new AndrolibException(ex); + } + } + + private NinePatchData getNinePatch(byte[] data) throws AndrolibException, + IOException { + ExtDataInput di = new ExtDataInput(new ByteArrayInputStream(data)); + find9patchChunk(di, NP_CHUNK_TYPE); + return NinePatchData.decode(di); + } + + private OpticalInset getOpticalInset(byte[] data) throws AndrolibException, + IOException { + ExtDataInput di = new ExtDataInput(new ByteArrayInputStream(data)); + find9patchChunk(di, OI_CHUNK_TYPE); + return OpticalInset.decode(di); + } + + private void find9patchChunk(DataInput di, int magic) throws AndrolibException, + IOException { + di.skipBytes(8); + while (true) { + int size; + try { + size = di.readInt(); + } catch (IOException ex) { + throw new CantFind9PatchChunkException("Cant find nine patch chunk", ex); + } + if (di.readInt() == magic) { + return; + } + di.skipBytes(size + 4); + } + } + + private void drawHLineA(Bitmap bm, int y, int x1, int x2) { + for (int x = x1; x <= x2; x++) { + bm.setPixel(x, y, NP_COLOR); + } + } + + private void drawVLineA(Bitmap bm, int x, int y1, int y2) { + for (int y = y1; y <= y2; y++) { + bm.setPixel(x, y, NP_COLOR); + } + } + + private static final int NP_CHUNK_TYPE = 0x6e705463; // npTc + private static final int OI_CHUNK_TYPE = 0x6e704c62; // npLb + private static final int NP_COLOR = 0xff000000; + private static final int OI_COLOR = 0xffff0000; +} diff --git a/brut.apktool/apktool-lib/src/test/java/brut/androlib/decode/MissingDiv9PatchTest.java b/brut.apktool/apktool-lib/src/test/java/brut/androlib/decode/MissingDiv9PatchTest.java index 3f493c77..67eeea31 100644 --- a/brut.apktool/apktool-lib/src/test/java/brut/androlib/decode/MissingDiv9PatchTest.java +++ b/brut.apktool/apktool-lib/src/test/java/brut/androlib/decode/MissingDiv9PatchTest.java @@ -18,10 +18,13 @@ package brut.androlib.decode; import brut.androlib.BaseTest; import brut.androlib.TestUtils; +import brut.androlib.res.decoder.Res9patchAndroidStreamDecoder; import brut.androlib.res.decoder.Res9patchStreamDecoder; +import brut.androlib.res.decoder.ResStreamDecoder; import brut.common.BrutException; import brut.directory.ExtFile; import brut.util.OS; +import brut.util.OSDetection; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; @@ -51,7 +54,7 @@ public class MissingDiv9PatchTest extends BaseTest { InputStream inputStream = getFileInputStream(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - Res9patchStreamDecoder decoder = new Res9patchStreamDecoder(); + ResStreamDecoder decoder = OSDetection.isAndroid() ? new Res9patchAndroidStreamDecoder() : new Res9patchStreamDecoder(); decoder.decode(inputStream, outputStream); BufferedImage image = ImageIO.read(new ByteArrayInputStream(outputStream.toByteArray())); diff --git a/brut.j.util/src/main/java/brut/util/OSDetection.java b/brut.j.util/src/main/java/brut/util/OSDetection.java index 2f7eae5c..ec7921de 100644 --- a/brut.j.util/src/main/java/brut/util/OSDetection.java +++ b/brut.j.util/src/main/java/brut/util/OSDetection.java @@ -32,6 +32,15 @@ public class OSDetection { return (OS.contains("nix") || OS.contains("nux") || OS.contains("aix") || (OS.contains("sunos"))); } + public static boolean isAndroid() { + try { + Class.forName("android.app.Activity"); + return true; + } catch (ClassNotFoundException ignored) { + return false; + } + } + public static boolean is64Bit() { if (isWindows()) { String arch = System.getenv("PROCESSOR_ARCHITECTURE");