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 Ok(mut url) = Url::parse("ndrdemo://device-link") else {
57        return String::new();
58    };
59    url.query_pairs_mut()
60        .append_pair("owner", owner)
61        .append_pair("device", device);
62    url.to_string()
63}
64
65#[uniffi::export]
66pub fn decode_device_approval_qr(raw: String) -> Option<DeviceApprovalQrPayload> {
67    let trimmed = raw.trim();
68    if trimmed.is_empty() {
69        return None;
70    }
71
72    let parsed = Url::parse(trimmed).ok()?;
73    if !parsed
74        .scheme()
75        .eq_ignore_ascii_case(DEVICE_APPROVAL_QR_SCHEME)
76    {
77        return None;
78    }
79    if !parsed
80        .host_str()
81        .is_some_and(|host| host.eq_ignore_ascii_case(DEVICE_APPROVAL_QR_HOST))
82    {
83        return None;
84    }
85
86    let mut owner_input = None;
87    let mut device_input = None;
88    for (key, value) in parsed.query_pairs() {
89        match key.as_ref() {
90            "owner" => {
91                let trimmed_value = value.trim();
92                if !trimmed_value.is_empty() {
93                    owner_input = Some(trimmed_value.to_string());
94                }
95            }
96            "device" => {
97                let trimmed_value = value.trim();
98                if !trimmed_value.is_empty() {
99                    device_input = Some(trimmed_value.to_string());
100                }
101            }
102            _ => {}
103        }
104    }
105
106    Some(DeviceApprovalQrPayload {
107        owner_input: owner_input?,
108        device_input: device_input?,
109    })
110}
111
112#[cfg(test)]
113mod tests {
114    use super::{decode_device_approval_qr, encode_device_approval_qr, DeviceApprovalQrPayload};
115
116    #[test]
117    fn device_approval_qr_round_trip() {
118        let encoded = encode_device_approval_qr("npub-owner".into(), "npub-device".into());
119        let decoded = decode_device_approval_qr(encoded).expect("decode");
120        assert_eq!(
121            decoded,
122            DeviceApprovalQrPayload {
123                owner_input: "npub-owner".into(),
124                device_input: "npub-device".into(),
125            }
126        );
127    }
128
129    #[test]
130    fn device_approval_qr_rejects_wrong_inputs() {
131        assert!(decode_device_approval_qr("".into()).is_none());
132        assert!(decode_device_approval_qr("npub1plainvalue".into()).is_none());
133        assert!(decode_device_approval_qr("https://example.com".into()).is_none());
134        assert!(
135            decode_device_approval_qr("ndrdemo://device-link?owner=npub1owneronly".into())
136                .is_none()
137        );
138        assert!(
139            decode_device_approval_qr("ndrdemo://device-link?device=npub1deviceonly".into())
140                .is_none()
141        );
142    }
143}