Viewing is open to everyone. Downloading and starring need a GitHub sign-in. Sign in with GitHub

CKeyPayload.cs

v0.1.0-build.85 Latest Commit ede38f5

Size
10.0 KB
Lines
229
Language
csharp
SHA-256
28f2502fcd1b…
View on GitHub

Sign in with GitHub to download. Each download is recorded with your GitHub username.

1using System;
2using System.Globalization;
3using System.Text;
4
5namespace OpenCKey
6{
7 /// <summary>What a recognized text payload offers (spec/CKey-Payload-Conventions.md).</summary>
8 public enum CKeyActionKind
9 {
10 /// <summary>https: / http: — open in the browser.</summary>
11 Web,
12 /// <summary>mailto: — compose an email.</summary>
13 Email,
14 /// <summary>tel: — offer to call.</summary>
15 Call,
16 /// <summary>sms: — compose a message.</summary>
17 Message,
18 /// <summary>geo: or address: — show in a map application.</summary>
19 Map,
20 /// <summary>BEGIN:VCARD … END:VCARD — offer to add the contact.</summary>
21 Contact,
22 }
23
24 /// <summary>
25 /// A proposed action for a decoded payload. It is only a proposal: a host
26 /// must show it with the payload and act only when the user confirms
27 /// (safety rule 1: never act automatically).
28 /// </summary>
29 public sealed class CKeyPayloadAction
30 {
31 internal CKeyPayloadAction(CKeyActionKind kind, string label, string target, string? uri,
32 double? latitude = null, double? longitude = null, string? vCard = null)
33 {
34 Kind = kind;
35 Label = label;
36 Target = target;
37 Uri = uri;
38 Latitude = latitude;
39 Longitude = longitude;
40 VCard = vCard;
41 }
42
43 /// <summary>The kind of action.</summary>
44 public CKeyActionKind Kind { get; }
45 /// <summary>A label that states the target, e.g. "Open example.com" or "Call +15551234567".</summary>
46 public string Label { get; }
47 /// <summary>The target: host, email address, phone number, map label/address, or contact name.</summary>
48 public string Target { get; }
49 /// <summary>
50 /// A URI the host may hand to the OS after the user confirms (web, mailto,
51 /// tel, sms and geo forms); null for address: and vCards, which the host
52 /// routes to its own map or contacts integration.
53 /// </summary>
54 public string? Uri { get; }
55 /// <summary>geo: latitude.</summary>
56 public double? Latitude { get; }
57 /// <summary>geo: longitude.</summary>
58 public double? Longitude { get; }
59 /// <summary>The complete vCard text for <see cref="CKeyActionKind.Contact"/>.</summary>
60 public string? VCard { get; }
61
62 /// <inheritdoc />
63 public override string ToString() => Label;
64 }
65
66 /// <summary>Payload interpretation helpers (spec/CKey-Payload-Conventions.md).</summary>
67 public static class CKeyPayload
68 {
69 private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true);
70
71 /// <summary>
72 /// The payload as text when it is valid UTF-8 with no control characters
73 /// other than tab, LF and CR; otherwise null (show it as binary/hex).
74 /// An empty payload is not text.
75 /// </summary>
76 public static string? ReadableText(ReadOnlySpan<byte> payload)
77 {
78 if (payload.IsEmpty) return null;
79 string text;
80 try
81 {
82 text = DecodeStrict(payload);
83 }
84 catch (DecoderFallbackException)
85 {
86 return null;
87 }
88 foreach (char ch in text)
89 {
90 if (ch == '\t' || ch == '\n' || ch == '\r') continue;
91 if (char.IsControl(ch)) return null;
92 }
93 return text;
94 }
95
96 private static unsafe string DecodeStrict(ReadOnlySpan<byte> payload)
97 {
98 fixed (byte* p = payload) return StrictUtf8.GetString(p, payload.Length);
99 }
100
101 /// <summary>Lower-case hexadecimal of the payload bytes.</summary>
102 public static string ToHex(ReadOnlySpan<byte> payload)
103 {
104 var sb = new StringBuilder(payload.Length * 2);
105 foreach (byte b in payload) sb.Append(b.ToString("x2", CultureInfo.InvariantCulture));
106 return sb.ToString();
107 }
108
109 /// <summary>
110 /// Classifies a text payload into a proposed action, or null for plain
111 /// text. Only the allowlisted forms are recognized (https/http, mailto,
112 /// tel, sms, geo, address, vCard); javascript:, data:, file:, intent:,
113 /// custom schemes and anything else yield null. This never acts.
114 /// </summary>
115 public static CKeyPayloadAction? Classify(string? text)
116 {
117 if (text == null) return null;
118 string trimmed = text.Trim();
119 if (trimmed.Length == 0) return null;
120 if (trimmed.StartsWith("BEGIN:VCARD", StringComparison.OrdinalIgnoreCase)) return ContactCard(trimmed);
121 int colon = trimmed.IndexOf(':');
122 if (colon <= 0) return null;
123 string scheme = trimmed.Substring(0, colon).ToLowerInvariant();
124 string rest = trimmed.Substring(colon + 1);
125 int q = rest.IndexOf('?');
126 string beforeQuery = q >= 0 ? rest.Substring(0, q) : rest;
127 string query = q >= 0 ? rest.Substring(q + 1) : string.Empty;
128 switch (scheme)
129 {
130 case "http":
131 case "https":
132 {
133 if (!System.Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri) || string.IsNullOrEmpty(uri.Host)) return null;
134 if (uri.Scheme != System.Uri.UriSchemeHttp && uri.Scheme != System.Uri.UriSchemeHttps) return null;
135 return new CKeyPayloadAction(CKeyActionKind.Web, "Open " + uri.Host, uri.Host, trimmed);
136 }
137 case "mailto":
138 {
139 string address = Unescape(beforeQuery);
140 if (address.IndexOf('@') < 0) return null;
141 return new CKeyPayloadAction(CKeyActionKind.Email, "Email " + address, address, trimmed);
142 }
143 case "tel":
144 case "sms":
145 {
146 // E.164 is recommended (tel:+15551234567); keep only dialable characters.
147 var number = new StringBuilder();
148 int digits = 0;
149 foreach (char ch in beforeQuery)
150 {
151 if (ch >= '0' && ch <= '9') { number.Append(ch); digits++; }
152 else if (ch == '+') number.Append(ch);
153 }
154 if (digits < 3) return null;
155 string n = number.ToString();
156 return scheme == "tel"
157 ? new CKeyPayloadAction(CKeyActionKind.Call, "Call " + n, n, "tel:" + n)
158 : new CKeyPayloadAction(CKeyActionKind.Message, "Message " + n, n, trimmed);
159 }
160 case "geo":
161 {
162 // RFC 5870: geo:lat,lon[,alt][;params][?q=label]
163 string[] coordinates = beforeQuery.Split(';')[0].Split(',');
164 if (coordinates.Length < 2) return null;
165 if (!double.TryParse(coordinates[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double lat) ||
166 !double.TryParse(coordinates[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double lon)) return null;
167 if (!(lat >= -90 && lat <= 90) || !(lon >= -180 && lon <= 180)) return null;
168 string? label = null;
169 foreach (string item in query.Split('&'))
170 {
171 if (item.StartsWith("q=", StringComparison.Ordinal)) { label = Unescape(item.Substring(2)); break; }
172 }
173 if (string.IsNullOrEmpty(label))
174 label = string.Format(CultureInfo.InvariantCulture, "{0:F5}, {1:F5}", lat, lon);
175 return new CKeyPayloadAction(CKeyActionKind.Map, $"Show {label} on a map", label!, trimmed, lat, lon);
176 }
177 case "address":
178 {
179 // CKey convention: free-text postal address (UTF-8, no
180 // percent-encoding); line breaks separate address lines.
181 var lines = rest.Split(new[] { "\r\n", "\n", "\r", "\u0085", "\u2028", "\u2029" }, StringSplitOptions.None);
182 var sb = new StringBuilder();
183 foreach (string line in lines)
184 {
185 string t = line.Trim();
186 if (t.Length == 0) continue;
187 if (sb.Length > 0) sb.Append(", ");
188 sb.Append(t);
189 }
190 if (sb.Length == 0) return null;
191 string address = sb.ToString();
192 return new CKeyPayloadAction(CKeyActionKind.Map, $"Show {address} on a map", address, null);
193 }
194 default:
195 return null;
196 }
197 }
198
199 private static CKeyPayloadAction? ContactCard(string card)
200 {
201 if (card.IndexOf("END:VCARD", StringComparison.OrdinalIgnoreCase) < 0) return null;
202 string name = "contact";
203 foreach (string raw in card.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.None))
204 {
205 string line = raw.Trim();
206 if (line.StartsWith("FN:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("FN;", StringComparison.OrdinalIgnoreCase))
207 {
208 int c = line.IndexOf(':');
209 if (c >= 0 && c + 1 < line.Length) name = line.Substring(c + 1).Trim();
210 break;
211 }
212 }
213 if (name.Length == 0) name = "contact";
214 return new CKeyPayloadAction(CKeyActionKind.Contact, $"Add {name} to contacts", name, null, vCard: card);
215 }
216
217 private static string Unescape(string s)
218 {
219 try
220 {
221 return System.Uri.UnescapeDataString(s);
222 }
223 catch (UriFormatException)
224 {
225 return s;
226 }
227 }
228 }
229}