1 /**
2 	Google Cloud Messaging (GCM) for D
3 
4 	Copyright: © 2016 sigod
5 	License: Subject to the terms of the MIT license, as written in the included LICENSE file.
6 	Authors: sigod
7 */
8 module gcm;
9 
10 private {
11 	import core.time : weeks;
12 	import std.json;
13 	import std.range : isInputRange, ElementType;
14 	import std.typecons : Nullable;
15 }
16 
17 /// Convenience function for GCMessage. Converts T to JSONValue.
18 GCMessage gcmessage(T = JSONValue)(T data = T.init)
19 {
20 	GCMessage ret;
21 	ret.data = convert(data);
22 	return ret;
23 }
24 /// ditto
25 GCMessage gcmessage(T = JSONValue)(GCMNotification ntf, T data = T.init)
26 {
27 	GCMessage ret;
28 	ret.notification = ntf;
29 	ret.data = convert(data);
30 	return ret;
31 }
32 
33 enum GCMPriority
34 {
35 	normal = "normal",
36 	high = "high"
37 }
38 
39 struct GCMessage
40 {
41 	/// This parameter identifies a group of messages that can be collapsed.
42 	string collapse_key;
43 
44 	/// Sets the priority of the message. Valid values are "normal" and "high".
45 	GCMPriority priority;
46 
47 	/// When a notification or message is sent and this is set to true, an inactive client app is awoken.
48 	Nullable!bool content_available;
49 
50 	/// When this parameter is set to true, it indicates that the message should not be sent until the device becomes active.
51 	bool delay_while_idle;
52 
53 	/// This parameter specifies how long (in seconds) the message should be kept in GCM storage if the device is offline.
54 	int time_to_live = weeks(4).total!"seconds";
55 
56 	/**
57 		This parameter specifies the package name of the application where
58 		the registration tokens must match in order to receive the message.
59 	*/
60 	string restricted_package_name;
61 
62 	/// This parameter, when set to true, allows developers to test a request without actually sending a message.
63 	bool dry_run;
64 
65 	/// This parameter specifies the key-value pairs of the notification payload.
66 	Nullable!GCMNotification notification;
67 
68 	/// This parameter specifies the key-value pairs of the message's payload.
69 	JSONValue data;
70 }
71 
72 struct GCMNotification
73 {
74 	/// Indicates notification title. This field is not visible on iOS phones and tablets.
75 	string title;
76 
77 	/// Indicates notification body text.
78 	string body_;
79 
80 	/// Indicates notification icon. On Android: sets value to `myicon` for drawable resource `myicon.png`.
81 	string icon;
82 
83 	/// Indicates sound to be played. Supports only default currently.
84 	string sound;
85 
86 	/// Indicates the badge on client app home icon.
87 	string badge;
88 
89 	/**
90 		Indicates whether each notification message results in a new entry on the notification center on Android.
91 		If not set, each request creates a new notification. If set, and a notification with the same tag is already
92 		being shown, the new notification replaces the existing one in notification center.
93 	*/
94 	string tag;
95 
96 	/// Indicates color of the icon, expressed in #rrggbb format
97 	string color;
98 
99 	/// The action associated with a user click on the notification.
100 	string click_action;
101 
102 	/// Indicates the key to the body string for localization.
103 	string body_loc_key;
104 
105 	/// Indicates the string value to replace format specifiers in body string for localization.
106 	@asString
107 	string[] body_loc_args;
108 
109 	/// Indicates the key to the title string for localization.
110 	string title_loc_key;
111 
112 	/// Indicates the string value to replace format specifiers in title string for localization.
113 	@asString
114 	string[] title_loc_args;
115 }
116 
117 ///
118 struct GCMResponseResult
119 {
120 	string message_id;
121 	string registration_id;
122 	string error;
123 }
124 
125 ///
126 struct GCMResponse
127 {
128 	string message_id;
129 	string error;
130 
131 	long multicast_id;
132 	long success;
133 	long failure;
134 	long canonical_ids;
135 	GCMResponseResult[] results;
136 }
137 
138 /**
139  * Wrapper around `sendMulticast` since GCM's answers inconsistent
140  * for direct messages. Sometimes you get plain text instead of JSON.
141  */
142 Nullable!MulticastMessageResponse sendDirect(string key, string receiver, GCMessage message)
143 {
144 	//TODO: convert into proper *MessageResponce?
145 	return sendMulticast(key, [receiver], message);
146 }
147 
148 ///
149 struct DeviceGroup
150 {
151 	string api_key;
152 	string sender_id;
153 	string notification_key_name;
154 	string notification_key;
155 }
156 
157 /// Functions for managing device groups
158 bool create(ref DeviceGroup group, string[] registration_ids)
159 {
160 	if (auto response = groupOperation(group, "create", registration_ids)) {
161 		auto json = response.parseJSON();
162 
163 		if (auto key = "notification_key" in json.object) {
164 			group.notification_key = (*key).str();
165 			return true;
166 		}
167 	}
168 
169 	return false;
170 }
171 
172 /// ditto
173 bool add(DeviceGroup group, string[] registration_ids)
174 {
175 	return groupOperation(group, "add", registration_ids) !is null;
176 }
177 
178 /// ditto
179 bool remove(DeviceGroup group, string[] registration_ids)
180 {
181 	return groupOperation(group, "remove", registration_ids) !is null;
182 }
183 
184 struct DeviceGroupResponse
185 {
186 	byte success;
187 	byte failure;
188 	string[] failed_registration_ids;
189 }
190 
191 Nullable!DeviceGroupResponse sendGroup(DeviceGroup group, GCMessage message)
192 {
193 	return sendGroup(group.api_key, group.notification_key, message);
194 }
195 
196 Nullable!DeviceGroupResponse sendGroup(string key, string to, GCMessage message)
197 {
198 	auto request = finalMessage(message, to);
199 
200 	if (auto response = send(key, request)) {
201 		DeviceGroupResponse ret;
202 
203 		if (response.parse(ret))
204 			return cast(Nullable!DeviceGroupResponse)ret;
205 	}
206 
207 	return Nullable!DeviceGroupResponse.init;
208 }
209 
210 struct TopicMessageResponse
211 {
212 	long message_id;
213 	string error;
214 }
215 
216 Nullable!TopicMessageResponse sendTopic(string key, string topic, GCMessage message)
217 {
218 	import std.algorithm : startsWith;
219 	assert(topic.startsWith("/topics/"), "all topics must start with '/topics/'");
220 
221 	auto request = finalMessage(message, topic);
222 
223 	if (auto response = send(key, request)) {
224 		TopicMessageResponse ret;
225 
226 		if (response.parse(ret))
227 			return cast(Nullable!TopicMessageResponse)ret;
228 	}
229 
230 	return Nullable!TopicMessageResponse.init;
231 }
232 
233 struct MulticastMessageResponse
234 {
235 	long multicast_id;
236 	short success;
237 	short failure;
238 	short canonical_ids;
239 	MulticastMessageResult[] results;
240 }
241 
242 struct MulticastMessageResult
243 {
244 	string message_id;
245 	string registration_id;
246 	string error;
247 }
248 
249 Nullable!MulticastMessageResponse sendMulticast(Range)(string key, Range registration_ids, GCMessage message)
250 	if (isInputRange!Range && is(ElementType!Range : const(char)[]))
251 {
252 	auto request = finalMessage(message, registration_ids);
253 
254 	if (auto response = send(key, request)) {
255 		MulticastMessageResponse ret;
256 
257 		if (response.parse(ret))
258 			return cast(Nullable!MulticastMessageResponse)ret;
259 	}
260 
261 	return Nullable!MulticastMessageResponse.init;
262 }
263 
264 ///
265 enum asString;
266 
267 private:
268 
269 import std.net.curl;
270 
271 string finalMessage(in GCMessage message, in char[] to)
272 {
273 	auto json = convert(message);
274 	json["to"] = to;
275 	return json.toString();
276 }
277 
278 string finalMessage(Range)(in GCMessage message, Range ids)
279 {
280 	import std.algorithm : map;
281 	import std.array : array;
282 
283 	auto json = convert(message);
284 	// this way JSONValue will use provided array instead of allocating new one
285 	json["registration_ids"] = ids.map!(e => JSONValue(e)).array;
286 	assert(json["registration_ids"].array.length <= 1000, "number of registration_ids limited to 1000, see #2");
287 	return json.toString();
288 }
289 
290 char[] send(string key, in char[] message)
291 {
292 	HTTP client = HTTP();
293 
294 	client.addRequestHeader("Content-Type", "application/json");
295 	client.addRequestHeader("Authorization", "key=" ~ key);
296 
297 	try {
298 		return post("https://gcm-http.googleapis.com/gcm/send", message, client);
299 	}
300 	catch (Exception e) {
301 		import std.stdio : stderr;
302 		stderr.writeln("[GCM] request failed: ", e);
303 
304 		return null;
305 	}
306 }
307 
308 char[] groupOperation(DeviceGroup group, string operation, string[] registration_ids)
309 {
310 	assert(registration_ids.length);
311 
312 	static struct Request
313 	{
314 		string operation;
315 		string notification_key_name;
316 		string notification_key;
317 		string[] registration_ids;
318 	}
319 
320 	Request request = void;
321 	request.operation = operation;
322 	request.notification_key_name = group.notification_key_name;
323 	request.notification_key = operation == "create" ? null : group.notification_key;
324 	request.registration_ids = registration_ids;
325 
326 	HTTP client = HTTP();
327 
328 	client.addRequestHeader("Content-Type", "application/json");
329 	client.addRequestHeader("Authorization", "key=" ~ group.api_key);
330 	client.addRequestHeader("project_id", group.sender_id);
331 
332 	try {
333 		return post("https://android.googleapis.com/gcm/notification", convert(request).toString(), client);
334 	}
335 	catch (Exception e) {
336 		import std.stdio : stderr;
337 		stderr.writeln("[GCM] request failed: ", e);
338 
339 		return null;
340 	}
341 }
342 
343 alias Alias(alias a) = a;
344 
345 string stripName(string name)()
346 {
347 	import std.algorithm : endsWith;
348 
349 	static if (name.endsWith('_'))
350 		return name[0 .. $ - 1];
351 	else
352 		return name;
353 }
354 
355 template stripNullable(T)
356 {
357 	static if (is(T == Nullable!V, V))
358 		alias stripNullable = V;
359 	else
360 		alias stripNullable = T;
361 }
362 
363 template isISOExtStringSerializable(T)
364 {
365 	enum bool isISOExtStringSerializable =
366 		is(typeof(T.init.toISOExtString()) == string) && is(typeof(T.fromISOExtString("")) == T);
367 }
368 
369 JSONValue convert(T)(T value)
370 {
371 	import std.algorithm : each, map;
372 	import std.array : array;
373 	import std.conv : to;
374 	import std.traits : hasUDA, isAssociativeArray, isSomeFunction;
375 
376 	alias Type = stripNullable!T;
377 
378 	static if (is(T == Nullable!Type)) {
379 		if (value.isNull) return JSONValue(null);
380 	}
381 	else static if (is(T == class)) {
382 		if (value is null) return JSONValue(null);
383 	}
384 
385 	static if (is(Type == JSONValue)) {
386 		return value;
387 	}
388 	else static if (is(typeof(JSONValue(value)))) {
389 		return JSONValue(value);
390 	}
391 	else static if (isISOExtStringSerializable!Type) {
392 		return JSONValue(value.toISOExtString());
393 	}
394 	else static if (isInputRange!Type) {
395 		return JSONValue(value.map!(e => convert(e)).array);
396 	}
397 	else static if (isAssociativeArray!Type) {
398 		JSONValue[string] object;
399 
400 		value.byKeyValue().each!((pair) {
401 			object[pair.key.to!string] = convert(pair.value);
402 		});
403 
404 		return JSONValue(object);
405 	}
406 	else static if (is(Type == struct) || is(Type == class)) {
407 		JSONValue[string] object;
408 
409 		foreach (field_name; __traits(derivedMembers, Type)) {
410 			alias FieldType = typeof(__traits(getMember, value, field_name));
411 
412 			//TODO: support getters?
413 			static if (!isSomeFunction!FieldType) {
414 				auto field = convert(__traits(getMember, value, field_name));
415 
416 				static if (hasUDA!(__traits(getMember, Type, field_name), asString))
417 					field = JSONValue(field.toString());
418 
419 				object[stripName!field_name] = field;
420 			}
421 		}
422 
423 		return JSONValue(object);
424 	}
425 	else
426 		static assert(false, Type.stringof ~ " not supported");
427 }
428 
429 unittest
430 {
431 	assert(convert(Nullable!int.init) == parseJSON(`null`));
432 	assert(convert(Nullable!int(42)) == parseJSON(`42`));
433 
434 	assert(convert(42) == parseJSON(`42`));
435 	assert(convert("42") == parseJSON(`"42"`));
436 	assert(convert(4.2) == parseJSON(`4.2`));
437 }
438 
439 unittest
440 {
441 	import std.datetime : SysTime, UTC;
442 	assert(convert(SysTime(0, UTC())).toString() == `"0001-01-01T00:00:00Z"`);
443 }
444 
445 unittest
446 {
447 	import std.algorithm : map;
448 	assert(convert([1, 2, 3].map!(e => e*3)) == parseJSON(`[3,6,9]`));
449 }
450 
451 unittest
452 {
453 	assert(convert([1:2, 2:4, 3:6]) == parseJSON(`{"1":2,"2":4,"3":6}`));
454 }
455 
456 unittest
457 {
458 	static struct Inner
459 	{
460 		int a;
461 	}
462 	static struct S
463 	{
464 		Inner inner;
465 	}
466 	assert(convert(S(Inner(42))) == parseJSON(`{"inner":{"a":42}}`));
467 }
468 
469 unittest
470 {
471 	static class C
472 	{
473 		int a;
474 		this(int v) { a = v; }
475 	}
476 	assert(convert(new C(42)) == parseJSON(`{"a":42}`));
477 }
478 
479 unittest
480 {
481 	static struct S
482 	{
483 		int in_;
484 	}
485 	assert(convert(S(1)) == parseJSON(`{"in":1}`));
486 }
487 
488 bool parse(T)(in char[] response, out T ret)
489 {
490 	try {
491 		ret = response.parseJSON.parse!T;
492 
493 		return true;
494 	}
495 	catch (JSONException e) {
496 		import std.stdio : stderr;
497 		stderr.writeln("[GCM] parsing failed: ", e);
498 
499 		return false;
500 	}
501 }
502 
503 T parse(T)(JSONValue json)
504 {
505 	import std.array : array;
506 	import std.algorithm : map;
507 	import std.traits : isIntegral;
508 
509 	assert(json.type == JSON_TYPE.OBJECT);
510 
511 	T ret;
512 
513 	foreach (field_name; __traits(allMembers, T)) {
514 		alias FieldType = typeof(__traits(getMember, T, field_name));
515 
516 		if (auto field = stripName!field_name in json.object) {
517 			static if (isIntegral!FieldType) {
518 				if ((*field).type == JSON_TYPE.INTEGER)
519 					__traits(getMember, ret, field_name) = cast(FieldType)(*field).integer;
520 			}
521 			else static if (is(FieldType == string)) {
522 				if ((*field).type == JSON_TYPE.STRING)
523 					__traits(getMember, ret, field_name) = (*field).str;
524 			}
525 			else static if (is(FieldType == E[], E)) {
526 				if ((*field).type == JSON_TYPE.ARRAY) {
527 					static if (is(E == string))
528 						__traits(getMember, ret, field_name) = (*field).array.map!(e => e.str).array;
529 					else static if (is(E == struct))
530 						__traits(getMember, ret, field_name) = (*field).array.map!(e => e.parse!E).array;
531 				}
532 			}
533 		}
534 	}
535 
536 	return ret;
537 }
538 
539 unittest {
540 	auto r = `{"success":1, "failure":2, "failed_registration_ids":["regId1", "regId2"]}`;
541 	auto expected = DeviceGroupResponse(1, 2, ["regId1", "regId2"]);
542 
543 	DeviceGroupResponse result;
544 	assert(r.parse!DeviceGroupResponse(result));
545 
546 	assert(result == expected);
547 }
548 
549 unittest {
550 	static struct Inner
551 	{
552 		int field0;
553 	}
554 	static struct Outer
555 	{
556 		int field0;
557 		Inner[] field1;
558 	}
559 
560 	auto r = `{"field0":3, "field1":[{"field0":0}, {"field0":1}, {"field0":2}]}`;
561 	auto expected = Outer(3, [Inner(0), Inner(1), Inner(2)]);
562 
563 	Outer result;
564 	assert(r.parse(result));
565 
566 	assert(result == expected);
567 }
568 
569 unittest
570 {
571 	static struct S
572 	{
573 		int in_;
574 	}
575 	assert(parseJSON(`{"in":1}`).parse!S.in_ == 1);
576 }