diff --git a/app/schemas/com.parishod.watomatic.model.logs.MessageLogsDB/3.json b/app/schemas/com.parishod.watomatic.model.logs.MessageLogsDB/3.json new file mode 100644 index 000000000..08686fb7a --- /dev/null +++ b/app/schemas/com.parishod.watomatic.model.logs.MessageLogsDB/3.json @@ -0,0 +1,129 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "e8b6351e87f979cedde00cb90e2b55e5", + "entities": [ + { + "tableName": "message_logs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `index` INTEGER NOT NULL, `notif_id` TEXT, `notif_title` TEXT, `notif_arrived_time` INTEGER NOT NULL, `notif_is_replied` INTEGER NOT NULL, `notif_replied_msg` TEXT, `notif_reply_time` INTEGER NOT NULL, `notif_event` TEXT, FOREIGN KEY(`index`) REFERENCES `app_packages`(`index`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notifId", + "columnName": "notif_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "notifTitle", + "columnName": "notif_title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "notifArrivedTime", + "columnName": "notif_arrived_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notifIsReplied", + "columnName": "notif_is_replied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notifRepliedMsg", + "columnName": "notif_replied_msg", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "notifReplyTime", + "columnName": "notif_reply_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notifEvent", + "columnName": "notif_event", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_message_logs_index", + "unique": false, + "columnNames": [ + "index" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_message_logs_index` ON `${TABLE_NAME}` (`index`)" + } + ], + "foreignKeys": [ + { + "table": "app_packages", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "index" + ], + "referencedColumns": [ + "index" + ] + } + ] + }, + { + "tableName": "app_packages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`index` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `package_name` TEXT)", + "fields": [ + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "index" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e8b6351e87f979cedde00cb90e2b55e5')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/parishod/watomatic/NotificationService.java b/app/src/main/java/com/parishod/watomatic/NotificationService.java index cf8bfe1c0..62d23a8d4 100644 --- a/app/src/main/java/com/parishod/watomatic/NotificationService.java +++ b/app/src/main/java/com/parishod/watomatic/NotificationService.java @@ -1,71 +1,22 @@ package com.parishod.watomatic; -import android.app.PendingIntent; import android.content.Intent; -import android.os.Bundle; import android.service.notification.NotificationListenerService; import android.service.notification.StatusBarNotification; -import android.text.SpannableString; -import android.util.Log; +import com.parishod.watomatic.model.interfaces.OnMessageReplied; +import com.parishod.watomatic.model.utils.MessagingHelper; -import androidx.core.app.RemoteInput; - -import com.parishod.watomatic.model.CustomRepliesData; -import com.parishod.watomatic.model.preferences.PreferencesManager; -import com.parishod.watomatic.model.utils.ContactsHelper; -import com.parishod.watomatic.model.utils.DbUtils; -import com.parishod.watomatic.model.utils.NotificationHelper; -import com.parishod.watomatic.model.utils.NotificationUtils; - -import static java.lang.Math.max; - -public class NotificationService extends NotificationListenerService { - private final String TAG = NotificationService.class.getSimpleName(); - CustomRepliesData customRepliesData; - private DbUtils dbUtils; +public class NotificationService extends NotificationListenerService implements OnMessageReplied { @Override public void onNotificationPosted(StatusBarNotification sbn) { super.onNotificationPosted(sbn); - if (canReply(sbn) && shouldReply(sbn)) { - sendReply(sbn); - } + new MessagingHelper(this, this).handleMessage(sbn); } - private boolean canReply(StatusBarNotification sbn) { - return isServiceEnabled() && - isSupportedPackage(sbn) && - NotificationUtils.isNewNotification(sbn) && - isGroupMessageAndReplyAllowed(sbn) && - canSendReplyNow(sbn); - } - - private boolean shouldReply(StatusBarNotification sbn) { - PreferencesManager prefs = PreferencesManager.getPreferencesInstance(this); - boolean isGroup = sbn.getNotification().extras.getBoolean("android.isGroupConversation"); - - //Check contact based replies - if (prefs.isContactReplyEnabled() && !isGroup) { - //Title contains sender name (at least on WhatsApp) - String senderName = sbn.getNotification().extras.getString("android.title"); - //Check if should reply to contact - boolean isNameSelected = - (ContactsHelper.getInstance(this).hasContactPermission() - && prefs.getReplyToNames().contains(senderName)) || - prefs.getCustomReplyNames().contains(senderName); - if ((isNameSelected && prefs.isContactReplyBlacklistMode()) || - !isNameSelected && !prefs.isContactReplyBlacklistMode()) { - //If contact is on the list and contact reply is on blacklist mode, - // or contact is not in the list and reply is on whitelist mode, - // we don't want to reply - return false; - } - } - - //Check more conditions on future feature implementations - - //If we got here, all conditions to reply are met - return true; + @Override + public void onMessageReplied(String key) { + cancelNotification(key); } @Override @@ -74,97 +25,4 @@ public int onStartCommand(Intent intent, int flags, int startId) { //START_STICKY to order the system to restart your service as soon as possible when it was killed. return START_STICKY; } - - private void sendReply(StatusBarNotification sbn) { - NotificationWear notificationWear = NotificationUtils.extractWearNotification(sbn); - // Possibly transient or non-user notification from WhatsApp like - // "Checking for new messages" or "WhatsApp web is Active" - if (notificationWear.getRemoteInputs().isEmpty()) { - return; - } - - customRepliesData = CustomRepliesData.getInstance(this); - - RemoteInput[] remoteInputs = new RemoteInput[notificationWear.getRemoteInputs().size()]; - - Intent localIntent = new Intent(); - localIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - Bundle localBundle = new Bundle();//notificationWear.bundle; - int i = 0; - for (RemoteInput remoteIn : notificationWear.getRemoteInputs()) { - remoteInputs[i] = remoteIn; - // This works. Might need additional parameter to make it for Hangouts? (notification_tag?) - localBundle.putCharSequence(remoteInputs[i].getResultKey(), customRepliesData.getTextToSendOrElse()); - i++; - } - - RemoteInput.addResultsToIntent(remoteInputs, localIntent, localBundle); - try { - if (notificationWear.getPendingIntent() != null) { - if (dbUtils == null) { - dbUtils = new DbUtils(getApplicationContext()); - } - dbUtils.logReply(sbn, NotificationUtils.getTitle(sbn)); - notificationWear.getPendingIntent().send(this, 0, localIntent); - if (PreferencesManager.getPreferencesInstance(this).isShowNotificationEnabled()) { - NotificationHelper.getInstance(getApplicationContext()).sendNotification(sbn.getNotification().extras.getString("android.title"), sbn.getNotification().extras.getString("android.text"), sbn.getPackageName()); - } - cancelNotification(sbn.getKey()); - if (canPurgeMessages()) { - dbUtils.purgeMessageLogs(); - PreferencesManager.getPreferencesInstance(this).setPurgeMessageTime(System.currentTimeMillis()); - } - } - } catch (PendingIntent.CanceledException e) { - Log.e(TAG, "replyToLastNotification error: " + e.getLocalizedMessage()); - } - } - - private boolean canPurgeMessages() { - //Added L to avoid numeric overflow expression - //https://stackoverflow.com/questions/43801874/numeric-overflow-in-expression-manipulating-timestamps - long daysBeforePurgeInMS = 30 * 24 * 60 * 60 * 1000L; - return (System.currentTimeMillis() - PreferencesManager.getPreferencesInstance(this).getLastPurgedTime()) > daysBeforePurgeInMS; - } - - private boolean isSupportedPackage(StatusBarNotification sbn) { - return PreferencesManager.getPreferencesInstance(this) - .getEnabledApps() - .contains(sbn.getPackageName()); - } - - private boolean canSendReplyNow(StatusBarNotification sbn) { - // Do not reply to consecutive notifications from same person/group that arrive in below time - // This helps to prevent infinite loops when users on both end uses Watomatic or similar app - int DELAY_BETWEEN_REPLY_IN_MILLISEC = 10 * 1000; - - String title = NotificationUtils.getTitle(sbn); - String selfDisplayName = sbn.getNotification().extras.getString("android.selfDisplayName"); - if (title != null && title.equalsIgnoreCase(selfDisplayName)) { //to protect double reply in case where if notification is not dismissed and existing notification is updated with our reply - return false; - } - if (dbUtils == null) { - dbUtils = new DbUtils(getApplicationContext()); - } - long timeDelay = PreferencesManager.getPreferencesInstance(this).getAutoReplyDelay(); - return (System.currentTimeMillis() - dbUtils.getLastRepliedTime(sbn.getPackageName(), title) >= max(timeDelay, DELAY_BETWEEN_REPLY_IN_MILLISEC)); - } - - private boolean isGroupMessageAndReplyAllowed(StatusBarNotification sbn) { - String rawTitle = NotificationUtils.getTitleRaw(sbn); - //android.text returning SpannableString - SpannableString rawText = SpannableString.valueOf("" + sbn.getNotification().extras.get("android.text")); - // Detect possible group image message by checking for colon and text starts with camera icon #181 - boolean isPossiblyAnImageGrpMsg = ((rawTitle != null) && (": ".contains(rawTitle) || "@ ".contains(rawTitle))) - && ((rawText != null) && rawText.toString().contains("\uD83D\uDCF7")); - if (!sbn.getNotification().extras.getBoolean("android.isGroupConversation")) { - return !isPossiblyAnImageGrpMsg; - } else { - return PreferencesManager.getPreferencesInstance(this).isGroupReplyEnabled(); - } - } - - private boolean isServiceEnabled() { - return PreferencesManager.getPreferencesInstance(this).isServiceEnabled(); - } } diff --git a/app/src/main/java/com/parishod/watomatic/fragment/SettingsFragment.java b/app/src/main/java/com/parishod/watomatic/fragment/SettingsFragment.java index 2ce9b5f2b..6b2a2f7da 100644 --- a/app/src/main/java/com/parishod/watomatic/fragment/SettingsFragment.java +++ b/app/src/main/java/com/parishod/watomatic/fragment/SettingsFragment.java @@ -8,6 +8,7 @@ import com.parishod.watomatic.R; import com.parishod.watomatic.model.utils.AutoStartHelper; +import com.parishod.watomatic.model.utils.CustomDialog; import com.parishod.watomatic.model.utils.ServieUtils; public class SettingsFragment extends PreferenceFragmentCompat { @@ -40,6 +41,16 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { return true; }); } + + Preference sendAppLogsPref = findPreference(getString(R.string.pref_send_app_logs)); + if (sendAppLogsPref != null) { + sendAppLogsPref.setOnPreferenceClickListener(preference -> { + if(getActivity() != null) + new CustomDialog(getActivity()).showAppLogsShareDialog(); + return true; + }); + } + } @Override diff --git a/app/src/main/java/com/parishod/watomatic/model/interfaces/OnMessageReplied.java b/app/src/main/java/com/parishod/watomatic/model/interfaces/OnMessageReplied.java new file mode 100644 index 000000000..dcee13fad --- /dev/null +++ b/app/src/main/java/com/parishod/watomatic/model/interfaces/OnMessageReplied.java @@ -0,0 +1,5 @@ +package com.parishod.watomatic.model.interfaces; + +public interface OnMessageReplied { + void onMessageReplied(String key); +} diff --git a/app/src/main/java/com/parishod/watomatic/model/logs/AppPackageDao.java b/app/src/main/java/com/parishod/watomatic/model/logs/AppPackageDao.java index 2b8acab5c..724a5e534 100644 --- a/app/src/main/java/com/parishod/watomatic/model/logs/AppPackageDao.java +++ b/app/src/main/java/com/parishod/watomatic/model/logs/AppPackageDao.java @@ -12,4 +12,7 @@ public interface AppPackageDao { @Insert void insertAppPackage(AppPackage appPackage); + + @Query("SELECT package_name FROM app_packages WHERE [index]=:index") + String getPackageName(int index); } diff --git a/app/src/main/java/com/parishod/watomatic/model/logs/MessageLog.java b/app/src/main/java/com/parishod/watomatic/model/logs/MessageLog.java index 5ace6ef78..f147ce357 100644 --- a/app/src/main/java/com/parishod/watomatic/model/logs/MessageLog.java +++ b/app/src/main/java/com/parishod/watomatic/model/logs/MessageLog.java @@ -35,12 +35,16 @@ public class MessageLog { private String notifRepliedMsg; @ColumnInfo(name = "notif_reply_time") private long notifReplyTime; + @ColumnInfo(name = "notif_event") + private String notifEvent; public MessageLog(int index, String notifTitle, long notifArrivedTime, String notifRepliedMsg, - long notifReplyTime + long notifReplyTime, + boolean notifIsReplied, + String notifEvent ) { this.index = index; this.notifId = null; @@ -48,7 +52,8 @@ public MessageLog(int index, this.notifArrivedTime = notifArrivedTime; this.notifRepliedMsg = notifRepliedMsg; this.notifReplyTime = notifReplyTime; - this.notifIsReplied = true; + this.notifIsReplied = notifIsReplied; + this.notifEvent = notifEvent; } public int getId() { @@ -114,4 +119,17 @@ public long getNotifReplyTime() { public void setNotifReplyTime(long notifReplyTime) { this.notifReplyTime = notifReplyTime; } + + public String getNotifEvent() { + return notifEvent; + } + + public void setNotifEvent(String notifEvent) { + this.notifEvent = notifEvent; + } + + public String toString(){ + return "" + id + "; " + index + "; " + notifId + "; " + notifTitle + "; " + notifArrivedTime + "; " + notifIsReplied + "; " + + notifReplyTime + "; " + notifEvent; + } } diff --git a/app/src/main/java/com/parishod/watomatic/model/logs/MessageLogsDB.java b/app/src/main/java/com/parishod/watomatic/model/logs/MessageLogsDB.java index f784bbeef..4c5a5bc9d 100644 --- a/app/src/main/java/com/parishod/watomatic/model/logs/MessageLogsDB.java +++ b/app/src/main/java/com/parishod/watomatic/model/logs/MessageLogsDB.java @@ -5,10 +5,12 @@ import androidx.room.Database; import androidx.room.Room; import androidx.room.RoomDatabase; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; import com.parishod.watomatic.model.utils.Constants; -@Database(entities = {MessageLog.class, AppPackage.class}, version = 2) +@Database(entities = {MessageLog.class, AppPackage.class}, version = 3) public abstract class MessageLogsDB extends RoomDatabase { private static final String DB_NAME = Constants.LOGS_DB_NAME; private static MessageLogsDB _instance; @@ -16,7 +18,7 @@ public abstract class MessageLogsDB extends RoomDatabase { public static synchronized MessageLogsDB getInstance(Context context) { if (_instance == null) { _instance = Room.databaseBuilder(context.getApplicationContext(), MessageLogsDB.class, DB_NAME) - .fallbackToDestructiveMigration() + .addMigrations(MIGRATION_1_3, MIGRATION_2_3) .allowMainThreadQueries() .build(); } @@ -26,4 +28,21 @@ public static synchronized MessageLogsDB getInstance(Context context) { public abstract MessageLogsDao logsDao(); public abstract AppPackageDao appPackageDao(); + + static final Migration MIGRATION_1_3 = new Migration(1, 3) { + @Override + public void migrate(SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE message_logs " + + " ADD COLUMN notif_event TEXT"); + } + }; + + static final Migration MIGRATION_2_3 = new Migration(2, 3) { + @Override + public void migrate(SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE message_logs " + + " ADD COLUMN notif_event TEXT"); + } + }; + } diff --git a/app/src/main/java/com/parishod/watomatic/model/logs/MessageLogsDao.java b/app/src/main/java/com/parishod/watomatic/model/logs/MessageLogsDao.java index ee85f235f..b9573eb72 100644 --- a/app/src/main/java/com/parishod/watomatic/model/logs/MessageLogsDao.java +++ b/app/src/main/java/com/parishod/watomatic/model/logs/MessageLogsDao.java @@ -4,6 +4,8 @@ import androidx.room.Insert; import androidx.room.Query; +import java.util.List; + @Dao public interface MessageLogsDao { @Query("SELECT message_logs.notif_reply_time FROM MESSAGE_LOGS " + @@ -24,4 +26,7 @@ public interface MessageLogsDao { @Query("SELECT notif_reply_time FROM MESSAGE_LOGS ORDER BY notif_reply_time DESC LIMIT 1") long getFirstRepliedTime(); + + @Query("SELECT * FROM MESSAGE_LOGS") + List getAppLogs(); } diff --git a/app/src/main/java/com/parishod/watomatic/model/utils/AppUtils.java b/app/src/main/java/com/parishod/watomatic/model/utils/AppUtils.java index b00717295..f2ba22835 100644 --- a/app/src/main/java/com/parishod/watomatic/model/utils/AppUtils.java +++ b/app/src/main/java/com/parishod/watomatic/model/utils/AppUtils.java @@ -2,6 +2,9 @@ import android.content.Context; import android.content.pm.PackageManager; +import androidx.core.app.ShareCompat; +import com.parishod.watomatic.R; +import com.parishod.watomatic.activity.settings.SettingsActivity; public class AppUtils { final private Context appContext; @@ -28,4 +31,21 @@ public boolean isPackageInstalled(String packageName) { return false; } } + + public void launchEmailCompose(String subject, String body) { + try { + ShareCompat.IntentBuilder intentBuilder = new ShareCompat.IntentBuilder(appContext); + intentBuilder + .setType("text/plain") + .addEmailTo(Constants.EMAIL_ADDRESS) + .setSubject(subject) + .setText(body) + //.setHtmlText(body) //If you are using HTML in your body text + .setChooserTitle(appContext.getResources().getString(R.string.send_app_logs)) + .startChooser(); + } catch (Exception e){ + e.printStackTrace(); + } + + } } diff --git a/app/src/main/java/com/parishod/watomatic/model/utils/Constants.kt b/app/src/main/java/com/parishod/watomatic/model/utils/Constants.kt index 516e241dc..3c4ede247 100644 --- a/app/src/main/java/com/parishod/watomatic/model/utils/Constants.kt +++ b/app/src/main/java/com/parishod/watomatic/model/utils/Constants.kt @@ -37,4 +37,5 @@ object Constants { const val EMAIL_ADDRESS = "watomatic@deekshith.in" const val EMAIL_SUBJECT = "Watomatic-Feedback" const val TELEGRAM_URL = "tg://resolve?domain=watomatic" + const val APP_LOGS_EMAIL_SUBJECT = "Watomatic App Logs" } diff --git a/app/src/main/java/com/parishod/watomatic/model/utils/CustomDialog.java b/app/src/main/java/com/parishod/watomatic/model/utils/CustomDialog.java index 4aa57a48b..bfeb23b8c 100644 --- a/app/src/main/java/com/parishod/watomatic/model/utils/CustomDialog.java +++ b/app/src/main/java/com/parishod/watomatic/model/utils/CustomDialog.java @@ -10,15 +10,18 @@ import android.os.Build; import android.os.Bundle; import android.view.View; +import android.widget.CompoundButton; import androidx.appcompat.widget.AppCompatButton; import androidx.appcompat.widget.AppCompatImageView; import androidx.appcompat.widget.AppCompatTextView; import androidx.core.content.ContextCompat; +import com.google.android.material.checkbox.MaterialCheckBox; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.parishod.watomatic.R; +import com.parishod.watomatic.model.logs.MessageLog; import com.parishod.watomatic.model.preferences.PreferencesManager; import java.util.ArrayList; @@ -245,4 +248,52 @@ private boolean isTelegramAppInstalled() { } return false; } + + public void showAppLogsShareDialog(){ + if (dialog != null) { + dialog.dismiss(); + } + + dialog = new Dialog(mContext); + dialog.setContentView(R.layout.app_logs_share_dialog); + dialog.setCancelable(false); + + AppCompatTextView appLogsMessage = dialog.findViewById(R.id.app_log_message); + + DbUtils dbUtils = new DbUtils(mContext); + List applogs = dbUtils.getAppLogs(); + + StringBuilder applog = new StringBuilder(); + for (MessageLog messageLog: applogs + ) { + applog.append(dbUtils.getPackageName(messageLog.getIndex())).append("; ").append(messageLog.toString()).append("\n"); + } + + appLogsMessage.setText(applog.toString()); + + AppCompatButton sendBtn = dialog.findViewById(R.id.send_button); + sendBtn.setOnClickListener(v -> { + AppUtils.getInstance(mContext).launchEmailCompose(Constants.APP_LOGS_EMAIL_SUBJECT, applog.toString()); + dialog.dismiss(); + }); + + AppCompatButton cancelBtn = dialog.findViewById(R.id.cancel_button); + cancelBtn.setOnClickListener(v -> { + dialog.dismiss(); + }); + + MaterialCheckBox consentCheckBox = dialog.findViewById(R.id.consent_checkbox); + consentCheckBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean b) { + if(b){ + sendBtn.setEnabled(true); + }else{ + sendBtn.setEnabled(false); + } + } + }); + + dialog.show(); + } } diff --git a/app/src/main/java/com/parishod/watomatic/model/utils/DbUtils.java b/app/src/main/java/com/parishod/watomatic/model/utils/DbUtils.java index 463f0685a..35193ed19 100644 --- a/app/src/main/java/com/parishod/watomatic/model/utils/DbUtils.java +++ b/app/src/main/java/com/parishod/watomatic/model/utils/DbUtils.java @@ -8,6 +8,8 @@ import com.parishod.watomatic.model.logs.MessageLog; import com.parishod.watomatic.model.logs.MessageLogsDB; +import java.util.List; + public class DbUtils { private final Context mContext; @@ -25,7 +27,7 @@ public void purgeMessageLogs() { messageLogsDB.logsDao().purgeMessageLogs(); } - public void logReply(StatusBarNotification sbn, String title) { + public void logReply(StatusBarNotification sbn, String title, boolean isReplied, String event) { CustomRepliesData customRepliesData = CustomRepliesData.getInstance(mContext); MessageLogsDB messageLogsDB = MessageLogsDB.getInstance(mContext.getApplicationContext()); int packageIndex = messageLogsDB.appPackageDao().getPackageIndex(sbn.getPackageName()); @@ -34,7 +36,8 @@ public void logReply(StatusBarNotification sbn, String title) { messageLogsDB.appPackageDao().insertAppPackage(appPackage); packageIndex = messageLogsDB.appPackageDao().getPackageIndex(sbn.getPackageName()); } - MessageLog logs = new MessageLog(packageIndex, title, sbn.getNotification().when, customRepliesData.getTextToSendOrElse(), System.currentTimeMillis()); + MessageLog logs = new MessageLog(packageIndex, title, sbn.getNotification().when, customRepliesData.getTextToSendOrElse(), + System.currentTimeMillis(), isReplied, event); messageLogsDB.logsDao().logReply(logs); } @@ -47,4 +50,14 @@ public long getFirstRepliedTime() { MessageLogsDB messageLogsDB = MessageLogsDB.getInstance(mContext.getApplicationContext()); return messageLogsDB.logsDao().getFirstRepliedTime(); } + + public List getAppLogs(){ + MessageLogsDB messageLogsDB = MessageLogsDB.getInstance(mContext.getApplicationContext()); + return messageLogsDB.logsDao().getAppLogs(); + } + + public String getPackageName(int index){ + MessageLogsDB messageLogsDB = MessageLogsDB.getInstance(mContext.getApplicationContext()); + return messageLogsDB.appPackageDao().getPackageName(index); + } } diff --git a/app/src/main/java/com/parishod/watomatic/model/utils/EventLogger.java b/app/src/main/java/com/parishod/watomatic/model/utils/EventLogger.java new file mode 100644 index 000000000..207faf55a --- /dev/null +++ b/app/src/main/java/com/parishod/watomatic/model/utils/EventLogger.java @@ -0,0 +1,66 @@ +package com.parishod.watomatic.model.utils; + +import java.util.HashMap; + +public class EventLogger { + private final String SERVICE_ENABLED = "se"; + private final String SUPPORTED_PACKAGE = "sp"; + private final String NEW_NOTIFICATION = "nn"; + private final String GROUP_REPLY_ENABLED = "gre"; + private final String GROUP_MESSAGE = "gm"; + private final String CAN_REPLY_NOW = "crn"; + private final String CONTACTS_REPLY_ENABLED = "cre"; + private final String CONTACTS_REPLY_REASON = "crr"; + private final String REMOTE_INPUTS_EMPTY = "rie"; + private final String REPLY_ERROR = "err"; + + private HashMap event; + + public EventLogger(){ + event = new HashMap(); + } + + public void setServiceEnabled(boolean isServiceEnabled){ + event.put(SERVICE_ENABLED, isServiceEnabled); + } + + public void setSupportedPackage(boolean isPackageSupported){ + event.put(SUPPORTED_PACKAGE, isPackageSupported); + } + + public void setNewNotification(boolean isNew){ + event.put(NEW_NOTIFICATION, isNew); + } + + public void setGroupReplyEnabled(boolean isGroupReplyEnabled){ + event.put(GROUP_REPLY_ENABLED, isGroupReplyEnabled); + } + + public void setIsGroupMessage(boolean isGroupMsg){ + event.put(GROUP_MESSAGE, isGroupMsg); + } + + public void setCanSendReplyNow(boolean canSendReplyNow){ + event.put(CAN_REPLY_NOW, canSendReplyNow); + } + + public void setContactsReplyEnabled(boolean isContactsReplyEnabled){ + event.put(CONTACTS_REPLY_ENABLED, isContactsReplyEnabled); + } + + public void setContactsReplyReason(String contactsReplyReason){ + event.put(CONTACTS_REPLY_REASON, contactsReplyReason); + } + + public void setRemoteInputsEmpty(boolean remoteInputsEmpty){ + event.put(REMOTE_INPUTS_EMPTY, remoteInputsEmpty); + } + + public void setReplyErrReason(String replyErrReason){ + event.put(REPLY_ERROR, replyErrReason); + } + + public HashMap getEvent(){ + return event; + } +} diff --git a/app/src/main/java/com/parishod/watomatic/model/utils/MessagingHelper.java b/app/src/main/java/com/parishod/watomatic/model/utils/MessagingHelper.java new file mode 100644 index 000000000..92f110f4c --- /dev/null +++ b/app/src/main/java/com/parishod/watomatic/model/utils/MessagingHelper.java @@ -0,0 +1,208 @@ +package com.parishod.watomatic.model.utils; + +import static java.lang.Math.max; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.service.notification.StatusBarNotification; +import android.text.SpannableString; +import android.util.Log; + +import androidx.core.app.RemoteInput; + +import com.parishod.watomatic.NotificationWear; +import com.parishod.watomatic.R; +import com.parishod.watomatic.model.App; +import com.parishod.watomatic.model.CustomRepliesData; +import com.parishod.watomatic.model.interfaces.OnMessageReplied; +import com.parishod.watomatic.model.preferences.PreferencesManager; + +public class MessagingHelper { + final private Context mContext; + CustomRepliesData customRepliesData; + private DbUtils dbUtils; + private EventLogger eventLogger; + final private OnMessageReplied onMessageReplied; + + public MessagingHelper(Context context, OnMessageReplied onMessageReplied){ + this.mContext = context; + this.onMessageReplied = onMessageReplied; + } + + public void handleMessage(StatusBarNotification sbn){ + eventLogger = new EventLogger(); + if (canReply(sbn) && shouldReply(sbn)) { + sendReply(sbn); + }else{ + //if notification is from supported apps only log the data + for (App supportedApp : Constants.SUPPORTED_APPS) { + if(supportedApp.getPackageName().equalsIgnoreCase(sbn.getPackageName())){ + dbUtils.logReply(sbn, NotificationUtils.getTitle(sbn), false, eventLogger.getEvent().toString()); + break; + } + } + } + } + + private boolean canReply(StatusBarNotification sbn) { + eventLogger.setNewNotification(NotificationUtils.isNewNotification(sbn)); + return isServiceEnabled() && + isSupportedPackage(sbn) && + NotificationUtils.isNewNotification(sbn) && + isGroupMessageAndReplyAllowed(sbn) && + canSendReplyNow(sbn); + } + + private boolean shouldReply(StatusBarNotification sbn) { + PreferencesManager prefs = PreferencesManager.getPreferencesInstance(mContext); + boolean isGroup = sbn.getNotification().extras.getBoolean("android.isGroupConversation"); + boolean isContactsReplyEnabled = prefs.isContactReplyEnabled(); + + eventLogger.setContactsReplyEnabled(isContactsReplyEnabled); + + //Check contact based replies + if (isContactsReplyEnabled && !isGroup) { + //Title contains sender name (at least on WhatsApp) + String senderName = sbn.getNotification().extras.getString("android.title"); + if (!ContactsHelper.getInstance(mContext).hasContactPermission()){ + eventLogger.setContactsReplyReason(mContext.getString(R.string.log_msg_contact_permission_denied)); + } + if (ContactsHelper.getInstance(mContext).hasContactPermission() && !prefs.getReplyToNames().contains(senderName)){ + eventLogger.setContactsReplyReason(mContext.getString(R.string.log_msg_contact_mismatch)); + } + if (!prefs.getCustomReplyNames().contains(senderName)){ + eventLogger.setContactsReplyReason(mContext.getString(R.string.log_msg_contact_mismatch)); + } + if(prefs.isContactReplyBlacklistMode()){ + eventLogger.setContactsReplyReason(mContext.getString(R.string.log_msg_contact_blacklisted)); + } + //Check if should reply to contact + boolean isNameSelected = + (ContactsHelper.getInstance(mContext).hasContactPermission() + && prefs.getReplyToNames().contains(senderName)) || + prefs.getCustomReplyNames().contains(senderName); + if ((isNameSelected && prefs.isContactReplyBlacklistMode()) || + !isNameSelected && !prefs.isContactReplyBlacklistMode()) { + //If contact is on the list and contact reply is on blacklist mode, + // or contact is not in the list and reply is on whitelist mode, + // we don't want to reply + return false; + } + } + + //Check more conditions on future feature implementations + + //If we got here, all conditions to reply are met + return true; + } + + private void sendReply(StatusBarNotification sbn) { + NotificationWear notificationWear = NotificationUtils.extractWearNotification(sbn); + // Possibly transient or non-user notification from WhatsApp like + // "Checking for new messages" or "WhatsApp web is Active" + if (notificationWear.getRemoteInputs().isEmpty()) { + eventLogger.setRemoteInputsEmpty(true); + dbUtils.logReply(sbn, NotificationUtils.getTitle(sbn), false, eventLogger.getEvent().toString()); + return; + } + + eventLogger.setRemoteInputsEmpty(false); + + customRepliesData = CustomRepliesData.getInstance(mContext); + + RemoteInput[] remoteInputs = new RemoteInput[notificationWear.getRemoteInputs().size()]; + + Intent localIntent = new Intent(); + localIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + Bundle localBundle = new Bundle();//notificationWear.bundle; + int i = 0; + for (RemoteInput remoteIn : notificationWear.getRemoteInputs()) { + remoteInputs[i] = remoteIn; + // This works. Might need additional parameter to make it for Hangouts? (notification_tag?) + localBundle.putCharSequence(remoteInputs[i].getResultKey(), customRepliesData.getTextToSendOrElse()); + i++; + } + + RemoteInput.addResultsToIntent(remoteInputs, localIntent, localBundle); + try { + if (notificationWear.getPendingIntent() != null) { + if (dbUtils == null) { + dbUtils = new DbUtils(mContext.getApplicationContext()); + } + dbUtils.logReply(sbn, NotificationUtils.getTitle(sbn), true, eventLogger.getEvent().toString()); + notificationWear.getPendingIntent().send(mContext, 0, localIntent); + if (PreferencesManager.getPreferencesInstance(mContext).isShowNotificationEnabled()) { + NotificationHelper.getInstance(mContext.getApplicationContext()).sendNotification(sbn.getNotification().extras.getString("android.title"), sbn.getNotification().extras.getString("android.text"), sbn.getPackageName()); + } + onMessageReplied.onMessageReplied(sbn.getKey()); + if (canPurgeMessages()) { + dbUtils.purgeMessageLogs(); + PreferencesManager.getPreferencesInstance(mContext).setPurgeMessageTime(System.currentTimeMillis()); + } + } + } catch (PendingIntent.CanceledException e) { + Log.e("MessagingHelper", "replyToLastNotification error: " + e.getLocalizedMessage()); + eventLogger.setReplyErrReason(e.getLocalizedMessage()); + dbUtils.logReply(sbn, NotificationUtils.getTitle(sbn), false, eventLogger.getEvent().toString()); + } + } + + private boolean canPurgeMessages() { + //Added L to avoid numeric overflow expression + //https://stackoverflow.com/questions/43801874/numeric-overflow-in-expression-manipulating-timestamps + long daysBeforePurgeInMS = 30 * 24 * 60 * 60 * 1000L; + return (System.currentTimeMillis() - PreferencesManager.getPreferencesInstance(mContext).getLastPurgedTime()) > daysBeforePurgeInMS; + } + + private boolean isSupportedPackage(StatusBarNotification sbn) { + boolean isSupportedPackage = PreferencesManager.getPreferencesInstance(mContext) + .getEnabledApps() + .contains(sbn.getPackageName()); + eventLogger.setSupportedPackage(isSupportedPackage); + return isSupportedPackage; + } + + private boolean canSendReplyNow(StatusBarNotification sbn) { + // Do not reply to consecutive notifications from same person/group that arrive in below time + // This helps to prevent infinite loops when users on both end uses Watomatic or similar app + int DELAY_BETWEEN_REPLY_IN_MILLISEC = 10 * 1000; + + String title = NotificationUtils.getTitle(sbn); + String selfDisplayName = sbn.getNotification().extras.getString("android.selfDisplayName"); + if (title != null && title.equalsIgnoreCase(selfDisplayName)) { //to protect double reply in case where if notification is not dismissed and existing notification is updated with our reply + eventLogger.setReplyErrReason("Possibly Duplicate Reply"); + return false; + } + if (dbUtils == null) { + dbUtils = new DbUtils(mContext.getApplicationContext()); + } + long timeDelay = PreferencesManager.getPreferencesInstance(mContext).getAutoReplyDelay(); + boolean canReplyNow = (System.currentTimeMillis() - dbUtils.getLastRepliedTime(sbn.getPackageName(), title) >= max(timeDelay, DELAY_BETWEEN_REPLY_IN_MILLISEC)); + eventLogger.setCanSendReplyNow(canReplyNow); + return canReplyNow; + } + + private boolean isGroupMessageAndReplyAllowed(StatusBarNotification sbn) { + String rawTitle = NotificationUtils.getTitleRaw(sbn); + //android.text returning SpannableString + SpannableString rawText = SpannableString.valueOf("" + sbn.getNotification().extras.get("android.text")); + // Detect possible group image message by checking for colon and text starts with camera icon #181 + boolean isPossiblyAnImageGrpMsg = ((rawTitle != null) && (": ".contains(rawTitle) || "@ ".contains(rawTitle))) + && ((rawText != null) && rawText.toString().contains("\uD83D\uDCF7")); + eventLogger.setGroupReplyEnabled(PreferencesManager.getPreferencesInstance(mContext).isGroupReplyEnabled()); + eventLogger.setIsGroupMessage(sbn.getNotification().extras.getBoolean("android.isGroupConversation")); + if (!sbn.getNotification().extras.getBoolean("android.isGroupConversation")) { + return !isPossiblyAnImageGrpMsg; + } else { + return PreferencesManager.getPreferencesInstance(mContext).isGroupReplyEnabled(); + } + } + + private boolean isServiceEnabled() { + boolean isServiceEnabled = PreferencesManager.getPreferencesInstance(mContext).isServiceEnabled(); + eventLogger.setServiceEnabled(isServiceEnabled); + return isServiceEnabled; + } +} diff --git a/app/src/main/res/drawable/cancel_button_bg.xml b/app/src/main/res/drawable/cancel_button_bg.xml new file mode 100644 index 000000000..8647999b0 --- /dev/null +++ b/app/src/main/res/drawable/cancel_button_bg.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/app_logs_share_dialog.xml b/app/src/main/res/layout/app_logs_share_dialog.xml new file mode 100644 index 000000000..6ff5a3447 --- /dev/null +++ b/app/src/main/res/layout/app_logs_share_dialog.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ddb3784bd..ce6bb74c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -71,6 +71,7 @@ pref_auto_start_permission pref_is_append_watomatic_attribution pref_show_foreground_service_notification + pref_send_app_logs Show notification for replied messages @@ -83,6 +84,7 @@ Always-On notification Display a persistent notification to ensure Watomatic is not killed by the system Some devices might need you to manually enable Auto start for Watomatic from device settings + Send App Logs English (en) @@ -205,4 +207,9 @@ Current donation progress Name cannot be blank Name already present + I Agree to send the above logs. + send + Contacts Permission Denied + Contact didnot match + Contact Blacklisted \ No newline at end of file diff --git a/app/src/main/res/xml/fragment_settings.xml b/app/src/main/res/xml/fragment_settings.xml index 4050a6931..176eb88ec 100644 --- a/app/src/main/res/xml/fragment_settings.xml +++ b/app/src/main/res/xml/fragment_settings.xml @@ -29,6 +29,9 @@ + +