android tv for updates support

This commit is contained in:
2026-07-25 23:48:40 +03:00
parent ee340f11ed
commit 934d5d274f
4 changed files with 160 additions and 58 deletions
@@ -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()
@@ -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<Unit> { 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<Unit>
): 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)
}
}
@@ -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>(UpdateState.Idle)
val state: Value<UpdateState> = _state
@@ -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
}
}
}
)
}
}