support for local notification service on Android

- add option in profile on Android to enable local notifications
- use foreground service to keep the app alive when running in the background
- implement enable and disbable notification function in status module

When enabling notifications, a foreground service is started that displays
a sticky notification to make the user aware that the app is running in the
background.
Notifications are updated whenever a new.message signal is handled on java side.
Currently only one to one chats are generating notifications but that can be
easily extended to other types of messages, including mentions and keywords.
The ens name of the user as well as keywords to follow should then be passed
to the native side when calling the enable function.

Signed-off-by: yenda <eric@status.im>
This commit is contained in:
yenda 2019-10-21 15:09:57 +02:00
parent a6c2518de7
commit 36ad6fb762
No known key found for this signature in database
GPG Key ID: 0095623C0069DCE6
23 changed files with 701 additions and 225 deletions

View File

@ -17,3 +17,4 @@ SNOOPY=0
RPC_NETWORKS_ONLY=1
PARTITIONED_TOPIC=0
MOBILE_UI_FOR_DESKTOP=1
LOCAL_NOTIFICATIONs=0

View File

@ -4,6 +4,7 @@
package="im.status.ethereum">
<!-- non-dangerous permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.NFC"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@ -68,6 +69,7 @@
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity"/>
<service android:name="im.status.ethereum.module.ForegroundService"></service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View File

@ -110,6 +110,7 @@ var TopLevel = {
"DeviceEventEmitter" : function () {},
"Dimensions" : function () {},
"dispatch" : function () {},
"disableNotifications" : function () {},
"displayNotification" : function () {},
"dividedBy" : function () {},
"DocumentDirectoryPath" : function () {},
@ -117,6 +118,7 @@ var TopLevel = {
"dy" : function () {},
"ease" : function () {},
"Easing" : function () {},
"enableNotifications" : function () {},
"enableVibration" : function () {},
"encode" : function () {},
"encodeURIComponent" : function () {},

View File

@ -24,6 +24,6 @@ android {
dependencies {
implementation 'com.facebook.react:react-native:+' // from node_modules
compile 'com.github.status-im:function:0.0.1'
implementation 'androidx.appcompat:appcompat:1.0.0'
implementation(group: 'status-im', name: 'status-go', version: getStatusGoSHA1(), ext: 'aar')
}

View File

@ -0,0 +1,50 @@
package im.status.ethereum.module;
import android.content.Context;
import android.content.Intent;
import android.app.Service;
import android.os.IBinder;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import android.os.Build;
public class ForegroundService extends Service {
private static final String CHANNEL_ID = "status-service";
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
// NOTE: recent versions of Android require the service to display
// a sticky notification to inform the user that the service is running
Context context = getApplicationContext();
// Create the NotificationChannel, but only on API 26+ because
// the NotificationChannel class is new and not in the support library
if (Build.VERSION.SDK_INT >= 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;
}
}

View File

@ -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<String, Person> persons;
private HashMap<String, StatusChat> 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<String, Person>();
this.chats = new HashMap<String, StatusChat>();
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<StatusMessage> 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<StatusChat> 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<StatusMessage> 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<StatusMessage> 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<StatusMessage>();
}
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<StatusMessage> getMessages() {
return messages;
}
public void setMessages(ArrayList<StatusMessage> messages) {
this.messages = messages;
}
public String getSummary() {
return "<b>" + getLastMessage().getAuthor().getName() + "</b>: " + 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;
}
}

View File

@ -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<String> 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<String> errorList = new Stack<String>();
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<String, Object> getConstants() {
HashMap<String, Object> constants = new HashMap<String, Object>();
@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<String, Object> getConstants() {
HashMap<String, Object> constants = new HashMap<String, Object>();
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);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 B

View File

@ -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

View File

@ -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;

View File

@ -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]

View File

@ -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

View File

@ -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]

View File

@ -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)))

View File

@ -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" %)}))))

View File

@ -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]))

View File

@ -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