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)
+ }
+}