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 }