diff --git a/build.gradle b/build.gradle
index 92339b13f2..5e9d0ecedc 100644
--- a/build.gradle
+++ b/build.gradle
@@ -27,7 +27,7 @@ buildscript {
ext.slf4jVersion = '1.7.36'
ext.volleyVersion = '1.2.1'
- ext.wireVersion = '4.8.0'
+ ext.wireVersion = '4.9.9'
ext.androidBuildGradleVersion = '8.2.2'
diff --git a/vending-app/src/main/AndroidManifest.xml b/vending-app/src/main/AndroidManifest.xml
index eb12ae7378..4530559240 100644
--- a/vending-app/src/main/AndroidManifest.xml
+++ b/vending-app/src/main/AndroidManifest.xml
@@ -19,6 +19,7 @@
+
+
+
+
+
+
+
@@ -175,5 +183,9 @@
+
+
diff --git a/vending-app/src/main/aidl/com/google/android/play/core/splitinstall/protocol/ISplitInstallService.aidl b/vending-app/src/main/aidl/com/google/android/play/core/splitinstall/protocol/ISplitInstallService.aidl
new file mode 100644
index 0000000000..6a87bffe74
--- /dev/null
+++ b/vending-app/src/main/aidl/com/google/android/play/core/splitinstall/protocol/ISplitInstallService.aidl
@@ -0,0 +1,22 @@
+/**
+ * SPDX-FileCopyrightText: 2024 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.google.android.play.core.splitinstall.protocol;
+import com.google.android.play.core.splitinstall.protocol.ISplitInstallServiceCallback;
+
+interface ISplitInstallService {
+ void startInstall(String pkg,in List splits,in Bundle bundle, ISplitInstallServiceCallback callback) = 1;
+ void completeInstalls(String pkg, int sessionId,in Bundle bundle, ISplitInstallServiceCallback callback) = 2;
+ void cancelInstall(String pkg, int sessionId, ISplitInstallServiceCallback callback) = 3;
+ void getSessionState(String pkg, int sessionId, ISplitInstallServiceCallback callback) = 4;
+ void getSessionStates(String pkg, ISplitInstallServiceCallback callback) = 5;
+ void splitRemoval(String pkg,in List splits, ISplitInstallServiceCallback callback) = 6;
+ void splitDeferred(String pkg,in List splits,in Bundle bundle, ISplitInstallServiceCallback callback) = 7;
+ void getSessionState2(String pkg, int sessionId, ISplitInstallServiceCallback callback) = 8;
+ void getSessionStates2(String pkg, ISplitInstallServiceCallback callback) = 9;
+ void getSplitsAppUpdate(String pkg, ISplitInstallServiceCallback callback) = 10;
+ void completeInstallAppUpdate(String pkg, ISplitInstallServiceCallback callback) = 11;
+ void languageSplitInstall(String pkg,in List splits,in Bundle bundle, ISplitInstallServiceCallback callback) = 12;
+ void languageSplitUninstall(String pkg,in List splits, ISplitInstallServiceCallback callback) =13;
+}
\ No newline at end of file
diff --git a/vending-app/src/main/aidl/com/google/android/play/core/splitinstall/protocol/ISplitInstallServiceCallback.aidl b/vending-app/src/main/aidl/com/google/android/play/core/splitinstall/protocol/ISplitInstallServiceCallback.aidl
new file mode 100644
index 0000000000..4661530d66
--- /dev/null
+++ b/vending-app/src/main/aidl/com/google/android/play/core/splitinstall/protocol/ISplitInstallServiceCallback.aidl
@@ -0,0 +1,19 @@
+/**
+ * SPDX-FileCopyrightText: 2024 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.google.android.play.core.splitinstall.protocol;
+
+
+interface ISplitInstallServiceCallback {
+ oneway void onStartInstall(int status, in Bundle bundle) = 1;
+ oneway void onInstallCompleted(int status, in Bundle bundle) = 2;
+ oneway void onCancelInstall(int status, in Bundle bundle) = 3;
+ oneway void onGetSessionState(int status, in Bundle bundle) = 4;
+ oneway void onError(in Bundle bundle) = 5;
+ oneway void onGetSessionStates(in List list) = 6;
+ oneway void onDeferredUninstall(in Bundle bundle) = 7;
+ oneway void onDeferredInstall(in Bundle bundle) = 8;
+ oneway void onDeferredLanguageInstall(in Bundle bundle) = 11;
+ oneway void onDeferredLanguageUninstall(in Bundle bundle) = 12;
+}
\ No newline at end of file
diff --git a/vending-app/src/main/java/com/android/vending/licensing/LicenseChecker.kt b/vending-app/src/main/java/com/android/vending/licensing/LicenseChecker.kt
index 057a709e15..5567c39395 100644
--- a/vending-app/src/main/java/com/android/vending/licensing/LicenseChecker.kt
+++ b/vending-app/src/main/java/com/android/vending/licensing/LicenseChecker.kt
@@ -2,20 +2,18 @@ package com.android.vending.licensing
import android.accounts.Account
import android.accounts.AccountManager
-import android.accounts.AccountManagerFuture
import android.accounts.AuthenticatorException
import android.accounts.OperationCanceledException
import android.content.pm.PackageInfo
-import android.os.Bundle
import android.os.RemoteException
import android.util.Log
+import com.android.vending.AUTH_TOKEN_SCOPE
import com.android.vending.LicenseResult
+import com.android.vending.buildRequestHeaders
+import com.android.vending.getAuthToken
import com.android.volley.VolleyError
import org.microg.vending.billing.core.HttpClient
import java.io.IOException
-import kotlin.coroutines.resume
-import kotlin.coroutines.resumeWithException
-import kotlin.coroutines.suspendCoroutine
private const val TAG = "FakeLicenseChecker"
@@ -69,8 +67,6 @@ const val ERROR_INVALID_PACKAGE_NAME: Int = 0x102
*/
const val ERROR_NON_MATCHING_UID: Int = 0x103
-const val AUTH_TOKEN_SCOPE: String = "oauth2:https://www.googleapis.com/auth/googleplay"
-
sealed class LicenseRequestParameters
data class V1Parameters(
val nonce: Long
@@ -108,7 +104,7 @@ suspend fun HttpClient.checkLicense(
) : LicenseResponse {
val auth = try {
- accountManager.getAuthToken(account, AUTH_TOKEN_SCOPE, false)
+ getAuthToken(accountManager, account, AUTH_TOKEN_SCOPE)
.getString(AccountManager.KEY_AUTHTOKEN)
} catch (e: AuthenticatorException) {
Log.e(TAG, "Could not fetch auth token for account $account")
@@ -145,7 +141,7 @@ suspend fun HttpClient.makeLicenseV1Request(
packageName: String, auth: String, versionCode: Int, nonce: Long, androidId: Long
): V1Response? = get(
url = "https://play-fe.googleapis.com/fdfe/apps/checkLicense?pkgn=$packageName&vc=$versionCode&nnc=$nonce",
- headers = getLicenseRequestHeaders(auth, androidId),
+ headers = buildRequestHeaders(auth, androidId),
adapter = LicenseResult.ADAPTER
).information?.v1?.let {
if (it.result != null && it.signedData != null && it.signature != null) {
@@ -160,22 +156,9 @@ suspend fun HttpClient.makeLicenseV2Request(
androidId: Long
): V2Response? = get(
url = "https://play-fe.googleapis.com/fdfe/apps/checkLicenseServerFallback?pkgn=$packageName&vc=$versionCode",
- headers = getLicenseRequestHeaders(auth, androidId),
+ headers = buildRequestHeaders(auth, androidId),
adapter = LicenseResult.ADAPTER
).information?.v2?.license?.jwt?.let {
// Field present ←→ user has license
V2Response(LICENSED, it)
-}
-
-
-suspend fun AccountManager.getAuthToken(account: Account, authTokenType: String, notifyAuthFailure: Boolean) =
- suspendCoroutine { continuation ->
- getAuthToken(account, authTokenType, notifyAuthFailure, { future: AccountManagerFuture ->
- try {
- val result = future.result
- continuation.resume(result)
- } catch (e: Exception) {
- continuation.resumeWithException(e)
- }
- }, null)
- }
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/vending-app/src/main/java/com/android/vending/licensing/LicenseRequestHeaders.kt b/vending-app/src/main/java/com/android/vending/licensing/LicenseRequestHeaders.kt
deleted file mode 100644
index 157d40efc3..0000000000
--- a/vending-app/src/main/java/com/android/vending/licensing/LicenseRequestHeaders.kt
+++ /dev/null
@@ -1,178 +0,0 @@
-package com.android.vending.licensing
-
-import android.util.Base64
-import android.util.Log
-import com.android.vending.AndroidVersionMeta
-import com.android.vending.DeviceMeta
-import com.android.vending.EncodedTriple
-import com.android.vending.EncodedTripleWrapper
-import com.android.vending.IntWrapper
-import com.android.vending.LicenseRequestHeader
-import com.android.vending.Locality
-import com.android.vending.LocalityWrapper
-import com.android.vending.StringWrapper
-import com.android.vending.Timestamp
-import com.android.vending.TimestampContainer
-import com.android.vending.TimestampContainer1
-import com.android.vending.TimestampContainer1Wrapper
-import com.android.vending.TimestampContainer2
-import com.android.vending.TimestampStringWrapper
-import com.android.vending.TimestampWrapper
-import com.android.vending.UnknownByte12
-import com.android.vending.UserAgent
-import com.android.vending.Uuid
-import com.google.android.gms.common.BuildConfig
-import okio.ByteString
-import org.microg.gms.profile.Build
-import java.io.ByteArrayOutputStream
-import java.io.IOException
-import java.net.URLEncoder
-import java.util.UUID
-import java.util.zip.GZIPOutputStream
-
-private const val TAG = "FakeLicenseRequest"
-
-private const val BASE64_FLAGS = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
-private const val FINSKY_VERSION = "Finsky/37.5.24-29%20%5B0%5D%20%5BPR%5D%20565477504"
-
-internal fun getLicenseRequestHeaders(auth: String, androidId: Long): Map {
- var millis = System.currentTimeMillis()
- val timestamp = TimestampContainer.Builder()
- .container2(
- TimestampContainer2.Builder()
- .wrapper(TimestampWrapper.Builder().timestamp(makeTimestamp(millis)).build())
- .timestamp(makeTimestamp(millis))
- .build()
- )
- millis = System.currentTimeMillis()
- timestamp
- .container1Wrapper(
- TimestampContainer1Wrapper.Builder()
- .androidId(androidId.toString())
- .container(
- TimestampContainer1.Builder()
- .timestamp(millis.toString() + "000")
- .wrapper(makeTimestamp(millis))
- .build()
- )
- .build()
- )
- val encodedTimestamps = String(
- Base64.encode(timestamp.build().encode().encodeGzip(), BASE64_FLAGS)
- )
-
- val locality = Locality.Builder()
- .unknown1(1)
- .unknown2(2)
- .countryCode("")
- .region(
- TimestampStringWrapper.Builder()
- .string("").timestamp(makeTimestamp(System.currentTimeMillis())).build()
- )
- .country(
- TimestampStringWrapper.Builder()
- .string("").timestamp(makeTimestamp(System.currentTimeMillis())).build()
- )
- .unknown3(0)
- .build()
- val encodedLocality = String(
- Base64.encode(locality.encode(), BASE64_FLAGS)
- )
-
- val header = LicenseRequestHeader.Builder()
- .encodedTimestamps(StringWrapper.Builder().string(encodedTimestamps).build())
- .triple(
- EncodedTripleWrapper.Builder().triple(
- EncodedTriple.Builder()
- .encoded1("")
- .encoded2("")
- .empty("")
- .build()
- ).build()
- )
- .locality(LocalityWrapper.Builder().encodedLocalityProto(encodedLocality).build())
- .unknown(IntWrapper.Builder().integer(5).build())
- .empty("")
- .deviceMeta(
- DeviceMeta.Builder()
- .android(
- AndroidVersionMeta.Builder()
- .androidSdk(Build.VERSION.SDK_INT)
- .buildNumber(Build.ID)
- .androidVersion(Build.VERSION.RELEASE)
- .unknown(0)
- .build()
- )
- .unknown1(
- UnknownByte12.Builder().bytes(ByteString.EMPTY).build()
- )
- .unknown2(1)
- .build()
- )
- .userAgent(
- UserAgent.Builder()
- .deviceName(Build.DEVICE)
- .deviceHardware(Build.HARDWARE)
- .deviceModelName(Build.MODEL)
- .finskyVersion(FINSKY_VERSION)
- .deviceProductName(Build.MODEL)
- .androidId(androidId) // must not be 0
- .buildFingerprint(Build.FINGERPRINT)
- .build()
- )
- .uuid(
- Uuid.Builder()
- .uuid(UUID.randomUUID().toString())
- .unknown(2)
- .build()
- )
- .build().encode()
- val xPsRh = String(Base64.encode(header.encodeGzip(), BASE64_FLAGS))
-
- Log.v(TAG, "X-PS-RH: $xPsRh")
-
- val userAgent =
- "$FINSKY_VERSION (api=3,versionCode=${BuildConfig.VERSION_CODE},sdk=${Build.VERSION.SDK}," +
- "device=${encodeString(Build.DEVICE)},hardware=${encodeString(Build.HARDWARE)}," +
- "product=${encodeString(Build.PRODUCT)},platformVersionRelease=${encodeString(Build.VERSION.RELEASE)}," +
- "model=${encodeString(Build.MODEL)},buildId=${encodeString(Build.ID)},isWideScreen=${0}," +
- "supportedAbis=${Build.SUPPORTED_ABIS.joinToString(";")})"
- Log.v(TAG, "User-Agent: $userAgent")
-
- return mapOf(
- "X-PS-RH" to xPsRh,
- "User-Agent" to userAgent,
- "Authorization" to "Bearer $auth",
- "Accept-Language" to "en-US",
- "Connection" to "Keep-Alive"
- )
-}
-
-private fun makeTimestamp(millis: Long): Timestamp {
- return Timestamp.Builder()
- .seconds((millis / 1000))
- .nanos(((millis % 1000) * 1000000).toInt())
- .build()
-}
-
-private fun encodeString(s: String?): String {
- return URLEncoder.encode(s).replace("+", "%20")
-}
-
-/**
- * From [StackOverflow](https://stackoverflow.com/a/46688434/), CC BY-SA 4.0 by Sergey Frolov, adapted.
- */
-fun ByteArray.encodeGzip(): ByteArray {
- try {
- ByteArrayOutputStream().use { byteOutput ->
- GZIPOutputStream(byteOutput).use { gzipOutput ->
- gzipOutput.write(this)
- gzipOutput.finish()
- return byteOutput.toByteArray()
- }
- }
- } catch (e: IOException) {
- Log.e(TAG, "Failed to encode bytes as GZIP")
- return ByteArray(0)
- }
-}
\ No newline at end of file
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 9835e0e8db..d926c95849 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
@@ -10,6 +10,9 @@ import com.squareup.wire.Message
import com.squareup.wire.ProtoAdapter
import org.json.JSONObject
import org.microg.gms.utils.singleInstanceOf
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
@@ -17,7 +20,37 @@ import kotlin.coroutines.suspendCoroutine
private const val POST_TIMEOUT = 8000
class HttpClient(context: Context) {
- private val requestQueue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) }
+
+ val requestQueue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) }
+
+ suspend fun download(url: String, downloadFile: File, tag: String): String = suspendCoroutine { continuation ->
+ val uriBuilder = Uri.parse(url).buildUpon()
+ requestQueue.add(object : Request(Method.GET, uriBuilder.build().toString(), null) {
+ override fun parseNetworkResponse(response: NetworkResponse): Response {
+ if (response.statusCode != 200) throw VolleyError(response)
+ return try {
+ val parentDir = downloadFile.getParentFile()
+ if (parentDir != null && !parentDir.exists() && !parentDir.mkdirs()) {
+ throw IOException("Failed to create directories: ${parentDir.absolutePath}")
+ }
+ val fos = FileOutputStream(downloadFile)
+ fos.write(response.data)
+ fos.close()
+ Response.success(downloadFile.absolutePath, HttpHeaderParser.parseCacheHeaders(response))
+ } catch (e: Exception) {
+ Response.error(VolleyError(e))
+ }
+ }
+
+ override fun deliverResponse(response: String) {
+ continuation.resume(response)
+ }
+
+ override fun deliverError(error: VolleyError) {
+ continuation.resumeWithException(error)
+ }
+ }.setShouldCache(false).setTag(tag))
+ }
suspend fun get(
url: String,
diff --git a/vending-app/src/main/kotlin/com/android/vending/extensions.kt b/vending-app/src/main/kotlin/com/android/vending/extensions.kt
new file mode 100644
index 0000000000..5b010d2c32
--- /dev/null
+++ b/vending-app/src/main/kotlin/com/android/vending/extensions.kt
@@ -0,0 +1,131 @@
+/**
+ * SPDX-FileCopyrightText: 2024 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.android.vending
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.accounts.AccountManagerFuture
+import android.os.Bundle
+import android.util.Base64
+import android.util.Log
+import com.google.android.gms.common.BuildConfig
+import okio.ByteString
+import org.microg.gms.profile.Build
+import java.io.ByteArrayOutputStream
+import java.io.IOException
+import java.net.URLEncoder
+import java.util.UUID
+import java.util.zip.GZIPOutputStream
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlin.coroutines.suspendCoroutine
+
+private const val TAG = "FakeLicenseRequest"
+
+const val AUTH_TOKEN_SCOPE: String = "oauth2:https://www.googleapis.com/auth/googleplay"
+
+private const val BASE64_FLAGS = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
+private const val FINSKY_VERSION = "Finsky/37.5.24-29%20%5B0%5D%20%5BPR%5D%20565477504"
+
+fun buildRequestHeaders(auth: String, androidId: Long, language: List ?= null): Map {
+ var millis = System.currentTimeMillis()
+ val timestamp = TimestampContainer.Builder().container2(
+ TimestampContainer2.Builder().wrapper(TimestampWrapper.Builder().timestamp(makeTimestamp(millis)).build()).timestamp(makeTimestamp(millis)).build()
+ )
+ millis = System.currentTimeMillis()
+ timestamp.container1Wrapper(
+ TimestampContainer1Wrapper.Builder().androidId(androidId.toString()).container(
+ TimestampContainer1.Builder().timestamp(millis.toString() + "000").wrapper(makeTimestamp(millis)).build()
+ ).build()
+ )
+
+ val encodedTimestamps = String(Base64.encode(timestamp.build().encode().encodeGzip(), BASE64_FLAGS))
+ val locality = Locality.Builder().unknown1(1).unknown2(2).countryCode("").region(
+ TimestampStringWrapper.Builder().string("").timestamp(makeTimestamp(System.currentTimeMillis())).build()
+ ).country(
+ TimestampStringWrapper.Builder().string("").timestamp(makeTimestamp(System.currentTimeMillis())).build()
+ ).unknown3(0).build()
+ val encodedLocality = String(
+ Base64.encode(locality.encode(), BASE64_FLAGS)
+ )
+
+ val header = RequestHeader.Builder().encodedTimestamps(StringWrapper.Builder().string(encodedTimestamps).build()).triple(
+ EncodedTripleWrapper.Builder().triple(
+ EncodedTriple.Builder().encoded1("").encoded2("").empty("").build()
+ ).build()
+ ).locality(LocalityWrapper.Builder().encodedLocalityProto(encodedLocality).build()).unknown(IntWrapper.Builder().integer(5).build()).empty("").deviceMeta(
+ DeviceMeta.Builder().android(
+ AndroidVersionMeta.Builder().androidSdk(Build.VERSION.SDK_INT).buildNumber(Build.ID).androidVersion(Build.VERSION.RELEASE).unknown(0).build()
+ ).unknown1(
+ UnknownByte12.Builder().bytes(ByteString.EMPTY).build()
+ ).unknown2(1).build()
+ ).userAgent(
+ UserAgent.Builder().deviceName(Build.DEVICE).deviceHardware(Build.HARDWARE).deviceModelName(Build.MODEL).finskyVersion(FINSKY_VERSION)
+ .deviceProductName(Build.MODEL).androidId(androidId) // must not be 0
+ .buildFingerprint(Build.FINGERPRINT).build()
+ ).uuid(
+ Uuid.Builder().uuid(UUID.randomUUID().toString()).unknown(2).build()
+ ).apply {
+ if (language != null) {
+ languages(
+ RequestLanguagePackage.Builder().language(language).build()
+ )
+ }
+ }.build().encode()
+
+ val xPsRh = String(Base64.encode(header.encodeGzip(), BASE64_FLAGS))
+ Log.v(TAG, "X-PS-RH: $xPsRh")
+ val userAgent =
+ "$FINSKY_VERSION (api=3,versionCode=${BuildConfig.VERSION_CODE},sdk=${Build.VERSION.SDK}," + "device=${encodeString(Build.DEVICE)},hardware=${
+ encodeString(Build.HARDWARE)
+ }," + "product=${encodeString(Build.PRODUCT)},platformVersionRelease=${encodeString(Build.VERSION.RELEASE)}," + "model=${encodeString(Build.MODEL)},buildId=${
+ encodeString(
+ Build.ID
+ )
+ },isWideScreen=${0}," + "supportedAbis=${Build.SUPPORTED_ABIS.joinToString(";")})"
+ Log.v(TAG, "User-Agent: $userAgent")
+
+ return mapOf(
+ "X-PS-RH" to xPsRh, "User-Agent" to userAgent, "Authorization" to "Bearer $auth", "Accept-Language" to "en-US", "Connection" to "Keep-Alive"
+ )
+}
+
+private fun makeTimestamp(millis: Long): Timestamp {
+ return Timestamp.Builder().seconds((millis / 1000)).nanos(((millis % 1000) * 1000000).toInt()).build()
+}
+
+private fun encodeString(s: String?): String {
+ return URLEncoder.encode(s).replace("+", "%20")
+}
+
+/**
+ * From [StackOverflow](https://stackoverflow.com/a/46688434/), CC BY-SA 4.0 by Sergey Frolov, adapted.
+ */
+fun ByteArray.encodeGzip(): ByteArray {
+ try {
+ ByteArrayOutputStream().use { byteOutput ->
+ GZIPOutputStream(byteOutput).use { gzipOutput ->
+ gzipOutput.write(this)
+ gzipOutput.finish()
+ return byteOutput.toByteArray()
+ }
+ }
+ } catch (e: IOException) {
+ Log.e(TAG, "Failed to encode bytes as GZIP")
+ return ByteArray(0)
+ }
+}
+suspend fun getAuthToken(accountManager: AccountManager, account: Account, authTokenType: String) =
+ suspendCoroutine { continuation ->
+ accountManager.getAuthToken(account, authTokenType, false, { future: AccountManagerFuture ->
+ try {
+ val result = future.result
+ continuation.resume(result)
+ } catch (e: Exception) {
+ continuation.resumeWithException(e)
+ }
+ }, null)
+ }
diff --git a/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/SplitInstallManager.kt b/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/SplitInstallManager.kt
new file mode 100644
index 0000000000..aa92d5ef7c
--- /dev/null
+++ b/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/SplitInstallManager.kt
@@ -0,0 +1,337 @@
+/**
+ * SPDX-FileCopyrightText: 2024 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.google.android.finsky.splitinstallservice
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.accounts.AuthenticatorException
+import android.annotation.SuppressLint
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInfo
+import android.content.pm.PackageInstaller
+import android.os.Build
+import android.os.Bundle
+import android.util.ArraySet
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.collection.arraySetOf
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import androidx.core.content.pm.PackageInfoCompat
+import com.android.vending.AUTH_TOKEN_SCOPE
+import com.android.vending.R
+import com.android.vending.buildRequestHeaders
+import com.android.vending.getAuthToken
+import com.google.android.finsky.GoogleApiResponse
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.withContext
+import org.microg.vending.billing.DEFAULT_ACCOUNT_TYPE
+import org.microg.vending.billing.core.HttpClient
+import java.io.File
+import java.io.FileInputStream
+import java.io.IOException
+
+private const val SPLIT_INSTALL_NOTIFY_ID = 111
+private const val SPLIT_INSTALL_REQUEST_TAG = "splitInstallRequestTag"
+private const val SPLIT_LANGUAGE_TAG = "config."
+
+private const val NOTIFY_CHANNEL_ID = "splitInstall"
+private const val NOTIFY_CHANNEL_NAME = "Split Install"
+private const val KEY_LANGUAGE = "language"
+private const val KEY_LANGUAGES = "languages"
+private const val KEY_MODULE_NAME = "module_name"
+private const val KEY_BYTES_DOWNLOADED = "bytes_downloaded"
+private const val KEY_TOTAL_BYTES_TO_DOWNLOAD = "total_bytes_to_download"
+private const val KEY_STATUS = "status"
+private const val KEY_ERROR_CODE = "error_code"
+private const val KEY_SESSION_ID = "session_id"
+private const val KEY_SESSION_STATE = "session_state"
+
+private const val STATUS_UNKNOWN = -1
+private const val STATUS_DOWNLOADING = 0
+private const val STATUS_DOWNLOADED = 1
+
+private const val ACTION_UPDATE_SERVICE = "com.google.android.play.core.splitinstall.receiver.SplitInstallUpdateIntentService"
+
+private const val FILE_SAVE_PATH = "phonesky-download-service"
+private const val TAG = "SplitInstallManager"
+
+class SplitInstallManager(val context: Context) {
+
+ private var httpClient: HttpClient = HttpClient(context)
+
+ suspend fun startInstall(callingPackage: String, splits: List): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
+// val callingPackage = runCatching { PackageUtils.getAndCheckCallingPackage(context, packageName) }.getOrNull() ?: return
+ if (splits.all { it.getString(KEY_LANGUAGE) == null && it.getString(KEY_MODULE_NAME) == null }) return false
+ Log.d(TAG, "startInstall: start")
+ val needInstallSplitPack = arraySetOf()
+ for (split in splits) {
+ val splitName = split.getString(KEY_LANGUAGE)?.let { "$SPLIT_LANGUAGE_TAG$it" } ?: split.getString(KEY_MODULE_NAME) ?: continue
+ val splitInstalled = checkSplitInstalled(callingPackage, splitName)
+ if (splitInstalled) continue
+ needInstallSplitPack.add(splitName)
+ }
+ Log.d(TAG, "startInstall needInstallSplitPack: $needInstallSplitPack")
+ if (needInstallSplitPack.isEmpty()) return false
+ val oauthToken = runCatching { withContext(Dispatchers.IO) { getOauthToken() } }.getOrNull()
+ Log.d(TAG, "startInstall oauthToken: $oauthToken")
+ if (oauthToken.isNullOrEmpty()) return false
+ notify(context)
+ val triples = runCatching { requestDownloadUrls(callingPackage, oauthToken, needInstallSplitPack) }.getOrNull()
+ Log.w(TAG, "startInstall requestDownloadUrls triples: $triples")
+ if (triples.isNullOrEmpty()) {
+ NotificationManagerCompat.from(context).cancel(SPLIT_INSTALL_NOTIFY_ID)
+ return false
+ }
+ val intent = runCatching { installSplitPackage(context, callingPackage, triples) }.getOrNull()
+ NotificationManagerCompat.from(context).cancel(SPLIT_INSTALL_NOTIFY_ID)
+ if (intent == null) { return false }
+ sendCompleteBroad(context, callingPackage, intent)
+ return true
+ }
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ private suspend fun installSplitPackage(context: Context, callingPackage: String, downloadList: ArraySet>): Intent {
+ Log.d(TAG, "installSplitPackage start ")
+ if (!context.splitSaveFile().exists()) context.splitSaveFile().mkdir()
+ val downloadSplitPackage = downloadSplitPackage(context, callingPackage, downloadList)
+ if (!downloadSplitPackage) {
+ Log.w(TAG, "installSplitPackage download failed")
+ throw RuntimeException("installSplitPackage downloadSplitPackage has error")
+ }
+ Log.d(TAG, "installSplitPackage downloaded success")
+
+ val packageInstaller = context.packageManager.packageInstaller
+ val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_INHERIT_EXISTING)
+ params.setAppPackageName(callingPackage)
+ params.setAppLabel(callingPackage + "Subcontracting")
+ params.setInstallLocation(PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY)
+ try {
+ @SuppressLint("PrivateApi") val method = PackageInstaller.SessionParams::class.java.getDeclaredMethod(
+ "setDontKillApp", Boolean::class.javaPrimitiveType
+ )
+ method.invoke(params, true)
+ } catch (e: Exception) {
+ Log.w(TAG, "Error setting dontKillApp", e)
+ }
+ val sessionId: Int
+ var session: PackageInstaller.Session? = null
+ var totalDownloaded = 0L
+ try {
+ sessionId = packageInstaller.createSession(params)
+ session = packageInstaller.openSession(sessionId)
+ downloadList.forEach { item ->
+ val pkgPath = File(context.splitSaveFile().toString(), item.first)
+ session.openWrite(item.first, 0, -1).use { outputStream ->
+ FileInputStream(pkgPath).use { inputStream -> inputStream.copyTo(outputStream) }
+ session.fsync(outputStream)
+ }
+ totalDownloaded += pkgPath.length()
+ pkgPath.delete()
+ }
+ val deferred = CompletableDeferred()
+ deferredMap[sessionId] = deferred
+ val intent = Intent(context, InstallResultReceiver::class.java).apply {
+ putExtra(KEY_BYTES_DOWNLOADED, totalDownloaded)
+ }
+ val pendingIntent = PendingIntent.getBroadcast(context, sessionId, intent, 0)
+ session.commit(pendingIntent.intentSender)
+ Log.d(TAG, "installSplitPackage session commit")
+ return deferred.await()
+ } catch (e: IOException) {
+ Log.w(TAG, "Error installing split", e)
+ throw e
+ } finally {
+ session?.close()
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ private suspend fun downloadSplitPackage(context: Context, callingPackage: String, downloadList: ArraySet>): Boolean =
+ coroutineScope {
+ val results = downloadList.map { info ->
+ Log.d(TAG, "downloadSplitPackage: $info")
+ async {
+ val downloaded = runCatching {
+ httpClient.download(info.second, File(context.splitSaveFile().toString(), info.first), SPLIT_INSTALL_REQUEST_TAG)
+ }.onFailure {
+ Log.w(TAG, "downloadSplitPackage url:${info.second} save:${info.first}", it)
+ }.getOrNull() != null
+ downloaded.also { updateSplitInstallRecord(callingPackage, Triple(info.first, info.second, if (it) STATUS_DOWNLOADED else STATUS_UNKNOWN)) }
+ }
+ }.awaitAll()
+ return@coroutineScope results.all { it }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ private suspend fun requestDownloadUrls(callingPackage: String, authToken: String, packs: MutableSet): ArraySet> {
+ val versionCode = PackageInfoCompat.getLongVersionCode(context.packageManager.getPackageInfo(callingPackage, 0))
+ val requestUrl =
+ StringBuilder("https://play-fe.googleapis.com/fdfe/delivery?doc=$callingPackage&ot=1&vc=$versionCode&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=")
+ packs.forEach { requestUrl.append("&mn=").append(it) }
+ Log.d(TAG, "requestDownloadUrls start")
+ val languages = packs.filter { it.startsWith(SPLIT_LANGUAGE_TAG) }.map { it.replace(SPLIT_LANGUAGE_TAG, "") }
+ Log.d(TAG, "requestDownloadUrls languages: $languages")
+ val response = httpClient.get(
+ url = requestUrl.toString(),
+ headers = buildRequestHeaders(authToken, 1, languages).onEach { Log.d(TAG, "key:${it.key} value:${it.value}") },
+ adapter = GoogleApiResponse.ADAPTER
+ )
+ Log.d(TAG, "requestDownloadUrls end response -> $response")
+ val splitPkgInfoList = response.response?.splitReqResult?.pkgList?.pkgDownLoadInfo ?: throw RuntimeException("splitPkgInfoList is null")
+ val packSet = ArraySet>()
+ splitPkgInfoList.filter {
+ !it.splitPkgName.isNullOrEmpty() && !it.downloadUrl.isNullOrEmpty()
+ }.forEach { info ->
+ packs.filter {
+ it.contains(info.splitPkgName!!)
+ }.forEach {
+ packSet.add(Triple(first = it, second = info.downloadUrl!!, STATUS_DOWNLOADING))
+ }
+ }
+ Log.d(TAG, "requestDownloadUrls end packSet -> $packSet")
+ return packSet.onEach { updateSplitInstallRecord(callingPackage, it) }
+ }
+
+ private suspend fun getOauthToken(): String {
+ val accounts = AccountManager.get(context).getAccountsByType(DEFAULT_ACCOUNT_TYPE)
+ var oauthToken: String? = null
+ if (accounts.isEmpty()) {
+ Log.w(TAG, "No Google account found")
+ throw RuntimeException("No Google account found")
+ } else for (account: Account in accounts) {
+ oauthToken = try {
+ getAuthToken(AccountManager.get(context), account, AUTH_TOKEN_SCOPE).getString(AccountManager.KEY_AUTHTOKEN)
+ } catch (e: AuthenticatorException) {
+ Log.w(TAG, "Could not fetch auth token for account $account")
+ null
+ }
+ if (oauthToken != null) {
+ break
+ }
+ }
+ return oauthToken ?: throw RuntimeException("oauthToken is null")
+ }
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ private fun checkSplitInstalled(callingPackage: String, splitName: String): Boolean {
+ if (!splitInstallRecord.containsKey(callingPackage)) return false
+ return splitInstallRecord[callingPackage]?.find { it.first == splitName }?.third != STATUS_UNKNOWN
+ }
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ private fun updateSplitInstallRecord(callingPackage: String, triple: Triple) {
+ splitInstallRecord[callingPackage]?.let { triples ->
+ val find = triples.find { it.first == triple.first }
+ find?.let { triples.remove(it) }
+ triples.add(triple)
+ } ?: run {
+ val triples = ArraySet>()
+ triples.add(triple)
+ splitInstallRecord[callingPackage] = triples
+ }
+ }
+
+ private fun notify(context: Context) {
+ val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ notificationManager.createNotificationChannel(
+ NotificationChannel(NOTIFY_CHANNEL_ID, NOTIFY_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)
+ )
+ }
+ NotificationCompat.Builder(context, NOTIFY_CHANNEL_ID).setSmallIcon(android.R.drawable.stat_sys_download)
+ .setContentTitle(context.getString(R.string.split_install, context.getString(R.string.app_name))).setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setDefaults(
+ NotificationCompat.DEFAULT_ALL
+ ).build().also {
+ notificationManager.notify(SPLIT_INSTALL_NOTIFY_ID, it)
+ }
+ }
+
+ private fun Context.splitSaveFile() = File(filesDir, FILE_SAVE_PATH)
+
+ private fun sendCompleteBroad(context: Context, packageName: String, intent: Intent) {
+ Log.d(TAG, "sendCompleteBroadcast: intent:$intent")
+ val extra = Bundle().apply {
+ putInt(KEY_STATUS, 5)
+ putInt(KEY_ERROR_CODE, 0)
+ putInt(KEY_SESSION_ID, 0)
+ putLong(KEY_TOTAL_BYTES_TO_DOWNLOAD, intent.getLongExtra(KEY_BYTES_DOWNLOADED, 0))
+ putString(KEY_LANGUAGES, intent.getStringExtra(KEY_LANGUAGE))
+ putLong(KEY_BYTES_DOWNLOADED, intent.getLongExtra(KEY_BYTES_DOWNLOADED, 0))
+ }
+ val broadcastIntent = Intent(ACTION_UPDATE_SERVICE).apply {
+ setPackage(packageName)
+ putExtra(KEY_SESSION_STATE, extra)
+ addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
+ addFlags(Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
+ }
+ context.sendBroadcast(broadcastIntent)
+ }
+
+ fun release() {
+ httpClient.requestQueue.cancelAll(SPLIT_INSTALL_REQUEST_TAG)
+ splitInstallRecord.clear()
+ deferredMap.clear()
+ }
+
+ internal class InstallResultReceiver : BroadcastReceiver() {
+ @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+ override fun onReceive(context: Context, intent: Intent) {
+ val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
+ val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
+ Log.d(TAG, "onReceive status: $status sessionId: $sessionId")
+ try {
+ when (status) {
+ PackageInstaller.STATUS_SUCCESS -> {
+ Log.d(TAG, "InstallResultReceiver onReceive: install success")
+ if (sessionId != -1) {
+ deferredMap[sessionId]?.complete(intent)
+ deferredMap.remove(sessionId)
+ }
+ }
+
+ PackageInstaller.STATUS_PENDING_USER_ACTION -> {
+ val extraIntent = intent.extras?.getParcelable(Intent.EXTRA_INTENT) as Intent?
+ extraIntent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ extraIntent?.run { ContextCompat.startActivity(context, this, null) }
+ }
+
+ else -> {
+ NotificationManagerCompat.from(context).cancel(SPLIT_INSTALL_NOTIFY_ID)
+ val errorMsg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
+ Log.w(TAG, "InstallResultReceiver onReceive: install fail -> $errorMsg")
+ if (sessionId != -1) {
+ deferredMap[sessionId]?.completeExceptionally(RuntimeException("install fail -> $errorMsg"))
+ deferredMap.remove(sessionId)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Error handling install result", e)
+ if (sessionId != -1) {
+ deferredMap[sessionId]?.completeExceptionally(e)
+ }
+ }
+ }
+ }
+
+ companion object {
+ // Installation records, including subpackage name, download path, and installation status
+ private val splitInstallRecord = HashMap>>()
+ private val deferredMap = mutableMapOf>()
+ }
+}
diff --git a/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/SplitInstallService.kt b/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/SplitInstallService.kt
new file mode 100644
index 0000000000..c49356e723
--- /dev/null
+++ b/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/SplitInstallService.kt
@@ -0,0 +1,105 @@
+/**
+ * SPDX-FileCopyrightText: 2024 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.google.android.finsky.splitinstallservice
+
+import android.content.Intent
+import android.os.Bundle
+import android.os.IBinder
+import android.util.Log
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleService
+import androidx.lifecycle.lifecycleScope
+import com.google.android.gms.common.api.CommonStatusCodes
+import com.google.android.play.core.splitinstall.protocol.ISplitInstallService
+import com.google.android.play.core.splitinstall.protocol.ISplitInstallServiceCallback
+import kotlinx.coroutines.launch
+import org.microg.gms.profile.ProfileManager
+
+private const val TAG = "SplitInstallService"
+
+class SplitInstallService : LifecycleService() {
+
+ private lateinit var splitInstallManager: SplitInstallManager
+
+ override fun onBind(intent: Intent): IBinder? {
+ super.onBind(intent)
+ Log.d(TAG, "onBind: ")
+ ProfileManager.ensureInitialized(this)
+ splitInstallManager = SplitInstallManager(this)
+ return SplitInstallServiceImpl(splitInstallManager, lifecycle).asBinder()
+ }
+
+ override fun onUnbind(intent: Intent?): Boolean {
+ Log.d(TAG, "onUnbind: ")
+ splitInstallManager.release()
+ return super.onUnbind(intent)
+ }
+}
+
+class SplitInstallServiceImpl(private val installManager: SplitInstallManager, override val lifecycle: Lifecycle) : ISplitInstallService.Stub(),
+ LifecycleOwner {
+
+ override fun startInstall(pkg: String, splits: List, bundle0: Bundle, callback: ISplitInstallServiceCallback) {
+ Log.d(TAG, "Method Called by package: $pkg")
+ lifecycleScope.launch {
+ val installStatus = installManager.startInstall(pkg, splits)
+ Log.d(TAG, "startInstall: installStatus -> $installStatus")
+ callback.onStartInstall(CommonStatusCodes.SUCCESS, Bundle())
+ }
+ }
+
+ override fun completeInstalls(pkg: String, sessionId: Int, bundle0: Bundle, callback: ISplitInstallServiceCallback) {
+ Log.d(TAG, "Method (completeInstalls) called but not implement by package -> $pkg")
+ }
+
+ override fun cancelInstall(pkg: String, sessionId: Int, callback: ISplitInstallServiceCallback) {
+ Log.d(TAG, "Method (cancelInstall) called but not implement by package -> $pkg")
+ }
+
+ override fun getSessionState(pkg: String, sessionId: Int, callback: ISplitInstallServiceCallback) {
+ Log.d(TAG, "Method (getSessionState) called but not implement by package -> $pkg")
+ }
+
+ override fun getSessionStates(pkg: String, callback: ISplitInstallServiceCallback) {
+ Log.d(TAG, "Method (getSessionStates) called but not implement by package -> $pkg")
+ callback.onGetSessionStates(ArrayList(1))
+ }
+
+ override fun splitRemoval(pkg: String, splits: List, callback: ISplitInstallServiceCallback) {
+ Log.d(TAG, "Method (splitRemoval) called but not implement by package -> $pkg")
+ }
+
+ override fun splitDeferred(pkg: String, splits: List, bundle0: Bundle, callback: ISplitInstallServiceCallback) {
+ Log.d(TAG, "Method (splitDeferred) called but not implement by package -> $pkg")
+ callback.onDeferredInstall(Bundle())
+ }
+
+ override fun getSessionState2(pkg: String, sessionId: Int, callback: ISplitInstallServiceCallback) {
+ Log.d(TAG, "Method (getSessionState2) called but not implement by package -> $pkg")
+ }
+
+ override fun getSessionStates2(pkg: String, callback: ISplitInstallServiceCallback) {
+ Log.d(TAG, "Method (getSessionStates2) called but not implement by package -> $pkg")
+ }
+
+ override fun getSplitsAppUpdate(pkg: String, callback: ISplitInstallServiceCallback) {
+ Log.d(TAG, "Method (getSplitsAppUpdate) called but not implement by package -> $pkg")
+ }
+
+ override fun completeInstallAppUpdate(pkg: String, callback: ISplitInstallServiceCallback) {
+ Log.d(TAG, "Method (completeInstallAppUpdate) called but not implement by package -> $pkg")
+ }
+
+ override fun languageSplitInstall(pkg: String, splits: List, bundle0: Bundle, callback: ISplitInstallServiceCallback) {
+ Log.d(TAG, "Method Called by package: $pkg")
+ }
+
+ override fun languageSplitUninstall(pkg: String, splits: List, callback: ISplitInstallServiceCallback) {
+ Log.d(TAG, "Method (languageSplitUninstall) called but not implement by package -> $pkg")
+ }
+
+}
diff --git a/vending-app/src/main/proto/LicenseRequest.proto b/vending-app/src/main/proto/RequestHeader.proto
similarity index 93%
rename from vending-app/src/main/proto/LicenseRequest.proto
rename to vending-app/src/main/proto/RequestHeader.proto
index dc0b71339e..4fb678a558 100644
--- a/vending-app/src/main/proto/LicenseRequest.proto
+++ b/vending-app/src/main/proto/RequestHeader.proto
@@ -3,12 +3,13 @@ syntax = "proto2";
option java_package = "com.android.vending";
option java_multiple_files = true;
-message LicenseRequestHeader {
+message RequestHeader {
optional StringWrapper encodedTimestamps = 1;
optional EncodedTripleWrapper triple = 10;
optional LocalityWrapper locality = 11;
optional IntWrapper unknown = 12;
optional string empty = 14;
+ optional RequestLanguagePackage languages = 15;
optional DeviceMeta deviceMeta = 20;
optional UserAgent userAgent = 21;
optional Uuid uuid = 27;
@@ -36,6 +37,10 @@ message IntWrapper {
optional uint32 integer = 1;
}
+message RequestLanguagePackage {
+ repeated string language = 1;
+}
+
message DeviceMeta {
optional AndroidVersionMeta android = 1;
optional UnknownByte12 unknown1 = 2;
diff --git a/vending-app/src/main/proto/SplitInstall.proto b/vending-app/src/main/proto/SplitInstall.proto
new file mode 100644
index 0000000000..38c7a50043
--- /dev/null
+++ b/vending-app/src/main/proto/SplitInstall.proto
@@ -0,0 +1,83 @@
+
+option java_package = "com.google.android.finsky";
+option java_multiple_files = true;
+
+message GoogleApiResponse {
+ optional ApiResponse response = 1;
+ optional UnknownType type= 5;
+ optional bytes unknownFieldBytes= 9;
+}
+
+message UnknownType {
+ optional int64 id=1;
+}
+
+message ApiResponse {
+ optional TocResponse tocApi = 6;
+ optional SplitResponse splitReqResult = 21;
+// optional SyncApiResp syncResult = 183;
+}
+
+message TocResponse {
+ optional string tocTokenValue = 22;
+}
+
+message SplitResponse {
+ optional int32 unknownInt32 = 1;
+ optional PkgFetchInfo pkgList = 2;
+}
+
+message PkgFetchInfo {
+ repeated SplitPkgInfo pkgDownLoadInfo = 15;
+}
+
+message SplitPkgInfo {
+ optional string splitPkgName = 1;
+ optional int64 size = 2;
+ optional string checkSum = 4;
+ optional string downloadUrl = 5;
+ optional DownloadInfo slaveDownloadInfo = 8;
+ optional string checksum = 9;
+ optional string unknownPkgInfoString = 15;
+ optional DownloadInfo otherDownloadInfo = 16;
+}
+
+message DownloadInfo {
+ optional int32 id = 1;
+ optional int64 size = 2;
+ optional string url = 3;
+}
+
+//message SyncApiResp {
+// repeated SyncRespContent content = 1;
+// optional SyncToken syncTokenValue = 2;
+//// repeated string c=3;
+//}
+//
+//message SyncToken {
+// optional string mvalue = 1;
+//}
+//
+//
+//message SyncRespContent {
+// oneof b {
+// UnknowTypeaynt unknowEmptyField = 2;
+// }
+// optional int64 token = 1;
+//}
+//
+//message UnknownType {
+// optional UnknowEmptyAynx a=1;
+// optional int32 id=2; //unknow enum
+//}
+//
+//message UnknowEmptyAynx {
+// oneof b {
+// UnknowTypeawwm oneofField25 = 26;
+// }
+//}
+//
+//message UnknowTypeawwm {
+// optional int32 id=1;
+//}
+
diff --git a/vending-app/src/main/res/values-zh-rCN/strings.xml b/vending-app/src/main/res/values-zh-rCN/strings.xml
index e0999d351a..f0554d99ee 100644
--- a/vending-app/src/main/res/values-zh-rCN/strings.xml
+++ b/vending-app/src/main/res/values-zh-rCN/strings.xml
@@ -18,4 +18,5 @@
如果应用出现异常,请登录您购买该应用所使用的 Google 帐号。
登录
忽略
+ 正在下载 %s 所需的组件
\ No newline at end of file
diff --git a/vending-app/src/main/res/values/strings.xml b/vending-app/src/main/res/values/strings.xml
index d505d6fdfd..0393927deb 100644
--- a/vending-app/src/main/res/values/strings.xml
+++ b/vending-app/src/main/res/values/strings.xml
@@ -28,4 +28,5 @@
Forget password?
Learn more
Verify
+ Downloading required components for %s