android tv for updates support
This commit is contained in:
+2
-2
@@ -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()
|
||||||
|
|||||||
+113
-16
@@ -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,
|
||||||
|
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,
|
||||||
"${app.packageName}.provider",
|
receiver,
|
||||||
file
|
IntentFilter(actionInstallStatus),
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||||
)
|
)
|
||||||
intent.setDataAndType(uri, "application/vnd.android.package-archive")
|
|
||||||
app.startActivity(intent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -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
|
||||||
|
|||||||
+40
-39
@@ -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,53 +46,52 @@ 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))
|
||||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||||
when (val currentState = state) {
|
.padding(16.dp)
|
||||||
is UpdateState.Checking -> UpdateCheckingContent()
|
) {
|
||||||
is UpdateState.UpdateAvailable -> UpdateAvailableContent(
|
when (val currentState = state) {
|
||||||
versionName = currentState.versionName,
|
is UpdateState.Checking -> UpdateCheckingContent()
|
||||||
changeLog = currentState.changeLog,
|
is UpdateState.UpdateAvailable -> UpdateAvailableContent(
|
||||||
onDownloadClick = { component.downloadUpdate(currentState.url) }
|
versionName = currentState.versionName,
|
||||||
)
|
changeLog = currentState.changeLog,
|
||||||
is UpdateState.InstallPermissionRequired -> UpdatePermissionContent(
|
onDownloadClick = { component.downloadUpdate(currentState.url) }
|
||||||
onGrantClick = { component.requestPermission() }
|
)
|
||||||
)
|
is UpdateState.InstallPermissionRequired -> UpdatePermissionContent(
|
||||||
is UpdateState.Downloading -> UpdateDownloadingContent(
|
onGrantClick = { component.requestPermission() }
|
||||||
progress = currentState.progress,
|
)
|
||||||
downloadedBytes = currentState.downloadedBytes,
|
is UpdateState.Downloading -> UpdateDownloadingContent(
|
||||||
totalBytes = currentState.totalBytes
|
progress = currentState.progress,
|
||||||
)
|
downloadedBytes = currentState.downloadedBytes,
|
||||||
is UpdateState.ReadyToInstall -> UpdateReadyContent(
|
totalBytes = currentState.totalBytes
|
||||||
onInstallClick = { component.installApk(currentState.filePath) }
|
)
|
||||||
)
|
is UpdateState.ReadyToInstall -> UpdateReadyContent(
|
||||||
is UpdateState.Error -> UpdateErrorContent(
|
onInstallClick = { component.installApk(currentState.filePath) }
|
||||||
message = currentState.message,
|
)
|
||||||
onRetryClick = { component.checkUpdates() }
|
is UpdateState.Error -> UpdateErrorContent(
|
||||||
)
|
message = currentState.message,
|
||||||
is UpdateState.UpToDate -> {
|
onRetryClick = { component.checkUpdates() }
|
||||||
UpdateNotFoundContent(onSkip = { component.reset() })
|
)
|
||||||
|
is UpdateState.UpToDate -> {
|
||||||
|
UpdateNotFoundContent(onSkip = { component.reset() })
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
else -> Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user