From 2e1be8f9628cba584f8473dcd3b2c0d38f08cc7c Mon Sep 17 00:00:00 2001 From: Nathan Brizendine Date: Mon, 8 Jan 2024 15:21:50 +0100 Subject: [PATCH] Add support for passing in APKS files externally --- .../java/com/spotify/ruler/cli/RulerCli.kt | 68 +++++++++------ .../spotify/ruler/common/apk/ApkCreator.kt | 86 +++++++++++++++---- 2 files changed, 114 insertions(+), 40 deletions(-) diff --git a/ruler-cli/src/main/java/com/spotify/ruler/cli/RulerCli.kt b/ruler-cli/src/main/java/com/spotify/ruler/cli/RulerCli.kt index 5447b8a..03e26d4 100644 --- a/ruler-cli/src/main/java/com/spotify/ruler/cli/RulerCli.kt +++ b/ruler-cli/src/main/java/com/spotify/ruler/cli/RulerCli.kt @@ -26,6 +26,8 @@ import com.spotify.ruler.common.BaseRulerTask import com.spotify.ruler.common.FEATURE_NAME import com.spotify.ruler.common.apk.ApkCreator import com.spotify.ruler.common.apk.InjectedToolApkCreator +import com.spotify.ruler.common.apk.parseSplitApkDirectory +import com.spotify.ruler.common.apk.unzipFile import com.spotify.ruler.common.dependency.ArtifactResult import com.spotify.ruler.common.dependency.DependencyComponent import com.spotify.ruler.common.dependency.DependencyEntry @@ -40,6 +42,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import java.io.File +import java.nio.file.Files import java.util.logging.Level import java.util.logging.Logger @@ -47,7 +50,8 @@ class RulerCli : CliktCommand(), BaseRulerTask { private val logger = Logger.getLogger("Ruler") private val dependencyMap by option().file().required() private val rulerConfigJson by option().file().required() - private val apkFile by option().file().required() + private val apkFile by option().file() + private val bundleFile by option().file() private val reportDir by option().file(canBeDir = true).required() private val mappingFile: File? by option().file() private val resourceMappingFile: File? by option().file() @@ -69,7 +73,7 @@ class RulerCli : CliktCommand(), BaseRulerTask { val json = Json.decodeFromStream(rulerConfigJson.inputStream()) RulerConfig( projectPath = json.projectPath, - apkFilesMap = createApkFile(json.projectPath, json.deviceSpec!!), + apkFilesMap = apkFiles(config = json), reportDir = reportDir, ownershipFile = json.ownershipFile?.let { File(it) }, staticDependenciesFile = json.staticComponentsPath?.let { File(it) }, @@ -106,6 +110,41 @@ class RulerCli : CliktCommand(), BaseRulerTask { dependencySanitizer.sanitize(entries) } + private fun apkFiles(config: JsonRulerConfig): Map> { + return if (apkFile != null) { + if (apkFile!!.extension == "apk") { + logger.log(Level.INFO, "Using APK file ${apkFile?.path}") + mapOf(FEATURE_NAME to listOf(apkFile!!)) + } else { + logger.log(Level.INFO, "Using Split APK file ${apkFile?.path}") + val directory = Files.createTempDirectory("split_apk_tmp") + unzipFile(apkFile!!, directory) + parseSplitApkDirectory(directory.toFile()) + } + } else if (bundleFile != null) { + with(if (aapt2Tool != null) { + logger.log( + Level.INFO, + "Creating InjectedToolApkCreator with ${aapt2Tool?.path}" + ) + InjectedToolApkCreator(aapt2Tool!!.toPath()) + } else { + ApkCreator(File(config.projectPath)) + } + ) { + createSplitApks( + bundleFile!!, + config.deviceSpec!!, + File(config.projectPath).resolve(File("tmp")).apply { + mkdir() + } + ) + } + } else { + throw IllegalArgumentException("No APK file or bundle file provided") + } + } + override fun provideDependencies(): Map> = dependencies override fun run() { @@ -113,7 +152,8 @@ class RulerCli : CliktCommand(), BaseRulerTask { ~~~~~ Starting Ruler ~~~~~ Using Dependency Map: ${dependencyMap.path} Using Ruler Config: ${rulerConfigJson.path} - Using App File: ${apkFile.path} + Using APK File: ${apkFile?.path} + Using Bundle File: ${bundleFile?.path} Using Proguard Mapping File: ${mappingFile?.path} Using Resource Mapping File: ${resourceMappingFile?.path} Using AAPT2: ${aapt2Tool?.path} @@ -122,28 +162,6 @@ class RulerCli : CliktCommand(), BaseRulerTask { """.trimIndent()) super.run() } - - private fun createApkFile(projectPath: String, deviceSpec: DeviceSpec): Map> { - - val apkCreator = if (aapt2Tool != null) { - InjectedToolApkCreator(aapt2Tool!!.toPath()) - } else { - ApkCreator(File(projectPath)) - } - - return if (apkFile.extension == "apk") { - mapOf(FEATURE_NAME to listOf(apkFile)) - } else { - apkCreator.createSplitApks( - apkFile, - deviceSpec, - File(projectPath).resolve(File("tmp")).apply { - mkdir() - } - ) - } - } - } @Serializable diff --git a/ruler-common/src/main/java/com/spotify/ruler/common/apk/ApkCreator.kt b/ruler-common/src/main/java/com/spotify/ruler/common/apk/ApkCreator.kt index 40110a0..1f9b5c6 100644 --- a/ruler-common/src/main/java/com/spotify/ruler/common/apk/ApkCreator.kt +++ b/ruler-common/src/main/java/com/spotify/ruler/common/apk/ApkCreator.kt @@ -1,4 +1,3 @@ - /* * Copyright 2021 Spotify AB * @@ -33,20 +32,24 @@ import com.android.utils.StdLogger import com.spotify.ruler.common.models.DeviceSpec import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import java.io.BufferedReader import java.io.File -import java.io.IOException -import java.io.InputStreamReader +import java.io.FileInputStream +import java.io.FileOutputStream import java.io.StringReader +import java.nio.file.Files import java.nio.file.Path import java.util.Optional -import java.util.concurrent.TimeUnit +import java.util.logging.Level +import java.util.logging.Logger +import java.util.zip.ZipInputStream /** * Responsible for creating APKs based on provided app bundle (AAB) files. * * @param rootDir Root directory of the Gradle project, needed to look up the path of certain binaries. */ + +const val BUFFER_SIZE = 1024 open class ApkCreator(private val rootDir: File) { private val rulerDebugKey = "rulerDebug.keystore" @@ -61,7 +64,11 @@ open class ApkCreator(private val rootDir: File) { * @param targetDir Directory where the APKs should be located. Contents of this directory will be deleted * @return Map of modules from the AAB file with all the APKs belonging to each module */ - fun createSplitApks(bundleFile: File, deviceSpec: DeviceSpec, targetDir: File): Map> { + fun createSplitApks( + bundleFile: File, + deviceSpec: DeviceSpec, + targetDir: File + ): Map> { targetDir.listFiles()?.forEach(File::deleteRecursively) // Overwrite existing files BuildApksCommand.builder() @@ -74,14 +81,7 @@ open class ApkCreator(private val rootDir: File) { .build() .execute() - val result = BuildApksResult.parseFrom(targetDir.resolve("toc.pb").readBytes()) - val variant = result.variantList.single() // We're targeting one device -> we only expect a single variant - - return variant.apkSetList.associate { apkSet -> - val moduleName = apkSet.moduleMetadata.name - val moduleSplits = apkSet.apkDescriptionList.map { targetDir.resolve(it.path) } - moduleName to moduleSplits - } + return parseSplitApkDirectory(targetDir) } /** Converts the given [deviceSpec] into a format which bundletool understands. */ @@ -134,6 +134,62 @@ open class ApkCreator(private val rootDir: File) { } } -class InjectedToolApkCreator(private val aapt2Tool: Path): ApkCreator(File("")) { +class InjectedToolApkCreator(private val aapt2Tool: Path) : ApkCreator(File("")) { override fun getAapt2Location(): Path = aapt2Tool } + +@Suppress("NestedBlockDepth") +fun unzipFile(zipFile: File, destDirectory: Path) { + val logger = Logger.getLogger("Ruler") + val buffer = ByteArray(BUFFER_SIZE) + + // Create a temporary directory + Files.createDirectories(destDirectory) + + // Create ZipInputStream to read the zip file + val zipInputStream = ZipInputStream(FileInputStream(zipFile)) + + // Loop through each entry in the zip file + var zipEntry = zipInputStream.nextEntry + while (zipEntry != null) { + val newFile = destDirectory.resolve(zipEntry.name) + logger.log(Level.INFO, "extracting $zipEntry to $newFile") + + // Create necessary directories if they don't exist + if (zipEntry.isDirectory) { + Files.createDirectories(newFile) + } else { + newFile.toFile().parentFile.mkdirs() + // Create FileOutputStream to write the file + FileOutputStream(newFile.toFile()).use { fos -> + // Read and write the data + var len = zipInputStream.read(buffer) + while (len > 0) { + fos.write(buffer, 0, len) + len = zipInputStream.read(buffer) + } + } + } + + // Move to the next entry in the zip file + zipEntry = zipInputStream.nextEntry + } + + // Close the ZipInputStream + zipInputStream.closeEntry() + zipInputStream.close() + + println("File successfully unzipped to $destDirectory") +} + +fun parseSplitApkDirectory(targetDir: File): Map> { + val result = BuildApksResult.parseFrom(targetDir.resolve("toc.pb").readBytes()) + val variant = + result.variantList.single() // We're targeting one device -> we only expect a single variant + + return variant.apkSetList.associate { apkSet -> + val moduleName = apkSet.moduleMetadata.name + val moduleSplits = apkSet.apkDescriptionList.map { targetDir.resolve(it.path) } + moduleName to moduleSplits + } +}