Skip to content

Fix split install issue #2898

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jul 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions vending-app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@
android:exported="false"
tools:targetApi="21" />

<receiver
android:name=".installer.InstallReceiver"
android:exported="false"
tools:targetApi="21" />

<!-- Work account store -->
<activity android:name="org.microg.vending.ui.WorkAppsActivity"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ class GooglePlayApi {
const val URL_DELIVERY = "$URL_FDFE/delivery"
const val URL_ENTERPRISE_CLIENT_POLICY = "$URL_FDFE/getEnterpriseClientPolicy"
const val URL_SYNC = "$URL_FDFE/sync"
const val URL_BULK = "$URL_FDFE/bulkGrantEntitlement"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.microg.vending.billing.core

import android.content.Context
import android.net.Uri
import android.util.Log
import com.squareup.wire.Message
import com.squareup.wire.ProtoAdapter
import io.ktor.client.HttpClient
Expand Down Expand Up @@ -32,6 +33,7 @@ import java.io.IOException
import java.io.OutputStream

private const val POST_TIMEOUT = 8000L
private const val TAG = "HttpClient"

class HttpClient {

Expand Down Expand Up @@ -60,32 +62,44 @@ class HttpClient {
}

suspend fun download(
url: String,
downloadTo: OutputStream,
params: Map<String, String> = emptyMap(),
emitProgress: (bytesDownloaded: Long) -> Unit = {}
url: String,
downloadTo: OutputStream,
params: Map<String, String> = emptyMap(),
downloadedBytes: Long = 0,
emitProgress: (bytesDownloaded: Long) -> Unit = {}
) {
client.prepareGet(url.asUrl(params)).execute { response ->
val body: ByteReadChannel = response.body()

// 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)
try {
Log.d(TAG, "download downloadedBytes:$downloadedBytes")
client.prepareGet(url.asUrl(params)){
if (downloadedBytes > 0) {
headers {
append(HttpHeaders.Range, "bytes=$downloadedBytes-")
}
} while (rc > 0)
} finally {
ByteArrayPool.recycle(buffer)
}
}.execute { response ->
val body: ByteReadChannel = response.body()
// Modified version of `ByteReadChannel.copyTo(OutputStream, Long)` to indicate progress
val buffer = ByteArrayPool.borrow()
try {
var copied = downloadedBytes
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
}
// don't close `downloadTo` yet
} catch (e: Exception) {
Log.w(TAG, "download error : $e")
throw e
}
}

Expand Down
46 changes: 36 additions & 10 deletions vending-app/src/main/java/org/microg/vending/delivery/Delivery.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,20 @@

package org.microg.vending.delivery

import android.accounts.AccountManager
import android.content.Context
import android.util.Log
import com.android.vending.buildRequestHeaders
import com.google.android.finsky.BulkGrant
import com.google.android.finsky.BulkRequest
import com.google.android.finsky.BulkRequestWrapper
import com.google.android.finsky.BulkResponseWrapper
import com.google.android.finsky.DeviceSyncInfo
import com.google.android.finsky.SyncResponse
import com.google.android.finsky.splitinstallservice.PackageComponent
import com.google.android.finsky.syncDeviceInfo
import org.microg.vending.billing.core.AuthData
import org.microg.vending.billing.core.GooglePlayApi
import org.microg.vending.billing.core.GooglePlayApi.Companion.URL_DELIVERY
import org.microg.vending.billing.core.HttpClient
import org.microg.vending.billing.proto.GoogleApiResponse
Expand All @@ -22,18 +32,19 @@ private const val TAG = "GmsVendingDelivery"
* only those will be contained in the result.
*/
suspend fun HttpClient.requestDownloadUrls(
packageName: String,
versionCode: Long,
auth: AuthData,
requestSplitPackages: List<String>? = null,
deliveryToken: String? = null,
context: Context,
packageName: String,
versionCode: Long,
auth: AuthData,
requestSplitPackages: List<String>? = null,
deliveryToken: String? = null,
): List<PackageComponent> {

val requestUrl = StringBuilder("$URL_DELIVERY?doc=$packageName&ot=1&vc=$versionCode")

requestSplitPackages?.apply {
requestUrl.append(
"&bvc=$versionCode&pf=1&pf=2&pf=3&pf=4&pf=5&pf=7&pf=8&pf=9&pf=10&da=4&bda=4&bf=4&fdcf=1&fdcf=2&ch="
"&bvc=$versionCode&pf=1&pf=2&pf=3&pf=4&pf=5&pf=7&pf=8&pf=9&pf=10&bda=4&bf=4&fdcf=1&fdcf=2&ch="
)
forEach { requestUrl.append("&mn=").append(it) }
}
Expand All @@ -48,16 +59,31 @@ suspend fun HttpClient.requestDownloadUrls(
}
Log.d(TAG, "requestDownloadUrls languages: $languages")

val androidId = auth.gsfId.toLong(16)
val headers = buildRequestHeaders(
auth = auth.authToken,
// TODO: understand behavior. Using proper Android ID doesn't work when downloading split APKs
androidId = if (requestSplitPackages != null) 1 else auth.gsfId.toLong(16),
androidId = androidId,
languages
).minus(
// TODO: understand behavior. According to tests, these headers break split install queries but may be needed for normal ones
(if (requestSplitPackages != null) listOf("X-DFE-Encoded-Targets", "X-DFE-Phenotype", "X-DFE-Device-Id", "X-DFE-Client-Id") else emptyList()).toSet()
)

kotlin.runCatching {
//Authorize the account to prevent the inability to obtain split information
post(
url = GooglePlayApi.URL_BULK,
headers = headers,
payload = BulkRequestWrapper.build {
request(BulkRequest.build {
packageName(packageName)
grant(BulkGrant.build { grantLevel = 1 })
})
},
adapter = BulkResponseWrapper.ADAPTER
)
}

val response = get(
url = requestUrl.toString(),
headers = headers,
Expand All @@ -76,9 +102,9 @@ suspend fun HttpClient.requestDownloadUrls(
if (requestSplitPackages != null) {
// Only download requested, if specific components were requested
requestSplitPackages.firstOrNull { requestComponent ->
requestComponent.contains(it.splitPackageName!!)
(it.splitPackageName?.contains(requestComponent) == true || requestComponent.contains(it.splitPackageName!!))
}?.let { requestComponent ->
PackageComponent(packageName, requestComponent, it.downloadUrl!!, it.size!!.toLong())
PackageComponent(packageName, it.splitPackageName!!, it.downloadUrl!!, it.size!!.toLong())
}
} else {
// Download all offered components (server chooses)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

package org.microg.vending.enterprise

import android.app.PendingIntent

internal sealed interface InstallProgress

internal data class Downloading(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ package org.microg.vending.ui
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.content.pm.PackageManager.NameNotFoundException
import android.os.Build
import android.util.Log
Expand All @@ -24,11 +27,31 @@ import org.microg.vending.enterprise.InstallProgress

private const val INSTALL_NOTIFICATION_CHANNEL_ID = "packageInstall"

internal fun Context.notifyInstallPrompt(packageName: String, sessionId: Int, installIntent: PendingIntent, deleteIntent: PendingIntent) {
val notificationManager = NotificationManagerCompat.from(this)
val label = try {
val applicationInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo
applicationInfo?.loadLabel(packageManager) ?: return
} catch (e: NameNotFoundException) {
Log.e(TAG, "Couldn't load label for $packageName (${e.message}). Is it not installed?")
return
}
getInstallPromptNotificationBuilder().apply {
setDeleteIntent(deleteIntent)
setContentTitle(getString(R.string.installer_notification_progress_splitinstall_click_install, label))
addAction(R.drawable.ic_download, getString(R.string.vending_overview_row_action_install), installIntent)
setContentIntent(installIntent)
setAutoCancel(true)
}.apply {
notificationManager.notify(sessionId, this.build())
}
}

internal fun Context.notifySplitInstallProgress(packageName: String, sessionId: Int, progress: InstallProgress) {

val label = try {
packageManager.getPackageInfo(packageName, 0).applicationInfo!!
.loadLabel(packageManager)
val applicationInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo
applicationInfo?.loadLabel(packageManager) ?: return
} catch (e: NameNotFoundException) {
Log.e(TAG, "Couldn't load label for $packageName (${e.message}). Is it not installed?")
return
Expand All @@ -44,11 +67,20 @@ internal fun Context.notifySplitInstallProgress(packageName: String, sessionId:
when (progress) {
is Downloading -> getDownloadNotificationBuilder().apply {
setContentTitle(getString(R.string.installer_notification_progress_splitinstall_downloading, label))
setProgress(progress.bytesDownloaded.toInt(), progress.bytesTotal.toInt(), false)
setProgress(100, ((progress.bytesDownloaded.toFloat() / progress.bytesTotal) * 100).toInt().coerceIn(0, 100), false)
}
CommitingSession -> getDownloadNotificationBuilder().apply {
setContentTitle(getString(R.string.installer_notification_progress_splitinstall_commiting, label))
setProgress(0, 1, true)
CommitingSession -> {
// Check whether silent installation is possible. Only show the notification if silent installation is supported,
// to prevent cases where the user cancels the install page and the notification is not removed.
if (isSystem(packageManager)) {
getDownloadNotificationBuilder().apply {
setContentTitle(getString(R.string.installer_notification_progress_splitinstall_commiting, label))
setProgress(0, 1, true)
}
} else {
notificationManager.cancel(sessionId)
null
}
}
else -> null.also { notificationManager.cancel(sessionId) }
}?.apply {
Expand Down Expand Up @@ -88,16 +120,23 @@ internal fun Context.notifyInstallProgress(
return this.build().also { notificationManager.notify(sessionId, it) }
}
CommitingSession -> {
setContentTitle(
getString(
if (isDependency) R.string.installer_notification_progress_splitinstall_commiting
else R.string.installer_notification_progress_commiting,
displayName
// Check whether silent installation is possible. Only show the notification if silent installation is supported,
// to prevent cases where the user cancels the install page and the notification is not removed.
if (isSystem(packageManager)) {
setContentTitle(
getString(
if (isDependency) R.string.installer_notification_progress_splitinstall_commiting
else R.string.installer_notification_progress_commiting,
displayName
)
)
)
setProgress(0, 0, true)
setOngoing(true)
return this.build().also { notificationManager.notify(sessionId, it) }
setProgress(0, 0, true)
setOngoing(true)
return this.build().also { notificationManager.notify(sessionId, it) }
} else {
notificationManager.cancel(sessionId)
return null
}
}
InstallComplete -> {
if (!isDependency) {
Expand Down Expand Up @@ -135,6 +174,12 @@ internal fun Context.notifyInstallProgress(

}

private fun Context.getInstallPromptNotificationBuilder() =
NotificationCompat.Builder(this, INSTALL_NOTIFICATION_CHANNEL_ID)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setLocalOnly(true)

private fun Context.getDownloadNotificationBuilder() =
NotificationCompat.Builder(this, INSTALL_NOTIFICATION_CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_download)
Expand All @@ -157,4 +202,13 @@ private fun Context.createNotificationChannel() {
}
)
}
}

private fun Context.isSystem(pm: PackageManager): Boolean {
try {
val ai = pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
return (ai.flags and (ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP)) != 0
} catch (e: NameNotFoundException) {
return false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ private const val FILE_SAVE_PATH = "phonesky-download-service"
internal const val TAG = "GmsPackageInstaller"

const val KEY_BYTES_DOWNLOADED = "bytes_downloaded"
const val VENDING_INSTALL_ACTION = "com.android.vending.ACTION_INSTALL"
const val VENDING_INSTALL_DELETE_ACTION = "com.android.vending.ACTION_INSTALL_DELETE"
const val SESSION_ID = "session_id"
const val SESSION_RESULT_RECEIVER_INTENT = "session_result_receiver_intent"
const val SPLIT_LANGUAGE_TAG = "config."

fun Context.packageDownloadLocation() = File(cacheDir, FILE_SAVE_PATH).apply {
if (!exists()) mkdir()
Expand Down
Loading