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);"