fix/chore: some fixes and move installion logic out from ViewModels

This commit is contained in:
Michael 2026-02-14 22:40:28 +03:00
parent 47384b8af8
commit 6708c57959
No known key found for this signature in database
8 changed files with 194 additions and 179 deletions

View file

@ -4,9 +4,14 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Toast
import androidx.core.content.FileProvider
import dev.mi6e4ka.openstore.R
import dev.mi6e4ka.openstore.data.model.DownloadLinkResponseItem
import kotlinx.coroutines.CoroutineScope
import java.io.File
@ -18,8 +23,12 @@ 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 java.util.concurrent.TimeUnit
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import kotlin.concurrent.atomics.AtomicLong
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.coroutines.cancellation.CancellationException
@ -123,11 +132,6 @@ class ApkInstaller(private val context: Context) {
}
}
fun isApkAlreadyDownloaded(fileName: String): Boolean {
val apkFile = getApkFile(fileName)
return apkFile.exists() && apkFile.length() > 0
}
fun getApkFile(fileName: String): File {
return File(context.externalCacheDir ?: context.cacheDir, fileName)
}
@ -140,33 +144,59 @@ class ApkInstaller(private val context: Context) {
}
}
}
fun getApkFileSize(fileName: String): Long {
return getApkFile(fileName).length()
}
private val installer = context.packageManager.packageInstaller
private val flags = PendingIntent.FLAG_MUTABLE
private val intent = Intent(context, PackageInstallerStatusReceiver::class.java)
private val sessionParams =
PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
fun installApp(
packageName: String,
apkFiles: List<File>,
onSessionFinished: (Boolean) -> Unit = {},
onError: (Exception) -> Unit = {}
) {
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val installerType = prefs.getString("installer_type", "session")
when (installerType) {
"session" -> {
installViaSession(apkFiles, onSessionFinished, onError)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
setRequestUpdateOwnership(true)
"intent" -> {
installViaIntent(packageName, apkFiles, onError)
}
}
}
fun installSplitApk(context: Context, apkFiles: List<File>, installerCallback: PackageInstaller.SessionCallback, onError: (e: Exception) -> Unit) {
val sessionId = installer.createSession(sessionParams)
installer.registerSessionCallback(
installerCallback,
Handler(Looper.getMainLooper())
)
val session = installer.openSession(sessionId)
private fun installViaSession(
apkFiles: List<File>,
onSessionFinished: (Boolean) -> Unit,
onError: (Exception) -> Unit
) {
try {
val sessionParams =
PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
setRequestUpdateOwnership(true)
}
}
val sessionId = installer.createSession(sessionParams)
installer.registerSessionCallback(object : PackageInstaller.SessionCallback() {
override fun onCreated(sessionId: Int) {}
override fun onBadgingChanged(sessionId: Int) {}
override fun onActiveChanged(sessionId: Int, active: Boolean) {}
override fun onProgressChanged(sessionId: Int, progress: Float) {}
override fun onFinished(id: Int, success: Boolean) {
if (id == sessionId) {
onSessionFinished(success)
installer.unregisterSessionCallback(this)
}
}
}, Handler(Looper.getMainLooper()))
val session = installer.openSession(sessionId)
session.use { activeSession ->
apkFiles.forEach { file ->
val sizeBytes = file.length()
@ -181,9 +211,59 @@ class ApkInstaller(private val context: Context) {
activeSession.commit(pendingIntent.intentSender)
}
} catch (e: Exception) {
installer.abandonSession(sessionId)
Log.e("ApkInstaller", "Error during session install", e)
onError(e)
}
}
}
private fun installViaIntent(
packageName: String,
apkFiles: List<File>,
onError: (Exception) -> Unit
) {
try {
val apkUri: Uri
if (apkFiles.size == 1) {
apkUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
apkFiles[0]
)
} else {
Toast.makeText(context, R.string.intent_installer_split_apk_build, Toast.LENGTH_SHORT)
.show()
val apksFile = File(context.externalCacheDir ?: context.cacheDir, "$packageName.apks")
buildSplitApk(apkFiles, apksFile)
apkUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
apksFile
)
}
Toast.makeText(context, R.string.intent_installer_warning, Toast.LENGTH_SHORT)
.show()
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(apkUri, "application/vnd.android.package-archive")
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
} catch (e: Exception) {
Log.e("ApkInstaller", "Error during intent install", e)
onError(e)
}
}
private fun buildSplitApk(apkFiles: List<File>, outputZip: File) {
ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZip))).use { zos ->
apkFiles.forEach { file ->
FileInputStream(file).use { fis ->
val entry = ZipEntry(file.name)
zos.putNextEntry(entry)
fis.copyTo(zos)
zos.closeEntry()
}
}
}
}
}

