diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/HttpClient.kt b/vending-app/src/main/java/org/microg/vending/billing/core/HttpClient.kt index a2a1d1122..928e0cfa8 100644 --- a/vending-app/src/main/java/org/microg/vending/billing/core/HttpClient.kt +++ b/vending-app/src/main/java/org/microg/vending/billing/core/HttpClient.kt @@ -21,6 +21,7 @@ import io.ktor.http.URLBuilder import io.ktor.http.Url import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.jvm.javaio.copyTo +import io.ktor.utils.io.pool.ByteArrayPool import org.json.JSONObject import org.microg.gms.utils.singleInstanceOf import java.io.File @@ -54,11 +55,30 @@ class HttpClient { suspend fun download( url: String, downloadTo: OutputStream, - params: Map = emptyMap() + params: Map = emptyMap(), + emitProgress: (bytesDownloaded: Long) -> Unit = {} ) { client.prepareGet(url.asUrl(params)).execute { response -> val body: ByteReadChannel = response.body() - body.copyTo(out = downloadTo) + + // Modified version of `ByteReadChannel.copyTo(OutputStream, Long)` to indicate progress + val buffer = ByteArrayPool.borrow() + try { + var copied = 0L + val bufferSize = buffer.size + + do { + val rc = body.readAvailable(buffer, 0, bufferSize) + copied += rc + if (rc > 0) { + downloadTo.write(buffer, 0, rc) + } + emitProgress(copied) + } while (rc > 0) + } finally { + ByteArrayPool.recycle(buffer) + } + // don't close `downloadTo` yet } } diff --git a/vending-app/src/main/java/org/microg/vending/delivery/Delivery.kt b/vending-app/src/main/java/org/microg/vending/delivery/Delivery.kt index 97eddb570..a69404512 100644 --- a/vending-app/src/main/java/org/microg/vending/delivery/Delivery.kt +++ b/vending-app/src/main/java/org/microg/vending/delivery/Delivery.kt @@ -62,7 +62,7 @@ suspend fun HttpClient.requestDownloadUrls( val basePackage = response.payload!!.deliveryResponse!!.deliveryData?.let { if (it.baseUrl != null && it.baseBytes != null) { - PackageComponent(packageName, "base", it.baseUrl, it.baseBytes) + PackageComponent(packageName, "base", it.baseUrl, it.baseBytes.toLong()) } else null } val splitComponents = response.payload.deliveryResponse!!.deliveryData!!.splitPackages.filter { @@ -73,11 +73,11 @@ suspend fun HttpClient.requestDownloadUrls( requestSplitPackages.firstOrNull { requestComponent -> requestComponent.contains(it.splitPackageName!!) }?.let { requestComponent -> - PackageComponent(packageName, requestComponent, it.downloadUrl!!, it.size!!) + PackageComponent(packageName, requestComponent, it.downloadUrl!!, it.size!!.toLong()) } } else { // Download all offered components (server chooses) - PackageComponent(packageName, it.splitPackageName!!, it.downloadUrl!!, it.size!!) + PackageComponent(packageName, it.splitPackageName!!, it.downloadUrl!!, it.size!!.toLong()) } } diff --git a/vending-app/src/main/java/org/microg/vending/enterprise/AppState.kt b/vending-app/src/main/java/org/microg/vending/enterprise/AppState.kt index 339f82f3d..7af3f28d2 100644 --- a/vending-app/src/main/java/org/microg/vending/enterprise/AppState.kt +++ b/vending-app/src/main/java/org/microg/vending/enterprise/AppState.kt @@ -1,24 +1,28 @@ package org.microg.vending.enterprise -enum class AppState { - /** - * App cannot be installed on this user's device - */ - NOT_COMPATIBLE, - /** - * App is available, but not installed on the user's device. - */ - NOT_INSTALLED, - /** - * App is already installed on the device, but an update is available. - */ - UPDATE_AVAILABLE, - /** - * An app operation is currently outstanding - */ - PENDING, - /** - * App is installed on device and up to date. - */ - INSTALLED -} \ No newline at end of file +internal sealed interface AppState + +/** + * App cannot be installed on this user's device + */ +internal data object NotCompatible : AppState + +/** + * App is available, but not installed on the user's device. + */ +internal data object NotInstalled : AppState + +/** + * App is already installed on the device, but an update is available. + */ +internal data object UpdateAvailable : AppState + +/** + * An unspecific app operation is currently outstanding + */ +internal data object Pending : AppState + +/** + * App is installed on device and up to date. + */ +internal data object Installed : AppState \ No newline at end of file diff --git a/vending-app/src/main/java/org/microg/vending/enterprise/InstallProgress.kt b/vending-app/src/main/java/org/microg/vending/enterprise/InstallProgress.kt new file mode 100644 index 000000000..6967288b3 --- /dev/null +++ b/vending-app/src/main/java/org/microg/vending/enterprise/InstallProgress.kt @@ -0,0 +1,13 @@ +package org.microg.vending.enterprise + +internal sealed interface InstallProgress + +internal data class Downloading( + val bytesDownloaded: Long, + val bytesTotal: Long +) : InstallProgress, AppState +internal data object CommitingSession : InstallProgress +internal data object InstallComplete : InstallProgress +internal data class InstallError( + val errorMessage: String +) : InstallProgress \ No newline at end of file diff --git a/vending-app/src/main/java/org/microg/vending/ui/VendingActivity.kt b/vending-app/src/main/java/org/microg/vending/ui/VendingActivity.kt index 91b620eb4..7b0062d48 100644 --- a/vending-app/src/main/java/org/microg/vending/ui/VendingActivity.kt +++ b/vending-app/src/main/java/org/microg/vending/ui/VendingActivity.kt @@ -42,6 +42,13 @@ import org.microg.vending.billing.proto.GoogleApiResponse import org.microg.vending.enterprise.EnterpriseApp import org.microg.vending.delivery.requestDownloadUrls import org.microg.vending.enterprise.AppState +import org.microg.vending.enterprise.CommitingSession +import org.microg.vending.enterprise.Downloading +import org.microg.vending.enterprise.Installed +import org.microg.vending.enterprise.NotCompatible +import org.microg.vending.enterprise.NotInstalled +import org.microg.vending.enterprise.Pending +import org.microg.vending.enterprise.UpdateAvailable import org.microg.vending.proto.AppMeta import org.microg.vending.proto.GetItemsRequest import org.microg.vending.proto.RequestApp @@ -53,10 +60,10 @@ import java.io.IOException @RequiresApi(android.os.Build.VERSION_CODES.LOLLIPOP) class VendingActivity : ComponentActivity() { - var apps: MutableMap = mutableStateMapOf() - var networkState by mutableStateOf(NetworkState.ACTIVE) + private var apps: MutableMap = mutableStateMapOf() + private var networkState by mutableStateOf(NetworkState.ACTIVE) - var auth: AuthData? = null + private var auth: AuthData? = null override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() @@ -85,7 +92,7 @@ class VendingActivity : ComponentActivity() { runBlocking { val previousState = apps[app]!! - apps[app] = AppState.PENDING + apps[app] = Pending val client = HttpClient() @@ -109,11 +116,14 @@ class VendingActivity : ComponentActivity() { components = downloadUrls.getOrThrow(), httpClient = client, isUpdate = isUpdate - ) + ) { progress -> + if (progress is Downloading) apps[app] = progress + else if (progress is CommitingSession) apps[app] = Pending + } }.onSuccess { - apps[app] = AppState.INSTALLED - }.onFailure { - Log.w(TAG, "Installation from network unsuccessful.") + apps[app] = Installed + }.onFailure { exception -> + Log.w(TAG, "Installation from network unsuccessful.", exception) apps[app] = previousState } } @@ -125,9 +135,9 @@ class VendingActivity : ComponentActivity() { runBlocking { val previousState = apps[app]!! - apps[app] = AppState.PENDING + apps[app] = Pending runCatching { uninstallPackage(app.packageName) }.onSuccess { - apps[app] = AppState.NOT_INSTALLED + apps[app] = NotInstalled }.onFailure { apps[app] = previousState } @@ -210,11 +220,11 @@ class VendingActivity : ComponentActivity() { item.offer!!.version!!.versionCode!! } else null - val state = if (!available && installedDetails == null) AppState.NOT_COMPATIBLE - else if (!available && installedDetails != null) AppState.INSTALLED - else if (available && installedDetails == null) AppState.NOT_INSTALLED - else if (available && installedDetails != null && installedDetails.versionCode < versionCode!!) AppState.UPDATE_AVAILABLE - else /* if (available && installedDetails != null) */ AppState.INSTALLED + val state = if (!available && installedDetails == null) NotCompatible + else if (!available && installedDetails != null) Installed + else if (available && installedDetails == null) NotInstalled + else if (available && installedDetails != null && installedDetails.versionCode < versionCode!!) UpdateAvailable + else /* if (available && installedDetails != null) */ Installed EnterpriseApp( packageName, diff --git a/vending-app/src/main/java/org/microg/vending/ui/components/AppRow.kt b/vending-app/src/main/java/org/microg/vending/ui/components/AppRow.kt index b49c36e05..7242bda68 100644 --- a/vending-app/src/main/java/org/microg/vending/ui/components/AppRow.kt +++ b/vending-app/src/main/java/org/microg/vending/ui/components/AppRow.kt @@ -26,9 +26,15 @@ import coil.compose.AsyncImage import com.android.vending.R import org.microg.vending.enterprise.App import org.microg.vending.enterprise.AppState +import org.microg.vending.enterprise.Downloading +import org.microg.vending.enterprise.Installed +import org.microg.vending.enterprise.NotCompatible +import org.microg.vending.enterprise.NotInstalled +import org.microg.vending.enterprise.Pending +import org.microg.vending.enterprise.UpdateAvailable @Composable -fun AppRow(app: App, state: AppState, install: () -> Unit, update: () -> Unit, uninstall: () -> Unit) { +internal fun AppRow(app: App, state: AppState, install: () -> Unit, update: () -> Unit, uninstall: () -> Unit) { Row( Modifier.padding(top = 8.dp, bottom = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start), @@ -47,28 +53,34 @@ fun AppRow(app: App, state: AppState, install: () -> Unit, update: () -> Unit, u Text(app.displayName) Spacer(Modifier.weight(1f)) - if (state == AppState.NOT_COMPATIBLE) { + if (state == NotCompatible) { Icon(Icons.Default.Warning, null, Modifier.padding(end=8.dp), tint = MaterialTheme.colorScheme.secondary) // TODO better UI } - if (state == AppState.UPDATE_AVAILABLE || state == AppState.INSTALLED) { + if (state == UpdateAvailable || state == Installed) { IconButton(uninstall) { Icon(Icons.Default.Delete, stringResource(R.string.vending_overview_row_action_uninstall), tint = MaterialTheme.colorScheme.secondary) } } - if (state == AppState.UPDATE_AVAILABLE) { + if (state == UpdateAvailable) { FilledIconButton(update, colors = IconButtonDefaults.filledIconButtonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)) { Icon(painterResource(R.drawable.ic_update), stringResource(R.string.vending_overview_row_action_update), tint = MaterialTheme.colorScheme.secondary) } } - if (state == AppState.NOT_INSTALLED) { + if (state == NotInstalled) { FilledIconButton(install, colors = IconButtonDefaults.filledIconButtonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)) { Icon(painterResource(R.drawable.ic_download), stringResource(R.string.vending_overview_row_action_install), tint = MaterialTheme.colorScheme.secondary) } } - if (state == AppState.PENDING) { + if (state == Pending) { CircularProgressIndicator(Modifier.padding(4.dp)) } + if (state is Downloading) { + CircularProgressIndicator( + progress = state.bytesDownloaded.toFloat() / state.bytesTotal.toFloat(), + modifier = Modifier.padding(4.dp) + ) + } } } @@ -77,29 +89,35 @@ private val previewApp = App("org.mozilla.firefox", 0, "Firefox", null, null) @Preview @Composable fun AppRowNotCompatiblePreview() { - AppRow(previewApp, AppState.NOT_COMPATIBLE, {}, {}, {}) + AppRow(previewApp, NotCompatible, {}, {}, {}) } @Preview @Composable fun AppRowNotInstalledPreview() { - AppRow(previewApp, AppState.NOT_INSTALLED, {}, {}, {}) + AppRow(previewApp, NotInstalled, {}, {}, {}) } @Preview @Composable fun AppRowUpdateablePreview() { - AppRow(previewApp, AppState.UPDATE_AVAILABLE, {}, {}, {}) + AppRow(previewApp, UpdateAvailable, {}, {}, {}) } @Preview @Composable fun AppRowInstalledPreview() { - AppRow(previewApp, AppState.INSTALLED, {}, {}, {}) + AppRow(previewApp, Installed, {}, {}, {}) } @Preview @Composable fun AppRowPendingPreview() { - AppRow(previewApp, AppState.PENDING, {}, {}, {}) + AppRow(previewApp, Pending, {}, {}, {}) } + +@Preview +@Composable +fun AppRowProgressPreview() { + AppRow(previewApp, Downloading(75, 100), {}, {}, {}) +} \ No newline at end of file diff --git a/vending-app/src/main/java/org/microg/vending/ui/components/EnterpriseList.kt b/vending-app/src/main/java/org/microg/vending/ui/components/EnterpriseList.kt index bedec8d59..84e217bf8 100644 --- a/vending-app/src/main/java/org/microg/vending/ui/components/EnterpriseList.kt +++ b/vending-app/src/main/java/org/microg/vending/ui/components/EnterpriseList.kt @@ -26,11 +26,14 @@ import androidx.compose.ui.unit.dp import com.android.vending.R import org.microg.vending.enterprise.AppState import org.microg.vending.enterprise.EnterpriseApp +import org.microg.vending.enterprise.Installed +import org.microg.vending.enterprise.NotCompatible +import org.microg.vending.enterprise.NotInstalled import org.microg.vending.enterprise.proto.AppInstallPolicy @Composable -fun EnterpriseList(appStates: Map, install: (app: EnterpriseApp, isUpdate: Boolean) -> Unit, uninstall: (app: EnterpriseApp) -> Unit) { +internal fun EnterpriseList(appStates: Map, install: (app: EnterpriseApp, isUpdate: Boolean) -> Unit, uninstall: (app: EnterpriseApp) -> Unit) { if (appStates.isNotEmpty()) LazyColumn(Modifier.padding(horizontal = 16.dp)) { val apps = appStates.keys @@ -105,9 +108,9 @@ fun InListWarning(@StringRes text: Int) { fun EnterpriseListPreview() { EnterpriseList( mapOf( - EnterpriseApp("com.android.vending", 0, "Market", null, "", AppInstallPolicy.MANDATORY) to AppState.INSTALLED, - EnterpriseApp("org.mozilla.firefox", 0, "Firefox", null, "", AppInstallPolicy.OPTIONAL) to AppState.NOT_INSTALLED, - EnterpriseApp("org.thoughtcrime.securesms", 0, "Signal", null, "", AppInstallPolicy.OPTIONAL) to AppState.NOT_COMPATIBLE + EnterpriseApp("com.android.vending", 0, "Market", null, "", AppInstallPolicy.MANDATORY) to Installed, + EnterpriseApp("org.mozilla.firefox", 0, "Firefox", null, "", AppInstallPolicy.OPTIONAL) to NotInstalled, + EnterpriseApp("org.thoughtcrime.securesms", 0, "Signal", null, "", AppInstallPolicy.OPTIONAL) to NotCompatible ), { _, _ -> }, {} ) } diff --git a/vending-app/src/main/kotlin/com/android/vending/installer/Install.kt b/vending-app/src/main/kotlin/com/android/vending/installer/Install.kt index db62562fe..5b5e685e1 100644 --- a/vending-app/src/main/kotlin/com/android/vending/installer/Install.kt +++ b/vending-app/src/main/kotlin/com/android/vending/installer/Install.kt @@ -13,6 +13,11 @@ import androidx.annotation.RequiresApi import com.google.android.finsky.splitinstallservice.PackageComponent import kotlinx.coroutines.CompletableDeferred import org.microg.vending.billing.core.HttpClient +import org.microg.vending.enterprise.CommitingSession +import org.microg.vending.enterprise.Downloading +import org.microg.vending.enterprise.InstallComplete +import org.microg.vending.enterprise.InstallError +import org.microg.vending.enterprise.InstallProgress import java.io.File import java.io.FileInputStream import java.io.IOException @@ -38,15 +43,28 @@ internal suspend fun Context.installPackagesFromNetwork( packageName: String, components: List, httpClient: HttpClient = HttpClient(), - isUpdate: Boolean = false -) = installPackagesInternal( - packageName = packageName, - componentNames = components.map { it.componentName }, - isUpdate = isUpdate -) { fileName, to -> - val component = components.find { it.componentName == fileName }!! - Log.v(TAG, "installing $fileName for $packageName from network") - httpClient.download(component.url, to) + isUpdate: Boolean = false, + emitProgress: (InstallProgress) -> Unit = {} +) { + + val downloadProgress = mutableMapOf() + + installPackagesInternal( + packageName = packageName, + componentNames = components.map { it.componentName }, + isUpdate = isUpdate, + emitProgress = emitProgress, + ) { fileName, to -> + val component = components.find { it.componentName == fileName }!! + Log.v(TAG, "installing $fileName for $packageName from network") + httpClient.download(component.url, to) { progress -> + downloadProgress[component] = progress + emitProgress(Downloading( + bytesDownloaded = downloadProgress.values.sum(), + bytesTotal = components.sumOf { it.size } + )) + } + } } @RequiresApi(Build.VERSION_CODES.LOLLIPOP) @@ -54,6 +72,7 @@ private suspend fun Context.installPackagesInternal( packageName: String, componentNames: List, isUpdate: Boolean = false, + emitProgress: (InstallProgress) -> Unit = {}, writeComponent: suspend (componentName: String, to: OutputStream) -> Unit ) { Log.v(TAG, "installPackages start") @@ -92,13 +111,21 @@ private suspend fun Context.installPackagesInternal( val deferred = CompletableDeferred() SessionResultReceiver.pendingSessions[sessionId] = SessionResultReceiver.OnResult( - onSuccess = { deferred.complete(Unit) }, - onFailure = { message -> deferred.completeExceptionally(RuntimeException(message)) } + onSuccess = { + deferred.complete(Unit) + emitProgress(InstallComplete) + }, + onFailure = { message -> + deferred.completeExceptionally(RuntimeException(message)) + emitProgress(InstallError(message ?: "UNKNOWN")) + } ) val intent = Intent(this, SessionResultReceiver::class.java) val pendingIntent = PendingIntent.getBroadcast(this, sessionId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE) + + emitProgress(CommitingSession) session.commit(pendingIntent.intentSender) Log.d(TAG, "installPackages session commit") diff --git a/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/PackageComponent.kt b/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/PackageComponent.kt index 5d06d44fd..1939af29d 100644 --- a/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/PackageComponent.kt +++ b/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/PackageComponent.kt @@ -7,5 +7,5 @@ data class PackageComponent( /** * Size in bytes */ - val size: Int + val size: Long ) \ No newline at end of file