| // Copyright 2026 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| use bt_common::Uuid; |
| use bt_gatt::types::{ |
| AttributePermissions, CharacteristicProperties, CharacteristicProperty, GattError, Handle, |
| SecurityLevels, |
| }; |
| use bt_gatt::Characteristic; |
| |
| use crate::types::*; |
| |
| // ============================================================================ |
| // Characteristic Definitions & Handles (MCS v1.0.1 Section 3) |
| // ============================================================================ |
| |
| /// Handle assigned to the Media Player Name characteristic. |
| pub(crate) const MEDIA_PLAYER_NAME_HANDLE: Handle = Handle(1); |
| /// Handle assigned to the Track Changed characteristic. |
| pub(crate) const TRACK_CHANGED_HANDLE: Handle = Handle(2); |
| /// Handle assigned to the Track Title characteristic. |
| pub(crate) const TRACK_TITLE_HANDLE: Handle = Handle(3); |
| /// Handle assigned to the Track Duration characteristic. |
| pub(crate) const TRACK_DURATION_HANDLE: Handle = Handle(4); |
| /// Handle assigned to the Track Position characteristic. |
| pub(crate) const TRACK_POSITION_HANDLE: Handle = Handle(5); |
| /// Handle assigned to the Media State characteristic. |
| pub(crate) const MEDIA_STATE_HANDLE: Handle = Handle(6); |
| /// Handle assigned to the Content Control ID (CCID) characteristic. |
| pub(crate) const CONTENT_CONTROL_ID_HANDLE: Handle = Handle(7); |
| |
| // ============================================================================ |
| // Optional Characteristic Handle Definitions (MCS v1.0.1 Section 3) |
| // ============================================================================ |
| |
| /// Handle assigned to the Media Player Icon URL characteristic. |
| pub(crate) const MEDIA_PLAYER_ICON_URL_HANDLE: Handle = Handle(8); |
| /// Handle assigned to the Playback Speed characteristic. |
| pub(crate) const PLAYBACK_SPEED_HANDLE: Handle = Handle(9); |
| /// Handle assigned to the Seeking Speed characteristic. |
| pub(crate) const SEEKING_SPEED_HANDLE: Handle = Handle(10); |
| /// Handle assigned to the Playing Order characteristic. |
| pub(crate) const PLAYING_ORDER_HANDLE: Handle = Handle(11); |
| /// Handle assigned to the Playing Orders Supported characteristic. |
| pub(crate) const PLAYING_ORDERS_SUPPORTED_HANDLE: Handle = Handle(12); |
| /// Handle assigned to the Media Control Point characteristic. |
| pub(crate) const MEDIA_CONTROL_POINT_HANDLE: Handle = Handle(13); |
| /// Handle assigned to the Media Control Point Opcodes Supported characteristic. |
| pub(crate) const MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE: Handle = Handle(14); |
| |
| /// Constructs a characteristic definition with the specified handle, UUID, |
| /// properties, and encryption-required permissions conforming to MCS v1.0.1 |
| /// Section 3. |
| pub(crate) fn build_characteristic( |
| handle: Handle, |
| uuid: Uuid, |
| properties: impl Into<CharacteristicProperties>, |
| ) -> Characteristic { |
| let properties = properties.into(); |
| Characteristic { |
| handle, |
| uuid, |
| properties, |
| permissions: AttributePermissions::with_levels( |
| &properties, |
| &SecurityLevels::encryption_required(), |
| ), |
| descriptors: Vec::new(), |
| } |
| } |
| |
| /// The mandatory characteristics defined in MCS v1.0.1 Section 3, Table 3.1. |
| pub(crate) fn mandatory_characteristics() -> [Characteristic; 7] { |
| [ |
| build_characteristic( |
| MEDIA_PLAYER_NAME_HANDLE, |
| MEDIA_PLAYER_NAME_UUID, |
| CharacteristicProperties::READ_NOTIFY, |
| ), |
| build_characteristic( |
| TRACK_CHANGED_HANDLE, |
| TRACK_CHANGED_UUID, |
| CharacteristicProperty::Notify, |
| ), |
| build_characteristic( |
| TRACK_TITLE_HANDLE, |
| TRACK_TITLE_UUID, |
| CharacteristicProperties::READ_NOTIFY, |
| ), |
| build_characteristic( |
| TRACK_DURATION_HANDLE, |
| TRACK_DURATION_UUID, |
| CharacteristicProperties::READ_NOTIFY, |
| ), |
| build_characteristic( |
| TRACK_POSITION_HANDLE, |
| TRACK_POSITION_UUID, |
| CharacteristicProperties::READ_WRITE_NOTIFY, |
| ), |
| build_characteristic( |
| MEDIA_STATE_HANDLE, |
| MEDIA_STATE_UUID, |
| CharacteristicProperties::READ_NOTIFY, |
| ), |
| build_characteristic( |
| CONTENT_CONTROL_ID_HANDLE, |
| CONTENT_CONTROL_ID_UUID, |
| CharacteristicProperty::Read, |
| ), |
| ] |
| } |
| |
| /// Local state of the characteristics in this server. |
| #[derive(Debug, Clone, PartialEq, Eq)] |
| pub(crate) struct McsLocalState { |
| /// Content Control ID (CCID) identifying this media service instance. |
| pub(crate) ccid: u8, |
| /// Human-readable media player application name. |
| pub(crate) player_name: String, |
| /// Title of the currently selected track (empty if no track loaded). |
| pub(crate) track_title: String, |
| /// Total duration of the current track (MCS v1.0.1 Section 3.6). |
| pub(crate) track_duration: TrackDuration, |
| /// Base playback position of the current track (MCS v1.0.1 Section 3.7). |
| pub(crate) track_position: TrackPosition, |
| /// Timestamp when `track_position` was set or updated. |
| pub(crate) position_updated_at: Option<std::time::Instant>, |
| /// Current player activity state. |
| pub(crate) media_state: MediaState, |
| /// URL pointing to media player icon graphic, if supported. |
| pub(crate) icon_url: Option<String>, |
| /// Playback speed multiplier, if supported. |
| pub(crate) playback_speed: Option<PlaybackSpeed>, |
| /// Seeking speed factor, if supported. |
| pub(crate) seeking_speed: Option<SeekingSpeed>, |
| /// Playing order and supported playing orders, if supported. |
| pub(crate) playing_orders: Option<PlayingOrderState>, |
| /// Supported media control point opcodes, if Media Control Point is |
| /// supported. |
| pub(crate) supported_opcodes: Option<SupportedOpcodes>, |
| } |
| |
| impl McsLocalState { |
| /// Creates a new [`McsLocalState`] with default values for an inactive |
| /// player with no track loaded per MCS v1.0.1 Section 3. |
| pub(crate) fn new(ccid: u8, player_name: impl Into<String>) -> Self { |
| Self { |
| ccid, |
| player_name: player_name.into(), |
| track_title: String::new(), |
| track_duration: TrackDuration::Unknown, |
| track_position: TrackPosition::Unavailable, |
| position_updated_at: None, |
| media_state: MediaState::Inactive, |
| icon_url: None, |
| playback_speed: None, |
| seeking_speed: None, |
| playing_orders: None, |
| supported_opcodes: None, |
| } |
| } |
| |
| /// Calculates the instantaneous track position based on elapsed playback |
| /// time (MCS v1.0.1 Section 3.7). |
| pub(crate) fn current_track_position(&self) -> TrackPosition { |
| // Per MCS Section 3.17, an inactive player has no current track, so it is |
| // unavailable. |
| if self.media_state == MediaState::Inactive { |
| return TrackPosition::Unavailable; |
| } |
| |
| // Playback timing has not started since the track was initialized or loaded. |
| let Some(updated_at) = self.position_updated_at else { |
| return self.track_position; |
| }; |
| |
| // Playback is paused or seeking, so the position remains fixed at the current |
| // offset. |
| if self.media_state != MediaState::Playing { |
| return self.track_position; |
| } |
| |
| let base = match (self.track_position, self.track_duration) { |
| (TrackPosition::FromStart(base), _) => base, |
| (TrackPosition::FromEnd(end), TrackDuration::Duration(total)) => { |
| total.saturating_sub(end) |
| } |
| _ => return self.track_position, |
| }; |
| |
| // TODO(b/540400364): Factor in optional playback speed when supported |
| let mut current = base + updated_at.elapsed(); |
| if let TrackDuration::Duration(total) = self.track_duration { |
| current = current.min(total); |
| } |
| TrackPosition::FromStart(current) |
| } |
| |
| /// Sets the track position and records the update timestamp. |
| pub(crate) fn set_track_position(&mut self, position: TrackPosition) { |
| self.track_position = position; |
| self.position_updated_at = Some(std::time::Instant::now()); |
| } |
| |
| /// Sets the playback speed. |
| pub(crate) fn set_playback_speed(&mut self, speed: PlaybackSpeed) { |
| self.playback_speed = Some(speed); |
| } |
| |
| /// Sets the playing order if playing order is supported. |
| pub(crate) fn set_playing_order(&mut self, order: PlayingOrder) { |
| if let Some(ref mut orders) = self.playing_orders { |
| if orders.supported.contains(order.into()) { |
| orders.current = order; |
| } |
| } |
| } |
| |
| #[cfg(test)] |
| pub(crate) fn set_media_state(&mut self, state: MediaState) { |
| self.media_state = state; |
| } |
| |
| #[cfg(test)] |
| pub(crate) fn set_supported_opcodes(&mut self, opcodes: SupportedOpcodes) { |
| self.supported_opcodes = Some(opcodes); |
| } |
| |
| /// Reads the characteristic bytes for `handle` at `offset`. |
| pub(crate) fn handle_read(&self, handle: Handle, offset: usize) -> Result<Vec<u8>, GattError> { |
| let read_at_offset = |
| |bytes: &[u8]| bytes.get(offset..).map(Vec::from).ok_or(GattError::InvalidOffset); |
| |
| match handle { |
| // Track Changed and Media Control Point are not readable per MCS v1.0.1 Section 3, |
| // Table 3.1. |
| TRACK_CHANGED_HANDLE | MEDIA_CONTROL_POINT_HANDLE => Err(GattError::ReadNotPermitted), |
| MEDIA_PLAYER_NAME_HANDLE => read_at_offset(self.player_name.as_bytes()), |
| TRACK_TITLE_HANDLE => read_at_offset(self.track_title.as_bytes()), |
| TRACK_DURATION_HANDLE => read_at_offset(&self.track_duration.raw_10ms().to_le_bytes()), |
| TRACK_POSITION_HANDLE => { |
| read_at_offset(&self.current_track_position().raw_10ms().to_le_bytes()) |
| } |
| MEDIA_STATE_HANDLE => read_at_offset(&[self.media_state.into()]), |
| CONTENT_CONTROL_ID_HANDLE => read_at_offset(&[self.ccid]), |
| MEDIA_PLAYER_ICON_URL_HANDLE => { |
| let url = self.icon_url.as_ref().ok_or(GattError::InvalidHandle)?; |
| read_at_offset(url.as_bytes()) |
| } |
| PLAYBACK_SPEED_HANDLE => { |
| let speed = self.playback_speed.ok_or(GattError::InvalidHandle)?; |
| read_at_offset(&[speed.into()]) |
| } |
| SEEKING_SPEED_HANDLE => { |
| let speed = self.seeking_speed.ok_or(GattError::InvalidHandle)?; |
| read_at_offset(&[speed.into()]) |
| } |
| PLAYING_ORDER_HANDLE => { |
| let orders = self.playing_orders.as_ref().ok_or(GattError::InvalidHandle)?; |
| read_at_offset(&[orders.current.into()]) |
| } |
| PLAYING_ORDERS_SUPPORTED_HANDLE => { |
| let orders = self.playing_orders.as_ref().ok_or(GattError::InvalidHandle)?; |
| read_at_offset(&orders.supported.bits().to_le_bytes()) |
| } |
| MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE => { |
| let opcodes = self.supported_opcodes.ok_or(GattError::InvalidHandle)?; |
| read_at_offset(&opcodes.bits().to_le_bytes()) |
| } |
| _ => Err(GattError::InvalidHandle), |
| } |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use assert_matches::assert_matches; |
| use std::time::Duration; |
| |
| #[test] |
| fn local_state_set_playing_order() { |
| let mut state = McsLocalState::new(0x01, "Player"); |
| state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::SINGLE_ONCE)); |
| assert_eq!(state.playing_orders.as_ref().unwrap().current, PlayingOrder::InOrderOnce); |
| |
| state.set_playing_order(PlayingOrder::SingleOnce); |
| assert_eq!(state.playing_orders.as_ref().unwrap().current, PlayingOrder::SingleOnce); |
| |
| // Unsupported playing order is not applied |
| state.set_playing_order(PlayingOrder::ShuffleOnce); |
| assert_eq!(state.playing_orders.as_ref().unwrap().current, PlayingOrder::SingleOnce); |
| } |
| |
| #[test] |
| fn local_state_handle_read() { |
| let mut state = McsLocalState::new(0x01, "Player"); |
| state.media_state = MediaState::Paused; |
| state.playback_speed = Some(PlaybackSpeed::NORMAL); |
| state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::all())); |
| |
| // Track Position |
| assert_eq!( |
| state.handle_read(TRACK_POSITION_HANDLE, 0), |
| Ok(TrackPosition::Unavailable.raw_10ms().to_le_bytes().to_vec()) |
| ); |
| |
| // Playback Speed |
| assert_eq!(state.handle_read(PLAYBACK_SPEED_HANDLE, 0), Ok(vec![0x00])); |
| |
| // Playing Order |
| assert_eq!( |
| state.handle_read(PLAYING_ORDER_HANDLE, 0), |
| Ok(vec![PlayingOrder::InOrderOnce.into()]) |
| ); |
| } |
| |
| #[test] |
| fn local_state_track_position_calculation() { |
| let mut state = McsLocalState::new(0x01, "Player"); |
| state.track_duration = TrackDuration::from_raw_10ms(1000); |
| |
| // Inactive player always has unavailable track position |
| state.media_state = MediaState::Inactive; |
| state.track_position = TrackPosition::from_raw_10ms(500); |
| assert_eq!(state.current_track_position(), TrackPosition::Unavailable); |
| |
| // Paused player returns base track position |
| state.media_state = MediaState::Paused; |
| assert_eq!(state.current_track_position(), TrackPosition::from_raw_10ms(500)); |
| |
| // Playing player calculates elapsed time |
| state.media_state = MediaState::Playing; |
| state.position_updated_at = Some(std::time::Instant::now() - Duration::from_secs(2)); |
| match state.current_track_position() { |
| TrackPosition::FromStart(dur) => { |
| assert!(dur >= Duration::from_millis(6900) && dur <= Duration::from_millis(7200)); |
| } |
| other => panic!("Expected FromStart duration, got {other:?}"), |
| } |
| |
| // Clamped to total duration |
| state.position_updated_at = Some(std::time::Instant::now() - Duration::from_secs(20)); |
| assert_eq!(state.current_track_position(), TrackPosition::from_raw_10ms(1000)); |
| } |
| |
| #[test] |
| fn read_mandatory_characteristics_default_values() { |
| let state = McsLocalState::new(0x05, "Test Player"); |
| |
| assert_eq!(state.handle_read(MEDIA_PLAYER_NAME_HANDLE, 0), Ok(b"Test Player".to_vec())); |
| assert_eq!(state.handle_read(TRACK_TITLE_HANDLE, 0), Ok(b"".to_vec())); |
| assert_eq!( |
| state.handle_read(TRACK_DURATION_HANDLE, 0), |
| Ok(TrackDuration::Unknown.raw_10ms().to_le_bytes().to_vec()) |
| ); |
| assert_eq!( |
| state.handle_read(TRACK_POSITION_HANDLE, 0), |
| Ok(TrackPosition::Unavailable.raw_10ms().to_le_bytes().to_vec()) |
| ); |
| assert_eq!(state.handle_read(MEDIA_STATE_HANDLE, 0), Ok(vec![MediaState::Inactive.into()])); |
| assert_eq!(state.handle_read(CONTENT_CONTROL_ID_HANDLE, 0), Ok(vec![0x05])); |
| } |
| |
| #[test] |
| fn read_optional_characteristics_configured_values() { |
| let mut state = McsLocalState::new(0x01, "Player"); |
| state.icon_url = Some("https://example.com/icon.png".to_string()); |
| state.playback_speed = Some(PlaybackSpeed::DOUBLE); |
| state.seeking_speed = Some(SeekingSpeed::new(10)); |
| state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::SHUFFLE_ONCE)); |
| state.supported_opcodes = Some(SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE); |
| |
| assert_eq!( |
| state.handle_read(MEDIA_PLAYER_ICON_URL_HANDLE, 0), |
| Ok(b"https://example.com/icon.png".to_vec()) |
| ); |
| assert_eq!( |
| state.handle_read(PLAYBACK_SPEED_HANDLE, 0), |
| Ok(vec![PlaybackSpeed::DOUBLE.into()]) |
| ); |
| assert_eq!( |
| state.handle_read(SEEKING_SPEED_HANDLE, 0), |
| Ok(vec![SeekingSpeed::new(10).into()]) |
| ); |
| assert_eq!( |
| state.handle_read(PLAYING_ORDER_HANDLE, 0), |
| Ok(vec![PlayingOrder::InOrderOnce.into()]) |
| ); |
| assert_eq!( |
| state.handle_read(PLAYING_ORDERS_SUPPORTED_HANDLE, 0), |
| Ok(SupportedPlayingOrders::SHUFFLE_ONCE.bits().to_le_bytes().to_vec()) |
| ); |
| assert_eq!( |
| state.handle_read(MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE, 0), |
| Ok((SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE).bits().to_le_bytes().to_vec()) |
| ); |
| } |
| |
| #[test] |
| fn read_string_characteristics_with_offset() { |
| let mut state = McsLocalState::new(0x01, "Long Player Name"); |
| state.track_title = "Track Number 1".to_string(); |
| state.icon_url = Some("https://example.com".to_string()); |
| |
| assert_eq!(state.handle_read(MEDIA_PLAYER_NAME_HANDLE, 5), Ok(b"Player Name".to_vec())); |
| assert_eq!(state.handle_read(TRACK_TITLE_HANDLE, 6), Ok(b"Number 1".to_vec())); |
| assert_eq!(state.handle_read(MEDIA_PLAYER_ICON_URL_HANDLE, 8), Ok(b"example.com".to_vec())); |
| |
| assert_matches!( |
| state.handle_read(MEDIA_PLAYER_NAME_HANDLE, 100), |
| Err(GattError::InvalidOffset) |
| ); |
| } |
| |
| #[test] |
| fn read_track_position_during_active_playback() { |
| let mut state = McsLocalState::new(0x01, "Player"); |
| state.track_duration = TrackDuration::from_raw_10ms(10000); |
| state.media_state = MediaState::Playing; |
| state.track_position = TrackPosition::from_raw_10ms(200); |
| state.position_updated_at = Some(std::time::Instant::now() - Duration::from_millis(500)); |
| |
| let res = state.handle_read(TRACK_POSITION_HANDLE, 0).expect("read should succeed"); |
| let raw = i32::from_le_bytes(res.try_into().unwrap()); |
| assert!(raw >= 240 && raw <= 260, "Expected track position ~250 (10ms units), got {raw}"); |
| } |
| |
| #[test] |
| fn read_unconfigured_optional_characteristics_returns_invalid_handle() { |
| let state = McsLocalState::new(0x01, "Player"); |
| |
| let optional_handles = [ |
| MEDIA_PLAYER_ICON_URL_HANDLE, |
| PLAYBACK_SPEED_HANDLE, |
| SEEKING_SPEED_HANDLE, |
| PLAYING_ORDER_HANDLE, |
| PLAYING_ORDERS_SUPPORTED_HANDLE, |
| MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE, |
| ]; |
| |
| for handle in optional_handles { |
| assert_matches!( |
| state.handle_read(handle, 0), |
| Err(GattError::InvalidHandle), |
| "Handle {:?} should return InvalidHandle when not configured", |
| handle |
| ); |
| } |
| } |
| |
| #[test] |
| fn read_non_readable_characteristics_returns_error() { |
| let mut state = McsLocalState::new(0x01, "Player"); |
| state.supported_opcodes = Some(SupportedOpcodes::all()); |
| |
| assert_matches!( |
| state.handle_read(TRACK_CHANGED_HANDLE, 0), |
| Err(GattError::ReadNotPermitted) |
| ); |
| assert_matches!( |
| state.handle_read(MEDIA_CONTROL_POINT_HANDLE, 0), |
| Err(GattError::ReadNotPermitted) |
| ); |
| } |
| |
| #[test] |
| fn mandatory_characteristics_properties_and_permissions() { |
| let mandatory = mandatory_characteristics(); |
| assert_eq!(mandatory.len(), 7); |
| |
| assert_eq!(mandatory[0].handle, MEDIA_PLAYER_NAME_HANDLE); |
| assert_eq!(mandatory[0].uuid, MEDIA_PLAYER_NAME_UUID); |
| assert_eq!(mandatory[0].properties, CharacteristicProperties::READ_NOTIFY); |
| |
| assert_eq!(mandatory[1].handle, TRACK_CHANGED_HANDLE); |
| assert_eq!(mandatory[1].uuid, TRACK_CHANGED_UUID); |
| assert_eq!( |
| mandatory[1].properties, |
| CharacteristicProperties::from(CharacteristicProperty::Notify) |
| ); |
| |
| assert_eq!(mandatory[2].handle, TRACK_TITLE_HANDLE); |
| assert_eq!(mandatory[2].uuid, TRACK_TITLE_UUID); |
| assert_eq!(mandatory[2].properties, CharacteristicProperties::READ_NOTIFY); |
| |
| assert_eq!(mandatory[3].handle, TRACK_DURATION_HANDLE); |
| assert_eq!(mandatory[3].uuid, TRACK_DURATION_UUID); |
| assert_eq!(mandatory[3].properties, CharacteristicProperties::READ_NOTIFY); |
| |
| assert_eq!(mandatory[4].handle, TRACK_POSITION_HANDLE); |
| assert_eq!(mandatory[4].uuid, TRACK_POSITION_UUID); |
| assert_eq!(mandatory[4].properties, CharacteristicProperties::READ_WRITE_NOTIFY); |
| |
| assert_eq!(mandatory[5].handle, MEDIA_STATE_HANDLE); |
| assert_eq!(mandatory[5].uuid, MEDIA_STATE_UUID); |
| assert_eq!(mandatory[5].properties, CharacteristicProperties::READ_NOTIFY); |
| |
| assert_eq!(mandatory[6].handle, CONTENT_CONTROL_ID_HANDLE); |
| assert_eq!(mandatory[6].uuid, CONTENT_CONTROL_ID_UUID); |
| assert_eq!( |
| mandatory[6].properties, |
| CharacteristicProperties::from(CharacteristicProperty::Read) |
| ); |
| |
| for chrc in &mandatory { |
| assert!(chrc.permissions.read.map_or(true, |s| s.encryption)); |
| assert!(chrc.permissions.write.map_or(true, |s| s.encryption)); |
| assert!(chrc.permissions.update.map_or(true, |s| s.encryption)); |
| } |
| } |
| } |