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