diff --git a/.env.release b/.env.release index 8015cb8769..eeba5520bc 100644 --- a/.env.release +++ b/.env.release @@ -17,3 +17,4 @@ SNOOPY=0 RPC_NETWORKS_ONLY=1 PARTITIONED_TOPIC=0 MOBILE_UI_FOR_DESKTOP=1 +LOCAL_NOTIFICATIONs=0 \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cc5fc3c932..ff89606f45 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ package="im.status.ethereum"> + @@ -68,6 +69,7 @@ + = Build.VERSION_CODES.O) { + NotificationManager notificationManager = + context.getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(new NotificationChannel(CHANNEL_ID, + "Status Service", + NotificationManager.IMPORTANCE_HIGH)); + } + String content = "Keep Status running to receive notifications"; + Notification notification = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_notify_status) + .setContentTitle("Background notification service opened") + .setContentText(content) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .build(); + // the id of the foreground notification MUST NOT be 0 + startForeground(1, notification); + return START_STICKY; + } +} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/module/NewMessageSignalHandler.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/NewMessageSignalHandler.java new file mode 100644 index 0000000000..155de35bc0 --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/NewMessageSignalHandler.java @@ -0,0 +1,313 @@ +package im.status.ethereum.module; + +import android.content.Context; +import android.content.ContentResolver; +import android.content.Intent; + +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONArray; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; + +import androidx.core.app.Person; +import androidx.core.app.Person.Builder; + +import android.util.Base64; + +import androidx.core.graphics.drawable.IconCompat; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; + +import androidx.core.app.NotificationManagerCompat; +import androidx.core.app.NotificationCompat; + +import android.os.Build; +import android.net.Uri; +import android.media.AudioAttributes; + +import android.util.Log; + +class NewMessageSignalHandler { + private static final String GROUP_STATUS_MESSAGES = "im.status.notifications.message"; + private static final String CHANNEL_NAME = "Status"; + private static final String CHANNEL_DESCRIPTION = "Get notifications on new messages and mentions"; + private static final String CHANNEL_ID = "status-chat-notifications"; + private static final String TAG = "StatusModule"; + private NotificationManager notificationManager; + private HashMap persons; + private HashMap chats; + private Context context; + private Intent serviceIntent; + private Boolean shouldRefreshNotifications; + + public NewMessageSignalHandler(Context context) { + // NOTE: when instanciated the NewMessageSignalHandler class starts a foreground service + // to keep the app running in the background in order to receive notifications + // call the stop() method in order to stop the service + this.context = context; + this.persons = new HashMap(); + this.chats = new HashMap(); + this.notificationManager = context.getSystemService(NotificationManager.class); + this.createNotificationChannel(); + this.shouldRefreshNotifications = false; + Log.e(TAG, "Starting Foreground Service"); + Intent serviceIntent = new Intent(context, ForegroundService.class); + context.startService(serviceIntent); + } + + public void stop() { + Log.e(TAG, "Stopping Foreground Service"); + Intent serviceIntent = new Intent(context, ForegroundService.class); + context.stopService(serviceIntent); + } + + private void createNotificationChannel() { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Uri soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.getPackageName() + "/" + R.raw.notification_sound); + NotificationChannel channel = new NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH); + channel.setDescription(CHANNEL_DESCRIPTION); + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build(); + channel.setSound(soundUri, audioAttributes); + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } + + public void notify(int notificationId, StatusChat chat) { + NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle("Me"); + ArrayList messages = chat.getMessages(); + for (int i = 0; i < messages.size(); i++) { + StatusMessage message = messages.get(i); + messagingStyle.addMessage(message.getText(), + message.getTimestamp(), + message.getAuthor()); + } + + 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(GROUP_STATUS_MESSAGES); + if (Build.VERSION.SDK_INT >= 21) { + builder.setVibrate(new long[0]); + } + notificationManager.notify(notificationId, builder.build()); + } + + public void refreshNotifications() { + NotificationCompat.InboxStyle summaryStyle = new NotificationCompat.InboxStyle(); + String summary = ""; + int notificationId = 2; // we start at 2 because the service is using 1 and can't use 0 + int messageCounter = 0; + Iterator chatIterator = chats.values().iterator(); + while(chatIterator.hasNext()) { + StatusChat chat = (StatusChat)chatIterator.next(); + notify(notificationId, chat); + notificationId++; + messageCounter += chat.getMessages().size(); + summaryStyle.addLine(chat.getSummary()); + summary += chat.getSummary() + "\n"; + } + // NOTE: this is necessary for old versions of Android, newer versions are + // building this group themselves and I was not able to make any change to + // what this group displays + NotificationCompat.Builder groupBuilder = + new NotificationCompat.Builder(context, CHANNEL_ID) + .setContentText(summary) + .setSmallIcon(R.drawable.ic_stat_notify_status) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setContentTitle("You got " + messageCounter + " messages in " + chats.size() + " chats") + .setStyle(summaryStyle + .setBigContentTitle("You got " + messageCounter + " messages in " + chats.size() + " chats")) + .setGroup(GROUP_STATUS_MESSAGES) + .setGroupSummary(true); + notificationManager.notify(notificationId, groupBuilder.build()); + } + + void handleNewMessageSignal(JSONObject newMessageSignal) { + try { + JSONArray chatsNewMessagesData = newMessageSignal.getJSONObject("event").getJSONArray("messages"); + for (int i = 0; i < chatsNewMessagesData.length(); i++) { + try { + upsertChat(chatsNewMessagesData.getJSONObject(i)); + } catch (JSONException e) { + Log.e(TAG, "JSON conversion failed: " + e.getMessage()); + } + } + if(shouldRefreshNotifications) { + refreshNotifications(); + shouldRefreshNotifications = false; + } + } catch (JSONException e) { + Log.e(TAG, "JSON conversion failed: " + e.getMessage()); + } + } + + private Person getPerson(String publicKey, String icon, String name) { + // TODO: invalidate cache if icon and name are not the same as + // the Person returned (in case the user set a different icon or username for instance) + // not critical it's just for notifications at the moment + // using a HashMap to cache Person because it's immutable + Person person = persons.get(publicKey); + if (person == null) { + String base64Image = icon.split(",")[1]; + byte[] decodedString = Base64.decode(base64Image, Base64.DEFAULT); + Bitmap decodedByte = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.length); + person = new Person.Builder().setIcon(IconCompat.createWithBitmap(decodedByte)).setName(name).build(); + persons.put(publicKey, person); + } + return person; + } + + private void upsertChat(JSONObject chatNewMessagesData) { + try { + JSONObject chatData = chatNewMessagesData.getJSONObject("chat"); + // NOTE: this is an exemple of chatData + // {"chatId":"contact-discovery-3622","filterId":"c0239d63f830e8b25f4bf7183c8d207f355a925b89514a17068cae4898e7f193", + // "symKeyId":"","oneToOne":true,"identity":"046599511623d7385b926ce709ac57d518dac10d451a81f75cd32c7fb4b1c...", + // "topic":"0xc446561b","discovery":false,"negotiated":false,"listen":true} + boolean oneToOne = chatData.getBoolean("oneToOne"); + // NOTE: for now we only notify one to one chats + // TODO: also notifiy on mentions, keywords and group chats + // TODO: one would have to pass the ens name and keywords to notify on when instanciating the class as well + // as have a method to add new ones after the handler is instanciated + if (oneToOne) { + JSONArray messagesData = chatNewMessagesData.getJSONArray("messages"); + + // there is no proper id for oneToOne chat in chatData so we peek into first message sig + // TODO: won't work for sync becaus it could be our own message + String id = messagesData.getJSONObject(0).getJSONObject("message").getString("sig"); + StatusChat chat = chats.get(id); + + + // if the chat was not already there, we create one + if (chat == null) { + chat = new StatusChat(id, oneToOne); + } + + ArrayList messages = chat.getMessages(); + // parse the new messages + for (int j = 0; j < messagesData.length(); j++) { + StatusMessage message = createMessage(messagesData.getJSONObject(j)); + if (message != null) { + messages.add(message); + } + } + + if (!messages.isEmpty()) { + chat.setMessages(messages); + chats.put(id, chat); + shouldRefreshNotifications = true; + } + } + } catch (JSONException e) { + Log.e(TAG, "JSON conversion failed: " + e.getMessage()); + } + } + + private StatusMessage createMessage(JSONObject messageData) { + try { + JSONObject metadata = messageData.getJSONObject("metadata"); + JSONObject authorMetadata = metadata.getJSONObject("author"); + JSONArray payload = new JSONArray(messageData.getString("payload")); + // NOTE: this is an exemple of payload we are currently working with + // it is in the transit format, which is basically JSON + // refer to `transport.message.transit.cljs` on react side for details + // ["~#c4",["7","text/plain","~:public-group-user-message",157201130275201,1572011302752,["^ ","~:chat-id","test","~:text","7"]]] + if (payload.getString(0).equals("~#c4")) { + Person author = getPerson(authorMetadata.getString("publicKey"), authorMetadata.getString("identicon"), authorMetadata.getString("alias")); + JSONArray payloadContent = payload.getJSONArray(1); + String text = payloadContent.getString(0); + Double timestamp = payloadContent.getDouble(4); + return new StatusMessage(author, timestamp.longValue(), text); + } + } catch (JSONException e) { + Log.e(TAG, "JSON conversion failed: " + e.getMessage()); + } + return null; + } +} + +class StatusChat { + private ArrayList messages; + private String id; + private String name; + private Boolean oneToOne; + + StatusChat(String id, Boolean oneToOne) { + this.id = id; + this.oneToOne = oneToOne; + this.messages = new ArrayList(); + } + + public String getId() { + return id; + } + + public String getName() { + //TODO this should be improved as it would rename the chat + // after our own user if we were posting from another device + // in 1-1 chats it should be the name of the user whose + // key is different than ours + return getLastMessage().getAuthor().getName().toString(); + } + + private StatusMessage getLastMessage() { + return messages.get(messages.size()-1); + } + + public long getTimestamp() { + return getLastMessage().getTimestamp(); + } + + public ArrayList getMessages() { + return messages; + } + + public void setMessages(ArrayList messages) { + this.messages = messages; + } + + public String getSummary() { + return "" + getLastMessage().getAuthor().getName() + ": " + getLastMessage().getText(); + } +} + + +class StatusMessage { + public Person getAuthor() { + return author; + } + + public long getTimestamp() { + return timestamp; + } + + public String getText() { + return text; + } + + private Person author; + private long timestamp; + private String text; + + StatusMessage(Person author, long timestamp, String text) { + this.author = author; + this.timestamp = timestamp; + this.text = text; + } +} 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 25352fabe4..9270bce90a 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 @@ -10,7 +10,9 @@ import android.net.Uri; import android.os.Build; import android.os.Environment; import android.preference.PreferenceManager; + import androidx.core.content.FileProvider; + import android.util.Log; import android.view.Window; import android.view.WindowManager; @@ -24,6 +26,8 @@ import com.facebook.react.bridge.LifecycleEventListener; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.WritableMap; import com.facebook.react.modules.core.DeviceEventManagerModule; @@ -32,6 +36,8 @@ import statusgo.Statusgo; import org.json.JSONException; import org.json.JSONObject; +import org.json.JSONArray; + import java.io.BufferedInputStream; import java.io.BufferedOutputStream; @@ -51,16 +57,18 @@ import java.util.zip.ZipOutputStream; import javax.annotation.Nullable; +import android.app.Service; + class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventListener, SignalHandler { private static final String TAG = "StatusModule"; private static final String logsZipFileName = "Status-debug-logs.zip"; private static final String gethLogFileName = "geth.log"; private static final String statusLogFileName = "Status.log"; - private static StatusModule module; private ReactApplicationContext reactContext; private boolean rootedDevice; + private NewMessageSignalHandler newMessageSignalHandler; StatusModule(ReactApplicationContext reactContext, boolean rootedDevice) { super(reactContext); @@ -82,46 +90,72 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL @Override public void onHostPause() { - } @Override public void onHostDestroy() { + Intent intent = new Intent(getReactApplicationContext(), ForegroundService.class); + getReactApplicationContext().stopService(intent); + } + @ReactMethod + public void enableNotifications() { + this.newMessageSignalHandler = new NewMessageSignalHandler(reactContext); + } + + @ReactMethod + public void disableNotifications() { + if (newMessageSignalHandler != null) { + newMessageSignalHandler.stop(); + newMessageSignalHandler = null; + } } private boolean checkAvailability() { - // We wait at least 10s for getCurrentActivity to return a value, - // otherwise we give up - for (int attempts = 0; attempts < 100; attempts++) { - if (getCurrentActivity() != null) { - return true; + // We wait at least 10s for getCurrentActivity to return a value, + // otherwise we give up + for (int attempts = 0; attempts < 100; attempts++) { + if (getCurrentActivity() != null) { + return true; + } + try { + Thread.sleep(100); + } catch (InterruptedException ex) { + if (getCurrentActivity() != null) { + return true; + } + Log.d(TAG, "Activity doesn't exist"); + return false; + } } - try { - Thread.sleep(100); - } catch (InterruptedException ex) { - if (getCurrentActivity() != null) { - return true; - } - Log.d(TAG, "Activity doesn't exist"); - return false; - } - } - Log.d(TAG, "Activity doesn't exist"); - return false; + Log.d(TAG, "Activity doesn't exist"); + return false; } public String getNoBackupDirectory() { - return this.getReactApplicationContext().getNoBackupFilesDir().getAbsolutePath(); + return this.getReactApplicationContext().getNoBackupFilesDir().getAbsolutePath(); } - public void handleSignal(String jsonEvent) { - Log.d(TAG, "Signal event: " + jsonEvent); - WritableMap params = Arguments.createMap(); - params.putString("jsonEvent", jsonEvent); - this.getReactApplicationContext().getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit("gethEvent", params); + public void handleSignal(final String jsonEventString) { + try { + final JSONObject jsonEvent = new JSONObject(jsonEventString); + String eventType = jsonEvent.getString("type"); + Log.d(TAG, "Signal event: " + jsonEventString); + // NOTE: the newMessageSignalHandler is only instanciated if the user + // enabled notifications in the app + if (newMessageSignalHandler != null) { + if (eventType.equals("messages.new")) { + newMessageSignalHandler.handleNewMessageSignal(jsonEvent); + } + } + WritableMap params = Arguments.createMap(); + params.putString("jsonEvent", jsonEventString); + this.getReactApplicationContext().getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit("gethEvent", params); + } catch (JSONException e) { + Log.e(TAG, "JSON conversion failed: " + e.getMessage()); + } } private File getLogsFile() { @@ -278,29 +312,27 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL } @ReactMethod - public void saveAccountAndLogin(final String accountData, final String password , final String config, final String subAccountsData) { + public void saveAccountAndLogin(final String accountData, final String password, final String config, final String subAccountsData) { Log.d(TAG, "saveAccountAndLogin"); String finalConfig = prepareDirAndUpdateConfig(config); String result = Statusgo.saveAccountAndLogin(accountData, password, finalConfig, subAccountsData); if (result.startsWith("{\"error\":\"\"")) { Log.d(TAG, "StartNode result: " + result); Log.d(TAG, "Geth node started"); - } - else { + } else { Log.e(TAG, "StartNode failed: " + result); } } @ReactMethod - public void saveAccountAndLoginWithKeycard(final String accountData, final String password , final String config, final String chatKey) { + public void saveAccountAndLoginWithKeycard(final String accountData, final String password, final String config, final String chatKey) { Log.d(TAG, "saveAccountAndLoginWithKeycard"); String finalConfig = prepareDirAndUpdateConfig(config); String result = Statusgo.saveAccountAndLoginWithKeycard(accountData, password, finalConfig, chatKey); if (result.startsWith("{\"error\":\"\"")) { Log.d(TAG, "StartNode result: " + result); Log.d(TAG, "Geth node started"); - } - else { + } else { Log.e(TAG, "StartNode failed: " + result); } } @@ -311,8 +343,7 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL String result = Statusgo.login(accountData, password); if (result.startsWith("{\"error\":\"\"")) { Log.d(TAG, "Login result: " + result); - } - else { + } else { Log.e(TAG, "Login failed: " + result); } } @@ -320,11 +351,11 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL @ReactMethod public void logout() { Log.d(TAG, "logout"); + disableNotifications(); String result = Statusgo.logout(); if (result.startsWith("{\"error\":\"\"")) { Log.d(TAG, "Logout result: " + result); - } - else { + } else { Log.e(TAG, "Logout failed: " + result); } } @@ -387,11 +418,11 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL } Runnable r = new Runnable() { - @Override - public void run() { - Statusgo.initKeystore(keydir); - } - }; + @Override + public void run() { + Statusgo.initKeystore(keydir); + } + }; StatusThreadPoolExecutor.getInstance().execute(r); } @@ -409,12 +440,12 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL } Runnable r = new Runnable() { - @Override - public void run() { - String result =Statusgo.openAccounts(rootDir); - callback.invoke(result); - } - }; + @Override + public void run() { + String result = Statusgo.openAccounts(rootDir); + callback.invoke(result); + } + }; StatusThreadPoolExecutor.getInstance().execute(r); } @@ -450,8 +481,7 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL String result = Statusgo.loginWithKeycard(accountData, password, chatKey); if (result.startsWith("{\"error\":\"\"")) { Log.d(TAG, "LoginWithKeycard result: " + result); - } - else { + } else { Log.e(TAG, "LoginWithKeycard failed: " + result); } } @@ -459,13 +489,13 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL private Boolean zip(File[] _files, File zipFile, Stack errorList) { final int BUFFER = 0x8000; - try { - BufferedInputStream origin = null; - FileOutputStream dest = new FileOutputStream(zipFile); - ZipOutputStream out = new ZipOutputStream(new BufferedOutputStream(dest)); - byte data[] = new byte[BUFFER]; + try { + BufferedInputStream origin = null; + FileOutputStream dest = new FileOutputStream(zipFile); + ZipOutputStream out = new ZipOutputStream(new BufferedOutputStream(dest)); + byte data[] = new byte[BUFFER]; - for (int i = 0; i < _files.length; i++) { + for (int i = 0; i < _files.length; i++) { final File file = _files[i]; if (file == null || !file.exists()) { continue; @@ -488,16 +518,16 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL Log.e(TAG, e.getMessage()); errorList.push(e.getMessage()); } - } + } out.close(); return true; - } catch (Exception e) { + } catch (Exception e) { Log.e(TAG, e.getMessage()); e.printStackTrace(); return false; - } + } } private void dumpAdbLogsTo(final FileOutputStream statusLogStream) throws IOException { @@ -518,13 +548,13 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL final Activity activity = getCurrentActivity(); new AlertDialog.Builder(activity) - .setTitle("Error") - .setMessage(message) - .setNegativeButton("Exit", new DialogInterface.OnClickListener() { - public void onClick(final DialogInterface dialog, final int id) { - dialog.dismiss(); - } - }).show(); + .setTitle("Error") + .setMessage(message) + .setNegativeButton("Exit", new DialogInterface.OnClickListener() { + public void onClick(final DialogInterface dialog, final int id) { + dialog.dismiss(); + } + }).show(); } @ReactMethod @@ -543,8 +573,7 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL OutputStreamWriter outputStreamWriter = new OutputStreamWriter(new FileOutputStream(dbFile)); outputStreamWriter.write(dbJson); outputStreamWriter.close(); - } - catch (IOException e) { + } catch (IOException e) { Log.e(TAG, "File write failed: " + e.toString()); showErrorMessage(e.getLocalizedMessage()); } @@ -567,7 +596,7 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL dumpAdbLogsTo(new FileOutputStream(statusLogFile)); final Stack errorList = new Stack(); - final Boolean zipped = zip(new File[] {dbFile, gethLogFile, statusLogFile}, zipFile, errorList); + final Boolean zipped = zip(new File[]{dbFile, gethLogFile, statusLogFile}, zipFile, errorList); if (zipped && zipFile.exists()) { zipFile.setReadable(true, false); callback.invoke(zipFile.getAbsolutePath()); @@ -579,8 +608,7 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL showErrorMessage(e.getLocalizedMessage()); e.printStackTrace(); return; - } - finally { + } finally { dbFile.delete(); statusLogFile.delete(); zipFile.deleteOnExit(); @@ -596,13 +624,13 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL } Runnable r = new Runnable() { - @Override - public void run() { - String res = Statusgo.addPeer(enode); + @Override + public void run() { + String res = Statusgo.addPeer(enode); - callback.invoke(res); - } - }; + callback.invoke(res); + } + }; StatusThreadPoolExecutor.getInstance().execute(r); } @@ -1026,138 +1054,138 @@ class StatusModule extends ReactContextBaseJavaModule implements LifecycleEventL } } - private Boolean is24Hour() { - return android.text.format.DateFormat.is24HourFormat(this.reactContext.getApplicationContext()); - } - - @ReactMethod - public void extractGroupMembershipSignatures(final String signaturePairs, final Callback callback) { - Log.d(TAG, "extractGroupMembershipSignatures"); - if (!checkAvailability()) { - callback.invoke(false); - return; + private Boolean is24Hour() { + return android.text.format.DateFormat.is24HourFormat(this.reactContext.getApplicationContext()); } - Runnable r = new Runnable() { - @Override - public void run() { - String result = Statusgo.extractGroupMembershipSignatures(signaturePairs); + @ReactMethod + public void extractGroupMembershipSignatures(final String signaturePairs, final Callback callback) { + Log.d(TAG, "extractGroupMembershipSignatures"); + if (!checkAvailability()) { + callback.invoke(false); + return; + } - callback.invoke(result); - } - }; + Runnable r = new Runnable() { + @Override + public void run() { + String result = Statusgo.extractGroupMembershipSignatures(signaturePairs); - StatusThreadPoolExecutor.getInstance().execute(r); - } + callback.invoke(result); + } + }; - @ReactMethod - public void signGroupMembership(final String content, final Callback callback) { - Log.d(TAG, "signGroupMembership"); - if (!checkAvailability()) { - callback.invoke(false); - return; + StatusThreadPoolExecutor.getInstance().execute(r); } - Runnable r = new Runnable() { - @Override - public void run() { - String result = Statusgo.signGroupMembership(content); + @ReactMethod + public void signGroupMembership(final String content, final Callback callback) { + Log.d(TAG, "signGroupMembership"); + if (!checkAvailability()) { + callback.invoke(false); + return; + } - callback.invoke(result); - } - }; + Runnable r = new Runnable() { + @Override + public void run() { + String result = Statusgo.signGroupMembership(content); - StatusThreadPoolExecutor.getInstance().execute(r); - } + callback.invoke(result); + } + }; - - @ReactMethod - public void updateMailservers(final String enodes, final Callback callback) { - Log.d(TAG, "updateMailservers"); - if (!checkAvailability()) { - callback.invoke(false); - return; + StatusThreadPoolExecutor.getInstance().execute(r); } - Runnable r = new Runnable() { - @Override - public void run() { - String res = Statusgo.updateMailservers(enodes); - callback.invoke(res); - } - }; + @ReactMethod + public void updateMailservers(final String enodes, final Callback callback) { + Log.d(TAG, "updateMailservers"); + if (!checkAvailability()) { + callback.invoke(false); + return; + } - StatusThreadPoolExecutor.getInstance().execute(r); - } + Runnable r = new Runnable() { + @Override + public void run() { + String res = Statusgo.updateMailservers(enodes); - @ReactMethod - public void chaosModeUpdate(final boolean on, final Callback callback) { - Log.d(TAG, "chaosModeUpdate"); - if (!checkAvailability()) { - callback.invoke(false); - return; + callback.invoke(res); + } + }; + + StatusThreadPoolExecutor.getInstance().execute(r); } - Runnable r = new Runnable() { - @Override - public void run() { - String res = Statusgo.chaosModeUpdate(on); + @ReactMethod + public void chaosModeUpdate(final boolean on, final Callback callback) { + Log.d(TAG, "chaosModeUpdate"); + if (!checkAvailability()) { + callback.invoke(false); + return; + } - callback.invoke(res); - } - }; + Runnable r = new Runnable() { + @Override + public void run() { + String res = Statusgo.chaosModeUpdate(on); - StatusThreadPoolExecutor.getInstance().execute(r); - } + callback.invoke(res); + } + }; - @ReactMethod(isBlockingSynchronousMethod = true) - public String generateAlias(final String seed) { - return Statusgo.generateAlias(seed); - } - - @ReactMethod(isBlockingSynchronousMethod = true) - public String identicon(final String seed) { - return Statusgo.identicon(seed); - } - - - @ReactMethod - public void getNodesFromContract(final String rpcEndpoint, final String contractAddress, final Callback callback) { - Log.d(TAG, "getNodesFromContract"); - if (!checkAvailability()) { - callback.invoke(false); - return; + StatusThreadPoolExecutor.getInstance().execute(r); } - Runnable r = new Runnable() { - @Override - public void run() { - String res = Statusgo.getNodesFromContract(rpcEndpoint, contractAddress); + @ReactMethod(isBlockingSynchronousMethod = true) + public String generateAlias(final String seed) { + return Statusgo.generateAlias(seed); + } - Log.d(TAG, res); - callback.invoke(res); - } - }; + @ReactMethod(isBlockingSynchronousMethod = true) + public String identicon(final String seed) { + return Statusgo.identicon(seed); + } - StatusThreadPoolExecutor.getInstance().execute(r); - } - @Override - public @Nullable - Map getConstants() { - HashMap constants = new HashMap(); + @ReactMethod + public void getNodesFromContract(final String rpcEndpoint, final String contractAddress, final Callback callback) { + Log.d(TAG, "getNodesFromContract"); + if (!checkAvailability()) { + callback.invoke(false); + return; + } - constants.put("is24Hour", this.is24Hour()); - constants.put("model", Build.MODEL); - constants.put("brand", Build.BRAND); - constants.put("buildId", Build.ID); - constants.put("deviceId", Build.BOARD); - return constants; - } + Runnable r = new Runnable() { + @Override + public void run() { + String res = Statusgo.getNodesFromContract(rpcEndpoint, contractAddress); - @ReactMethod - public void isDeviceRooted(final Callback callback) { - callback.invoke(rootedDevice); - } + Log.d(TAG, res); + callback.invoke(res); + } + }; + + StatusThreadPoolExecutor.getInstance().execute(r); + } + + @Override + public @Nullable + Map getConstants() { + HashMap constants = new HashMap(); + + constants.put("is24Hour", this.is24Hour()); + constants.put("model", Build.MODEL); + constants.put("brand", Build.BRAND); + constants.put("buildId", Build.ID); + constants.put("deviceId", Build.BOARD); + return constants; + } + + @ReactMethod + public void isDeviceRooted(final Callback callback) { + callback.invoke(rootedDevice); + } } diff --git a/modules/react-native-status/android/src/main/res/drawable-hdpi/ic_stat_notify_status.png b/modules/react-native-status/android/src/main/res/drawable-hdpi/ic_stat_notify_status.png new file mode 100644 index 0000000000..6466c90c39 Binary files /dev/null and b/modules/react-native-status/android/src/main/res/drawable-hdpi/ic_stat_notify_status.png differ diff --git a/modules/react-native-status/android/src/main/res/drawable-mdpi/ic_stat_notify_status.png b/modules/react-native-status/android/src/main/res/drawable-mdpi/ic_stat_notify_status.png new file mode 100644 index 0000000000..ef91b14a64 Binary files /dev/null and b/modules/react-native-status/android/src/main/res/drawable-mdpi/ic_stat_notify_status.png differ diff --git a/modules/react-native-status/android/src/main/res/drawable-xhdpi/ic_stat_notify_status.png b/modules/react-native-status/android/src/main/res/drawable-xhdpi/ic_stat_notify_status.png new file mode 100644 index 0000000000..014de5e163 Binary files /dev/null and b/modules/react-native-status/android/src/main/res/drawable-xhdpi/ic_stat_notify_status.png differ diff --git a/modules/react-native-status/android/src/main/res/drawable-xxhdpi/ic_stat_notify_status.png b/modules/react-native-status/android/src/main/res/drawable-xxhdpi/ic_stat_notify_status.png new file mode 100644 index 0000000000..19432801ef Binary files /dev/null and b/modules/react-native-status/android/src/main/res/drawable-xxhdpi/ic_stat_notify_status.png differ diff --git a/modules/react-native-status/android/src/main/res/drawable-xxxhdpi/ic_stat_notify_status.png b/modules/react-native-status/android/src/main/res/drawable-xxxhdpi/ic_stat_notify_status.png new file mode 100644 index 0000000000..911d98601a Binary files /dev/null and b/modules/react-native-status/android/src/main/res/drawable-xxxhdpi/ic_stat_notify_status.png differ diff --git a/modules/react-native-status/android/src/main/res/drawable/notification_icon.png b/modules/react-native-status/android/src/main/res/drawable/notification_icon.png new file mode 100644 index 0000000000..a040aa06c8 Binary files /dev/null and b/modules/react-native-status/android/src/main/res/drawable/notification_icon.png differ diff --git a/modules/react-native-status/android/src/main/res/raw/notification_sound.mp3 b/modules/react-native-status/android/src/main/res/raw/notification_sound.mp3 new file mode 100644 index 0000000000..81b702fd43 Binary files /dev/null and b/modules/react-native-status/android/src/main/res/raw/notification_sound.mp3 differ diff --git a/nix/mobile/android/maven-and-npm-deps/maven/maven-inputs.txt b/nix/mobile/android/maven-and-npm-deps/maven/maven-inputs.txt index 4701d34a02..e96c8148d9 100644 --- a/nix/mobile/android/maven-and-npm-deps/maven/maven-inputs.txt +++ b/nix/mobile/android/maven-and-npm-deps/maven/maven-inputs.txt @@ -61,6 +61,7 @@ https://dl.google.com/dl/android/maven2/com/android/databinding/baseLibrary/3.3. https://dl.google.com/dl/android/maven2/com/android/databinding/baseLibrary/3.4.1/baseLibrary-3.4.1 https://dl.google.com/dl/android/maven2/com/android/databinding/compilerCommon/3.0.1/compilerCommon-3.0.1 https://dl.google.com/dl/android/maven2/com/android/support/appcompat-v7/28.0.0/appcompat-v7-28.0.0 +https://dl.google.com/dl/android/maven2/com/android/support/support-compat/28.0.0/support-compat-28.0.0 https://dl.google.com/dl/android/maven2/com/android/tools/analytics-library/crash/26.2.1/crash-26.2.1 https://dl.google.com/dl/android/maven2/com/android/tools/analytics-library/crash/26.3.1/crash-26.3.1 https://dl.google.com/dl/android/maven2/com/android/tools/analytics-library/crash/26.4.1/crash-26.4.1 diff --git a/nix/mobile/android/maven-and-npm-deps/maven/maven-sources.nix b/nix/mobile/android/maven-and-npm-deps/maven/maven-sources.nix index 3245c88600..c37d528316 100644 --- a/nix/mobile/android/maven-and-npm-deps/maven/maven-sources.nix +++ b/nix/mobile/android/maven-and-npm-deps/maven/maven-sources.nix @@ -965,6 +965,21 @@ in { sha256 = "0lhp66q8rxf8cxylr8g6qjqy6s26prgrnmq133cnwx2r0ciyba53"; }; }; + "https://dl.google.com/dl/android/maven2/com/android/support/support-compat/28.0.0/support-compat-28.0.0" = + { + host = repositories.google; + path = + "com/android/support/support-compat/28.0.0/support-compat-28.0.0"; + type = "aar"; + pom = { + sha1 = "ededbbdbfc461c09f992371624bf7fa564748c36"; + sha256 = "06ln7psm2gm6nskdj48cgd2mrzs1mlk6m0px3jb0zz4249na0ybb"; + }; + jar = { + sha1 = "d252b640ed832cf8addc35ef0a9f9186dc7738a5"; + sha256 = "12hi2xc9qshbdr2jw96664i3va9wj0pjjhv9r2hrwgzavc0knzp1"; + }; + }; "https://dl.google.com/dl/android/maven2/com/android/tools/analytics-library/crash/26.2.1/crash-26.2.1" = { host = repositories.google; diff --git a/src/status_im/multiaccounts/core.cljs b/src/status_im/multiaccounts/core.cljs index 13c5730138..0487908c14 100644 --- a/src/status_im/multiaccounts/core.cljs +++ b/src/status_im/multiaccounts/core.cljs @@ -5,6 +5,7 @@ [status-im.i18n :as i18n] [status-im.multiaccounts.update.core :as multiaccounts.update] [status-im.native-module.core :as native-module] + [status-im.notifications.core :as notifications] [status-im.utils.build :as build] [status-im.utils.config :as config] [status-im.utils.fx :as fx] @@ -65,51 +66,72 @@ {:dev-mode? dev-mode?} {})) +(fx/defn switch-notifications + {:events [:multiaccounts.ui/notifications-switched]} + [cofx notifications-enabled?] + (fx/merge cofx + {(if notifications-enabled? + ::notifications/enable + ::notifications/disable) nil} + (multiaccounts.update/multiaccount-update + {:notifications-enabled? notifications-enabled?} + {}))) + (fx/defn switch-chaos-mode [{:keys [db] :as cofx} chaos-mode?] (when (:multiaccount db) (fx/merge cofx {::chaos-mode-changed chaos-mode?} - (multiaccounts.update/multiaccount-update {:chaos-mode? chaos-mode?} - {})))) + (multiaccounts.update/multiaccount-update + {:chaos-mode? chaos-mode?} + {})))) (fx/defn enable-notifications [cofx desktop-notifications?] - (multiaccounts.update/multiaccount-update cofx - {:desktop-notifications? desktop-notifications?} - {})) + (multiaccounts.update/multiaccount-update + cofx + {:desktop-notifications? desktop-notifications?} + {})) (fx/defn toggle-datasync [{:keys [db] :as cofx} enabled?] (let [settings (get-in db [:multiaccount :settings]) - warning {:utils/show-popup {:title (i18n/label :t/datasync-warning-title) - :content (i18n/label :t/datasync-warning-content)}}] + warning {:utils/show-popup + {:title (i18n/label :t/datasync-warning-title) + :content (i18n/label :t/datasync-warning-content)}}] (fx/merge cofx (when enabled? warning) - (multiaccounts.update/update-settings (assoc settings :datasync? enabled?) - {})))) + (multiaccounts.update/update-settings + (assoc settings :datasync? enabled?) + {})))) (fx/defn toggle-v1-messages [{:keys [db] :as cofx} enabled?] (let [settings (get-in db [:multiaccount :settings]) - warning {:utils/show-popup {:title (i18n/label :t/v1-messages-warning-title) - :content (i18n/label :t/v1-messages-warning-content)}}] + warning {:utils/show-popup + {:title (i18n/label :t/v1-messages-warning-title) + :content (i18n/label :t/v1-messages-warning-content)}}] (fx/merge cofx (when enabled? warning) - (multiaccounts.update/update-settings (assoc settings :v1-messages? enabled?) - {})))) + (multiaccounts.update/update-settings + (assoc settings :v1-messages? enabled?) + {})))) (fx/defn toggle-disable-discovery-topic [{:keys [db] :as cofx} enabled?] (let [settings (get-in db [:multiaccount :settings]) - warning {:utils/show-popup {:title (i18n/label :t/disable-discovery-topic-warning-title) - :content (i18n/label :t/disable-discovery-topic-warning-content)}}] + warning {:utils/show-popup + {:title + (i18n/label :t/disable-discovery-topic-warning-title) + :content + (i18n/label :t/disable-discovery-topic-warning-content)}}] (fx/merge cofx (when enabled? warning) - (multiaccounts.update/update-settings (assoc settings :disable-discovery-topic? enabled?) - {})))) + (multiaccounts.update/update-settings + (assoc settings :disable-discovery-topic? enabled?) + {})))) (fx/defn switch-web3-opt-in-mode [{:keys [db] :as cofx} opt-in] diff --git a/src/status_im/multiaccounts/login/core.cljs b/src/status_im/multiaccounts/login/core.cljs index ac327a3eaf..67b75bbf43 100644 --- a/src/status_im/multiaccounts/login/core.cljs +++ b/src/status_im/multiaccounts/login/core.cljs @@ -12,6 +12,7 @@ [status-im.i18n :as i18n] [status-im.native-module.core :as status] [status-im.node.core :as node] + [status-im.notifications.core :as notifications] [status-im.protocol.core :as protocol] [status-im.stickers.core :as stickers] [status-im.ui.screens.mobile-network-settings.events :as mobile-network] @@ -122,11 +123,12 @@ (when (not= network-id fetched-network-id) ;;TODO: this shouldn't happen but in case it does ;;we probably want a better error message - (utils/show-popup (i18n/label :t/ethereum-node-started-incorrectly-title) - (i18n/label :t/ethereum-node-started-incorrectly-description - {:network-id network-id - :fetched-network-id fetched-network-id}) - #(re-frame/dispatch [::close-app-confirmed]))))}]}) + (utils/show-popup + (i18n/label :t/ethereum-node-started-incorrectly-title) + (i18n/label :t/ethereum-node-started-incorrectly-description + {:network-id network-id + :fetched-network-id fetched-network-id}) + #(re-frame/dispatch [::close-app-confirmed]))))}]}) (defn deserialize-config [{:keys [multiaccount current-network networks]}] @@ -144,14 +146,18 @@ (fx/defn get-config-callback {:events [::get-config-callback]} [{:keys [db] :as cofx} config] - (let [[{:keys [address] :as multiaccount} current-network networks] (deserialize-config config) + (let [[{:keys [address notifications-enabled?] :as multiaccount} + current-network networks] (deserialize-config config) network-id (str (get-in networks [current-network :config :NetworkId]))] (fx/merge cofx - {:db (assoc db - :networks/current-network current-network - :networks/networks networks - :multiaccount (convert-multiaccount-addresses - multiaccount))} + (cond-> {:db (assoc db + :networks/current-network current-network + :networks/networks networks + :multiaccount (convert-multiaccount-addresses + multiaccount))} + (and platform/android? + notifications-enabled?) + (assoc ::notifications/enable nil)) ;; NOTE: initializing mailserver depends on user mailserver ;; preference which is why we wait for config callback (protocol/initialize-protocol {:default-mailserver true}) @@ -169,9 +175,15 @@ [{:keys [db] :as cofx} address password save-password?] (let [auth-method (:auth-method db) new-auth-method (if save-password? - (when-not (or (= "biometric" auth-method) (= "password" auth-method)) - (if (= auth-method "biometric-prepare") "biometric" "password")) - (when (and auth-method (not= auth-method "none")) "none"))] + (when-not (or (= "biometric" auth-method) + (= "password" auth-method)) + (if (= auth-method "biometric-prepare") + "biometric" + "password")) + (when (and auth-method + (not= auth-method + "none")) + "none"))] (fx/merge cofx {:db (assoc db :chats/loading? true) ::json-rpc/call diff --git a/src/status_im/native_module/core.cljs b/src/status_im/native_module/core.cljs index 954e64b95e..135e6b7bfd 100644 --- a/src/status_im/native_module/core.cljs +++ b/src/status_im/native_module/core.cljs @@ -30,6 +30,12 @@ config #(callback (types/json->clj %)))) +(defn enable-notifications [] + (.enableNotifications (status))) + +(defn disable-notifications [] + (.disableNotifications (status))) + (defn save-account-and-login "NOTE: beware, the password has to be sha3 hashed" [multiaccount-data hashed-password config accounts-data] diff --git a/src/status_im/notifications/core.cljs b/src/status_im/notifications/core.cljs new file mode 100644 index 0000000000..95b4a5dd03 --- /dev/null +++ b/src/status_im/notifications/core.cljs @@ -0,0 +1,13 @@ +(ns status-im.notifications.core + (:require [re-frame.core :as re-frame] + [status-im.native-module.core :as status])) + +(re-frame/reg-fx + ::enable + (fn [_] + (status/enable-notifications))) + +(re-frame/reg-fx + ::disable + (fn [_] + (status/disable-notifications))) diff --git a/src/status_im/transport/message/core.cljs b/src/status_im/transport/message/core.cljs index 81613925ca..7caf006cef 100644 --- a/src/status_im/transport/message/core.cljs +++ b/src/status_im/transport/message/core.cljs @@ -6,6 +6,7 @@ [status-im.utils.handlers :as handlers] [status-im.ethereum.json-rpc :as json-rpc] [status-im.ethereum.core :as ethereum] + [status-im.native-module.core :as status] [status-im.transport.message.contact :as contact] [status-im.transport.message.protocol :as protocol] [status-im.transport.message.transit :as transit] @@ -197,4 +198,3 @@ :params [confirmations] :on-success #(log/debug "successfully confirmed messages") :on-failure #(log/error "failed to confirm messages" %)})))) - diff --git a/src/status_im/ui/screens/profile/user/views.cljs b/src/status_im/ui/screens/profile/user/views.cljs index 9b93db55e5..8b03c28950 100644 --- a/src/status_im/ui/screens/profile/user/views.cljs +++ b/src/status_im/ui/screens/profile/user/views.cljs @@ -111,7 +111,7 @@ (defn- flat-list-content [preferred-name registrar tribute-to-talk active-contacts-count show-backup-seed? - keycard-account?] + keycard-account? notifications-enabled?] [(cond-> {:title (or (when registrar preferred-name) :t/ens-usernames) :subtitle (if registrar @@ -151,15 +151,23 @@ [(when show-backup-seed? [components.common/counter {:size 22} 1]) :chevron] :on-press #(re-frame/dispatch [:navigate-to :privacy-and-security])} - ;; TODO commented out for now because it will be enabled for android notifications - #_{:icon :main-icons/notification + (when (and platform/android? + config/local-notifications?) + {:icon :main-icons/notification :title :t/notifications :accessibility-label :notifications-button - ;; TODO commented out for now, uncomment when notifications-settings view - ;; is populated. Then remove :on-press below - ;; :on-press #(re-frame/dispatch [:navigate-to :notifications-settings]) - :on-press #(.openURL react/linking "app-settings://notification/status-im") - :accessories [:chevron]} + :on-press + #(re-frame/dispatch + [:multiaccounts.ui/notifications-switched (not notifications-enabled?)]) + :accessories + [[react/switch + {:track-color #js {:true colors/blue :false nil} + :value notifications-enabled? + :on-value-change + #(re-frame/dispatch + [:multiaccounts.ui/notifications-switched + (not notifications-enabled?)]) + :disabled false}]]}) {:icon :main-icons/mobile :title :t/sync-settings :accessibility-label :sync-settings-button @@ -212,7 +220,8 @@ seed-backed-up? mnemonic keycard-key-uid - address] + address + notifications-enabled?] :as multiaccount} @(re-frame/subscribe [:multiaccount]) active-contacts-count @(re-frame/subscribe [:contacts/active-count]) tribute-to-talk @(re-frame/subscribe [:tribute-to-talk/profile]) @@ -225,7 +234,7 @@ (flat-list-content preferred-name registrar tribute-to-talk active-contacts-count show-backup-seed? - keycard-key-uid) + keycard-key-uid notifications-enabled?) list-ref scroll-y])) diff --git a/src/status_im/utils/config.cljs b/src/status_im/utils/config.cljs index d0de80bc17..fc935432e9 100644 --- a/src/status_im/utils/config.cljs +++ b/src/status_im/utils/config.cljs @@ -31,6 +31,8 @@ (def max-message-delivery-attempts (js/parseInt (get-config :MAX_MESSAGE_DELIVERY_ATTEMPTS "6"))) (def contract-nodes-enabled? (enabled? (get-config :CONTRACT_NODES "0"))) (def mobile-ui-for-desktop? (enabled? (get-config :MOBILE_UI_FOR_DESKTOP "0"))) +;; NOTE: only disabled in releases +(def local-notifications? (enabled? (get-config :LOCAL_NOTIFICATIONS "1"))) ;; CONFIG VALUES (def log-level