[bt][hfp] Hold procedure for supplementary services.

Test: Added unit tests
Bug: 64558

Change-Id: Iba93d69dcb21c45288565e99472dea2ff4f2d24e
Reviewed-on: https://fuchsia-review.googlesource.com/c/fuchsia/+/514046
Commit-Queue: Auto-Submit <auto-submit@fuchsia-infra.iam.gserviceaccount.com>
Fuchsia-Auto-Submit: Jeff Belgum <belgum@google.com>
Reviewed-by: Matthew Kehrt <kehrt@google.com>
diff --git a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/BUILD.gn b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/BUILD.gn
index 0397958..814e4b2 100644
--- a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/BUILD.gn
+++ b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/BUILD.gn
@@ -59,6 +59,7 @@
     "src/procedure/dtmf.rs",
     "src/procedure/extended_errors.rs",
     "src/procedure/hang_up.rs",
+    "src/procedure/hold.rs",
     "src/procedure/indicators_activation.rs",
     "src/procedure/nrec.rs",
     "src/procedure/phone_status.rs",
diff --git a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/calls.rs b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/calls.rs
index b783229..f18973d 100644
--- a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/calls.rs
+++ b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/calls.rs
@@ -5,7 +5,7 @@
 use {
     crate::{
         error::CallError,
-        procedure::dtmf::DtmfCode,
+        procedure::{dtmf::DtmfCode, hold::CallHoldAction},
         protocol::indicators::{CallIndicators, CallIndicatorsUpdates},
     },
     async_utils::{
@@ -338,10 +338,18 @@
         self.current_calls.remove(index);
     }
 
+    /// Iterator over all calls in `state`.
+    fn all_by_state(
+        &self,
+        state: CallState,
+    ) -> impl Iterator<Item = (CallIdx, &CallEntry)> + Clone {
+        self.calls().filter(move |c| c.1.state == state)
+    }
+
     /// Operations that act on calls in a particular state should act on the call that was put into
     /// that state first. `oldest_by_state` allows querying for a call that meets that criteria.
     fn oldest_by_state(&self, state: CallState) -> Option<(CallIdx, &CallEntry)> {
-        self.calls().filter(|c| c.1.state == state).min_by_key(|c| c.1.state_updated_at)
+        self.all_by_state(state).min_by_key(|c| c.1.state_updated_at)
     }
 
     /// Return the Call that has been in the IncomingRinging call state the longest if at least one
@@ -391,6 +399,55 @@
         self.request_terminate(idx)
     }
 
+    /// Perform the supplementary call service related to held or waiting calls specified by
+    /// `action`. See HFP v1.8, Section 4.22 for more information.
+    ///
+    /// Waiting calls are prioritized over held calls when an action operates on a specific call.
+    /// Per 4.34.2 AT+CHLD: "Where both a held and a waiting call exist, the above procedures shall
+    /// apply to the waiting call (i.e., not to the held call) in conflicting situation."
+    ///
+    /// Returns an error if the CallIdx associated with a specific action is invalid.
+    pub fn hold(&mut self, action: CallHoldAction) -> Result<(), CallError> {
+        use {CallHoldAction::*, CallState::*};
+        match action {
+            ReleaseAllHeld => {
+                let waiting_calls: Vec<_> =
+                    self.all_by_state(IncomingWaiting).map(|c| c.0).collect();
+                let held_calls: Vec<_> = self.all_by_state(OngoingHeld).map(|c| c.0).collect();
+
+                // Terminated state on incoming waiting calls is prioritized over held calls.
+                let to_release = if !waiting_calls.is_empty() { waiting_calls } else { held_calls };
+
+                for idx in to_release {
+                    let _ = self.request_terminate(idx);
+                }
+            }
+            ReleaseAllActive => {
+                // Active state on incoming waiting calls is prioritized over held calls.
+                let idx = self
+                    .oldest_by_state(IncomingWaiting)
+                    .or_else(|| self.oldest_by_state(OngoingHeld))
+                    .ok_or(CallError::None(vec![IncomingWaiting, OngoingHeld]))?
+                    .0;
+
+                self.request_active(idx, true)?;
+            }
+            ReleaseSpecified(idx) => self.request_terminate(idx)?,
+            HoldActiveAndAccept => {
+                // Active state on incoming waiting calls is prioritized over held calls.
+                let idx = self
+                    .oldest_by_state(IncomingWaiting)
+                    .or_else(|| self.oldest_by_state(OngoingHeld))
+                    .ok_or(CallError::None(vec![IncomingWaiting, OngoingHeld]))?
+                    .0;
+
+                self.request_active(idx, false)?;
+            }
+            HoldAllExceptSpecified(idx) => self.request_active(idx, false)?,
+        }
+        Ok(())
+    }
+
     /// Returns true if the state of any calls requires ringing.
     pub fn should_ring(&self) -> bool {
         self.calls().any(|c| c.1.state == CallState::IncomingRinging)
diff --git a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/task.rs b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/task.rs
index 436275a8..0a22c73 100644
--- a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/task.rs
+++ b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/peer/task.rs
@@ -273,6 +273,12 @@
                 });
                 self.connection.receive_ag_request(marker, response(result)).await;
             }
