1use qrcode::{EcLevel, QrCode};
2use url::Url;
3
4#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
5pub struct QrCodeMatrix {
6 pub size: u32,
8 pub modules: String,
11}
12
13#[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}