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 java.io.File
@Factory
@Factory(binds = [BackendUpdateFetcher::class])
class BackendUpdateFetcher(
private val envFetcher: EnvFetcher,
private val json: Json,
private val dispatcherProvider: DispatcherProvider
) : UpdateFetcher {
private var config: UpdateInfo? = null
private var updateFile: File? = null
override suspend fun fetch(versionCode: Long): UpdateInfo = withContext(dispatcherProvider.io) {
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 movieTagFactory: MovieTagRepositoryFactory,
val userTagFactory: UserTagRepositoryFactory,
val updateFetcher: UpdateFetcher
val updateFetcher: BackendUpdateFetcher
)
@Single
@@ -1,15 +1,20 @@
package ru.shadowsparky.vbox.backend.presentation.routing
import io.ktor.server.response.respond
import io.ktor.server.response.respondFile
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.util.getOrFail
import ru.shadowsparky.updater.domain.UpdateFetcher
import ru.shadowsparky.vbox.backend.data.BackendUpdateFetcher
import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX
fun Route.setupUpdates(updateFetcher: UpdateFetcher) {
fun Route.setupUpdates(updateFetcher: BackendUpdateFetcher) {
get("$DYNAMIC_PREFIX/checkUpdates") {
val versionCode = call.parameters.getOrFail(UpdateFetcher.ARG_VERSION_CODE).toLong()
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">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"
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>
@@ -2,14 +2,17 @@ package ru.shadowsparky.updater.data
import android.app.Application
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.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
@@ -18,55 +21,58 @@ import java.io.File
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 {
val response = httpClient.get(url)
val contentLength = response.contentLength() ?: -1L
val channel: ByteReadChannel = response.bodyAsChannel()
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)
val directory = File(destinationPath)
if (!directory.exists()) {
directory.mkdirs()
}
if (file.exists()) file.delete()
val fileName = "update.apk"
val fullPath = "$destinationPath/$fileName"
val file = File(fullPath)
val bufferSize = 8192
val buffer = ByteArray(bufferSize)
var downloadedBytes = 0L
if (file.exists()) file.delete()
file.outputStream().use { outputStream ->
while (!channel.isClosedForRead) {
val read = channel.readAvailable(buffer, 0, buffer.size)
if (read <= 0) break
val bufferSize = 8192
val buffer = ByteArray(bufferSize)
var downloadedBytes = 0L
outputStream.write(buffer, 0, read)
downloadedBytes += read
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
if (contentLength > 0) {
val progress = downloadedBytes.toFloat() / contentLength.toFloat()
emit(
DownloadProgress.Active(
progress = progress,
downloadedBytes = downloadedBytes,
totalBytes = contentLength
)
)
)
}
}
}
}
emit(DownloadProgress.Success(path = file.absolutePath))
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()
}
}
@@ -4,6 +4,8 @@ import kotlinx.coroutines.flow.Flow
interface UpdateDownloader {
fun download(url: String): Flow<DownloadProgress>
suspend fun clean()
}
sealed interface DownloadProgress {
@@ -31,6 +31,7 @@ class UpdateInteractor(
} else {
emit(
UpdateState.UpdateAvailable(
url = checkNotNull(updateInfo.url) { "url required" },
versionName = updateInfo.versionName ?: "v$serverVersion",
changeLog = updateInfo.changelog,
isForceUpdate = updateInfo.force
@@ -51,7 +52,7 @@ class UpdateInteractor(
emit(UpdateState.InstallPermissionRequired)
return@flow
}
emit(UpdateState.Downloading(0f, 0, 0))
downloader.download(url).collect { progress ->
when (progress) {
is DownloadProgress.Active -> {
@@ -81,6 +82,10 @@ class UpdateInteractor(
installer?.install(path)
}
suspend fun reset() {
downloader?.clean()
}
suspend fun hasInstallPermission(): Boolean {
return installer?.hasInstallPermission() ?: false
}
@@ -10,6 +10,7 @@ sealed interface UpdateState {
data object Checking : UpdateState
data class UpdateAvailable(
val url: String,
val versionName: String,
val changeLog: String?,
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_permission
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -61,17 +61,13 @@ fun UpdateBottomSheet(
sheetState = sheetState,
modifier = modifier
) {
AnimatedContent(
targetState = state,
label = "UpdateStateAnimation",
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)
) { currentState ->
when (currentState) {
Box(Modifier.padding(horizontal = 8.dp)) {
when (val currentState = state) {
is UpdateState.Checking -> UpdateCheckingContent()
is UpdateState.UpdateAvailable -> UpdateAvailableContent(
versionName = currentState.versionName,
changeLog = currentState.changeLog,
onDownloadClick = { component.downloadUpdate(currentState.versionName) }
onDownloadClick = { component.downloadUpdate(currentState.url) }
)
is UpdateState.InstallPermissionRequired -> UpdatePermissionContent(
onGrantClick = { component.requestPermission() }
@@ -89,7 +85,7 @@ fun UpdateBottomSheet(
onRetryClick = { component.checkUpdates() }
)
is UpdateState.UpToDate -> {
UpdateNotFoundContent(onSkip = { component.reset() })
UpdateNotFoundContent(onSkip = { component.reset() })
}
else -> Unit
}
@@ -67,7 +67,11 @@ class UpdateComponent(
}
fun reset() {
_state.value = UpdateState.Idle
scope.launch {
_state.value = UpdateState.Checking
interactor.reset()
_state.value = UpdateState.Idle
}
}
}