diff --git a/index/build.gradle b/index/build.gradle index 75c9e4297..ab946f853 100644 --- a/index/build.gradle +++ b/index/build.gradle @@ -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 diff --git a/index/src/androidAndroidTest/kotlin/org/fdroid/index/v1/IndexV1CreatorTest.kt b/index/src/androidAndroidTest/kotlin/org/fdroid/index/v1/IndexV1CreatorTest.kt new file mode 100644 index 000000000..0ea809f0d --- /dev/null +++ b/index/src/androidAndroidTest/kotlin/org/fdroid/index/v1/IndexV1CreatorTest.kt @@ -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)) + } +} diff --git a/index/src/androidMain/kotlin/org/fdroid/index/IndexCreator.kt b/index/src/androidMain/kotlin/org/fdroid/index/IndexCreator.kt new file mode 100644 index 000000000..7006eb5d3 --- /dev/null +++ b/index/src/androidMain/kotlin/org/fdroid/index/IndexCreator.kt @@ -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( + protected val packageManager: PackageManager, + protected val repoDir: File, + protected val packageNames: Set, +) { + + 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 { + val apkJar = JarFile(packageInfo.applicationInfo.publicSourceDir) + val abis = HashSet() + 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() + } + +} diff --git a/index/src/androidMain/kotlin/org/fdroid/index/IndexUtils.kt b/index/src/androidMain/kotlin/org/fdroid/index/IndexUtils.kt index 4daefecb4..68df42d82 100644 --- a/index/src/androidMain/kotlin/org/fdroid/index/IndexUtils.kt +++ b/index/src/androidMain/kotlin/org/fdroid/index/IndexUtils.kt @@ -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() + } + } + } diff --git a/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Creator.kt b/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Creator.kt new file mode 100644 index 000000000..1d1686e9f --- /dev/null +++ b/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Creator.kt @@ -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, + private val repo: RepoV1, +) : IndexCreator(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(packageNames.size) + val packages = HashMap>(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, + packages: HashMap>, + ) { + @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(), + ) + } +} diff --git a/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Verifier.kt b/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Verifier.kt index 28d2fd3f9..14deca174 100644 --- a/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Verifier.kt +++ b/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Verifier.kt @@ -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" /**