diff --git a/play-services-fitness/core/src/main/AndroidManifest.xml b/play-services-fitness/core/src/main/AndroidManifest.xml index ab5b28aadb..e1ce7838de 100644 --- a/play-services-fitness/core/src/main/AndroidManifest.xml +++ b/play-services-fitness/core/src/main/AndroidManifest.xml @@ -6,7 +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 new file mode 100644 index 0000000000..eb33291b9d --- /dev/null +++ b/play-services-fitness/core/src/main/kotlin/com/google/android/gms/fitness/service/FitnessStepRecorder.kt @@ -0,0 +1,206 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +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 +import android.hardware.SensorManager +import android.util.Base64 +import android.util.Log +import androidx.core.content.edit +import com.google.android.gms.common.internal.safeparcel.SafeParcelableSerializer +import com.google.android.gms.fitness.data.DataSource +import com.google.android.gms.fitness.data.DataType +import com.google.android.gms.fitness.data.Subscription + +private const val TAG = "FitnessStepRecorder" +private const val PREFS_NAME = "fitness-step-recorder" +private const val SUBSCRIPTION_PREFS_NAME = "fitness-recording-subscriptions" +private const val LAST_RAW_STEPS = "last-raw-steps" +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:" +private const val SAMPLE_TIME_PREFIX = "sample-time:" +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() + +private fun storedSample(preferences: SharedPreferences, key: String, value: Any?): StepSample? = when { + key.startsWith(EVENT_PREFIX) -> (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 + 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 + 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..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 @@ -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,104 @@ 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 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 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(), + val packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName) + ?: throw IllegalArgumentException("Missing package name") + Log.d(TAG, "handleServiceRequest: packageName: $packageName") + callback.onPostInitCompleteWithConnectionInfo(CommonStatusCodes.SUCCESS, FitHistoryBrokerImpl(applicationContext, packageName).asBinder(), ConnectionInfo().apply { features = FITNESS_FEATURES }) } } -class FitHistoryBrokerImpl : IGoogleFitHistoryApi.Stub() { +class FitHistoryBrokerImpl( + private val context: Context, + private val packageName: String +) : 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))) + } + + context.enforceActivityRecognitionPermission(packageName) + 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 +141,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 +203,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..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 @@ -6,28 +6,38 @@ 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 com.google.android.gms.fitness.service.enforceActivityRecognitionPermission 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, packageName), ConnectionInfo().apply { features = FITNESS_FEATURES }) @@ -35,21 +45,37 @@ class FitRecordingBroker : BaseService(TAG, GmsService.FIT_RECORDING) { } -class FitRecordingBrokerImpl() : IGoogleFitRecordingApi.Stub() { +class FitRecordingBrokerImpl( + private val context: Context, + private val clientId: String, + private val packageName: 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 = 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)) } 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 + )) } -} \ No newline at end of file + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean = + warnOnTransactionIssues(code, reply, flags, TAG) { super.onTransact(code, data, reply, flags) } +} 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);"