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