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.jsonutils.getNullable
18 import expo.modules.manifests.core.Manifest
19 import host.exp.exponent.Constants
20 import host.exp.exponent.ExponentManifest
21 import host.exp.exponent.ExponentManifest.BitmapListener
22 import host.exp.exponent.analytics.EXL
23 import host.exp.exponent.fcm.FcmRegistrationIntentService
24 import host.exp.exponent.kernel.ExperienceKey
25 import host.exp.exponent.kernel.ExponentUrls
26 import host.exp.exponent.kernel.KernelConstants
27 import host.exp.exponent.network.ExpoHttpCallback
28 import host.exp.exponent.network.ExpoResponse
29 import host.exp.exponent.network.ExponentNetwork
30 import host.exp.exponent.storage.ExponentDB
31 import host.exp.exponent.storage.ExponentDB.ExperienceResultListener
32 import host.exp.exponent.storage.ExponentDBObject
33 import host.exp.exponent.storage.ExponentSharedPreferences
34 import host.exp.exponent.utils.AsyncCondition
35 import host.exp.exponent.utils.AsyncCondition.AsyncConditionListener
36 import host.exp.exponent.utils.ColorParser
37 import host.exp.exponent.utils.JSONUtils.getJSONString
38 import host.exp.expoview.R
39 import okhttp3.MediaType.Companion.toMediaTypeOrNull
40 import okhttp3.RequestBody.Companion.toRequestBody
41 import org.json.JSONException
42 import org.json.JSONObject
43 import java.io.IOException
44 import java.text.DateFormat
45 import java.text.SimpleDateFormat
46 import java.util.*
47 
48 object NotificationHelper {
49   private val TAG = NotificationHelper::class.java.simpleName
50 
getColornull51   fun getColor(
52     colorString: String?,
53     manifest: Manifest,
54     exponentManifest: ExponentManifest
55   ): Int {
56     val colorStringLocal = colorString ?: manifest.getNotificationPreferences()?.getNullable(ExponentManifest.MANIFEST_NOTIFICATION_COLOR_KEY)
57     return if (colorString != null && ColorParser.isValid(colorStringLocal)) {
58       Color.parseColor(colorString)
59     } else {
60       exponentManifest.getColorFromManifest(manifest)
61     }
62   }
63 
loadIconnull64   fun loadIcon(
65     url: String?,
66     manifest: Manifest,
67     exponentManifest: ExponentManifest,
68     bitmapListener: BitmapListener?
69   ) {
70     val notificationPreferences = manifest.getNotificationPreferences()
71     var iconUrl: String?
72     if (url == null) {
73       iconUrl = manifest.getIconUrl()
74       if (notificationPreferences != null) {
75         iconUrl = notificationPreferences.getNullable(ExponentManifest.MANIFEST_NOTIFICATION_ICON_URL_KEY)
76       }
77     } else {
78       iconUrl = url
79     }
80 
81     exponentManifest.loadIconBitmap(iconUrl, bitmapListener!!)
82   }
83 
getPushNotificationTokennull84   @JvmStatic fun getPushNotificationToken(
85     deviceId: String,
86     experienceId: String?,
87     projectId: String?,
88     exponentNetwork: ExponentNetwork,
89     exponentSharedPreferences: ExponentSharedPreferences,
90     listener: TokenListener
91   ) {
92     if (Constants.FCM_ENABLED) {
93       FcmRegistrationIntentService.getTokenAndRegister(exponentSharedPreferences.context)
94     }
95 
96     AsyncCondition.wait(
97       ExponentNotificationIntentService.DEVICE_PUSH_TOKEN_KEY,
98       object : AsyncConditionListener {
99         override fun isReady(): Boolean {
100           return (exponentSharedPreferences.getString(ExponentSharedPreferences.ExponentSharedPreferencesKey.FCM_TOKEN_KEY) != null || ExponentNotificationIntentService.hasTokenError)
101         }
102 
103         override fun execute() {
104           val sharedPreferencesToken = exponentSharedPreferences.getString(ExponentSharedPreferences.ExponentSharedPreferencesKey.FCM_TOKEN_KEY)
105           if (sharedPreferencesToken == null || sharedPreferencesToken.isEmpty()) {
106             var message = "No device token found."
107             if (!Constants.FCM_ENABLED) {
108               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"
109             }
110             listener.onFailure(Exception(message))
111             return
112           }
113 
114           val params = JSONObject().apply {
115             try {
116               put("deviceId", deviceId)
117               put("appId", exponentSharedPreferences.context.applicationContext.packageName)
118               put("deviceToken", sharedPreferencesToken)
119               put("type", "fcm")
120               put("development", false)
121 
122               when {
123                 projectId !== null -> {
124                   put("projectId", projectId)
125                 }
126                 experienceId !== null -> {
127                   put("experienceId", experienceId)
128                 }
129                 else -> {
130                   listener.onFailure(Exception("Must supply either experienceId or projectId"))
131                   return
132                 }
133               }
134             } catch (e: JSONException) {
135               listener.onFailure(Exception("Error constructing request"))
136               return@execute
137             }
138           }
139 
140           val body = params.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
141           val request = ExponentUrls.addExponentHeadersToUrl("https://exp.host/--/api/v2/push/getExpoPushToken")
142             .header("Content-Type", "application/json")
143             .post(body)
144             .build()
145           exponentNetwork.client.call(
146             request,
147             object : ExpoHttpCallback {
148               override fun onFailure(e: IOException) {
149                 listener.onFailure(e)
150               }
151 
152               @Throws(IOException::class)
153               override fun onResponse(response: ExpoResponse) {
154                 if (!response.isSuccessful) {
155                   listener.onFailure(Exception("Couldn't get android push token for device"))
156                   return
157                 }
158 
159                 try {
160                   val result = JSONObject(response.body().string())
161                   val data = result.getJSONObject("data")
162                   listener.onSuccess(data.getString("expoPushToken"))
163                 } catch (e: Exception) {
164                   listener.onFailure(e)
165                 }
166               }
167             }
168           )
169         }
170       }
171     )
172   }
173 
createChannelnull174   @JvmStatic fun createChannel(
175     context: Context,
176     experienceKey: ExperienceKey,
177     channelId: String,
178     channelName: String?,
179     details: HashMap<*, *>
180   ) {
181     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
182       val description: String? = if (details.containsKey(NotificationConstants.NOTIFICATION_CHANNEL_DESCRIPTION)) {
183         details[NotificationConstants.NOTIFICATION_CHANNEL_DESCRIPTION] as String?
184       } else {
185         null
186       }
187       val importance: String? = if (details.containsKey(NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY)) {
188         details[NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY] as String?
189       } else {
190         null
191       }
192       val sound: Boolean? = if (details.containsKey(NotificationConstants.NOTIFICATION_CHANNEL_SOUND)) {
193         details[NotificationConstants.NOTIFICATION_CHANNEL_SOUND] as Boolean?
194       } else {
195         null
196       }
197       val vibrate: Any? = if (details.containsKey(NotificationConstants.NOTIFICATION_CHANNEL_VIBRATE)) {
198         details[NotificationConstants.NOTIFICATION_CHANNEL_VIBRATE]
199       } else {
200         null
201       }
202       val badge: Boolean? = if (details.containsKey(NotificationConstants.NOTIFICATION_CHANNEL_BADGE)) {
203         details[NotificationConstants.NOTIFICATION_CHANNEL_BADGE] as Boolean?
204       } else {
205         null
206       }
207 
208       createChannel(
209         context,
210         experienceKey,
211         channelId,
212         channelName,
213         description,
214         importance,
215         sound,
216         vibrate,
217         badge
218       )
219     } else {
220       // since channels do not exist on Android 7.1 and below, we'll save the settings in shared
221       // preferences and apply them to individual notifications that have this channelId from now on
222       // this is essentially a "polyfill" of notification channels for Android 7.1 and below
223       // and means that devs don't have to worry about supporting both versions of Android at once
224       ExponentNotificationManager(context).saveChannelSettings(experienceKey, channelId, details)
225     }
226   }
227 
createChannelnull228   @JvmStatic fun createChannel(
229     context: Context,
230     experienceKey: ExperienceKey,
231     channelId: String,
232     details: JSONObject
233   ) {
234     try {
235       // we want to throw immediately if there is no channel name
236       val channelName = details.getString(NotificationConstants.NOTIFICATION_CHANNEL_NAME)
237       val description: String? = details.getNullable(NotificationConstants.NOTIFICATION_CHANNEL_DESCRIPTION)
238       val priority: String? = details.getNullable(NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY)
239       val sound: Boolean? = details.getNullable(NotificationConstants.NOTIFICATION_CHANNEL_SOUND)
240       val badge: Boolean? = if (!details.isNull(NotificationConstants.NOTIFICATION_CHANNEL_BADGE)) {
241         details.optBoolean(NotificationConstants.NOTIFICATION_CHANNEL_BADGE, true)
242       } else {
243         null
244       }
245 
246       val vibrateJsonArray = details.optJSONArray(NotificationConstants.NOTIFICATION_CHANNEL_VIBRATE)
247       val vibrate = if (vibrateJsonArray != null) {
248         val vibrateArrayList = ArrayList<Double>()
249         for (i in 0 until vibrateJsonArray.length()) {
250           vibrateArrayList.add(vibrateJsonArray.getDouble(i))
251         }
252         vibrateArrayList
253       } else {
254         details.optBoolean(NotificationConstants.NOTIFICATION_CHANNEL_VIBRATE, false)
255       }
256 
257       createChannel(
258         context,
259         experienceKey,
260         channelId,
261         channelName,
262         description,
263         priority,
264         sound,
265         vibrate,
266         badge
267       )
268     } catch (e: Exception) {
269       EXL.e(TAG, "Could not create channel from stored JSON Object: " + e.message)
270     }
271   }
272 
createChannelnull273   private fun createChannel(
274     context: Context,
275     experienceKey: ExperienceKey,
276     channelId: String,
277     channelName: String?,
278     description: String?,
279     importanceString: String?,
280     sound: Boolean?,
281     vibrate: Any?,
282     badge: Boolean?
283   ) {
284     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
285       val importance = when (importanceString) {
286         NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY_MAX -> NotificationManager.IMPORTANCE_MAX
287         NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY_HIGH -> NotificationManager.IMPORTANCE_HIGH
288         NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY_LOW -> NotificationManager.IMPORTANCE_LOW
289         NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY_MIN -> NotificationManager.IMPORTANCE_MIN
290         else -> NotificationManager.IMPORTANCE_DEFAULT
291       }
292 
293       val channel = NotificationChannel(
294         ExponentNotificationManager.getScopedChannelId(experienceKey, channelId),
295         channelName,
296         importance
297       )
298 
299       // sound is now on by default for channels
300       if (sound == null || !sound) {
301         channel.setSound(null, null)
302       }
303 
304       if (vibrate != null) {
305         if (vibrate is ArrayList<*>) {
306           val pattern = LongArray(vibrate.size)
307           for (i in vibrate.indices) {
308             pattern[i] = (vibrate[i] as Double).toInt().toLong()
309           }
310           channel.vibrationPattern = pattern
311         } else if (vibrate is Boolean && vibrate) {
312           channel.vibrationPattern = longArrayOf(0, 500)
313         }
314       }
315 
316       if (description != null) {
317         channel.description = description
318       }
319 
320       if (badge != null) {
321         channel.setShowBadge(badge)
322       }
323 
324       ExponentNotificationManager(context).createNotificationChannel(experienceKey, channel)
325     }
326   }
327 
maybeCreateLegacyStoredChannelnull328   @JvmStatic fun maybeCreateLegacyStoredChannel(
329     context: Context,
330     experienceKey: ExperienceKey,
331     channelId: String,
332     details: HashMap<*, *>
333   ) {
334     // no version check here because if we're on Android 7.1 or below, we still want to save
335     // the channel in shared preferences
336     val existingChannel = ExponentNotificationManager(context).getNotificationChannel(experienceKey, channelId)
337     if (existingChannel == null && details.containsKey(NotificationConstants.NOTIFICATION_CHANNEL_NAME)) {
338       createChannel(
339         context,
340         experienceKey,
341         channelId,
342         details[NotificationConstants.NOTIFICATION_CHANNEL_NAME] as String?,
343         details
344       )
345     }
346   }
347 
deleteChannelnull348   @JvmStatic fun deleteChannel(context: Context?, experienceKey: ExperienceKey?, channelId: String?) {
349     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
350       ExponentNotificationManager(context!!).deleteNotificationChannel(experienceKey!!, channelId!!)
351     } else {
352       // deleting a channel on O+ still retains all its settings, so doing nothing here emulates that
353     }
354   }
355 
showNotificationnull356   @JvmStatic fun showNotification(
357     context: Context,
358     id: Int,
359     details: HashMap<*, *>,
360     exponentManifest: ExponentManifest,
361     listener: Listener
362   ) {
363     val manager = ExponentNotificationManager(context)
364     val notificationScopeKey = details[NotificationConstants.NOTIFICATION_EXPERIENCE_SCOPE_KEY_KEY] as String?
365     val experienceScopeKey = notificationScopeKey ?: (details[NotificationConstants.NOTIFICATION_EXPERIENCE_ID_KEY] as String?)!!
366 
367     ExponentDB.experienceScopeKeyToExperience(
368       experienceScopeKey,
369       object : ExperienceResultListener {
370         override fun onSuccess(exponentDBObject: ExponentDBObject) {
371           Thread(
372             Runnable {
373               val manifest = exponentDBObject.manifest
374               val experienceKey = try {
375                 ExperienceKey.fromManifest(manifest)
376               } catch (e: JSONException) {
377                 listener.onFailure(Exception("Couldn't deserialize JSON for experience scope key $experienceScopeKey"))
378                 return@Runnable
379               }
380 
381               val builder = NotificationCompat.Builder(
382                 context,
383                 ExponentNotificationManager.getScopedChannelId(
384                   experienceKey,
385                   NotificationConstants.NOTIFICATION_DEFAULT_CHANNEL_ID
386                 )
387               ).apply {
388                 setSmallIcon(if (Constants.isStandaloneApp()) R.drawable.shell_notification_icon else R.drawable.notification_icon)
389                 setAutoCancel(true)
390               }
391 
392               val data = details["data"] as HashMap<*, *>
393               if (data.containsKey("channelId")) {
394                 val channelId = data["channelId"] as String
395                 builder.setChannelId(
396                   ExponentNotificationManager.getScopedChannelId(
397                     experienceKey,
398                     channelId
399                   )
400                 )
401 
402                 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
403                   // if we don't yet have a channel matching this ID, check shared preferences --
404                   // it's possible this device has just been upgraded to Android 8+ and the channel
405                   // needs to be created in the system
406                   if (manager.getNotificationChannel(experienceKey, channelId) == null) {
407                     val storedChannelDetails = manager.readChannelSettings(experienceKey, channelId)
408                     if (storedChannelDetails != null) {
409                       createChannel(context, experienceKey, channelId, storedChannelDetails)
410                     }
411                   }
412                 } else {
413                   // on Android 7.1 and below, read channel settings for sound, priority, and vibrate from shared preferences
414                   // and apply these settings to the notification individually, since channels do not exist
415                   val storedChannelDetails = manager.readChannelSettings(experienceKey, channelId)
416                   if (storedChannelDetails != null) {
417                     if (storedChannelDetails.optBoolean(NotificationConstants.NOTIFICATION_CHANNEL_SOUND, false)
418                     ) {
419                       builder.setDefaults(NotificationCompat.DEFAULT_SOUND)
420                     }
421 
422                     builder.priority = when (storedChannelDetails.getNullable<String>(NotificationConstants.NOTIFICATION_CHANNEL_PRIORITY)) {
423                       "max" -> NotificationCompat.PRIORITY_MAX
424                       "high" -> NotificationCompat.PRIORITY_HIGH
425                       "low" -> NotificationCompat.PRIORITY_LOW
426                       "min" -> NotificationCompat.PRIORITY_MIN
427                       else -> NotificationCompat.PRIORITY_DEFAULT
428                     }
429 
430                     try {
431                       val vibrateJsonArray = storedChannelDetails.optJSONArray(NotificationConstants.NOTIFICATION_CHANNEL_VIBRATE)
432                       if (vibrateJsonArray != null) {
433                         val pattern = LongArray(vibrateJsonArray.length())
434                         for (i in 0 until vibrateJsonArray.length()) {
435                           pattern[i] = vibrateJsonArray.getDouble(i).toInt().toLong()
436                         }
437                         builder.setVibrate(pattern)
438                       } else if (storedChannelDetails.optBoolean(NotificationConstants.NOTIFICATION_CHANNEL_VIBRATE, false)) {
439                         builder.setVibrate(longArrayOf(0, 500))
440                       }
441                     } catch (e: Exception) {
442                       EXL.e(
443                         TAG,
444                         "Failed to set vibrate settings on notification from stored channel: " + e.message
445                       )
446                     }
447                   } else {
448                     EXL.e(TAG, "No stored channel found for $experienceScopeKey: $channelId")
449                   }
450                 }
451               } else {
452                 // make a default channel so that people don't have to explicitly create a channel to see notifications
453                 createChannel(
454                   context,
455                   experienceKey,
456                   NotificationConstants.NOTIFICATION_DEFAULT_CHANNEL_ID,
457                   context.getString(R.string.default_notification_channel_group),
458                   HashMap<Any?, Any?>()
459                 )
460               }
461 
462               if (data.containsKey("title")) {
463                 val title = data["title"] as String
464                 builder.setContentTitle(title)
465                 builder.setTicker(title)
466               }
467 
468               if (data.containsKey("body")) {
469                 val body = data["body"] as String
470                 builder.setContentText(body)
471                 builder.setStyle(NotificationCompat.BigTextStyle().bigText(body))
472               }
473 
474               if (data.containsKey("count")) {
475                 builder.setNumber((data["count"] as Double).toInt())
476               }
477 
478               if (data.containsKey("sticky")) {
479                 builder.setOngoing((data["sticky"] as Boolean))
480               }
481 
482               val intent = if (data.containsKey("link")) {
483                 Intent(Intent.ACTION_VIEW, Uri.parse(data["link"] as String))
484               } else {
485                 val activityClass = KernelConstants.MAIN_ACTIVITY_CLASS
486                 Intent(context, activityClass).apply {
487                   putExtra(KernelConstants.NOTIFICATION_MANIFEST_URL_KEY, exponentDBObject.manifestUrl)
488                 }
489               }
490 
491               val body: String = try {
492                 if (data.containsKey("data")) getJSONString(data["data"]!!) else ""
493               } catch (e: JSONException) {
494                 listener.onFailure(Exception("Couldn't deserialize JSON for experience scope key $experienceScopeKey"))
495                 return@Runnable
496               }
497 
498               val notificationEvent = ReceivedNotificationEvent(experienceScopeKey, body, id, isMultiple = false, isRemote = false)
499 
500               intent.putExtra(KernelConstants.NOTIFICATION_KEY, body) // deprecated
501               intent.putExtra(KernelConstants.NOTIFICATION_OBJECT_KEY, notificationEvent.toJSONObject(null).toString())
502 
503               // We're defaulting to the behaviour prior API 31 (mutable) even though Android recommends immutability
504               val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
505               val contentIntent = PendingIntent.getActivity(context, id, intent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag)
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 
scheduleLocalNotificationnull561   @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 {
onSuccessnull626     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