mirror of
https://github.com/f-droid/fdroidclient.git
synced 2026-04-20 14:57:15 -04:00
[index] Add IndexV1Creator for Android
This also can serve as a base for an IndexV2Creator later.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
117
index/src/androidMain/kotlin/org/fdroid/index/IndexCreator.kt
Normal file
117
index/src/androidMain/kotlin/org/fdroid/index/IndexCreator.kt
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user