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.ExpressiveLazyColumn
import ru.shadowsparky.ui.components.SwitchPreference import ru.shadowsparky.ui.components.SwitchPreference
import ru.shadowsparky.ui.components.TextPreference import ru.shadowsparky.ui.components.TextPreference
import ru.shadowsparky.updater.presentation.UpdateBottomSheet import ru.shadowsparky.updater.presentation.UpdateDialog
@Composable @Composable
fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) { fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
@@ -116,7 +116,7 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
} }
} }
) )
UpdateBottomSheet(comp.updateComponent) UpdateDialog(comp.updateComponent)
LaunchedEffect(null) { LaunchedEffect(null) {
withFrameNanos {} withFrameNanos {}
requester.requestFocus() requester.requestFocus()
@@ -1,25 +1,37 @@
package ru.shadowsparky.updater.data package ru.shadowsparky.updater.data
import android.app.Application import android.app.Application
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.core.content.FileProvider import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.content.IntentCompat
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import org.koin.core.annotation.Factory import org.koin.core.annotation.Factory
import ru.shadowsparky.updater.domain.UpdateInstaller import ru.shadowsparky.updater.domain.UpdateInstaller
import java.io.File import java.io.File
import java.io.FileInputStream
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Factory @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 { override suspend fun hasInstallPermission(): Boolean =
return app.packageManager.canRequestPackageInstalls() app.packageManager.canRequestPackageInstalls()
}
override suspend fun requestInstallPermission() { override suspend fun requestInstallPermission() {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { 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) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
app.startActivity(intent) app.startActivity(intent)
@@ -27,17 +39,102 @@ class AndroidUpdateInstaller(private val app: Application) : UpdateInstaller {
override suspend fun install(path: String) { override suspend fun install(path: String) {
val file = File(path) val file = File(path)
if (!file.exists()) return if (!file.exists()) {
val intent = Intent(Intent.ACTION_VIEW).apply { error("APK file not found at: $path")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
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, app,
"${app.packageName}.provider", sessionId,
file 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,
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.decompose.value.Value
import com.arkivanov.essenty.lifecycle.doOnDestroy import com.arkivanov.essenty.lifecycle.doOnDestroy
import com.arkivanov.essenty.lifecycle.doOnResume import com.arkivanov.essenty.lifecycle.doOnResume
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -19,8 +20,11 @@ class UpdateComponent(
private val interactor: UpdateInteractor private val interactor: UpdateInteractor
) : ComponentContext by componentContext { ) : ComponentContext by componentContext {
val hasSupport = interactor.hasSupport 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) private val _state = MutableValue<UpdateState>(UpdateState.Idle)
val state: Value<UpdateState> = _state 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_not_found
import androiddev.libs.updater.updater_client.generated.resources.updater_title_permission import androiddev.libs.updater.updater_client.generated.resources.updater_title_permission
import androiddev.libs.updater.updater_client.generated.resources.updater_title_ready 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.extensions.compose.subscribeAsState
@@ -44,24 +46,22 @@ import ru.shadowsparky.updater.domain.UpdateState
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun UpdateBottomSheet( fun UpdateDialog(
component: UpdateComponent, component: UpdateComponent,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val state by component.state.subscribeAsState() 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 } val isVisible = remember(state) { state !is UpdateState.Idle }
if (isVisible) { if (isVisible) {
ModalBottomSheet( BasicAlertDialog(
modifier = modifier,
onDismissRequest = { component.reset() }, onDismissRequest = { component.reset() },
sheetState = sheetState, content = {
modifier = modifier Box(
modifier = Modifier.clip(RoundedCornerShape(28.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(16.dp)
) { ) {
Box(Modifier.padding(horizontal = 8.dp)) {
when (val currentState = state) { when (val currentState = state) {
is UpdateState.Checking -> UpdateCheckingContent() is UpdateState.Checking -> UpdateCheckingContent()
is UpdateState.UpdateAvailable -> UpdateAvailableContent( is UpdateState.UpdateAvailable -> UpdateAvailableContent(
@@ -91,6 +91,7 @@ fun UpdateBottomSheet(
} }
} }
} }
)
} }
} }