1 package host.exp.exponent.notifications 2 3 import android.app.Notification 4 import android.app.PendingIntent 5 import android.content.Context 6 import android.content.Intent 7 import android.graphics.Bitmap 8 import android.media.RingtoneManager 9 import android.os.Build 10 import androidx.core.app.NotificationCompat 11 import androidx.core.app.NotificationManagerCompat 12 import de.greenrobot.event.EventBus 13 import expo.modules.manifests.core.Manifest 14 import host.exp.exponent.Constants 15 import host.exp.exponent.ExponentManifest 16 import host.exp.exponent.ExponentManifest.BitmapListener 17 import host.exp.exponent.analytics.EXL 18 import host.exp.exponent.di.NativeModuleDepsProvider 19 import host.exp.exponent.kernel.ExperienceKey 20 import host.exp.exponent.kernel.KernelConstants 21 import host.exp.exponent.storage.ExponentDB 22 import host.exp.exponent.storage.ExponentDB.ExperienceResultListener 23 import host.exp.exponent.storage.ExponentDBObject 24 import host.exp.exponent.storage.ExponentSharedPreferences 25 import host.exp.expoview.R 26 import org.json.JSONArray 27 import org.json.JSONException 28 import org.json.JSONObject 29 import java.util.* 30 import javax.inject.Inject 31 import kotlin.math.min 32 33 class PushNotificationHelper { 34 private enum class Mode { 35 DEFAULT, COLLAPSE 36 } 37 38 @Inject 39 lateinit var exponentManifest: ExponentManifest 40 41 @Inject 42 lateinit var exponentSharedPreferences: ExponentSharedPreferences 43 44 fun onMessageReceived( 45 context: Context, 46 experienceScopeKey: String, 47 channelId: String?, 48 message: String?, 49 body: String?, 50 title: String?, 51 categoryId: String? 52 ) { 53 ExponentDB.experienceScopeKeyToExperience( 54 experienceScopeKey, 55 object : ExperienceResultListener { 56 override fun onSuccess(exponentDBObject: ExponentDBObject) { 57 try { 58 sendNotification( 59 context, 60 message, 61 channelId, 62 exponentDBObject.manifestUrl, 63 exponentDBObject.manifest, 64 body, 65 title, 66 categoryId 67 ) 68 } catch (e: JSONException) { 69 EXL.e(TAG, "Couldn't deserialize JSON for experience scope key $experienceScopeKey") 70 } 71 } 72 73 override fun onFailure() { 74 EXL.e(TAG, "No experience found or invalid manifest for scope key $experienceScopeKey") 75 } 76 } 77 ) 78 } 79 80 @Throws(JSONException::class) 81 private fun sendNotification( 82 context: Context, 83 message: String?, 84 channelId: String?, 85 manifestUrl: String, 86 manifest: Manifest, 87 body: String?, 88 title: String?, 89 categoryId: String? 90 ) { 91 val experienceKey = ExperienceKey.fromManifest(manifest) 92 val name = manifest.getName() 93 if (name == null) { 94 EXL.e(TAG, "No name found for experience scope key " + experienceKey.scopeKey) 95 return 96 } 97 98 val manager = ExponentNotificationManager(context) 99 val notificationPreferences = manifest.getNotificationPreferences() 100 101 NotificationHelper.loadIcon( 102 null, 103 manifest, 104 exponentManifest, 105 object : BitmapListener { 106 override fun onLoadBitmap(bitmap: Bitmap?) { 107 var mode = Mode.DEFAULT 108 var collapsedTitle: String? = null 109 var unreadNotifications = JSONArray() 110 111 // Modes 112 if (notificationPreferences != null) { 113 val modeString = notificationPreferences.optString(ExponentManifest.MANIFEST_NOTIFICATION_ANDROID_MODE) 114 if (NotificationConstants.NOTIFICATION_COLLAPSE_MODE == modeString) { 115 mode = Mode.COLLAPSE 116 } 117 } 118 119 // Update metadata 120 val notificationId = if (mode == Mode.COLLAPSE) experienceKey.scopeKey.hashCode() else Random().nextInt() 121 addUnreadNotificationToMetadata(experienceKey, message, notificationId) 122 123 // Collapse mode fields 124 if (mode == Mode.COLLAPSE) { 125 unreadNotifications = getUnreadNotificationsFromMetadata(experienceKey) 126 val collapsedTitleRaw = if (notificationPreferences!!.has(ExponentManifest.MANIFEST_NOTIFICATION_ANDROID_COLLAPSED_TITLE)) { 127 notificationPreferences.optString(ExponentManifest.MANIFEST_NOTIFICATION_ANDROID_COLLAPSED_TITLE) 128 } else { 129 null 130 } 131 if (collapsedTitleRaw != null) { 132 collapsedTitle = collapsedTitleRaw.replace(NotificationConstants.NOTIFICATION_UNREAD_COUNT_KEY, "" + unreadNotifications.length()) 133 } 134 } 135 136 val scopedChannelId: String 137 var defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) 138 if (channelId != null) { 139 scopedChannelId = ExponentNotificationManager.getScopedChannelId(experienceKey, channelId) 140 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 141 // if we don't yet have a channel matching this ID, check shared preferences -- 142 // it's possible this device has just been upgraded to Android 8+ and the channel 143 // needs to be created in the system 144 if (manager.getNotificationChannel(experienceKey, channelId) == null) { 145 val storedChannelDetails = manager.readChannelSettings(experienceKey, channelId) 146 if (storedChannelDetails != null) { 147 NotificationHelper.createChannel(context, experienceKey, channelId, storedChannelDetails) 148 } 149 } 150 } else { 151 // on Android 7.1 and below, read channel settings for sound from shared preferences 152 // and apply this to the notification individually, since channels do not exist 153 val storedChannelDetails = manager.readChannelSettings(experienceKey, channelId) 154 if (storedChannelDetails != null) { 155 // Default to `sound: true` if nothing is stored for this channel 156 // to match old behavior of push notifications on Android 7.1 and below (always had sound) 157 if (!storedChannelDetails.optBoolean(NotificationConstants.NOTIFICATION_CHANNEL_SOUND, true)) { 158 defaultSoundUri = null 159 } 160 } 161 } 162 } else { 163 scopedChannelId = ExponentNotificationManager.getScopedChannelId( 164 experienceKey, 165 NotificationConstants.NOTIFICATION_DEFAULT_CHANNEL_ID 166 ) 167 NotificationHelper.createChannel( 168 context, 169 experienceKey, 170 NotificationConstants.NOTIFICATION_DEFAULT_CHANNEL_ID, 171 context.getString(R.string.default_notification_channel_group), 172 hashMapOf<Any, Any>() 173 ) 174 } 175 val color = NotificationHelper.getColor(null, manifest, exponentManifest) 176 177 // Create notification object 178 val isMultiple = mode == Mode.COLLAPSE && unreadNotifications.length() > 1 179 val notificationEvent = ReceivedNotificationEvent(experienceKey.scopeKey, body, notificationId, isMultiple, true) 180 181 // Create pending intent 182 val intent = Intent(context, KernelConstants.MAIN_ACTIVITY_CLASS).apply { 183 putExtra(KernelConstants.NOTIFICATION_MANIFEST_URL_KEY, manifestUrl) 184 putExtra(KernelConstants.NOTIFICATION_KEY, body) // deprecated 185 putExtra(KernelConstants.NOTIFICATION_OBJECT_KEY, notificationEvent.toJSONObject(null).toString()) 186 } 187 val pendingIntent = PendingIntent.getActivity( 188 context, 189 notificationId, 190 intent, 191 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT 192 ) 193 194 // Build notification 195 val notificationBuilder = if (isMultiple) { 196 val style = NotificationCompat.InboxStyle().setBigContentTitle(collapsedTitle) 197 198 for (i in 0 until min(unreadNotifications.length(), NotificationConstants.MAX_COLLAPSED_NOTIFICATIONS)) { 199 try { 200 val unreadNotification = unreadNotifications[i] as JSONObject 201 style.addLine(unreadNotification.getString(NotificationConstants.NOTIFICATION_MESSAGE_KEY)) 202 } catch (e: JSONException) { 203 e.printStackTrace() 204 } 205 } 206 207 if (unreadNotifications.length() > NotificationConstants.MAX_COLLAPSED_NOTIFICATIONS) { 208 style.addLine("and " + (unreadNotifications.length() - NotificationConstants.MAX_COLLAPSED_NOTIFICATIONS) + " more...") 209 } 210 211 NotificationCompat.Builder(context, scopedChannelId) 212 .setSmallIcon(if (Constants.isStandaloneApp()) R.drawable.shell_notification_icon else R.drawable.notification_icon) 213 .setContentTitle(collapsedTitle) 214 .setColor(color) 215 .setContentText(name) 216 .setAutoCancel(true) 217 .setSound(defaultSoundUri) 218 .setContentIntent(pendingIntent) 219 .setStyle(style) 220 } else { 221 val contentTitle: String = if (title == null) { 222 name 223 } else { 224 if (Constants.isStandaloneApp()) title else "$name - $title" 225 } 226 227 NotificationCompat.Builder(context, scopedChannelId) 228 .setSmallIcon(if (Constants.isStandaloneApp()) R.drawable.shell_notification_icon else R.drawable.notification_icon) 229 .setContentTitle(contentTitle) 230 .setColor(color) 231 .setContentText(message) 232 .setStyle(NotificationCompat.BigTextStyle().bigText(message)) 233 .setAutoCancel(true) 234 .setSound(defaultSoundUri) 235 .setContentIntent(pendingIntent) 236 } 237 238 Thread { // Add actions 239 if (categoryId != null) { 240 NotificationActionCenter.setCategory( 241 categoryId, 242 notificationBuilder, 243 context, 244 object : IntentProvider { 245 override fun provide(): Intent { 246 return Intent(context, KernelConstants.MAIN_ACTIVITY_CLASS).apply { 247 putExtra(KernelConstants.NOTIFICATION_MANIFEST_URL_KEY, manifestUrl) 248 putExtra(KernelConstants.NOTIFICATION_KEY, body) // deprecated 249 putExtra(KernelConstants.NOTIFICATION_OBJECT_KEY, notificationEvent.toJSONObject(null).toString()) 250 } 251 } 252 } 253 ) 254 } 255 256 // Add icon 257 val notification: Notification = if (manifestUrl != Constants.INITIAL_URL) { 258 notificationBuilder.setLargeIcon(bitmap).build() 259 } else { 260 // TODO: don't actually need to load bitmap in this case 261 notificationBuilder.build() 262 } 263 264 // Display 265 manager.notify(experienceKey, notificationId, notification) 266 267 // Send event. Will be consumed if experience is already open. 268 EventBus.getDefault().post(notificationEvent) 269 }.start() 270 } 271 } 272 ) 273 } 274 275 private fun addUnreadNotificationToMetadata( 276 experienceKey: ExperienceKey, 277 message: String?, 278 notificationId: Int 279 ) { 280 try { 281 val notification = JSONObject().apply { 282 put(NotificationConstants.NOTIFICATION_MESSAGE_KEY, message) 283 put(NotificationConstants.NOTIFICATION_ID_KEY, notificationId) 284 } 285 286 val metadata = (exponentSharedPreferences.getExperienceMetadata(experienceKey) ?: JSONObject()).apply { 287 val unreadNotifications = (optJSONArray(ExponentSharedPreferences.EXPERIENCE_METADATA_UNREAD_REMOTE_NOTIFICATIONS) ?: JSONArray()).apply { 288 put(notification) 289 } 290 put(ExponentSharedPreferences.EXPERIENCE_METADATA_UNREAD_REMOTE_NOTIFICATIONS, unreadNotifications) 291 } 292 293 exponentSharedPreferences.updateExperienceMetadata(experienceKey, metadata) 294 } catch (e: JSONException) { 295 e.printStackTrace() 296 } 297 } 298 299 private fun getUnreadNotificationsFromMetadata(experienceKey: ExperienceKey): JSONArray { 300 val metadata = exponentSharedPreferences.getExperienceMetadata(experienceKey) ?: return JSONArray() 301 if (metadata.has(ExponentSharedPreferences.EXPERIENCE_METADATA_UNREAD_REMOTE_NOTIFICATIONS)) { 302 try { 303 return metadata.getJSONArray(ExponentSharedPreferences.EXPERIENCE_METADATA_UNREAD_REMOTE_NOTIFICATIONS) 304 } catch (e: JSONException) { 305 e.printStackTrace() 306 } 307 } 308 return JSONArray() 309 } 310 311 fun removeNotifications(context: Context, unreadNotifications: JSONArray?) { 312 if (unreadNotifications == null) { 313 return 314 } 315 316 val notificationManager = NotificationManagerCompat.from(context) 317 for (i in 0 until unreadNotifications.length()) { 318 try { 319 notificationManager.cancel( 320 (unreadNotifications[i] as JSONObject).getString( 321 NotificationConstants.NOTIFICATION_ID_KEY 322 ).toInt() 323 ) 324 } catch (e: JSONException) { 325 e.printStackTrace() 326 } 327 } 328 } 329 330 companion object { 331 private val TAG = PushNotificationHelper::class.java.simpleName 332 333 val instance by lazy { 334 PushNotificationHelper() 335 } 336 } 337 338 init { 339 NativeModuleDepsProvider.instance.inject(PushNotificationHelper::class.java, this) 340 } 341 } 342