diff --git a/app/src/main/java/dev/mi6e4ka/openstore/internal/installer/ApkInstaller.kt b/app/src/main/java/dev/mi6e4ka/openstore/internal/installer/ApkInstaller.kt index 0c0c97e..0f958ed 100644 --- a/app/src/main/java/dev/mi6e4ka/openstore/internal/installer/ApkInstaller.kt +++ b/app/src/main/java/dev/mi6e4ka/openstore/internal/installer/ApkInstaller.kt @@ -21,6 +21,7 @@ import java.io.FileOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -28,7 +29,7 @@ import okhttp3.Request import java.io.BufferedOutputStream import java.io.IOException import net.jpountz.xxhash.XXHashFactory -import java.security.MessageDigest +import java.util.Collections import java.util.concurrent.TimeUnit import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -58,94 +59,97 @@ class ApkInstaller(private val context: Context) { val downloadRunner = CoroutineScope(Dispatchers.IO).launch { val totalBytesToDownload = files.sumOf { it.size } val totalBytesDownloaded = AtomicLong(0) - val downloadedFiles = mutableListOf() + val downloadedFiles = Collections.synchronizedList(mutableListOf()) try { - val downloadJobs = files.map { file -> - async { - val fileName = file.url.split("/").last() - val apkFile = getApkFile(fileName) - downloadedFiles.add(apkFile) - - if (apkFile.exists() && apkFile.length() == file.size) { - if (verifyFileHash(apkFile, file.hash)) { - return@async apkFile + val resultingFiles = coroutineScope { + files.map { file -> + async { + val fileName = file.url.split("/").last() + val apkFile = getApkFile(fileName) + if (!downloadedFiles.contains(apkFile)) { + downloadedFiles.add(apkFile) + } + + if (apkFile.exists() && apkFile.length() == file.size) { + if (verifyFileHash(apkFile, file.hash)) { + return@async apkFile + } + apkFile.delete() + } + + val request = Request.Builder().url(file.url).build() + val call = okHttpClient.newCall(request) + + call.execute().use { response -> + if (!response.isSuccessful) throw IOException("Ошибка загрузки: ${response.code}") + + val responseBody = + response.body ?: throw IOException("Пустой ответ от сервера") + + responseBody.byteStream().use { inputStream -> + FileOutputStream(apkFile).use { outputStream -> + val buffer = ByteArray(8192) + var bytesRead: Int + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + if (call.isCanceled()) { + throw IOException("Canceled") + } + + outputStream.write(buffer, 0, bytesRead) + + val newTotalDownloaded = + totalBytesDownloaded.addAndFetch(bytesRead.toLong()) + val percent = if (totalBytesToDownload > 0) { + (newTotalDownloaded * 100 / totalBytesToDownload).toInt() + } else { + 0 + } + + withContext(Dispatchers.Main) { + onProgress(percent, newTotalDownloaded, totalBytesToDownload) + } + } + outputStream.flush() + } + } + + if (!verifyFileHash(apkFile, file.hash)) { + throw HashMismatchException(apkFile, file.hash) + } + + apkFile } - apkFile.delete() } - - val request = Request.Builder().url(file.url).build() - val call = okHttpClient.newCall(request) - - call.execute().use { response -> - if (!response.isSuccessful) throw IOException("Ошибка загрузки: ${response.code}") - - val responseBody = - response.body ?: throw IOException("Пустой ответ от сервера") - val inputStream = responseBody.byteStream() - val outputStream = FileOutputStream(apkFile) - - val buffer = ByteArray(8192) - var bytesRead: Int - - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - if (call.isCanceled()) { - throw IOException("Canceled") - } - - outputStream.write(buffer, 0, bytesRead) - - val newTotalDownloaded = - totalBytesDownloaded.addAndFetch(bytesRead.toLong()) - val percent = if (totalBytesToDownload > 0) { - (newTotalDownloaded * 100 / totalBytesToDownload).toInt() - } else { - 0 - } - - withContext(Dispatchers.Main) { - onProgress(percent, newTotalDownloaded, totalBytesToDownload) - } - } - - outputStream.flush() - inputStream.close() - outputStream.close() - - if (!verifyFileHash(apkFile, file.hash)) { - return@async apkFile - } - - apkFile - } - } - } - val resultingFiles = downloadJobs.awaitAll() - val invalidPair = files.zip(resultingFiles) - .firstOrNull { (fileSpec, downloadedFile) -> !verifyFileHash(downloadedFile, fileSpec.hash) } - - if (invalidPair != null) { - val (fileSpec, downloadedFile) = invalidPair - val expectedHash = formatHashForDisplay(fileSpec.hash) - val actualHash = computeExistingHash(downloadedFile, fileSpec.hash) - withContext(Dispatchers.Main) { - onInvalidHash(resultingFiles, expectedHash, actualHash) - } - return@launch + }.awaitAll() } withContext(Dispatchers.Main) { onSuccess(resultingFiles) } } catch (e: Exception) { + if (e is HashMismatchException) { + val expectedHash = formatHashForDisplay(e.expectedHash) + val actualHash = computeExistingHash(e.file) + val filesCopy = downloadedFiles.toList() + withContext(Dispatchers.Main) { + onInvalidHash(filesCopy, expectedHash, actualHash) + } + return@launch + } + downloadedFiles.forEach { file -> if (file.exists()) { file.delete() } } - if (e is CancellationException) { - onCancel() - } else { - onError(e) + + withContext(Dispatchers.Main) { + if (e is CancellationException) { + onCancel() + } else { + onError(e) + } } } } @@ -155,6 +159,8 @@ class ApkInstaller(private val context: Context) { } } + private class HashMismatchException(val file: File, val expectedHash: String) : IOException("Hash mismatch") + fun getApkFile(fileName: String): File { return File(context.externalCacheDir ?: context.cacheDir, fileName) } @@ -174,38 +180,12 @@ class ApkInstaller(private val context: Context) { private fun verifyFileHash(file: File, expectedHash: String): Boolean { val normalizedHash = expectedHash.trim().ifEmpty { return true } val expectedBytes = decodeHashValue(normalizedHash) ?: return true - return when (expectedBytes.size) { - 8 -> { - val actualBytes = digestFileXXH64(file) ?: return true - actualBytes.contentEquals(expectedBytes) - } - 16 -> { - val actualBytes = digestFile(file, "MD5") ?: return true - actualBytes.contentEquals(expectedBytes) - } - 20 -> { - val actualBytes = digestFile(file, "SHA-1") ?: return true - actualBytes.contentEquals(expectedBytes) - } - 32 -> { - val actualBytes = digestFile(file, "SHA-256") ?: return true - actualBytes.contentEquals(expectedBytes) - } - 48 -> { - val actualBytes = digestFile(file, "SHA-384") ?: return true - actualBytes.contentEquals(expectedBytes) - } - 64 -> { - val actualBytes = digestFile(file, "SHA-512") ?: return true - actualBytes.contentEquals(expectedBytes) - } - else -> { - // Unknown digest length, compare by hex string if possible. - digestFile(file, "SHA-256")?.let { bytes -> - bytes.toHexString().equals(normalizedHash, ignoreCase = true) - } ?: true - } - } + + // Only XXH64 is supported now (8 bytes) + if (expectedBytes.size != 8) return true + + val actualBytes = digestFileXXH64(file) ?: return false + return actualBytes.contentEquals(expectedBytes) } private fun decodeHashValue(hash: String): ByteArray? { @@ -228,40 +208,9 @@ class ApkInstaller(private val context: Context) { } } - private fun computeExistingHash(file: File, expectedHash: String): String { - val normalizedHash = expectedHash.trim().ifEmpty { return "" } - val expectedBytes = decodeHashValue(normalizedHash) ?: return "" - val actualBytes = when (expectedBytes.size) { - 8 -> digestFileXXH64(file) - 16 -> digestFile(file, "MD5") - 20 -> digestFile(file, "SHA-1") - 32 -> digestFile(file, "SHA-256") - 48 -> digestFile(file, "SHA-384") - 64 -> digestFile(file, "SHA-512") - else -> digestFile(file, "SHA-256") - } ?: return "" - - return if (normalizedHash.matches(Regex("^[0-9A-Fa-f]+$"))) { - actualBytes.toHexString() - } else { - Base64.encodeToString(actualBytes, Base64.NO_WRAP) - } - } - - private fun digestFile(file: File, algorithm: String): ByteArray? { - return try { - val digest = MessageDigest.getInstance(algorithm) - FileInputStream(file).use { fis -> - val buffer = ByteArray(8192) - var bytesRead: Int - while (fis.read(buffer).also { bytesRead = it } != -1) { - digest.update(buffer, 0, bytesRead) - } - } - digest.digest() - } catch (e: Exception) { - null - } + private fun computeExistingHash(file: File): String { + val actualBytes = digestFileXXH64(file) ?: return "" + return actualBytes.toHexString() } private fun digestFileXXH64(file: File): ByteArray? { @@ -292,7 +241,11 @@ class ApkInstaller(private val context: Context) { } private fun ByteArray.toHexString(): String { - return joinToString(separator = "") { "%02x".format(it) } + val sb = StringBuilder(size * 2) + for (b in this) { + sb.append(String.format("%02x", b)) + } + return sb.toString() } fun installApp( diff --git a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/details/DetailsScreen.kt b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/details/DetailsScreen.kt index 309b20d..dbe5a0d 100644 --- a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/details/DetailsScreen.kt +++ b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/details/DetailsScreen.kt @@ -220,8 +220,8 @@ fun DetailsScreen( } if (showHashDetails) { Spacer(modifier = Modifier.size(8.dp)) - Text(stringResource(R.string.expected_hash) + " " + (state.invalidExpectedHash ?: "")) - Text(stringResource(R.string.actual_hash) + " " + (state.invalidActualHash ?: "")) + Text(stringResource(R.string.expected_hash, state.invalidExpectedHash ?: "")) + Text(stringResource(R.string.actual_hash, state.invalidActualHash ?: "")) } } }, diff --git a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesScreen.kt b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesScreen.kt index a931030..050715c 100644 --- a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesScreen.kt +++ b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesScreen.kt @@ -84,8 +84,8 @@ fun UpdatesScreen(navController: NavController) { } if (showHashDetails) { Spacer(modifier = Modifier.size(8.dp)) - Text(stringResource(R.string.expected_hash) + " " + (state.invalidExpectedHash ?: "")) - Text(stringResource(R.string.actual_hash) + " " + (state.invalidActualHash ?: "")) + Text(stringResource(R.string.expected_hash, state.invalidExpectedHash ?: "")) + Text(stringResource(R.string.actual_hash, state.invalidActualHash ?: "")) } } }, diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 7af8bdd..bf6f7c3 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -36,8 +36,8 @@ File downloaded incorrectly Checksum does not match. Retry download or install file as is. Show hashes - Expected hash: - Actual hash: + Expected hash: %1$s + Actual hash: %1$s Download again Install as is Nothing found diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd4bd8a..a805d7a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,8 +36,8 @@ Файл скачан некорректно Контрольная сумма не совпадает. Повторите загрузку или установите файл как есть. Показать хеши - Ожидаемый хеш: - Фактический хеш: + Ожидаемый хеш: %1$s + Фактический хеш: %1$s Скачать по новой Установить как есть Ничего не найдено diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 46299b9..3e34470 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ animation = "1.9.0" material = "1.14.0-alpha04" kotlinxDatetime = "0.7.0" appcompat = "1.7.1" +lz4Java = "1.8.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -56,9 +57,9 @@ androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", material = { group = "com.google.android.material", name = "material", version.ref = "material" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +lz4-java = { group = "org.lz4", name = "lz4-java", version.ref = "lz4Java" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -