Show update download progress (#165)

Shows a simple dialog while the app update is downloading to show
progress
This commit is contained in:
damontecres 2025-11-06 18:15:25 -05:00 committed by GitHub
parent fc35bafceb
commit 5d085a3ab4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 190 additions and 11 deletions

4
.gitignore vendored
View file

@ -53,3 +53,7 @@ app/libs/
ffmpeg_decoder/ ffmpeg_decoder/
app/release/ app/release/
app/debug/ app/debug/
app/src/main/jnilibs/
app/src/main/libs/
app/src/main/obj/

View file

@ -15,9 +15,11 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -28,6 +30,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
@ -35,6 +39,7 @@ import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -46,19 +51,25 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.DownloadCallback
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Release import com.github.damontecres.wholphin.util.Release
import com.github.damontecres.wholphin.util.UpdateChecker import com.github.damontecres.wholphin.util.UpdateChecker
import com.github.damontecres.wholphin.util.Version import com.github.damontecres.wholphin.util.Version
import com.github.damontecres.wholphin.util.formatBytes
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@ -69,10 +80,15 @@ class UpdateViewModel
constructor( constructor(
val updater: UpdateChecker, val updater: UpdateChecker,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel(),
DownloadCallback {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val release = MutableLiveData<Release?>(null) val release = MutableLiveData<Release?>(null)
val downloading = MutableLiveData<Boolean>(false)
val contentLength = MutableLiveData<Long>(-1)
val bytesDownloaded = MutableLiveData<Long>(-1)
val currentVersion = updater.getInstalledVersion() val currentVersion = updater.getInstalledVersion()
fun init(updateUrl: String) { fun init(updateUrl: String) {
@ -80,17 +96,55 @@ class UpdateViewModel
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) { viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) {
val release = updater.getLatestRelease(updateUrl) val release = updater.getLatestRelease(updateUrl)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
contentLength.value = -1
bytesDownloaded.value = -1
this@UpdateViewModel.release.value = release this@UpdateViewModel.release.value = release
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
} }
} }
private var downloadJob: Job? = null
fun installRelease(release: Release) { fun installRelease(release: Release) {
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to install update")) { downloadJob =
updater.installRelease(release) viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Failed to install update",
),
) {
downloading.setValueOnMain(true)
updater.installRelease(release, this@UpdateViewModel)
downloading.setValueOnMain(false)
}
}
fun cancelDownload() {
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error",
),
) {
downloadJob?.cancel()
withContext(Dispatchers.Main) {
downloading.value = false
contentLength.value = -1
bytesDownloaded.value = -1
}
} }
} }
override fun contentLength(contentLength: Long) {
this@UpdateViewModel.contentLength.value = contentLength
}
override fun bytesDownloaded(bytes: Long) {
this@UpdateViewModel.bytesDownloaded.value = bytes
}
} }
@Composable @Composable
@ -101,6 +155,11 @@ fun InstallUpdatePage(
) { ) {
val loading by viewModel.loading.observeAsState(LoadingState.Pending) val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val release by viewModel.release.observeAsState(null) val release by viewModel.release.observeAsState(null)
val isDownloading by viewModel.downloading.observeAsState(false)
val contentLength by viewModel.contentLength.observeAsState(-1L)
val bytesDownloaded by viewModel.bytesDownloaded.observeAsState(-1)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init(preferences.appPreferences.updateUrl) viewModel.init(preferences.appPreferences.updateUrl)
} }
@ -121,7 +180,7 @@ fun InstallUpdatePage(
LoadingState.Pending, LoadingState.Pending,
-> LoadingPage(modifier) -> LoadingPage(modifier)
LoadingState.Success -> LoadingState.Success -> {
release?.let { release?.let {
InstallUpdatePageContent( InstallUpdatePageContent(
currentVersion = viewModel.currentVersion, currentVersion = viewModel.currentVersion,
@ -136,9 +195,26 @@ fun InstallUpdatePage(
onCancel = { onCancel = {
viewModel.navigationManager.goBack() viewModel.navigationManager.goBack()
}, },
modifier = modifier, modifier =
modifier
.ifElse(
isDownloading,
Modifier
.blur(8.dp)
.alpha(.33f),
),
) )
} }
if (isDownloading) {
DownloadDialog(
contentLength = contentLength,
bytesDownloaded = bytesDownloaded,
onDismissRequest = {
viewModel.cancelDownload()
},
)
}
}
} }
} }
@ -250,6 +326,65 @@ fun InstallUpdatePageContent(
} }
} }
@Composable
fun DownloadDialog(
contentLength: Long,
bytesDownloaded: Long,
onDismissRequest: () -> Unit,
) {
val progress =
if (contentLength > 0) {
bytesDownloaded.toFloat() / contentLength
} else {
null
}
BasicDialog(
onDismissRequest = onDismissRequest,
elevation = 6.dp,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(16.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier,
) {
Text(
text = stringResource(R.string.downloading),
fontSize = 24.sp,
color = MaterialTheme.colorScheme.onSurface,
)
if (progress != null) {
CircularProgressIndicator(
progress = { progress },
Modifier
.size(48.dp)
.padding(8.dp),
)
} else {
CircularProgressIndicator(
Modifier
.size(48.dp)
.padding(8.dp),
)
}
}
if (progress != null) {
val bytes = formatBytes(bytesDownloaded)
val size = formatBytes(contentLength)
Text(
text = "$bytes / $size",
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
@PreviewTvSpec @PreviewTvSpec
@Composable @Composable
private fun InstallUpdatePageContentPreview() { private fun InstallUpdatePageContentPreview() {

View file

@ -30,8 +30,11 @@ import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.internal.headersContentLength
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -170,7 +173,10 @@ class UpdateChecker
} }
} }
suspend fun installRelease(release: Release) { suspend fun installRelease(
release: Release,
callback: DownloadCallback,
) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
cleanup() cleanup()
val request = val request =
@ -182,6 +188,9 @@ class UpdateChecker
okHttpClient.newCall(request).execute().use { okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) { if (it.isSuccessful && it.body != null) {
Timber.v("Request successful for ${release.downloadUrl}") Timber.v("Request successful for ${release.downloadUrl}")
withContext(Dispatchers.Main) {
callback.contentLength(it.headersContentLength())
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues = val contentValues =
ContentValues().apply { ContentValues().apply {
@ -201,7 +210,7 @@ class UpdateChecker
if (uri != null) { if (uri != null) {
it.body!!.byteStream().use { input -> it.body!!.byteStream().use { input ->
resolver.openOutputStream(uri).use { output -> resolver.openOutputStream(uri).use { output ->
input.copyTo(output!!) copyTo(input, output!!, callback = callback)
} }
} }
@ -212,7 +221,7 @@ class UpdateChecker
} else { } else {
Timber.e("Resolver URI is null, trying fallback") Timber.e("Resolver URI is null, trying fallback")
// showToast(context, "Unable to download the apk") // showToast(context, "Unable to download the apk")
val targetFile = fallbackDownload(it) val targetFile = fallbackDownload(it, callback)
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE) val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
intent.data = intent.data =
@ -224,7 +233,7 @@ class UpdateChecker
context.startActivity(intent) context.startActivity(intent)
} }
} else { } else {
val targetFile = fallbackDownload(it) val targetFile = fallbackDownload(it, callback)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE) val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
@ -250,13 +259,18 @@ class UpdateChecker
} }
} }
private fun fallbackDownload(response: Response): File { private suspend fun fallbackDownload(
response: Response,
callback: DownloadCallback,
): File {
val downloadDir = val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
downloadDir.mkdirs() downloadDir.mkdirs()
val targetFile = File(downloadDir, ASSET_NAME) val targetFile = File(downloadDir, ASSET_NAME)
targetFile.outputStream().use { output -> targetFile.outputStream().use { output ->
response.body!!.byteStream().copyTo(output) response.body!!.byteStream().use { input ->
copyTo(input, output, callback = callback)
}
} }
return targetFile return targetFile
} }
@ -324,3 +338,29 @@ data class Release(
val body: String?, val body: String?,
val notes: List<String>, val notes: List<String>,
) )
interface DownloadCallback {
fun contentLength(contentLength: Long)
fun bytesDownloaded(bytes: Long)
}
suspend fun copyTo(
input: InputStream,
out: OutputStream,
bufferSize: Int = 16 * 1024,
callback: DownloadCallback,
): Long {
var bytesCopied: Long = 0
val buffer = ByteArray(bufferSize)
var bytes = input.read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
withContext(Dispatchers.Main) {
callback.bytesDownloaded(bytesCopied)
}
bytes = input.read(buffer)
}
return bytesCopied
}