diff --git a/build.gradle b/build.gradle
index e1f2a3c15b..63aabd8fa3 100644
--- a/build.gradle
+++ b/build.gradle
@@ -27,7 +27,9 @@ buildscript {
ext.slf4jVersion = '1.7.36'
ext.volleyVersion = '1.2.1'
- ext.wireVersion = '4.8.0'
+ ext.okHttpVersion = '4.12.0'
+ ext.ktorVersion = '2.3.12'
+ ext.wireVersion = '4.9.9'
ext.tinkVersion = '1.13.0'
ext.androidBuildGradleVersion = '8.2.2'
diff --git a/play-services-auth-workaccount/build.gradle b/play-services-auth-workaccount/build.gradle
new file mode 100644
index 0000000000..22d408c8e5
--- /dev/null
+++ b/play-services-auth-workaccount/build.gradle
@@ -0,0 +1,38 @@
+/*
+ * SPDX-FileCopyrightText: 2023 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+apply plugin: 'com.android.library'
+apply plugin: 'maven-publish'
+apply plugin: 'signing'
+
+android {
+ namespace "com.google.android.gms.auth.workaccount"
+
+ compileSdkVersion androidCompileSdk
+ buildToolsVersion "$androidBuildVersionTools"
+
+ buildFeatures {
+ aidl = true
+ }
+
+ defaultConfig {
+ versionName version
+ minSdkVersion androidMinSdk
+ targetSdkVersion androidTargetSdk
+ }
+
+ compileOptions {
+ sourceCompatibility = 1.8
+ targetCompatibility = 1.8
+ }
+
+}
+
+apply from: '../gradle/publish-android.gradle'
+
+description = 'microG implementation of managed work account support'
+
+dependencies {
+}
diff --git a/play-services-auth-workaccount/core/build.gradle b/play-services-auth-workaccount/core/build.gradle
new file mode 100644
index 0000000000..41a453103e
--- /dev/null
+++ b/play-services-auth-workaccount/core/build.gradle
@@ -0,0 +1,45 @@
+/*
+ * SPDX-FileCopyrightText: 2023 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+dependencies {
+ api project(':play-services-auth-workaccount')
+ api project(':play-services-auth')
+ implementation project(':play-services-base-core')
+
+ implementation "androidx.appcompat:appcompat:$appcompatVersion"
+}
+
+android {
+ namespace "com.google.android.gms.auth.workaccount"
+
+ compileSdkVersion androidCompileSdk
+ buildToolsVersion "$androidBuildVersionTools"
+
+ defaultConfig {
+ versionName version
+ minSdkVersion androidMinSdk
+ targetSdkVersion androidTargetSdk
+ }
+
+ sourceSets {
+ main.java.srcDirs += 'src/main/kotlin'
+ }
+
+ compileOptions {
+ sourceCompatibility = 1.8
+ targetCompatibility = 1.8
+ }
+
+ kotlinOptions {
+ jvmTarget = 1.8
+ }
+
+ lintOptions {
+ disable 'MissingTranslation'
+ }
+}
diff --git a/play-services-auth-workaccount/core/src/main/AndroidManifest.xml b/play-services-auth-workaccount/core/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..49a9a15453
--- /dev/null
+++ b/play-services-auth-workaccount/core/src/main/AndroidManifest.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/play-services-auth-workaccount/core/src/main/kotlin/com/google/android/gms/auth/account/authenticator/WorkAccountAuthenticator.kt b/play-services-auth-workaccount/core/src/main/kotlin/com/google/android/gms/auth/account/authenticator/WorkAccountAuthenticator.kt
new file mode 100644
index 0000000000..84800e829d
--- /dev/null
+++ b/play-services-auth-workaccount/core/src/main/kotlin/com/google/android/gms/auth/account/authenticator/WorkAccountAuthenticator.kt
@@ -0,0 +1,242 @@
+/*
+ * SPDX-FileCopyrightText: 2024 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.google.android.gms.auth.account.authenticator
+
+import android.accounts.AbstractAccountAuthenticator
+import android.accounts.Account
+import android.accounts.AccountAuthenticatorResponse
+import android.accounts.AccountManager
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.auth.workaccount.R
+import org.microg.gms.auth.AuthConstants
+import org.microg.gms.common.PackageUtils
+import org.microg.gms.auth.AuthRequest
+import org.microg.gms.auth.AuthResponse
+import org.microg.gms.auth.workaccount.WorkProfileSettings
+import java.io.IOException
+import kotlin.jvm.Throws
+
+class WorkAccountAuthenticator(val context: Context) : AbstractAccountAuthenticator(context) {
+
+ override fun editProperties(
+ response: AccountAuthenticatorResponse,
+ accountType: String?
+ ): Bundle {
+ TODO("Not yet implemented: editProperties")
+ }
+
+ override fun addAccount(
+ response: AccountAuthenticatorResponse,
+ accountType: String,
+ authTokenType: String?,
+ requiredFeatures: Array?,
+ options: Bundle
+ ): Bundle? {
+
+ if (!WorkProfileSettings(context).allowCreateWorkAccount) {
+ return Bundle().apply {
+ putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION)
+ putString(AccountManager.KEY_ERROR_MESSAGE, context.getString(R.string.auth_work_authenticator_disabled_error)
+ )
+ }
+ } else if (
+ !options.containsKey(KEY_ACCOUNT_CREATION_TOKEN)
+ || options.getString(KEY_ACCOUNT_CREATION_TOKEN) == null
+ || options.getInt(AccountManager.KEY_CALLER_UID) != android.os.Process.myUid()) {
+ Log.e(TAG,
+ "refusing to add account without creation token or from external app: " +
+ "could have been manually initiated by user (not supported) " +
+ "or by unauthorized app (not allowed)"
+ )
+
+ // TODO: The error message is not automatically displayed by the settings app as of now.
+ // We can consider showing the error message through a popup instead.
+
+ return Bundle().apply {
+ putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION)
+ putString(AccountManager.KEY_ERROR_MESSAGE, context.getString(R.string.auth_work_authenticator_add_manual_error)
+ )
+ }
+ }
+
+ val oauthToken: String = options.getString(KEY_ACCOUNT_CREATION_TOKEN)!!
+
+ try {
+ tryAddAccount(oauthToken, response)
+ } catch (exception: Exception) {
+ response.onResult(Bundle().apply {
+ putInt(
+ AccountManager.KEY_ERROR_CODE,
+ AccountManager.ERROR_CODE_NETWORK_ERROR
+ )
+ putString(AccountManager.KEY_ERROR_MESSAGE, exception.message)
+ })
+ }
+
+ /* Note: as is not documented, `null` must only be returned after `response.onResult` was
+ * already called, hence forcing the requests to be synchronous. They are still async to
+ * the caller's main thread because AccountManager forces potentially blocking operations,
+ * like waiting for a response upon `addAccount`, not to be on the main thread.
+ */
+ return null
+ }
+
+ @Throws(Exception::class)
+ private fun tryAddAccount(
+ oauthToken: String,
+ response: AccountAuthenticatorResponse
+ ) {
+ val authResponse = AuthRequest().fromContext(context)
+ .appIsGms()
+ .callerIsGms()
+ .service("ac2dm")
+ .token(oauthToken).isAccessToken()
+ .addAccount()
+ .getAccountId()
+ .droidguardResults(null)
+ .response
+
+ val accountManager = AccountManager.get(context)
+ if (accountManager.addAccountExplicitly(
+ Account(authResponse.email, AuthConstants.WORK_ACCOUNT_TYPE),
+ authResponse.token, Bundle().apply {
+ // Work accounts have no SID / LSID ("BAD_COOKIE") and no first/last name.
+ if (authResponse.accountId.isNotBlank()) {
+ putString(KEY_GOOGLE_USER_ID, authResponse.accountId)
+ }
+ putString(AuthConstants.KEY_ACCOUNT_CAPABILITIES, authResponse.capabilities)
+ putString(AuthConstants.KEY_ACCOUNT_SERVICES, authResponse.services)
+ if (authResponse.services != "android") {
+ Log.i(
+ TAG,
+ "unexpected 'services' value ${authResponse.services} (usually 'android')"
+ )
+ }
+ }
+ )
+ ) {
+
+ // Notify vending package
+ context.sendBroadcast(
+ Intent(WORK_ACCOUNT_CHANGED_BOARDCAST).setPackage("com.android.vending")
+ )
+
+ // Report successful creation to caller
+ response.onResult(Bundle().apply {
+ putString(AccountManager.KEY_ACCOUNT_NAME, authResponse.email)
+ putString(AccountManager.KEY_ACCOUNT_TYPE, AuthConstants.WORK_ACCOUNT_TYPE)
+ })
+ }
+ }
+
+ override fun confirmCredentials(
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
+ options: Bundle?
+ ): Bundle {
+ return Bundle().apply {
+ putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true)
+ }
+ }
+
+ override fun getAuthToken(
+ response: AccountAuthenticatorResponse?,
+ account: Account,
+ authTokenType: String?,
+ options: Bundle?
+ ): Bundle {
+ try {
+ val authResponse: AuthResponse =
+ AuthRequest().fromContext(context)
+ .source("android")
+ .app(
+ context.packageName,
+ PackageUtils.firstSignatureDigest(context, context.packageName)
+ )
+ .email(account.name)
+ .token(AccountManager.get(context).getPassword(account))
+ .service(authTokenType)
+ .delegation(0, null)
+// .oauth2Foreground(oauth2Foreground)
+// .oauth2Prompt(oauth2Prompt)
+// .oauth2IncludeProfile(includeProfile)
+// .oauth2IncludeEmail(includeEmail)
+// .itCaveatTypes(itCaveatTypes)
+// .tokenRequestOptions(tokenRequestOptions)
+ .systemPartition(true)
+ .hasPermission(true)
+// .putDynamicFiledMap(dynamicFields)
+ .appIsGms()
+ .callerIsApp()
+ .response
+
+ return Bundle().apply {
+ putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
+ putString(AccountManager.KEY_ACCOUNT_TYPE, account.type)
+ putString(AccountManager.KEY_AUTHTOKEN, authResponse.auth)
+ }
+ } catch (e: IOException) {
+ return Bundle().apply {
+ putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_NETWORK_ERROR)
+ putString(AccountManager.KEY_ERROR_MESSAGE, e.message)
+ }
+ }
+ }
+
+ override fun getAuthTokenLabel(authTokenType: String?): String {
+ TODO("Not yet implemented: getAuthTokenLabel")
+ }
+
+ override fun updateCredentials(
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
+ authTokenType: String?,
+ options: Bundle?
+ ): Bundle {
+ TODO("Not yet implemented: updateCredentials")
+ }
+
+ override fun hasFeatures(
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
+ features: Array
+ ): Bundle {
+ Log.i(TAG, "Queried features: " + features.joinToString(", "))
+ return Bundle().apply {
+ putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false)
+ }
+ }
+
+ /**
+ * Prevent accidental deletion, unlike GMS. The account can only be removed through client apps;
+ * ideally, it would only be removed by the app that requested it to be created / the DPC
+ * manager, though this is not enforced. On API 21, the account can also be removed by hand
+ * because `removeAccountExplicitly` is not available on API 21.
+ */
+ override fun getAccountRemovalAllowed(
+ response: AccountAuthenticatorResponse?,
+ account: Account?
+ ): Bundle {
+ return Bundle().apply {
+ putBoolean(AccountManager.KEY_BOOLEAN_RESULT,
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1
+ )
+ }
+ }
+
+ companion object {
+ const val TAG = "WorkAccAuthenticator"
+
+ const val WORK_ACCOUNT_CHANGED_BOARDCAST = "org.microg.vending.WORK_ACCOUNT_CHANGED"
+
+ const val KEY_ACCOUNT_CREATION_TOKEN = "creationToken"
+ private const val KEY_GOOGLE_USER_ID = AuthConstants.GOOGLE_USER_ID
+ }
+}
\ No newline at end of file
diff --git a/play-services-auth-workaccount/core/src/main/kotlin/com/google/android/gms/auth/account/authenticator/WorkAccountAuthenticatorService.kt b/play-services-auth-workaccount/core/src/main/kotlin/com/google/android/gms/auth/account/authenticator/WorkAccountAuthenticatorService.kt
new file mode 100644
index 0000000000..9f4a7d135e
--- /dev/null
+++ b/play-services-auth-workaccount/core/src/main/kotlin/com/google/android/gms/auth/account/authenticator/WorkAccountAuthenticatorService.kt
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: 2024 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.google.android.gms.auth.account.authenticator
+
+import android.accounts.AccountManager
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+
+class WorkAccountAuthenticatorService : Service() {
+ private val authenticator by lazy { WorkAccountAuthenticator(this) }
+
+ override fun onBind(intent: Intent): IBinder? {
+ if (intent.action == AccountManager.ACTION_AUTHENTICATOR_INTENT) {
+ return authenticator.iBinder
+ }
+ return null
+ }
+}
\ No newline at end of file
diff --git a/play-services-auth-workaccount/core/src/main/kotlin/org/microg/gms/auth/workaccount/WorkAccountService.kt b/play-services-auth-workaccount/core/src/main/kotlin/org/microg/gms/auth/workaccount/WorkAccountService.kt
new file mode 100644
index 0000000000..f32d89b0ab
--- /dev/null
+++ b/play-services-auth-workaccount/core/src/main/kotlin/org/microg/gms/auth/workaccount/WorkAccountService.kt
@@ -0,0 +1,168 @@
+/*
+ * SPDX-FileCopyrightText: 2024 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.auth.workaccount
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.app.admin.DevicePolicyManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.os.Parcel
+import android.util.Log
+import com.google.android.gms.auth.account.IWorkAccountCallback
+import com.google.android.gms.auth.account.IWorkAccountService
+import com.google.android.gms.auth.account.authenticator.WorkAccountAuthenticator.Companion.KEY_ACCOUNT_CREATION_TOKEN
+import com.google.android.gms.auth.account.authenticator.WorkAccountAuthenticator.Companion.WORK_ACCOUNT_CHANGED_BOARDCAST
+import com.google.android.gms.auth.account.authenticator.WorkAccountAuthenticatorService
+import com.google.android.gms.common.Feature
+import com.google.android.gms.common.api.CommonStatusCodes
+import com.google.android.gms.common.internal.ConnectionInfo
+import com.google.android.gms.common.internal.GetServiceRequest
+import com.google.android.gms.common.internal.IGmsCallbacks
+import org.microg.gms.BaseService
+import org.microg.gms.auth.AuthConstants
+import org.microg.gms.common.GmsService
+import org.microg.gms.common.PackageUtils
+
+private const val TAG = "GmsWorkAccountService"
+
+class WorkAccountService : BaseService(TAG, GmsService.WORK_ACCOUNT) {
+ override fun handleServiceRequest(
+ callback: IGmsCallbacks,
+ request: GetServiceRequest,
+ service: GmsService
+ ) {
+ val packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName)
+ val policyManager = getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
+ val authorized = policyManager.isDeviceAdminApp(packageName)
+
+ if (authorized) {
+ callback.onPostInitCompleteWithConnectionInfo(
+ CommonStatusCodes.SUCCESS,
+ WorkAccountServiceImpl(this),
+ ConnectionInfo().apply {
+ features = arrayOf(Feature("work_account_client_is_whitelisted", 1))
+ })
+ } else {
+ // Return mock response, don't tell client that it is whitelisted
+ callback.onPostInitCompleteWithConnectionInfo(
+ CommonStatusCodes.SUCCESS,
+ UnauthorizedWorkAccountServiceImpl(),
+ ConnectionInfo().apply {
+ features = emptyArray()
+ })
+ }
+ }
+}
+
+private fun DevicePolicyManager.isDeviceAdminApp(packageName: String?): Boolean {
+ if (packageName == null) return false
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ isDeviceOwnerApp(packageName) || isProfileOwnerApp(packageName)
+ } else {
+ isDeviceOwnerApp(packageName)
+ }
+}
+
+class WorkAccountServiceImpl(val context: Context) : IWorkAccountService.Stub() {
+
+ val packageManager: PackageManager = context.packageManager
+ val accountManager: AccountManager = AccountManager.get(context)
+
+ override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
+ Log.d(TAG, "$code, $data, $reply, $flags")
+ return super.onTransact(code, data, reply, flags)
+ }
+
+ override fun setWorkAuthenticatorEnabled(enabled: Boolean) {
+ Log.d(TAG, "setWorkAuthenticatorEnabled with $enabled")
+
+ val componentName = ComponentName(
+ context,
+ WorkAccountAuthenticatorService::class.java
+ )
+ packageManager.setComponentEnabledSetting(
+ componentName,
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
+ PackageManager.DONT_KILL_APP
+ )
+ }
+
+ override fun addWorkAccount(
+ callback: IWorkAccountCallback?,
+ token: String?
+ ) {
+ Log.d(TAG, "addWorkAccount with token $token")
+ val future = accountManager.addAccount(
+ AuthConstants.WORK_ACCOUNT_TYPE,
+ null,
+ null,
+ Bundle().apply { putString(KEY_ACCOUNT_CREATION_TOKEN, token) },
+ null,
+ null,
+ null
+ )
+ Thread {
+ try {
+ future.result.let { result ->
+ callback?.onAccountAdded(
+ Account(
+ result.getString(AccountManager.KEY_ACCOUNT_NAME),
+ result.getString(AccountManager.KEY_ACCOUNT_TYPE)
+ )
+ )
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "could not add work account with error message: ${e.message}")
+ }
+ }.start()
+ }
+
+ override fun removeWorkAccount(
+ callback: IWorkAccountCallback?,
+ account: Account?
+ ) {
+ Log.d(TAG, "removeWorkAccount with account ${account?.name}")
+ account?.let {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
+
+ val success = accountManager.removeAccountExplicitly(it)
+
+ // Notify vending package
+ context.sendBroadcast(
+ Intent(WORK_ACCOUNT_CHANGED_BOARDCAST).setPackage("com.android.vending")
+ )
+
+ callback?.onAccountRemoved(success)
+ } else {
+ val future = accountManager.removeAccount(it, null, null)
+ Thread {
+ future.result.let { result ->
+ callback?.onAccountRemoved(result)
+ }
+ }.start()
+ }
+ }
+ }
+}
+
+class UnauthorizedWorkAccountServiceImpl : IWorkAccountService.Stub() {
+ override fun setWorkAuthenticatorEnabled(enabled: Boolean) {
+ throw SecurityException("client not admin, yet tried to enable work authenticator")
+ }
+
+ override fun addWorkAccount(callback: IWorkAccountCallback?, token: String?) {
+ throw SecurityException("client not admin, yet tried to add work account")
+ }
+
+ override fun removeWorkAccount(callback: IWorkAccountCallback?, account: Account?) {
+ throw SecurityException("client not admin, yet tried to remove work account")
+ }
+}
\ No newline at end of file
diff --git a/play-services-auth-workaccount/core/src/main/kotlin/org/microg/gms/auth/workaccount/WorkProfileSettings.kt b/play-services-auth-workaccount/core/src/main/kotlin/org/microg/gms/auth/workaccount/WorkProfileSettings.kt
new file mode 100644
index 0000000000..dce81b81ad
--- /dev/null
+++ b/play-services-auth-workaccount/core/src/main/kotlin/org/microg/gms/auth/workaccount/WorkProfileSettings.kt
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.auth.workaccount
+
+import android.content.ContentValues
+import android.content.Context
+import android.database.Cursor
+import org.microg.gms.settings.SettingsContract
+
+class WorkProfileSettings(private val context: Context) {
+ private fun getSettings(vararg projection: String, f: (Cursor) -> T): T =
+ SettingsContract.getSettings(
+ context,
+ SettingsContract.WorkProfile.getContentUri(context),
+ projection,
+ f
+ )
+
+ private fun setSettings(v: ContentValues.() -> Unit) =
+ SettingsContract.setSettings(context, SettingsContract.WorkProfile.getContentUri(context), v)
+
+ var allowCreateWorkAccount: Boolean
+ get() = getSettings(SettingsContract.WorkProfile.CREATE_WORK_ACCOUNT) { c -> c.getInt(0) != 0 }
+ set(value) = setSettings { put(SettingsContract.WorkProfile.CREATE_WORK_ACCOUNT, value) }
+}
\ No newline at end of file
diff --git a/play-services-auth-workaccount/core/src/main/res/drawable/ic_briefcase.xml b/play-services-auth-workaccount/core/src/main/res/drawable/ic_briefcase.xml
new file mode 100644
index 0000000000..8315631566
--- /dev/null
+++ b/play-services-auth-workaccount/core/src/main/res/drawable/ic_briefcase.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/play-services-auth-workaccount/core/src/main/res/values/strings.xml b/play-services-auth-workaccount/core/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..1f76af9fdf
--- /dev/null
+++ b/play-services-auth-workaccount/core/src/main/res/values/strings.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ Managed Google for Work account
+ This type of account is created automatically by your profile administrator if needed.
+ Creating a work account is disabled in microG settings.
+
+
diff --git a/play-services-auth-workaccount/core/src/main/res/xml/auth_work_authenticator.xml b/play-services-auth-workaccount/core/src/main/res/xml/auth_work_authenticator.xml
new file mode 100644
index 0000000000..5e4e22db62
--- /dev/null
+++ b/play-services-auth-workaccount/core/src/main/res/xml/auth_work_authenticator.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/play-services-auth-workaccount/src/main/AndroidManifest.xml b/play-services-auth-workaccount/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..fe3a9e6548
--- /dev/null
+++ b/play-services-auth-workaccount/src/main/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
\ No newline at end of file
diff --git a/play-services-auth-workaccount/src/main/aidl/com/google/android/gms/auth/account/IWorkAccountCallback.aidl b/play-services-auth-workaccount/src/main/aidl/com/google/android/gms/auth/account/IWorkAccountCallback.aidl
new file mode 100644
index 0000000000..524bcd8e6c
--- /dev/null
+++ b/play-services-auth-workaccount/src/main/aidl/com/google/android/gms/auth/account/IWorkAccountCallback.aidl
@@ -0,0 +1,13 @@
+/*
+ * SPDX-FileCopyrightText: 2024 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.google.android.gms.auth.account;
+
+import android.accounts.Account;
+
+interface IWorkAccountCallback {
+ void onAccountAdded(in Account account) = 0;
+ void onAccountRemoved(boolean success) = 1;
+}
\ No newline at end of file
diff --git a/play-services-auth-workaccount/src/main/aidl/com/google/android/gms/auth/account/IWorkAccountService.aidl b/play-services-auth-workaccount/src/main/aidl/com/google/android/gms/auth/account/IWorkAccountService.aidl
new file mode 100644
index 0000000000..7db4657fdf
--- /dev/null
+++ b/play-services-auth-workaccount/src/main/aidl/com/google/android/gms/auth/account/IWorkAccountService.aidl
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: 2024 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.google.android.gms.auth.account;
+
+import android.accounts.Account;
+import com.google.android.gms.auth.account.IWorkAccountCallback;
+
+interface IWorkAccountService {
+
+ void setWorkAuthenticatorEnabled(boolean enabled) = 0;
+
+ void addWorkAccount(IWorkAccountCallback callback, String token) = 1;
+
+ void removeWorkAccount(IWorkAccountCallback callback, in Account account) = 2;
+}
\ No newline at end of file
diff --git a/play-services-base/core/build.gradle b/play-services-base/core/build.gradle
index fc039276b9..c570aefecf 100644
--- a/play-services-base/core/build.gradle
+++ b/play-services-base/core/build.gradle
@@ -10,6 +10,7 @@ apply plugin: 'signing'
dependencies {
api project(':play-services-basement-ktx')
+ implementation project(":play-services-core-proto")
implementation "androidx.annotation:annotation:$annotationVersion"
implementation "androidx.appcompat:appcompat:$appcompatVersion"
diff --git a/play-services-base/core/src/main/AndroidManifest.xml b/play-services-base/core/src/main/AndroidManifest.xml
index 5f539647cc..fa996c361d 100644
--- a/play-services-base/core/src/main/AndroidManifest.xml
+++ b/play-services-base/core/src/main/AndroidManifest.xml
@@ -3,19 +3,43 @@
~ SPDX-FileCopyrightText: 2020, microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/play-services-core/src/main/java/org/microg/gms/auth/AuthRequest.java b/play-services-base/core/src/main/java/org/microg/gms/auth/AuthRequest.java
similarity index 99%
rename from play-services-core/src/main/java/org/microg/gms/auth/AuthRequest.java
rename to play-services-base/core/src/main/java/org/microg/gms/auth/AuthRequest.java
index 2a253d3857..fc50c5b897 100644
--- a/play-services-core/src/main/java/org/microg/gms/auth/AuthRequest.java
+++ b/play-services-base/core/src/main/java/org/microg/gms/auth/AuthRequest.java
@@ -13,7 +13,6 @@
import org.microg.gms.common.HttpFormClient;
import org.microg.gms.common.Utils;
import org.microg.gms.profile.ProfileManager;
-import org.microg.gms.settings.SettingsContract;
import java.io.IOException;
import java.util.Locale;
diff --git a/play-services-core/src/main/java/org/microg/gms/auth/AuthResponse.java b/play-services-base/core/src/main/java/org/microg/gms/auth/AuthResponse.java
similarity index 97%
rename from play-services-core/src/main/java/org/microg/gms/auth/AuthResponse.java
rename to play-services-base/core/src/main/java/org/microg/gms/auth/AuthResponse.java
index 8937580ff4..6e945b75e6 100644
--- a/play-services-core/src/main/java/org/microg/gms/auth/AuthResponse.java
+++ b/play-services-base/core/src/main/java/org/microg/gms/auth/AuthResponse.java
@@ -71,6 +71,8 @@ public class AuthResponse {
public String resolutionDataBase64;
@ResponseField("it")
public String auths;
+ @ResponseField("capabilities")
+ public String capabilities;
public static AuthResponse parse(String result) {
AuthResponse response = new AuthResponse();
@@ -126,6 +128,7 @@ public String toString() {
if (auths != null) sb.append(", auths='").append(auths).append('\'');
if (itMetadata != null) sb.append(", itMetadata='").append(itMetadata).append('\'');
if (resolutionDataBase64 != null) sb.append(", resolutionDataBase64='").append(resolutionDataBase64).append('\'');
+ if (capabilities != null) sb.append(", capabilitites='").append(capabilities).append('\'');
sb.append('}');
return sb.toString();
}
diff --git a/play-services-base/core/src/main/java/org/microg/gms/common/DeviceConfiguration.java b/play-services-base/core/src/main/java/org/microg/gms/common/DeviceConfiguration.java
index 906567bb7d..eadbc58a03 100644
--- a/play-services-base/core/src/main/java/org/microg/gms/common/DeviceConfiguration.java
+++ b/play-services-base/core/src/main/java/org/microg/gms/common/DeviceConfiguration.java
@@ -67,7 +67,7 @@ public DeviceConfiguration(Context context) {
keyboardType = configurationInfo.reqKeyboardType;
navigation = configurationInfo.reqNavigation;
Configuration configuration = context.getResources().getConfiguration();
- screenLayout = configuration.screenLayout;
+ screenLayout = configuration.screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK;
hasHardKeyboard = (configurationInfo.reqInputFeatures & ConfigurationInfo.INPUT_FEATURE_HARD_KEYBOARD) > 0;
hasFiveWayNavigation = (configurationInfo.reqInputFeatures & ConfigurationInfo.INPUT_FEATURE_FIVE_WAY_NAV) > 0;
DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
diff --git a/play-services-base/core/src/main/java/org/microg/gms/common/HttpFormClient.java b/play-services-base/core/src/main/java/org/microg/gms/common/HttpFormClient.java
index 07e71af1ef..00bb4c3dea 100644
--- a/play-services-base/core/src/main/java/org/microg/gms/common/HttpFormClient.java
+++ b/play-services-base/core/src/main/java/org/microg/gms/common/HttpFormClient.java
@@ -205,14 +205,11 @@ private static T parseResponse(Class tClass, HttpURLConnection connection
public static void requestAsync(final String url, final Request request, final Class tClass,
final Callback callback) {
- new Thread(new Runnable() {
- @Override
- public void run() {
- try {
- callback.onResponse(request(url, request, tClass));
- } catch (Exception e) {
- callback.onException(e);
- }
+ new Thread(() -> {
+ try {
+ callback.onResponse(request(url, request, tClass));
+ } catch (Exception e) {
+ callback.onException(e);
}
}).start();
}
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/auth/AuthPrefs.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/auth/AuthPrefs.kt
similarity index 100%
rename from play-services-core/src/main/kotlin/org/microg/gms/auth/AuthPrefs.kt
rename to play-services-base/core/src/main/kotlin/org/microg/gms/auth/AuthPrefs.kt
diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/checkin/LastCheckinInfo.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/checkin/LastCheckinInfo.kt
new file mode 100644
index 0000000000..e33c0d7111
--- /dev/null
+++ b/play-services-base/core/src/main/kotlin/org/microg/gms/checkin/LastCheckinInfo.kt
@@ -0,0 +1,77 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.checkin
+
+import android.content.Context
+import org.microg.gms.settings.SettingsContract
+
+data class LastCheckinInfo(
+ val lastCheckin: Long,
+ val androidId: Long,
+ val securityToken: Long,
+ val digest: String,
+ val versionInfo: String,
+ val deviceDataVersionInfo: String,
+) {
+
+ constructor(r: CheckinResponse) : this(
+ lastCheckin = r.timeMs ?: 0L,
+ androidId = r.androidId ?: 0L,
+ securityToken = r.securityToken ?: 0L,
+ digest = r.digest ?: SettingsContract.CheckIn.INITIAL_DIGEST,
+ versionInfo = r.versionInfo ?: "",
+ deviceDataVersionInfo = r.deviceDataVersionInfo ?: "",
+ )
+
+ companion object {
+ @JvmStatic
+ fun read(context: Context): LastCheckinInfo {
+ val projection = arrayOf(
+ SettingsContract.CheckIn.ANDROID_ID,
+ SettingsContract.CheckIn.DIGEST,
+ SettingsContract.CheckIn.LAST_CHECK_IN,
+ SettingsContract.CheckIn.SECURITY_TOKEN,
+ SettingsContract.CheckIn.VERSION_INFO,
+ SettingsContract.CheckIn.DEVICE_DATA_VERSION_INFO,
+ )
+ return SettingsContract.getSettings(
+ context,
+ SettingsContract.CheckIn.getContentUri(context),
+ projection
+ ) { c ->
+ LastCheckinInfo(
+ androidId = c.getLong(0),
+ digest = c.getString(1),
+ lastCheckin = c.getLong(2),
+ securityToken = c.getLong(3),
+ versionInfo = c.getString(4),
+ deviceDataVersionInfo = c.getString(5),
+ )
+ }
+ }
+
+ @JvmStatic
+ fun clear(context: Context) =
+ SettingsContract.setSettings(context, SettingsContract.CheckIn.getContentUri(context)) {
+ put(SettingsContract.CheckIn.ANDROID_ID, 0L)
+ put(SettingsContract.CheckIn.DIGEST, SettingsContract.CheckIn.INITIAL_DIGEST)
+ put(SettingsContract.CheckIn.LAST_CHECK_IN, 0L)
+ put(SettingsContract.CheckIn.SECURITY_TOKEN, 0L)
+ put(SettingsContract.CheckIn.VERSION_INFO, "")
+ put(SettingsContract.CheckIn.DEVICE_DATA_VERSION_INFO, "")
+ }
+ }
+
+ fun write(context: Context) =
+ SettingsContract.setSettings(context, SettingsContract.CheckIn.getContentUri(context)) {
+ put(SettingsContract.CheckIn.ANDROID_ID, androidId)
+ put(SettingsContract.CheckIn.DIGEST, digest)
+ put(SettingsContract.CheckIn.LAST_CHECK_IN, lastCheckin)
+ put(SettingsContract.CheckIn.SECURITY_TOKEN, securityToken)
+ put(SettingsContract.CheckIn.VERSION_INFO, versionInfo)
+ put(SettingsContract.CheckIn.DEVICE_DATA_VERSION_INFO, deviceDataVersionInfo)
+ }
+}
\ No newline at end of file
diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/common/DeviceConfigProto.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/common/DeviceConfigProto.kt
new file mode 100644
index 0000000000..4507677849
--- /dev/null
+++ b/play-services-base/core/src/main/kotlin/org/microg/gms/common/DeviceConfigProto.kt
@@ -0,0 +1,26 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.common
+
+import org.microg.gms.checkin.DeviceConfig
+
+fun DeviceConfiguration.asProto(): DeviceConfig = DeviceConfig(
+ availableFeature = availableFeatures,
+ densityDpi = densityDpi,
+ glEsVersion = glEsVersion,
+ glExtension = glExtensions,
+ hasFiveWayNavigation = hasFiveWayNavigation,
+ hasHardKeyboard = hasHardKeyboard,
+ heightPixels = heightPixels,
+ keyboardType = keyboardType,
+ locale = locales,
+ nativePlatform = nativePlatforms,
+ navigation = navigation,
+ screenLayout = screenLayout,
+ sharedLibrary = sharedLibraries,
+ touchScreen = touchScreen,
+ widthPixels = widthPixels
+)
\ No newline at end of file
diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/crossprofile/CrossProfileRequestActivity.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/crossprofile/CrossProfileRequestActivity.kt
new file mode 100644
index 0000000000..fb07b37ed9
--- /dev/null
+++ b/play-services-base/core/src/main/kotlin/org/microg/gms/crossprofile/CrossProfileRequestActivity.kt
@@ -0,0 +1,91 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.crossprofile
+
+import android.app.Activity
+import android.content.Intent
+import android.content.pm.CrossProfileApps
+import android.os.Build
+import android.os.Bundle
+import android.os.UserManager
+import android.util.Log
+import androidx.annotation.RequiresApi
+import org.microg.gms.settings.SettingsContract.CROSS_PROFILE_PERMISSION
+import org.microg.gms.settings.SettingsContract.CROSS_PROFILE_SHARED_PREFERENCES_NAME
+import androidx.core.content.edit
+
+/**
+ * Two-step process:
+ * 1. request to hear back from `CrossProfileRequestActivity`
+ * 2. receive resulting URI as intent data
+ *
+ * This dance so complicated because Android platform does not offer better APIs that only need
+ * `INTERACT_ACROSS_PROFILES`, an appops permission (and not `INTERACT_ACROSS_USERS`, a
+ * privileged|system permission).
+ */
+@RequiresApi(Build.VERSION_CODES.R)
+class CrossProfileRequestActivity : Activity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Check that we are work profile
+ val userManager = getSystemService(UserManager::class.java)
+ if (!userManager.isManagedProfile) {
+ Log.w(CrossProfileSendActivity.TAG, "I was asked to send a cross-profile request, but I am not on a work profile!")
+ finish()
+ return
+ }
+
+ val crossProfileApps = getSystemService(CrossProfileApps::class.java)
+
+ val targetProfiles = crossProfileApps.targetUserProfiles
+
+ if (!crossProfileApps.canInteractAcrossProfiles() || targetProfiles.isEmpty()) {
+ Log.w(
+ TAG, "I am supposed to send a cross-profile request, but the prerequisites are not met: " +
+ "can interact = ${crossProfileApps.canInteractAcrossProfiles()}, " +
+ "#targetProfiles = ${targetProfiles.size}")
+ finish()
+ return
+ }
+
+ val intent = Intent(this, CrossProfileSendActivity::class.java)
+
+ Log.d(TAG, "asking for cross-profile URI")
+ crossProfileApps.startActivity(
+ intent,
+ targetProfiles.first(),
+ // if this parameter is provided, it works like `startActivityForResult` (with requestCode 0)
+ this
+ )
+
+ // finish only after receiving result
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ Log.d(TAG, data?.data.toString())
+
+ val uri = data?.data
+ if (uri == null) {
+ Log.w(TAG, "expected to receive data, but intent did not contain any.")
+ finish()
+ return
+ }
+
+ contentResolver.takePersistableUriPermission(uri, 0)
+
+ val preferences = getSharedPreferences(CROSS_PROFILE_SHARED_PREFERENCES_NAME, MODE_PRIVATE)
+ Log.i(TAG, "storing work URI")
+ preferences.edit { putString(CROSS_PROFILE_PERMISSION, uri.toString()) }
+
+ finish()
+ }
+
+ companion object {
+ const val TAG = "GmsCrossProfileRequest"
+ }
+}
\ No newline at end of file
diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/crossprofile/CrossProfileSendActivity.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/crossprofile/CrossProfileSendActivity.kt
new file mode 100644
index 0000000000..a4af62cd01
--- /dev/null
+++ b/play-services-base/core/src/main/kotlin/org/microg/gms/crossprofile/CrossProfileSendActivity.kt
@@ -0,0 +1,58 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.crossprofile
+
+import android.app.Activity
+import android.content.Intent
+import android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
+import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
+import android.content.pm.CrossProfileApps
+import android.os.Build
+import android.os.Bundle
+import android.os.UserManager
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.net.toUri
+import org.microg.gms.settings.SettingsContract.getAuthority
+
+@RequiresApi(Build.VERSION_CODES.R)
+class CrossProfileSendActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Check that we are primary profile
+ val userManager = getSystemService(UserManager::class.java)
+ if (userManager.isManagedProfile) {
+ Log.w(TAG, "Cross-profile send request was received on work profile!")
+ finish()
+ return
+ }
+
+ // Check prerequisites
+ val crossProfileApps = getSystemService(CrossProfileApps::class.java)
+ val targetProfiles = crossProfileApps.targetUserProfiles
+
+ if (!crossProfileApps.canInteractAcrossProfiles() || targetProfiles.isEmpty()) {
+ Log.w(
+ TAG, "received cross-profile request, but I believe I cannot answer, as prerequisites are not met: " +
+ "can interact = ${crossProfileApps.canInteractAcrossProfiles()}, " +
+ "#targetProfiles = ${targetProfiles.size}. Note that this is expected during initial setup of a work profile.")
+ }
+
+ // Respond
+ Log.d(TAG, "responding to cross-profile request")
+
+ setResult(1, Intent().apply {
+ setData("content://${getAuthority(this@CrossProfileSendActivity)}".toUri())
+ addFlags(FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
+ })
+ finish()
+ }
+
+ companion object {
+ const val TAG = "GmsCrossProfileSend"
+ }
+}
\ No newline at end of file
diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/crossprofile/UserInitReceiver.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/crossprofile/UserInitReceiver.kt
new file mode 100644
index 0000000000..23a9df95a5
--- /dev/null
+++ b/play-services-base/core/src/main/kotlin/org/microg/gms/crossprofile/UserInitReceiver.kt
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.crossprofile
+
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.UserManager
+import android.util.Log
+
+class UserInitReceiver : BroadcastReceiver() {
+ @SuppressLint("UnsafeProtectedBroadcastReceiver") // exported="false"
+ override fun onReceive(context: Context, intent: Intent?) {
+
+ // Check that we are work profile
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val userManager = context.getSystemService(UserManager::class.java)
+ if (userManager.isManagedProfile) {
+ Log.d(TAG, "A new managed profile is being initialized; telling `CrossProfileRequestActivity` to request access to main profile's data.")
+ // CrossProfileActivity will check whether permissions are present
+ context.startActivity(
+ Intent(context, CrossProfileRequestActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ )
+ } else {
+ Log.d(TAG, "A new user is being initialized, but it is not a managed profile. Not connecting data")
+ }
+ }
+ }
+
+ companion object {
+ const val TAG = "GmsUserInit"
+ }
+}
\ No newline at end of file
diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsContract.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsContract.kt
index e752bfea67..9ab57b83b0 100644
--- a/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsContract.kt
+++ b/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsContract.kt
@@ -7,25 +7,97 @@ package org.microg.gms.settings
import android.content.ContentValues
import android.content.Context
+import android.content.Context.MODE_PRIVATE
+import android.content.Intent
+import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
+import android.content.pm.CrossProfileApps
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.os.Binder
+import android.os.Build
import android.os.Bundle
+import android.os.UserManager
+import android.util.Log
+import androidx.core.net.toUri
+import org.microg.gms.crossprofile.CrossProfileRequestActivity
+import org.microg.gms.ui.TAG
object SettingsContract {
const val META_DATA_KEY_SOURCE_PACKAGE = "org.microg.gms.settings:source-package"
+
+ /**
+ * Stores keys that are useful only for connecting to the SettingsProvider from
+ * main profile in a managed / work profile
+ */
+ const val CROSS_PROFILE_SHARED_PREFERENCES_NAME = "crossProfile"
+ const val CROSS_PROFILE_PERMISSION = "uri"
+
fun getAuthority(context: Context): String {
val metaData = runCatching { context.packageManager.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA).metaData }.getOrNull() ?: Bundle.EMPTY
val sourcePackage = metaData.getString(META_DATA_KEY_SOURCE_PACKAGE, context.packageName)
return "${sourcePackage}.microg.settings"
}
- fun getAuthorityUri(context: Context): Uri = Uri.parse("content://${getAuthority(context)}")
+
+ /**
+ * URI for preferences local to this profile
+ */
+ fun getAuthorityUri(context: Context) = "content://${getAuthority(context)}".toUri()
+
+ /* Cross-profile interactivity, granting access to same preferences across all profiles of a user:
+ * URI points to our `SettingsProvider` on normal profile and is supposed to point to
+ * _primary_ profile's `SettingsProvider` work / managed profile. If this is not yet established,
+ * we need to start the `CrossProfileRequestActivity`, which asks `CrossProfileSendActivity` to
+ * send it a URI that entitles it to access the primary profile's settings. (This would normally
+ * happen while creating the profile from `UserInitReceiver`.)
+ */
+ fun getCrossProfileSharedAuthorityUri(context: Context): Uri {
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ Log.v(TAG, "cross-profile interactivity not possible on this Android version")
+ return "content://${getAuthority(context)}".toUri()
+ }
+
+ val userManager = context.getSystemService(UserManager::class.java)
+ val workProfile = userManager.isManagedProfile
+
+ if (!workProfile) {
+ return "content://${getAuthority(context)}".toUri()
+ }
+
+ /* Check special shared preferences file if it contains a URI that permits us to access
+ * main profile's settings content provider
+ */
+ val preferences = context.getSharedPreferences(CROSS_PROFILE_SHARED_PREFERENCES_NAME, MODE_PRIVATE)
+ if (preferences.contains(CROSS_PROFILE_PERMISSION)) {
+ Log.v(TAG, "using work profile stored URI")
+ return preferences.getString(CROSS_PROFILE_PERMISSION, null)!!.toUri()
+ }
+
+ val crossProfileApps = context.getSystemService(CrossProfileApps::class.java)
+ val targetProfiles = crossProfileApps.targetUserProfiles
+
+ if (!crossProfileApps.canInteractAcrossProfiles() || targetProfiles.isEmpty()) {
+ Log.w(TAG, "prerequisites for cross-profile interactivity not met: " +
+ "can interact = ${crossProfileApps.canInteractAcrossProfiles()}, " +
+ "#targetProfiles = ${targetProfiles.size}")
+ return "content://${getAuthority(context)}".toUri()
+ } else {
+
+ Log.d(TAG, "Initiating activity to request storage URI from main profile")
+ context.startActivity(Intent(context, CrossProfileRequestActivity::class.java).apply {
+ addFlags(FLAG_ACTIVITY_NEW_TASK)
+ })
+
+ // while proper response is not yet available, work on local data :(
+ return "content://${getAuthority(context)}".toUri()
+ }
+ }
object CheckIn {
- private const val id = "check-in"
- fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), id)
- fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$id"
+ const val ID = "check-in"
+ fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), ID)
+ fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$ID"
const val ENABLED = "checkin_enable_service"
const val ANDROID_ID = "androidId"
@@ -49,9 +121,9 @@ object SettingsContract {
}
object Gcm {
- private const val id = "gcm"
- fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), id)
- fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$id"
+ const val ID = "gcm"
+ fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), ID)
+ fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$ID"
const val FULL_LOG = "gcm_full_log"
const val LAST_PERSISTENT_ID = "gcm_last_persistent_id"
@@ -83,9 +155,9 @@ object SettingsContract {
}
object Auth {
- private const val id = "auth"
- fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), id)
- fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$id"
+ const val ID = "auth"
+ fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), ID)
+ fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$ID"
const val TRUST_GOOGLE = "auth_manager_trust_google"
const val VISIBLE = "auth_manager_visible"
@@ -101,9 +173,9 @@ object SettingsContract {
}
object Exposure {
- private const val id = "exposureNotification"
- fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), id)
- fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$id"
+ const val ID = "exposureNotification"
+ fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), ID)
+ fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$ID"
const val SCANNER_ENABLED = "exposure_scanner_enabled"
const val LAST_CLEANUP = "exposure_last_cleanup"
@@ -115,9 +187,9 @@ object SettingsContract {
}
object SafetyNet {
- private const val id = "safety-net"
- fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), id)
- fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$id"
+ const val ID = "safety-net"
+ fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), ID)
+ fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$ID"
const val ENABLED = "safetynet_enabled"
@@ -127,9 +199,9 @@ object SettingsContract {
}
object DroidGuard {
- private const val id = "droidguard"
- fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), id)
- fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$id"
+ const val ID = "droidguard"
+ fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), ID)
+ fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$ID"
const val ENABLED = "droidguard_enabled"
const val MODE = "droidguard_mode"
@@ -145,9 +217,9 @@ object SettingsContract {
}
object Profile {
- private const val id = "profile"
- fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), id)
- fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$id"
+ const val ID = "profile"
+ fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), ID)
+ fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$ID"
const val PROFILE = "device_profile"
const val SERIAL = "device_profile_serial"
@@ -159,9 +231,9 @@ object SettingsContract {
}
object Location {
- private const val id = "location"
- fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), id)
- fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$id"
+ const val ID = "location"
+ fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), ID)
+ fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$ID"
const val WIFI_ICHNAEA = "location_wifi_mls"
const val WIFI_MOVING = "location_wifi_moving"
@@ -191,12 +263,13 @@ object SettingsContract {
}
object Vending {
- private const val id = "vending"
- fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), id)
- fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$id"
+ const val ID = "vending"
+ fun getContentUri(context: Context) = Uri.withAppendedPath(getAuthorityUri(context), ID)
+ fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$ID"
const val LICENSING = "vending_licensing"
const val LICENSING_PURCHASE_FREE_APPS = "vending_licensing_purchase_free_apps"
+ const val SPLIT_INSTALL = "vending_split_install"
const val BILLING = "vending_billing"
const val ASSET_DELIVERY = "vending_asset_delivery"
const val ASSET_DEVICE_SYNC = "vending_device_sync"
@@ -204,12 +277,25 @@ object SettingsContract {
val PROJECTION = arrayOf(
LICENSING,
LICENSING_PURCHASE_FREE_APPS,
+ SPLIT_INSTALL,
BILLING,
ASSET_DELIVERY,
ASSET_DEVICE_SYNC,
)
}
+ object WorkProfile {
+ const val ID = "workprofile"
+ fun getContentUri(context: Context) = Uri.withAppendedPath(getCrossProfileSharedAuthorityUri(context), ID)
+ fun getContentType(context: Context) = "vnd.android.cursor.item/vnd.${getAuthority(context)}.$ID"
+
+ const val CREATE_WORK_ACCOUNT = "workprofile_allow_create_work_account"
+
+ val PROJECTION = arrayOf(
+ CREATE_WORK_ACCOUNT
+ )
+ }
+
private fun withoutCallingIdentity(f: () -> T): T {
val identity = Binder.clearCallingIdentity()
try {
diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsProvider.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsProvider.kt
index 96dab1b602..77f98ff2aa 100644
--- a/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsProvider.kt
+++ b/play-services-base/core/src/main/kotlin/org/microg/gms/settings/SettingsProvider.kt
@@ -26,6 +26,7 @@ import org.microg.gms.settings.SettingsContract.Location
import org.microg.gms.settings.SettingsContract.Profile
import org.microg.gms.settings.SettingsContract.SafetyNet
import org.microg.gms.settings.SettingsContract.Vending
+import org.microg.gms.settings.SettingsContract.WorkProfile
import org.microg.gms.settings.SettingsContract.getAuthority
import java.io.File
@@ -72,16 +73,17 @@ class SettingsProvider : ContentProvider() {
selection: String?,
selectionArgs: Array?,
sortOrder: String?
- ): Cursor? = when (uri) {
- CheckIn.getContentUri(context!!) -> queryCheckIn(projection ?: CheckIn.PROJECTION)
- Gcm.getContentUri(context!!) -> queryGcm(projection ?: Gcm.PROJECTION)
- Auth.getContentUri(context!!) -> queryAuth(projection ?: Auth.PROJECTION)
- Exposure.getContentUri(context!!) -> queryExposure(projection ?: Exposure.PROJECTION)
- SafetyNet.getContentUri(context!!) -> querySafetyNet(projection ?: SafetyNet.PROJECTION)
- DroidGuard.getContentUri(context!!) -> queryDroidGuard(projection ?: DroidGuard.PROJECTION)
- Profile.getContentUri(context!!) -> queryProfile(projection ?: Profile.PROJECTION)
- Location.getContentUri(context!!) -> queryLocation(projection ?: Location.PROJECTION)
- Vending.getContentUri(context!!) -> queryVending(projection ?: Vending.PROJECTION)
+ ): Cursor? = when (uri.pathSegments.last()) {
+ CheckIn.ID -> queryCheckIn(projection ?: CheckIn.PROJECTION)
+ Gcm.ID -> queryGcm(projection ?: Gcm.PROJECTION)
+ Auth.ID -> queryAuth(projection ?: Auth.PROJECTION)
+ Exposure.ID -> queryExposure(projection ?: Exposure.PROJECTION)
+ SafetyNet.ID -> querySafetyNet(projection ?: SafetyNet.PROJECTION)
+ DroidGuard.ID -> queryDroidGuard(projection ?: DroidGuard.PROJECTION)
+ Profile.ID -> queryProfile(projection ?: Profile.PROJECTION)
+ Location.ID -> queryLocation(projection ?: Location.PROJECTION)
+ Vending.ID -> queryVending(projection ?: Vending.PROJECTION)
+ WorkProfile.ID -> queryWorkProfile(projection ?: WorkProfile.PROJECTION)
else -> null
}
@@ -93,16 +95,17 @@ class SettingsProvider : ContentProvider() {
): Int {
warnIfNotMainProcess(context, this.javaClass)
if (values == null) return 0
- when (uri) {
- CheckIn.getContentUri(context!!) -> updateCheckIn(values)
- Gcm.getContentUri(context!!) -> updateGcm(values)
- Auth.getContentUri(context!!) -> updateAuth(values)
- Exposure.getContentUri(context!!) -> updateExposure(values)
- SafetyNet.getContentUri(context!!) -> updateSafetyNet(values)
- DroidGuard.getContentUri(context!!) -> updateDroidGuard(values)
- Profile.getContentUri(context!!) -> updateProfile(values)
- Location.getContentUri(context!!) -> updateLocation(values)
- Vending.getContentUri(context!!) -> updateVending(values)
+ when (uri.pathSegments.last()) {
+ CheckIn.ID -> updateCheckIn(values)
+ Gcm.ID -> updateGcm(values)
+ Auth.ID -> updateAuth(values)
+ Exposure.ID -> updateExposure(values)
+ SafetyNet.ID -> updateSafetyNet(values)
+ DroidGuard.ID -> updateDroidGuard(values)
+ Profile.ID -> updateProfile(values)
+ Location.ID -> updateLocation(values)
+ Vending.ID -> updateVending(values)
+ WorkProfile.ID -> updateWorkProfile(values)
else -> return 0
}
return 1
@@ -357,6 +360,7 @@ class SettingsProvider : ContentProvider() {
Vending.BILLING -> getSettingsBoolean(key, false)
Vending.ASSET_DELIVERY -> getSettingsBoolean(key, false)
Vending.ASSET_DEVICE_SYNC -> getSettingsBoolean(key, false)
+ Vending.SPLIT_INSTALL -> getSettingsBoolean(key, false)
else -> throw IllegalArgumentException("Unknown key: $key")
}
}
@@ -369,6 +373,7 @@ class SettingsProvider : ContentProvider() {
Vending.LICENSING -> editor.putBoolean(key, value as Boolean)
Vending.LICENSING_PURCHASE_FREE_APPS -> editor.putBoolean(key, value as Boolean)
Vending.BILLING -> editor.putBoolean(key, value as Boolean)
+ Vending.SPLIT_INSTALL -> editor.putBoolean(key, value as Boolean)
Vending.ASSET_DELIVERY -> editor.putBoolean(key, value as Boolean)
Vending.ASSET_DEVICE_SYNC -> editor.putBoolean(key, value as Boolean)
else -> throw IllegalArgumentException("Unknown key: $key")
@@ -377,6 +382,25 @@ class SettingsProvider : ContentProvider() {
editor.apply()
}
+ private fun queryWorkProfile(p: Array): Cursor = MatrixCursor(p).addRow(p) { key ->
+ when (key) {
+ WorkProfile.CREATE_WORK_ACCOUNT -> getSettingsBoolean(key, false)
+ else -> throw IllegalArgumentException("Unknown key: $key")
+ }
+ }
+
+ private fun updateWorkProfile(values: ContentValues) {
+ if (values.size() == 0) return
+ val editor = preferences.edit()
+ values.valueSet().forEach { (key, value) ->
+ when (key) {
+ WorkProfile.CREATE_WORK_ACCOUNT -> editor.putBoolean(key, value as Boolean)
+ else -> throw IllegalArgumentException("Unknown key: $key")
+ }
+ }
+ editor.apply()
+ }
+
private fun MatrixCursor.addRow(
p: Array,
valueGetter: (String) -> Any?
diff --git a/play-services-basement/src/main/java/org/microg/gms/auth/AuthConstants.java b/play-services-basement/src/main/java/org/microg/gms/auth/AuthConstants.java
index 7de65dafd0..1fa905322b 100644
--- a/play-services-basement/src/main/java/org/microg/gms/auth/AuthConstants.java
+++ b/play-services-basement/src/main/java/org/microg/gms/auth/AuthConstants.java
@@ -24,6 +24,11 @@ public class AuthConstants {
public static final String PROVIDER_EXTRA_CLEAR_PASSWORD = "clear_password";
public static final String PROVIDER_EXTRA_ACCOUNTS = "accounts";
public static final String DEFAULT_ACCOUNT_TYPE = "com.google";
+ public static final String WORK_ACCOUNT_TYPE = "com.google.work";
+
+ public static final String KEY_ACCOUNT_SERVICES = "services";
+ public static final String KEY_ACCOUNT_CAPABILITIES = "capabilities";
+
public static final String GOOGLE_USER_ID = "GoogleUserId";
public static final String GOOGLE_SIGN_IN_STATUS = "googleSignInStatus";
public static final String GOOGLE_SIGN_IN_ACCOUNT = "googleSignInAccount";
diff --git a/play-services-basement/src/main/java/org/microg/gms/common/GmsService.java b/play-services-basement/src/main/java/org/microg/gms/common/GmsService.java
index 364aa7c909..6b6c754cb2 100644
--- a/play-services-basement/src/main/java/org/microg/gms/common/GmsService.java
+++ b/play-services-basement/src/main/java/org/microg/gms/common/GmsService.java
@@ -102,7 +102,7 @@ public enum GmsService {
FIREBASE_AUTH(112, "com.google.firebase.auth.api.gms.service.START"),
APP_INDEXING(113),
GASS(116, "com.google.android.gms.gass.START"),
- WORK_ACCOUNT(120),
+ WORK_ACCOUNT(120, "com.google.android.gms.auth.account.workaccount.START"),
INSTANT_APPS(121, "com.google.android.gms.instantapps.START"),
CAST_FIRSTPATY(122, "com.google.android.gms.cast.firstparty.START"),
AD_CACHE(123, "com.google.android.gms.ads.service.CACHE"),
diff --git a/play-services-core-proto/src/main/proto/checkin.proto b/play-services-core-proto/src/main/proto/checkin.proto
index 2e0b75f311..96cf9b73cc 100644
--- a/play-services-core-proto/src/main/proto/checkin.proto
+++ b/play-services-core-proto/src/main/proto/checkin.proto
@@ -2,6 +2,8 @@ option java_package = "org.microg.gms.checkin";
option java_outer_classname = "CheckinProto";
+import "deviceconfig.proto";
+
// Sample data, if provided, is fished from a Nexus 7 (2013) / flo running Android 5.0
message CheckinRequest {
// unused
@@ -156,73 +158,6 @@ message CheckinRequest {
optional string esn = 17;
optional DeviceConfig deviceConfiguration = 18;
- message DeviceConfig {
- // ConfigurationInfo.reqTouchScreen
- // eg. 3
- optional int32 touchScreen = 1;
-
- // ConfigurationInfo.reqKeyboardType
- // eg. 1
- optional int32 keyboardType = 2;
-
- // ConfigurationInfo.reqNavigation
- // eg. 1
- optional int32 navigation = 3;
- // ConfigurationInfo.screenLayout
- // eg. 3
- optional int32 screenLayout = 4;
-
- // ConfigurationInfo.reqInputFeatures & ConfigurationInfo.INPUT_FEATURE_HARD_KEYBOARD
- // eg. 0
- optional bool hasHardKeyboard = 5;
-
- // ConfigurationInfo.reqInputFeatures & ConfigurationInfo.INPUT_FEATURE_FIVE_WAY_NAV
- // eg. 0
- optional bool hasFiveWayNavigation = 6;
-
- // DisplayMetrics.densityDpi
- // eg. 320
- optional int32 densityDpi = 7;
-
- // ConfigurationInfo.reqGlEsVersion
- // eg. 196608
- optional int32 glEsVersion = 8;
-
- // PackageManager.getSystemSharedLibraryNames
- // eg. "android.test.runner", "com.android.future.usb.accessory", "com.android.location.provider",
- // "com.android.media.remotedisplay", "com.android.mediadrm.signer", "com.google.android.maps",
- // "com.google.android.media.effects", "com.google.widevine.software.drm", "javax.obex"
- repeated string sharedLibrary = 9;
-
- // PackageManager.getSystemAvailableFeatures
- // eg. android.hardware.[...]
- repeated string availableFeature = 10;
-
- // Build.CPU_ABI and Build.CPU_ABI2 != "unknown"
- // eg. "armeabi-v7a", "armeabi"
- repeated string nativePlatform = 11;
-
- // DisplayMetrics.widthPixels
- // eg. 1200
- optional int32 widthPixels = 12;
-
- // DisplayMetrics.heightPixels
- // eg. 1824
- optional int32 heightPixels = 13;
-
- // Context.getAssets.getLocales
- // eg. [...], "en-US", [...]
- repeated string locale = 14;
-
- // GLES10.glGetString(GLES10.GL_EXTENSIONS)
- // eg. "GL_AMD_compressed_ATC_texture", [...]
- repeated string glExtension = 15;
-
- // unused
- optional int32 deviceClass = 16;
- // unused
- optional int32 maxApkDownloadSizeMb = 17;
- }
// "ethernet" or "wifi"
repeated string macAddressType = 19;
diff --git a/play-services-core-proto/src/main/proto/deviceconfig.proto b/play-services-core-proto/src/main/proto/deviceconfig.proto
new file mode 100644
index 0000000000..7b1954083f
--- /dev/null
+++ b/play-services-core-proto/src/main/proto/deviceconfig.proto
@@ -0,0 +1,76 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+option java_package = "org.microg.gms.checkin";
+option java_outer_classname = "DeviceConfig";
+
+
+message DeviceConfig {
+ // ConfigurationInfo.reqTouchScreen
+ // eg. 3
+ optional int32 touchScreen = 1;
+
+ // ConfigurationInfo.reqKeyboardType
+ // eg. 1
+ optional int32 keyboardType = 2;
+
+ // ConfigurationInfo.reqNavigation
+ // eg. 1
+ optional int32 navigation = 3;
+ // ConfigurationInfo.screenLayout
+ // eg. 3
+ optional int32 screenLayout = 4;
+
+ // ConfigurationInfo.reqInputFeatures & ConfigurationInfo.INPUT_FEATURE_HARD_KEYBOARD
+ // eg. 0
+ optional bool hasHardKeyboard = 5;
+
+ // ConfigurationInfo.reqInputFeatures & ConfigurationInfo.INPUT_FEATURE_FIVE_WAY_NAV
+ // eg. 0
+ optional bool hasFiveWayNavigation = 6;
+
+ // DisplayMetrics.densityDpi
+ // eg. 320
+ optional int32 densityDpi = 7;
+
+ // ConfigurationInfo.reqGlEsVersion
+ // eg. 196608
+ optional int32 glEsVersion = 8;
+
+ // PackageManager.getSystemSharedLibraryNames
+ // eg. "android.test.runner", "com.android.future.usb.accessory", "com.android.location.provider",
+ // "com.android.media.remotedisplay", "com.android.mediadrm.signer", "com.google.android.maps",
+ // "com.google.android.media.effects", "com.google.widevine.software.drm", "javax.obex"
+ repeated string sharedLibrary = 9;
+
+ // PackageManager.getSystemAvailableFeatures
+ // eg. android.hardware.[...]
+ repeated string availableFeature = 10;
+
+ // Build.CPU_ABI and Build.CPU_ABI2 != "unknown"
+ // eg. "armeabi-v7a", "armeabi"
+ repeated string nativePlatform = 11;
+
+ // DisplayMetrics.widthPixels
+ // eg. 1200
+ optional int32 widthPixels = 12;
+
+ // DisplayMetrics.heightPixels
+ // eg. 1824
+ optional int32 heightPixels = 13;
+
+ // Context.getAssets.getLocales
+ // eg. [...], "en-US", [...]
+ repeated string locale = 14;
+
+ // GLES10.glGetString(GLES10.GL_EXTENSIONS)
+ // eg. "GL_AMD_compressed_ATC_texture", [...]
+ repeated string glExtension = 15;
+
+ // unused
+ optional int32 deviceClass = 16;
+ // unused
+ optional int32 maxApkDownloadSizeMb = 17;
+}
diff --git a/play-services-core-proto/src/main/proto/uploaddeviceconfig.proto b/play-services-core-proto/src/main/proto/uploaddeviceconfig.proto
new file mode 100644
index 0000000000..58293ff09b
--- /dev/null
+++ b/play-services-core-proto/src/main/proto/uploaddeviceconfig.proto
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// This should be part of the vending package, but it is hard to import proto
+// files from other modules.
+
+option java_package = "org.microg.vending";
+option java_outer_classname = "UploadDeviceConfigRequest";
+
+import "deviceconfig.proto";
+
+message UploadDeviceConfigRequest {
+ optional DeviceConfig deviceConfiguration = 1;
+ optional string manufacturer = 2;
+ optional string gcmRegistrationId = 3;
+}
\ No newline at end of file
diff --git a/play-services-core/build.gradle b/play-services-core/build.gradle
index 2decd9038b..06e1dee7a5 100644
--- a/play-services-core/build.gradle
+++ b/play-services-core/build.gradle
@@ -26,6 +26,7 @@ dependencies {
implementation project(':play-services-appinvite-core')
implementation project(':play-services-appset-core')
implementation project(':play-services-auth-api-phone-core')
+ implementation project(':play-services-auth-workaccount-core')
implementation project(':play-services-base-core')
implementation project(':play-services-cast-core')
implementation project(':play-services-cast-framework-core')
diff --git a/play-services-core/microg-ui-tools/src/main/java/org/microg/tools/ui/AbstractSelfCheckFragment.java b/play-services-core/microg-ui-tools/src/main/java/org/microg/tools/ui/AbstractSelfCheckFragment.java
index 4a06c678f1..0ec36444ac 100644
--- a/play-services-core/microg-ui-tools/src/main/java/org/microg/tools/ui/AbstractSelfCheckFragment.java
+++ b/play-services-core/microg-ui-tools/src/main/java/org/microg/tools/ui/AbstractSelfCheckFragment.java
@@ -113,12 +113,11 @@ public boolean onTouch(View v, MotionEvent event) {
}
if (resolver != null) {
resultEntry.setClickable(true);
- resultEntry.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- resolver.tryResolve(AbstractSelfCheckFragment.this);
- }
- });
+ resultEntry.setOnClickListener(v ->
+ resolver.tryResolve(AbstractSelfCheckFragment.this)
+ );
+ } else {
+ resultEntry.findViewById(R.id.self_check_result).setEnabled(false);
}
}
viewGroup.addView(resultEntry);
diff --git a/play-services-core/microg-ui-tools/src/main/res/values/strings.xml b/play-services-core/microg-ui-tools/src/main/res/values/strings.xml
index e62b0f4af7..06d5ddbd7b 100644
--- a/play-services-core/microg-ui-tools/src/main/res/values/strings.xml
+++ b/play-services-core/microg-ui-tools/src/main/res/values/strings.xml
@@ -30,6 +30,7 @@
Permissions granted
Permission to %1$s:
+ Permission to interact with work profile:
Touch here to grant permission. Not granting the permission can result in misbehaving applications.
microG UI Demo
diff --git a/play-services-core/src/huawei/AndroidManifest.xml b/play-services-core/src/huawei/AndroidManifest.xml
index e39829b8a2..10eb1d71a0 100644
--- a/play-services-core/src/huawei/AndroidManifest.xml
+++ b/play-services-core/src/huawei/AndroidManifest.xml
@@ -43,5 +43,8 @@
+
\ No newline at end of file
diff --git a/play-services-core/src/main/java/org/microg/gms/auth/loginservice/AccountAuthenticator.java b/play-services-core/src/main/java/org/microg/gms/auth/loginservice/AccountAuthenticator.java
index 8dc989990b..09ea35bae8 100644
--- a/play-services-core/src/main/java/org/microg/gms/auth/loginservice/AccountAuthenticator.java
+++ b/play-services-core/src/main/java/org/microg/gms/auth/loginservice/AccountAuthenticator.java
@@ -27,13 +27,11 @@
import android.util.Base64;
import android.util.Log;
-import com.google.android.gms.R;
-
import com.google.android.gms.common.internal.CertData;
import org.microg.gms.auth.*;
import org.microg.gms.auth.login.LoginActivity;
import org.microg.gms.common.PackageUtils;
-import org.microg.gms.utils.ExtendedPackageInfo;
+import org.microg.gms.auth.AuthResponse;
import org.microg.gms.utils.PackageManagerUtilsKt;
import java.util.Arrays;
diff --git a/play-services-core/src/main/java/org/microg/gms/checkin/CheckinClient.java b/play-services-core/src/main/java/org/microg/gms/checkin/CheckinClient.java
index 2374d25d6e..e101907d31 100644
--- a/play-services-core/src/main/java/org/microg/gms/checkin/CheckinClient.java
+++ b/play-services-core/src/main/java/org/microg/gms/checkin/CheckinClient.java
@@ -19,6 +19,7 @@
import android.content.Context;
import android.util.Log;
+import org.microg.gms.common.DeviceConfigProtoKt;
import org.microg.gms.common.DeviceConfiguration;
import org.microg.gms.common.DeviceIdentifier;
import org.microg.gms.common.PhoneInfo;
@@ -116,23 +117,7 @@ public static CheckinRequest makeRequest(Context context, DeviceConfiguration de
.stat(TODO_LIST_CHECKIN)
.userNumber(0)
.build())
- .deviceConfiguration(new CheckinRequest.DeviceConfig.Builder()
- .availableFeature(deviceConfiguration.availableFeatures)
- .densityDpi(deviceConfiguration.densityDpi)
- .glEsVersion(deviceConfiguration.glEsVersion)
- .glExtension(deviceConfiguration.glExtensions)
- .hasFiveWayNavigation(deviceConfiguration.hasFiveWayNavigation)
- .hasHardKeyboard(deviceConfiguration.hasHardKeyboard)
- .heightPixels(deviceConfiguration.heightPixels)
- .keyboardType(deviceConfiguration.keyboardType)
- .locale(deviceConfiguration.locales)
- .nativePlatform(deviceConfiguration.nativePlatforms)
- .navigation(deviceConfiguration.navigation)
- .screenLayout(deviceConfiguration.screenLayout & 0xF)
- .sharedLibrary(deviceConfiguration.sharedLibraries)
- .touchScreen(deviceConfiguration.touchScreen)
- .widthPixels(deviceConfiguration.widthPixels)
- .build())
+ .deviceConfiguration(DeviceConfigProtoKt.asProto(deviceConfiguration))
.digest(checkinInfo.getDigest())
.esn(deviceIdent.esn)
.fragment(0)
diff --git a/play-services-core/src/main/java/org/microg/gms/checkin/CheckinService.java b/play-services-core/src/main/java/org/microg/gms/checkin/CheckinService.java
index a349fa3c88..44c503f156 100644
--- a/play-services-core/src/main/java/org/microg/gms/checkin/CheckinService.java
+++ b/play-services-core/src/main/java/org/microg/gms/checkin/CheckinService.java
@@ -33,7 +33,6 @@
import androidx.core.app.PendingIntentCompat;
import androidx.legacy.content.WakefulBroadcastReceiver;
-import com.google.android.gms.R;
import com.google.android.gms.checkin.internal.ICheckinService;
import org.microg.gms.auth.AuthConstants;
diff --git a/play-services-core/src/main/java/org/microg/gms/gcm/PushRegisterManager.java b/play-services-core/src/main/java/org/microg/gms/gcm/PushRegisterManager.java
index cd55aff016..26ad22b654 100644
--- a/play-services-core/src/main/java/org/microg/gms/gcm/PushRegisterManager.java
+++ b/play-services-core/src/main/java/org/microg/gms/gcm/PushRegisterManager.java
@@ -22,8 +22,6 @@
import org.microg.gms.checkin.LastCheckinInfo;
import org.microg.gms.common.HttpFormClient;
-import org.microg.gms.common.PackageUtils;
-import org.microg.gms.common.Utils;
import org.microg.gms.utils.ExtendedPackageInfo;
import java.io.IOException;
diff --git a/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java b/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java
index 917dfa9e44..34f38c6790 100644
--- a/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java
+++ b/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java
@@ -18,8 +18,8 @@
import android.content.Context;
import android.content.Intent;
+import android.content.pm.CrossProfileApps;
import android.content.pm.PackageManager;
-import android.content.pm.PermissionGroupInfo;
import android.content.pm.PermissionInfo;
import android.net.Uri;
import android.os.Build;
@@ -52,11 +52,9 @@
import static android.Manifest.permission.POST_NOTIFICATIONS;
import static android.Manifest.permission.READ_EXTERNAL_STORAGE;
import static android.Manifest.permission.READ_PHONE_STATE;
-import static android.Manifest.permission.READ_SMS;
import static android.Manifest.permission.RECEIVE_SMS;
import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE;
import static android.os.Build.VERSION.SDK_INT;
-import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
public class SelfCheckFragment extends AbstractSelfCheckFragment {
@@ -86,6 +84,7 @@ protected void prepareSelfCheckList(Context context, List checks
public void doChecks(Context context, ResultCollector collector) {
super.doChecks(context, collector);
PackageManager pm = context.getPackageManager();
+ // Add SYSTEM_ALERT_WINDOW appops permission
try {
PermissionInfo info = pm.getPermissionInfo("android.permission.SYSTEM_ALERT_WINDOW", 0);
CharSequence permLabel = info.loadLabel(pm);
@@ -101,6 +100,22 @@ public void doChecks(Context context, ResultCollector collector) {
} catch (Exception e) {
Log.w("SelfCheckPerms", e);
}
+ // Add INTERACT_ACROSS_PROFILES appop permission (INTERACT_ACROSS_USERS is superior)
+ if (SDK_INT >= Build.VERSION_CODES.R) try {
+ CrossProfileApps crossProfile = context.getSystemService(CrossProfileApps.class);
+ collector.addResult(
+ context.getString(org.microg.tools.ui.R.string.self_check_name_permission_interact_across_profiles),
+ context.checkSelfPermission("android.permission.INTERACT_ACROSS_USERS") == PackageManager.PERMISSION_GRANTED
+ || crossProfile.canInteractAcrossProfiles() ? Result.Positive : Result.Negative,
+ context.getString(org.microg.tools.ui.R.string.self_check_resolution_permission),
+ crossProfile.canRequestInteractAcrossProfiles() ? fragment -> {
+ Intent intent = crossProfile.createRequestInteractAcrossProfilesIntent();
+ startActivityForResult(intent, 43);
+ } : null
+ );
+ } catch (Exception e) {
+ Log.w("SelfCheckPerms", e);
+ }
}
});
}
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/checkin/LastCheckinInfo.kt b/play-services-core/src/main/kotlin/org/microg/gms/checkin/LastCheckinInfo.kt
deleted file mode 100644
index ab2dc0422e..0000000000
--- a/play-services-core/src/main/kotlin/org/microg/gms/checkin/LastCheckinInfo.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * Copyright (C) 2013-2017 microG Project Team
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.microg.gms.checkin
-
-import android.content.Context
-import org.microg.gms.settings.SettingsContract
-import org.microg.gms.settings.SettingsContract.CheckIn
-
-data class LastCheckinInfo(
- val lastCheckin: Long,
- val androidId: Long,
- val securityToken: Long,
- val digest: String,
- val versionInfo: String,
- val deviceDataVersionInfo: String,
-) {
-
- constructor(r: CheckinResponse) : this(
- lastCheckin = r.timeMs ?: 0L,
- androidId = r.androidId ?: 0L,
- securityToken = r.securityToken ?: 0L,
- digest = r.digest ?: CheckIn.INITIAL_DIGEST,
- versionInfo = r.versionInfo ?: "",
- deviceDataVersionInfo = r.deviceDataVersionInfo ?: "",
- )
-
- companion object {
- @JvmStatic
- fun read(context: Context): LastCheckinInfo {
- val projection = arrayOf(
- CheckIn.ANDROID_ID,
- CheckIn.DIGEST,
- CheckIn.LAST_CHECK_IN,
- CheckIn.SECURITY_TOKEN,
- CheckIn.VERSION_INFO,
- CheckIn.DEVICE_DATA_VERSION_INFO,
- )
- return SettingsContract.getSettings(context, CheckIn.getContentUri(context), projection) { c ->
- LastCheckinInfo(
- androidId = c.getLong(0),
- digest = c.getString(1),
- lastCheckin = c.getLong(2),
- securityToken = c.getLong(3),
- versionInfo = c.getString(4),
- deviceDataVersionInfo = c.getString(5),
- )
- }
- }
-
- @JvmStatic
- fun clear(context: Context) = SettingsContract.setSettings(context, CheckIn.getContentUri(context)) {
- put(CheckIn.ANDROID_ID, 0L)
- put(CheckIn.DIGEST, CheckIn.INITIAL_DIGEST)
- put(CheckIn.LAST_CHECK_IN, 0L)
- put(CheckIn.SECURITY_TOKEN, 0L)
- put(CheckIn.VERSION_INFO, "")
- put(CheckIn.DEVICE_DATA_VERSION_INFO, "")
- }
- }
-
- fun write(context: Context) = SettingsContract.setSettings(context, CheckIn.getContentUri(context)) {
- put(CheckIn.ANDROID_ID, androidId)
- put(CheckIn.DIGEST, digest)
- put(CheckIn.LAST_CHECK_IN, lastCheckin)
- put(CheckIn.SECURITY_TOKEN, securityToken)
- put(CheckIn.VERSION_INFO, versionInfo)
- put(CheckIn.DEVICE_DATA_VERSION_INFO, deviceDataVersionInfo)
- }
-}
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/SettingsFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/SettingsFragment.kt
index 529e10371b..70335535ce 100644
--- a/play-services-core/src/main/kotlin/org/microg/gms/ui/SettingsFragment.kt
+++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/SettingsFragment.kt
@@ -15,7 +15,6 @@ import com.google.android.gms.R
import org.microg.gms.checkin.CheckinPreferences
import org.microg.gms.gcm.GcmDatabase
import org.microg.gms.gcm.GcmPrefs
-import org.microg.gms.vending.VendingPreferences
import org.microg.gms.safetynet.SafetyNetPreferences
import org.microg.gms.ui.settings.SettingsProvider
import org.microg.gms.ui.settings.getAllSettingsProviders
@@ -51,6 +50,10 @@ class SettingsFragment : ResourceSettingsFragment() {
findNavController().navigate(requireContext(), R.id.openVendingSettings)
true
}
+ findPreference(PREF_WORK_PROFILE)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener {
+ findNavController().navigate(requireContext(), R.id.openWorkProfileSettings)
+ true
+ }
findPreference(PREF_ABOUT)!!.apply {
onPreferenceClickListener = Preference.OnPreferenceClickListener {
@@ -134,6 +137,7 @@ class SettingsFragment : ResourceSettingsFragment() {
const val PREF_LOCATION = "pref_location"
const val PREF_CHECKIN = "pref_checkin"
const val PREF_VENDING = "pref_vending"
+ const val PREF_WORK_PROFILE = "pref_work_profile"
const val PREF_ACCOUNTS = "pref_accounts"
}
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/VendingFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/VendingFragment.kt
index 6f81909840..46faa4b4f4 100644
--- a/play-services-core/src/main/kotlin/org/microg/gms/ui/VendingFragment.kt
+++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/VendingFragment.kt
@@ -17,6 +17,7 @@ import org.microg.gms.vending.VendingPreferences
class VendingFragment : PreferenceFragmentCompat() {
private lateinit var licensingEnabled: TwoStatePreference
private lateinit var licensingPurchaseFreeAppsEnabled: TwoStatePreference
+ private lateinit var licensingSplitInstallEnabled: TwoStatePreference
private lateinit var iapEnable: TwoStatePreference
private lateinit var assetDeliveryEnabled: TwoStatePreference
private lateinit var deviceSyncEnabled: TwoStatePreference
@@ -51,6 +52,18 @@ class VendingFragment : PreferenceFragmentCompat() {
true
}
+ licensingSplitInstallEnabled = preferenceScreen.findPreference(PREF_SPLIT_INSTALL_ENABLED) ?: licensingSplitInstallEnabled
+ licensingSplitInstallEnabled.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
+ val appContext = requireContext().applicationContext
+ lifecycleScope.launchWhenResumed {
+ if (newValue is Boolean) {
+ VendingPreferences.setSplitInstallEnabled(appContext, newValue)
+ }
+ updateContent()
+ }
+ true
+ }
+
iapEnable = preferenceScreen.findPreference(PREF_IAP_ENABLED) ?: iapEnable
iapEnable.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val appContext = requireContext().applicationContext
@@ -98,6 +111,7 @@ class VendingFragment : PreferenceFragmentCompat() {
lifecycleScope.launchWhenResumed {
licensingEnabled.isChecked = VendingPreferences.isLicensingEnabled(appContext)
licensingPurchaseFreeAppsEnabled.isChecked = VendingPreferences.isLicensingPurchaseFreeAppsEnabled(appContext)
+ licensingSplitInstallEnabled.isChecked = VendingPreferences.isLicensingSplitInstallEnabled(appContext)
iapEnable.isChecked = VendingPreferences.isBillingEnabled(appContext)
assetDeliveryEnabled.isChecked = VendingPreferences.isAssetDeliveryEnabled(appContext)
deviceSyncEnabled.isChecked = VendingPreferences.isDeviceSyncEnabled(appContext)
@@ -107,6 +121,7 @@ class VendingFragment : PreferenceFragmentCompat() {
companion object {
const val PREF_LICENSING_ENABLED = "vending_licensing"
const val PREF_LICENSING_PURCHASE_FREE_APPS_ENABLED = "vending_licensing_purchase_free_apps"
+ const val PREF_SPLIT_INSTALL_ENABLED = "vending_split_install"
const val PREF_IAP_ENABLED = "vending_iap"
const val PREF_ASSET_DELIVERY_ENABLED = "vending_asset_delivery"
const val PREF_DEVICE_SYNC_ENABLED = "vending_device_sync"
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/WorkProfileFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/WorkProfileFragment.kt
new file mode 100644
index 0000000000..50d1ab1bbe
--- /dev/null
+++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/WorkProfileFragment.kt
@@ -0,0 +1,58 @@
+/*
+ * SPDX-FileCopyrightText: 2023, e Foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.ui
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.Preference
+import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.TwoStatePreference
+import com.google.android.gms.R
+import org.microg.gms.vending.VendingPreferences
+import org.microg.gms.workprofile.WorkProfilePreferences
+
+class WorkProfileFragment : PreferenceFragmentCompat() {
+ private lateinit var workProfileEnabled: SwitchBarPreference
+
+ private lateinit var workProfilePreferences: WorkProfilePreferences
+
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ addPreferencesFromResource(R.xml.preferences_work_profile)
+ }
+
+ @SuppressLint("RestrictedApi")
+ override fun onBindPreferences() {
+
+ workProfilePreferences = WorkProfilePreferences(requireContext().applicationContext)
+
+ workProfileEnabled = preferenceScreen.findPreference(PREF_CREATE_ACCOUNT) ?: workProfileEnabled
+ workProfileEnabled.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
+ lifecycleScope.launchWhenResumed {
+ if (newValue is Boolean) {
+ workProfilePreferences.allowCreateWorkAccount = newValue
+ }
+ updateContent()
+ }
+ true
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ updateContent()
+ }
+
+ private fun updateContent() {
+ lifecycleScope.launchWhenResumed {
+ workProfileEnabled.isChecked = workProfilePreferences.allowCreateWorkAccount
+ }
+ }
+
+ companion object {
+ const val PREF_CREATE_ACCOUNT = "workprofile_allow_create_work_account"
+ }
+}
\ No newline at end of file
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/vending/VendingPreferences.kt b/play-services-core/src/main/kotlin/org/microg/gms/vending/VendingPreferences.kt
index 6cf38827d2..07f21c50e0 100644
--- a/play-services-core/src/main/kotlin/org/microg/gms/vending/VendingPreferences.kt
+++ b/play-services-core/src/main/kotlin/org/microg/gms/vending/VendingPreferences.kt
@@ -40,6 +40,21 @@ object VendingPreferences {
}
}
+ @JvmStatic
+ fun isLicensingSplitInstallEnabled(context: Context): Boolean {
+ val projection = arrayOf(SettingsContract.Vending.SPLIT_INSTALL)
+ return SettingsContract.getSettings(context, SettingsContract.Vending.getContentUri(context), projection) { c ->
+ c.getInt(0) != 0
+ }
+ }
+
+ @JvmStatic
+ fun setSplitInstallEnabled(context: Context, enabled: Boolean) {
+ SettingsContract.setSettings(context, SettingsContract.Vending.getContentUri(context)) {
+ put(SettingsContract.Vending.SPLIT_INSTALL, enabled)
+ }
+ }
+
@JvmStatic
fun isBillingEnabled(context: Context): Boolean {
val projection = arrayOf(SettingsContract.Vending.BILLING)
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/workprofile/WorkProfilePreferences.kt b/play-services-core/src/main/kotlin/org/microg/gms/workprofile/WorkProfilePreferences.kt
new file mode 100644
index 0000000000..5047e33428
--- /dev/null
+++ b/play-services-core/src/main/kotlin/org/microg/gms/workprofile/WorkProfilePreferences.kt
@@ -0,0 +1,30 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.workprofile
+
+import android.content.ContentValues
+import android.content.Context
+import android.database.Cursor
+import org.microg.gms.settings.SettingsContract
+import org.microg.gms.settings.SettingsContract.CheckIn
+
+class WorkProfilePreferences(private val context: Context) {
+ private fun getSettings(vararg projection: String, f: (Cursor) -> T): T =
+ SettingsContract.getSettings(
+ context,
+ SettingsContract.WorkProfile.getContentUri(context),
+ projection,
+ f
+ )
+
+ private fun setSettings(v: ContentValues.() -> Unit) =
+ SettingsContract.setSettings(context, SettingsContract.WorkProfile.getContentUri(context), v)
+
+ var allowCreateWorkAccount: Boolean
+ get() = getSettings(SettingsContract.WorkProfile.CREATE_WORK_ACCOUNT) { c -> c.getInt(0) != 0 }
+ set(value) = setSettings { put(SettingsContract.WorkProfile.CREATE_WORK_ACCOUNT, value) }
+
+}
\ No newline at end of file
diff --git a/play-services-core/src/main/res/drawable/ic_work.xml b/play-services-core/src/main/res/drawable/ic_work.xml
new file mode 100644
index 0000000000..75bd9dd220
--- /dev/null
+++ b/play-services-core/src/main/res/drawable/ic_work.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/play-services-core/src/main/res/navigation/nav_settings.xml b/play-services-core/src/main/res/navigation/nav_settings.xml
index 69e6927cde..d3a8235b59 100644
--- a/play-services-core/src/main/res/navigation/nav_settings.xml
+++ b/play-services-core/src/main/res/navigation/nav_settings.xml
@@ -26,6 +26,9 @@
+
@@ -158,6 +161,13 @@
android:name="org.microg.gms.ui.VendingFragment"
android:label="@string/service_name_vending" />
+
+
+
+
الشبكات المستخدمة لإشعارات الدفع
خدمات مايكرو-جي المحدودة
تنبيهات حساب جوجل
-
+ تمكين تنزيل ميزات معينة للتطبيق عند الطلب.
+ باستخدام التثبيت المجزأ، سيقوم التطبيق بتنزيل وحدات الميزات المحددة بناءً على الاستخدام الحالي.
+
\ No newline at end of file
diff --git a/play-services-core/src/main/res/values-be/strings.xml b/play-services-core/src/main/res/values-be/strings.xml
index 582259ca62..b47ad7182b 100644
--- a/play-services-core/src/main/res/values-be/strings.xml
+++ b/play-services-core/src/main/res/values-be/strings.xml
@@ -246,4 +246,6 @@
%1$s спрабуе атрымаць доступ да ўліковага запісу пад выглядам %2$s ад %3$s. Гэта дасць прывілеяваны доступ да вашага ўліковага запісу.
Аўтаматычна дабаўляць бясплатныя прыкладанні ў бібліятэку
Бясплатныя прыкладанні могуць правяраць, ці загружаліся яны з Google Play. Аўтаматычнае даданне бясплатных дадаткаў у бібліятэку вашага акаўнта дазваляе заўсёды праходзіць праверку для ўсіх даступных вам бясплатных прыкладанняў.
-
+ Уключыць спампоўванне пэўных функцый прыкладання па патрабаванні.
+ Выкарыстоўваючы падзеленую ўстаноўку, прыкладанне будзе спампоўваць пэўныя модулі функцый у залежнасці ад бягучага выкарыстання.
+
\ No newline at end of file
diff --git a/play-services-core/src/main/res/values-zh-rCN/strings.xml b/play-services-core/src/main/res/values-zh-rCN/strings.xml
index a1c86cc0e0..89497a9a42 100644
--- a/play-services-core/src/main/res/values-zh-rCN/strings.xml
+++ b/play-services-core/src/main/res/values-zh-rCN/strings.xml
@@ -288,4 +288,6 @@ microG GmsCore 内置一套自由的 SafetyNet 实现,但是官方服务器要
启用按需资产传递
使用 Play 资产传递的应用会根据当前使用设备的信息下载额外的资产。
启用设备信息同步
-
+ 启用按需下载应用的某些功能
+ 使用分包安装,应用会根据当前使用情况下载特定功能模块
+
\ No newline at end of file
diff --git a/play-services-core/src/main/res/values/strings.xml b/play-services-core/src/main/res/values/strings.xml
index 56861446c0..1cd29cee4b 100644
--- a/play-services-core/src/main/res/values/strings.xml
+++ b/play-services-core/src/main/res/values/strings.xml
@@ -96,6 +96,7 @@ Please set up a password, PIN, or pattern lock screen."
Cloud Messaging
Google SafetyNet
Play Store services
+ Work profile
Google Play Games
%1$s would like to use Play Games
@@ -284,6 +285,9 @@ Please set up a password, PIN, or pattern lock screen."
Some apps require verification that you have purchased them on Google Play. When requested by an app, microG can download a proof of purchase from Google. If disabled, or if no Google account is added, requests for license verification are ignored.
Automatically add free apps to library
Free apps may check whether they had been downloaded from Google Play. Automatically add free apps to your account library to always pass the check for all free apps currently available to you.
+ Google Play Feature Delivery
+ On-demand component installation
+ Allow apps to download and install additional components
Feedback currently not possible
Backup currently not possible
@@ -300,6 +304,12 @@ Please set up a password, PIN, or pattern lock screen."
Applications using Play Asset Delivery will download additional assets based on the information of the device currently in use.
Enable device information sync
+ Allow work account setup
+ When setting up a work profile for your workplace or educational institution,
+ setup may attempt to connect to Google to enable downloading apps to that profile.
+ It is your responsibility to ensure that your usage of microG is in line with corporate policies.
+ microG is provided on a best-effort basis and cannot guarantee to behave exactly as expected.
+
Cancel
Continue
Signing you in
diff --git a/play-services-core/src/main/res/xml/preferences_start.xml b/play-services-core/src/main/res/xml/preferences_start.xml
index 532d0c1459..8d15cd0773 100644
--- a/play-services-core/src/main/res/xml/preferences_start.xml
+++ b/play-services-core/src/main/res/xml/preferences_start.xml
@@ -48,6 +48,10 @@
android:icon="@drawable/ic_shop"
android:key="pref_vending"
android:title="@string/service_name_vending" />
+
+
+
+
+
diff --git a/play-services-core/src/main/res/xml/preferences_work_profile.xml b/play-services-core/src/main/res/xml/preferences_work_profile.xml
new file mode 100644
index 0000000000..99d5cf14d1
--- /dev/null
+++ b/play-services-core/src/main/res/xml/preferences_work_profile.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/settings.gradle b/settings.gradle
index 5923a1560a..a4c85dafd1 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -28,6 +28,7 @@ include ':play-services-appinvite'
include ':play-services-appset'
include ':play-services-auth'
include ':play-services-auth-api-phone'
+include ':play-services-auth-workaccount'
include ':play-services-auth-base'
include ':play-services-base'
include ':play-services-basement'
@@ -81,6 +82,7 @@ sublude ':play-services-ads-lite:core'
sublude ':play-services-appinvite:core'
sublude ':play-services-appset:core'
sublude ':play-services-auth-api-phone:core'
+sublude ':play-services-auth-workaccount:core'
sublude ':play-services-base:core'
sublude ':play-services-base:core:package'
sublude ':play-services-cast:core'
diff --git a/vending-app/build.gradle b/vending-app/build.gradle
index d256e4f652..b0ba03685d 100644
--- a/vending-app/build.gradle
+++ b/vending-app/build.gradle
@@ -17,6 +17,8 @@ android {
versionCode vendingAppVersionCode
minSdkVersion androidMinSdk
targetSdkVersion androidTargetSdk
+
+ multiDexEnabled true
}
buildTypes {
@@ -26,6 +28,7 @@ android {
removeUnusedResources false
obfuscate false
optimizeCode false
+ proguardFile 'proguard-rules.pro'
}
}
release {
@@ -34,6 +37,7 @@ android {
removeUnusedResources true
obfuscate false
optimizeCode true
+ proguardFile 'proguard-rules.pro'
}
}
}
@@ -91,13 +95,17 @@ dependencies {
implementation project(':fake-signature')
implementation project(':play-services-auth')
implementation project(':play-services-base-core')
+ implementation project(':play-services-core-proto')
implementation "com.squareup.wire:wire-runtime:$wireVersion"
- implementation "com.android.volley:volley:$volleyVersion"
+ implementation "com.squareup.wire:wire-grpc-client:$wireVersion"
+
+ implementation "com.squareup.okhttp3:okhttp:$okHttpVersion"
+ implementation "io.ktor:ktor-client-core:$ktorVersion"
+ implementation "io.ktor:ktor-client-okhttp:$ktorVersion"
implementation "androidx.webkit:webkit:$webkitVersion"
- implementation "com.squareup.wire:wire-grpc-client:$wireVersion"
//compose
implementation platform('androidx.compose:compose-bom:2022.10.00')
@@ -110,6 +118,12 @@ dependencies {
implementation "com.google.android.material:material:$materialVersion"
implementation "com.google.accompanist:accompanist-systemuicontroller:0.28.0"
+ implementation 'androidx.compose.ui:ui-tooling-preview'
+ debugImplementation 'androidx.compose.ui:ui-tooling'
+
+ // Coil (image loading)
+ implementation("io.coil-kt:coil-compose:2.7.0")
+
//droidguard
implementation project(':play-services-droidguard')
implementation project(':play-services-tasks-ktx')
@@ -126,6 +140,9 @@ dependencies {
// tink
implementation "com.google.crypto.tink:tink-android:$tinkVersion"
+
+ // multidex
+ implementation "androidx.multidex:multidex:$multidexVersion"
}
wire {
diff --git a/vending-app/proguard-rules.pro b/vending-app/proguard-rules.pro
new file mode 100644
index 0000000000..2f69c875b7
--- /dev/null
+++ b/vending-app/proguard-rules.pro
@@ -0,0 +1,9 @@
+# SPDX-FileCopyrightText: 2025 e foundation
+# SPDX-License-Identifier: Apache-2.0
+
+# OKHttp rules
+-dontwarn okhttp3.internal.platform.**
+-dontwarn org.conscrypt.**
+-dontwarn org.bouncycastle.**
+-dontwarn org.openjsse.**
+-dontwarn org.slf4j.impl.StaticLoggerBinder
\ No newline at end of file
diff --git a/vending-app/src/main/AndroidManifest.xml b/vending-app/src/main/AndroidManifest.xml
index 308f8bef45..e9b07c4195 100644
--- a/vending-app/src/main/AndroidManifest.xml
+++ b/vending-app/src/main/AndroidManifest.xml
@@ -19,12 +19,21 @@
+
+
+
+
+
+
+
@@ -49,13 +58,15 @@
androidx.compose.ui.unit,androidx.compose.ui.text,androidx.compose.ui.graphics,androidx.compose.ui.geometry,
androidx.activity.compose,androidx.compose.runtime.saveable,
androidx.compose.material.ripple,androidx.compose.foundation.layout,androidx.compose.animation.core,
- coil.singleton, coil.base, androidx.compose.material3, com.google.accompanist.systemuicontroller, androidx.compose.animation.graphics" />
+ coil.singleton, coil.base, androidx.compose.material3, com.google.accompanist.systemuicontroller, androidx.compose.animation.graphics,
+ androidx.compose.ui.tooling.data, androidx.compose.ui.tooling.preview" />
+ android:label="@string/app_name"
+ android:name="androidx.multidex.MultiDexApplication">
-
@@ -149,6 +159,13 @@
+
+
+
+
+
+
@@ -177,6 +194,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/VendingPreferences.kt b/vending-app/src/main/java/com/android/vending/VendingPreferences.kt
index 4d23d3e703..9000e05942 100644
--- a/vending-app/src/main/java/com/android/vending/VendingPreferences.kt
+++ b/vending-app/src/main/java/com/android/vending/VendingPreferences.kt
@@ -26,6 +26,14 @@ object VendingPreferences {
}
}
+ @JvmStatic
+ fun isSplitInstallEnabled(context: Context): Boolean {
+ val projection = arrayOf(SettingsContract.Vending.SPLIT_INSTALL)
+ return SettingsContract.getSettings(context, SettingsContract.Vending.getContentUri(context), projection) { c ->
+ c.getInt(0) != 0
+ }
+ }
+
@JvmStatic
fun isBillingEnabled(context: Context): Boolean {
val projection = arrayOf(SettingsContract.Vending.BILLING)
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 7cd86e1a6a..c7760062af 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,22 +2,17 @@ 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.getRequestHeaders
-import com.android.volley.VolleyError
+import com.android.vending.buildRequestHeaders
+import com.android.vending.getAuthToken
import org.microg.vending.billing.core.HttpClient
+import org.microg.vending.billing.proto.GoogleApiResponse
import java.io.IOException
-import kotlin.coroutines.resume
-import kotlin.coroutines.resumeWithException
-import kotlin.coroutines.suspendCoroutine
private const val TAG = "FakeLicenseChecker"
@@ -109,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")
@@ -131,11 +126,8 @@ suspend fun HttpClient.checkLicense(
packageName, auth, packageInfo.versionCode, decodedAndroidId
)
} ?: ErrorResponse(NOT_LICENSED)
- } catch (e: VolleyError) {
- Log.e(TAG, "License request failed with $e")
- ErrorResponse(ERROR_CONTACTING_SERVER)
} catch (e: IOException) {
- Log.e(TAG, "Encountered a network error during operation ($e)")
+ Log.e(TAG, "Encountered a network error during operation", e)
ErrorResponse(ERROR_CONTACTING_SERVER)
} catch (e: OperationCanceledException) {
ErrorResponse(ERROR_CONTACTING_SERVER)
@@ -146,9 +138,9 @@ 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 = getRequestHeaders(auth, androidId),
- adapter = LicenseResult.ADAPTER
-).information?.v1?.let {
+ headers = buildRequestHeaders(auth, androidId),
+ adapter = GoogleApiResponse.ADAPTER
+).payload?.licenseV1Response?.let {
if (it.result != null && it.signedData != null && it.signature != null) {
V1Response(it.result, it.signedData, it.signature)
} else null
@@ -161,22 +153,9 @@ suspend fun HttpClient.makeLicenseV2Request(
androidId: Long
): V2Response? = get(
url = "https://play-fe.googleapis.com/fdfe/apps/checkLicenseServerFallback?pkgn=$packageName&vc=$versionCode",
- headers = getRequestHeaders(auth, androidId),
- adapter = LicenseResult.ADAPTER
-).information?.v2?.license?.jwt?.let {
+ headers = buildRequestHeaders(auth, androidId),
+ adapter = GoogleApiResponse.ADAPTER
+).payload?.licenseV2Response?.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/LicensingService.kt b/vending-app/src/main/java/com/android/vending/licensing/LicensingService.kt
index 47c5afcce3..cdf9cca632 100644
--- a/vending-app/src/main/java/com/android/vending/licensing/LicensingService.kt
+++ b/vending-app/src/main/java/com/android/vending/licensing/LicensingService.kt
@@ -16,8 +16,6 @@ import android.os.RemoteException
import android.util.Log
import com.android.vending.VendingPreferences.isLicensingEnabled
import com.android.vending.VendingPreferences.isLicensingPurchaseFreeAppsEnabled
-import com.android.volley.RequestQueue
-import com.android.volley.toolbox.Volley
import kotlinx.coroutines.runBlocking
import org.microg.gms.auth.AuthConstants
import org.microg.gms.profile.ProfileManager.ensureInitialized
@@ -25,7 +23,6 @@ import org.microg.vending.billing.acquireFreeAppLicense
import org.microg.vending.billing.core.HttpClient
class LicensingService : Service() {
- private lateinit var queue: RequestQueue
private lateinit var accountManager: AccountManager
private lateinit var androidId: String
private lateinit var httpClient: HttpClient
@@ -199,9 +196,8 @@ class LicensingService : Service() {
androidId = java.lang.Long.toHexString(cursor.getLong(0))
}
}
- queue = Volley.newRequestQueue(this)
accountManager = AccountManager.get(this)
- httpClient = HttpClient(this)
+ httpClient = HttpClient()
return mLicenseService
}
diff --git a/vending-app/src/main/java/org/microg/vending/WorkAccountChangedReceiver.kt b/vending-app/src/main/java/org/microg/vending/WorkAccountChangedReceiver.kt
new file mode 100644
index 0000000000..cb2eef9797
--- /dev/null
+++ b/vending-app/src/main/java/org/microg/vending/WorkAccountChangedReceiver.kt
@@ -0,0 +1,44 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.vending
+
+import android.accounts.AccountManager
+import android.content.BroadcastReceiver
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.util.Log
+import org.microg.gms.auth.AuthConstants
+import org.microg.vending.ui.WorkAppsActivity
+
+class WorkAccountChangedReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent?) {
+ val accountManager = AccountManager.get(context)
+ val hasWorkAccounts = accountManager.getAccountsByType(AuthConstants.WORK_ACCOUNT_TYPE).isNotEmpty()
+
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ Log.d(TAG, "setting VendingActivity state to enabled = $hasWorkAccounts")
+
+ val componentName = ComponentName(
+ context,
+ WorkAppsActivity::class.java
+ )
+ context.packageManager.setComponentEnabledSetting(
+ componentName,
+ if (hasWorkAccounts) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DEFAULT,
+ 0
+ )
+ }
+ }
+
+ companion object {
+ const val TAG = "GmsVendingWorkAccRcvr"
+ }
+}
\ No newline at end of file
diff --git a/vending-app/src/main/java/org/microg/vending/billing/AcquireFreeAppLicense.kt b/vending-app/src/main/java/org/microg/vending/billing/AcquireFreeAppLicense.kt
index 56f3d128eb..8899fa1090 100644
--- a/vending-app/src/main/java/org/microg/vending/billing/AcquireFreeAppLicense.kt
+++ b/vending-app/src/main/java/org/microg/vending/billing/AcquireFreeAppLicense.kt
@@ -3,12 +3,12 @@ package org.microg.vending.billing
import android.accounts.Account
import android.content.Context
import android.util.Log
-import com.android.volley.VolleyError
+import io.ktor.utils.io.errors.IOException
import org.microg.vending.billing.core.GooglePlayApi.Companion.URL_DETAILS
import org.microg.vending.billing.core.GooglePlayApi.Companion.URL_PURCHASE
import org.microg.vending.billing.core.HeaderProvider
import org.microg.vending.billing.core.HttpClient
-import org.microg.vending.billing.proto.ResponseWrapper
+import org.microg.vending.billing.proto.GoogleApiResponse
suspend fun HttpClient.acquireFreeAppLicense(context: Context, account: Account, packageName: String): Boolean {
val authData = AuthManager.getAuthData(context, account)
@@ -27,10 +27,10 @@ suspend fun HttpClient.acquireFreeAppLicense(context: Context, account: Account,
url = URL_DETAILS,
headers = headers,
params = mapOf("doc" to packageName),
- adapter = ResponseWrapper.ADAPTER
+ adapter = GoogleApiResponse.ADAPTER
).payload?.detailsResponse
- } catch (e: VolleyError) {
- Log.e(TAG, "Unable to auto-purchase $packageName because of a network error or unexpected response when gathering app data")
+ } catch (e: IOException) {
+ Log.e(TAG, "Unable to auto-purchase $packageName because of a network error or unexpected response when gathering app data", e)
return false
}
@@ -65,14 +65,14 @@ suspend fun HttpClient.acquireFreeAppLicense(context: Context, account: Account,
url = URL_PURCHASE,
headers = headers,
params = parameters,
- adapter = ResponseWrapper.ADAPTER
+ adapter = GoogleApiResponse.ADAPTER
).payload?.buyResponse
- } catch (e: VolleyError) {
- Log.e(TAG, "Unable to auto-purchase $packageName because of a network error or unexpected response during purchase")
+ } catch (e: IOException) {
+ Log.e(TAG, "Unable to auto-purchase $packageName because of a network error or unexpected response during purchase", e)
return false
}
- if (buyResult?.encodedDeliveryToken.isNullOrBlank()) {
+ if (buyResult?.deliveryToken.isNullOrBlank()) {
Log.e(TAG, "Auto-purchasing $packageName failed. Was the purchase rejected by the server?")
return false
} else {
diff --git a/vending-app/src/main/java/org/microg/vending/billing/AuthManager.kt b/vending-app/src/main/java/org/microg/vending/billing/AuthManager.kt
index 1363445e0a..28bc4826e4 100644
--- a/vending-app/src/main/java/org/microg/vending/billing/AuthManager.kt
+++ b/vending-app/src/main/java/org/microg/vending/billing/AuthManager.kt
@@ -15,7 +15,10 @@ import java.util.concurrent.TimeUnit
object AuthManager {
private const val TOKEN_TYPE = "oauth2:https://www.googleapis.com/auth/googleplay https://www.googleapis.com/auth/accounts.reauth"
- fun getAuthData(context: Context, account: Account): AuthData? {
+ fun getAuthData(context: Context, account: Account? = AccountManager.get(context).getAccountsByType(DEFAULT_ACCOUNT_TYPE).firstOrNull()): AuthData? {
+
+ if (account == null) return null
+
val deviceCheckInConsistencyToken = CheckinServiceClient.getConsistencyToken(context)
val gsfId = GServices.getString(context.contentResolver, "android_id", "0")!!.toBigInteger().toString(16)
if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "gsfId: $gsfId, deviceDataVersionInfo: $deviceCheckInConsistencyToken")
diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/GooglePlayApi.kt b/vending-app/src/main/java/org/microg/vending/billing/core/GooglePlayApi.kt
index a8e539e34e..4d6699a75a 100644
--- a/vending-app/src/main/java/org/microg/vending/billing/core/GooglePlayApi.kt
+++ b/vending-app/src/main/java/org/microg/vending/billing/core/GooglePlayApi.kt
@@ -11,7 +11,10 @@ class GooglePlayApi {
const val URL_GET_PURCHASE_HISTORY = "$URL_FDFE/inAppPurchaseHistory"
const val URL_AUTH_PROOF_TOKENS = "https://www.googleapis.com/reauth/v1beta/users/me/reauthProofTokens"
const val URL_DETAILS = "$URL_FDFE/details"
+ const val URL_ITEM_DETAILS = "$URL_FDFE/getItems"
const val URL_PURCHASE = "$URL_FDFE/purchase"
+ const val URL_DELIVERY = "$URL_FDFE/delivery"
+ const val URL_ENTERPRISE_CLIENT_POLICY = "$URL_FDFE/getEnterpriseClientPolicy"
const val URL_SYNC = "$URL_FDFE/sync"
}
}
\ No newline at end of file
diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/HeaderProvider.kt b/vending-app/src/main/java/org/microg/vending/billing/core/HeaderProvider.kt
index b8ce33284a..bc982f74c8 100644
--- a/vending-app/src/main/java/org/microg/vending/billing/core/HeaderProvider.kt
+++ b/vending-app/src/main/java/org/microg/vending/billing/core/HeaderProvider.kt
@@ -1,5 +1,8 @@
package org.microg.vending.billing.core
+import android.util.Log
+import org.microg.vending.billing.TAG
+
object HeaderProvider {
fun getBaseHeaders(authData: AuthData, deviceInfo: DeviceEnvInfo): MutableMap {
val headers: MutableMap = HashMap()
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 3d66ae1669..183bba4747 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
@@ -2,27 +2,92 @@ package org.microg.vending.billing.core
import android.content.Context
import android.net.Uri
-import com.android.volley.DefaultRetryPolicy
-import com.android.volley.NetworkResponse
-import com.android.volley.Request
-import com.android.volley.Response
-import com.android.volley.VolleyError
-import com.android.volley.toolbox.HttpHeaderParser
-import com.android.volley.toolbox.JsonObjectRequest
-import com.android.volley.toolbox.Volley
import com.squareup.wire.Message
import com.squareup.wire.ProtoAdapter
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.engine.okhttp.OkHttp
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.plugins.cache.HttpCache
+import io.ktor.client.plugins.timeout
+import io.ktor.client.request.forms.submitForm
+import io.ktor.client.request.get
+import io.ktor.client.request.headers
+import io.ktor.client.request.post
+import io.ktor.client.request.prepareGet
+import io.ktor.client.request.setBody
+import io.ktor.client.request.url
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.ParametersImpl
+import io.ktor.http.URLBuilder
+import io.ktor.http.Url
+import io.ktor.utils.io.ByteReadChannel
+import io.ktor.utils.io.pool.ByteArrayPool
import org.json.JSONObject
import org.microg.gms.utils.singleInstanceOf
-import kotlin.coroutines.resume
-import kotlin.coroutines.resumeWithException
-import kotlin.coroutines.suspendCoroutine
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.OutputStream
-private const val POST_TIMEOUT = 8000
+private const val POST_TIMEOUT = 8000L
-class HttpClient(context: Context) {
+class HttpClient {
- val requestQueue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) }
+ private val client = singleInstanceOf { HttpClient(OkHttp) {
+ expectSuccess = true
+ install(HttpTimeout)
+ } }
+
+ private val clientWithCache = singleInstanceOf { HttpClient(OkHttp) {
+ expectSuccess = true
+ install(HttpCache)
+ install(HttpTimeout)
+ } }
+
+ suspend fun download(
+ url: String,
+ downloadFile: File,
+ params: Map = emptyMap()
+ ): File = downloadFile.also { toFile ->
+ val parentDir = downloadFile.getParentFile()
+ if (parentDir != null && !parentDir.exists() && !parentDir.mkdirs()) {
+ throw IOException("Failed to create directories: ${parentDir.absolutePath}")
+ }
+
+ FileOutputStream(toFile).use { download(url, it, params) }
+ }
+
+ suspend fun download(
+ url: String,
+ downloadTo: OutputStream,
+ params: Map = emptyMap(),
+ 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)
+ }
+ } while (rc > 0)
+ } finally {
+ ByteArrayPool.recycle(buffer)
+ }
+ // don't close `downloadTo` yet
+ }
+ }
suspend fun get(
url: String,
@@ -30,29 +95,48 @@ class HttpClient(context: Context) {
params: Map = emptyMap(),
adapter: ProtoAdapter,
cache: Boolean = true
- ): O = suspendCoroutine { continuation ->
- val uriBuilder = Uri.parse(url).buildUpon()
- params.forEach {
- uriBuilder.appendQueryParameter(it.key, it.value)
- }
- requestQueue.add(object : Request(Method.GET, uriBuilder.build().toString(), null) {
- override fun parseNetworkResponse(response: NetworkResponse): Response {
- if (response.statusCode != 200) throw VolleyError(response)
- return Response.success(adapter.decode(response.data), HttpHeaderParser.parseCacheHeaders(response))
- }
+ ): O {
- override fun deliverResponse(response: O) {
- continuation.resume(response)
+ val response = (if (cache) clientWithCache else client).get(url.asUrl(params)) {
+ headers {
+ headers.forEach {
+ append(it.key, it.value)
+ }
}
+ }
+ if (response.status != HttpStatusCode.OK) throw IOException("Server responded with status ${response.status}")
+ else return adapter.decode(response.body())
+ }
- override fun deliverError(error: VolleyError) {
- continuation.resumeWithException(error)
+ /**
+ * Post empty body.
+ */
+ suspend fun , O> post(
+ url: String,
+ headers: Map = emptyMap(),
+ params: Map = emptyMap(),
+ adapter: ProtoAdapter,
+ cache: Boolean = false
+ ): O {
+ val response = (if (cache) clientWithCache else client).post(url.asUrl(params)) {
+ setBody(ByteArray(0))
+ headers {
+ headers.forEach {
+ append(it.key, it.value)
+ }
+
+ append(HttpHeaders.ContentType, "application/x-protobuf")
}
-
- override fun getHeaders(): Map = headers
- }.setShouldCache(cache))
+ timeout {
+ requestTimeoutMillis = POST_TIMEOUT
+ }
+ }
+ return adapter.decode(response.body())
}
+ /**
+ * Post protobuf-encoded body.
+ */
suspend fun , O> post(
url: String,
headers: Map = emptyMap(),
@@ -60,56 +144,52 @@ class HttpClient(context: Context) {
payload: I,
adapter: ProtoAdapter,
cache: Boolean = false
- ): O = suspendCoroutine { continuation ->
- val uriBuilder = Uri.parse(url).buildUpon()
- params.forEach {
- uriBuilder.appendQueryParameter(it.key, it.value)
- }
- requestQueue.add(object : Request(Method.POST, uriBuilder.build().toString(), null) {
- override fun parseNetworkResponse(response: NetworkResponse): Response {
- if (response.statusCode != 200) throw VolleyError(response)
- return Response.success(adapter.decode(response.data), HttpHeaderParser.parseCacheHeaders(response))
+ ): O {
+ val response = (if (cache) clientWithCache else client).post(url.asUrl(params)) {
+ setBody(ByteReadChannel(payload.encode()))
+ headers {
+ headers.forEach {
+ append(it.key, it.value)
+ }
+
+ append(HttpHeaders.ContentType, "application/x-protobuf")
}
-
- override fun deliverResponse(response: O) {
- continuation.resume(response)
- }
-
- override fun deliverError(error: VolleyError) {
- continuation.resumeWithException(error)
+ timeout {
+ requestTimeoutMillis = POST_TIMEOUT
}
-
- override fun getHeaders(): Map = headers
- override fun getBody(): ByteArray = payload.encode()
- override fun getBodyContentType(): String = "application/x-protobuf"
- }.setShouldCache(cache).setRetryPolicy(DefaultRetryPolicy(POST_TIMEOUT, 0, 0.0F)))
+ }
+ return adapter.decode(response.body())
}
+ /**
+ * Post JSON body.
+ */
suspend fun post(
url: String,
headers: Map = emptyMap(),
params: Map = emptyMap(),
payload: JSONObject,
cache: Boolean = false
- ): JSONObject = suspendCoroutine { continuation ->
- val uriBuilder = Uri.parse(url).buildUpon()
- params.forEach {
- uriBuilder.appendQueryParameter(it.key, it.value)
- }
- requestQueue.add(object : JsonObjectRequest(Method.POST, uriBuilder.build().toString(), payload, null, null) {
-
- override fun deliverResponse(response: JSONObject) {
- continuation.resume(response)
+ ): JSONObject {
+ val response = (if (cache) clientWithCache else client).post(url.asUrl(params)) {
+ setBody(payload.toString())
+ headers {
+ headers.forEach {
+ append(it.key, it.value)
+ }
+
+ append(HttpHeaders.ContentType, "application/json")
}
-
- override fun deliverError(error: VolleyError) {
- continuation.resumeWithException(error)
+ timeout {
+ requestTimeoutMillis = POST_TIMEOUT
}
-
- override fun getHeaders(): Map = headers
- }.setShouldCache(cache).setRetryPolicy(DefaultRetryPolicy(POST_TIMEOUT, 0, 0.0F)))
+ }
+ return JSONObject(response.body())
}
+ /**
+ * Post form body.
+ */
suspend fun post(
url: String,
headers: Map = emptyMap(),
@@ -117,27 +197,27 @@ class HttpClient(context: Context) {
form: Map = emptyMap(),
adapter: ProtoAdapter,
cache: Boolean = false
- ): O = suspendCoroutine { continuation ->
- val uriBuilder = Uri.parse(url).buildUpon()
- params.forEach {
- uriBuilder.appendQueryParameter(it.key, it.value)
- }
- requestQueue.add(object : Request(Method.POST, uriBuilder.build().toString(), null) {
- override fun parseNetworkResponse(response: NetworkResponse): Response {
- if (response.statusCode != 200) throw VolleyError(response)
- return Response.success(adapter.decode(response.data), HttpHeaderParser.parseCacheHeaders(response))
- }
-
- override fun deliverResponse(response: O) {
- continuation.resume(response)
+ ): O {
+ val response = (if (cache) clientWithCache else client).submitForm(
+ formParameters = ParametersImpl(form.mapValues { listOf(it.key) }),
+ encodeInQuery = false
+ ) {
+ url(url.asUrl(params))
+ headers { // Content-Type is set to `x-www-form-urlencode` automatically
+ headers.forEach {
+ append(it.key, it.value)
+ }
}
-
- override fun deliverError(error: VolleyError) {
- continuation.resumeWithException(error)
+ timeout {
+ requestTimeoutMillis = POST_TIMEOUT
}
-
- override fun getHeaders(): Map = headers
- override fun getParams(): Map = form
- }.setShouldCache(cache).setRetryPolicy(DefaultRetryPolicy(POST_TIMEOUT, 0, 0.0F)))
+ }
+ return adapter.decode(response.body())
}
+
+ private fun String.asUrl(params: Map): Url = URLBuilder(this).apply {
+ params.forEach {
+ parameters.append(it.key, it.value)
+ }
+ }.build()
}
\ No newline at end of file
diff --git a/vending-app/src/main/java/org/microg/vending/billing/core/IAPCore.kt b/vending-app/src/main/java/org/microg/vending/billing/core/IAPCore.kt
index e845440086..7984fe1ae9 100644
--- a/vending-app/src/main/java/org/microg/vending/billing/core/IAPCore.kt
+++ b/vending-app/src/main/java/org/microg/vending/billing/core/IAPCore.kt
@@ -3,10 +3,10 @@ package org.microg.vending.billing.core
import android.content.Context
import android.util.Base64
import android.util.Log
-import com.android.vending.Timestamp
import org.json.JSONObject
import org.microg.gms.utils.toBase64
import org.microg.vending.billing.proto.*
+import org.microg.vending.proto.Timestamp
import java.io.IOException
import java.util.concurrent.TimeUnit
@@ -19,7 +19,7 @@ class IAPCore(
private val authData: AuthData
) {
suspend fun requestAuthProofToken(password: String): String {
- return HttpClient(context).post(
+ return HttpClient().post(
GooglePlayApi.URL_AUTH_PROOF_TOKENS,
headers = HeaderProvider.getBaseHeaders(authData, deviceInfo),
payload = JSONObject().apply {
@@ -107,18 +107,18 @@ class IAPCore(
val requestBody = skuDetailsRequest.encode()
val cacheEntry = skuDetailsCache.get(requestBody)
if (cacheEntry != null) {
- val getSkuDetailsResult = GetSkuDetailsResult.parseFrom(ResponseWrapper.ADAPTER.decode(cacheEntry).payload?.skuDetailsResponse)
+ val getSkuDetailsResult = GetSkuDetailsResult.parseFrom(GoogleApiResponse.ADAPTER.decode(cacheEntry).payload?.skuDetailsResponse)
if (getSkuDetailsResult.skuDetailsList != null && getSkuDetailsResult.skuDetailsList.isNotEmpty()) {
Log.d("IAPCore", "getSkuDetails from cache ")
return getSkuDetailsResult
}
}
Log.d("IAPCore", "getSkuDetails: ")
- val response = HttpClient(context).post(
+ val response = HttpClient().post(
GooglePlayApi.URL_SKU_DETAILS,
headers = HeaderProvider.getDefaultHeaders(authData, deviceInfo),
payload = skuDetailsRequest,
- adapter = ResponseWrapper.ADAPTER
+ adapter = GoogleApiResponse.ADAPTER
)
skuDetailsCache.put(requestBody, response.encode())
GetSkuDetailsResult.parseFrom(response.payload?.skuDetailsResponse)
@@ -254,12 +254,12 @@ class IAPCore(
}.build()
}
return try {
- val response = HttpClient(context).post(
+ val response = HttpClient().post(
GooglePlayApi.URL_EES_ACQUIRE,
headers = HeaderProvider.getDefaultHeaders(authData, deviceInfo),
params = mapOf("theme" to acquireRequest.theme.toString()),
payload = acquireRequest,
- ResponseWrapper.ADAPTER
+ GoogleApiResponse.ADAPTER
)
AcquireResult.parseFrom(params, acquireRequest, response.payload?.acquireResponse)
} catch (e: Exception) {
@@ -279,11 +279,11 @@ class IAPCore(
)
return try {
- val response = HttpClient(context).post(
+ val response = HttpClient().post(
GooglePlayApi.URL_CONSUME_PURCHASE,
headers = HeaderProvider.getDefaultHeaders(authData, deviceInfo),
form = request,
- adapter = ResponseWrapper.ADAPTER
+ adapter = GoogleApiResponse.ADAPTER
)
ConsumePurchaseResult.parseFrom(response.payload?.consumePurchaseResponse)
} catch (e: Exception) {
@@ -300,11 +300,11 @@ class IAPCore(
}.build()
return try {
- val response = HttpClient(context).post(
+ val response = HttpClient().post(
GooglePlayApi.URL_ACKNOWLEDGE_PURCHASE,
headers = HeaderProvider.getDefaultHeaders(authData, deviceInfo),
payload = acknowledgePurchaseRequest,
- adapter = ResponseWrapper.ADAPTER
+ adapter = GoogleApiResponse.ADAPTER
)
AcknowledgePurchaseResult.parseFrom(response.payload?.acknowledgePurchaseResponse)
} catch (e: Exception) {
@@ -328,11 +328,11 @@ class IAPCore(
}
return try {
- val response = HttpClient(context).get(
+ val response = HttpClient().get(
GooglePlayApi.URL_GET_PURCHASE_HISTORY,
HeaderProvider.getDefaultHeaders(authData, deviceInfo),
reqParams,
- ResponseWrapper.ADAPTER
+ GoogleApiResponse.ADAPTER
)
GetPurchaseHistoryResult.parseFrom(response.payload?.purchaseHistoryResponse)
} catch (e: IOException) {
diff --git a/vending-app/src/main/java/org/microg/vending/billing/ui/logic/InAppBillingViewModel.kt b/vending-app/src/main/java/org/microg/vending/billing/ui/logic/InAppBillingViewModel.kt
index 969bcd123d..26f034d792 100644
--- a/vending-app/src/main/java/org/microg/vending/billing/ui/logic/InAppBillingViewModel.kt
+++ b/vending-app/src/main/java/org/microg/vending/billing/ui/logic/InAppBillingViewModel.kt
@@ -16,7 +16,7 @@ import androidx.core.os.bundleOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.billingclient.api.BillingClient
-import com.android.volley.VolleyError
+import io.ktor.utils.io.errors.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
@@ -103,10 +103,8 @@ class InAppBillingViewModel : ViewModel() {
val param = startParams!!.getString(KEY_IAP_SHEET_UI_PARAM)
val (statusCode, encodedRapt) = try {
200 to InAppBillingServiceImpl.requestAuthProofToken(ContextProvider.context, param!!, password)
- } catch (e: VolleyError) {
- Log.w(TAG, e)
- e.networkResponse.statusCode to null
} catch (e: Exception) {
+ Log.w(TAG, e)
-1 to null
}
if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, "requestAuthProofToken statusCode=$statusCode, encodedRapt=$encodedRapt")
diff --git a/vending-app/src/main/java/org/microg/vending/delivery/Delivery.kt b/vending-app/src/main/java/org/microg/vending/delivery/Delivery.kt
new file mode 100644
index 0000000000..51c7d6b275
--- /dev/null
+++ b/vending-app/src/main/java/org/microg/vending/delivery/Delivery.kt
@@ -0,0 +1,98 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.vending.delivery
+
+import android.util.Log
+import com.android.vending.buildRequestHeaders
+import com.google.android.finsky.splitinstallservice.PackageComponent
+import org.microg.vending.billing.core.AuthData
+import org.microg.vending.billing.core.GooglePlayApi.Companion.URL_DELIVERY
+import org.microg.vending.billing.core.HttpClient
+import org.microg.vending.billing.proto.GoogleApiResponse
+import org.microg.vending.splitinstall.SPLIT_LANGUAGE_TAG
+
+private const val TAG = "GmsVendingDelivery"
+
+/**
+ * Call the FDFE delivery endpoint to retrieve download URLs for the
+ * desired components. If specific split install packages are requested,
+ * only those will be contained in the result.
+ */
+suspend fun HttpClient.requestDownloadUrls(
+ packageName: String,
+ versionCode: Long,
+ auth: AuthData,
+ requestSplitPackages: List? = null,
+ deliveryToken: String? = null,
+): List {
+
+ 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="
+ )
+ forEach { requestUrl.append("&mn=").append(it) }
+ }
+
+ deliveryToken?.let {
+ requestUrl.append("&dtok=$it")
+ }
+
+ Log.v(TAG, "requestDownloadUrls start")
+ val languages = requestSplitPackages?.filter { it.startsWith(SPLIT_LANGUAGE_TAG) }?.map {
+ it.replace(SPLIT_LANGUAGE_TAG, "")
+ }
+ Log.d(TAG, "requestDownloadUrls languages: $languages")
+
+ 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),
+ 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()
+ )
+
+ val response = get(
+ url = requestUrl.toString(),
+ headers = headers,
+ adapter = GoogleApiResponse.ADAPTER
+ )
+ Log.d(TAG, "requestDownloadUrls end response -> $response")
+
+ val basePackage = response.payload!!.deliveryResponse!!.deliveryData?.let {
+ if (it.baseUrl != null && it.baseBytes != null) {
+ PackageComponent(packageName, "base", it.baseUrl, it.baseBytes.toLong())
+ } else null
+ }
+ val splitComponents = response.payload.deliveryResponse!!.deliveryData!!.splitPackages.filter {
+ !it.splitPackageName.isNullOrEmpty() && !it.downloadUrl.isNullOrEmpty()
+ }.map {
+ if (requestSplitPackages != null) {
+ // Only download requested, if specific components were requested
+ requestSplitPackages.firstOrNull { requestComponent ->
+ requestComponent.contains(it.splitPackageName!!)
+ }?.let { requestComponent ->
+ PackageComponent(packageName, requestComponent, it.downloadUrl!!, it.size!!.toLong())
+ }
+ } else {
+ // Download all offered components (server chooses)
+ PackageComponent(packageName, it.splitPackageName!!, it.downloadUrl!!, it.size!!.toLong())
+ }
+ }
+
+ val components = if (requestSplitPackages != null) {
+ splitComponents
+ } else {
+ listOf(basePackage) + splitComponents
+ }.filterNotNull()
+
+ Log.d(TAG, "requestDownloadUrls end -> $components")
+
+ return components
+}
\ No newline at end of file
diff --git a/vending-app/src/main/java/org/microg/vending/enterprise/App.kt b/vending-app/src/main/java/org/microg/vending/enterprise/App.kt
new file mode 100644
index 0000000000..4b9e571133
--- /dev/null
+++ b/vending-app/src/main/java/org/microg/vending/enterprise/App.kt
@@ -0,0 +1,26 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.vending.enterprise
+
+open class App(
+ val packageName: String,
+ val versionCode: Int?,
+ val displayName: String,
+ val iconUrl: String?,
+ val dependencies: List,
+ val deliveryToken: String?
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is App) return false
+
+ if (packageName != other.packageName) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int = packageName.hashCode()
+}
\ No newline at end of file
diff --git a/vending-app/src/main/java/org/microg/vending/enterprise/AppState.kt b/vending-app/src/main/java/org/microg/vending/enterprise/AppState.kt
new file mode 100644
index 0000000000..f16e89f00f
--- /dev/null
+++ b/vending-app/src/main/java/org/microg/vending/enterprise/AppState.kt
@@ -0,0 +1,33 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.vending.enterprise
+
+internal sealed interface AppState
+
+/**
+ * App cannot be installed on this user's device
+ */
+internal data object NotCompatible : AppState
+
+/**
+ * App is available, but not installed on the user's device.
+ */
+internal data object NotInstalled : AppState
+
+/**
+ * App is already installed on the device, but an update is available.
+ */
+internal data object UpdateAvailable : AppState
+
+/**
+ * An unspecific app operation is currently outstanding
+ */
+internal data object Pending : AppState
+
+/**
+ * App is installed on device and up to date.
+ */
+internal data object Installed : AppState
\ No newline at end of file
diff --git a/vending-app/src/main/java/org/microg/vending/enterprise/EnterpriseApp.kt b/vending-app/src/main/java/org/microg/vending/enterprise/EnterpriseApp.kt
new file mode 100644
index 0000000000..1e21f2b64a
--- /dev/null
+++ b/vending-app/src/main/java/org/microg/vending/enterprise/EnterpriseApp.kt
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.vending.enterprise
+
+import org.microg.vending.enterprise.proto.AppInstallPolicy
+
+class EnterpriseApp(
+ packageName: String,
+ versionCode: Int?,
+ displayName: String,
+ iconUrl: String?,
+ deliveryToken: String?,
+ dependencies: List,
+ val policy: AppInstallPolicy
+) : App(packageName, versionCode, displayName, iconUrl, dependencies, deliveryToken)
\ No newline at end of file
diff --git a/vending-app/src/main/java/org/microg/vending/enterprise/InstallProgress.kt b/vending-app/src/main/java/org/microg/vending/enterprise/InstallProgress.kt
new file mode 100644
index 0000000000..de038340df
--- /dev/null
+++ b/vending-app/src/main/java/org/microg/vending/enterprise/InstallProgress.kt
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.vending.enterprise
+
+internal sealed interface InstallProgress
+
+internal data class Downloading(
+ val bytesDownloaded: Long,
+ val bytesTotal: Long
+) : InstallProgress, AppState
+internal data object CommitingSession : InstallProgress
+internal data object InstallComplete : InstallProgress
+internal data class InstallError(
+ val errorMessage: String
+) : InstallProgress
\ No newline at end of file
diff --git a/vending-app/src/main/java/org/microg/vending/splitinstall/Constants.kt b/vending-app/src/main/java/org/microg/vending/splitinstall/Constants.kt
new file mode 100644
index 0000000000..ccffd76a6f
--- /dev/null
+++ b/vending-app/src/main/java/org/microg/vending/splitinstall/Constants.kt
@@ -0,0 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.vending.splitinstall
+
+const val SPLIT_LANGUAGE_TAG = "config."
diff --git a/vending-app/src/main/java/org/microg/vending/ui/InstallProgressNotification.kt b/vending-app/src/main/java/org/microg/vending/ui/InstallProgressNotification.kt
new file mode 100644
index 0000000000..7b7f55d488
--- /dev/null
+++ b/vending-app/src/main/java/org/microg/vending/ui/InstallProgressNotification.kt
@@ -0,0 +1,157 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.vending.ui
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.pm.PackageManager.NameNotFoundException
+import android.os.Build
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.android.vending.R
+import org.microg.gms.ui.TAG
+import org.microg.vending.enterprise.CommitingSession
+import org.microg.vending.enterprise.Downloading
+import org.microg.vending.enterprise.InstallComplete
+import org.microg.vending.enterprise.InstallError
+import org.microg.vending.enterprise.InstallProgress
+
+private const val INSTALL_NOTIFICATION_CHANNEL_ID = "packageInstall"
+
+internal fun Context.notifySplitInstallProgress(packageName: String, sessionId: Int, progress: InstallProgress) {
+
+ val label = try {
+ packageManager.getPackageInfo(packageName, 0).applicationInfo
+ .loadLabel(packageManager)
+ } catch (e: NameNotFoundException) {
+ Log.e(TAG, "Couldn't load label for $packageName (${e.message}). Is it not installed?")
+ return
+ }
+
+ createNotificationChannel()
+
+ val notificationManager = NotificationManagerCompat.from(this)
+
+ when (progress) {
+ is Downloading -> getDownloadNotificationBuilder().apply {
+ setContentTitle(getString(R.string.installer_notification_progress_splitinstall_downloading, label))
+ setProgress(progress.bytesDownloaded.toInt(), progress.bytesTotal.toInt(), false)
+ }
+ CommitingSession -> getDownloadNotificationBuilder().apply {
+ setContentTitle(getString(R.string.installer_notification_progress_splitinstall_commiting, label))
+ setProgress(0, 1, true)
+ }
+ else -> null.also { notificationManager.cancel(sessionId) }
+ }?.apply {
+ setOngoing(true)
+
+ notificationManager.notify(sessionId, this.build())
+ }
+
+}
+
+/**
+ * @return The notification after it had been posted _if_ it is an ongoing notification.
+ */
+internal fun Context.notifyInstallProgress(
+ displayName: String,
+ sessionId: Int,
+ progress: InstallProgress,
+ isDependency: Boolean = false
+): Notification? {
+
+ createNotificationChannel()
+ getDownloadNotificationBuilder().apply {
+
+ val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ when (progress) {
+ is Downloading -> {
+ setContentTitle(
+ getString(
+ if (isDependency) R.string.installer_notification_progress_splitinstall_downloading
+ else R.string.installer_notification_progress_downloading,
+ displayName
+ )
+ )
+ setProgress(progress.bytesTotal.toInt(), progress.bytesDownloaded.toInt(), false)
+ setOngoing(true)
+ 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
+ )
+ )
+ setProgress(0, 0, true)
+ setOngoing(true)
+ return this.build().also { notificationManager.notify(sessionId, it) }
+ }
+ InstallComplete -> {
+ if (!isDependency) {
+ setContentTitle(
+ getString(
+ R.string.installer_notification_progress_complete,
+ displayName
+ )
+ )
+ setSmallIcon(android.R.drawable.stat_sys_download_done)
+ notificationManager.notify(sessionId, this.build())
+ } else {
+ notificationManager.cancel(sessionId)
+ }
+ return null
+ }
+ is InstallError -> {
+ if (!isDependency) {
+ setContentTitle(
+ getString(
+ R.string.installer_notification_progress_failed,
+ displayName
+ )
+ )
+ setSmallIcon(android.R.drawable.stat_notify_error)
+ // see `InstallComplete` case
+ notificationManager.notify(sessionId, this.build())
+ } else {
+ notificationManager.cancel(sessionId)
+ }
+ return null
+ }
+ }
+ }
+
+}
+
+private fun Context.getDownloadNotificationBuilder() =
+ NotificationCompat.Builder(this, INSTALL_NOTIFICATION_CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setLocalOnly(true)
+
+private fun Context.createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.createNotificationChannel(
+ NotificationChannel(
+ INSTALL_NOTIFICATION_CHANNEL_ID,
+ getString(R.string.installer_notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = getString(R.string.installer_notification_channel_description)
+ enableVibration(false)
+ enableLights(false)
+ setShowBadge(false)
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/vending-app/src/main/java/org/microg/vending/ui/NetworkState.kt b/vending-app/src/main/java/org/microg/vending/ui/NetworkState.kt
new file mode 100644
index 0000000000..813fef15d2
--- /dev/null
+++ b/vending-app/src/main/java/org/microg/vending/ui/NetworkState.kt
@@ -0,0 +1,12 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.vending.ui
+
+enum class NetworkState {
+ ACTIVE,
+ PASSIVE,
+ ERROR
+}
\ No newline at end of file
diff --git a/vending-app/src/main/java/org/microg/vending/ui/WorkAppsActivity.kt b/vending-app/src/main/java/org/microg/vending/ui/WorkAppsActivity.kt
new file mode 100644
index 0000000000..6b25a317e7
--- /dev/null
+++ b/vending-app/src/main/java/org/microg/vending/ui/WorkAppsActivity.kt
@@ -0,0 +1,298 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.vending.ui
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Bundle
+import android.os.IBinder
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import com.android.vending.buildRequestHeaders
+import com.android.vending.installer.uninstallPackage
+import kotlinx.coroutines.runBlocking
+import org.microg.gms.auth.AuthConstants
+import org.microg.gms.common.DeviceConfiguration
+import org.microg.gms.common.asProto
+import org.microg.gms.profile.Build
+import org.microg.gms.profile.ProfileManager
+import org.microg.vending.UploadDeviceConfigRequest
+import org.microg.vending.WorkAccountChangedReceiver
+import org.microg.vending.billing.AuthManager
+import org.microg.vending.billing.core.AuthData
+import org.microg.vending.billing.core.GooglePlayApi.Companion.URL_ENTERPRISE_CLIENT_POLICY
+import org.microg.vending.billing.core.GooglePlayApi.Companion.URL_FDFE
+import org.microg.vending.billing.core.GooglePlayApi.Companion.URL_ITEM_DETAILS
+import org.microg.vending.billing.core.HttpClient
+import org.microg.vending.billing.createDeviceEnvInfo
+import org.microg.vending.billing.proto.GoogleApiResponse
+import org.microg.vending.enterprise.EnterpriseApp
+import org.microg.vending.enterprise.App
+import org.microg.vending.enterprise.AppState
+import org.microg.vending.enterprise.Installed
+import org.microg.vending.enterprise.NotCompatible
+import org.microg.vending.enterprise.NotInstalled
+import org.microg.vending.enterprise.Pending
+import org.microg.vending.enterprise.UpdateAvailable
+import org.microg.vending.enterprise.proto.AppInstallPolicy
+import com.android.vending.installer.InstallService
+import org.microg.vending.proto.AppMeta
+import org.microg.vending.proto.GetItemsRequest
+import org.microg.vending.proto.RequestApp
+import org.microg.vending.proto.RequestItem
+import org.microg.vending.ui.components.EnterpriseList
+import org.microg.vending.ui.components.NetworkState
+import java.io.IOException
+
+@RequiresApi(android.os.Build.VERSION_CODES.LOLLIPOP)
+class WorkAppsActivity : ComponentActivity() {
+
+ private val apps: MutableMap = mutableStateMapOf()
+ private var networkState by mutableStateOf(NetworkState.ACTIVE)
+
+ private var auth: AuthData? = null
+ set(value) {
+ field = value
+ installService?.auth = value
+ }
+
+ private var installService: InstallService? = null
+
+ private val serviceConnection = object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+ installService = (service as InstallService.LocalBinder).getService()
+ installService?.auth = auth
+ installService?.apps = apps
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ installService = null
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+
+ ProfileManager.ensureInitialized(this)
+
+ val accountManager = AccountManager.get(this)
+ val accounts = accountManager.getAccountsByType(AuthConstants.WORK_ACCOUNT_TYPE)
+ if (accounts.isEmpty()) {
+ // Component should not be enabled; disable through receiver, and redirect to main activity
+ WorkAccountChangedReceiver().onReceive(this, null)
+ startActivity(Intent(this, MainActivity::class.java))
+ finish()
+ } else if (accounts.size > 1) {
+ Log.w(TAG, "Multiple work accounts found. This is unexpected and could point " +
+ "towards misuse of the work account service API by the DPC.")
+ }
+ val account = accounts.first()
+
+ load(account)
+
+ setContent {
+ VendingUi(account,
+ install = { app: EnterpriseApp, isUpdate: Boolean ->
+ Intent(this@WorkAppsActivity, InstallService::class.java).let {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ startForegroundService(it)
+ } else {
+ startService(it)
+ }
+ }
+ installService?.installAsync(app, isUpdate)
+ },
+
+ //Thread { runBlocking { install(app, isUpdate) } }.start() },
+ uninstall = { app -> Thread { runBlocking { uninstall(app) } }.start() }
+ )
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ bindService(
+ Intent(this, InstallService::class.java),
+ serviceConnection, Context.BIND_AUTO_CREATE
+ )
+ }
+
+ override fun onStop() {
+ super.onStop()
+ unbindService(serviceConnection)
+ }
+
+ private fun load(account: Account) {
+ networkState = NetworkState.ACTIVE
+ Thread {
+ runBlocking {
+ try {
+ // Authenticate
+ auth = AuthManager.getAuthData(this@WorkAppsActivity, account)
+ val authData = auth
+ val deviceInfo = createDeviceEnvInfo(this@WorkAppsActivity)
+ if (deviceInfo == null || authData == null) {
+ Log.e(TAG, "Unable to open play store when deviceInfo = $deviceInfo and authData = $authData")
+ networkState = NetworkState.ERROR
+ return@runBlocking
+ }
+
+ val headers = buildRequestHeaders(authData.authToken, authData.gsfId.toLong(16))
+ val client = HttpClient()
+
+ // Register device for server-side compatibility checking
+ val upload = client.post(
+ url = "$URL_FDFE/uploadDeviceConfig",
+ headers = headers.minus("X-PS-RH"),
+ payload = UploadDeviceConfigRequest(
+ DeviceConfiguration(this@WorkAppsActivity).asProto(),
+ manufacturer = Build.MANUFACTURER,
+ //gcmRegistrationId = TODO: looks like remote-triggered app downloads may be announced through GCM?
+ ),
+ adapter = GoogleApiResponse.ADAPTER
+ )
+ Log.d(TAG, "uploaddc: ${upload.payload!!.uploadDeviceConfigResponse}")
+
+ // Fetch list of apps available to the scoped enterprise account
+ val apps = client.post(
+ url = URL_ENTERPRISE_CLIENT_POLICY,
+ headers = headers.plus("content-type" to "application/x-protobuf"),
+ adapter = GoogleApiResponse.ADAPTER
+ ).payload?.enterpriseClientPolicyResponse?.policy?.apps?.filter { it.packageName != null }
+
+ if (apps == null) {
+ Log.e(TAG, "unexpected network response: missing expected fields")
+ networkState = NetworkState.ERROR
+ return@runBlocking
+ }
+
+ Log.v(TAG, "app policy: ${apps.joinToString { "${it.packageName}: ${it.policy}" }}")
+
+ if (apps.isEmpty()) {
+ // Don't fetch details of empty app list (otherwise HTTP 400)
+ networkState = NetworkState.PASSIVE
+ this@WorkAppsActivity.apps.clear()
+ return@runBlocking
+ }
+
+ // Fetch details about all available apps
+ val details = client.post(
+ url = URL_ITEM_DETAILS,
+ // TODO: meaning unclear, but returns 400 without. constant? possibly has influence on which fields are returned?
+ headers = headers.plus("x-dfe-item-field-mask" to "GgWGHay3ByILPP/Avy+4A4YlCRM"),
+ payload = GetItemsRequest(
+ apps.map {
+ RequestItem(RequestApp(AppMeta(it.packageName)))
+ }
+ ),
+ adapter = GoogleApiResponse.ADAPTER
+ ).getItemsResponses.mapNotNull { it.response }.associate { item ->
+ val packageName = item.meta!!.packageName!!
+ val installedDetails = this@WorkAppsActivity.packageManager.getInstalledPackages(0).find {
+ it.applicationInfo.packageName == packageName
+ }
+
+ val available = item.offer?.delivery != null
+
+ val versionCode = if (available) {
+ item.offer!!.version!!.versionCode!!
+ } else null
+
+ val state = if (!available && installedDetails == null) NotCompatible
+ else if (!available && installedDetails != null) Installed
+ else if (available && installedDetails == null) NotInstalled
+ else if (available && installedDetails != null && installedDetails.versionCode < versionCode!!) UpdateAvailable
+ else /* if (available && installedDetails != null) */ Installed
+
+ EnterpriseApp(
+ packageName,
+ versionCode,
+ item.detail!!.name!!.displayName!!,
+ item.detail.icon?.icon?.paint?.url,
+ item.offer?.delivery?.key,
+ item.offer?.delivery?.dependencies?.map {
+ App(it.packageName!!, it.versionCode!!, it.packageName, null, emptyList(), null)
+ } ?: emptyList(),
+ apps.find { it.packageName!! == item.meta.packageName }!!.policy ?: AppInstallPolicy.OPTIONAL,
+ ) to state
+ }.onEach {
+ Log.v(TAG, "${it.key.packageName} (state: ${it.value}) delivery token: ${it.key.deliveryToken ?: "none acquired"}")
+ }
+
+ this@WorkAppsActivity.apps.apply {
+ clear()
+ putAll(details)
+ }
+ networkState = NetworkState.PASSIVE
+ } catch (e: IOException) {
+ networkState = NetworkState.ERROR
+ Log.e(TAG, "Network error: ${e.message}")
+ e.printStackTrace()
+ } catch (e: Exception) {
+ networkState = NetworkState.ERROR
+ Log.e(TAG, "Unexpected network response, cannot process")
+ e.printStackTrace()
+ }
+ }
+ }.start()
+
+ }
+
+ private suspend fun uninstall(app: EnterpriseApp) {
+ val previousState = apps[app]!!
+ apps[app] = Pending
+ runCatching { uninstallPackage(app.packageName) }.onSuccess {
+ apps[app] = NotInstalled
+ }.onFailure {
+ apps[app] = previousState
+ }
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ fun VendingUi(
+ account: Account,
+ install: (app: EnterpriseApp, isUpdate: Boolean) -> Unit,
+ uninstall: (app: EnterpriseApp) -> Unit
+ ) {
+ MaterialTheme {
+ Scaffold(
+ topBar = {
+ WorkVendingTopAppBar()
+ }
+ ) { innerPadding ->
+ Column(Modifier.padding(innerPadding)) {
+ NetworkState(networkState, { load(account) }) {
+ EnterpriseList(apps, install, uninstall)
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ const val TAG = "GmsVendingWorkApp"
+ }
+}
\ No newline at end of file
diff --git a/vending-app/src/main/java/org/microg/vending/ui/WorkVendingTopAppBar.kt b/vending-app/src/main/java/org/microg/vending/ui/WorkVendingTopAppBar.kt
new file mode 100644
index 0000000000..833b5df9c0
--- /dev/null
+++ b/vending-app/src/main/java/org/microg/vending/ui/WorkVendingTopAppBar.kt
@@ -0,0 +1,55 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.vending.ui
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.vending.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WorkVendingTopAppBar() = TopAppBar(
+ title = {
+ Row {
+ Icon(
+ painterResource(R.drawable.ic_work),
+ contentDescription = null,
+ Modifier.align(Alignment.CenterVertically),
+ tint = LocalContentColor.current
+ )
+ Text(
+ stringResource(R.string.vending_activity_name),
+ Modifier
+ .align(Alignment.CenterVertically)
+ .padding(start = 8.dp)
+ )
+ }
+ },
+ colors = TopAppBarDefaults.smallTopAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+)
+
+@Preview
+@Composable
+fun PreviewWorkVendingTopAppBar() {
+ WorkVendingTopAppBar()
+}
\ No newline at end of file
diff --git a/vending-app/src/main/java/org/microg/vending/ui/components/AppRow.kt b/vending-app/src/main/java/org/microg/vending/ui/components/AppRow.kt
new file mode 100644
index 0000000000..f97b30b39f
--- /dev/null
+++ b/vending-app/src/main/java/org/microg/vending/ui/components/AppRow.kt
@@ -0,0 +1,134 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.vending.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import com.android.vending.R
+import org.microg.vending.enterprise.App
+import org.microg.vending.enterprise.AppState
+import org.microg.vending.enterprise.Downloading
+import org.microg.vending.enterprise.Installed
+import org.microg.vending.enterprise.NotCompatible
+import org.microg.vending.enterprise.NotInstalled
+import org.microg.vending.enterprise.Pending
+import org.microg.vending.enterprise.UpdateAvailable
+
+@Composable
+internal fun AppRow(app: App, state: AppState, install: () -> Unit, update: () -> Unit, uninstall: () -> Unit) {
+ Row(
+ Modifier.padding(top = 8.dp, bottom = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val iconSpace = Modifier.size(48.dp)
+ if (app.iconUrl != null) {
+ AsyncImage(
+ model = app.iconUrl,
+ modifier = iconSpace,
+ contentDescription = null,
+ )
+ } else {
+ Spacer(iconSpace)
+ }
+ Text(app.displayName, Modifier.weight(1f))
+
+ if (state == NotCompatible) {
+ Icon(Icons.Default.Warning, null, Modifier.padding(end=8.dp), tint = MaterialTheme.colorScheme.secondary)
+ // TODO better UI
+ }
+ if (state == UpdateAvailable || state == Installed) {
+ IconButton(uninstall) {
+ Icon(Icons.Default.Delete, stringResource(R.string.vending_overview_row_action_uninstall), tint = MaterialTheme.colorScheme.secondary)
+ }
+ }
+ if (state == UpdateAvailable) {
+ FilledIconButton(update, colors = IconButtonDefaults.filledIconButtonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)) {
+ Icon(painterResource(R.drawable.ic_update), stringResource(R.string.vending_overview_row_action_update), tint = MaterialTheme.colorScheme.secondary)
+ }
+ }
+ if (state == NotInstalled) {
+ FilledIconButton(install, colors = IconButtonDefaults.filledIconButtonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)) {
+ Icon(painterResource(R.drawable.ic_download), stringResource(R.string.vending_overview_row_action_install), tint = MaterialTheme.colorScheme.secondary)
+ }
+ }
+ if (state == Pending) {
+ CircularProgressIndicator(Modifier.padding(4.dp))
+ }
+ if (state is Downloading) {
+ CircularProgressIndicator(
+ progress = state.bytesDownloaded.toFloat() / state.bytesTotal.toFloat(),
+ modifier = Modifier.padding(4.dp)
+ )
+ }
+ }
+
+}
+
+private val previewApp = App("org.mozilla.firefox", 0, "Firefox", null, emptyList(), null)
+@Preview
+@Composable
+fun AppRowNotCompatiblePreview() {
+ AppRow(previewApp, NotCompatible, {}, {}, {})
+}
+
+@Preview
+@Composable
+fun AppRowNotInstalledPreview() {
+ AppRow(previewApp, NotInstalled, {}, {}, {})
+}
+
+@Preview
+@Composable
+fun AppRowUpdateablePreview() {
+ AppRow(previewApp, UpdateAvailable, {}, {}, {})
+}
+
+@Preview
+@Composable
+fun AppRowInstalledPreview() {
+ AppRow(previewApp, Installed, {}, {}, {})
+}
+
+@Preview
+@Composable
+fun AppRowPendingPreview() {
+ AppRow(previewApp, Pending, {}, {}, {})
+}
+
+@Preview
+@Composable
+fun AppRowProgressPreview() {
+ AppRow(previewApp, Downloading(75, 100), {}, {}, {})
+}
+
+@Preview
+@Composable
+fun AppRowVeryLongPreview() {
+ val longPreviewApp = App("com.example", 0, "This is an application that has a very long title which would (if we didn't fix that) push out the icons", null, emptyList(), null)
+ AppRow(longPreviewApp, UpdateAvailable, {}, {}, {})
+}
diff --git a/vending-app/src/main/java/org/microg/vending/ui/components/EnterpriseList.kt b/vending-app/src/main/java/org/microg/vending/ui/components/EnterpriseList.kt
new file mode 100644
index 0000000000..cc6308c5ed
--- /dev/null
+++ b/vending-app/src/main/java/org/microg/vending/ui/components/EnterpriseList.kt
@@ -0,0 +1,149 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.vending.ui.components
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.vending.R
+import org.microg.vending.enterprise.AppState
+import org.microg.vending.enterprise.EnterpriseApp
+import org.microg.vending.enterprise.Installed
+import org.microg.vending.enterprise.NotCompatible
+import org.microg.vending.enterprise.NotInstalled
+import org.microg.vending.enterprise.proto.AppInstallPolicy
+
+
+@Composable
+internal fun EnterpriseList(appStates: Map, install: (app: EnterpriseApp, isUpdate: Boolean) -> Unit, uninstall: (app: EnterpriseApp) -> Unit) {
+ if (appStates.isNotEmpty()) LazyColumn(Modifier.padding(horizontal = 16.dp)) {
+
+ val apps = appStates.keys
+ val requiredApps = apps.filter { it.policy == AppInstallPolicy.MANDATORY }
+ if (requiredApps.isNotEmpty()) {
+ item { InListHeading(R.string.vending_overview_enterprise_row_mandatory) }
+ item { InListWarning(R.string.vending_overview_enterprise_row_mandatory_hint) }
+ items(requiredApps.sortedBy { it.packageName }) {
+ AppRow(it, appStates[it]!!, { install(it, false) }, { install(it, true) }, { uninstall(it) })
+ }
+ }
+
+ val optionalApps = apps.filter { it.policy == AppInstallPolicy.OPTIONAL }
+ if (optionalApps.isNotEmpty()) {
+ item { InListHeading(R.string.vending_overview_enterprise_row_offered) }
+ items(optionalApps.sortedBy { it.packageName }) {
+ AppRow(it, appStates[it]!!, { install(it, false) }, { install(it, true) }, { uninstall(it) })
+ }
+ }
+
+ } else Box(
+ Modifier
+ .fillMaxSize()
+ .padding(24.dp)
+ ) {
+ Column(Modifier.align(Alignment.Center), verticalArrangement = Arrangement.spacedBy(32.dp)) {
+ Text(
+ stringResource(R.string.vending_overview_enterprise_no_apps_available),
+ textAlign = TextAlign.Center
+ )
+
+ Row(
+ Modifier
+ .clip(shape = RoundedCornerShape(16.dp))
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Info,
+ contentDescription = null,
+ Modifier.padding(start = 16.dp, top = 16.dp, bottom = 16.dp, end = 16.dp),
+ MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ Text(
+ stringResource(R.string.vending_overview_enterprise_no_apps_available_wait),
+ Modifier.padding(top = 16.dp, bottom = 16.dp, end = 16.dp),
+ MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+ }
+
+}
+
+@Composable
+fun InListHeading(@StringRes text: Int) {
+ Text(
+ stringResource(text),
+ modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
+ style = MaterialTheme.typography.headlineSmall
+ )
+}
+
+@Composable
+fun InListWarning(@StringRes text: Int) {
+ Column(Modifier.padding(bottom = 8.dp)) {
+ Row(
+ Modifier
+ .clip(shape = RoundedCornerShape(16.dp))
+ .background(MaterialTheme.colorScheme.errorContainer),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Warning,
+ contentDescription = null,
+ Modifier.padding(start = 16.dp, top = 16.dp, bottom = 16.dp, end = 16.dp),
+ MaterialTheme.colorScheme.onErrorContainer
+ )
+ Text(
+ stringResource(text),
+ Modifier.padding(top = 16.dp, bottom = 16.dp, end = 16.dp),
+ MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+
+}
+
+@Preview
+@Composable
+fun EnterpriseListPreview() {
+ EnterpriseList(
+ mapOf(
+ EnterpriseApp("com.android.vending", 0, "Market", null, "", emptyList(), AppInstallPolicy.MANDATORY) to Installed,
+ EnterpriseApp("org.mozilla.firefox", 0, "Firefox", null, "", emptyList(), AppInstallPolicy.OPTIONAL) to NotInstalled,
+ EnterpriseApp("org.thoughtcrime.securesms", 0, "Signal", null, "", emptyList(), AppInstallPolicy.OPTIONAL) to NotCompatible
+ ), { _, _ -> }, {}
+ )
+}
+
+@Preview
+@Composable
+fun EnterpriseListEmptyPreview() {
+ EnterpriseList(emptyMap(), { _, _ -> }, {})
+}
diff --git a/vending-app/src/main/java/org/microg/vending/ui/components/NetworkState.kt b/vending-app/src/main/java/org/microg/vending/ui/components/NetworkState.kt
new file mode 100644
index 0000000000..f2b1c01c12
--- /dev/null
+++ b/vending-app/src/main/java/org/microg/vending/ui/components/NetworkState.kt
@@ -0,0 +1,68 @@
+package org.microg.vending.ui.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.vending.R
+import org.microg.vending.ui.NetworkState
+
+@Composable
+fun NetworkState(networkState: NetworkState, retry: () -> Unit, content: @Composable () -> Unit) {
+ when (networkState) {
+ NetworkState.ACTIVE -> {
+ Box(Modifier.fillMaxSize()) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+
+ NetworkState.ERROR -> {
+ Box(Modifier.fillMaxSize().padding(24.dp)) {
+ Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(stringResource(R.string.error_network))
+ Button(retry, Modifier.padding(top = 8.dp)) {
+ Text(stringResource(R.string.error_retry))
+ }
+ }
+ }
+ }
+
+ NetworkState.PASSIVE -> {
+ content()
+ }
+ }
+}
+
+
+@Preview
+@Composable
+fun NetworkStateActivePreview() {
+ NetworkState(NetworkState.ACTIVE, { }) {}
+}
+
+@Preview
+@Composable
+fun NetworkStateErrorPreview() {
+ NetworkState(NetworkState.ERROR, { }) {}
+}
+
+@Preview
+@Composable
+fun NetworkStatePassivePreview() {
+ NetworkState(NetworkState.PASSIVE, {}) {
+ Text("Network operation complete.", Modifier.padding(16.dp))
+ }
+}
\ No newline at end of file
diff --git a/vending-app/src/main/kotlin/com/android/vending/VendingRequestHeaders.kt b/vending-app/src/main/kotlin/com/android/vending/VendingRequestHeaders.kt
deleted file mode 100644
index 82d5e3c3a3..0000000000
--- a/vending-app/src/main/kotlin/com/android/vending/VendingRequestHeaders.kt
+++ /dev/null
@@ -1,162 +0,0 @@
-package com.android.vending
-
-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
-
-private const val TAG = "VendingRequestHeaders"
-
-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"
-
-internal fun getRequestHeaders(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 hashMapOf(
- "X-PS-RH" to xPsRh,
- "User-Agent" to userAgent,
- "Accept-Language" to "en-US",
- "Connection" to "Keep-Alive"
- ).apply {
- if (auth.isNotEmpty()) put("Authorization", "Bearer $auth")
- }
-}
-
-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/kotlin/com/android/vending/extensions.kt b/vending-app/src/main/kotlin/com/android/vending/extensions.kt
new file mode 100644
index 0000000000..4373fe326a
--- /dev/null
+++ b/vending-app/src/main/kotlin/com/android/vending/extensions.kt
@@ -0,0 +1,150 @@
+/**
+ * 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 okio.ByteString
+import org.microg.gms.profile.Build
+import org.microg.vending.billing.getUserAgent
+import org.microg.vending.proto.AndroidVersionMeta
+import org.microg.vending.proto.DeviceMeta
+import org.microg.vending.proto.EncodedTriple
+import org.microg.vending.proto.EncodedTripleWrapper
+import org.microg.vending.proto.IntWrapper
+import org.microg.vending.proto.Locality
+import org.microg.vending.proto.LocalityWrapper
+import org.microg.vending.proto.RequestHeader
+import org.microg.vending.proto.RequestLanguagePackage
+import org.microg.vending.proto.StringWrapper
+import org.microg.vending.proto.Timestamp
+import org.microg.vending.proto.TimestampContainer
+import org.microg.vending.proto.TimestampContainer1
+import org.microg.vending.proto.TimestampContainer1Wrapper
+import org.microg.vending.proto.TimestampContainer2
+import org.microg.vending.proto.TimestampStringWrapper
+import org.microg.vending.proto.TimestampWrapper
+import org.microg.vending.proto.UnknownByte12
+import org.microg.vending.proto.UserAgent
+import org.microg.vending.proto.Uuid
+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 = "VendingRequestHeaders"
+
+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().toString()
+ ).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 = getUserAgent()
+
+ return mapOf(
+ "X-PS-RH" to xPsRh,
+ "User-Agent" to userAgent,
+ "Accept-Language" to "en-US",
+ "Connection" to "Keep-Alive",
+ "X-DFE-Device-Id" to androidId.toBigInteger().toString(16),
+ "X-DFE-Client-Id" to "am-google",
+ "X-DFE-Encoded-Targets" to "CAESN/qigQYC2AMBFfUbyA7SM5Ij/CvfBoIDgxHqGP8R3xzIBvoQtBKFDZ4HAY4FrwSVMasHBO0O2Q8akgYRAQECAQO7AQEpKZ0CnwECAwRrAQYBr9PPAoK7sQMBAQMCBAkIDAgBAwEDBAICBAUZEgMEBAMLAQEBBQEBAcYBARYED+cBfS8CHQEKkAEMMxcBIQoUDwYHIjd3DQ4MFk0JWGYZEREYAQOLAYEBFDMIEYMBAgICAgICOxkCD18LGQKEAcgDBIQBAgGLARkYCy8oBTJlBCUocxQn0QUBDkkGxgNZQq0BZSbeAmIDgAEBOgGtAaMCDAOQAZ4BBIEBKUtQUYYBQscDDxPSARA1oAEHAWmnAsMB2wFyywGLAxol+wImlwOOA80CtwN26A0WjwJVbQEJPAH+BRDeAfkHK/ABASEBCSAaHQemAzkaRiu2Ad8BdXeiAwEBGBUBBN4LEIABK4gB2AFLfwECAdoENq0CkQGMBsIBiQEtiwGgA1zyAUQ4uwS8AwhsvgPyAcEDF27vApsBHaICGhl3GSKxAR8MC6cBAgItmQYG9QIeywLvAeYBDArLAh8HASI4ELICDVmVBgsY/gHWARtcAsMBpALiAdsBA7QBpAJmIArpByn0AyAKBwHTARIHAX8D+AMBcRIBBbEDmwUBMacCHAciNp0BAQF0OgQLJDuSAh54kwFSP0eeAQQ4M5EBQgMEmwFXywFo0gFyWwMcapQBBugBPUW2AVgBKmy3AR6PAbMBGQxrUJECvQR+8gFoWDsYgQNwRSczBRXQAgtRswEW0ALMAREYAUEBIG6yATYCRE8OxgER8gMBvQEDRkwLc8MBTwHZAUOnAXiiBakDIbYBNNcCIUmuArIBSakBrgFHKs0EgwV/G3AD0wE6LgECtQJ4xQFwFbUCjQPkBS6vAQqEAUZF3QIM9wEhCoYCQhXsBCyZArQDugIziALWAdIBlQHwBdUErQE6qQaSA4EEIvYBHir9AQVLmgMCApsCKAwHuwgrENsBAjNYswEVmgIt7QJnN4wDEnta+wGfAcUBxgEtEFXQAQWdAUAeBcwBAQM7rAEJATJ0LENrdh73A6UBhAE+qwEeASxLZUMhDREuH0CGARbd7K0GlQo",
+ "X-DFE-Phenotype" to "H4sIAAAAAAAAAB3OO3KjMAAA0KRNuWXukBkBQkAJ2MhgAZb5u2GCwQZbCH_EJ77QHmgvtDtbv-Z9_H63zXXU0NVPB1odlyGy7751Q3CitlPDvFd8lxhz3tpNmz7P92CFw73zdHU2Ie0Ad2kmR8lxhiErTFLt3RPGfJQHSDy7Clw10bg8kqf2owLokN4SecJTLoSwBnzQSd652_MOf2d1vKBNVedzg4ciPoLz2mQ8efGAgYeLou-l-PXn_7Sna1MfhHuySxt-4esulEDp8Sbq54CPPKjpANW-lkU2IZ0F92LBI-ukCKSptqeq1eXU96LD9nZfhKHdtjSWwJqUm_2r6pMHOxk01saVanmNopjX3YxQafC4iC6T55aRbC8nTI98AF_kItIQAJb5EQxnKTO7TZDWnr01HVPxelb9A2OWX6poidMWl16K54kcu_jhXw-JSBQkVcD_fPsLSZu6joIBAAA"
+ ) + if (auth.isNotEmpty()) mapOf("Authorization" to "Bearer $auth") else emptyMap()
+}
+
+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/android/vending/installer/Constants.kt b/vending-app/src/main/kotlin/com/android/vending/installer/Constants.kt
new file mode 100644
index 0000000000..a280116793
--- /dev/null
+++ b/vending-app/src/main/kotlin/com/android/vending/installer/Constants.kt
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.android.vending.installer
+
+import android.content.Context
+import java.io.File
+
+private const val FILE_SAVE_PATH = "phonesky-download-service"
+internal const val TAG = "GmsPackageInstaller"
+
+const val KEY_BYTES_DOWNLOADED = "bytes_downloaded"
+
+fun Context.packageDownloadLocation() = File(cacheDir, FILE_SAVE_PATH).apply {
+ if (!exists()) mkdir()
+}
diff --git a/vending-app/src/main/kotlin/com/android/vending/installer/Install.kt b/vending-app/src/main/kotlin/com/android/vending/installer/Install.kt
new file mode 100644
index 0000000000..15956c78d1
--- /dev/null
+++ b/vending-app/src/main/kotlin/com/android/vending/installer/Install.kt
@@ -0,0 +1,156 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.android.vending.installer
+
+import android.annotation.SuppressLint
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInfo
+import android.content.pm.PackageInstaller
+import android.content.pm.PackageInstaller.SessionParams
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import com.google.android.finsky.splitinstallservice.PackageComponent
+import kotlinx.coroutines.CompletableDeferred
+import org.microg.vending.billing.core.HttpClient
+import org.microg.vending.enterprise.CommitingSession
+import org.microg.vending.enterprise.Downloading
+import org.microg.vending.enterprise.InstallComplete
+import org.microg.vending.enterprise.InstallError
+import org.microg.vending.enterprise.InstallProgress
+import java.io.File
+import java.io.FileInputStream
+import java.io.IOException
+import java.io.OutputStream
+
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+internal suspend fun Context.installPackages(
+ packageName: String,
+ componentFiles: List,
+ isUpdate: Boolean = false
+) = installPackagesInternal(
+ packageName = packageName,
+ componentNames = componentFiles.map { it.name },
+ isUpdate = isUpdate
+) { session, fileName, to ->
+ val component = componentFiles.find { it.name == fileName }!!
+ FileInputStream(component).use { it.copyTo(to) }
+ component.delete()
+}
+
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+internal suspend fun Context.installPackagesFromNetwork(
+ packageName: String,
+ components: List,
+ httpClient: HttpClient = HttpClient(),
+ isUpdate: Boolean = false,
+ emitProgress: (session: Int, InstallProgress) -> Unit = { _, _ -> }
+) {
+
+ val downloadProgress = mutableMapOf()
+
+ installPackagesInternal(
+ packageName = packageName,
+ componentNames = components.map { it.componentName },
+ isUpdate = isUpdate,
+ emitProgress = emitProgress,
+ ) { session, fileName, to ->
+ val component = components.find { it.componentName == fileName }!!
+ Log.v(TAG, "installing $fileName for $packageName from network")
+ // Emit progress for the first time as soon as possible, before any network interaction
+ emitProgress(session, Downloading(
+ bytesDownloaded = downloadProgress.values.sum(),
+ bytesTotal = components.sumOf { it.size }
+ ))
+ httpClient.download(component.url, to) { progress ->
+ downloadProgress[component] = progress
+ emitProgress(session, Downloading(
+ bytesDownloaded = downloadProgress.values.sum(),
+ bytesTotal = components.sumOf { it.size }
+ ))
+ }
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+private suspend fun Context.installPackagesInternal(
+ packageName: String,
+ componentNames: List,
+ isUpdate: Boolean = false,
+ emitProgress: (session: Int, InstallProgress) -> Unit = { _, _ -> },
+ writeComponent: suspend (session: Int, componentName: String, to: OutputStream) -> Unit
+) {
+ Log.v(TAG, "installPackages start")
+
+ val packageInstaller = packageManager.packageInstaller
+ val installed = packageManager.getInstalledPackages(0).any {
+ it.applicationInfo.packageName == packageName
+ }
+ // Contrary to docs, MODE_INHERIT_EXISTING cannot be used if package is not yet installed.
+ val params = SessionParams(
+ if (!installed || isUpdate) SessionParams.MODE_FULL_INSTALL
+ else SessionParams.MODE_INHERIT_EXISTING
+ )
+ params.setAppPackageName(packageName)
+ params.setAppLabel(packageName)
+ params.setInstallLocation(PackageInfo.INSTALL_LOCATION_INTERNAL_ONLY)
+ try {
+ @SuppressLint("PrivateApi") val method = SessionParams::class.java.getDeclaredMethod(
+ "setDontKillApp", Boolean::class.javaPrimitiveType
+ )
+ method.invoke(params, true)
+ } catch (e: Exception) {
+ Log.w(TAG, "Error setting dontKillApp", e)
+ }
+ var session: PackageInstaller.Session? = null
+ // might throw, but we need no handling here as we don't emit progress beforehand
+ val sessionId: Int = packageInstaller.createSession(params)
+ try {
+ session = packageInstaller.openSession(sessionId)
+ for (component in componentNames) {
+ session.openWrite(component, 0, -1).use { outputStream ->
+ writeComponent(sessionId, component, outputStream)
+ session!!.fsync(outputStream)
+ }
+ }
+ val deferred = CompletableDeferred()
+
+ SessionResultReceiver.pendingSessions[sessionId] = SessionResultReceiver.OnResult(
+ onSuccess = {
+ deferred.complete(Unit)
+ emitProgress(sessionId, InstallComplete)
+ },
+ onFailure = { message ->
+ deferred.completeExceptionally(RuntimeException(message))
+ emitProgress(sessionId, InstallError(message ?: "UNKNOWN"))
+ }
+ )
+
+ val intent = Intent(this, SessionResultReceiver::class.java)
+ val pendingIntent = PendingIntent.getBroadcast(this, sessionId, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
+
+ emitProgress(sessionId, CommitingSession)
+ session.commit(pendingIntent.intentSender)
+ // don't abandon if `finally` step is reached after this point
+ session = null
+
+ Log.d(TAG, "installPackages session commit")
+ return deferred.await()
+ } catch (e: IOException) {
+ Log.e(TAG, "Error installing packages", e)
+ emitProgress(sessionId, InstallError(e.message ?: "UNKNOWN"))
+ throw e
+ } finally {
+ // discard downloaded data
+ session?.let {
+ Log.d(TAG, "Discarding session after error")
+ it.abandon()
+ }
+ }
+}
diff --git a/vending-app/src/main/kotlin/com/android/vending/installer/InstallService.kt b/vending-app/src/main/kotlin/com/android/vending/installer/InstallService.kt
new file mode 100644
index 0000000000..b3e57b1edc
--- /dev/null
+++ b/vending-app/src/main/kotlin/com/android/vending/installer/InstallService.kt
@@ -0,0 +1,242 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.android.vending.installer
+
+import android.app.Service
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Binder
+import android.os.IBinder
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.app.ServiceCompat
+import com.android.vending.buildRequestHeaders
+import kotlinx.coroutines.runBlocking
+import org.microg.vending.billing.core.AuthData
+import org.microg.vending.billing.core.GooglePlayApi.Companion.URL_PURCHASE
+import org.microg.vending.billing.core.HttpClient
+import org.microg.vending.billing.proto.GoogleApiResponse
+import org.microg.vending.delivery.requestDownloadUrls
+import org.microg.vending.enterprise.AppState
+import org.microg.vending.enterprise.CommitingSession
+import org.microg.vending.enterprise.Downloading
+import org.microg.vending.enterprise.EnterpriseApp
+import org.microg.vending.enterprise.InstallError
+import org.microg.vending.enterprise.Installed
+import org.microg.vending.enterprise.Pending
+import org.microg.vending.enterprise.proto.AppInstallPolicy
+import org.microg.vending.ui.notifyInstallProgress
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.random.Random
+
+@RequiresApi(android.os.Build.VERSION_CODES.LOLLIPOP)
+class InstallService : Service() {
+
+ private val binder = LocalBinder()
+
+ private val runningThreads = AtomicInteger(0)
+
+ /**
+ * Note: `isForeground` can be `false` even if the service is actually
+ * running in the foreground while installing a dependency, see below.
+ * State `false` means that the next notification must be designated as
+ * foreground notification.
+ */
+ private val isForeground = AtomicBoolean(false)
+
+ internal var auth: AuthData? = null
+ internal lateinit var apps: MutableMap
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ return START_STICKY
+ }
+
+ fun installAsync(app: EnterpriseApp, isUpdate: Boolean) =
+ Thread {
+ runningThreads.incrementAndGet()
+ runBlocking { install(app, isUpdate) }
+ if (runningThreads.decrementAndGet() == 0) {
+ // Demote ourselves explicitly – notification cannot be removed otherwise
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
+ stopSelf()
+ }
+ }.start()
+
+ suspend fun install(app: EnterpriseApp, isUpdate: Boolean) {
+
+ val previousState = apps[app]!!
+ apps[app] = Pending
+
+ val client = HttpClient()
+
+ // Purchase app (only needs to be done once, in theory – behaviour seems flaky)
+ // Ignore failures
+ runCatching {
+ if (app.policy != AppInstallPolicy.MANDATORY) {
+ val parameters = mapOf(
+ "ot" to "1",
+ "doc" to app.packageName,
+ "vc" to app.versionCode.toString()
+ )
+ client.post(
+ url = URL_PURCHASE,
+ headers = buildRequestHeaders(
+ auth!!.authToken,
+ auth!!.gsfId.toLong(16)
+ ),
+ params = parameters,
+ adapter = GoogleApiResponse.ADAPTER
+ )
+ }
+ }.onFailure { Log.i(TAG, "couldn't purchase ${app.packageName}: ${it.message}") }
+ .onSuccess { Log.d(TAG, "purchased ${app.packageName} successfully") }
+
+ // Install dependencies (different package name → needs to happen in a separate transaction)
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) for (dependency in app.dependencies) {
+
+ val installedDetails = packageManager.getSharedLibraries(0)
+ .map { it.declaringPackage }
+ // multiple different library versions can be installed at the same time
+ .filter { it.packageName == dependency.packageName }
+ .maxByOrNull { it.versionCode }
+
+ val upToDate = installedDetails?.let {
+ it.versionCode >= dependency.versionCode!!
+ }
+
+ if (upToDate == true) {
+ Log.d(
+ TAG, "not installing ${dependency.packageName} as it is already up to date " +
+ "(need version ${dependency.versionCode}, we have version ${installedDetails.versionCode})")
+ continue
+ } else if (upToDate == false) {
+ Log.d(
+ TAG, "${dependency.packageName} is already installed, but an update is necessary " +
+ "(need version ${dependency.versionCode}, we only have version ${installedDetails.versionCode})")
+ }
+
+ val downloadUrls = runCatching {
+
+ client.requestDownloadUrls(
+ dependency.packageName,
+ dependency.versionCode!!.toLong(),
+ auth!!
+ // no delivery token available
+ ) }
+
+ if (downloadUrls.isFailure) {
+ Log.w(TAG, "Failed to request download URLs for dependency ${dependency.packageName}: ${downloadUrls.exceptionOrNull()!!.message}")
+ apps[app] = previousState
+ return
+ }
+
+ runCatching {
+
+ var lastNotification = 0L
+ // This method posts its first notification as soon as the install session is created (i.e. before network interaction)
+ installPackagesFromNetwork(
+ packageName = dependency.packageName,
+ components = downloadUrls.getOrThrow(),
+ httpClient = client,
+ isUpdate = false // static libraries may never be installed as updates
+ ) { session, progress ->
+
+ // Android rate limits notification updates by some vague rule of "not too many in less than one second"
+ if (progress !is Downloading || lastNotification + NOTIFICATION_INTERVAL < System.currentTimeMillis()) {
+ notifyInstallProgress(app.displayName, session, progress, isDependency = true)?.let {
+ /* We can tolerate if this notification is removed by the Android platform,
+ * since we would post it again while download is running / discard it ourselves
+ * after download has finished.
+ * On the other hand, we couldn't tolerate Android system to prevent us from
+ * cancelling this notification ourselves, since it has a different ID
+ * (separate session) compared to the main download that happens after all
+ * dependencies are loaded.
+ * Therefore, do _not_ set `isForeground` to `true` here, so that another
+ * notification takes this role soon.
+ */
+ if (!isForeground.get()) {
+ ServiceCompat.startForeground(
+ this, session, it, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+ )
+ }
+ }
+ lastNotification = System.currentTimeMillis()
+ }
+ }
+ }.onFailure { exception ->
+ Log.w(TAG, "Installation from network unsuccessful.", exception)
+ notifyInstallProgress(app.displayName, sessionId = Random.nextInt(), progress = InstallError("unused"), false)
+ apps[app] = previousState
+ return
+ }
+ }
+
+ // Get download links for requested package
+ val downloadUrls = runCatching {
+
+ client.requestDownloadUrls(
+ app.packageName,
+ app.versionCode!!.toLong(),
+ auth!!,
+ deliveryToken = app.deliveryToken
+ ) }
+
+ if (downloadUrls.isFailure) {
+ Log.w(TAG, "Failed to request download URLs: ${downloadUrls.exceptionOrNull()!!.message}")
+ apps[app] = previousState
+ return
+ }
+
+ runCatching {
+
+ var lastNotification = 0L
+ installPackagesFromNetwork(
+ packageName = app.packageName,
+ components = downloadUrls.getOrThrow(),
+ httpClient = client,
+ isUpdate = isUpdate
+ ) { session, progress ->
+
+
+ // Android rate limits notification updates by some vague rule of "not too many in less than one second"
+ if (progress !is Downloading || lastNotification + NOTIFICATION_INTERVAL < System.currentTimeMillis()) {
+ notifyInstallProgress(app.displayName, session, progress)?.let {
+ if (!isForeground.getAndSet(true)) {
+ ServiceCompat.startForeground(
+ this, session, it, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+ )
+ }
+ }
+ lastNotification = System.currentTimeMillis()
+ }
+
+ if (progress is Downloading) apps[app] = progress
+ else if (progress is CommitingSession) apps[app] = Pending
+ }
+ }.onSuccess {
+ apps[app] = Installed
+ }.onFailure { exception ->
+ Log.w(TAG, "Installation from network unsuccessful.", exception)
+ apps[app] = previousState
+ }
+ }
+
+ inner class LocalBinder : Binder() {
+ fun getService(): InstallService = this@InstallService
+ }
+
+ override fun onBind(intent: Intent?): IBinder {
+ return binder
+ }
+
+ companion object {
+ const val TAG = "GmsInstallService"
+
+ const val NOTIFICATION_INTERVAL = 500
+ }
+
+}
\ No newline at end of file
diff --git a/vending-app/src/main/kotlin/com/android/vending/installer/SessionResultReceiver.kt b/vending-app/src/main/kotlin/com/android/vending/installer/SessionResultReceiver.kt
new file mode 100644
index 0000000000..ae3b3f665b
--- /dev/null
+++ b/vending-app/src/main/kotlin/com/android/vending/installer/SessionResultReceiver.kt
@@ -0,0 +1,65 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.android.vending.installer
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import com.google.android.finsky.splitinstallservice.SplitInstallManager
+
+@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+internal class SessionResultReceiver : BroadcastReceiver() {
+ 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, "SessionResultReceiver received a successful transaction")
+ if (sessionId != -1) {
+ pendingSessions[sessionId]?.apply { onSuccess() }
+ pendingSessions.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 -> {
+ val errorMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
+ Log.w(TAG, "SessionResultReceiver received a failed transaction result: $errorMessage")
+ if (sessionId != -1) {
+ pendingSessions[sessionId]?.apply { onFailure(errorMessage) }
+ pendingSessions.remove(sessionId)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "SessionResultReceiver encountered error while handling session result", e)
+ if (sessionId != -1) {
+ pendingSessions[sessionId]?.apply { onFailure(e.message) }
+ }
+ }
+ }
+
+ data class OnResult(
+ val onSuccess: () -> Unit,
+ val onFailure: (message: String?) -> Unit
+ )
+
+ companion object {
+ val pendingSessions: MutableMap = mutableMapOf()
+ }
+}
\ No newline at end of file
diff --git a/vending-app/src/main/kotlin/com/android/vending/installer/Uninstall.kt b/vending-app/src/main/kotlin/com/android/vending/installer/Uninstall.kt
new file mode 100644
index 0000000000..47f11fc18c
--- /dev/null
+++ b/vending-app/src/main/kotlin/com/android/vending/installer/Uninstall.kt
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.android.vending.installer
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import androidx.annotation.RequiresApi
+import kotlinx.coroutines.CompletableDeferred
+
+@RequiresApi(android.os.Build.VERSION_CODES.LOLLIPOP)
+suspend fun Context.uninstallPackage(packageName: String) {
+ val installer = packageManager.packageInstaller
+ val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
+ val session = installer.createSession(sessionParams)
+
+ val deferred = CompletableDeferred()
+
+ SessionResultReceiver.pendingSessions[session] = SessionResultReceiver.OnResult(
+ onSuccess = { deferred.complete(Unit) },
+ onFailure = { message -> deferred.completeExceptionally(RuntimeException(message)) }
+ )
+
+ installer.uninstall(
+ packageName, PendingIntent.getBroadcast(
+ this, session, Intent(this, SessionResultReceiver::class.java).apply {
+ // for an unknown reason, the session ID is not added to the response automatically :(
+ putExtra(PackageInstaller.EXTRA_SESSION_ID, session)
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ ).intentSender
+ )
+
+ deferred.await()
+
+}
\ No newline at end of file
diff --git a/vending-app/src/main/kotlin/com/google/android/finsky/IntegrityExtensions.kt b/vending-app/src/main/kotlin/com/google/android/finsky/IntegrityExtensions.kt
index 786004c8be..4958ba77ed 100644
--- a/vending-app/src/main/kotlin/com/google/android/finsky/IntegrityExtensions.kt
+++ b/vending-app/src/main/kotlin/com/google/android/finsky/IntegrityExtensions.kt
@@ -18,8 +18,7 @@ import android.security.keystore.KeyProperties
import android.text.TextUtils
import android.util.Base64
import android.util.Log
-import com.android.vending.Timestamp
-import com.android.vending.getRequestHeaders
+import com.android.vending.buildRequestHeaders
import com.android.vending.makeTimestamp
import com.google.android.finsky.expressintegrityservice.ExpressIntegritySession
import com.google.android.finsky.expressintegrityservice.IntermediateIntegrityResponseData
@@ -41,6 +40,7 @@ import org.microg.gms.profile.Build
import org.microg.vending.billing.DEFAULT_ACCOUNT_TYPE
import org.microg.vending.billing.GServices
import org.microg.vending.billing.core.HttpClient
+import org.microg.vending.proto.Timestamp
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
@@ -401,6 +401,7 @@ private suspend fun requestDeviceIntegrityToken(
return requestExpressSyncData(context, authToken, tokenWrapper)
}
+// TODO: deduplicate with vending/extensions.kt
suspend fun getAuthToken(context: Context, authTokenType: String): String {
val accountManager = AccountManager.get(context)
val accounts = accountManager.getAccountsByType(DEFAULT_ACCOUNT_TYPE)
@@ -429,9 +430,9 @@ suspend fun getAuthToken(context: Context, authTokenType: String): String {
suspend fun requestIntegritySyncData(context: Context, authToken: String, request: IntegrityRequest): IntegrityResponse {
val androidId = GServices.getString(context.contentResolver, "android_id", "1")?.toLong() ?: 1
- return HttpClient(context).post(
+ return HttpClient().post(
url = "https://play-fe.googleapis.com/fdfe/integrity",
- headers = getRequestHeaders(authToken, androidId),
+ headers = buildRequestHeaders(authToken, androidId),
payload = request,
adapter = IntegrityResponse.ADAPTER
)
@@ -439,9 +440,9 @@ suspend fun requestIntegritySyncData(context: Context, authToken: String, reques
suspend fun requestExpressSyncData(context: Context, authToken: String, request: TokenRequestWrapper): TokenResponse {
val androidId = GServices.getString(context.contentResolver, "android_id", "1")?.toLong() ?: 1
- return HttpClient(context).post(
+ return HttpClient().post(
url = "https://play-fe.googleapis.com/fdfe/sync?nocache_qos=lt",
- headers = getRequestHeaders(authToken, androidId),
+ headers = buildRequestHeaders(authToken, androidId),
payload = request,
adapter = TokenResponse.ADAPTER
)
@@ -451,9 +452,9 @@ suspend fun requestIntermediateIntegrity(
context: Context, authToken: String, request: IntermediateIntegrityRequest
): IntermediateIntegrityResponseWrapperExtend {
val androidId = GServices.getString(context.contentResolver, "android_id", "1")?.toLong() ?: 1
- return HttpClient(context).post(
+ return HttpClient().post(
url = "https://play-fe.googleapis.com/fdfe/intermediateIntegrity",
- headers = getRequestHeaders(authToken, androidId),
+ headers = buildRequestHeaders(authToken, androidId),
payload = request,
adapter = IntermediateIntegrityResponseWrapperExtend.ADAPTER
)
diff --git a/vending-app/src/main/kotlin/com/google/android/finsky/accounts/impl/AccountsChangedReceiver.kt b/vending-app/src/main/kotlin/com/google/android/finsky/accounts/impl/AccountsChangedReceiver.kt
index efee5d394e..7898773d53 100644
--- a/vending-app/src/main/kotlin/com/google/android/finsky/accounts/impl/AccountsChangedReceiver.kt
+++ b/vending-app/src/main/kotlin/com/google/android/finsky/accounts/impl/AccountsChangedReceiver.kt
@@ -12,7 +12,7 @@ import android.content.Intent
import android.util.Log
import com.android.vending.VendingPreferences
import com.android.vending.AUTH_TOKEN_SCOPE
-import com.android.vending.licensing.getAuthToken
+import com.android.vending.getAuthToken
import com.google.android.finsky.syncDeviceInfo
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -46,7 +46,7 @@ class AccountsChangedReceiver : BroadcastReceiver() {
ProfileManager.ensureInitialized(context)
val androidId = GServices.getString(context.contentResolver, "android_id", "1")?.toLong() ?: 1
val authToken = account.let {
- AccountManager.get(context).getAuthToken(it, AUTH_TOKEN_SCOPE, false).getString(AccountManager.KEY_AUTHTOKEN)
+ getAuthToken(AccountManager.get(context), it, AUTH_TOKEN_SCOPE).getString(AccountManager.KEY_AUTHTOKEN)
} ?: throw RuntimeException("oauthToken is null")
syncDeviceInfo(context, account, authToken, androidId)
}
diff --git a/vending-app/src/main/kotlin/com/google/android/finsky/assetmoduleservice/AssetModuleService.kt b/vending-app/src/main/kotlin/com/google/android/finsky/assetmoduleservice/AssetModuleService.kt
index cde513190e..8d4cba1ed0 100644
--- a/vending-app/src/main/kotlin/com/google/android/finsky/assetmoduleservice/AssetModuleService.kt
+++ b/vending-app/src/main/kotlin/com/google/android/finsky/assetmoduleservice/AssetModuleService.kt
@@ -34,13 +34,13 @@ class AssetModuleService : LifecycleService() {
Log.d(TAG, "onBind: ")
ProfileManager.ensureInitialized(this)
accountManager = AccountManager.get(this)
- httpClient = HttpClient(this)
+ httpClient = HttpClient()
return AssetModuleServiceImpl(this, lifecycle, httpClient, accountManager, packageDownloadData).asBinder()
}
override fun onDestroy() {
Log.d(TAG, "onDestroy: ")
- httpClient.requestQueue.cancelAll(TAG_REQUEST)
+ // TODO cancel downloads
super.onDestroy()
}
diff --git a/vending-app/src/main/kotlin/com/google/android/finsky/expressintegrityservice/DeviceIntegrity.kt b/vending-app/src/main/kotlin/com/google/android/finsky/expressintegrityservice/DeviceIntegrity.kt
index 2a4f058910..2209315bc9 100644
--- a/vending-app/src/main/kotlin/com/google/android/finsky/expressintegrityservice/DeviceIntegrity.kt
+++ b/vending-app/src/main/kotlin/com/google/android/finsky/expressintegrityservice/DeviceIntegrity.kt
@@ -5,9 +5,9 @@
package com.google.android.finsky.expressintegrityservice
-import com.android.vending.Timestamp
import com.google.android.finsky.ClientKey
import okio.ByteString
+import org.microg.vending.proto.Timestamp
data class DeviceIntegrity(
var clientKey: ClientKey?, var deviceIntegrityToken: ByteString?, var creationTime: Timestamp?, var lastManualSoftRefreshTime: Timestamp?
diff --git a/vending-app/src/main/kotlin/com/google/android/finsky/expressintegrityservice/ExpressIntegrityService.kt b/vending-app/src/main/kotlin/com/google/android/finsky/expressintegrityservice/ExpressIntegrityService.kt
index f1a50e939a..523dfdcefc 100644
--- a/vending-app/src/main/kotlin/com/google/android/finsky/expressintegrityservice/ExpressIntegrityService.kt
+++ b/vending-app/src/main/kotlin/com/google/android/finsky/expressintegrityservice/ExpressIntegrityService.kt
@@ -21,9 +21,7 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.android.vending.AUTH_TOKEN_SCOPE
-import com.android.vending.Timestamp
import com.android.vending.makeTimestamp
-import com.android.volley.AuthFailureError
import com.google.android.finsky.AuthTokenWrapper
import com.google.android.finsky.ClientKey
import com.google.android.finsky.ClientKeyExtend
@@ -67,6 +65,7 @@ import com.google.crypto.tink.config.TinkConfig
import okio.ByteString.Companion.toByteString
import org.microg.gms.profile.ProfileManager
import org.microg.vending.billing.DEFAULT_ACCOUNT_TYPE
+import org.microg.vending.proto.Timestamp
import kotlin.random.Random
private const val TAG = "ExpressIntegrityService"
diff --git a/vending-app/src/main/kotlin/com/google/android/finsky/expressintegrityservice/IntermediateIntegrity.kt b/vending-app/src/main/kotlin/com/google/android/finsky/expressintegrityservice/IntermediateIntegrity.kt
index 8011597d5b..21e807fee7 100644
--- a/vending-app/src/main/kotlin/com/google/android/finsky/expressintegrityservice/IntermediateIntegrity.kt
+++ b/vending-app/src/main/kotlin/com/google/android/finsky/expressintegrityservice/IntermediateIntegrity.kt
@@ -5,9 +5,9 @@
package com.google.android.finsky.expressintegrityservice
-import com.android.vending.Timestamp
import com.google.android.finsky.ClientKey
import okio.ByteString
+import org.microg.vending.proto.Timestamp
data class IntermediateIntegrity(
var packageName: String,
diff --git a/vending-app/src/main/kotlin/com/google/android/finsky/extensions.kt b/vending-app/src/main/kotlin/com/google/android/finsky/extensions.kt
index b40e6dbb87..a3d44ae147 100644
--- a/vending-app/src/main/kotlin/com/google/android/finsky/extensions.kt
+++ b/vending-app/src/main/kotlin/com/google/android/finsky/extensions.kt
@@ -19,8 +19,7 @@ import androidx.collection.arraySetOf
import androidx.core.content.pm.PackageInfoCompat
import com.android.vending.AUTH_TOKEN_SCOPE
import com.android.vending.VendingPreferences
-import com.android.vending.getRequestHeaders
-import com.android.vending.licensing.getAuthToken
+import com.android.vending.buildRequestHeaders
import com.google.android.finsky.assetmoduleservice.AssetPackException
import com.google.android.finsky.assetmoduleservice.ChunkData
import com.google.android.finsky.assetmoduleservice.DownloadData
@@ -93,7 +92,7 @@ suspend fun HttpClient.initAssetModuleData(
return null
} else {
for (candidate in accounts) {
- authToken = accountManager.getAuthToken(candidate, AUTH_TOKEN_SCOPE, false).getString(AccountManager.KEY_AUTHTOKEN)
+ authToken = com.android.vending.getAuthToken(accountManager, candidate, AUTH_TOKEN_SCOPE).getString(AccountManager.KEY_AUTHTOKEN)
if (authToken != null) {
account = candidate
break
@@ -118,7 +117,7 @@ suspend fun HttpClient.initAssetModuleData(
val moduleDeliveryInfo = post(
url = ASSET_MODULE_DELIVERY_URL,
- headers = getRequestHeaders(authToken, androidId),
+ headers = buildRequestHeaders(authToken, androidId),
payload = requestPayload,
adapter = AssetModuleDeliveryResponse.ADAPTER
).wrapper?.deliveryInfo
@@ -139,9 +138,9 @@ suspend fun syncDeviceInfo(context: Context, account: Account, authToken: String
return
}
runCatching {
- HttpClient(context).post(
+ HttpClient().post(
url = GooglePlayApi.URL_SYNC,
- headers = getRequestHeaders(authToken, androidId),
+ headers = buildRequestHeaders(authToken, androidId),
payload = DeviceSyncInfo.buildSyncRequest(context, androidId, account),
adapter = SyncResponse.ADAPTER
)
diff --git a/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/DownloadStatus.kt b/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/DownloadStatus.kt
new file mode 100644
index 0000000000..ce79fa22a1
--- /dev/null
+++ b/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/DownloadStatus.kt
@@ -0,0 +1,12 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.google.android.finsky.splitinstallservice
+
+enum class DownloadStatus {
+ PENDING,
+ FAILED,
+ COMPLETE
+}
\ No newline at end of file
diff --git a/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/PackageComponent.kt b/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/PackageComponent.kt
new file mode 100644
index 0000000000..d7a07e7805
--- /dev/null
+++ b/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/PackageComponent.kt
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.google.android.finsky.splitinstallservice
+
+data class PackageComponent(
+ val packageName: String,
+ val componentName: String,
+ val url: String,
+ /**
+ * Size in bytes
+ */
+ val size: Long
+)
\ No newline at end of file
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..ea8b724c49
--- /dev/null
+++ b/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/SplitInstallManager.kt
@@ -0,0 +1,152 @@
+/**
+ * SPDX-FileCopyrightText: 2024 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.google.android.finsky.splitinstallservice
+
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.content.pm.PackageInfoCompat
+import com.android.vending.installer.KEY_BYTES_DOWNLOADED
+import com.android.vending.installer.installPackagesFromNetwork
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.microg.vending.billing.AuthManager
+import org.microg.vending.billing.core.HttpClient
+import org.microg.vending.delivery.requestDownloadUrls
+import org.microg.vending.enterprise.Downloading
+import org.microg.vending.splitinstall.SPLIT_LANGUAGE_TAG
+import org.microg.vending.ui.notifySplitInstallProgress
+
+private const val KEY_LANGUAGE = "language"
+private const val KEY_LANGUAGES = "languages"
+private const val KEY_MODULE_NAME = "module_name"
+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 ACTION_UPDATE_SERVICE = "com.google.android.play.core.splitinstall.receiver.SplitInstallUpdateIntentService"
+
+private const val TAG = "SplitInstallManager"
+
+class SplitInstallManager(val context: Context) {
+
+ private var httpClient: HttpClient = HttpClient()
+
+ suspend fun splitInstallFlow(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.v(TAG, "splitInstallFlow: start")
+
+ val packagesToDownload = splits.mapNotNull { split ->
+ split.getString(KEY_LANGUAGE)?.let { "$SPLIT_LANGUAGE_TAG$it" }
+ ?: split.getString(KEY_MODULE_NAME)
+ }.filter { shouldDownload(callingPackage, it) }
+
+ Log.v(TAG, "splitInstallFlow will query for these packages: $packagesToDownload")
+ if (packagesToDownload.isEmpty()) return false
+
+ val authData = runCatching { withContext(Dispatchers.IO) {
+ AuthManager.getAuthData(context)
+ } }.getOrNull()
+ Log.v(TAG, "splitInstallFlow oauthToken: $authData")
+ if (authData?.authToken.isNullOrEmpty()) return false
+ authData!!
+
+
+ val components = runCatching {
+ httpClient.requestDownloadUrls(
+ packageName = callingPackage,
+ versionCode = PackageInfoCompat.getLongVersionCode(
+ context.packageManager.getPackageInfo(callingPackage, 0)
+ ),
+ auth = authData,
+ requestSplitPackages = packagesToDownload
+ )
+ }.getOrNull()
+ Log.v(TAG, "splitInstallFlow requestDownloadUrls returned these components: $components")
+ if (components.isNullOrEmpty()) {
+ return false
+ }
+
+ components.forEach {
+ splitInstallRecord[it] = DownloadStatus.PENDING
+ }
+
+ val success = runCatching {
+
+ var lastNotification = 0L
+ context.installPackagesFromNetwork(
+ packageName = callingPackage,
+ components = components,
+ httpClient = httpClient,
+ isUpdate = false
+ ) { session, progress ->
+ // Android rate limits notification updates by some vague rule of "not too many in less than one second"
+ if (progress !is Downloading || lastNotification + 250 < System.currentTimeMillis()) {
+ context.notifySplitInstallProgress(callingPackage, session, progress)
+ lastNotification = System.currentTimeMillis()
+ }
+ }
+ }.isSuccess
+
+ return if (success) {
+ sendCompleteBroad(context, callingPackage, components.sumOf { it.size })
+ components.forEach { splitInstallRecord[it] = DownloadStatus.COMPLETE }
+ true
+ } else {
+ components.forEach { splitInstallRecord[it] = DownloadStatus.FAILED }
+ false
+ }
+ }
+
+ /**
+ * Tests if a split apk has already been requested in this session. Returns true if it is
+ * pending or downloaded, and returns false if download failed or it is not yet known.
+ */
+ @RequiresApi(Build.VERSION_CODES.M)
+ private fun shouldDownload(callingPackage: String, splitName: String): Boolean {
+ return splitInstallRecord.keys.find { it.packageName == callingPackage && it.componentName == splitName }
+ ?.let {
+ splitInstallRecord[it] == DownloadStatus.FAILED
+ } ?: true
+ }
+
+ private fun sendCompleteBroad(context: Context, packageName: String, bytes: Long) {
+ Log.d(TAG, "sendCompleteBroadcast: $bytes bytes")
+ val extra = Bundle().apply {
+ putInt(KEY_STATUS, 5)
+ putInt(KEY_ERROR_CODE, 0)
+ putInt(KEY_SESSION_ID, 0)
+ putLong(KEY_TOTAL_BYTES_TO_DOWNLOAD, bytes)
+ //putString(KEY_LANGUAGES, intent.getStringExtra(KEY_LANGUAGE))
+ putLong(KEY_BYTES_DOWNLOADED, bytes)
+ }
+ 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() {
+ splitInstallRecord.clear()
+ deferredMap.clear()
+ }
+
+ companion object {
+ // Installation records, including (sub)package name, download path, and installation status
+ internal val splitInstallRecord: MutableMap = mutableMapOf()
+ 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..a8e21998e4
--- /dev/null
+++ b/vending-app/src/main/kotlin/com/google/android/finsky/splitinstallservice/SplitInstallService.kt
@@ -0,0 +1,112 @@
+/**
+ * SPDX-FileCopyrightText: 2024 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.google.android.finsky.splitinstallservice
+
+import android.content.Context
+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.android.vending.VendingPreferences
+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, this, lifecycle).asBinder()
+ }
+
+ override fun onUnbind(intent: Intent?): Boolean {
+ Log.d(TAG, "onUnbind: ")
+ splitInstallManager.release()
+ return super.onUnbind(intent)
+ }
+}
+
+class SplitInstallServiceImpl(private val installManager: SplitInstallManager, private val context: Context, 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")
+ if (VendingPreferences.isSplitInstallEnabled(context)) {
+ lifecycleScope.launch {
+ val installStatus = installManager.splitInstallFlow(pkg, splits)
+ Log.d(TAG, "startInstall: installStatus -> $installStatus")
+ callback.onStartInstall(CommonStatusCodes.SUCCESS, Bundle())
+ }
+ } else {
+ Log.w(TAG, "refusing to perform split installation for $pkg as the service is disabled")
+ callback.onStartInstall(CommonStatusCodes.ERROR, 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/DeliveryResponse.proto b/vending-app/src/main/proto/DeliveryResponse.proto
new file mode 100644
index 0000000000..978b507c3b
--- /dev/null
+++ b/vending-app/src/main/proto/DeliveryResponse.proto
@@ -0,0 +1,62 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+option java_package = "org.microg.vending.delivery.proto";
+option java_multiple_files = true;
+
+message DeliveryResponse {
+ optional DeliveryStatus status = 1;
+ optional DeliveryData deliveryData = 2;
+}
+
+enum DeliveryStatus {
+ SUCCESS = 1;
+ NOT_SUPPORTED = 2;
+ NOT_PURCHASED = 3;
+ APP_REMOVED = 7;
+ APP_NOT_SUPPORTED = 9;
+}
+
+message DeliveryData {
+ /*
+ * Size of the file downloaded through `baseUrl` in bytes.
+ */
+ optional uint32 baseBytes = 1;
+ /*
+ * Location of `base.apk`.
+ */
+ optional string baseUrl = 3;
+ repeated SplitDeliveryData splitPackages = 15;
+}
+
+/*
+ * Encodes additional app bundle components (according to observations, both
+ * OBB and split APK files).
+ */
+message SplitDeliveryData {
+ optional string splitPackageName = 1;
+ /*
+ * Size of the package described by this message, i.e. the file at `downloadUrl`.
+ */
+ optional uint32 size = 2;
+ optional string sha1 = 4;
+ optional string downloadUrl = 5;
+ /*
+ * Alternative download? Meaning not clear, unused.
+ */
+ optional DownloadInfo downloadInfo1 = 8;
+ optional string sha256 = 9;
+ optional string unknownInfoString = 15;
+ /*
+ * Alternative download? Meaning not clear, unused.
+ */
+ optional DownloadInfo downloadInfo2 = 16;
+}
+
+message DownloadInfo {
+ optional int32 id = 1;
+ optional uint32 bytes = 2;
+ optional string url = 3;
+}
diff --git a/vending-app/src/main/proto/EnterpriseClientPolicy.proto b/vending-app/src/main/proto/EnterpriseClientPolicy.proto
new file mode 100644
index 0000000000..d541905168
--- /dev/null
+++ b/vending-app/src/main/proto/EnterpriseClientPolicy.proto
@@ -0,0 +1,32 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+syntax = "proto3";
+
+option java_package = "org.microg.vending.enterprise.proto";
+option java_multiple_files = true;
+
+message EnterpriseClientPolicyResponse {
+ optional EnterprisePolicy policy = 1;
+}
+
+message EnterprisePolicy {
+ repeated App apps = 1;
+ // There are six more elements with unknown purpose.
+}
+
+message App {
+ optional string packageName = 1;
+ optional AppInstallPolicy policy = 2;
+ optional string emptyString = 4; // = ""
+ optional int32 unknownNumber = 9; // = 1
+}
+
+// TODO: could be inaccurate
+enum AppInstallPolicy {
+ UNKNOWN = 0;
+ OPTIONAL = 1;
+ MANDATORY = 3;
+}
\ No newline at end of file
diff --git a/vending-app/src/main/proto/GetItemsRequest.proto b/vending-app/src/main/proto/GetItemsRequest.proto
new file mode 100644
index 0000000000..bcb975641a
--- /dev/null
+++ b/vending-app/src/main/proto/GetItemsRequest.proto
@@ -0,0 +1,32 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+syntax = "proto3";
+
+option java_package = "org.microg.vending.proto";
+option java_multiple_files = true;
+
+message GetItemsRequest {
+ repeated RequestItem items = 2;
+}
+
+// Reason for hierarchy is unknown.
+
+message RequestItem {
+ RequestApp app = 1;
+ LocalData local = 2;
+}
+
+message RequestApp {
+ AppMeta meta = 1;
+}
+
+message AppMeta {
+ optional string packageName = 1;
+}
+
+message LocalData {
+ // suspected to contain local version code and signature, if available
+}
\ No newline at end of file
diff --git a/vending-app/src/main/proto/GetItemsResponse.proto b/vending-app/src/main/proto/GetItemsResponse.proto
new file mode 100644
index 0000000000..52d368df72
--- /dev/null
+++ b/vending-app/src/main/proto/GetItemsResponse.proto
@@ -0,0 +1,63 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+syntax = "proto3";
+
+option java_package = "org.microg.vending.proto";
+option java_multiple_files = true;
+
+import "GetItemsRequest.proto";
+
+message GetItemsResponse {
+ optional RequestApp query = 1;
+ optional ItemResponse response = 2;
+}
+
+message ItemResponse {
+ optional AppMeta meta = 1;
+ optional ItemAppDetail detail = 2;
+ optional ItemOffer offer = 3;
+}
+
+message ItemAppDetail {
+ optional Name name = 1;
+ optional ItemIcon icon = 2;
+}
+
+message Name {
+ optional string displayName = 1;
+}
+
+message ItemIcon {
+ optional IconVariant icon = 1;
+}
+
+message IconVariant {
+ optional IconPaint paint = 6;
+}
+
+message IconPaint {
+ optional string url = 1;
+}
+
+message ItemOffer {
+ optional ItemVersion version = 2;
+ optional ItemDelivery delivery = 28;
+}
+
+message ItemVersion {
+ optional int32 versionCode = 1;
+ optional int32 versionName = 2;
+}
+
+message ItemDelivery {
+ repeated ItemDependency dependencies = 10;
+ optional string key = 14;
+}
+
+message ItemDependency {
+ optional string packageName = 1;
+ optional int32 versionCode = 2;
+}
diff --git a/vending-app/src/main/proto/GooglePlayResponse.proto b/vending-app/src/main/proto/GooglePlayResponse.proto
new file mode 100644
index 0000000000..4f2e838187
--- /dev/null
+++ b/vending-app/src/main/proto/GooglePlayResponse.proto
@@ -0,0 +1,65 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+syntax = "proto3";
+
+option java_package = "org.microg.vending.billing.proto";
+option java_multiple_files = true;
+
+import "EnterpriseClientPolicy.proto";
+import "DeliveryResponse.proto";
+import "LicenseResult.proto";
+import "UploadDeviceConfigResponse.proto";
+import "GetItemsResponse.proto";
+import "Purchase.proto";
+
+message GoogleApiResponse {
+ optional Payload payload = 1;
+ optional ServerCommands commands = 2;
+ repeated PreFetch preFetch = 3;
+ optional ServerMeta meta = 5;
+
+ optional bytes serverLogsCookie = 9; // not used
+
+ /*
+ * For getItems queries, field 1.145 only encodes some kind of index. The
+ * real content is here, at field 11.
+ */
+ repeated GetItemsResponse getItemsResponses = 11;
+}
+
+message Payload {
+ optional DetailsResponse detailsResponse = 2;
+ optional BuyResponse buyResponse = 4;
+ optional DeliveryResponse deliveryResponse = 21;
+ optional UploadDeviceConfigResponse uploadDeviceConfigResponse = 28;
+ optional ConsumePurchaseResponse consumePurchaseResponse = 30;
+ optional PurchaseHistoryResponse purchaseHistoryResponse = 67;
+ optional LicenseCheckV1Response licenseV1Response = 76;
+ optional SkuDetailsResponse skuDetailsResponse = 82;
+ optional AcquireResponse acquireResponse = 94;
+ optional EnterpriseClientPolicyResponse enterpriseClientPolicyResponse = 135;
+ optional AcknowledgePurchaseResponse acknowledgePurchaseResponse = 140;
+ optional LicenseCheckV2Response licenseV2Response = 173;
+ // optional SyncApiResp syncResult = 183;
+}
+
+message ServerCommands {
+ bool clearCache = 1;
+ string displayErrorMessage = 2;
+ string logErrorStacktrace = 3;
+}
+
+message PreFetch {
+ string url = 1;
+ GoogleApiResponse response = 2;
+ string etag = 3;
+ int64 ttl = 4;
+ int64 softTtl = 5;
+}
+
+message ServerMeta {
+ optional int64 latencyMillis = 1;
+}
\ No newline at end of file
diff --git a/vending-app/src/main/proto/Integrity.proto b/vending-app/src/main/proto/Integrity.proto
index f90e4bac9d..27cb337f5c 100644
--- a/vending-app/src/main/proto/Integrity.proto
+++ b/vending-app/src/main/proto/Integrity.proto
@@ -1,3 +1,7 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
option java_package = "com.google.android.finsky";
option java_multiple_files = true;
diff --git a/vending-app/src/main/proto/LicenseResult.proto b/vending-app/src/main/proto/LicenseResult.proto
index 238aa263f6..957e250ebe 100644
--- a/vending-app/src/main/proto/LicenseResult.proto
+++ b/vending-app/src/main/proto/LicenseResult.proto
@@ -1,25 +1,21 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
syntax = "proto2";
-option java_package = "com.android.vending";
+option java_package = "org.microg.vending.licensing.proto";
option java_multiple_files = true;
-message LicenseResult {
- optional LicenseInformation information = 1;
-}
-
-message LicenseInformation {
- optional V1Container v1 = 76;
- optional V2Container v2 = 173;
-}
-
-message V1Container {
+message LicenseCheckV1Response {
optional uint32 result = 1;
optional string signedData = 2;
optional string signature = 3;
}
-message V2Container {
+message LicenseCheckV2Response {
optional AppLicense license = 1;
}
diff --git a/vending-app/src/main/proto/Locality.proto b/vending-app/src/main/proto/Locality.proto
index 8e74fbcf69..d82f529741 100644
--- a/vending-app/src/main/proto/Locality.proto
+++ b/vending-app/src/main/proto/Locality.proto
@@ -1,6 +1,11 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
syntax = "proto2";
-option java_package = "com.android.vending";
+option java_package = "org.microg.vending.proto";
option java_multiple_files = true;
import "Timestamp.proto";
diff --git a/vending-app/src/main/proto/GooglePlay.proto b/vending-app/src/main/proto/Purchase.proto
similarity index 93%
rename from vending-app/src/main/proto/GooglePlay.proto
rename to vending-app/src/main/proto/Purchase.proto
index bc32cd1201..e03d298426 100644
--- a/vending-app/src/main/proto/GooglePlay.proto
+++ b/vending-app/src/main/proto/Purchase.proto
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: 2025 microG project team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
syntax = "proto3";
option java_package = "org.microg.vending.billing.proto";
@@ -271,30 +276,12 @@ message DynamicSku {
string unknown3 = 3;
}
-message ResponseWrapper {
- Payload payload = 1;
- ServerCommands commands = 2;
- repeated PreFetch preFetch = 3;
- ServerMetadata serverMetadata = 5;
- bytes serverLogsCookie = 9;
-}
-
-message Payload {
- DetailsResponse detailsResponse = 2;
- BuyResponse buyResponse = 4;
- ConsumePurchaseResponse consumePurchaseResponse = 30;
- PurchaseHistoryResponse purchaseHistoryResponse = 67;
- SkuDetailsResponse skuDetailsResponse = 82;
- AcquireResponse acquireResponse = 94;
- AcknowledgePurchaseResponse acknowledgePurchaseResponse = 140;
-}
-
message DetailsResponse {
Item item = 4;
}
message BuyResponse {
- string encodedDeliveryToken = 55;
+ string deliveryToken = 55;
}
message Item {
@@ -703,22 +690,4 @@ message PurchaseHistoryResponse {
repeated string signature = 3;
string continuationToken = 4;
FailedResponse failedResponse = 5;
-}
-
-message ServerCommands {
- bool clearCache = 1;
- string displayErrorMessage = 2;
- string logErrorStacktrace = 3;
-}
-
-message PreFetch {
- string url = 1;
- ResponseWrapper response = 2;
- string etag = 3;
- int64 ttl = 4;
- int64 softTtl = 5;
-}
-
-message ServerMetadata {
- int64 latencyMillis = 1;
}
\ No newline at end of file
diff --git a/vending-app/src/main/proto/LicenseRequest.proto b/vending-app/src/main/proto/RequestHeader.proto
similarity index 81%
rename from vending-app/src/main/proto/LicenseRequest.proto
rename to vending-app/src/main/proto/RequestHeader.proto
index dc0b71339e..e60a620168 100644
--- a/vending-app/src/main/proto/LicenseRequest.proto
+++ b/vending-app/src/main/proto/RequestHeader.proto
@@ -1,14 +1,20 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
syntax = "proto2";
-option java_package = "com.android.vending";
+option java_package = "org.microg.vending.proto";
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,9 +42,13 @@ message IntWrapper {
optional uint32 integer = 1;
}
+message RequestLanguagePackage {
+ repeated string language = 1;
+}
+
message DeviceMeta {
optional AndroidVersionMeta android = 1;
- optional UnknownByte12 unknown1 = 2;
+ optional string unknown1 = 2; // inconsistent observations; a field of type "UnknownByte12" was observed as well
optional uint32 unknown2 = 3; // observed value: 1
}
diff --git a/vending-app/src/main/proto/SyncRequest.proto b/vending-app/src/main/proto/SyncRequest.proto
index 546b390380..5b2f56370e 100644
--- a/vending-app/src/main/proto/SyncRequest.proto
+++ b/vending-app/src/main/proto/SyncRequest.proto
@@ -1,3 +1,7 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
option java_package = "com.google.android.finsky";
option java_multiple_files = true;
diff --git a/vending-app/src/main/proto/Timestamp.proto b/vending-app/src/main/proto/Timestamp.proto
index fa98206e11..a718401e90 100644
--- a/vending-app/src/main/proto/Timestamp.proto
+++ b/vending-app/src/main/proto/Timestamp.proto
@@ -1,6 +1,11 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
syntax = "proto2";
-option java_package = "com.android.vending";
+option java_package = "org.microg.vending.proto";
option java_multiple_files = true;
message TimestampContainer {
diff --git a/vending-app/src/main/proto/UploadDeviceConfigResponse.proto b/vending-app/src/main/proto/UploadDeviceConfigResponse.proto
new file mode 100644
index 0000000000..3f612b891a
--- /dev/null
+++ b/vending-app/src/main/proto/UploadDeviceConfigResponse.proto
@@ -0,0 +1,12 @@
+/*
+ * SPDX-FileCopyrightText: 2025 e foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+syntax = "proto3";
+
+option java_package = "org.microg.vending.proto";
+
+message UploadDeviceConfigResponse {
+ optional string deviceConfigToken = 1;
+}
\ No newline at end of file
diff --git a/vending-app/src/main/res/drawable/ic_download.xml b/vending-app/src/main/res/drawable/ic_download.xml
new file mode 100644
index 0000000000..80aafe20b1
--- /dev/null
+++ b/vending-app/src/main/res/drawable/ic_download.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/vending-app/src/main/res/drawable/ic_update.xml b/vending-app/src/main/res/drawable/ic_update.xml
new file mode 100644
index 0000000000..4eba07e840
--- /dev/null
+++ b/vending-app/src/main/res/drawable/ic_update.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/vending-app/src/main/res/drawable/ic_work.xml b/vending-app/src/main/res/drawable/ic_work.xml
new file mode 100644
index 0000000000..8315631566
--- /dev/null
+++ b/vending-app/src/main/res/drawable/ic_work.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
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 1353d852d2..868cfbd491 100644
--- a/vending-app/src/main/res/values-zh-rCN/strings.xml
+++ b/vending-app/src/main/res/values-zh-rCN/strings.xml
@@ -18,6 +18,7 @@
如果应用出现异常,请登录您购买该应用所使用的 Google 帐号。
登录
忽略
+ 正在下载 %s 所需的组件
microG Companion
%s的附件文件
文件下载中
diff --git a/vending-app/src/main/res/values/strings.xml b/vending-app/src/main/res/values/strings.xml
index f2683abdfc..7bddf617cf 100644
--- a/vending-app/src/main/res/values/strings.xml
+++ b/vending-app/src/main/res/values/strings.xml
@@ -17,18 +17,43 @@
Sign In
Ignore
+ Work app store
+ Installation required
+ Your administrator requires these apps to be installed in your managed profile.
+ Your device is missing mandatory apps chosen by your administrator.
+ Available apps
+ These are all the apps made available by your enterprise.
+ No apps have been made available by your administrator.
+ It may take a few hours after setting up your work profile before apps are ready to download.
+ Update available
+ Installed apps
+ Install
+ Update
+ Uninstall
+
Pay currently not possible
Confirm Purchase
Not connected to the internet. Please make sure Wi-Fi or mobile network is turned on and try again.
The password you entered is incorrect.
Unknown error, please exit and try again.
+ Retry
Enter your password
Remember my login on this device
Forget password?
Learn more
Verify
+ App and component installation
+ Shows app and component installation progress, success and failure messages.
+ Downloading \"%s\"
+ Installing \"%s\"
+ Installed \"%s\"
+ Failed to install \"%s\"
+
+ Required components for %s
+ Downloading required components for %s
+ Installing required components for %s
Additional files for %s
Downloading