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