+            InformationRequest::Hold { command, response } => {
+                let result = self.calls.hold(command).map_err(|e| {
+                    warn!("Unexpected Action {:?} from Hands Free: {}", command, e);
+                });
+                self.connection.receive_ag_request(marker, response(result)).await;
+            }
         };
     }
 
diff --git a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/procedure.rs b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/procedure.rs
index 12df6cb..6a49c27 100644
--- a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/procedure.rs
+++ b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/procedure.rs
@@ -38,6 +38,9 @@
 /// Defines the implementation of the Hang Up Procedure.
 pub mod hang_up;
 
+/// Defines the implementation of the Hold Procedure.
+pub mod hold;
+
 /// Defines the implementation of the SLC Initialization Procedure.
 pub mod slc_initialization;
 
@@ -74,6 +77,7 @@
 use dtmf::{DtmfCode, DtmfProcedure};
 use extended_errors::ExtendedErrorsProcedure;
 use hang_up::HangUpProcedure;
+use hold::{CallHoldAction, HoldProcedure};
 use indicators_activation::IndicatorsActivationProcedure;
 use nrec::NrecProcedure;
 use phone_status::PhoneStatusProcedure;
@@ -85,7 +89,10 @@
 use transfer_hf_indicator::TransferHfIndicatorProcedure;
 use volume_synchronization::VolumeSynchronizationProcedure;
 
-const THREE_WAY_SUPPORT: &[&str] = &["0", "1", "1X", "2", "2X", "3", "4"];
+// TODO (fxbug.dev/74091): Add multiparty support.
+// TODO (fxbug.dev/74093): Add Explicit Call Transfer support.
+const THREE_WAY_SUPPORT: &[&str] = &["0", "1", "1X", "2", "2X"];
+
 // TODO(fxb/71668) Stop using raw bytes.
 const CIND_TEST_RESPONSE_BYTES: &[u8] = b"+CIND: \
 (\"service\",(0,1)),\
@@ -178,6 +185,8 @@
     HangUp,
     /// The Transfer of HF Indicator Values procedure as defined in HFP v1.8 Section 4.36.1.5.
     TransferHfIndicator,
+    /// The Call Hold procedure as defined in HFP v1.8 Section 4.22
+    Hold,
 }
 
 impl ProcedureMarker {
@@ -204,6 +213,7 @@
             Self::Answer => Box::new(AnswerProcedure::new()),
             Self::HangUp => Box::new(HangUpProcedure::new()),
             Self::TransferHfIndicator => Box::new(TransferHfIndicatorProcedure::new()),
+            Self::Hold => Box::new(HoldProcedure::new()),
         }
     }
 
@@ -237,6 +247,7 @@
             at::Command::Chup { .. } => Ok(Self::HangUp),
             at::Command::Biev { .. } => Ok(Self::TransferHfIndicator),
             at::Command::Bia { .. } => Ok(Self::Indicators),
+            at::Command::Chld { .. } => Ok(Self::Hold),
             _ => Err(ProcedureError::NotImplemented),
         }
     }
@@ -268,6 +279,8 @@
     Answer { response: Box<dyn FnOnce(Result<(), ()>) -> AgUpdate> },
 
     HangUp { response: Box<dyn FnOnce(Result<(), ()>) -> AgUpdate> },
