Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package io.homeassistant.companion.android.sensors
Copy link
Copy Markdown
Member

@jpelgrom jpelgrom Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's necessary to separate the receiver from the SensorManager, it might be best to move it to a more specific package like you've done for workers. Keep the main package for sensors.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not necessary but I prefer to have a clear separation between the two, to keep a clear separation of responsibilities.


import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.google.android.gms.location.ActivityRecognitionResult
import com.google.android.gms.location.DetectedActivity
import com.google.android.gms.location.SleepClassifyEvent
import com.google.android.gms.location.SleepSegmentEvent
import io.homeassistant.companion.android.common.util.STATE_UNKNOWN
import io.homeassistant.companion.android.sensors.worker.ActivitySensorWorker
import io.homeassistant.companion.android.sensors.worker.SleepSensorWorker
import timber.log.Timber

/**
* Lightweight receiver for GMS activity recognition and sleep intents.
*
* Extracts data synchronously from the intent and enqueues an
* [ActivitySensorWorker] or [SleepSensorWorker] for the actual sensor
* update work, to keep the BroadcastReceiver lifecycle bellow 10s.
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in KDoc: "bellow 10s" should be "below 10s".

Suggested change
* update work, to keep the BroadcastReceiver lifecycle bellow 10s.
* update work, to keep the BroadcastReceiver lifecycle below 10s.

Copilot uses AI. Check for mistakes.
*/
class ActivitySensorBroadcastReceiver : BroadcastReceiver() {

override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ActivitySensorManager.ACTION_UPDATE_ACTIVITY -> handleActivityIntent(context, intent)
ActivitySensorManager.ACTION_SLEEP_ACTIVITY -> handleSleepIntent(context, intent)
else -> Timber.w("Unknown intent action: ${intent.action}")
}
}

private fun handleActivityIntent(context: Context, intent: Intent) {
if (!ActivityRecognitionResult.hasResult(intent)) {
Timber.w("Activity intent has no recognition result")
return
}
val result = ActivityRecognitionResult.extractResult(intent) ?: return

var probActivity = typeToString(result.mostProbableActivity)
if (probActivity == "on_foot") {
probActivity = getSubActivity(result)
}

val confidences = result.probableActivities.associate { typeToString(it) to it.confidence }

ActivitySensorWorker.enqueue(
context = context,
activity = probActivity,
confidences = confidences,
)
}

private fun handleSleepIntent(context: Context, intent: Intent) {
val hasClassifyEvents = SleepClassifyEvent.hasEvents(intent)
val hasSegmentEvents = SleepSegmentEvent.hasEvents(intent)

if (!hasClassifyEvents && !hasSegmentEvents) {
Timber.w("Sleep intent has no classify or segment events")
return
}

val classifyData = if (hasClassifyEvents) {
val events = SleepClassifyEvent.extractEvents(intent)
events.lastOrNull()?.let {
SleepSensorWorker.ClassifyData(
confidence = it.confidence,
light = it.light,
motion = it.motion,
timestampMillis = it.timestampMillis,
)
}
} else {
null
}

val segmentData = if (hasSegmentEvents) {
val events = SleepSegmentEvent.extractEvents(intent)
events.lastOrNull()?.let {
SleepSensorWorker.SegmentData(
durationMillis = it.segmentDurationMillis,
startMillis = it.startTimeMillis,
endMillis = it.endTimeMillis,
status = getSleepSegmentStatus(it.status),
)
}
} else {
null
}

SleepSensorWorker.enqueue(
context = context,
classifyData = classifyData,
segmentData = segmentData,
)
}

private fun typeToString(activity: DetectedActivity): String {
return when (activity.type) {
DetectedActivity.IN_VEHICLE -> "in_vehicle"
DetectedActivity.ON_BICYCLE -> "on_bicycle"
DetectedActivity.ON_FOOT -> "on_foot"
DetectedActivity.RUNNING -> "running"
DetectedActivity.STILL -> "still"
DetectedActivity.TILTING -> "tilting"
DetectedActivity.WALKING -> "walking"
DetectedActivity.UNKNOWN -> STATE_UNKNOWN
else -> STATE_UNKNOWN
}
}

