Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import io.embrace.android.gradle.plugin.tasks.ndk.CompressSharedObjectFilesTask

plugins {
id("java")
id("io.embrace.swazzler")
id("io.embrace.android.testplugin")
}

project.tasks.register("compressTask", CompressSharedObjectFilesTask) { task ->
task.architecturesDirectory.set(
project.layout.projectDirectory.dir("testArchitecturesDir")
)
task.compressedSharedObjectFilesDirectory.set(
project.layout.buildDirectory.dir("compressedSharedObjectFiles")
)
integrationTest.configureEmbraceTask(task)
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import io.embrace.android.gradle.plugin.tasks.ndk.HashSharedObjectFilesTask

plugins {
id("java")
id("io.embrace.swazzler")
id("io.embrace.android.testplugin")
}

// Register the hashing task
project.tasks.register("hashTask", HashSharedObjectFilesTask) { task ->
task.compressedSharedObjectFilesDirectory.set(
project.layout.projectDirectory.dir("compressedSharedObjectFiles")
)
task.architecturesToHashedSharedObjectFilesMap.set(
project.layout.buildDirectory.file("output.json")
)
integrationTest.configureEmbraceTask(task)
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package io.embrace.android.gradle.integration.testcases

import io.embrace.android.gradle.integration.framework.PluginIntegrationTestRule
import io.embrace.android.gradle.integration.framework.file
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test

/**
* Tests the compression of shared object files.
* Given an input directory structure:
* testArchitecturesDir/
* ├── arm64-v8a/
* │ ├── libexample1.so
* │ └── libexample2.so
* └── armeabi-v7a/
* ├── libexample1.so
* └── libexample2.so
*
* The task will create compressed files in:
* build/compressedSharedObjectFiles/
* ├── arm64-v8a/
* │ ├── libexample1.so (compressed)
* │ └── libexample2.so (compressed)
* └── armeabi-v7a/
* ├── libexample1.so (compressed)
* └── libexample2.so (compressed)
*/
class CompressSharedObjectFilesTaskTest {

private val expectedSharedObjectFiles = listOf("libemb-donuts.so", "libemb-crisps.so")
private val expectedArchitectures = listOf("x86_64", "x86", "armeabi-v7a", "arm64-v8a")

@Rule
@JvmField
val rule: PluginIntegrationTestRule = PluginIntegrationTestRule()

@Test
fun `verify compression reduces folder size`() {
rule.runTest(
fixture = "compress-native-libs",
task = "compressTask",
setup = { projectDir ->
assertTrue(projectDir.file("testArchitecturesDir").exists())
},
assertions = { projectDir ->
val originalSize = projectDir.file("testArchitecturesDir")
.walk()
.filter { it.isFile }
.sumOf { it.length() } // returns size in bytes

val compressedSize = projectDir.file("build/compressedSharedObjectFiles")
.walk()
.filter { it.isFile }
.sumOf { it.length() } // returns size in bytes

assertTrue(compressedSize < originalSize)
}
)
}

@Test
fun `verify compressed files maintain directory structure`() {
rule.runTest(
fixture = "compress-native-libs",
task = "compressTask",
assertions = { projectDir ->
expectedArchitectures.forEach { architecture ->
expectedSharedObjectFiles.forEach { sharedObjectFile ->
val compressedFile = projectDir.file("build/compressedSharedObjectFiles/$architecture/$sharedObjectFile")
assertTrue(compressedFile.exists())
}
}
}
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package io.embrace.android.gradle.integration.testcases

import io.embrace.android.gradle.integration.framework.PluginIntegrationTestRule
import io.embrace.android.gradle.integration.framework.file
import io.embrace.android.gradle.plugin.hash.calculateSha1ForFile
import io.embrace.android.gradle.plugin.tasks.ndk.ArchitecturesToHashedSharedObjectFilesMap
import io.embrace.android.gradle.plugin.util.serialization.MoshiSerializer
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test

/**
* Tests the hashing of compressed shared object files.
* Given compressed files in:
* compressedSharedObjectFiles/
* ├── arm64-v8a/
* │ ├── libexample1.so (compressed)
* │ └── libexample2.so (compressed)
* └── armeabi-v7a/
* ├── libexample1.so (compressed)
* └── libexample2.so (compressed)
*
* The task will create a JSON map where:
* - Keys are architecture names (e.g., "arm64-v8a")
* - Values are maps where:
* - Keys are shared object filenames (e.g., "libexample1.so")
* - Values are SHA1 hashes of the compressed files
* {
* "arm64-v8a": {
* "libexample1.so": "2a21dc0b99017d5db5960b80d94815a0fe0f3fc2",
* "libexample2.so": "3b32ed1c88128e6ec4b71b93a4926a1bf1f4gd3"
* },
* "armeabi-v7a": {
* "libexample1.so": "4c43fe2d77239f7fd5a71c91b85926b2g2g5he4",
* "libexample2.so": "5d54gf3e66340g8ge6b82da2c96a37c3h3h6if5"
* }
* }
*/
class HashSharedObjectFilesTaskTest {

private val expectedSharedObjectFiles = listOf("libemb-donuts.so", "libemb-crisps.so")
private val expectedArchitectures = listOf("x86_64", "x86", "armeabi-v7a", "arm64-v8a")

@Rule
@JvmField
val rule: PluginIntegrationTestRule = PluginIntegrationTestRule()

@Test
fun `map output is correct`() {
rule.runTest(
fixture = "hash-native-libs",
task = "hashTask",
setup = { projectDir ->
assertTrue(projectDir.file("compressedSharedObjectFiles").exists())
},
assertions = { projectDir ->
val deserializedOutputMap = MoshiSerializer().fromJson(
projectDir.file("build/output.json").readText(),
ArchitecturesToHashedSharedObjectFilesMap::class.java
).architecturesToHashedSharedObjectFiles

expectedArchitectures.forEach { architecture ->
expectedSharedObjectFiles.forEach { sharedObjectFile ->
val sha1Hash = deserializedOutputMap[architecture]?.get(sharedObjectFile)
val compressedSoFile = projectDir.file("compressedSharedObjectFiles/$architecture/$sharedObjectFile")
assertTrue(sha1Hash == calculateSha1ForFile(compressedSoFile))
}
}
}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package io.embrace.android.gradle.plugin.tasks.il2cpp
import io.embrace.android.gradle.plugin.Logger
import io.embrace.android.gradle.plugin.config.UnitySymbolsDir
import io.embrace.android.gradle.plugin.instrumentation.config.model.UnityConfig
import io.embrace.android.gradle.plugin.model.AndroidCompactedVariantData
import org.gradle.api.file.Directory
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
Expand Down Expand Up @@ -48,7 +47,7 @@ internal class UnitySymbolFilesManager {
fun getSymbolsDir(
realProjectDirectory: Directory,
projectDirectory: Directory,
unityConfig: UnityConfig?
unityConfig: UnityConfig?,
): UnitySymbolsDir {
val customArchiveName = unityConfig?.symbolsArchiveName?.takeIf { it.isNotEmpty() }
val unitySymbolsArchiveFile = getUnitySymbolsArchive(projectDirectory, customArchiveName)
Expand Down Expand Up @@ -81,16 +80,14 @@ internal class UnitySymbolFilesManager {
*/
fun getSymbolFiles(
unitySymbolsDir: UnitySymbolsDir,
buildDir: Directory,
variantData: AndroidCompactedVariantData
decompressedUnitySharedObjectDirectory: Directory,
): Array<File> {
val symbolsDir = unitySymbolsDir.unitySymbolsDir ?: return emptyArray()

return if (unitySymbolsDir.isDirPresent() && unitySymbolsDir.zippedSymbols) {
extractSoFilesFromZipFile(
symbolsDir,
buildDir,
variantData
decompressedUnitySharedObjectDirectory
)
} else {
val files = symbolsDir.listFiles()
Expand All @@ -109,7 +106,7 @@ internal class UnitySymbolFilesManager {
*/
private fun getUnitySymbolsArchive(
projectDir: Directory,
customArchiveName: String?
customArchiveName: String?,
): File? {
val parentDir = projectDir.asFile.parentFile ?: return null
// Search symbols zip file at same level of exported project folder
Expand All @@ -132,7 +129,7 @@ internal class UnitySymbolFilesManager {

private fun File.searchSymbolsArchive(
defaultArchiveName: String,
customArchiveName: String?
customArchiveName: String?,
): File? {
// Use the last file found that matches the criteria for being a Unity symbols file
var foundFile: File? = null
Expand Down Expand Up @@ -184,10 +181,9 @@ internal class UnitySymbolFilesManager {

private fun extractSoFilesFromZipFile(
objFolder: File,
buildDir: Directory,
variantData: AndroidCompactedVariantData
decompressedUnitySharedObjectDirectory: Directory,
): Array<File> {
val decompressedFile = File(getUncompressedUnityFilesPath(buildDir, variantData))
val decompressedFile = decompressedUnitySharedObjectDirectory.asFile

// Remove previous files from build intermediates folder
try {
Expand All @@ -209,7 +205,7 @@ internal class UnitySymbolFilesManager {
}

val buffer = ByteArray(BUFFER_SIZE)
val outDir: Path = Paths.get(getUncompressedUnityFilesPath(buildDir, variantData))
val outDir: Path = Paths.get(decompressedFile.path)

return try {
FileInputStream(objFolder).use { fis ->
Expand All @@ -234,7 +230,7 @@ internal class UnitySymbolFilesManager {
stream: ZipInputStream,
outDir: Path,
buffer: ByteArray,
decompressedFile: File
decompressedFile: File,
): Array<File> {
var entry = stream.nextEntry
while (entry != null) {
Expand Down Expand Up @@ -262,19 +258,4 @@ internal class UnitySymbolFilesManager {
}
return decompressedFile.listFiles() ?: emptyArray()
}

private fun getUncompressedUnityFilesPath(
buildDir: Directory,
variantData: AndroidCompactedVariantData
): String {
return "${buildDir.asFile.absoluteFile}/intermediates/embrace/unity/${getMappingFileFolder(variantData)}"
}

private fun getMappingFileFolder(variantData: AndroidCompactedVariantData): String {
return if (variantData.flavorName.isBlank()) {
variantData.buildTypeName
} else {
"${variantData.flavorName}/${variantData.buildTypeName}"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.embrace.android.gradle.plugin.tasks.ndk

import com.squareup.moshi.JsonClass

/**
* Data class representing a mapping of architectures to their shared object files and corresponding hashes.
* The structure is:
* - Keys are architecture names (e.g., "arm64-v8a")
* - Values are maps where:
* - Keys are shared object filenames (e.g., "libexample1.so")
* - Values are SHA1 hashes of the compressed files
*/
@JsonClass(generateAdapter = true)
data class ArchitecturesToHashedSharedObjectFilesMap(
val architecturesToHashedSharedObjectFiles: Map<String, Map<String, String>>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package io.embrace.android.gradle.plugin.tasks.ndk

import io.embrace.android.gradle.plugin.hash.calculateSha1ForFile
import io.embrace.android.gradle.plugin.tasks.EmbraceTaskImpl
import io.embrace.android.gradle.plugin.util.compression.ZstdFileCompressor
import io.embrace.android.gradle.plugin.util.serialization.MoshiSerializer
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.model.ObjectFactory
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.SkipWhenEmpty
import org.gradle.api.tasks.TaskAction
import java.io.File
import javax.inject.Inject

/**
* Given a directory containing architecture directories (arm64-v8a, armeabi-v7a, etc.) with shared object files (.so files),
* this task:
* 1. Compresses each shared object file using Zstd compression
* 2. Stores the compressed files in compressedSharedObjectFilesDirectory, preserving the architecture directory structure
Comment on lines +21 to +22
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't realise we were doing Zstd compression on SO files. IMO it'd be worth breaking that into a separate task that takes the uncompressed SO files as an input and the compressed files as an output. Sorry for the confusion from our discussion yesterday

* 3. Calculates SHA1 hashes of the compressed files
* 4. Creates a JSON mapping of architectures to a map of shared object filenames to their hashes
*/
abstract class CompressAndHashSharedObjectFilesTask @Inject constructor(
objectFactory: ObjectFactory,
) : EmbraceTaskImpl(objectFactory) {

private val serializer = MoshiSerializer()
private val compressor = ZstdFileCompressor()

@get:InputDirectory
@get:SkipWhenEmpty
val architecturesDirectory: DirectoryProperty = objectFactory.directoryProperty()

@get:OutputDirectory
val compressedSharedObjectFilesDirectory: DirectoryProperty = objectFactory.directoryProperty()

@get:OutputFile
val architecturesToHashedSharedObjectFilesMap: RegularFileProperty = objectFactory.fileProperty()

@TaskAction
fun onRun() {
val outputMap = createOutputMap()

// Serialize the map to JSON and write it to the output file
val serializableMap = ArchitecturesToHashedSharedObjectFilesMap(outputMap)
architecturesToHashedSharedObjectFilesMap.get().asFile.outputStream().use { outputStream ->
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
architecturesToHashedSharedObjectFilesMap.get().asFile.outputStream().use { outputStream ->
architecturesToHashedSharedObjectFilesMap.get().asFile.outputStream().use { outputStream ->

It'd be worth using a buffered stream here to improve perf

serializer.toJson(serializableMap, ArchitecturesToHashedSharedObjectFilesMap::class.java, outputStream)
}
}

private fun createOutputMap(): Map<String, Map<String, String>> =
architecturesDirectory.get().asFile
.listFiles()
?.filter { it.isDirectory && it.listFiles()?.isNotEmpty() == true }
?.associate { archDir ->
archDir.name to mapSharedObjectsToHashes(archDir)
} ?: error("Compression and hashing of shared object files failed")

private fun mapSharedObjectsToHashes(architectureDir: File): Map<String, String> {
val outputDirectory = compressedSharedObjectFilesDirectory.dir(architectureDir.name).get().asFile
val sharedObjectFiles = architectureDir.listFiles { file ->
file.name.endsWith(".so")
} ?: error("Shared object files not found") // Should never happen

return sharedObjectFiles.associate { it.name to compressAndHashSharedObjectFile(it, outputDirectory) }
}

private fun compressAndHashSharedObjectFile(sharedObjectFile: File, outputDirectory: File): String {
val compressedFile = File(outputDirectory, sharedObjectFile.name)
return compressor.compress(sharedObjectFile, compressedFile)?.let {
calculateSha1ForFile(it)
} ?: error("Compression and hashing of shared object file ${sharedObjectFile.name} failed")
}

companion object {
const val NAME: String = "compressAndHashSharedObjectFiles"
}
}
Loading
Loading