+
+    Hold { command: CallHoldAction, response: Box<dyn FnOnce(Result<(), ()>) -> AgUpdate> },
 }
 
 impl From<&InformationRequest> for ProcedureMarker {
@@ -286,6 +299,7 @@
             QueryCurrentCalls { .. } => Self::QueryCurrentCalls,
             Answer { .. } => Self::Answer,
             HangUp { .. } => Self::HangUp,
+            Hold { .. } => Self::Hold,
         }
     }
 }
@@ -317,6 +331,7 @@
             }
             Self::Answer { .. } => "Answer",
             Self::HangUp { .. } => "HangUp",
+            Self::Hold { .. } => "Hold",
         }
         .to_string();
         write!(f, "{}", output)
diff --git a/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/procedure/hold.rs b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/procedure/hold.rs
new file mode 100644
index 0000000..99743f5
--- /dev/null
+++ b/src/connectivity/bluetooth/profiles/bt-hfp-audio-gateway/src/procedure/hold.rs
@@ -0,0 +1,293 @@
+// Copyright 2021 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 super::{
+    AgUpdate, InformationRequest, Procedure, ProcedureError, ProcedureMarker, ProcedureRequest,
+};
+
+use crate::peer::{calls::CallIdx, service_level_connection::SlcState};
+use {
+    at_commands as at,
+    core::convert::{TryFrom, TryInto},
+};
+
+#[derive(Debug, PartialEq, Clone, Copy)]
+enum State {
+    /// Initial state of the Hold Procedure.
+    Start,
+    /// A request has been received from the HF to hold, activate, or terminate calls.
+    HoldRequest,
+    /// The AG has responded to the HF's request to set the state.
+    Terminated,
+}
+
+impl State {
+    /// Transition to the next state in the Hold procedure.
+    fn transition(&mut self) {
+        match *self {
+            Self::Start => *self = Self::HoldRequest,
+            Self::HoldRequest => *self = Self::Terminated,
+            Self::Terminated => *self = Self::Terminated,
+        }
+    }
+}
+
+/// Action to perform a call related supplementary services. During a call, the following procedures
+/// shall be available for the subscriber to control the operation of Call Waiting or Call Hold;
+///
+/// See 3GPP TS 22.030 v16.0.0 / ETSI TS 122.030 v16.0.0
+#[derive(PartialEq, Clone, Copy, Debug)]
+pub enum CallHoldAction {
+    /// Releases all held calls or sets User Determined User Busy (UDUB) for a waiting call.
+    ReleaseAllHeld,
+    /// Releases all active calls (if any exist) and accepts the other (held or waiting) call.
+    ReleaseAllActive,
+    /// Releases call with specified CallIdx.
+    ReleaseSpecified(CallIdx),
+    /// Places all active calls (if any exist) on hold and accepts the other (held or waiting) call.
+    HoldActiveAndAccept,
+    /// Request private consultation mode with specified call (CallIdx). (Place all calls on hold
+    /// EXCEPT the call indicated by CallIdx.)
+    HoldAllExceptSpecified(CallIdx),
+}
+
+impl TryFrom<&str> for CallHoldAction {
+    type Error = ();
+    fn try_from(x: &str) -> Result<Self, Self::Error> {
+        let x = x.trim();
+        match x {
+            "0" => Ok(Self::ReleaseAllHeld),
+            "1" => Ok(Self::ReleaseAllActive),
+            "2" => Ok(Self::HoldActiveAndAccept),
+            cmd if cmd.starts_with("1") => {
+                let idx = cmd.strip_prefix("1").unwrap_or("").parse().map_err(|_| ())?;
+                Ok(Self::ReleaseSpecified(idx))
+            }
+            cmd if cmd.starts_with("2") => {
+                let idx = cmd.strip_prefix("2").unwrap_or("").parse().map_err(|_| ())?;
+                Ok(Self::HoldAllExceptSpecified(idx))
+            }
+            _ => Err(()),
+        }
+    }
+}
+
+/// The HF performs supplemental services on calls via this procedure. See HFP v1.8, Section 4.22.
+///
+/// This procedure is implemented from the perspective of the AG. Namely, outgoing `requests`
+/// typically request information about the current state of the AG, to be sent to the remote
+/// peer acting as the HF.
+#[derive(Debug)]
+pub struct HoldProcedure {
+    /// The current state of the procedure
+    state: State,
+}
+
+impl HoldProcedure {
+    /// Create a new Hold procedure in the Start state.
+    pub fn new() -> Self {
+        Self { state: State::Start }
+    }
+}
+
+impl Procedure for HoldProcedure {
+    fn marker(&self) -> ProcedureMarker {
+        ProcedureMarker::Hold
+    }
+
+    fn hf_update(&mut self, update: at::Command, _state: &mut SlcState) -> ProcedureRequest {
+        match (self.state, &update) {
+            (State::Start, at::Command::Chld { command }) => {
+                self.state.transition();
+                match command.as_str().try_into() {
+                    Ok(command) => {
+                        let response = Box::new(|res: Result<(), ()>| {
+                            res.map(|()| AgUpdate::Ok).unwrap_or(AgUpdate::Error)
+                        });
+                        InformationRequest::Hold { command, response }.into()
+                    }
+                    Err(()) => ProcedureRequest::Error(ProcedureError::UnexpectedHf(update)),
+                }
+            }
+            _ => ProcedureRequest::Error(ProcedureError::UnexpectedHf(update)),
+        }
+    }
+
+    fn ag_update(&mut self, update: AgUpdate, _state: &mut SlcState) -> ProcedureRequest {
+        match (self.state, update) {
+            (State::HoldRequest, update @ AgUpdate::Ok)
+            | (State::HoldRequest, update @ AgUpdate::Error) => {
+                self.state.transition();
+                update.into()
+            }
+            (_, update) => ProcedureRequest::Error(ProcedureError::UnexpectedAg(update)),
+        }
+    }
+
+    fn is_terminated(&self) -> bool {
+        self.state == State::Terminated
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use matches::assert_matches;
+
+    #[test]
+    fn state_transitions() {
+        let mut state = State::Start;
+        state.transition();
+        assert_eq!(state, State::HoldRequest);
+        state.transition();
+        assert_eq!(state, State::Terminated);
+        state.transition();
+        assert_eq!(state, State::Terminated);
+    }
+
+    #[test]
+    fn call_hold_action_valid_conversions() {
+        assert_eq!(CallHoldAction::try_from("0"), Ok(CallHoldAction::ReleaseAllHeld));
+        assert_eq!(CallHoldAction::try_from("1"), Ok(CallHoldAction::ReleaseAllActive));
+        assert_eq!(CallHoldAction::try_from("2"), Ok(CallHoldAction::HoldActiveAndAccept));
+        assert_eq!(CallHoldAction::try_from("11"), Ok(CallHoldAction::ReleaseSpecified(1)));
+        assert_eq!(CallHoldAction::try_from("21"), Ok(CallHoldAction::HoldAllExceptSpecified(1)));
+        // Two digit call index
+        assert_eq!(CallHoldAction::try_from("231"), Ok(CallHoldAction::HoldAllExceptSpecified(31)));
+    }
+
+    #[test]
+    fn call_hold_action_invalid_conversions() {
+        // Empty string
+        assert!(CallHoldAction::try_from("").is_err());
+        // Not a number
+        assert!(CallHoldAction::try_from("a").is_err());
+        // Number out of range
+        assert!(CallHoldAction::try_from("3").is_err());
+        // Unexpected second number
+        assert!(CallHoldAction::try_from("01").is_err());
+        // Invalid second character on "1"
+        assert!(CallHoldAction::try_from("1a").is_err());
+        // Invalid second character on "2"
+        assert!(CallHoldAction::try_from("2-").is_err());
+    }
+
+    #[test]
+    fn correct_marker() {
+        let marker = HoldProcedure::new().marker();
+        assert_eq!(marker, ProcedureMarker::Hold);
+    }
+
+    #[test]
+    fn is_terminated_in_terminated_state() {
+        let mut proc = HoldProcedure::new();
+        assert!(!proc.is_terminated());
+        proc.state = State::HoldRequest;
+        assert!(!proc.is_terminated());
+        proc.state = State::Terminated;
+        assert!(proc.is_terminated());
+    }
+
+    #[test]
+    fn unexpected_hf_update_returns_error() {
+        let mut proc = HoldProcedure::new();
+        let mut state = SlcState::default();
+
+        // SLCI AT command.
+        let random_hf = at::Command::CindRead {};
+        assert_matches!(
+            proc.hf_update(random_hf, &mut state),
+            ProcedureRequest::Error(ProcedureError::UnexpectedHf(_))
+        );
+    }
+
+    #[test]
+    fn unexpected_ag_update_returns_error() {
+        let mut proc = HoldProcedure::new();
+        let mut state = SlcState::default();
+        // SLCI AT command.
+        let random_ag = AgUpdate::ThreeWaySupport;
+        assert_matches!(
+            proc.ag_update(random_ag, &mut state),
+            ProcedureRequest::Error(ProcedureError::UnexpectedAg(_))
+        );
+    }
+
+    #[test]
+    fn hold_produces_ok_result() {
+        let mut proc = HoldProcedure::new();
+        let mut state = SlcState::default();
+
+        let req = proc.hf_update(at::Command::Chld { command: String::from("1") }, &mut state);
+        let update = match req {
+            ProcedureRequest::Info(InformationRequest::Hold { command, response }) => {
+                assert_eq!(command, CallHoldAction::ReleaseAllActive);
+                response(Ok(()))
+            }
+            x => panic!("Unexpected message: {:?}", x),
+        };
+        let req = proc.ag_update(update, &mut state);
+        assert_matches!(
+            req,
+            ProcedureRequest::SendMessages(resp) if resp == vec![at::Response::Ok]
+        );
+
+        // Check that the procedure is terminated and any new messages produce an error.
+        assert!(proc.is_terminated());
+        assert_matches!(
+            proc.hf_update(at::Command::Chld { command: String::from("1") }, &mut state),
+            ProcedureRequest::Error(ProcedureError::UnexpectedHf(_))
+        );
+        assert_matches!(
+            proc.ag_update(AgUpdate::Ok, &mut state),
+            ProcedureRequest::Error(ProcedureError::UnexpectedAg(_))
+        );
+    }
+
+    #[test]
+    fn hold_produces_err_result() {
+        let mut proc = HoldProcedure::new();
+        let mut state = SlcState::default();
+
+        let req = proc.hf_update(at::Command::Chld { command: String::from("22") }, &mut state);
+        let update = match req {
+            ProcedureRequest::Info(InformationRequest::Hold { command, response }) => {
+                assert_eq!(command, CallHoldAction::HoldAllExceptSpecified(2));
+                response(Err(()))
+            }
+            x => panic!("Unexpected message: {:?}", x),
+        };
+        let req = proc.ag_update(update, &mut state);
+        assert_matches!(
+            req,
+            ProcedureRequest::SendMessages(resp) if resp == vec![at::Response::Error]
+        );
+
+        // Check that the procedure is terminated and any new messages produce an error.
+        assert!(proc.is_terminated());
+        assert_matches!(
+            proc.hf_update(at::Command::Chld { command: String::from("1") }, &mut state),
+            ProcedureRequest::Error(ProcedureError::UnexpectedHf(_))
+        );
+        assert_matches!(
+            proc.ag_update(AgUpdate::Ok, &mut state),
+            ProcedureRequest::Error(ProcedureError::UnexpectedAg(_))
+        );
+    }
+
+    #[test]
+    fn invalid_hold_command_string_produces_err() {
+        let mut proc = HoldProcedure::new();
+        let mut state = SlcState::default();
+
+        let req =
+            proc.hf_update(at::Command::Chld { command: String::from("invalid") }, &mut state);
+        assert_matches!(
+            req,
+            ProcedureRequest::Error(ProcedureError::UnexpectedHf(at::Command::Chld { command }))
+                if command == "invalid"
+        );
+        assert!(!proc.is_terminated());
+    }
+}
diff --git a/src/connectivity/lib/at-commands/definitions/hfp.at b/src/connectivity/lib/at-commands/definitions/hfp.at
index 508fad6..4084cd7 100644
--- a/src/connectivity/lib/at-commands/definitions/hfp.at
+++ b/src/connectivity/lib/at-commands/definitions/hfp.at
@@ -179,3 +179,7 @@
 # Call Waiting Notification
 # HFP 1.8 4.34.2
 response { CCWA: number: String, ty: Integer }
+
+# Additional Call Services
+# HFP 1.8 4.34.2
+command { AT+CHLD=command: String }