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