View file

@ -34,8 +34,14 @@ class PackageInstallerStatusReceiver : BroadcastReceiver() {
}
}
else -> {
Toast.makeText(context, if (isUninstall) R.string.failed_uninstall else R.string.failed_install, Toast.LENGTH_SHORT).show()
val errorMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
if (errorMessage?.contains("INSUFFICIENT_STORAGE", ignoreCase = true) == true) {
Toast.makeText(context, R.string.insufficient_memory, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, if (isUninstall) R.string.failed_uninstall else R.string.failed_install, Toast.LENGTH_SHORT).show()
}
Log.e("app manager", errorMessage ?: "no error message")
}
}
@ -44,4 +50,4 @@ class PackageInstallerStatusReceiver : BroadcastReceiver() {
companion object {
const val ACTION_UNINSTALL = "action_uninstall"
}
}
}

View file

@ -7,14 +7,11 @@ import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.util.Log
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@ -32,11 +29,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
import androidx.core.net.toUri
import java.io.BufferedOutputStream
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
data class DetailsState(
val isLoading: Boolean = true,
@ -153,99 +145,40 @@ class DetailsViewModel(private val appPlatform: String, private val apkInstaller
cancelDownload = null
}
fun buildSplitApk(apkFiles: List<File>, outputZip: File) {
ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZip))).use { zos ->
apkFiles.forEach { file ->
FileInputStream(file).use { fis ->
val entry = ZipEntry(file.name)
zos.putNextEntry(entry)
fis.copyTo(zos)
zos.closeEntry()
}
}
}
}
private fun installApp(context: Context, apkFiles: List<File>) {
viewModelScope.launch {
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val installerType = prefs.getString("installer_type", "session")
when(installerType) {
"session" ->
{
_state.update { it.copy(installStatus = InstallStatus.Pending) }
val installerCallback = object : PackageInstaller.SessionCallback() {
override fun onCreated(sessionId: Int) {}
override fun onBadgingChanged(sessionId: Int) {}
override fun onActiveChanged(sessionId: Int, active: Boolean) {}
override fun onProgressChanged(sessionId: Int, progress: Float) {}
override fun onFinished(sessionId: Int, success: Boolean) {
_state.update { it.copy(installStatus = InstallStatus.Success, isDownloading = false)}
if (success) {
_state.update { it.copy(isInstalled = true, isNeedUpgrade = false) }
apkInstaller.cleanApkFiles(apkFiles.map {it.name})
}
}
}
apkInstaller.installSplitApk(
context,
apkFiles,
installerCallback,
onError = { error ->
if (error.message?.contains("Failed to allocate") == true) {
Toast.makeText(
context,
R.string.insufficient_memory,
Toast.LENGTH_SHORT
).show()
} else {
println("apk installer $error")
Toast.makeText(context, R.string.installing_error, Toast.LENGTH_SHORT)
.show()
}
_state.update {
it.copy(
installStatus = InstallStatus.Idle,
isDownloading = false
)
}
})
val packageName = state.value.item?.packageName ?: "tmp"
_state.update { it.copy(installStatus = InstallStatus.Pending) }
apkInstaller.installApp(
packageName = packageName,
apkFiles = apkFiles,
onSessionFinished = { success ->
_state.update { it.copy(installStatus = InstallStatus.Success, isDownloading = false)}
if (success) {
_state.update { it.copy(isInstalled = true, isNeedUpgrade = false) }
apkInstaller.cleanApkFiles(apkFiles.map {it.name})
}
"intent" -> {
_state.update { it.copy(installStatus = InstallStatus.Idle, isDownloading = false) }
val apkUri : Uri
if (apkFiles.size == 1) {
apkUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
apkFiles[0]
)
} else {
Toast.makeText(context, R.string.intent_installer_split_apk_build, Toast.LENGTH_SHORT)
.show()
val apksFile = File(context.externalCacheDir ?: context.cacheDir, "${state.value.item?.packageName ?: "tmp"}.apks")
buildSplitApk(apkFiles, apksFile)
apkUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
apksFile
)
}
Toast.makeText(context, R.string.intent_installer_warning, Toast.LENGTH_SHORT)
},
onError = { error ->
if (error.message?.contains("Failed to allocate") == true) {
Toast.makeText(
context,
R.string.insufficient_memory,
Toast.LENGTH_SHORT
).show()
} else {
println("apk installer $error")
Toast.makeText(context, R.string.installing_error, Toast.LENGTH_SHORT)
.show()
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(
apkUri,
"application/vnd.android.package-archive"
)
flags =
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
}
_state.update {
it.copy(
installStatus = InstallStatus.Idle,
isDownloading = false
)
}
}
}
)
}
private fun getPackageInfo(context: Context, packageName: String): PackageInfo? {

View file

@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
@ -96,7 +97,7 @@ fun SettingsScreen(
select = listOf(
SettingsSectionSelect(
title = "OpenStore",
value = "dev.mi6e4ka.dev"
value = "dev.mi6e4ka.openstore"
),
SettingsSectionSelect(
title = "RuStore",
@ -162,7 +163,7 @@ fun SettingsCard(
if (item.type == ParamType.SWITCH) prefs.getBoolean(item.id, false) else false
)}
ElevatedCard (
Card (
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),

View file

@ -135,7 +135,7 @@ fun UpdatesScreen(navController: NavController) {
Icon(
painter = painterResource(R.drawable.ic_check_circle_24px),
contentDescription = null,
modifier = Modifier.size(64.dp),
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.size(16.dp))
@ -278,13 +278,13 @@ fun UpdateAppCard(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "from ${app.installSource}",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Text(
// text = "from ${app.installSource}",
// fontSize = 12.sp,
// color = MaterialTheme.colorScheme.secondary,
// maxLines = 1,
// overflow = TextOverflow.Ellipsis
// )
if (isDownloading) {
Spacer(modifier = Modifier.size(4.dp))
LinearProgressIndicator(

View file

@ -3,11 +3,12 @@ package dev.mi6e4ka.openstore.ui.screen.updates
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.os.Build
import android.widget.Toast
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.mi6e4ka.openstore.R
import dev.mi6e4ka.openstore.data.model.AppUpdateRequestEntry
import dev.mi6e4ka.openstore.di.AppModule
import dev.mi6e4ka.openstore.internal.installer.ApkInstaller
@ -172,13 +173,8 @@ class UpdatesViewModel(private val apkInstaller: ApkInstaller) : ViewModel() {
onSuccess = { apkFiles ->
installApp(context, app.packageName, apkFiles)
},
onError = { _ ->
_state.update {
it.copy(
downloadingPackages = it.downloadingPackages - app.packageName,
downloadProgress = it.downloadProgress - app.packageName
)
}
onError = { error ->
handleError(context, app.packageName, error)
},
onCancel = {
_state.update {
@ -190,47 +186,46 @@ class UpdatesViewModel(private val apkInstaller: ApkInstaller) : ViewModel() {
}
)
} catch (e: Exception) {
_state.update {
it.copy(
downloadingPackages = it.downloadingPackages - app.packageName,
downloadProgress = it.downloadProgress - app.packageName
)
}
handleError(context, app.packageName, e)
}
}
}
private fun installApp(context: Context, packageName: String, apkFiles: List<File>) {
viewModelScope.launch {
val installerCallback = object : PackageInstaller.SessionCallback() {
override fun onCreated(sessionId: Int) {}
override fun onBadgingChanged(sessionId: Int) {}
override fun onActiveChanged(sessionId: Int, active: Boolean) {}
override fun onProgressChanged(sessionId: Int, progress: Float) {}
override fun onFinished(sessionId: Int, success: Boolean) {
_state.update {
it.copy(
downloadingPackages = it.downloadingPackages - packageName,
downloadProgress = it.downloadProgress - packageName,
appsWithUpdates = if (success) {
it.appsWithUpdates.filter { app -> app.packageName != packageName }
} else it.appsWithUpdates
)
}
if (success) {
apkInstaller.cleanApkFiles(apkFiles.map { it.name })
}
}
}
apkInstaller.installSplitApk(context, apkFiles, installerCallback, onError = { _ ->
apkInstaller.installApp(
packageName = packageName,
apkFiles = apkFiles,
onSessionFinished = { success ->
_state.update {
it.copy(
downloadingPackages = it.downloadingPackages - packageName,
downloadProgress = it.downloadProgress - packageName
downloadProgress = it.downloadProgress - packageName,
appsWithUpdates = if (success) {
it.appsWithUpdates.filter { app -> app.packageName != packageName }
} else it.appsWithUpdates
)
}
})
if (success) {
apkInstaller.cleanApkFiles(apkFiles.map { it.name })
}
},
onError = { error ->
handleError(context, packageName, error)
}
)
}
private fun handleError(context: Context, packageName: String, error: Throwable) {
if (error.message?.contains("Failed to allocate") == true) {
Toast.makeText(context, R.string.insufficient_memory, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, R.string.loading_error, Toast.LENGTH_SHORT).show()
}
_state.update {
it.copy(
downloadingPackages = it.downloadingPackages - packageName,
downloadProgress = it.downloadProgress - packageName
)
}
}

View file

@ -4,7 +4,7 @@
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M424,664L696,392L654,350L424,580L306,462L264,504L424,664ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,840Q630,840 735,735Q840,630 840,480Q840,330 735,225Q630,120 480,120Q330,120 225,225Q120,330 120,480Q120,630 225,735Q330,840 480,840ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M424,664L706,382L650,326L424,552L310,438L254,494L424,664ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View file

@ -4,7 +4,7 @@
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,800Q347,800 253.5,706.5Q160,613 160,480L200,480Q200,596 281,678Q362,760 480,760Q598,760 679,678Q760,596 760,480Q760,364 679,282Q598,200 480,200Q411,200 353,231Q295,262 254,314L360,314L360,354L174,354L174,168L214,168L214,270Q262,210 330.5,175Q399,140 480,140Q547,140 607,166.5Q667,193 712.5,238.5Q758,284 784.5,344Q811,404 811,472Q811,540 784.5,600Q758,660 712.5,705.5Q667,751 607,777.5Q547,804 480,800Z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M480,800Q346,800 253,707Q160,614 160,480Q160,346 253,253Q346,160 480,160Q549,160 612,188.5Q675,217 720,270L720,160L800,160L800,440L520,440L520,360L688,360Q656,304 600.5,272Q545,240 480,240Q380,240 310,310Q240,380 240,480Q240,580 310,650Q380,720 480,720Q557,720 619,676Q681,632 706,560L790,560Q762,666 676,733Q590,800 480,800Z"/>
</vector>