From ff4afa61ab275461dda260888418dc8612e90d4c Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 27 Apr 2026 15:45:27 +0200 Subject: [PATCH] feat: improved added hash check for downloaded apks (fix #9) (#20) from #19, thx Dmitry Co-authored-by: oltnd Reviewed-on: https://codeberg.org/mi6e4ka/openstore/pulls/20 --- app/build.gradle.kts | 1 + app/proguard-rules.pro | 3 + .../internal/installer/ApkInstaller.kt | 208 +++++++++++++----- .../ui/screen/details/DetailsScreen.kt | 34 +++ .../ui/screen/details/DetailsViewModel.kt | 51 +++++ .../ui/screen/updates/UpdatesScreen.kt | 36 +++ .../ui/screen/updates/UpdatesViewModel.kt | 55 +++++ app/src/main/res/values-en/strings.xml | 8 + app/src/main/res/values/strings.xml | 7 + gradle/libs.versions.toml | 3 +- 10 files changed, 351 insertions(+), 55 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4c46fbd..809481a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -105,5 +105,6 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.coil.compose) + implementation(libs.lz4.java) coreLibraryDesugaring(libs.desugar.jdk.libs) } \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f1b4245..9568d0d 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -19,3 +19,6 @@ # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile + +# Keep xxhash classes used by LZ4 library +-keep class net.jpountz.xxhash.** { *; } 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 b6f7206..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 @@ -8,6 +8,7 @@ import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper +import android.util.Base64 import android.util.Log import android.widget.Toast import androidx.core.content.FileProvider @@ -15,17 +16,20 @@ import dev.mi6e4ka.openstore.R import dev.mi6e4ka.openstore.data.model.DownloadLinkResponseItem import kotlinx.coroutines.CoroutineScope import java.io.File +import java.io.FileInputStream 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 import okhttp3.Request import java.io.BufferedOutputStream -import java.io.FileInputStream import java.io.IOException +import net.jpountz.xxhash.XXHashFactory +import java.util.Collections import java.util.concurrent.TimeUnit import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -50,79 +54,102 @@ class ApkInstaller(private val context: Context) { onSuccess: (List) -> Unit, onError: (Throwable) -> Unit, onCancel: () -> Unit, + onInvalidHash: (List, String?, String?) -> Unit = { _, _, _ -> } ): () -> Unit { 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) { - return@async apkFile - } - - 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) - } + val resultingFiles = coroutineScope { + files.map { file -> + async { + val fileName = file.url.split("/").last() + val apkFile = getApkFile(fileName) + if (!downloadedFiles.contains(apkFile)) { + downloadedFiles.add(apkFile) } - outputStream.flush() - inputStream.close() - outputStream.close() + if (apkFile.exists() && apkFile.length() == file.size) { + if (verifyFileHash(apkFile, file.hash)) { + return@async apkFile + } + apkFile.delete() + } - apkFile + 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 + } } - } + }.awaitAll() } - val resultingFiles = downloadJobs.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) + } } } } @@ -132,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) } @@ -148,6 +177,77 @@ class ApkInstaller(private val context: Context) { private val flags = PendingIntent.FLAG_MUTABLE private val intent = Intent(context, PackageInstallerStatusReceiver::class.java) + private fun verifyFileHash(file: File, expectedHash: String): Boolean { + val normalizedHash = expectedHash.trim().ifEmpty { return true } + val expectedBytes = decodeHashValue(normalizedHash) ?: return 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? { + return try { + if (hash.matches(Regex("^[0-9A-Fa-f]+$"))) { + hash.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } else { + Base64.decode(hash, Base64.DEFAULT) + } + } catch (e: Exception) { + null + } + } + + private fun formatHashForDisplay(hash: String): String { + return if (hash.matches(Regex("^[0-9A-Fa-f]+$"))) { + hash.lowercase() + } else { + hash + } + } + + private fun computeExistingHash(file: File): String { + val actualBytes = digestFileXXH64(file) ?: return "" + return actualBytes.toHexString() + } + + private fun digestFileXXH64(file: File): ByteArray? { + return try { + val factory = XXHashFactory.fastestInstance() + val hash64 = factory.newStreamingHash64(0L) + FileInputStream(file).use { fis -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (fis.read(buffer).also { bytesRead = it } != -1) { + hash64.update(buffer, 0, bytesRead) + } + } + val hashValue = hash64.getValue() + byteArrayOf( + (hashValue shr 56).toByte(), + (hashValue shr 48).toByte(), + (hashValue shr 40).toByte(), + (hashValue shr 32).toByte(), + (hashValue shr 24).toByte(), + (hashValue shr 16).toByte(), + (hashValue shr 8).toByte(), + hashValue.toByte() + ) + } catch (e: Exception) { + null + } + } + + private fun ByteArray.toHexString(): String { + val sb = StringBuilder(size * 2) + for (b in this) { + sb.append(String.format("%02x", b)) + } + return sb.toString() + } + fun installApp( packageName: String, apkFiles: List, 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 d9b31ab..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 @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -37,9 +38,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -105,6 +108,7 @@ fun DetailsScreen( val scope = rememberCoroutineScope() var showAppDescriptionSheet by remember { mutableStateOf(false) } + var showHashDetails by remember { mutableStateOf(false) } // val appDescriptionSheetState = rememberModalBottomSheetState( // skipPartiallyExpanded = true // ) @@ -203,6 +207,36 @@ fun DetailsScreen( } } } + if (state.invalidDownloadedApkFiles != null) { + AlertDialog( + onDismissRequest = { viewModel.clearInvalidDownloadState() }, + title = { Text(stringResource(R.string.invalid_download_hash_title)) }, + text = { + Column { + Text(stringResource(R.string.invalid_download_hash_message)) + Spacer(modifier = Modifier.size(12.dp)) + TextButton(onClick = { showHashDetails = !showHashDetails }) { + Text(stringResource(R.string.show_hashes)) + } + if (showHashDetails) { + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.expected_hash, state.invalidExpectedHash ?: "")) + Text(stringResource(R.string.actual_hash, state.invalidActualHash ?: "")) + } + } + }, + confirmButton = { + TextButton(onClick = { viewModel.retryDownloadAfterInvalid(context) }) { + Text(stringResource(R.string.download_again)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.installAsIsAfterInvalid(context) }) { + Text(stringResource(R.string.install_as_is)) + } + } + ) + } if (state.isLoading) { LinearProgressIndicator(modifier = Modifier .padding(horizontal = 4.dp) diff --git a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/details/DetailsViewModel.kt b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/details/DetailsViewModel.kt index 85c7c58..b172e0e 100644 --- a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/details/DetailsViewModel.kt +++ b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/details/DetailsViewModel.kt @@ -46,6 +46,10 @@ data class DetailsState( val isInstalled: Boolean = false, val isNeedUpgrade: Boolean = false, val isSupported: Boolean = true, + val invalidDownloadedApkFiles: List? = null, + val invalidDownloadMessage: String? = null, + val invalidExpectedHash: String? = null, + val invalidActualHash: String? = null ) sealed class InstallStatus { @@ -135,6 +139,17 @@ class DetailsViewModel(private val appPlatform: String, private val apkInstaller }, onCancel = { _state.update { it.copy(isDownloading = false) } + }, + onInvalidHash = { apkFiles, expectedHash, actualHash -> + _state.update { + it.copy( + isDownloading = false, + invalidDownloadedApkFiles = apkFiles, + invalidDownloadMessage = "Файл скачан некорректно.", + invalidExpectedHash = expectedHash, + invalidActualHash = actualHash + ) + } } ) } @@ -181,6 +196,42 @@ class DetailsViewModel(private val appPlatform: String, private val apkInstaller ) } + fun retryDownloadAfterInvalid(context: Context) { + _state.update { + it.copy( + invalidDownloadedApkFiles = null, + invalidDownloadMessage = null, + invalidExpectedHash = null, + invalidActualHash = null + ) + } + downloadAndInstallApp(context) + } + + fun installAsIsAfterInvalid(context: Context) { + val apkFiles = state.value.invalidDownloadedApkFiles ?: return + _state.update { + it.copy( + invalidDownloadedApkFiles = null, + invalidDownloadMessage = null, + invalidExpectedHash = null, + invalidActualHash = null + ) + } + installApp(context, apkFiles) + } + + fun clearInvalidDownloadState() { + _state.update { + it.copy( + invalidDownloadedApkFiles = null, + invalidDownloadMessage = null, + invalidExpectedHash = null, + invalidActualHash = null + ) + } + } + private fun getPackageInfo(context: Context, packageName: String): PackageInfo? { Log.d(null, "try to check package $packageName") return try { 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 ca32ee9..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 @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -33,12 +34,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -65,6 +69,38 @@ fun UpdatesScreen(navController: NavController) { factory = UpdatesViewModelFactory(apkInstaller) ) val state by viewModel.state.collectAsState() + var showHashDetails by remember { mutableStateOf(false) } + + if (state.invalidDownloadedApkFiles != null && state.invalidDownloadedAppPackage != null) { + AlertDialog( + onDismissRequest = { viewModel.clearInvalidDownloadState() }, + title = { Text(stringResource(R.string.invalid_download_hash_title)) }, + text = { + Column { + Text(stringResource(R.string.invalid_download_hash_message)) + Spacer(modifier = Modifier.size(12.dp)) + TextButton(onClick = { showHashDetails = !showHashDetails }) { + Text(stringResource(R.string.show_hashes)) + } + if (showHashDetails) { + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.expected_hash, state.invalidExpectedHash ?: "")) + Text(stringResource(R.string.actual_hash, state.invalidActualHash ?: "")) + } + } + }, + confirmButton = { + TextButton(onClick = { viewModel.retryDownloadAfterInvalid(context) }) { + Text(stringResource(R.string.download_again)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.installAsIsAfterInvalid(context) }) { + Text(stringResource(R.string.install_as_is)) + } + } + ) + } LaunchedEffect(Unit) { viewModel.initPreferences(context) diff --git a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesViewModel.kt b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesViewModel.kt index f780b30..084edb0 100644 --- a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesViewModel.kt +++ b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesViewModel.kt @@ -41,6 +41,10 @@ data class UpdatesState( val error: String? = null, val downloadingPackages: Set = emptySet(), val downloadProgress: Map = emptyMap(), + val invalidDownloadedApkFiles: List? = null, + val invalidDownloadedAppPackage: String? = null, + val invalidExpectedHash: String? = null, + val invalidActualHash: String? = null, val excludeGooglePlay: Boolean = true ) @@ -183,6 +187,18 @@ class UpdatesViewModel(private val apkInstaller: ApkInstaller) : ViewModel() { downloadProgress = it.downloadProgress - app.packageName ) } + }, + onInvalidHash = { apkFiles, expectedHash, actualHash -> + _state.update { + it.copy( + downloadingPackages = it.downloadingPackages - app.packageName, + downloadProgress = it.downloadProgress - app.packageName, + invalidDownloadedApkFiles = apkFiles, + invalidDownloadedAppPackage = app.packageName, + invalidExpectedHash = expectedHash, + invalidActualHash = actualHash + ) + } } ) } catch (e: Exception) { @@ -234,6 +250,45 @@ class UpdatesViewModel(private val apkInstaller: ApkInstaller) : ViewModel() { cancelDownloads.remove(packageName) } + fun retryDownloadAfterInvalid(context: Context) { + val packageName = state.value.invalidDownloadedAppPackage ?: return + _state.update { + it.copy( + invalidDownloadedAppPackage = null, + invalidDownloadedApkFiles = null, + invalidExpectedHash = null, + invalidActualHash = null + ) + } + val app = state.value.appsWithUpdates.find { it.packageName == packageName } ?: return + downloadAndUpdateApp(context, app) + } + + fun installAsIsAfterInvalid(context: Context) { + val packageName = state.value.invalidDownloadedAppPackage ?: return + val apkFiles = state.value.invalidDownloadedApkFiles ?: return + _state.update { + it.copy( + invalidDownloadedAppPackage = null, + invalidDownloadedApkFiles = null, + invalidExpectedHash = null, + invalidActualHash = null + ) + } + installApp(context, packageName, apkFiles) + } + + fun clearInvalidDownloadState() { + _state.update { + it.copy( + invalidDownloadedAppPackage = null, + invalidDownloadedApkFiles = null, + invalidExpectedHash = null, + invalidActualHash = null + ) + } + } + private fun getInstalledApps(context: Context): List { val pm = context.packageManager val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 89414c7..bf6f7c3 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -1,4 +1,5 @@ + OpenStore About app safety Error: %1$s Provided by %1$s @@ -32,6 +33,13 @@ Positive Negative Download has started + File downloaded incorrectly + Checksum does not match. Retry download or install file as is. + Show hashes + Expected hash: %1$s + Actual hash: %1$s + Download again + Install as is Nothing found Settings Installer diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5bb259f..a805d7a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,13 @@ Позитивные Негативные Загрузка началась + Файл скачан некорректно + Контрольная сумма не совпадает. Повторите загрузку или установите файл как есть. + Показать хеши + Ожидаемый хеш: %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" } -