feat: improved added hash check for downloaded apks (fix #9) (#20)

from #19, thx Dmitry

Co-authored-by: oltnd <oltn@kkmbr.xyz>
Reviewed-on: https://codeberg.org/mi6e4ka/openstore/pulls/20
This commit is contained in:
Michael 2026-04-27 15:45:27 +02:00 committed by mi6e4ka
parent c759c1fcf9
commit ff4afa61ab
10 changed files with 351 additions and 55 deletions

View file

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

View file

@ -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.** { *; }

View file

@ -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<File>) -> Unit,
onError: (Throwable) -> Unit,
onCancel: () -> Unit,
onInvalidHash: (List<File>, String?, String?) -> Unit = { _, _, _ -> }
): () -> Unit {
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) {
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<File>,

View file

@ -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)

View file

@ -46,6 +46,10 @@ data class DetailsState(
val isInstalled: Boolean = false,
val isNeedUpgrade: Boolean = false,
val isSupported: Boolean = true,
val invalidDownloadedApkFiles: List<File>? = 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 {

View file

@ -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)

View file

@ -41,6 +41,10 @@ data class UpdatesState(
val error: String? = null,
val downloadingPackages: Set<String> = emptySet(),
val downloadProgress: Map<String, Int> = emptyMap(),
val invalidDownloadedApkFiles: List<File>? = 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<AppUpdateRequestEntry> {
val pm = context.packageManager
val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {

View file

@ -1,4 +1,5 @@
<resources>
<string name="app_name">OpenStore</string>
<string name="safety_card_text">About app safety</string>
<string name="error_message">Error: %1$s</string>
<string name="provided_by">Provided by %1$s</string>
@ -32,6 +33,13 @@
<string name="positive_first">Positive</string>
<string name="negative_first">Negative</string>
<string name="download_has_started">Download has started</string>
<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: %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>
<string name="settings_title">Settings</string>
<string name="settings_installer_title">Installer</string>

View file

@ -33,6 +33,13 @@
<string name="positive_first">Позитивные</string>
<string name="negative_first">Негативные</string>
<string name="download_has_started">Загрузка началась</string>
<string name="invalid_download_hash_title">Файл скачан некорректно</string>
<string name="invalid_download_hash_message">Контрольная сумма не совпадает. Повторите загрузку или установите файл как есть.</string>
<string name="show_hashes">Показать хеши</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>
<string name="settings_title">Настройки</string>
<string name="settings_installer_title">Установщик</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" }