updater ui, data implementation

This commit is contained in:
2026-07-25 17:57:46 +03:00
parent 7bd09459fa
commit aa6c62d283
14 changed files with 178 additions and 13 deletions
@@ -27,6 +27,7 @@ dependencies {
implementation(project(":libs:ui"))
implementation(project(":libs:http-client"))
implementation(project(":libs:video-player"))
implementation(project(":libs:updater"))
debugImplementation(libs.ui.tooling)
implementation(libs.ui.tooling.preview)
testImplementation(libs.koin.test)
+1
View File
@@ -26,6 +26,7 @@ kotlin {
implementation(project(":libs:ui"))
implementation(project(":libs:http-client"))
implementation(project(":libs:video-player"))
implementation(project(":libs:updater"))
}
androidMain.dependencies {
implementation(libs.androidx.appcompat)
@@ -67,4 +67,5 @@
<string name="user_tags_tag_delete">Удалить</string>
<string name="movie_tags_empty">Настроить теги</string>
<string name="user_tags_filter_disable">Не фильтровать</string>
<string name="check_for_updates">Поиск обновлений</string>
</resources>
@@ -1,7 +1,7 @@
package ru.shadowsparky.vbox.shared.presentation.settings
import com.arkivanov.decompose.ComponentContext
import kotlinx.coroutines.flow.filterNotNull
import com.arkivanov.decompose.childContext
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
@@ -11,6 +11,7 @@ import ru.shadowsparky.http.domain.LogoutAction
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.updater.presentation.UpdateComponentFactory
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.OfflineStorage
import ru.shadowsparky.vbox.shared.domain.PlayerSettingsStorge
@@ -29,6 +30,7 @@ class SettingsComponent(
private val playerSettingsStorge: PlayerSettingsStorge,
val offlineSupport: Boolean,
val isDebug: Boolean,
updateComponentFactory: UpdateComponentFactory,
val doBack: () -> Unit,
val editServAddr: () -> Unit,
val doAuth: () -> Unit,
@@ -37,9 +39,11 @@ class SettingsComponent(
val showOffline: () -> Unit,
val showTags: () -> Unit
) : ComponentContext by context {
val serverInfo = serverConfigurationRepository.serverConfiguration
.filterNotNull()
.map { it.asHttpStr() }
private val updateComponent = updateComponentFactory.create(childContext("UpdateComponent"))
val hasUpdateSupport = updateComponent.hasSupport
val serverInfo = serverConfigurationRepository.serverConfiguration.map { it.asHttpStr() }
private val componentScope = createComponentScope()
val externalPlayerState = playerSettingsStorge.useExternalPlayer
@@ -52,6 +56,10 @@ class SettingsComponent(
}
}
fun checkUpdates() {
updateComponent.checkUpdates()
}
fun logout() {
componentScope.launch(dispatcherProvider.main) {
logoutUseCase.logout()
@@ -68,7 +76,8 @@ class SettingsComponentFactory(
private val appConfig: AppConfig,
private val playerSettingsStorge: PlayerSettingsStorge,
private val tokenStorage: TokenStorage,
private val offlineStorage: OfflineStorage?
private val offlineStorage: OfflineStorage?,
private val updateComponentFactory: UpdateComponentFactory
) : ComponentFactory<Child.SettingsChild, Route.Settings, Route> {
override fun create(
@@ -80,7 +89,8 @@ class SettingsComponentFactory(
child,
dispatcherProvider,
serverConfigurationRepository,
isDebug = appConfig.isDebug(),
isDebug = appConfig.debug,
updateComponentFactory = updateComponentFactory,
doBack = { root.onBack() },
editServAddr = { root.nav(Route.Settings.EditServ()) },
doAuth = { root.nav(Route.Settings.Auth(required = true)) },
@@ -4,6 +4,7 @@ import androiddev.apps.vbox.client.shared.generated.resources.Res
import androiddev.apps.vbox.client.shared.generated.resources.account_auth
import androiddev.apps.vbox.client.shared.generated.resources.account_logout_auth
import androiddev.apps.vbox.client.shared.generated.resources.account_reg
import androiddev.apps.vbox.client.shared.generated.resources.check_for_updates
import androiddev.apps.vbox.client.shared.generated.resources.custom_serv_addr
import androiddev.apps.vbox.client.shared.generated.resources.edit_password
import androiddev.apps.vbox.client.shared.generated.resources.external_player_title
@@ -72,6 +73,14 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
modifier = Modifier.clickable { comp.showTags() }
)
}
if (comp.hasUpdateSupport) {
item {
TextPreference(
text = stringResource(Res.string.check_for_updates),
modifier = Modifier.clickable { comp.checkUpdates() }
)
}
}
}
section {
val needAuth = tokenInfo == null
+2
View File
@@ -48,6 +48,7 @@ kotlin-dsl-version = "5.1.2"
coreKtx = "1.19.0"
workRuntimeKtx = "2.11.2"
krpc = "0.10.3"
core = "1.19.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -139,6 +140,7 @@ kotlinx-rpc-krpc-ktor-server = { module = "org.jetbrains.kotlinx:kotlinx-rpc-krp
kotlinx-rpc-krpc-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-rpc-krpc-serialization-json", version.ref = "krpc" }
kotlinx-rpc-krpc-server = { module = "org.jetbrains.kotlinx:kotlinx-rpc-krpc-server", version.ref = "krpc" }
kotlinx-rpc-krpc-client = { module = "org.jetbrains.kotlinx:kotlinx-rpc-krpc-client", version.ref = "krpc" }
androidx-core = { group = "androidx.core", name = "core", version.ref = "core" }
[plugins]
jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose-gradle-plugin" }
+5
View File
@@ -15,6 +15,11 @@ kotlin {
implementation(libs.ui.tooling.preview)
}
}
val androidMain by getting {
dependencies {
implementation(libs.androidx.core)
}
}
}
android { namespace = "ru.shadowsparky.updater" }
}
@@ -0,0 +1,5 @@
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"
tools:ignore="RequestInstallPackagesPolicy" />
</manifest>
@@ -0,0 +1,43 @@
package ru.shadowsparky.updater.data
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import org.koin.core.annotation.Factory
import ru.shadowsparky.updater.domain.UpdateInstaller
import java.io.File
@Factory
class AndroidUpdateInstaller(private val app: Application) : UpdateInstaller {
override suspend fun hasInstallPermission(): Boolean {
return app.packageManager.canRequestPackageInstalls()
}
override suspend fun requestInstallPermission() {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${app.packageName}".toUri()
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
app.startActivity(intent)
}
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)
}
val uri: Uri = FileProvider.getUriForFile(
app,
"${app.packageName}.provider",
file
)
intent.setDataAndType(uri, "application/vnd.android.package-archive")
app.startActivity(intent)
}
}
@@ -0,0 +1,72 @@
package ru.shadowsparky.updater.data
import android.app.Application
import io.ktor.client.HttpClient
import io.ktor.client.request.get
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 org.koin.core.annotation.Factory
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,
) : UpdateDownloader {
private val destinationPath = "${app.cacheDir.absolutePath}/update"
override fun download(url: String): Flow<DownloadProgress> = flow {
try {
val response = httpClient.get(url)
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
)
)
}
}
}
emit(DownloadProgress.Success(path = file.absolutePath))
} catch (e: Exception) {
emit(DownloadProgress.Error(e))
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="updater_isolated_cache" path="update/" />
</paths>
@@ -12,6 +12,9 @@ class UpdateInteractor(
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)
@@ -90,7 +90,7 @@ fun UpdateBottomSheet(
}
@Composable
fun UpdateCheckingContent(modifier: Modifier = Modifier) {
private fun UpdateCheckingContent(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
@@ -98,7 +98,7 @@ fun UpdateCheckingContent(modifier: Modifier = Modifier) {
}
@Composable
fun UpdateAvailableContent(
private fun UpdateAvailableContent(
versionName: String,
changeLog: String?,
onDownloadClick: () -> Unit,
@@ -131,7 +131,7 @@ fun UpdateAvailableContent(
}
@Composable
fun UpdatePermissionContent(
private fun UpdatePermissionContent(
onGrantClick: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -149,7 +149,7 @@ fun UpdatePermissionContent(
}
@Composable
fun UpdateDownloadingContent(
private fun UpdateDownloadingContent(
progress: Float,
downloadedBytes: Long,
totalBytes: Long,
@@ -174,7 +174,7 @@ fun UpdateDownloadingContent(
}
@Composable
fun UpdateReadyContent(
private fun UpdateReadyContent(
onInstallClick: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -191,7 +191,7 @@ fun UpdateReadyContent(
}
@Composable
fun UpdateErrorContent(
private fun UpdateErrorContent(
message: String,
onRetryClick: () -> Unit,
modifier: Modifier = Modifier
@@ -10,13 +10,15 @@ import kotlinx.coroutines.Dispatchers
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,
private val interactor: UpdateInteractor
) : ComponentContext by componentContext {
val hasSupport = interactor.hasSupport
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@@ -64,3 +66,10 @@ class UpdateComponent(
scope.launch { interactor.installApk(path) }
}
}
@Factory
class UpdateComponentFactory(private val interactor: UpdateInteractor) {
fun create(componentContext: ComponentContext): UpdateComponent {
return UpdateComponent(componentContext, interactor)
}
}