mod inner;
mod io;
mod tls;
mod types;
pub use types::{
ConnectOptions, ConnectResult, MqttAuthHandler, MqttMessage, MqttMessageListener,
SubscribeResult, TlsOptions, WillOptions,
};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::time::Duration;
use bytes::{Bytes, BytesMut};
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex as AsyncMutex;
use crate::error::{MqttError, MqttResult};
use crate::protocol::{
QoS,
ack::SimpleAck,
packet::{AuthPacket, DisconnectPacket, Packet},
properties::Properties,
publish::PublishPacket,
subscribe::{SubAckReasonCode, SubscribeFilter, SubscribePacket, UnsubscribePacket},
topic::{is_valid_filter, is_valid_topic_name},
};
use crate::support::{LockExt, guard_callback};
use inner::{Delivery, Inner, ListenerSlot, unexpected};
use io::{
build_connect_packet, exchange, exchange_with_retry, keepalive_loop, read_loop,
read_one_packet, send_packet,
};
const MAX_PUBLISH_RETRIES: u32 = 3;
const RECONNECT_POLL_INTERVAL: Duration = Duration::from_millis(250);
const MAX_AUTH_ROUNDS: u32 = 16;
const REASON_FAILURE_THRESHOLD: u8 = 0x80;
struct ClientShared {
options: ConnectOptions,
inner: AsyncMutex<Option<Arc<Inner>>>,
delivery: ListenerSlot,
subscriptions: Mutex<HashMap<String, QoS>>,
auth_handler: Mutex<Option<Arc<dyn MqttAuthHandler>>>,
user_disconnected: AtomicBool,
supervisor_running: AtomicBool,
}
impl ClientShared {
async fn connected_inner(&self) -> MqttResult<Arc<Inner>> {
let guard = self.inner.lock().await;
match guard.as_ref() {
Some(inner) if inner.is_connected() => Ok(inner.clone()),
_ => Err(MqttError::NotConnected),
}
}
async fn is_live(&self) -> bool {
self.inner
.lock()
.await
.as_ref()
.is_some_and(|i| i.is_connected())
}
}
#[derive(uniffi::Object)]
pub struct MqttClient {
shared: Arc<ClientShared>,
}
impl std::fmt::Debug for MqttClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MqttClient").finish_non_exhaustive()
}
}
#[uniffi::export(async_runtime = "tokio")]
impl MqttClient {
#[uniffi::constructor]
pub fn new(options: ConnectOptions) -> Self {
MqttClient {
shared: Arc::new(ClientShared {
options,
inner: AsyncMutex::new(None),
delivery: Arc::new(Delivery::default()),
subscriptions: Mutex::new(HashMap::new()),
auth_handler: Mutex::new(None),
user_disconnected: AtomicBool::new(false),
supervisor_running: AtomicBool::new(false),
}),
}
}
pub fn set_message_listener(&self, listener: Arc<dyn MqttMessageListener>) {
*self.shared.delivery.listener.lock_safe() = Some(listener);
}
pub fn set_auth_handler(&self, handler: Arc<dyn MqttAuthHandler>) {
*self.shared.auth_handler.lock_safe() = Some(handler);
}
pub fn enable_message_queue(&self, capacity: u32) {
self.shared.delivery.queue.enable(capacity as usize);
}
pub async fn next_message(&self, timeout_ms: u32) -> Option<MqttMessage> {
self.shared
.delivery
.queue
.next(Duration::from_millis(u64::from(timeout_ms)))
.await
}
pub fn last_disconnect_reason(&self) -> Option<String> {
self.shared.delivery.last_disconnect.lock_safe().clone()
}
pub async fn connect(&self) -> MqttResult<ConnectResult> {
self.shared.options.validate()?;
self.shared
.user_disconnected
.store(false, Ordering::Relaxed);
let result = connect_once(&self.shared).await?;
if self.shared.options.auto_reconnect {
spawn_reconnect_supervisor(&self.shared);
}
Ok(result)
}
pub fn is_connected(&self) -> bool {
match self.shared.inner.try_lock() {
Ok(guard) => guard.as_ref().is_some_and(|i| i.is_connected()),
Err(_) => false,
}
}
pub async fn publish(
&self,
topic: String,
payload: Vec<u8>,
qos: QoS,
retain: bool,
) -> MqttResult<()> {
if !is_valid_topic_name(&topic) {
return Err(MqttError::Protocol(format!(
"invalid topic name {topic:?}: must be non-empty and contain no wildcards"
)));
}
let inner = self.shared.connected_inner().await?;
let payload = Bytes::from(payload);
let publish = |packet_id: Option<u16>, dup: bool| {
Packet::Publish(PublishPacket {
dup,
qos,
retain,
topic: topic.clone(),
packet_id,
payload: payload.clone(),
properties: Properties::new(),
})
};
match qos {
QoS::AtMostOnce => send_packet(&inner, &publish(None, false)).await,
QoS::AtLeastOnce => {
let id = inner.alloc_packet_id();
let ack = exchange_with_retry(&inner, id, MAX_PUBLISH_RETRIES, |dup| {
publish(Some(id), dup)
})
.await?;
match ack {
Packet::PubAck(ack) => check_reason("PUBLISH", ack.reason_code),
other => Err(unexpected(&other)),
}
}
QoS::ExactlyOnce => {
let id = inner.alloc_packet_id();
let rec = exchange_with_retry(&inner, id, MAX_PUBLISH_RETRIES, |dup| {
publish(Some(id), dup)
})
.await?;
match rec {
Packet::PubRec(ack) => check_reason("PUBLISH", ack.reason_code)?,
other => return Err(unexpected(&other)),
}
let comp = exchange_with_retry(&inner, id, MAX_PUBLISH_RETRIES, |_| {
Packet::PubRel(SimpleAck::success(id))
})
.await?;
match comp {
Packet::PubComp(ack) => check_reason("PUBREL", ack.reason_code),
other => Err(unexpected(&other)),
}
}
}
}
pub async fn subscribe(&self, topic_filter: String, qos: QoS) -> MqttResult<SubscribeResult> {
if !is_valid_filter(&topic_filter) {
return Err(MqttError::Protocol(format!(
"invalid topic filter {topic_filter:?}"
)));
}
let inner = self.shared.connected_inner().await?;
let response = exchange(&inner, |id| subscribe_packet(id, &topic_filter, qos)).await?;
match response {
Packet::SubAck(ack) => {
let code = ack
.reason_codes
.first()
.copied()
.unwrap_or(SubAckReasonCode::FAILURE);
if code.0 < REASON_FAILURE_THRESHOLD {
self.shared
.subscriptions
.lock_safe()
.insert(topic_filter, qos);
}
Ok(SubscribeResult {
reason_code: code.0,
})
}
other => Err(unexpected(&other)),
}
}
pub async fn unsubscribe(&self, topic_filter: String) -> MqttResult<()> {
if !is_valid_filter(&topic_filter) {
return Err(MqttError::Protocol(format!(
"invalid topic filter {topic_filter:?}"
)));
}
let inner = self.shared.connected_inner().await?;
let response = exchange(&inner, |id| {
Packet::Unsubscribe(UnsubscribePacket {
packet_id: id,
topic_filters: vec![topic_filter.clone()],
properties: Properties::new(),
})
})
.await?;
match response {
Packet::UnsubAck(_) => {
self.shared.subscriptions.lock_safe().remove(&topic_filter);
Ok(())
}
other => Err(unexpected(&other)),
}
}
pub async fn disconnect(&self) -> MqttResult<()> {
self.shared.user_disconnected.store(true, Ordering::Relaxed);
let Some(inner) = self.shared.inner.lock().await.take() else {
return Ok(());
};
if inner.is_connected() {
let disconnect = Packet::Disconnect(DisconnectPacket::normal());
let _ = send_packet(&inner, &disconnect).await;
}
inner.shutdown().await;
Ok(())
}
}
impl Drop for MqttClient {
fn drop(&mut self) {
let Ok(mut guard) = self.shared.inner.try_lock() else {
return;
};
if let Some(inner) = guard.take() {
inner.shutdown_now();
}
}
}
fn check_reason(what: &str, reason_code: u8) -> MqttResult<()> {
if reason_code >= REASON_FAILURE_THRESHOLD {
Err(MqttError::Session(format!(
"{what} rejected by broker: reason code 0x{reason_code:02X}"
)))
} else {
Ok(())
}
}
fn subscribe_packet(packet_id: u16, topic_filter: &str, qos: QoS) -> Packet {
Packet::Subscribe(SubscribePacket {
packet_id,
filters: vec![SubscribeFilter::new(topic_filter.to_string(), qos)],
properties: Properties::new(),
})
}
async fn connect_once(shared: &Arc<ClientShared>) -> MqttResult<ConnectResult> {
let mut guard = shared.inner.lock().await;
if guard.as_ref().is_some_and(|i| i.is_connected()) {
return Err(MqttError::AlreadyConnected);
}
let options = &shared.options;
let connect_timeout = options.connect_timeout();
let max_packet_size = options.max_packet_size();
let stream: Box<dyn tls::Transport> =
tokio::time::timeout(connect_timeout, tls::connect_transport(options))
.await
.map_err(|_| MqttError::Timeout)??;
let (mut reader, mut writer) = tokio::io::split(stream);
let encoded = Packet::Connect(build_connect_packet(options))
.encode(options.version)
.map_err(|e| MqttError::Protocol(e.to_string()))?;
writer.write_all(&encoded).await?;
let mut buf = BytesMut::with_capacity(1024);
let mut auth_rounds = 0;
let connack = loop {
let packet = read_one_packet(
&mut reader,
&mut buf,
options.version,
connect_timeout,
max_packet_size,
)
.await?;
match packet {
Packet::ConnAck(ack) => break ack,
Packet::Auth(auth) if auth.reason_code == AuthPacket::CONTINUE => {
auth_rounds += 1;
if auth_rounds > MAX_AUTH_ROUNDS {
return Err(MqttError::ConnectionRefused(
"broker kept sending authentication challenges".into(),
));
}
let response = answer_challenge(shared, &auth).await?;
let method = auth
.properties
.auth_method()
.unwrap_or_default()
.to_string();
let reply = Packet::Auth(AuthPacket::continue_with(&method, Bytes::from(response)))
.encode(options.version)
.map_err(|e| MqttError::Protocol(e.to_string()))?;
writer.write_all(&reply).await?;
}
other => {
return Err(MqttError::MalformedPacket(format!(
"expected CONNACK, got {other:?}"
)));
}
}
};
if !connack.reason_code.is_success() {
return Err(MqttError::ConnectionRefused(format!(
"reason code 0x{:02X}",
connack.reason_code.0
)));
}
if let Some(old) = guard.take() {
old.shutdown().await;
}
let inner = Arc::new(Inner::new(
options.version,
writer,
shared.delivery.clone(),
options.operation_timeout(),
));
let read_handle = tokio::spawn(read_loop(inner.clone(), reader, buf, max_packet_size));
*inner.read_task.lock().await = Some(read_handle);
if options.keep_alive_secs > 0 {
let ka_handle = tokio::spawn(keepalive_loop(inner.clone(), options.keep_alive_secs));
*inner.keepalive_task.lock().await = Some(ka_handle);
}
*guard = Some(inner);
Ok(ConnectResult {
session_present: connack.session_present,
reason_code: connack.reason_code.0,
})
}
async fn answer_challenge(shared: &Arc<ClientShared>, auth: &AuthPacket) -> MqttResult<Vec<u8>> {
let handler = shared.auth_handler.lock_safe().clone().ok_or_else(|| {
MqttError::ConnectionRefused(
"broker requested enhanced authentication but no MqttAuthHandler is registered".into(),
)
})?;
let method = auth
.properties
.auth_method()
.unwrap_or_default()
.to_string();
let challenge = auth
.properties
.auth_data()
.map(|d| d.to_vec())
.unwrap_or_default();
tokio::task::spawn_blocking(move || {
guard_callback("auth_handler", || handler.respond(method, challenge)).flatten()
})
.await
.ok()
.flatten()
.ok_or_else(|| MqttError::ConnectionRefused("authentication aborted by the client".into()))
}
async fn replay_subscriptions(shared: &Arc<ClientShared>) {
let subs: Vec<(String, QoS)> = shared
.subscriptions
.lock_safe()
.iter()
.map(|(topic, qos)| (topic.clone(), *qos))
.collect();
let Ok(inner) = shared.connected_inner().await else {
return;
};
for (topic_filter, qos) in subs {
match exchange(&inner, |id| subscribe_packet(id, &topic_filter, qos)).await {
Ok(_) => {}
Err(MqttError::NotConnected | MqttError::Io(_) | MqttError::Session(_)) => {
tracing::warn!(topic = %topic_filter, "re-subscribe failed while reconnecting");
return;
}
Err(e) => {
tracing::warn!(topic = %topic_filter, error = %e, "re-subscribe was not acked while reconnecting");
}
}
}
}
fn spawn_reconnect_supervisor(shared: &Arc<ClientShared>) {
if shared.supervisor_running.swap(true, Ordering::AcqRel) {
return; }
let weak = Arc::downgrade(shared);
tokio::spawn(async move {
loop {
reconnect_supervisor(&weak).await;
let Some(strong) = weak.upgrade() else {
return;
};
strong.supervisor_running.store(false, Ordering::Release);
if strong.user_disconnected.load(Ordering::Relaxed)
|| strong.supervisor_running.swap(true, Ordering::AcqRel)
{
return;
}
}
});
}
async fn reconnect_supervisor(shared: &Weak<ClientShared>) {
loop {
loop {
let Some(strong) = shared.upgrade() else {
return;
};
if strong.user_disconnected.load(Ordering::Relaxed) {
return;
}
let live = strong.is_live().await;
drop(strong);
if !live {
break;
}
tokio::time::sleep(RECONNECT_POLL_INTERVAL).await;
}
let Some(strong) = shared.upgrade() else {
return;
};
let mut backoff = strong.options.initial_reconnect_backoff();
let max_backoff = strong.options.max_reconnect_backoff();
drop(strong);
loop {
tokio::time::sleep(backoff).await;
let Some(strong) = shared.upgrade() else {
return;
};
if strong.user_disconnected.load(Ordering::Relaxed) {
return;
}
match connect_once(&strong).await {
Ok(_) => {
replay_subscriptions(&strong).await;
break;
}
Err(MqttError::AlreadyConnected) => break,
Err(e) => {
tracing::debug!(error = %e, ?backoff, "reconnect attempt failed");
backoff = (backoff * 2).min(max_backoff);
}
}
}
}
}