#![cfg(feature = "appclip")]
use anyd::codes::appclip;
const APPLE_VECTORS: &[(&str, &str)] = &[
("https://example.com", "0000000000000000000000008e33db36"),
("https://a.co", "00000000000000000000000000004e2f"),
("https://www.apple.com", "000000000000000000000478a3cee226"),
(
"https://example.com/path",
"000000000000000000238cf6cdb5d582",
),
(
"https://appclip.example.com",
"000000000000000000000000ae33db36",
),
(
"https://app.nextdns.io?p=",
"0000000000000000d02424a9f120a6e4",
),
(
"https://app.nextdns.io?p=a",
"000000000000001a0484953e2414dc85",
),
(
"https://app.nextdns.io?p=a&p1=b",
"000000000001a0484953e2414dc85031",
),
(
"https://app.nextdns.io?p=a&",
"000000000902424a9f120a6e3ccda046",
),
(
"https://app.nextdns.io?p=abcdef",
"000000003409092a7c4829b90b858ed5",
),
(
"https://app.nextdns.io?&p=a&&p1=b",
"000000000001a0484953e2414dc85031",
),
(
"https://app.nextdns.io?x=a",
"0000000000902424a9f120a6e720cea5",
),
(
"https://app.nextdns.io/bag",
"000000000000003409092a7c4829b809",
),
(
"https://app.nextdns.io/biz",
"000000000000003409092a7c4829b80a",
),
(
"https://app.nextdns.io/cat",
"000000000000003409092a7c4829b812",
),
(
"https://app.nextdns.io/shop?p=a",
"0000000000003409092a7c4829b87785",
),
(
"https://app.nextdns.io/use",
"000000000000003409092a7c4829b894",
),
(
"https://app.nextdns.io/a?p=abcdef",
"00048121254f89053722fb320b858ed5",
),
(
"https://app.nextdns.io/id?p=a&p1=b",
"0000000003409092a7c4829b83e85031",
),
(
"https://app.nextdns.io/id?p=abcdef",
"00000068121254f89053707d0b858ed5",
),
(
"https://qr.netflix.com/C/AAAA",
"0000000116d5992f39664d1bfa2cb2cb",
),
];
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[test]
fn compression_matches_apple_framework() {
for &(url, want) in APPLE_VECTORS {
let payload =
appclip::compress_url(url).unwrap_or_else(|e| panic!("compress {url}: {e:?}"));
assert_eq!(hex(&payload), want, "payload mismatch for {url}");
}
}
#[test]
fn full_roundtrip_through_ring_bits() {
let urls = [
"https://example.com",
"https://example.com/",
"https://example.com/path",
"https://example.com/A/",
"https://example.com/a-b",
"https://example.com/12345",
"https://example.com/product/A",
"https://example.com?x=y",
"https://example.com?x=123",
"https://example.com?x=a-b&z=456",
"https://example.com/product/123?x=A&y=456",
"https://appclip.example.com",
"https://www.apple.com",
"https://app.nextdns.io/shop?p=a",
"https://app.nextdns.io/id?p=a&p1=b",
"https://qr.netflix.com/C/AAAA",
"https://karpeleslab.github.io",
"https://example.com/watch?v=123",
];
for url in urls {
let payload =
appclip::compress_url(url).unwrap_or_else(|e| panic!("compress {url}: {e:?}"));
let bits =
appclip::encode_payload(&payload).unwrap_or_else(|e| panic!("encode {url}: {e:?}"));
assert!(bits.len() >= 129, "{url}: bit vector too short");
let back =
appclip::decode_payload(&bits).unwrap_or_else(|e| panic!("decode bits {url}: {e:?}"));
assert_eq!(hex(&back), hex(&payload), "payload round-trip for {url}");
let recovered =
appclip::decompress_url(&back).unwrap_or_else(|e| panic!("decompress {url}: {e:?}"));
let recompressed = appclip::compress_url(&recovered)
.unwrap_or_else(|e| panic!("recompress {recovered}: {e:?}"));
let again = appclip::decompress_url(&recompressed)
.unwrap_or_else(|e| panic!("re-decompress {recovered}: {e:?}"));
assert_eq!(again, recovered, "canonical fixpoint for {url}");
}
}
#[test]
fn canonical_escapes_match() {
let pairs = [
("https://example.com/[", "https://example.com/%5B"),
("https://example.com/]", "https://example.com/%5D"),
("https://example.com?[", "https://example.com?%5B"),
("https://example.com#]", "https://example.com#%5D"),
];
for (a, b) in pairs {
let pa = appclip::compress_url(a).unwrap_or_else(|e| panic!("{a}: {e:?}"));
let pb = appclip::compress_url(b).unwrap_or_else(|e| panic!("{b}: {e:?}"));
assert_eq!(hex(&pa), hex(&pb), "{a} vs {b}");
}
}
#[test]
fn rejects_invalid_urls() {
for url in [
"http://example.com", "https://", "https://user@example.com", "https://example.com:8443", "https://exämple.com", "https://xn--e1afmkfd.xn--p1ai", "https://example", "https://karpeleslab.github.io/anydcode/scanner-docs",
] {
assert!(
appclip::compress_url(url).is_err(),
"{url} should be rejected"
);
}
}
fn rasterize(bits: &[bool], size: usize, rotate_deg: f64) -> anyd::GrayImage {
rasterize_at(bits, size, rotate_deg, 0.72, 0.53, 0.49)
}
fn rasterize_at(
bits: &[bool],
size: usize,
rotate_deg: f64,
diameter_frac: f64,
cx_frac: f64,
cy_frac: f64,
) -> anyd::GrayImage {
const RINGS: [(f64, f64, usize, f64); 5] = [
(177.2016, -78.0, 17, 7.5),
(224.1012, -85.0, 23, 5.6),
(271.0008, -70.0, 26, 5.0),
(317.9004, -63.0, 29, 4.2),
(364.8, -70.0, 33, 3.5),
];
let mut arcs: Vec<Vec<(f64, f64, f64)>> = Vec::new();
let gap_bits = &bits[..128];
let color_stream = &bits[128..];
let mut offset = 0usize;
let mut ci = 0usize;
for &(_, _, n, half_gap) in RINGS.iter() {
let bit_angle = 360.0 / n as f64;
let state: Vec<Option<bool>> = (0..n)
.map(|i| {
if gap_bits[offset + i] {
None
} else {
let c = color_stream.get(ci).copied().unwrap_or(false);
ci += 1;
Some(c)
}
})
.collect();
offset += n;
let mut ring_arcs = Vec::new();
let mut i = 0usize;
while i < n {
let Some(third) = state[i] else {
i += 1;
continue;
};
let mut span = 1usize;
while i + span < n && state[i + span].is_none() {
span += 1;
}
if i + span == n {
span += state.iter().take_while(|p| p.is_none()).count();
}
let start = i as f64 * bit_angle + half_gap;
let end = (i + span) as f64 * bit_angle - half_gap;
let luma = if third { 136.0 / 255.0 } else { 0.0 };
ring_arcs.push((start, end, luma));
i += span;
}
arcs.push(ring_arcs);
}
let scale = size as f64 * diameter_frac / 800.0;
let (cx, cy) = (size as f64 * cx_frac, size as f64 * cy_frac);
let rot = rotate_deg.to_radians();
let mut img = anyd::GrayImage::filled(size, size, 214);
for py in 0..size {
for px in 0..size {
let mut acc = 0.0f64;
for sy in 0..2 {
for sx in 0..2 {
let fx = px as f64 + 0.25 + 0.5 * sx as f64 - cx;
let fy = py as f64 + 0.25 + 0.5 * sy as f64 - cy;
let (s, c) = (-rot).sin_cos();
let ux = (c * fx - s * fy) / scale;
let uy = (s * fx + c * fy) / scale;
let r = ux.hypot(uy);
if r > 400.0 {
acc += 214.0 / 255.0; continue;
}
let theta = uy.atan2(ux).to_degrees();
let mut v = 1.0f64; for (ring, &(rr, ring_rot, _, _)) in RINGS.iter().enumerate() {
if (r - rr).abs() > 23.5 / 2.0 {
continue;
}
let a = (theta - ring_rot).rem_euclid(360.0);
for &(start, end, luma) in &arcs[ring] {
if (a >= start && a <= end) || (a + 360.0 >= start && a + 360.0 <= end)
{
v = luma;
break;
}
}
break;
}
acc += v;
}
}
img.set(px, py, (acc / 4.0 * 255.0).round() as u8);
}
}
img
}
#[test]
fn scan_rasterized_codes() {
for (url, size, rot) in [
("https://example.com/shop?p=1", 500usize, 0.0f64),
("https://appclip.example.com", 380, 37.0),
("https://www.apple.com", 640, -113.0),
] {
let payload = appclip::compress_url(url).unwrap();
let bits = appclip::encode_payload(&payload).unwrap();
let img = rasterize(&bits, size, rot);
let sym = appclip::scan(&img.as_frame())
.unwrap_or_else(|e| panic!("scan {url} ({size}px, {rot}°): {e:?}"));
assert_eq!(sym.symbology, anyd::Symbology::AppClipCode);
assert_eq!(sym.text().as_deref(), Some(url), "payload for {url}");
let loc = sym.location.expect("scan reports a location");
let c = loc.outline.center();
let (ex, ey) = (size as f32 * 0.53, size as f32 * 0.49);
assert!(
(c.x - ex).abs() < size as f32 * 0.1 && (c.y - ey).abs() < size as f32 * 0.1,
"{url}: located at {c:?}, expected near ({ex},{ey})"
);
}
}
#[test]
fn scan_2d_pipeline_reads_appclip() {
let payload = appclip::compress_url("https://example.com/shop?p=1").unwrap();
let bits = appclip::encode_payload(&payload).unwrap();
let img = rasterize(&bits, 460, 12.0);
let found = anyd::pipeline::scan_2d(&img.as_frame());
assert!(
found
.iter()
.any(|s| s.symbology == anyd::Symbology::AppClipCode
&& s.text().as_deref() == Some("https://example.com/shop?p=1")),
"scan_2d results: {:?}",
found
.iter()
.map(|s| (s.symbology, s.text()))
.collect::<Vec<_>>()
);
}
#[test]
fn scan_rejects_plain_scene() {
let mut img = anyd::GrayImage::filled(400, 400, 255);
for y in 0..400 {
for x in 0..400 {
if (x / 25 + y / 25) % 2 == 0 {
img.set(x, y, 30);
}
}
}
assert!(appclip::scan(&img.as_frame()).is_err());
}
#[test]
fn svg_generation_structure() {
let svg = appclip::generate_svg("https://example.com", &appclip::Options::default()).unwrap();
assert!(svg.contains("data-design=\"Fingerprint\""));
assert!(svg.contains("<circle"));
for ring in 1..=5 {
assert!(svg.contains(&format!("ring-{ring}")), "missing ring {ring}");
}
assert!(svg.contains("data-logo-type=\"Camera\""));
assert!(svg.matches("<path d=\"M ").count() >= 12);
let opts = appclip::Options {
logo: appclip::LogoKind::Nfc,
..Default::default()
};
let nfc = appclip::generate_svg("https://example.com", &opts).unwrap();
assert!(nfc.contains("data-logo-type=\"phone\""));
for i in 0..18 {
assert!(appclip::Options::with_template(i).is_some(), "template {i}");
}
assert!(appclip::Options::with_template(18).is_none());
}