1 package host.exp.exponent.notifications
2 
3 import android.app.NotificationChannel
4 import android.app.NotificationManager
5 import android.app.PendingIntent
6 import android.content.Context
7 import android.content.Intent
8 import android.graphics.Bitmap
9 import android.graphics.Color
10 import android.net.Uri
11 import android.os.Build
12 import android.os.SystemClock
13 import android.text.format.DateUtils
14 import androidx.core.app.NotificationCompat
15 import de.greenrobot.event.EventBus
16 import expo.modules.core.errors.InvalidArgumentException
17 import expo.modules.manifests.core.Manifest
18 import host.exp.exponent.Constants
19 import host.exp.exponent.ExponentManifest
20 import host.exp.exponent.ExponentManifest.BitmapListener
21 import host.exp.exponent.analytics.EXL
22 import host.exp.exponent.fcm.FcmRegistrationIntentService
23 import host.exp.exponent.kernel.ExperienceKey
24 import host.exp.exponent.kernel.ExponentUrls
25 import host.exp.exponent.kernel.KernelConstants
26 import host.exp.exponent.network.ExpoHttpCallback
27 import host.exp.exponent.network.ExpoResponse
28 import host.exp.exponent.network.ExponentNetwork
29 import host.exp.exponent.storage.ExponentDB
30 import host.exp.exponent.storage.ExponentDB.ExperienceResultListener
31 import host.exp.exponent.storage.ExponentDBObject
32 import host.exp.exponent.storage.ExponentSharedPreferences
33 import host.exp.exponent.utils.AsyncCondition
34 import host.exp.exponent.utils.AsyncCondition.AsyncConditionListener
35 import host.exp.exponent.utils.ColorParser
36 import host.exp.exponent.utils.JSONUtils.getJSONString
37 import host.exp.expoview.R
38 import okhttp3.MediaType
39 import okhttp3.RequestBody
40 import org.json.JSONException
41 import org.json.JSONObject
42 import java.io.IOException
43 import java.text.DateFormat
44 import java.text.SimpleDateFormat
45 import java.util.*
46 
47 object NotificationHelper {
48   private val TAG = NotificationHelper::class.java.simpleName
49 
50   fun getColor(
51     colorString: String?,
52     manifest: Manifest,
53     exponentManifest: ExponentManifest
54   ): Int {
55     val colorStringLocal = colorString ?: manifest.getNotificationPreferences()?.optString(ExponentManifest.MANIFEST_NOTIFICATION_COLOR_KEY)
56     return if (colorString != null && ColorParser.isValid(colorStringLocal)) {
57       Color.parseColor(colorString)
58     } else {
59       exponentManifest.getColorFromManifest(manifest)
60     }
61   }
62 
63   fun loadIcon(
64     url: String?,
65     manifest: Manifest,
66     exponentManifest: ExponentManifest,
67     bitmapListener: BitmapListener?
68   ) {
69     val notificationPreferences = manifest.getNotificationPreferences()
70     var iconUrl: String?
71     if (url == null) {
72       iconUrl = manifest.getIconUrl()
73       if (notificationPreferences != null) {
74         iconUrl = if (notificationPreferences.has(ExponentManifest.MANIFEST_NOTIFICATION_ICON_URL_KEY)) {
75           notificationPreferences.optString(ExponentManifest.MANIFEST_NOTIFICATION_ICON_URL_KEY)
76         } else {
77           null
78         }
79       }
80     } else {
81       iconUrl = url
82     }
83 
84     exponentManifest.loadIconBitmap(iconUrl, bitmapListener!!)
85   }
86 
87   @JvmStatic fun getPushNotificationToken(
88     deviceId: String?,
89     experienceId: String?,
90     exponentNetwork: ExponentNetwork,
91     exponentSharedPreferences: ExponentSharedPreferences,
92     listener: TokenListener
93   ) {
94     if (Constants.FCM_ENABLED) {
95       FcmRegistrationIntentService.getTokenAndRegister(exponentSharedPreferences.context)
96     }
97 
98     AsyncCondition.wait(
99       ExponentNotificationIntentService.DEVICE_PUSH_TOKEN_KEY,
100       object : AsyncConditionListener {
101         override fun isReady(): Boolean {
102           return (exponentSharedPreferences.getString(ExponentSharedPreferences.ExponentSharedPreferencesKey.FCM_TOKEN_KEY) != null || ExponentNotificationIntentService.hasTokenError)
103         }
104 
105         override fun execute() {
106           val sharedPreferencesToken = exponentSharedPreferences.getString(ExponentSharedPreferences.ExponentSharedPreferencesKey.FCM_TOKEN_KEY)
107           if (sharedPreferencesToken == null || sharedPreferencesToken.isEmpty()) {
108             var message = "No device token found."
109             if (!Constants.FCM_ENABLED) {
110               message += " You need to enable FCM in order to get a push token. Follow this guide to set up FCM for your standalone app: https://docs.expo.io/versions/latest/guides/using-fcm"
111             }
112             listener.onFailure(Exception(message))
113             return
114           }
115 
116           val params = JSONObject().apply {
117             try {
118               put("deviceId", deviceId)
119               put("experienceId", experienceId)
120               put("appId", exponentSharedPreferences.context.applicationContext.packageName)
121               put("deviceToken", sharedPreferencesToken)
122               put("type", "fcm")
123               put("development", false)
124             } catch (e: JSONException) {
125               listener.onFailure(Exception("Error constructing request"))
126               return@execute
127             }
128           }
129 
130           val body = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), params.toString())
131           val request = ExponentUrls.addExponentHeadersToUrl("https://exp.host/--/api/v2/push/getExpoPushToken")
132             .header("Content-Type", "application/json")
133             .post(body)
134             .build()
135           exponentNetwork.client.call(
136             request,
137             object : ExpoHttpCallback {
138               override fun onFailure(e: IOException) {
139                 listener.onFailure(e)
140               }
141 
142               @Throws(IOException::class)
143               override fun onResponse(response: ExpoResponse) {
144                 if (!response.isSuccessful) {
145                   listener.onFailure(Exception("Couldn't get android push token for device"))
146                   return
147                 }
148 
149                 try {
150                   val result = JSONObject(response.body().string())
151                   val data = result.getJSONObject("data")
152                   listener.onSuccess(data.getString("expoPushToken"))
153                 } catch (e: Exception) {
154                   listener.onFailure(e)
155                 }
156               }
157             }
158           )
159         }
160       }
161     )
162   }
163 
164   @JvmStatic fun createChannel(
165     context: Context,
166     experienceKey: ExperienceKey,
167     channelId: String,
168     channelName: String?,
169     details: HashMap<*, *>
170   ) {
171     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
172       val description: String? = if (details.containsKey(NotificationConstants.NOTIFICATION_CHANNEL_DESCRIPTION)) {
173         details[NotificationConstants.NOTIFICATION_CHANNEL_DESCRIPTION] as String?
174       } else {
175         null
176       }
177       val importance: String? = if (details.containsKey(NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY)) {
178         details[NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY] as String?
179       } else {
180         null
181       }
182       val sound: Boolean? = if (details.containsKey(NotificationConstants.NOTIFICATION_CHANNEL_SOUND)) {
183         details[NotificationConstants.NOTIFICATION_CHANNEL_SOUND] as Boolean?
184       } else {
185         null
186       }
187       val vibrate: Any? = if (details.containsKey(NotificationConstants.NOTIFICATION_CHANNEL_VIBRATE)) {
188         details[NotificationConstants.NOTIFICATION_CHANNEL_VIBRATE]
189       } else {
190         null
191       }
192       val badge: Boolean? = if (details.containsKey(NotificationConstants.NOTIFICATION_CHANNEL_BADGE)) {
193         details[NotificationConstants.NOTIFICATION_CHANNEL_BADGE] as Boolean?
194       } else {
195         null
196       }
197 
198       createChannel(
199         context,
200         experienceKey,
201         channelId,
202         channelName,
203         description,
204         importance,
205         sound,
206         vibrate,
207         badge
208       )
209     } else {
210       // since channels do not exist on Android 7.1 and below, we'll save the settings in shared
211       // preferences and apply them to individual notifications that have this channelId from now on
212       // this is essentially a "polyfill" of notification channels for Android 7.1 and below
213       // and means that devs don't have to worry about supporting both versions of Android at once
214       ExponentNotificationManager(context).saveChannelSettings(experienceKey, channelId, details)
215     }
216   }
217 
218   @JvmStatic fun createChannel(
219     context: Context,
220     experienceKey: ExperienceKey,
221     channelId: String,
222     details: JSONObject
223   ) {
224     try {
225       // we want to throw immediately if there is no channel name
226       val channelName = details.getString(NotificationConstants.NOTIFICATION_CHANNEL_NAME)
227       val description: String? = if (!details.isNull(NotificationConstants.NOTIFICATION_CHANNEL_DESCRIPTION)) {
228         details.optString(NotificationConstants.NOTIFICATION_CHANNEL_DESCRIPTION)
229       } else {
230         null
231       }
232       val priority: String? = if (!details.isNull(NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY)) {
233         details.optString(NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY)
234       } else {
235         null
236       }
237       val sound: Boolean? = if (!details.isNull(NotificationConstants.NOTIFICATION_CHANNEL_SOUND)) {
238         details.optBoolean(NotificationConstants.NOTIFICATION_CHANNEL_SOUND)
239       } else {
240         null
241       }
242       val badge: Boolean? = if (!details.isNull(NotificationConstants.NOTIFICATION_CHANNEL_BADGE)) {
243         details.optBoolean(NotificationConstants.NOTIFICATION_CHANNEL_BADGE, true)
244       } else {
245         null
246       }
247 
248       val vibrateJsonArray = details.optJSONArray(NotificationConstants.NOTIFICATION_CHANNEL_VIBRATE)
249       val vibrate = if (vibrateJsonArray != null) {
250         val vibrateArrayList = ArrayList<Double>()
251         for (i in 0 until vibrateJsonArray.length()) {
252           vibrateArrayList.add(vibrateJsonArray.getDouble(i))
253         }
254         vibrateArrayList
255       } else {
256         details.optBoolean(NotificationConstants.NOTIFICATION_CHANNEL_VIBRATE, false)
257       }
258 
259       createChannel(
260         context,
261         experienceKey,
262         channelId,
263         channelName,
264         description,
265         priority,
266         sound,
267         vibrate,
268         badge
269       )
270     } catch (e: Exception) {
271       EXL.e(TAG, "Could not create channel from stored JSON Object: " + e.message)
272     }
273   }
274 
275   private fun createChannel(
276     context: Context,
277     experienceKey: ExperienceKey,
278     channelId: String,
279     channelName: String?,
280     description: String?,
281     importanceString: String?,
282     sound: Boolean?,
283     vibrate: Any?,
284     badge: Boolean?
285   ) {
286     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
287       val importance = when (importanceString) {
288         NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY_MAX -> NotificationManager.IMPORTANCE_MAX
289         NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY_HIGH -> NotificationManager.IMPORTANCE_HIGH
290         NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY_LOW -> NotificationManager.IMPORTANCE_LOW
291         NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY_MIN -> NotificationManager.IMPORTANCE_MIN
292         else -> NotificationManager.IMPORTANCE_DEFAULT
293       }
294 
295       val channel = NotificationChannel(
296         ExponentNotificationManager.getScopedChannelId(experienceKey, channelId),
297         channelName,
298         importance
299       )
300 
301       // sound is now on by default for channels
302       if (sound == null || !sound) {
303         channel.setSound(null, null)
304       }
305 
306       if (vibrate != null) {
307         if (vibrate is ArrayList<*>) {
308           val pattern = LongArray(vibrate.size)
309           for (i in vibrate.indices) {
310             pattern[i] = (vibrate[i] as Double).toInt().toLong()
311           }
312           channel.vibrationPattern = pattern
313         } else if (vibrate is Boolean && vibrate) {
314           channel.vibrationPattern = longArrayOf(0, 500)
315         }
316       }
317 
318       if (description != null) {
319         channel.description = description
320       }
321 
322       if (badge != null) {
323         channel.setShowBadge(badge)
324       }
325 
326       ExponentNotificationManager(context).createNotificationChannel(experienceKey, channel)
327     }
328   }
329 
330   @JvmStatic fun maybeCreateLegacyStoredChannel(
331     context: Context,
332     experienceKey: ExperienceKey,
333     channelId: String,
334     details: HashMap<*, *>
335   ) {
336     // no version check here because if we're on Android 7.1 or below, we still want to save
337     // the channel in shared preferences
338     val existingChannel = ExponentNotificationManager(context).getNotificationChannel(experienceKey, channelId)
339     if (existingChannel == null && details.containsKey(NotificationConstants.NOTIFICATION_CHANNEL_NAME)) {
340       createChannel(
341         context,
342         experienceKey,
343         channelId,
344         details[NotificationConstants.NOTIFICATION_CHANNEL_NAME] as String?,
345         details
346       )
347     }
348   }
349 
350   @JvmStatic fun deleteChannel(context: Context?, experienceKey: ExperienceKey?, channelId: String?) {
351     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
352       ExponentNotificationManager(context!!).deleteNotificationChannel(experienceKey!!, channelId!!)
353     } else {
354       // deleting a channel on O+ still retains all its settings, so doing nothing here emulates that
355     }
356   }
357 
358   @JvmStatic fun showNotification(
359     context: Context,
360     id: Int,
361     details: HashMap<*, *>,
362     exponentManifest: ExponentManifest,
363     listener: Listener
364   ) {
365     val manager = ExponentNotificationManager(context)
366     val notificationScopeKey = details[NotificationConstants.NOTIFICATION_EXPERIENCE_SCOPE_KEY_KEY] as String?
367     val experienceScopeKey = notificationScopeKey ?: (details[NotificationConstants.NOTIFICATION_EXPERIENCE_ID_KEY] as String?)!!
368 
369     ExponentDB.experienceScopeKeyToExperience(
370       experienceScopeKey,
371       object : ExperienceResultListener {
372         override fun onSuccess(exponentDBObject: ExponentDBObject) {
373           Thread(
374             Runnable {
375               val manifest = exponentDBObject.manifest
376               val experienceKey = try {
377                 ExperienceKey.fromManifest(manifest)
378               } catch (e: JSONException) {
379                 listener.onFailure(Exception("Couldn't deserialize JSON for experience scope key $experienceScopeKey"))
380                 return@Runnable
381               }
382 
383               val builder = NotificationCompat.Builder(
384                 context,
385                 ExponentNotificationManager.getScopedChannelId(
386                   experienceKey,
387                   NotificationConstants.NOTIFICATION_DEFAULT_CHANNEL_ID
388                 )
389               ).apply {
390                 setSmallIcon(if (Constants.isStandaloneApp()) R.drawable.shell_notification_icon else R.drawable.notification_icon)
391                 setAutoCancel(true)
392               }
393 
394               val data = details["data"] as HashMap<*, *>
395               if (data.containsKey("channelId")) {
396                 val channelId = data["channelId"] as String
397                 builder.setChannelId(
398                   ExponentNotificationManager.getScopedChannelId(
399                     experienceKey,
400                     channelId
401                   )
402                 )
403 
404                 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
405                   // if we don't yet have a channel matching this ID, check shared preferences --
406                   // it's possible this device has just been upgraded to Android 8+ and the channel
407                   // needs to be created in the system
408                   if (manager.getNotificationChannel(experienceKey, channelId) == null) {
409                     val storedChannelDetails = manager.readChannelSettings(experienceKey, channelId)
410                     if (storedChannelDetails != null) {
411                       createChannel(context, experienceKey, channelId, storedChannelDetails)
412                     }
413                   }
414                 } else {
415                   // on Android 7.1 and below, read channel settings for sound, priority, and vibrate from shared preferences
416                   // and apply these settings to the notification individually, since channels do not exist
417                   val storedChannelDetails = manager.readChannelSettings(experienceKey, channelId)
418                   if (storedChannelDetails != null) {
419                     if (storedChannelDetails.optBoolean(NotificationConstants.NOTIFICATION_CHANNEL_SOUND, false)
420                     ) {
421                       builder.setDefaults(NotificationCompat.DEFAULT_SOUND)
422                     }
423 
424                     builder.priority = when (storedChannelDetails.optString(NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY)) {
425                       "max" -> NotificationCompat.PRIORITY_MAX
426                       "high" -> NotificationCompat.PRIORITY_HIGH
427                       "low" -> NotificationCompat.PRIORITY_LOW
428                       "min" -> NotificationCompat.PRIORITY_MIN
429                       else -> NotificationCompat.PRIORITY_DEFAULT
430                     }
431 
432                     try {
433                       val vibrateJsonArray = storedChannelDetails.optJSONArray(NotificationConstants.NOTIFICATION_CHANNEL_VIBRATE)
434                       if (vibrateJsonArray != null) {
435                         val pattern = LongArray(vibrateJsonArray.length())
436                         for (i in 0 until vibrateJsonArray.length()) {
437                           pattern[i] = vibrateJsonArray.getDouble(i).toInt().toLong()
438                         }
439                         builder.setVibrate(pattern)
440                       } else if (storedChannelDetails.optBoolean(NotificationConstants.NOTIFICATION_CHANNEL_VIBRATE, false)) {
441                         builder.setVibrate(longArrayOf(0, 500))
442                       }
443                     } catch (e: Exception) {
444                       EXL.e(
445                         TAG,
446                         "Failed to set vibrate settings on notification from stored channel: " + e.message
447                       )
448                     }
449                   } else {
450                     EXL.e(TAG, "No stored channel found for $experienceScopeKey: $channelId")
451                   }
452                 }
453               } else {
454                 // make a default channel so that people don't have to explicitly create a channel to see notifications
455                 createChannel(
456                   context,
457                   experienceKey,
458                   NotificationConstants.NOTIFICATION_DEFAULT_CHANNEL_ID,
459                   context.getString(R.string.default_notification_channel_group),
460                   HashMap<Any?, Any?>()
461                 )
462               }
463 
464               if (data.containsKey("title")) {
465                 val title = data["title"] as String
466                 builder.setContentTitle(title)
467                 builder.setTicker(title)
468               }
469 
470               if (data.containsKey("body")) {
471                 val body = data["body"] as String
472                 builder.setContentText(body)
473                 builder.setStyle(NotificationCompat.BigTextStyle().bigText(body))
474               }
475 
476               if (data.containsKey("count")) {
477                 builder.setNumber((data["count"] as Double).toInt())
478               }
479 
480               if (data.containsKey("sticky")) {
481                 builder.setOngoing((data["sticky"] as Boolean))
482               }
483 
484               val intent = if (data.containsKey("link")) {
485                 Intent(Intent.ACTION_VIEW, Uri.parse(data["link"] as String))
486               } else {
487                 val activityClass = KernelConstants.MAIN_ACTIVITY_CLASS
488                 Intent(context, activityClass).apply {
489                   putExtra(KernelConstants.NOTIFICATION_MANIFEST_URL_KEY, exponentDBObject.manifestUrl)
490                 }
491               }
492 
493               val body: String = try {
494                 if (data.containsKey("data")) getJSONString(data["data"]!!) else ""
495               } catch (e: JSONException) {
496                 listener.onFailure(Exception("Couldn't deserialize JSON for experience scope key $experienceScopeKey"))
497                 return@Runnable
498               }
499 
500               val notificationEvent = ReceivedNotificationEvent(experienceScopeKey, body, id, isMultiple = false, isRemote = false)
501 
502               intent.putExtra(KernelConstants.NOTIFICATION_KEY, body) // deprecated
503               intent.putExtra(KernelConstants.NOTIFICATION_OBJECT_KEY, notificationEvent.toJSONObject(null).toString())
504 
505               val contentIntent = PendingIntent.getActivity(context, id, intent, PendingIntent.FLAG_UPDATE_CURRENT)
506               builder.setContentIntent(contentIntent)
507 
508               if (data.containsKey("categoryId")) {
509                 val manifestUrl = exponentDBObject.manifestUrl
510 
511                 NotificationActionCenter.setCategory(
512                   data["categoryId"] as String,
513                   builder,
514                   context,
515                   object : IntentProvider {
516                     override fun provide(): Intent {
517                       return Intent(context, KernelConstants.MAIN_ACTIVITY_CLASS).apply {
518                         putExtra(KernelConstants.NOTIFICATION_MANIFEST_URL_KEY, manifestUrl)
519 
520                         val notificationEventInner = ReceivedNotificationEvent(experienceScopeKey, body, id, isMultiple = false, isRemote = false)
521                         putExtra(KernelConstants.NOTIFICATION_KEY, body) // deprecated
522                         putExtra(KernelConstants.NOTIFICATION_OBJECT_KEY, notificationEventInner.toJSONObject(null).toString())
523                       }
524                     }
525                   }
526                 )
527               }
528 
529               builder.color = getColor(
530                 if (data.containsKey("color")) data["color"] as String? else null,
531                 manifest,
532                 exponentManifest
533               )
534 
535               loadIcon(
536                 if (data.containsKey("icon")) data["icon"] as String? else null,
537                 manifest,
538                 exponentManifest,
539                 object : BitmapListener {
540                   override fun onLoadBitmap(bitmap: Bitmap?) {
541                     if (data.containsKey("icon")) {
542                       builder.setLargeIcon(bitmap)
543                     }
544                     manager.notify(experienceKey, id, builder.build())
545                     EventBus.getDefault().post(notificationEvent)
546                     listener.onSuccess(id)
547                   }
548                 }
549               )
550             }
551           ).start()
552         }
553 
554         override fun onFailure() {
555           listener.onFailure(Exception("No experience found or invalid manifest for scope key $experienceScopeKey"))
556         }
557       }
558     )
559   }
560 
561   @JvmStatic fun scheduleLocalNotification(
562     context: Context?,
563     id: Int,
564     data: HashMap<String?, Any?>,
565     options: HashMap<*, *>,
566     experienceKey: ExperienceKey,
567     listener: Listener
568   ) {
569     val details = hashMapOf(
570       "data" to data,
571       NotificationConstants.NOTIFICATION_EXPERIENCE_ID_KEY to experienceKey.scopeKey,
572       NotificationConstants.NOTIFICATION_EXPERIENCE_SCOPE_KEY_KEY to experienceKey.scopeKey
573     )
574 
575     var time: Long = 0
576 
577     if (options.containsKey("time")) {
578       try {
579         when (val suppliedTime = options["time"]) {
580           is Number -> time = suppliedTime.toLong() - System.currentTimeMillis()
581           is String -> { // TODO: DELETE WHEN SDK 32 IS DEPRECATED
582             val format: DateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
583             format.timeZone = TimeZone.getTimeZone("UTC")
584             time = format.parse(suppliedTime as String?).time - System.currentTimeMillis()
585           }
586           else -> throw InvalidArgumentException("Invalid time provided: $suppliedTime")
587         }
588       } catch (e: Exception) {
589         listener.onFailure(e)
590         return
591       }
592     }
593 
594     time += SystemClock.elapsedRealtime()
595 
596     val manager = ExponentNotificationManager(context!!)
597 
598     val interval = when {
599       options.containsKey("repeat") -> {
600         when (options["repeat"] as String?) {
601           "minute" -> DateUtils.MINUTE_IN_MILLIS
602           "hour" -> DateUtils.HOUR_IN_MILLIS
603           "day" -> DateUtils.DAY_IN_MILLIS
604           "week" -> DateUtils.WEEK_IN_MILLIS
605           "month" -> DateUtils.DAY_IN_MILLIS * 30
606           "year" -> DateUtils.DAY_IN_MILLIS * 365
607           else -> {
608             listener.onFailure(Exception("Invalid repeat interval specified"))
609             return
610           }
611         }
612       }
613       options.containsKey("intervalMs") -> options["intervalMs"] as Long?
614       else -> null
615     }
616 
617     try {
618       manager.schedule(experienceKey, id, details, time, interval)
619       listener.onSuccess(id)
620     } catch (e: Exception) {
621       listener.onFailure(e)
622     }
623   }
624 
625   interface Listener {
626     fun onSuccess(id: Int)
627     fun onFailure(e: Exception)
628   }
629 
630   interface TokenListener {
631     fun onSuccess(token: String)
632     fun onFailure(e: Exception)
633   }
634 }
635