diff --git a/app/res/drawable/ic_connect_message_editext_bg_24_border.xml b/app/res/drawable/ic_connect_message_editext_bg_24_border.xml
new file mode 100644
index 000000000..aa9c6891c
--- /dev/null
+++ b/app/res/drawable/ic_connect_message_editext_bg_24_border.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/res/drawable/ic_connect_message_photo_camera.xml b/app/res/drawable/ic_connect_message_photo_camera.xml
new file mode 100644
index 000000000..8e30510cd
--- /dev/null
+++ b/app/res/drawable/ic_connect_message_photo_camera.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/app/res/drawable/ic_connect_message_read.xml b/app/res/drawable/ic_connect_message_read.xml
new file mode 100644
index 000000000..2dfc3ea4d
--- /dev/null
+++ b/app/res/drawable/ic_connect_message_read.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/res/drawable/ic_connect_message_receiver_bg.xml b/app/res/drawable/ic_connect_message_receiver_bg.xml
new file mode 100644
index 000000000..954f363de
--- /dev/null
+++ b/app/res/drawable/ic_connect_message_receiver_bg.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/res/drawable/ic_connect_message_receiver_edge.xml b/app/res/drawable/ic_connect_message_receiver_edge.xml
new file mode 100644
index 000000000..34ea7b545
--- /dev/null
+++ b/app/res/drawable/ic_connect_message_receiver_edge.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/res/drawable/ic_connect_message_send.png b/app/res/drawable/ic_connect_message_send.png
new file mode 100644
index 000000000..a3e2eeda1
Binary files /dev/null and b/app/res/drawable/ic_connect_message_send.png differ
diff --git a/app/res/drawable/ic_connect_message_sender_bg.xml b/app/res/drawable/ic_connect_message_sender_bg.xml
new file mode 100644
index 000000000..9fb09b83c
--- /dev/null
+++ b/app/res/drawable/ic_connect_message_sender_bg.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/res/drawable/ic_connect_message_sender_edge.xml b/app/res/drawable/ic_connect_message_sender_edge.xml
new file mode 100644
index 000000000..7360221b7
--- /dev/null
+++ b/app/res/drawable/ic_connect_message_sender_edge.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/res/drawable/ic_contact_support_30.xml b/app/res/drawable/ic_contact_support_30.xml
new file mode 100644
index 000000000..f26626b74
--- /dev/null
+++ b/app/res/drawable/ic_contact_support_30.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/res/drawable/rounded_blue_bg.xml b/app/res/drawable/rounded_blue_bg.xml
new file mode 100644
index 000000000..a301d5da2
--- /dev/null
+++ b/app/res/drawable/rounded_blue_bg.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/res/layout/activity_connect_messaging.xml b/app/res/layout/activity_connect_messaging.xml
new file mode 100644
index 000000000..744f60fbb
--- /dev/null
+++ b/app/res/layout/activity_connect_messaging.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/res/layout/fragment_channel_consent_bottom_sheet.xml b/app/res/layout/fragment_channel_consent_bottom_sheet.xml
new file mode 100644
index 000000000..337bfa25d
--- /dev/null
+++ b/app/res/layout/fragment_channel_consent_bottom_sheet.xml
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/res/layout/fragment_channel_list.xml b/app/res/layout/fragment_channel_list.xml
new file mode 100644
index 000000000..749e2d3cd
--- /dev/null
+++ b/app/res/layout/fragment_channel_list.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/res/layout/fragment_connect_message.xml b/app/res/layout/fragment_connect_message.xml
new file mode 100644
index 000000000..bf3ccd3bc
--- /dev/null
+++ b/app/res/layout/fragment_connect_message.xml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/res/layout/item_channel.xml b/app/res/layout/item_channel.xml
new file mode 100644
index 000000000..fde5881d6
--- /dev/null
+++ b/app/res/layout/item_channel.xml
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/res/layout/item_chat_left_view.xml b/app/res/layout/item_chat_left_view.xml
new file mode 100644
index 000000000..97773416c
--- /dev/null
+++ b/app/res/layout/item_chat_left_view.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/res/layout/item_chat_right_view.xml b/app/res/layout/item_chat_right_view.xml
new file mode 100644
index 000000000..c95729b50
--- /dev/null
+++ b/app/res/layout/item_chat_right_view.xml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/res/navigation/nav_graph_connect_messaging.xml b/app/res/navigation/nav_graph_connect_messaging.xml
new file mode 100644
index 000000000..cd4b86edd
--- /dev/null
+++ b/app/res/navigation/nav_graph_connect_messaging.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/res/values/colors.xml b/app/res/values/colors.xml
index 0423d9774..7267cc33c 100644
--- a/app/res/values/colors.xml
+++ b/app/res/values/colors.xml
@@ -143,7 +143,7 @@
#F44336
#D32F2F
#B71C1C
-
+
@color/cc_neutral_bg
@color/cc_brand_color
@@ -197,5 +197,8 @@
#E7EAF8
#9CA3AF
#ebecfa
+ #5C6FD0
+ #F1F5F9
+ #334155
diff --git a/app/res/values/strings.xml b/app/res/values/strings.xml
index a67d27ba7..427cfb28e 100644
--- a/app/res/values/strings.xml
+++ b/app/res/values/strings.xml
@@ -832,4 +832,23 @@
https://connectid.dimagi.com/users/recover/initiate_deactivation
https://connectid.dimagi.com/users/recover/confirm_deactivation
A problem occurred with the database, please recover your account.
+ Messaging
+ Channels
+ Unconsented channel
+ You
+ Them
+ Consent to channel
+ Do you agree to open a messaging thread on this channel?
+ Accept
+ Decline
+
+ New Message
+ You received a new message from %s, press here to view.
+ New Channel
+ A new messaging channel is available from %s, press here to view
+
+ https://connectid.dimagi.com/messaging/retrieve_messages/
+ https://connectid.dimagi.com/messaging/update_consent/
+ https://connectid.dimagi.com/messaging/send_message/
+ https://connectid.dimagi.com/messaging/update_received/
diff --git a/app/src/org/commcare/activities/connect/ConnectMessagingActivity.java b/app/src/org/commcare/activities/connect/ConnectMessagingActivity.java
new file mode 100644
index 000000000..51d1fd232
--- /dev/null
+++ b/app/src/org/commcare/activities/connect/ConnectMessagingActivity.java
@@ -0,0 +1,110 @@
+package org.commcare.activities.connect;
+
+import android.os.Bundle;
+import android.util.Log;
+
+import org.commcare.activities.CommCareActivity;
+import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord;
+import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord;
+import org.commcare.connect.ConnectManager;
+import org.commcare.connect.database.ConnectMessageUtils;
+import org.commcare.dalvik.R;
+import org.commcare.google.services.analytics.FirebaseAnalyticsUtil;
+import org.commcare.util.LogTypes;
+import org.javarosa.core.services.Logger;
+
+import androidx.navigation.NavController;
+import androidx.navigation.NavOptions;
+import androidx.navigation.fragment.NavHostFragment;
+import androidx.navigation.ui.NavigationUI;
+
+public class ConnectMessagingActivity extends CommCareActivity {
+ public static final String CCC_MESSAGE = "ccc_message";
+
+ public NavController controller;
+ NavController.OnDestinationChangedListener destinationListener = null;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_connect_messaging);
+ setTitle(R.string.connect_messaging_title);
+
+ destinationListener = FirebaseAnalyticsUtil.getDestinationChangeListener();
+
+ NavHostFragment navHostFragment =
+ (NavHostFragment)getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment_connect_messaging);
+ controller = navHostFragment.getNavController();
+ controller.addOnDestinationChangedListener(destinationListener);
+ NavigationUI.setupActionBarWithNavController(this, controller);
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ String action = getIntent().getStringExtra("action");
+ if (action != null) {
+ handleRedirect(action);
+ }
+ }
+
+ @Override
+ protected boolean shouldShowBreadcrumbBar() {
+ return false;
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (destinationListener != null) {
+ NavHostFragment navHostFragment = (NavHostFragment)getSupportFragmentManager()
+ .findFragmentById(R.id.nav_host_fragment_connect_messaging);
+ if (navHostFragment != null) {
+ NavController navController = navHostFragment.getNavController();
+ navController.removeOnDestinationChangedListener(destinationListener);
+ }
+ destinationListener = null;
+ }
+
+ super.onDestroy();
+ }
+
+ @Override
+ public void setTitle(CharSequence title) {
+ super.setTitle(title);
+ getSupportActionBar().setTitle(title);
+ }
+
+ private void handleRedirect(String action) {
+ if (action.equals(CCC_MESSAGE)) {
+ ConnectManager.init(this);
+ ConnectManager.unlockConnect(this, success -> {
+ if (success) {
+ String channelId = getIntent().getStringExtra(
+ ConnectMessagingMessageRecord.META_MESSAGE_CHANNEL_ID);
+ if (channelId == null) {
+ Logger.log(LogTypes.TYPE_FCM, "Channel id is null");
+ return;
+ }
+ ConnectMessagingChannelRecord channel = ConnectMessageUtils.getMessagingChannel(this, channelId);
+
+ if (channel == null) {
+ Logger.log(LogTypes.TYPE_FCM, "Channel is null");
+ return;
+ }
+
+ int fragmentId = channel.getConsented() ? R.id.connectMessageFragment : R.id.channelListFragment;
+
+ Bundle bundle = new Bundle();
+ bundle.putString("channel_id", channelId);
+
+ NavOptions options = new NavOptions.Builder()
+ .setPopUpTo(controller.getGraph().getStartDestinationId(), true)
+ .build();
+ controller.navigate(fragmentId, bundle, options);
+ }
+ });
+ }
+ }
+
+ @Override
+ public boolean onSupportNavigateUp() {
+ NavHostFragment navHostFragment = (NavHostFragment)getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment_connect_messaging);
+ return navHostFragment.getNavController().navigateUp() || super.onSupportNavigateUp();
+ }
+}
diff --git a/app/src/org/commcare/adapters/ChannelAdapter.java b/app/src/org/commcare/adapters/ChannelAdapter.java
new file mode 100644
index 000000000..33ba14e78
--- /dev/null
+++ b/app/src/org/commcare/adapters/ChannelAdapter.java
@@ -0,0 +1,127 @@
+package org.commcare.adapters;
+
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord;
+import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord;
+import org.commcare.dalvik.databinding.ItemChannelBinding;
+import org.javarosa.core.model.utils.DateUtils;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.TimeZone;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+public class ChannelAdapter extends RecyclerView.Adapter {
+
+ private List channels;
+ private final OnChannelClickListener clickListener;
+
+ public ChannelAdapter(List channels, OnChannelClickListener clickListener) {
+ this.channels = channels;
+ this.clickListener = clickListener;
+ }
+
+ public void setChannels(List channels) {
+ this.channels = channels;
+ notifyDataSetChanged();
+ }
+
+ @NonNull
+ @Override
+ public ChannelViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ return new ChannelViewHolder(ItemChannelBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ChannelViewHolder holder, int position) {
+ holder.bind(holder.binding, channels.get(position), clickListener);
+ }
+
+ @Override
+ public int getItemCount() {
+ return channels.size();
+ }
+
+ static class ChannelViewHolder extends RecyclerView.ViewHolder {
+
+ private final ItemChannelBinding binding;
+
+ public ChannelViewHolder(@NonNull ItemChannelBinding binding) {
+ super(binding.getRoot());
+ this.binding = binding;
+ }
+
+ private String formatLastDate(Date lastDate) {
+ if (DateUtils.dateDiff(new Date(), lastDate) == 0) {
+ return DateUtils.formatTime(lastDate, DateUtils.FORMAT_HUMAN_READABLE_SHORT);
+ } else {
+ SimpleDateFormat outputFormat = new SimpleDateFormat("dd MMM, yyyy", Locale.ENGLISH);
+ outputFormat.setTimeZone(TimeZone.getDefault());
+ return outputFormat.format(lastDate);
+ }
+ }
+
+ public void bind(ItemChannelBinding binding, ConnectMessagingChannelRecord channel, OnChannelClickListener clickListener) {
+ binding.tvChannelName.setText(channel.getChannelName());
+
+ Date lastDate = null;
+ int unread = 0;
+ for (ConnectMessagingMessageRecord message : channel.getMessages()) {
+ if (lastDate == null || lastDate.before(message.getTimeStamp())) {
+ lastDate = message.getTimeStamp();
+ }
+
+ if (!message.getUserViewed()) {
+ unread++;
+ }
+ }
+
+ binding.tvChannelDescription.setText(channel.getPreview());
+
+ boolean showDate = lastDate != null;
+ binding.tvLastChatTime.setVisibility(showDate ? View.VISIBLE : View.GONE);
+ if (showDate) {
+ binding.tvLastChatTime.setText(formatLastDate(lastDate));
+ }
+
+ boolean showUnread = unread > 0;
+ binding.tvUnreadCount.setVisibility(showUnread ? View.VISIBLE : View.GONE);
+ if (showUnread) {
+ binding.tvUnreadCount.setText(String.valueOf(unread));
+ }
+
+ binding.itemRootLayout.setOnClickListener(view -> {
+ if (clickListener != null) {
+ clickListener.onChannelClick(channel);
+ }
+ });
+ }
+ }
+
+ private static String formatDate(String dateStr) {
+ try {
+ SimpleDateFormat inputFormat = new SimpleDateFormat("EEE MMM dd HH:mm:ss z yyyy", Locale.ENGLISH);
+ inputFormat.setTimeZone(TimeZone.getDefault());
+ SimpleDateFormat outputFormat = new SimpleDateFormat("dd MMM, yyyy", Locale.ENGLISH);
+ outputFormat.setTimeZone(TimeZone.getDefault());
+ Date date = inputFormat.parse(dateStr);
+ return outputFormat.format(date);
+ } catch (ParseException e) {
+ Log.e("ChannelAdapter", "Error parsing date: " + dateStr, e);
+ return "Unknown Date";
+ }
+ }
+
+ public interface OnChannelClickListener {
+ void onChannelClick(ConnectMessagingChannelRecord channel);
+ }
+}
diff --git a/app/src/org/commcare/adapters/ConnectMessageAdapter.java b/app/src/org/commcare/adapters/ConnectMessageAdapter.java
new file mode 100644
index 000000000..dcb042c32
--- /dev/null
+++ b/app/src/org/commcare/adapters/ConnectMessageAdapter.java
@@ -0,0 +1,103 @@
+package org.commcare.adapters;
+
+import android.text.SpannableStringBuilder;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import org.commcare.dalvik.databinding.ItemChatLeftViewBinding;
+import org.commcare.dalvik.databinding.ItemChatRightViewBinding;
+import org.commcare.fragments.connectMessaging.ConnectMessageChatData;
+import org.commcare.utils.MarkupUtil;
+import org.javarosa.core.model.utils.DateUtils;
+
+import java.util.List;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+public class ConnectMessageAdapter extends RecyclerView.Adapter {
+
+ public static final int LEFTVIEW = 0;
+ public static final int RIGHTVIEW = 1;
+ List messages;
+
+ public ConnectMessageAdapter(List messages) {
+ this.messages = messages;
+ }
+
+ public void updateData(List messages) {
+ this.messages = messages;
+ notifyDataSetChanged();
+ }
+
+ private static void bindCommon(TextView messageView, TextView userNameView, ConnectMessageChatData chat) {
+ SpannableStringBuilder builder = new SpannableStringBuilder();
+ builder.append(chat.getMessage());
+ MarkupUtil.setMarkdown(messageView, builder, new SpannableStringBuilder());
+ userNameView.setText(DateUtils.formatDateTime(chat.getTimestamp(), DateUtils.FORMAT_HUMAN_READABLE_SHORT));
+ }
+
+ public static class LeftViewHolder extends RecyclerView.ViewHolder {
+ ItemChatLeftViewBinding binding;
+
+ public LeftViewHolder(ItemChatLeftViewBinding binding) {
+ super(binding.getRoot());
+ this.binding = binding;
+ }
+
+ public void bind(ConnectMessageChatData chat) {
+ bindCommon(binding.tvChatMessage, binding.tvUserName, chat);
+ }
+ }
+
+ public static class RightViewHolder extends RecyclerView.ViewHolder {
+ ItemChatRightViewBinding binding;
+
+ public RightViewHolder(ItemChatRightViewBinding binding) {
+ super(binding.getRoot());
+ this.binding = binding;
+ }
+
+ public void bind(ConnectMessageChatData chat) {
+ bindCommon(binding.tvChatMessage, binding.tvUserName, chat);
+ }
+ }
+
+ @NonNull
+ @Override
+ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ if (viewType == LEFTVIEW) {
+ ItemChatLeftViewBinding binding = ItemChatLeftViewBinding.inflate(
+ LayoutInflater.from(parent.getContext()), parent, false);
+ return new LeftViewHolder(binding);
+ } else {
+ ItemChatRightViewBinding binding = ItemChatRightViewBinding.inflate(
+ LayoutInflater.from(parent.getContext()), parent, false);
+ return new RightViewHolder(binding);
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
+ ConnectMessageChatData chat = messages.get(position);
+ if (chat == null) {
+ return;
+ }
+ if (getItemViewType(position) == LEFTVIEW) {
+ ((LeftViewHolder)holder).bind(chat);
+ } else {
+ ((RightViewHolder)holder).bind(chat);
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return messages.size();
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return messages.get(position).getType() == LEFTVIEW ? LEFTVIEW : RIGHTVIEW;
+ }
+}
diff --git a/app/src/org/commcare/android/database/connect/models/ConnectMessagingChannelRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectMessagingChannelRecord.java
new file mode 100644
index 000000000..c57612082
--- /dev/null
+++ b/app/src/org/commcare/android/database/connect/models/ConnectMessagingChannelRecord.java
@@ -0,0 +1,170 @@
+package org.commcare.android.database.connect.models;
+
+import org.commcare.android.storage.framework.Persisted;
+import org.commcare.models.framework.Persisting;
+import org.commcare.modern.database.Table;
+import org.commcare.modern.models.MetaField;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.Serializable;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Table(ConnectMessagingChannelRecord.STORAGE_KEY)
+public class ConnectMessagingChannelRecord extends Persisted implements Serializable {
+
+ /**
+ * Name of database that stores Connect payment units
+ */
+ public static final String STORAGE_KEY = "connect_messaging_channel";
+
+ public static final String META_CHANNEL_ID = "channel_id";
+ public static final String META_CHANNEL_CREATED = "created";
+ public static final String META_ANSWERED_CONSENT = "answered_consent";
+ public static final String META_CONSENT = "consent";
+ public static final String META_CHANNEL_NAME = "channel_source";
+ public static final String META_KEY_URL = "key_url";
+ public static final String META_KEY = "key";
+
+ @Persisting(1)
+ @MetaField(META_CHANNEL_ID)
+ private String channelId;
+
+ @Persisting(2)
+ @MetaField(META_CHANNEL_CREATED)
+ private Date channelCreated;
+
+ @Persisting(3)
+ @MetaField(META_ANSWERED_CONSENT)
+ private boolean answeredConsent;
+
+ @Persisting(4)
+ @MetaField(META_CONSENT)
+ private boolean consented;
+
+ @Persisting(5)
+ @MetaField(META_CHANNEL_NAME)
+ private String channelName;
+
+ @Persisting(6)
+ @MetaField(META_KEY_URL)
+ private String keyUrl;
+
+ @Persisting(7)
+ @MetaField(META_KEY)
+ private String key;
+
+ private String preview;
+
+ private List messages;
+
+
+ public ConnectMessagingChannelRecord() {
+ messages = new ArrayList<>();
+ }
+
+ public static ConnectMessagingChannelRecord fromJson(JSONObject json) throws JSONException, ParseException {
+ ConnectMessagingChannelRecord connectMessagingChannelRecord = new ConnectMessagingChannelRecord();
+
+ connectMessagingChannelRecord.channelId = json.getString(META_CHANNEL_ID);
+ connectMessagingChannelRecord.consented = json.getBoolean(META_CONSENT);
+ connectMessagingChannelRecord.channelName = json.getString(META_CHANNEL_NAME);
+ connectMessagingChannelRecord.keyUrl = json.getString(META_KEY_URL);
+
+ connectMessagingChannelRecord.channelCreated = new Date();
+ connectMessagingChannelRecord.answeredConsent = false;
+ connectMessagingChannelRecord.key = "";
+
+ return connectMessagingChannelRecord;
+ }
+
+ public static ConnectMessagingChannelRecord fromMessagePayload(Map payloadData) {
+ if (payloadData == null || !payloadData.containsKey(META_CHANNEL_ID)
+ || !payloadData.containsKey(META_CONSENT)
+ || !payloadData.containsKey(META_CHANNEL_NAME)
+ || !payloadData.containsKey(META_KEY_URL)) {
+ throw new IllegalArgumentException("Missing required fields in payload data");
+ }
+ ConnectMessagingChannelRecord connectMessagingChannelRecord = new ConnectMessagingChannelRecord();
+ connectMessagingChannelRecord.channelId = payloadData.get(META_CHANNEL_ID);
+ connectMessagingChannelRecord.consented = payloadData.get(META_CONSENT).equals("true");
+ connectMessagingChannelRecord.channelName = payloadData.get(META_CHANNEL_NAME);
+ connectMessagingChannelRecord.keyUrl = payloadData.get(META_KEY_URL);
+ connectMessagingChannelRecord.channelCreated = new Date();
+ connectMessagingChannelRecord.answeredConsent = false;
+ connectMessagingChannelRecord.key = "";
+ return connectMessagingChannelRecord;
+ }
+
+ public String getChannelId() {
+ return channelId;
+ }
+
+ public void setChannelId(String channelId) {
+ this.channelId = channelId;
+ }
+
+ public Date getChannelCreated() {
+ return channelCreated;
+ }
+
+ public void setChannelCreated(Date channelCreated) {
+ this.channelCreated = channelCreated;
+ }
+
+ public boolean getAnsweredConsent() {
+ return answeredConsent;
+ }
+
+ public void setAnsweredConsent(boolean answeredConsent) {
+ this.answeredConsent = answeredConsent;
+ }
+
+ public boolean getConsented() {
+ return consented;
+ }
+
+ public void setConsented(boolean consented) {
+ this.consented = consented;
+ }
+
+ public String getChannelName() {
+ return channelName;
+ }
+
+ public void setChannelName(String channelName) {
+ this.channelName = channelName;
+ }
+
+ public String getKeyUrl() {
+ return keyUrl;
+ }
+
+ public void setKeyUrl(String keyUrl) {
+ this.keyUrl = keyUrl;
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ public void setKey(String key) {
+ this.key = key;
+ }
+
+ public List getMessages() {
+ return messages;
+ }
+
+ public void setPreview(String preview) {
+ this.preview = preview;
+ }
+
+ public String getPreview() {
+ return preview;
+ }
+}
diff --git a/app/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecord.java
new file mode 100644
index 000000000..ddf84dd1f
--- /dev/null
+++ b/app/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecord.java
@@ -0,0 +1,244 @@
+package org.commcare.android.database.connect.models;
+
+import org.commcare.android.storage.framework.Persisted;
+import org.commcare.models.framework.Persisting;
+import org.commcare.modern.database.Table;
+import org.commcare.modern.models.MetaField;
+import org.commcare.util.Base64;
+import org.commcare.util.EncryptionUtils;
+import org.commcare.util.LogTypes;
+import org.javarosa.core.model.utils.DateUtils;
+import org.javarosa.core.services.Logger;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.Serializable;
+import java.nio.ByteBuffer;
+import java.text.ParseException;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import androidx.annotation.NonNull;
+
+@Table(ConnectMessagingMessageRecord.STORAGE_KEY)
+public class ConnectMessagingMessageRecord extends Persisted implements Serializable {
+
+ /**
+ * Name of database that stores Connect payment units
+ */
+ public static final String STORAGE_KEY = "connect_messaging_message";
+
+ public static final String META_MESSAGE_ID = "message_id";
+ public static final String META_MESSAGE_CHANNEL_ID = "channel";
+ public static final String META_MESSAGE_TIMESTAMP = "timestamp";
+ public static final String META_MESSAGE = "content";
+ public static final String META_MESSAGE_IS_OUTGOING = "is_outgoing";
+ public static final String META_MESSAGE_CONFIRM = "confirmed";
+ public static final String META_MESSAGE_USER_VIEWED = "user_viewed";
+
+ public ConnectMessagingMessageRecord() {
+
+ }
+
+ @Persisting(1)
+ @MetaField(META_MESSAGE_ID)
+ private String messageId;
+
+ @Persisting(2)
+ @MetaField(META_MESSAGE_CHANNEL_ID)
+ private String channelId;
+
+ @Persisting(3)
+ @MetaField(META_MESSAGE_TIMESTAMP)
+ private Date timeStamp;
+
+ @Persisting(4)
+ @MetaField(META_MESSAGE)
+ private String message;
+
+ @Persisting(5)
+ @MetaField(META_MESSAGE_IS_OUTGOING)
+ private boolean isOutgoing;
+
+ @Persisting(6)
+ @MetaField(META_MESSAGE_CONFIRM)
+ private boolean confirmed;
+
+ @Persisting(7)
+ @MetaField(META_MESSAGE_USER_VIEWED)
+ private boolean userViewed;
+
+ public static ConnectMessagingMessageRecord fromJson(JSONObject json, List channels) throws JSONException, ParseException {
+ ConnectMessagingMessageRecord connectMessagingMessageRecord = new ConnectMessagingMessageRecord();
+
+ connectMessagingMessageRecord.messageId = json.getString(META_MESSAGE_ID);
+ connectMessagingMessageRecord.channelId = json.getString(META_MESSAGE_CHANNEL_ID);
+
+ ConnectMessagingChannelRecord channel = getChannel(channels, connectMessagingMessageRecord.channelId);
+ if (channel == null) {
+ return null;
+ }
+
+ String dateString = json.getString(META_MESSAGE_TIMESTAMP);
+ connectMessagingMessageRecord.timeStamp = DateUtils.parseDateTime(dateString);
+
+ String tag = json.getString("tag");
+ String nonce = json.getString("nonce");
+ String cipherText = json.getString("ciphertext");
+
+ String decrypted = decrypt(cipherText, nonce, tag, channel.getKey());
+
+ if (decrypted == null) {
+ return null;
+ }
+
+ connectMessagingMessageRecord.message = decrypted;
+
+ connectMessagingMessageRecord.isOutgoing = false;
+ connectMessagingMessageRecord.confirmed = false;
+ connectMessagingMessageRecord.userViewed = false;
+
+ return connectMessagingMessageRecord;
+ }
+
+ public static ConnectMessagingMessageRecord fromMessagePayload(Map payloadData, String encryptionKey) {
+ String channel = payloadData.get(META_MESSAGE_CHANNEL_ID);
+ String cipher = payloadData.get("ciphertext");
+ String tag = payloadData.get("tag");
+ String nonce = payloadData.get("nonce");
+ String decrypted = decrypt(cipher, nonce, tag, encryptionKey);
+
+ if (decrypted == null) {
+ return null;
+ }
+
+ ConnectMessagingMessageRecord record = new ConnectMessagingMessageRecord();
+ record.setMessageId(payloadData.get(META_MESSAGE_ID));
+ record.setTimeStamp(DateUtils.parseDateTime(payloadData.get(META_MESSAGE_TIMESTAMP)));
+ record.setChannelId(channel);
+ record.setConfirmed(false);
+ record.setMessage(decrypted);
+ record.setUserViewed(false);
+ record.setIsOutgoing(false);
+
+ return record;
+ }
+
+ private static ConnectMessagingChannelRecord getChannel(List channels, String channelId) {
+ for (ConnectMessagingChannelRecord channel : channels) {
+ if (channel.getChannelId().equals(channelId)) {
+ return channel;
+ }
+ }
+
+ return null;
+ }
+
+ private static String decrypt(String cipherText, String nonce, String tag, String key) {
+ try {
+ byte[] cipherTextBytes = Base64.decode(cipherText);
+ byte[] nonceBytes = Base64.decode(nonce);
+ byte[] tagBytes = Base64.decode(tag);
+
+ ByteBuffer bytes = ByteBuffer.allocate(cipherTextBytes.length + nonceBytes.length + tagBytes.length + 1);
+ bytes.put((byte)nonceBytes.length);
+ bytes.put(nonceBytes);
+ bytes.put(cipherTextBytes);
+ bytes.put(tagBytes);
+
+ String encoded = Base64.encode(bytes.array());
+ return EncryptionUtils.decrypt(encoded, key);
+ } catch (IllegalArgumentException e) {
+ Logger.log(LogTypes.TYPE_ERROR_CRYPTO, "Invalid Base64 encoding in message");
+ return null;
+ } catch (Exception e) {
+ Logger.log(LogTypes.TYPE_ERROR_CRYPTO, "Decryption failed: " + e.getMessage());
+ return null;
+ }
+ }
+
+ public static String[] encrypt(@NonNull String text, @NonNull String key) {
+ try {
+ String encoded = EncryptionUtils.encrypt(text, key);
+ byte[] bytes = Base64.decode(encoded);
+
+ ByteBuffer buffer = ByteBuffer.wrap(bytes);
+
+ int nonceLength = buffer.get();
+ byte[] nonceBytes = new byte[nonceLength];
+ buffer.get(nonceBytes);
+ String nonce = Base64.encode(nonceBytes);
+
+ int tagLength = 16;
+ int textLength = bytes.length - 1 - nonceLength - tagLength;
+ byte[] cipherBytes = new byte[textLength];
+ buffer.get(cipherBytes);
+ String cipherText = Base64.encode(cipherBytes);
+
+ byte[] tagBytes = new byte[tagLength];
+ buffer.get(tagBytes);
+ String tag = Base64.encode(tagBytes);
+
+ return new String[]{cipherText, nonce, tag};
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public String getMessageId() {
+ return messageId;
+ }
+
+ public void setMessageId(String messageId) {
+ this.messageId = messageId;
+ }
+
+ public String getChannelId() {
+ return channelId;
+ }
+
+ public void setChannelId(String channelId) {
+ this.channelId = channelId;
+ }
+
+ public Date getTimeStamp() {
+ return timeStamp;
+ }
+
+ public void setTimeStamp(Date timeStamp) {
+ this.timeStamp = timeStamp;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ public boolean getIsOutgoing() {
+ return isOutgoing;
+ }
+
+ public void setIsOutgoing(boolean isOutgoing) {
+ this.isOutgoing = isOutgoing;
+ }
+
+ public boolean getConfirmed() {
+ return confirmed;
+ }
+
+ public void setConfirmed(boolean confirmed) {
+ this.confirmed = confirmed;
+ }
+
+ public boolean getUserViewed() {
+ return userViewed;
+ }
+
+ public void setUserViewed(boolean userViewed) {
+ this.userViewed = userViewed;
+ }
+}
diff --git a/app/src/org/commcare/connect/MessageManager.java b/app/src/org/commcare/connect/MessageManager.java
new file mode 100644
index 000000000..8b747c2c3
--- /dev/null
+++ b/app/src/org/commcare/connect/MessageManager.java
@@ -0,0 +1,342 @@
+package org.commcare.connect;
+
+import android.content.Context;
+import android.util.Log;
+import android.widget.Toast;
+
+import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord;
+import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord;
+import org.commcare.android.database.connect.models.ConnectUserRecord;
+import org.commcare.connect.database.ConnectMessageUtils;
+import org.commcare.connect.network.ApiConnectId;
+import org.commcare.connect.network.IApiCallback;
+import org.commcare.dalvik.R;
+import org.javarosa.core.io.StreamsUtil;
+import org.javarosa.core.services.Logger;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+public class MessageManager {
+
+ private static MessageManager manager = null;
+
+ public static MessageManager getInstance() {
+ if (manager == null) {
+ manager = new MessageManager();
+ }
+
+ return manager;
+ }
+
+ public static ConnectMessagingMessageRecord handleReceivedMessage(Context context, Map payloadData) {
+ ConnectMessagingMessageRecord message = null;
+ String channelId = payloadData.get(ConnectMessagingMessageRecord.META_MESSAGE_CHANNEL_ID);
+
+ //Make sure we know and have consented to the channel
+ ConnectMessagingChannelRecord channel = ConnectMessageUtils.getMessagingChannel(context, channelId);
+ if (channel != null && channel.getConsented()) {
+ message = ConnectMessagingMessageRecord.fromMessagePayload(payloadData, channel.getKey());
+ if (message != null) {
+ ConnectMessageUtils.storeMessagingMessage(context, message);
+ }
+ }
+
+ return message;
+ }
+
+ public static ConnectMessagingChannelRecord handleReceivedChannel(Context context, Map payloadData) {
+ ConnectMessagingChannelRecord channel = ConnectMessagingChannelRecord.fromMessagePayload(payloadData);
+ ConnectMessageUtils.storeMessagingChannel(context, channel);
+
+ return channel;
+ }
+
+ public static void retrieveMessages(Context context, ConnectManager.ConnectActivityCompleteListener listener) {
+ IApiCallback callback = new IApiCallback() {
+ @Override
+ public void processSuccess(int responseCode, InputStream responseData) {
+ try {
+ String responseAsString = new String(
+ StreamsUtil.inputStreamToByteArray(responseData));
+ Log.e("DEBUG_TESTING", "processSuccess: " + responseAsString);
+ List channels = new ArrayList<>();
+ List messages = new ArrayList<>();
+ if (responseAsString.length() > 0) {
+ JSONObject json = new JSONObject(responseAsString);
+ JSONArray channelsJson = json.getJSONArray("channels");
+ for (int i = 0; i < channelsJson.length(); i++) {
+ JSONObject obj = (JSONObject)channelsJson.get(i);
+ ConnectMessagingChannelRecord channel = ConnectMessagingChannelRecord.fromJson(obj);
+ channels.add(channel);
+ }
+
+ JSONArray messagesJson = json.getJSONArray("messages");
+ List existingChannels = ConnectMessageUtils.getMessagingChannels(context);
+ for (int i = 0; i < messagesJson.length(); i++) {
+ JSONObject obj = (JSONObject)messagesJson.get(i);
+ ConnectMessagingMessageRecord message = ConnectMessagingMessageRecord.fromJson(obj, existingChannels);
+ if (message != null) {
+ messages.add(message);
+ }
+ }
+ }
+
+ ConnectMessageUtils.storeMessagingChannels(context, channels, true);
+ ConnectMessageUtils.storeMessagingMessages(context, messages, false);
+
+ for (ConnectMessagingChannelRecord channel : channels) {
+ if (channel.getConsented() && channel.getKey().length() == 0) {
+ getChannelEncryptionKey(context, channel, null);
+ }
+ }
+
+ if (messages.size() > 0) {
+ MessageManager.updateReceivedMessages(context, success -> {
+ Log.d("Check", Boolean.toString(success));
+ });
+ }
+
+ listener.connectActivityComplete(true);
+ } catch (Exception e) {
+ Log.e("Error", "Oops", e);
+ listener.connectActivityComplete(false);
+ }
+ }
+
+ @Override
+ public void processFailure(int responseCode, IOException e) {
+ Log.e("DEBUG_TESTING", "processFailure: " + responseCode);
+ listener.connectActivityComplete(false);
+
+ String message = "";
+ if (responseCode > 0) {
+ message = String.format(Locale.getDefault(), "(%d)", responseCode);
+ } else if (e != null) {
+ message = e.toString();
+ }
+ }
+
+ @Override
+ public void processNetworkFailure() {
+ Log.e("DEBUG_TESTING", "processNetworkFailure: ");
+ listener.connectActivityComplete(false);
+ }
+
+ @Override
+ public void processOldApiError() {
+ Log.e("DEBUG_TESTING", "processOldApiError: ");
+ listener.connectActivityComplete(false);
+ }
+ };
+
+ ConnectUserRecord user = ConnectManager.getUser(context);
+ ApiConnectId.retrieveMessages(context, user.getUserId(), user.getPassword(), callback);
+ }
+
+ public static void updateChannelConsent(Context context, ConnectMessagingChannelRecord channel,
+ ConnectManager.ConnectActivityCompleteListener listener) {
+ IApiCallback callback = new IApiCallback() {
+ @Override
+ public void processSuccess(int responseCode, InputStream responseData) {
+ try {
+ String responseAsString = new String(
+ StreamsUtil.inputStreamToByteArray(responseData));
+ Log.e("DEBUG_TESTING", "processSuccess: " + responseAsString);
+
+ ConnectMessageUtils.storeMessagingChannel(context, channel);
+
+ if (channel.getConsented()) {
+ getChannelEncryptionKey(context, channel, listener);
+ } else {
+ listener.connectActivityComplete(true);
+ }
+ } catch (Exception e) {
+ Log.e("Error", "Oops", e);
+ listener.connectActivityComplete(false);
+ }
+ }
+
+ @Override
+ public void processFailure(int responseCode, IOException e) {
+ Log.e("DEBUG_TESTING", "processFailure: " + responseCode);
+ //listener.connectActivityComplete(false);
+ getChannelEncryptionKey(context, channel, listener);
+
+ String message = "";
+ if (responseCode > 0) {
+ message = String.format(Locale.getDefault(), "(%d)", responseCode);
+ } else if (e != null) {
+ message = e.toString();
+ }
+ }
+
+ @Override
+ public void processNetworkFailure() {
+ Log.e("DEBUG_TESTING", "processNetworkFailure: ");
+ listener.connectActivityComplete(false);
+ }
+
+ @Override
+ public void processOldApiError() {
+ Log.e("DEBUG_TESTING", "processOldApiError: ");
+ listener.connectActivityComplete(false);
+ }
+ };
+
+ ConnectUserRecord user = ConnectManager.getUser(context);
+ boolean isBusy = !ApiConnectId.updateChannelConsent(context, user.getUserId(), user.getPassword(),
+ channel.getChannelId(), channel.getConsented(), callback);
+
+ if (isBusy) {
+ Toast.makeText(context, R.string.busy_message, Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ public static void getChannelEncryptionKey(Context context, ConnectMessagingChannelRecord channel,
+ ConnectManager.ConnectActivityCompleteListener listener) {
+ ApiConnectId.retrieveChannelEncryptionKey(context, channel.getChannelId(), channel.getKeyUrl(),
+ new IApiCallback() {
+ @Override
+ public void processSuccess(int responseCode, InputStream responseData) {
+ try {
+ String responseAsString = new String(
+ StreamsUtil.inputStreamToByteArray(responseData));
+ Log.e("DEBUG_TESTING", "processSuccess: " + responseAsString);
+
+ if (responseAsString.length() > 0) {
+ JSONObject json = new JSONObject(responseAsString);
+ channel.setKey(json.getString("key"));
+ ConnectMessageUtils.storeMessagingChannel(context, channel);
+ }
+
+ if (listener != null) {
+ listener.connectActivityComplete(true);
+ }
+ } catch (Exception e) {
+ Log.e("Error", "Oops", e);
+ if (listener != null) {
+ listener.connectActivityComplete(false);
+ }
+ }
+ }
+
+ @Override
+ public void processFailure(int responseCode, IOException e) {
+ Log.d("DEBUG", "Chcek");
+ if (listener != null) {
+ listener.connectActivityComplete(false);
+ }
+ }
+
+ @Override
+ public void processNetworkFailure() {
+ Log.d("DEBUG", "Chcek");
+ if (listener != null) {
+ listener.connectActivityComplete(false);
+ }
+ }
+
+ @Override
+ public void processOldApiError() {
+ Log.d("DEBUG", "Chcek");
+ if (listener != null) {
+ listener.connectActivityComplete(false);
+ }
+ }
+ });
+ }
+
+ public static void updateReceivedMessages(Context context, ConnectManager.ConnectActivityCompleteListener listener) {
+ List messages = ConnectMessageUtils.getMessagingMessagesAll(context);
+ List unsent = new ArrayList<>();
+ List unsentIds = new ArrayList<>();
+ for (ConnectMessagingMessageRecord message : messages) {
+ if (!message.getIsOutgoing() && !message.getConfirmed()) {
+ unsent.add(message);
+ unsentIds.add(message.getMessageId());
+ }
+ }
+
+ if (unsentIds.size() > 0) {
+ ConnectUserRecord user = ConnectManager.getUser(context);
+ ApiConnectId.confirmReceivedMessages(context, user.getUserId(), user.getPassword(), unsentIds, new IApiCallback() {
+ @Override
+ public void processSuccess(int responseCode, InputStream responseData) {
+ for (ConnectMessagingMessageRecord message : unsent) {
+ message.setConfirmed(true);
+ ConnectMessageUtils.storeMessagingMessage(context, message);
+ }
+ listener.connectActivityComplete(true);
+ }
+
+ @Override
+ public void processFailure(int responseCode, IOException e) {
+ listener.connectActivityComplete(false);
+ }
+
+ @Override
+ public void processNetworkFailure() {
+ listener.connectActivityComplete(false);
+ }
+
+ @Override
+ public void processOldApiError() {
+ listener.connectActivityComplete(false);
+ }
+ });
+ }
+ }
+
+ public static void sendUnsentMessages(Context context) {
+ List messages = ConnectMessageUtils.getMessagingMessagesAll(context);
+ for (ConnectMessagingMessageRecord message : messages) {
+ if (message.getIsOutgoing() && !message.getConfirmed()) {
+ sendMessage(context, message, success -> {
+ Log.d("Check", Boolean.toString(success));
+ });
+ break;
+ }
+ }
+ }
+
+ public static void sendMessage(Context context, ConnectMessagingMessageRecord message,
+ ConnectManager.ConnectActivityCompleteListener listener) {
+ ConnectMessagingChannelRecord channel = ConnectMessageUtils.getMessagingChannel(context, message.getChannelId());
+
+ if (channel.getKey().length() > 0) {
+ ConnectUserRecord user = ConnectManager.getUser(context);
+ ApiConnectId.sendMessagingMessage(context, user.getUserId(), user.getPassword(), message, channel.getKey(), new IApiCallback() {
+ @Override
+ public void processSuccess(int responseCode, InputStream responseData) {
+ message.setConfirmed(true);
+ ConnectMessageUtils.storeMessagingMessage(context, message);
+ listener.connectActivityComplete(true);
+ }
+
+ @Override
+ public void processFailure(int responseCode, IOException e) {
+ listener.connectActivityComplete(false);
+ }
+
+ @Override
+ public void processNetworkFailure() {
+ listener.connectActivityComplete(false);
+ }
+
+ @Override
+ public void processOldApiError() {
+ listener.connectActivityComplete(false);
+ }
+ });
+ } else {
+ Logger.log("Messaging", "Tried to send message but no encryption key");
+ }
+ }
+}
diff --git a/app/src/org/commcare/connect/database/ConnectMessageUtils.java b/app/src/org/commcare/connect/database/ConnectMessageUtils.java
new file mode 100644
index 000000000..57baa45e7
--- /dev/null
+++ b/app/src/org/commcare/connect/database/ConnectMessageUtils.java
@@ -0,0 +1,183 @@
+package org.commcare.connect.database;
+
+import android.content.Context;
+
+import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord;
+import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord;
+import org.commcare.dalvik.R;
+import org.commcare.models.database.SqlStorage;
+
+import java.util.List;
+import java.util.Vector;
+
+public class ConnectMessageUtils {
+ public static List getMessagingChannels(Context context) {
+ List channels = ConnectDatabaseHelper.getConnectStorage(context, ConnectMessagingChannelRecord.class)
+ .getRecordsForValues(new String[]{}, new Object[]{});
+
+ for (ConnectMessagingMessageRecord message : getMessagingMessagesAll(context)) {
+ for (ConnectMessagingChannelRecord searchChannel : channels) {
+ if (message.getChannelId().equals(searchChannel.getChannelId())) {
+ searchChannel.getMessages().add(message);
+ break;
+ }
+ }
+ }
+
+ for (ConnectMessagingChannelRecord channel : channels) {
+ List messages = channel.getMessages();
+ ConnectMessagingMessageRecord lastMessage = messages.size() > 0 ?
+ messages.get(messages.size() - 1) : null;
+ String preview = "";
+ if (!channel.getConsented()) {
+ preview = context.getString(R.string.connect_messaging_channel_list_unconsented);
+ } else if (lastMessage != null) {
+ int senderId = lastMessage.getIsOutgoing() ?
+ R.string.connect_messaging_channel_preview_you :
+ R.string.connect_messaging_channel_preview_them;
+ String sender = context.getString(senderId);
+
+ String trimmed = lastMessage.getMessage().split("\n")[0];
+ int maxLength = 25;
+ if (trimmed.length() > maxLength) {
+ trimmed = trimmed.substring(0, maxLength - 3) + "...";
+ }
+
+ preview = String.format("%s: %s", sender, trimmed);
+ }
+
+ channel.setPreview(preview);
+ }
+
+ return channels;
+ }
+
+ public static ConnectMessagingChannelRecord getMessagingChannel(Context context, String channelId) {
+ List channels = ConnectDatabaseHelper.getConnectStorage(context, ConnectMessagingChannelRecord.class)
+ .getRecordsForValues(new String[]{ConnectMessagingChannelRecord.META_CHANNEL_ID},
+ new Object[]{channelId});
+
+ if (channels.size() > 0) {
+ return channels.get(0);
+ }
+
+ return null;
+ }
+
+ public static void storeMessagingChannel(Context context, ConnectMessagingChannelRecord channel) {
+ ConnectMessagingChannelRecord existing = getMessagingChannel(context, channel.getChannelId());
+ if (existing != null) {
+ channel.setID(existing.getID());
+ }
+
+ ConnectDatabaseHelper.getConnectStorage(context, ConnectMessagingChannelRecord.class).write(channel);
+ }
+
+ public static void storeMessagingChannels(Context context, List channels, boolean pruneMissing) {
+ SqlStorage storage = ConnectDatabaseHelper.getConnectStorage(context, ConnectMessagingChannelRecord.class);
+
+ List existingList = getMessagingChannels(context);
+
+ //Delete payments that are no longer available
+ Vector recordIdsToDelete = new Vector<>();
+ for (ConnectMessagingChannelRecord existing : existingList) {
+ boolean stillExists = false;
+ for (ConnectMessagingChannelRecord incoming : channels) {
+ if (existing.getChannelId().equals(incoming.getChannelId())) {
+ incoming.setID(existing.getID());
+
+ incoming.setChannelCreated(existing.getChannelCreated());
+
+ if (!incoming.getAnsweredConsent()) {
+ incoming.setAnsweredConsent(existing.getAnsweredConsent());
+ }
+
+ if (existing.getKey().length() > 0) {
+ incoming.setKey(existing.getKey());
+ }
+
+ stillExists = true;
+ break;
+ }
+ }
+
+ if (!stillExists && pruneMissing) {
+ //Mark the delivery for deletion
+ //Remember the ID so we can delete them all at once after the loop
+ recordIdsToDelete.add(existing.getID());
+ }
+ }
+
+ if (pruneMissing) {
+ storage.removeAll(recordIdsToDelete);
+ }
+
+ //Now insert/update deliveries
+ for (ConnectMessagingChannelRecord incomingRecord : channels) {
+ storage.write(incomingRecord);
+ }
+ }
+
+ public static List getMessagingMessagesAll(Context context) {
+ return ConnectDatabaseHelper.getConnectStorage(context, ConnectMessagingMessageRecord.class)
+ .getRecordsForValues(new String[]{}, new Object[]{});
+ }
+
+ public static List getMessagingMessagesForChannel(Context context, String channelId) {
+ return ConnectDatabaseHelper.getConnectStorage(context, ConnectMessagingMessageRecord.class)
+ .getRecordsForValues(new String[]{ConnectMessagingMessageRecord.META_MESSAGE_CHANNEL_ID}, new Object[]{channelId});
+ }
+
+ public static List getUnviewedMessages(Context context) {
+ return ConnectDatabaseHelper.getConnectStorage(context, ConnectMessagingMessageRecord.class)
+ .getRecordsForValues(new String[]{ConnectMessagingMessageRecord.META_MESSAGE_USER_VIEWED}, new Object[]{false});
+ }
+
+ public static void storeMessagingMessage(Context context, ConnectMessagingMessageRecord message) {
+ SqlStorage storage = ConnectDatabaseHelper.getConnectStorage(context, ConnectMessagingMessageRecord.class);
+
+ List existingList = getMessagingMessagesForChannel(context, message.getChannelId());
+ for (ConnectMessagingMessageRecord existing : existingList) {
+ if (existing.getMessageId().equals(message.getMessageId())) {
+ message.setID(existing.getID());
+ break;
+ }
+ }
+
+ storage.write(message);
+ }
+
+ public static void storeMessagingMessages(Context context, List messages, boolean pruneMissing) {
+ SqlStorage storage = ConnectDatabaseHelper.getConnectStorage(context, ConnectMessagingMessageRecord.class);
+
+ List existingList = getMessagingMessagesAll(context);
+
+ //Delete payments that are no longer available
+ Vector recordIdsToDelete = new Vector<>();
+ for (ConnectMessagingMessageRecord existing : existingList) {
+ boolean stillExists = false;
+ for (ConnectMessagingMessageRecord incoming : messages) {
+ if (existing.getMessageId().equals(incoming.getMessageId())) {
+ incoming.setID(existing.getID());
+ stillExists = true;
+ break;
+ }
+ }
+
+ if (!stillExists && pruneMissing) {
+ //Mark the delivery for deletion
+ //Remember the ID so we can delete them all at once after the loop
+ recordIdsToDelete.add(existing.getID());
+ }
+ }
+
+ if (pruneMissing) {
+ storage.removeAll(recordIdsToDelete);
+ }
+
+ //Now insert/update deliveries
+ for (ConnectMessagingMessageRecord incomingRecord : messages) {
+ storage.write(incomingRecord);
+ }
+ }
+}
diff --git a/app/src/org/commcare/connect/network/ApiConnect.java b/app/src/org/commcare/connect/network/ApiConnect.java
index 068e1e3e4..6696c87c4 100644
--- a/app/src/org/commcare/connect/network/ApiConnect.java
+++ b/app/src/org/commcare/connect/network/ApiConnect.java
@@ -20,7 +20,7 @@ public static boolean getConnectOpportunities(Context context, IApiCallback hand
}
ConnectSsoHelper.retrieveConnectTokenAsync(context, token -> {
- if(token == null) {
+ if (token == null) {
return;
}
@@ -39,12 +39,12 @@ public static boolean startLearnApp(Context context, int jobId, IApiCallback han
}
ConnectSsoHelper.retrieveConnectTokenAsync(context, token -> {
- if(token == null) {
+ if (token == null) {
return;
}
String url = context.getString(R.string.ConnectStartLearningURL, BuildConfig.CCC_HOST);
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("opportunity", String.format(Locale.getDefault(), "%d", jobId));
ConnectNetworkHelper.post(context, url, API_VERSION_CONNECT, token, params, true, false, handler);
@@ -59,7 +59,7 @@ public static boolean getLearnProgress(Context context, int jobId, IApiCallback
}
ConnectSsoHelper.retrieveConnectTokenAsync(context, token -> {
- if(token == null) {
+ if (token == null) {
return;
}
@@ -78,12 +78,12 @@ public static boolean claimJob(Context context, int jobId, IApiCallback handler)
}
ConnectSsoHelper.retrieveConnectTokenAsync(context, token -> {
- if(token == null) {
+ if (token == null) {
return;
}
String url = context.getString(R.string.ConnectClaimJobURL, BuildConfig.CCC_HOST, jobId);
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
ConnectNetworkHelper.post(context, url, API_VERSION_CONNECT, token, params, false, false, handler);
});
@@ -97,7 +97,7 @@ public static boolean getDeliveries(Context context, int jobId, IApiCallback han
}
ConnectSsoHelper.retrieveConnectTokenAsync(context, token -> {
- if(token == null) {
+ if (token == null) {
return;
}
@@ -116,13 +116,13 @@ public static boolean setPaymentConfirmed(Context context, String paymentId, boo
}
ConnectSsoHelper.retrieveConnectTokenAsync(context, token -> {
- if(token == null) {
+ if (token == null) {
return;
}
String url = context.getString(R.string.ConnectPaymentConfirmationURL, BuildConfig.CCC_HOST, paymentId);
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("confirmed", confirmed ? "true" : "false");
ConnectNetworkHelper.post(context, url, API_VERSION_CONNECT, token, params, true, false, handler);
diff --git a/app/src/org/commcare/connect/network/ApiConnectId.java b/app/src/org/commcare/connect/network/ApiConnectId.java
index 120c633ff..72d4eaa13 100644
--- a/app/src/org/commcare/connect/network/ApiConnectId.java
+++ b/app/src/org/commcare/connect/network/ApiConnectId.java
@@ -5,10 +5,12 @@
import android.os.Handler;
import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
import org.commcare.CommCareApplication;
import org.commcare.activities.CommCareActivity;
import org.commcare.android.database.connect.models.ConnectLinkedAppRecord;
+import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord;
import org.commcare.connect.ConnectConstants;
import org.commcare.android.database.connect.models.ConnectUserRecord;
import org.commcare.connect.database.ConnectAppDatabaseUtil;
@@ -26,6 +28,7 @@
import org.commcare.utils.CrashUtil;
import org.commcare.utils.FirebaseMessagingUtil;
import org.javarosa.core.io.StreamsUtil;
+import org.javarosa.core.model.utils.DateUtils;
import org.javarosa.core.services.Logger;
import org.json.JSONException;
import org.json.JSONObject;
@@ -36,6 +39,7 @@
import java.net.URL;
import java.util.Date;
import java.util.HashMap;
+import java.util.List;
import okhttp3.ResponseBody;
import retrofit2.Call;
@@ -52,7 +56,7 @@ public class ApiConnectId {
private static final String CONNECT_CLIENT_ID = "zqFUtAAMrxmjnC1Ji74KAa6ZpY1mZly0J0PlalIa";
public static void linkHqWorker(Context context, String hqUsername, ConnectLinkedAppRecord appRecord, String connectToken) {
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("token", connectToken);
String host;
@@ -84,7 +88,7 @@ public static void linkHqWorker(Context context, String hqUsername, ConnectLinke
}
public static AuthInfo.TokenAuth retrieveHqTokenApi(Context context, String hqUsername, String connectToken) throws MalformedURLException {
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("client_id", HQ_CLIENT_ID);
params.put("scope", "mobile_access sync");
params.put("grant_type", "password");
@@ -114,7 +118,7 @@ public static AuthInfo.TokenAuth retrieveHqTokenApi(Context context, String hqUs
public static ConnectNetworkHelper.PostResult makeHeartbeatRequestSync(Context context) {
String url = ApiClient.BASE_URL + context.getString(R.string.ConnectHeartbeatURL);
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
String token = FirebaseMessagingUtil.getFCMToken();
if (token != null) {
params.put("fcm_token", token);
@@ -130,7 +134,7 @@ public static AuthInfo.TokenAuth retrieveConnectIdTokenSync(Context context) {
ConnectUserRecord user = ConnectUserDatabaseUtil.getUser(context);
if (user != null) {
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("client_id", CONNECT_CLIENT_ID);
params.put("scope", "openid");
params.put("grant_type", "password");
@@ -233,7 +237,7 @@ public void onFailure(Call call, Throwable t) {
public static void checkPassword(Context context, String phone, String secret,
String password, IApiCallback callback) {
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("phone", phone);
params.put("secret_key", secret);
params.put("password", password);
@@ -245,7 +249,7 @@ public static void checkPassword(Context context, String phone, String secret,
public static void resetPassword(Context context, String phoneNumber, String recoverySecret,
String newPassword, IApiCallback callback) {
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("phone", phoneNumber);
params.put("secret_key", recoverySecret);
params.put("password", newPassword);
@@ -257,7 +261,7 @@ public static void resetPassword(Context context, String phoneNumber, String rec
public static void checkPin(Context context, String phone, String secret,
String pin, IApiCallback callback) {
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("phone", phone);
params.put("secret_key", secret);
params.put("recovery_pin", pin);
@@ -273,7 +277,7 @@ public static void changePin(Context context, String username, String password,
AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false);
String token = HttpUtils.getCredential(authInfo);
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("recovery_pin", pin);
ApiService apiService = ApiClient.getClient().create(ApiService.class);
@@ -289,7 +293,7 @@ public static void checkPhoneAvailable(Context context, String phone, IApiCallba
public static void registerUser(Context context, String username, String password, String displayName,
String phone, IApiCallback callback) {
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("username", username);
params.put("password", password);
params.put("name", displayName);
@@ -306,7 +310,7 @@ public static void changePhone(Context context, String username, String password
//Update the phone number with the server
AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false);
String token = HttpUtils.getCredential(authInfo);
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("old_phone_number", oldPhone);
params.put("new_phone_number", newPhone);
ApiService apiService = ApiClient.getClient().create(ApiService.class);
@@ -320,7 +324,7 @@ public static void updateUserProfile(Context context, String username,
//Update the phone number with the server
AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false);
String token = HttpUtils.getCredential(authInfo);
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
if (secondaryPhone != null) {
params.put("secondary_phone", secondaryPhone);
}
@@ -337,14 +341,14 @@ public static void requestRegistrationOtpPrimary(Context context, String usernam
IApiCallback callback) {
AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false);
String token = HttpUtils.getCredential(authInfo);
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
ApiService apiService = ApiClient.getClient().create(ApiService.class);
Call call = apiService.validatePhone(token, params);
callApi(context, call, callback);
}
public static void requestRecoveryOtpPrimary(Context context, String phone, IApiCallback callback) {
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("phone", phone);
ApiService apiService = ApiClient.getClient().create(ApiService.class);
Call call = apiService.requestOTPPrimary(params);
@@ -353,7 +357,7 @@ public static void requestRecoveryOtpPrimary(Context context, String phone, IApi
public static void requestRecoveryOtpSecondary(Context context, String phone, String secret,
IApiCallback callback) {
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("phone", phone);
params.put("secret_key", secret);
ApiService apiService = ApiClient.getClient().create(ApiService.class);
@@ -365,7 +369,7 @@ public static void requestVerificationOtpSecondary(Context context, String usern
IApiCallback callback) {
AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false);
String basicToken = HttpUtils.getCredential(authInfo);
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
ApiService apiService = ApiClient.getClient().create(ApiService.class);
Call call = apiService.validateSecondaryPhone(basicToken, params);
callApi(context, call, callback);
@@ -375,7 +379,7 @@ public static void confirmRegistrationOtpPrimary(Context context, String usernam
String token, IApiCallback callback) {
AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false);
String basicToken = HttpUtils.getCredential(authInfo);
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("token", token);
ApiService apiService = ApiClient.getClient().create(ApiService.class);
@@ -385,7 +389,7 @@ public static void confirmRegistrationOtpPrimary(Context context, String usernam
public static void confirmRecoveryOtpPrimary(Context context, String phone, String secret,
String token, IApiCallback callback) {
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("phone", phone);
params.put("secret_key", secret);
params.put("token", token);
@@ -396,7 +400,7 @@ public static void confirmRecoveryOtpPrimary(Context context, String phone, Stri
public static void confirmRecoveryOtpSecondary(Context context, String phone, String secret,
String token, IApiCallback callback) {
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("phone", phone);
params.put("secret_key", secret);
params.put("token", token);
@@ -409,7 +413,7 @@ public static void confirmVerificationOtpSecondary(Context context, String usern
String token, IApiCallback callback) {
AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false);
String token1 = HttpUtils.getCredential(authInfo);
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("token", token);
ApiService apiService = ApiClient.getClient().create(ApiService.class);
Call call = apiService.confirmOTPSecondary(token1, params);
@@ -417,7 +421,7 @@ public static void confirmVerificationOtpSecondary(Context context, String usern
}
public static void requestInitiateAccountDeactivation(Context context, String phone, String secretKey, IApiCallback callback) {
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("secret_key", secretKey);
params.put("phone_number", phone);
ApiService apiService = ApiClient.getClient().create(ApiService.class);
@@ -427,7 +431,7 @@ public static void requestInitiateAccountDeactivation(Context context, String ph
public static void confirmUserDeactivation(Context context, String phone, String secret,
String token, IApiCallback callback) {
- HashMap params = new HashMap<>();
+ HashMap params = new HashMap<>();
params.put("phone_number", phone);
params.put("secret_key", secret);
params.put("token", token);
@@ -468,4 +472,88 @@ private static void handleNetworkError(Throwable t) {
Logger.log(LogTypes.TYPE_ERROR_SERVER_COMMS, "Unexpected Error: " + message);
}
}
+
+ public static void retrieveMessages(Context context, String username, String password, IApiCallback callback) {
+ AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false);
+
+ Multimap params = ArrayListMultimap.create();
+ ConnectNetworkHelper.get(context,
+ context.getString(R.string.ConnectMessageRetrieveMessagesURL),
+ API_VERSION_CONNECT_ID, authInfo, params, true, callback);
+ }
+
+ public static boolean updateChannelConsent(Context context, String username, String password,
+ String channel, boolean consented,
+ IApiCallback callback) {
+ AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false);
+
+ HashMap params = new HashMap<>();
+ params.put("channel", channel);
+ params.put("consent", consented);
+
+ return ConnectNetworkHelper.post(context,
+ context.getString(R.string.ConnectMessageChannelConsentURL),
+ API_VERSION_CONNECT_ID, authInfo, params, false, false, callback);
+ }
+
+ public static void retrieveChannelEncryptionKey(Context context, String channelId, String channelUrl, IApiCallback callback) {
+ ConnectSsoHelper.retrieveConnectTokenAsync(context, token -> {
+ if (token == null) {
+ callback.processFailure(401, new IOException("Failed to retrieve token"));
+ return;
+ }
+ HashMap params = new HashMap<>();
+ params.put("channel_id", channelId);
+ ConnectNetworkHelper.post(context,
+ channelUrl,
+ null, token, params, true, true, callback);
+ });
+ }
+
+ public static void confirmReceivedMessages(Context context, String username, String password,
+ List messageIds, IApiCallback callback) {
+ AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false);
+
+ HashMap params = new HashMap<>();
+ params.put("messages", messageIds);
+
+ ConnectNetworkHelper.post(context,
+ context.getString(R.string.ConnectMessageConfirmURL),
+ API_VERSION_CONNECT_ID, authInfo, params, false, true, callback);
+ }
+
+ public static void sendMessagingMessage(Context context, String username, String password,
+ ConnectMessagingMessageRecord message, String key, IApiCallback callback) {
+ if (message == null || key == null) {
+ callback.processFailure(400, new IOException("Message or key is null"));
+ return;
+ }
+ AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false);
+ String[] parts = ConnectMessagingMessageRecord.encrypt(message.getMessage(), key);
+ if (parts == null) {
+ callback.processFailure(500, new IOException("Message encryption failed"));
+ return;
+ }
+ HashMap params = new HashMap<>();
+ params.put("channel", message.getChannelId());
+ HashMap content = new HashMap<>();
+ try {
+ if (parts[0] == null || parts[1] == null || parts[2] == null) {
+ callback.processFailure(500, new IOException("Invalid encryption parts"));
+ return;
+ }
+ content.put("ciphertext", parts[0]);
+ content.put("nonce", parts[1]);
+ content.put("tag", parts[2]);
+ } catch (Exception e) {
+ Logger.exception("Sending message", e);
+ callback.processFailure(500, new IOException(e.getMessage()));
+ }
+ params.put("content", content);
+ params.put("timestamp", DateUtils.formatDateTime(message.getTimeStamp(), DateUtils.FORMAT_ISO8601));
+ params.put("message_id", message.getMessageId());
+ ConnectNetworkHelper.post(context,
+ context.getString(R.string.ConnectMessageSendURL),
+ API_VERSION_CONNECT_ID, authInfo, params, false, true, callback);
+ }
}
\ No newline at end of file
diff --git a/app/src/org/commcare/connect/network/ConnectNetworkHelper.java b/app/src/org/commcare/connect/network/ConnectNetworkHelper.java
index c7ba5394c..44cf46d9b 100644
--- a/app/src/org/commcare/connect/network/ConnectNetworkHelper.java
+++ b/app/src/org/commcare/connect/network/ConnectNetworkHelper.java
@@ -90,7 +90,7 @@ private static void setCallInProgress(String call) {
}
public static boolean post(Context context, String url, String version, AuthInfo authInfo,
- HashMap params, boolean useFormEncoding,
+ HashMap params, boolean useFormEncoding,
boolean background, IApiCallback handler) {
return getInstance().postInternal(context, url, version, authInfo, params, useFormEncoding,
background, handler);
@@ -108,7 +108,7 @@ private static void addVersionHeader(HashMap headers, String ver
}
public static PostResult postSync(Context context, String url, String version, AuthInfo authInfo,
- HashMap params, boolean useFormEncoding,
+ HashMap params, boolean useFormEncoding,
boolean background) {
ConnectNetworkHelper instance = getInstance();
@@ -162,7 +162,7 @@ public static PostResult postSync(Context context, String url, String version, A
}
private boolean postInternal(Context context, String url, String version, AuthInfo authInfo,
- HashMap params, boolean useFormEncoding,
+ HashMap params, boolean useFormEncoding,
boolean background, IApiCallback handler) {
if (!background) {
if (isBusy()) {
@@ -190,13 +190,17 @@ private boolean postInternal(Context context, String url, String version, AuthIn
return true;
}
- private static RequestBody buildPostFormHeaders(HashMap params, boolean useFormEncoding, String version, HashMap outputHeaders) {
+ private static RequestBody buildPostFormHeaders(HashMap params, boolean useFormEncoding, String version, HashMap outputHeaders) {
RequestBody requestBody;
if (useFormEncoding) {
Multimap multimap = ArrayListMultimap.create();
- for (Map.Entry entry : params.entrySet()) {
- multimap.put(entry.getKey(), entry.getValue());
+ for (Map.Entry entry : params.entrySet()) {
+ Object value = entry.getValue();
+ if (value == null) {
+ continue;
+ }
+ multimap.put(entry.getKey(), String.valueOf(value));
}
requestBody = ModernHttpRequester.getPostBody(multimap);
diff --git a/app/src/org/commcare/connect/network/connectId/ApiService.java b/app/src/org/commcare/connect/network/connectId/ApiService.java
index 8a6fe16b1..ffb00d617 100644
--- a/app/src/org/commcare/connect/network/connectId/ApiService.java
+++ b/app/src/org/commcare/connect/network/connectId/ApiService.java
@@ -12,56 +12,56 @@ public interface ApiService {
Call checkPhoneNumber(@Query("phone_number") String phoneNumber);
@POST(ApiEndPoints.registerUser)
- Call registerUser(@Body Map registrationRequest);
+ Call registerUser(@Body Map registrationRequest);
@POST(ApiEndPoints.changePhoneNo)
- Call changePhoneNo(@Header("Authorization") String token, @Body Map changeRequest);
+ Call changePhoneNo(@Header("Authorization") String token, @Body Map changeRequest);
@POST(ApiEndPoints.updateProfile)
- Call updateProfile(@Header("Authorization") String token, @Body Map updateProfile);
+ Call updateProfile(@Header("Authorization") String token, @Body Map updateProfile);
@POST(ApiEndPoints.validatePhone)
- Call validatePhone(@Header("Authorization") String token, @Body Map requestOTP);
+ Call validatePhone(@Header("Authorization") String token, @Body Map requestOTP);
@POST(ApiEndPoints.recoverOTPPrimary)
- Call requestOTPPrimary(@Body Map requestOTP);
+ Call requestOTPPrimary(@Body Map requestOTP);
@POST(ApiEndPoints.recoverOTPSecondary)
- Call validateSecondaryPhone(@Header("Authorization") String token, @Body Map validateSecondaryPhoneRequest);
+ Call validateSecondaryPhone(@Header("Authorization") String token, @Body Map validateSecondaryPhoneRequest);
@POST(ApiEndPoints.recoverConfirmOTPSecondary)
- Call recoverConfirmOTPSecondary(@Body Map recoverConfirmOTPSecondaryRequest);
+ Call recoverConfirmOTPSecondary(@Body Map recoverConfirmOTPSecondaryRequest);
@POST(ApiEndPoints.confirmOTPSecondary)
- Call confirmOTPSecondary(@Header("Authorization") String token, @Body Map confirmOTPSecondaryRequest);
+ Call confirmOTPSecondary(@Header("Authorization") String token, @Body Map confirmOTPSecondaryRequest);
@POST(ApiEndPoints.accountDeactivation)
- Call accountDeactivation(@Body Map accountDeactivationRequest);
+ Call accountDeactivation(@Body Map accountDeactivationRequest);
@POST(ApiEndPoints.confirmDeactivation)
- Call confirmDeactivation(@Body Map confirmDeactivationRequest);
+ Call confirmDeactivation(@Body Map confirmDeactivationRequest);
@POST(ApiEndPoints.recoverConfirmOTP)
- Call recoverConfirmOTP(@Body Map confirmOTPRequest);
+ Call recoverConfirmOTP(@Body Map confirmOTPRequest);
@POST(ApiEndPoints.confirmOTP)
- Call confirmOTP(@Header("Authorization") String token, @Body Map confirmOTPRequest);
+ Call confirmOTP(@Header("Authorization") String token, @Body Map confirmOTPRequest);
@POST(ApiEndPoints.recoverSecondary)
- Call recoverSecondary(@Body Map recoverSecondaryRequest);
+ Call recoverSecondary(@Body Map recoverSecondaryRequest);
@POST(ApiEndPoints.confirmPIN)
- Call confirmPIN(@Body Map confirmPINRequest);
+ Call confirmPIN(@Body Map confirmPINRequest);
@POST(ApiEndPoints.setPIN)
- Call changePIN(@Header("Authorization") String token, @Body Map changePINRequest);
+ Call changePIN(@Header("Authorization") String token, @Body Map changePINRequest);
@POST(ApiEndPoints.resetPassword)
- Call resetPassword(@Body Map resetPasswordRequest);
+ Call resetPassword(@Body Map resetPasswordRequest);
@POST(ApiEndPoints.changePassword)
- Call changePassword(@Header("Authorization") String token, @Body Map changePasswordRequest);
+ Call changePassword(@Header("Authorization") String token, @Body Map changePasswordRequest);
@POST(ApiEndPoints.confirmPassword)
- Call checkPassword(@Body Map confirmPasswordRequest);
+ Call checkPassword(@Body Map confirmPasswordRequest);
}
\ No newline at end of file
diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChannelConsentBottomSheet.java b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChannelConsentBottomSheet.java
new file mode 100644
index 000000000..a407c573e
--- /dev/null
+++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChannelConsentBottomSheet.java
@@ -0,0 +1,76 @@
+package org.commcare.fragments.connectMessaging;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
+
+import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord;
+import org.commcare.connect.MessageManager;
+import org.commcare.connect.database.ConnectMessageUtils;
+import org.commcare.dalvik.databinding.FragmentChannelConsentBottomSheetBinding;
+
+import androidx.annotation.NonNull;
+import androidx.navigation.NavDirections;
+import androidx.navigation.fragment.NavHostFragment;
+
+public class ConnectMessageChannelConsentBottomSheet extends BottomSheetDialogFragment {
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ FragmentChannelConsentBottomSheetBinding binding = FragmentChannelConsentBottomSheetBinding
+ .inflate(inflater, container, false);
+
+ ConnectMessageChannelConsentBottomSheetArgs args = ConnectMessageChannelConsentBottomSheetArgs
+ .fromBundle(getArguments());
+
+ ConnectMessagingChannelRecord channel = ConnectMessageUtils.getMessagingChannel(requireContext(),
+ args.getChannelId());
+ if (channel == null) {
+ Toast.makeText(requireContext(), "Channel not found", Toast.LENGTH_SHORT).show();
+ NavHostFragment.findNavController(this).popBackStack();
+ return null;
+ }
+
+ binding.channelName.setText(channel.getChannelName());
+
+ binding.acceptButton.setOnClickListener(v -> {
+ channel.setAnsweredConsent(true);
+ channel.setConsented(true);
+ MessageManager.updateChannelConsent(requireContext(), channel, success -> {
+ if (success) {
+ NavDirections directions = ConnectMessageChannelConsentBottomSheetDirections
+ .actionChannelConsentToConnectMessageFragment(channel.getChannelId());
+ NavHostFragment.findNavController(this).navigate(directions);
+ } else {
+ Context context = getContext();
+ if (context != null) {
+ Toast.makeText(context, "Failed to grant consent to channel", Toast.LENGTH_SHORT).show();
+ }
+
+ NavHostFragment.findNavController(this).popBackStack();
+ }
+ });
+ });
+
+ binding.declineButton.setOnClickListener(v -> {
+ channel.setAnsweredConsent(true);
+ channel.setConsented(false);
+ MessageManager.updateChannelConsent(requireContext(), channel, success -> {
+ if (!success) {
+ Context context = getContext();
+ if (context != null) {
+ Toast.makeText(context, "Failed to decline channel consent", Toast.LENGTH_SHORT).show();
+ }
+ }
+ NavHostFragment.findNavController(this).popBackStack();
+ });
+ });
+
+ return binding.getRoot();
+ }
+}
diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChannelListFragment.java b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChannelListFragment.java
new file mode 100644
index 000000000..51a71840b
--- /dev/null
+++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChannelListFragment.java
@@ -0,0 +1,128 @@
+package org.commcare.fragments.connectMessaging;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.commcare.adapters.ChannelAdapter;
+import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord;
+import org.commcare.connect.MessageManager;
+import org.commcare.connect.database.ConnectMessageUtils;
+import org.commcare.dalvik.R;
+import org.commcare.dalvik.databinding.FragmentChannelListBinding;
+import org.commcare.services.CommCareFirebaseMessagingService;
+
+import java.util.List;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.navigation.NavDirections;
+import androidx.navigation.Navigation;
+import androidx.recyclerview.widget.LinearLayoutManager;
+
+public class ConnectMessageChannelListFragment extends Fragment {
+
+ public static boolean isActive;
+ private FragmentChannelListBinding binding;
+ private ChannelAdapter channelAdapter;
+
+ public static ConnectMessageChannelListFragment newInstance() {
+ ConnectMessageChannelListFragment fragment = new ConnectMessageChannelListFragment();
+ Bundle args = new Bundle();
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ binding = FragmentChannelListBinding.inflate(inflater, container, false);
+ View view = binding.getRoot();
+
+ requireActivity().setTitle(R.string.connect_messaging_channel_list_title);
+
+ binding.rvChannel.setLayoutManager(new LinearLayoutManager(getContext()));
+
+ List channels = ConnectMessageUtils.getMessagingChannels(getContext());
+
+ channelAdapter = new ChannelAdapter(channels, this::selectChannel);
+
+ binding.rvChannel.setAdapter(channelAdapter);
+
+ MessageManager.retrieveMessages(requireActivity(), success -> {
+ refreshUi();
+ });
+
+ MessageManager.sendUnsentMessages(requireActivity());
+
+ String channelId = getArguments() != null ? getArguments().getString("channel_id") : null;
+ if (channelId != null) {
+ ConnectMessagingChannelRecord channel = ConnectMessageUtils.getMessagingChannel(requireContext(), channelId);
+ selectChannel(channel);
+ }
+
+ return view;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ isActive = true;
+
+ LocalBroadcastManager.getInstance(requireContext()).registerReceiver(updateReceiver,
+ new IntentFilter(CommCareFirebaseMessagingService.MESSAGING_UPDATE_BROADCAST));
+
+ MessageManager.retrieveMessages(requireActivity(), success -> {
+ refreshUi();
+ });
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ isActive = false;
+ LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(updateReceiver);
+ }
+
+ private final BroadcastReceiver updateReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ refreshUi();
+ }
+ };
+
+ private void selectChannel(ConnectMessagingChannelRecord channel) {
+ NavDirections directions;
+ if (channel.getConsented()) {
+ directions = ConnectMessageChannelListFragmentDirections
+ .actionChannelListFragmentToConnectMessageFragment(channel.getChannelId());
+ } else {
+ //Get consent for channel
+ directions = ConnectMessageChannelListFragmentDirections
+ .actionChannelListFragmentToChannelConsentBottomSheet(channel.getChannelId(),
+ channel.getChannelName());
+ }
+
+ Navigation.findNavController(binding.rvChannel).navigate(directions);
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ binding = null;
+ }
+
+ public void refreshUi() {
+ Context context = getContext();
+ if (context != null && channelAdapter != null) {
+ List channels = ConnectMessageUtils.getMessagingChannels(context);
+ channelAdapter.setChannels(channels);
+ }
+ }
+}
diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChatData.java b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChatData.java
new file mode 100644
index 000000000..491426791
--- /dev/null
+++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChatData.java
@@ -0,0 +1,70 @@
+package org.commcare.fragments.connectMessaging;
+
+import java.util.Date;
+
+import androidx.annotation.NonNull;
+
+public class ConnectMessageChatData {
+
+ private int type;
+ private String message;
+ private String userName;
+ private Date timestamp;
+ private int countUnread;
+ private boolean isMessageRead;
+
+ // Constructor with parameters
+ public ConnectMessageChatData(int type, @NonNull String message, @NonNull String userName, @NonNull Date timestamp, boolean isMessageRead) {
+ this.type = type;
+ this.message = message;
+ this.userName = userName;
+ this.timestamp = timestamp;
+ this.isMessageRead = isMessageRead;
+ this.countUnread = 0;
+ }
+
+ // Getters and setters
+ public int getType() {
+ return type;
+ }
+
+ public void setType(int type) {
+ this.type = type;
+ }
+
+ public Date getTimestamp() {
+ return timestamp;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ public String getUserName() {
+ return userName;
+ }
+
+ public void setUserName(String userName) {
+ this.userName = userName;
+ }
+
+ public int getCountUnread() {
+ return countUnread;
+ }
+
+ public void setCountUnread(int countUnread) {
+ this.countUnread = countUnread;
+ }
+
+ public boolean isMessageRead() {
+ return isMessageRead;
+ }
+
+ public void setMessageRead(boolean messageRead) {
+ isMessageRead = messageRead;
+ }
+}
diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageFragment.java b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageFragment.java
new file mode 100644
index 000000000..3e3dd9690
--- /dev/null
+++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageFragment.java
@@ -0,0 +1,198 @@
+package org.commcare.fragments.connectMessaging;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.os.Handler;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.commcare.adapters.ConnectMessageAdapter;
+import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord;
+import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord;
+import org.commcare.connect.MessageManager;
+import org.commcare.connect.database.ConnectDatabaseHelper;
+import org.commcare.connect.database.ConnectMessageUtils;
+import org.commcare.dalvik.databinding.FragmentConnectMessageBinding;
+import org.commcare.services.CommCareFirebaseMessagingService;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.UUID;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+public class ConnectMessageFragment extends Fragment {
+ public static String activeChannel;
+ private String channelId;
+ private FragmentConnectMessageBinding binding;
+ private ConnectMessageAdapter adapter;
+ private Runnable apiCallRunnable; // The task to run periodically
+ private static final int INTERVAL = 30000;
+ private final Handler handler = new Handler(); // To post periodic tasks
+
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ binding = FragmentConnectMessageBinding.inflate(inflater, container, false);
+
+ ConnectMessageFragmentArgs args = ConnectMessageFragmentArgs.fromBundle(getArguments());
+ channelId = args.getChannelId();
+
+ ConnectMessagingChannelRecord channel = ConnectMessageUtils.getMessagingChannel(requireContext(), channelId);
+ getActivity().setTitle(channel.getChannelName());
+
+ handleSendButtonListener();
+ setChatAdapter();
+ apiCallRunnable = new Runnable() {
+ @Override
+ public void run() {
+ makeApiCall(); // Perform the API call
+ handler.postDelayed(this, INTERVAL); // Schedule the next call
+ }
+ };
+
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ activeChannel = channelId;
+
+ LocalBroadcastManager.getInstance(requireContext()).registerReceiver(updateReceiver,
+ new IntentFilter(CommCareFirebaseMessagingService.MESSAGING_UPDATE_BROADCAST));
+
+ // Start periodic API calls
+ handler.post(apiCallRunnable);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ activeChannel = null;
+
+ LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(updateReceiver);
+
+ // Stop the periodic API calls when the screen is not active
+ handler.removeCallbacks(apiCallRunnable);
+ }
+
+ private final BroadcastReceiver updateReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ refreshUi();
+ }
+ };
+
+ private void makeApiCall() {
+ MessageManager.retrieveMessages(requireActivity(), success -> {
+ refreshUi();
+ });
+ }
+
+ private void handleSendButtonListener() {
+ binding.etMessage.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ if (s.length() > 0) {
+ binding.imgSendMessage.setVisibility(View.VISIBLE);
+ } else {
+ binding.imgSendMessage.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ }
+ });
+
+ binding.etMessage.setOnFocusChangeListener((v, hasFocus) -> {
+ if (hasFocus) {
+
+ binding.rvChat.postDelayed(() -> {
+ RecyclerView.Adapter> adapter = binding.rvChat.getAdapter();
+ if (adapter != null) {
+ int numItems = adapter.getItemCount();
+ if (numItems > 0) {
+ binding.rvChat.scrollToPosition(numItems - 1);
+ }
+ }
+ }, 250);
+ }
+ });
+
+ binding.imgSendMessage.setOnClickListener(v -> {
+ String messageText = binding.etMessage.getText().toString().trim();
+ if (messageText.isEmpty()) {
+ return;
+ }
+ ConnectMessagingMessageRecord message = new ConnectMessagingMessageRecord();
+ message.setMessageId(UUID.randomUUID().toString());
+ message.setMessage(messageText);
+ message.setChannelId(channelId);
+ message.setTimeStamp(new Date());
+ message.setIsOutgoing(true);
+ message.setConfirmed(false);
+ message.setUserViewed(true);
+
+ binding.etMessage.setText("");
+
+ ConnectMessageUtils.storeMessagingMessage(requireContext(), message);
+ refreshUi();
+
+ MessageManager.sendMessage(requireContext(), message, success -> {
+ refreshUi();
+ });
+ });
+ }
+
+ private void setChatAdapter() {
+ List messages = new ArrayList<>();
+
+ adapter = new ConnectMessageAdapter(messages);
+ binding.rvChat.setAdapter(adapter);
+
+ refreshUi();
+ }
+
+ public void refreshUi() {
+ Context context = getContext();
+ if (context != null && adapter != null) {
+ List messages = ConnectMessageUtils.getMessagingMessagesForChannel(context, channelId);
+
+ List chats = new ArrayList<>();
+ for (ConnectMessagingMessageRecord message : messages) {
+ int viewType = message.getIsOutgoing() ? ConnectMessageAdapter.RIGHTVIEW : ConnectMessageAdapter.LEFTVIEW;
+ chats.add(new ConnectMessageChatData(viewType,
+ message.getMessage(),
+ message.getIsOutgoing() ? "You" : "Them",
+ message.getTimeStamp(),
+ message.getConfirmed()));
+
+ if (!message.getUserViewed()) {
+ message.setUserViewed(true);
+ ConnectMessageUtils.storeMessagingMessage(context, message);
+ }
+ }
+
+ adapter.updateData(chats);
+ binding.rvChat.scrollToPosition(messages.size() - 1);
+ }
+ }
+}
+
diff --git a/app/src/org/commcare/google/services/analytics/CCAnalyticsParam.java b/app/src/org/commcare/google/services/analytics/CCAnalyticsParam.java
index d5c9058d8..fb177e341 100644
--- a/app/src/org/commcare/google/services/analytics/CCAnalyticsParam.java
+++ b/app/src/org/commcare/google/services/analytics/CCAnalyticsParam.java
@@ -6,13 +6,12 @@
public class CCAnalyticsParam {
-
static final String CC_APP_ID = "cc_app_id";
static final String CC_APP_BUILD_PROFILE_ID = "cc_app_build_profile_id";
static final String CCHQ_DOMAIN = "cchq_domain";
static final String SERVER = "server";
static final String FREE_DISK = "free_disk";
-
+ static final String NOTIFICATION_TYPE = "notification_type";
static final String ACTION_TYPE = "action_type";
static final String DIRECTION = "direction";
static final String TIME_IN_MINUTES = "time_in_minutes";
diff --git a/app/src/org/commcare/google/services/analytics/FirebaseAnalyticsUtil.java b/app/src/org/commcare/google/services/analytics/FirebaseAnalyticsUtil.java
index 8b9063a48..6080d8e80 100644
--- a/app/src/org/commcare/google/services/analytics/FirebaseAnalyticsUtil.java
+++ b/app/src/org/commcare/google/services/analytics/FirebaseAnalyticsUtil.java
@@ -16,6 +16,10 @@
import java.util.Date;
+import androidx.navigation.NavController;
+import androidx.navigation.NavDestination;
+import androidx.navigation.fragment.FragmentNavigator;
+
import static org.commcare.google.services.analytics.AnalyticsParamValue.CORRUPT_APP_STATE;
import static org.commcare.google.services.analytics.AnalyticsParamValue.STAGE_UPDATE_FAILURE;
import static org.commcare.google.services.analytics.AnalyticsParamValue.UPDATE_RESET;
@@ -33,6 +37,8 @@ public class FirebaseAnalyticsUtil {
// constant to approximate time taken by an user to go to the video playing app after clicking on the video
private static final long VIDEO_USAGE_ERROR_APPROXIMATION = 3;
+ private static final String UNKNOWN_DESTINATION = "UnknownDestination";
+ private static final String UNKNOWN_LABEL = "Unknown";
private static void reportEvent(String eventName, String paramKey, String paramVal) {
reportEvent(eventName, new String[]{paramKey}, new String[]{paramVal});
@@ -460,4 +466,26 @@ public static void reportCccPaymentConfirmationInteraction(boolean positive) {
b.putLong(CCAnalyticsParam.PARAM_API_SUCCESS, positive ? 1 : 0);
reportEvent(CCAnalyticsEvent.CCC_PAYMENT_CONFIRMATION_INTERACT, b);
}
+
+ public static void reportNotificationType(String notificationType) {
+ reportEvent(CCAnalyticsEvent.CCC_NOTIFICATION_TYPE,
+ CCAnalyticsParam.NOTIFICATION_TYPE, notificationType);
+ }
+
+ public static NavController.OnDestinationChangedListener getDestinationChangeListener() {
+ return (navController, navDestination, args) -> {
+ String currentFragmentClassName = UNKNOWN_DESTINATION;
+ NavDestination destination = navController.getCurrentDestination();
+ if (destination instanceof FragmentNavigator.Destination) {
+ currentFragmentClassName = ((FragmentNavigator.Destination)destination).getClassName();
+ }
+
+ Bundle bundle = new Bundle();
+ CharSequence label = navDestination.getLabel();
+ bundle.putString(FirebaseAnalytics.Param.SCREEN_NAME,
+ label != null ? label.toString() : UNKNOWN_LABEL);
+ bundle.putString(FirebaseAnalytics.Param.SCREEN_CLASS, currentFragmentClassName);
+ reportEvent(FirebaseAnalytics.Event.SCREEN_VIEW, bundle);
+ };
+ }
}
diff --git a/app/src/org/commcare/services/CommCareFirebaseMessagingService.java b/app/src/org/commcare/services/CommCareFirebaseMessagingService.java
index 58ed5d5fd..904dfd0d0 100644
--- a/app/src/org/commcare/services/CommCareFirebaseMessagingService.java
+++ b/app/src/org/commcare/services/CommCareFirebaseMessagingService.java
@@ -2,17 +2,28 @@
import android.app.NotificationManager;
import android.app.PendingIntent;
+import android.content.Context;
import android.content.Intent;
import android.os.Build;
import androidx.core.app.NotificationCompat;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
import org.commcare.CommCareNoficationManager;
import org.commcare.activities.DispatchActivity;
+import org.commcare.activities.connect.ConnectMessagingActivity;
+import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord;
+import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord;
+import org.commcare.connect.ConnectConstants;
+import org.commcare.connect.MessageManager;
+import org.commcare.connect.database.ConnectMessageUtils;
import org.commcare.dalvik.R;
+import org.commcare.fragments.connectMessaging.ConnectMessageChannelListFragment;
+import org.commcare.fragments.connectMessaging.ConnectMessageFragment;
+import org.commcare.google.services.analytics.FirebaseAnalyticsUtil;
import org.commcare.sync.FirebaseMessagingDataSyncer;
import org.commcare.util.LogTypes;
import org.commcare.utils.FirebaseMessagingUtil;
@@ -26,87 +37,198 @@
* key.
*/
public class CommCareFirebaseMessagingService extends FirebaseMessagingService {
-
+ public static final String OPPORTUNITY_ID = "opportunity_id";
private final static int FCM_NOTIFICATION = R.string.fcm_notification;
- enum ActionTypes{
+ public static final String MESSAGING_UPDATE_BROADCAST = "com.dimagi.messaging.update";
+ public static final String PAYMENT_ID = "payment_id";
+ public static final String PAYMENT_STATUS = "payment_status";
+ private static final String CCC_ACTION_PREFIX = "ccc_";
+
+ enum ActionTypes {
SYNC,
INVALID
}
+
private FirebaseMessagingDataSyncer dataSyncer;
+
{
dataSyncer = new FirebaseMessagingDataSyncer(this);
}
- /**
- * Upon receiving a new message from FCM, CommCare needs to:
- * 1) Trigger the notification if the message contains a Notification object. Note that the
- * presence of a Notification object causes the onMessageReceived to not be called when the
- * app is in the background, which means that the data object won't be processed from here
- * 2) Verify if the message contains a data object and trigger the necessary steps according
- * to the action it carries
- *
- */
+ /**
+ * Upon receiving a new message from FCM, CommCare needs to:
+ * 1) Trigger the notification if the message contains a Notification object. Note that the
+ * presence of a Notification object causes the onMessageReceived to not be called when the
+ * app is in the background, which means that the data object won't be processed from here
+ * 2) Verify if the message contains a data object and trigger the necessary steps according
+ * to the action it carries
+ */
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
- Logger.log(LogTypes.TYPE_FCM, "Message received: " + remoteMessage.getMessageId());
Map payloadData = remoteMessage.getData();
- RemoteMessage.Notification payloadNotification = remoteMessage.getNotification();
-
- if (payloadNotification != null) {
- showNotification(payloadNotification);
- }
// Check if the message contains a data object, there is no further action if not
- if (payloadData.size() == 0){
+ if (payloadData.isEmpty()) {
return;
}
- FCMMessageData fcmMessageData = new FCMMessageData(payloadData);
+ showNotification(payloadData);
- switch(fcmMessageData.getAction()){
- case SYNC -> dataSyncer.syncData(fcmMessageData);
- default ->
- Logger.log(LogTypes.TYPE_FCM, "Invalid FCM action");
+ if (!hasCccAction(payloadData.get("action"))) {
+ FCMMessageData fcmMessageData = new FCMMessageData(payloadData);
+
+ switch (fcmMessageData.getAction()) {
+ case SYNC -> dataSyncer.syncData(fcmMessageData);
+ default -> Logger.log(LogTypes.TYPE_FCM, "Invalid FCM action");
+ }
}
}
@Override
public void onNewToken(String token) {
- // TODO: Remove the token from the log
- Logger.log(LogTypes.TYPE_FCM, "New registration token was generated: "+token);
FirebaseMessagingUtil.updateFCMToken(token);
}
- /**
- * This method purpose is to show notifications to the user when the app is in the foreground.
- * When the app is in the background, FCM is responsible for notifying the user
- *
- */
- private void showNotification(RemoteMessage.Notification notification) {
- String notificationTitle = notification.getTitle();
- String notificationText = notification.getBody();
- NotificationManager mNM = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
-
- Intent i = new Intent(this, DispatchActivity.class);
- i.setAction(Intent.ACTION_MAIN);
- i.addCategory(Intent.CATEGORY_LAUNCHER);
-
- int pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT;
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- pendingIntentFlags = pendingIntentFlags | PendingIntent.FLAG_IMMUTABLE;
+ /**
+ * This method purpose is to show notifications to the user when the app is in the foreground.
+ * When the app is in the background, FCM is responsible for notifying the user
+ */
+ private void showNotification(Map payloadData) {
+ String notificationTitle = payloadData.get("title");
+ String notificationText = payloadData.get("body");
+ Intent intent = null;
+ String action = payloadData.get("action");
+ if (hasCccAction(action)) {
+ FirebaseAnalyticsUtil.reportNotificationType(action);
+ if (action.equals(ConnectMessagingActivity.CCC_MESSAGE)) {
+ // Instead of handling the message notification inline,
+ // delegate to a helper method for clarity.
+ handleMessageNotification(payloadData, action);
+ return;
+ } else {
+ //Intent for ConnectActivity
+// intent = new Intent(getApplicationContext(), ConnectActivity.class);
+// intent.putExtra("action", action);
+// if(payloadData.containsKey(OPPORTUNITY_ID)) {
+// intent.putExtra(OPPORTUNITY_ID, payloadData.get(OPPORTUNITY_ID));
+// }
+ }
+ } else {
+ intent = new Intent(this, DispatchActivity.class);
+ intent.setAction(Intent.ACTION_MAIN);
+ intent.addCategory(Intent.CATEGORY_LAUNCHER);
}
- PendingIntent contentIntent = PendingIntent.getActivity(this, 0, i, pendingIntentFlags);
+ if (intent != null) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP |
+ Intent.FLAG_ACTIVITY_NEW_TASK);
+ int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+ ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
+ : PendingIntent.FLAG_UPDATE_CURRENT;
+ PendingIntent contentIntent = PendingIntent.getActivity(this, 0, intent, flags);
+ NotificationCompat.Builder fcmNotification = new NotificationCompat.Builder(this,
+ CommCareNoficationManager.NOTIFICATION_CHANNEL_PUSH_NOTIFICATIONS_ID)
+ .setContentTitle(notificationTitle)
+ .setContentText(notificationText)
+ .setContentIntent(contentIntent)
+ .setAutoCancel(true)
+ .setSmallIcon(R.drawable.commcare_actionbar_logo)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setWhen(System.currentTimeMillis());
+ // Check if the payload action is CCC_PAYMENTS
+// if (action.equals(ConnectConstants.CCC_DEST_PAYMENTS)) {
+// // Yes button intent with payment_id from payload
+// Intent yesIntent = new Intent(this, PaymentAcknowledgeReceiver.class);
+// yesIntent.putExtra(OPPORTUNITY_ID, payloadData.get(OPPORTUNITY_ID));
+// yesIntent.putExtra(PAYMENT_ID, payloadData.get(PAYMENT_ID));
+// yesIntent.putExtra(PAYMENT_STATUS, true);
+// PendingIntent yesPendingIntent = PendingIntent.getBroadcast(this, 1,
+// yesIntent, flags);
+//
+// // No button intent with payment_id from payload
+// Intent noIntent = new Intent(this, PaymentAcknowledgeReceiver.class);
+// noIntent.putExtra(OPPORTUNITY_ID, payloadData.get(OPPORTUNITY_ID));
+// noIntent.putExtra(PAYMENT_ID, payloadData.get(PAYMENT_ID));
+// noIntent.putExtra(PAYMENT_STATUS, false);
+// PendingIntent noPendingIntent = PendingIntent.getBroadcast(this, 2,
+// noIntent, flags);
+//
+// // Add Yes & No action button to the notification
+// fcmNotification.addAction(0, getString(R.string.connect_payment_acknowledge_notification_yes), yesPendingIntent);
+// fcmNotification.addAction(0, getString(R.string.connect_payment_acknowledge_notification_no), noPendingIntent);
+// }
+ NotificationManager mNM = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
+ mNM.notify(FCM_NOTIFICATION, fcmNotification.build());
+ }
+ }
- NotificationCompat.Builder fcmNotification = new NotificationCompat.Builder(this,
- CommCareNoficationManager.NOTIFICATION_CHANNEL_PUSH_NOTIFICATIONS_ID)
- .setContentTitle(notificationTitle)
- .setContentText(notificationText)
- .setContentIntent(contentIntent)
+ // New helper method to create the base notification.
+ private NotificationCompat.Builder createBaseNotification(String title, String text, PendingIntent intent) {
+ return new NotificationCompat.Builder(this, CommCareNoficationManager.NOTIFICATION_CHANNEL_PUSH_NOTIFICATIONS_ID)
+ .setContentTitle(title)
+ .setContentText(text)
+ .setContentIntent(intent)
+ .setAutoCancel(true)
.setSmallIcon(R.drawable.commcare_actionbar_logo)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setWhen(System.currentTimeMillis());
+ }
+
+ // New helper method to handle message-related notifications.
+ private void handleMessageNotification(Map payloadData, String action) {
+ boolean isMessage = payloadData.containsKey(ConnectMessagingMessageRecord.META_MESSAGE_ID);
+ String channelId;
+ String notificationTitle;
+ String notificationMessage;
+ if (isMessage) {
+ ConnectMessagingMessageRecord message = MessageManager.handleReceivedMessage(this, payloadData);
+ if (message == null) {
+ Logger.log(LogTypes.TYPE_FCM, "Ignoring message without known consented channel: " +
+ payloadData.get(ConnectMessagingMessageRecord.META_MESSAGE_ID));
+ return;
+ }
+ ConnectMessagingChannelRecord channel = ConnectMessageUtils.getMessagingChannel(this, message.getChannelId());
+ notificationTitle = getString(R.string.connect_messaging_message_notification_title);
+ notificationMessage = getString(R.string.connect_messaging_message_notification_message, channel.getChannelName());
+ channelId = message.getChannelId();
+ } else {
+ ConnectMessagingChannelRecord channel = MessageManager.handleReceivedChannel(this, payloadData);
+ notificationTitle = getString(R.string.connect_messaging_channel_notification_title);
+ notificationMessage = getString(R.string.connect_messaging_channel_notification_message, channel.getChannelName());
+ channelId = channel.getChannelId();
+ }
+ Intent broadcastIntent = new Intent(MESSAGING_UPDATE_BROADCAST);
+ LocalBroadcastManager.getInstance(this).sendBroadcast(broadcastIntent);
+ if (!ConnectMessageChannelListFragment.isActive &&
+ !channelId.equals(ConnectMessageFragment.activeChannel)) {
+ Intent intent = new Intent(getApplicationContext(), ConnectMessagingActivity.class);
+ intent.putExtra("action", action);
+ intent.putExtra(ConnectMessagingMessageRecord.META_MESSAGE_CHANNEL_ID, channelId);
+ showNotificationWithIntent(intent, notificationTitle, notificationMessage);
+ }
+ }
- mNM.notify(FCM_NOTIFICATION, fcmNotification.build());
+ // New helper method to show the notification using a given intent.
+ private void showNotificationWithIntent(Intent intent, String title, String text) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
+ int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+ ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
+ : PendingIntent.FLAG_UPDATE_CURRENT;
+ PendingIntent contentIntent = PendingIntent.getActivity(this, 0, intent, flags);
+ NotificationCompat.Builder builder = createBaseNotification(title, text, contentIntent);
+ NotificationManager mNM = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
+ mNM.notify(FCM_NOTIFICATION, builder.build());
+ }
+
+ private boolean hasCccAction(String action) {
+ return action != null && action.contains(CCC_ACTION_PREFIX);
+ }
+
+ public static void clearNotification(Context context) {
+ NotificationManager notificationManager = (NotificationManager)context.getSystemService(
+ Context.NOTIFICATION_SERVICE);
+ if (notificationManager != null) {
+ notificationManager.cancel(R.string.fcm_notification);
+ }
}
}