fix: some fixes from gemini

This commit is contained in:
Michael 2026-04-27 16:38:13 +03:00
parent 846677c37a
commit fc91100a99
No known key found for this signature in database
6 changed files with 105 additions and 151 deletions

View file

@ -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<File>()
val downloadedFiles = Collections.synchronizedList(mutableListOf<File>())
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(

View file

@ -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 ?: ""))
}
}
},

View file

@ -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 ?: ""))
}
}
},

View file

@ -36,8 +36,8 @@
<string name="invalid_download_hash_title">File downloaded incorrectly</string>
<string name="invalid_download_hash_message">Checksum does not match. Retry download or install file as is.</string>
<string name="show_hashes">Show hashes</string>
<string name="expected_hash">Expected hash:</string>
<string name="actual_hash">Actual hash:</string>
<string name="expected_hash">Expected hash: %1$s</string>
<string name="actual_hash">Actual hash: %1$s</string>
<string name="download_again">Download again</string>
<string name="install_as_is">Install as is</string>
<string name="not_found_error">Nothing found</string>

View file

@ -36,8 +36,8 @@
<string name="invalid_download_hash_title">Файл скачан некорректно</string>
<string name="invalid_download_hash_message">Контрольная сумма не совпадает. Повторите загрузку или установите файл как есть.</string>
<string name="show_hashes">Показать хеши</string>
<string name="expected_hash">Ожидаемый хеш:</string>
<string name="actual_hash">Фактический хеш:</string>
<string name="expected_hash">Ожидаемый хеш: %1$s</string>
<string name="actual_hash">Фактический хеш: %1$s</string>
<string name="download_again">Скачать по новой</string>
<string name="install_as_is">Установить как есть</string>
<string name="not_found_error">Ничего не найдено</string>

View file

@ -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" }