From 21840b2d120a7528bddc43079fb9c94c269c7e86 Mon Sep 17 00:00:00 2001 From: Davinci9196 Date: Mon, 17 Aug 2026 16:29:04 +0800 Subject: [PATCH 1/3] fix(fitness): align step recording and history with GMS behavior --- .../core/src/main/AndroidManifest.xml | 2 + .../fitness/service/FitnessStepRecorder.kt | 191 ++++++++++++++++++ .../service/history/FitHistoryBroker.kt | 105 +++++++++- .../service/recording/FitRecordingBroker.kt | 38 +++- .../android/gms/fitness/data/RawBucket.java | 66 ++++++ .../gms/fitness/data/RawDataPoint.java | 67 ++++++ .../android/gms/fitness/data/RawDataSet.java | 45 +++++ .../gms/fitness/request/DataReadResult.java | 15 +- .../microg/safeparcel/SafeParcelProcessor.kt | 1 + 9 files changed, 510 insertions(+), 20 deletions(-) create mode 100644 play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/FitnessStepRecorder.kt create mode 100644 play-services-fitness/src/main/java/com/google/android/gms/fitness/data/RawBucket.java create mode 100644 play-services-fitness/src/main/java/com/google/android/gms/fitness/data/RawDataPoint.java create mode 100644 play-services-fitness/src/main/java/com/google/android/gms/fitness/data/RawDataSet.java diff --git a/play-services-fitness/core/src/main/AndroidManifest.xml b/play-services-fitness/core/src/main/AndroidManifest.xml index ab5b28aadb..3f8b9d192d 100644 --- a/play-services-fitness/core/src/main/AndroidManifest.xml +++ b/play-services-fitness/core/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + (value as? String)?.split(',')?.takeIf { it.size == 3 }?.let { + val start = it[0].toLongOrNull() ?: return@let null + val end = it[1].toLongOrNull() ?: return@let null + val steps = it[2].toIntOrNull() ?: return@let null + StepSample(start, end, steps) + } + else -> sampleMinute(key)?.let { minute -> + val steps = value as? Int ?: return@let null + StepSample(minute, preferences.getLong("$SAMPLE_TIME_PREFIX$minute", minute + MINUTE_MILLIS), steps) + } +} + +internal object FitnessStepRecorder : SensorEventListener { + private var preferences: SharedPreferences? = null + private var sensorManager: SensorManager? = null + private var registered = false + + private fun preferences(context: Context): SharedPreferences = preferences + ?: context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).also { preferences = it } + + private fun subscriptionPreferences(context: Context): SharedPreferences = + context.applicationContext.getSharedPreferences(SUBSCRIPTION_PREFS_NAME, Context.MODE_PRIVATE) + + private fun subscriptionKey(clientId: String): String = SUBSCRIPTION_PREFIX + Base64.encodeToString( + clientId.toByteArray(Charsets.UTF_8), Base64.URL_SAFE or Base64.NO_WRAP + ) + + private fun decodeSubscription(value: String?): Subscription? { + value ?: return null + return runCatching { + SafeParcelableSerializer.deserializeFromBytes(Base64.decode(value, Base64.DEFAULT), Subscription.CREATOR) + }.getOrNull() + } + + @Synchronized + private fun start(context: Context): Boolean { + if (registered) return true + val appContext = context.applicationContext + preferences(appContext) + val manager = appContext.getSystemService(Context.SENSOR_SERVICE) as? SensorManager ?: return false + val sensor = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) ?: return false + return try { + manager.registerListener(this, sensor, SensorManager.SENSOR_DELAY_NORMAL).also { success -> + registered = success + sensorManager = manager.takeIf { success } + } + } catch (e: SecurityException) { + Log.w(TAG, "Activity recognition permission is missing", e) + false + } + } + + @Synchronized + private fun stop() { + if (registered) sensorManager?.unregisterListener(this) + registered = false + sensorManager = null + } + + @Synchronized + fun resume(context: Context) { + if (subscriptionPreferences(context).all.keys.any { it.startsWith(SUBSCRIPTION_PREFIX) }) start(context) + } + + @Synchronized + fun subscribe(context: Context, clientId: String, subscription: Subscription): Boolean { + if (!start(context)) return false + val encoded = Base64.encodeToString(SafeParcelableSerializer.serializeToBytes(subscription), Base64.NO_WRAP) + subscriptionPreferences(context).edit { putString(subscriptionKey(clientId), encoded) } + return true + } + + @Synchronized + fun unsubscribe(context: Context, clientId: String, dataType: DataType?, dataSource: DataSource?) { + if (dataType == null && dataSource == null) return + val preferences = subscriptionPreferences(context) + val key = subscriptionKey(clientId) + decodeSubscription(preferences.getString(key, null))?.let { subscription -> + val subscribedType = subscription.dataType ?: subscription.dataSource?.dataType + val matchesType = dataType == null || dataType.name == subscribedType?.name + val matchesSource = dataSource == null || dataSource.streamIdentifier == subscription.dataSource?.streamIdentifier + if (matchesType && matchesSource) preferences.edit { remove(key) } + } + if (preferences.all.keys.none { it.startsWith(SUBSCRIPTION_PREFIX) }) stop() + } + + @Synchronized + fun subscriptions(context: Context, clientId: String, dataType: DataType?): List = + listOfNotNull(decodeSubscription(subscriptionPreferences(context).getString(subscriptionKey(clientId), null))) + .filter { dataType == null || dataType.name == (it.dataType ?: it.dataSource?.dataType)?.name } + + @Synchronized + fun samples(context: Context, startTimeMillis: Long, endTimeMillis: Long): List { + if (endTimeMillis <= startTimeMillis) return emptyList() + val preferences = preferences(context) + return preferences.all.mapNotNull { (key, value) -> + storedSample(preferences, key, value)?.takeIf { + it.endTimeMillis > startTimeMillis && it.endTimeMillis <= endTimeMillis + }?.let { it.copy(startTimeMillis = maxOf(it.startTimeMillis, startTimeMillis)) } + }.sortedBy { it.endTimeMillis } + } + + @Synchronized + fun deleteSamples(context: Context, startTimeMillis: Long, endTimeMillis: Long) { + if (endTimeMillis <= startTimeMillis) return + val preferences = preferences(context) + val keys = preferences.all.mapNotNull { (key, value) -> + key.takeIf { + storedSample(preferences, key, value)?.let { sample -> + sample.endTimeMillis > startTimeMillis && sample.endTimeMillis <= endTimeMillis + } == true + } + } + preferences.edit { + keys.forEach { key -> + remove(key) + sampleMinute(key)?.let { remove("$SAMPLE_TIME_PREFIX$it") } + } + } + } + + @Synchronized + override fun onSensorChanged(event: SensorEvent) { + if (event.sensor.type != Sensor.TYPE_STEP_COUNTER) return + val value = event.values.firstOrNull()?.takeIf { it.isFinite() } ?: return + val rawSteps = value.toInt() + if (rawSteps < 0) return + val preferences = preferences ?: return + preferences.edit { + val previousRawSteps = preferences.getInt(LAST_RAW_STEPS, -1) + val now = System.currentTimeMillis() + val previousEventTime = preferences.getLong(LAST_EVENT_TIME, now) + putInt(LAST_RAW_STEPS, rawSteps) + putLong(LAST_EVENT_TIME, now) + val steps = when { + previousRawSteps < 0 -> 0 + rawSteps >= previousRawSteps -> rawSteps - previousRawSteps + else -> rawSteps + } + if (steps > 0) { + val eventId = preferences.getLong(NEXT_EVENT_ID, 0) + val start = previousEventTime.takeIf { it <= now } ?: now + putLong(NEXT_EVENT_ID, eventId + 1) + putString("$EVENT_PREFIX$eventId", "$start,$now,$steps") + } + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit +} diff --git a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/history/FitHistoryBroker.kt b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/history/FitHistoryBroker.kt index bee9f3091e..755f9f6698 100644 --- a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/history/FitHistoryBroker.kt +++ b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/history/FitHistoryBroker.kt @@ -6,6 +6,7 @@ package com.google.android.gms.fitness.service.history import com.google.android.gms.fitness.service.FITNESS_FEATURES +import android.content.Context import android.os.Parcel import android.util.Log import com.google.android.gms.common.api.CommonStatusCodes @@ -13,6 +14,14 @@ 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 com.google.android.gms.fitness.internal.IGoogleFitHistoryApi +import com.google.android.gms.common.api.Status +import com.google.android.gms.fitness.data.Bucket +import com.google.android.gms.fitness.data.DataPoint +import com.google.android.gms.fitness.data.DataSet +import com.google.android.gms.fitness.data.DataSource +import com.google.android.gms.fitness.data.DataType +import com.google.android.gms.fitness.data.RawBucket +import com.google.android.gms.fitness.data.RawDataSet import com.google.android.gms.fitness.request.DailyTotalRequest import com.google.android.gms.fitness.request.DataDeleteRequest import com.google.android.gms.fitness.request.DataInsertRequest @@ -27,25 +36,95 @@ import com.google.android.gms.fitness.request.GetSyncInfoRequest import com.google.android.gms.fitness.request.ReadRawRequest import com.google.android.gms.fitness.request.ReadStatsRequest import com.google.android.gms.fitness.request.SessionChangesRequest +import com.google.android.gms.fitness.request.DataReadResult +import com.google.android.gms.fitness.service.FitnessStepRecorder +import com.google.android.gms.fitness.service.StepSample import org.microg.gms.BaseService +import org.microg.gms.common.Constants import org.microg.gms.common.GmsService import org.microg.gms.utils.warnOnTransactionIssues +import java.util.concurrent.TimeUnit private const val TAG = "FitHistoryBroker" class FitHistoryBroker : BaseService(TAG, GmsService.FIT_HISTORY) { override fun handleServiceRequest(callback: IGmsCallbacks, request: GetServiceRequest, service: GmsService) { - callback.onPostInitCompleteWithConnectionInfo(CommonStatusCodes.SUCCESS, FitHistoryBrokerImpl().asBinder(), + callback.onPostInitCompleteWithConnectionInfo(CommonStatusCodes.SUCCESS, FitHistoryBrokerImpl(applicationContext).asBinder(), ConnectionInfo().apply { features = FITNESS_FEATURES }) } } -class FitHistoryBrokerImpl : IGoogleFitHistoryApi.Stub() { +class FitHistoryBrokerImpl(private val context: Context) : IGoogleFitHistoryApi.Stub() { override fun readData(request: DataReadRequest?) { - Log.d(TAG, "Not implemented readData: $request") + Log.d(TAG, "readData: $request") + if (request == null) return + val requestedTypes = request.dataTypes.orEmpty() + request.aggregatedDataTypes.orEmpty() + + request.dataSources.orEmpty().map { it.dataType } + + request.aggregatedDataSources.orEmpty().map { it.dataType } + if (requestedTypes.none { it.name == DataType.TYPE_STEP_COUNT_DELTA.name }) { + return request.callback.onPostResult(dataReadResult(Status(5008))) + } + + FitnessStepRecorder.resume(context) + val samples = FitnessStepRecorder.samples(context, request.startTimeMillis, request.endTimeMillis) + val dataSources = mutableListOf() + val result = dataReadResult(Status.SUCCESS).apply { uniqueDataSources = dataSources } + if (request.bucketDurationMillis > 0) { + if (request.bucketType != Bucket.TYPE_TIME) { + return request.callback.onPostResult(dataReadResult(Status(5012))) + } + result.rawBuckets = buildBuckets(request, samples, dataSources) + } else { + result.rawDataSets = listOf(buildRawDataSet(samples, dataSources)) + } + request.callback.onPostResult(result) + } + + private fun dataReadResult(status: Status) = DataReadResult().apply { + this.status = status + rawDataSets = emptyList() + rawBuckets = emptyList() + uniqueDataSources = emptyList() + batchCount = 1 + } + + private fun buildRawDataSet(samples: List, dataSources: MutableList) = + RawDataSet(DataSet.builder(STEP_DATA_SOURCE).apply { + samples.forEach { sample -> + add(DataPoint.builder(STEP_DATA_SOURCE) + .setIntValues(sample.steps) + .setTimeInterval(sample.startTimeMillis, sample.endTimeMillis, TimeUnit.MILLISECONDS) + .build()) + } + }.build(), dataSources) + + private fun buildBuckets( + request: DataReadRequest, + samples: List, + dataSources: MutableList + ): List = buildList { + var start = request.startTimeMillis + while (start < request.endTimeMillis) { + val end = if (start > Long.MAX_VALUE - request.bucketDurationMillis) { + request.endTimeMillis + } else { + minOf(start + request.bucketDurationMillis, request.endTimeMillis) + } + val steps = samples.filter { it.endTimeMillis > start && it.endTimeMillis <= end }.sumOf { it.steps } + if (steps > 0) { + val dataSet = DataSet.builder(STEP_DATA_SOURCE) + .add(DataPoint.builder(STEP_DATA_SOURCE) + .setIntValues(steps) + .setTimeInterval(start, end, TimeUnit.MILLISECONDS) + .build()) + .build() + add(RawBucket(start, end, null, 4, listOf(RawDataSet(dataSet, dataSources)), Bucket.TYPE_TIME)) + } + start = end + } } override fun insertData(request: DataInsertRequest?) { @@ -53,7 +132,16 @@ class FitHistoryBrokerImpl : IGoogleFitHistoryApi.Stub() { } override fun deleteData(request: DataDeleteRequest?) { - Log.d(TAG, "Not implemented deleteData: $request") + Log.d(TAG, "deleteData: $request") + if (request == null) return + val stepTypes = setOf(DataType.TYPE_STEP_COUNT_DELTA.name, DataType.TYPE_STEP_COUNT_CUMULATIVE.name) + val deletesSteps = request.deleteAllData || + request.dataTypes.orEmpty().any { it.name in stepTypes } || + request.dataSources.orEmpty().any { it.dataType.name in stepTypes } + if (deletesSteps) { + FitnessStepRecorder.deleteSamples(context, request.startTimeMillis, request.endTimeMillis) + } + request.callback?.onResult(Status.SUCCESS) } override fun getSyncInfo(request: GetSyncInfoRequest) { @@ -106,4 +194,13 @@ class FitHistoryBrokerImpl : IGoogleFitHistoryApi.Stub() { override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean = warnOnTransactionIssues(code, reply, flags, TAG) { super.onTransact(code, data, reply, flags) } + + companion object { + private val STEP_DATA_SOURCE = DataSource.Builder() + .setAppPackageName(Constants.GMS_PACKAGE_NAME) + .setDataType(DataType.TYPE_STEP_COUNT_DELTA) + .setType(DataSource.TYPE_DERIVED) + .setStreamName("estimated_steps") + .build() + } } diff --git a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/recording/FitRecordingBroker.kt b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/recording/FitRecordingBroker.kt index 3561646ce5..571c35922c 100644 --- a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/recording/FitRecordingBroker.kt +++ b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/recording/FitRecordingBroker.kt @@ -6,28 +6,37 @@ package com.google.android.gms.fitness.service.recording import com.google.android.gms.fitness.service.FITNESS_FEATURES +import android.content.Context +import android.os.Parcel import android.util.Log import com.google.android.gms.common.api.CommonStatusCodes import com.google.android.gms.common.api.Status 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 com.google.android.gms.fitness.data.Subscription +import com.google.android.gms.fitness.data.DataType import com.google.android.gms.fitness.internal.IGoogleFitRecordingApi import com.google.android.gms.fitness.request.ListSubscriptionsRequest import com.google.android.gms.fitness.request.SubscribeRequest import com.google.android.gms.fitness.request.UnsubscribeRequest +import com.google.android.gms.fitness.service.FitnessStepRecorder import com.google.android.gms.fitness.result.ListSubscriptionsResult import org.microg.gms.BaseService import org.microg.gms.common.GmsService +import org.microg.gms.common.PackageUtils +import org.microg.gms.utils.warnOnTransactionIssues private const val TAG = "FitRecordingBroker" class FitRecordingBroker : BaseService(TAG, GmsService.FIT_RECORDING) { override fun handleServiceRequest(callback: IGmsCallbacks, request: GetServiceRequest, service: GmsService) { - Log.d(TAG, "handleServiceRequest: account: ${request.account.name} packageName: ${request.packageName}") - callback.onPostInitCompleteWithConnectionInfo(CommonStatusCodes.SUCCESS, FitRecordingBrokerImpl(), + val packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName) + ?: throw IllegalArgumentException("Missing package name") + val clientId = "${request.account?.name.orEmpty()}\n$packageName" + Log.d(TAG, "handleServiceRequest: packageName: $packageName") + FitnessStepRecorder.resume(this) + callback.onPostInitCompleteWithConnectionInfo(CommonStatusCodes.SUCCESS, FitRecordingBrokerImpl(applicationContext, clientId), ConnectionInfo().apply { features = FITNESS_FEATURES }) @@ -35,21 +44,32 @@ class FitRecordingBroker : BaseService(TAG, GmsService.FIT_RECORDING) { } -class FitRecordingBrokerImpl() : IGoogleFitRecordingApi.Stub() { +class FitRecordingBrokerImpl( + private val context: Context, + private val clientId: String +) : IGoogleFitRecordingApi.Stub() { override fun subscribe(request: SubscribeRequest) { - Log.d(TAG, "Not yet implemented subscribe request: $request") - return request.callback.onResult(Status.SUCCESS) + Log.d(TAG, "subscribe request: $request") + val dataType = request.subscription.dataType ?: request.subscription.dataSource?.dataType + val success = dataType?.name == DataType.TYPE_STEP_COUNT_DELTA.name && + FitnessStepRecorder.subscribe(context, clientId, request.subscription) + return request.callback.onResult(if (success) Status.SUCCESS else Status(5008)) } override fun unsubscribe(request: UnsubscribeRequest) { - Log.d(TAG, "Not yet implemented unsubscribe request: $request") + Log.d(TAG, "unsubscribe request: $request") + FitnessStepRecorder.unsubscribe(context, clientId, request.dataType, request.dataSource) request.callback.onResult(Status.SUCCESS) } override fun listSubscriptions(request: ListSubscriptionsRequest) { - Log.d(TAG, "Not yet implemented listSubscriptions request: $request") - return request.callback.onListSubscriptions(ListSubscriptionsResult(emptyList(), Status(5008))) + Log.d(TAG, "listSubscriptions request: $request") + return request.callback.onListSubscriptions(ListSubscriptionsResult( + FitnessStepRecorder.subscriptions(context, clientId, request.dataType), Status.SUCCESS + )) } + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean = + warnOnTransactionIssues(code, reply, flags, TAG) { super.onTransact(code, data, reply, flags) } } \ No newline at end of file diff --git a/play-services-fitness/src/main/java/com/google/android/gms/fitness/data/RawBucket.java b/play-services-fitness/src/main/java/com/google/android/gms/fitness/data/RawBucket.java new file mode 100644 index 0000000000..ea59850037 --- /dev/null +++ b/play-services-fitness/src/main/java/com/google/android/gms/fitness/data/RawBucket.java @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.fitness.data; + +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@SafeParcelable.Class +public final class RawBucket extends AbstractSafeParcelable { + @Field(1) + final long startTimeMillis; + @Field(2) + final long endTimeMillis; + @Field(3) + @Nullable + final Session session; + @Field(4) + final int activityType; + @Field(5) + final List dataSets; + @Field(6) + final int bucketType; + + @Constructor + public RawBucket(@Param(1) long startTimeMillis, @Param(2) long endTimeMillis, + @Nullable @Param(3) Session session, @Param(4) int activityType, + @Param(5) List dataSets, @Param(6) int bucketType) { + this.startTimeMillis = startTimeMillis; + this.endTimeMillis = endTimeMillis; + this.session = session; + this.activityType = activityType; + this.dataSets = dataSets; + this.bucketType = bucketType; + } + + public RawBucket(Bucket bucket, List dataSources) { + this.startTimeMillis = bucket.getStartTime(TimeUnit.MILLISECONDS); + this.endTimeMillis = bucket.getEndTime(TimeUnit.MILLISECONDS); + this.session = bucket.getSession(); + this.activityType = bucket.getActivityType(); + this.bucketType = bucket.getBucketType(); + List sets = bucket.getDataSets(); + this.dataSets = new ArrayList<>(sets.size()); + for (DataSet set : sets) dataSets.add(new RawDataSet(set, dataSources)); + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(RawBucket.class); +} diff --git a/play-services-fitness/src/main/java/com/google/android/gms/fitness/data/RawDataPoint.java b/play-services-fitness/src/main/java/com/google/android/gms/fitness/data/RawDataPoint.java new file mode 100644 index 0000000000..62b4c1fbd6 --- /dev/null +++ b/play-services-fitness/src/main/java/com/google/android/gms/fitness/data/RawDataPoint.java @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.fitness.data; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +import java.util.List; + +@SafeParcelable.Class +public final class RawDataPoint extends AbstractSafeParcelable { + @Field(1) + final long timestampNanos; + @Field(2) + final long startTimeNanos; + @Field(3) + final Value[] values; + @Field(4) + final int dataSourceIndex; + @Field(5) + final int originalDataSourceIndex; + @Field(6) + final long rawTimestamp; + + @Constructor + public RawDataPoint(@Param(1) long timestampNanos, @Param(2) long startTimeNanos, + @Param(3) Value[] values, @Param(4) int dataSourceIndex, + @Param(5) int originalDataSourceIndex, @Param(6) long rawTimestamp) { + this.timestampNanos = timestampNanos; + this.startTimeNanos = startTimeNanos; + this.values = values; + this.dataSourceIndex = dataSourceIndex; + this.originalDataSourceIndex = originalDataSourceIndex; + this.rawTimestamp = rawTimestamp; + } + + RawDataPoint(DataPoint dataPoint, List dataSources) { + this(dataPoint.getTimestampNanos(), dataPoint.getStartTimeNanos(), dataPoint.getValues(), + indexOf(dataPoint.getDataSource(), dataSources), + indexOf(dataPoint.getOriginalDataSourceIfSet(), dataSources), dataPoint.getRawTimestamp()); + } + + static int indexOf(DataSource dataSource, List dataSources) { + if (dataSource == null) return -1; + int index = dataSources.indexOf(dataSource); + if (index < 0) { + dataSources.add(dataSource); + index = dataSources.size() - 1; + } + return index; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(RawDataPoint.class); +} diff --git a/play-services-fitness/src/main/java/com/google/android/gms/fitness/data/RawDataSet.java b/play-services-fitness/src/main/java/com/google/android/gms/fitness/data/RawDataSet.java new file mode 100644 index 0000000000..8d3d5a7b04 --- /dev/null +++ b/play-services-fitness/src/main/java/com/google/android/gms/fitness/data/RawDataSet.java @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.fitness.data; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +import java.util.ArrayList; +import java.util.List; + +@SafeParcelable.Class +public final class RawDataSet extends AbstractSafeParcelable { + @Field(1) + final int dataSourceIndex; + @Field(3) + final List dataPoints; + + @Constructor + public RawDataSet(@Param(1) int dataSourceIndex, @Param(3) List dataPoints) { + this.dataSourceIndex = dataSourceIndex; + this.dataPoints = dataPoints; + } + + public RawDataSet(DataSet dataSet, List dataSources) { + this.dataSourceIndex = RawDataPoint.indexOf(dataSet.getDataSource(), dataSources); + List points = dataSet.getRawDataPoints(); + this.dataPoints = new ArrayList<>(points.size()); + for (DataPoint point : points) dataPoints.add(new RawDataPoint(point, dataSources)); + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(RawDataSet.class); +} diff --git a/play-services-fitness/src/main/java/com/google/android/gms/fitness/request/DataReadResult.java b/play-services-fitness/src/main/java/com/google/android/gms/fitness/request/DataReadResult.java index dd091a6543..2cf8c6a0c6 100644 --- a/play-services-fitness/src/main/java/com/google/android/gms/fitness/request/DataReadResult.java +++ b/play-services-fitness/src/main/java/com/google/android/gms/fitness/request/DataReadResult.java @@ -13,24 +13,25 @@ import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; import com.google.android.gms.common.internal.safeparcel.SafeParcelable; import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; -import com.google.android.gms.fitness.data.Bucket; -import com.google.android.gms.fitness.data.DataSet; +import com.google.android.gms.fitness.data.DataSource; +import com.google.android.gms.fitness.data.RawBucket; +import com.google.android.gms.fitness.data.RawDataSet; import java.util.List; @SafeParcelable.Class public class DataReadResult extends AbstractSafeParcelable { - @Field(1) - public List rawDataSets; + @Field(value = 1, useValueParcel = true) + public List rawDataSets; @Field(2) public Status status; - @Field(3) - public List rawBuckets; + @Field(value = 3, useValueParcel = true) + public List rawBuckets; @Field(5) public int batchCount; @Field(6) - public List uniqueDataSources; + public List uniqueDataSources; @Override public void writeToParcel(@NonNull Parcel dest, int flags) { diff --git a/safe-parcel-processor/src/main/kotlin/org/microg/safeparcel/SafeParcelProcessor.kt b/safe-parcel-processor/src/main/kotlin/org/microg/safeparcel/SafeParcelProcessor.kt index 16606bdea3..004d186dca 100644 --- a/safe-parcel-processor/src/main/kotlin/org/microg/safeparcel/SafeParcelProcessor.kt +++ b/safe-parcel-processor/src/main/kotlin/org/microg/safeparcel/SafeParcelProcessor.kt @@ -407,6 +407,7 @@ class FieldInfo(val clazz: ClassInfo, val fieldElement: VariableElement) { } else -> when { + isList && isParcelable && useValueParcel -> "$SafeParcelWriter.write(parcel, $id, $variableName, $mayNull);" isParcelable -> "$SafeParcelWriter.write(parcel, $id, $variableName, flags, $mayNull);" isIInterface -> "$SafeParcelWriter.write(parcel, $id, $variableName.asBinder(), $mayNull);" else -> "$SafeParcelWriter.write(parcel, $id, $variableName, $mayNull);" From f74c28abfa81e3e5be3244e02fd6d73159a5460d Mon Sep 17 00:00:00 2001 From: Davinci9196 Date: Tue, 18 Aug 2026 11:44:48 +0800 Subject: [PATCH 2/3] add permission request --- .../core/src/main/AndroidManifest.xml | 10 +++- .../service/FitnessPermissionActivity.kt | 56 +++++++++++++++++++ .../fitness/service/FitnessStepRecorder.kt | 15 +++++ .../service/history/FitHistoryBroker.kt | 6 ++ .../service/recording/FitRecordingBroker.kt | 2 + 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/FitnessPermissionActivity.kt diff --git a/play-services-fitness/core/src/main/AndroidManifest.xml b/play-services-fitness/core/src/main/AndroidManifest.xml index 3f8b9d192d..e1ce7838de 100644 --- a/play-services-fitness/core/src/main/AndroidManifest.xml +++ b/play-services-fitness/core/src/main/AndroidManifest.xml @@ -6,9 +6,17 @@ + + + - \ No newline at end of file + diff --git a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/FitnessPermissionActivity.kt b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/FitnessPermissionActivity.kt new file mode 100644 index 0000000000..36b741867f --- /dev/null +++ b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/FitnessPermissionActivity.kt @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.fitness.service + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.util.Log +import androidx.core.content.edit + +private const val TAG = "FitnessPermission" +private const val PREFS_NAME = "fitness-permissions" +private const val PERMISSION_REQUESTED = "activity-recognition-requested" +private const val REQUEST_ACTIVITY_RECOGNITION = 1 + +internal fun Context.ensureActivityRecognitionPermission(): Boolean { + if (android.os.Build.VERSION.SDK_INT < 29 || + checkSelfPermission(Manifest.permission.ACTIVITY_RECOGNITION) == PackageManager.PERMISSION_GRANTED + ) return true + + val preferences = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + if (!preferences.getBoolean(PERMISSION_REQUESTED, false)) { + runCatching { + startActivity(Intent(this, FitnessPermissionActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + }) + }.onFailure { Log.w(TAG, "Unable to request activity recognition permission", it) } + } + return false +} + +internal class FitnessPermissionActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (android.os.Build.VERSION.SDK_INT < 29 || + checkSelfPermission(Manifest.permission.ACTIVITY_RECOGNITION) == PackageManager.PERMISSION_GRANTED + ) { + finish() + return + } + getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + .edit { putBoolean(PERMISSION_REQUESTED, true) } + requestPermissions(arrayOf(Manifest.permission.ACTIVITY_RECOGNITION), REQUEST_ACTIVITY_RECOGNITION) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQUEST_ACTIVITY_RECOGNITION) finish() + } +} diff --git a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/FitnessStepRecorder.kt b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/FitnessStepRecorder.kt index c9305b7d55..eb33291b9d 100644 --- a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/FitnessStepRecorder.kt +++ b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/FitnessStepRecorder.kt @@ -5,8 +5,10 @@ package com.google.android.gms.fitness.service +import android.Manifest import android.content.Context import android.content.SharedPreferences +import android.content.pm.PackageManager import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener @@ -27,6 +29,7 @@ private const val LAST_EVENT_TIME = "last-event-time" private const val NEXT_EVENT_ID = "next-event-id" private const val EVENT_PREFIX = "event:" private const val SUBSCRIPTION_PREFIX = "subscription:" +private const val LEGACY_ACTIVITY_RECOGNITION_PERMISSION = "com.google.android.gms.permission.ACTIVITY_RECOGNITION" // Legacy minute samples are kept readable so an update does not discard recorded steps. private const val SAMPLE_PREFIX = "sample:" @@ -35,6 +38,17 @@ private const val MINUTE_MILLIS = 60_000L internal data class StepSample(val startTimeMillis: Long, val endTimeMillis: Long, val steps: Int) +internal fun Context.enforceActivityRecognitionPermission(packageName: String) { + val permission = if (android.os.Build.VERSION.SDK_INT >= 29) { + Manifest.permission.ACTIVITY_RECOGNITION + } else { + LEGACY_ACTIVITY_RECOGNITION_PERMISSION + } + if (packageManager.checkPermission(permission, packageName) != PackageManager.PERMISSION_GRANTED) { + throw SecurityException("$packageName does not hold $permission") + } +} + private fun sampleMinute(key: String): Long? = key.takeIf { it.startsWith(SAMPLE_PREFIX) }?.removePrefix(SAMPLE_PREFIX)?.toLongOrNull() @@ -77,6 +91,7 @@ internal object FitnessStepRecorder : SensorEventListener { private fun start(context: Context): Boolean { if (registered) return true val appContext = context.applicationContext + if (!appContext.ensureActivityRecognitionPermission()) return false preferences(appContext) val manager = appContext.getSystemService(Context.SENSOR_SERVICE) as? SensorManager ?: return false val sensor = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) ?: return false diff --git a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/history/FitHistoryBroker.kt b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/history/FitHistoryBroker.kt index 755f9f6698..ff836e0477 100644 --- a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/history/FitHistoryBroker.kt +++ b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/history/FitHistoryBroker.kt @@ -39,9 +39,11 @@ import com.google.android.gms.fitness.request.SessionChangesRequest import com.google.android.gms.fitness.request.DataReadResult import com.google.android.gms.fitness.service.FitnessStepRecorder import com.google.android.gms.fitness.service.StepSample +import com.google.android.gms.fitness.service.enforceActivityRecognitionPermission import org.microg.gms.BaseService import org.microg.gms.common.Constants import org.microg.gms.common.GmsService +import org.microg.gms.common.PackageUtils import org.microg.gms.utils.warnOnTransactionIssues import java.util.concurrent.TimeUnit @@ -49,6 +51,10 @@ private const val TAG = "FitHistoryBroker" class FitHistoryBroker : BaseService(TAG, GmsService.FIT_HISTORY) { override fun handleServiceRequest(callback: IGmsCallbacks, request: GetServiceRequest, service: GmsService) { + val packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName) + ?: throw IllegalArgumentException("Missing package name") + enforceActivityRecognitionPermission(packageName) + Log.d(TAG, "handleServiceRequest: packageName: $packageName") callback.onPostInitCompleteWithConnectionInfo(CommonStatusCodes.SUCCESS, FitHistoryBrokerImpl(applicationContext).asBinder(), ConnectionInfo().apply { features = FITNESS_FEATURES diff --git a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/recording/FitRecordingBroker.kt b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/recording/FitRecordingBroker.kt index 571c35922c..326bd0fee9 100644 --- a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/recording/FitRecordingBroker.kt +++ b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/recording/FitRecordingBroker.kt @@ -21,6 +21,7 @@ import com.google.android.gms.fitness.request.SubscribeRequest import com.google.android.gms.fitness.request.UnsubscribeRequest import com.google.android.gms.fitness.service.FitnessStepRecorder import com.google.android.gms.fitness.result.ListSubscriptionsResult +import com.google.android.gms.fitness.service.enforceActivityRecognitionPermission import org.microg.gms.BaseService import org.microg.gms.common.GmsService import org.microg.gms.common.PackageUtils @@ -33,6 +34,7 @@ class FitRecordingBroker : BaseService(TAG, GmsService.FIT_RECORDING) { override fun handleServiceRequest(callback: IGmsCallbacks, request: GetServiceRequest, service: GmsService) { val packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName) ?: throw IllegalArgumentException("Missing package name") + enforceActivityRecognitionPermission(packageName) val clientId = "${request.account?.name.orEmpty()}\n$packageName" Log.d(TAG, "handleServiceRequest: packageName: $packageName") FitnessStepRecorder.resume(this) From 1acdcfd8bd6e5890407d5d60602b0469a9770a10 Mon Sep 17 00:00:00 2001 From: Davinci9196 Date: Tue, 25 Aug 2026 17:09:33 +0800 Subject: [PATCH 3/3] defer activity permission checks until use --- .../fitness/service/history/FitHistoryBroker.kt | 9 ++++++--- .../service/recording/FitRecordingBroker.kt | 16 ++++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/history/FitHistoryBroker.kt b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/history/FitHistoryBroker.kt index ff836e0477..37e7a66765 100644 --- a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/history/FitHistoryBroker.kt +++ b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/history/FitHistoryBroker.kt @@ -53,16 +53,18 @@ class FitHistoryBroker : BaseService(TAG, GmsService.FIT_HISTORY) { override fun handleServiceRequest(callback: IGmsCallbacks, request: GetServiceRequest, service: GmsService) { val packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName) ?: throw IllegalArgumentException("Missing package name") - enforceActivityRecognitionPermission(packageName) Log.d(TAG, "handleServiceRequest: packageName: $packageName") - callback.onPostInitCompleteWithConnectionInfo(CommonStatusCodes.SUCCESS, FitHistoryBrokerImpl(applicationContext).asBinder(), + callback.onPostInitCompleteWithConnectionInfo(CommonStatusCodes.SUCCESS, FitHistoryBrokerImpl(applicationContext, packageName).asBinder(), ConnectionInfo().apply { features = FITNESS_FEATURES }) } } -class FitHistoryBrokerImpl(private val context: Context) : IGoogleFitHistoryApi.Stub() { +class FitHistoryBrokerImpl( + private val context: Context, + private val packageName: String +) : IGoogleFitHistoryApi.Stub() { override fun readData(request: DataReadRequest?) { Log.d(TAG, "readData: $request") @@ -74,6 +76,7 @@ class FitHistoryBrokerImpl(private val context: Context) : IGoogleFitHistoryApi. return request.callback.onPostResult(dataReadResult(Status(5008))) } + context.enforceActivityRecognitionPermission(packageName) FitnessStepRecorder.resume(context) val samples = FitnessStepRecorder.samples(context, request.startTimeMillis, request.endTimeMillis) val dataSources = mutableListOf() diff --git a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/recording/FitRecordingBroker.kt b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/recording/FitRecordingBroker.kt index 326bd0fee9..7074d4bd5b 100644 --- a/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/recording/FitRecordingBroker.kt +++ b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/recording/FitRecordingBroker.kt @@ -34,11 +34,10 @@ class FitRecordingBroker : BaseService(TAG, GmsService.FIT_RECORDING) { override fun handleServiceRequest(callback: IGmsCallbacks, request: GetServiceRequest, service: GmsService) { val packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName) ?: throw IllegalArgumentException("Missing package name") - enforceActivityRecognitionPermission(packageName) val clientId = "${request.account?.name.orEmpty()}\n$packageName" Log.d(TAG, "handleServiceRequest: packageName: $packageName") FitnessStepRecorder.resume(this) - callback.onPostInitCompleteWithConnectionInfo(CommonStatusCodes.SUCCESS, FitRecordingBrokerImpl(applicationContext, clientId), + callback.onPostInitCompleteWithConnectionInfo(CommonStatusCodes.SUCCESS, FitRecordingBrokerImpl(applicationContext, clientId, packageName), ConnectionInfo().apply { features = FITNESS_FEATURES }) @@ -48,14 +47,19 @@ class FitRecordingBroker : BaseService(TAG, GmsService.FIT_RECORDING) { class FitRecordingBrokerImpl( private val context: Context, - private val clientId: String + private val clientId: String, + private val packageName: String ) : IGoogleFitRecordingApi.Stub() { override fun subscribe(request: SubscribeRequest) { Log.d(TAG, "subscribe request: $request") val dataType = request.subscription.dataType ?: request.subscription.dataSource?.dataType - val success = dataType?.name == DataType.TYPE_STEP_COUNT_DELTA.name && - FitnessStepRecorder.subscribe(context, clientId, request.subscription) + val success = if (dataType?.name == DataType.TYPE_STEP_COUNT_DELTA.name) { + context.enforceActivityRecognitionPermission(packageName) + FitnessStepRecorder.subscribe(context, clientId, request.subscription) + } else { + false + } return request.callback.onResult(if (success) Status.SUCCESS else Status(5008)) } @@ -74,4 +78,4 @@ class FitRecordingBrokerImpl( override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean = warnOnTransactionIssues(code, reply, flags, TAG) { super.onTransact(code, data, reply, flags) } -} \ No newline at end of file +}