diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4254941dd8..58360d5fb5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -75,6 +75,7 @@ + getPackages() { StatusPackage statusPackage = new StatusPackage(RootUtil.isDeviceRooted()); + List packages = new PackageList(this).getPackages(); packages.add(statusPackage); packages.add(new ReactNativeDialogsPackage()); packages.add(new RNStatusKeycardPackage()); + packages.add(new PushNotificationPackage()); return packages; } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 849681f8c3..8d5b9f10f1 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,5 +1,7 @@ PODS: - boost-for-react-native (1.63.0) + - BVLinearGradient (2.5.6): + - React - CocoaAsyncSocket (7.6.4) - CocoaLibEvent (1.0.0) - DoubleConversion (1.1.6) @@ -373,6 +375,7 @@ PODS: - Yoga (~> 1.14) DEPENDENCIES: + - BVLinearGradient (from `../node_modules/react-native-linear-gradient`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - FBReactNativeSpec (from `../node_modules/react-native/Libraries/FBReactNativeSpec`) @@ -473,6 +476,8 @@ SPEC REPOS: - YogaKit EXTERNAL SOURCES: + BVLinearGradient: + :path: "../node_modules/react-native-linear-gradient" DoubleConversion: :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" FBLazyVector: @@ -588,6 +593,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost-for-react-native: 39c7adb57c4e60d6c5479dd8623128eb5b3f0f2c + BVLinearGradient: e3aad03778a456d77928f594a649e96995f1c872 CocoaAsyncSocket: 694058e7c0ed05a9e217d1b3c7ded962f4180845 CocoaLibEvent: 2fab71b8bd46dd33ddb959f7928ec5909f838e3f DoubleConversion: 5805e889d232975c086db112ece9ed034df7a0b2 diff --git a/ios/StatusIm/AppDelegate.m b/ios/StatusIm/AppDelegate.m index 0787c8ed96..f5e8b7b919 100644 --- a/ios/StatusIm/AppDelegate.m +++ b/ios/StatusIm/AppDelegate.m @@ -171,5 +171,18 @@ didReceiveNotificationResponse:(UNNotificationResponse *)response { [RNCPushNotificationIOS didReceiveLocalNotification:notification]; } +// Manage notifications while app is in the foreground +- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler +{ + NSDictionary *userInfo = notification.request.content.userInfo; + + NSString *notificationType = userInfo[@"notificationType"]; // check your notification type + if (![notificationType isEqual: @"local-notification"]) { // we silence all notifications which are not local + completionHandler(UNNotificationPresentationOptionNone); + return; + } + + completionHandler(UNAuthorizationOptionSound | UNAuthorizationOptionAlert | UNAuthorizationOptionBadge); +} @end diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/module/LocalNotificationsService.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/LocalNotificationsService.java new file mode 100644 index 0000000000..6de1503443 --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/LocalNotificationsService.java @@ -0,0 +1,20 @@ +package im.status.ethereum.module; + +import android.content.Intent; +import android.os.Bundle; +import com.facebook.react.HeadlessJsTaskService; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.jstasks.HeadlessJsTaskConfig; +import javax.annotation.Nullable; + +public class LocalNotificationsService extends HeadlessJsTaskService { + + @Override + protected @Nullable HeadlessJsTaskConfig getTaskConfig(Intent intent) { + Bundle extras = intent.getExtras(); + if (extras != null) { + return new HeadlessJsTaskConfig("LocalNotifications", Arguments.fromBundle(extras), 60000, true); + } + return null; + } +} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/module/StatusModule.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/StatusModule.java index 157f8f3442..bc09459a4c 100644 --- a/modules/react-native-status/android/src/main/java/im/status/ethereum/module/StatusModule.java +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/StatusModule.java @@ -7,6 +7,7 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; +import android.os.Bundle; import android.os.Build; import android.os.Environment; @@ -162,6 +163,18 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL newMessageSignalHandler.handleNewMessageSignal(jsonEvent); } } + + if(eventType.equals("local-notifications")) { + Context ctx = this.getReactApplicationContext(); + Intent intent = new Intent(ctx, LocalNotificationsService.class); + Bundle bundle = new Bundle(); + + bundle.putString("event", jsonEventString); + intent.putExtras(bundle); + + ctx.startService(intent); + } + WritableMap params = Arguments.createMap(); params.putString("jsonEvent", jsonEventString); this.getReactApplicationContext().getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit("gethEvent", params); @@ -1137,6 +1150,18 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL Statusgo.startWallet(); } + @ReactMethod + public void stopLocalNotifications() { + Log.d(TAG, "stopLocalNotifications"); + Statusgo.stopLocalNotifications(); + } + + @ReactMethod + public void startLocalNotifications() { + Log.d(TAG, "startLocalNotifications"); + Statusgo.startLocalNotifications(); + } + @ReactMethod public void setBlankPreviewFlag(final Boolean blankPreview) { final SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this.reactContext); 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 new file mode 100644 index 0000000000..fffca9330f --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotification.java @@ -0,0 +1,110 @@ +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 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; + + public PushNotification(ReactApplicationContext reactContext) { + super(reactContext); + reactContext.addActivityEventListener(this); + Application applicationContext = (Application) reactContext.getApplicationContext(); + + pushNotificationHelper = new PushNotificationHelper(applicationContext); + + 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) { + 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); + } +} 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 new file mode 100644 index 0000000000..88dbcec836 --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationActions.java @@ -0,0 +1,90 @@ +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.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) { + PushNotificationHelper helper = new PushNotificationHelper((Application) context.getApplicationContext()); + + 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/PushNotificationHelper.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationHelper.java new file mode 100644 index 0000000000..9f7f94697f --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationHelper.java @@ -0,0 +1,549 @@ +// 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.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.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 androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; + +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.Map; + +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 PushNotificationHelper(Application context) { + this.context = context; + } + + 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 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.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); + } + } + + 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 = Integer.parseInt(notificationIdString); + + PendingIntent pendingIntent = PendingIntent.getActivity(context, notificationID, intent, + PendingIntent.FLAG_UPDATE_CURRENT); + + 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); + notification.setContentIntent(pendingIntent); + + 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); + + 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; + } + +} 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 new file mode 100644 index 0000000000..585dca43d5 --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationJsDelivery.java @@ -0,0 +1,86 @@ +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/PushNotificationPackage.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPackage.java new file mode 100644 index 0000000000..0a4391fdd8 --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPackage.java @@ -0,0 +1,27 @@ +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/PushNotificationPicturesAggregator.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPicturesAggregator.java new file mode 100644 index 0000000000..f34c28cbfd --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/pushnotifications/PushNotificationPicturesAggregator.java @@ -0,0 +1,136 @@ +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/ios/RCTStatus/RCTStatus.m b/modules/react-native-status/ios/RCTStatus/RCTStatus.m index 9048ebd119..5d5dbd54ec 100644 --- a/modules/react-native-status/ios/RCTStatus/RCTStatus.m +++ b/modules/react-native-status/ios/RCTStatus/RCTStatus.m @@ -754,6 +754,22 @@ RCT_EXPORT_METHOD(startWallet) { StatusgoStartWallet(); } +RCT_EXPORT_METHOD(stopLocalNotifications) { +#if DEBUG + NSLog(@"StopLocalNotifications() method called"); +#endif +StatusgoStopLocalNotifications(); +} + +RCT_EXPORT_METHOD(startLocalNotifications) { +#if DEBUG + NSLog(@"StartLocalNotifications() method called"); +#endif +StatusgoStartLocalNotifications(); +} + + + RCT_EXPORT_METHOD(setBlankPreviewFlag:(BOOL *)newValue) { NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults]; diff --git a/src/mocks/js_dependencies.cljs b/src/mocks/js_dependencies.cljs index f8b57679e6..4154a9b3a4 100644 --- a/src/mocks/js_dependencies.cljs +++ b/src/mocks/js_dependencies.cljs @@ -18,6 +18,7 @@ (def react-native (clj->js {:NativeModules {:RNGestureHandlerModule {:Direction (fn [])} + :PushNotifications {} :ReanimatedModule {:configureProps (fn [])}} :View {} diff --git a/src/status_im/core.cljs b/src/status_im/core.cljs index c0288871a8..84f4b91037 100644 --- a/src/status_im/core.cljs +++ b/src/status_im/core.cljs @@ -11,6 +11,7 @@ [reagent.core :as reagent] [reagent.impl.batching :as batching] [status-im.i18n :as i18n] + [status-im.notifications.local :as notifications] [status-im.native-module.core :as status] [status-im.ui.components.react :as react] [status-im.ui.screens.views :as views] @@ -87,4 +88,7 @@ (when platform/android? (status/set-soft-input-mode status/adjust-resize)) (.registerComponent ^js app-registry "StatusIm" #(reagent/reactify-component root)) + (notifications/listen-notifications) + (when platform/android? + (.registerHeadlessTask ^js app-registry "LocalNotifications" notifications/handle)) (snoopy/subscribe!)) diff --git a/src/status_im/ethereum/json_rpc.cljs b/src/status_im/ethereum/json_rpc.cljs index 0996ebcdd4..9b20c4507c 100644 --- a/src/status_im/ethereum/json_rpc.cljs +++ b/src/status_im/ethereum/json_rpc.cljs @@ -109,6 +109,8 @@ "wakuext_enablePushNotificationsBlockMentions" {} "wakuext_disablePushNotificationsBlockMentions" {} "status_chats" {} + "localnotifications_switchWalletNotifications" {} + "localnotifications_notificationPreferences" {} "wallet_getTransfers" {} "wallet_getTokensBalances" {} "wallet_getBlocksByAddress" {} diff --git a/src/status_im/ethereum/tokens.cljs b/src/status_im/ethereum/tokens.cljs index 049eb05a58..18a8dcc4b4 100644 --- a/src/status_im/ethereum/tokens.cljs +++ b/src/status_im/ethereum/tokens.cljs @@ -790,7 +790,7 @@ {:name "Status Test Token" :symbol :STT :decimals 18 - :address "0x43d5adc3b49130a575ae6e4b00dfa4bc55c71621"}]) + :address "0xc55cf4b03948d7ebc8b9e8bad92643703811d162"}]) :xdai (resolve-icons :xdai @@ -801,6 +801,18 @@ :custom []}) +(defn normalize-chain [tokens] + (reduce (fn [acc {:keys [address] :as token}] + (assoc acc address token)) + {} + tokens)) + +(def all-tokens-normalized + (reduce-kv (fn [m k v] + (assoc m k (normalize-chain v))) + {} + all-default-tokens)) + (defn nfts-for [all-tokens] (filter :nft? (vals all-tokens))) diff --git a/src/status_im/multiaccounts/login/core.cljs b/src/status_im/multiaccounts/login/core.cljs index a81d2ae609..826801248f 100644 --- a/src/status_im/multiaccounts/login/core.cljs +++ b/src/status_im/multiaccounts/login/core.cljs @@ -36,6 +36,11 @@ (fn [[key-uid account-data hashed-password]] (status/login key-uid account-data hashed-password))) +(re-frame/reg-fx + ::enable-local-notifications + (fn [] + (status/start-local-notifications))) + (defn rpc->accounts [accounts] (reduce (fn [acc {:keys [chat type wallet] :as account}] (if chat @@ -58,8 +63,10 @@ [{:keys [db] :as cofx} accounts custom-tokens favourites new-account?] (fx/merge cofx - {:db (assoc db :multiaccount/accounts - (rpc->accounts accounts))} + {:db (assoc db :multiaccount/accounts + (rpc->accounts accounts)) + ;; NOTE: Local notifications should be enabled only after wallet was started + ::enable-local-notifications nil} (wallet/initialize-tokens custom-tokens) (wallet/initialize-favourites favourites) (wallet/update-balances nil new-account?) @@ -180,12 +187,12 @@ (fx/defn get-settings-callback {:events [::get-settings-callback]} [{:keys [db] :as cofx} settings] - (let [{:keys [notifications-enabled?] + (let [{:keys [notifications-enabled?] :networks/keys [current-network networks] - :as settings} + :as settings} (data-store.settings/rpc->settings settings) multiaccount (dissoc settings :networks/current-network :networks/networks) - network-id (str (get-in networks [current-network :config :NetworkId]))] + network-id (str (get-in networks [current-network :config :NetworkId]))] (fx/merge cofx (cond-> {:db (-> db (dissoc :multiaccounts/login) @@ -242,6 +249,7 @@ :on-success #(re-frame/dispatch [::protocol/initialize-protocol {:mailservers (or % [])}])} {:method "settings_getSettings" :on-success #(re-frame/dispatch [::get-settings-callback %])}]} + (notifications/load-notification-preferences) (when save-password? (keychain/save-user-password key-uid password)) (keychain/save-auth-method key-uid (or new-auth-method auth-method keychain/auth-method-none))))) diff --git a/src/status_im/multiaccounts/logout/core.cljs b/src/status_im/multiaccounts/logout/core.cljs index 56ee559d5f..497236764e 100644 --- a/src/status_im/multiaccounts/logout/core.cljs +++ b/src/status_im/multiaccounts/logout/core.cljs @@ -16,6 +16,7 @@ (fx/merge cofx {::logout nil ::multiaccounts/webview-debug-changed false + ::disable-local-notifications nil :keychain/clear-user-password key-uid ::init/open-multiaccounts #(re-frame/dispatch [::init/initialize-multiaccounts % {:logout? logout?}])} (notifications/logout-disable) @@ -24,6 +25,11 @@ (chaos-mode/stop-checking) (init/initialize-app-db)))) +(re-frame/reg-fx + ::disable-local-notifications + (fn [] + (status/stop-local-notifications))) + (fx/defn logout {:events [:logout :multiaccounts.logout.ui/logout-confirmed]} [cofx] diff --git a/src/status_im/native_module/core.cljs b/src/status_im/native_module/core.cljs index 9ecb1b476d..3f630d9c96 100644 --- a/src/status_im/native_module/core.cljs +++ b/src/status_im/native_module/core.cljs @@ -271,6 +271,14 @@ (log/debug "[native-module] start-wallet") (.startWallet ^js (status))) +(defn stop-local-notifications [] + (log/debug "[native-module] stop-local-notifications") + (.stopLocalNotifications ^js (status))) + +(defn start-local-notifications [] + (log/debug "[native-module] start-local-notifications") + (.startLocalNotifications ^js (status))) + (defn set-blank-preview-flag [flag] (log/debug "[native-module] set-blank-preview-flag") (.setBlankPreviewFlag ^js (status) flag)) diff --git a/src/status_im/navigation.cljs b/src/status_im/navigation.cljs index 747faabb6a..5393842304 100644 --- a/src/status_im/navigation.cljs +++ b/src/status_im/navigation.cljs @@ -28,15 +28,19 @@ (log/debug :navigate-replace view-id params) (navigation/navigate-replace (name view-id) params))) +(defn- all-screens-params [db view screen-params] + (cond-> db + (and (seq screen-params) (:screen screen-params) (:params screen-params)) + (all-screens-params (:screen screen-params) (:params screen-params)) + + (seq screen-params) + (assoc-in [:navigation/screen-params view] screen-params))) + (fx/defn navigate-to-cofx [{:keys [db]} go-to-view-id screen-params] {:db - (cond-> (assoc db :view-id go-to-view-id) - ;; TODO: Inspect the need of screen-params - (and (seq screen-params) (:screen screen-params) (:params screen-params)) - (assoc-in [:navigation/screen-params (:screen screen-params)] (:params screen-params)) - (seq screen-params) - (assoc-in [:navigation/screen-params go-to-view-id] screen-params)) + (-> (assoc db :view-id go-to-view-id) + (all-screens-params go-to-view-id screen-params)) ::navigate-to [go-to-view-id screen-params]}) (fx/defn navigate-to diff --git a/src/status_im/node/core.cljs b/src/status_im/node/core.cljs index 7a957160b5..bb25df0e45 100644 --- a/src/status_im/node/core.cljs +++ b/src/status_im/node/core.cljs @@ -115,6 +115,7 @@ :always (assoc :WalletConfig {:Enabled true} + :LocalNotificationsConfig {:Enabled true} :BrowsersConfig {:Enabled true} :PermissionsConfig {:Enabled true} :MailserversConfig {:Enabled true} diff --git a/src/status_im/notifications/android.cljs b/src/status_im/notifications/android.cljs new file mode 100644 index 0000000000..dfda12327d --- /dev/null +++ b/src/status_im/notifications/android.cljs @@ -0,0 +1,17 @@ +(ns status-im.notifications.android + (:require ["react-native" :as react-native] + [quo.platform :as platform] + [taoensso.timbre :as log])) + +(defn pn-android [] + (when platform/android? + (.-PushNotification ^js (.-NativeModules react-native)))) + +(defn present-local-notification [opts] + (.presentLocalNotification ^js (pn-android) (clj->js opts))) + +(defn create-channel [{:keys [channel-id channel-name]}] + (.createChannel ^js (pn-android) + #js {:channelId channel-id + :channelName channel-name} + #(log/info "Notifications create channel:" %))) diff --git a/src/status_im/notifications/core.cljs b/src/status_im/notifications/core.cljs index f2b67a8fed..8cad3f6d63 100644 --- a/src/status_im/notifications/core.cljs +++ b/src/status_im/notifications/core.cljs @@ -4,6 +4,7 @@ [status-im.utils.fx :as fx] [status-im.multiaccounts.update.core :as multiaccounts.update] ["@react-native-community/push-notification-ios" :default pn-ios] + [status-im.notifications.android :as pn-android] [status-im.native-module.core :as status] [quo.platform :as platform] [status-im.utils.config :as config] @@ -64,7 +65,10 @@ ::enable (fn [_] (if platform/android? - (status/enable-notifications) + (do + (pn-android/create-channel {:channel-id "status-im-notifications" + :channel-name "Status push notifications"}) + (status/enable-notifications)) (enable-ios-notifications)))) (re-frame/reg-fx @@ -224,3 +228,37 @@ :on-success #(do (log/info "[push-notifications] servers fetched" %) (re-frame/dispatch [::servers-fetched %]))}]}) + +;; Wallet transactions + +(fx/defn handle-preferences-load + {:events [::preferences-loaded]} + [{:keys [db]} preferences] + {:db (assoc db :push-notifications/preferences preferences)}) + +(fx/defn load-notification-preferences + {:events [::load-notification-preferences]} + [cofx] + {::json-rpc/call [{:method "localnotifications_notificationPreferences" + :params [] + :on-success #(re-frame/dispatch [::preferences-loaded %])}]}) + +(defn preference= [x y] + (and (= (:service x) (:service y)) + (= (:event x) (:event y)) + (= (:identifier x) (:identifier y)))) + +(defn- update-preference [all new] + (conj (filter (comp not (partial preference= new)) all) new)) + +(fx/defn switch-transaction-notifications + {:events [::switch-transaction-notifications]} + [{:keys [db] :as cofx} enabled?] + {:db (update db :push-notifications/preferences update-preference {:enabled (not enabled?) + :service "wallet" + :event "transaction" + :identifier "all"}) + ::json-rpc/call [{:method "localnotifications_switchWalletNotifications" + :params [(not enabled?)] + :on-success #(log/info "[push-notifications] switch-transaction-notifications successful" %) + :on-error #(log/error "[push-notifications] switch-transaction-notifications error" %)}]}) diff --git a/src/status_im/notifications/local.cljs b/src/status_im/notifications/local.cljs new file mode 100644 index 0000000000..717cf3d70e --- /dev/null +++ b/src/status_im/notifications/local.cljs @@ -0,0 +1,118 @@ +(ns status-im.notifications.local + (:require [taoensso.timbre :as log] + [clojure.string :as cstr] + [status-im.utils.fx :as fx] + [status-im.ethereum.decode :as decode] + ["@react-native-community/push-notification-ios" :default pn-ios] + [status-im.notifications.android :as pn-android] + [status-im.ethereum.tokens :as tokens] + [status-im.utils.utils :as utils] + [status-im.utils.types :as types] + [status-im.utils.money :as money] + [status-im.ethereum.core :as ethereum] + [status-im.i18n :as i18n] + [quo.platform :as platform] + [re-frame.core :as re-frame] + [status-im.ui.components.react :as react] + [cljs-bean.core :as bean])) + +(def default-erc20-token + {:symbol :ERC20 + :decimals 18 + :name "ERC20"}) + +(def notification-event-ios "localNotification") +(def notification-event-android "remoteNotificationReceived") + +(defn local-push-ios [{:keys [title message user-info]}] + (.presentLocalNotification pn-ios #js {:alertBody message + :alertTitle title + ;; NOTE: Use a special type to hide in Obj-C code other notifications + :userInfo (bean/->js (merge user-info + {:notificationType "local-notification"}))})) + +(defn local-push-android [{:keys [title message icon user-info]}] + (pn-android/present-local-notification (merge {:channelId "status-im-notifications" + :title title + :message message + :showBadge false} + (when user-info + {:userInfo (bean/->js user-info)}) + (when icon + {:largeIconUrl (:uri (react/resolve-asset-source icon))})))) + +(defn handle-notification-press [{{deep-link :deepLink} :userInfo + interaction :userInteraction}] + (when (and deep-link + (or platform/ios? + (and platform/android? interaction))) + (re-frame/dispatch [:universal-links/handle-url deep-link]))) + +(defn listen-notifications [] + (if platform/ios? + (.addEventListener ^js pn-ios + notification-event-ios + (fn [notification] + (handle-notification-press {:userInfo (bean/bean (.getData ^js notification))}))) + (.addListener ^js react/device-event-emitter + notification-event-android + (fn [^js data] + (when (and data (.-dataJSON data)) + (handle-notification-press (types/json->clj (.-dataJSON data)))))))) + +(defn create-notification [{{:keys [state from to fromAccount toAccount value erc20 contract network]} + :body + :as notification}] + (let [chain (ethereum/chain-id->chain-keyword network) + token (if erc20 + (get-in tokens/all-tokens-normalized [(keyword chain) + (cstr/lower-case contract)] + default-erc20-token) + (tokens/native-currency (keyword chain))) + amount (money/wei->ether (decode/uint value)) + to (or (:name toAccount) (utils/get-shortened-address to)) + from (or (:name fromAccount) (utils/get-shortened-address from)) + title (case state + "inbound" (i18n/label :t/push-inbound-transaction {:value amount + :currency (:symbol token)}) + "outbound" (i18n/label :t/push-outbound-transaction {:value amount + :currency (:symbol token)}) + "failed" (i18n/label :t/push-failed-transaction {:value amount + :currency (:symbol token)}) + nil) + description (case state + "inbound" (i18n/label :t/push-inbound-transaction-body {:from from + :to to}) + "outbound" (i18n/label :t/push-outbound-transaction-body {:from from + :to to}) + "failed" (i18n/label :t/push-failed-transaction-body {:value amount + :currency (:symbol token) + :to to}) + nil)] + {:title title + :icon (get-in token [:icon :source]) + :user-info notification + :message description})) + +(re-frame/reg-fx + ::local-push-ios + (fn [evt] + (-> evt create-notification local-push-ios))) + +(fx/defn process + [_ evt] + (when platform/ios? + {::local-push-ios evt})) + +(defn handle [] + (fn [^js message] + (let [evt (types/json->clj (.-event message))] + (js/Promise. + (fn [on-success on-error] + (try + (when (= "local-notifications" (:type evt)) + (-> (:event evt) create-notification local-push-android)) + (on-success) + (catch :default e + (log/warn "failed to handle background notification" e) + (on-error e)))))))) diff --git a/src/status_im/router/core.cljs b/src/status_im/router/core.cljs index 7d9dc5f82d..c942beab3b 100644 --- a/src/status_im/router/core.cljs +++ b/src/status_im/router/core.cljs @@ -44,6 +44,7 @@ "browser/" browser-extractor ["p/" :chat-id] :private-chat "g/" group-chat-extractor + ["wallet/" :account] :wallet-account ["u/" :user-id] :user ["user/" :user-id] :user ["referral/" :referrer] :referrals} @@ -168,6 +169,10 @@ {:type :referrals :referrer referrer}) +(defn match-wallet-account [{:keys [account]}] + {:type :wallet-account + :account (when account (string/lower-case account))}) + (defn handle-uri [chain uri cb] (let [{:keys [handler route-params query-params]} (match-uri uri)] (log/info "[router] uri " uri " matched " handler " with " route-params) @@ -196,6 +201,9 @@ (= handler :referrals) (cb (match-referral route-params)) + (= handler :wallet-account) + (cb (match-wallet-account route-params)) + (ethereum/address? uri) (cb (address->eip681 uri)) diff --git a/src/status_im/signals/core.cljs b/src/status_im/signals/core.cljs index 74404daf92..069d18c696 100644 --- a/src/status_im/signals/core.cljs +++ b/src/status_im/signals/core.cljs @@ -6,6 +6,7 @@ [status-im.multiaccounts.model :as multiaccounts.model] [status-im.transport.filters.core :as transport.filters] [status-im.transport.message.core :as transport.message] + [status-im.notifications.local :as local-notifications] [status-im.utils.fx :as fx] [taoensso.timbre :as log])) @@ -61,4 +62,5 @@ "whisper.filter.added" (transport.filters/handle-negotiated-filter cofx (js->clj event-js :keywordize-keys true)) "messages.new" (transport.message/process-response cofx event-js) "wallet" (ethereum.subscriptions/new-wallet-event cofx (js->clj event-js :keywordize-keys true)) + "local-notifications" (local-notifications/process cofx (js->clj event-js :keywordize-keys true)) (log/debug "Event " type " not handled")))) diff --git a/src/status_im/subs.cljs b/src/status_im/subs.cljs index 87ddd2299e..f6c71f1f42 100644 --- a/src/status_im/subs.cljs +++ b/src/status_im/subs.cljs @@ -43,7 +43,8 @@ status-im.ui.screens.keycard.settings.subs status-im.ui.screens.keycard.pin.subs status-im.ui.screens.keycard.setup.subs - [status-im.chat.models.mentions :as mentions])) + [status-im.chat.models.mentions :as mentions] + [status-im.notifications.core :as notifications])) ;; TOP LEVEL =========================================================================================================== @@ -209,6 +210,7 @@ ;; push notifications (reg-root-key-sub :push-notifications/servers :push-notifications/servers) +(reg-root-key-sub :push-notifications/preferences :push-notifications/preferences) ;;GENERAL ============================================================================================================== @@ -520,13 +522,16 @@ :<- [:multiaccount/accounts] :<- [:get-screen-params :wallet-account] (fn [[accounts acc]] - (some #(when (= (:address %) (:address acc)) %) accounts))) + (some #(when (= (string/lower-case (:address %)) + (string/lower-case (:address acc))) %) accounts))) (re-frame/reg-sub :account-by-address :<- [:multiaccount/accounts] (fn [accounts [_ address]] - (some #(when (= (:address %) address) %) accounts))) + (when (and (string? address)) + (some #(when (= (string/lower-case (:address %)) + (string/lower-case address)) %) accounts)))) (re-frame/reg-sub :multiple-multiaccounts? @@ -2339,3 +2344,13 @@ :<- [:networks/manage] (fn [manage] (not-any? :error (vals manage)))) + +;; NOTIFICATIONS + +(re-frame/reg-sub + :notifications/wallet-transactions + :<- [:push-notifications/preferences] + (fn [pref] + (first (filter #(notifications/preference= % {:service "wallet" + :event "transaction" + :identifier "all"}) pref)))) diff --git a/src/status_im/ui/components/react.cljs b/src/status_im/ui/components/react.cljs index e2d0c58788..b2ff393854 100644 --- a/src/status_im/ui/components/react.cljs +++ b/src/status_im/ui/components/react.cljs @@ -32,6 +32,7 @@ (def image-class (reagent/adapt-react-class (.-Image react-native))) (defn image-get-size [uri callback] (.getSize (.-Image react-native) uri callback)) +(defn resolve-asset-source [uri] (js->clj (.resolveAssetSource (.-Image react-native) uri) :keywordize-keys true)) (def linear-gradient (reagent/adapt-react-class LinearGradient)) diff --git a/src/status_im/ui/screens/notifications_settings/views.cljs b/src/status_im/ui/screens/notifications_settings/views.cljs index 4f2041f39a..52d2c26b9a 100644 --- a/src/status_im/ui/screens/notifications_settings/views.cljs +++ b/src/status_im/ui/screens/notifications_settings/views.cljs @@ -4,6 +4,7 @@ [reagent.core :as reagent] [status-im.i18n :as i18n] [quo.core :as quo] + [quo.platform :as platform] [quo.design-system.colors :as quo-colors] [status-im.notifications.core :as notifications] [status-im.ui.components.colors :as colors] @@ -13,44 +14,79 @@ (defonce server (reagent/atom "")) -(defn notifications-settings [] +(defn local-notifications [] + [:<> + (let [{:keys [enabled]} @(re-frame/subscribe [:notifications/wallet-transactions])] + [quo/separator {:color (:ui-02 @quo-colors/theme) + :style {:margin-vertical 8}}] + [quo/list-header (i18n/label :t/local-notifications)] + [quo/list-item + {:size :small + :title (i18n/label :t/notifications-transactions) + :accessibility-label :notifications-button + :active enabled + :on-press #(re-frame/dispatch + [::notifications/switch-transaction-notifications enabled]) + :accessory :switch}])]) + +(defn notifications-settings-ios [] (let [{:keys [remote-push-notifications-enabled? push-notifications-block-mentions? push-notifications-from-contacts-only?]} @(re-frame/subscribe [:multiaccount])] - [react/view {:flex 1} - [topbar/topbar {:title (i18n/label :t/notification-settings)}] - [react/scroll-view {:style {:flex 1} - :content-container-style {:padding-vertical 8}} - [quo/list-item - {:size :small - :title (i18n/label :t/show-notifications) - :accessibility-label :notifications-button - :active remote-push-notifications-enabled? - :on-press #(re-frame/dispatch [::notifications/switch (not remote-push-notifications-enabled?)]) - :accessory :switch}] - [react/view {:height 1 - :background-color (:ui-02 @quo-colors/theme) - :margin-vertical 8}] - [quo/list-header (i18n/label :t/notifications-preferences)] - [quo/list-item - {:size :small - :title (i18n/label :t/notifications-non-contacts) - :accessibility-label :notifications-button - :active (and remote-push-notifications-enabled? - (not push-notifications-from-contacts-only?)) - :on-press #(re-frame/dispatch - [::notifications/switch-non-contacts (not push-notifications-from-contacts-only?)]) - :accessory :switch}] - [quo/list-item - {:size :small - :title (i18n/label :t/allow-mention-notifications) - :accessibility-label :notifications-button - :active (and remote-push-notifications-enabled? - (not push-notifications-block-mentions?)) - :on-press #(re-frame/dispatch - [::notifications/switch-block-mentions (not push-notifications-block-mentions?)]) - :accessory :switch}]]])) + [:<> + [quo/list-item + {:size :small + :title (i18n/label :t/show-notifications) + :accessibility-label :notifications-button + :active remote-push-notifications-enabled? + :on-press #(re-frame/dispatch [::notifications/switch (not remote-push-notifications-enabled?)]) + :accessory :switch}] + [quo/separator {:color (:ui-02 @quo-colors/theme) + :style {:margin-vertical 8}}] + [quo/list-header (i18n/label :t/notifications-preferences)] + [quo/list-item + {:size :small + :title (i18n/label :t/notifications-non-contacts) + :accessibility-label :notifications-button + :active (and remote-push-notifications-enabled? + (not push-notifications-from-contacts-only?)) + :on-press #(re-frame/dispatch + [::notifications/switch-non-contacts (not push-notifications-from-contacts-only?)]) + :accessory :switch}] + [quo/list-item + {:size :small + :title (i18n/label :t/allow-mention-notifications) + :accessibility-label :notifications-button + :active (and remote-push-notifications-enabled? + (not push-notifications-block-mentions?)) + :on-press #(re-frame/dispatch + [::notifications/switch-block-mentions (not push-notifications-block-mentions?)]) + :accessory :switch}] + [local-notifications]])) + +(defn notifications-settings-android [] + (let [{:keys [notifications-enabled?]} + @(re-frame/subscribe [:multiaccount])] + [:<> + [quo/list-item + {:icon :main-icons/notification + :title (i18n/label :t/notifications) + :accessibility-label :notifications-settings-button + :active notifications-enabled? + :on-press #(re-frame/dispatch + [::notifications/switch (not notifications-enabled?)]) + :accessory :switch}] + [local-notifications]])) + +(defn notifications-settings [] + [react/view {:flex 1} + [topbar/topbar {:title (i18n/label :t/notification-settings)}] + [react/scroll-view {:style {:flex 1} + :content-container-style {:padding-vertical 8}} + (if platform/ios? + [notifications-settings-ios] + [notifications-settings-android])]]) (defn notifications-advanced-settings [] (let [{:keys [remote-push-notifications-enabled? diff --git a/src/status_im/ui/screens/profile/user/views.cljs b/src/status_im/ui/screens/profile/user/views.cljs index 52dcc65300..981e8b49f1 100644 --- a/src/status_im/ui/screens/profile/user/views.cljs +++ b/src/status_im/ui/screens/profile/user/views.cljs @@ -3,7 +3,6 @@ [reagent.core :as reagent] [status-im.i18n :as i18n] [quo.core :as quo] - [status-im.notifications.core :as notifications] [status-im.ui.components.colors :as colors] [status-im.multiaccounts.core :as multiaccounts] [status-im.ui.components.common.common :as components.common] @@ -98,7 +97,6 @@ (defn content [] (let [{:keys [preferred-name mnemonic - notifications-enabled? keycard-pairing]} @(re-frame/subscribe [:multiaccount]) chain @(re-frame/subscribe [:chain-keyword]) @@ -153,23 +151,12 @@ :accessibility-label :appearance-settings-button :chevron true :on-press #(re-frame/dispatch [:navigate-to :appearance])}] - (if platform/ios? - [quo/list-item - {:icon :main-icons/notification - :title (i18n/label :t/notifications) - :accessibility-label :notifications-settings-button - :chevron true - :on-press #(re-frame/dispatch [:navigate-to :notifications])}] - (when (and platform/android? - config/local-notifications?) - [quo/list-item - {:icon :main-icons/notification - :title (i18n/label :t/notifications) - :accessibility-label :notifications-settings-button - :active notifications-enabled? - :on-press #(re-frame/dispatch - [::notifications/switch (not notifications-enabled?)]) - :accessory :switch}])) + [quo/list-item + {:icon :main-icons/notification + :title (i18n/label :t/notifications) + :accessibility-label :notifications-settings-button + :chevron true + :on-press #(re-frame/dispatch [:navigate-to :notifications])}] [quo/list-item {:icon :main-icons/mobile :title (i18n/label :t/sync-settings) diff --git a/src/status_im/utils/universal_links/core.cljs b/src/status_im/utils/universal_links/core.cljs index 0d556cbc84..5710b16ffd 100644 --- a/src/status_im/utils/universal_links/core.cljs +++ b/src/status_im/utils/universal_links/core.cljs @@ -1,5 +1,6 @@ (ns status-im.utils.universal-links.core (:require [goog.string :as gstring] + [clojure.string :as string] [re-frame.core :as re-frame] [status-im.multiaccounts.model :as multiaccounts.model] [status-im.chat.models :as chat] @@ -84,6 +85,20 @@ ;; TODO: Use only for testing {::acquisition/check-referrer referrer}) +(defn existing-account? [{:keys [db]} address] + (when address + (some #(when (= (string/lower-case (:address %)) + (string/lower-case address)) %) + (:multiaccount/accounts db)))) + +(fx/defn handle-wallet-account [cofx {address :account}] + (when-let [account (existing-account? cofx address)] + (navigation/navigate-to-cofx cofx + :tabs + {:screen :wallet-stack + :params {:screen :wallet-account + :params account}}))) + (defn handle-not-found [full-url] (log/info "universal-links: no handler for " full-url)) @@ -98,13 +113,14 @@ {:events [::match-value]} [cofx url {:keys [type] :as data}] (case type - :group-chat (handle-group-chat cofx data) - :public-chat (handle-public-chat cofx data) - :private-chat (handle-private-chat cofx data) - :contact (handle-view-profile cofx data) - :browser (handle-browse cofx data) - :eip681 (handle-eip681 cofx data) - :referrals (handle-referrer-url cofx data) + :group-chat (handle-group-chat cofx data) + :public-chat (handle-public-chat cofx data) + :private-chat (handle-private-chat cofx data) + :contact (handle-view-profile cofx data) + :browser (handle-browse cofx data) + :eip681 (handle-eip681 cofx data) + :referrals (handle-referrer-url cofx data) + :wallet-account (handle-wallet-account cofx data) (handle-not-found url))) (fx/defn route-url diff --git a/status-go-version.json b/status-go-version.json index 5e667444d9..6767ddc61f 100644 --- a/status-go-version.json +++ b/status-go-version.json @@ -2,7 +2,7 @@ "_comment": "DO NOT EDIT THIS FILE BY HAND. USE 'scripts/update-status-go.sh ' instead", "owner": "status-im", "repo": "status-go", - "version": "v0.62.14", - "commit-sha1": "b3880027710ee7a28bbdeacbe099412627485d62", - "src-sha256": "0g3c1rb0hnqr6wk09n4zk9985qs9m7v44f03vfdsrbzvzx93wgkd" + "version": "v0.62.16", + "commit-sha1": "d04e54e54e8ea85c9bd35aff5482e064f5ee76b0", + "src-sha256": "11ry2ncpf5i177c641nvs27b96niwbfwqc79vn3dqkdawr7y0aiy" } diff --git a/test/appium/tests/atomic/chats/test_one_to_one.py b/test/appium/tests/atomic/chats/test_one_to_one.py index d69f50361e..15b0d04286 100644 --- a/test/appium/tests/atomic/chats/test_one_to_one.py +++ b/test/appium/tests/atomic/chats/test_one_to_one.py @@ -47,6 +47,7 @@ class TestMessagesOneToOneChatMultiple(MultipleDeviceTestCase): default_username_1 = profile_1.default_username_text.text profile_1.settings_button.click() profile_1.profile_notifications_button.click() + profile_1.profile_notifications_toggle_button.click() device_1_home = profile_1.get_back_to_home_view() device_2_public_key = device_2_home.get_public_key_and_username() diff --git a/test/appium/views/profile_view.py b/test/appium/views/profile_view.py index 88f89bb79f..722f2f3e1b 100644 --- a/test/appium/views/profile_view.py +++ b/test/appium/views/profile_view.py @@ -467,6 +467,12 @@ class PrifileNotificationsButton(BaseButton): self.locator = self.Locator.accessibility_id("notifications-settings-button") +class PrifileNotificationsToggleButton(BaseButton): + def __init__(self, driver): + super().__init__(driver) + self.locator = self.Locator.xpath_selector("//*[@content-desc='notifications-settings-button']") + + class RemovePictureButton(BaseButton): def __init__(self, driver): super().__init__(driver) @@ -645,6 +651,7 @@ class ProfileView(BaseView): self.confirm_edit_button = ConfirmEditButton(self.driver) self.cross_icon = CrossIcon(self.driver) self.profile_notifications_button = PrifileNotificationsButton(self.driver) + self.profile_notifications_toggle_button = PrifileNotificationsToggleButton(self.driver) self.advanced_button = AdvancedButton(self.driver) self.log_level_setting = LogLevelSetting(self.driver) self.debug_mode_toggle = DebugModeToggle(self.driver) diff --git a/translations/en.json b/translations/en.json index ed8a1019e3..a1e68dd27d 100644 --- a/translations/en.json +++ b/translations/en.json @@ -816,10 +816,17 @@ "notifications-preferences": "Notification preferences", "notifications-switch": "Show notifications", "notifications-non-contacts": "Notifications from non-contacts", - "send-push-notifications": "Send push notifications", + "notifications-transactions": "Wallet transactions", + "local-notifications": "Local notifications", "send-push-notifications-description": "When disabled, the person receiving your messages won't be notified of their arrival", "push-notifications-server-enabled": "Server enabled", "push-notifications-servers": "Push notification servers", + "push-inbound-transaction": "You received {{value}} {{currency}}", + "push-outbound-transaction": "You sent {{value}} {{currency}}", + "push-failed-transaction": "Your transaction failed", + "push-inbound-transaction-body": "From {{from}} to {{to}}", + "push-outbound-transaction-body": "From {{from}} to {{to}}", + "push-failed-transaction-body": "{{value}} {{currency}} to {{to}}", "allow-mention-notifications": "Show @ mentions", "server": "Server", "specify-server-public-key": "Enter server public key",