[index] Add IndexV1Creator for Android

This also can serve as a base for an IndexV2Creator later.
This commit is contained in:
Torsten Grote
2022-05-31 11:41:33 -03:00
parent 9b947f7052
commit 6c3184abd9
6 changed files with 285 additions and 1 deletions

View File

@@ -72,6 +72,12 @@ kotlin {
implementation 'junit:junit:4.13.2'
}
}
androidAndroidTest {
dependencies {
implementation 'androidx.test:runner:1.4.0'
implementation 'androidx.test.ext:junit:1.1.3'
}
}
nativeMain {
dependencies {
}
@@ -91,6 +97,9 @@ android {
defaultConfig {
minSdkVersion 21
consumerProguardFiles 'consumer-rules.pro'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments disableAnalytics: 'true'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8

View File

@@ -0,0 +1,41 @@
package org.fdroid.index.v1
import android.content.Context
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.fdroid.index.IndexParser
import org.fdroid.test.TestDataMinV1
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import java.io.File
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
internal class IndexV1CreatorTest {
@get:Rule
var tmpFolder: TemporaryFolder = TemporaryFolder()
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun test() {
val repoDir = tmpFolder.newFolder()
val repo = TestDataMinV1.repo
val packageNames = context.packageManager.getInstalledPackages(0).filter {
(it.applicationInfo.flags and FLAG_SYSTEM == 0) and (Random.nextInt(0, 3) == 0)
}.map { it.packageName }.toSet()
val indexCreator = IndexV1Creator(context.packageManager, repoDir, packageNames, repo)
val indexV1 = indexCreator.createRepo()
val indexFile = File(repoDir, JSON_FILE_NAME)
assertTrue(indexFile.exists())
val indexStr = indexFile.readBytes().decodeToString()
assertEquals(indexV1, IndexParser.parseV1(indexStr))
}
}

View File

@@ -0,0 +1,117 @@
package org.fdroid.index
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat.PNG
import android.graphics.Bitmap.Config.ARGB_8888
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.system.Os.symlink
import org.fdroid.index.IndexUtils.getVersionCode
import org.fdroid.index.IndexUtils.toHex
import java.io.File
import java.io.IOException
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.util.jar.JarFile
import java.util.regex.Pattern
public abstract class IndexCreator<T>(
protected val packageManager: PackageManager,
protected val repoDir: File,
protected val packageNames: Set<String>,
) {
private val iconDir = File(repoDir, "icons")
private val iconDirs =
listOf("icons-120", "icons-160", "icons-240", "icons-320", "icons-480", "icons-640")
private val nativeCodePattern = Pattern.compile("^lib/([a-z0-9-]+)/.*")
init {
require(repoDir.isDirectory) { "$repoDir is not a directory" }
require(repoDir.canWrite()) { "Can not write to $repoDir" }
}
@Throws(IOException::class)
public abstract fun createRepo(): T
protected fun prepareIconFolders() {
iconDir.mkdir()
iconDirs.forEach { dir ->
val file = File(repoDir, dir)
if (!file.exists()) symlink(iconDir.absolutePath, file.absolutePath)
}
}
/**
* Extracts the icon from an APK and writes it to the repo as a PNG.
* @return the name of the written icon file.
*/
protected fun copyIconToRepo(packageInfo: PackageInfo): String {
val packageName = packageInfo.packageName
val versionCode = packageInfo.getVersionCode()
val drawable = packageInfo.applicationInfo.loadIcon(packageManager)
val bitmap: Bitmap
if (drawable is BitmapDrawable) {
bitmap = drawable.bitmap
} else {
bitmap =
Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, ARGB_8888)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
}
val iconName = "${packageName}_$versionCode.png"
File(iconDir, iconName).outputStream().use { outputStream ->
bitmap.compress(PNG, 100, outputStream)
}
return iconName
}
/**
* Symlinks the APK to the repo. Does not support split APKs.
* @return the name of the linked/copied APK file.
*/
protected fun copyApkToRepo(packageInfo: PackageInfo): File {
val packageName = packageInfo.packageName
val versionCode = packageInfo.getVersionCode()
val apkName = "${packageName}_$versionCode.apk"
val apkFile = File(repoDir, apkName)
symlink(packageInfo.applicationInfo.publicSourceDir, apkFile.absolutePath)
return apkFile
}
protected fun hashFile(file: File): String {
val messageDigest: MessageDigest = try {
MessageDigest.getInstance("SHA-256")
} catch (e: NoSuchAlgorithmException) {
throw AssertionError(e)
}
file.inputStream().use { inputStream ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = inputStream.read(buffer)
while (bytes >= 0) {
messageDigest.update(buffer, 0, bytes)
bytes = inputStream.read(buffer)
}
}
return messageDigest.digest().toHex()
}
protected fun parseNativeCode(packageInfo: PackageInfo): List<String> {
val apkJar = JarFile(packageInfo.applicationInfo.publicSourceDir)
val abis = HashSet<String>()
val jarEntries = apkJar.entries()
while (jarEntries.hasMoreElements()) {
val jarEntry = jarEntries.nextElement()
val matcher = nativeCodePattern.matcher(jarEntry.name)
if (matcher.matches()) {
val group = matcher.group(1)
if (group != null) abis.add(group)
}
}
return abis.toList()
}
}

View File

@@ -1,5 +1,7 @@
package org.fdroid.index
import android.content.pm.PackageInfo
import android.os.Build
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
@@ -34,4 +36,13 @@ public object IndexUtils {
return messageDigest.digest()
}
internal fun PackageInfo.getVersionCode(): Long {
return if (Build.VERSION.SDK_INT >= 28) {
longVersionCode
} else {
@Suppress("DEPRECATION") // we use the new one above, if available
versionCode.toLong()
}
}
}

View File

@@ -0,0 +1,106 @@
package org.fdroid.index.v1
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_PERMISSIONS
import android.content.pm.PackageManager.GET_SIGNATURES
import android.os.Build.VERSION.SDK_INT
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.encodeToStream
import org.fdroid.index.IndexCreator
import org.fdroid.index.IndexParser
import org.fdroid.index.IndexUtils.getPackageSignature
import org.fdroid.index.IndexUtils.getVersionCode
import java.io.File
import java.io.IOException
/**
* Creates a deprecated V1 index from the given [packageNames]
* with information obtained from the [PackageManager].
*
* Attention: While [createRepo] creates `index-v1.json`,
* it does **not** create a signed `index-v1.jar`.
* The caller needs to handle this last signing step themselves.
*/
public class IndexV1Creator(
packageManager: PackageManager,
repoDir: File,
packageNames: Set<String>,
private val repo: RepoV1,
) : IndexCreator<IndexV1>(packageManager, repoDir, packageNames) {
@Throws(IOException::class)
@OptIn(ExperimentalSerializationApi::class)
public override fun createRepo(): IndexV1 {
prepareIconFolders()
val index = createIndex()
val indexJsonFile = File(repoDir, JSON_FILE_NAME)
indexJsonFile.outputStream().use { outputStream ->
IndexParser.json.encodeToStream(index, outputStream)
}
return index
}
private fun createIndex(): IndexV1 {
val apps = ArrayList<AppV1>(packageNames.size)
val packages = HashMap<String, List<PackageV1>>(packageNames.size)
for (packageName in packageNames) {
addApp(packageName, apps, packages)
}
return IndexV1(
repo = repo,
apps = apps,
packages = packages,
)
}
private fun addApp(
packageName: String,
apps: ArrayList<AppV1>,
packages: HashMap<String, List<PackageV1>>,
) {
@Suppress("DEPRECATION")
val flags = GET_SIGNATURES or GET_PERMISSIONS
@Suppress("PackageManagerGetSignatures")
val packageInfo = packageManager.getPackageInfo(packageName, flags)
apps.add(getApp(packageInfo))
packages[packageName] = listOf(getPackage(packageInfo))
}
private fun getApp(packageInfo: PackageInfo): AppV1 {
val icon = copyIconToRepo(packageInfo)
return AppV1(
packageName = packageInfo.packageName,
name = packageInfo.applicationInfo.loadLabel(packageManager).toString(),
license = "Unknown",
icon = icon,
)
}
private fun getPackage(packageInfo: PackageInfo): PackageV1 {
val apk = copyApkToRepo(packageInfo)
val hash = hashFile(apk)
val apkName = apk.name
val signer = getPackageSignature(packageInfo.signatures[0].toByteArray())
return PackageV1(
packageName = packageInfo.packageName,
versionCode = packageInfo.getVersionCode(),
versionName = packageInfo.versionName ?: packageInfo.getVersionCode().toString(),
apkName = apkName,
hash = hash,
hashType = "sha256",
sig = signer, // should be some custom MD5/hex thing, but it works without...
signer = signer,
size = File(packageInfo.applicationInfo.publicSourceDir).length(),
minSdkVersion = if (SDK_INT >= 24) packageInfo.applicationInfo.minSdkVersion else null,
targetSdkVersion = packageInfo.applicationInfo.targetSdkVersion,
usesPermission = packageInfo.requestedPermissions?.map {
PermissionV1(it)
} ?: emptyList(),
usesPermission23 = emptyList(),
nativeCode = parseNativeCode(packageInfo),
features = packageInfo.reqFeatures?.map { it.name } ?: emptyList(),
)
}
}

View File

@@ -5,7 +5,7 @@ import org.fdroid.index.SigningException
import java.io.File
import java.util.jar.Attributes
private const val JSON_FILE_NAME = "index-v1.json"
internal const val JSON_FILE_NAME = "index-v1.json"
private const val SUPPORTED_DIGEST = "SHA1-Digest"
/**