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