diff --git a/app/build.gradle b/app/build.gradle index 9538121cd8..89abde34d3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -120,6 +120,7 @@ dependencies { implementation('com.github.bumptech.glide:glide:4.9.0') { exclude group: 'com.android.support' } + implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" diff --git a/app/res/color/connect_message_audio_track_inactive.xml b/app/res/color/connect_message_audio_track_inactive.xml new file mode 100644 index 0000000000..8e36235aaf --- /dev/null +++ b/app/res/color/connect_message_audio_track_inactive.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/res/drawable/connect_message_pending_tile_bg.xml b/app/res/drawable/connect_message_pending_tile_bg.xml new file mode 100644 index 0000000000..bb219658d7 --- /dev/null +++ b/app/res/drawable/connect_message_pending_tile_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/res/drawable/connect_message_white_circle.xml b/app/res/drawable/connect_message_white_circle.xml new file mode 100644 index 0000000000..d043d2650e --- /dev/null +++ b/app/res/drawable/connect_message_white_circle.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/res/drawable/ic_connect_message_download.xml b/app/res/drawable/ic_connect_message_download.xml new file mode 100644 index 0000000000..84dcae417d --- /dev/null +++ b/app/res/drawable/ic_connect_message_download.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/res/drawable/ic_connect_message_file.xml b/app/res/drawable/ic_connect_message_file.xml new file mode 100644 index 0000000000..73f8e7441c --- /dev/null +++ b/app/res/drawable/ic_connect_message_file.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/res/drawable/ic_connect_message_pause.xml b/app/res/drawable/ic_connect_message_pause.xml new file mode 100644 index 0000000000..21e450944b --- /dev/null +++ b/app/res/drawable/ic_connect_message_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/res/drawable/ic_connect_message_play.xml b/app/res/drawable/ic_connect_message_play.xml new file mode 100644 index 0000000000..9e4b93f843 --- /dev/null +++ b/app/res/drawable/ic_connect_message_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/res/drawable/ic_connect_message_retry.xml b/app/res/drawable/ic_connect_message_retry.xml new file mode 100644 index 0000000000..59202d9f74 --- /dev/null +++ b/app/res/drawable/ic_connect_message_retry.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/res/layout/fragment_connect_message.xml b/app/res/layout/fragment_connect_message.xml index 2be0d218b9..4f74571a3c 100644 --- a/app/res/layout/fragment_connect_message.xml +++ b/app/res/layout/fragment_connect_message.xml @@ -15,11 +15,28 @@ android:overScrollMode="never" android:paddingBottom="10dp" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - app:layout_constraintBottom_toTopOf="@+id/cardMessage" + app:layout_constraintBottom_toTopOf="@+id/tvUpdateBanner" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + + + + + + + + + + diff --git a/app/res/layout/fragment_connect_messaging_settings.xml b/app/res/layout/fragment_connect_messaging_settings.xml new file mode 100644 index 0000000000..b7407c5f98 --- /dev/null +++ b/app/res/layout/fragment_connect_messaging_settings.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/res/layout/item_chat_left_rich_view.xml b/app/res/layout/item_chat_left_rich_view.xml new file mode 100644 index 0000000000..f9d0880a3e --- /dev/null +++ b/app/res/layout/item_chat_left_rich_view.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/res/layout/view_connect_message_attachment_audio.xml b/app/res/layout/view_connect_message_attachment_audio.xml new file mode 100644 index 0000000000..d4fab8b57a --- /dev/null +++ b/app/res/layout/view_connect_message_attachment_audio.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + diff --git a/app/res/layout/view_connect_message_attachment_file.xml b/app/res/layout/view_connect_message_attachment_file.xml new file mode 100644 index 0000000000..011bb5eeff --- /dev/null +++ b/app/res/layout/view_connect_message_attachment_file.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + diff --git a/app/res/layout/view_connect_message_attachment_image.xml b/app/res/layout/view_connect_message_attachment_image.xml new file mode 100644 index 0000000000..2adfc34ad2 --- /dev/null +++ b/app/res/layout/view_connect_message_attachment_image.xml @@ -0,0 +1,8 @@ + + diff --git a/app/res/layout/view_connect_message_attachment_pending.xml b/app/res/layout/view_connect_message_attachment_pending.xml new file mode 100644 index 0000000000..07467c5288 --- /dev/null +++ b/app/res/layout/view_connect_message_attachment_pending.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + diff --git a/app/res/navigation/nav_graph_connect_messaging.xml b/app/res/navigation/nav_graph_connect_messaging.xml index 29aab22a9b..b4346ed85e 100644 --- a/app/res/navigation/nav_graph_connect_messaging.xml +++ b/app/res/navigation/nav_graph_connect_messaging.xml @@ -13,8 +13,17 @@ + + + + + + + + \ No newline at end of file diff --git a/app/res/values-es/strings.xml b/app/res/values-es/strings.xml index fe893e7c91..af5a4db61f 100644 --- a/app/res/values-es/strings.xml +++ b/app/res/values-es/strings.xml @@ -759,4 +759,23 @@ Guardar Pausar Reanudar + Descargar + Descargando… + Error en la descarga + Toca para reintentar + Ya no está disponible + Ninguna aplicación de este teléfono puede abrir este archivo + Reproducir audio + Pausar audio + No se pudo reproducir este audio + Tu aplicación de Android debe actualizarse para ver este mensaje + Actualiza la aplicación para ver todos los mensajes de este canal + [Actualiza la aplicación para ver este mensaje] + Ajustes de mensajería + Con Wi-Fi y datos móviles + Los archivos adjuntos que toques siempre se descargan de inmediato, en cualquier red. + Descargar archivos adjuntos automáticamente + Archivos adjuntos grandes (más de 1 MB) solo con Wi-Fi + Solo descarga manual + [Mensaje multimedia por descargar] diff --git a/app/res/values-fr/strings.xml b/app/res/values-fr/strings.xml index e2262ba6bd..8aa45911c5 100644 --- a/app/res/values-fr/strings.xml +++ b/app/res/values-fr/strings.xml @@ -758,4 +758,23 @@ License. Enregistrer Pause Reprendre + Télécharger + Téléchargement… + Échec du téléchargement + Appuyez pour réessayer + N\'est plus disponible + Aucune application sur ce téléphone ne peut ouvrir ce fichier + Lire l\'audio + Mettre l\'audio en pause + Impossible de lire cet audio + Votre application Android doit être mise à jour pour afficher ce message + Mettez l\'application à jour pour voir tous les messages de ce canal + [Mettez l\'application à jour pour voir ce message] + Paramètres de messagerie + En Wi-Fi et données mobiles + Les pièces jointes sur lesquelles vous appuyez sont toujours téléchargées immédiatement, sur n\'importe quel réseau. + Télécharger automatiquement les pièces jointes + Pièces jointes volumineuses (plus de 1 Mo) uniquement en Wi-Fi + Téléchargement manuel uniquement + [Message multimédia à télécharger] diff --git a/app/res/values-ha/strings.xml b/app/res/values-ha/strings.xml index c7c0688b93..f3051ff493 100644 --- a/app/res/values-ha/strings.xml +++ b/app/res/values-ha/strings.xml @@ -753,4 +753,23 @@ Don cikakken aiki na wurin zama na aikace-aikace da yawa, da fatan za a yi sabon Ajiye Dakata Ci gaba + Sauke + Ana saukewa… + Saukewa ta kasa + Taɓa don sake gwadawa + Ba a samunsa yanzu + Babu wata manhaja a wannan wayar da za ta iya buɗe wannan fayil + Kunna sauti + Dakatar da sauti + Ba a iya kunna wannan sautin ba + Ana buƙatar sabunta manhajar Android ɗinka don ganin wannan saƙon + Sabunta manhajar don ganin dukkan saƙonni a wannan tasha + [Sabunta manhajar don ganin wannan saƙon] + Saitunan saƙonni + A Wi-Fi da bayanan waya + Haɗe-haɗen da ka taɓa ana saukewa nan take koyaushe, a kowace hanyar sadarwa. + Sauke haɗe-haɗe kai tsaye + Manyan haɗe-haɗe (sama da MB 1) a Wi-Fi kawai + Saukewa da hannu kawai + [Saƙon kafofin watsa labarai don saukewa] diff --git a/app/res/values-hi/strings.xml b/app/res/values-hi/strings.xml index 6e2b216b6b..0989002f06 100644 --- a/app/res/values-hi/strings.xml +++ b/app/res/values-hi/strings.xml @@ -755,4 +755,23 @@ License. सहेजें रोकें फिर से शुरू करें + डाउनलोड करें + डाउनलोड हो रहा है… + डाउनलोड विफल रहा + फिर से कोशिश करने के लिए टैप करें + अब उपलब्ध नहीं है + इस फ़ोन पर कोई भी ऐप यह फ़ाइल नहीं खोल सकता + ऑडियो चलाएं + ऑडियो रोकें + यह ऑडियो नहीं चलाया जा सका + यह संदेश देखने के लिए आपके Android ऐप को अपडेट करना होगा + इस चैनल के सभी संदेश देखने के लिए ऐप अपडेट करें + [यह संदेश देखने के लिए ऐप अपडेट करें] + संदेश सेटिंग + वाई-फ़ाई और मोबाइल डेटा पर + जिन अटैचमेंट पर आप टैप करते हैं, वे हमेशा किसी भी नेटवर्क पर तुरंत डाउनलोड होते हैं। + अटैचमेंट अपने-आप डाउनलोड करें + बड़े अटैचमेंट (1 MB से ज़्यादा) केवल वाई-फ़ाई पर + केवल मैन्युअल डाउनलोड + [डाउनलोड करने के लिए मीडिया संदेश] diff --git a/app/res/values-lt/strings.xml b/app/res/values-lt/strings.xml index db81f04823..b9fc0b3ae8 100644 --- a/app/res/values-lt/strings.xml +++ b/app/res/values-lt/strings.xml @@ -469,4 +469,23 @@ Išsaugoti Pristabdyti Tęsti + Atsisiųsti + Atsisiunčiama… + Nepavyko atsisiųsti + Palieskite, kad bandytumėte dar kartą + Nebepasiekiama + Jokia šio telefono programa negali atidaryti šio failo + Leisti garso įrašą + Pristabdyti garso įrašą + Nepavyko paleisti šio garso įrašo + Kad pamatytumėte šią žinutę, reikia atnaujinti jūsų Android programą + Atnaujinkite programą, kad pamatytumėte visas šio kanalo žinutes + [Atnaujinkite programą, kad pamatytumėte šią žinutę] + Žinučių nustatymai + Per „Wi-Fi“ ir mobiliuosius duomenis + Priedai, kuriuos paliečiate, visada atsisiunčiami iš karto per bet kurį tinklą. + Automatiškai atsisiųsti priedus + Didelius priedus (daugiau nei 1 MB) tik per „Wi-Fi“ + Tik rankinis atsisiuntimas + [Atsisiųstina medijos žinutė] diff --git a/app/res/values-no/strings.xml b/app/res/values-no/strings.xml index f7cc366b00..183cf6e4ce 100644 --- a/app/res/values-no/strings.xml +++ b/app/res/values-no/strings.xml @@ -445,4 +445,23 @@ Lagre Pause Gjenoppta + Last ned + Laster ned… + Nedlastingen mislyktes + Trykk for å prøve igjen + Ikke lenger tilgjengelig + Ingen app på denne telefonen kan åpne denne filen + Spill av lyd + Sett lyden på pause + Lyden kunne ikke spilles av + Android-appen din må oppdateres for å vise denne meldingen + Oppdater appen for å se alle meldingene i denne kanalen + [Oppdater appen for å se denne meldingen] + Meldingsinnstillinger + På Wi-Fi og mobildata + Vedlegg du trykker på, lastes alltid ned med en gang, på alle nettverk. + Last ned vedlegg automatisk + Store vedlegg (over 1 MB) bare på Wi-Fi + Bare manuell nedlasting + [Mediemelding som må lastes ned] diff --git a/app/res/values-pt/strings.xml b/app/res/values-pt/strings.xml index 3ee51268a2..0d4bff3480 100644 --- a/app/res/values-pt/strings.xml +++ b/app/res/values-pt/strings.xml @@ -765,4 +765,23 @@ Salvar Pausar Retomar + Baixar + Baixando… + Falha no download + Toque para tentar novamente + Não está mais disponível + Nenhum aplicativo neste telefone pode abrir este arquivo + Reproduzir áudio + Pausar áudio + Não foi possível reproduzir este áudio + Seu aplicativo Android precisa ser atualizado para ver esta mensagem + Atualize o aplicativo para ver todas as mensagens deste canal + [Atualize o aplicativo para ver esta mensagem] + Configurações de mensagens + No Wi-Fi e nos dados móveis + Os anexos que você tocar são sempre baixados na hora, em qualquer rede. + Baixar anexos automaticamente + Anexos grandes (mais de 1 MB) somente no Wi-Fi + Somente download manual + [Mensagem de mídia para baixar] diff --git a/app/res/values-sw/strings.xml b/app/res/values-sw/strings.xml index a7c058825b..8aa983329e 100644 --- a/app/res/values-sw/strings.xml +++ b/app/res/values-sw/strings.xml @@ -763,4 +763,23 @@ Hifadhi Sitisha Endelea + Pakua + Inapakua… + Upakuaji umeshindwa + Gusa ili ujaribu tena + Haipatikani tena + Hakuna programu kwenye simu hii inayoweza kufungua faili hili + Cheza sauti + Sitisha sauti + Sauti hii haikuweza kuchezwa + Programu yako ya Android inahitaji kusasishwa ili kuona ujumbe huu + Sasisha programu ili kuona jumbe zote katika chaneli hii + [Sasisha programu ili kuona ujumbe huu] + Mipangilio ya ujumbe + Kwenye Wi-Fi na data ya simu + Viambatisho unavyogusa hupakuliwa mara moja kila wakati, kwenye mtandao wowote. + Pakua viambatisho kiotomatiki + Viambatisho vikubwa (zaidi ya MB 1) kwenye Wi-Fi pekee + Upakuaji wa mwenyewe pekee + [Ujumbe wa media wa kupakua] diff --git a/app/res/values-ti/strings.xml b/app/res/values-ti/strings.xml index c97eea3d37..ebace2302c 100644 --- a/app/res/values-ti/strings.xml +++ b/app/res/values-ti/strings.xml @@ -747,4 +747,23 @@ ኣቐምጥ ደው በል ቐጽል + ኣውርድ + ይወርድ ኣሎ… + ምውራድ ኣይተዓወተን + ደጊምካ ንምፍታን ጠውቕ + ድሕሪ ሕጂ ኣይርከብን + ነዚ ፋይል ክኸፍቶ ዝኽእል ኣፕ ኣብዚ ተሌፎን የለን + ድምጺ ተጻወት + ድምጺ ደው ኣብል + እዚ ድምጺ ክጻወት ኣይከኣለን + ነዚ መልእኽቲ ንምርኣይ ናይ Android ኣፕኻ ክሕደስ ኣለዎ + ኣብዚ ቻነል ዘለዉ ኩሎም መልእኽቲታት ንምርኣይ ነቲ ኣፕ ኣሐድሶ + [ነዚ መልእኽቲ ንምርኣይ ነቲ ኣፕ ኣሐድሶ] + ቅጥዕታት መልእኽቲ + ብዋይፋይን ሞባይል ዳታን + እትጠውቖም ተተሓሒዝቲ ኩሉ ግዜ ብዝኾነ መርበብ ብቕጽበት ይወርዱ። + ተተሓሒዝቲ ብኣውቶማቲክ ኣውርድ + ዓበይቲ ተተሓሒዝቲ (ካብ 1 MB ንላዕሊ) ብዋይፋይ ጥራይ + ብኢድ ምውራድ ጥራይ + [ዝወርድ መልእኽቲ ሚድያ] diff --git a/app/res/values/dimens.xml b/app/res/values/dimens.xml index 0830f3e881..77445064a6 100755 --- a/app/res/values/dimens.xml +++ b/app/res/values/dimens.xml @@ -133,6 +133,17 @@ 3dp 1dp 16dp + 10dp + 3dp + 6dp + 2dp + 7dp + 4dp + 240dp + 128dp + 56dp + 48dp + 4dp 5dp 264dp diff --git a/app/res/values/strings.xml b/app/res/values/strings.xml index 0cb1d5da62..6736f74e6a 100644 --- a/app/res/values/strings.xml +++ b/app/res/values/strings.xml @@ -500,6 +500,25 @@ Sorry, something went wrong, showing all available messaging channels You Them + Download + Downloading… + Download failed + Tap to retry + No longer available + [Media message to download] + No app on this phone can open this file + Play audio + Pause audio + This audio could not be played + Your Android app needs to be updated to view this message + Update the app to see all messages in this channel + [Update the app to see this message] + Messaging settings + Download attachments automatically + On Wi-Fi and mobile data + Large attachments (over 1 MB) only on Wi-Fi + Manual download only + Attachments you tap always download right away, on any network. Click here to know more Last synced: %s diff --git a/app/res/values/styles.xml b/app/res/values/styles.xml index 26249085b0..0a85a2f1d8 100644 --- a/app/res/values/styles.xml +++ b/app/res/values/styles.xml @@ -484,4 +484,25 @@ @style/ConnectBottomSheetAnimation + + + + + + + + diff --git a/app/res/xml/provider_paths.xml b/app/res/xml/provider_paths.xml index 5749213627..b2c40a3c77 100644 --- a/app/res/xml/provider_paths.xml +++ b/app/res/xml/provider_paths.xml @@ -3,4 +3,5 @@ + diff --git a/app/src/org/commcare/adapters/ConnectMessageAdapter.java b/app/src/org/commcare/adapters/ConnectMessageAdapter.java index 9929af350c..4bd71469ea 100644 --- a/app/src/org/commcare/adapters/ConnectMessageAdapter.java +++ b/app/src/org/commcare/adapters/ConnectMessageAdapter.java @@ -1,20 +1,27 @@ package org.commcare.adapters; import android.text.SpannableStringBuilder; +import android.util.DisplayMetrics; import android.view.LayoutInflater; +import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; import androidx.viewbinding.ViewBinding; import org.commcare.dalvik.R; +import org.commcare.dalvik.databinding.ItemChatLeftRichViewBinding; import org.commcare.dalvik.databinding.ItemChatLeftViewBinding; import org.commcare.dalvik.databinding.ItemChatRightViewBinding; +import org.commcare.fragments.connectMessaging.ConnectMessageAttachmentListener; +import org.commcare.fragments.connectMessaging.ConnectMessageAttachmentsBinder; import org.commcare.fragments.connectMessaging.ConnectMessageChatData; +import org.commcare.fragments.connectMessaging.ConnectMessageMediaSizer; import org.commcare.utils.MarkupUtil; import org.javarosa.core.model.utils.DateUtils; @@ -26,18 +33,20 @@ public class ConnectMessageAdapter extends RecyclerView.Adapter messages; + private final ConnectMessageAttachmentListener attachmentListener; - public ConnectMessageAdapter(List messages) { + public ConnectMessageAdapter(List messages, + ConnectMessageAttachmentListener attachmentListener) { this.messages = messages; + this.attachmentListener = attachmentListener; } - /** - * Applies the new message list as a diff against the displayed one. - * - * @return true if the new list contains a message that was not already displayed - */ public boolean updateData(List newMessages) { DiffUtil.DiffResult diff = DiffUtil.calculateDiff( new MessageDiffCallback(messages, newMessages)); @@ -101,6 +110,7 @@ public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { ConnectMessageChatData oldChat = oldMessages.get(oldItemPosition); ConnectMessageChatData newChat = newMessages.get(newItemPosition); return hasSameDisplayContent(oldChat, newChat) + && hasSameAttachmentContent(oldChat, newChat) && oldChat.isMessageRead() == newChat.isMessageRead(); } @@ -109,14 +119,27 @@ public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { public Object getChangePayload(int oldItemPosition, int newItemPosition) { ConnectMessageChatData oldChat = oldMessages.get(oldItemPosition); ConnectMessageChatData newChat = newMessages.get(newItemPosition); - return hasSameDisplayContent(oldChat, newChat) ? PAYLOAD_READ_STATUS : null; + if (!hasSameDisplayContent(oldChat, newChat)) { + return null; + } + if (!hasSameAttachmentContent(oldChat, newChat)) { + return oldChat.hasRichContent() && newChat.hasRichContent() ? PAYLOAD_ATTACHMENTS : null; + } + return PAYLOAD_READ_STATUS; + } + + private static boolean hasSameAttachmentContent(ConnectMessageChatData oldChat, + ConnectMessageChatData newChat) { + return oldChat.getAttachments().equals(newChat.getAttachments()) + && oldChat.getPendingDownloadState() == newChat.getPendingDownloadState(); } private static boolean hasSameDisplayContent(ConnectMessageChatData oldChat, ConnectMessageChatData newChat) { return oldChat.getType() == newChat.getType() && Objects.equals(oldChat.getMessage(), newChat.getMessage()) - && Objects.equals(oldChat.getTimestamp(), newChat.getTimestamp()); + && Objects.equals(oldChat.getTimestamp(), newChat.getTimestamp()) + && oldChat.isUnsupportedVersion() == newChat.isUnsupportedVersion(); } } @@ -133,6 +156,90 @@ public RightViewHolder(ItemChatRightViewBinding binding) { } } + public class RichLeftViewHolder extends BaseMessageViewHolder { + private final ItemChatLeftRichViewBinding richBinding; + private final ViewGroup messageList; + private final ConnectMessageAttachmentsBinder attachmentsBinder; + + public RichLeftViewHolder(ItemChatLeftRichViewBinding binding, ViewGroup messageList) { + super(binding); + this.richBinding = binding; + this.messageList = messageList; + this.attachmentsBinder = new ConnectMessageAttachmentsBinder(binding.llAttachments, + attachmentListener); + } + + @Override + public void bind(ConnectMessageChatData chat) { + super.bind(chat); + bindAttachments(chat); + } + + public void bindAttachments(ConnectMessageChatData chat) { + boolean unsupported = chat.isUnsupportedVersion(); + boolean awaitingDownload = chat.isAwaitingDownload(); + boolean showsTile = unsupported || awaitingDownload; + boolean hasAttachments = showsTile || !chat.getAttachments().isEmpty(); + boolean hasText = chat.getMessage() != null && !chat.getMessage().trim().isEmpty(); + richBinding.tvChatMessage.setVisibility(showsTile || !hasText ? View.GONE : View.VISIBLE); + ConstraintLayout.LayoutParams guidelineParams = + (ConstraintLayout.LayoutParams)richBinding.guideline.getLayoutParams(); + guidelineParams.guidePercent = hasAttachments + ? MEDIA_BUBBLE_WIDTH_FRACTION + : TEXT_BUBBLE_WIDTH_FRACTION; + richBinding.guideline.setLayoutParams(guidelineParams); + + ConnectMessageAttachmentsBinder.Layout layout; + if (unsupported) { + layout = attachmentsBinder.bindUnsupportedMessage(); + } else if (awaitingDownload) { + layout = attachmentsBinder.bindPendingMessage(chat.getMessageId(), chat.getPendingDownloadState()); + } else { + layout = attachmentsBinder.bind(chat.getAttachments(), maxMediaWidth(), maxMediaHeight()); + } + + ViewGroup.LayoutParams bubbleParams = richBinding.llBubble.getLayoutParams(); + bubbleParams.width = layout.getFillsBubbleWidth() + ? ViewGroup.LayoutParams.MATCH_PARENT + : ViewGroup.LayoutParams.WRAP_CONTENT; + richBinding.llBubble.setLayoutParams(bubbleParams); + richBinding.tvChatMessage.setMaxWidth(hasAttachments && !layout.getFillsBubbleWidth() + ? Math.max(layout.getContentWidth(), bubbleContentWidth(TEXT_BUBBLE_WIDTH_FRACTION)) + : Integer.MAX_VALUE); + } + + private int maxMediaWidth() { + return bubbleContentWidth(MEDIA_BUBBLE_WIDTH_FRACTION); + } + + private int listWidth() { + if (messageList instanceof RecyclerView) { + RecyclerView.LayoutManager layoutManager = ((RecyclerView)messageList).getLayoutManager(); + if (layoutManager != null && layoutManager.getWidth() > 0) { + return layoutManager.getWidth(); + } + } + return messageList.getWidth() > 0 ? messageList.getWidth() : displayMetrics().widthPixels; + } + + private int bubbleContentWidth(float bubbleWidthFraction) { + int rowWidth = listWidth(); + int rowMargins = 2 * itemView.getResources().getDimensionPixelSize(R.dimen.spacer_small); + int bubblePadding = 2 * itemView.getResources().getDimensionPixelSize(R.dimen.connect_message_bubble_padding); + int tailWidth = richBinding.edge.getDrawable().getIntrinsicWidth(); + return Math.max(1, (int)(rowWidth * bubbleWidthFraction) - rowMargins - tailWidth - bubblePadding); + } + + private int maxMediaHeight() { + return Math.max(1, (int)(displayMetrics().heightPixels + * ConnectMessageMediaSizer.MAX_HEIGHT_FRACTION_OF_WINDOW)); + } + + private DisplayMetrics displayMetrics() { + return itemView.getResources().getDisplayMetrics(); + } + } + public class BaseMessageViewHolder extends RecyclerView.ViewHolder { ViewBinding binding; @@ -150,6 +257,9 @@ public void bind(ConnectMessageChatData chat) { if (binding instanceof ItemChatLeftViewBinding) { tvChatMessage = ((ItemChatLeftViewBinding)binding).tvChatMessage; tvChatDate = ((ItemChatLeftViewBinding)binding).tvChatDate; + } else if (binding instanceof ItemChatLeftRichViewBinding) { + tvChatMessage = ((ItemChatLeftRichViewBinding)binding).tvChatMessage; + tvChatDate = ((ItemChatLeftRichViewBinding)binding).tvChatDate; } else { tvChatMessage = ((ItemChatRightViewBinding)binding).tvChatMessage; tvChatDate = ((ItemChatRightViewBinding)binding).tvChatDate; @@ -173,31 +283,30 @@ public void bindReadStatus(ConnectMessageChatData chat) { @NonNull @Override public BaseMessageViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - if (viewType == LEFTVIEW) { - ItemChatLeftViewBinding binding = ItemChatLeftViewBinding.inflate( - LayoutInflater.from(parent.getContext()), parent, false); + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + if (viewType == LEFT_RICH_VIEW) { + ItemChatLeftRichViewBinding binding = ItemChatLeftRichViewBinding.inflate(inflater, parent, false); + return new RichLeftViewHolder(binding, parent); + } else if (viewType == LEFTVIEW) { + ItemChatLeftViewBinding binding = ItemChatLeftViewBinding.inflate(inflater, parent, false); return new LeftViewHolder(binding); } else { - ItemChatRightViewBinding binding = ItemChatRightViewBinding.inflate( - LayoutInflater.from(parent.getContext()), parent, false); + ItemChatRightViewBinding binding = ItemChatRightViewBinding.inflate(inflater, parent, false); return new RightViewHolder(binding); } } @Override public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { - ConnectMessageChatData chat = messages.get(position); - if (getItemViewType(position) == LEFTVIEW) { - ((LeftViewHolder)holder).bind(chat); - } else { - ((RightViewHolder)holder).bind(chat); - } + ((BaseMessageViewHolder)holder).bind(messages.get(position)); } @Override public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position, @NonNull List payloads) { - if (payloads.contains(PAYLOAD_READ_STATUS)) { + if (payloads.contains(PAYLOAD_ATTACHMENTS) && holder instanceof RichLeftViewHolder) { + ((RichLeftViewHolder)holder).bindAttachments(messages.get(position)); + } else if (payloads.contains(PAYLOAD_READ_STATUS)) { ((BaseMessageViewHolder)holder).bindReadStatus(messages.get(position)); } else { onBindViewHolder(holder, position); @@ -211,6 +320,10 @@ public int getItemCount() { @Override public int getItemViewType(int position) { - return messages.get(position).getType() == LEFTVIEW ? LEFTVIEW : RIGHTVIEW; + ConnectMessageChatData chat = messages.get(position); + if (chat.getType() != LEFTVIEW) { + return RIGHTVIEW; + } + return chat.hasRichContent() ? LEFT_RICH_VIEW : LEFTVIEW; } } diff --git a/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentRecord.kt b/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentRecord.kt new file mode 100644 index 0000000000..fe07d491c3 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentRecord.kt @@ -0,0 +1,111 @@ +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.util.UUID + +@Table(ConnectMessagingAttachmentRecord.STORAGE_KEY) +class ConnectMessagingAttachmentRecord : + Persisted(), + Serializable { + @Persisting(1) + @MetaField(META_ATTACHMENT_ID) + var attachmentId: String = "" + + @Persisting(2) + @MetaField(META_MESSAGE_ID) + var messageId: String = "" + + @Persisting(9) + @MetaField(META_CHANNEL_ID) + var channelId: String = "" + + @Persisting(3) + var position: Int = 0 + + @Persisting(4) + var name: String = "" + + @Persisting(5) + var type: String = "" + + @Persisting(6) + var size: Long = 0 + + @Persisting(7) + @MetaField(META_STATE) + var state: String = ConnectMessagingAttachmentState.WAITING.value + + @Persisting(8) + var attempts: Int = 0 + + var downloadState: ConnectMessagingAttachmentState + get() = ConnectMessagingAttachmentState.fromValue(state) + set(value) { + state = value.value + } + + companion object { + const val STORAGE_KEY = "connect_messaging_attachment" + const val META_ATTACHMENT_ID = "attachment_id" + const val META_MESSAGE_ID = "message_id" + const val META_CHANNEL_ID = "channel_id" + const val META_STATE = "state" + + private const val JSON_ATTACHMENTS = "attachments" + private const val JSON_ID = "id" + private const val JSON_NAME = "name" + private const val JSON_TYPE = "type" + private const val JSON_SIZE = "size" + + @JvmStatic + @Throws(JSONException::class) + fun listFromMessageJson( + messageJson: JSONObject, + messageId: String, + channelId: String, + ): List { + val attachmentsJson = messageJson.optJSONArray(JSON_ATTACHMENTS) ?: return emptyList() + return (0 until attachmentsJson.length()).map { index -> + fromJson(attachmentsJson.getJSONObject(index), messageId, channelId, index) + } + } + + private fun fromJson( + attachmentJson: JSONObject, + messageId: String, + channelId: String, + position: Int, + ): ConnectMessagingAttachmentRecord { + val attachmentId = attachmentJson.getString(JSON_ID) + if (!isCanonicalUuid(attachmentId)) { + throw JSONException("Attachment id for message $messageId is not a UUID: '$attachmentId'") + } + val size = attachmentJson.getLong(JSON_SIZE) + if (size < 0) { + throw JSONException("Attachment $attachmentId has a negative size: $size") + } + return ConnectMessagingAttachmentRecord().apply { + this.attachmentId = attachmentId + this.messageId = messageId + this.channelId = channelId + this.position = position + name = attachmentJson.getString(JSON_NAME) + type = attachmentJson.getString(JSON_TYPE) + this.size = size + } + } + + private fun isCanonicalUuid(value: String): Boolean = + try { + UUID.fromString(value).toString().equals(value, ignoreCase = true) + } catch (e: IllegalArgumentException) { + false + } + } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentState.kt b/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentState.kt new file mode 100644 index 0000000000..22ef176459 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentState.kt @@ -0,0 +1,21 @@ +package org.commcare.android.database.connect.models + +enum class ConnectMessagingAttachmentState( + val value: String, +) { + WAITING("waiting"), + QUEUED("queued"), + REQUESTED("requested"), + DOWNLOADING("downloading"), + AVAILABLE("available"), + FAILED("failed"), + EXPIRED("expired"), + ; + + companion object { + @JvmStatic + fun fromValue(value: String): ConnectMessagingAttachmentState = + values().firstOrNull { it.value == value } + ?: throw IllegalArgumentException("Unknown attachment state: '$value'") + } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecord.java index b3aba91137..197dd0a652 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecord.java @@ -1,5 +1,7 @@ package org.commcare.android.database.connect.models; +import androidx.annotation.Nullable; + import org.commcare.android.storage.framework.Persisted; import org.commcare.models.framework.Persisting; import org.commcare.modern.database.Table; @@ -15,6 +17,7 @@ import java.io.Serializable; import java.nio.ByteBuffer; import java.text.ParseException; +import java.util.Arrays; import java.util.Date; import java.util.List; @@ -36,6 +39,19 @@ public class ConnectMessagingMessageRecord extends Persisted implements Serializ public static final String META_MESSAGE_CONFIRM = "confirmed"; public static final String META_MESSAGE_USER_VIEWED = "user_viewed"; + public static final int VERSION_PLAIN = 0; + public static final int VERSION_RICH = 2; + private static final List SUPPORTED_VERSIONS = Arrays.asList(VERSION_PLAIN, VERSION_RICH); + private static final long NO_EXPIRY = 0; + + private static final String JSON_VERSION = "version"; + private static final String JSON_RICH_TEXT = "rich_text"; + private static final String JSON_FORMAT = "format"; + private static final String JSON_EXPIRES_AT = "expires_at"; + private static final String JSON_CIPHER_TEXT = "ciphertext"; + private static final String JSON_NONCE = "nonce"; + private static final String JSON_TAG = "tag"; + public ConnectMessagingMessageRecord() { } @@ -68,6 +84,34 @@ public ConnectMessagingMessageRecord() { @MetaField(META_MESSAGE_USER_VIEWED) private boolean userViewed; + @Persisting(8) + private int version; + + @Persisting(value = 9, nullable = true) + private String richText; + + @Persisting(value = 10, nullable = true) + private String format; + + @Persisting(11) + private long expiresAtMillis; + + public static ConnectMessagingMessageRecord fromV29(ConnectMessagingMessageRecordV29 oldRecord) { + ConnectMessagingMessageRecord record = new ConnectMessagingMessageRecord(); + record.messageId = oldRecord.getMessageId(); + record.channelId = oldRecord.getChannelId(); + record.timeStamp = oldRecord.getTimeStamp(); + record.message = oldRecord.getMessage(); + record.isOutgoing = oldRecord.isOutgoing(); + record.confirmed = oldRecord.getConfirmed(); + record.userViewed = oldRecord.getUserViewed(); + record.version = VERSION_PLAIN; + record.richText = null; + record.format = null; + record.expiresAtMillis = NO_EXPIRY; + return record; + } + /** * Creates a decrypted message record from encrypted JSON payload by using the channel key * @@ -96,9 +140,19 @@ public static ConnectMessagingMessageRecord fromJson( 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"); + connectMessagingMessageRecord.isOutgoing = false; + connectMessagingMessageRecord.confirmed = false; + connectMessagingMessageRecord.userViewed = false; + + connectMessagingMessageRecord.version = json.optInt(JSON_VERSION, VERSION_PLAIN); + if (connectMessagingMessageRecord.isUnsupportedVersion()) { + connectMessagingMessageRecord.message = ""; + return connectMessagingMessageRecord; + } + + String tag = json.getString(JSON_TAG); + String nonce = json.getString(JSON_NONCE); + String cipherText = json.getString(JSON_CIPHER_TEXT); String decrypted = decrypt(cipherText, nonce, tag, channel.getKey()); @@ -108,13 +162,45 @@ public static ConnectMessagingMessageRecord fromJson( connectMessagingMessageRecord.message = truncateMessage(decrypted, MESSAGE); - connectMessagingMessageRecord.isOutgoing = false; - connectMessagingMessageRecord.confirmed = false; - connectMessagingMessageRecord.userViewed = false; + if (connectMessagingMessageRecord.isRich()) { + readRichFields(connectMessagingMessageRecord, json, channel.getKey()); + } return connectMessagingMessageRecord; } + private static void readRichFields(ConnectMessagingMessageRecord record, JSONObject json, String key) + throws JSONException { + Object richTextValue = json.opt(JSON_RICH_TEXT); + if ("".equals(richTextValue)) { + record.richText = ""; + } else if (richTextValue instanceof JSONObject) { + JSONObject richText = (JSONObject)richTextValue; + String decryptedRichText = decrypt( + richText.optString(JSON_CIPHER_TEXT), + richText.optString(JSON_NONCE), + richText.optString(JSON_TAG), + key + ); + record.richText = decryptedRichText == null ? null : truncateMessage(decryptedRichText, MESSAGE); + } + if (record.richText != null) { + record.message = ""; + } + + record.format = json.has(JSON_FORMAT) ? json.getString(JSON_FORMAT) : null; + + if (json.has(JSON_EXPIRES_AT)) { + String expiresAt = json.getString(JSON_EXPIRES_AT); + Date expiryDate = DateUtils.parseDateTime(expiresAt); + if (expiryDate == null) { + throw new JSONException("Invalid expires_at for message " + record.messageId + ": '" + + expiresAt + "'"); + } + record.expiresAtMillis = expiryDate.getTime(); + } + } + private static ConnectMessagingChannelRecord getChannel( List channels, String channelId @@ -233,4 +319,35 @@ public boolean getUserViewed() { public void setUserViewed(boolean userViewed) { this.userViewed = userViewed; } + + public int getVersion() { + return version; + } + + public boolean isRich() { + return version == VERSION_RICH; + } + + public boolean isUnsupportedVersion() { + return !SUPPORTED_VERSIONS.contains(version); + } + + @Nullable + public String getRichText() { + return richText; + } + + @Nullable + public String getFormat() { + return format; + } + + @Nullable + public Date getExpiresAt() { + return expiresAtMillis == NO_EXPIRY ? null : new Date(expiresAtMillis); + } + + public String getDisplayText() { + return richText != null ? richText : message; + } } diff --git a/app/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecordV29.kt b/app/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecordV29.kt new file mode 100644 index 0000000000..00d2fbf857 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecordV29.kt @@ -0,0 +1,52 @@ +package org.commcare.android.database.connect.models + +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord.META_MESSAGE +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord.META_MESSAGE_CHANNEL_ID +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord.META_MESSAGE_CONFIRM +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord.META_MESSAGE_ID +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord.META_MESSAGE_IS_OUTGOING +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord.META_MESSAGE_TIMESTAMP +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord.META_MESSAGE_USER_VIEWED +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 java.io.Serializable +import java.util.Date + +@Table(ConnectMessagingMessageRecordV29.STORAGE_KEY) +class ConnectMessagingMessageRecordV29 : + Persisted(), + Serializable { + @Persisting(1) + @MetaField(META_MESSAGE_ID) + var messageId: String = "" + + @Persisting(2) + @MetaField(META_MESSAGE_CHANNEL_ID) + var channelId: String = "" + + @Persisting(3) + @MetaField(META_MESSAGE_TIMESTAMP) + var timeStamp: Date = Date() + + @Persisting(4) + @MetaField(META_MESSAGE) + var message: String = "" + + @Persisting(5) + @MetaField(META_MESSAGE_IS_OUTGOING) + var isOutgoing: Boolean = false + + @Persisting(6) + @MetaField(META_MESSAGE_CONFIRM) + var confirmed: Boolean = false + + @Persisting(7) + @MetaField(META_MESSAGE_USER_VIEWED) + var userViewed: Boolean = false + + companion object { + const val STORAGE_KEY = ConnectMessagingMessageRecord.STORAGE_KEY + } +} diff --git a/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt b/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt new file mode 100644 index 0000000000..2e871b0159 --- /dev/null +++ b/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt @@ -0,0 +1,134 @@ +package org.commcare.connect.database + +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord +import org.commcare.connect.messaging.ConnectMessagingAttachmentAutoDownloadPolicy +import org.commcare.models.database.SqlStorage +import java.util.Date + +object ConnectMessagingAttachmentDatabaseHelper { + private fun storage(): SqlStorage = + ConnectDatabaseHelper.getConnectStorage(ConnectMessagingAttachmentRecord::class.java) + + @JvmStatic + fun getAttachment(attachmentId: String): ConnectMessagingAttachmentRecord? = + storage() + .getRecordsForValues( + arrayOf(ConnectMessagingAttachmentRecord.META_ATTACHMENT_ID), + arrayOf(attachmentId), + ).firstOrNull() + + @JvmStatic + fun getAttachmentsByMessageId(channelId: String): Map> = + storage() + .getRecordsForValues( + arrayOf(ConnectMessagingAttachmentRecord.META_CHANNEL_ID), + arrayOf(channelId), + ).groupBy { it.messageId } + .mapValues { (_, attachments) -> attachments.sortedBy { it.position } } + + fun getAttachmentsInState(state: ConnectMessagingAttachmentState): List = + storage().getRecordsForValues( + arrayOf(ConnectMessagingAttachmentRecord.META_STATE), + arrayOf(state.value), + ) + + fun save(attachment: ConnectMessagingAttachmentRecord) { + storage().write(attachment) + } + + fun returnPendingAttachmentsToWaiting() { + val pending = + getAttachmentsInState(ConnectMessagingAttachmentState.QUEUED) + + getAttachmentsInState(ConnectMessagingAttachmentState.DOWNLOADING) + for (attachment in pending) { + attachment.downloadState = ConnectMessagingAttachmentState.WAITING + attachment.attempts = 0 + save(attachment) + } + } + + @JvmStatic + fun getAttachmentsForMessage(messageId: String): List = + storage() + .getRecordsForValues( + arrayOf(ConnectMessagingAttachmentRecord.META_MESSAGE_ID), + arrayOf(messageId), + ).sortedBy { it.position } + + @JvmStatic + fun requeueMessage(messageId: String): List { + val attachments = getAttachmentsForMessage(messageId) + if (attachments.any { it.downloadState == ConnectMessagingAttachmentState.EXPIRED }) { + return emptyList() + } + val requeued = + attachments.filter { + it.downloadState == ConnectMessagingAttachmentState.WAITING || + it.downloadState == ConnectMessagingAttachmentState.QUEUED || + it.downloadState == ConnectMessagingAttachmentState.FAILED + } + for (attachment in requeued) { + attachment.downloadState = ConnectMessagingAttachmentState.REQUESTED + attachment.attempts = 0 + save(attachment) + } + return requeued.map { it.attachmentId } + } + + @JvmStatic + @JvmOverloads + fun storeNewAttachments( + incoming: List, + automaticDownloadEnabled: Boolean, + now: Date = Date(), + ) { + val newAttachments = incoming.filter { getAttachment(it.attachmentId) == null } + if (newAttachments.isEmpty()) { + return + } + + val messagesById = + newAttachments + .map { it.messageId } + .distinct() + .associateWith { messageId -> + // RetrieveNotificationsResponseParser pairs attachments with their message; processParsedDataIntoDB stores messages first + getMessage(messageId) + ?: throw IllegalStateException("Attachment refers to message $messageId, which is not stored") + } + val newestIncomingIdsByChannel = + messagesById.values + .map { it.channelId } + .distinct() + .associateWith { channelId -> + ConnectMessagingAttachmentAutoDownloadPolicy.newestIncomingMessageIds( + ConnectMessagingDatabaseHelper.getMessagingMessagesForChannel(channelId), + ) + } + + val storage = storage() + for (attachment in newAttachments) { + val message = messagesById.getValue(attachment.messageId) + val newestIncomingIds = newestIncomingIdsByChannel.getValue(message.channelId) + attachment.downloadState = + ConnectMessagingAttachmentAutoDownloadPolicy.initialState( + message.timeStamp, + message.expiresAt, + automaticDownloadEnabled && message.messageId in newestIncomingIds, + now, + ) + attachment.attempts = 0 + storage.write(attachment) + } + } + + fun getMessage(messageId: String): ConnectMessagingMessageRecord? = + ConnectDatabaseHelper + .getConnectStorage(ConnectMessagingMessageRecord::class.java) + .getRecordsForValues( + arrayOf(ConnectMessagingMessageRecord.META_MESSAGE_ID), + arrayOf(messageId), + ).firstOrNull() +} diff --git a/app/src/org/commcare/connect/database/ConnectMessagingAttachmentFileStore.kt b/app/src/org/commcare/connect/database/ConnectMessagingAttachmentFileStore.kt new file mode 100644 index 0000000000..541996a2e9 --- /dev/null +++ b/app/src/org/commcare/connect/database/ConnectMessagingAttachmentFileStore.kt @@ -0,0 +1,42 @@ +package org.commcare.connect.database + +import android.content.Context +import java.io.File +import java.io.IOException + +object ConnectMessagingAttachmentFileStore { + private const val DIRECTORY_NAME = "connect_messaging_attachments" + private const val PARTIAL_FILE_SUFFIX = ".part" + + fun directory(context: Context): File = File(context.filesDir, DIRECTORY_NAME) + + @JvmStatic + fun fileFor( + context: Context, + attachmentId: String, + ): File = File(directory(context), attachmentId) + + @Throws(IOException::class) + fun write( + context: Context, + attachmentId: String, + contents: ByteArray, + ) { + val directory = directory(context) + if (!directory.isDirectory && !directory.mkdirs()) { + throw IOException("Could not create attachment directory $directory") + } + val partialFile = File(directory, attachmentId + PARTIAL_FILE_SUFFIX) + partialFile.writeBytes(contents) + val completeFile = fileFor(context, attachmentId) + if (!partialFile.renameTo(completeFile)) { + partialFile.delete() + throw IOException("Could not move $partialFile to $completeFile") + } + } + + @JvmStatic + fun deleteAll(context: Context) { + directory(context).deleteRecursively() + } +} diff --git a/app/src/org/commcare/connect/database/ConnectMessagingDatabaseHelper.java b/app/src/org/commcare/connect/database/ConnectMessagingDatabaseHelper.java index 9cc1c745e9..43fa663434 100644 --- a/app/src/org/commcare/connect/database/ConnectMessagingDatabaseHelper.java +++ b/app/src/org/commcare/connect/database/ConnectMessagingDatabaseHelper.java @@ -7,8 +7,11 @@ import androidx.core.content.ContextCompat; +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord; +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState; import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord; import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord; +import org.commcare.connect.messaging.ConnectMessagingMessagePackage; import org.commcare.dalvik.R; import org.commcare.models.database.SqlStorage; import org.commcare.utils.DimensionUtils; @@ -43,10 +46,17 @@ public static List getMessagingChannels(Context c SpannableString preview; if (lastMessage != null && channel.getConsented()) { - String trimmed = lastMessage.getMessage().split("\n")[0]; - int maxLength = 25; - if (trimmed.length() > maxLength) { - trimmed = trimmed.substring(0, maxLength - 3) + "..."; + String trimmed; + if (lastMessage.isUnsupportedVersion()) { + trimmed = context.getString(R.string.connect_messaging_preview_update_app); + } else if (isAwaitingDownload(lastMessage)) { + trimmed = context.getString(R.string.connect_messaging_preview_pending_download); + } else { + trimmed = lastMessage.getDisplayText().split("\n")[0]; + int maxLength = 25; + if (trimmed.length() > maxLength) { + trimmed = trimmed.substring(0, maxLength - 3) + "..."; + } } preview = new SpannableString( @@ -76,6 +86,17 @@ public static List getMessagingChannels(Context c return channels; } + private static boolean isAwaitingDownload(ConnectMessagingMessageRecord message) { + if (!message.isRich()) { + return false; + } + List attachments = + ConnectMessagingAttachmentDatabaseHelper.getAttachmentsForMessage(message.getMessageId()); + return !attachments.isEmpty() + && ConnectMessagingMessagePackage.stateOfAttachments(attachments) + != ConnectMessagingAttachmentState.AVAILABLE; + } + public static ConnectMessagingChannelRecord getMessagingChannel( String channelId ) { @@ -222,6 +243,9 @@ public static void storeMessagingMessages( for (ConnectMessagingMessageRecord incoming : messages) { if (existing.getMessageId().equals(incoming.getMessageId())) { incoming.setID(existing.getID()); + if (incoming.getVersion() == existing.getVersion()) { + incoming.setUserViewed(existing.getUserViewed()); + } stillExists = true; break; } diff --git a/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java b/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java index 6537c07f04..766409cca4 100644 --- a/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java +++ b/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java @@ -3,7 +3,9 @@ import org.commcare.CommCareApplication; import org.commcare.android.database.connect.models.ConnectUserRecord; import org.commcare.android.database.global.models.ConnectKeyRecord; +import org.commcare.connect.messaging.ConnectMessagingAttachmentDownloadScheduler; import org.commcare.models.database.connect.DatabaseConnectOpenHelper; +import org.commcare.preferences.ConnectMessagingPreferences; public class ConnectUserDatabaseUtil { @@ -27,6 +29,9 @@ public static void storeUser(ConnectUserRecord user) { } public static void forgetUser() { + ConnectMessagingAttachmentDownloadScheduler.cancelDownloads(CommCareApplication.instance()); + ConnectMessagingAttachmentFileStore.deleteAll(CommCareApplication.instance()); + ConnectMessagingPreferences.clear(CommCareApplication.instance()); DatabaseConnectOpenHelper.deleteDb(); CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class).removeAll(); ConnectDatabaseHelper.dbBroken = false; diff --git a/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentAutoDownloadPolicy.kt b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentAutoDownloadPolicy.kt new file mode 100644 index 0000000000..0fcf8d2025 --- /dev/null +++ b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentAutoDownloadPolicy.kt @@ -0,0 +1,33 @@ +package org.commcare.connect.messaging + +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord +import java.util.Date +import java.util.concurrent.TimeUnit + +object ConnectMessagingAttachmentAutoDownloadPolicy { + const val MAX_AUTO_DOWNLOAD_AGE_DAYS = 7L + const val NEWEST_INCOMING_MESSAGES_TO_AUTO_DOWNLOAD = 50 + + fun initialState( + messageTimestamp: Date, + expiresAt: Date?, + isAmongNewestIncoming: Boolean, + now: Date, + ): ConnectMessagingAttachmentState { + val isRecent = now.time - messageTimestamp.time < TimeUnit.DAYS.toMillis(MAX_AUTO_DOWNLOAD_AGE_DAYS) + return when { + expiresAt != null && !expiresAt.after(now) -> ConnectMessagingAttachmentState.EXPIRED + isAmongNewestIncoming && isRecent -> ConnectMessagingAttachmentState.QUEUED + else -> ConnectMessagingAttachmentState.WAITING + } + } + + fun newestIncomingMessageIds(channelMessages: Collection): Set = + channelMessages + .filter { !it.isOutgoing } + .sortedByDescending { it.timeStamp } + .take(NEWEST_INCOMING_MESSAGES_TO_AUTO_DOWNLOAD) + .map { it.messageId } + .toSet() +} diff --git a/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentCrypto.kt b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentCrypto.kt new file mode 100644 index 0000000000..f8ed2feddd --- /dev/null +++ b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentCrypto.kt @@ -0,0 +1,39 @@ +package org.commcare.connect.messaging + +import org.commcare.util.Base64 +import org.commcare.util.Base64DecoderException +import java.security.GeneralSecurityException +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +object ConnectMessagingAttachmentCrypto { + private const val NONCE_LENGTH_BYTES = 12 + private const val TAG_LENGTH_BYTES = 16 + private const val TAG_LENGTH_BITS = TAG_LENGTH_BYTES * 8 + + @Throws(GeneralSecurityException::class) + fun decrypt( + encrypted: ByteArray, + base64ChannelKey: String, + ): ByteArray { + if (encrypted.size < NONCE_LENGTH_BYTES + TAG_LENGTH_BYTES) { + throw GeneralSecurityException( + "Attachment of ${encrypted.size} bytes is shorter than its nonce and tag", + ) + } + val key = + try { + Base64.decode(base64ChannelKey) + } catch (e: Base64DecoderException) { + throw GeneralSecurityException("Channel key is not valid base64", e) + } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(key, "AES"), + GCMParameterSpec(TAG_LENGTH_BITS, encrypted, 0, NONCE_LENGTH_BYTES), + ) + return cipher.doFinal(encrypted, NONCE_LENGTH_BYTES, encrypted.size - NONCE_LENGTH_BYTES) + } +} diff --git a/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloadScheduler.kt b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloadScheduler.kt new file mode 100644 index 0000000000..dda5f9baf3 --- /dev/null +++ b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloadScheduler.kt @@ -0,0 +1,176 @@ +package org.commcare.connect.messaging + +import android.content.Context +import android.content.Intent +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.workDataOf +import org.commcare.connect.database.ConnectMessagingAttachmentDatabaseHelper +import org.commcare.connect.workers.ConnectMessagingAttachmentDownloadWorker +import org.commcare.preferences.ConnectMessagingPreferences +import org.commcare.preferences.ConnectMessagingPreferences.AttachmentAutoDownload +import org.commcare.utils.FirebaseMessagingUtil +import org.commcare.utils.PushNotificationApiHelper +import java.util.concurrent.TimeUnit + +data class ConnectMessagingAttachmentDownloadConditions( + val networkType: NetworkType, + val largeFileThresholdBytes: Long, + val largeFileNetworkType: NetworkType, +) { + companion object { + const val LARGE_FILE_THRESHOLD_BYTES = 1024L * 1024L + + @JvmStatic + fun fromPreferences(context: Context): ConnectMessagingAttachmentDownloadConditions? { + val largeFileNetworkType = + when (ConnectMessagingPreferences.getAttachmentAutoDownload(context)) { + AttachmentAutoDownload.MANUAL_ONLY -> return null + AttachmentAutoDownload.LARGE_ON_WIFI_ONLY -> NetworkType.UNMETERED + AttachmentAutoDownload.ANY_NETWORK -> NetworkType.CONNECTED + } + return ConnectMessagingAttachmentDownloadConditions( + networkType = NetworkType.CONNECTED, + largeFileThresholdBytes = LARGE_FILE_THRESHOLD_BYTES, + largeFileNetworkType = largeFileNetworkType, + ) + } + } +} + +object ConnectMessagingAttachmentDownloadScheduler { + private const val WORK_TAG = "connect_messaging_attachment_download" + const val ALL_FILES_WORK_NAME = WORK_TAG + const val SMALL_FILES_WORK_NAME = "${WORK_TAG}_small" + const val LARGE_FILES_WORK_NAME = "${WORK_TAG}_large" + private const val REQUESTED_WORK_NAME_PREFIX = "${WORK_TAG}_requested_" + private const val REQUESTED_RETRY_WORK_NAME_PREFIX = "${WORK_TAG}_retry_" + + @JvmStatic + fun scheduleQueuedDownloads(context: Context) { + enqueueAutomaticDownloads(context, ExistingWorkPolicy.KEEP) + } + + @JvmStatic + fun applyAutoDownloadSetting(context: Context) { + val workManager = WorkManager.getInstance(context) + workManager.cancelUniqueWork(ALL_FILES_WORK_NAME) + workManager.cancelUniqueWork(SMALL_FILES_WORK_NAME) + workManager.cancelUniqueWork(LARGE_FILES_WORK_NAME) + if (ConnectMessagingPreferences.isAutomaticDownloadEnabled(context)) { + enqueueAutomaticDownloads(context, ExistingWorkPolicy.REPLACE) + } else { + ConnectMessagingAttachmentDatabaseHelper.returnPendingAttachmentsToWaiting() + LocalBroadcastManager + .getInstance(context) + .sendBroadcast(Intent(FirebaseMessagingUtil.MESSAGING_UPDATE_BROADCAST)) + } + } + + @JvmStatic + fun downloadNow( + context: Context, + attachmentId: String, + ) { + WorkManager.getInstance(context).cancelUniqueWork(requestedRetryWorkName(attachmentId)) + enqueueWork( + context, + requestedWorkName(attachmentId), + NetworkType.NOT_REQUIRED, + requestedData(attachmentId, isBackgroundRetry = false), + ExistingWorkPolicy.REPLACE, + ) + } + + fun retryRequestedDownloadLater( + context: Context, + attachmentId: String, + ) { + enqueueWork( + context, + requestedRetryWorkName(attachmentId), + NetworkType.CONNECTED, + requestedData(attachmentId, isBackgroundRetry = true), + ExistingWorkPolicy.REPLACE, + PushNotificationApiHelper.SYNC_BACKOFF_DELAY_IN_MINS, + ) + } + + @JvmStatic + fun cancelDownloads(context: Context) { + WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG) + } + + fun requestedWorkName(attachmentId: String) = REQUESTED_WORK_NAME_PREFIX + attachmentId + + fun requestedRetryWorkName(attachmentId: String) = REQUESTED_RETRY_WORK_NAME_PREFIX + attachmentId + + private fun requestedData( + attachmentId: String, + isBackgroundRetry: Boolean, + ): Data = + workDataOf( + ConnectMessagingAttachmentDownloadWorker.KEY_ATTACHMENT_ID to attachmentId, + ConnectMessagingAttachmentDownloadWorker.KEY_BACKGROUND_RETRY to isBackgroundRetry, + ) + + private fun enqueueAutomaticDownloads( + context: Context, + policy: ExistingWorkPolicy, + ) { + val conditions = ConnectMessagingAttachmentDownloadConditions.fromPreferences(context) ?: return + if (conditions.networkType == conditions.largeFileNetworkType) { + enqueueWork(context, ALL_FILES_WORK_NAME, conditions.networkType, sizeRangeData(0L..Long.MAX_VALUE), policy) + return + } + enqueueWork( + context, + SMALL_FILES_WORK_NAME, + conditions.networkType, + sizeRangeData(0L..conditions.largeFileThresholdBytes), + policy, + ) + enqueueWork( + context, + LARGE_FILES_WORK_NAME, + conditions.largeFileNetworkType, + sizeRangeData((conditions.largeFileThresholdBytes + 1)..Long.MAX_VALUE), + policy, + ) + } + + private fun sizeRangeData(sizeRange: LongRange): Data = + workDataOf( + ConnectMessagingAttachmentDownloadWorker.KEY_MIN_SIZE_BYTES to sizeRange.first, + ConnectMessagingAttachmentDownloadWorker.KEY_MAX_SIZE_BYTES to sizeRange.last, + ) + + private fun enqueueWork( + context: Context, + workName: String, + networkType: NetworkType, + inputData: Data, + policy: ExistingWorkPolicy, + initialDelayMinutes: Long = 0, + ) { + val request = + OneTimeWorkRequest + .Builder(ConnectMessagingAttachmentDownloadWorker::class.java) + .addTag(WORK_TAG) + .setConstraints(Constraints.Builder().setRequiredNetworkType(networkType).build()) + .setInitialDelay(initialDelayMinutes, TimeUnit.MINUTES) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + PushNotificationApiHelper.SYNC_BACKOFF_DELAY_IN_MINS, + TimeUnit.MINUTES, + ).setInputData(inputData) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(workName, policy, request) + } +} diff --git a/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloader.kt b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloader.kt new file mode 100644 index 0000000000..36e5d385a0 --- /dev/null +++ b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloader.kt @@ -0,0 +1,211 @@ +package org.commcare.connect.messaging + +import android.content.Context +import android.content.Intent +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import kotlinx.coroutines.sync.Mutex +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord +import org.commcare.connect.database.ConnectMessagingAttachmentDatabaseHelper +import org.commcare.connect.database.ConnectMessagingAttachmentFileStore +import org.commcare.connect.database.ConnectMessagingDatabaseHelper +import org.commcare.util.LogTypes +import org.commcare.utils.FirebaseMessagingUtil +import org.javarosa.core.services.Logger +import java.io.IOException +import java.net.HttpURLConnection +import java.security.GeneralSecurityException +import java.util.Date + +class ConnectMessagingAttachmentDownloader( + private val context: Context, + private val fetcher: ConnectMessagingAttachmentFetcher, + private val clock: () -> Date = { Date() }, +) { + enum class PassResult { + COMPLETE, + RETRY_LATER, + STOPPED_BY_AUTH_FAILURE, + } + + private enum class Outcome { + FINISHED, + RETRY_LATER, + AUTH_FAILURE, + } + + private var hasUnannouncedChanges = false + + fun runPass( + isEligible: (ConnectMessagingAttachmentRecord) -> Boolean, + isStopped: () -> Boolean, + ): PassResult { + try { + failInterruptedDownloads() + val attempted = HashSet() + var retryLater = false + while (!isStopped()) { + val next = nextPending(isEligible, attempted) ?: break + attempted.add(next.attachment.attachmentId) + when (download(next.attachment, next.message)) { + Outcome.RETRY_LATER -> retryLater = true + Outcome.AUTH_FAILURE -> return PassResult.STOPPED_BY_AUTH_FAILURE + Outcome.FINISHED -> Unit + } + } + return if (retryLater) PassResult.RETRY_LATER else PassResult.COMPLETE + } finally { + announceChanges() + } + } + + private fun failInterruptedDownloads() { + for (attachment in ConnectMessagingAttachmentDatabaseHelper.getAttachmentsInState( + ConnectMessagingAttachmentState.DOWNLOADING, + )) { + update(attachment, ConnectMessagingAttachmentState.FAILED) + } + } + + private class PendingAttachment( + val attachment: ConnectMessagingAttachmentRecord, + val message: ConnectMessagingMessageRecord?, + ) + + private fun nextPending( + isEligible: (ConnectMessagingAttachmentRecord) -> Boolean, + attempted: Set, + ): PendingAttachment? = + pendingAttachments() + .filter { it.attachmentId !in attempted && isEligible(it) } + .map { PendingAttachment(it, ConnectMessagingAttachmentDatabaseHelper.getMessage(it.messageId)) } + .minWithOrNull( + compareBy( + { it.attachment.downloadState != ConnectMessagingAttachmentState.REQUESTED }, + { it.message?.timeStamp ?: Date(0) }, + { it.attachment.position }, + ), + ) + + private fun pendingAttachments(): List = + ConnectMessagingAttachmentDatabaseHelper.getAttachmentsInState(ConnectMessagingAttachmentState.REQUESTED) + + ConnectMessagingAttachmentDatabaseHelper.getAttachmentsInState(ConnectMessagingAttachmentState.QUEUED) + + ConnectMessagingAttachmentDatabaseHelper + .getAttachmentsInState(ConnectMessagingAttachmentState.FAILED) + .filter { it.attempts < MAX_ATTEMPTS } + + private fun download( + attachment: ConnectMessagingAttachmentRecord, + message: ConnectMessagingMessageRecord?, + ): Outcome { + if (message == null) { + attachment.attempts = MAX_ATTEMPTS + return finish(attachment, ConnectMessagingAttachmentState.FAILED) + } + val expiresAt = message.expiresAt + if (expiresAt != null && !expiresAt.after(clock())) { + return finish(attachment, ConnectMessagingAttachmentState.EXPIRED) + } + val channelKey = ConnectMessagingDatabaseHelper.getMessagingChannel(message.channelId)?.key.orEmpty() + if (channelKey.isEmpty()) { + return countFailedAttempt(attachment, "no key for channel ${message.channelId}") + } + + update(attachment, ConnectMessagingAttachmentState.DOWNLOADING) + announceChanges() + val response = + try { + fetcher.fetch(message.messageId, attachment.attachmentId, attachment.size) + } catch (e: IOException) { + return countFailedAttempt(attachment, "network error: ${e.message}") + } + + return when (response.statusCode) { + HttpURLConnection.HTTP_OK -> { + store(attachment, response.body, channelKey) + } + + HttpURLConnection.HTTP_GONE -> { + finish(attachment, ConnectMessagingAttachmentState.EXPIRED) + } + + HttpURLConnection.HTTP_UNAUTHORIZED, HttpURLConnection.HTTP_FORBIDDEN -> { + update(attachment, ConnectMessagingAttachmentState.FAILED) + Outcome.AUTH_FAILURE + } + + else -> { + countFailedAttempt(attachment, "HTTP ${response.statusCode}") + } + } + } + + private fun store( + attachment: ConnectMessagingAttachmentRecord, + encrypted: ByteArray?, + channelKey: String, + ): Outcome { + if (encrypted == null || encrypted.size.toLong() != attachment.size) { + return countFailedAttempt(attachment, "expected ${attachment.size} bytes, received ${encrypted?.size}") + } + return try { + val decrypted = ConnectMessagingAttachmentCrypto.decrypt(encrypted, channelKey) + ConnectMessagingAttachmentFileStore.write(context, attachment.attachmentId, decrypted) + attachment.attempts = 0 + finish(attachment, ConnectMessagingAttachmentState.AVAILABLE) + } catch (e: GeneralSecurityException) { + countFailedAttempt(attachment, "decryption failed: $e") + } catch (e: IOException) { + countFailedAttempt(attachment, "could not save: ${e.message}") + } + } + + private fun countFailedAttempt( + attachment: ConnectMessagingAttachmentRecord, + reason: String, + ): Outcome { + attachment.attempts += 1 + update(attachment, ConnectMessagingAttachmentState.FAILED) + if (attachment.attempts < MAX_ATTEMPTS) { + return Outcome.RETRY_LATER + } + Logger.log( + LogTypes.TYPE_WARNING_NETWORK, + "Messaging attachment ${attachment.attachmentId} failed after $MAX_ATTEMPTS attempts: $reason", + ) + return Outcome.FINISHED + } + + private fun finish( + attachment: ConnectMessagingAttachmentRecord, + state: ConnectMessagingAttachmentState, + ): Outcome { + update(attachment, state) + return Outcome.FINISHED + } + + private fun update( + attachment: ConnectMessagingAttachmentRecord, + state: ConnectMessagingAttachmentState, + ) { + attachment.downloadState = state + ConnectMessagingAttachmentDatabaseHelper.save(attachment) + hasUnannouncedChanges = true + } + + private fun announceChanges() { + if (!hasUnannouncedChanges) { + return + } + hasUnannouncedChanges = false + LocalBroadcastManager + .getInstance(context) + .sendBroadcast(Intent(FirebaseMessagingUtil.MESSAGING_UPDATE_BROADCAST)) + } + + companion object { + const val MAX_ATTEMPTS = 3 + val passLock = Mutex() + } +} diff --git a/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentFetcher.kt b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentFetcher.kt new file mode 100644 index 0000000000..50e1cf7719 --- /dev/null +++ b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentFetcher.kt @@ -0,0 +1,17 @@ +package org.commcare.connect.messaging + +import java.io.IOException + +fun interface ConnectMessagingAttachmentFetcher { + @Throws(IOException::class) + fun fetch( + messageId: String, + attachmentId: String, + expectedSize: Long, + ): ConnectMessagingAttachmentFetchResponse +} + +class ConnectMessagingAttachmentFetchResponse( + val statusCode: Int, + val body: ByteArray?, +) diff --git a/app/src/org/commcare/connect/messaging/ConnectMessagingMessagePackage.kt b/app/src/org/commcare/connect/messaging/ConnectMessagingMessagePackage.kt new file mode 100644 index 0000000000..5a12c95a93 --- /dev/null +++ b/app/src/org/commcare/connect/messaging/ConnectMessagingMessagePackage.kt @@ -0,0 +1,29 @@ +package org.commcare.connect.messaging + +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState + +object ConnectMessagingMessagePackage { + fun stateOf(attachmentStates: Collection): ConnectMessagingAttachmentState { + val pending = attachmentStates.filter { it != ConnectMessagingAttachmentState.AVAILABLE } + return when { + pending.isEmpty() -> ConnectMessagingAttachmentState.AVAILABLE + + ConnectMessagingAttachmentState.EXPIRED in pending -> ConnectMessagingAttachmentState.EXPIRED + + pending.any { + it == ConnectMessagingAttachmentState.DOWNLOADING || it == ConnectMessagingAttachmentState.REQUESTED + } -> ConnectMessagingAttachmentState.DOWNLOADING + + ConnectMessagingAttachmentState.FAILED in pending -> ConnectMessagingAttachmentState.FAILED + + ConnectMessagingAttachmentState.QUEUED in pending -> ConnectMessagingAttachmentState.QUEUED + + else -> ConnectMessagingAttachmentState.WAITING + } + } + + @JvmStatic + fun stateOfAttachments(attachments: List?): ConnectMessagingAttachmentState = + stateOf(attachments.orEmpty().map { it.downloadState }) +} diff --git a/app/src/org/commcare/connect/messaging/PersonalIdAttachmentFetcher.kt b/app/src/org/commcare/connect/messaging/PersonalIdAttachmentFetcher.kt new file mode 100644 index 0000000000..06f1593aec --- /dev/null +++ b/app/src/org/commcare/connect/messaging/PersonalIdAttachmentFetcher.kt @@ -0,0 +1,51 @@ +package org.commcare.connect.messaging + +import org.commcare.connect.network.personalId.ApiPersonalId +import java.io.ByteArrayOutputStream +import java.io.InputStream + +class PersonalIdAttachmentFetcher( + private val userId: String, + private val password: String, +) : ConnectMessagingAttachmentFetcher { + override fun fetch( + messageId: String, + attachmentId: String, + expectedSize: Long, + ): ConnectMessagingAttachmentFetchResponse { + val response = + ApiPersonalId + .buildMessageAttachmentDownloadCall(userId, password, messageId, attachmentId) + .execute() + if (!response.isSuccessful) { + response.errorBody()?.close() + return ConnectMessagingAttachmentFetchResponse(response.code(), null) + } + val body = response.body() ?: return ConnectMessagingAttachmentFetchResponse(response.code(), null) + return body.use { + ConnectMessagingAttachmentFetchResponse(response.code(), readAtMost(it.byteStream(), expectedSize + 1)) + } + } + + private fun readAtMost( + stream: InputStream, + limit: Long, + ): ByteArray { + val output = ByteArrayOutputStream() + val buffer = ByteArray(BUFFER_SIZE) + var total = 0L + while (total < limit) { + val read = stream.read(buffer, 0, minOf(buffer.size.toLong(), limit - total).toInt()) + if (read == -1) { + break + } + output.write(buffer, 0, read) + total += read + } + return output.toByteArray() + } + + private companion object { + const val BUFFER_SIZE = 8192 + } +} diff --git a/app/src/org/commcare/connect/network/personalId/ApiPersonalId.java b/app/src/org/commcare/connect/network/personalId/ApiPersonalId.java index ddcbe344ac..f45564b96f 100644 --- a/app/src/org/commcare/connect/network/personalId/ApiPersonalId.java +++ b/app/src/org/commcare/connect/network/personalId/ApiPersonalId.java @@ -347,6 +347,17 @@ public static void retrieveNotifications( BaseApi.Companion.callApi(context, call, callback, PersonalIdApiEndpoints.RETRIEVE_NOTIFICATIONS); } + public static Call buildMessageAttachmentDownloadCall( + String userId, + String password, + String messageId, + String attachmentId + ) { + AuthInfo authInfo = new AuthInfo.ProvidedAuth(userId, password, false); + String tokenAuth = HttpUtils.getCredential(authInfo); + return PersonalIdApiClient.getClientApi().downloadMessageAttachment(tokenAuth, messageId, attachmentId); + } + public static void updateNotifications( Context context, String userId, diff --git a/app/src/org/commcare/connect/network/personalId/PersonalIdApiEndpoints.kt b/app/src/org/commcare/connect/network/personalId/PersonalIdApiEndpoints.kt index 8c7f2962f0..e2c12a1097 100644 --- a/app/src/org/commcare/connect/network/personalId/PersonalIdApiEndpoints.kt +++ b/app/src/org/commcare/connect/network/personalId/PersonalIdApiEndpoints.kt @@ -22,5 +22,6 @@ object PersonalIdApiEndpoints { const val UPDATE_NOTIFICATIONS = "/messaging/update_notification_received/" const val MESSAGE_CHANNEL_CONSENT_URL = "/messaging/update_consent/" const val MESSAGE_SEND_URL = "/messaging/send_message/" + const val MESSAGE_ATTACHMENT_URL = "/messaging/messages/{messageId}/attachments/{attachmentId}/" const val RELEASE_TOGGLES = "/toggles" } diff --git a/app/src/org/commcare/connect/network/personalId/PersonalIdApiService.java b/app/src/org/commcare/connect/network/personalId/PersonalIdApiService.java index 00bab2e80e..39545ca8a7 100644 --- a/app/src/org/commcare/connect/network/personalId/PersonalIdApiService.java +++ b/app/src/org/commcare/connect/network/personalId/PersonalIdApiService.java @@ -10,6 +10,8 @@ import retrofit2.http.Header; import retrofit2.http.HeaderMap; import retrofit2.http.POST; +import retrofit2.http.Path; +import retrofit2.http.Streaming; import retrofit2.http.Url; public interface PersonalIdApiService { @@ -99,6 +101,12 @@ Call sendMessagingMessage(@Header("Authorization") String token, @HeaderMap Map headers, @Body RequestBody sendMessagingMessageRequest); + @Streaming + @GET(PersonalIdApiEndpoints.MESSAGE_ATTACHMENT_URL) + Call downloadMessageAttachment(@Header("Authorization") String token, + @Path("messageId") String messageId, + @Path("attachmentId") String attachmentId); + @POST Call makePostRequest( @Url String url, diff --git a/app/src/org/commcare/connect/network/personalId/parser/NotificationParseResult.kt b/app/src/org/commcare/connect/network/personalId/parser/NotificationParseResult.kt index 498a7e033d..7cfead100e 100644 --- a/app/src/org/commcare/connect/network/personalId/parser/NotificationParseResult.kt +++ b/app/src/org/commcare/connect/network/personalId/parser/NotificationParseResult.kt @@ -1,5 +1,6 @@ package org.commcare.connect.network.personalId.parser +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord import org.commcare.android.database.connect.models.PushNotificationRecord @@ -13,4 +14,5 @@ data class NotificationParseResult( val channels: List, val messages: List, val messagingNotificationIds: List, + val attachments: List, ) diff --git a/app/src/org/commcare/connect/network/personalId/parser/RetrieveNotificationsResponseParser.kt b/app/src/org/commcare/connect/network/personalId/parser/RetrieveNotificationsResponseParser.kt index 35a3bbb5ae..f9f3f4e901 100644 --- a/app/src/org/commcare/connect/network/personalId/parser/RetrieveNotificationsResponseParser.kt +++ b/app/src/org/commcare/connect/network/personalId/parser/RetrieveNotificationsResponseParser.kt @@ -1,6 +1,7 @@ package org.commcare.connect.network.personalId.parser import android.content.Context +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord import org.commcare.android.database.connect.models.PushNotificationRecord @@ -33,13 +34,14 @@ class RetrieveNotificationsResponseParser( } val channels = parseChannels(responseJsonObject) - val (nonMessageNotifications, messages, messagesNotificationsIds) = parseAndSeparateNotifications() + val separated = parseAndSeparateNotifications() return NotificationParseResult( - nonMessageNotifications, + separated.nonMessagingNotifications, channels, - messages, - messagesNotificationsIds, + separated.messages, + separated.messagingNotificationIds, + separated.attachments, ) } @@ -62,14 +64,11 @@ class RetrieveNotificationsResponseParser( * - Messaging notifications as ConnectMessagingMessageRecord * This avoids double parsing and eliminates filtering overhead */ - private fun parseAndSeparateNotifications(): Triple< - MutableList, - MutableList, - MutableList, - > { + private fun parseAndSeparateNotifications(): SeparatedNotifications { val nonMessageNotifications = mutableListOf() val messages = mutableListOf() val messagesNotificationsIds = mutableListOf() + val attachments = mutableListOf() notificationsJsonArray.let { jsonArray -> // Get existing channels from database - these have the encryption keys required for message decryption @@ -85,8 +84,19 @@ class RetrieveNotificationsResponseParser( existingChannels, ) if (message != null) { + if (message.isRich) { + attachments.addAll( + ConnectMessagingAttachmentRecord.listFromMessageJson( + notificationJsonObject, + message.messageId, + message.channelId, + ), + ) + } messages.add(message) - messagesNotificationsIds.add(notificationJsonObject.getString(META_NOTIFICATION_ID)) + if (!message.isUnsupportedVersion) { + messagesNotificationsIds.add(notificationJsonObject.getString(META_NOTIFICATION_ID)) + } } } else { // Handle non-messaging notifications @@ -96,9 +106,16 @@ class RetrieveNotificationsResponseParser( } } - return Triple(nonMessageNotifications, messages, messagesNotificationsIds) + return SeparatedNotifications(nonMessageNotifications, messages, messagesNotificationsIds, attachments) } + private data class SeparatedNotifications( + val nonMessagingNotifications: List, + val messages: List, + val messagingNotificationIds: List, + val attachments: List, + ) + /** * Checks if a notification JSON object is of messaging type */ diff --git a/app/src/org/commcare/connect/workers/ConnectMessagingAttachmentDownloadWorker.kt b/app/src/org/commcare/connect/workers/ConnectMessagingAttachmentDownloadWorker.kt new file mode 100644 index 0000000000..71d3999229 --- /dev/null +++ b/app/src/org/commcare/connect/workers/ConnectMessagingAttachmentDownloadWorker.kt @@ -0,0 +1,67 @@ +package org.commcare.connect.workers + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord +import org.commcare.connect.PersonalIdManager +import org.commcare.connect.database.ConnectUserDatabaseUtil +import org.commcare.connect.messaging.ConnectMessagingAttachmentDownloadScheduler +import org.commcare.connect.messaging.ConnectMessagingAttachmentDownloader +import org.commcare.connect.messaging.PersonalIdAttachmentFetcher +import org.commcare.preferences.ConnectMessagingPreferences +import org.commcare.utils.coroutines.DispatcherProvider + +class ConnectMessagingAttachmentDownloadWorker( + context: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(context, workerParams) { + override suspend fun doWork(): Result { + val user = ConnectUserDatabaseUtil.getUser() + if (!PersonalIdManager.getInstance().isloggedIn() || user == null) { + return Result.success() + } + val requestedAttachmentId = inputData.getString(KEY_ATTACHMENT_ID) + if (requestedAttachmentId == null && !ConnectMessagingPreferences.isAutomaticDownloadEnabled(applicationContext)) { + return Result.success() + } + val downloader = + ConnectMessagingAttachmentDownloader( + applicationContext, + PersonalIdAttachmentFetcher(user.userId, user.password), + ) + val isEligible = eligibilityFromInput() + val result = + withContext(DispatcherProvider.io()) { + ConnectMessagingAttachmentDownloader.passLock.withLock { + downloader.runPass(isEligible) { isStopped } + } + } + if (result != ConnectMessagingAttachmentDownloader.PassResult.RETRY_LATER) { + return Result.success() + } + if (requestedAttachmentId != null && !inputData.getBoolean(KEY_BACKGROUND_RETRY, false)) { + ConnectMessagingAttachmentDownloadScheduler.retryRequestedDownloadLater(applicationContext, requestedAttachmentId) + return Result.success() + } + return Result.retry() + } + + private fun eligibilityFromInput(): (ConnectMessagingAttachmentRecord) -> Boolean { + val requestedAttachmentId = inputData.getString(KEY_ATTACHMENT_ID) + if (requestedAttachmentId != null) { + return { it.attachmentId == requestedAttachmentId } + } + val sizeRange = inputData.getLong(KEY_MIN_SIZE_BYTES, 0L)..inputData.getLong(KEY_MAX_SIZE_BYTES, Long.MAX_VALUE) + return { it.size in sizeRange } + } + + companion object { + const val KEY_MIN_SIZE_BYTES = "min_size_bytes" + const val KEY_MAX_SIZE_BYTES = "max_size_bytes" + const val KEY_ATTACHMENT_ID = "attachment_id" + const val KEY_BACKGROUND_RETRY = "background_retry" + } +} diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentItem.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentItem.kt new file mode 100644 index 0000000000..1fbceb2794 --- /dev/null +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentItem.kt @@ -0,0 +1,57 @@ +package org.commcare.fragments.connectMessaging + +import android.content.Context +import android.view.View +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState +import org.commcare.connect.database.ConnectMessagingAttachmentFileStore +import java.io.File + +data class ConnectMessageAttachmentItem( + val attachmentId: String, + val name: String, + val mimeType: String, + val sizeBytes: Long, + val state: ConnectMessagingAttachmentState, + val file: File?, +) { + val isImage: Boolean + get() = mimeType.startsWith("image/") + + val isAudio: Boolean + get() = mimeType.startsWith("audio/") + + companion object { + @JvmStatic + fun fromRecord( + context: Context, + record: ConnectMessagingAttachmentRecord, + ): ConnectMessageAttachmentItem { + val state = record.downloadState + return ConnectMessageAttachmentItem( + attachmentId = record.attachmentId, + name = record.name, + mimeType = record.type, + sizeBytes = record.size, + state = state, + file = + if (state == ConnectMessagingAttachmentState.AVAILABLE) { + ConnectMessagingAttachmentFileStore.fileFor(context, record.attachmentId) + } else { + null + }, + ) + } + } +} + +interface ConnectMessageAttachmentListener { + fun onMessageDownloadRequested(messageId: String) + + fun onAttachmentOpenRequested(attachment: ConnectMessageAttachmentItem) + + fun onImageOpenRequested( + attachment: ConnectMessageAttachmentItem, + imageView: View, + ) +} diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt new file mode 100644 index 0000000000..03074beb6b --- /dev/null +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt @@ -0,0 +1,206 @@ +package org.commcare.fragments.connectMessaging + +import android.text.format.Formatter +import android.util.LruCache +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.core.content.ContextCompat +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState +import org.commcare.dalvik.R +import org.commcare.dalvik.databinding.ViewConnectMessageAttachmentFileBinding +import org.commcare.dalvik.databinding.ViewConnectMessageAttachmentImageBinding +import org.commcare.dalvik.databinding.ViewConnectMessageAttachmentPendingBinding +import java.io.File + +class ConnectMessageAttachmentsBinder( + private val container: LinearLayout, + private val listener: ConnectMessageAttachmentListener, +) { + class Layout( + val fillsBubbleWidth: Boolean, + val contentWidth: Int, + ) + + private val context = container.context + private val inflater = LayoutInflater.from(context) + + fun bindPendingMessage( + messageId: String, + pendingState: ConnectMessagingAttachmentState, + ): Layout = bindTile(createPendingView(messageId, pendingState)) + + fun bindUnsupportedMessage(): Layout { + val binding = ViewConnectMessageAttachmentPendingBinding.inflate(inflater, container, false) + binding.flAction.visibility = View.GONE + binding.tvLabel.setText(R.string.connect_messaging_update_app_notice) + return bindTile(binding.root) + } + + private fun bindTile(tile: View): Layout { + container.removeAllViews() + container.visibility = View.VISIBLE + container.addView(tile) + return Layout(false, context.resources.getDimensionPixelSize(R.dimen.connect_message_pending_tile_width)) + } + + fun bind( + attachments: List, + maxMediaWidth: Int, + maxMediaHeight: Int, + ): Layout { + container.removeAllViews() + container.visibility = if (attachments.isEmpty()) View.GONE else View.VISIBLE + var fillsBubbleWidth = false + var contentWidth = 0 + attachments.forEachIndexed { index, attachment -> + val view: View + val width: Int + val imageSize = attachment.file?.takeIf { attachment.isImage }?.let { imageSize(attachment.attachmentId, it) } + when { + imageSize != null -> { + val pixelsPerDp = context.resources.displayMetrics.density + val minimumSize = context.resources.getDimensionPixelSize(R.dimen.connect_touch_target_min) + val fitted = + ConnectMessageMediaSizer.fitImage( + imageSize.width, + imageSize.height, + maxMediaWidth, + maxMediaHeight, + pixelsPerDp, + minimumSize, + ) + val opensViewer = + ConnectMessageMediaSizer.isLargeEnoughToOpen(imageSize.width, imageSize.height, pixelsPerDp, minimumSize) + view = createImageView(attachment, fitted, opensViewer) + width = fitted.width + } + + attachment.file != null && attachment.isAudio -> { + view = ConnectMessageAudioAttachmentView(context).apply { bind(attachment) } + width = maxMediaWidth + fillsBubbleWidth = true + } + + else -> { + view = createFileView(attachment) + width = maxMediaWidth + } + } + contentWidth = maxOf(contentWidth, width) + container.addView(view, layoutParamsFor(view, index)) + } + return Layout(fillsBubbleWidth, contentWidth) + } + + private fun layoutParamsFor( + view: View, + index: Int, + ): LinearLayout.LayoutParams { + val params = + view.layoutParams?.let { LinearLayout.LayoutParams(it) } + ?: LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) + params.gravity = Gravity.CENTER_HORIZONTAL + if (view is ConnectMessageAudioAttachmentView) { + params.width = ViewGroup.LayoutParams.MATCH_PARENT + } + if (index > 0) { + params.topMargin = context.resources.getDimensionPixelSize(R.dimen.connect_space_sm) + } + return params + } + + private fun createImageView( + attachment: ConnectMessageAttachmentItem, + size: ConnectMessageMediaSizer.Size, + opensViewer: Boolean, + ): View { + val binding = ViewConnectMessageAttachmentImageBinding.inflate(inflater, container, false) + val imageView = binding.root + imageView.layoutParams = ViewGroup.LayoutParams(size.width, size.height) + imageView.contentDescription = attachment.name + imageView.transitionName = ConnectMessageImageViewerFragment.transitionNameFor(attachment.attachmentId) + Glide + .with(imageView) + .load(attachment.file) + .override(size.width, size.height) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(imageView) + if (opensViewer) { + imageView.setOnClickListener { listener.onImageOpenRequested(attachment, imageView) } + } + return imageView + } + + private fun createFileView(attachment: ConnectMessageAttachmentItem): View { + val binding = ViewConnectMessageAttachmentFileBinding.inflate(inflater, container, false) + binding.tvFileName.text = attachment.name + binding.tvFileSize.text = Formatter.formatShortFileSize(context, attachment.sizeBytes) + binding.root.setOnClickListener { listener.onAttachmentOpenRequested(attachment) } + return binding.root + } + + private fun createPendingView( + messageId: String, + pendingState: ConnectMessagingAttachmentState, + ): View { + val binding = ViewConnectMessageAttachmentPendingBinding.inflate(inflater, container, false) + binding.tvLabel.setTextColor(ContextCompat.getColor(context, R.color.white)) + binding.tvSecondaryLabel.visibility = View.GONE + binding.progress.visibility = View.GONE + binding.ivAction.visibility = View.VISIBLE + val canRequestDownload: Boolean + when (pendingState) { + ConnectMessagingAttachmentState.REQUESTED, ConnectMessagingAttachmentState.DOWNLOADING -> { + binding.ivAction.visibility = View.GONE + binding.progress.visibility = View.VISIBLE + binding.tvLabel.setText(R.string.connect_messaging_attachment_downloading) + canRequestDownload = false + } + + ConnectMessagingAttachmentState.FAILED -> { + binding.ivAction.setImageResource(R.drawable.ic_connect_message_retry) + binding.tvLabel.setText(R.string.connect_messaging_attachment_download_failed) + binding.tvLabel.setTextColor(ContextCompat.getColor(context, R.color.connect_red_light)) + binding.tvSecondaryLabel.visibility = View.VISIBLE + canRequestDownload = true + } + + ConnectMessagingAttachmentState.EXPIRED -> { + binding.ivAction.visibility = View.GONE + binding.tvLabel.setText(R.string.connect_messaging_attachment_expired) + canRequestDownload = false + } + + ConnectMessagingAttachmentState.WAITING, + ConnectMessagingAttachmentState.QUEUED, + ConnectMessagingAttachmentState.AVAILABLE, + -> { + binding.ivAction.setImageResource(R.drawable.ic_connect_message_download) + binding.tvLabel.setText(R.string.connect_messaging_attachment_download) + canRequestDownload = pendingState != ConnectMessagingAttachmentState.AVAILABLE + } + } + if (canRequestDownload) { + binding.root.setOnClickListener { listener.onMessageDownloadRequested(messageId) } + } + return binding.root + } + + private fun imageSize( + attachmentId: String, + file: File, + ): ConnectMessageMediaSizer.Size? { + imageSizes.get(attachmentId)?.let { return it } + return ConnectMessageImageDimensions.read(file)?.also { imageSizes.put(attachmentId, it) } + } + + private companion object { + const val IMAGE_SIZE_CACHE_ENTRIES = 200 + val imageSizes = LruCache(IMAGE_SIZE_CACHE_ENTRIES) + } +} diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAudioAttachmentView.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAudioAttachmentView.kt new file mode 100644 index 0000000000..0b939c72f3 --- /dev/null +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAudioAttachmentView.kt @@ -0,0 +1,113 @@ +package org.commcare.fragments.connectMessaging + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.LayoutInflater +import android.widget.LinearLayout +import android.widget.Toast +import com.google.android.material.slider.Slider +import org.commcare.dalvik.R +import org.commcare.dalvik.databinding.ViewConnectMessageAttachmentAudioBinding +import java.io.File +import java.io.IOException +import java.util.Locale +import java.util.concurrent.TimeUnit + +class ConnectMessageAudioAttachmentView + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + ) : LinearLayout(context, attrs), + ConnectMessageAudioPlayer.Listener { + private val binding = ViewConnectMessageAttachmentAudioBinding.inflate(LayoutInflater.from(context), this) + private var attachmentId: String? = null + private var file: File? = null + private var isDragging = false + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + val inset = resources.getDimensionPixelSize(R.dimen.connect_message_audio_inset) + setPadding(inset, inset, inset, inset) + binding.btnPlay.setOnClickListener { togglePlayback() } + binding.slider.addOnChangeListener { _, value, fromUser -> + val id = attachmentId + if (fromUser && id != null) { + ConnectMessageAudioPlayer.seekTo(id, value.toInt()) + } + } + binding.slider.addOnSliderTouchListener( + object : Slider.OnSliderTouchListener { + override fun onStartTrackingTouch(slider: Slider) { + isDragging = true + } + + override fun onStopTrackingTouch(slider: Slider) { + isDragging = false + refresh() + } + }, + ) + } + + fun bind(attachment: ConnectMessageAttachmentItem) { + attachmentId = attachment.attachmentId + file = attachment.file + refresh() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + ConnectMessageAudioPlayer.addListener(this) + refresh() + } + + override fun onDetachedFromWindow() { + ConnectMessageAudioPlayer.removeListener(this) + super.onDetachedFromWindow() + } + + override fun onAudioPlaybackChanged() { + refresh() + } + + private fun togglePlayback() { + val id = attachmentId ?: return + val audioFile = file ?: return + try { + ConnectMessageAudioPlayer.togglePlayback(id, audioFile) + } catch (e: IOException) { + Toast.makeText(context, R.string.connect_messaging_audio_play_failed, Toast.LENGTH_SHORT).show() + } + } + + private fun refresh() { + val id = attachmentId ?: return + val audioFile = file ?: return + val playing = ConnectMessageAudioPlayer.isPlaying(id) + binding.ivPlayIcon.setImageResource( + if (playing) R.drawable.ic_connect_message_pause else R.drawable.ic_connect_message_play, + ) + binding.btnPlay.contentDescription = + context.getString( + if (playing) R.string.connect_messaging_audio_pause else R.string.connect_messaging_audio_play, + ) + + val durationMs = ConnectMessageAudioPlayer.durationMs(id, audioFile) + val positionMs = ConnectMessageAudioPlayer.positionMs(id).coerceIn(0, maxOf(durationMs, 0)) + binding.slider.isEnabled = durationMs > 0 + if (!isDragging) { + binding.slider.valueTo = maxOf(durationMs, 1).toFloat() + binding.slider.value = positionMs.toFloat().coerceAtMost(binding.slider.valueTo) + } + val shownMs = if (ConnectMessageAudioPlayer.hasStarted(id)) positionMs else durationMs + binding.tvTime.text = formatTime(shownMs) + } + + private fun formatTime(milliseconds: Int): String { + val totalSeconds = TimeUnit.MILLISECONDS.toSeconds(milliseconds.toLong()) + return String.format(Locale.getDefault(), "%d:%02d", totalSeconds / 60, totalSeconds % 60) + } + } diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAudioPlayer.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAudioPlayer.kt new file mode 100644 index 0000000000..93047cd0b4 --- /dev/null +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAudioPlayer.kt @@ -0,0 +1,151 @@ +package org.commcare.fragments.connectMessaging + +import android.media.MediaMetadataRetriever +import android.media.MediaPlayer +import android.os.Handler +import android.os.Looper +import java.io.File +import java.io.IOException + +object ConnectMessageAudioPlayer { + fun interface Listener { + fun onAudioPlaybackChanged() + } + + private const val PROGRESS_INTERVAL_MS = 200L + + private var mediaPlayer: MediaPlayer? = null + private var loadedAttachmentId: String? = null + private val pendingPositionsMs = HashMap() + private val durationsMs = HashMap() + private val listeners = LinkedHashSet() + private val handler = Handler(Looper.getMainLooper()) + private val progressTick = + object : Runnable { + override fun run() { + notifyListeners() + scheduleProgressTick() + } + } + + fun addListener(listener: Listener) { + listeners.add(listener) + } + + fun removeListener(listener: Listener) { + listeners.remove(listener) + } + + fun isPlaying(attachmentId: String): Boolean = isLoaded(attachmentId) && mediaPlayer?.isPlaying == true + + fun hasStarted(attachmentId: String): Boolean = isLoaded(attachmentId) || (pendingPositionsMs[attachmentId] ?: 0) > 0 + + fun positionMs(attachmentId: String): Int { + val player = mediaPlayer + return if (player != null && attachmentId == loadedAttachmentId) { + player.currentPosition + } else { + pendingPositionsMs[attachmentId] ?: 0 + } + } + + fun durationMs( + attachmentId: String, + file: File, + ): Int = durationsMs.getOrPut(attachmentId) { readDurationMs(file) } + + @Throws(IOException::class) + fun togglePlayback( + attachmentId: String, + file: File, + ) { + val player = mediaPlayer + if (player != null && attachmentId == loadedAttachmentId) { + if (player.isPlaying) { + player.pause() + } else { + player.start() + } + onPlaybackStateChanged() + return + } + + stop() + val newPlayer = MediaPlayer() + try { + newPlayer.setDataSource(file.path) + newPlayer.prepare() + } catch (e: IOException) { + newPlayer.release() + throw e + } + newPlayer.setOnCompletionListener { finishPlayback(attachmentId) } + durationsMs[attachmentId] = newPlayer.duration + pendingPositionsMs.remove(attachmentId)?.let { newPlayer.seekTo(it) } + newPlayer.start() + mediaPlayer = newPlayer + loadedAttachmentId = attachmentId + onPlaybackStateChanged() + } + + fun seekTo( + attachmentId: String, + positionMs: Int, + ) { + val player = mediaPlayer + if (player != null && attachmentId == loadedAttachmentId) { + player.seekTo(positionMs) + } else { + pendingPositionsMs[attachmentId] = positionMs + } + notifyListeners() + } + + fun stop() { + mediaPlayer?.release() + mediaPlayer = null + loadedAttachmentId = null + pendingPositionsMs.clear() + onPlaybackStateChanged() + } + + private fun isLoaded(attachmentId: String): Boolean = mediaPlayer != null && attachmentId == loadedAttachmentId + + private fun finishPlayback(attachmentId: String) { + mediaPlayer?.release() + mediaPlayer = null + loadedAttachmentId = null + pendingPositionsMs.remove(attachmentId) + onPlaybackStateChanged() + } + + private fun onPlaybackStateChanged() { + handler.removeCallbacks(progressTick) + notifyListeners() + scheduleProgressTick() + } + + private fun scheduleProgressTick() { + if (mediaPlayer?.isPlaying == true) { + handler.postDelayed(progressTick, PROGRESS_INTERVAL_MS) + } + } + + private fun notifyListeners() { + for (listener in listeners.toList()) { + listener.onAudioPlaybackChanged() + } + } + + private fun readDurationMs(file: File): Int { + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(file.path) + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toIntOrNull() ?: 0 + } catch (e: RuntimeException) { + 0 + } finally { + retriever.release() + } + } +} diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChannelListFragment.java b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChannelListFragment.java index 1f35cba65e..f9199d6b08 100644 --- a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChannelListFragment.java +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChannelListFragment.java @@ -6,12 +6,17 @@ import android.content.IntentFilter; import android.os.Bundle; import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.view.MenuProvider; import androidx.fragment.app.Fragment; +import androidx.lifecycle.Lifecycle; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.navigation.NavDirections; import androidx.navigation.Navigation; @@ -35,6 +40,7 @@ public class ConnectMessageChannelListFragment extends Fragment { public static boolean isActive; + private static final int MENU_SETTINGS = Menu.FIRST; private FragmentChannelListBinding binding; private ChannelAdapter channelAdapter; @@ -69,6 +75,7 @@ public View onCreateView( @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); + setupMenuItems(); String channelId = getArguments() != null ? getArguments().getString(CHANNEL_ID) : null; if (channelId != null) { getArguments().remove(CHANNEL_ID); @@ -118,6 +125,30 @@ public void onReceive(Context context, Intent intent) { } }; + private void setupMenuItems() { + requireActivity().addMenuProvider( + new MenuProvider() { + @Override + public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { + menu.add(Menu.NONE, MENU_SETTINGS, Menu.NONE, R.string.menu_settings); + } + + @Override + public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { + if (menuItem.getItemId() != MENU_SETTINGS) { + return false; + } + Navigation.findNavController(requireView()).navigate( + ConnectMessageChannelListFragmentDirections + .actionChannelListFragmentToConnectMessagingSettingsFragment()); + return true; + } + }, + getViewLifecycleOwner(), + Lifecycle.State.RESUMED + ); + } + private void selectChannel(ConnectMessagingChannelRecord channel) { Navigation.findNavController(requireView()) .navigate(getConnectMessageFragmentDirection(channel)); diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChatData.java b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChatData.java index 80413bc814..68cfd67e5c 100644 --- a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChatData.java +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChatData.java @@ -1,6 +1,12 @@ package org.commcare.fragments.connectMessaging; +import androidx.annotation.Nullable; + +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState; + +import java.util.Collections; import java.util.Date; +import java.util.List; public class ConnectMessageChatData { private String messageId; @@ -10,15 +16,50 @@ public class ConnectMessageChatData { private Date timestamp; private int countUnread; private boolean isMessageRead; + private final List attachments; + private final boolean unsupportedVersion; + @Nullable + private final ConnectMessagingAttachmentState pendingDownloadState; // Constructor with parameters public ConnectMessageChatData(String messageId, int type, String message, String userName, Date timestamp, boolean isMessageRead) { + this(messageId, type, message, userName, timestamp, isMessageRead, Collections.emptyList(), false, null); + } + + public ConnectMessageChatData(String messageId, int type, String message, String userName, Date timestamp, + boolean isMessageRead, List attachments, + boolean unsupportedVersion, + @Nullable ConnectMessagingAttachmentState pendingDownloadState) { this.messageId = messageId; this.type = type; this.message = message; this.userName = userName; this.timestamp = timestamp; this.isMessageRead = isMessageRead; + this.attachments = attachments; + this.unsupportedVersion = unsupportedVersion; + this.pendingDownloadState = pendingDownloadState; + } + + public List getAttachments() { + return attachments; + } + + public boolean isUnsupportedVersion() { + return unsupportedVersion; + } + + @Nullable + public ConnectMessagingAttachmentState getPendingDownloadState() { + return pendingDownloadState; + } + + public boolean isAwaitingDownload() { + return pendingDownloadState != null; + } + + public boolean hasRichContent() { + return !attachments.isEmpty() || unsupportedVersion || isAwaitingDownload(); } // Getters and setters diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageFragment.java b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageFragment.java index 61e2366421..c3926f6a4b 100644 --- a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageFragment.java +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageFragment.java @@ -1,5 +1,6 @@ package org.commcare.fragments.connectMessaging; +import android.content.ActivityNotFoundException; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; @@ -21,26 +22,38 @@ import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.core.view.MenuProvider; +import androidx.core.view.OneShotPreDrawListener; import androidx.fragment.app.Fragment; import androidx.lifecycle.Lifecycle; import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import androidx.navigation.Navigation; +import androidx.navigation.fragment.FragmentNavigator; import androidx.recyclerview.widget.RecyclerView; +import androidx.transition.Fade; import org.commcare.activities.connect.ConnectMessagingActivity; import org.commcare.adapters.ConnectMessageAdapter; +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord; +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState; 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.ConnectMessagingAttachmentDatabaseHelper; import org.commcare.connect.database.ConnectMessagingDatabaseHelper; +import org.commcare.connect.messaging.ConnectMessagingAttachmentDownloadScheduler; +import org.commcare.connect.messaging.ConnectMessagingMessagePackage; import org.commcare.dalvik.R; import org.commcare.dalvik.databinding.FragmentConnectMessageBinding; import org.commcare.google.services.analytics.AnalyticsParamValue; import org.commcare.google.services.analytics.FirebaseAnalyticsUtil; +import org.commcare.utils.FileUtil; import org.commcare.utils.FirebaseMessagingUtil; import org.commcare.views.dialogs.CustomThreeButtonAlertDialog; +import java.io.File; import java.util.ArrayList; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; @@ -52,7 +65,7 @@ import static org.commcare.google.services.analytics.AnalyticsParamValue.CCC_MESSAGING_EVENT_TYPE_CONFIRM_UNSUBSCRIBE; import static org.commcare.google.services.analytics.AnalyticsParamValue.CCC_MESSAGING_EVENT_TYPE_CONSENT_API_RESULT; -public class ConnectMessageFragment extends Fragment { +public class ConnectMessageFragment extends Fragment implements ConnectMessageAttachmentListener { private static String activeChannel; private String channelId; private FragmentConnectMessageBinding binding; @@ -71,6 +84,13 @@ public class ConnectMessageFragment extends Fragment { private static final int MENU_UNSUBSCRIBE = Menu.FIRST; private static final int MENU_RESUBSCRIBE = Menu.FIRST + 1; + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setExitTransition(new Fade().setDuration(ConnectMessageImageViewerFragment.TRANSITION_DURATION_MS)); + setReenterTransition(null); + } + @Override public View onCreateView( @NonNull LayoutInflater inflater, @@ -107,6 +127,13 @@ public void run() { return binding.getRoot(); } + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + postponeEnterTransition(); + OneShotPreDrawListener.add(view, this::startPostponedEnterTransition); + } + private void setupMenuItems() { requireActivity().addMenuProvider( new MenuProvider() { @@ -185,6 +212,7 @@ public void onPause() { // Stop the periodic API calls when the screen is not active handler.removeCallbacks(apiCallRunnable); + ConnectMessageAudioPlayer.INSTANCE.stop(); } @Nullable @@ -266,7 +294,7 @@ private void sendMessage() { binding.etMessage.setText(""); ConnectMessagingDatabaseHelper.storeMessagingMessage(requireContext(), message); - ConnectMessageChatData chat = fromMessage(message); + ConnectMessageChatData chat = fromMessage(message, Collections.emptyMap()); adapter.addMessage(chat); scrollToLatestMessage(); @@ -287,7 +315,7 @@ private void sendMessage() { private void setChatAdapter() { List messages = new ArrayList<>(); - adapter = new ConnectMessageAdapter(messages); + adapter = new ConnectMessageAdapter(messages, this); binding.rvChat.setAdapter(adapter); refreshUi(); } @@ -297,10 +325,14 @@ public void refreshUi() { if (context != null) { List messages = ConnectMessagingDatabaseHelper .getMessagingMessagesForChannel(channelId); + Map> attachmentsByMessageId = + ConnectMessagingAttachmentDatabaseHelper.getAttachmentsByMessageId(channelId); List chats = new ArrayList<>(); + boolean hasUnsupportedMessages = false; for (ConnectMessagingMessageRecord message : messages) { - chats.add(fromMessage(message)); + chats.add(fromMessage(message, attachmentsByMessageId)); + hasUnsupportedMessages |= message.isUnsupportedVersion(); if (!message.getUserViewed()) { message.setUserViewed(true); @@ -308,6 +340,8 @@ public void refreshUi() { } } + binding.tvUpdateBanner.setVisibility(hasUnsupportedMessages ? View.VISIBLE : View.GONE); + boolean initialLoad = adapter.getItemCount() == 0; boolean wasNearBottom = isNearBottom(); boolean hasNewMessages = adapter.updateData(chats); @@ -328,17 +362,78 @@ private boolean isNearBottom() { return distanceFromBottom <= getResources().getDimensionPixelSize(R.dimen.dp60); } - private ConnectMessageChatData fromMessage(ConnectMessagingMessageRecord message) { + private ConnectMessageChatData fromMessage(ConnectMessagingMessageRecord message, + Map> attachmentsByMessageId) { int viewType = message.getIsOutgoing() ? ConnectMessageAdapter.RIGHTVIEW : ConnectMessageAdapter.LEFTVIEW; + List attachments = new ArrayList<>(); + ConnectMessagingAttachmentState pendingDownloadState = null; + List records = message.isRich() + ? attachmentsByMessageId.get(message.getMessageId()) + : null; + if (records != null) { + ConnectMessagingAttachmentState packageState = + ConnectMessagingMessagePackage.stateOfAttachments(records); + if (packageState == ConnectMessagingAttachmentState.AVAILABLE) { + for (ConnectMessagingAttachmentRecord record : records) { + attachments.add(ConnectMessageAttachmentItem.fromRecord(requireContext(), record)); + } + } else { + pendingDownloadState = packageState; + } + } return new ConnectMessageChatData(message.getMessageId(), viewType, - message.getMessage(), + message.getDisplayText(), message.getIsOutgoing() ? getString(R.string.connect_message_you) : getString(R.string.connect_message_them), message.getTimeStamp(), - message.getConfirmed()); + message.getConfirmed(), + attachments, + message.isUnsupportedVersion(), + pendingDownloadState); + } + + @Override + public void onMessageDownloadRequested(@NonNull String messageId) { + List requeuedAttachmentIds = ConnectMessagingAttachmentDatabaseHelper.requeueMessage(messageId); + for (String attachmentId : requeuedAttachmentIds) { + ConnectMessagingAttachmentDownloadScheduler.downloadNow(requireContext(), attachmentId); + } + if (!requeuedAttachmentIds.isEmpty()) { + refreshUi(); + } + } + + @Override + public void onAttachmentOpenRequested(@NonNull ConnectMessageAttachmentItem attachment) { + File file = attachment.getFile(); + if (file == null) { + return; + } + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(FileUtil.getUriForExternalFile(requireContext(), file), attachment.getMimeType()); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + try { + startActivity(intent); + } catch (ActivityNotFoundException e) { + Toast.makeText(requireContext(), R.string.connect_messaging_attachment_open_failed, + Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onImageOpenRequested(@NonNull ConnectMessageAttachmentItem attachment, @NonNull View imageView) { + if (attachment.getFile() == null) { + return; + } + FragmentNavigator.Extras sharedImage = new FragmentNavigator.Extras.Builder() + .addSharedElement(imageView, imageView.getTransitionName()) + .build(); + Navigation.findNavController(requireView()).navigate(ConnectMessageFragmentDirections + .actionConnectMessageFragmentToConnectMessageImageViewerFragment(attachment.getAttachmentId()), + sharedImage); } private void scrollToLatestMessage() { diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageDimensions.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageDimensions.kt new file mode 100644 index 0000000000..197f31ce56 --- /dev/null +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageDimensions.kt @@ -0,0 +1,30 @@ +package org.commcare.fragments.connectMessaging + +import android.graphics.BitmapFactory +import androidx.exifinterface.media.ExifInterface +import org.javarosa.core.services.Logger +import java.io.File +import java.io.IOException + +object ConnectMessageImageDimensions { + fun read(file: File): ConnectMessageMediaSizer.Size? { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.path, options) + if (options.outWidth <= 0 || options.outHeight <= 0) { + return null + } + return if (isStoredSideways(file)) { + ConnectMessageMediaSizer.Size(options.outHeight, options.outWidth) + } else { + ConnectMessageMediaSizer.Size(options.outWidth, options.outHeight) + } + } + + private fun isStoredSideways(file: File): Boolean = + try { + ExifInterface(file).rotationDegrees % 180 != 0 + } catch (e: IOException) { + Logger.exception("Could not read EXIF orientation of ${file.name}", e) + false + } +} diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerFragment.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerFragment.kt new file mode 100644 index 0000000000..e0c51ce7dd --- /dev/null +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerFragment.kt @@ -0,0 +1,253 @@ +package org.commcare.fragments.connectMessaging + +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.text.format.DateUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.doOnLayout +import androidx.fragment.app.Fragment +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController +import androidx.transition.Fade +import androidx.transition.Transition +import androidx.transition.TransitionListenerAdapter +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.davemorrissey.labs.subscaleview.ImageSource +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import com.google.android.material.transition.MaterialContainerTransform +import org.commcare.connect.database.ConnectMessagingAttachmentDatabaseHelper +import org.commcare.connect.database.ConnectMessagingAttachmentFileStore +import org.commcare.dalvik.R +import org.commcare.dalvik.databinding.FragmentConnectMessageImageViewerBinding +import org.commcare.utils.MarkupUtil +import java.io.File +import kotlin.math.roundToInt + +class ConnectMessageImageViewerFragment : Fragment() { + private var binding: FragmentConnectMessageImageViewerBinding? = null + private var title: String? = null + private var hasCaption = false + private var isImageSeated = false + private var isZoomableImageReady = false + private val returnListener = + NavController.OnDestinationChangedListener { _, destination, _ -> + if (destination.id != R.id.connectMessageImageViewerFragment) { + showTransitionImage() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + sharedElementEnterTransition = + containerTransform().addListener( + object : TransitionListenerAdapter() { + override fun onTransitionEnd(transition: Transition) { + seatImage() + } + }, + ) + returnTransition = + Fade().apply { + addTarget(R.id.vBackdrop) + addTarget(R.id.tvCaption) + duration = TRANSITION_DURATION_MS + } + sharedElementReturnTransition = containerTransform() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val viewerBinding = FragmentConnectMessageImageViewerBinding.inflate(inflater, container, false) + binding = viewerBinding + return viewerBinding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + val attachmentId = ConnectMessageImageViewerFragmentArgs.fromBundle(requireArguments()).attachmentId + val attachment = ConnectMessagingAttachmentDatabaseHelper.getAttachment(attachmentId) + val message = attachment?.let { ConnectMessagingAttachmentDatabaseHelper.getMessage(it.messageId) } + val file = ConnectMessagingAttachmentFileStore.fileFor(requireContext(), attachmentId) + val imageSize = if (file.isFile) ConnectMessageImageDimensions.read(file) else null + val viewerBinding = binding + if (attachment == null || message == null || imageSize == null || viewerBinding == null) { + findNavController().popBackStack() + return + } + + isImageSeated = false + isZoomableImageReady = false + viewerBinding.ivTransitionImage.transitionName = transitionNameFor(attachmentId) + postponeEnterTransition() + view.postDelayed({ startPostponedEnterTransition() }, MAX_TRANSITION_WAIT_MS) + view.postDelayed({ seatImage() }, SEAT_FALLBACK_MS) + findNavController().addOnDestinationChangedListener(returnListener) + title = + DateUtils.formatDateTime( + requireContext(), + message.timeStamp.time, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_MONTH, + ) + bindCaption(message.displayText) + bindImage(viewerBinding, file, imageSize, attachment.name) + } + + override fun onResume() { + super.onResume() + title?.let { requireActivity().title = it } + } + + override fun onDestroyView() { + findNavController().removeOnDestinationChangedListener(returnListener) + binding = null + super.onDestroyView() + } + + private fun bindCaption(text: String) { + val caption = binding?.tvCaption ?: return + hasCaption = text.isNotBlank() + if (hasCaption) { + MarkupUtil.setMarkdown(caption, SpannableStringBuilder(text), SpannableStringBuilder()) + } + } + + private fun bindImage( + viewerBinding: FragmentConnectMessageImageViewerBinding, + file: File, + imageSize: ConnectMessageMediaSizer.Size, + name: String, + ) { + val zoomableImage = viewerBinding.ivImage + zoomableImage.contentDescription = name + zoomableImage.orientation = SubsamplingScaleImageView.ORIENTATION_USE_EXIF + zoomableImage.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM) + zoomableImage.setOnImageEventListener( + object : SubsamplingScaleImageView.DefaultOnImageEventListener() { + override fun onReady() { + isZoomableImageReady = true + showZoomableImageIfSeated() + } + }, + ) + zoomableImage.doOnLayout { + val limits = + ConnectMessageImageZoom.limitsFor( + imageSize.width, + imageSize.height, + it.width, + it.height, + resources.displayMetrics.density, + ) + zoomableImage.minScale = limits.minScale + zoomableImage.maxScale = limits.maxScale + zoomableImage.setDoubleTapZoomScale(limits.doubleTapScale) + zoomableImage.setImage(ImageSource.uri(Uri.fromFile(file))) + bindTransitionImage( + viewerBinding, + file, + (imageSize.width * limits.minScale).roundToInt(), + (imageSize.height * limits.minScale).roundToInt(), + ) + } + } + + private fun bindTransitionImage( + viewerBinding: FragmentConnectMessageImageViewerBinding, + file: File, + seatedWidth: Int, + seatedHeight: Int, + ) { + val transitionImage = viewerBinding.ivTransitionImage + transitionImage.layoutParams = + transitionImage.layoutParams.apply { + width = seatedWidth + height = seatedHeight + } + Glide + .with(transitionImage) + .load(file) + .override(seatedWidth, seatedHeight) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .dontAnimate() + .listener( + object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean, + ): Boolean { + startPostponedEnterTransition() + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean, + ): Boolean { + startPostponedEnterTransition() + return false + } + }, + ).into(transitionImage) + } + + private fun seatImage() { + val viewerBinding = binding ?: return + if (isImageSeated) { + return + } + isImageSeated = true + viewerBinding.tvCaption.visibility = if (hasCaption) View.VISIBLE else View.GONE + showZoomableImageIfSeated() + } + + private fun showZoomableImageIfSeated() { + val viewerBinding = binding ?: return + if (isImageSeated && isZoomableImageReady) { + viewerBinding.ivImage.alpha = 1f + viewerBinding.ivTransitionImage.visibility = View.INVISIBLE + } + } + + private fun showTransitionImage() { + val viewerBinding = binding ?: return + viewerBinding.ivTransitionImage.visibility = View.VISIBLE + viewerBinding.ivImage.alpha = 0f + } + + private fun containerTransform() = + MaterialContainerTransform().apply { + drawingViewId = R.id.nav_host_fragment_connect_messaging + scrimColor = Color.TRANSPARENT + duration = TRANSITION_DURATION_MS + } + + companion object { + private const val MAX_TRANSITION_WAIT_MS = 300L + const val TRANSITION_DURATION_MS = 300L + private const val SEAT_FALLBACK_MS = MAX_TRANSITION_WAIT_MS + TRANSITION_DURATION_MS + 400L + + @JvmStatic + fun transitionNameFor(attachmentId: String) = "connect_message_image_$attachmentId" + } +} diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageZoom.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageZoom.kt new file mode 100644 index 0000000000..00ab1ffd1d --- /dev/null +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageZoom.kt @@ -0,0 +1,31 @@ +package org.commcare.fragments.connectMessaging + +import kotlin.math.max +import kotlin.math.min + +object ConnectMessageImageZoom { + const val ZOOM_FACTOR = 2f + + data class Limits( + val minScale: Float, + val maxScale: Float, + val doubleTapScale: Float, + ) + + fun limitsFor( + imageWidth: Int, + imageHeight: Int, + viewWidth: Int, + viewHeight: Int, + pixelsPerDp: Float, + ): Limits { + require(imageWidth > 0 && imageHeight > 0 && viewWidth > 0 && viewHeight > 0) { + "Sizes must be positive, got image ${imageWidth}x$imageHeight in view ${viewWidth}x$viewHeight" + } + val fitScale = min(viewWidth.toFloat() / imageWidth, viewHeight.toFloat() / imageHeight) + val minScale = min(fitScale, pixelsPerDp) + val maxScale = max(ZOOM_FACTOR, ZOOM_FACTOR * minScale) + val doubleTapScale = min(maxScale, max(1f, ZOOM_FACTOR * minScale)) + return Limits(minScale, maxScale, doubleTapScale) + } +} diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizer.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizer.kt new file mode 100644 index 0000000000..fb2092bdb5 --- /dev/null +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizer.kt @@ -0,0 +1,43 @@ +package org.commcare.fragments.connectMessaging + +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +object ConnectMessageMediaSizer { + const val MAX_HEIGHT_FRACTION_OF_WINDOW = 0.4f + + data class Size( + val width: Int, + val height: Int, + ) + + fun fitImage( + intrinsicWidth: Int, + intrinsicHeight: Int, + maxWidth: Int, + maxHeight: Int, + pixelsPerDp: Float, + minimumSize: Int, + ): Size { + require(intrinsicWidth > 0 && intrinsicHeight > 0) { + "Image dimensions must be positive, got ${intrinsicWidth}x$intrinsicHeight" + } + val aspectRatio = intrinsicWidth.toFloat() / intrinsicHeight + val widthLimit = min(maxWidth.toFloat(), maxHeight * aspectRatio) + var width = min(widthLimit, intrinsicWidth * pixelsPerDp) + val shorterSide = min(width, width / aspectRatio) + if (shorterSide < minimumSize) { + width = min(widthLimit, width * minimumSize / shorterSide) + } + val height = width / aspectRatio + return Size(max(1, width.roundToInt()), max(1, height.roundToInt())) + } + + fun isLargeEnoughToOpen( + intrinsicWidth: Int, + intrinsicHeight: Int, + pixelsPerDp: Float, + minimumSize: Int, + ): Boolean = intrinsicWidth * pixelsPerDp >= minimumSize && intrinsicHeight * pixelsPerDp >= minimumSize +} diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessagingSettingsFragment.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessagingSettingsFragment.kt new file mode 100644 index 0000000000..26d88d22dd --- /dev/null +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessagingSettingsFragment.kt @@ -0,0 +1,62 @@ +package org.commcare.fragments.connectMessaging + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import org.commcare.connect.messaging.ConnectMessagingAttachmentDownloadScheduler +import org.commcare.dalvik.R +import org.commcare.dalvik.databinding.FragmentConnectMessagingSettingsBinding +import org.commcare.preferences.ConnectMessagingPreferences +import org.commcare.preferences.ConnectMessagingPreferences.AttachmentAutoDownload + +class ConnectMessagingSettingsFragment : Fragment() { + private var binding: FragmentConnectMessagingSettingsBinding? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val settingsBinding = FragmentConnectMessagingSettingsBinding.inflate(inflater, container, false) + binding = settingsBinding + + settingsBinding.rgAutoDownload.check( + when (ConnectMessagingPreferences.getAttachmentAutoDownload(requireContext())) { + AttachmentAutoDownload.ANY_NETWORK -> R.id.rbAnyNetwork + AttachmentAutoDownload.LARGE_ON_WIFI_ONLY -> R.id.rbLargeOnWifiOnly + AttachmentAutoDownload.MANUAL_ONLY -> R.id.rbManualOnly + }, + ) + settingsBinding.rgAutoDownload.setOnCheckedChangeListener { _, checkedId -> + onAutoDownloadSelected( + when (checkedId) { + R.id.rbLargeOnWifiOnly -> AttachmentAutoDownload.LARGE_ON_WIFI_ONLY + R.id.rbManualOnly -> AttachmentAutoDownload.MANUAL_ONLY + else -> AttachmentAutoDownload.ANY_NETWORK + }, + ) + } + return settingsBinding.root + } + + override fun onResume() { + super.onResume() + requireActivity().setTitle(R.string.connect_messaging_settings_title) + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + private fun onAutoDownloadSelected(selected: AttachmentAutoDownload) { + val context = requireContext() + if (selected == ConnectMessagingPreferences.getAttachmentAutoDownload(context)) { + return + } + ConnectMessagingPreferences.setAttachmentAutoDownload(context, selected) + ConnectMessagingAttachmentDownloadScheduler.applyAutoDownloadSetting(context) + } +} diff --git a/app/src/org/commcare/models/database/connect/ConnectDatabaseSchemaManager.kt b/app/src/org/commcare/models/database/connect/ConnectDatabaseSchemaManager.kt index 7c72914fa3..3ce2390e12 100644 --- a/app/src/org/commcare/models/database/connect/ConnectDatabaseSchemaManager.kt +++ b/app/src/org/commcare/models/database/connect/ConnectDatabaseSchemaManager.kt @@ -9,6 +9,7 @@ import org.commcare.android.database.connect.models.ConnectJobPaymentRecord import org.commcare.android.database.connect.models.ConnectJobRecord import org.commcare.android.database.connect.models.ConnectLearnModuleSummaryRecord import org.commcare.android.database.connect.models.ConnectLinkedAppRecord +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord import org.commcare.android.database.connect.models.ConnectPaymentUnitRecord @@ -54,8 +55,10 @@ object ConnectDatabaseSchemaManager { * V.27 - Added connect_tasks table (ConnectTaskRecord) for DB-persisted task tracking * V.28 - Added channel_name to ConnectMessagingChannelRecord * V.29 - Added module_id (server id) to ConnectLearnModuleSummaryRecord + * V.30 - Added rich message fields to ConnectMessagingMessageRecord and the + * connect_messaging_attachment table (ConnectMessagingAttachmentRecord) */ - const val DB_VERSION_CONNECT = 29 + const val DB_VERSION_CONNECT = 30 @JvmStatic fun initializeSchema(database: IDatabase) { @@ -73,6 +76,7 @@ object ConnectDatabaseSchemaManager { database.execSQL(TableBuilder(ConnectPaymentUnitRecord::class.java).tableCreateString) database.execSQL(TableBuilder(ConnectMessagingChannelRecord::class.java).tableCreateString) database.execSQL(TableBuilder(ConnectMessagingMessageRecord::class.java).tableCreateString) + database.execSQL(TableBuilder(ConnectMessagingAttachmentRecord::class.java).tableCreateString) database.execSQL(TableBuilder(ConnectJobDeliveryFlagRecord::class.java).tableCreateString) database.execSQL(TableBuilder(PersonalIdWorkHistory::class.java).tableCreateString) database.execSQL(TableBuilder(PushNotificationRecord::class.java).tableCreateString) diff --git a/app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java b/app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java index 2d858ed5c8..a5b168db50 100644 --- a/app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java +++ b/app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java @@ -29,9 +29,11 @@ import org.commcare.android.database.connect.models.ConnectLinkedAppRecordV3; import org.commcare.android.database.connect.models.ConnectLinkedAppRecordV8; import org.commcare.android.database.connect.models.ConnectLinkedAppRecordV9; +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord; import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord; import org.commcare.android.database.connect.models.ConnectMessagingChannelRecordV27; import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord; +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecordV29; import org.commcare.android.database.connect.models.ConnectPaymentUnitRecord; import org.commcare.android.database.connect.models.ConnectPaymentUnitRecordV21; import org.commcare.android.database.connect.models.ConnectReleaseToggleRecord; @@ -202,6 +204,11 @@ public void upgrade(IDatabase db, int oldVersion) { upgradeTwentyEightTwentyNine(db); oldVersion = 29; } + + if (oldVersion == 29) { + upgradeTwentyNineThirty(db); + oldVersion = 30; + } } private void upgradeOneTwo(IDatabase db) { @@ -1187,6 +1194,34 @@ private void upgradeTwentyEightTwentyNine(IDatabase db) { } } + private void upgradeTwentyNineThirty(IDatabase db) { + db.beginTransaction(); + try { + SqlStorage oldStorage = new SqlStorage<>( + ConnectMessagingMessageRecord.STORAGE_KEY, + ConnectMessagingMessageRecordV29.class, + new ConcreteAndroidDbHelper(c, db)); + + SqlStorage newStorage = new SqlStorage<>( + ConnectMessagingMessageRecord.STORAGE_KEY, + ConnectMessagingMessageRecord.class, + new ConcreteAndroidDbHelper(c, db)); + + for (ConnectMessagingMessageRecordV29 oldRecord : oldStorage) { + ConnectMessagingMessageRecord newRecord = ConnectMessagingMessageRecord.fromV29(oldRecord); + newRecord.setID(oldRecord.getID()); + newStorage.write(newRecord); + } + + addTableForNewModel(db, ConnectMessagingAttachmentRecord.STORAGE_KEY, + new ConnectMessagingAttachmentRecord()); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + private static void addTableForNewModel(IDatabase db, String storageKey, Persistable modelToAdd) { db.beginTransaction(); diff --git a/app/src/org/commcare/preferences/ConnectMessagingPreferences.kt b/app/src/org/commcare/preferences/ConnectMessagingPreferences.kt new file mode 100644 index 0000000000..aa1e5ebc0d --- /dev/null +++ b/app/src/org/commcare/preferences/ConnectMessagingPreferences.kt @@ -0,0 +1,47 @@ +package org.commcare.preferences + +import android.content.Context +import androidx.core.content.edit + +object ConnectMessagingPreferences { + private const val PREF_NAME = "connect_messaging_prefs" + private const val KEY_ATTACHMENT_AUTO_DOWNLOAD = "attachment_auto_download" + + enum class AttachmentAutoDownload( + val value: String, + ) { + ANY_NETWORK("any_network"), + LARGE_ON_WIFI_ONLY("large_on_wifi_only"), + MANUAL_ONLY("manual_only"), + } + + @JvmStatic + fun getAttachmentAutoDownload(context: Context): AttachmentAutoDownload { + val stored = + context + .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + .getString(KEY_ATTACHMENT_AUTO_DOWNLOAD, null) + return AttachmentAutoDownload.values().firstOrNull { it.value == stored } + ?: AttachmentAutoDownload.ANY_NETWORK + } + + @JvmStatic + fun setAttachmentAutoDownload( + context: Context, + setting: AttachmentAutoDownload, + ) { + context + .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + .edit { putString(KEY_ATTACHMENT_AUTO_DOWNLOAD, setting.value) } + } + + @JvmStatic + fun isAutomaticDownloadEnabled(context: Context): Boolean = getAttachmentAutoDownload(context) != AttachmentAutoDownload.MANUAL_ONLY + + @JvmStatic + fun clear(context: Context) { + context + .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + .edit { clear() } + } +} diff --git a/app/src/org/commcare/utils/PushNotificationApiHelper.kt b/app/src/org/commcare/utils/PushNotificationApiHelper.kt index 58a9b5ff8f..bf5d0973c4 100644 --- a/app/src/org/commcare/utils/PushNotificationApiHelper.kt +++ b/app/src/org/commcare/utils/PushNotificationApiHelper.kt @@ -28,16 +28,20 @@ import org.commcare.connect.ConnectConstants.PAYMENT_ID import org.commcare.connect.ConnectConstants.PAYMENT_UUID import org.commcare.connect.ConnectConstants.REDIRECT_ACTION import org.commcare.connect.PersonalIdManager +import org.commcare.connect.database.ConnectMessagingAttachmentDatabaseHelper import org.commcare.connect.database.ConnectMessagingDatabaseHelper import org.commcare.connect.database.ConnectUserDatabaseUtil import org.commcare.connect.database.NotificationRecordDatabaseHelper +import org.commcare.connect.messaging.ConnectMessagingAttachmentDownloadScheduler import org.commcare.connect.network.base.PersonalIdOrConnectApiErrorHandler import org.commcare.connect.network.personalId.PersonalIdApiHandler import org.commcare.connect.network.personalId.parser.NotificationParseResult import org.commcare.pn.helper.NotificationBroadcastHelper import org.commcare.pn.workers.MessagingChannelsKeySyncWorker +import org.commcare.preferences.ConnectMessagingPreferences import org.commcare.preferences.NotificationPrefs import org.commcare.utils.coroutines.DispatcherProvider +import org.javarosa.core.services.Logger import java.util.concurrent.TimeUnit import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -84,7 +88,15 @@ object PushNotificationApiHelper { scheduleMessagingChannelsKeySync(context) CoroutineScope(DispatcherProvider.io()).launch { - val (savedNotifications, savedNotificationIds) = processParsedDataIntoDB(context, parseResult) + val (savedNotifications, savedNotificationIds) = + try { + processParsedDataIntoDB(context, parseResult) + } catch (e: IllegalStateException) { + Logger.exception("Could not store retrieved notifications", e) + continuation.resume(Result.failure(e)) + return@launch + } + ConnectMessagingAttachmentDownloadScheduler.scheduleQueuedDownloads(context) // Update notification preferences and send broadcasts if (savedNotificationIds.isNotEmpty()) { @@ -141,6 +153,13 @@ object PushNotificationApiHelper { ConnectMessagingDatabaseHelper.storeMessagingMessages(context, parseResult.messages, false) } + if (parseResult.attachments.isNotEmpty()) { + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments( + parseResult.attachments, + ConnectMessagingPreferences.isAutomaticDownloadEnabled(context), + ) + } + // Store non-messaging notifications val savedNotificationIds = if (parseResult.nonMessagingNotifications.isNotEmpty()) { diff --git a/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt index c948299ff4..721df4add2 100644 --- a/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt +++ b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt @@ -2,14 +2,23 @@ package org.commcare.adapters import android.content.Context import android.view.ContextThemeWrapper +import android.view.View import android.widget.FrameLayout +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import org.commcare.CommCareTestApplication +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState import org.commcare.dalvik.R +import org.commcare.dalvik.databinding.ItemChatLeftRichViewBinding import org.commcare.dalvik.databinding.ItemChatRightViewBinding +import org.commcare.dalvik.databinding.ViewConnectMessageAttachmentPendingBinding +import org.commcare.fragments.connectMessaging.ConnectMessageAttachmentItem +import org.commcare.fragments.connectMessaging.ConnectMessageAttachmentListener +import org.commcare.fragments.connectMessaging.ConnectMessageAudioAttachmentView import org.commcare.fragments.connectMessaging.ConnectMessageChatData +import org.commcare.fragments.connectMessaging.ConnectMessageTestImages import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -18,6 +27,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Shadows import org.robolectric.annotation.Config +import java.io.File import java.util.Date @Config(application = CommCareTestApplication::class) @@ -28,7 +38,7 @@ class ConnectMessageAdapterTest { @Before fun setUp() { - adapter = ConnectMessageAdapter(ArrayList()) + adapter = ConnectMessageAdapter(ArrayList(), IgnoringAttachmentListener) observer = RecordingObserver() adapter.registerAdapterDataObserver(observer) } @@ -139,8 +149,224 @@ class ConnectMessageAdapterTest { assertEquals(UNTOUCHED_TEXT, binding.tvChatMessage.text.toString()) } + @Test + fun `incoming message awaiting download uses the rich row and a plain one does not`() { + adapter.updateData(listOf(getIncomingChat("plain"), getPendingChat("rich", ConnectMessagingAttachmentState.QUEUED))) + + assertEquals(ConnectMessageAdapter.LEFTVIEW, adapter.getItemViewType(0)) + assertEquals(ConnectMessageAdapter.LEFT_RICH_VIEW, adapter.getItemViewType(1)) + } + + @Test + fun `download state change is a payload-only change on the owning row`() { + val queued = ConnectMessagingAttachmentState.QUEUED + adapter.updateData(listOf(getPendingChat("a", queued), getPendingChat("b", queued))) + observer.events.clear() + + val hasNewMessages = + adapter.updateData(listOf(getPendingChat("a", queued), getPendingChat("b", ConnectMessagingAttachmentState.FAILED))) + + assertFalse(hasNewMessages) + assertEquals(listOf(Event.Changed(1, 1, hasPayload = true)), observer.events) + } + + @Test + fun `pending message hides its text and the attachments payload redraws only the tile`() { + adapter.updateData(listOf(getPendingChat("a", ConnectMessagingAttachmentState.QUEUED))) + val holder = adapter.onCreateViewHolder(FrameLayout(themedContext()), ConnectMessageAdapter.LEFT_RICH_VIEW) + adapter.onBindViewHolder(holder, 0) + val binding = ItemChatLeftRichViewBinding.bind(holder.itemView) + binding.tvChatMessage.text = UNTOUCHED_TEXT + + adapter.updateData(listOf(getPendingChat("a", ConnectMessagingAttachmentState.FAILED))) + adapter.onBindViewHolder(holder, 0, mutableListOf(requireNotNull(observer.lastPayload))) + + val tile = ViewConnectMessageAttachmentPendingBinding.bind(binding.llAttachments.getChildAt(0)) + assertEquals(themedContext().getString(R.string.connect_messaging_attachment_download_failed), tile.tvLabel.text.toString()) + assertEquals(UNTOUCHED_TEXT, binding.tvChatMessage.text.toString()) + assertEquals(View.GONE, binding.tvChatMessage.visibility) + } + + @Test + fun `finishing the download reveals the text and media through the attachments payload`() { + adapter.updateData(listOf(getPendingChat("a", ConnectMessagingAttachmentState.DOWNLOADING))) + val holder = adapter.onCreateViewHolder(FrameLayout(themedContext()), ConnectMessageAdapter.LEFT_RICH_VIEW) + adapter.onBindViewHolder(holder, 0) + val binding = ItemChatLeftRichViewBinding.bind(holder.itemView) + observer.events.clear() + + adapter.updateData(listOf(getDownloadedChat("a"))) + adapter.onBindViewHolder(holder, 0, mutableListOf(requireNotNull(observer.lastPayload))) + + assertEquals(listOf(Event.Changed(0, 1, hasPayload = true)), observer.events) + assertEquals(View.VISIBLE, binding.tvChatMessage.visibility) + assertEquals("message a", binding.tvChatMessage.text.toString()) + assertTrue(binding.llAttachments.getChildAt(0) is ConnectMessageAudioAttachmentView) + } + + @Test + fun `long text under a narrow portrait image keeps the normal text width`() { + val row = bindImageRow("portrait", storedWidth = 300, storedHeight = 800, text = LONG_TEXT) + + val image = row.llAttachments.getChildAt(0) + val imageWidth = image.layoutParams.width + assertTrue("text held to the image width", row.tvChatMessage.maxWidth > imageWidth) + } + + @Test + fun `narrow image is centered above wider text`() { + val row = bindImageRow("centered", storedWidth = 300, storedHeight = 800, text = LONG_TEXT) + val rowWidth = themedContext().resources.displayMetrics.widthPixels + row.root.measure( + View.MeasureSpec.makeMeasureSpec(rowWidth, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + ) + row.root.layout(0, 0, row.root.measuredWidth, row.root.measuredHeight) + + val bubble = row.llBubble + val image = row.llAttachments.getChildAt(0) + val imageCenter = row.llAttachments.left + image.left + image.width / 2f + val contentCenter = bubble.paddingLeft + (bubble.width - bubble.paddingLeft - bubble.paddingRight) / 2f + assertTrue("text should be wider than the image", row.tvChatMessage.width > image.width) + assertEquals(contentCenter, imageCenter, 1f) + } + + @Test + fun `text under a wide image can span the whole image`() { + val row = bindImageRow("landscape", storedWidth = 1600, storedHeight = 900, text = LONG_TEXT) + + val image = row.llAttachments.getChildAt(0) + val imageWidth = image.layoutParams.width + assertEquals(imageWidth, row.tvChatMessage.maxWidth) + } + + @Test + fun `rows laid out while the list is still being measured use the list width, not the screen width`() { + val listWidth = themedContext().resources.displayMetrics.widthPixels - NARROWER_THAN_SCREEN + adapter.updateData(listOf(getImageChat("measuring", storedWidth = 1600, storedHeight = 900))) + val chatList = + RecyclerView(themedContext()).apply { + layoutManager = LinearLayoutManager(themedContext()) + adapter = this@ConnectMessageAdapterTest.adapter + } + + chatList.measure( + View.MeasureSpec.makeMeasureSpec(listWidth, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(LIST_HEIGHT_LIMIT, View.MeasureSpec.AT_MOST), + ) + + val measuringRow = ItemChatLeftRichViewBinding.bind(chatList.getChildAt(0)) + val laidOutRow = bindImageRow("laid-out", storedWidth = 1600, storedHeight = 900, listWidth = listWidth) + val measuringImage = measuringRow.llAttachments.getChildAt(0) + val laidOutImage = laidOutRow.llAttachments.getChildAt(0) + assertEquals(laidOutImage.layoutParams.width, measuringImage.layoutParams.width) + } + + @Test + fun `image size does not change when the list shrinks for the keyboard`() { + val keyboardClosed = bindImageRow("keyboard-closed", storedWidth = 300, storedHeight = 800, listHeight = 1600) + val keyboardOpen = bindImageRow("keyboard-open", storedWidth = 300, storedHeight = 800, listHeight = 700) + + val closedSize = keyboardClosed.llAttachments.getChildAt(0).layoutParams + val openSize = keyboardOpen.llAttachments.getChildAt(0).layoutParams + assertEquals(closedSize.width, openSize.width) + assertEquals(closedSize.height, openSize.height) + } + + @Test + fun `media message with blank text shows only its media`() { + val attachment = getDownloadedChat("a").attachments.single() + adapter.updateData(listOf(getRichChat("a", listOf(attachment), null, text = ""))) + val holder = adapter.onCreateViewHolder(FrameLayout(themedContext()), ConnectMessageAdapter.LEFT_RICH_VIEW) + + adapter.onBindViewHolder(holder, 0) + + val binding = ItemChatLeftRichViewBinding.bind(holder.itemView) + assertEquals(View.GONE, binding.tvChatMessage.visibility) + assertTrue(binding.llAttachments.getChildAt(0) is ConnectMessageAudioAttachmentView) + } + + @Test + fun `message too new for this app shows only the update placeholder`() { + val chat = ConnectMessageChatData("a", ConnectMessageAdapter.LEFTVIEW, "", "them", Date(TIMESTAMP), false, emptyList(), true, null) + adapter.updateData(listOf(chat)) + assertEquals(ConnectMessageAdapter.LEFT_RICH_VIEW, adapter.getItemViewType(0)) + val holder = adapter.onCreateViewHolder(FrameLayout(themedContext()), ConnectMessageAdapter.LEFT_RICH_VIEW) + + adapter.onBindViewHolder(holder, 0) + + val binding = ItemChatLeftRichViewBinding.bind(holder.itemView) + val tile = ViewConnectMessageAttachmentPendingBinding.bind(binding.llAttachments.getChildAt(0)) + assertEquals(themedContext().getString(R.string.connect_messaging_update_app_notice), tile.tvLabel.text.toString()) + assertEquals(View.GONE, binding.tvChatMessage.visibility) + } + + private fun bindImageRow( + attachmentId: String, + storedWidth: Int, + storedHeight: Int, + text: String = "caption", + listWidth: Int = themedContext().resources.displayMetrics.widthPixels, + listHeight: Int = 1600, + ): ItemChatLeftRichViewBinding { + adapter.updateData(listOf(getImageChat(attachmentId, storedWidth, storedHeight, text))) + val messageList = FrameLayout(themedContext()).apply { layout(0, 0, listWidth, listHeight) } + val holder = adapter.onCreateViewHolder(messageList, ConnectMessageAdapter.LEFT_RICH_VIEW) + adapter.onBindViewHolder(holder, 0) + return ItemChatLeftRichViewBinding.bind(holder.itemView) + } + + private fun getImageChat( + attachmentId: String, + storedWidth: Int, + storedHeight: Int, + text: String = "caption", + ): ConnectMessageChatData { + val file = ConnectMessageTestImages.writePng(File(themedContext().cacheDir, attachmentId), storedWidth, storedHeight) + val image = + ConnectMessageAttachmentItem( + attachmentId, + "$attachmentId.png", + "image/png", + file.length(), + ConnectMessagingAttachmentState.AVAILABLE, + file, + ) + return getRichChat(attachmentId, listOf(image), null, text = text) + } + private fun themedContext(): Context = ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.ConnectTheme) + private fun getPendingChat( + id: String, + pendingState: ConnectMessagingAttachmentState, + ) = getRichChat(id, emptyList(), pendingState) + + private fun getDownloadedChat(id: String): ConnectMessageChatData { + val file = File(themedContext().cacheDir, "audio-$id").apply { writeBytes(byteArrayOf(1, 2, 3)) } + val attachment = + ConnectMessageAttachmentItem("attachment-$id", "note.mp3", "audio/mpeg", 3, ConnectMessagingAttachmentState.AVAILABLE, file) + return getRichChat(id, listOf(attachment), null) + } + + private fun getRichChat( + id: String, + attachments: List, + pendingState: ConnectMessagingAttachmentState?, + text: String = "message $id", + ) = ConnectMessageChatData(id, ConnectMessageAdapter.LEFTVIEW, text, "them", Date(TIMESTAMP), false, attachments, false, pendingState) + + private object IgnoringAttachmentListener : ConnectMessageAttachmentListener { + override fun onMessageDownloadRequested(messageId: String) = Unit + + override fun onAttachmentOpenRequested(attachment: ConnectMessageAttachmentItem) = Unit + + override fun onImageOpenRequested( + attachment: ConnectMessageAttachmentItem, + imageView: View, + ) = Unit + } + private fun getReadIconResId(binding: ItemChatRightViewBinding) = Shadows.shadowOf(binding.imgMessageReadStatus.drawable).createdFromResId @@ -223,5 +449,8 @@ class ConnectMessageAdapterTest { companion object { private const val TIMESTAMP = 1_000L private const val UNTOUCHED_TEXT = "untouched" + private const val NARROWER_THAN_SCREEN = 40 + private const val LIST_HEIGHT_LIMIT = 2000 + private val LONG_TEXT = "Please keep the baby warm and feeding well. ".repeat(12) } } diff --git a/app/unit-tests/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecordMigrationTest.kt b/app/unit-tests/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecordMigrationTest.kt new file mode 100644 index 0000000000..b7225c638d --- /dev/null +++ b/app/unit-tests/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecordMigrationTest.kt @@ -0,0 +1,41 @@ +package org.commcare.android.database.connect.models + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Date + +class ConnectMessagingMessageRecordMigrationTest { + @Test + fun `fromV29 keeps the stored message and marks it as a plain message`() { + val timestamp = Date(1_700_000_000_000) + val oldRecord = + ConnectMessagingMessageRecordV29().apply { + messageId = "message-1" + channelId = "channel-1" + timeStamp = timestamp + message = "Hello" + isOutgoing = true + confirmed = true + userViewed = true + } + + val record = ConnectMessagingMessageRecord.fromV29(oldRecord) + + assertEquals("message-1", record.messageId) + assertEquals("channel-1", record.channelId) + assertEquals(timestamp, record.timeStamp) + assertEquals("Hello", record.message) + assertTrue(record.isOutgoing) + assertTrue(record.confirmed) + assertTrue(record.userViewed) + assertEquals(ConnectMessagingMessageRecord.VERSION_PLAIN, record.version) + assertFalse(record.isRich) + assertNull(record.richText) + assertNull(record.format) + assertNull(record.expiresAt) + assertEquals("Hello", record.displayText) + } +} diff --git a/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java b/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java index 8ebf33f83f..3a21c2b53d 100644 --- a/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java +++ b/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java @@ -427,7 +427,11 @@ public class FormStorageTest { "org.commcare.android.database.connect.models.ConnectMessagingChannelRecordV27", // Added in 2.65 - "org.commcare.android.database.connect.models.ConnectLearnModuleSummaryRecordV28" + "org.commcare.android.database.connect.models.ConnectLearnModuleSummaryRecordV28", + + // Added in 2.66 + "org.commcare.android.database.connect.models.ConnectMessagingMessageRecordV29", + "org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord" ); diff --git a/app/unit-tests/src/org/commcare/android/util/ConnectTestUtils.kt b/app/unit-tests/src/org/commcare/android/util/ConnectTestUtils.kt index 41b8a94067..f5302be92d 100644 --- a/app/unit-tests/src/org/commcare/android/util/ConnectTestUtils.kt +++ b/app/unit-tests/src/org/commcare/android/util/ConnectTestUtils.kt @@ -92,10 +92,14 @@ object ConnectTestUtils { * disk, and the test Connect DB is in-memory. Reads and writes still go to that in-memory DB; * the empty file only makes the existence probe agree with it. */ - fun signInToPersonalId(hasConnectAccess: Boolean = false) { + fun signInToPersonalId( + hasConnectAccess: Boolean = false, + userId: String = "", + password: String = "", + ) { val context = ApplicationProvider.getApplicationContext() createConnectDbFile() - val user = ConnectUserRecord("", "", "", "Test User", "", Date(), null, false, "", hasConnectAccess) + val user = ConnectUserRecord("", userId, password, "Test User", "", Date(), null, false, "", hasConnectAccess) user.updateConnectToken("test-connect-token", daysFromNow(1)) ConnectUserDatabaseUtil.storeUser(user) PersonalIdManager.getInstance().init(context) diff --git a/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelperTest.kt b/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelperTest.kt new file mode 100644 index 0000000000..c983ccffbe --- /dev/null +++ b/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelperTest.kt @@ -0,0 +1,207 @@ +package org.commcare.connect.database + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.commcare.CommCareTestApplication +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.util.Date +import java.util.concurrent.TimeUnit + +@Config(application = CommCareTestApplication::class) +@RunWith(AndroidJUnit4::class) +class ConnectMessagingAttachmentDatabaseHelperTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val now = Date() + + @After + fun tearDown() { + ConnectDatabaseHelper.teardown() + } + + @Test + fun `attachment on a recent message is queued for download`() { + storeIncomingMessage("message-1", daysAgo = 1) + + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments(listOf(attachment("attachment-1", "message-1")), true, now) + + assertEquals(ConnectMessagingAttachmentState.QUEUED, stateOf("attachment-1")) + } + + @Test + fun `attachment on an old message waits for a tap`() { + storeIncomingMessage("message-1", daysAgo = 10) + + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments(listOf(attachment("attachment-1", "message-1")), true, now) + + assertEquals(ConnectMessagingAttachmentState.WAITING, stateOf("attachment-1")) + } + + @Test + fun `redelivered attachment keeps its existing state and attempts`() { + storeIncomingMessage("message-1", daysAgo = 1) + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments(listOf(attachment("attachment-1", "message-1")), true, now) + val stored = ConnectMessagingAttachmentDatabaseHelper.getAttachment("attachment-1")!! + stored.downloadState = ConnectMessagingAttachmentState.AVAILABLE + stored.attempts = 2 + ConnectDatabaseHelper.getConnectStorage(ConnectMessagingAttachmentRecord::class.java).write(stored) + + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments(listOf(attachment("attachment-1", "message-1")), true, now) + + val attachments = attachmentsOf("message-1") + assertEquals(1, attachments.size) + assertEquals(ConnectMessagingAttachmentState.AVAILABLE, attachments[0].downloadState) + assertEquals(2, attachments[0].attempts) + } + + @Test + fun `attachments for a message come back in sender order`() { + storeIncomingMessage("message-1", daysAgo = 1) + + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments( + listOf(attachment("attachment-b", "message-1", position = 1), attachment("attachment-a", "message-1", position = 0)), + true, + now, + ) + + assertEquals( + listOf("attachment-a", "attachment-b"), + attachmentsOf("message-1").map { it.attachmentId }, + ) + } + + @Test + fun `attachments are looked up only within the requested channel`() { + ConnectMessagingAttachmentDatabaseHelper.save(attachment("here", "message-1")) + ConnectMessagingAttachmentDatabaseHelper.save(attachment("elsewhere", "message-2").apply { channelId = "channel-2" }) + + assertEquals(setOf("message-1"), ConnectMessagingAttachmentDatabaseHelper.getAttachmentsByMessageId(CHANNEL_ID).keys) + } + + @Test + fun `attachment on a recent message waits for a tap when automatic download is off`() { + storeIncomingMessage("message-1", daysAgo = 1) + + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments(listOf(attachment("attachment-1", "message-1")), false, now) + + assertEquals(ConnectMessagingAttachmentState.WAITING, stateOf("attachment-1")) + } + + @Test + fun `pending downloads go back to waiting and finished ones are kept`() { + storeIncomingMessage("message-1", daysAgo = 1) + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments( + listOf( + attachment("queued", "message-1", position = 0), + attachment("downloading", "message-1", position = 1), + attachment("available", "message-1", position = 2), + ), + true, + now, + ) + setState("downloading", ConnectMessagingAttachmentState.DOWNLOADING) + setState("available", ConnectMessagingAttachmentState.AVAILABLE) + + ConnectMessagingAttachmentDatabaseHelper.returnPendingAttachmentsToWaiting() + + assertEquals(ConnectMessagingAttachmentState.WAITING, stateOf("queued")) + assertEquals(ConnectMessagingAttachmentState.WAITING, stateOf("downloading")) + assertEquals(ConnectMessagingAttachmentState.AVAILABLE, stateOf("available")) + } + + @Test + fun `requeueing a message requests its unfinished attachments and keeps finished ones`() { + storeIncomingMessage("message-1", daysAgo = 10) + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments( + listOf( + attachment("waiting", "message-1", position = 0), + attachment("queued", "message-1", position = 1), + attachment("failed", "message-1", position = 2), + attachment("available", "message-1", position = 3), + ), + true, + now, + ) + setState("queued", ConnectMessagingAttachmentState.QUEUED) + setState("failed", ConnectMessagingAttachmentState.FAILED) + setState("available", ConnectMessagingAttachmentState.AVAILABLE) + + val requeued = ConnectMessagingAttachmentDatabaseHelper.requeueMessage("message-1") + + assertEquals(listOf("waiting", "queued", "failed"), requeued) + assertEquals(ConnectMessagingAttachmentState.REQUESTED, stateOf("waiting")) + assertEquals(ConnectMessagingAttachmentState.REQUESTED, stateOf("queued")) + assertEquals(ConnectMessagingAttachmentState.REQUESTED, stateOf("failed")) + assertEquals(ConnectMessagingAttachmentState.AVAILABLE, stateOf("available")) + } + + @Test + fun `requeueing an expired message does nothing`() { + storeIncomingMessage("message-1", daysAgo = 10) + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments( + listOf(attachment("waiting", "message-1", position = 0), attachment("expired", "message-1", position = 1)), + true, + now, + ) + setState("expired", ConnectMessagingAttachmentState.EXPIRED) + + val requeued = ConnectMessagingAttachmentDatabaseHelper.requeueMessage("message-1") + + assertEquals(emptyList(), requeued) + assertEquals(ConnectMessagingAttachmentState.WAITING, stateOf("waiting")) + } + + private fun setState( + attachmentId: String, + state: ConnectMessagingAttachmentState, + ) { + val attachment = ConnectMessagingAttachmentDatabaseHelper.getAttachment(attachmentId)!! + attachment.downloadState = state + ConnectMessagingAttachmentDatabaseHelper.save(attachment) + } + + private fun storeIncomingMessage( + messageId: String, + daysAgo: Long, + ) { + val message = + ConnectMessagingMessageRecord().apply { + this.messageId = messageId + channelId = CHANNEL_ID + timeStamp = Date(now.time - TimeUnit.DAYS.toMillis(daysAgo)) + this.message = "Hello" + isOutgoing = false + } + ConnectMessagingDatabaseHelper.storeMessagingMessage(context, message) + } + + private fun attachment( + attachmentId: String, + messageId: String, + position: Int = 0, + ) = ConnectMessagingAttachmentRecord().apply { + this.attachmentId = attachmentId + this.messageId = messageId + channelId = CHANNEL_ID + this.position = position + name = "file-$attachmentId" + type = "image/png" + size = 100 + } + + private fun stateOf(attachmentId: String) = ConnectMessagingAttachmentDatabaseHelper.getAttachment(attachmentId)!!.downloadState + + private fun attachmentsOf(messageId: String) = + ConnectMessagingAttachmentDatabaseHelper.getAttachmentsByMessageId(CHANNEL_ID).getValue(messageId) + + private companion object { + const val CHANNEL_ID = "channel-1" + } +} diff --git a/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentFileStoreTest.kt b/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentFileStoreTest.kt new file mode 100644 index 0000000000..d59a1036ab --- /dev/null +++ b/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentFileStoreTest.kt @@ -0,0 +1,55 @@ +package org.commcare.connect.database + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.commcare.CommCareTestApplication +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.io.File + +@Config(application = CommCareTestApplication::class) +@RunWith(AndroidJUnit4::class) +class ConnectMessagingAttachmentFileStoreTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @After + fun tearDown() { + ConnectMessagingAttachmentFileStore.deleteAll(context) + } + + @Test + fun `write stores the contents under the attachment id and leaves no partial file`() { + val contents = byteArrayOf(1, 2, 3) + + ConnectMessagingAttachmentFileStore.write(context, "attachment-1", contents) + + val directory = ConnectMessagingAttachmentFileStore.directory(context) + assertArrayEquals(contents, ConnectMessagingAttachmentFileStore.fileFor(context, "attachment-1").readBytes()) + assertFalse(File(directory, "attachment-1.part").exists()) + } + + @Test + fun `write replaces an existing file for the same attachment`() { + ConnectMessagingAttachmentFileStore.write(context, "attachment-1", byteArrayOf(1)) + + ConnectMessagingAttachmentFileStore.write(context, "attachment-1", byteArrayOf(2, 2)) + + assertArrayEquals(byteArrayOf(2, 2), ConnectMessagingAttachmentFileStore.fileFor(context, "attachment-1").readBytes()) + } + + @Test + fun `deleteAll removes the attachment directory`() { + ConnectMessagingAttachmentFileStore.write(context, "attachment-1", byteArrayOf(1)) + assertTrue(ConnectMessagingAttachmentFileStore.directory(context).exists()) + + ConnectMessagingAttachmentFileStore.deleteAll(context) + + assertFalse(ConnectMessagingAttachmentFileStore.directory(context).exists()) + } +} diff --git a/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingChannelPreviewTest.kt b/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingChannelPreviewTest.kt new file mode 100644 index 0000000000..375ddade33 --- /dev/null +++ b/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingChannelPreviewTest.kt @@ -0,0 +1,128 @@ +package org.commcare.connect.database + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.commcare.CommCareTestApplication +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState +import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord +import org.commcare.connect.network.personalId.parser.NotificationTestUtil +import org.commcare.dalvik.R +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.util.Date + +@Config(application = CommCareTestApplication::class) +@RunWith(AndroidJUnit4::class) +class ConnectMessagingChannelPreviewTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var channel: ConnectMessagingChannelRecord + + @Before + fun setUp() { + channel = + ConnectMessagingChannelRecord().apply { + channelId = CHANNEL_ID + channelCreated = Date() + consented = true + channelSource = "" + keyUrl = "" + key = NotificationTestUtil.TEST_ENCRYPTION_KEY + channelName = "" + } + ConnectMessagingDatabaseHelper.storeMessagingChannel(context, channel) + } + + @After + fun tearDown() { + ConnectDatabaseHelper.teardown() + } + + @Test + fun `rich message still downloading is previewed as a media message to download`() { + storeRichMessageWithAttachment(ConnectMessagingAttachmentState.QUEUED) + + assertEquals(context.getString(R.string.connect_messaging_preview_pending_download), previewText()) + } + + @Test + fun `downloaded rich message is previewed by its text`() { + storeRichMessageWithAttachment(ConnectMessagingAttachmentState.AVAILABLE) + + assertEquals("Site visit notes", previewText()) + } + + @Test + fun `rich message is previewed by its rich text rather than its content`() { + storeRichMessage(content = "Plain fallback", richText = "Site visit notes") + + assertEquals("Site visit notes", previewText()) + } + + @Test + fun `rich message without rich text is previewed by its content`() { + storeRichMessage(content = "Site visit notes", richText = null) + + assertEquals("Site visit notes", previewText()) + } + + @Test + fun `message too new for this app is previewed as needing an update`() { + storeRichMessage(content = "Placeholder from the server", richText = null, version = 3) + + assertEquals(context.getString(R.string.connect_messaging_preview_update_app), previewText()) + } + + private fun previewText() = + ConnectMessagingDatabaseHelper + .getMessagingChannels(context) + .single { it.channelId == CHANNEL_ID } + .preview + .toString() + + private fun storeRichMessage( + content: String, + richText: String?, + version: Int = 2, + ) { + val json = + JSONObject( + NotificationTestUtil.createRichMessagingNotification( + "notification-1", + MESSAGE_ID, + CHANNEL_ID, + content = content, + version = version, + richText = richText, + ), + ) + val message = ConnectMessagingMessageRecord.fromJson(json, listOf(channel))!! + ConnectMessagingDatabaseHelper.storeMessagingMessage(context, message) + } + + private fun storeRichMessageWithAttachment(state: ConnectMessagingAttachmentState) { + storeRichMessage(content = "Site visit notes", richText = null) + ConnectMessagingAttachmentDatabaseHelper.save( + ConnectMessagingAttachmentRecord().apply { + attachmentId = "attachment-1" + messageId = MESSAGE_ID + name = "map.png" + type = "image/png" + size = 100 + downloadState = state + }, + ) + } + + private companion object { + const val CHANNEL_ID = "channel-1" + const val MESSAGE_ID = "message-1" + } +} diff --git a/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingMessageStorageTest.kt b/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingMessageStorageTest.kt new file mode 100644 index 0000000000..ebfe3b00a8 --- /dev/null +++ b/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingMessageStorageTest.kt @@ -0,0 +1,112 @@ +package org.commcare.connect.database + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.commcare.CommCareTestApplication +import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord +import org.commcare.connect.network.personalId.parser.NotificationTestUtil +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.util.Date + +@Config(application = CommCareTestApplication::class) +@RunWith(AndroidJUnit4::class) +class ConnectMessagingMessageStorageTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var channel: ConnectMessagingChannelRecord + + @Before + fun setUp() { + channel = + ConnectMessagingChannelRecord().apply { + channelId = CHANNEL_ID + channelCreated = Date() + consented = true + channelSource = "" + keyUrl = "" + key = NotificationTestUtil.TEST_ENCRYPTION_KEY + channelName = "" + } + ConnectMessagingDatabaseHelper.storeMessagingChannel(context, channel) + } + + @After + fun tearDown() { + ConnectDatabaseHelper.teardown() + } + + @Test + fun `unacknowledged message delivered again stays read`() { + deliver(version = 3) + markRead() + + deliver(version = 3) + + assertTrue(storedMessage().userViewed) + } + + @Test + fun `message delivered again in a version the app now understands is unread with its full content`() { + deliver(version = 3) + markRead() + + deliver(version = 2) + + val message = storedMessage() + assertFalse(message.userViewed) + assertEquals("Site visit notes", message.displayText) + } + + @Test + fun `stored media-only message still shows no text instead of its legacy content`() { + deliver(version = 2, emptyRichText = true) + + assertEquals("", storedMessage().displayText) + } + + private fun deliver( + version: Int, + emptyRichText: Boolean = false, + ) { + val json = + JSONObject( + NotificationTestUtil.createRichMessagingNotification( + "notification-1", + MESSAGE_ID, + CHANNEL_ID, + content = "Site visit notes", + version = version, + ), + ) + if (emptyRichText) { + json.put("rich_text", "") + } + ConnectMessagingDatabaseHelper.storeMessagingMessages( + context, + listOf(ConnectMessagingMessageRecord.fromJson(json, listOf(channel))!!), + false, + ) + } + + private fun markRead() { + val message = storedMessage() + message.userViewed = true + ConnectMessagingDatabaseHelper.storeMessagingMessage(context, message) + } + + private fun storedMessage() = ConnectMessagingDatabaseHelper.getMessagingMessagesForChannel(CHANNEL_ID).single() + + private companion object { + const val CHANNEL_ID = "channel-1" + const val MESSAGE_ID = "message-1" + } +} diff --git a/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentAutoDownloadPolicyTest.kt b/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentAutoDownloadPolicyTest.kt new file mode 100644 index 0000000000..266a874f1a --- /dev/null +++ b/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentAutoDownloadPolicyTest.kt @@ -0,0 +1,70 @@ +package org.commcare.connect.messaging + +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Date +import java.util.concurrent.TimeUnit + +class ConnectMessagingAttachmentAutoDownloadPolicyTest { + private val now = Date(1_800_000_000_000) + + @Test + fun `recent message among the newest incoming is queued`() { + val state = ConnectMessagingAttachmentAutoDownloadPolicy.initialState(daysAgo(1), null, true, now) + + assertEquals(ConnectMessagingAttachmentState.QUEUED, state) + } + + @Test + fun `message seven or more days old waits for a tap`() { + val state = ConnectMessagingAttachmentAutoDownloadPolicy.initialState(daysAgo(7), null, true, now) + + assertEquals(ConnectMessagingAttachmentState.WAITING, state) + } + + @Test + fun `recent message beyond the newest incoming waits for a tap`() { + val state = ConnectMessagingAttachmentAutoDownloadPolicy.initialState(daysAgo(1), null, false, now) + + assertEquals(ConnectMessagingAttachmentState.WAITING, state) + } + + @Test + fun `message past its expiry is expired even when recent`() { + val state = ConnectMessagingAttachmentAutoDownloadPolicy.initialState(daysAgo(1), now, true, now) + + assertEquals(ConnectMessagingAttachmentState.EXPIRED, state) + } + + @Test + fun `message expiring in the future is not expired`() { + val state = + ConnectMessagingAttachmentAutoDownloadPolicy.initialState(daysAgo(1), Date(now.time + 1), true, now) + + assertEquals(ConnectMessagingAttachmentState.QUEUED, state) + } + + @Test + fun `newest incoming ids are the latest fifty incoming messages and skip outgoing ones`() { + val incoming = (1..51).map { message("incoming-$it", minutesAgo = it.toLong(), outgoing = false) } + val outgoing = message("outgoing", minutesAgo = 0, outgoing = true) + + val newest = ConnectMessagingAttachmentAutoDownloadPolicy.newestIncomingMessageIds(incoming + outgoing) + + assertEquals((1..50).map { "incoming-$it" }.toSet(), newest) + } + + private fun daysAgo(days: Long) = Date(now.time - TimeUnit.DAYS.toMillis(days)) + + private fun message( + messageId: String, + minutesAgo: Long, + outgoing: Boolean, + ) = ConnectMessagingMessageRecord().apply { + this.messageId = messageId + timeStamp = Date(now.time - TimeUnit.MINUTES.toMillis(minutesAgo)) + isOutgoing = outgoing + } +} diff --git a/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentCryptoTest.kt b/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentCryptoTest.kt new file mode 100644 index 0000000000..a97f2ebe65 --- /dev/null +++ b/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentCryptoTest.kt @@ -0,0 +1,42 @@ +package org.commcare.connect.messaging + +import org.commcare.util.Base64 +import org.junit.Assert.assertEquals +import org.junit.Test +import java.security.GeneralSecurityException +import javax.crypto.AEADBadTagException + +class ConnectMessagingAttachmentCryptoTest { + @Test + fun `decrypts the PersonalID test vector`() { + val plaintext = ConnectMessagingAttachmentCrypto.decrypt(Base64.decode(TEST_VECTOR_BLOB), TEST_VECTOR_KEY) + + assertEquals("PersonalID attachment test vector", String(plaintext, Charsets.UTF_8)) + } + + @Test(expected = AEADBadTagException::class) + fun `altered bytes fail authentication`() { + val blob = Base64.decode(TEST_VECTOR_BLOB) + blob[20] = (blob[20].toInt() xor 1).toByte() + + ConnectMessagingAttachmentCrypto.decrypt(blob, TEST_VECTOR_KEY) + } + + @Test(expected = AEADBadTagException::class) + fun `a different channel key fails authentication`() { + ConnectMessagingAttachmentCrypto.decrypt( + Base64.decode(TEST_VECTOR_BLOB), + "MTIzNDU2Nzg5MGFiY2RlZmdoaWprbG1ub3BxcnM3dXY=", + ) + } + + @Test(expected = GeneralSecurityException::class) + fun `a blob shorter than nonce and tag is rejected`() { + ConnectMessagingAttachmentCrypto.decrypt(ByteArray(27), TEST_VECTOR_KEY) + } + + private companion object { + const val TEST_VECTOR_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + const val TEST_VECTOR_BLOB = "oKGio6Slpqeoqaqrtn0OXiqlY9MrIaeycw6hvRjBPH7mlzYJ73oG8BrIAW6gb+xqABsBM0cg1w0oetetHQ==" + } +} diff --git a/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloadSchedulerTest.kt b/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloadSchedulerTest.kt new file mode 100644 index 0000000000..960c1b9423 --- /dev/null +++ b/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloadSchedulerTest.kt @@ -0,0 +1,142 @@ +package org.commcare.connect.messaging + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.NetworkType +import androidx.work.WorkInfo +import androidx.work.WorkManager +import org.commcare.CommCareTestApplication +import org.commcare.preferences.ConnectMessagingPreferences +import org.commcare.preferences.ConnectMessagingPreferences.AttachmentAutoDownload +import org.commcare.utils.PushNotificationApiHelper +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.util.concurrent.TimeUnit + +@Config(application = CommCareTestApplication::class) +@RunWith(AndroidJUnit4::class) +class ConnectMessagingAttachmentDownloadSchedulerTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @After + fun tearDown() { + ConnectMessagingAttachmentDownloadScheduler.cancelDownloads(context) + ConnectMessagingPreferences.clear(context) + } + + @Test + fun `by default one job downloads every queued attachment on any network`() { + ConnectMessagingAttachmentDownloadScheduler.scheduleQueuedDownloads(context) + + assertEquals(NetworkType.CONNECTED, pendingNetworkType(ConnectMessagingAttachmentDownloadScheduler.ALL_FILES_WORK_NAME)) + assertTrue(pendingWork(ConnectMessagingAttachmentDownloadScheduler.LARGE_FILES_WORK_NAME).isEmpty()) + } + + @Test + fun `large on wifi only holds large automatic downloads for wifi and keeps small ones on any network`() { + ConnectMessagingPreferences.setAttachmentAutoDownload(context, AttachmentAutoDownload.LARGE_ON_WIFI_ONLY) + + ConnectMessagingAttachmentDownloadScheduler.scheduleQueuedDownloads(context) + + assertEquals(NetworkType.CONNECTED, pendingNetworkType(ConnectMessagingAttachmentDownloadScheduler.SMALL_FILES_WORK_NAME)) + assertEquals(NetworkType.UNMETERED, pendingNetworkType(ConnectMessagingAttachmentDownloadScheduler.LARGE_FILES_WORK_NAME)) + assertTrue(pendingWork(ConnectMessagingAttachmentDownloadScheduler.ALL_FILES_WORK_NAME).isEmpty()) + } + + @Test + fun `manual only schedules no automatic downloads`() { + ConnectMessagingPreferences.setAttachmentAutoDownload(context, AttachmentAutoDownload.MANUAL_ONLY) + + ConnectMessagingAttachmentDownloadScheduler.scheduleQueuedDownloads(context) + + assertTrue(automaticWork().isEmpty()) + } + + @Test + fun `a tapped attachment is attempted right away whatever the setting or network`() { + for (setting in AttachmentAutoDownload.values()) { + ConnectMessagingPreferences.setAttachmentAutoDownload(context, setting) + + ConnectMessagingAttachmentDownloadScheduler.downloadNow(context, "attachment-${setting.value}") + + val work = latestWork(ConnectMessagingAttachmentDownloadScheduler.requestedWorkName("attachment-${setting.value}")) + assertEquals(NetworkType.NOT_REQUIRED, work.constraints.requiredNetworkType) + assertEquals(0L, work.initialDelayMillis) + } + } + + @Test + fun `a failed tapped attachment is retried in the background once a network is available`() { + ConnectMessagingAttachmentDownloadScheduler.retryRequestedDownloadLater(context, "attachment-1") + + val work = pendingWork(ConnectMessagingAttachmentDownloadScheduler.requestedRetryWorkName("attachment-1")).single() + assertEquals(NetworkType.CONNECTED, work.constraints.requiredNetworkType) + assertEquals(TimeUnit.MINUTES.toMillis(PushNotificationApiHelper.SYNC_BACKOFF_DELAY_IN_MINS), work.initialDelayMillis) + } + + @Test + fun `tapping again replaces the pending background retry with an immediate attempt`() { + ConnectMessagingAttachmentDownloadScheduler.retryRequestedDownloadLater(context, "attachment-1") + + ConnectMessagingAttachmentDownloadScheduler.downloadNow(context, "attachment-1") + + assertEquals( + WorkInfo.State.CANCELLED, + latestWork(ConnectMessagingAttachmentDownloadScheduler.requestedRetryWorkName("attachment-1")).state, + ) + assertEquals( + NetworkType.NOT_REQUIRED, + latestWork(ConnectMessagingAttachmentDownloadScheduler.requestedWorkName("attachment-1")).constraints.requiredNetworkType, + ) + } + + @Test + fun `applying a new setting replaces the automatic jobs`() { + ConnectMessagingPreferences.setAttachmentAutoDownload(context, AttachmentAutoDownload.LARGE_ON_WIFI_ONLY) + ConnectMessagingAttachmentDownloadScheduler.scheduleQueuedDownloads(context) + + ConnectMessagingPreferences.setAttachmentAutoDownload(context, AttachmentAutoDownload.ANY_NETWORK) + ConnectMessagingAttachmentDownloadScheduler.applyAutoDownloadSetting(context) + + assertEquals(NetworkType.CONNECTED, pendingNetworkType(ConnectMessagingAttachmentDownloadScheduler.ALL_FILES_WORK_NAME)) + assertTrue(pendingWork(ConnectMessagingAttachmentDownloadScheduler.LARGE_FILES_WORK_NAME).isEmpty()) + } + + @Test + fun `applying manual only cancels the automatic jobs`() { + ConnectMessagingAttachmentDownloadScheduler.scheduleQueuedDownloads(context) + + ConnectMessagingPreferences.setAttachmentAutoDownload(context, AttachmentAutoDownload.MANUAL_ONLY) + ConnectMessagingAttachmentDownloadScheduler.applyAutoDownloadSetting(context) + + assertTrue(automaticWork().isEmpty()) + } + + private fun automaticWork(): List = + listOf( + ConnectMessagingAttachmentDownloadScheduler.ALL_FILES_WORK_NAME, + ConnectMessagingAttachmentDownloadScheduler.SMALL_FILES_WORK_NAME, + ConnectMessagingAttachmentDownloadScheduler.LARGE_FILES_WORK_NAME, + ).flatMap { pendingWork(it) } + + private fun pendingWork(workName: String): List = + WorkManager + .getInstance(context) + .getWorkInfosForUniqueWork(workName) + .get() + .filter { !it.state.isFinished } + + private fun latestWork(workName: String): WorkInfo = + WorkManager + .getInstance(context) + .getWorkInfosForUniqueWork(workName) + .get() + .single() + + private fun pendingNetworkType(workName: String): NetworkType = pendingWork(workName).single().constraints.requiredNetworkType +} diff --git a/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloaderTest.kt b/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloaderTest.kt new file mode 100644 index 0000000000..11d256e331 --- /dev/null +++ b/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloaderTest.kt @@ -0,0 +1,251 @@ +package org.commcare.connect.messaging + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.commcare.CommCareTestApplication +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState +import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord +import org.commcare.connect.database.ConnectDatabaseHelper +import org.commcare.connect.database.ConnectMessagingAttachmentDatabaseHelper +import org.commcare.connect.database.ConnectMessagingAttachmentFileStore +import org.commcare.connect.database.ConnectMessagingDatabaseHelper +import org.commcare.util.Base64 +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.io.IOException +import java.util.Date + +@Config(application = CommCareTestApplication::class) +@RunWith(AndroidJUnit4::class) +class ConnectMessagingAttachmentDownloaderTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val encryptedVector = Base64.decode(TEST_VECTOR_BLOB) + private val fetchedAttachmentIds = mutableListOf() + + @Before + fun setUp() { + ConnectMessagingDatabaseHelper.storeMessagingChannel( + context, + ConnectMessagingChannelRecord().apply { + channelId = CHANNEL_ID + channelCreated = Date() + consented = true + channelSource = "" + keyUrl = "" + key = TEST_VECTOR_KEY + channelName = "" + }, + ) + } + + @After + fun tearDown() { + ConnectMessagingAttachmentFileStore.deleteAll(context) + ConnectDatabaseHelper.teardown() + } + + @Test + fun `downloaded attachment is decrypted to disk and becomes available`() { + storeQueuedAttachment("attachment-1", "message-1") + + val result = runPassWith { _, _, _ -> ok(encryptedVector) } + + assertEquals(ConnectMessagingAttachmentDownloader.PassResult.COMPLETE, result) + assertEquals(ConnectMessagingAttachmentState.AVAILABLE, stateOf("attachment-1")) + assertEquals( + "PersonalID attachment test vector", + ConnectMessagingAttachmentFileStore.fileFor(context, "attachment-1").readText(), + ) + } + + @Test + fun `size mismatch shows as failed straight away and asks for a retry`() { + storeQueuedAttachment("attachment-1", "message-1") + + val result = runPassWith { _, _, _ -> ok(encryptedVector.copyOf(TEST_VECTOR_SIZE - 1)) } + + assertEquals(ConnectMessagingAttachmentDownloader.PassResult.RETRY_LATER, result) + assertEquals(ConnectMessagingAttachmentState.FAILED, stateOf("attachment-1")) + assertEquals(1, attemptsOf("attachment-1")) + } + + @Test + fun `failed attachment with attempts left is retried by a later pass`() { + storeQueuedAttachment("attachment-1", "message-1", attempts = 1, state = ConnectMessagingAttachmentState.FAILED) + + runPassWith { _, _, _ -> ok(encryptedVector) } + + assertEquals(ConnectMessagingAttachmentState.AVAILABLE, stateOf("attachment-1")) + } + + @Test + fun `failed attachment with no attempts left waits for a tap`() { + storeQueuedAttachment("attachment-1", "message-1", attempts = 3, state = ConnectMessagingAttachmentState.FAILED) + + runPassWith { _, _, _ -> ok(encryptedVector) } + + assertEquals(emptyList(), fetchedAttachmentIds) + assertEquals(ConnectMessagingAttachmentState.FAILED, stateOf("attachment-1")) + } + + @Test + fun `third failed attempt marks the attachment failed`() { + storeQueuedAttachment("attachment-1", "message-1", attempts = 2) + + val result = runPassWith { _, _, _ -> throw IOException("connection reset") } + + assertEquals(ConnectMessagingAttachmentDownloader.PassResult.COMPLETE, result) + assertEquals(ConnectMessagingAttachmentState.FAILED, stateOf("attachment-1")) + assertFalse(ConnectMessagingAttachmentFileStore.fileFor(context, "attachment-1").exists()) + } + + @Test + fun `tampered bytes count as a failed attempt and write nothing`() { + storeQueuedAttachment("attachment-1", "message-1") + val tampered = encryptedVector.copyOf().also { it[20] = (it[20].toInt() xor 1).toByte() } + + runPassWith { _, _, _ -> ok(tampered) } + + assertEquals(1, attemptsOf("attachment-1")) + assertFalse(ConnectMessagingAttachmentFileStore.fileFor(context, "attachment-1").exists()) + } + + @Test + fun `server reporting the message gone marks the attachment expired`() { + storeQueuedAttachment("attachment-1", "message-1") + + runPassWith { _, _, _ -> ConnectMessagingAttachmentFetchResponse(410, null) } + + assertEquals(ConnectMessagingAttachmentState.EXPIRED, stateOf("attachment-1")) + } + + @Test + fun `auth failure stops the pass and fails the attachment without counting an attempt`() { + storeQueuedAttachment("attachment-1", "message-1", messageMinutesAgo = 2) + storeQueuedAttachment("attachment-2", "message-2", messageMinutesAgo = 1) + + val result = runPassWith { _, _, _ -> ConnectMessagingAttachmentFetchResponse(401, null) } + + assertEquals(ConnectMessagingAttachmentDownloader.PassResult.STOPPED_BY_AUTH_FAILURE, result) + assertEquals(listOf("attachment-1"), fetchedAttachmentIds) + assertEquals(ConnectMessagingAttachmentState.FAILED, stateOf("attachment-1")) + assertEquals(0, attemptsOf("attachment-1")) + assertEquals(ConnectMessagingAttachmentState.QUEUED, stateOf("attachment-2")) + } + + @Test + fun `attachment left downloading by a stopped job is downloaded again`() { + storeQueuedAttachment("attachment-1", "message-1", state = ConnectMessagingAttachmentState.DOWNLOADING) + + runPassWith { _, _, _ -> ok(encryptedVector) } + + assertEquals(ConnectMessagingAttachmentState.AVAILABLE, stateOf("attachment-1")) + } + + @Test + fun `attachments of older messages download first and waiting ones are left alone`() { + storeQueuedAttachment("newer", "message-newer", messageMinutesAgo = 1) + storeQueuedAttachment("older", "message-older", messageMinutesAgo = 5) + storeQueuedAttachment("tap-only", "message-tap", messageMinutesAgo = 9, state = ConnectMessagingAttachmentState.WAITING) + + runPassWith { _, _, _ -> ok(encryptedVector) } + + assertEquals(listOf("older", "newer"), fetchedAttachmentIds) + assertEquals(ConnectMessagingAttachmentState.WAITING, stateOf("tap-only")) + } + + @Test + fun `tapped attachments download ahead of older automatic ones`() { + storeQueuedAttachment("older", "message-older", messageMinutesAgo = 5) + storeQueuedAttachment("tapped", "message-tapped", messageMinutesAgo = 1, state = ConnectMessagingAttachmentState.REQUESTED) + + runPassWith { _, _, _ -> ok(encryptedVector) } + + assertEquals(listOf("tapped", "older"), fetchedAttachmentIds) + } + + @Test + fun `failed tapped download shows as failed straight away`() { + storeQueuedAttachment("attachment-1", "message-1", state = ConnectMessagingAttachmentState.REQUESTED) + + val result = runPassWith(requestedAttachmentId = "attachment-1") { _, _, _ -> throw IOException("connection refused") } + + assertEquals(ConnectMessagingAttachmentDownloader.PassResult.RETRY_LATER, result) + assertEquals(ConnectMessagingAttachmentState.FAILED, stateOf("attachment-1")) + } + + @Test + fun `tapped download only fetches the tapped attachment and marks other interrupted ones failed`() { + storeQueuedAttachment("tapped", "message-1", state = ConnectMessagingAttachmentState.REQUESTED) + storeQueuedAttachment("other", "message-2", state = ConnectMessagingAttachmentState.DOWNLOADING) + + runPassWith(requestedAttachmentId = "tapped") { _, _, _ -> ok(encryptedVector) } + + assertEquals(listOf("tapped"), fetchedAttachmentIds) + assertEquals(ConnectMessagingAttachmentState.AVAILABLE, stateOf("tapped")) + assertEquals(ConnectMessagingAttachmentState.FAILED, stateOf("other")) + } + + private fun runPassWith( + requestedAttachmentId: String? = null, + fetcher: ConnectMessagingAttachmentFetcher, + ): ConnectMessagingAttachmentDownloader.PassResult { + val recordingFetcher = + ConnectMessagingAttachmentFetcher { messageId, attachmentId, expectedSize -> + fetchedAttachmentIds.add(attachmentId) + fetcher.fetch(messageId, attachmentId, expectedSize) + } + val downloader = ConnectMessagingAttachmentDownloader(context, recordingFetcher) + return downloader.runPass({ requestedAttachmentId == null || it.attachmentId == requestedAttachmentId }) { false } + } + + private fun ok(body: ByteArray) = ConnectMessagingAttachmentFetchResponse(200, body) + + private fun storeQueuedAttachment( + attachmentId: String, + messageId: String, + messageMinutesAgo: Long = 1, + attempts: Int = 0, + state: ConnectMessagingAttachmentState = ConnectMessagingAttachmentState.QUEUED, + ) { + ConnectMessagingDatabaseHelper.storeMessagingMessage( + context, + ConnectMessagingMessageRecord().apply { + this.messageId = messageId + channelId = CHANNEL_ID + timeStamp = Date(System.currentTimeMillis() - messageMinutesAgo * 60_000) + message = "Hello" + }, + ) + ConnectMessagingAttachmentDatabaseHelper.save( + ConnectMessagingAttachmentRecord().apply { + this.attachmentId = attachmentId + this.messageId = messageId + name = "file" + type = "image/png" + size = TEST_VECTOR_SIZE.toLong() + downloadState = state + this.attempts = attempts + }, + ) + } + + private fun stateOf(attachmentId: String) = ConnectMessagingAttachmentDatabaseHelper.getAttachment(attachmentId)!!.downloadState + + private fun attemptsOf(attachmentId: String) = ConnectMessagingAttachmentDatabaseHelper.getAttachment(attachmentId)!!.attempts + + private companion object { + const val CHANNEL_ID = "channel-1" + const val TEST_VECTOR_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + const val TEST_VECTOR_BLOB = "oKGio6Slpqeoqaqrtn0OXiqlY9MrIaeycw6hvRjBPH7mlzYJ73oG8BrIAW6gb+xqABsBM0cg1w0oetetHQ==" + const val TEST_VECTOR_SIZE = 61 + } +} diff --git a/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingMessagePackageTest.kt b/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingMessagePackageTest.kt new file mode 100644 index 0000000000..2fef0abae8 --- /dev/null +++ b/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingMessagePackageTest.kt @@ -0,0 +1,47 @@ +package org.commcare.connect.messaging + +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState.AVAILABLE +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState.DOWNLOADING +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState.EXPIRED +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState.FAILED +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState.QUEUED +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState.REQUESTED +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState.WAITING +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConnectMessagingMessagePackageTest { + @Test + fun `message without attachments is complete`() { + assertEquals(AVAILABLE, ConnectMessagingMessagePackage.stateOf(emptyList())) + } + + @Test + fun `message is complete only once every attachment is available`() { + assertEquals(AVAILABLE, ConnectMessagingMessagePackage.stateOf(listOf(AVAILABLE, AVAILABLE))) + assertEquals(QUEUED, ConnectMessagingMessagePackage.stateOf(listOf(AVAILABLE, QUEUED))) + } + + @Test + fun `any expired attachment means the message can no longer be shown`() { + assertEquals(EXPIRED, ConnectMessagingMessagePackage.stateOf(listOf(AVAILABLE, DOWNLOADING, EXPIRED))) + } + + @Test + fun `a download in progress or about to start outranks a failed part`() { + assertEquals(DOWNLOADING, ConnectMessagingMessagePackage.stateOf(listOf(FAILED, DOWNLOADING))) + assertEquals(DOWNLOADING, ConnectMessagingMessagePackage.stateOf(listOf(FAILED, REQUESTED))) + } + + @Test + fun `a failed part with nothing in progress fails the message`() { + assertEquals(FAILED, ConnectMessagingMessagePackage.stateOf(listOf(AVAILABLE, FAILED, WAITING))) + assertEquals(FAILED, ConnectMessagingMessagePackage.stateOf(listOf(FAILED, QUEUED))) + } + + @Test + fun `parts not being downloaded leave the message ready to download`() { + assertEquals(WAITING, ConnectMessagingMessagePackage.stateOf(listOf(AVAILABLE, WAITING))) + assertEquals(QUEUED, ConnectMessagingMessagePackage.stateOf(listOf(WAITING, QUEUED))) + } +} diff --git a/app/unit-tests/src/org/commcare/connect/network/personalId/parser/NotificationTestUtil.kt b/app/unit-tests/src/org/commcare/connect/network/personalId/parser/NotificationTestUtil.kt index 694c122381..d1b8733472 100644 --- a/app/unit-tests/src/org/commcare/connect/network/personalId/parser/NotificationTestUtil.kt +++ b/app/unit-tests/src/org/commcare/connect/network/personalId/parser/NotificationTestUtil.kt @@ -1,6 +1,8 @@ package org.commcare.connect.network.personalId.parser import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord +import org.json.JSONArray +import org.json.JSONObject import java.io.ByteArrayInputStream /** @@ -100,6 +102,52 @@ object NotificationTestUtil { """.trimIndent() } + fun createRichMessagingNotification( + notificationId: String, + messageId: String, + channel: String, + content: String, + version: Int = 2, + richText: String? = null, + format: String? = null, + expiresAt: String? = null, + attachments: List? = emptyList(), + encryptionKey: String = TEST_ENCRYPTION_KEY, + ): String { + val json = + JSONObject( + createMessagingNotificationWithValidEncryption( + notificationId = notificationId, + messageId = messageId, + channel = channel, + messageContent = content, + encryptionKey = encryptionKey, + ), + ) + json.put("version", version) + richText?.let { + val encrypted = ConnectMessagingMessageRecord.encrypt(it, encryptionKey) + json.put( + "rich_text", + JSONObject() + .put("ciphertext", encrypted[0]) + .put("nonce", encrypted[1]) + .put("tag", encrypted[2]), + ) + } + format?.let { json.put("format", it) } + expiresAt?.let { json.put("expires_at", it) } + attachments?.let { json.put("attachments", JSONArray(it.joinToString(prefix = "[", postfix = "]"))) } + return json.toString() + } + + fun createAttachmentJson( + id: String, + name: String, + type: String = "image/png", + size: Long = 100, + ): String = """{"id": "$id", "name": "$name", "type": "$type", "size": $size}""" + fun createChannelJson( channelId: String, channelSource: String = "Test Channel", diff --git a/app/unit-tests/src/org/commcare/connect/network/personalId/parser/RetrieveNotificationsResponseParserTest.kt b/app/unit-tests/src/org/commcare/connect/network/personalId/parser/RetrieveNotificationsResponseParserTest.kt index 18c263377a..ee4b316746 100644 --- a/app/unit-tests/src/org/commcare/connect/network/personalId/parser/RetrieveNotificationsResponseParserTest.kt +++ b/app/unit-tests/src/org/commcare/connect/network/personalId/parser/RetrieveNotificationsResponseParserTest.kt @@ -4,10 +4,14 @@ import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 import org.commcare.CommCareTestApplication import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord import org.commcare.connect.database.ConnectMessagingDatabaseHelper +import org.javarosa.core.model.utils.DateUtils import org.json.JSONException +import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -253,8 +257,7 @@ class RetrieveNotificationsResponseParserTest { } @Test(expected = JSONException::class) - fun testParseNotificationMissingRequiredFields() { - // Test notification without required timestamp field + fun `push notification missing its timestamp fails the whole parse`() { val incompleteNotification = """ { @@ -263,15 +266,247 @@ class RetrieveNotificationsResponseParserTest { "notification_type": "PUSH" } """.trimIndent() - val response = NotificationTestUtil.createCompleteResponse(notifications = listOf(incompleteNotification)) + val validNotification = NotificationTestUtil.createPushNotificationJson("push_001", "Title 1") + val response = + NotificationTestUtil.createCompleteResponse( + notifications = listOf(incompleteNotification, validNotification), + ) + + parseWithStoredChannels(response, emptyList()) + } + + @Test(expected = JSONException::class) + fun `malformed messaging entry fails the whole parse`() { + val malformedMessage = + """ + { + "notification_id": "msg_bad", + "notification_type": "MESSAGING", + "channel": "channel_001" + } + """.trimIndent() + val validMessage = + NotificationTestUtil.createMessagingNotificationWithValidEncryption( + "msg_001", + "message_001", + "channel_001", + "Hello", + ) + val response = NotificationTestUtil.createCompleteResponse(notifications = listOf(malformedMessage, validMessage)) + + parseWithStoredChannels(response, listOf(channelWithTestKey("channel_001"))) + } + + @Test + fun `plain message is stored as version zero with no attachments`() { + val result = + parseMessages( + NotificationTestUtil.createMessagingNotificationWithValidEncryption("msg_001", "message_001", "channel_001", "Hello"), + ) + + val message = result.messages.single() + assertEquals(ConnectMessagingMessageRecord.VERSION_PLAIN, message.version) + assertEquals("Hello", message.displayText) + assertNull(message.richText) + assertTrue(result.attachments.isEmpty()) + } + + @Test + fun `version 2 message reads rich text, format, expiry and attachments in order`() { + val notification = + NotificationTestUtil.createRichMessagingNotification( + "msg_001", + "message_001", + "channel_001", + content = "Plain text", + richText = "**Rich** text", + format = "gallery", + expiresAt = "2026-02-01T00:00:00Z", + attachments = + listOf( + NotificationTestUtil.createAttachmentJson(FIRST_ATTACHMENT_ID, "site-map.jpg", "image/jpeg", 184279), + NotificationTestUtil.createAttachmentJson(SECOND_ATTACHMENT_ID, "instructions.mp3", "audio/mpeg", 412964), + ), + ) + + val result = parseMessages(notification) + + val message = result.messages.single() + assertTrue(message.isRich) + assertEquals("", message.message) + assertEquals("**Rich** text", message.displayText) + assertEquals("gallery", message.format) + assertEquals(DateUtils.parseDateTime("2026-02-01T00:00:00Z"), message.expiresAt) + assertEquals(listOf(FIRST_ATTACHMENT_ID, SECOND_ATTACHMENT_ID), result.attachments.map { it.attachmentId }) + assertEquals(listOf(0, 1), result.attachments.map { it.position }) + assertEquals(listOf("message_001", "message_001"), result.attachments.map { it.messageId }) + assertEquals(listOf("channel_001", "channel_001"), result.attachments.map { it.channelId }) + assertEquals("instructions.mp3", result.attachments[1].name) + assertEquals("audio/mpeg", result.attachments[1].type) + assertEquals(412964L, result.attachments[1].size) + } + + @Test + fun `version 2 message without optional fields shows its content`() { + val result = + parseMessages( + NotificationTestUtil.createRichMessagingNotification("msg_001", "message_001", "channel_001", content = "Plain text"), + ) + + val message = result.messages.single() + assertTrue(message.isRich) + assertEquals("Plain text", message.displayText) + assertNull(message.format) + assertNull(message.expiresAt) + assertTrue(result.attachments.isEmpty()) + } + + @Test + fun `version 2 message with an empty rich text field shows no text rather than its content`() { + val notification = + JSONObject( + NotificationTestUtil.createRichMessagingNotification( + "msg_001", + "message_001", + "channel_001", + content = "Legacy placeholder", + attachments = listOf(NotificationTestUtil.createAttachmentJson(FIRST_ATTACHMENT_ID, "site-map.jpg")), + ), + ).put("rich_text", "") + + val result = parseMessages(notification.toString()) + + val message = result.messages.single() + assertEquals("", message.richText) + assertEquals("", message.message) + assertEquals("", message.displayText) + assertEquals(1, result.attachments.size) + } + + @Test + fun `version 2 message whose rich text does not decrypt falls back to content`() { + val notification = + NotificationTestUtil.createRichMessagingNotification( + "msg_001", + "message_001", + "channel_001", + content = "Plain text", + richText = "Rich text", + encryptionKey = NotificationTestUtil.TEST_ENCRYPTION_KEY, + ) + val richTextWithBadCipher = + JSONObject(notification).apply { + getJSONObject("rich_text").put("ciphertext", "AAAA") + } + + val result = parseMessages(richTextWithBadCipher.toString()) + + assertEquals("Plain text", result.messages.single().displayText) + } + + @Test + fun `unknown version keeps none of the message and is left unacknowledged for redelivery`() { + val notification = + NotificationTestUtil.createRichMessagingNotification( + "msg_001", + "message_001", + "channel_001", + content = "Plain text", + version = 3, + richText = "Rich text", + expiresAt = "2030-01-01T00:00:00Z", + attachments = listOf(NotificationTestUtil.createAttachmentJson(FIRST_ATTACHMENT_ID, "site-map.jpg")), + ) + + val result = parseMessages(notification) + + val message = result.messages.single() + assertTrue(message.isUnsupportedVersion) + assertEquals("", message.message) + assertEquals("", message.displayText) + assertNull(message.expiresAt) + assertTrue(result.attachments.isEmpty()) + assertTrue(result.messagingNotificationIds.isEmpty()) + } + + @Test(expected = JSONException::class) + fun `version 2 message with a malformed attachment fails the whole parse`() { + val malformed = + NotificationTestUtil.createRichMessagingNotification( + "msg_bad", + "message_bad", + "channel_001", + content = "Broken", + attachments = listOf("""{"name": "x"}"""), + ) + val valid = + NotificationTestUtil.createRichMessagingNotification( + "msg_001", + "message_001", + "channel_001", + content = "Fine", + attachments = listOf(NotificationTestUtil.createAttachmentJson(FIRST_ATTACHMENT_ID, "site-map.jpg")), + ) + + parseMessages(malformed, valid) + } + @Test(expected = JSONException::class) + fun `attachment whose id is not a UUID fails the whole parse`() { + val notification = + NotificationTestUtil.createRichMessagingNotification( + "msg_001", + "message_001", + "channel_001", + content = "Plain text", + attachments = listOf(NotificationTestUtil.createAttachmentJson("../escape", "site-map.jpg")), + ) + + parseMessages(notification) + } + + @Test(expected = JSONException::class) + fun `version 2 message with an invalid expiry fails the whole parse`() { + val notification = + NotificationTestUtil.createRichMessagingNotification( + "msg_001", + "message_001", + "channel_001", + content = "Plain text", + expiresAt = "next tuesday", + ) + + parseMessages(notification) + } + + private fun parseMessages(vararg messagingNotifications: String): NotificationParseResult = + parseWithStoredChannels( + NotificationTestUtil.createCompleteResponse(notifications = messagingNotifications.toList()), + listOf(channelWithTestKey("channel_001")), + ) + + private fun parseWithStoredChannels( + response: String, + storedChannels: List, + ): NotificationParseResult = mockStatic(ConnectMessagingDatabaseHelper::class.java).use { mockedHelper -> mockedHelper .`when`> { ConnectMessagingDatabaseHelper.getMessagingChannels(any()) - }.thenReturn(emptyList()) - + }.thenReturn(storedChannels) parseResponse(response) } + + private fun channelWithTestKey(channelId: String): ConnectMessagingChannelRecord { + val channel = mock(ConnectMessagingChannelRecord::class.java) + `when`(channel.channelId).thenReturn(channelId) + `when`(channel.key).thenReturn(NotificationTestUtil.TEST_ENCRYPTION_KEY) + `when`(channel.consented).thenReturn(true) + return channel + } + + private companion object { + const val FIRST_ATTACHMENT_ID = "5b0c1e7a-0f6b-4b8e-9d2a-1c3e5f7a9b0d" + const val SECOND_ATTACHMENT_ID = "9e1d2f3a-4b5c-4d6e-8f90-a1b2c3d4e5f6" } } diff --git a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt new file mode 100644 index 0000000000..0f095e8d3a --- /dev/null +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt @@ -0,0 +1,223 @@ +package org.commcare.fragments.connectMessaging + +import android.content.Context +import android.view.ContextThemeWrapper +import android.view.View +import android.widget.LinearLayout +import androidx.exifinterface.media.ExifInterface +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.commcare.CommCareTestApplication +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState +import org.commcare.dalvik.R +import org.commcare.dalvik.databinding.ViewConnectMessageAttachmentFileBinding +import org.commcare.dalvik.databinding.ViewConnectMessageAttachmentPendingBinding +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.io.File + +@Config(application = CommCareTestApplication::class) +@RunWith(AndroidJUnit4::class) +class ConnectMessageAttachmentsBinderTest { + private val context: Context = ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.ConnectTheme) + private val container = LinearLayout(context) + private val requestedDownloads = mutableListOf() + private val openedAttachments = mutableListOf() + private val openedImages = mutableListOf>() + private val binder = + ConnectMessageAttachmentsBinder( + container, + object : ConnectMessageAttachmentListener { + override fun onMessageDownloadRequested(messageId: String) { + requestedDownloads.add(messageId) + } + + override fun onAttachmentOpenRequested(attachment: ConnectMessageAttachmentItem) { + openedAttachments.add(attachment.attachmentId) + } + + override fun onImageOpenRequested( + attachment: ConnectMessageAttachmentItem, + imageView: View, + ) { + openedImages.add(attachment.attachmentId to imageView) + } + }, + ) + + @Test + fun `no attachments hides the attachment block`() { + binder.bind(emptyList(), MAX_WIDTH, MAX_HEIGHT) + + assertEquals(View.GONE, container.visibility) + } + + @Test + fun `downloaded audio shows the inline player and fills the bubble`() { + val layout = binder.bind(listOf(available("audio", "audio/mpeg")), MAX_WIDTH, MAX_HEIGHT) + + assertTrue(container.getChildAt(0) is ConnectMessageAudioAttachmentView) + assertTrue(layout.fillsBubbleWidth) + } + + @Test + fun `phone photo stored sideways is sized by its upright orientation`() { + val photo = image("sideways-photo", storedWidth = 400, storedHeight = 300, ExifInterface.ORIENTATION_ROTATE_90) + + binder.bind(listOf(photo), MAX_WIDTH, MAX_HEIGHT) + + val size = container.getChildAt(0).layoutParams + assertTrue("expected portrait, got ${size.width}x${size.height}", size.height > size.width) + } + + @Test + fun `photo without rotation keeps its stored orientation`() { + val photo = image("upright-photo", storedWidth = 400, storedHeight = 300) + + binder.bind(listOf(photo), MAX_WIDTH, MAX_HEIGHT) + + val size = container.getChildAt(0).layoutParams + assertTrue("expected landscape, got ${size.width}x${size.height}", size.width > size.height) + } + + @Test + fun `image large enough to see opens the viewer when tapped`() { + val photo = image("tappable-photo", storedWidth = 400, storedHeight = 300) + + binder.bind(listOf(photo), MAX_WIDTH, MAX_HEIGHT) + val imageView = container.getChildAt(0) + imageView.performClick() + + assertEquals(listOf("tappable-photo" to imageView), openedImages) + assertEquals(ConnectMessageImageViewerFragment.transitionNameFor("tappable-photo"), imageView.transitionName) + } + + @Test + fun `image smaller than the minimum size is shown at that size but cannot be opened`() { + val minimumSize = context.resources.getDimensionPixelSize(R.dimen.connect_touch_target_min) + val icon = image("tiny-icon", storedWidth = 16, storedHeight = 16) + + binder.bind(listOf(icon), MAX_WIDTH, MAX_HEIGHT) + + val view = container.getChildAt(0) + assertEquals(minimumSize, view.layoutParams.width) + assertFalse(view.hasOnClickListeners()) + } + + @Test + fun `downloaded file of another type shows a chip that opens it`() { + binder.bind(listOf(available("doc", "application/pdf")), MAX_WIDTH, MAX_HEIGHT) + + val chip = ViewConnectMessageAttachmentFileBinding.bind(container.getChildAt(0)) + assertEquals("doc.bin", chip.tvFileName.text.toString()) + chip.root.performClick() + assertEquals(listOf("doc"), openedAttachments) + } + + @Test + fun `message waiting for download shows one download tile that requests the whole message`() { + val tile = bindPendingTile(ConnectMessagingAttachmentState.WAITING) + + assertEquals(context.getString(R.string.connect_messaging_attachment_download), tile.tvLabel.text.toString()) + assertEquals(1, container.childCount) + tile.root.performClick() + assertEquals(listOf("message-1"), requestedDownloads) + } + + @Test + fun `message queued for automatic download shows the download tile until a download starts`() { + val tile = bindPendingTile(ConnectMessagingAttachmentState.QUEUED) + + assertEquals(View.GONE, tile.progress.visibility) + assertEquals(context.getString(R.string.connect_messaging_attachment_download), tile.tvLabel.text.toString()) + tile.root.performClick() + assertEquals(listOf("message-1"), requestedDownloads) + } + + @Test + fun `failed message download offers a retry`() { + val tile = bindPendingTile(ConnectMessagingAttachmentState.FAILED) + + assertEquals(context.getString(R.string.connect_messaging_attachment_download_failed), tile.tvLabel.text.toString()) + assertEquals(View.VISIBLE, tile.tvSecondaryLabel.visibility) + tile.root.performClick() + assertEquals(listOf("message-1"), requestedDownloads) + } + + @Test + fun `downloading or just tapped message shows progress and ignores taps`() { + for (state in listOf(ConnectMessagingAttachmentState.DOWNLOADING, ConnectMessagingAttachmentState.REQUESTED)) { + val tile = bindPendingTile(state) + + assertEquals(View.VISIBLE, tile.progress.visibility) + assertEquals(View.GONE, tile.ivAction.visibility) + assertFalse(tile.root.hasOnClickListeners()) + } + } + + @Test + fun `expired message says it is no longer available and ignores taps`() { + val tile = bindPendingTile(ConnectMessagingAttachmentState.EXPIRED) + + assertEquals(context.getString(R.string.connect_messaging_attachment_expired), tile.tvLabel.text.toString()) + assertEquals(View.GONE, tile.ivAction.visibility) + assertFalse(tile.root.hasOnClickListeners()) + } + + @Test + fun `message too new for this app shows an update placeholder with no action`() { + binder.bindUnsupportedMessage() + val tile = ViewConnectMessageAttachmentPendingBinding.bind(container.getChildAt(0)) + + assertEquals(context.getString(R.string.connect_messaging_update_app_notice), tile.tvLabel.text.toString()) + assertEquals(View.GONE, tile.flAction.visibility) + assertFalse(tile.root.hasOnClickListeners()) + } + + private fun bindPendingTile(state: ConnectMessagingAttachmentState): ViewConnectMessageAttachmentPendingBinding { + binder.bindPendingMessage("message-1", state) + return ViewConnectMessageAttachmentPendingBinding.bind(container.getChildAt(0)) + } + + private fun available( + attachmentId: String, + mimeType: String, + ): ConnectMessageAttachmentItem { + val file = File(context.cacheDir, attachmentId).apply { writeBytes(byteArrayOf(1, 2, 3)) } + return ConnectMessageAttachmentItem( + attachmentId, + "$attachmentId.bin", + mimeType, + 3, + ConnectMessagingAttachmentState.AVAILABLE, + file, + ) + } + + private fun image( + attachmentId: String, + storedWidth: Int, + storedHeight: Int, + exifOrientation: Int = ExifInterface.ORIENTATION_NORMAL, + ): ConnectMessageAttachmentItem { + val file = + ConnectMessageTestImages.writePng(File(context.cacheDir, attachmentId), storedWidth, storedHeight, exifOrientation) + return ConnectMessageAttachmentItem( + attachmentId, + "$attachmentId.png", + "image/png", + file.length(), + ConnectMessagingAttachmentState.AVAILABLE, + file, + ) + } + + private companion object { + const val MAX_WIDTH = 344 + const val MAX_HEIGHT = 361 + } +} diff --git a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerTest.kt b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerTest.kt new file mode 100644 index 0000000000..5cd460f2a3 --- /dev/null +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerTest.kt @@ -0,0 +1,287 @@ +package org.commcare.fragments.connectMessaging + +import android.content.Context +import android.os.Bundle +import android.text.format.DateUtils +import android.view.View +import android.widget.TextView +import androidx.navigation.fragment.NavHostFragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import okhttp3.mockwebserver.MockResponse +import org.commcare.CommCareTestApplication +import org.commcare.activities.connect.ConnectMessagingActivity +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState +import org.commcare.android.database.connect.models.ConnectMessagingChannelRecord +import org.commcare.android.database.connect.models.ConnectMessagingMessageRecord +import org.commcare.android.util.ConnectTestUtils.signInToPersonalId +import org.commcare.connect.PersonalIdManager +import org.commcare.connect.database.ConnectDatabaseHelper +import org.commcare.connect.database.ConnectMessagingAttachmentDatabaseHelper +import org.commcare.connect.database.ConnectMessagingAttachmentFileStore +import org.commcare.connect.database.ConnectMessagingDatabaseHelper +import org.commcare.connect.network.PersonalIdMockApiServer +import org.commcare.connect.network.personalId.parser.NotificationTestUtil +import org.commcare.dalvik.R +import org.commcare.dalvik.databinding.ItemChatLeftRichViewBinding +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLooper +import java.util.Date +import java.util.concurrent.TimeUnit + +@Config(application = CommCareTestApplication::class) +@RunWith(AndroidJUnit4::class) +class ConnectMessageImageViewerTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val mockApi = PersonalIdMockApiServer() + private lateinit var activity: ConnectMessagingActivity + private lateinit var savedPersonalIdStatus: PersonalIdManager.PersonalIdStatus + private lateinit var channel: ConnectMessagingChannelRecord + + @Before + fun setUp() { + mockApi.start() + repeat(SYNC_RESPONSES) { + mockApi.server.enqueue(MockResponse().setBody("""{"notifications": [], "channels": []}""")) + } + savedPersonalIdStatus = PersonalIdManager.getInstance().status + signInToPersonalId(userId = "test-user", password = "test-password") + channel = + ConnectMessagingChannelRecord().apply { + channelId = CHANNEL_ID + channelCreated = Date() + consented = true + channelSource = "" + keyUrl = "" + key = NotificationTestUtil.TEST_ENCRYPTION_KEY + channelName = "Site team" + } + ConnectMessagingDatabaseHelper.storeMessagingChannel(context, channel) + } + + @After + fun tearDown() { + mockApi.shutdown() + ConnectMessagingAttachmentFileStore.deleteAll(context) + ConnectDatabaseHelper.teardown() + PersonalIdManager.getInstance().status = savedPersonalIdStatus + } + + @Test + fun `tapping an image opens the viewer with its date and caption, and back returns to the chat`() { + storeImageMessage(caption = "Site map for Thursday") + openChat() + + chatImage().performClick() + settle() + + assertEquals(R.id.connectMessageImageViewerFragment, navController().currentDestination?.id) + assertEquals(expectedTitle(), activity.title.toString()) + val caption = activity.findViewById(R.id.tvCaption) + assertEquals(View.VISIBLE, caption.visibility) + assertEquals("Site map for Thursday", caption.text.toString().trim()) + + activity.onBackPressedDispatcher.onBackPressed() + settle() + + assertEquals(R.id.connectMessageFragment, navController().currentDestination?.id) + } + + @Test + fun `viewer grows out of the tapped image through a shared transition name`() { + storeImageMessage(caption = "Site map for Thursday") + openChat() + val image = chatImage() + + image.performClick() + settle() + + val transitionImage = activity.findViewById(R.id.ivTransitionImage) + assertEquals(ConnectMessageImageViewerFragment.transitionNameFor(ATTACHMENT_ID), image.transitionName) + assertEquals(image.transitionName, transitionImage.transitionName) + } + + @Test + fun `going back hands the image to the shrinking copy`() { + storeImageMessage(caption = "Site map for Thursday") + openChat() + chatImage().performClick() + settle() + val transitionImage = activity.findViewById(R.id.ivTransitionImage) + val zoomableImage = activity.findViewById(R.id.ivImage) + + activity.onBackPressedDispatcher.onBackPressed() + ShadowLooper.idleMainLooper() + + assertEquals(View.VISIBLE, transitionImage.visibility) + assertEquals(0f, zoomableImage.alpha, 0f) + } + + @Test + fun `zoomable image stays drawn while it loads so it can take over and be pinched`() { + storeImageMessage(caption = "Site map for Thursday") + openChat() + + chatImage().performClick() + settle() + + assertEquals(View.VISIBLE, activity.findViewById(R.id.ivImage).visibility) + } + + @Test + fun `returning from the viewer keeps the tapped image on screen for the shrink back`() { + storeImageMessage(caption = "Site map for Thursday") + storePlainMessages(LATER_MESSAGES) + openChat() + val chat = layOutChat() + chat.scrollToPosition(0) + layOutChat() + + imageAt(chat, 0).performClick() + settle() + assertEquals(R.id.connectMessageImageViewerFragment, navController().currentDestination?.id) + activity.onBackPressedDispatcher.onBackPressed() + settle() + + val returnedChat = layOutChat() + assertNotSame(chat, returnedChat) + assertEquals(0, (returnedChat.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()) + } + + @Test + fun `media-only message shows the image without a caption`() { + storeImageMessage(caption = "") + openChat() + + chatImage().performClick() + settle() + + assertEquals(View.GONE, activity.findViewById(R.id.tvCaption).visibility) + } + + @Test + fun `viewer closes when the image file is gone`() { + storeImageMessage(caption = "Site map for Thursday", writeFile = false) + openChat() + + navController().navigate( + R.id.connectMessageImageViewerFragment, + Bundle().apply { putString("attachment_id", ATTACHMENT_ID) }, + ) + ShadowLooper.idleMainLooper() + + assertEquals(R.id.connectMessageFragment, navController().currentDestination?.id) + } + + private fun storeImageMessage( + caption: String, + writeFile: Boolean = true, + ) { + val json = + JSONObject( + NotificationTestUtil.createRichMessagingNotification( + "notification-1", + MESSAGE_ID, + CHANNEL_ID, + content = "[Image]", + richText = caption.ifEmpty { null }, + attachments = listOf(NotificationTestUtil.createAttachmentJson(ATTACHMENT_ID, "site-map.png")), + ), + ) + if (caption.isEmpty()) { + json.put("rich_text", "") + } + val message = ConnectMessagingMessageRecord.fromJson(json, listOf(channel))!! + ConnectMessagingDatabaseHelper.storeMessagingMessages(context, listOf(message), false) + for (attachment in ConnectMessagingAttachmentRecord.listFromMessageJson(json, MESSAGE_ID, CHANNEL_ID)) { + attachment.downloadState = ConnectMessagingAttachmentState.AVAILABLE + ConnectMessagingAttachmentDatabaseHelper.save(attachment) + } + if (writeFile) { + ConnectMessagingAttachmentFileStore.directory(context).mkdirs() + ConnectMessageTestImages.writePng(ConnectMessagingAttachmentFileStore.fileFor(context, ATTACHMENT_ID), 400, 300) + } + } + + private fun storePlainMessages(count: Int) { + val messages = + (1..count).map { index -> + ConnectMessagingMessageRecord().apply { + messageId = "later-$index" + channelId = CHANNEL_ID + timeStamp = Date() + message = "Later message $index" + isOutgoing = false + } + } + ConnectMessagingDatabaseHelper.storeMessagingMessages(context, messages, false) + } + + private fun settle() { + ShadowLooper.idleMainLooper(SETTLE_TIME_MS, TimeUnit.MILLISECONDS) + } + + private fun expectedTitle(): String { + val message = ConnectMessagingAttachmentDatabaseHelper.getMessage(MESSAGE_ID)!! + return DateUtils.formatDateTime( + activity, + message.timeStamp.time, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_MONTH, + ) + } + + private fun openChat() { + activity = Robolectric.buildActivity(ConnectMessagingActivity::class.java).setup().get() + mockApi.drainHttp() + navController().navigate(R.id.connectMessageFragment, Bundle().apply { putString("channel_id", CHANNEL_ID) }) + ShadowLooper.idleMainLooper() + mockApi.drainHttp() + ShadowLooper.idleMainLooper() + } + + private fun chatImage(): View = imageAt(layOutChat(), 0) + + private fun layOutChat(): RecyclerView { + val chat = activity.findViewById(R.id.rvChat) + chat.measure( + View.MeasureSpec.makeMeasureSpec(CHAT_WIDTH_PX, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(CHAT_HEIGHT_PX, View.MeasureSpec.EXACTLY), + ) + chat.layout(0, 0, CHAT_WIDTH_PX, CHAT_HEIGHT_PX) + return chat + } + + private fun imageAt( + chat: RecyclerView, + position: Int, + ): View { + val row = ItemChatLeftRichViewBinding.bind(chat.findViewHolderForAdapterPosition(position)!!.itemView) + return row.llAttachments.getChildAt(0) + } + + private fun navController() = + (activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment_connect_messaging) as NavHostFragment) + .navController + + private companion object { + const val CHANNEL_ID = "channel-1" + const val MESSAGE_ID = "message-1" + const val ATTACHMENT_ID = "3f0c4a52-9d1e-4b7a-8c2d-5e6f7a8b9c0d" + const val SYNC_RESPONSES = 3 + const val CHAT_WIDTH_PX = 1080 + const val CHAT_HEIGHT_PX = 1920 + const val LATER_MESSAGES = 25 + const val SETTLE_TIME_MS = 2000L + } +} diff --git a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageZoomTest.kt b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageZoomTest.kt new file mode 100644 index 0000000000..831200279f --- /dev/null +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageZoomTest.kt @@ -0,0 +1,59 @@ +package org.commcare.fragments.connectMessaging + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConnectMessageImageZoomTest { + @Test + fun `small image opens at its natural size in dp and zooms to twice that`() { + val limits = limitsFor(200, 100) + + assertEquals(PIXELS_PER_DP, limits.minScale, DELTA) + assertEquals(2 * PIXELS_PER_DP, limits.maxScale, DELTA) + assertEquals(2 * PIXELS_PER_DP, limits.doubleTapScale, DELTA) + } + + @Test + fun `large photo opens fitted and zooms to two screen pixels per image pixel`() { + val limits = limitsFor(4000, 3000) + + assertEquals(0.25f, limits.minScale, DELTA) + assertEquals(2f, limits.maxScale, DELTA) + assertEquals(1f, limits.doubleTapScale, DELTA) + } + + @Test + fun `image slightly larger than the screen still doubles on double tap`() { + val limits = limitsFor(1250, 800) + + assertEquals(0.8f, limits.minScale, DELTA) + assertEquals(1.6f, limits.doubleTapScale, DELTA) + } + + @Test + fun `image whose natural size overflows the screen opens fitted and can zoom to twice that`() { + val limits = limitsFor(800, 600) + + assertEquals(1.25f, limits.minScale, DELTA) + assertEquals(2.5f, limits.maxScale, DELTA) + } + + @Test + fun `tall image is fitted by its height so it is fully visible`() { + val limits = limitsFor(1000, 4000) + + assertEquals(0.5f, limits.minScale, DELTA) + } + + private fun limitsFor( + imageWidth: Int, + imageHeight: Int, + ) = ConnectMessageImageZoom.limitsFor(imageWidth, imageHeight, VIEW_WIDTH, VIEW_HEIGHT, PIXELS_PER_DP) + + private companion object { + const val VIEW_WIDTH = 1000 + const val VIEW_HEIGHT = 2000 + const val PIXELS_PER_DP = 2.5f + const val DELTA = 0.001f + } +} diff --git a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizerTest.kt b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizerTest.kt new file mode 100644 index 0000000000..668d5785ad --- /dev/null +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizerTest.kt @@ -0,0 +1,72 @@ +package org.commcare.fragments.connectMessaging + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectMessageMediaSizerTest { + @Test + fun `wide photo fills the available width`() { + val size = fit(4000, 3000) + + assertEquals(ConnectMessageMediaSizer.Size(344, 258), size) + } + + @Test + fun `tall photo stops at the maximum height and narrows to keep its shape`() { + val size = fit(3000, 4000) + + assertEquals(ConnectMessageMediaSizer.Size(271, 361), size) + } + + @Test + fun `small image is shown at its natural size in dp rather than stretched`() { + val size = fit(100, 60) + + assertEquals(ConnectMessageMediaSizer.Size(200, 120), size) + } + + @Test + fun `tiny image is raised to the minimum size`() { + val size = fit(16, 16) + + assertEquals(ConnectMessageMediaSizer.Size(MINIMUM_SIZE, MINIMUM_SIZE), size) + } + + @Test + fun `thin image is raised until its shorter side reaches the minimum`() { + val size = fit(100, 20) + + assertEquals(ConnectMessageMediaSizer.Size(344, 69), size) + } + + @Test + fun `only images at least the minimum size in both dimensions open the viewer`() { + assertTrue(ConnectMessageMediaSizer.isLargeEnoughToOpen(48, 48, PIXELS_PER_DP, MINIMUM_SIZE)) + assertFalse(ConnectMessageMediaSizer.isLargeEnoughToOpen(16, 16, PIXELS_PER_DP, MINIMUM_SIZE)) + assertFalse(ConnectMessageMediaSizer.isLargeEnoughToOpen(400, 40, PIXELS_PER_DP, MINIMUM_SIZE)) + } + + @Test(expected = IllegalArgumentException::class) + fun `image without dimensions is rejected`() { + fit(0, 100) + } + + private fun fit( + width: Int, + height: Int, + ) = ConnectMessageMediaSizer.fitImage( + width, + height, + maxWidth = 344, + maxHeight = 361, + pixelsPerDp = PIXELS_PER_DP, + minimumSize = MINIMUM_SIZE, + ) + + private companion object { + const val PIXELS_PER_DP = 2f + const val MINIMUM_SIZE = 96 + } +} diff --git a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageTestImages.kt b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageTestImages.kt new file mode 100644 index 0000000000..21f6e447fe --- /dev/null +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageTestImages.kt @@ -0,0 +1,60 @@ +package org.commcare.fragments.connectMessaging + +import androidx.exifinterface.media.ExifInterface +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.io.File +import java.util.zip.CRC32 +import java.util.zip.DeflaterOutputStream + +object ConnectMessageTestImages { + private val PNG_SIGNATURE = byteArrayOf(0x89.toByte(), 'P'.code.toByte(), 'N'.code.toByte(), 'G'.code.toByte(), 13, 10, 26, 10) + + fun writePng( + file: File, + storedWidth: Int, + storedHeight: Int, + exifOrientation: Int = ExifInterface.ORIENTATION_NORMAL, + ): File { + DataOutputStream(file.outputStream()).use { out -> + out.write(PNG_SIGNATURE) + writeChunk( + out, + "IHDR", + ByteArrayOutputStream() + .also { + DataOutputStream(it).apply { + writeInt(storedWidth) + writeInt(storedHeight) + write(byteArrayOf(8, 0, 0, 0, 0)) + } + }.toByteArray(), + ) + val pixels = ByteArrayOutputStream() + DeflaterOutputStream(pixels).use { deflater -> + repeat(storedHeight) { deflater.write(ByteArray(storedWidth + 1)) } + } + writeChunk(out, "IDAT", pixels.toByteArray()) + writeChunk(out, "IEND", ByteArray(0)) + } + if (exifOrientation != ExifInterface.ORIENTATION_NORMAL) { + ExifInterface(file).apply { + setAttribute(ExifInterface.TAG_ORIENTATION, exifOrientation.toString()) + saveAttributes() + } + } + return file + } + + private fun writeChunk( + out: DataOutputStream, + type: String, + data: ByteArray, + ) { + val typeBytes = type.toByteArray(Charsets.US_ASCII) + out.writeInt(data.size) + out.write(typeBytes) + out.write(data) + out.writeInt(CRC32().apply { update(typeBytes + data) }.value.toInt()) + } +} diff --git a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageUnsupportedVersionTest.kt b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageUnsupportedVersionTest.kt new file mode 100644 index 0000000000..d2eef90692 --- /dev/null +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageUnsupportedVersionTest.kt @@ -0,0 +1,142 @@ +package org.commcare.fragments.connectMessaging + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.navigation.fragment.NavHostFragment +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import okhttp3.mockwebserver.MockResponse +import org.commcare.CommCareTestApplication +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.android.util.ConnectTestUtils.signInToPersonalId +import org.commcare.connect.PersonalIdManager +import org.commcare.connect.database.ConnectDatabaseHelper +import org.commcare.connect.database.ConnectMessagingDatabaseHelper +import org.commcare.connect.network.PersonalIdMockApiServer +import org.commcare.connect.network.personalId.parser.NotificationTestUtil +import org.commcare.dalvik.R +import org.commcare.dalvik.databinding.ItemChatLeftRichViewBinding +import org.commcare.dalvik.databinding.ViewConnectMessageAttachmentPendingBinding +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLooper +import java.util.Date + +@Config(application = CommCareTestApplication::class) +@RunWith(AndroidJUnit4::class) +class ConnectMessageUnsupportedVersionTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val mockApi = PersonalIdMockApiServer() + private lateinit var activity: ConnectMessagingActivity + private lateinit var savedPersonalIdStatus: PersonalIdManager.PersonalIdStatus + private lateinit var channel: ConnectMessagingChannelRecord + + @Before + fun setUp() { + mockApi.start() + repeat(SYNC_RESPONSES) { + mockApi.server.enqueue(MockResponse().setBody("""{"notifications": [], "channels": []}""")) + } + savedPersonalIdStatus = PersonalIdManager.getInstance().status + signInToPersonalId(userId = "test-user", password = "test-password") + channel = + ConnectMessagingChannelRecord().apply { + channelId = CHANNEL_ID + channelCreated = Date() + consented = true + channelSource = "" + keyUrl = "" + key = NotificationTestUtil.TEST_ENCRYPTION_KEY + channelName = "Site team" + } + ConnectMessagingDatabaseHelper.storeMessagingChannel(context, channel) + } + + @After + fun tearDown() { + mockApi.shutdown() + ConnectDatabaseHelper.teardown() + PersonalIdManager.getInstance().status = savedPersonalIdStatus + } + + @Test + fun `message too new for this app shows a placeholder and an update banner`() { + storeMessage(version = 3) + + openChat() + + assertEquals(View.VISIBLE, activity.findViewById(R.id.tvUpdateBanner).visibility) + val row = ItemChatLeftRichViewBinding.bind(chatRow(0)) + assertEquals(View.GONE, row.tvChatMessage.visibility) + val tile = ViewConnectMessageAttachmentPendingBinding.bind(row.llAttachments.getChildAt(0)) + assertEquals(context.getString(R.string.connect_messaging_update_app_notice), tile.tvLabel.text.toString()) + } + + @Test + fun `channel with only supported messages shows no update banner`() { + storeMessage(version = 2) + + openChat() + + assertEquals(View.GONE, activity.findViewById(R.id.tvUpdateBanner).visibility) + } + + private fun storeMessage(version: Int) { + val json = + JSONObject( + NotificationTestUtil.createRichMessagingNotification( + "notification-1", + "message-1", + CHANNEL_ID, + content = "Placeholder from the server", + version = version, + ), + ) + ConnectMessagingDatabaseHelper.storeMessagingMessages( + context, + listOf(ConnectMessagingMessageRecord.fromJson(json, listOf(channel))!!), + false, + ) + } + + private fun openChat() { + activity = Robolectric.buildActivity(ConnectMessagingActivity::class.java).setup().get() + mockApi.drainHttp() + navController().navigate(R.id.connectMessageFragment, Bundle().apply { putString("channel_id", CHANNEL_ID) }) + ShadowLooper.idleMainLooper() + mockApi.drainHttp() + ShadowLooper.idleMainLooper() + } + + private fun chatRow(position: Int): View { + val chat = activity.findViewById(R.id.rvChat) + chat.measure( + View.MeasureSpec.makeMeasureSpec(CHAT_WIDTH_PX, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(CHAT_HEIGHT_PX, View.MeasureSpec.EXACTLY), + ) + chat.layout(0, 0, CHAT_WIDTH_PX, CHAT_HEIGHT_PX) + return chat.findViewHolderForAdapterPosition(position)!!.itemView + } + + private fun navController() = + (activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment_connect_messaging) as NavHostFragment) + .navController + + private companion object { + const val CHANNEL_ID = "channel-1" + const val SYNC_RESPONSES = 3 + const val CHAT_WIDTH_PX = 1080 + const val CHAT_HEIGHT_PX = 1920 + } +} diff --git a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessagingSettingsTest.kt b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessagingSettingsTest.kt new file mode 100644 index 0000000000..68795f2d6c --- /dev/null +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessagingSettingsTest.kt @@ -0,0 +1,144 @@ +package org.commcare.fragments.connectMessaging + +import android.content.Context +import android.view.Menu +import android.widget.RadioButton +import androidx.navigation.fragment.NavHostFragment +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.NetworkType +import androidx.work.WorkInfo +import androidx.work.WorkManager +import okhttp3.mockwebserver.MockResponse +import org.commcare.CommCareTestApplication +import org.commcare.activities.connect.ConnectMessagingActivity +import org.commcare.android.util.ConnectTestUtils.signInToPersonalId +import org.commcare.connect.PersonalIdManager +import org.commcare.connect.database.ConnectDatabaseHelper +import org.commcare.connect.messaging.ConnectMessagingAttachmentDownloadScheduler +import org.commcare.connect.network.PersonalIdMockApiServer +import org.commcare.dalvik.R +import org.commcare.preferences.ConnectMessagingPreferences +import org.commcare.preferences.ConnectMessagingPreferences.AttachmentAutoDownload +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLooper + +@Config(application = CommCareTestApplication::class) +@RunWith(AndroidJUnit4::class) +class ConnectMessagingSettingsTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val mockApi = PersonalIdMockApiServer() + private lateinit var activity: ConnectMessagingActivity + private lateinit var savedPersonalIdStatus: PersonalIdManager.PersonalIdStatus + + @Before + fun setUp() { + mockApi.start() + repeat(SYNC_RESPONSES) { + mockApi.server.enqueue(MockResponse().setBody("""{"notifications": [], "channels": []}""")) + } + savedPersonalIdStatus = PersonalIdManager.getInstance().status + signInToPersonalId(userId = "test-user", password = "test-password") + } + + @After + fun tearDown() { + mockApi.shutdown() + ConnectMessagingAttachmentDownloadScheduler.cancelDownloads(context) + ConnectMessagingPreferences.clear(context) + ConnectDatabaseHelper.teardown() + PersonalIdManager.getInstance().status = savedPersonalIdStatus + } + + @Test + fun `settings in the channel list menu opens messaging settings`() { + launchChannelList() + + val settingsItem = shadowOf(activity).optionsMenu.findItem(Menu.FIRST) + assertEquals(context.getString(R.string.menu_settings), settingsItem.title.toString()) + shadowOf(activity).clickMenuItem(Menu.FIRST) + ShadowLooper.idleMainLooper() + + assertEquals(R.id.connectMessagingSettingsFragment, navController().currentDestination?.id) + assertEquals(context.getString(R.string.connect_messaging_settings_title), activity.title.toString()) + } + + @Test + fun `settings screen starts on wifi and mobile data`() { + openSettings() + + assertTrue(radioButton(R.id.rbAnyNetwork).isChecked) + } + + @Test + fun `settings screen shows a saved choice`() { + ConnectMessagingPreferences.setAttachmentAutoDownload(context, AttachmentAutoDownload.MANUAL_ONLY) + + openSettings() + + assertTrue(radioButton(R.id.rbManualOnly).isChecked) + } + + @Test + fun `choosing large attachments on wifi only holds large automatic downloads for wifi`() { + openSettings() + + radioButton(R.id.rbLargeOnWifiOnly).performClick() + ShadowLooper.idleMainLooper() + + assertEquals(AttachmentAutoDownload.LARGE_ON_WIFI_ONLY, ConnectMessagingPreferences.getAttachmentAutoDownload(context)) + assertEquals( + NetworkType.UNMETERED, + pendingWork(ConnectMessagingAttachmentDownloadScheduler.LARGE_FILES_WORK_NAME).single().constraints.requiredNetworkType, + ) + } + + @Test + fun `choosing manual download only stops automatic downloads`() { + openSettings() + + radioButton(R.id.rbManualOnly).performClick() + ShadowLooper.idleMainLooper() + + assertEquals(AttachmentAutoDownload.MANUAL_ONLY, ConnectMessagingPreferences.getAttachmentAutoDownload(context)) + assertTrue(pendingWork(ConnectMessagingAttachmentDownloadScheduler.ALL_FILES_WORK_NAME).isEmpty()) + assertTrue(pendingWork(ConnectMessagingAttachmentDownloadScheduler.SMALL_FILES_WORK_NAME).isEmpty()) + assertTrue(pendingWork(ConnectMessagingAttachmentDownloadScheduler.LARGE_FILES_WORK_NAME).isEmpty()) + } + + private fun launchChannelList() { + activity = Robolectric.buildActivity(ConnectMessagingActivity::class.java).setup().get() + mockApi.drainHttp() + } + + private fun openSettings() { + launchChannelList() + navController().navigate(R.id.connectMessagingSettingsFragment) + ShadowLooper.idleMainLooper() + } + + private fun radioButton(id: Int): RadioButton = activity.findViewById(id) + + private fun pendingWork(workName: String): List = + WorkManager + .getInstance(context) + .getWorkInfosForUniqueWork(workName) + .get() + .filter { !it.state.isFinished } + + private fun navController() = + (activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment_connect_messaging) as NavHostFragment) + .navController + + private companion object { + const val SYNC_RESPONSES = 3 + } +} diff --git a/app/unit-tests/src/org/commcare/preferences/ConnectMessagingPreferencesTest.kt b/app/unit-tests/src/org/commcare/preferences/ConnectMessagingPreferencesTest.kt new file mode 100644 index 0000000000..d3168f3fac --- /dev/null +++ b/app/unit-tests/src/org/commcare/preferences/ConnectMessagingPreferencesTest.kt @@ -0,0 +1,54 @@ +package org.commcare.preferences + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.commcare.CommCareTestApplication +import org.commcare.preferences.ConnectMessagingPreferences.AttachmentAutoDownload +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@Config(application = CommCareTestApplication::class) +@RunWith(AndroidJUnit4::class) +class ConnectMessagingPreferencesTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @After + fun tearDown() { + ConnectMessagingPreferences.clear(context) + } + + @Test + fun `attachments download automatically on any network by default`() { + assertEquals(AttachmentAutoDownload.ANY_NETWORK, ConnectMessagingPreferences.getAttachmentAutoDownload(context)) + assertTrue(ConnectMessagingPreferences.isAutomaticDownloadEnabled(context)) + } + + @Test + fun `chosen setting is remembered`() { + ConnectMessagingPreferences.setAttachmentAutoDownload(context, AttachmentAutoDownload.LARGE_ON_WIFI_ONLY) + + assertEquals(AttachmentAutoDownload.LARGE_ON_WIFI_ONLY, ConnectMessagingPreferences.getAttachmentAutoDownload(context)) + } + + @Test + fun `manual only turns automatic download off`() { + ConnectMessagingPreferences.setAttachmentAutoDownload(context, AttachmentAutoDownload.MANUAL_ONLY) + + assertFalse(ConnectMessagingPreferences.isAutomaticDownloadEnabled(context)) + } + + @Test + fun `clearing returns to the default`() { + ConnectMessagingPreferences.setAttachmentAutoDownload(context, AttachmentAutoDownload.MANUAL_ONLY) + + ConnectMessagingPreferences.clear(context) + + assertEquals(AttachmentAutoDownload.ANY_NETWORK, ConnectMessagingPreferences.getAttachmentAutoDownload(context)) + } +} diff --git a/docs/connect/messaging-media.md b/docs/connect/messaging-media.md new file mode 100644 index 0000000000..bd82a80c5c --- /dev/null +++ b/docs/connect/messaging-media.md @@ -0,0 +1,125 @@ +# Connect Messaging: rich messages and attachments + +PersonalID channel messages can carry encrypted attachments and a richer markdown text. Plain +messages behave as before. Visual design: https://claude.ai/artifact/QvRs3amdJSEMwkgzpzxAam +(private until shared). + +## Data flow + +1. **Sync.** `RetrieveNotificationsResponseParser` reads `version`, `rich_text`, `format`, + `expires_at` and `attachments` for version 2 messages. Wherever a version 2 message's text + appears (chat and channel preview), `rich_text` is shown whenever present; an empty string + (`"rich_text": ""`) marks a media-only message, which shows no text. `content` is not stored + when rich text is present; it is only the fallback when `rich_text` is absent or fails to + decrypt. Any entry that fails to parse, or an attachment whose message wasn't stored, fails + the whole sync, so nothing is acknowledged and the error stays visible until it's fixed. + + **Unsupported versions** (anything but absent, 0 or 2) keep none of the message: only its id, + channel, timestamp and version are stored, and it is not acked. The server redelivers it on + every sync until an app that understands the version replaces the stored copy; read state is + kept while the version is unchanged. The chat shows a "Your Android app needs to be updated to + view this message" tile, plus a soft red banner (`error_banner_bg`) above the input while the + channel holds one; the channel list previews it as "[Update the app to see this message]". +2. **Storage** (Connect DB v30). `ConnectMessagingMessageRecord` holds the rich fields; + `ConnectMessagingAttachmentRecord` (`connect_messaging_attachment`) holds one row per + attachment, keyed by the server's attachment id. Re-delivered attachments keep their state. + Decrypted files live in `filesDir/connect_messaging_attachments/` and are + deleted, with the download job cancelled, on PersonalID logout. +3. **Download.** `ConnectMessagingAttachmentDownloadWorker` downloads queued attachments oldest + message first, checks the size, decrypts with the channel key (AES-256-GCM, nonce + + ciphertext + tag) and writes the file atomically. Automatic downloads run as unique work + scheduled after every sync; tapping a message gives each of its unfinished attachments its + own job, run at once without waiting for a network so a failure shows right away, and ahead + of automatic downloads. Each attachment downloads independently. +4. **Chat.** A rich message with attachments stays hidden behind one generic tile, with no text + or attachment details, until every attachment is available; the channel list previews it as + "[Media message to download]". Every state change sends `com.dimagi.messaging.update`; only + the owning row redraws. + +## Automatic download setting + +Messaging home › ⋮ › Settings › "Download attachments automatically", stored in +`ConnectMessagingPreferences` and cleared on PersonalID logout: + +| Option | Automatic downloads | +|---|---| +| On Wi-Fi and mobile data (default) | All attachments, any network | +| Large attachments (over 1 MB) only on Wi-Fi | Up to 1 MB on any network; larger ones wait for an unmetered network | +| Manual download only | None. New attachments start as `waiting`; switching to this returns queued downloads to `waiting` | + +Tapping a pending message always downloads it right away, whatever the setting. + +## Attachment states + +The message tile shows the combined state, in priority order: any `expired` part makes it "No +longer available", a part downloading or just tapped shows the spinner, a failed part shows the +retry tile, otherwise the download tile. It opens fully once every part is `available`. The +spinner never stands for waiting on a network or a retry backoff. + +| State | Set when | Tile while the message is pending | +|---|---|---| +| `queued` | First stored, if automatic download is on, the message is under 7 days old and among the channel's newest 50 incoming messages | Download tile | +| `waiting` | First stored, otherwise | Download tile | +| `requested` | Tapped | Spinner tile | +| `downloading` | A job is fetching it (marked `failed` if the job died) | Spinner tile | +| `available` | Decrypted file on disk; kept after expiry | Shown as image, audio player or file chip once all parts are | +| `failed` | Any unsuccessful attempt (network, HTTP error, size mismatch, decryption, 401/403) | Retry tile | +| `expired` | `expires_at` passed or the server returned 410 before download | "No longer available" tile | + +A failed part stays eligible for background retry until its third counted failure. The +automatic jobs retry on their usual backoff; a failed tap schedules its own retry, 3 minutes +later once a network is available and backing off after that. Tapping retries at once and +resets the count. 401/403 stop the pass without counting an attempt. + +## Design (dp) + +- **Row.** Messages with attachments drop the 70% text-bubble cap for media. Media first, text + below it, then the timestamp; the text wraps at the wider of the media and the normal 70% text + width, so a narrow portrait image doesn't squeeze it; narrower media is centered above the + text. Bubble padding 10, 8 between attachments and before the text. +- **Image.** Shown at its natural size, one image pixel per dp: `w = min(maxW, maxH × aspect, + natural width)`, `h = w ÷ aspect`; `maxW` = list width − 38 (row margins, tail, bubble + padding), `maxH` = window height × 0.4 (about half the chat list with the keyboard closed, + and unaffected by it). An image under 48dp (`connect_touch_target_min`) in either dimension + is enlarged until its shorter side reaches 48dp, within those limits, and can't be tapped. + Dimensions follow the EXIF orientation, as Glide does when drawing. Corners 6. Loaded with + Glide without a disk cache, so no decrypted copies leave the attachment directory. +- **Lightbox.** Tapping an image opens `ConnectMessageImageViewerFragment`: black background, + the message's date and time as the title, and the caption (up to 3 lines) on a `black_60` + strip. Subsampling Scale Image View shows the file in place. It opens at the smaller of + fit-to-screen and natural size in dp, and pinch-zooms to the larger of 2 screen pixels per + image pixel and twice the opening size; double-tap zooms to at least 1 screen pixel per + image pixel, within that limit. Only the image grows out of the tapped thumbnail and shrinks + back into it (a Material container transform on a stand-in image matching where the zoomable + view shows it). The black backdrop fades in across the screen while the image grows (the + chat fades out over the viewer's static backdrop, since the leaving fragment draws on top), + the caption appears once it's in place, and both fade out in place while it shrinks back. +- **Audio.** Fills the bubble: 40 white play circle in a 48 touch target, seek bar (4 track, + 14 thumb, white over white 40%), time 12sp. Idle shows the duration, then the position. One + track plays at a time and playback stops when the chat closes. +- **Other files.** Chip with name and size; tapping opens it with `ACTION_VIEW` through the + app's `FileProvider`. +- **Not downloaded.** One fixed 240-wide tile per message (min 128 high), corners 6, + `connect_blue_color`: 56 white circle with a `connect_message_receiver_bg` icon and a 12sp + white label; no text, names, sizes or media types. Spinner: 48 indeterminate ring, `connect_message_receiver_bg` track, + white indicator. Failed: "Download failed" in `connect_red_light` over "Tap to retry". + +Dimensions, styles and icons are prefixed `connect_message_` / `ConnectMessage` in `res/`. + +## Not built yet + +- `format` is stored but every value uses the default layout (no gallery strip). +- Download progress is indeterminate; cancelling isn't possible. +- The lightbox shows one image; there's no swiping between a message's images. + +## Edge cases + +- **Entry and back:** attachments live inside the existing chat screen; settings open from the + channel list menu and Back returns to the list. Back from the lightbox returns to the chat, + and the lightbox closes itself if the image file is gone. +- **Session:** behind PersonalID like the rest of messaging; logout removes files and work. +- **Lifecycle:** download state lives in the database, so it survives the app being killed. + Audio position is not kept across rotation or leaving the chat. +- **Form factors:** phone, both orientations; image sizes follow the current list size. +- **Connectivity:** automatic downloads wait for a network; every failure shows the retry tile + at once while background retries continue with backoff.