Skip to main content

iris_chat_core/
qr.rs

1use qrcode::{EcLevel, QrCode};
2use url::Url;
3
4#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
5pub struct QrCodeMatrix {
6    /// Square module count (one side of the matrix).
7    pub size: u32,
8    /// "1" = dark module, "0" = light module. Length == size * size.
9    /// We use a string instead of Vec<bool> to keep the FFI surface cheap.
10    pub modules: String,
11}
12
13/// Render `text` to a QR-code module matrix. Returns a square matrix encoded
14/// as `1`/`0` characters in row-major order. Returns `None` for inputs that
15/// don't fit at the medium error-correction level.
16#[uniffi::export]
17pub fn encode_text_qr(text: String) -> Option<QrCodeMatrix> {
18    let trimmed = text.trim();
19    if trimmed.is_empty() {
20        return None;
21    }
22    let code = QrCode::with_error_correction_level(trimmed.as_bytes(), EcLevel::M).ok()?;
23    let width = code.width();
24    let cells = code.to_colors();
25    let mut modules = String::with_capacity(width * width);
26    for color in cells {
27        modules.push(if color == qrcode::Color::Dark {
28            '1'
29        } else {
30            '0'
31        });
32    }
33    Some(QrCodeMatrix {
34        size: width as u32,
35        modules,
36    })
37}
38
39#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
40pub struct DeviceApprovalQrPayload {
41    pub owner_input: String,
42    pub device_input: String,
43}
44
45const DEVICE_APPROVAL_QR_SCHEME: &str = "ndrdemo";
46const DEVICE_APPROVAL_QR_HOST: &str = "device-link";
47
48#[uniffi::export]
49pub fn encode_device_approval_qr(owner_input: String, device_input: String) -> String {
50    let owner = owner_input.trim();
51    let device = device_input.trim();
52    if owner.is_empty() || device.is_empty() {
53        return String::new();
54    }
55
56    let mut url = Url::parse("ndrdemo://device-link").expect("valid device approval base url");
57    url.query_pairs_mut()
58        .append_pair("owner", owner)
59        .append_pair("device", device);
60    url.to_string()
61}
62
63#[uniffi::export]
64pub fn decode_device_approval_qr(raw: String) -> Option<DeviceApprovalQrPayload> {
65    let trimmed = raw.trim();
66    if trimmed.is_empty() {
67        return None;
68    }
69
70    let parsed = Url::parse(trimmed).ok()?;
71    if !parsed
72        .scheme()
73        .eq_ignore_ascii_case(DEVICE_APPROVAL_QR_SCHEME)
74    {
75        return None;
76    }
77    if !parsed
78        .host_str()
79        .is_some_and(|host| host.eq_ignore_ascii_case(DEVICE_APPROVAL_QR_HOST))
80    {
81        return None;
82    }
83
84    let mut owner_input = None;
85    let mut device_input = None;
86    for (key, value) in parsed.query_pairs() {
87        match key.as_ref() {
88            "owner" => {
89                let trimmed_value = value.trim();
90                if !trimmed_value.is_empty() {
91                    owner_input = Some(trimmed_value.to_string());
92                }
93            }
94            "device" => {
95                let trimmed_value = value.trim();
96                if !trimmed_value.is_empty() {
97                    device_input = Some(trimmed_value.to_string());
98                }
99            }
100            _ => {}
101        }
102    }
103
104    Some(DeviceApprovalQrPayload {
105        owner_input: owner_input?,
106        device_input: device_input?,
107    })
108}
109
110#[cfg(test)]
111mod tests {
112    use super::{decode_device_approval_qr, encode_device_approval_qr, DeviceApprovalQrPayload};
113
114    #[test]
115    fn device_approval_qr_round_trip() {
116        let encoded = encode_device_approval_qr("npub-owner".into(), "npub-device".into());
117        let decoded = decode_device_approval_qr(encoded).expect("decode");
118        assert_eq!(
119            decoded,
120            DeviceApprovalQrPayload {
121                owner_input: "npub-owner".into(),
122                device_input: "npub-device".into(),
123            }
124        );
125    }
126
127    #[test]
128    fn device_approval_qr_rejects_wrong_inputs() {
129        assert!(decode_device_approval_qr("".into()).is_none());
130        assert!(decode_device_approval_qr("npub1plainvalue".into()).is_none());
131        assert!(decode_device_approval_qr("https://example.com".into()).is_none());
132        assert!(
133            decode_device_approval_qr("ndrdemo://device-link?owner=npub1owneronly".into())
134                .is_none()
135        );
136        assert!(
137            decode_device_approval_qr("ndrdemo://device-link?device=npub1deviceonly".into())
138                .is_none()
139        );
140    }
141}