add chat basics
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
|
||||
tools:ignore="RequestInstallPackagesPolicy" />
|
||||
|
||||
<application>
|
||||
<receiver
|
||||
android:name=".presentation.UpdateInstalledReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
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.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 {
|
||||
private val actionInstallStatus = "${app.packageName}.ACTION_INSTALL_STATUS"
|
||||
|
||||
override suspend fun hasInstallPermission(): Boolean =
|
||||
app.packageManager.canRequestPackageInstalls()
|
||||
|
||||
override suspend fun requestInstallPermission() {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||
data = Uri.parse("package:${app.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
app.startActivity(intent)
|
||||
}
|
||||
|
||||
override suspend fun install(path: String) {
|
||||
val file = File(path)
|
||||
if (!file.exists()) {
|
||||
error("APK file not found at: $path")
|
||||
}
|
||||
|
||||
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,
|
||||
receiver,
|
||||
IntentFilter(actionInstallStatus),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package ru.shadowsparky.updater.data
|
||||
|
||||
import android.app.Application
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.contentLength
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.readAvailable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.updater.domain.DownloadProgress
|
||||
import ru.shadowsparky.updater.domain.UpdateDownloader
|
||||
import java.io.File
|
||||
|
||||
@Factory
|
||||
class KtorUpdateDownloader(
|
||||
private val httpClient: HttpClient,
|
||||
app: Application,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : UpdateDownloader {
|
||||
private val destinationPath = "${app.cacheDir.absolutePath}/update"
|
||||
|
||||
override fun download(url: String): Flow<DownloadProgress> = flow {
|
||||
try {
|
||||
httpClient.prepareGet(url).execute { response ->
|
||||
val contentLength = response.contentLength() ?: -1L
|
||||
val channel: ByteReadChannel = response.bodyAsChannel()
|
||||
val directory = File(destinationPath)
|
||||
if (!directory.exists()) {
|
||||
directory.mkdirs()
|
||||
}
|
||||
val fileName = "update.apk"
|
||||
val fullPath = "$destinationPath/$fileName"
|
||||
val file = File(fullPath)
|
||||
|
||||
if (file.exists()) file.delete()
|
||||
|
||||
val bufferSize = 8192
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var downloadedBytes = 0L
|
||||
|
||||
file.outputStream().use { outputStream ->
|
||||
while (!channel.isClosedForRead) {
|
||||
val read = channel.readAvailable(buffer, 0, buffer.size)
|
||||
if (read <= 0) break
|
||||
|
||||
outputStream.write(buffer, 0, read)
|
||||
downloadedBytes += read
|
||||
|
||||
if (contentLength > 0) {
|
||||
val progress = downloadedBytes.toFloat() / contentLength.toFloat()
|
||||
emit(
|
||||
DownloadProgress.Active(
|
||||
progress = progress,
|
||||
downloadedBytes = downloadedBytes,
|
||||
totalBytes = contentLength
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val finalFile = File("$destinationPath/update.apk")
|
||||
emit(DownloadProgress.Success(path = finalFile.absolutePath))
|
||||
} catch (e: Exception) {
|
||||
emit(DownloadProgress.Error(e))
|
||||
}
|
||||
}.flowOn(dispatcherProvider.io)
|
||||
|
||||
override suspend fun clean(): Unit = withContext(dispatcherProvider.io) {
|
||||
File(destinationPath).deleteRecursively()
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package ru.shadowsparky.updater.presentation
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.shadowsparky.koin
|
||||
import ru.shadowsparky.updater.domain.UpdateDownloader
|
||||
|
||||
class UpdateInstalledReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(p0: Context?, p1: Intent?) {
|
||||
if (p1?.action == Intent.ACTION_MY_PACKAGE_REPLACED) {
|
||||
koin.getOrNull<UpdateDownloader>()?.let { downloader ->
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
val async = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
downloader.clean()
|
||||
} finally {
|
||||
async.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="updater_isolated_cache" path="update/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,20 @@
|
||||
<resources>
|
||||
<string name="updater_title_available">Доступно обновление на версию %1$s</string>
|
||||
<string name="updater_title_not_found">Обновления не найдены</string>
|
||||
<string name="updater_title_completed">Обновление успешно установлено</string>
|
||||
|
||||
<string name="updater_btn_close">Закрыть</string>
|
||||
|
||||
<string name="updater_btn_download">Скачать</string>
|
||||
<string name="updater_changelog_title">Что нового:</string>
|
||||
<string name="updater_changelog_empty">Улучшения производительности и исправления ошибок.</string>
|
||||
<string name="updater_title_permission">Нужно разрешение</string>
|
||||
<string name="updater_desc_permission">Для установки обновления приложению требуется разрешение на установку из неизвестных источников. Пожалуйста, включите его в настройках.</string>
|
||||
<string name="updater_btn_grant">В настройки</string>
|
||||
<string name="updater_title_downloading">Скачивание обновления…</string>
|
||||
<string name="updater_download_progress">%1$s МБ из %2$s МБ</string>
|
||||
<string name="updater_title_ready">Обновление готово к установке</string>
|
||||
<string name="updater_btn_install">Установить</string>
|
||||
<string name="updater_title_error">Ошибка обновления</string>
|
||||
<string name="updater_btn_retry">Повторить</string>
|
||||
</resources>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package ru.shadowsparky.updater
|
||||
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Configuration
|
||||
import org.koin.core.annotation.Module
|
||||
|
||||
@Module
|
||||
@ComponentScan
|
||||
@Configuration
|
||||
class UpdaterModule
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package ru.shadowsparky.updater.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface UpdateDownloader {
|
||||
fun download(url: String): Flow<DownloadProgress>
|
||||
|
||||
suspend fun clean()
|
||||
}
|
||||
|
||||
sealed interface DownloadProgress {
|
||||
data class Active(val progress: Float, val downloadedBytes: Long, val totalBytes: Long) : DownloadProgress
|
||||
data class Success(val path: String) : DownloadProgress
|
||||
data class Error(val throwable: Throwable) : DownloadProgress
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.shadowsparky.updater.domain
|
||||
|
||||
interface UpdateInstaller {
|
||||
suspend fun hasInstallPermission(): Boolean
|
||||
suspend fun requestInstallPermission()
|
||||
suspend fun install(path: String)
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package ru.shadowsparky.updater.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.AppConfig
|
||||
|
||||
@Factory
|
||||
class UpdateInteractor(
|
||||
private val fetcher: UpdateFetcher?,
|
||||
private val downloader: UpdateDownloader?,
|
||||
private val installer: UpdateInstaller?,
|
||||
private val appConfig: AppConfig?
|
||||
) {
|
||||
val hasSupport = fetcher != null && downloader != null && installer != null &&
|
||||
appConfig?.versionCode != null
|
||||
|
||||
fun checkUpdate(): Flow<UpdateState> = flow {
|
||||
if (appConfig?.versionCode == null || fetcher == null) {
|
||||
emit(UpdateState.UpToDate)
|
||||
return@flow
|
||||
}
|
||||
emit(UpdateState.Checking)
|
||||
try {
|
||||
val currentVersion = appConfig.versionCode ?: 0L
|
||||
val updateInfo = fetcher.fetch(currentVersion)
|
||||
val serverVersion = updateInfo.versionCode ?: 0L
|
||||
|
||||
if (serverVersion <= currentVersion) {
|
||||
emit(UpdateState.UpToDate)
|
||||
} else {
|
||||
emit(
|
||||
UpdateState.UpdateAvailable(
|
||||
url = checkNotNull(updateInfo.url) { "url required" },
|
||||
versionName = updateInfo.versionName ?: "v$serverVersion",
|
||||
changeLog = updateInfo.changelog,
|
||||
isForceUpdate = updateInfo.force
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emit(UpdateState.Error(message = e.message ?: "Ошибка проверки", throwable = e))
|
||||
}
|
||||
}
|
||||
|
||||
fun startDownload(url: String): Flow<UpdateState> = flow {
|
||||
if (installer == null || downloader == null) {
|
||||
emit(UpdateState.UpToDate)
|
||||
return@flow
|
||||
}
|
||||
if (!installer.hasInstallPermission()) {
|
||||
emit(UpdateState.InstallPermissionRequired)
|
||||
return@flow
|
||||
}
|
||||
emit(UpdateState.Downloading(0f, 0, 0))
|
||||
downloader.download(url).firstOrNull { progress ->
|
||||
when (progress) {
|
||||
is DownloadProgress.Active -> {
|
||||
emit(
|
||||
UpdateState.Downloading(
|
||||
progress = progress.progress,
|
||||
downloadedBytes = progress.downloadedBytes,
|
||||
totalBytes = progress.totalBytes
|
||||
)
|
||||
)
|
||||
false
|
||||
}
|
||||
is DownloadProgress.Success -> {
|
||||
emit(UpdateState.ReadyToInstall(filePath = progress.path))
|
||||
true
|
||||
}
|
||||
is DownloadProgress.Error -> {
|
||||
emit(UpdateState.Error(message = progress.throwable.message ?: "Ошибка скачивания", throwable = progress.throwable))
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestInstallPermission() {
|
||||
installer?.requestInstallPermission()
|
||||
}
|
||||
|
||||
suspend fun installApk(path: String) {
|
||||
installer?.install(path)
|
||||
}
|
||||
|
||||
suspend fun reset() {
|
||||
downloader?.clean()
|
||||
}
|
||||
|
||||
suspend fun hasInstallPermission(): Boolean {
|
||||
return installer?.hasInstallPermission() ?: false
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package ru.shadowsparky.updater.domain
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed interface UpdateState {
|
||||
|
||||
data object Idle : UpdateState
|
||||
|
||||
data object Checking : UpdateState
|
||||
|
||||
data class UpdateAvailable(
|
||||
val url: String,
|
||||
val versionName: String,
|
||||
val changeLog: String?,
|
||||
val isForceUpdate: Boolean
|
||||
) : UpdateState
|
||||
|
||||
data object InstallPermissionRequired : UpdateState
|
||||
|
||||
data object UpToDate : UpdateState
|
||||
|
||||
data object Completed : UpdateState
|
||||
|
||||
data class Downloading(
|
||||
val progress: Float,
|
||||
val downloadedBytes: Long,
|
||||
val totalBytes: Long
|
||||
) : UpdateState
|
||||
|
||||
data class ReadyToInstall(val filePath: String) : UpdateState
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
val throwable: Throwable? = null
|
||||
) : UpdateState
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package ru.shadowsparky.updater.presentation
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
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.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.updater.domain.UpdateInteractor
|
||||
import ru.shadowsparky.updater.domain.UpdateState
|
||||
|
||||
class UpdateComponent(
|
||||
componentContext: ComponentContext,
|
||||
private val interactor: UpdateInteractor
|
||||
) : ComponentContext by componentContext {
|
||||
val hasSupport = interactor.hasSupport
|
||||
var isForceUpdate = false
|
||||
private set
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
_state.value = UpdateState.Error(throwable.message ?: "$throwable", throwable)
|
||||
}
|
||||
|
||||
private var lastJob: Job? = null
|
||||
private val scope = CoroutineScope(exceptionHandler + SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private val _state = MutableValue<UpdateState>(UpdateState.Idle)
|
||||
val state: Value<UpdateState> = _state
|
||||
|
||||
private var pendingUrl: String? = null
|
||||
|
||||
init {
|
||||
lifecycle.doOnDestroy { scope.cancel() }
|
||||
lifecycle.doOnResume {
|
||||
val url = pendingUrl
|
||||
if (_state.value is UpdateState.InstallPermissionRequired && url != null) {
|
||||
scope.launch {
|
||||
if (interactor.hasInstallPermission()) downloadUpdate(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkUpdates(byUser: Boolean = true) {
|
||||
if (!hasSupport) return
|
||||
lastJob = scope.launch {
|
||||
interactor.checkUpdate().collect {
|
||||
isForceUpdate = (it as? UpdateState.UpdateAvailable)?.isForceUpdate ?: false
|
||||
_state.value = if (byUser) {
|
||||
it
|
||||
} else {
|
||||
when (it) {
|
||||
UpdateState.Checking,
|
||||
UpdateState.UpToDate,
|
||||
is UpdateState.Error -> UpdateState.Idle
|
||||
else -> it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadUpdate(url: String) {
|
||||
pendingUrl = url
|
||||
lastJob = scope.launch {
|
||||
interactor.startDownload(url).collect { newState ->
|
||||
_state.value = newState
|
||||
if (newState !is UpdateState.InstallPermissionRequired) {
|
||||
pendingUrl = null
|
||||
}
|
||||
if (newState is UpdateState.ReadyToInstall) {
|
||||
installApk(newState.filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPermission() {
|
||||
scope.launch { interactor.requestInstallPermission() }
|
||||
}
|
||||
|
||||
fun installApk(path: String) {
|
||||
scope.launch {
|
||||
_state.value = UpdateState.Checking
|
||||
interactor.installApk(path)
|
||||
_state.value = UpdateState.Completed
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
scope.launch {
|
||||
_state.value = UpdateState.Checking
|
||||
if (lastJob?.isActive == true) lastJob?.cancel()
|
||||
interactor.reset()
|
||||
_state.value = UpdateState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Factory
|
||||
class UpdateComponentFactory(private val interactor: UpdateInteractor) {
|
||||
fun create(componentContext: ComponentContext): UpdateComponent {
|
||||
return UpdateComponent(componentContext, interactor)
|
||||
}
|
||||
}
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
package ru.shadowsparky.updater.presentation
|
||||
|
||||
import androiddev.libs.updater.updater_client.generated.resources.Res
|
||||
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_close
|
||||
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_download
|
||||
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_grant
|
||||
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_install
|
||||
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_retry
|
||||
import androiddev.libs.updater.updater_client.generated.resources.updater_changelog_empty
|
||||
import androiddev.libs.updater.updater_client.generated.resources.updater_changelog_title
|
||||
import androiddev.libs.updater.updater_client.generated.resources.updater_desc_permission
|
||||
import androiddev.libs.updater.updater_client.generated.resources.updater_download_progress
|
||||
import androiddev.libs.updater.updater_client.generated.resources.updater_title_available
|
||||
import androiddev.libs.updater.updater_client.generated.resources.updater_title_completed
|
||||
import androiddev.libs.updater.updater_client.generated.resources.updater_title_downloading
|
||||
import androiddev.libs.updater.updater_client.generated.resources.updater_title_error
|
||||
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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.shadowsparky.ui.components.AlertContent
|
||||
import ru.shadowsparky.ui.components.LazyColumn
|
||||
import ru.shadowsparky.ui.components.Loading
|
||||
import ru.shadowsparky.updater.domain.UpdateState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UpdateDialog(
|
||||
component: UpdateComponent,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val state by component.state.subscribeAsState()
|
||||
val canDismiss = remember(state) { !component.isForceUpdate }
|
||||
val isVisible = remember(state) { state !is UpdateState.Idle }
|
||||
if (isVisible) {
|
||||
AlertContent(
|
||||
modifier = modifier,
|
||||
onDismissRequest = { component.reset() },
|
||||
canDismiss = canDismiss,
|
||||
content = {
|
||||
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 -> {
|
||||
UpdateFinishedContent(onSkip = { component.reset() })
|
||||
}
|
||||
|
||||
is UpdateState.Completed -> {
|
||||
UpdateFinishedContent(
|
||||
Res.string.updater_title_completed,
|
||||
onSkip = { component.reset() }
|
||||
)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdateCheckingContent() {
|
||||
Loading(Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdateAvailableContent(
|
||||
versionName: String,
|
||||
changeLog: String?,
|
||||
onDownloadClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.updater_title_available, versionName),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.updater_changelog_title),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = changeLog ?: stringResource(Res.string.updater_changelog_empty),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
item {
|
||||
Button(onClick = onDownloadClick, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(Res.string.updater_btn_download),
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdatePermissionContent(
|
||||
onGrantClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.updater_title_permission),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.updater_desc_permission),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Button(onClick = onGrantClick, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(Res.string.updater_btn_grant),
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdateDownloadingContent(
|
||||
progress: Float,
|
||||
downloadedBytes: Long,
|
||||
totalBytes: Long,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.updater_title_downloading),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
||||
|
||||
val downloadedMb = ((downloadedBytes / (1024f * 1024f)) * 10).toInt() / 10.0
|
||||
val totalMb = ((totalBytes / (1024f * 1024f)) * 10).toInt() / 10.0
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.updater_download_progress,
|
||||
downloadedMb.toString(),
|
||||
totalMb.toString()
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdateReadyContent(
|
||||
onInstallClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.updater_title_ready),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
Button(onClick = onInstallClick, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(Res.string.updater_btn_install),
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdateFinishedContent(
|
||||
text: StringResource = Res.string.updater_title_not_found,
|
||||
onSkip: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(text = stringResource(text), style = MaterialTheme.typography.titleLarge)
|
||||
Button(onClick = onSkip, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(Res.string.updater_btn_close),
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdateErrorContent(
|
||||
message: String,
|
||||
onRetryClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.updater_title_error),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(text = message, style = MaterialTheme.typography.bodyMedium)
|
||||
Button(onClick = onRetryClick, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(Res.string.updater_btn_retry),
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun UpdateAvailablePreview() {
|
||||
MaterialTheme {
|
||||
UpdateAvailableContent(
|
||||
versionName = "v1.4.0",
|
||||
changeLog = "- Добавили темную тему\n- Исправили падение на главном экране\n- Повысили стабильность",
|
||||
onDownloadClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun UpdateDownloadingPreview() {
|
||||
MaterialTheme {
|
||||
UpdateDownloadingContent(
|
||||
progress = 0.45f,
|
||||
downloadedBytes = 24_117_248L, // ~23 MB
|
||||
totalBytes = 52_428_800L // 50 MB
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun UpdatePermissionPreview() {
|
||||
MaterialTheme {
|
||||
UpdatePermissionContent(onGrantClick = {})
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun UpdateErrorPreview() {
|
||||
MaterialTheme {
|
||||
UpdateErrorContent(message = "Http status 503: Service Unavailable", onRetryClick = {})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user