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