diff --git a/play-services-api/src/main/aidl/com/google/android/gms/inappreach/internal/IInAppReachService.aidl b/play-services-api/src/main/aidl/com/google/android/gms/inappreach/internal/IInAppReachService.aidl new file mode 100644 index 0000000000..9d437f8c1e --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/inappreach/internal/IInAppReachService.aidl @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.inappreach.internal; + +import com.google.android.gms.common.api.ApiMetadata; +import com.google.android.gms.common.api.internal.IStatusCallback; +import com.google.android.gms.inappreach.internal.IOnAccountHealthAlertsListener; +import com.google.android.gms.inappreach.internal.IOnAccountMessagesListener; +import com.google.android.gms.inappreach.internal.IOnAccountDataResponseListener; + +interface IInAppReachService { + void registerAccountHealthAlerts(IStatusCallback callback, String clientPackageName, IOnAccountHealthAlertsListener listener, in ApiMetadata apiMetadata) = 0; + void unregisterAccountHealthAlerts(IStatusCallback callback, String clientPackageName, in ApiMetadata apiMetadata) = 1; + void registerAccountMessages(IStatusCallback callback, String clientPackageName, IOnAccountMessagesListener listener, in ApiMetadata apiMetadata) = 6; + void unregisterAccountMessages(IStatusCallback callback, String clientPackageName, in ApiMetadata apiMetadata) = 7; + void registerAccountDataResponse(IStatusCallback callback, String clientPackageName, IOnAccountDataResponseListener listener, in ApiMetadata apiMetadata) = 10; + void unregisterAccountDataResponse(IStatusCallback callback, String clientPackageName, in ApiMetadata apiMetadata) = 11; + void registerAccountDataResponseV2(IStatusCallback callback, String clientPackageName, IOnAccountDataResponseListener listener, in ApiMetadata apiMetadata) = 14; + void unregisterAccountDataResponseV2(IStatusCallback callback, String clientPackageName, in ApiMetadata apiMetadata) = 15; +} diff --git a/play-services-api/src/main/aidl/com/google/android/gms/inappreach/internal/IOnAccountDataResponseListener.aidl b/play-services-api/src/main/aidl/com/google/android/gms/inappreach/internal/IOnAccountDataResponseListener.aidl new file mode 100644 index 0000000000..2d29beb914 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/inappreach/internal/IOnAccountDataResponseListener.aidl @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.inappreach.internal; + +interface IOnAccountDataResponseListener { + oneway void onAccountDataResponse(in byte[] response) = 0; +} diff --git a/play-services-api/src/main/aidl/com/google/android/gms/inappreach/internal/IOnAccountHealthAlertsListener.aidl b/play-services-api/src/main/aidl/com/google/android/gms/inappreach/internal/IOnAccountHealthAlertsListener.aidl new file mode 100644 index 0000000000..7d0e15746a --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/inappreach/internal/IOnAccountHealthAlertsListener.aidl @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.inappreach.internal; + +interface IOnAccountHealthAlertsListener { + oneway void onAccountHealthAlerts(in byte[] response) = 0; +} diff --git a/play-services-api/src/main/aidl/com/google/android/gms/inappreach/internal/IOnAccountMessagesListener.aidl b/play-services-api/src/main/aidl/com/google/android/gms/inappreach/internal/IOnAccountMessagesListener.aidl new file mode 100644 index 0000000000..4114d38677 --- /dev/null +++ b/play-services-api/src/main/aidl/com/google/android/gms/inappreach/internal/IOnAccountMessagesListener.aidl @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.inappreach.internal; + +interface IOnAccountMessagesListener { + oneway void onAccountMessages(in byte[] response) = 0; +} diff --git a/play-services-core-proto/src/main/proto/inappreach.proto b/play-services-core-proto/src/main/proto/inappreach.proto new file mode 100644 index 0000000000..2a3d2a13b2 --- /dev/null +++ b/play-services-core-proto/src/main/proto/inappreach.proto @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +syntax = "proto2"; + +option java_package = "org.microg.gms.inappreach.proto"; +option java_multiple_files = true; + +message AccountDataResponse { + map accounts = 1; +} + +message AccountData { + optional string accountName = 1; + optional AccountDataPayload payload = 2; +} + +message AccountDataPayload { + optional AccountDataMetadata metadata = 1; + repeated StorageCard cards = 2; +} + +message AccountDataMetadata { + optional int32 type = 1; +} + +message StorageCard { + optional string title = 1; + optional int32 type = 2; + optional StorageProgress progress = 3; + optional StorageActions actions = 5; + optional StoragePayloadType payloadType = 6; + optional int32 style = 7; + optional int32 layout = 11; + optional int32 emphasis = 15; + optional int32 size = 18; +} + +message StorageProgress { + optional float fraction = 1; + optional string subtitle = 2; +} + +message StorageActions { + optional CallToAction primary = 1; + optional CallToAction secondary = 2; + optional int32 alignment = 3; + optional int32 arrangement = 4; +} + +message StoragePayloadType { + optional string typeUrl = 1; +} + +message CallToAction { + optional string typeUrl = 1; + optional CallToActionDetails details = 2; +} + +message CallToActionDetails { + optional CallToActionParameters parameters = 3; + optional string label = 4; + optional int32 enabled = 6; +} + +message CallToActionParameters { + optional int32 action = 3; + optional int32 source = 4; +} diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index 713deb4842..6bfca8afb1 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -562,6 +562,12 @@ + + + + + + @@ -1421,7 +1427,6 @@ - diff --git a/play-services-core/src/main/kotlin/org/microg/gms/inappreach/InAppReachService.kt b/play-services-core/src/main/kotlin/org/microg/gms/inappreach/InAppReachService.kt new file mode 100644 index 0000000000..e4ff5ce170 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/inappreach/InAppReachService.kt @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.inappreach + +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.common.GmsService +import org.microg.gms.common.PackageUtils + +private const val TAG = "InAppReachService" +private val FEATURES = arrayOf( + Feature("account_health_alerts", 1), + Feature("account_messages", 1), + Feature("account_data_response", 1), + Feature("account_data_response_v2", 1) +) + +class InAppReachService : BaseService(TAG, GmsService.INAPP_REACH) { + override fun handleServiceRequest(callback: IGmsCallbacks, request: GetServiceRequest, service: GmsService) { + val packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName) + ?: throw IllegalArgumentException("Missing package name") + callback.onPostInitCompleteWithConnectionInfo( + CommonStatusCodes.SUCCESS, + InAppReachServiceImpl(this, packageName, lifecycle).asBinder(), + ConnectionInfo().apply { features = FEATURES } + ) + } +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/inappreach/InAppReachServiceImpl.kt b/play-services-core/src/main/kotlin/org/microg/gms/inappreach/InAppReachServiceImpl.kt new file mode 100644 index 0000000000..d6503c8f2d --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/inappreach/InAppReachServiceImpl.kt @@ -0,0 +1,258 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.inappreach + +import android.accounts.AccountManager +import android.content.Context +import android.util.Log +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.android.volley.RequestQueue +import com.android.volley.toolbox.JsonObjectRequest +import com.android.volley.toolbox.Volley +import com.google.android.gms.R +import com.google.android.gms.common.api.ApiMetadata +import com.google.android.gms.common.api.Status +import com.google.android.gms.common.api.internal.IStatusCallback +import com.google.android.gms.inappreach.internal.IInAppReachService +import com.google.android.gms.inappreach.internal.IOnAccountDataResponseListener +import com.google.android.gms.inappreach.internal.IOnAccountHealthAlertsListener +import com.google.android.gms.inappreach.internal.IOnAccountMessagesListener +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import org.json.JSONObject +import org.microg.gms.auth.AuthConstants +import org.microg.gms.auth.AuthManager +import org.microg.gms.common.Constants +import org.microg.gms.inappreach.proto.AccountData +import org.microg.gms.inappreach.proto.AccountDataMetadata +import org.microg.gms.inappreach.proto.AccountDataPayload +import org.microg.gms.inappreach.proto.AccountDataResponse +import org.microg.gms.inappreach.proto.CallToAction +import org.microg.gms.inappreach.proto.CallToActionDetails +import org.microg.gms.inappreach.proto.CallToActionParameters +import org.microg.gms.inappreach.proto.StorageActions +import org.microg.gms.inappreach.proto.StorageCard +import org.microg.gms.inappreach.proto.StoragePayloadType +import org.microg.gms.inappreach.proto.StorageProgress +import org.microg.gms.utils.singleInstanceOf +import java.util.Locale +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private const val TAG = "InAppReachService" +private const val DRIVE_SCOPE = "oauth2:https://www.googleapis.com/auth/drive.metadata.readonly" +private const val DRIVE_QUOTA_URL = "https://www.googleapis.com/drive/v3/about?fields=storageQuota" +private const val CALL_TO_ACTION_TYPE = "type.googleapis.com/google.internal.subscriptions.firstparty.v1.CallToAction" +private const val CALL_TO_ACTION_GET_STORAGE = 1 +private const val CALL_TO_ACTION_CLEAN_UP_STORAGE = 3 +private const val CALL_TO_ACTION_SOURCE = 465 + +class InAppReachServiceImpl( + private val context: Context, + private val packageName: String, + override val lifecycle: Lifecycle +) : IInAppReachService.Stub(), LifecycleOwner { + private var responseJob: Job? = null + + override fun registerAccountHealthAlerts( + callback: IStatusCallback?, + clientPackageName: String?, + listener: IOnAccountHealthAlertsListener?, + apiMetadata: ApiMetadata? + ) = acknowledge(clientPackageName, callback) + + override fun unregisterAccountHealthAlerts( + callback: IStatusCallback?, + clientPackageName: String?, + apiMetadata: ApiMetadata? + ) = acknowledge(clientPackageName, callback) + + override fun registerAccountMessages( + callback: IStatusCallback?, + clientPackageName: String?, + listener: IOnAccountMessagesListener?, + apiMetadata: ApiMetadata? + ) = acknowledge(clientPackageName, callback) + + override fun unregisterAccountMessages( + callback: IStatusCallback?, + clientPackageName: String?, + apiMetadata: ApiMetadata? + ) = acknowledge(clientPackageName, callback) + + override fun registerAccountDataResponse( + callback: IStatusCallback?, + clientPackageName: String?, + listener: IOnAccountDataResponseListener?, + apiMetadata: ApiMetadata? + ) { + require(clientPackageName == packageName) { "Package name mismatch" } + responseJob?.cancel() + responseJob = listener?.let { + lifecycleScope.launch(Dispatchers.IO) { + val response = buildAccountDataResponse(context) + currentCoroutineContext().ensureActive() + notifyAccountDataResponse(it, response) + } + } + notifySuccess(callback) + } + + override fun unregisterAccountDataResponse( + callback: IStatusCallback?, + clientPackageName: String?, + apiMetadata: ApiMetadata? + ) { + unregister(clientPackageName, callback) + } + + override fun registerAccountDataResponseV2( + callback: IStatusCallback?, + clientPackageName: String?, + listener: IOnAccountDataResponseListener?, + apiMetadata: ApiMetadata? + ) { + registerAccountDataResponse(callback, clientPackageName, listener, apiMetadata) + } + + override fun unregisterAccountDataResponseV2( + callback: IStatusCallback?, + clientPackageName: String?, + apiMetadata: ApiMetadata? + ) { + unregister(clientPackageName, callback) + } + + private fun unregister(clientPackageName: String?, callback: IStatusCallback?) { + require(clientPackageName == packageName) { "Package name mismatch" } + responseJob?.cancel() + responseJob = null + notifySuccess(callback) + } + + private fun acknowledge(clientPackageName: String?, callback: IStatusCallback?) { + require(clientPackageName == packageName) { "Package name mismatch" } + notifySuccess(callback) + } +} + +private suspend fun buildAccountDataResponse(context: Context): ByteArray { + val accounts = linkedMapOf() + for (account in AccountManager.get(context).getAccountsByType(AuthConstants.DEFAULT_ACCOUNT_TYPE)) { + currentCoroutineContext().ensureActive() + try { + val (usage, limit) = getStorageQuota(context, account.name) ?: continue + if (limit <= 0) continue + accounts[account.name] = AccountData( + accountName = account.name, + payload = AccountDataPayload( + metadata = AccountDataMetadata(type = 1), + cards = listOf(storageCard(context, usage, limit)) + ) + ) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Unable to load storage quota", e) + } + } + return AccountDataResponse.ADAPTER.encode(AccountDataResponse(accounts)) +} + +private suspend fun getStorageQuota( + context: Context, + accountName: String, + queue: RequestQueue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) } +): Pair? { + val auth = AuthManager(context, accountName, Constants.GMS_PACKAGE_NAME, DRIVE_SCOPE).apply { + isGmsApp = true + }.requestAuth(false).auth ?: return null + val response = suspendCancellableCoroutine { continuation -> + val request = object : JsonObjectRequest( + DRIVE_QUOTA_URL, + { if (continuation.isActive) continuation.resume(it) }, + { if (continuation.isActive) continuation.resumeWithException(it) } + ) { + override fun getHeaders(): MutableMap = mutableMapOf( + "Authorization" to "Bearer $auth" + ) + } + continuation.invokeOnCancellation { request.cancel() } + queue.add(request) + } + val quota = response.getJSONObject("storageQuota") + return quota.getLong("usage") to quota.getLong("limit") +} + +private fun storageCard(context: Context, usage: Long, limit: Long): StorageCard { + val percent = usage * 100 / limit + val total = formatBytes(limit) + return StorageCard( + title = context.getString(R.string.in_app_reach_storage_usage, percent, total), + type = 1, + progress = StorageProgress( + fraction = (usage.toFloat() / limit).coerceIn(0f, 1f), + subtitle = "${formatBytes(usage)}/$total" + ), + actions = StorageActions( + primary = callToAction(CALL_TO_ACTION_GET_STORAGE, context.getString(R.string.in_app_reach_get_storage)), + secondary = callToAction(CALL_TO_ACTION_CLEAN_UP_STORAGE, context.getString(R.string.in_app_reach_clean_up_storage)), + alignment = 0, + arrangement = 1 + ), + payloadType = StoragePayloadType(typeUrl = CALL_TO_ACTION_TYPE), + style = 1, + layout = 2, + emphasis = 1, + size = 2 + ) +} + +private fun callToAction(kind: Int, label: String) = CallToAction( + typeUrl = CALL_TO_ACTION_TYPE, + details = CallToActionDetails( + parameters = CallToActionParameters( + action = kind, + source = CALL_TO_ACTION_SOURCE + ), + label = label, + enabled = 1 + ) +) + +private fun formatBytes(bytes: Long): String = when { + bytes >= 1024L * 1024 * 1024 -> formatUnit(bytes, 1024L * 1024 * 1024, "GB") + bytes >= 1024L * 1024 -> formatUnit(bytes, 1024L * 1024, "MB") + else -> formatUnit(bytes, 1024L, "KB") +} + +private fun formatUnit(bytes: Long, unit: Long, suffix: String): String { + val value = bytes.toDouble() / unit + return if (value == value.toLong().toDouble()) "${value.toLong()} $suffix" + else String.format(Locale.US, "%.2f %s", value, suffix) +} + +private fun notifySuccess(callback: IStatusCallback?) { + try { + callback?.onResult(Status.SUCCESS) + } catch (e: Exception) { + Log.w(TAG, "Unable to notify status callback", e) + } +} + +private fun notifyAccountDataResponse(listener: IOnAccountDataResponseListener, response: ByteArray) { + try { + listener.onAccountDataResponse(response) + } catch (e: Exception) { + Log.w(TAG, "Unable to notify account data response listener", e) + } +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/phenotype/PhenotypeService.kt b/play-services-core/src/main/kotlin/org/microg/gms/phenotype/PhenotypeService.kt index 39cc8f9a0d..af7bda1f65 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/phenotype/PhenotypeService.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/phenotype/PhenotypeService.kt @@ -125,6 +125,13 @@ private val CONFIGURATION_OPTIONS = mapOf( Flag("45668769", true, 0), Flag("45633067", true, 0), ), + "gmail_android.device#com.google.android.gm" to arrayOf( + Flag("45729659", true, 0), + ), + "com.google.android.libraries.subscriptions#com.google.android.gm" to arrayOf( + Flag("45651738", true, 0), + Flag("45667364", true, 0), + ), "com.google.android.apps.photos" to arrayOf( Flag("45617431", true, 0), // Enable v3 movie editor; legacy MovieEditorActivity NPEs on a missing media extra 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 86a5a7362f..9ff2505ec7 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 @@ -417,4 +417,9 @@ microG GmsCore 内置一套自由的 SafetyNet 实现,但是官方服务器要 蓝牙 混合 ID: %1$s + + + 已使用 %1$d%%,共 %2$s + 获取存储空间 + 清理存储空间 diff --git a/play-services-core/src/main/res/values-zh-rTW/strings.xml b/play-services-core/src/main/res/values-zh-rTW/strings.xml index bd9ff44bd3..262e44c5dc 100644 --- a/play-services-core/src/main/res/values-zh-rTW/strings.xml +++ b/play-services-core/src/main/res/values-zh-rTW/strings.xml @@ -422,4 +422,9 @@ 藍牙 混合 ID: %1$s + + + 已使用 %1$d%%,共 %2$s + 取得儲存空間 + 清理儲存空間 diff --git a/play-services-core/src/main/res/values/strings.xml b/play-services-core/src/main/res/values/strings.xml index 48d05db18b..2b850e3aa7 100644 --- a/play-services-core/src/main/res/values/strings.xml +++ b/play-services-core/src/main/res/values/strings.xml @@ -475,4 +475,9 @@ Please set up a password, PIN, or pattern lock screen." Bluetooth Hybrid ID: %1$s + + + %1$d%% of %2$s used + Get storage + Clean up storage