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); + } } }