add update file

This commit is contained in:
2026-07-25 23:08:44 +03:00
parent 356d70b737
commit ee340f11ed
10 changed files with 90 additions and 48 deletions
@@ -9,13 +9,14 @@ import ru.shadowsparky.updater.domain.UpdateFetcher
import ru.shadowsparky.updater.domain.UpdateInfo import ru.shadowsparky.updater.domain.UpdateInfo
import java.io.File import java.io.File
@Factory @Factory(binds = [BackendUpdateFetcher::class])
class BackendUpdateFetcher( class BackendUpdateFetcher(
private val envFetcher: EnvFetcher, private val envFetcher: EnvFetcher,
private val json: Json, private val json: Json,
private val dispatcherProvider: DispatcherProvider private val dispatcherProvider: DispatcherProvider
) : UpdateFetcher { ) : UpdateFetcher {
private var config: UpdateInfo? = null private var config: UpdateInfo? = null
private var updateFile: File? = null
override suspend fun fetch(versionCode: Long): UpdateInfo = withContext(dispatcherProvider.io) { override suspend fun fetch(versionCode: Long): UpdateInfo = withContext(dispatcherProvider.io) {
config?.let { return@withContext it } config?.let { return@withContext it }
@@ -28,4 +29,13 @@ class BackendUpdateFetcher(
} }
} }
} }
suspend fun updateFile(): File = withContext(dispatcherProvider.io) {
val updateFilePath = envFetcher.get("UPDATE_FILE")
if (updateFilePath.isBlank()) error("Update not found")
val file = File(updateFilePath)
if (!file.exists()) error("File not exists")
updateFile = file
file
}
} }
@@ -25,7 +25,7 @@ class RoutingEntryPoint(
val savedMovieFactory: SavedMovieRepositoryFactory, val savedMovieFactory: SavedMovieRepositoryFactory,
val movieTagFactory: MovieTagRepositoryFactory, val movieTagFactory: MovieTagRepositoryFactory,
val userTagFactory: UserTagRepositoryFactory, val userTagFactory: UserTagRepositoryFactory,
val updateFetcher: UpdateFetcher val updateFetcher: BackendUpdateFetcher
) )
@Single @Single
@@ -1,15 +1,20 @@
package ru.shadowsparky.vbox.backend.presentation.routing package ru.shadowsparky.vbox.backend.presentation.routing
import io.ktor.server.response.respond import io.ktor.server.response.respond
import io.ktor.server.response.respondFile
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.util.getOrFail import io.ktor.server.util.getOrFail
import ru.shadowsparky.updater.domain.UpdateFetcher import ru.shadowsparky.updater.domain.UpdateFetcher
import ru.shadowsparky.vbox.backend.data.BackendUpdateFetcher
import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX
fun Route.setupUpdates(updateFetcher: UpdateFetcher) { fun Route.setupUpdates(updateFetcher: BackendUpdateFetcher) {
get("$DYNAMIC_PREFIX/checkUpdates") { get("$DYNAMIC_PREFIX/checkUpdates") {
val versionCode = call.parameters.getOrFail(UpdateFetcher.ARG_VERSION_CODE).toLong() val versionCode = call.parameters.getOrFail(UpdateFetcher.ARG_VERSION_CODE).toLong()
call.respond(updateFetcher.fetch(versionCode)) call.respond(updateFetcher.fetch(versionCode))
} }
get("$DYNAMIC_PREFIX/getApk") {
call.respondFile(updateFetcher.updateFile())
}
} }
@@ -2,4 +2,17 @@
xmlns:android="http://schemas.android.com/apk/res/android"> xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"
tools:ignore="RequestInstallPackagesPolicy" /> tools:ignore="RequestInstallPackagesPolicy" />
<application>
<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> </manifest>
@@ -2,14 +2,17 @@ package ru.shadowsparky.updater.data
import android.app.Application import android.app.Application
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.request.get import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.contentLength import io.ktor.http.contentLength
import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readAvailable import io.ktor.utils.io.readAvailable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
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 org.koin.core.annotation.Factory
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.updater.domain.DownloadProgress import ru.shadowsparky.updater.domain.DownloadProgress
import ru.shadowsparky.updater.domain.UpdateDownloader import ru.shadowsparky.updater.domain.UpdateDownloader
import java.io.File import java.io.File
@@ -18,20 +21,19 @@ import java.io.File
class KtorUpdateDownloader( class KtorUpdateDownloader(
private val httpClient: HttpClient, private val httpClient: HttpClient,
app: Application, app: Application,
private val dispatcherProvider: DispatcherProvider
) : UpdateDownloader { ) : UpdateDownloader {
private val destinationPath = "${app.cacheDir.absolutePath}/update" private val destinationPath = "${app.cacheDir.absolutePath}/update"
override fun download(url: String): Flow<DownloadProgress> = flow { override fun download(url: String): Flow<DownloadProgress> = flow {
try { try {
val response = httpClient.get(url) httpClient.prepareGet(url).execute { response ->
val contentLength = response.contentLength() ?: -1L val contentLength = response.contentLength() ?: -1L
val channel: ByteReadChannel = response.bodyAsChannel() val channel: ByteReadChannel = response.bodyAsChannel()
val directory = File(destinationPath) val directory = File(destinationPath)
if (!directory.exists()) { if (!directory.exists()) {
directory.mkdirs() directory.mkdirs()
} }
val fileName = "update.apk" val fileName = "update.apk"
val fullPath = "$destinationPath/$fileName" val fullPath = "$destinationPath/$fileName"
val file = File(fullPath) val file = File(fullPath)
@@ -62,11 +64,15 @@ class KtorUpdateDownloader(
} }
} }
} }
}
emit(DownloadProgress.Success(path = file.absolutePath)) val finalFile = File("$destinationPath/update.apk")
emit(DownloadProgress.Success(path = finalFile.absolutePath))
} catch (e: Exception) { } catch (e: Exception) {
emit(DownloadProgress.Error(e)) emit(DownloadProgress.Error(e))
} }
}.flowOn(dispatcherProvider.io)
override suspend fun clean(): Unit = withContext(dispatcherProvider.io) {
File(destinationPath).deleteRecursively()
} }
} }
@@ -4,6 +4,8 @@ import kotlinx.coroutines.flow.Flow
interface UpdateDownloader { interface UpdateDownloader {
fun download(url: String): Flow<DownloadProgress> fun download(url: String): Flow<DownloadProgress>
suspend fun clean()
} }
sealed interface DownloadProgress { sealed interface DownloadProgress {
@@ -31,6 +31,7 @@ class UpdateInteractor(
} else { } else {
emit( emit(
UpdateState.UpdateAvailable( UpdateState.UpdateAvailable(
url = checkNotNull(updateInfo.url) { "url required" },
versionName = updateInfo.versionName ?: "v$serverVersion", versionName = updateInfo.versionName ?: "v$serverVersion",
changeLog = updateInfo.changelog, changeLog = updateInfo.changelog,
isForceUpdate = updateInfo.force isForceUpdate = updateInfo.force
@@ -51,7 +52,7 @@ class UpdateInteractor(
emit(UpdateState.InstallPermissionRequired) emit(UpdateState.InstallPermissionRequired)
return@flow return@flow
} }
emit(UpdateState.Downloading(0f, 0, 0))
downloader.download(url).collect { progress -> downloader.download(url).collect { progress ->
when (progress) { when (progress) {
is DownloadProgress.Active -> { is DownloadProgress.Active -> {
@@ -81,6 +82,10 @@ class UpdateInteractor(
installer?.install(path) installer?.install(path)
} }
suspend fun reset() {
downloader?.clean()
}
suspend fun hasInstallPermission(): Boolean { suspend fun hasInstallPermission(): Boolean {
return installer?.hasInstallPermission() ?: false return installer?.hasInstallPermission() ?: false
} }
@@ -10,6 +10,7 @@ sealed interface UpdateState {
data object Checking : UpdateState data object Checking : UpdateState
data class UpdateAvailable( data class UpdateAvailable(
val url: String,
val versionName: String, val versionName: String,
val changeLog: String?, val changeLog: String?,
val isForceUpdate: Boolean val isForceUpdate: Boolean
@@ -16,8 +16,8 @@ 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.animation.AnimatedContent
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -61,17 +61,13 @@ fun UpdateBottomSheet(
sheetState = sheetState, sheetState = sheetState,
modifier = modifier modifier = modifier
) { ) {
AnimatedContent( Box(Modifier.padding(horizontal = 8.dp)) {
targetState = state, when (val currentState = state) {
label = "UpdateStateAnimation",
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)
) { currentState ->
when (currentState) {
is UpdateState.Checking -> UpdateCheckingContent() is UpdateState.Checking -> UpdateCheckingContent()
is UpdateState.UpdateAvailable -> UpdateAvailableContent( is UpdateState.UpdateAvailable -> UpdateAvailableContent(
versionName = currentState.versionName, versionName = currentState.versionName,
changeLog = currentState.changeLog, changeLog = currentState.changeLog,
onDownloadClick = { component.downloadUpdate(currentState.versionName) } onDownloadClick = { component.downloadUpdate(currentState.url) }
) )
is UpdateState.InstallPermissionRequired -> UpdatePermissionContent( is UpdateState.InstallPermissionRequired -> UpdatePermissionContent(
onGrantClick = { component.requestPermission() } onGrantClick = { component.requestPermission() }
@@ -67,8 +67,12 @@ class UpdateComponent(
} }
fun reset() { fun reset() {
scope.launch {
_state.value = UpdateState.Checking
interactor.reset()
_state.value = UpdateState.Idle _state.value = UpdateState.Idle
} }
}
} }
@Factory @Factory