diff --git a/apps/vbox/client/androidApp/build.gradle.kts b/apps/vbox/client/androidApp/build.gradle.kts index 78e67c3..5f599df 100644 --- a/apps/vbox/client/androidApp/build.gradle.kts +++ b/apps/vbox/client/androidApp/build.gradle.kts @@ -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) diff --git a/apps/vbox/client/shared/build.gradle.kts b/apps/vbox/client/shared/build.gradle.kts index 365ca0d..87931d9 100644 --- a/apps/vbox/client/shared/build.gradle.kts +++ b/apps/vbox/client/shared/build.gradle.kts @@ -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) diff --git a/apps/vbox/client/shared/src/commonMain/composeResources/values/strings.xml b/apps/vbox/client/shared/src/commonMain/composeResources/values/strings.xml index 73aa8be..2961ce5 100644 --- a/apps/vbox/client/shared/src/commonMain/composeResources/values/strings.xml +++ b/apps/vbox/client/shared/src/commonMain/composeResources/values/strings.xml @@ -67,4 +67,5 @@ Удалить Настроить теги Не фильтровать + Поиск обновлений diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt index cc5f4a1..a51ad8f 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt @@ -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 { 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)) }, diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt index fd75a78..1336c83 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 71a107b..3dd390c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/libs/updater/build.gradle.kts b/libs/updater/build.gradle.kts index 4150377..3f660fe 100644 --- a/libs/updater/build.gradle.kts +++ b/libs/updater/build.gradle.kts @@ -15,6 +15,11 @@ kotlin { implementation(libs.ui.tooling.preview) } } + val androidMain by getting { + dependencies { + implementation(libs.androidx.core) + } + } } android { namespace = "ru.shadowsparky.updater" } } diff --git a/libs/updater/src/androidMain/AndroidManifest.xml b/libs/updater/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..97217f0 --- /dev/null +++ b/libs/updater/src/androidMain/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + diff --git a/libs/updater/src/androidMain/kotlin/ru/shadowsparky/updater/data/AndroidUpdateInstaller.kt b/libs/updater/src/androidMain/kotlin/ru/shadowsparky/updater/data/AndroidUpdateInstaller.kt new file mode 100644 index 0000000..6ab1ea9 --- /dev/null +++ b/libs/updater/src/androidMain/kotlin/ru/shadowsparky/updater/data/AndroidUpdateInstaller.kt @@ -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) + } +} diff --git a/libs/updater/src/androidMain/kotlin/ru/shadowsparky/updater/data/KtorUpdateDownloader.kt b/libs/updater/src/androidMain/kotlin/ru/shadowsparky/updater/data/KtorUpdateDownloader.kt new file mode 100644 index 0000000..bfab356 --- /dev/null +++ b/libs/updater/src/androidMain/kotlin/ru/shadowsparky/updater/data/KtorUpdateDownloader.kt @@ -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 = 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)) + } + } +} + diff --git a/libs/updater/src/androidMain/res/xml/file_paths.xml b/libs/updater/src/androidMain/res/xml/file_paths.xml new file mode 100644 index 0000000..4f27865 --- /dev/null +++ b/libs/updater/src/androidMain/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInteractor.kt b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInteractor.kt index 1d8191f..0696d6f 100644 --- a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInteractor.kt +++ b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInteractor.kt @@ -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 = flow { if (appConfig?.versionCode == null || fetcher == null) { emit(UpdateState.UpToDate) diff --git a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateBottomSheet.kt b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateBottomSheet.kt index 20f3025..9a0c47f 100644 --- a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateBottomSheet.kt +++ b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateBottomSheet.kt @@ -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 diff --git a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt index 62f773a..0bc9570 100644 --- a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt +++ b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt @@ -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) + } +}