private fun getSubActivity(result: ActivityRecognitionResult): String {
if (result.probableActivities[1].type == DetectedActivity.RUNNING) return "running"
if (result.probableActivities[1].type == DetectedActivity.WALKING) return "walking"
return "on_foot"
Comment on lines +112 to +114
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getSubActivity assumes probableActivities has at least 2 entries ([1]). ActivityRecognitionResult.probableActivities is not guaranteed to contain 2+ items, so this can throw IndexOutOfBoundsException and crash the receiver. Use getOrNull(1) (or a size check) and fall back to "on_foot" when the second entry is missing.

Suggested change
if (result.probableActivities[1].type == DetectedActivity.RUNNING) return "running"
if (result.probableActivities[1].type == DetectedActivity.WALKING) return "walking"
return "on_foot"
val subActivity = result.probableActivities.getOrNull(1) ?: return "on_foot"
return when (subActivity.type) {
DetectedActivity.RUNNING -> "running"
DetectedActivity.WALKING -> "walking"
else -> "on_foot"
}

Copilot uses AI. Check for mistakes.
}

private fun getSleepSegmentStatus(status: Int): String {
return when (status) {
SleepSegmentEvent.STATUS_SUCCESSFUL -> "successful"
SleepSegmentEvent.STATUS_MISSING_DATA -> "missing data"
SleepSegmentEvent.STATUS_NOT_DETECTED -> "not detected"
else -> STATE_UNKNOWN
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,19 @@ package io.homeassistant.companion.android.sensors

import android.Manifest
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import com.google.android.gms.location.ActivityRecognition
import com.google.android.gms.location.ActivityRecognitionResult
import com.google.android.gms.location.DetectedActivity
import com.google.android.gms.location.SleepClassifyEvent
import com.google.android.gms.location.SleepSegmentEvent
import com.google.android.gms.location.SleepSegmentRequest
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.sensors.SensorManager
import io.homeassistant.companion.android.common.sensors.SensorReceiverBase
import io.homeassistant.companion.android.common.util.STATE_UNKNOWN
import io.homeassistant.companion.android.common.util.isAutomotive
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import timber.log.Timber

@AndroidEntryPoint
class ActivitySensorManager :
BroadcastReceiver(),
SensorManager {
class ActivitySensorManager : SensorManager {

companion object {
const val ACTION_UPDATE_ACTIVITY =
Expand All @@ -38,7 +24,7 @@ class ActivitySensorManager :
"io.homeassistant.companion.android.background.SLEEP_ACTIVITY"
private var sleepRegistration = false

private val activity = SensorManager.BasicSensor(
internal val activity = SensorManager.BasicSensor(
"detected_activity",
"sensor",
commonR.string.basic_sensor_name_activity,
Expand All @@ -47,7 +33,7 @@ class ActivitySensorManager :
deviceClass = "enum",
)

private val sleepConfidence = SensorManager.BasicSensor(
internal val sleepConfidence = SensorManager.BasicSensor(
"sleep_confidence",
"sensor",
commonR.string.basic_sensor_name_sleep_confidence,
Expand All @@ -58,7 +44,7 @@ class ActivitySensorManager :
updateType = SensorManager.BasicSensor.UpdateType.CUSTOM,
)

private val sleepSegment = SensorManager.BasicSensor(
internal val sleepSegment = SensorManager.BasicSensor(
"sleep_segment",
"sensor",
commonR.string.basic_sensor_name_sleep_segment,
Expand All @@ -68,20 +54,27 @@ class ActivitySensorManager :
deviceClass = "duration",
updateType = SensorManager.BasicSensor.UpdateType.CUSTOM,
)
}

private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO + Job())
internal val ACTIVITY_OPTIONS = listOf(
"in_vehicle", "on_bicycle", "on_foot", "still", "tilting", "walking", "running",
)

override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_UPDATE_ACTIVITY -> ioScope.launch { handleActivityUpdate(intent, context) }
ACTION_SLEEP_ACTIVITY -> ioScope.launch { handleSleepUpdate(intent, context) }
else -> Timber.w("Unknown intent action: ${intent.action}!")
internal fun getSensorIcon(activity: String): String {
return when (activity) {
"in_vehicle" -> "mdi:car"
"on_bicycle" -> "mdi:bike"
"on_foot" -> "mdi:shoe-print"
"still" -> "mdi:human-male"
"tilting" -> "mdi:phone-rotate-portrait"
"walking" -> "mdi:walk"
"running" -> "mdi:run"
else -> "mdi:progress-question"
}
}
}

private fun getActivityPendingIntent(context: Context): PendingIntent {
val intent = Intent(context, ActivitySensorManager::class.java)
val intent = Intent(context, ActivitySensorBroadcastReceiver::class.java)
intent.action = ACTION_UPDATE_ACTIVITY
return PendingIntent.getBroadcast(
context,
Expand All @@ -92,7 +85,7 @@ class ActivitySensorManager :
}

private fun getSleepPendingIntent(context: Context): PendingIntent {
val intent = Intent(context, ActivitySensorManager::class.java)
val intent = Intent(context, ActivitySensorBroadcastReceiver::class.java)
intent.action = ACTION_SLEEP_ACTIVITY
return PendingIntent.getBroadcast(
context,
Expand All @@ -102,103 +95,6 @@ class ActivitySensorManager :
)
}

private suspend fun handleActivityUpdate(intent: Intent, context: Context) {
Timber.d("Received activity update.")
if (ActivityRecognitionResult.hasResult(intent)) {
val result = ActivityRecognitionResult.extractResult(intent)
var probActivity = result?.let { typeToString(it.mostProbableActivity) }

if (probActivity == "on_foot") {
probActivity = result?.let { getSubActivity(it) }
}

if (probActivity != null && result != null) {
onSensorUpdated(
context,
activity,
probActivity,
getSensorIcon(probActivity),
result.probableActivities.associate { typeToString(it) to it.confidence }.plus(
"options" to
listOf("in_vehicle", "on_bicycle", "on_foot", "still", "tilting", "walking", "running"),
),
)
}
}
}

private suspend fun handleSleepUpdate(intent: Intent, context: Context) {
Timber.d("Received sleep update")
if (SleepClassifyEvent.hasEvents(intent) && isEnabled(context, sleepConfidence)) {
Timber.d("Sleep classify event detected")
val sleepClassifyEvent = SleepClassifyEvent.extractEvents(intent)
if (sleepClassifyEvent.size > 0) {
Timber.d("Sleep classify has an actual event")
onSensorUpdated(
context,
sleepConfidence,
sleepClassifyEvent.last().confidence,
sleepConfidence.statelessIcon,
mapOf(
"light" to sleepClassifyEvent.last().light,
"motion" to sleepClassifyEvent.last().motion,
"timestamp" to sleepClassifyEvent.last().timestampMillis,
),
)

// Send the update immediately
SensorReceiver.updateAllSensors(context)
}
}
if (SleepSegmentEvent.hasEvents(intent) && isEnabled(context, sleepSegment)) {
Timber.d("Sleep segment event detected")
val sleepSegmentEvent = SleepSegmentEvent.extractEvents(intent)
if (sleepSegmentEvent.size > 0) {
Timber.d("Sleep segment has an actual event")
onSensorUpdated(
context,
sleepSegment,
sleepSegmentEvent.last().segmentDurationMillis,
sleepSegment.statelessIcon,
mapOf(
"start" to sleepSegmentEvent.last().startTimeMillis,
"end" to sleepSegmentEvent.last().endTimeMillis,
"status" to getSleepSegmentStatus(sleepSegmentEvent.last().status),
),
)
}
}
}

private fun typeToString(activity: DetectedActivity): String {
return when (activity.type) {
DetectedActivity.IN_VEHICLE -> "in_vehicle"
DetectedActivity.ON_BICYCLE -> "on_bicycle"
DetectedActivity.ON_FOOT -> "on_foot"
DetectedActivity.RUNNING -> "running"
DetectedActivity.STILL -> "still"
DetectedActivity.TILTING -> "tilting"
DetectedActivity.WALKING -> "walking"
DetectedActivity.UNKNOWN -> STATE_UNKNOWN
else -> STATE_UNKNOWN
}
}

private fun getSubActivity(result: ActivityRecognitionResult): String {
if (result.probableActivities[1].type == DetectedActivity.RUNNING) return "running"
if (result.probableActivities[1].type == DetectedActivity.WALKING) return "walking"
return "on_foot"
}

private fun getSleepSegmentStatus(int: Int): String {
return when (int) {
SleepSegmentEvent.STATUS_SUCCESSFUL -> "successful"
SleepSegmentEvent.STATUS_MISSING_DATA -> "missing data"
SleepSegmentEvent.STATUS_NOT_DETECTED -> "not detected"
else -> STATE_UNKNOWN
}
}

override fun docsLink(): String {
return "https://companion.home-assistant.io/docs/core/sensors#activity-sensors"
}
Expand Down Expand Up @@ -290,17 +186,4 @@ class ActivitySensorManager :
}
}
}

private fun getSensorIcon(activity: String): String {
return when (activity) {
"in_vehicle" -> "mdi:car"
"on_bicycle" -> "mdi:bike"
"on_foot" -> "mdi:shoe-print"
"still" -> "mdi:human-male"
"tilting" -> "mdi:phone-rotate-portrait"
"walking" -> "mdi:walk"
"running" -> "mdi:run"
else -> "mdi:progress-question"
}
}
}
Loading