package com.mattermost.helpers; import android.annotation.SuppressLint; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.PorterDuff; import android.graphics.PorterDuffXfermode; import android.graphics.Rect; import android.graphics.RectF; import android.os.Build; import android.os.Bundle; import android.text.TextUtils; import android.util.Base64; import androidx.annotation.NonNull; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import androidx.core.app.Person; import androidx.core.app.RemoteInput; import androidx.core.graphics.drawable.IconCompat; import com.tokilabs.mattermost.*; import com.mattermost.rnutils.helpers.NotificationHelper; import com.nozbe.watermelondb.WMDatabase; import com.mattermost.turbolog.TurboLog; import java.io.IOException; import java.security.KeyFactory; import java.security.PublicKey; import java.security.spec.X509EncodedKeySpec; import java.util.Date; import java.util.Objects; import io.jsonwebtoken.IncorrectClaimException; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.MissingClaimException; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import static com.mattermost.helpers.database_extension.GeneralKt.getDatabaseForServer; import static com.mattermost.helpers.database_extension.GeneralKt.getDeviceToken; import static com.mattermost.helpers.database_extension.SystemKt.queryConfigServerVersion; import static com.mattermost.helpers.database_extension.SystemKt.queryConfigSigningKey; import static com.mattermost.helpers.database_extension.UserKt.getLastPictureUpdate; public class CustomPushNotificationHelper { public static final String CHANNEL_HIGH_IMPORTANCE_ID = "channel_01"; public static final String CHANNEL_MIN_IMPORTANCE_ID = "channel_02"; public static final String KEY_TEXT_REPLY = "CAN_REPLY"; public static final String NOTIFICATION_ID = "notificationId"; public static final String NOTIFICATION = "notification"; public static final String PUSH_TYPE_MESSAGE = "message"; public static final String PUSH_TYPE_CLEAR = "clear"; public static final String PUSH_TYPE_SESSION = "session"; public static final String CATEGORY_CAN_REPLY = "CAN_REPLY"; private static NotificationChannel mHighImportanceChannel; private static NotificationChannel mMinImportanceChannel; private static final OkHttpClient client = new OkHttpClient(); private static final BitmapCache bitmapCache = new BitmapCache(); private static void addMessagingStyleMessages(Context context, NotificationCompat.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) { String message = bundle.getString("message", bundle.getString("body")); String senderId = bundle.getString("sender_id"); String serverUrl = bundle.getString("server_url"); String type = bundle.getString("type"); String urlOverride = bundle.getString("override_icon_url"); if (senderId == null) { senderId = "sender_id"; } String senderName = getSenderName(bundle); if (conversationTitle == null || !android.text.TextUtils.isEmpty(senderName.trim())) { message = removeSenderNameFromMessage(message, senderName); } long timestamp = new Date().getTime(); Person.Builder sender = new Person.Builder() .setKey(senderId) .setName(senderName); if (serverUrl != null && type != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) { try { Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride); if (avatar != null) { sender.setIcon(IconCompat.createWithBitmap(avatar)); } } catch (IOException e) { e.printStackTrace(); } } messagingStyle.addMessage(message, timestamp, sender.build()); } private static void addNotificationExtras(NotificationCompat.Builder notification, Bundle bundle) { Bundle userInfoBundle = bundle.getBundle("userInfo"); if (userInfoBundle == null) { userInfoBundle = new Bundle(); } String channelId = bundle.getString("channel_id"); if (channelId != null) { userInfoBundle.putString("channel_id", channelId); } String postId = bundle.getString("post_id"); if (postId != null) { userInfoBundle.putString("post_id", postId); } String rootId = bundle.getString("root_id"); if (rootId != null) { userInfoBundle.putString("root_id", rootId); } String crtEnabled = bundle.getString("is_crt_enabled"); if (crtEnabled != null) { userInfoBundle.putString("is_crt_enabled", crtEnabled); } String serverUrl = bundle.getString("server_url"); if (serverUrl != null) { userInfoBundle.putString("server_url", serverUrl); } notification.addExtras(userInfoBundle); } @SuppressLint("UnspecifiedImmutableFlag") private static void addNotificationReplyAction(Context context, NotificationCompat.Builder notification, Bundle bundle, int notificationId) { String postId = bundle.getString("post_id"); String serverUrl = bundle.getString("server_url"); boolean canReply = bundle.containsKey("category") && Objects.equals(bundle.getString("category"), CATEGORY_CAN_REPLY); if (android.text.TextUtils.isEmpty(postId) || serverUrl == null || !canReply) { return; } Intent replyIntent = new Intent(context, NotificationReplyBroadcastReceiver.class); replyIntent.setAction(KEY_TEXT_REPLY); replyIntent.putExtra(NOTIFICATION_ID, notificationId); replyIntent.putExtra(NOTIFICATION, bundle); PendingIntent replyPendingIntent; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { replyPendingIntent = PendingIntent.getBroadcast( context, notificationId, replyIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE); } else { replyPendingIntent = PendingIntent.getBroadcast( context, notificationId, replyIntent, PendingIntent.FLAG_UPDATE_CURRENT); } RemoteInput remoteInput = new RemoteInput.Builder(KEY_TEXT_REPLY) .setLabel("Reply") .build(); int icon = R.drawable.ic_notif_action_reply; CharSequence title = "Reply"; NotificationCompat.Action replyAction = new NotificationCompat.Action.Builder(icon, title, replyPendingIntent) .addRemoteInput(remoteInput) .setAllowGeneratedReplies(true) .build(); notification .setShowWhen(true) .addAction(replyAction); } public static NotificationCompat.Builder createNotificationBuilder(Context context, PendingIntent intent, Bundle bundle, boolean createSummary) { final NotificationCompat.Builder notification = new NotificationCompat.Builder(context, CHANNEL_HIGH_IMPORTANCE_ID); String channelId = bundle.getString("channel_id"); String postId = bundle.getString("post_id"); String rootId = bundle.getString("root_id"); int notificationId = postId != null ? postId.hashCode() : NotificationHelper.INSTANCE.MESSAGE_NOTIFICATION_ID; boolean is_crt_enabled = bundle.containsKey("is_crt_enabled") && Objects.equals(bundle.getString("is_crt_enabled"), "true"); String groupId = is_crt_enabled && !android.text.TextUtils.isEmpty(rootId) ? rootId : channelId; addNotificationExtras(notification, bundle); setNotificationIcons(context, notification, bundle); setNotificationMessagingStyle(context, notification, bundle); setNotificationGroup(notification, groupId, createSummary); setNotificationBadgeType(notification); setNotificationChannel(context, notification); setNotificationDeleteIntent(context, notification, bundle, notificationId); addNotificationReplyAction(context, notification, bundle, notificationId); notification .setContentIntent(intent) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setPriority(Notification.PRIORITY_HIGH) .setCategory(Notification.CATEGORY_MESSAGE) .setAutoCancel(true); return notification; } public static void createNotificationChannels(Context context) { // Notification channels are not supported in Android Nougat and below if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return; } final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); if (mHighImportanceChannel == null) { mHighImportanceChannel = new NotificationChannel(CHANNEL_HIGH_IMPORTANCE_ID, "High Importance", NotificationManager.IMPORTANCE_HIGH); mHighImportanceChannel.setShowBadge(true); notificationManager.createNotificationChannel(mHighImportanceChannel); } if (mMinImportanceChannel == null) { mMinImportanceChannel = new NotificationChannel(CHANNEL_MIN_IMPORTANCE_ID, "Min Importance", NotificationManager.IMPORTANCE_MIN); mMinImportanceChannel.setShowBadge(true); notificationManager.createNotificationChannel(mMinImportanceChannel); } } public static boolean verifySignature(final Context context, String signature, String serverUrl, String ackId) { if (signature == null) { // Backward compatibility with old push proxies TurboLog.Companion.i("Mattermost Notifications Signature verification", "No signature in the notification"); return true; } if (serverUrl == null) { TurboLog.Companion.i("Mattermost Notifications Signature verification", "No server_url for server_id"); return false; } DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance(); if (dbHelper == null) { TurboLog.Companion.i("Mattermost Notifications Signature verification", "Cannot access the database"); return false; } WMDatabase db = getDatabaseForServer(dbHelper, context, serverUrl); if (db == null) { TurboLog.Companion.i("Mattermost Notifications Signature verification", "Cannot access the server database"); return false; } if (signature.equals("NO_SIGNATURE")) { String version = queryConfigServerVersion(db); if (version == null) { TurboLog.Companion.i("Mattermost Notifications Signature verification", "No server version"); return false; } if (!version.matches("[0-9]+(\\.[0-9]+)*")) { TurboLog.Companion.i("Mattermost Notifications Signature verification", "Invalid server version"); return false; } String[] parts = version.split("\\."); int major = parts.length > 0 ? Integer.parseInt(parts[0]) : 0; int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0; int patch = parts.length > 2 ? Integer.parseInt(parts[2]) : 0; int[][] targets = {{9,8,0},{9,7,3},{9,6,3},{9,5,5},{8,1,14}}; boolean rejected = false; for (int i = 0; i < targets.length; i++) { boolean first = i == 0; int[] targetVersion = targets[i]; int majorTarget = targetVersion[0]; int minorTarget = targetVersion[1]; int patchTarget = targetVersion[2]; if (major > majorTarget) { // Only reject if we are considering the first (highest) version. // Any version in between should be acceptable. rejected = first; break; } if (major < majorTarget) { // Continue to see if it complies with a smaller target continue; } // Same major if (minor > minorTarget) { // Only reject if we are considering the first (highest) version. // Any version in between should be acceptable. rejected = first; break; } if (minor < minorTarget) { // Continue to see if it complies with a smaller target continue; } // Same major and same minor if (patch >= patchTarget) { rejected = true; break; } // Patch is lower than target return true; } if (rejected) { TurboLog.Companion.i("Mattermost Notifications Signature verification", "Server version should send signature"); return false; } // Version number is below any of the targets, so it should not send the signature return true; } String signingKey = queryConfigSigningKey(db); if (signingKey == null) { TurboLog.Companion.i("Mattermost Notifications Signature verification", "No signing key"); return false; } try { byte[] encoded = Base64.decode(signingKey, 0); KeyFactory kf = KeyFactory.getInstance("EC"); PublicKey pubKey = (PublicKey) kf.generatePublic(new X509EncodedKeySpec(encoded)); String storedDeviceToken = getDeviceToken(dbHelper); if (storedDeviceToken == null) { TurboLog.Companion.i("Mattermost Notifications Signature verification", "No device token stored"); return false; } String[] tokenParts = storedDeviceToken.split(":", 2); if (tokenParts.length != 2) { TurboLog.Companion.i("Mattermost Notifications Signature verification", "Wrong stored device token format"); return false; } String deviceToken = tokenParts[1].substring(0, tokenParts[1].length() -1 ); if (deviceToken.isEmpty()) { TurboLog.Companion.i("Mattermost Notifications Signature verification", "Empty stored device token"); return false; } Jwts.parser() .require("ack_id", ackId) .require("device_id", deviceToken) .verifyWith((PublicKey) pubKey) .build() .parseSignedClaims(signature); } catch (MissingClaimException e) { TurboLog.Companion.i("Mattermost Notifications Signature verification", String.format("Missing claim: %s", e.getMessage())); e.printStackTrace(); return false; } catch (IncorrectClaimException e) { TurboLog.Companion.i("Mattermost Notifications Signature verification", String.format("Incorrect claim: %s", e.getMessage())); e.printStackTrace(); return false; } catch (JwtException e) { TurboLog.Companion.i("Mattermost Notifications Signature verification", String.format("Cannot verify JWT: %s", e.getMessage())); e.printStackTrace(); return false; } catch (Exception e) { TurboLog.Companion.i("Mattermost Notifications Signature verification", String.format("Exception while parsing JWT: %s", e.getMessage())); e.printStackTrace(); return false; } return true; } private static Bitmap getCircleBitmap(Bitmap bitmap) { final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); final Canvas canvas = new Canvas(output); final int color = Color.RED; final Paint paint = new Paint(); final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); final RectF rectF = new RectF(rect); paint.setAntiAlias(true); canvas.drawARGB(0, 0, 0, 0); paint.setColor(color); canvas.drawOval(rectF, paint); paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); canvas.drawBitmap(bitmap, rect, rect, paint); bitmap.recycle(); return output; } private static String getConversationTitle(Bundle bundle) { String title = bundle.getString("channel_name"); if (android.text.TextUtils.isEmpty(title)) { title = bundle.getString("sender_name"); } if (android.text.TextUtils.isEmpty(title)) { title = bundle.getString("title", ""); } return title; } private static NotificationCompat.MessagingStyle getMessagingStyle(Context context, Bundle bundle) { NotificationCompat.MessagingStyle messagingStyle; final String senderId = "me"; final String serverUrl = bundle.getString("server_url"); final String type = bundle.getString("type"); String urlOverride = bundle.getString("override_icon_url"); Person.Builder sender = new Person.Builder() .setKey(senderId) .setName("Me"); if (serverUrl != null && type != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) { try { Bitmap avatar = userAvatar(context, serverUrl, "me", urlOverride); if (avatar != null) { sender.setIcon(IconCompat.createWithBitmap(avatar)); } } catch (IOException e) { e.printStackTrace(); } } messagingStyle = new NotificationCompat.MessagingStyle(sender.build()); String conversationTitle = getConversationTitle(bundle); setMessagingStyleConversationTitle(messagingStyle, conversationTitle, bundle); addMessagingStyleMessages(context, messagingStyle, conversationTitle, bundle); return messagingStyle; } private static String getSenderName(Bundle bundle) { String senderName = bundle.getString("sender_name"); if (senderName != null) { return senderName; } String channelName = bundle.getString("channel_name"); if (channelName != null && channelName.startsWith("@")) { return channelName; } String message = bundle.getString("message"); if (message != null) { String name = message.split(":")[0]; if (!name.equals(message)) { return name; } } return getConversationTitle(bundle); } private static String removeSenderNameFromMessage(String message, String senderName) { int index = message.indexOf(senderName); if (index == 0) { message = message.substring(senderName.length()); } return message.replaceFirst(": ", "").trim(); } private static void setMessagingStyleConversationTitle(NotificationCompat.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) { String channelName = getConversationTitle(bundle); String senderName = bundle.getString("sender_name"); if (TextUtils.isEmpty(senderName)) { senderName = getSenderName(bundle); } if (conversationTitle != null && !channelName.equals(senderName)) { messagingStyle.setConversationTitle(conversationTitle); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { messagingStyle.setGroupConversation(true); } } } private static void setNotificationBadgeType(NotificationCompat.Builder notification) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { notification.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE); } } private static void setNotificationChannel(Context context, NotificationCompat.Builder notification) { // If Android Oreo or above we need to register a channel if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return; } if (mHighImportanceChannel == null) { createNotificationChannels(context); } NotificationChannel notificationChannel = mHighImportanceChannel; notification.setChannelId(notificationChannel.getId()); } private static void setNotificationDeleteIntent(Context context, NotificationCompat.Builder notification, Bundle bundle, int notificationId) { // Let's add a delete intent when the notification is dismissed final String PUSH_NOTIFICATION_EXTRA_NAME = "pushNotification"; Intent delIntent = new Intent(context, NotificationDismissService.class); delIntent.putExtra(NOTIFICATION_ID, notificationId); delIntent.putExtra(PUSH_NOTIFICATION_EXTRA_NAME, bundle); @SuppressLint("UnspecifiedImmutableFlag") PendingIntent deleteIntent = PendingIntent.getService(context, (int) System.currentTimeMillis(), delIntent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE); notification.setDeleteIntent(deleteIntent); } private static void setNotificationMessagingStyle(Context context, NotificationCompat.Builder notification, Bundle bundle) { NotificationCompat.MessagingStyle messagingStyle = getMessagingStyle(context, bundle); notification.setStyle(messagingStyle); } private static void setNotificationGroup(NotificationCompat.Builder notification, String channelId, boolean setAsSummary) { notification.setGroup(channelId); if (setAsSummary) { // if this is the first notification for the channel then set as summary, otherwise skip notification.setGroupSummary(true); } } private static void setNotificationIcons(Context context, NotificationCompat.Builder notification, Bundle bundle) { String channelName = getConversationTitle(bundle); String senderName = bundle.getString("sender_name"); String serverUrl = bundle.getString("server_url"); String urlOverride = bundle.getString("override_icon_url"); notification.setSmallIcon(R.mipmap.ic_notification); if (serverUrl != null && channelName.equals(senderName)) { try { String senderId = bundle.getString("sender_id"); Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride); if (avatar != null) { notification.setLargeIcon(avatar); } } catch (IOException e) { e.printStackTrace(); } } } private static Bitmap userAvatar(final Context context, @NonNull final String serverUrl, final String userId, final String urlOverride) throws IOException { try { Response response; Double lastUpdateAt = 0.0; if (!TextUtils.isEmpty(urlOverride)) { Request request = new Request.Builder().url(urlOverride).build(); TurboLog.Companion.i("ReactNative", String.format("Fetch override profile image %s", urlOverride)); response = client.newCall(request).execute(); } else { DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance(); if (dbHelper != null) { WMDatabase db = getDatabaseForServer(dbHelper, context, serverUrl); if (db != null) { lastUpdateAt = getLastPictureUpdate(db, userId); if (lastUpdateAt == null) { lastUpdateAt = 0.0; } db.close(); } } Bitmap cached = bitmapCache.bitmap(userId, lastUpdateAt, serverUrl); if (cached != null) { Bitmap bitmap = cached.copy(cached.getConfig(), false); return getCircleBitmap(bitmap); } bitmapCache.removeBitmap(userId, serverUrl); String url = String.format("api/v4/users/%s/image", userId); TurboLog.Companion.i("ReactNative", String.format("Fetch profile image %s", url)); response = Network.getSync(serverUrl, url, null); } if (response.code() == 200) { assert response.body() != null; byte[] bytes = Objects.requireNonNull(response.body()).bytes(); Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); if (TextUtils.isEmpty(urlOverride) && !TextUtils.isEmpty(userId)) { bitmapCache.insertBitmap(bitmap.copy(bitmap.getConfig(), false), userId, lastUpdateAt, serverUrl); } return getCircleBitmap(bitmap); } return null; } catch (Exception e) { e.printStackTrace(); return null; } } }