diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 68c6ac4..37495b1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { applicationId = "com.example.googlehomeapisampleapp" minSdk = 29 targetSdk = 36 - versionCode = 40 - versionName = "1.8.0" + versionCode = 41 + versionName = "1.8.1" // Store your GCP project web client ID in local.properties and access it via project properties. // If local.properties doesn't exist in your app root folder, just create it diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/HomeModule.kt b/app/src/main/java/com/example/googlehomeapisampleapp/HomeModule.kt index 4605526..2c5e32e 100644 --- a/app/src/main/java/com/example/googlehomeapisampleapp/HomeModule.kt +++ b/app/src/main/java/com/example/googlehomeapisampleapp/HomeModule.kt @@ -42,6 +42,7 @@ import com.google.home.google.GoogleTVDevice import com.google.home.google.MediaActivityState import com.google.home.google.Notification import com.google.home.google.PushAvStreamTransport +import com.google.home.google.RecordingMode import com.google.home.google.Time import com.google.home.google.VoiceStarter import com.google.home.google.Volume @@ -153,6 +154,7 @@ object HomeModule { OccupancySensing, OnOff, PushAvStreamTransport, + RecordingMode, TemperatureControl, TemperatureMeasurement, Thermostat, diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/CameraStreamView.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/CameraStreamView.kt index dffeb4f..0989de0 100644 --- a/app/src/main/java/com/example/googlehomeapisampleapp/camera/CameraStreamView.kt +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/CameraStreamView.kt @@ -25,6 +25,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -33,6 +34,7 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -40,7 +42,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MicOff -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -51,7 +52,6 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold @@ -73,6 +73,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.example.googlehomeapisampleapp.RuntimePermissionsManager +import com.example.googlehomeapisampleapp.camera.timeline.CameraTimeline +import com.example.googlehomeapisampleapp.camera.timeline.CameraTimelineUiState import com.google.home.google.ChimeTrait @OptIn(ExperimentalMaterial3Api::class) @@ -91,6 +93,10 @@ fun CameraStreamView( isChimeToggleSupported: Boolean = false, isChimeEnabled: Boolean = false, chimeType: ChimeTrait.ExternalChimeType = ChimeTrait.ExternalChimeType.Electronic, + cameraTimelineUiState: CameraTimelineUiState? = null, + recordingModeOptions: List = emptyList(), + selectedRecordingModeIndex: Int? = null, + onSetRecordingMode: (Int) -> Unit = {}, onTurnCameraOn: (Boolean) -> Unit = {}, onSetTalkback: (Boolean) -> Unit = {}, onSetAudioRecording: (Boolean) -> Unit = {}, @@ -137,22 +143,50 @@ fun CameraStreamView( containerColor = Color.Black ) { _ -> Column(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f), + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { - PunchThroughSurface( - isVisible = isCurrentlyStreaming, - onSurfaceCreated = onSurfaceCreated, - onSurfaceDestroyed = onSurfaceDestroyed, - modifier = Modifier.fillMaxSize() + val screenMaxHeight = maxHeight + + Box( + modifier = Modifier + .fillMaxWidth() + .run { + if (cameraTimelineUiState != null) { + // When timeline is present, cap video at 50% of screen height + heightIn(max = screenMaxHeight * 0.5f) + } else { + // When no timeline, use original 4:3 aspect ratio + aspectRatio(4f / 3f) + } + }, + contentAlignment = Alignment.Center + ) { + PunchThroughSurface( + isVisible = isCurrentlyStreaming, + onSurfaceCreated = onSurfaceCreated, + onSurfaceDestroyed = onSurfaceDestroyed, + modifier = Modifier.fillMaxSize() + ) + LiveStreamOverlay(playerState, isCurrentlyStreaming, onRetry) + } + } + if (cameraTimelineUiState != null) { + CameraTimeline( + uiState = cameraTimelineUiState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() ) - LiveStreamOverlay(playerState, isCurrentlyStreaming, onRetry) } if (isTalkbackSupported && isCurrentlyStreaming) { - Spacer(modifier = Modifier.height(32.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { MicrophoneOverlay( isEnabled = isTalkbackEnabled, onToggle = { requestedEnabled -> @@ -167,7 +201,17 @@ fun CameraStreamView( } Box( - modifier = Modifier.fillMaxWidth().weight(1f).padding(26.dp), + modifier = Modifier + .fillMaxWidth() + .run { + if (cameraTimelineUiState == null) { + // When no timeline, give FAB its own weighted space + weight(1f).padding(26.dp) + } else { + // When timeline is present, just add padding + padding(26.dp) + } + }, contentAlignment = Alignment.BottomEnd ) { FloatingActionButton(onClick = { showBottomSheet = true }) { @@ -206,6 +250,48 @@ fun CameraStreamView( } ) + // RECORDING MODE + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text( + "Recording Settings", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + val availableModes = recordingModeOptions.filter { it.available } + val currentMode = recordingModeOptions.firstOrNull { it.index == selectedRecordingModeIndex } + var showRecordingModeMenu by remember { mutableStateOf(false) } + + ListItem( + headlineContent = { Text("Recording Mode") }, + supportingContent = { + Text("Current: ${currentMode?.readableString ?: "Unknown"}") + }, + modifier = Modifier.clickable(enabled = availableModes.isNotEmpty()) { + showRecordingModeMenu = true + }, + trailingContent = { + Box { + Icon(Icons.Default.ChevronRight, null) + DropdownMenu( + expanded = showRecordingModeMenu, + onDismissRequest = { showRecordingModeMenu = false } + ) { + availableModes.forEach { option -> + DropdownMenuItem( + text = { Text(option.readableString) }, + onClick = { + onSetRecordingMode(option.index) + showRecordingModeMenu = false + } + ) + } + } + } + } + ) + // --- DOORBELL CHIME --- if (isDoorbell) { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/CameraStreamViewModel.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/CameraStreamViewModel.kt index 4110049..df89c0b 100644 --- a/app/src/main/java/com/example/googlehomeapisampleapp/camera/CameraStreamViewModel.kt +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/CameraStreamViewModel.kt @@ -34,6 +34,8 @@ import com.example.googlehomeapisampleapp.camera.livestreamplayer.LiveStreamPlay import com.example.googlehomeapisampleapp.camera.livestreamplayer.LiveStreamPlayerFactory import com.example.googlehomeapisampleapp.camera.livestreamplayer.OnOffController import com.example.googlehomeapisampleapp.camera.livestreamplayer.OnOffControllerFactory +import com.example.googlehomeapisampleapp.camera.timeline.CameraTimelinePresenter +import com.example.googlehomeapisampleapp.camera.timeline.CameraTimelineUiState import com.example.googlehomeapisampleapp.doorbell.DoorbellChimeController import com.example.googlehomeapisampleapp.doorbell.DoorbellChimeControllerFactory import com.google.home.HomeDevice @@ -55,6 +57,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -70,6 +73,8 @@ open class CameraStreamViewModel @Inject internal constructor( private val onOffControllerFactory: OnOffControllerFactory, private val cameraAvStreamManagementControllerFactory: CameraAvStreamManagementControllerFactory, private val doorbellChimeControllerFactory: DoorbellChimeControllerFactory, + private val recordingModeControllerFactory: RecordingModeControllerFactory, + private val cameraTimelinePresenter: CameraTimelinePresenter, ) : ViewModel() { private val TAG = "CameraStreamViewModel" private val TOGGLE_WAIT_TIME = 4000L @@ -143,6 +148,28 @@ open class CameraStreamViewModel @Inject internal constructor( .flatMapLatest { it?.externalChimeType ?: flowOf(ChimeTrait.ExternalChimeType.Electronic) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ChimeTrait.ExternalChimeType.Electronic) + // Recording mode controller + private val _recordingModeController = MutableStateFlow(null) + + /** + * Emits the full list of recording mode options for this device, each + * annotated with its availability and a human-readable label. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val recordingModeOptions: StateFlow> = + _recordingModeController + .flatMapLatest { it?.recordingModeOptions ?: flowOf(emptyList()) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + /** + * Emits the index of the currently active recording mode, or null if unavailable. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val selectedRecordingModeIndex: StateFlow = + _recordingModeController + .flatMapLatest { it?.selectedRecordingModeIndex ?: flowOf(null) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + // --- UI State Flows --- @OptIn(ExperimentalCoroutinesApi::class) val isRecording: StateFlow = _onOffController @@ -209,6 +236,24 @@ open class CameraStreamViewModel @Inject internal constructor( private var surface: Surface? = null + @OptIn(ExperimentalCoroutinesApi::class) + val cameraTimelineUiState: StateFlow = + flow { + emit(deviceDeferred.await()) + } + .flatMapLatest { device -> + Log.d(TAG, "Timeline: Starting presenter for device ${device.id.id}") + cameraTimelinePresenter.present( + deviceId = device.id.id, + initialTimestamp = null, + ) + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + null + ) + // --- Commands --- fun setRecording(enabled: Boolean) { @@ -301,6 +346,18 @@ open class CameraStreamViewModel @Inject internal constructor( } } + /** + * Updates the camera's recording mode (CVR, EBR, ETR, Still Images, Live View, Disabled). + * + * @param index The index of the selected mode from [recordingModeOptions]. + */ + fun setRecordingMode(index: Int) { + val controller = _recordingModeController.value ?: return + viewModelScope.launch { + controller.setRecordingMode(index) + } + } + @OptIn(ExperimentalCoroutinesApi::class) fun setDevice(device: HomeDevice) { val currentDevice = if (deviceDeferred.isCompleted) deviceDeferred.getCompleted() else null @@ -351,6 +408,7 @@ open class CameraStreamViewModel @Inject internal constructor( val controller = onOffControllerFactory.create(device) _onOffController.value = controller _cameraAvStreamManagementController.value = cameraAvStreamManagementControllerFactory.create(device) + _recordingModeController.value = recordingModeControllerFactory.create(device) val player = liveStreamPlayerFactory.createPlayerFromDevice(device, viewModelScope, micGranted) _liveStreamPlayer.value = player diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/RecordingModeController.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/RecordingModeController.kt new file mode 100644 index 0000000..ee143a4 --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/RecordingModeController.kt @@ -0,0 +1,164 @@ +/* Copyright 2026 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package com.example.googlehomeapisampleapp.camera + +import android.util.Log +import com.google.home.HomeDevice +import com.google.home.google.GoogleCameraDevice +import com.google.home.google.GoogleDoorbellDevice +import com.google.home.google.RecordingMode +import com.google.home.google.RecordingModeTrait +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.transform + +/** + * Represents a single recording mode option with its availability status + * and human-readable label. + * + * @param recordingMode The SDK enum value for this recording mode. + * @param index The index used by the SDK to identify this mode. + * @param available Whether this mode can currently be selected on this device. + * @param readableString A human-readable label shown in the UI. + */ +data class RecordingModeOption( + val recordingMode: RecordingModeTrait.RecordingModeEnum, + val index: Int, + val available: Boolean, + val readableString: String, +) + +/** + * Interface for reading and updating the camera's recording mode via the + * [RecordingMode] GHP trait. + */ +interface RecordingModeController { + /** + * Emits the full list of recording mode options supported by the device, + * each annotated with availability and a human-readable label. + */ + val recordingModeOptions: Flow> + + /** + * Emits the index of the currently selected recording mode, or null if unknown. + */ + val selectedRecordingModeIndex: Flow + + /** + * Updates the device's selected recording mode. + * + * @param index The index of the mode to select, from [recordingModeOptions]. + * @return true if the update succeeded, false otherwise. + */ + suspend fun setRecordingMode(index: Int): Boolean +} + +/** + * Production implementation of [RecordingModeController]. + * + * Resolves the correct device type at construction time. If the device is + * neither a [GoogleCameraDevice] nor a [GoogleDoorbellDevice], the controller + * safely emits empty state and no-ops on writes rather than hanging indefinitely. + */ +class RecordingModeControllerImpl(private val device: HomeDevice) : RecordingModeController { + private val TAG = "RecordingModeController" + + // Resolved once at construction. Null means unsupported device type — + // flows will emit empty/null and writes will no-op safely. + private val deviceType = when { + device.has(GoogleCameraDevice) -> GoogleCameraDevice + device.has(GoogleDoorbellDevice) -> GoogleDoorbellDevice + else -> null + } + + override val recordingModeOptions: Flow> = + if (deviceType == null) { + Log.w(TAG, "Device ${device.id} has RecordingMode trait but unknown device type.") + flowOf(emptyList()) + } else { + device.type(deviceType) + .transform { type -> + val trait = type.trait(RecordingMode) + if (trait == null) { + emit(emptyList()) + return@transform + } + val supported = trait.supportedRecordingModes + ?.map { it.recordingMode } ?: emptyList() + val available = trait.availableRecordingModes + ?.map { it.toInt() } ?: emptyList() + + emit(supported.mapIndexed { index, mode -> + RecordingModeOption( + recordingMode = mode, + index = index, + available = available.contains(index), + readableString = mode.toReadableString(), + ) + }) + } + .distinctUntilChanged() + } + + override val selectedRecordingModeIndex: Flow = + if (deviceType == null) { + flowOf(null) + } else { + device.type(deviceType) + .transform { type -> + val trait = type.trait(RecordingMode) + emit(trait?.selectedRecordingMode?.toInt()) + } + .distinctUntilChanged() + } + + override suspend fun setRecordingMode(index: Int): Boolean { + // Guard against unsupported device type to avoid indefinite suspension + val resolvedType = deviceType ?: run { + Log.w(TAG, "Cannot set recording mode — unsupported device type for ${device.id}") + return false + } + return try { + val type = device.type(resolvedType).first() + val trait = type.trait(RecordingMode) ?: run { + Log.w(TAG, "RecordingMode trait not available on device ${device.id}") + return false + } + trait.update { + setSelectedRecordingMode(index.toUByte()) + } + true + } catch (e: Exception) { + Log.e(TAG, "Failed to set recording mode to index $index on device ${device.id}", e) + false + } + } + + companion object { + private fun RecordingModeTrait.RecordingModeEnum.toReadableString(): String = + when (this) { + RecordingModeTrait.RecordingModeEnum.Disabled -> "Disabled" + RecordingModeTrait.RecordingModeEnum.Cvr -> "Continuous Video Recording" + RecordingModeTrait.RecordingModeEnum.Ebr -> "Event Based Recording" + RecordingModeTrait.RecordingModeEnum.Etr -> "Event Triggered Recording" + RecordingModeTrait.RecordingModeEnum.LiveView -> "Live View" + RecordingModeTrait.RecordingModeEnum.Images -> "Still Images" + else -> "Unknown" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/RecordingModeControllerFactory.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/RecordingModeControllerFactory.kt new file mode 100644 index 0000000..ea43d1d --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/RecordingModeControllerFactory.kt @@ -0,0 +1,50 @@ +/* Copyright 2026 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package com.example.googlehomeapisampleapp.camera + +import android.util.Log +import com.google.home.HomeDevice +import com.google.home.google.RecordingMode +import javax.inject.Inject + +/** + * Factory for creating [RecordingModeController] instances. + * + * Returns null if the device does not support the [RecordingMode] trait, + * following the same pattern as [OnOffControllerFactory] and + * [CameraAvStreamManagementControllerFactory]. + */ +class RecordingModeControllerFactory @Inject internal constructor() { + + /** + * Creates a [RecordingModeController] for the given device. + * + * @param device The [HomeDevice] to control. + * @return A [RecordingModeController] if the device supports recording mode, + * or null otherwise. + */ + fun create(device: HomeDevice): RecordingModeController? { + if (device.has(RecordingMode)) { + return RecordingModeControllerImpl(device) + } + Log.w(TAG, "RecordingMode trait not found on device ${device.id}.") + return null + } + + companion object { + private const val TAG = "RecordingModeControllerFactory" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimeline.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimeline.kt new file mode 100644 index 0000000..19cee88 --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimeline.kt @@ -0,0 +1,95 @@ +/* Copyright 2025 Google LLC */ +package com.example.googlehomeapisampleapp.camera.timeline + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import java.time.Instant + +private const val DEFAULT_PIXELS_PER_HOUR = 200f + +@Composable +fun CameraTimeline( + uiState: CameraTimelineUiState, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(Color.Black) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .handleTimelineGestures( + onGesture = { _, zoomChange, panChange -> + if (zoomChange != 1f) { + uiState.onZoom(zoomChange, 0f) + } else { + uiState.onPan(panChange) + } + }, + onGestureStart = {}, + onGestureEnd = {}, + ) + ) { + TimelineTrack( + timelineDataProvider = { uiState.timelineData }, + currentTimeProvider = { uiState.currentTime }, + pixelsPerHourProvider = { DEFAULT_PIXELS_PER_HOUR }, + modifier = Modifier.fillMaxSize(), + ) + Playhead( + currentTimeProvider = { uiState.currentTime }, + ) + + DateChip( + currentTime = uiState.currentTime, + onDateSelected = uiState.onDateSelected, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = if (!uiState.isLive) 56.dp else 8.dp), + ) + + if (!uiState.isLive) { + Button( + onClick = { uiState.onSwitchToLive() }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("Switch to Live View") + } + } + } + } + val currentOnTick by rememberUpdatedState(uiState.onTick) + val currentIsLive by rememberUpdatedState(uiState.isLive) + + LaunchedEffect(currentIsLive) { + if (currentIsLive) { + while (true) { + delay(1000) + currentOnTick(Instant.now()) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimelineDefaults.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimelineDefaults.kt new file mode 100644 index 0000000..8d4bf41 --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimelineDefaults.kt @@ -0,0 +1,37 @@ +/* Copyright 2025 Google LLC */ +package com.example.googlehomeapisampleapp.camera.timeline + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +internal object CameraTimelineDefaults { + + /** Total width reserved for the playhead time label (e.g. "12:00:00 PM"). */ + val maxPlayheadWidth: Dp = 80.dp + + /** + * Vertical offset from the top of the timeline canvas to the playhead line. + * Kept small so it sits near the top of the timeline section (not full screen). + */ + val playheadOffset: Dp = 80.dp + + /** Width of the video recording segment bar. */ + val videoSegmentWidth: Dp = 12.dp + + /** Width of the camera state (no-video reason) segment bar. */ + val cameraStateSegmentWidth: Dp = 12.dp + + /** Width of the events segment bar. */ + val eventSegmentWidth: Dp = 12.dp + + /** Horizontal gap between adjacent segment bars. */ + val segmentSpacing: Dp = 6.dp + + /** + * Height of the vertical gradient overlay drawn around the playhead line. + */ + val gradientHeight: Dp = 80.dp + + /** Length of the horizontal tick dash drawn at each hour marker. */ + val markerDashWidth: Dp = 8.dp +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimelineGestureHandler.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimelineGestureHandler.kt new file mode 100644 index 0000000..6774a52 --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimelineGestureHandler.kt @@ -0,0 +1,79 @@ +/* Copyright 2025 Google LLC */ +package com.example.googlehomeapisampleapp.camera.timeline + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animateDecay +import androidx.compose.animation.splineBasedDecay +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker1D +import kotlinx.coroutines.launch + +internal fun Modifier.handleTimelineGestures( + onGesture: (centroid: Offset, zoomChange: Float, panChange: Float) -> Unit, + onGestureStart: () -> Unit, + onGestureEnd: () -> Unit, +): Modifier = composed { + val offsetY = remember { Animatable(0f) } + val coroutineScope = rememberCoroutineScope() + val updatedOnGesture by rememberUpdatedState(onGesture) + val updatedOnGestureStart by rememberUpdatedState(onGestureStart) + val updatedOnGestureEnd by rememberUpdatedState(onGestureEnd) + var isInteracting by remember { mutableStateOf(false) } + val velocityTracker = remember { VelocityTracker1D(isDataDifferential = true) } + + pointerInput(Unit) { + val decay = splineBasedDecay(this) + + detectTransformGestures( + onGesture = { centroid, pan, zoom, _ -> + if (!isInteracting) { + isInteracting = true + velocityTracker.resetTracking() + updatedOnGestureStart() + } + velocityTracker.addDataPoint( + System.currentTimeMillis(), + pan.y, + ) + updatedOnGesture(centroid, zoom, pan.y) + }, + ) + }.pointerInput("fling") { + awaitPointerEventScope { + while (true) { + awaitPointerEvent() + val event = currentEvent + if (event.changes.all { !it.pressed } && isInteracting) { + val velocity = velocityTracker.calculateVelocity() + isInteracting = false + coroutineScope.launch { + offsetY.snapTo(0f) + var lastValue = 0f + try { + offsetY.animateDecay( + velocity, + splineBasedDecay(this@awaitPointerEventScope), + ) { + val delta = value - lastValue + lastValue = value + updatedOnGesture(Offset.Zero, 1f, delta) + } + } finally { + updatedOnGestureEnd() + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimelinePresenter.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimelinePresenter.kt new file mode 100644 index 0000000..05ab8e2 --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimelinePresenter.kt @@ -0,0 +1,134 @@ +package com.example.googlehomeapisampleapp.camera.timeline + +import android.util.Log +import com.example.googlehomeapisampleapp.camera.timeline.repository.TimelineData +import com.example.googlehomeapisampleapp.camera.timeline.repository.TimelineRange +import com.example.googlehomeapisampleapp.camera.timeline.repository.TimelineRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "CameraTimelinePresenter" +private const val FETCH_DEBOUNCE_MS = 300L +private const val BASE_SECONDS_PER_PIXEL = 18f // 3600s / 200px + +@OptIn(FlowPreview::class) +@Singleton +class CameraTimelinePresenter @Inject constructor( + private val timelineRepository: TimelineRepository, +) { + private val coroutineScope = CoroutineScope(Dispatchers.Main) + private var observerJob: Job? = null + + fun present( + deviceId: String, + initialTimestamp: Instant?, + ): Flow { + Log.d(TAG, "Presenting timeline for device: $deviceId") + + var currentTime = initialTimestamp ?: Instant.now() + var zoomLevel = 1f + var panOffsetSeconds = 0L + var isLive = initialTimestamp == null + + val stateFlow = MutableStateFlow( + CameraTimelineUiState( + timelineData = TimelineData(), + currentTime = currentTime, + isLive = isLive, + onPan = {}, + onZoom = { _, _ -> }, + onFlingStart = {}, + onDateSelected = {}, + onTick = {}, + onSwitchToLive = {}, + ) + ) + val fetchTriggerFlow = MutableStateFlow(0L) + coroutineScope.launch { + fetchTriggerFlow + .debounce(FETCH_DEBOUNCE_MS) + .collect { + val displayTime = stateFlow.value.currentTime + val halfWindowSeconds = (12 * 3600 / zoomLevel).toLong() + val fetchRange = TimelineRange( + start = displayTime.minusSeconds(halfWindowSeconds), + endInclusive = displayTime.plusSeconds(halfWindowSeconds), + ) + timelineRepository.fetchTimelinePeriod(deviceId, fetchRange) + } + } + + fun updateUiState( + displayTime: Instant, + triggerFetch: Boolean = true, + ) { + val now = Instant.now() + val clampedTime = displayTime.coerceAtMost(now) + + + stateFlow.update { current -> + current.copy( + currentTime = clampedTime, + isLive = isLive, + onPan = { deltaPixels -> + val adjustedSecondsPerPixel = BASE_SECONDS_PER_PIXEL / zoomLevel + if (isLive && deltaPixels != 0f) isLive = false + panOffsetSeconds = (panOffsetSeconds + (deltaPixels * adjustedSecondsPerPixel).toLong()) + .coerceAtLeast(0L) + val newTime = currentTime.minusSeconds(panOffsetSeconds).coerceAtMost(Instant.now()) + updateUiState(newTime) + }, + onZoom = { scaleFactor, _ -> + zoomLevel = (zoomLevel * scaleFactor).coerceIn(0.5f, 4f) + updateUiState(clampedTime) + }, + onFlingStart = { _ -> + if (isLive) isLive = false + updateUiState(clampedTime) + }, + onDateSelected = { timestampMillis -> + isLive = false + currentTime = Instant.ofEpochMilli(timestampMillis) + panOffsetSeconds = 0L + updateUiState(currentTime) + }, + onTick = { newTime -> + if (isLive) { + currentTime = newTime + updateUiState(newTime) + } + }, + onSwitchToLive = { + isLive = true + currentTime = Instant.now() + panOffsetSeconds = 0L + updateUiState(currentTime) + }, + ) + } + + if (triggerFetch) { + fetchTriggerFlow.value = System.currentTimeMillis() + } + } + observerJob?.cancel() + observerJob = coroutineScope.launch { + timelineRepository.observeTimelines(deviceId).collect { data -> + stateFlow.update { current -> current.copy(timelineData = data) } + } + } + + updateUiState(currentTime, triggerFetch = true) + return stateFlow + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimelineUiState.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimelineUiState.kt new file mode 100644 index 0000000..4d063f3 --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/CameraTimelineUiState.kt @@ -0,0 +1,31 @@ +/* Copyright 2025 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package com.example.googlehomeapisampleapp.camera.timeline + +import com.example.googlehomeapisampleapp.camera.timeline.repository.TimelineData +import java.time.Instant + +data class CameraTimelineUiState( + val timelineData: TimelineData, + val currentTime: Instant, + val isLive: Boolean, + val onPan: (Float) -> Unit, + val onZoom: (Float, Float) -> Unit, + val onFlingStart: (Float) -> Unit, + val onDateSelected: (Long) -> Unit, + val onTick: (Instant) -> Unit, + val onSwitchToLive: () -> Unit, +) \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/DateChip.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/DateChip.kt new file mode 100644 index 0000000..cf0582e --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/DateChip.kt @@ -0,0 +1,89 @@ +package com.example.googlehomeapisampleapp.camera.timeline + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DateChip( + currentTime: Instant, + onDateSelected: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + val currentDate = currentTime.atZone(ZoneId.systemDefault()).toLocalDate() + val today = Instant.now().atZone(ZoneId.systemDefault()).toLocalDate() + val yesterday = today.minusDays(1) + var showDatePicker by remember { mutableStateOf(false) } + + if (showDatePicker) { + val selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + return utcTimeMillis <= Instant.now().toEpochMilli() + } + } + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = currentDate.atStartOfDay(ZoneId.systemDefault()) + .toInstant().toEpochMilli(), + selectableDates = selectableDates, + ) + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis?.let { millis -> + onDateSelected(millis) + } + showDatePicker = false + }) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { + Text("Cancel") + } + }, + ) { + DatePicker(state = datePickerState) + } + } + + val dateText = remember(currentDate, today, yesterday) { + when (currentDate) { + today -> "Today" + yesterday -> "Yesterday" + else -> currentDate.format( + DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) + ) + } + } + + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + FilterChip( + selected = true, + onClick = { showDatePicker = true }, + label = { Text(dateText) }, + modifier = Modifier.testTag("DateChip"), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/Playhead.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/Playhead.kt new file mode 100644 index 0000000..c16892e --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/Playhead.kt @@ -0,0 +1,65 @@ +/* Copyright 2025 Google LLC */ +package com.example.googlehomeapisampleapp.camera.timeline + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +@Composable +internal fun Playhead(currentTimeProvider: () -> Instant) { + val density = LocalDensity.current + val textEndX = with(density) { CameraTimelineDefaults.maxPlayheadWidth.toPx() } + val playheadOffsetPx = with(density) { CameraTimelineDefaults.playheadOffset.toPx() } + val primaryColor = MaterialTheme.colorScheme.primary + val textMeasurer = rememberTextMeasurer() + val textStyle = remember(primaryColor) { + TextStyle( + color = primaryColor, + fontSize = 12.sp, + textAlign = TextAlign.Right, + fontFeatureSettings = "tnum", + ) + } + + val timeFormatter = remember { + DateTimeFormatter.ofPattern("h:mm:ss a", Locale.getDefault()) + } + + Canvas(modifier = Modifier.fillMaxSize().testTag("Playhead")) { + val currentTime = currentTimeProvider() + val timeText = currentTime.atZone(ZoneId.systemDefault()).format(timeFormatter) + + val paddingPx = 8.dp.toPx() + val lineStartX = textEndX + paddingPx + val textLayoutResult = textMeasurer.measure(timeText, style = textStyle) + val textWidth = textLayoutResult.size.width + val textHeight = textLayoutResult.size.height + val textVertCenter = playheadOffsetPx - (textHeight / 2) + val textLeft = textEndX - textWidth + + drawText(textLayoutResult, topLeft = Offset(textLeft, textVertCenter)) + + drawLine( + color = primaryColor, + start = Offset(lineStartX, playheadOffsetPx), + end = Offset(size.width, playheadOffsetPx), + strokeWidth = 1.dp.toPx(), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/TimelineTrack.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/TimelineTrack.kt new file mode 100644 index 0000000..e094139 --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/TimelineTrack.kt @@ -0,0 +1,329 @@ +package com.example.googlehomeapisampleapp.camera.timeline + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.googlehomeapisampleapp.camera.timeline.repository.TimelineData +import com.example.googlehomeapisampleapp.camera.timeline.repository.TimelinePeriod +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.Locale + +@Composable +internal fun TimelineTrack( + timelineDataProvider: () -> TimelineData, + currentTimeProvider: () -> Instant, + pixelsPerHourProvider: () -> Float, + modifier: Modifier = Modifier, +) { + val backgroundColor = MaterialTheme.colorScheme.background + val labelColor = MaterialTheme.colorScheme.onSurfaceVariant + val lineColor = MaterialTheme.colorScheme.outlineVariant + val videoColor = MaterialTheme.colorScheme.primary + val videoBackgroundColor = MaterialTheme.colorScheme.surfaceContainerHighest + val cameraStateColor = MaterialTheme.colorScheme.secondary + val eventColor = MaterialTheme.colorScheme.tertiary + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + val timeMarkerTextStyle = remember(labelColor) { + TextStyle( + color = labelColor, + fontSize = 10.sp, + textAlign = TextAlign.Left, + fontFeatureSettings = "tnum", + ) + } + + val textLayoutCache = remember { mutableMapOf() } + val timeFormatter = remember { DateTimeFormatter.ofPattern("h a", Locale.getDefault()) } + + val trackStartOffset = with(density) { CameraTimelineDefaults.maxPlayheadWidth.toPx() + 20.dp.toPx() } + val videoWidthPx = with(density) { CameraTimelineDefaults.videoSegmentWidth.toPx() } + val cameraStateWidthPx = with(density) { CameraTimelineDefaults.cameraStateSegmentWidth.toPx() } + val eventWidthPx = with(density) { CameraTimelineDefaults.eventSegmentWidth.toPx() } + val spacingPx = with(density) { CameraTimelineDefaults.segmentSpacing.toPx() } + + val playheadOffsetPx = with(density) { CameraTimelineDefaults.playheadOffset.toPx() } + val cameraStateXOffset = trackStartOffset + videoWidthPx + spacingPx + val eventXOffset = cameraStateXOffset + cameraStateWidthPx + spacingPx + + Canvas( + modifier = modifier + .fillMaxSize() + .testTag("TimelineTrack") + .graphicsLayer(clip = true) + .drawWithCache { + val gradientHeightPx = with(this) { CameraTimelineDefaults.gradientHeight.toPx() } + val playheadOffsetPx = with(this) { CameraTimelineDefaults.playheadOffset.toPx() } + val gradientBrush = Brush.verticalGradient( + colors = listOf(Color.Transparent, backgroundColor, backgroundColor, Color.Transparent), + startY = playheadOffsetPx - gradientHeightPx / 2, + endY = playheadOffsetPx + gradientHeightPx / 2, + tileMode = TileMode.Clamp, + ) + onDrawWithContent { + drawContent() + drawGradientOverlay(gradientBrush, CameraTimelineDefaults.playheadOffset) + } + } + ) { + val currentTime = currentTimeProvider() + val pixelsPerHour = pixelsPerHourProvider() + val maxTime = currentTimeProvider() + + val backgroundYOffset = calculateBackgroundTop(currentTime, maxTime, pixelsPerHour) + val visibleRange = getVisibleTimeRange(pixelsPerHour, currentTime, size.height) + + drawTimeMarkers( + currentTimeProvider = currentTimeProvider, + maxTime = maxTime, + visibleRange = visibleRange, + pixelsPerHour = pixelsPerHour, + playheadOffsetPx = playheadOffsetPx, + lineColor = lineColor, + textLayoutCache = textLayoutCache, + textMeasurer = textMeasurer, + textStyle = timeMarkerTextStyle, + timeFormatter = timeFormatter, + ) + + val timelineData = timelineDataProvider() + + drawTimelineBar( + timelinePeriods = timelineData.videoTimelines, + visibleRange = visibleRange, + currentTime = currentTime, + backgroundYOffset = backgroundYOffset, + pixelsPerHour = pixelsPerHour, + color = videoColor, + backgroundColor = videoBackgroundColor, + xOffset = trackStartOffset, + width = videoWidthPx, + mergeOverlappingTimelinePeriods = true, + ) + + drawTimelineBar( + timelinePeriods = timelineData.cameraStateTimelines, + visibleRange = visibleRange, + currentTime = currentTime, + backgroundYOffset = backgroundYOffset, + pixelsPerHour = pixelsPerHour, + color = cameraStateColor, + backgroundColor = videoBackgroundColor, + xOffset = cameraStateXOffset, + width = cameraStateWidthPx, + ) + + drawTimelineBar( + timelinePeriods = timelineData.eventTimelines, + visibleRange = visibleRange, + currentTime = currentTime, + backgroundYOffset = backgroundYOffset, + pixelsPerHour = pixelsPerHour, + color = eventColor, + backgroundColor = videoBackgroundColor, + xOffset = eventXOffset, + width = eventWidthPx, + ) + } +} + +private fun DrawScope.drawTimeMarkers( + currentTimeProvider: () -> Instant, + maxTime: Instant, + visibleRange: ClosedRange, + pixelsPerHour: Float, + playheadOffsetPx: Float, + lineColor: Color, + textLayoutCache: MutableMap, + textMeasurer: TextMeasurer, + textStyle: TextStyle, + timeFormatter: DateTimeFormatter, +) { + val currentTime = currentTimeProvider() + val dashWidthPx = CameraTimelineDefaults.markerDashWidth.toPx() + val paddingPx = 8.dp.toPx() + + val endDateTime = visibleRange.endInclusive.atZone(ZoneId.systemDefault()) + var currentMarker = endDateTime.truncatedTo(ChronoUnit.HOURS) + if (currentMarker.isBefore(endDateTime)) { + currentMarker = currentMarker.plusHours(1) + } + + val maxMarkerTime = maxTime.atZone(ZoneId.systemDefault()).truncatedTo(ChronoUnit.HOURS) + if (currentMarker.isAfter(maxMarkerTime)) { + currentMarker = maxMarkerTime + } + + while (currentMarker.toInstant().isAfter(visibleRange.start)) { + val markerInstant = currentMarker.toInstant() + val timeText = currentMarker.format(timeFormatter) + val textLayoutResult = textLayoutCache.getOrPut(timeText) { + textMeasurer.measure(timeText, style = textStyle) + } + + val yOffsetPx = markerInstant.toTimelineY(currentTime, pixelsPerHour, playheadOffsetPx) + + val dashStartX = paddingPx + val dashEndX = dashStartX + dashWidthPx + + val textX = dashEndX + paddingPx + val textHeight = textLayoutResult.size.height + val textVertCenter = yOffsetPx - (textHeight / 2) + + drawText(textLayoutResult, topLeft = Offset(textX, textVertCenter)) + + drawLine( + color = lineColor, + start = Offset(dashStartX, yOffsetPx), + end = Offset(dashEndX, yOffsetPx), + strokeWidth = 1.dp.toPx(), + ) + + currentMarker = currentMarker.minusHours(1) + } +} + +private fun Density.calculateBackgroundTop( + currentTime: Instant, + maxTime: Instant, + pixelsPerHour: Float, +): Float { + val playheadOffsetPx = CameraTimelineDefaults.playheadOffset.toPx() + val maxTimeY = maxTime.toTimelineY(currentTime, pixelsPerHour, playheadOffsetPx) + return maxOf(0f, maxTimeY) +} + +private fun DrawScope.drawGradientOverlay(gradientBrush: Brush, timeMarkerOffset: androidx.compose.ui.unit.Dp) { + val heightPx = CameraTimelineDefaults.gradientHeight.toPx() + val playheadWidthPx = CameraTimelineDefaults.maxPlayheadWidth.toPx() + val playheadOffsetPx = timeMarkerOffset.toPx() + + drawRect( + brush = gradientBrush, + topLeft = Offset(x = 0f, y = playheadOffsetPx - heightPx / 2), + size = Size(width = playheadWidthPx, height = heightPx), + ) +} + +private fun DrawScope.drawTimelineBar( + timelinePeriods: List, + visibleRange: ClosedRange, + currentTime: Instant, + backgroundYOffset: Float, + pixelsPerHour: Float, + color: Color, + backgroundColor: Color, + xOffset: Float, + width: Float, + mergeOverlappingTimelinePeriods: Boolean = false, +) { + val playheadOffsetPx = CameraTimelineDefaults.playheadOffset.toPx() + + drawRect( + color = backgroundColor, + topLeft = Offset(xOffset, backgroundYOffset), + size = Size(width, size.height - backgroundYOffset), + ) + + val startIndex = timelinePeriods.indexOfVisibleRangeStart(visibleRange.start) + + if (startIndex == -1) { + return + } + + var i = startIndex + while (i < timelinePeriods.size) { + val timeline = timelinePeriods[i] + if (timeline.startTime > visibleRange.endInclusive) { + break + } + + val mergedStartTime = timeline.startTime + var mergedEndTime = timeline.endTime + + if (mergeOverlappingTimelinePeriods) { + while (i + 1 < timelinePeriods.size) { + val nextTimeline = timelinePeriods[i + 1] + if (nextTimeline.startTime <= mergedEndTime) { + mergedEndTime = maxOf(mergedEndTime, nextTimeline.endTime) + i++ + } else { + break + } + } + } + + val rectTop = mergedEndTime.toTimelineY(currentTime, pixelsPerHour, playheadOffsetPx) + val durationSeconds = mergedEndTime.epochSecond - mergedStartTime.epochSecond + val rectHeight = durationSeconds.secondsToPixels(pixelsPerHour) + + drawRoundRect( + color = color, + topLeft = Offset(xOffset, rectTop), + size = Size(width, rectHeight), + cornerRadius = CornerRadius(5.dp.toPx()), + ) + i++ + } +} + +private fun List.indexOfVisibleRangeStart(visibleRangeStart: Instant): Int { + val index = binarySearchBy(visibleRangeStart) { it.endTime } + return if (index < 0) -(index + 1) else index +} + +internal fun Density.getVisibleTimeRange( + pixelsPerHour: Float, + currentTime: Instant, + heightPx: Float, +): ClosedRange { + val playheadOffsetPx = CameraTimelineDefaults.playheadOffset.toPx() + + val hoursPast = (heightPx - playheadOffsetPx) / pixelsPerHour + val hoursFuture = playheadOffsetPx / pixelsPerHour + + val startVisible = currentTime.minusSeconds(((hoursPast + 2) * SECONDS_IN_HOUR).toLong()) + val endVisible = currentTime.plusSeconds(((hoursFuture + 2) * SECONDS_IN_HOUR).toLong()) + return startVisible..endVisible +} + +private fun Instant.toTimelineY( + currentTime: Instant, + pixelsPerHour: Float, + playheadOffsetPx: Float, +): Float { + val diffSeconds = currentTime.epochSecond - this.epochSecond + return playheadOffsetPx + (diffSeconds / SECONDS_IN_HOUR.toFloat() * pixelsPerHour) +} + +private fun Long.secondsToPixels(pixelsPerHour: Float): Float { + return (this / SECONDS_IN_HOUR.toFloat()) * pixelsPerHour +} + +private const val SECONDS_IN_HOUR = 3600 \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/DataModels.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/DataModels.kt new file mode 100644 index 0000000..df0017c --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/DataModels.kt @@ -0,0 +1,186 @@ +/* Copyright 2025 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package com.example.googlehomeapisampleapp.camera.timeline.repository + +import com.google.home.google.CameraTimelineTrait.TimelineMode as TraitTimelineMode +import com.google.home.google.CameraTimelineTrait.TimelinePeriodStruct +import com.google.home.google.CameraTimelineTrait.VideoUnavailableReason +import java.time.Instant + +/** + * A timeline period for the timeline. + * + * @property startTime The start time of the period. + * @property endTime The end time of the period. + * + * NOTE: Implements [ClosedRange] with inclusive bounds on both ends. Two adjacent + * periods where one ends exactly when the next begins will appear to overlap. + * [TimelineRange] has the same caveat. Callers must account for this when + * checking containment or overlap. + */ +sealed class TimelinePeriod(open val startTime: Instant, open val endTime: Instant) : + ClosedRange { + override val start: Instant = startTime + override val endInclusive: Instant = endTime + + data class Unspecified(override val startTime: Instant, override val endTime: Instant) : + TimelinePeriod(startTime, endTime) + + data class Video(override val startTime: Instant, override val endTime: Instant) : + TimelinePeriod(startTime, endTime) + + data class Events( + override val startTime: Instant, + override val endTime: Instant, + val sessionId: String, + // thumbnailUrl is intentionally null: thumbnail URLs are only available + // via the History API, not from CameraTimelineTrait. + val thumbnailUrl: String?, + ) : TimelinePeriod(startTime, endTime) + + data class NoVideo( + override val startTime: Instant, + override val endTime: Instant, + val noVideoReason: NoVideoReason, + ) : TimelinePeriod(startTime, endTime) +} + +/** + * A time range used for fetching and caching timeline data. + * + * NOTE: Uses [ClosedRange] with inclusive bounds — adjacent ranges that share + * an endpoint will appear to overlap. See [TimelinePeriod] for details. + */ +data class TimelineRange(override val start: Instant, override val endInclusive: Instant) : + ClosedRange { + val end: Instant = endInclusive +} + +/** Defines the different types of data that can be displayed on the timeline. */ +enum class TimelineMode { + UNSPECIFIED, + VIDEO, + EVENTS, + CAMERA_STATES, +} + +/** Maps the trait's timeline mode to the local [TimelineMode]. */ +fun TraitTimelineMode.toTimelineMode(): TimelineMode { + return when (this) { + TraitTimelineMode.Video -> TimelineMode.VIDEO + TraitTimelineMode.Events -> TimelineMode.EVENTS + TraitTimelineMode.CameraStates -> TimelineMode.CAMERA_STATES + else -> TimelineMode.UNSPECIFIED + } +} + +/** Maps [TimelinePeriodStruct] to the local [TimelinePeriod] using the given [TimelineMode]. */ +fun TimelinePeriodStruct.toTimelinePeriod(mode: TimelineMode): TimelinePeriod { + val startTime = Instant.ofEpochMilli(this.startTimeMillis.toLong()) + val endTime = Instant.ofEpochMilli(this.endTimeMillis.toLong()) + + return when (mode) { + TimelineMode.EVENTS -> { + val noVideoPeriodData = this.noVideoPeriodData.getOrNull() + val sessionId = noVideoPeriodData?.cameraHistoryItem?.getOrNull()?.sessionId ?: "" + TimelinePeriod.Events( + startTime = startTime, + endTime = endTime, + sessionId = sessionId, + thumbnailUrl = null, + ) + } + TimelineMode.CAMERA_STATES -> { + val noVideoPeriodData = this.noVideoPeriodData.getOrNull() + val noVideoReason = noVideoPeriodData?.reason?.toNoVideoReason() ?: NoVideoReason.UNSPECIFIED + TimelinePeriod.NoVideo( + startTime = startTime, + endTime = endTime, + noVideoReason = noVideoReason, + ) + } + TimelineMode.VIDEO -> { + TimelinePeriod.Video(startTime = startTime, endTime = endTime) + } + TimelineMode.UNSPECIFIED -> { + TimelinePeriod.Unspecified(startTime = startTime, endTime = endTime) + } + } +} + +/** + * Holds the different types of data that can be displayed on the timeline. + * + * Each list of [TimelinePeriod] MUST be sorted by [TimelinePeriod.startTime] in ascending order + * (oldest to newest). + */ +data class TimelineData( + val videoTimelines: List = emptyList(), + val eventTimelines: List = emptyList(), + val cameraStateTimelines: List = emptyList(), +) { + init { + require(videoTimelines.zipWithNext().all { (a, b) -> a.startTime <= b.startTime }) { + "videoTimelines must be sorted by startTime ascending" + } + require(eventTimelines.zipWithNext().all { (a, b) -> a.startTime <= b.startTime }) { + "eventTimelines must be sorted by startTime ascending" + } + require(cameraStateTimelines.zipWithNext().all { (a, b) -> a.startTime <= b.startTime }) { + "cameraStateTimelines must be sorted by startTime ascending" + } + } +} + +enum class NoVideoReason { + UNSPECIFIED, + USER, + SCHEDULE, + OCCUPANCY, + NO_EVENTS, + CHARGING, + PRIVACY_SWITCH, + THERMAL_OVERRIDE, + DEVICE_UPDATING, + USED_BY_DUO, + NOT_CONNECTED, + BATTERY_OVERRIDE, + UNMOUNTED, + BATTERY_FAULT, + STILL_IMAGE_ONLY, + ETR_MODE, +} + +fun VideoUnavailableReason.toNoVideoReason(): NoVideoReason { + return when (this) { + VideoUnavailableReason.User -> NoVideoReason.USER + VideoUnavailableReason.Schedule -> NoVideoReason.SCHEDULE + VideoUnavailableReason.Occupancy -> NoVideoReason.OCCUPANCY + VideoUnavailableReason.NoEvents -> NoVideoReason.NO_EVENTS + VideoUnavailableReason.Charging -> NoVideoReason.CHARGING + VideoUnavailableReason.PrivacySwitch -> NoVideoReason.PRIVACY_SWITCH + VideoUnavailableReason.ThermalOverride -> NoVideoReason.THERMAL_OVERRIDE + VideoUnavailableReason.DeviceUpdating -> NoVideoReason.DEVICE_UPDATING + VideoUnavailableReason.UsedByDuo -> NoVideoReason.USED_BY_DUO + VideoUnavailableReason.NotConnected -> NoVideoReason.NOT_CONNECTED + VideoUnavailableReason.BatteryOverride -> NoVideoReason.BATTERY_OVERRIDE + VideoUnavailableReason.Unmounted -> NoVideoReason.UNMOUNTED + VideoUnavailableReason.BatteryFault -> NoVideoReason.BATTERY_FAULT + VideoUnavailableReason.StillImageOnly -> NoVideoReason.STILL_IMAGE_ONLY + VideoUnavailableReason.EtrMode -> NoVideoReason.ETR_MODE + else -> NoVideoReason.UNSPECIFIED + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/GhpTimelineDataSource.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/GhpTimelineDataSource.kt new file mode 100644 index 0000000..14f584d --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/GhpTimelineDataSource.kt @@ -0,0 +1,130 @@ +package com.example.googlehomeapisampleapp.camera.timeline.repository + +import android.util.Log +import com.example.googlehomeapisampleapp.HomeClientProvider +import com.google.home.google.CameraTimeline +import com.google.home.google.CameraTimelineTrait.TimelineMode as TraitTimelineMode +import com.google.home.google.GoogleCameraDevice +import com.google.home.google.GoogleDoorbellDevice +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withTimeout +import java.time.Duration as JavaDuration +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds + +@Singleton +class GhpTimelineDataSource @Inject constructor( + private val homeClientProvider: HomeClientProvider +) : TimelineDataSource { + + override fun fetch(deviceId: String, range: TimelineRange): Flow = flow { + val chunks = range.split(JavaDuration.ofHours(11)) + val trait = getCameraTimelineTrait(deviceId) + + for (chunk in chunks) { + var pageToken: String? = null + do { + val response = withRetry { + withTimeout(20.seconds) { + trait.listTimelinePeriods( + startTimeMillis = chunk.start.toEpochMilli().toULong(), + endTimeMillis = chunk.endInclusive.toEpochMilli().toULong(), + modes = listOf( + TraitTimelineMode.Video, + TraitTimelineMode.Events, + TraitTimelineMode.CameraStates, + ), + optionalArgs = { + if (!pageToken.isNullOrEmpty()) { + this.pageToken = pageToken!! + } + }, + ) + } + } ?: break + + val intervals = response.timelinePeriodsStreams + .flatMap { stream -> + val mode = stream.timelineMode.toTimelineMode() + stream.periods.map { period -> + val timelinePeriod = period.toTimelinePeriod(mode) + mode to timelinePeriod + } + } + .groupBy({ pair -> pair.first }, { pair -> pair.second }) + + if (intervals.isNotEmpty()) { + emit( + TimelineData( + videoTimelines = intervals[TimelineMode.VIDEO]?.map { it as TimelinePeriod.Video } + ?: emptyList(), + eventTimelines = intervals[TimelineMode.EVENTS]?.map { it as TimelinePeriod.Events } + ?: emptyList(), + cameraStateTimelines = intervals[TimelineMode.CAMERA_STATES]?.map { it as TimelinePeriod.NoVideo } + ?: emptyList(), + ) + ) + } + pageToken = response.nextPageToken + } while (!pageToken.isNullOrEmpty()) + } + } + + private fun TimelineRange.split(maxDuration: JavaDuration): List { + if (JavaDuration.between(start, endInclusive) <= maxDuration) { + return listOf(this) + } + val results = mutableListOf() + var currentStart = start + while (currentStart < endInclusive) { + var nextEnd = currentStart.plus(maxDuration) + if (nextEnd > endInclusive) { + nextEnd = endInclusive + } + results.add(TimelineRange(currentStart, nextEnd)) + currentStart = nextEnd + } + return results + } + + private suspend fun withRetry(times: Int = 1, block: suspend () -> T): T? { + var currentAttempt = 0 + while (true) { + try { + return block() + } catch (e: Exception) { + if (e is TimeoutCancellationException) { + Log.w(TAG, "Timeout fetching timelines") + } else { + Log.w(TAG, "Error fetching timelines", e) + } + + if (currentAttempt < times) { + currentAttempt++ + } else { + return null + } + } + } + } + + private suspend fun getCameraTimelineTrait(deviceId: String): CameraTimeline { + val homeClient = homeClientProvider.getClient() + val devices = homeClient.devices().first() + val device = devices.find { it.id.id == deviceId } + ?: throw IllegalArgumentException("Device not found: $deviceId") + + val cameraType = listOf(GoogleCameraDevice, GoogleDoorbellDevice).first { device.has(it) } + val deviceType = device.type(cameraType).first() + return deviceType.trait(CameraTimeline) + ?: throw IllegalStateException("CameraTimeline trait not found") + } + + companion object { + private const val TAG = "GhpTimelineDataSource" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/InMemoryTimelineCache.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/InMemoryTimelineCache.kt new file mode 100644 index 0000000..1ac8d79 --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/InMemoryTimelineCache.kt @@ -0,0 +1,172 @@ +package com.example.googlehomeapisampleapp.camera.timeline.repository + +import android.util.Log +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class InMemoryTimelineCache @Inject constructor() : TimelineCache { + private val deviceCaches = MutableStateFlow>(emptyMap()) + + override fun getAllTimelines(deviceId: String): Flow { + return deviceCaches.map { it[deviceId]?.timelines ?: TimelineData() }.distinctUntilChanged() + } + + override fun getTimelines(deviceId: String, range: TimelineRange): TimelineData { + val timelines = deviceCaches.value[deviceId]?.timelines ?: return TimelineData() + + return TimelineData( + videoTimelines = timelines.videoTimelines.overlappingWith(range), + eventTimelines = timelines.eventTimelines.overlappingWith(range), + cameraStateTimelines = timelines.cameraStateTimelines.overlappingWith(range), + ) + } + + override fun getMissingRanges(deviceId: String, range: TimelineRange): List { + if (range.start >= range.endInclusive) return emptyList() + + val fetchedRanges = deviceCaches.value[deviceId]?.fetchedRanges ?: return listOf(range) + if (fetchedRanges.isEmpty()) { + return listOf(range) + } + + val gaps = mutableListOf() + var coveredUntil = range.start + + for (fetchedRange in fetchedRanges) { + if (fetchedRange.start >= range.endInclusive) break + if (fetchedRange.endInclusive <= coveredUntil) continue + + if (fetchedRange.start > coveredUntil) { + gaps.add(TimelineRange(coveredUntil, fetchedRange.start)) + } + coveredUntil = maxOf(coveredUntil, fetchedRange.endInclusive) + } + + if (coveredUntil < range.endInclusive) { + gaps.add(TimelineRange(coveredUntil, range.endInclusive)) + } + + return gaps + } + + override fun updateTimelines(deviceId: String, range: TimelineRange, results: TimelineData) { + Log.i(TAG, "updateTimelines: deviceId=$deviceId") + + deviceCaches.update { currentMap -> + val deviceCache = currentMap[deviceId] ?: DeviceCache() + val timelines = deviceCache.timelines + + val videoList = timelines.videoTimelines.toMutableList() + val eventList = timelines.eventTimelines.toMutableList() + val cameraStateList = timelines.cameraStateTimelines.toMutableList() + + insertSortedResults(videoList, results.videoTimelines) + insertSortedResults(eventList, results.eventTimelines) + insertSortedResults(cameraStateList, results.cameraStateTimelines) + + val newTimelines = TimelineData( + videoTimelines = videoList.takeLast(MAX_PERIODS_PER_TYPE), + eventTimelines = eventList.takeLast(MAX_PERIODS_PER_TYPE), + cameraStateTimelines = cameraStateList.takeLast(MAX_PERIODS_PER_TYPE), + ) + + val newFetchedRanges = deviceCache.fetchedRanges.toMutableList() + mergeAndAdd(newFetchedRanges, TimelineRange(range.start, range.endInclusive)) + + currentMap + (deviceId to DeviceCache(newTimelines, newFetchedRanges)) + } + } + + private fun insertSortedResults(current: MutableList, new: List) { + val existingKeys = current.map { it.startTime to it.endTime }.toHashSet() + val toInsert = new.filter { (it.startTime to it.endTime) !in existingKeys } + if (toInsert.isEmpty()) return + val insertionIndex = run { + val idx = current.binarySearchBy(toInsert.first().startTime) { it.startTime } + if (idx < 0) -(idx + 1) else idx + } + current.addAll(insertionIndex, toInsert) + } + + private fun mergeAndAdd(list: MutableList, newInterval: TimelineRange) { + if (newInterval.start > newInterval.endInclusive) return + + var insertionIndex = 0 + while (insertionIndex < list.size && list[insertionIndex].start < newInterval.start) { + insertionIndex++ + } + + var mergedStart = newInterval.start + var mergedEnd = newInterval.endInclusive + + if (insertionIndex > 0) { + val prev = list[insertionIndex - 1] + if (prev.endInclusive >= mergedStart) { + mergedStart = prev.start + mergedEnd = maxOf(mergedEnd, prev.endInclusive) + list.removeAt(insertionIndex - 1) + insertionIndex-- + } + } + + while (insertionIndex < list.size) { + val next = list[insertionIndex] + if (next.start <= mergedEnd) { + mergedEnd = maxOf(mergedEnd, next.endInclusive) + list.removeAt(insertionIndex) + } else { + break + } + } + + list.add(insertionIndex, TimelineRange(mergedStart, mergedEnd)) + } + + private fun insertSorted(list: MutableList, newInterval: T) { + if (newInterval.startTime > newInterval.endTime) return + + val index = list.binarySearchBy(newInterval.startTime) { it.startTime } + val insertionIndex = if (index < 0) -(index + 1) else index + list.add(insertionIndex, newInterval) + } + + private fun List.overlappingWith(range: TimelineRange): List { + if (isEmpty()) return emptyList() + + var fromIndex = binarySearchBy(range.start) { it.endTime } + if (fromIndex < 0) { + fromIndex = -(fromIndex + 1) + } + + var toIndex = binarySearchBy(range.endInclusive) { it.startTime } + if (toIndex < 0) { + toIndex = -(toIndex + 1) + } else { + toIndex++ + } + + if (fromIndex > toIndex || fromIndex >= size) return emptyList() + return subList(fromIndex, toIndex) + } + + override fun clear() { + Log.i(TAG, "Clearing cache") + deviceCaches.update { emptyMap() } + } + + private data class DeviceCache( + val timelines: TimelineData = TimelineData(), + val fetchedRanges: List = emptyList(), + ) + + companion object { + private const val TAG = "InMemoryTimelineCache" + private const val MAX_PERIODS_PER_TYPE = 500 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineCache.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineCache.kt new file mode 100644 index 0000000..e0f013b --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineCache.kt @@ -0,0 +1,26 @@ +package com.example.googlehomeapisampleapp.camera.timeline.repository + +import kotlinx.coroutines.flow.Flow + +interface TimelineCache { + /** Returns a flow of all cached timeline intervals for a device. */ + fun getAllTimelines(deviceId: String): Flow + + /** + * Returns the cached timeline intervals for a device within the given range. + */ + fun getTimelines(deviceId: String, range: TimelineRange): TimelineData + + /** + * Returns the portions of the given `range` that are missing from the cache. + */ + fun getMissingRanges(deviceId: String, range: TimelineRange): List + + /** + * Adds the given results to the cache and marks the range as covered. + */ + fun updateTimelines(deviceId: String, range: TimelineRange, results: TimelineData) + + /** Clears the cache. */ + fun clear() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineDataSource.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineDataSource.kt new file mode 100644 index 0000000..36a2f8f --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineDataSource.kt @@ -0,0 +1,11 @@ +package com.example.googlehomeapisampleapp.camera.timeline.repository + +import kotlinx.coroutines.flow.Flow + +/** Interface for fetching timeline data from the network/trait. */ +interface TimelineDataSource { + /** + * Fetches timeline data for a device within a given time range. + */ + fun fetch(deviceId: String, range: TimelineRange): Flow +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineModule.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineModule.kt new file mode 100644 index 0000000..bd8793a --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineModule.kt @@ -0,0 +1,24 @@ +/* Copyright 2025 Google LLC */ +package com.example.googlehomeapisampleapp.camera.timeline.repository + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class TimelineModule { + @Binds + @Singleton + abstract fun bindTimelineDataSource(impl: GhpTimelineDataSource): TimelineDataSource + + @Binds + @Singleton + abstract fun bindTimelineCache(impl: InMemoryTimelineCache): TimelineCache + + @Binds + @Singleton + abstract fun bindTimelineRepository(impl: TimelineRepositoryImpl): TimelineRepository +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineRepository.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineRepository.kt new file mode 100644 index 0000000..dd0eed0 --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineRepository.kt @@ -0,0 +1,17 @@ +package com.example.googlehomeapisampleapp.camera.timeline.repository + +import kotlinx.coroutines.flow.Flow + +/** Repository for retrieving timeline data. */ +interface TimelineRepository { + /** + * Returns a flow of all currently cached timeline data for the given device. + */ + fun observeTimelines(deviceId: String): Flow + + /** + * Fetches timeline data for a device within a given time range, + * using the cache where possible. + */ + suspend fun fetchTimelinePeriod(deviceId: String, range: TimelineRange) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineRepositoryImpl.kt b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineRepositoryImpl.kt new file mode 100644 index 0000000..d63ec9d --- /dev/null +++ b/app/src/main/java/com/example/googlehomeapisampleapp/camera/timeline/repository/TimelineRepositoryImpl.kt @@ -0,0 +1,34 @@ +package com.example.googlehomeapisampleapp.camera.timeline.repository + +import android.util.Log +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "TimelineRepositoryImpl" + +@Singleton +class TimelineRepositoryImpl @Inject constructor( + private val dataSource: TimelineDataSource, + private val cache: TimelineCache, +) : TimelineRepository { + + override fun observeTimelines(deviceId: String): Flow { + return cache.getAllTimelines(deviceId) + } + + override suspend fun fetchTimelinePeriod(deviceId: String, range: TimelineRange) { + val missingRanges = cache.getMissingRanges(deviceId, range) + if (missingRanges.isEmpty()) { + Log.d(TAG, "Cache fully covers range for device: $deviceId") + return + } + + for (missingRange in missingRanges) { + Log.d(TAG, "Fetching missing range $missingRange for device: $deviceId") + dataSource.fetch(deviceId, missingRange).collect { chunk -> + cache.updateTimelines(deviceId, missingRange, chunk) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/history/HistoryUiDataModel.kt b/app/src/main/java/com/example/googlehomeapisampleapp/history/HistoryUiDataModel.kt index 462a86d..4896cd0 100644 --- a/app/src/main/java/com/example/googlehomeapisampleapp/history/HistoryUiDataModel.kt +++ b/app/src/main/java/com/example/googlehomeapisampleapp/history/HistoryUiDataModel.kt @@ -14,6 +14,7 @@ limitations under the License. */ package com.example.googlehomeapisampleapp.history +import android.util.Log import com.google.home.HistoryItem import com.google.home.annotation.HomeExperimentalApi import com.google.home.google.CameraHistory @@ -42,6 +43,7 @@ sealed interface HistoryUiDataModel : HistoryEventUi { val eventType: HistoryUiEventType, val mediaUrl: MediaUrl, val deviceId: String, + val shortCaption: String? = null ) : HistoryUiDataModel } @@ -116,12 +118,18 @@ private val EVENT_TYPE_PRIORITY = listOf( @OptIn(HomeExperimentalApi::class) fun HistoryItem.toUiDataModel(): HistoryUiDataModel { val event = this.event + Log.d("HistoryUiDataModel", "toUiDataModel: event type = ${event.javaClass.simpleName}, eventName = ${event.eventName}") return when (event) { is CameraHistory.HistoryItemEvent -> { val eventTypes = event.eventTracks?.flatMap { it.eventTypes }?.toSet() val detected = EVENT_TYPE_PRIORITY.firstOrNull { eventTypes?.contains(it) == true } ?: CameraHistoryTrait.EventType.Unknown + Log.d("HistoryUiDataModel", "CameraEvent captions: ${event.captions}") + // Extract the short caption if it exists + val shortCaption = event.captions?.find { it.captionType == CameraHistoryTrait.CaptionType.Short }?.captionText + Log.d("HistoryUiDataModel", "Extracted shortCaption: $shortCaption") + HistoryUiDataModel.CameraEvent( eventId = id.id, timestamp = timestamp, @@ -129,17 +137,21 @@ fun HistoryItem.toUiDataModel(): HistoryUiDataModel { eventType = HistoryUiEventType.fromEventType(detected), mediaUrl = MediaUrl.fromCameraHistoryMediaUrl(event.mediaUrl), deviceId = entityId.id, + shortCaption = shortCaption // Add this argument + ) + } + else -> { + Log.d("HistoryUiDataModel", "DefaultEvent: fallback handling") + HistoryUiDataModel.DefaultEvent( + eventId = id.id, + timestamp = timestamp, + entityName = entityName ?: "Event", + eventName = event.eventName, ) } - else -> HistoryUiDataModel.DefaultEvent( - eventId = id.id, - timestamp = timestamp, - entityName = entityName ?: "Event", - eventName = event.eventName, - ) } } /** Formats an [Instant] to a human-readable time string. */ internal fun Instant.formatToTime(): String = - DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()).format(this) \ No newline at end of file + DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()).format(this) diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/history/HistoryVideoPlayer.kt b/app/src/main/java/com/example/googlehomeapisampleapp/history/HistoryVideoPlayer.kt index b1462d2..c0161ef 100644 --- a/app/src/main/java/com/example/googlehomeapisampleapp/history/HistoryVideoPlayer.kt +++ b/app/src/main/java/com/example/googlehomeapisampleapp/history/HistoryVideoPlayer.kt @@ -12,6 +12,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ + package com.example.googlehomeapisampleapp.history import android.view.ViewGroup @@ -47,13 +48,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -61,6 +62,8 @@ import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException @@ -68,6 +71,7 @@ import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView +import coil3.compose.AsyncImage /** Playback state for [HistoryVideoPlayer]. */ enum class VideoPlayerState { @@ -79,34 +83,99 @@ enum class VideoPlayerState { } /** - * Composable video player that plays camera history clips. + * Composable that displays the appropriate media for a camera history event: * - * Uses MP4 as the primary playback path. Nest camera DASH and HLS streams embed - * short-lived cc_tokens in segment URLs that expire before ExoPlayer can fetch them, - * causing 400 errors. The MP4 URL uses a longer-lived cc_signature on a different - * serving endpoint and is reliable. DASH and HLS are kept as fallbacks in case the - * MP4 URL is unavailable. + * - **Video** (MP4, DASH, HLS): Plays the clip with audio via ExoPlayer. + * - **Still Image** (previewUrl or thumbnailUrl): Shows the snapshot image. + * - **No media**: Shows a placeholder icon. * - * Playback order: MP4 (primary) → DASH (fallback) → HLS (final fallback). + * Playback order for video: MP4 (primary) → DASH (fallback) → HLS (final fallback). + * MP4 is preferred because Nest DASH/HLS streams embed short-lived tokens in segment + * URLs that can expire before ExoPlayer fetches them. * - * @param dashManifestUrl The DASH manifest URL (.mpd). Fallback if mp4Url is blank. - * @param hlsUrl The HLS master playlist URL (.m3u8). Final fallback. - * @param mp4Url The direct MP4 download URL. Primary playback path. - * @param modifier Optional modifier for the outer container. + * @param mediaUrl The [MediaUrl] for this event, containing all available media URLs. + * @param modifier Optional modifier for the outer container. */ @OptIn(UnstableApi::class) @Composable fun HistoryVideoPlayer( - dashManifestUrl: String?, - hlsUrl: String? = null, - mp4Url: String? = null, + mediaUrl: MediaUrl, modifier: Modifier = Modifier, +) { + val hasVideo = mediaUrl.hasVideo + val imageUrl = mediaUrl.previewUrl.ifBlank { mediaUrl.thumbnailUrl } + val hasImage = imageUrl.isNotBlank() + + Box( + modifier = modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp)) + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + when { + // No media available + !hasVideo && !hasImage -> { + Icon( + imageVector = Icons.Outlined.Videocam, + contentDescription = "No media available", + modifier = Modifier.size(64.dp), + tint = Color.DarkGray, + ) + } + + // Still image — show snapshot captured in Still Images recording mode + !hasVideo && hasImage -> { + AsyncImage( + model = imageUrl, + contentDescription = "Event snapshot", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + Text( + text = "Still Image", + color = Color.White, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + .background( + color = Color.Black.copy(alpha = 0.5f), + shape = RoundedCornerShape(4.dp), + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + + // Video available — play with audio + else -> { + VideoPlayerContent( + mp4Url = mediaUrl.mp4DownloadUrl, + dashManifestUrl = mediaUrl.dashManifestUrl, + hlsUrl = mediaUrl.hlsMasterPlaylistUrl, + ) + } + } + } +} + +/** + * Internal composable that handles video playback with full audio support. + * Manages ExoPlayer lifecycle, fallback URL logic, and buffering state. + */ +@OptIn(UnstableApi::class) +@Composable +private fun VideoPlayerContent( + mp4Url: String, + dashManifestUrl: String, + hlsUrl: String, ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val currentMp4Url by rememberUpdatedState(mp4Url) val currentDashUrl by rememberUpdatedState(dashManifestUrl) val currentHlsUrl by rememberUpdatedState(hlsUrl) - val currentMp4Url by rememberUpdatedState(mp4Url) var playerState by remember { mutableStateOf(VideoPlayerState.IDLE) } var errorMessage by remember { mutableStateOf(null) } @@ -117,7 +186,15 @@ fun HistoryVideoPlayer( val exoPlayer = remember { ExoPlayer.Builder(context).build().apply { - playWhenReady = true + // Configure audio attributes so the system grants audio focus + // and routes audio through the correct output stream. + val audioAttributes = AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build() + setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true) + volume = 1f + playWhenReady = false } } @@ -143,20 +220,17 @@ fun HistoryVideoPlayer( errorMessage = null when { - !currentMp4Url.isNullOrBlank() -> - loadMediaItem(currentMp4Url!!, MimeTypes.VIDEO_MP4) - !currentDashUrl.isNullOrBlank() -> { + currentMp4Url.isNotBlank() -> + loadMediaItem(currentMp4Url, MimeTypes.VIDEO_MP4) + currentDashUrl.isNotBlank() -> { usingDash = true - loadMediaItem(currentDashUrl!!, MimeTypes.APPLICATION_MPD) + loadMediaItem(currentDashUrl, MimeTypes.APPLICATION_MPD) } - !currentHlsUrl.isNullOrBlank() -> { + currentHlsUrl.isNotBlank() -> { usingHls = true - loadMediaItem(currentHlsUrl!!, MimeTypes.APPLICATION_M3U8) - } - else -> { - playerState = VideoPlayerState.IDLE - errorMessage = null + loadMediaItem(currentHlsUrl, MimeTypes.APPLICATION_M3U8) } + else -> playerState = VideoPlayerState.IDLE } } @@ -174,25 +248,26 @@ fun HistoryVideoPlayer( } override fun onPlayerError(error: PlaybackException) { + // Attempt fallback URLs in order: MP4 → DASH → HLS when { - !usingDash && !usingHls && !currentDashUrl.isNullOrBlank() -> { + !usingDash && !usingHls && currentDashUrl.isNotBlank() -> { usingDash = true playerState = VideoPlayerState.LOADING errorMessage = null - loadMediaItem(currentDashUrl!!, MimeTypes.APPLICATION_MPD) + loadMediaItem(currentDashUrl, MimeTypes.APPLICATION_MPD) } - !usingDash && !usingHls && !currentHlsUrl.isNullOrBlank() -> { + !usingDash && !usingHls && currentHlsUrl.isNotBlank() -> { usingHls = true playerState = VideoPlayerState.LOADING errorMessage = null - loadMediaItem(currentHlsUrl!!, MimeTypes.APPLICATION_M3U8) + loadMediaItem(currentHlsUrl, MimeTypes.APPLICATION_M3U8) } - usingDash && !usingHls && !currentHlsUrl.isNullOrBlank() -> { + usingDash && !usingHls && currentHlsUrl.isNotBlank() -> { usingDash = false usingHls = true playerState = VideoPlayerState.LOADING errorMessage = null - loadMediaItem(currentHlsUrl!!, MimeTypes.APPLICATION_M3U8) + loadMediaItem(currentHlsUrl, MimeTypes.APPLICATION_M3U8) } else -> { playerState = VideoPlayerState.ERROR @@ -205,8 +280,7 @@ fun HistoryVideoPlayer( onDispose { exoPlayer.removeListener(listener) } } - // Start playback when a new event is opened - LaunchedEffect(dashManifestUrl, mp4Url, hlsUrl) { + LaunchedEffect(mp4Url, dashManifestUrl, hlsUrl) { startPlayback() } @@ -228,93 +302,59 @@ fun HistoryVideoPlayer( onDispose { exoPlayer.release() } } - Box( - modifier = modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp)) - .background(Color.Black), - contentAlignment = Alignment.Center, - ) { - when { - // No clip available — placeholder - currentMp4Url.isNullOrBlank() && - currentDashUrl.isNullOrBlank() && - currentHlsUrl.isNullOrBlank() -> { - Icon( - imageVector = Icons.Outlined.Videocam, - contentDescription = "No video selected", - modifier = Modifier.size(64.dp), - tint = Color.DarkGray, - ) - } - - // Error state with retry - playerState == VideoPlayerState.ERROR -> { - Box( - modifier = Modifier.matchParentSize(), - contentAlignment = Alignment.Center, - ) { - Text( - text = errorMessage ?: "Unable to play video", - color = Color.White, - style = MaterialTheme.typography.bodyMedium, - ) - Icon( - imageVector = Icons.Outlined.Refresh, - contentDescription = "Retry", - tint = Color.White, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 16.dp) - .size(32.dp) - .clickable { startPlayback() }, + if (playerState == VideoPlayerState.ERROR) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = errorMessage ?: "Unable to play video", + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + ) + Icon( + imageVector = Icons.Outlined.Refresh, + contentDescription = "Retry playback", + tint = Color.White, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp) + .size(32.dp) + .clickable { startPlayback() }, + ) + } + } else { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + useController = true + controllerAutoShow = true + controllerShowTimeoutMs = 3000 + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, ) } - } + }, + update = { playerView -> playerView.player = exoPlayer }, + modifier = Modifier.fillMaxSize(), + ) - // Video surface — shown for LOADING, PLAYING, and ENDED states. - // ENDED keeps the player surface visible so the user can replay via controls. - else -> { - AndroidView( - factory = { ctx -> - PlayerView(ctx).apply { - player = exoPlayer - useController = true - controllerAutoShow = true - controllerShowTimeoutMs = 3000 - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - } - }, - update = { playerView -> - playerView.player = exoPlayer - }, - modifier = Modifier.matchParentSize(), - ) - - // Loading overlay — only shown while buffering, not when ended - AnimatedVisibility( - visible = playerState == VideoPlayerState.LOADING, - enter = fadeIn(), - exit = fadeOut(), - ) { - CircularProgressIndicator( - color = Color.White, - strokeWidth = 3.dp, - modifier = Modifier.size(48.dp), - ) - } - } + AnimatedVisibility( + visible = playerState == VideoPlayerState.LOADING, + enter = fadeIn(), + exit = fadeOut(), + ) { + CircularProgressIndicator( + color = Color.White, + strokeWidth = 3.dp, + modifier = Modifier.size(48.dp), + ) } } } /** - * Full-screen video player with a top bar and back button. - * Shown when a user taps a camera history event from the list. + * Full-screen media viewer shown when a user taps a camera history event. + * Displays video with audio for clip events, or a still image for snapshot events. */ @Composable fun HistoryVideoPlayerScreen( @@ -357,9 +397,7 @@ fun HistoryVideoPlayerScreen( // Video player — MP4 primary, DASH/HLS as fallbacks HistoryVideoPlayer( - dashManifestUrl = event.mediaUrl.dashManifestUrl, - hlsUrl = event.mediaUrl.hlsMasterPlaylistUrl, - mp4Url = event.mediaUrl.mp4DownloadUrl, + mediaUrl = event.mediaUrl, modifier = Modifier.weight(1f), ) } diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/history/HistoryView.kt b/app/src/main/java/com/example/googlehomeapisampleapp/history/HistoryView.kt index 89866af..ea008d3 100644 --- a/app/src/main/java/com/example/googlehomeapisampleapp/history/HistoryView.kt +++ b/app/src/main/java/com/example/googlehomeapisampleapp/history/HistoryView.kt @@ -223,7 +223,7 @@ fun HistoryItemRow( private fun getEventMetadata(model: HistoryUiDataModel): Pair { return when (model) { is HistoryUiDataModel.CameraEvent -> { - val (title, icon) = when (model.eventType) { + val defaultTitleIcon = when (model.eventType) { HistoryUiEventType.Person -> "Person" to Icons.Outlined.Person HistoryUiEventType.Motion -> "Motion" to Icons.Outlined.MotionPhotosOn HistoryUiEventType.Doorbell -> "Doorbell" to Icons.Outlined.Doorbell @@ -231,7 +231,9 @@ private fun getEventMetadata(model: HistoryUiDataModel): Pair "Animal" to Icons.Outlined.Pets HistoryUiEventType.Unknown -> "Camera Event" to Icons.Outlined.Videocam } - title to icon + // Prioritize shortCaption if available and not blank + val title = model.shortCaption?.takeIf { it.isNotBlank() } ?: defaultTitleIcon.first + title to defaultTitleIcon.second // Keep the original icon } is HistoryUiDataModel.DefaultEvent -> { (model.eventName ?: "Activity") to Icons.Outlined.History diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/view/devices/DeviceView.kt b/app/src/main/java/com/example/googlehomeapisampleapp/view/devices/DeviceView.kt index ab58f18..921ba89 100644 --- a/app/src/main/java/com/example/googlehomeapisampleapp/view/devices/DeviceView.kt +++ b/app/src/main/java/com/example/googlehomeapisampleapp/view/devices/DeviceView.kt @@ -240,6 +240,12 @@ fun DeviceView(homeAppVM: HomeAppViewModel) { //Device info val deviceInfo by cameraVm.deviceInfo.collectAsStateWithLifecycle() + val cameraTimelineUiState by cameraVm.cameraTimelineUiState.collectAsStateWithLifecycle() + + // Recording Mode + val recordingModeOptions by cameraVm.recordingModeOptions.collectAsStateWithLifecycle() + val selectedRecordingModeIndex by cameraVm.selectedRecordingModeIndex.collectAsStateWithLifecycle() + CameraStreamView( playerState = playerState, isTalkbackSupported = isTalkbackSupported, @@ -267,6 +273,14 @@ fun DeviceView(homeAppVM: HomeAppViewModel) { chimeType = chimeType, onSetChimeType = { selectedType -> cameraVm.setExternalChimeType(selectedType) }, + // Timeline + cameraTimelineUiState = cameraTimelineUiState, + + // Recording Mode + recordingModeOptions = recordingModeOptions, + selectedRecordingModeIndex = selectedRecordingModeIndex, + onSetRecordingMode = { index -> cameraVm.setRecordingMode(index) }, + paddingValues = PaddingValues(0.dp), onRetry = { cameraVm.restartInitialization() }, onSetAudioRecording = {cameraVm.setAudioRecording(it) }, @@ -623,14 +637,23 @@ fun FanControlComponent( isConnected: Boolean, ) { val scope = rememberCoroutineScope() - val fanMode = trait.fanMode ?: FanControlTrait.FanModeEnum.Off - var sliderPosition by remember(fanMode) { + val fanMode = trait.fanMode + val percentSetting = trait.percentSetting + // Determine if this device supports fanMode or only percentSetting + val supportsFanMode = fanMode != null + val supportsPercent = percentSetting != null + + var sliderPosition by remember(fanMode, percentSetting) { mutableFloatStateOf( - when (fanMode) { - FanControlTrait.FanModeEnum.Off -> 0f - FanControlTrait.FanModeEnum.Low -> 25f - FanControlTrait.FanModeEnum.Medium -> 50f - FanControlTrait.FanModeEnum.High -> 75f + when { + supportsPercent -> percentSetting!!.toFloat() + supportsFanMode -> when (fanMode) { + FanControlTrait.FanModeEnum.Off -> 0f + FanControlTrait.FanModeEnum.Low -> 25f + FanControlTrait.FanModeEnum.Medium -> 50f + FanControlTrait.FanModeEnum.High -> 75f + else -> 0f + } else -> 0f } ) @@ -647,71 +670,70 @@ fun FanControlComponent( Column { Spacer(Modifier.height(8.dp)) - Box(Modifier.fillMaxWidth()) { - Text("Fan Mode", fontSize = 16.sp) - var expanded by remember { mutableStateOf(false) } - Box(modifier = Modifier.align(Alignment.CenterEnd)) { - TextButton( - onClick = { if (isConnected) expanded = true }, - enabled = isConnected - ) { - Text( - text = when (fanMode) { - FanControlTrait.FanModeEnum.Off -> "Off" - FanControlTrait.FanModeEnum.Low -> "Low" - FanControlTrait.FanModeEnum.Medium -> "Medium" - FanControlTrait.FanModeEnum.High -> "High" - else -> "Unknown" - }, - color = if (isConnected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - } - ) - Icon( - imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = null - ) - } + // Only show Fan Mode dropdown if the device supports it + if (supportsFanMode) { + Box(Modifier.fillMaxWidth()) { + Text("Fan Mode", fontSize = 16.sp) + var expanded by remember { mutableStateOf(false) } + Box(modifier = Modifier.align(Alignment.CenterEnd)) { + TextButton( + onClick = { if (isConnected) expanded = true }, + enabled = isConnected + ) { + Text( + text = when (fanMode) { + FanControlTrait.FanModeEnum.Off -> "Off" + FanControlTrait.FanModeEnum.Low -> "Low" + FanControlTrait.FanModeEnum.Medium -> "Medium" + FanControlTrait.FanModeEnum.High -> "High" + else -> "Unknown" + }, + color = if (isConnected) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + Icon( + imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = null + ) + } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - listOf( - FanControlTrait.FanModeEnum.Off to "Off", - FanControlTrait.FanModeEnum.Low to "Low", - FanControlTrait.FanModeEnum.Medium to "Medium", - FanControlTrait.FanModeEnum.High to "High" - ).forEach { (mode, label) -> - DropdownMenuItem( - text = { Text(label) }, - onClick = { - expanded = false - scope.launch { - try { - trait.update { setFanMode(mode) } - // Update slider to designated position when mode selected from dropdown - sliderPosition = when (mode) { - FanControlTrait.FanModeEnum.Off -> 0f - FanControlTrait.FanModeEnum.Low -> 25f - FanControlTrait.FanModeEnum.Medium -> 50f - FanControlTrait.FanModeEnum.High -> 75f - else -> 0f + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + listOf( + FanControlTrait.FanModeEnum.Off to "Off", + FanControlTrait.FanModeEnum.Low to "Low", + FanControlTrait.FanModeEnum.Medium to "Medium", + FanControlTrait.FanModeEnum.High to "High" + ).forEach { (mode, label) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + expanded = false + scope.launch { + try { + trait.update { setFanMode(mode) } + sliderPosition = when (mode) { + FanControlTrait.FanModeEnum.Off -> 0f + FanControlTrait.FanModeEnum.Low -> 25f + FanControlTrait.FanModeEnum.Medium -> 50f + FanControlTrait.FanModeEnum.High -> 75f + else -> 0f + } + } catch (e: Exception) { + MainActivity.showWarning(scope, "Failed to set fan mode: ${e.message}") } - } catch (e: Exception) { - MainActivity.showWarning(scope, "Failed to set fan mode: ${e.message}") } } - } - ) + ) + } } } } + Spacer(Modifier.height(16.dp)) } - Spacer(Modifier.height(16.dp)) + Box(Modifier.fillMaxWidth()) { Text("Fan Speed", fontSize = 16.sp) Text( @@ -734,8 +756,17 @@ fun FanControlComponent( onValueChangeFinished = { value -> scope.launch { try { - val newMode = percentageToFanMode(value) - trait.update { setFanMode(newMode) } + when { + // Prefer percentSetting if supported (playground fan) + supportsPercent -> { + trait.update { setPercentSetting(value.toInt().toUByte()) } + } + // Fall back to fanMode mapping + supportsFanMode -> { + val newMode = percentageToFanMode(value) + trait.update { setFanMode(newMode) } + } + } } catch (e: Exception) { MainActivity.showWarning(scope, "Failed to set fan speed: ${e.message}") } diff --git a/app/src/main/java/com/example/googlehomeapisampleapp/viewmodel/devices/DeviceViewModel.kt b/app/src/main/java/com/example/googlehomeapisampleapp/viewmodel/devices/DeviceViewModel.kt index 9b96eae..19b259b 100644 --- a/app/src/main/java/com/example/googlehomeapisampleapp/viewmodel/devices/DeviceViewModel.kt +++ b/app/src/main/java/com/example/googlehomeapisampleapp/viewmodel/devices/DeviceViewModel.kt @@ -61,10 +61,14 @@ import com.google.home.matter.standard.Thermostat import com.google.home.matter.standard.ThermostatDevice import com.google.home.matter.standard.WindowCovering import com.google.home.matter.standard.WindowCoveringDevice +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch /** @@ -127,7 +131,13 @@ class DeviceViewModel(val device: HomeDevice) : ViewModel() { fun deleteDevice() { viewModelScope.launch { try { + _uiEventFlow.emit(UiEvent.ShowToast("GHP isMatterDevice: ${device.isMatterDevice}")) + _uiEventFlow.emit(UiEvent.ShowToast("GHP isEligible: ${device.checkDecommissionEligibility()}")) + val eligibility = device.checkDecommissionEligibility() + Log.e("DeviceViewModel", "GHP isMatterDevice: ${device.isMatterDevice}") + Log.e("DeviceViewModel", "GHP Decommission eligibility evaluated: $eligibility") + if (eligibility is DecommissionEligibility.Eligible || eligibility is DecommissionEligibility.EligibleWithSideEffects) { device.decommissionDevice() _uiEventFlow.emit(UiEvent.ShowToast("Device deleted successfully.")) @@ -142,22 +152,35 @@ class DeviceViewModel(val device: HomeDevice) : ViewModel() { } } } + + @OptIn(ExperimentalCoroutinesApi::class) private suspend fun subscribeToType() { // Subscribe to changes on device type, and the traits/attributes within: - device.types().collect { typeSet -> - // Define the fallback priority order in one place. + device.types().flatMapLatest { typeSet -> + /** + * Fallback type priority order. + * + * For multi-functional devices (e.g., a Doorbell that also has a Camera), + * the first matching type in this list takes precedence. + * + * To adjust priority, simply reorder the elements below. + */ val fallbackPriorityOrder = listOf( - FanDevice::class, - GoogleCameraDevice::class, + ThermostatDevice::class, GoogleDoorbellDevice::class, + WindowCoveringDevice::class, + FanDevice::class, + DoorLockDevice::class, + SpeakerDevice::class, GoogleTVDevice::class, + DimmableLightDevice::class, + GoogleCameraDevice::class, OnOffLightDevice::class, - SpeakerDevice::class, TemperatureSensorDevice::class, ) // Find the primary type in a single, chained expression. - val primaryType: DeviceType = + val primaryTypeCandidate: DeviceType = // 1. First, try to find the officially marked primary type. typeSet.find { it.metadata.isPrimaryType } // 2. If not found, use the fallback priority list. @@ -167,11 +190,21 @@ class DeviceViewModel(val device: HomeDevice) : ViewModel() { typeSet.find { priorityClass.isInstance(it) } } .firstOrNull() - // 3. If still not found, use the first (and only) type if there's exactly one. - ?: typeSet.singleOrNull() + // 3. If still not found, use the first type if there are any. + ?: typeSet.firstOrNull() // 4. If all else fails, default to UnknownDeviceType. ?: UnknownDeviceType() + // Observe attribute state changes for the primary device type: + // If the type is Unknown, bypass observation to prevent UI hangs. + if (primaryTypeCandidate is UnknownDeviceType) { + flowOf(Pair(primaryTypeCandidate, typeSet)) + } else { + device.type(primaryTypeCandidate.factory).map { updatedPrimaryType -> + Pair(updatedPrimaryType, typeSet) + } + } + }.collect { (primaryType, typeSet) -> // Set the connectivityState from the primary device type: connectivity = primaryType.metadata.sourceConnectivity.connectivityState @@ -347,6 +380,26 @@ class DeviceViewModel(val device: HomeDevice) : ViewModel() { type.metadata.sourceConnectivity.connectivityState != ConnectivityState.PARTIALLY_ONLINE ) return "Offline" + if (type.factory == FanDevice) { + val fanControlTrait = traits.filterIsInstance().firstOrNull() + val onOffTrait = traits.filterIsInstance().firstOrNull() + + if (onOffTrait?.onOff == false) return "Off" + + val fanMode = fanControlTrait?.fanMode + val percentSetting = fanControlTrait?.percentSetting + + return when { + fanMode != null -> fanMode.toString() + percentSetting != null -> when { + percentSetting == 0.toUByte() -> "Off" + percentSetting <= 33.toUByte() -> "Low" + percentSetting <= 66.toUByte() -> "Medium" + else -> "High" + } + else -> "On" + } + } if (targetTrait == null) return "Unsupported" // Default for unmapped device types @@ -392,7 +445,20 @@ class DeviceViewModel(val device: HomeDevice) : ViewModel() { if (trait.lockState == DoorLockTrait.DlLockState.Locked) "Locked" else "Unlocked" } - is FanControl -> trait.fanMode?.toString() ?: "Unknown" + is FanControl -> { + val fanMode = trait.fanMode + val percentSetting = trait.percentSetting + when { + fanMode != null -> fanMode.toString() + percentSetting != null -> when { + percentSetting == 0.toUByte() -> "Off" + percentSetting <= 33.toUByte() -> "Low" + percentSetting <= 66.toUByte() -> "Medium" + else -> "High" + } + else -> "Unknown" + } + } is LevelControl -> { trait.currentLevel.toString() } @@ -435,12 +501,17 @@ class DeviceViewModel(val device: HomeDevice) : ViewModel() { } is WindowCovering -> { - val targetPercent100ths = trait.targetPositionLiftPercent100ths ?: 0u - val openPercentage = 100 - (targetPercent100ths.toInt() / 100) - if (openPercentage == 0) { - "Closed" + val currentPercent100ths = trait.currentPositionLiftPercent100ths ?: 0u + val targetPercent100ths = trait.targetPositionLiftPercent100ths + val currentOpen = 100 - (currentPercent100ths.toInt() / 100) + val status = if (currentOpen == 0) "Closed" else "${currentOpen}% Open" + + if (targetPercent100ths != null && targetPercent100ths != currentPercent100ths) { + val targetOpen = 100 - (targetPercent100ths.toInt() / 100) + val targetStatus = if (targetOpen == 0) "Closed" else "${targetOpen}% Open" + "$status (Target: $targetStatus)" } else { - "${openPercentage}% Open" + status } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5efc3ed..c77a600 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,9 +3,10 @@ agp = "8.9.3" kotlin = "2.2.21" coreKtx = "1.17.0" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.2" +activityCompose = "1.12.4" composeBom = "2026.01.00" materialIconsExtended = "1.7.8" +media3ExoplayerHls = "1.9.3" navigationCompose = "2.9.6" matter-android-demo-sdk = "1.0" hilt = "2.57.2" @@ -31,6 +32,7 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3ExoplayerHls" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }