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 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}