From 5d085a3ab43bb619c7970ec37cae9aebf5cd6b2c Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 6 Nov 2025 18:15:25 -0500 Subject: [PATCH] Show update download progress (#165) Shows a simple dialog while the app update is downloading to show progress --- .gitignore | 4 + .../wholphin/ui/setup/InstallUpdatePage.kt | 145 +++++++++++++++++- .../wholphin/util/UpdateChecker.kt | 52 ++++++- 3 files changed, 190 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 6ee09bce..d30a4b28 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,7 @@ app/libs/ ffmpeg_decoder/ app/release/ app/debug/ + +app/src/main/jnilibs/ +app/src/main/libs/ +app/src/main/obj/ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt index edb461f2..4d02def5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt @@ -15,9 +15,11 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -28,6 +30,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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.KeyEventType 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.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -46,19 +51,25 @@ import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.UserPreferences 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.LoadingPage +import com.github.damontecres.wholphin.ui.ifElse 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.util.DownloadCallback import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.Release import com.github.damontecres.wholphin.util.UpdateChecker import com.github.damontecres.wholphin.util.Version +import com.github.damontecres.wholphin.util.formatBytes import com.mikepenz.markdown.m3.Markdown import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -69,10 +80,15 @@ class UpdateViewModel constructor( val updater: UpdateChecker, val navigationManager: NavigationManager, - ) : ViewModel() { + ) : ViewModel(), + DownloadCallback { val loading = MutableLiveData(LoadingState.Pending) val release = MutableLiveData(null) + val downloading = MutableLiveData(false) + val contentLength = MutableLiveData(-1) + val bytesDownloaded = MutableLiveData(-1) + val currentVersion = updater.getInstalledVersion() fun init(updateUrl: String) { @@ -80,17 +96,55 @@ class UpdateViewModel viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) { val release = updater.getLatestRelease(updateUrl) withContext(Dispatchers.Main) { + contentLength.value = -1 + bytesDownloaded.value = -1 this@UpdateViewModel.release.value = release loading.value = LoadingState.Success } } } + private var downloadJob: Job? = null + fun installRelease(release: Release) { - viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to install update")) { - updater.installRelease(release) + downloadJob = + 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 @@ -101,6 +155,11 @@ fun InstallUpdatePage( ) { val loading by viewModel.loading.observeAsState(LoadingState.Pending) 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) { viewModel.init(preferences.appPreferences.updateUrl) } @@ -121,7 +180,7 @@ fun InstallUpdatePage( LoadingState.Pending, -> LoadingPage(modifier) - LoadingState.Success -> + LoadingState.Success -> { release?.let { InstallUpdatePageContent( currentVersion = viewModel.currentVersion, @@ -136,9 +195,26 @@ fun InstallUpdatePage( onCancel = { 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 @Composable private fun InstallUpdatePageContentPreview() { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/util/UpdateChecker.kt index d177e39d..144e566d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/UpdateChecker.kt @@ -30,8 +30,11 @@ import kotlinx.serialization.json.jsonPrimitive import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import okhttp3.internal.headersContentLength import timber.log.Timber import java.io.File +import java.io.InputStream +import java.io.OutputStream import java.util.Date import javax.inject.Inject 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) { cleanup() val request = @@ -182,6 +188,9 @@ class UpdateChecker okHttpClient.newCall(request).execute().use { if (it.isSuccessful && it.body != null) { Timber.v("Request successful for ${release.downloadUrl}") + withContext(Dispatchers.Main) { + callback.contentLength(it.headersContentLength()) + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val contentValues = ContentValues().apply { @@ -201,7 +210,7 @@ class UpdateChecker if (uri != null) { it.body!!.byteStream().use { input -> resolver.openOutputStream(uri).use { output -> - input.copyTo(output!!) + copyTo(input, output!!, callback = callback) } } @@ -212,7 +221,7 @@ class UpdateChecker } else { Timber.e("Resolver URI is null, trying fallback") // showToast(context, "Unable to download the apk") - val targetFile = fallbackDownload(it) + val targetFile = fallbackDownload(it, callback) val intent = Intent(Intent.ACTION_INSTALL_PACKAGE) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) intent.data = @@ -224,7 +233,7 @@ class UpdateChecker context.startActivity(intent) } } else { - val targetFile = fallbackDownload(it) + val targetFile = fallbackDownload(it, callback) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val intent = Intent(Intent.ACTION_INSTALL_PACKAGE) 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 = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) downloadDir.mkdirs() val targetFile = File(downloadDir, ASSET_NAME) targetFile.outputStream().use { output -> - response.body!!.byteStream().copyTo(output) + response.body!!.byteStream().use { input -> + copyTo(input, output, callback = callback) + } } return targetFile } @@ -324,3 +338,29 @@ data class Release( val body: String?, val notes: List, ) + +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 +}