diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt index 396e8ce..97b48d6 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt @@ -27,7 +27,7 @@ import ru.shadowsparky.ui.applyBlurSource import ru.shadowsparky.ui.components.ExpressiveLazyColumn import ru.shadowsparky.ui.components.SwitchPreference import ru.shadowsparky.ui.components.TextPreference -import ru.shadowsparky.updater.presentation.UpdateBottomSheet +import ru.shadowsparky.updater.presentation.UpdateDialog @Composable fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) { @@ -116,7 +116,7 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) { } } ) - UpdateBottomSheet(comp.updateComponent) + UpdateDialog(comp.updateComponent) LaunchedEffect(null) { withFrameNanos {} requester.requestFocus() diff --git a/libs/updater/updater-client/src/androidMain/kotlin/ru/shadowsparky/updater/data/AndroidUpdateInstaller.kt b/libs/updater/updater-client/src/androidMain/kotlin/ru/shadowsparky/updater/data/AndroidUpdateInstaller.kt index 6ab1ea9..04813e8 100644 --- a/libs/updater/updater-client/src/androidMain/kotlin/ru/shadowsparky/updater/data/AndroidUpdateInstaller.kt +++ b/libs/updater/updater-client/src/androidMain/kotlin/ru/shadowsparky/updater/data/AndroidUpdateInstaller.kt @@ -1,25 +1,37 @@ package ru.shadowsparky.updater.data import android.app.Application +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInstaller import android.net.Uri import android.provider.Settings -import androidx.core.content.FileProvider -import androidx.core.net.toUri +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine import org.koin.core.annotation.Factory import ru.shadowsparky.updater.domain.UpdateInstaller import java.io.File +import java.io.FileInputStream +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException @Factory -class AndroidUpdateInstaller(private val app: Application) : UpdateInstaller { +class AndroidUpdateInstaller( + private val app: Application +) : UpdateInstaller { + private val actionInstallStatus = "${app.packageName}.ACTION_INSTALL_STATUS" - override suspend fun hasInstallPermission(): Boolean { - return app.packageManager.canRequestPackageInstalls() - } + override suspend fun hasInstallPermission(): Boolean = + app.packageManager.canRequestPackageInstalls() override suspend fun requestInstallPermission() { val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { - data = "package:${app.packageName}".toUri() + data = Uri.parse("package:${app.packageName}") addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } app.startActivity(intent) @@ -27,17 +39,102 @@ class AndroidUpdateInstaller(private val app: Application) : UpdateInstaller { override suspend fun install(path: String) { val file = File(path) - if (!file.exists()) return - val intent = Intent(Intent.ACTION_VIEW).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (!file.exists()) { + error("APK file not found at: $path") } - val uri: Uri = FileProvider.getUriForFile( + + val packageInstaller = app.packageManager.packageInstaller + val sessionId = createSession(packageInstaller) + try { + writeApkToSession(packageInstaller, sessionId, file) + awaitInstallationResult(packageInstaller, sessionId) + } catch (e: Exception) { + runCatching { packageInstaller.abandonSession(sessionId) } + throw e + } finally { + file.delete() + } + } + + private fun createSession(packageInstaller: PackageInstaller): Int { + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + return packageInstaller.createSession(params) + } + + private fun writeApkToSession(packageInstaller: PackageInstaller, sessionId: Int, file: File) { + packageInstaller.openSession(sessionId).use { session -> + session.openWrite("update_stream", 0, file.length()).use { outputStream -> + FileInputStream(file).use { inputStream -> + val buffer = ByteArray(65536) + var read: Int + while (inputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + } + session.fsync(outputStream) + } + } + } + } + + private suspend fun awaitInstallationResult(packageInstaller: PackageInstaller, sessionId: Int) { + suspendCancellableCoroutine { continuation -> + val receiver = createStatusReceiver(continuation) + registerStatusReceiver(receiver) + + continuation.invokeOnCancellation { + runCatching { app.unregisterReceiver(receiver) } + runCatching { packageInstaller.abandonSession(sessionId) } + } + + val intent = Intent(actionInstallStatus) + .setPackage(app.packageName) + .addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + + val pendingIntent = PendingIntent.getBroadcast( + app, + sessionId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + ) + packageInstaller.openSession(sessionId).use { session -> + session.commit(pendingIntent.intentSender) + } + } + } + + private fun createStatusReceiver( + continuation: CancellableContinuation + ): BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + + when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + val userAction = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java) + userAction?.let { + it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(it) + } + } + PackageInstaller.STATUS_SUCCESS -> { + app.unregisterReceiver(this) + continuation.resume(Unit) + } + else -> { + app.unregisterReceiver(this) + continuation.resumeWithException(IllegalStateException("Installation failed ($status): $message")) + } + } + } + } + + private fun registerStatusReceiver(receiver: BroadcastReceiver) { + ContextCompat.registerReceiver( app, - "${app.packageName}.provider", - file + receiver, + IntentFilter(actionInstallStatus), + ContextCompat.RECEIVER_NOT_EXPORTED ) - intent.setDataAndType(uri, "application/vnd.android.package-archive") - app.startActivity(intent) } } diff --git a/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt b/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt index c51e48d..5ff391b 100644 --- a/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt +++ b/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt @@ -5,6 +5,7 @@ import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.lifecycle.doOnDestroy import com.arkivanov.essenty.lifecycle.doOnResume +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -19,8 +20,11 @@ class UpdateComponent( private val interactor: UpdateInteractor ) : ComponentContext by componentContext { val hasSupport = interactor.hasSupport + private val exceptionHandler = CoroutineExceptionHandler { context, throwable -> + _state.value = UpdateState.Error(throwable.message ?: "$throwable", throwable) + } - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val scope = CoroutineScope(exceptionHandler + SupervisorJob() + Dispatchers.Main.immediate) private val _state = MutableValue(UpdateState.Idle) val state: Value = _state diff --git a/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateBottomSheet.kt b/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateDialog.kt similarity index 80% rename from libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateBottomSheet.kt rename to libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateDialog.kt index 0a8f526..e04b648 100644 --- a/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateBottomSheet.kt +++ b/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateDialog.kt @@ -16,6 +16,7 @@ import androiddev.libs.updater.updater_client.generated.resources.updater_title_ import androiddev.libs.updater.updater_client.generated.resources.updater_title_not_found import androiddev.libs.updater.updater_client.generated.resources.updater_title_permission import androiddev.libs.updater.updater_client.generated.resources.updater_title_ready +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,18 +24,19 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.subscribeAsState @@ -44,53 +46,52 @@ import ru.shadowsparky.updater.domain.UpdateState @OptIn(ExperimentalMaterial3Api::class) @Composable -fun UpdateBottomSheet( +fun UpdateDialog( component: UpdateComponent, modifier: Modifier = Modifier ) { val state by component.state.subscribeAsState() - val sheetState = rememberModalBottomSheetState( - confirmValueChange = { state !is UpdateState.UpdateAvailable || !(state as UpdateState.UpdateAvailable).isForceUpdate } - ) - val isVisible = remember(state) { state !is UpdateState.Idle } - if (isVisible) { - ModalBottomSheet( + BasicAlertDialog( + modifier = modifier, onDismissRequest = { component.reset() }, - sheetState = sheetState, - modifier = modifier - ) { - Box(Modifier.padding(horizontal = 8.dp)) { - when (val currentState = state) { - is UpdateState.Checking -> UpdateCheckingContent() - is UpdateState.UpdateAvailable -> UpdateAvailableContent( - versionName = currentState.versionName, - changeLog = currentState.changeLog, - onDownloadClick = { component.downloadUpdate(currentState.url) } - ) - is UpdateState.InstallPermissionRequired -> UpdatePermissionContent( - onGrantClick = { component.requestPermission() } - ) - is UpdateState.Downloading -> UpdateDownloadingContent( - progress = currentState.progress, - downloadedBytes = currentState.downloadedBytes, - totalBytes = currentState.totalBytes - ) - is UpdateState.ReadyToInstall -> UpdateReadyContent( - onInstallClick = { component.installApk(currentState.filePath) } - ) - is UpdateState.Error -> UpdateErrorContent( - message = currentState.message, - onRetryClick = { component.checkUpdates() } - ) - is UpdateState.UpToDate -> { - UpdateNotFoundContent(onSkip = { component.reset() }) + content = { + Box( + modifier = Modifier.clip(RoundedCornerShape(28.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(16.dp) + ) { + when (val currentState = state) { + is UpdateState.Checking -> UpdateCheckingContent() + is UpdateState.UpdateAvailable -> UpdateAvailableContent( + versionName = currentState.versionName, + changeLog = currentState.changeLog, + onDownloadClick = { component.downloadUpdate(currentState.url) } + ) + is UpdateState.InstallPermissionRequired -> UpdatePermissionContent( + onGrantClick = { component.requestPermission() } + ) + is UpdateState.Downloading -> UpdateDownloadingContent( + progress = currentState.progress, + downloadedBytes = currentState.downloadedBytes, + totalBytes = currentState.totalBytes + ) + is UpdateState.ReadyToInstall -> UpdateReadyContent( + onInstallClick = { component.installApk(currentState.filePath) } + ) + is UpdateState.Error -> UpdateErrorContent( + message = currentState.message, + onRetryClick = { component.checkUpdates() } + ) + is UpdateState.UpToDate -> { + UpdateNotFoundContent(onSkip = { component.reset() }) + } + else -> Unit } - else -> Unit } } - } + ) } }