diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/ForegroundService.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/ForegroundService.java deleted file mode 100644 index 80002f20f1..0000000000 --- a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/ForegroundService.java +++ /dev/null @@ -1,109 +0,0 @@ -package im.status.ethereum.pushnotifications; - -import android.content.Context; -import android.content.Intent; -import android.app.Service; -import android.content.pm.ServiceInfo; -import android.os.IBinder; -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.provider.Settings; - -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; - -import android.os.Build; -import im.status.ethereum.module.R; - -public class ForegroundService extends Service { - private static final String CHANNEL_ID = "status-service"; - - @Override - public IBinder onBind(Intent intent) { - return null; - } - - @Override - public int onStartCommand(Intent i, int flags, int startId) { - // NOTE: recent versions of Android require the service to display - // a sticky notification to inform the user that the service is running - Context context = getApplicationContext(); - // Create the NotificationChannel, but only on API 26+ because - // the NotificationChannel class is new and not in the support library - - Intent intent = null; - String notificationContentText = null; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationManager notificationManager = - context.getSystemService(NotificationManager.class); - NotificationChannel channel = new NotificationChannel(CHANNEL_ID, - context.getResources().getString(R.string.status_service), - NotificationManager.IMPORTANCE_HIGH); - channel.setShowBadge(false); - notificationManager.createNotificationChannel(channel); - - // Create intent that takes the user to the notification channel settings so they can hide it - - intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS); - intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName()); - intent.putExtra(Settings.EXTRA_CHANNEL_ID, CHANNEL_ID); - - notificationContentText = context.getResources().getString(R.string.tap_to_hide_notification); - - } else { - - // For older versions of android intent takes the user to the Status app - - Class intentClass; - String packageName = context.getPackageName(); - Intent launchIntent = context.getPackageManager().getLaunchIntentForPackage(packageName); - String className = launchIntent.getComponent().getClassName(); - try { - intentClass = Class.forName(className); - } catch (ClassNotFoundException e) { - e.printStackTrace(); - return 0; - } - - intent = new Intent(context, intentClass); - intent.addCategory(Intent.CATEGORY_BROWSABLE); - intent.setAction(Intent.ACTION_VIEW); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); - - notificationContentText = context.getResources().getString(R.string.keep_status_running); - - } - - - - PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE); - Intent stopIntent = new Intent(PushNotificationHelper.ACTION_TAP_STOP); - PendingIntent stopPendingIntent = PendingIntent.getBroadcast(context, 0, stopIntent, - PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE); - - Notification notification = new NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_stat_notify_status) - .setContentTitle(context.getResources().getString(R.string.background_service_opened)) - .setContentText(notificationContentText) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setContentIntent(pendingIntent) - .setNumber(0) - .addAction(R.drawable.ic_stat_notify_status, - context.getResources().getString(R.string.stop), - stopPendingIntent) - .build(); - - // the id of the foreground notification MUST NOT be 0 - if (Build.VERSION.SDK_INT >= 33) { - startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE); - } else { - startForeground(1, notification); - } - - return START_STICKY; - } -} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/ForegroundService.kt b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/ForegroundService.kt new file mode 100644 index 0000000000..753a241940 --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/ForegroundService.kt @@ -0,0 +1,122 @@ +package im.status.ethereum.pushnotifications + +import android.content.Context +import android.content.Intent +import android.app.Service +import android.content.pm.ServiceInfo +import android.os.IBinder +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.provider.Settings +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import android.os.Build +import im.status.ethereum.module.R + +class ForegroundService : Service() { + companion object { + private const val CHANNEL_ID = "status-service" + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onStartCommand(i: Intent?, flags: Int, startId: Int): Int { + // NOTE: recent versions of Android require the service to display + // a sticky notification to inform the user that the service is running + val context = applicationContext + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + + // Create notification channel for Android O and above + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager = context.getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + CHANNEL_ID, + context.resources.getString(R.string.status_service), + NotificationManager.IMPORTANCE_HIGH + ).apply { + setShowBadge(false) + } + notificationManager.createNotificationChannel(channel) + } + + // Initialize intent and notification text based on Android version + val notificationIntent = createNotificationIntent(context) + val notificationText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.resources.getString(R.string.tap_to_hide_notification) + } else { + context.resources.getString(R.string.keep_status_running) + } + + val pendingIntent = PendingIntent.getActivity( + this, + 0, + notificationIntent, + PendingIntent.FLAG_IMMUTABLE + ) + + val stopIntent = Intent(PushNotificationHelper.ACTION_TAP_STOP) + val stopPendingIntent = PendingIntent.getBroadcast( + context, + 0, + stopIntent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_notify_status) + .setContentTitle(context.resources.getString(R.string.background_service_opened)) + .setContentText(notificationText) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setContentIntent(pendingIntent) + .setNumber(0) + .addAction( + R.drawable.ic_stat_notify_status, + context.resources.getString(R.string.stop), + stopPendingIntent + ) + .build() + + // the id of the foreground notification MUST NOT be 0 + if (Build.VERSION.SDK_INT >= 33) { + startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else { + startForeground(1, notification) + } + + return START_STICKY + } + + private fun createNotificationIntent(context: Context): Intent { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Create intent that takes the user to the notification channel settings + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, CHANNEL_ID) + } + } else { + // For older versions of android, intent takes the user to the Status app + val packageName = context.packageName + val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) + val className = launchIntent?.component?.className + + try { + val intentClass = Class.forName(className!!) + Intent(context, intentClass).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + action = Intent.ACTION_VIEW + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + } catch (e: ClassNotFoundException) { + e.printStackTrace() + // Return an empty intent as fallback + Intent() + } + } + } +} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotification.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotification.java deleted file mode 100644 index 02ab837c9a..0000000000 --- a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotification.java +++ /dev/null @@ -1,146 +0,0 @@ -package im.status.ethereum.pushnotifications; - -import android.app.Activity; -import android.app.Application; -import android.app.NotificationManager; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.core.app.NotificationManagerCompat; - -import java.security.SecureRandom; - -import com.facebook.react.bridge.ActivityEventListener; -import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.Callback; -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; -import com.facebook.react.bridge.ReadableArray; -import com.facebook.react.bridge.WritableArray; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.WritableMap; -import android.util.Log; - -import im.status.ethereum.pushnotifications.PushNotificationJsDelivery; - -public class PushNotification extends ReactContextBaseJavaModule implements ActivityEventListener { - public static final String LOG_TAG = "PushNotification"; - - private final SecureRandom mRandomNumberGenerator = new SecureRandom(); - private PushNotificationHelper pushNotificationHelper; - private PushNotificationJsDelivery delivery; - private ReactApplicationContext reactContext; - private boolean started; - - public PushNotification(ReactApplicationContext reactContext) { - super(reactContext); - this.reactContext = reactContext; - reactContext.addActivityEventListener(this); - Application applicationContext = (Application) reactContext.getApplicationContext(); - - IntentFilter intentFilter = new IntentFilter(); - pushNotificationHelper = new PushNotificationHelper(applicationContext, intentFilter); - - delivery = new PushNotificationJsDelivery(reactContext); - } - - @Override - public String getName() { - return "PushNotification"; - } - - // removed @Override temporarily just to get it working on different versions of RN - public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { - onActivityResult(requestCode, resultCode, data); - } - - // removed @Override temporarily just to get it working on different versions of RN - public void onActivityResult(int requestCode, int resultCode, Intent data) { - // Ignored, required to implement ActivityEventListener for RN 0.33 - } - - private Bundle getBundleFromIntent(Intent intent) { - Bundle bundle = null; - if (intent.hasExtra("notification")) { - bundle = intent.getBundleExtra("notification"); - } else if (intent.hasExtra("google.message_id")) { - bundle = new Bundle(); - - bundle.putBundle("data", intent.getExtras()); - } - - if(null != bundle && !bundle.getBoolean("foreground", false) && !bundle.containsKey("userInteraction")) { - bundle.putBoolean("userInteraction", true); - } - - return bundle; - } - - @Override - public void onNewIntent(Intent intent) { - Bundle bundle = this.getBundleFromIntent(intent); - if (bundle != null) { - delivery.notifyNotification(bundle); - } - } - - @ReactMethod - /** - * Creates a channel if it does not already exist. Returns whether the channel was created. - */ - public void createChannel(ReadableMap channelInfo, Callback callback) { - boolean created = pushNotificationHelper.createChannel(channelInfo); - - if(callback != null) { - callback.invoke(created); - } - } - - @ReactMethod - public void presentLocalNotification(ReadableMap details) { - if (!this.started) { - return; - } - - Bundle bundle = Arguments.toBundle(details); - // If notification ID is not provided by the user, generate one at random - if (bundle.getString("id") == null) { - bundle.putString("id", String.valueOf(mRandomNumberGenerator.nextInt())); - } - - pushNotificationHelper.sendToNotificationCentre(bundle); - } - - @ReactMethod - public void clearMessageNotifications(String conversationId) { - if (this.started) { - pushNotificationHelper.clearMessageNotifications(conversationId); - } - } - - @ReactMethod - public void clearAllMessageNotifications() { - pushNotificationHelper.clearAllMessageNotifications(); - } - - @ReactMethod - public void enableNotifications() { - this.started = true; - this.pushNotificationHelper.start(); - } - - @ReactMethod - public void disableNotifications() { - if (this.started) { - this.started = false; - this.pushNotificationHelper.stop(); - } - } -} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotification.kt b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotification.kt new file mode 100644 index 0000000000..918e088eb4 --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotification.kt @@ -0,0 +1,118 @@ +package im.status.ethereum.pushnotifications + +import android.app.Activity +import android.app.Application +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import android.util.Log +import androidx.annotation.NonNull +import androidx.core.app.NotificationManagerCompat +import com.facebook.react.bridge.* +import im.status.ethereum.pushnotifications.PushNotificationJsDelivery +import java.security.SecureRandom + +class PushNotification(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext), ActivityEventListener { + + companion object { + const val LOG_TAG = "PushNotification" + } + + private val mRandomNumberGenerator = SecureRandom() + private val pushNotificationHelper: PushNotificationHelper + private val delivery: PushNotificationJsDelivery + private val reactContext: ReactApplicationContext = reactContext + private var started: Boolean = false + + init { + reactContext.addActivityEventListener(this) + val applicationContext = reactContext.applicationContext as Application + + val intentFilter = IntentFilter() + pushNotificationHelper = PushNotificationHelper(applicationContext, intentFilter) + + delivery = PushNotificationJsDelivery(reactContext) + } + + override fun getName(): String = "PushNotification" + + // Primary implementation of ActivityEventListener interface + override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) { + // Call the helper method with the correct parameter order + handleActivityResult(requestCode, resultCode, data) + } + + // Private helper method for handling activity results + private fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + // Ignored, required to implement ActivityEventListener for RN 0.33 + } + + private fun getBundleFromIntent(intent: Intent): Bundle? { + return when { + intent.hasExtra("notification") -> intent.getBundleExtra("notification") + intent.hasExtra("google.message_id") -> Bundle().apply { + putBundle("data", intent.extras) + } + else -> null + }?.apply { + if (!getBoolean("foreground", false) && !containsKey("userInteraction")) { + putBoolean("userInteraction", true) + } + } + } + + override fun onNewIntent(intent: Intent) { + getBundleFromIntent(intent)?.let { bundle -> + delivery.notifyNotification(bundle) + } + } + + @ReactMethod + fun createChannel(channelInfo: ReadableMap, callback: Callback?) { + val created = pushNotificationHelper.createChannel(channelInfo) + callback?.invoke(created) + } + + @ReactMethod + fun presentLocalNotification(details: ReadableMap) { + if (!started) return + + Arguments.toBundle(details)?.apply { + // If notification ID is not provided by the user, generate one at random + if (getString("id") == null) { + putString("id", mRandomNumberGenerator.nextInt().toString()) + } + pushNotificationHelper.sendToNotificationCentre(this) + } + } + + @ReactMethod + fun clearMessageNotifications(conversationId: String) { + if (started) { + pushNotificationHelper.clearMessageNotifications(conversationId) + } + } + + @ReactMethod + fun clearAllMessageNotifications() { + pushNotificationHelper.clearAllMessageNotifications() + } + + @ReactMethod + fun enableNotifications() { + started = true + pushNotificationHelper.start() + } + + @ReactMethod + fun disableNotifications() { + if (started) { + started = false + pushNotificationHelper.stop() + } + } +} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationActions.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationActions.java deleted file mode 100644 index 3a9a3e5702..0000000000 --- a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationActions.java +++ /dev/null @@ -1,93 +0,0 @@ -package im.status.ethereum.pushnotifications; - -import android.os.Build; -import android.app.Application; -import android.app.NotificationManager; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.util.Log; - -import com.facebook.react.ReactApplication; -import com.facebook.react.ReactInstanceManager; -import com.facebook.react.bridge.ReactContext; - - -import im.status.ethereum.pushnotifications.PushNotificationJsDelivery; -import static im.status.ethereum.pushnotifications.PushNotification.LOG_TAG; - -public class PushNotificationActions extends BroadcastReceiver { - @Override - public void onReceive(final Context context, Intent intent) { - String intentActionPrefix = context.getPackageName() + ".ACTION_"; - - Log.i(LOG_TAG, "PushNotificationBootEventReceiver loading scheduled notifications"); - - if (null == intent.getAction() || !intent.getAction().startsWith(intentActionPrefix)) { - return; - } - - final Bundle bundle = intent.getBundleExtra("notification"); - - // Dismiss the notification popup. - NotificationManager manager = (NotificationManager) context.getSystemService(context.NOTIFICATION_SERVICE); - int notificationID = Integer.parseInt(bundle.getString("id")); - - boolean autoCancel = bundle.getBoolean("autoCancel", true); - - if(autoCancel) { - if (bundle.containsKey("tag")) { - String tag = bundle.getString("tag"); - manager.cancel(tag, notificationID); - } else { - manager.cancel(notificationID); - } - } - - boolean invokeApp = bundle.getBoolean("invokeApp", true); - - // Notify the action. - if(invokeApp) { - - IntentFilter intentFilter = new IntentFilter(); - PushNotificationHelper helper = new PushNotificationHelper((Application) context.getApplicationContext(), intentFilter); - - helper.invokeApp(bundle); - } else { - - // We need to run this on the main thread, as the React code assumes that is true. - // Namely, DevServerHelper constructs a Handler() without a Looper, which triggers: - // "Can't create handler inside thread that has not called Looper.prepare()" - Handler handler = new Handler(Looper.getMainLooper()); - handler.post(new Runnable() { - public void run() { - // Construct and load our normal React JS code bundle - final ReactInstanceManager mReactInstanceManager = ((ReactApplication) context.getApplicationContext()).getReactNativeHost().getReactInstanceManager(); - ReactContext context = mReactInstanceManager.getCurrentReactContext(); - PushNotificationJsDelivery delivery = new PushNotificationJsDelivery(context); - // If it's constructed, send a notification - if (context != null) { - delivery.notifyNotificationAction(bundle); - } else { - // Otherwise wait for construction, then send the notification - mReactInstanceManager.addReactInstanceEventListener(new ReactInstanceManager.ReactInstanceEventListener() { - public void onReactContextInitialized(ReactContext context) { - PushNotificationJsDelivery delivery = new PushNotificationJsDelivery(context); - delivery.notifyNotificationAction(bundle); - mReactInstanceManager.removeReactInstanceEventListener(this); - } - }); - if (!mReactInstanceManager.hasStartedCreatingInitialContext()) { - // Construct it in the background - mReactInstanceManager.createReactContextInBackground(); - } - } - } - }); - } - } -} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationActions.kt b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationActions.kt new file mode 100644 index 0000000000..9bcbc3dae7 --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationActions.kt @@ -0,0 +1,100 @@ +package im.status.ethereum.pushnotifications + +import android.os.Build +import android.app.Application +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.facebook.react.ReactApplication +import com.facebook.react.ReactInstanceManager +import com.facebook.react.bridge.ReactContext +import im.status.ethereum.pushnotifications.PushNotificationJsDelivery +import im.status.ethereum.pushnotifications.PushNotification.Companion.LOG_TAG + +/** + * BroadcastReceiver responsible for handling push notification actions. + * This class processes notification interactions and manages their lifecycle. + */ +class PushNotificationActions : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val intentActionPrefix = "${context.packageName}.ACTION_" + + Log.i(LOG_TAG, "PushNotificationBootEventReceiver loading scheduled notifications") + + if (intent.action == null || !intent.action!!.startsWith(intentActionPrefix)) { + return + } + + val bundle = intent.getBundleExtra("notification") ?: return + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val notificationID = bundle.getString("id")?.toInt() ?: return + + // Dismiss the notification popup. + val autoCancel = bundle.getBoolean("autoCancel", true) + if (autoCancel) { + bundle.getString("tag")?.let { tag -> + manager.cancel(tag, notificationID) + } ?: run { + manager.cancel(notificationID) + } + } + + // Notify the action. + val invokeApp = bundle.getBoolean("invokeApp", true) + + if (invokeApp) { + val intentFilter = IntentFilter() + val helper = PushNotificationHelper( + context.applicationContext as Application, + intentFilter + ) + helper.invokeApp(bundle) + } else { + // We need to run this on the main thread, as the React code assumes that is true. + // Namely, DevServerHelper constructs a Handler() without a Looper, which triggers: + // "Can't create handler inside thread that has not called Looper.prepare()" + Handler(Looper.getMainLooper()).post { + // Construct and load our normal React JS code bundle + val reactInstanceManager = (context.applicationContext as ReactApplication) + .reactNativeHost + .reactInstanceManager + + val currentContext = reactInstanceManager.currentReactContext + + when { + // If context exists, deliver notification immediately + currentContext != null -> { + PushNotificationJsDelivery(currentContext) + .notifyNotificationAction(bundle) + } + // Otherwise wait for construction, then send the notification + else -> { + val listener = object : ReactInstanceManager.ReactInstanceEventListener { + override fun onReactContextInitialized(reactContext: ReactContext) { + PushNotificationJsDelivery(reactContext) + .notifyNotificationAction(bundle) + reactInstanceManager.removeReactInstanceEventListener(this) + } + } + + // Add the listener and create context if needed + reactInstanceManager.addReactInstanceEventListener(listener) + if (!reactInstanceManager.hasStartedCreatingInitialContext()) { + // Construct it in the background + reactInstanceManager.createReactContextInBackground() + } + } + } + } + } + } +} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationHelper.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationHelper.java deleted file mode 100644 index 899f8ab96b..0000000000 --- a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationHelper.java +++ /dev/null @@ -1,890 +0,0 @@ -// https://github.com/zo0r/react-native-push-notification/blob/bedc8f646aab67d594f291449fbfa24e07b64fe8/android/src/main/java/com/dieam/reactnativepushnotification/modules/RNPushNotificationHelper.java Copy-Paste with removed firebase -package im.status.ethereum.pushnotifications; - -import android.app.ActivityManager; -import android.app.ActivityManager.RunningAppProcessInfo; -import android.app.AlarmManager; -import android.app.Application; -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.content.IntentFilter; -import android.content.BroadcastReceiver; -import android.content.SharedPreferences; -import android.content.pm.ApplicationInfo; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Color; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffXfermode; -import android.graphics.Rect; -import android.graphics.RectF; -import android.media.AudioAttributes; -import android.media.RingtoneManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.service.notification.StatusBarNotification; -import android.util.Log; -import android.util.Base64; - -import androidx.annotation.RequiresApi; -import androidx.core.app.NotificationCompat; -import androidx.core.app.Person; -import androidx.core.graphics.drawable.IconCompat; - -import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.ReadableArray; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.WritableArray; -import com.facebook.react.bridge.WritableMap; - -import org.json.JSONArray; -import org.json.JSONException; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Date; -import java.util.GregorianCalendar; -import java.util.List; -import java.util.HashMap; -import java.util.Map; - -import im.status.ethereum.module.R; -import static im.status.ethereum.pushnotifications.PushNotification.LOG_TAG; - -public class PushNotificationHelper { - - private Context context; - - private static final long DEFAULT_VIBRATION = 300L; - private static final String CHANNEL_ID = "status-im-notifications"; - public static final String ACTION_DELETE_NOTIFICATION = "im.status.ethereum.module.DELETE_NOTIFICATION"; - public static final String ACTION_TAP_STOP = "im.status.ethereum.module.TAP_STOP"; - final int flag = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE : PendingIntent.FLAG_CANCEL_CURRENT; - - private NotificationManager notificationManager; - - - private HashMap persons; - private HashMap messageGroups; - - private IntentFilter intentFilter; - - public PushNotificationHelper(Application context, IntentFilter intentFilter) { - this.context = context; - this.intentFilter = intentFilter; - this.persons = new HashMap(); - this.messageGroups = new HashMap(); - this.notificationManager = context.getSystemService(NotificationManager.class); - this.registerBroadcastReceiver(); - } - - public Intent getOpenAppIntent(String deepLink) { - Class intentClass; - String packageName = context.getPackageName(); - Intent launchIntent = context.getPackageManager().getLaunchIntentForPackage(packageName); - String className = launchIntent.getComponent().getClassName(); - try { - intentClass = Class.forName(className); - } catch (ClassNotFoundException e) { - e.printStackTrace(); - return null; - } - Intent intent = new Intent(context, intentClass); - intent.addCategory(Intent.CATEGORY_BROWSABLE); - intent.setAction(Intent.ACTION_VIEW); - //NOTE: you might wonder, why the heck did he decide to set these flags in particular. Well, - //the answer is a simple as it can get in the Android native development world. I noticed - //that my initial setup was opening the app but wasn't triggering any events on the js side, like - //the links do from the browser. So I compared both intents and noticed that the link from - //the browser produces an intent with the flag 0x14000000. I found out that it was the following - //flags in this link: - //https://stackoverflow.com/questions/52390129/android-intent-setflags-issue - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); - if (deepLink != null) { - intent.setData(Uri.parse(deepLink)); - } - return intent; - } - - //NOTE: we use a dynamically created BroadcastReceiver here so that we can capture - //intents from notifications and act on them. For instance when tapping/dismissing - //a chat notification we want to clear the chat so that next messages don't show - //the messages that we have seen again - private final BroadcastReceiver notificationActionReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - if (intent.getAction() == ACTION_DELETE_NOTIFICATION) { - String groupId = intent.getExtras().getString("im.status.ethereum.groupId"); - if (groupId != null) { - cleanGroup(groupId); - } - } - if (intent.getAction() == ACTION_TAP_STOP) { - stop(); - System.exit(0); - } - Log.e(LOG_TAG, "intent received: " + intent.getAction()); - } - }; - - public void registerBroadcastReceiver() { - this.intentFilter.addAction(ACTION_DELETE_NOTIFICATION); - this.intentFilter.addAction(ACTION_TAP_STOP); - context.registerReceiver(notificationActionReceiver, this.intentFilter, Context.RECEIVER_EXPORTED); - Log.e(LOG_TAG, "Broadcast Receiver registered"); - } - - - - private NotificationManager notificationManager() { - return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - } - - public void invokeApp(Bundle bundle) { - String packageName = context.getPackageName(); - Intent launchIntent = context.getPackageManager().getLaunchIntentForPackage(packageName); - String className = launchIntent.getComponent().getClassName(); - - try { - Class activityClass = Class.forName(className); - Intent activityIntent = new Intent(context, activityClass); - - if(bundle != null) { - activityIntent.putExtra("notification", bundle); - } - - activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - context.startActivity(activityIntent); - } catch(Exception e) { - Log.e(LOG_TAG, "Class not found", e); - return; - } - } - - public void sendToNotificationCentre(final Bundle bundle) { - PushNotificationPicturesAggregator aggregator = new PushNotificationPicturesAggregator(new PushNotificationPicturesAggregator.Callback() { - public void call(Bitmap largeIconImage, Bitmap bigPictureImage) { - sendToNotificationCentreWithPicture(bundle, largeIconImage, bigPictureImage); - } - }); - - aggregator.setLargeIconUrl(context, bundle.getString("largeIconUrl")); - aggregator.setBigPictureUrl(context, bundle.getString("bigPictureUrl")); - } - - public void handleConversation(final Bundle bundle) { - if (bundle.getBoolean("deleted")){ - this.removeStatusMessage(bundle); - } else { - this.addStatusMessage(bundle); - } - } - - public void clearMessageNotifications(String conversationId) { - notificationManager.cancel(conversationId.hashCode()); - cleanGroup(conversationId); - } - - public void clearAllMessageNotifications() { - notificationManager.cancelAll(); - } - - public void sendToNotificationCentreWithPicture(final Bundle bundle, Bitmap largeIconBitmap, Bitmap bigPictureBitmap) { - - try { - Class intentClass = getMainActivityClass(); - if (intentClass == null) { - Log.e(LOG_TAG, "No activity class found for the notification"); - return; - } - - if (bundle.getBoolean("isConversation")) { - this.handleConversation(bundle); - return; - } - - if (bundle.getString("message") == null) { - // this happens when a 'data' notification is received - we do not synthesize a local notification in this case - Log.d(LOG_TAG, "Ignore this message if you sent data-only notification. Cannot send to notification centre because there is no 'message' field in: " + bundle); - return; - } - - String notificationIdString = bundle.getString("id"); - if (notificationIdString == null) { - Log.e(LOG_TAG, "No notification ID specified for the notification"); - return; - } - - Resources res = context.getResources(); - String packageName = context.getPackageName(); - - String title = bundle.getString("title"); - if (title == null) { - ApplicationInfo appInfo = context.getApplicationInfo(); - title = context.getPackageManager().getApplicationLabel(appInfo).toString(); - } - - int priority = NotificationCompat.PRIORITY_HIGH; - final String priorityString = bundle.getString("priority"); - - if (priorityString != null) { - switch (priorityString.toLowerCase()) { - case "max": - priority = NotificationCompat.PRIORITY_MAX; - break; - case "high": - priority = NotificationCompat.PRIORITY_HIGH; - break; - case "low": - priority = NotificationCompat.PRIORITY_LOW; - break; - case "min": - priority = NotificationCompat.PRIORITY_MIN; - break; - case "default": - priority = NotificationCompat.PRIORITY_DEFAULT; - break; - default: - priority = NotificationCompat.PRIORITY_HIGH; - } - } - - int visibility = NotificationCompat.VISIBILITY_PRIVATE; - final String visibilityString = bundle.getString("visibility"); - - if (visibilityString != null) { - switch (visibilityString.toLowerCase()) { - case "private": - visibility = NotificationCompat.VISIBILITY_PRIVATE; - break; - case "public": - visibility = NotificationCompat.VISIBILITY_PUBLIC; - break; - case "secret": - visibility = NotificationCompat.VISIBILITY_SECRET; - break; - default: - visibility = NotificationCompat.VISIBILITY_PRIVATE; - } - } - - String channel_id = bundle.getString("channelId"); - - if(channel_id == null) { - channel_id = this.getNotificationDefaultChannelId(); - } - - NotificationCompat.Builder notification = new NotificationCompat.Builder(context, channel_id) - .setContentTitle(title) - .setTicker(bundle.getString("ticker")) - .setVisibility(visibility) - .setPriority(priority) - .setAutoCancel(bundle.getBoolean("autoCancel", true)) - .setOnlyAlertOnce(bundle.getBoolean("onlyAlertOnce", false)); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // API 24 and higher - // Restore showing timestamp on Android 7+ - // Source: https://developer.android.com/reference/android/app/Notification.Builder.html#setShowWhen(boolean) - boolean showWhen = bundle.getBoolean("showWhen", true); - - notification.setShowWhen(showWhen); - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // API 26 and higher - // Changing Default mode of notification - notification.setDefaults(Notification.DEFAULT_LIGHTS); - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) { // API 20 and higher - String group = bundle.getString("group"); - - if (group != null) { - notification.setGroup(group); - } - - if (bundle.containsKey("groupSummary") || bundle.getBoolean("groupSummary")) { - notification.setGroupSummary(bundle.getBoolean("groupSummary")); - } - } - - String numberString = bundle.getString("number"); - - if (numberString != null) { - notification.setNumber(Integer.parseInt(numberString)); - } - - // Small icon - int smallIconResId = 0; - - String smallIcon = bundle.getString("smallIcon"); - - if (smallIcon != null && !smallIcon.isEmpty()) { - smallIconResId = res.getIdentifier(smallIcon, "mipmap", packageName); - } else if(smallIcon == null) { - smallIconResId = res.getIdentifier("ic_stat_notify_status", "drawable", packageName); - } - - if (smallIconResId == 0) { - smallIconResId = res.getIdentifier("ic_launcher", "mipmap", packageName); - - if (smallIconResId == 0) { - smallIconResId = android.R.drawable.ic_dialog_info; - } - } - - notification.setSmallIcon(smallIconResId); - - // Large icon - if(largeIconBitmap == null) { - int largeIconResId = 0; - - String largeIcon = bundle.getString("largeIcon"); - - if (largeIcon != null && !largeIcon.isEmpty()) { - largeIconResId = res.getIdentifier(largeIcon, "mipmap", packageName); - } else if(largeIcon == null) { - largeIconResId = res.getIdentifier("ic_launcher", "mipmap", packageName); - } - - // Before Lolipop there was no large icon for notifications. - if (largeIconResId != 0 && (largeIcon != null || Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)) { - largeIconBitmap = BitmapFactory.decodeResource(res, largeIconResId); - } - } - - Bundle author = bundle.getBundle("notificationAuthor"); - - if (largeIconBitmap == null && author != null) { - String base64Image = author.getString("icon").split(",")[1]; - byte[] decodedString = Base64.decode(base64Image, Base64.DEFAULT); - Bitmap decodedByte = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.length); - notification.setLargeIcon(getCircleBitmap(decodedByte)); - } else if (largeIconBitmap != null){ - notification.setLargeIcon(largeIconBitmap); - } - - String message = bundle.getString("message"); - - notification.setContentText(message); - - String subText = bundle.getString("subText"); - - if (subText != null) { - notification.setSubText(subText); - } - - String bigText = bundle.getString("bigText"); - - if (bigText == null) { - bigText = message; - } - - NotificationCompat.Style style; - - if(bigPictureBitmap != null) { - style = new NotificationCompat.BigPictureStyle() - .bigPicture(bigPictureBitmap) - .setBigContentTitle(title) - .setSummaryText(message); - } else { - style = new NotificationCompat.BigTextStyle().bigText(bigText); - } - - notification.setStyle(style); - - Intent intent = new Intent(context, intentClass); - intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); - bundle.putBoolean("foreground", this.isApplicationInForeground()); - bundle.putBoolean("userInteraction", true); - intent.putExtra("notification", bundle); - - Uri soundUri = null; - - if (!bundle.containsKey("playSound") || bundle.getBoolean("playSound")) { - String soundName = bundle.getString("soundName"); - - if (soundName == null) { - soundName = "default"; - } - - soundUri = getSoundUri(soundName); - - notification.setSound(soundUri); - } - - if (soundUri == null || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - notification.setSound(null); - } - - if (bundle.containsKey("ongoing") || bundle.getBoolean("ongoing")) { - notification.setOngoing(bundle.getBoolean("ongoing")); - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - notification.setCategory(NotificationCompat.CATEGORY_CALL); - - String color = bundle.getString("color"); - int defaultColor = -1; - if (color != null) { - notification.setColor(Color.parseColor(color)); - } else if (defaultColor != -1) { - notification.setColor(defaultColor); - } - } - - int notificationID = notificationIdString.hashCode(); - - notification.setContentIntent(createOnTapIntent(context, notificationID, bundle.getString("deepLink"))) - .setDeleteIntent(createOnDismissedIntent(context, notificationID, bundle.getString("deepLink"))); - - NotificationManager notificationManager = notificationManager(); - - long[] vibratePattern = new long[]{0}; - - if (!bundle.containsKey("vibrate") || bundle.getBoolean("vibrate")) { - long vibration = bundle.containsKey("vibration") ? (long) bundle.getDouble("vibration") : DEFAULT_VIBRATION; - if (vibration == 0) - vibration = DEFAULT_VIBRATION; - - vibratePattern = new long[]{0, vibration}; - - notification.setVibrate(vibratePattern); - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Define the shortcutId - String shortcutId = bundle.getString("shortcutId"); - - if (shortcutId != null) { - notification.setShortcutId(shortcutId); - } - - Long timeoutAfter = (long) bundle.getDouble("timeoutAfter"); - - if (timeoutAfter != null && timeoutAfter >= 0) { - notification.setTimeoutAfter(timeoutAfter); - } - } - - Long when = (long) bundle.getDouble("when"); - - if (when != null && when >= 0) { - notification.setWhen(when); - } - - notification.setUsesChronometer(bundle.getBoolean("usesChronometer", false)); - notification.setChannelId(channel_id); - - JSONArray actionsArray = null; - try { - actionsArray = bundle.getString("actions") != null ? new JSONArray(bundle.getString("actions")) : null; - } catch (JSONException e) { - Log.e(LOG_TAG, "Exception while converting actions to JSON object.", e); - } - - if (actionsArray != null) { - // No icon for now. The icon value of 0 shows no icon. - int icon = 0; - - // Add button for each actions. - for (int i = 0; i < actionsArray.length(); i++) { - String action; - try { - action = actionsArray.getString(i); - } catch (JSONException e) { - Log.e(LOG_TAG, "Exception while getting action from actionsArray.", e); - continue; - } - - - Intent actionIntent = new Intent(context, PushNotificationActions.class); - actionIntent.setAction(packageName + ".ACTION_" + i); - - actionIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); - - // Add "action" for later identifying which button gets pressed. - bundle.putString("action", action); - actionIntent.putExtra("notification", bundle); - actionIntent.setPackage(packageName); - - PendingIntent pendingActionIntent = PendingIntent.getBroadcast(context, notificationID, actionIntent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - notification.addAction(new NotificationCompat.Action.Builder(icon, action, pendingActionIntent).build()); - } else { - notification.addAction(icon, action, pendingActionIntent); - } - } - } - - - if (!(this.isApplicationInForeground() && bundle.getBoolean("ignoreInForeground"))) { - Notification info = notification.build(); - info.defaults |= Notification.DEFAULT_LIGHTS; - - if (bundle.containsKey("tag")) { - String tag = bundle.getString("tag"); - notificationManager.notify(tag, notificationID, info); - } else { - notificationManager.notify(notificationID, info); - } - } - } catch (Exception e) { - Log.e(LOG_TAG, "failed to send push notification", e); - } - } - - private boolean checkOrCreateChannel(NotificationManager manager, String channel_id, String channel_name, String channel_description, Uri soundUri, int importance, long[] vibratePattern, boolean showBadge) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) - return false; - if (manager == null) - return false; - - NotificationChannel channel = manager.getNotificationChannel(channel_id); - - if ( - channel == null && channel_name != null && channel_description != null || - channel != null && - ( - channel_name != null && !channel.getName().equals(channel_name) || - channel_description != null && !channel.getDescription().equals(channel_description) - ) - ) { - // If channel doesn't exist create a new one. - // If channel name or description is updated then update the existing channel. - channel = new NotificationChannel(channel_id, channel_name, importance); - - channel.setDescription(channel_description); - channel.enableLights(true); - channel.enableVibration(vibratePattern != null); - channel.setVibrationPattern(vibratePattern); - channel.setShowBadge(showBadge); - - if (soundUri != null) { - AudioAttributes audioAttributes = new AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setUsage(AudioAttributes.USAGE_NOTIFICATION) - .build(); - - channel.setSound(soundUri, audioAttributes); - } else { - channel.setSound(null, null); - } - - manager.createNotificationChannel(channel); - - return true; - } - - return false; - } - - public boolean createChannel(ReadableMap channelInfo) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) - return false; - - String channelId = channelInfo.getString("channelId"); - String channelName = channelInfo.getString("channelName"); - String channelDescription = channelInfo.hasKey("channelDescription") ? channelInfo.getString("channelDescription") : ""; - String soundName = channelInfo.hasKey("soundName") ? channelInfo.getString("soundName") : "default"; - int importance = channelInfo.hasKey("importance") ? channelInfo.getInt("importance") : 4; - boolean vibrate = channelInfo.hasKey("vibrate") && channelInfo.getBoolean("vibrate"); - long[] vibratePattern = vibrate ? new long[] { DEFAULT_VIBRATION } : null; - boolean showBadge = channelInfo.hasKey("showBadge") && channelInfo.getBoolean("showBadge"); - - NotificationManager manager = notificationManager(); - - Uri soundUri = getSoundUri(soundName); - - return checkOrCreateChannel(manager, channelId, channelName, channelDescription, soundUri, importance, vibratePattern, showBadge); - } - - public String getNotificationDefaultChannelId() { - return this.CHANNEL_ID; - } - - private Uri getSoundUri(String soundName) { - if (soundName == null || "default".equalsIgnoreCase(soundName)) { - return RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); - } else { - - // sound name can be full filename, or just the resource name. - // So the strings 'my_sound.mp3' AND 'my_sound' are accepted - // The reason is to make the iOS and android javascript interfaces compatible - - int resId; - if (context.getResources().getIdentifier(soundName, "raw", context.getPackageName()) != 0) { - resId = context.getResources().getIdentifier(soundName, "raw", context.getPackageName()); - } else { - soundName = soundName.substring(0, soundName.lastIndexOf('.')); - resId = context.getResources().getIdentifier(soundName, "raw", context.getPackageName()); - } - - return Uri.parse("android.resource://" + context.getPackageName() + "/" + resId); - } - } - - public Class getMainActivityClass() { - String packageName = context.getPackageName(); - Intent launchIntent = context.getPackageManager().getLaunchIntentForPackage(packageName); - String className = launchIntent.getComponent().getClassName(); - try { - return Class.forName(className); - } catch (ClassNotFoundException e) { - e.printStackTrace(); - return null; - } - } - - public boolean isApplicationInForeground() { - ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); - List processInfos = activityManager.getRunningAppProcesses(); - if (processInfos != null) { - for (RunningAppProcessInfo processInfo : processInfos) { - if (processInfo.processName.equals(context.getPackageName()) - && processInfo.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND - && processInfo.pkgList.length > 0) { - return true; - } - } - } - return false; - } - - private 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 Person getPerson(Bundle bundle) { - String name = bundle.getString("name"); - - return new Person.Builder().setName(name).build(); - } - - private StatusMessage createMessage(Bundle data) { - Person author = getPerson(data.getBundle("notificationAuthor")); - long timeStampLongValue = (long) data.getDouble("timestamp"); - return new StatusMessage(data.getString("id"), author, timeStampLongValue, data.getString("message")); - } - - private PendingIntent createGroupOnDismissedIntent(Context context, int notificationId, String groupId, String deepLink) { - Intent intent = new Intent(ACTION_DELETE_NOTIFICATION); - intent.putExtra("im.status.ethereum.deepLink", deepLink); - intent.putExtra("im.status.ethereum.groupId", groupId); - return PendingIntent.getBroadcast(context.getApplicationContext(), notificationId, intent, flag); - } - - private PendingIntent createGroupOnTapIntent(Context context, int notificationId, String groupId, String deepLink) { - Intent intent = getOpenAppIntent(deepLink); - return PendingIntent.getActivity(context.getApplicationContext(), notificationId, intent, flag); - } - - private PendingIntent createOnTapIntent(Context context, int notificationId, String deepLink) { - Intent intent = getOpenAppIntent(deepLink); - return PendingIntent.getActivity(context.getApplicationContext(), notificationId, intent, flag); - } - - private PendingIntent createOnDismissedIntent(Context context, int notificationId, String deepLink) { - Intent intent = new Intent(ACTION_DELETE_NOTIFICATION); - intent.putExtra("im.status.ethereum.deepLink", deepLink); - return PendingIntent.getBroadcast(context.getApplicationContext(), notificationId, intent, flag); - } - - public void removeStatusMessage(Bundle bundle) { - String conversationId = bundle.getString("conversationId"); - StatusMessageGroup group = this.messageGroups.get(conversationId); - NotificationManager notificationManager = notificationManager(); - - if (group == null) { - group = new StatusMessageGroup(conversationId); - } - - this.messageGroups.put(conversationId, group); - - String id = bundle.getString("id"); - group.removeMessage(id); - - this.showMessages(bundle); - } - - public StatusMessageGroup getMessageGroup(String conversationId) { - return this.messageGroups.get(conversationId); - } - - public void addStatusMessage(Bundle bundle) { - String conversationId = bundle.getString("conversationId"); - StatusMessageGroup group = this.messageGroups.get(conversationId); - NotificationManager notificationManager = notificationManager(); - - if (group == null) { - group = new StatusMessageGroup(conversationId); - } - - this.messageGroups.put(conversationId, group); - - group.addMessage(createMessage(bundle)); - - this.showMessages(bundle); - } - - public void showMessages(Bundle bundle) { - String conversationId = bundle.getString("conversationId"); - StatusMessageGroup group = this.messageGroups.get(conversationId); - NotificationManager notificationManager = notificationManager(); - - NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle("Me"); - ArrayList messages = group.getMessages(); - - if (messages.size() == 0) { - notificationManager.cancel(conversationId.hashCode()); - return; - } - - for (int i = 0; i < messages.size(); i++) { - StatusMessage message = messages.get(i); - messagingStyle.addMessage(message.getText(), - message.getTimestamp(), - message.getAuthor()); - } - - if (bundle.getString("title") != null) { - messagingStyle.setConversationTitle(bundle.getString("title")); - } - - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_stat_notify_status) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setStyle(messagingStyle) - .setGroup(conversationId) - .setOnlyAlertOnce(true) - .setGroupSummary(true) - .setContentIntent(createGroupOnTapIntent(context, conversationId.hashCode(), conversationId, bundle.getString("deepLink"))) - .setDeleteIntent(createGroupOnDismissedIntent(context, conversationId.hashCode(), conversationId, bundle.getString("deepLink"))) - .setNumber(messages.size() + 1) - .setAutoCancel(true); - if (Build.VERSION.SDK_INT >= 21) { - builder.setVibrate(new long[0]); - } - notificationManager.notify(conversationId.hashCode(), builder.build()); - } - - class StatusMessageGroup { - private ArrayList messages; - private String id; - - StatusMessageGroup(String id) { - this.id = id; - this.messages = new ArrayList(); - } - public ArrayList getMessages() { - return messages; - } - - public void addMessage(StatusMessage message) { - this.messages.add(message); - } - - public void removeMessage(String id) { - ArrayList newMessages = new ArrayList(); - for(StatusMessage message: this.messages) { - if(!message.id.equals(id)) { - newMessages.add(message); - } - } - this.messages = newMessages; - } - - public String getId() { - return this.id; - } - } - - class StatusMessage { - public Person getAuthor() { - return author; - } - - public long getTimestamp() { - return timestamp; - } - - public String getText() { - return text; - } - - private String id; - private Person author; - private long timestamp; - private String text; - - StatusMessage(String id, Person author, long timestamp, String text) { - this.id = id; - this.author = author; - this.timestamp = timestamp; - this.text = text; - } - } - - private void removeGroup(String groupId) { - this.messageGroups.remove(groupId); - } - - private void cleanGroup(String groupId) { - removeGroup(groupId); - if (messageGroups.size() == 0) { - notificationManager.cancelAll(); - } - } - - public void start() { - Log.e(LOG_TAG, "Starting Foreground Service"); - Intent serviceIntent = new Intent(context, ForegroundService.class); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(serviceIntent); - } else { - context.startService(serviceIntent); - } - this.registerBroadcastReceiver(); - } - - public void stop() { - Log.e(LOG_TAG, "Stopping Foreground Service"); - //NOTE: we cancel all the current notifications, because the intents can't be used anymore - //since the broadcast receiver will be killed as well and won't be able to handle any intent - notificationManager.cancelAll(); - Intent serviceIntent = new Intent(context, ForegroundService.class); - context.stopService(serviceIntent); - context.unregisterReceiver(notificationActionReceiver); - } -} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationHelper.kt b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationHelper.kt new file mode 100644 index 0000000000..2869f1be9c --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationHelper.kt @@ -0,0 +1,750 @@ +// https://github.com/zo0r/react-native-push-notification/blob/bedc8f646aab67d594f291449fbfa24e07b64fe8/android/src/main/ +// java/com/dieam/reactnativepushnotification/modules/RNPushNotificationHelper.java Copy-Paste with removed firebase +package im.status.ethereum.pushnotifications + +import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo +import android.app.AlarmManager +import android.app.Application +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.content.IntentFilter +import android.content.BroadcastReceiver +import android.content.SharedPreferences +import android.content.pm.ApplicationInfo +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import android.graphics.RectF +import android.media.AudioAttributes +import android.media.RingtoneManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.service.notification.StatusBarNotification +import android.util.Log +import android.util.Base64 +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import androidx.core.graphics.drawable.IconCompat +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap +import org.json.JSONArray +import org.json.JSONException +import im.status.ethereum.module.R +import im.status.ethereum.pushnotifications.PushNotification.Companion.LOG_TAG + +class PushNotificationHelper(private val context: Application, private val intentFilter: IntentFilter) { + + companion object { + private const val DEFAULT_VIBRATION: Long = 300L + private const val CHANNEL_ID = "status-im-notifications" + const val ACTION_DELETE_NOTIFICATION = "im.status.ethereum.module.DELETE_NOTIFICATION" + const val ACTION_TAP_STOP = "im.status.ethereum.module.TAP_STOP" + } + + private val flag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else { + PendingIntent.FLAG_CANCEL_CURRENT + } + + private val notificationManager: NotificationManager by lazy { + context.getSystemService(NotificationManager::class.java) + } + + private val persons: MutableMap = mutableMapOf() + private val messageGroups: MutableMap = mutableMapOf() + + init { + registerBroadcastReceiver() + } + + private val notificationActionReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + ACTION_DELETE_NOTIFICATION -> { + intent.extras?.getString("im.status.ethereum.groupId")?.let { groupId -> + cleanGroup(groupId) + } + } + ACTION_TAP_STOP -> { + stop() + System.exit(0) + } + } + Log.e(LOG_TAG, "intent received: ${intent.action}") + } + } + + private fun registerBroadcastReceiver() { + intentFilter.apply { + addAction(ACTION_DELETE_NOTIFICATION) + addAction(ACTION_TAP_STOP) + } + context.registerReceiver(notificationActionReceiver, intentFilter, Context.RECEIVER_EXPORTED) + Log.e(LOG_TAG, "Broadcast Receiver registered") + } + + fun getOpenAppIntent(deepLink: String?): Intent? { + val packageName = context.packageName + val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) + val className = launchIntent?.component?.className ?: return null + + return try { + val intentClass = Class.forName(className) + Intent(context, intentClass).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + action = Intent.ACTION_VIEW + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + deepLink?.let { data = Uri.parse(it) } + } + } catch (e: ClassNotFoundException) { + e.printStackTrace() + null + } + } + + fun invokeApp(bundle: Bundle?) { + val packageName = context.packageName + val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) + val className = launchIntent?.component?.className ?: return + + try { + val activityClass = Class.forName(className) + Intent(context, activityClass).apply { + bundle?.let { putExtra("notification", it) } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(this) + } + } catch (e: ClassNotFoundException) { + Log.e(LOG_TAG, "Class not found", e) + } + } + + fun getMainActivityClass(): Class<*>? { + val packageName = context.packageName + val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) + val className = launchIntent?.component?.className ?: return null + + return try { + Class.forName(className) + } catch (e: ClassNotFoundException) { + e.printStackTrace() + null + } + } + + fun isApplicationInForeground(): Boolean { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val processInfos = activityManager.runningAppProcesses ?: return false + + return processInfos.any { processInfo -> + processInfo.processName == context.packageName && + processInfo.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND && + processInfo.pkgList.isNotEmpty() + } + } + + private fun getCircleBitmap(bitmap: Bitmap): Bitmap { + val output = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + val paint = Paint().apply { + isAntiAlias = true + color = Color.RED + } + val rect = Rect(0, 0, bitmap.width, bitmap.height) + val rectF = RectF(rect) + + canvas.drawARGB(0, 0, 0, 0) + canvas.drawOval(rectF, paint) + + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) + canvas.drawBitmap(bitmap, rect, rect, paint) + + bitmap.recycle() + return output + } + + // Represents a message in a Status conversation + data class StatusMessage( + val id: String, + val author: Person, + val timestamp: Long, + val text: String + ) + + // Represents a group of messages in a Status conversation + inner class StatusMessageGroup(val id: String) { + private val _messages = mutableListOf() + val messages: List get() = _messages.toList() + + fun addMessage(message: StatusMessage) { + _messages.add(message) + } + + fun removeMessage(id: String) { + _messages.removeAll { it.id == id } + } + } + + private fun getPerson(bundle: Bundle): Person { + return Person.Builder() + .setName(bundle.getString("name")) + .build() + } + + private fun createMessage(data: Bundle): StatusMessage { + val author = getPerson(data.getBundle("notificationAuthor") ?: Bundle()) + val timeStampLongValue = data.getDouble("timestamp").toLong() + return StatusMessage( + id = data.getString("id") ?: "", + author = author, + timestamp = timeStampLongValue, + text = data.getString("message") ?: "" + ) + } + + private fun createGroupOnDismissedIntent( + context: Context, + notificationId: Int, + groupId: String, + deepLink: String? + ): PendingIntent { + return Intent(ACTION_DELETE_NOTIFICATION).apply { + putExtra("im.status.ethereum.deepLink", deepLink) + putExtra("im.status.ethereum.groupId", groupId) + }.let { intent -> + PendingIntent.getBroadcast( + context.applicationContext, + notificationId, + intent, + flag + ) + } + } + + private fun createGroupOnTapIntent( + context: Context, + notificationId: Int, + groupId: String, + deepLink: String? + ): PendingIntent { + return getOpenAppIntent(deepLink)?.let { intent -> + PendingIntent.getActivity( + context.applicationContext, + notificationId, + intent, + flag + ) + } ?: throw IllegalStateException("Could not create open app intent") + } + + private fun createOnTapIntent( + context: Context, + notificationId: Int, + deepLink: String? + ): PendingIntent { + return getOpenAppIntent(deepLink)?.let { intent -> + PendingIntent.getActivity( + context.applicationContext, + notificationId, + intent, + flag + ) + } ?: throw IllegalStateException("Could not create open app intent") + } + + private fun createOnDismissedIntent( + context: Context, + notificationId: Int, + deepLink: String? + ): PendingIntent { + return Intent(ACTION_DELETE_NOTIFICATION).apply { + putExtra("im.status.ethereum.deepLink", deepLink) + }.let { intent -> + PendingIntent.getBroadcast( + context.applicationContext, + notificationId, + intent, + flag + ) + } + } + + fun removeStatusMessage(bundle: Bundle) { + val conversationId = bundle.getString("conversationId") ?: return + val group = messageGroups.getOrPut(conversationId) { + StatusMessageGroup(conversationId) + } + + bundle.getString("id")?.let { id -> + group.removeMessage(id) + } + + showMessages(bundle) + } + + fun getMessageGroup(conversationId: String): StatusMessageGroup? { + return messageGroups[conversationId] + } + + fun addStatusMessage(bundle: Bundle) { + val conversationId = bundle.getString("conversationId") ?: return + val group = messageGroups.getOrPut(conversationId) { + StatusMessageGroup(conversationId) + } + + group.addMessage(createMessage(bundle)) + showMessages(bundle) + } + + fun showMessages(bundle: Bundle) { + val conversationId = bundle.getString("conversationId") ?: return + val group = messageGroups[conversationId] ?: return + + val messagingStyle = NotificationCompat.MessagingStyle("Me") + + // If there are no messages, cancel the notification and return + if (group.messages.isEmpty()) { + notificationManager.cancel(conversationId.hashCode()) + return + } + + group.messages.forEach { message -> + messagingStyle.addMessage( + message.text, + message.timestamp, + message.author + ) + } + + // Set conversation title if available + bundle.getString("title")?.let { title -> + messagingStyle.conversationTitle = title + } + + val builder = NotificationCompat.Builder(context, CHANNEL_ID).apply { + setSmallIcon(R.drawable.ic_stat_notify_status) + setPriority(NotificationCompat.PRIORITY_HIGH) + setCategory(NotificationCompat.CATEGORY_MESSAGE) + setStyle(messagingStyle) + setGroup(conversationId) + setOnlyAlertOnce(true) + setGroupSummary(true) + setContentIntent(createGroupOnTapIntent(context, conversationId.hashCode(), conversationId, bundle.getString("deepLink"))) + setDeleteIntent(createGroupOnDismissedIntent(context, conversationId.hashCode(), conversationId, bundle.getString("deepLink"))) + setNumber(group.messages.size + 1) + setAutoCancel(true) + + // Set empty vibration pattern for Android 5.0 and above + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + setVibrate(LongArray(0)) + } + } + + // Show the notification + notificationManager.notify(conversationId.hashCode(), builder.build()) + } + + fun checkOrCreateChannel( + manager: NotificationManager?, + channelId: String, + channelName: String?, + channelDescription: String?, + soundUri: Uri?, + importance: Int, + vibratePattern: LongArray?, + showBadge: Boolean + ): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || manager == null) { + return false + } + + val existingChannel = manager.getNotificationChannel(channelId) + val shouldCreateOrUpdate = when { + // Channel doesn't exist and we have required info + existingChannel == null && channelName != null && channelDescription != null -> true + + // Channel exists but needs update + existingChannel != null && ( + (channelName != null && existingChannel.name != channelName) || + (channelDescription != null && existingChannel.description != channelDescription) + ) -> true + + else -> false + } + + if (shouldCreateOrUpdate) { + NotificationChannel(channelId, channelName, importance).apply { + description = channelDescription + enableLights(true) + enableVibration(vibratePattern != null) + vibrationPattern = vibratePattern + setShowBadge(showBadge) + + // Configure sound + if (soundUri != null) { + val audioAttributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build() + setSound(soundUri, audioAttributes) + } else { + setSound(null, null) + } + + manager.createNotificationChannel(this) + } + return true + } + + return false + } + + fun createChannel(channelInfo: ReadableMap): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return false + } + + return checkOrCreateChannel( + manager = notificationManager, + channelId = channelInfo.getString("channelId") ?: return false, + channelName = channelInfo.getString("channelName"), + channelDescription = if (channelInfo.hasKey("channelDescription")) { + channelInfo.getString("channelDescription") + } else "", + soundUri = getSoundUri( + if (channelInfo.hasKey("soundName")) { + channelInfo.getString("soundName") + } else "default" + ), + importance = if (channelInfo.hasKey("importance")) { + channelInfo.getInt("importance") + } else 4, + vibratePattern = if (channelInfo.hasKey("vibrate") && channelInfo.getBoolean("vibrate")) { + longArrayOf(DEFAULT_VIBRATION) + } else null, + showBadge = channelInfo.hasKey("showBadge") && channelInfo.getBoolean("showBadge") + ) + } + + fun getNotificationDefaultChannelId(): String = CHANNEL_ID + + private fun getSoundUri(soundName: String?): Uri { + return when { + soundName == null || soundName.equals("default", ignoreCase = true) -> { + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + } + else -> { + val resources = context.resources + val packageName = context.packageName + val resId = when { + // Try to find the resource as is + resources.getIdentifier(soundName, "raw", packageName) != 0 -> { + resources.getIdentifier(soundName, "raw", packageName) + } + // Try without file extension + else -> { + val nameWithoutExtension = soundName.substringBeforeLast('.') + resources.getIdentifier(nameWithoutExtension, "raw", packageName) + } + } + Uri.parse("android.resource://$packageName/$resId") + } + } + } + +private fun removeGroup(groupId: String) { + messageGroups.remove(groupId) + } + + private fun cleanGroup(groupId: String) { + removeGroup(groupId) + if (messageGroups.isEmpty()) { + notificationManager.cancelAll() + } + } + + fun start() { + Log.e(LOG_TAG, "Starting Foreground Service") + Intent(context, ForegroundService::class.java).also { serviceIntent -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent) + } else { + context.startService(serviceIntent) + } + } + registerBroadcastReceiver() + } + + fun stop() { + Log.e(LOG_TAG, "Stopping Foreground Service") + // Cancel all current notifications since their intents won't be valid + // after the broadcast receiver is killed + notificationManager.cancelAll() + + Intent(context, ForegroundService::class.java).also { serviceIntent -> + context.stopService(serviceIntent) + } + context.unregisterReceiver(notificationActionReceiver) + } + + fun sendToNotificationCentre(bundle: Bundle) { + val aggregator = PushNotificationPicturesAggregator { largeIconImage, bigPictureImage -> + sendToNotificationCentreWithPicture(bundle, largeIconImage, bigPictureImage) + } + + aggregator.apply { + setLargeIconUrl(context, bundle.getString("largeIconUrl")) + setBigPictureUrl(context, bundle.getString("bigPictureUrl")) + } + } + + fun handleConversation(bundle: Bundle) { + if (bundle.getBoolean("deleted")) { + removeStatusMessage(bundle) + } else { + addStatusMessage(bundle) + } + } + + fun clearMessageNotifications(conversationId: String) { + notificationManager.cancel(conversationId.hashCode()) + cleanGroup(conversationId) + } + + fun clearAllMessageNotifications() { + notificationManager.cancelAll() + } + + fun sendToNotificationCentreWithPicture( + bundle: Bundle, + largeIconBitmap: Bitmap?, + bigPictureBitmap: Bitmap? + ) { + try { + val intentClass = getMainActivityClass() ?: run { + Log.e(LOG_TAG, "No activity class found for the notification") + return + } + + if (bundle.getBoolean("isConversation")) { + handleConversation(bundle) + return + } + + if (bundle.getString("message") == null) { + Log.d(LOG_TAG, "Ignore this message if you sent data-only notification. " + + "Cannot send to notification centre because there is no 'message' field in: $bundle") + return + } + + val notificationIdString = bundle.getString("id") ?: run { + Log.e(LOG_TAG, "No notification ID specified for the notification") + return + } + + val resources = context.resources + val packageName = context.packageName + + val title = bundle.getString("title") ?: run { + val appInfo = context.applicationInfo + context.packageManager.getApplicationLabel(appInfo).toString() + } + + val priority = when (bundle.getString("priority")?.toLowerCase()) { + "max" -> NotificationCompat.PRIORITY_MAX + "high" -> NotificationCompat.PRIORITY_HIGH + "low" -> NotificationCompat.PRIORITY_LOW + "min" -> NotificationCompat.PRIORITY_MIN + "default" -> NotificationCompat.PRIORITY_DEFAULT + else -> NotificationCompat.PRIORITY_HIGH + } + + val visibility = when (bundle.getString("visibility")?.toLowerCase()) { + "private" -> NotificationCompat.VISIBILITY_PRIVATE + "public" -> NotificationCompat.VISIBILITY_PUBLIC + "secret" -> NotificationCompat.VISIBILITY_SECRET + else -> NotificationCompat.VISIBILITY_PRIVATE + } + + val channelId = bundle.getString("channelId") ?: getNotificationDefaultChannelId() + + NotificationCompat.Builder(context, channelId).apply { + setContentTitle(title) + setTicker(bundle.getString("ticker")) + setVisibility(visibility) + setPriority(priority) + setAutoCancel(bundle.getBoolean("autoCancel", true)) + setOnlyAlertOnce(bundle.getBoolean("onlyAlertOnce", false)) + + // Configure notification based on Android version + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + setShowWhen(bundle.getBoolean("showWhen", true)) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setDefaults(Notification.DEFAULT_LIGHTS) + } + + // Handle group settings for Android KitKat Watch and above + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) { + bundle.getString("group")?.let { group -> + setGroup(group) + } + + if (bundle.containsKey("groupSummary") || bundle.getBoolean("groupSummary")) { + setGroupSummary(bundle.getBoolean("groupSummary")) + } + } + + // Set notification number if provided + bundle.getString("number")?.toIntOrNull()?.let { number -> + setNumber(number) + } + + val smallIconResId = findSmallIconResourceId(resources, packageName, bundle) + setSmallIcon(smallIconResId) + + configureLargeIcon(this, largeIconBitmap, bundle, resources, packageName) + + val message = bundle.getString("message") ?: "" + setContentText(message) + + bundle.getString("subText")?.let { subText -> + setSubText(subText) + } + + val bigText = bundle.getString("bigText") ?: message + val style = when { + bigPictureBitmap != null -> NotificationCompat.BigPictureStyle() + .bigPicture(bigPictureBitmap) + .setBigContentTitle(title) + .setSummaryText(message) + else -> NotificationCompat.BigTextStyle().bigText(bigText) + } + setStyle(style) + + val notificationId = notificationIdString.hashCode() + setContentIntent(createOnTapIntent(context, notificationId, bundle.getString("deepLink"))) + setDeleteIntent(createOnDismissedIntent(context, notificationId, bundle.getString("deepLink"))) + + configureSoundAndVibration(this, bundle) + + setOngoing(bundle.getBoolean("ongoing", false)) + + // Handle color for Lollipop and above + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + setCategory(NotificationCompat.CATEGORY_CALL) + bundle.getString("color")?.let { color -> + setColor(Color.parseColor(color)) + } + } + + // Finally show the notification if conditions are met + if (!(isApplicationInForeground() && bundle.getBoolean("ignoreInForeground"))) { + val notification = build() + notification.defaults = notification.defaults or Notification.DEFAULT_LIGHTS + + if (bundle.containsKey("tag")) { + notificationManager.notify(bundle.getString("tag"), notificationId, notification) + } else { + notificationManager.notify(notificationId, notification) + } + } + } + + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to send push notification", e) + } + } + + private fun findSmallIconResourceId(resources: Resources, packageName: String, bundle: Bundle): Int { + val smallIcon = bundle.getString("smallIcon") + return when { + !smallIcon.isNullOrEmpty() -> + resources.getIdentifier(smallIcon, "mipmap", packageName) + smallIcon == null -> + resources.getIdentifier("ic_stat_notify_status", "drawable", packageName) + else -> { + val defaultIcon = resources.getIdentifier("ic_launcher", "mipmap", packageName) + defaultIcon.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info + } + } + } + + private fun configureLargeIcon( + builder: NotificationCompat.Builder, + largeIconBitmap: Bitmap?, + bundle: Bundle, + resources: Resources, + packageName: String + ) { + var finalLargeIcon = largeIconBitmap + + if (finalLargeIcon == null) { + val largeIcon = bundle.getString("largeIcon") + val largeIconResId = when { + !largeIcon.isNullOrEmpty() -> + resources.getIdentifier(largeIcon, "mipmap", packageName) + largeIcon == null -> + resources.getIdentifier("ic_launcher", "mipmap", packageName) + else -> 0 + } + + if (largeIconResId != 0 && (largeIcon != null || Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)) { + finalLargeIcon = BitmapFactory.decodeResource(resources, largeIconResId) + } + } + + bundle.getBundle("notificationAuthor")?.getString("icon")?.let { icon -> + val base64Image = icon.split(",")[1] + val decodedString = Base64.decode(base64Image, Base64.DEFAULT) + val decodedByte = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size) + builder.setLargeIcon(getCircleBitmap(decodedByte)) + } ?: finalLargeIcon?.let { icon -> + builder.setLargeIcon(icon) + } + } + + private fun configureSoundAndVibration( + builder: NotificationCompat.Builder, + bundle: Bundle + ) { + var soundUri: Uri? = null + + if (!bundle.containsKey("playSound") || bundle.getBoolean("playSound")) { + val soundName = bundle.getString("soundName") ?: "default" + soundUri = getSoundUri(soundName) + builder.setSound(soundUri) + } + + if (soundUri == null || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder.setSound(null) + } + + if (!bundle.containsKey("vibrate") || bundle.getBoolean("vibrate")) { + val vibration = if (bundle.containsKey("vibration")) { + bundle.getDouble("vibration").toLong() + } else DEFAULT_VIBRATION + + val finalVibration = if (vibration == 0L) DEFAULT_VIBRATION else vibration + builder.setVibrate(longArrayOf(0, finalVibration)) + } + } +} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationJsDelivery.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationJsDelivery.java deleted file mode 100644 index 585dca43d5..0000000000 --- a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationJsDelivery.java +++ /dev/null @@ -1,86 +0,0 @@ -package im.status.ethereum.pushnotifications; - -import android.os.Build; -import android.app.Application; -import android.app.NotificationManager; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.util.Log; - -import com.facebook.react.bridge.ReactContext; -import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.WritableMap; -import com.facebook.react.modules.core.DeviceEventManagerModule; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.util.Set; - -import static im.status.ethereum.pushnotifications.PushNotification.LOG_TAG; - -class PushNotificationJsDelivery { - - private ReactContext reactContext; - - PushNotificationJsDelivery(ReactContext context){ - reactContext = context; - } - - String convertJSON(Bundle bundle) { - try { - JSONObject json = convertJSONObject(bundle); - return json.toString(); - } catch (JSONException e) { - return null; - } - } - - // a Bundle is not a map, so we have to convert it explicitly - private JSONObject convertJSONObject(Bundle bundle) throws JSONException { - JSONObject json = new JSONObject(); - Set keys = bundle.keySet(); - for (String key : keys) { - Object value = bundle.get(key); - if (value instanceof Bundle) { - json.put(key, convertJSONObject((Bundle)value)); - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - json.put(key, JSONObject.wrap(value)); - } else { - json.put(key, value); - } - } - return json; - } - - void notifyNotification(Bundle bundle) { - String bundleString = convertJSON(bundle); - - WritableMap params = Arguments.createMap(); - params.putString("dataJSON", bundleString); - - sendEvent("remoteNotificationReceived", params); - } - - void notifyNotificationAction(Bundle bundle) { - String bundleString = convertJSON(bundle); - - WritableMap params = Arguments.createMap(); - params.putString("dataJSON", bundleString); - - sendEvent("notificationActionReceived", params); - } - - void sendEvent(String eventName, Object params) { - if (reactContext.hasActiveCatalystInstance()) { - reactContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) - .emit(eventName, params); - } - } - -} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationJsDelivery.kt b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationJsDelivery.kt new file mode 100644 index 0000000000..cb22cf4c3f --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationJsDelivery.kt @@ -0,0 +1,93 @@ +package im.status.ethereum.pushnotifications + +import android.os.Build +import android.app.Application +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.modules.core.DeviceEventManagerModule +import org.json.JSONException +import org.json.JSONObject +import im.status.ethereum.pushnotifications.PushNotification.Companion.LOG_TAG + +class PushNotificationJsDelivery( + private val reactContext: ReactContext +) { + /** + * Converts a Bundle to its JSON string representation. + * Returns null if conversion fails. + */ + fun convertJSON(bundle: Bundle): String? { + return try { + convertJSONObject(bundle).toString() + } catch (e: JSONException) { + null + } + } + + /** + * Converts a Bundle to a JSONObject, handling nested bundles and different value types. + * This explicit conversion is necessary because Bundle is not a standard map. + */ + @Throws(JSONException::class) + private fun convertJSONObject(bundle: Bundle): JSONObject { + return JSONObject().apply { + // Using Kotlin's for loop syntax and 'apply' scope function + bundle.keySet().forEach { key -> + bundle.get(key)?.let { value -> + when (value) { + is Bundle -> put(key, convertJSONObject(value)) + else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + put(key, JSONObject.wrap(value)) + } else { + put(key, value) + } + } + } + } + } + } + + /** + * Notifies about a received remote notification by sending an event to JavaScript + */ + fun notifyNotification(bundle: Bundle) { + convertJSON(bundle)?.let { bundleString -> + Arguments.createMap().apply { + putString("dataJSON", bundleString) + sendEvent("remoteNotificationReceived", this) + } + } + } + + /** + * Notifies about a notification action by sending an event to JavaScript + */ + fun notifyNotificationAction(bundle: Bundle) { + convertJSON(bundle)?.let { bundleString -> + Arguments.createMap().apply { + putString("dataJSON", bundleString) + sendEvent("notificationActionReceived", this) + } + } + } + + /** + * Sends an event to JavaScript if there's an active Catalyst instance + */ + fun sendEvent(eventName: String, params: Any) { + if (reactContext.hasActiveCatalystInstance()) { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(eventName, params) + } + } +} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPackage.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPackage.java deleted file mode 100644 index 0a4391fdd8..0000000000 --- a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPackage.java +++ /dev/null @@ -1,27 +0,0 @@ -package im.status.ethereum.pushnotifications; - -import im.status.ethereum.pushnotifications.PushNotification; -import com.facebook.react.ReactPackage; -import com.facebook.react.bridge.JavaScriptModule; -import com.facebook.react.bridge.NativeModule; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.uimanager.ViewManager; - -import java.util.Collections; -import java.util.List; - -public class PushNotificationPackage implements ReactPackage { - @Override - public List createNativeModules(ReactApplicationContext reactContext) { - return Collections.singletonList(new PushNotification(reactContext)); - } - - public List> createJSModules() { - return Collections.emptyList(); - } - - @Override - public List createViewManagers(ReactApplicationContext reactContext) { - return Collections.emptyList(); - } -} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPackage.kt b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPackage.kt new file mode 100644 index 0000000000..cd97ca09ff --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPackage.kt @@ -0,0 +1,24 @@ +package im.status.ethereum.pushnotifications + +import im.status.ethereum.pushnotifications.PushNotification +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.JavaScriptModule +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class PushNotificationPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(PushNotification(reactContext)) + } + + // Note: This function is deprecated in newer versions of React Native + // but kept for backwards compatibility + fun createJSModules(): List> { + return emptyList() + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPicturesAggregator.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPicturesAggregator.java deleted file mode 100644 index f34c28cbfd..0000000000 --- a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPicturesAggregator.java +++ /dev/null @@ -1,136 +0,0 @@ -package im.status.ethereum.pushnotifications; - -import androidx.annotation.Nullable; -import com.facebook.common.executors.CallerThreadExecutor; -import com.facebook.common.references.CloseableReference; -import com.facebook.datasource.DataSource; -import com.facebook.drawee.backends.pipeline.Fresco; -import com.facebook.imagepipeline.common.Priority; -import com.facebook.imagepipeline.core.ImagePipeline; -import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber; -import com.facebook.imagepipeline.image.CloseableImage; -import com.facebook.imagepipeline.request.ImageRequest; -import com.facebook.imagepipeline.request.ImageRequestBuilder; - -import android.util.Log; -import android.content.Context; -import android.graphics.Bitmap; -import android.net.Uri; -import java.util.concurrent.atomic.AtomicInteger; - -import static im.status.ethereum.pushnotifications.PushNotification.LOG_TAG; - -public class PushNotificationPicturesAggregator { - interface Callback { - public void call(Bitmap largeIconImage, Bitmap bigPictureImage); - } - - private AtomicInteger count = new AtomicInteger(0); - - private Bitmap largeIconImage; - private Bitmap bigPictureImage; - - private Callback callback; - - public PushNotificationPicturesAggregator(Callback callback) { - this.callback = callback; - } - - public void setBigPicture(Bitmap bitmap) { - this.bigPictureImage = bitmap; - this.finished(); - } - - public void setBigPictureUrl(Context context, String url) { - if(null == url) { - this.setBigPicture(null); - return; - } - - Uri uri = null; - - try { - uri = Uri.parse(url); - } catch(Exception ex) { - Log.e(LOG_TAG, "Failed to parse bigPictureUrl", ex); - this.setBigPicture(null); - return; - } - - final PushNotificationPicturesAggregator aggregator = this; - - this.downloadRequest(context, uri, new BaseBitmapDataSubscriber() { - @Override - public void onNewResultImpl(@Nullable Bitmap bitmap) { - aggregator.setBigPicture(bitmap); - } - - @Override - public void onFailureImpl(DataSource dataSource) { - aggregator.setBigPicture(null); - } - }); - } - - public void setLargeIcon(Bitmap bitmap) { - this.largeIconImage = bitmap; - this.finished(); - } - - public void setLargeIconUrl(Context context, String url) { - if(null == url) { - this.setLargeIcon(null); - return; - } - - Uri uri = null; - - try { - uri = Uri.parse(url); - } catch(Exception ex) { - Log.e(LOG_TAG, "Failed to parse largeIconUrl", ex); - this.setLargeIcon(null); - return; - } - - final PushNotificationPicturesAggregator aggregator = this; - - this.downloadRequest(context, uri, new BaseBitmapDataSubscriber() { - @Override - public void onNewResultImpl(@Nullable Bitmap bitmap) { - aggregator.setLargeIcon(bitmap); - } - - @Override - public void onFailureImpl(DataSource dataSource) { - aggregator.setLargeIcon(null); - } - }); - } - - private void downloadRequest(Context context, Uri uri, BaseBitmapDataSubscriber subscriber) { - ImageRequest imageRequest = ImageRequestBuilder - .newBuilderWithSource(uri) - .setRequestPriority(Priority.HIGH) - .setLowestPermittedRequestLevel(ImageRequest.RequestLevel.FULL_FETCH) - .build(); - - if(!Fresco.hasBeenInitialized()) { - Fresco.initialize(context); - } - - DataSource> dataSource = Fresco.getImagePipeline().fetchDecodedImage(imageRequest, context); - - dataSource.subscribe(subscriber, CallerThreadExecutor.getInstance()); - } - - private void finished() { - synchronized(this.count) { - int val = this.count.incrementAndGet(); - - if(val >= 2 && this.callback != null) { - this.callback.call(this.largeIconImage, this.bigPictureImage); - } - } - } -} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPicturesAggregator.kt b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPicturesAggregator.kt new file mode 100644 index 0000000000..f2a46b90c2 --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPicturesAggregator.kt @@ -0,0 +1,109 @@ +package im.status.ethereum.pushnotifications + +import androidx.annotation.Nullable +import com.facebook.common.executors.CallerThreadExecutor +import com.facebook.common.references.CloseableReference +import com.facebook.datasource.DataSource +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.imagepipeline.common.Priority +import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber +import com.facebook.imagepipeline.image.CloseableImage +import com.facebook.imagepipeline.request.ImageRequest +import com.facebook.imagepipeline.request.ImageRequestBuilder +import android.util.Log +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import java.util.concurrent.atomic.AtomicInteger +import im.status.ethereum.pushnotifications.PushNotification.Companion.LOG_TAG + +class PushNotificationPicturesAggregator( + private val callback: (Bitmap?, Bitmap?) -> Unit +) { + private val count = AtomicInteger(0) + private var largeIconImage: Bitmap? = null + private var bigPictureImage: Bitmap? = null + + fun setBigPicture(bitmap: Bitmap?) { + bigPictureImage = bitmap + finished() + } + + fun setBigPictureUrl(context: Context, url: String?) { + if (url == null) { + setBigPicture(null) + return + } + + val uri = try { + Uri.parse(url) + } catch (ex: Exception) { + Log.e(LOG_TAG, "Failed to parse bigPictureUrl", ex) + setBigPicture(null) + return + } + + downloadRequest(context, uri, object : BaseBitmapDataSubscriber() { + override fun onNewResultImpl(@Nullable bitmap: Bitmap?) { + setBigPicture(bitmap) + } + + override fun onFailureImpl(dataSource: DataSource>) { + setBigPicture(null) + } + }) + } + + fun setLargeIcon(bitmap: Bitmap?) { + largeIconImage = bitmap + finished() + } + + fun setLargeIconUrl(context: Context, url: String?) { + if (url == null) { + setLargeIcon(null) + return + } + + val uri = try { + Uri.parse(url) + } catch (ex: Exception) { + Log.e(LOG_TAG, "Failed to parse largeIconUrl", ex) + setLargeIcon(null) + return + } + + downloadRequest(context, uri, object : BaseBitmapDataSubscriber() { + override fun onNewResultImpl(@Nullable bitmap: Bitmap?) { + setLargeIcon(bitmap) + } + + override fun onFailureImpl(dataSource: DataSource>) { + setLargeIcon(null) + } + }) + } + + private fun downloadRequest(context: Context, uri: Uri, subscriber: BaseBitmapDataSubscriber) { + val imageRequest = ImageRequestBuilder + .newBuilderWithSource(uri) + .setRequestPriority(Priority.HIGH) + .setLowestPermittedRequestLevel(ImageRequest.RequestLevel.FULL_FETCH) + .build() + + if (!Fresco.hasBeenInitialized()) { + Fresco.initialize(context) + } + + val dataSource = Fresco.getImagePipeline().fetchDecodedImage(imageRequest, context) + dataSource.subscribe(subscriber, CallerThreadExecutor.getInstance()) + } + + private fun finished() { + synchronized(count) { + if (count.incrementAndGet() >= 2) { + callback(largeIconImage, bigPictureImage) + } + } + } +} diff --git a/modules/react-native-status/android/src/test/java/im/status/ethereum/pushnotifications/PushNotificationHelperTest.java b/modules/react-native-status/android/src/test/java/im/status/ethereum/pushnotifications/PushNotificationHelperTest.java deleted file mode 100644 index b2e18c93e9..0000000000 --- a/modules/react-native-status/android/src/test/java/im/status/ethereum/pushnotifications/PushNotificationHelperTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package im.status.ethereum.pushnotifications; - -import org.junit.Test; -import static org.junit.Assert.*; -import android.app.Application; -import android.app.NotificationManager; -import android.os.Bundle; - -import android.content.Intent; -import android.app.PendingIntent; -import android.content.IntentFilter; -import static org.mockito.Mockito.*; -import org.mockito.Spy; - -public class PushNotificationHelperTest { - - PushNotificationHelper helper; - - - @Test - public void testAddStatusMessage() { - final Application context = mock(Application.class); - final NotificationManager manager = mock(NotificationManager.class); - final IntentFilter filter = mock(IntentFilter.class); - - when(context.getSystemService(NotificationManager.class)).thenReturn(manager); - - // Create a spy for PushNotificationHelper - PushNotificationHelper realHelper = new PushNotificationHelper(context, filter); - helper = spy(realHelper); - - Bundle bundle = mock(Bundle.class); - Bundle personBundle = mock(Bundle.class); - - String conversationId = "conversation-id"; - String deepLink = "deep-link"; - - when(bundle.getString("conversationId")).thenReturn(conversationId); - when(bundle.getString("id")).thenReturn("id"); - when(bundle.getString("message")).thenReturn("message"); - when(bundle.getDouble("timestamp")).thenReturn(100000.0); - when(bundle.getString("deepLink")).thenReturn(deepLink); - - when(personBundle.getString("name")).thenReturn("name"); - when(bundle.getBundle("notificationAuthor")).thenReturn(personBundle); - - - doNothing().when(helper).showMessages(bundle); - - helper.addStatusMessage(bundle); - assertNotNull(helper.getMessageGroup(conversationId)); - } -} - diff --git a/modules/react-native-status/android/src/test/java/im/status/ethereum/pushnotifications/PushNotificationHelperTest.kt b/modules/react-native-status/android/src/test/java/im/status/ethereum/pushnotifications/PushNotificationHelperTest.kt new file mode 100644 index 0000000000..5477ab2795 --- /dev/null +++ b/modules/react-native-status/android/src/test/java/im/status/ethereum/pushnotifications/PushNotificationHelperTest.kt @@ -0,0 +1,49 @@ +package im.status.ethereum.pushnotifications + +import android.app.Application +import android.app.NotificationManager +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.mockito.Mockito.* +import org.mockito.Spy + +class PushNotificationHelperTest { + + private lateinit var helper: PushNotificationHelper + + @Test + fun testAddStatusMessage() { + val context = mock(Application::class.java) + val manager = mock(NotificationManager::class.java) + val filter = mock(IntentFilter::class.java) + + `when`(context.getSystemService(NotificationManager::class.java)).thenReturn(manager) + + // Create a spy for PushNotificationHelper + val realHelper = PushNotificationHelper(context, filter) + helper = spy(realHelper) + + val bundle = mock(Bundle::class.java) + val personBundle = mock(Bundle::class.java) + + val conversationId = "conversation-id" + val deepLink = "deep-link" + + `when`(bundle.getString("conversationId")).thenReturn(conversationId) + `when`(bundle.getString("id")).thenReturn("id") + `when`(bundle.getString("message")).thenReturn("message") + `when`(bundle.getDouble("timestamp")).thenReturn(100000.0) + `when`(bundle.getString("deepLink")).thenReturn(deepLink) + + `when`(personBundle.getString("name")).thenReturn("name") + `when`(bundle.getBundle("notificationAuthor")).thenReturn(personBundle) + + doNothing().`when`(helper).showMessages(bundle) + + helper.addStatusMessage(bundle) + assertNotNull(helper.getMessageGroup(conversationId)) + } +}