Skip to main content

imsg_obex/
client.rs

1use bytes::Bytes;
2use thiserror::Error;
3
4use crate::{
5    headers::Header,
6    packet::{OpCode, Packet, PacketError, PacketExtra},
7};
8
9const OBEX_VERSION: u8 = 0x10;
10const OBEX_FLAGS: u8 = 0x00;
11const OBEX_MAX_PACKET: u16 = 0xFFFF;
12
13// SETPATH 0x02: navigate to child, do not create
14const SETPATH_NAVIGATE: u8 = 0x02;
15// SETPATH 0x03: navigate to parent (backup bit set, no-create)
16const SETPATH_BACKUP: u8 = 0x03;
17
18/// OBEX client errors — connection state, packet codec, server rejection, and missing protocol headers.
19#[derive(Debug, Error)]
20pub enum ObexError {
21    /// No active connection; call `handle_connect_response` first.
22    #[error("not connected")]
23    NotConnected,
24    /// Packet codec failure during request encoding or response decode.
25    #[error("packet error: {0}")]
26    Packet(#[from] PacketError),
27    /// Server refused CONNECT; carries the response opcode byte.
28    #[error("connect rejected with opcode {0:#04x}")]
29    ConnectRejected(u8),
30    /// CONNECT response did not include a `ConnectionId` header; the session cannot be used.
31    #[error("connect response missing ConnectionId header")]
32    MissingConnectionId,
33    /// Message body exceeds the 4 GiB limit the OBEX `Length` header can express.
34    #[error("message body too large")]
35    BodyTooLarge,
36}
37
38enum State {
39    Disconnected,
40    Connected { conn_id: u32, max_packet: u16 },
41}
42
43/// Sans-IO OBEX client state machine. No I/O — callers handle transport.
44pub struct ObexClient {
45    state: State,
46}
47
48impl ObexClient {
49    /// Initial state: disconnected.
50    #[must_use]
51    pub const fn new() -> Self {
52        Self { state: State::Disconnected }
53    }
54
55    /// Targets the given 16-byte service UUID. `app_params` is an optional `AppParams` header,
56    /// e.g. a profile-specific capability bitmask such as PBAP's `PBAPSupportedFeatures` — this
57    /// layer treats it as opaque bytes.
58    ///
59    /// # Errors
60    /// Returns `Packet` if encoding fails (packet too large — not possible in practice).
61    pub fn connect_request(
62        target_uuid: &[u8; 16],
63        app_params: Option<Bytes>,
64    ) -> Result<Bytes, ObexError> {
65        let mut headers = vec![Header::Target(Bytes::copy_from_slice(target_uuid))];
66        if let Some(params) = app_params {
67            headers.push(Header::AppParams(params));
68        }
69        Ok(Packet {
70            opcode: OpCode::Connect,
71            extra: PacketExtra::Connect {
72                version: OBEX_VERSION,
73                flags: OBEX_FLAGS,
74                max_packet: OBEX_MAX_PACKET,
75            },
76            headers,
77        }
78        .encode()?)
79    }
80
81    /// Transitions to Connected on success; returns the assigned connection ID.
82    ///
83    /// # Errors
84    /// Returns `ConnectRejected` if the server returns a non-OK opcode, `MissingConnectionId`
85    /// if the response contains no `ConnectionId` header, or `Packet` on decode failure.
86    pub fn handle_connect_response(&mut self, data: &Bytes) -> Result<u32, ObexError> {
87        let packet = Packet::decode_connect_response(data)?;
88        if !packet.opcode.is_ok() {
89            return Err(ObexError::ConnectRejected(packet.opcode.to_byte()));
90        }
91        let conn_id = packet.header_connection_id().ok_or(ObexError::MissingConnectionId)?;
92        let max_packet = match &packet.extra {
93            PacketExtra::Connect { max_packet, .. } => *max_packet,
94            _ => OBEX_MAX_PACKET,
95        };
96        self.state = State::Connected { conn_id, max_packet };
97        Ok(conn_id)
98    }
99
100    /// SETPATH navigate-to-child by name.
101    ///
102    /// # Errors
103    /// Returns `NotConnected` if called before a successful CONNECT exchange.
104    pub fn setpath_request(&self, name: &str) -> Result<Bytes, ObexError> {
105        let conn_id = self.conn_id()?;
106        Ok(Packet {
107            opcode: OpCode::SetPath,
108            extra: PacketExtra::SetPath { flags: SETPATH_NAVIGATE, constants: 0x00 },
109            headers: vec![Header::ConnectionId(conn_id), Header::Name(name.to_owned())],
110        }
111        .encode()?)
112    }
113
114    /// SETPATH backup bit, empty Name. Use before navigating to a sibling. Does not validate current depth.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`ObexError::NotConnected`] if called before a successful CONNECT exchange.
119    pub fn setpath_backup_request(&self) -> Result<Bytes, ObexError> {
120        let conn_id = self.conn_id()?;
121        Ok(Packet {
122            opcode: OpCode::SetPath,
123            extra: PacketExtra::SetPath { flags: SETPATH_BACKUP, constants: 0x00 },
124            headers: vec![Header::ConnectionId(conn_id), Header::Name(String::new())],
125        }
126        .encode()?)
127    }
128
129    /// GET FINAL with `Type`, optional `Name`, and optional `AppParams`.
130    ///
131    /// # Errors
132    /// Returns `NotConnected` if called before a successful CONNECT exchange.
133    pub fn get_request(
134        &self,
135        type_: &[u8],
136        name: Option<&str>,
137        app_params: Option<Bytes>,
138    ) -> Result<Bytes, ObexError> {
139        let conn_id = self.conn_id()?;
140        let mut headers =
141            vec![Header::ConnectionId(conn_id), Header::Type(Bytes::copy_from_slice(type_))];
142        if let Some(n) = name {
143            headers.push(Header::Name(n.to_owned()));
144        }
145        if let Some(params) = app_params {
146            headers.push(Header::AppParams(params));
147        }
148        Ok(Packet { opcode: OpCode::GetFinal, extra: PacketExtra::None, headers }.encode()?)
149    }
150
151    /// GET FINAL with only `ConnectionId` — continues a multi-packet exchange after `Continue`.
152    ///
153    /// # Errors
154    /// Returns `NotConnected` if called before a successful CONNECT exchange.
155    pub fn get_continue_request(&self) -> Result<Bytes, ObexError> {
156        let conn_id = self.conn_id()?;
157        Ok(Packet {
158            opcode: OpCode::GetFinal,
159            extra: PacketExtra::None,
160            headers: vec![Header::ConnectionId(conn_id)],
161        }
162        .encode()?)
163    }
164
165    /// Prepends `ConnectionId` and `Type` before `extra_headers`; opcode is always `PutFinal`.
166    ///
167    /// # Errors
168    ///
169    /// Returns `NotConnected` if called before a successful `CONNECT` exchange.
170    /// Returns `Packet` if encoding fails (not possible in practice for typical header counts).
171    pub fn put_final_request(
172        &self,
173        type_: &[u8],
174        extra_headers: Vec<Header>,
175    ) -> Result<Bytes, ObexError> {
176        let conn_id = self.conn_id()?;
177        let mut headers =
178            vec![Header::ConnectionId(conn_id), Header::Type(Bytes::copy_from_slice(type_))];
179        headers.extend(extra_headers);
180        Ok(Packet { opcode: OpCode::PutFinal, extra: PacketExtra::None, headers }.encode()?)
181    }
182
183    /// Sends `ConnectionId` in the DISCONNECT payload.
184    ///
185    /// # Errors
186    /// Returns `NotConnected` if called before a successful CONNECT exchange.
187    pub fn disconnect_request(&self) -> Result<Bytes, ObexError> {
188        let conn_id = self.conn_id()?;
189        Ok(Packet {
190            opcode: OpCode::Disconnect,
191            extra: PacketExtra::None,
192            headers: vec![Header::ConnectionId(conn_id)],
193        }
194        .encode()?)
195    }
196
197    /// Stateless decode — does not advance client state.
198    ///
199    /// # Errors
200    /// Returns `Packet` on any decode failure.
201    pub fn parse_response(data: &Bytes) -> Result<Packet, ObexError> {
202        Ok(Packet::decode(data)?)
203    }
204
205    /// Connection ID assigned by the remote in the CONNECT response.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`ObexError::NotConnected`] before a successful CONNECT exchange.
210    pub const fn conn_id(&self) -> Result<u32, ObexError> {
211        match &self.state {
212            State::Connected { conn_id, .. } => Ok(*conn_id),
213            State::Disconnected => Err(ObexError::NotConnected),
214        }
215    }
216
217    /// True after a successful CONNECT exchange; false after disconnect or before first connect.
218    #[must_use]
219    pub const fn is_connected(&self) -> bool {
220        matches!(self.state, State::Connected { .. })
221    }
222
223    /// 0 if not connected; otherwise the server-negotiated max.
224    #[must_use]
225    pub const fn max_packet(&self) -> u16 {
226        match &self.state {
227            State::Connected { max_packet, .. } => *max_packet,
228            State::Disconnected => 0,
229        }
230    }
231}
232
233impl Default for ObexClient {
234    fn default() -> Self {
235        Self::new()
236    }
237}