From 0386b5ad3179651e9c9b771b72ad7c85d6128cc5 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Mon, 5 Oct 2026 10:34:05 -0500 Subject: [PATCH 01/46] Skip unparseable notifications instead of failing the whole sync [AI] One malformed entry used to abort the parse, so nothing was acked and the same sync failed forever. The bad entry is now logged and left unacked. Co-Authored-By: Claude Opus 5.5 --- .../RetrieveNotificationsResponseParser.kt | 37 +++++++----- ...RetrieveNotificationsResponseParserTest.kt | 56 +++++++++++++++++-- 2 files changed, 72 insertions(+), 21 deletions(-) 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..d6f4601a86 100644 --- a/app/src/org/commcare/connect/network/personalId/parser/RetrieveNotificationsResponseParser.kt +++ b/app/src/org/commcare/connect/network/personalId/parser/RetrieveNotificationsResponseParser.kt @@ -7,6 +7,7 @@ import org.commcare.android.database.connect.models.PushNotificationRecord import org.commcare.android.database.connect.models.PushNotificationRecord.Companion.META_NOTIFICATION_ID import org.commcare.connect.database.ConnectMessagingDatabaseHelper import org.commcare.connect.network.base.BaseApiResponseParser +import org.javarosa.core.services.Logger import org.json.JSONArray import org.json.JSONObject import java.io.InputStream @@ -75,23 +76,29 @@ class RetrieveNotificationsResponseParser( // Get existing channels from database - these have the encryption keys required for message decryption val existingChannels = ConnectMessagingDatabaseHelper.getMessagingChannels(context) for (notificationIndex in 0 until jsonArray.length()) { - val notificationJsonObject = jsonArray.getJSONObject(notificationIndex) + try { + val notificationJsonObject = jsonArray.getJSONObject(notificationIndex) - if (isNotificationMessageType(notificationJsonObject)) { - // Handle messaging notifications - parse as ConnectMessagingMessageRecord - val message = - ConnectMessagingMessageRecord.fromJson( - notificationJsonObject, - existingChannels, - ) - if (message != null) { - messages.add(message) - messagesNotificationsIds.add(notificationJsonObject.getString(META_NOTIFICATION_ID)) + if (isNotificationMessageType(notificationJsonObject)) { + // Handle messaging notifications - parse as ConnectMessagingMessageRecord + val message = + ConnectMessagingMessageRecord.fromJson( + notificationJsonObject, + existingChannels, + ) + if (message != null) { + val notificationId = notificationJsonObject.getString(META_NOTIFICATION_ID) + messages.add(message) + messagesNotificationsIds.add(notificationId) + } + } else { + // Handle non-messaging notifications + val notification = PushNotificationRecord.fromJson(notificationJsonObject) + nonMessageNotifications.add(notification) } - } else { - // Handle non-messaging notifications - val notification = PushNotificationRecord.fromJson(notificationJsonObject) - nonMessageNotifications.add(notification) + } catch (e: Exception) { + val notificationId = jsonArray.optJSONObject(notificationIndex)?.optString(META_NOTIFICATION_ID) + Logger.exception("Skipping notification $notificationId that could not be parsed", e) } } } 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..35529bce56 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 @@ -252,9 +252,8 @@ class RetrieveNotificationsResponseParserTest { parseResponse(response) } - @Test(expected = JSONException::class) - fun testParseNotificationMissingRequiredFields() { - // Test notification without required timestamp field + @Test + fun `push notification missing its timestamp is skipped and the rest still parse`() { val incompleteNotification = """ { @@ -263,15 +262,60 @@ 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), + ) + + val result = parseWithStoredChannels(response, emptyList()) + assertEquals(1, result.nonMessagingNotifications.size) + assertEquals("push_001", result.nonMessagingNotifications[0].notificationId) + } + + @Test + fun `malformed messaging entry is skipped and not acknowledged while the rest still 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)) + + val result = parseWithStoredChannels(response, listOf(channelWithTestKey("channel_001"))) + + assertEquals(listOf("message_001"), result.messages.map { it.messageId }) + assertEquals(listOf("msg_001"), result.messagingNotificationIds) + } + + 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 } } From 3474126c97fa1ea2710ceca6cb09ae571de2d6f6 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Mon, 5 Oct 2026 10:36:48 -0500 Subject: [PATCH 02/46] Store rich message fields and attachments (Connect DB v30) [AI] Messages gain version, rich text, format and expiry; attachments get their own table, with decrypted files kept in app-private storage and removed on PersonalID logout. Expiry is stored as epoch millis because Persisted cannot write a null Date. Co-Authored-By: Claude Opus 5.5 --- .../ConnectMessagingAttachmentRecord.kt | 58 +++++++++++++++++ .../models/ConnectMessagingAttachmentState.kt | 20 ++++++ .../models/ConnectMessagingMessageRecord.java | 65 +++++++++++++++++++ .../ConnectMessagingMessageRecordV29.kt | 52 +++++++++++++++ .../ConnectMessagingAttachmentFileStore.kt | 42 ++++++++++++ .../database/ConnectUserDatabaseUtil.java | 1 + .../connect/ConnectDatabaseSchemaManager.kt | 6 +- .../connect/ConnectDatabaseUpgrader.java | 35 ++++++++++ ...nectMessagingMessageRecordMigrationTest.kt | 41 ++++++++++++ ...ConnectMessagingAttachmentFileStoreTest.kt | 55 ++++++++++++++++ 10 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentRecord.kt create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentState.kt create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecordV29.kt create mode 100644 app/src/org/commcare/connect/database/ConnectMessagingAttachmentFileStore.kt create mode 100644 app/unit-tests/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecordMigrationTest.kt create mode 100644 app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentFileStoreTest.kt 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..41d5766673 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentRecord.kt @@ -0,0 +1,58 @@ +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 java.io.Serializable + +@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(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 + } + + val isImage: Boolean + get() = type.startsWith("image/") + + val isAudio: Boolean + get() = type.startsWith("audio/") + + 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_STATE = "state" + } +} 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..76351be86f --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentState.kt @@ -0,0 +1,20 @@ +package org.commcare.android.database.connect.models + +enum class ConnectMessagingAttachmentState( + val value: String, +) { + WAITING("waiting"), + QUEUED("queued"), + 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..4329bcc0c3 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; @@ -36,6 +38,10 @@ 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 long NO_EXPIRY = 0; + public ConnectMessagingMessageRecord() { } @@ -68,6 +74,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 * @@ -233,4 +267,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 version != VERSION_PLAIN && version != VERSION_RICH; + } + + @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/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/ConnectUserDatabaseUtil.java b/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java index 6537c07f04..3aa57da1a8 100644 --- a/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java +++ b/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java @@ -27,6 +27,7 @@ public static void storeUser(ConnectUserRecord user) { } public static void forgetUser() { + ConnectMessagingAttachmentFileStore.deleteAll(CommCareApplication.instance()); DatabaseConnectOpenHelper.deleteDb(); CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class).removeAll(); ConnectDatabaseHelper.dbBroken = false; 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..22f2834d4d 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); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + addTableForNewModel(db, ConnectMessagingAttachmentRecord.STORAGE_KEY, + new ConnectMessagingAttachmentRecord()); + } + private static void addTableForNewModel(IDatabase db, String storageKey, Persistable modelToAdd) { db.beginTransaction(); 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/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()) + } +} From db49751b09f49e0d2c0694e6b9a7a7b42d79d792 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Mon, 5 Oct 2026 10:40:50 -0500 Subject: [PATCH 03/46] Read rich message fields and attachments from the messaging sync [AI] Version 2 messages carry decrypted rich text, a format hint, an expiry and their attachment list; unknown versions keep only the plain content. New attachments start queued when their message is under a week old and among the channel's newest 50 incoming messages, otherwise waiting; re-delivered ones keep their state. Co-Authored-By: Claude Opus 5.5 --- .../ConnectMessagingAttachmentRecord.kt | 51 ++++++ .../models/ConnectMessagingMessageRecord.java | 45 ++++- ...onnectMessagingAttachmentDatabaseHelper.kt | 81 +++++++++ ...ctMessagingAttachmentAutoDownloadPolicy.kt | 33 ++++ .../parser/NotificationParseResult.kt | 2 + .../RetrieveNotificationsResponseParser.kt | 36 ++-- .../utils/PushNotificationApiHelper.kt | 5 + ...ctMessagingAttachmentDatabaseHelperTest.kt | 108 ++++++++++++ ...ssagingAttachmentAutoDownloadPolicyTest.kt | 70 ++++++++ .../personalId/parser/NotificationTestUtil.kt | 48 ++++++ ...RetrieveNotificationsResponseParserTest.kt | 159 ++++++++++++++++++ 11 files changed, 625 insertions(+), 13 deletions(-) create mode 100644 app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt create mode 100644 app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentAutoDownloadPolicy.kt create mode 100644 app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelperTest.kt create mode 100644 app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentAutoDownloadPolicyTest.kt diff --git a/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentRecord.kt b/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentRecord.kt index 41d5766673..f79b858785 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentRecord.kt +++ b/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentRecord.kt @@ -4,7 +4,10 @@ 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 : @@ -54,5 +57,53 @@ class ConnectMessagingAttachmentRecord : const val META_ATTACHMENT_ID = "attachment_id" const val META_MESSAGE_ID = "message_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, + ): List { + val attachmentsJson = messageJson.optJSONArray(JSON_ATTACHMENTS) ?: return emptyList() + return (0 until attachmentsJson.length()).map { index -> + fromJson(attachmentsJson.getJSONObject(index), messageId, index) + } + } + + private fun fromJson( + attachmentJson: JSONObject, + messageId: 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.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/ConnectMessagingMessageRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecord.java index 4329bcc0c3..79a8bbc6c9 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectMessagingMessageRecord.java @@ -42,6 +42,14 @@ public class ConnectMessagingMessageRecord extends Persisted implements Serializ public static final int VERSION_RICH = 2; 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() { } @@ -130,9 +138,9 @@ 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"); + 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()); @@ -146,9 +154,40 @@ public static ConnectMessagingMessageRecord fromJson( connectMessagingMessageRecord.confirmed = false; connectMessagingMessageRecord.userViewed = false; + connectMessagingMessageRecord.version = json.optInt(JSON_VERSION, VERSION_PLAIN); + if (connectMessagingMessageRecord.isRich()) { + readRichFields(connectMessagingMessageRecord, json, channel.getKey()); + } + return connectMessagingMessageRecord; } + private static void readRichFields(ConnectMessagingMessageRecord record, JSONObject json, String key) + throws JSONException, ParseException { + JSONObject richText = json.optJSONObject(JSON_RICH_TEXT); + if (richText != null) { + 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); + } + + 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 ParseException("Invalid expires_at for message " + record.messageId + ": '" + + expiresAt + "'", 0); + } + record.expiresAtMillis = expiryDate.getTime(); + } + } + private static ConnectMessagingChannelRecord getChannel( List channels, String channelId 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..d2768cf1e3 --- /dev/null +++ b/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt @@ -0,0 +1,81 @@ +package org.commcare.connect.database + +import org.commcare.android.database.connect.models.ConnectMessagingAttachmentRecord +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 getAttachmentsForMessage(messageId: String): List = + storage() + .getRecordsForValues( + arrayOf(ConnectMessagingAttachmentRecord.META_MESSAGE_ID), + arrayOf(messageId), + ).sortedBy { it.position } + + @JvmStatic + fun getAttachment(attachmentId: String): ConnectMessagingAttachmentRecord? = + storage() + .getRecordsForValues( + arrayOf(ConnectMessagingAttachmentRecord.META_ATTACHMENT_ID), + arrayOf(attachmentId), + ).firstOrNull() + + @JvmStatic + @JvmOverloads + fun storeNewAttachments( + incoming: List, + now: Date = Date(), + ) { + val newAttachments = incoming.filter { getAttachment(it.attachmentId) == null } + if (newAttachments.isEmpty()) { + return + } + + val messagesById = + newAttachments + .map { it.messageId } + .distinct() + .associateWith { messageId -> + 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, + message.messageId in newestIncomingIds, + now, + ) + attachment.attempts = 0 + storage.write(attachment) + } + } + + private 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/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/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 d6f4601a86..bca44ff689 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 @@ -34,13 +35,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, ) } @@ -63,14 +65,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 @@ -88,8 +87,18 @@ class RetrieveNotificationsResponseParser( ) if (message != null) { val notificationId = notificationJsonObject.getString(META_NOTIFICATION_ID) + val messageAttachments = + if (message.isRich) { + ConnectMessagingAttachmentRecord.listFromMessageJson( + notificationJsonObject, + message.messageId, + ) + } else { + emptyList() + } messages.add(message) messagesNotificationsIds.add(notificationId) + attachments.addAll(messageAttachments) } } else { // Handle non-messaging notifications @@ -103,9 +112,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/utils/PushNotificationApiHelper.kt b/app/src/org/commcare/utils/PushNotificationApiHelper.kt index 58a9b5ff8f..76ade229eb 100644 --- a/app/src/org/commcare/utils/PushNotificationApiHelper.kt +++ b/app/src/org/commcare/utils/PushNotificationApiHelper.kt @@ -28,6 +28,7 @@ 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 @@ -141,6 +142,10 @@ object PushNotificationApiHelper { ConnectMessagingDatabaseHelper.storeMessagingMessages(context, parseResult.messages, false) } + if (parseResult.attachments.isNotEmpty()) { + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments(parseResult.attachments) + } + // Store non-messaging notifications val savedNotificationIds = if (parseResult.nonMessagingNotifications.isNotEmpty()) { 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..484c6efdf6 --- /dev/null +++ b/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelperTest.kt @@ -0,0 +1,108 @@ +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")), 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")), 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")), 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")), now) + + val attachments = ConnectMessagingAttachmentDatabaseHelper.getAttachmentsForMessage("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)), + now, + ) + + assertEquals( + listOf("attachment-a", "attachment-b"), + ConnectMessagingAttachmentDatabaseHelper.getAttachmentsForMessage("message-1").map { it.attachmentId }, + ) + } + + private fun storeIncomingMessage( + messageId: String, + daysAgo: Long, + ) { + val message = + ConnectMessagingMessageRecord().apply { + this.messageId = messageId + channelId = "channel-1" + 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 + this.position = position + name = "file-$attachmentId" + type = "image/png" + size = 100 + } + + private fun stateOf(attachmentId: String) = ConnectMessagingAttachmentDatabaseHelper.getAttachment(attachmentId)!!.downloadState +} 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/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 35529bce56..24cdac3a6c 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 @@ -299,6 +303,156 @@ class RetrieveNotificationsResponseParserTest { assertEquals(listOf("msg_001"), result.messagingNotificationIds) } + @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("Plain text", 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("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 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 the content and ignores attachments`() { + val notification = + NotificationTestUtil.createRichMessagingNotification( + "msg_001", + "message_001", + "channel_001", + content = "Plain text", + version = 3, + attachments = listOf(NotificationTestUtil.createAttachmentJson(FIRST_ATTACHMENT_ID, "site-map.jpg")), + ) + + val result = parseMessages(notification) + + val message = result.messages.single() + assertTrue(message.isUnsupportedVersion) + assertEquals("Plain text", message.displayText) + assertTrue(result.attachments.isEmpty()) + } + + @Test + fun `version 2 message with a malformed attachment is skipped while the rest still 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")), + ) + + val result = parseMessages(malformed, valid) + + assertEquals(listOf("message_001"), result.messages.map { it.messageId }) + assertEquals(listOf("msg_001"), result.messagingNotificationIds) + assertEquals(listOf(FIRST_ATTACHMENT_ID), result.attachments.map { it.attachmentId }) + } + + @Test + fun `attachment whose id is not a UUID fails its message`() { + val notification = + NotificationTestUtil.createRichMessagingNotification( + "msg_001", + "message_001", + "channel_001", + content = "Plain text", + attachments = listOf(NotificationTestUtil.createAttachmentJson("../escape", "site-map.jpg")), + ) + + val result = parseMessages(notification) + + assertTrue(result.messages.isEmpty()) + assertTrue(result.messagingNotificationIds.isEmpty()) + } + + private fun parseMessages(vararg messagingNotifications: String): NotificationParseResult = + parseWithStoredChannels( + NotificationTestUtil.createCompleteResponse(notifications = messagingNotifications.toList()), + listOf(channelWithTestKey("channel_001")), + ) + private fun parseWithStoredChannels( response: String, storedChannels: List, @@ -318,4 +472,9 @@ class RetrieveNotificationsResponseParserTest { `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" + } } From d4caf3ead155c2d26881b9f8fd04f22f80b77b57 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Mon, 5 Oct 2026 10:46:27 -0500 Subject: [PATCH 04/46] Download and decrypt messaging attachments in the background [AI] A unique WorkManager job downloads queued attachments oldest message first, checks the size, decrypts with the channel key and writes the file atomically. Three failed attempts with backoff end in failed; 410 or a past expiry end in expired; auth failures stop the pass for the next sync. Network type and a large-file threshold are parameters for a future settings page. Co-Authored-By: Claude Opus 5.5 --- ...onnectMessagingAttachmentDatabaseHelper.kt | 41 +++- .../database/ConnectUserDatabaseUtil.java | 2 + .../ConnectMessagingAttachmentCrypto.kt | 39 ++++ ...ectMessagingAttachmentDownloadScheduler.kt | 110 ++++++++++ .../ConnectMessagingAttachmentDownloader.kt | 166 +++++++++++++++ .../ConnectMessagingAttachmentFetcher.kt | 17 ++ .../messaging/PersonalIdAttachmentFetcher.kt | 51 +++++ .../network/personalId/ApiPersonalId.java | 11 + .../personalId/PersonalIdApiEndpoints.kt | 1 + .../personalId/PersonalIdApiService.java | 8 + ...onnectMessagingAttachmentDownloadWorker.kt | 47 +++++ .../utils/PushNotificationApiHelper.kt | 2 + .../ConnectMessagingAttachmentCryptoTest.kt | 42 ++++ ...onnectMessagingAttachmentDownloaderTest.kt | 196 ++++++++++++++++++ 14 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentCrypto.kt create mode 100644 app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloadScheduler.kt create mode 100644 app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloader.kt create mode 100644 app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentFetcher.kt create mode 100644 app/src/org/commcare/connect/messaging/PersonalIdAttachmentFetcher.kt create mode 100644 app/src/org/commcare/connect/workers/ConnectMessagingAttachmentDownloadWorker.kt create mode 100644 app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentCryptoTest.kt create mode 100644 app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloaderTest.kt diff --git a/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt b/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt index d2768cf1e3..efe1b9766a 100644 --- a/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt +++ b/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt @@ -1,6 +1,7 @@ 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 @@ -26,6 +27,44 @@ object ConnectMessagingAttachmentDatabaseHelper { arrayOf(attachmentId), ).firstOrNull() + @JvmStatic + fun getAttachmentsByMessageId(): Map> = + storage() + .getRecordsForValues(arrayOf(), arrayOf()) + .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 markInterruptedDownloadsQueued() { + for (attachment in getAttachmentsInState(ConnectMessagingAttachmentState.DOWNLOADING)) { + attachment.downloadState = ConnectMessagingAttachmentState.QUEUED + save(attachment) + } + } + + @JvmStatic + fun requeue(attachmentId: String): Boolean { + val attachment = getAttachment(attachmentId) ?: return false + if (attachment.downloadState != ConnectMessagingAttachmentState.WAITING && + attachment.downloadState != ConnectMessagingAttachmentState.FAILED + ) { + return false + } + attachment.downloadState = ConnectMessagingAttachmentState.QUEUED + attachment.attempts = 0 + save(attachment) + return true + } + @JvmStatic @JvmOverloads fun storeNewAttachments( @@ -71,7 +110,7 @@ object ConnectMessagingAttachmentDatabaseHelper { } } - private fun getMessage(messageId: String): ConnectMessagingMessageRecord? = + fun getMessage(messageId: String): ConnectMessagingMessageRecord? = ConnectDatabaseHelper .getConnectStorage(ConnectMessagingMessageRecord::class.java) .getRecordsForValues( diff --git a/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java b/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java index 3aa57da1a8..a203ac1726 100644 --- a/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java +++ b/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java @@ -3,6 +3,7 @@ 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; public class ConnectUserDatabaseUtil { @@ -27,6 +28,7 @@ public static void storeUser(ConnectUserRecord user) { } public static void forgetUser() { + ConnectMessagingAttachmentDownloadScheduler.cancelDownloads(CommCareApplication.instance()); ConnectMessagingAttachmentFileStore.deleteAll(CommCareApplication.instance()); DatabaseConnectOpenHelper.deleteDb(); CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class).removeAll(); 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..d7f61bc0be --- /dev/null +++ b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloadScheduler.kt @@ -0,0 +1,110 @@ +package org.commcare.connect.messaging + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.workDataOf +import org.commcare.connect.workers.ConnectMessagingAttachmentDownloadWorker +import org.commcare.utils.PushNotificationApiHelper +import java.util.concurrent.TimeUnit + +data class ConnectMessagingAttachmentDownloadConditions( + val networkType: NetworkType, + val largeFileThresholdBytes: Long, + val largeFileNetworkType: NetworkType, +) { + companion object { + @JvmField + val DEFAULT = + ConnectMessagingAttachmentDownloadConditions( + networkType = NetworkType.CONNECTED, + largeFileThresholdBytes = 1024L * 1024L, + largeFileNetworkType = NetworkType.CONNECTED, + ) + } +} + +object ConnectMessagingAttachmentDownloadScheduler { + private const val WORK_NAME = "connect_messaging_attachment_download" + private const val SMALL_FILES_WORK_NAME = "${WORK_NAME}_small" + private const val LARGE_FILES_WORK_NAME = "${WORK_NAME}_large" + + @JvmStatic + @JvmOverloads + fun scheduleQueuedDownloads( + context: Context, + conditions: ConnectMessagingAttachmentDownloadConditions = ConnectMessagingAttachmentDownloadConditions.DEFAULT, + ) { + enqueue(context, conditions, ExistingWorkPolicy.KEEP) + } + + @JvmStatic + @JvmOverloads + fun restartDownloads( + context: Context, + conditions: ConnectMessagingAttachmentDownloadConditions = ConnectMessagingAttachmentDownloadConditions.DEFAULT, + ) { + enqueue(context, conditions, ExistingWorkPolicy.REPLACE) + } + + @JvmStatic + fun cancelDownloads(context: Context) { + val workManager = WorkManager.getInstance(context) + workManager.cancelUniqueWork(WORK_NAME) + workManager.cancelUniqueWork(SMALL_FILES_WORK_NAME) + workManager.cancelUniqueWork(LARGE_FILES_WORK_NAME) + } + + private fun enqueue( + context: Context, + conditions: ConnectMessagingAttachmentDownloadConditions, + policy: ExistingWorkPolicy, + ) { + if (conditions.networkType == conditions.largeFileNetworkType) { + enqueueWork(context, WORK_NAME, conditions.networkType, 0L..Long.MAX_VALUE, policy) + return + } + enqueueWork( + context, + SMALL_FILES_WORK_NAME, + conditions.networkType, + 0L..conditions.largeFileThresholdBytes, + policy, + ) + enqueueWork( + context, + LARGE_FILES_WORK_NAME, + conditions.largeFileNetworkType, + (conditions.largeFileThresholdBytes + 1)..Long.MAX_VALUE, + policy, + ) + } + + private fun enqueueWork( + context: Context, + workName: String, + networkType: NetworkType, + sizeRange: LongRange, + policy: ExistingWorkPolicy, + ) { + val request = + OneTimeWorkRequest + .Builder(ConnectMessagingAttachmentDownloadWorker::class.java) + .setConstraints(Constraints.Builder().setRequiredNetworkType(networkType).build()) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + PushNotificationApiHelper.SYNC_BACKOFF_DELAY_IN_MINS, + TimeUnit.MINUTES, + ).setInputData( + workDataOf( + ConnectMessagingAttachmentDownloadWorker.KEY_MIN_SIZE_BYTES to sizeRange.first, + ConnectMessagingAttachmentDownloadWorker.KEY_MAX_SIZE_BYTES to sizeRange.last, + ), + ).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..8b7967568f --- /dev/null +++ b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloader.kt @@ -0,0 +1,166 @@ +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, + } + + fun runPass( + sizeRange: LongRange, + isStopped: () -> Boolean, + ): PassResult { + ConnectMessagingAttachmentDatabaseHelper.markInterruptedDownloadsQueued() + val attempted = HashSet() + var retryLater = false + while (!isStopped()) { + val next = nextQueued(sizeRange, 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 + } + + private class QueuedAttachment( + val attachment: ConnectMessagingAttachmentRecord, + val message: ConnectMessagingMessageRecord?, + ) + + private fun nextQueued( + sizeRange: LongRange, + attempted: Set, + ): QueuedAttachment? = + ConnectMessagingAttachmentDatabaseHelper + .getAttachmentsInState(ConnectMessagingAttachmentState.QUEUED) + .filter { it.attachmentId !in attempted && it.size in sizeRange } + .map { QueuedAttachment(it, ConnectMessagingAttachmentDatabaseHelper.getMessage(it.messageId)) } + .minWithOrNull(compareBy({ it.message?.timeStamp ?: Date(0) }, { it.attachment.position })) + + private fun download( + attachment: ConnectMessagingAttachmentRecord, + message: ConnectMessagingMessageRecord?, + ): Outcome { + if (message == null) { + 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) + 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.QUEUED) + 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 + if (attachment.attempts < MAX_ATTEMPTS) { + update(attachment, ConnectMessagingAttachmentState.QUEUED) + return Outcome.RETRY_LATER + } + Logger.log( + LogTypes.TYPE_WARNING_NETWORK, + "Messaging attachment ${attachment.attachmentId} failed after $MAX_ATTEMPTS attempts: $reason", + ) + return finish(attachment, ConnectMessagingAttachmentState.FAILED) + } + + 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) + 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/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/workers/ConnectMessagingAttachmentDownloadWorker.kt b/app/src/org/commcare/connect/workers/ConnectMessagingAttachmentDownloadWorker.kt new file mode 100644 index 0000000000..0d2908c842 --- /dev/null +++ b/app/src/org/commcare/connect/workers/ConnectMessagingAttachmentDownloadWorker.kt @@ -0,0 +1,47 @@ +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.connect.PersonalIdManager +import org.commcare.connect.database.ConnectUserDatabaseUtil +import org.commcare.connect.messaging.ConnectMessagingAttachmentDownloader +import org.commcare.connect.messaging.PersonalIdAttachmentFetcher +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 sizeRange = + inputData.getLong(KEY_MIN_SIZE_BYTES, 0L)..inputData.getLong(KEY_MAX_SIZE_BYTES, Long.MAX_VALUE) + val downloader = + ConnectMessagingAttachmentDownloader( + applicationContext, + PersonalIdAttachmentFetcher(user.userId, user.password), + ) + val result = + withContext(DispatcherProvider.io()) { + ConnectMessagingAttachmentDownloader.passLock.withLock { + downloader.runPass(sizeRange) { isStopped } + } + } + return if (result == ConnectMessagingAttachmentDownloader.PassResult.RETRY_LATER) { + Result.retry() + } else { + Result.success() + } + } + + companion object { + const val KEY_MIN_SIZE_BYTES = "min_size_bytes" + const val KEY_MAX_SIZE_BYTES = "max_size_bytes" + } +} diff --git a/app/src/org/commcare/utils/PushNotificationApiHelper.kt b/app/src/org/commcare/utils/PushNotificationApiHelper.kt index 76ade229eb..4dc75a2c4f 100644 --- a/app/src/org/commcare/utils/PushNotificationApiHelper.kt +++ b/app/src/org/commcare/utils/PushNotificationApiHelper.kt @@ -32,6 +32,7 @@ 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 @@ -86,6 +87,7 @@ object PushNotificationApiHelper { scheduleMessagingChannelsKeySync(context) CoroutineScope(DispatcherProvider.io()).launch { val (savedNotifications, savedNotificationIds) = processParsedDataIntoDB(context, parseResult) + ConnectMessagingAttachmentDownloadScheduler.scheduleQueuedDownloads(context) // Update notification preferences and send broadcasts if (savedNotificationIds.isNotEmpty()) { 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/ConnectMessagingAttachmentDownloaderTest.kt b/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloaderTest.kt new file mode 100644 index 0000000000..871ee1331a --- /dev/null +++ b/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloaderTest.kt @@ -0,0 +1,196 @@ +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 counts an attempt 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.QUEUED, stateOf("attachment-1")) + assertEquals(1, attemptsOf("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 keeps attachments queued 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.QUEUED, 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")) + } + + private fun runPassWith(fetcher: ConnectMessagingAttachmentFetcher): ConnectMessagingAttachmentDownloader.PassResult { + val recordingFetcher = + ConnectMessagingAttachmentFetcher { messageId, attachmentId, expectedSize -> + fetchedAttachmentIds.add(attachmentId) + fetcher.fetch(messageId, attachmentId, expectedSize) + } + return ConnectMessagingAttachmentDownloader(context, recordingFetcher).runPass(0L..Long.MAX_VALUE) { 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 + } +} From d48153c46cdbb0a6908e24b84ca6b66f9acfa573 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Mon, 5 Oct 2026 10:57:45 -0500 Subject: [PATCH 05/46] Show message attachments in the chat [AI] Messages with attachments get a rich row: images sized to the bubble width up to half the list height, an inline audio player, a chip that opens other files, and download, downloading, failed and expired tiles. Media sits above the text. Attachment state changes redraw only the owning row. Co-Authored-By: Claude Opus 5.5 --- .../connect_message_audio_track_inactive.xml | 4 + .../connect_message_pending_tile_bg.xml | 6 + .../drawable/connect_message_white_circle.xml | 5 + .../drawable/ic_connect_message_download.xml | 9 + app/res/drawable/ic_connect_message_file.xml | 9 + app/res/drawable/ic_connect_message_pause.xml | 9 + app/res/drawable/ic_connect_message_play.xml | 9 + app/res/drawable/ic_connect_message_retry.xml | 9 + app/res/layout/item_chat_left_rich_view.xml | 81 ++++++++ .../view_connect_message_attachment_audio.xml | 47 +++++ .../view_connect_message_attachment_file.xml | 41 +++++ .../view_connect_message_attachment_image.xml | 8 + ...iew_connect_message_attachment_pending.xml | 62 +++++++ app/res/values-es/strings.xml | 10 + app/res/values-fr/strings.xml | 10 + app/res/values-ha/strings.xml | 10 + app/res/values-hi/strings.xml | 10 + app/res/values-lt/strings.xml | 10 + app/res/values-no/strings.xml | 10 + app/res/values-pt/strings.xml | 10 + app/res/values-sw/strings.xml | 10 + app/res/values-ti/strings.xml | 10 + app/res/values/dimens.xml | 11 ++ app/res/values/strings.xml | 11 ++ app/res/values/styles.xml | 28 +++ app/res/xml/provider_paths.xml | 1 + .../adapters/ConnectMessageAdapter.java | 129 ++++++++++--- .../ConnectMessageAttachmentItem.kt | 51 +++++ .../ConnectMessageAttachmentsBinder.kt | 174 ++++++++++++++++++ .../ConnectMessageAudioAttachmentView.kt | 113 ++++++++++++ .../ConnectMessageAudioPlayer.kt | 151 +++++++++++++++ .../ConnectMessageChatData.java | 24 +++ .../ConnectMessageFragment.java | 61 +++++- .../ConnectMessageMediaSizer.kt | 29 +++ .../adapters/ConnectMessageAdapterTest.kt | 65 ++++++- .../ConnectMessageAttachmentsBinderTest.kt | 134 ++++++++++++++ .../ConnectMessageMediaSizerTest.kt | 32 ++++ 37 files changed, 1374 insertions(+), 29 deletions(-) create mode 100644 app/res/color/connect_message_audio_track_inactive.xml create mode 100644 app/res/drawable/connect_message_pending_tile_bg.xml create mode 100644 app/res/drawable/connect_message_white_circle.xml create mode 100644 app/res/drawable/ic_connect_message_download.xml create mode 100644 app/res/drawable/ic_connect_message_file.xml create mode 100644 app/res/drawable/ic_connect_message_pause.xml create mode 100644 app/res/drawable/ic_connect_message_play.xml create mode 100644 app/res/drawable/ic_connect_message_retry.xml create mode 100644 app/res/layout/item_chat_left_rich_view.xml create mode 100644 app/res/layout/view_connect_message_attachment_audio.xml create mode 100644 app/res/layout/view_connect_message_attachment_file.xml create mode 100644 app/res/layout/view_connect_message_attachment_image.xml create mode 100644 app/res/layout/view_connect_message_attachment_pending.xml create mode 100644 app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentItem.kt create mode 100644 app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt create mode 100644 app/src/org/commcare/fragments/connectMessaging/ConnectMessageAudioAttachmentView.kt create mode 100644 app/src/org/commcare/fragments/connectMessaging/ConnectMessageAudioPlayer.kt create mode 100644 app/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizer.kt create mode 100644 app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt create mode 100644 app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizerTest.kt 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/item_chat_left_rich_view.xml b/app/res/layout/item_chat_left_rich_view.xml new file mode 100644 index 0000000000..f8f60407ad --- /dev/null +++ b/app/res/layout/item_chat_left_rich_view.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + 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..f553bd64f2 --- /dev/null +++ b/app/res/layout/view_connect_message_attachment_pending.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + diff --git a/app/res/values-es/strings.xml b/app/res/values-es/strings.xml index 1909ef2543..5fb9b1d152 100644 --- a/app/res/values-es/strings.xml +++ b/app/res/values-es/strings.xml @@ -758,4 +758,14 @@ 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 + Actualiza la aplicación para ver este mensaje diff --git a/app/res/values-fr/strings.xml b/app/res/values-fr/strings.xml index 530ae69496..429a168f4a 100644 --- a/app/res/values-fr/strings.xml +++ b/app/res/values-fr/strings.xml @@ -757,4 +757,14 @@ 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 + Mettez l\'application à jour pour voir ce message diff --git a/app/res/values-ha/strings.xml b/app/res/values-ha/strings.xml index 9276510b4e..b073794f53 100644 --- a/app/res/values-ha/strings.xml +++ b/app/res/values-ha/strings.xml @@ -752,4 +752,14 @@ 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 + Sabunta manhajar don ganin wannan saƙon diff --git a/app/res/values-hi/strings.xml b/app/res/values-hi/strings.xml index dc6441f67a..bcd2a6e96e 100644 --- a/app/res/values-hi/strings.xml +++ b/app/res/values-hi/strings.xml @@ -754,4 +754,14 @@ License. सहेजें रोकें फिर से शुरू करें + डाउनलोड करें + डाउनलोड हो रहा है… + डाउनलोड विफल रहा + फिर से कोशिश करने के लिए टैप करें + अब उपलब्ध नहीं है + इस फ़ोन पर कोई भी ऐप यह फ़ाइल नहीं खोल सकता + ऑडियो चलाएं + ऑडियो रोकें + यह ऑडियो नहीं चलाया जा सका + यह संदेश देखने के लिए ऐप अपडेट करें diff --git a/app/res/values-lt/strings.xml b/app/res/values-lt/strings.xml index b36beec418..432a313774 100644 --- a/app/res/values-lt/strings.xml +++ b/app/res/values-lt/strings.xml @@ -468,4 +468,14 @@ 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 + Atnaujinkite programą, kad pamatytumėte šią žinutę diff --git a/app/res/values-no/strings.xml b/app/res/values-no/strings.xml index 2ae0e6b0a4..13724c5caf 100644 --- a/app/res/values-no/strings.xml +++ b/app/res/values-no/strings.xml @@ -444,4 +444,14 @@ 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 + Oppdater appen for å se denne meldingen diff --git a/app/res/values-pt/strings.xml b/app/res/values-pt/strings.xml index 72b1eb0e84..79767de9d4 100644 --- a/app/res/values-pt/strings.xml +++ b/app/res/values-pt/strings.xml @@ -764,4 +764,14 @@ 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 + Atualize o aplicativo para ver esta mensagem diff --git a/app/res/values-sw/strings.xml b/app/res/values-sw/strings.xml index 30379b9ff3..608c118906 100644 --- a/app/res/values-sw/strings.xml +++ b/app/res/values-sw/strings.xml @@ -762,4 +762,14 @@ 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 + Sasisha programu ili kuona ujumbe huu diff --git a/app/res/values-ti/strings.xml b/app/res/values-ti/strings.xml index d1ac677e24..91a103a3b5 100644 --- a/app/res/values-ti/strings.xml +++ b/app/res/values-ti/strings.xml @@ -746,4 +746,14 @@ ኣቐምጥ ደው በል ቐጽል + ኣውርድ + ይወርድ ኣሎ… + ምውራድ ኣይተዓወተን + ደጊምካ ንምፍታን ጠውቕ + ድሕሪ ሕጂ ኣይርከብን + ነዚ ፋይል ክኸፍቶ ዝኽእል ኣፕ ኣብዚ ተሌፎን የለን + ድምጺ ተጻወት + ድምጺ ደው ኣብል + እዚ ድምጺ ክጻወት ኣይከኣለን + ነዚ መልእኽቲ ንምርኣይ ነቲ ኣፕ ኣሐድሶ 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 78125671ed..498e940613 100644 --- a/app/res/values/strings.xml +++ b/app/res/values/strings.xml @@ -499,6 +499,17 @@ Sorry, something went wrong, showing all available messaging channels You Them + Download + Downloading… + Download failed + Tap to retry + No longer available + %1$s · %2$s + No app on this phone can open this file + Play audio + Pause audio + This audio could not be played + Update the app to see this message Click here to know more Last synced: %s diff --git a/app/res/values/styles.xml b/app/res/values/styles.xml index 26249085b0..b97bfe8e4a 100644 --- a/app/res/values/styles.xml +++ b/app/res/values/styles.xml @@ -484,4 +484,32 @@ @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..6d8dbf189d 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) + && oldChat.getAttachments().equals(newChat.getAttachments()) && oldChat.isMessageRead() == newChat.isMessageRead(); } @@ -109,14 +119,21 @@ 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 (!oldChat.getAttachments().equals(newChat.getAttachments())) { + return oldChat.hasRichContent() && newChat.hasRichContent() ? PAYLOAD_ATTACHMENTS : null; + } + return PAYLOAD_READ_STATUS; } 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 +150,70 @@ 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); + richBinding.tvUpdateNotice.setVisibility(chat.isUnsupportedVersion() ? View.VISIBLE : View.GONE); + bindAttachments(chat); + } + + public void bindAttachments(ConnectMessageChatData chat) { + boolean hasAttachments = !chat.getAttachments().isEmpty(); + 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 = 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() + ? layout.getContentWidth() + : Integer.MAX_VALUE); + } + + private int maxMediaWidth() { + int rowWidth = messageList.getWidth() > 0 + ? messageList.getWidth() + : displayMetrics().widthPixels; + 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, rowWidth - rowMargins - tailWidth - bubblePadding); + } + + private int maxMediaHeight() { + int listHeight = messageList.getHeight() > 0 + ? messageList.getHeight() + : displayMetrics().heightPixels; + return Math.max(1, (int)(listHeight * ConnectMessageMediaSizer.MAX_HEIGHT_FRACTION_OF_LIST)); + } + + private DisplayMetrics displayMetrics() { + return itemView.getResources().getDisplayMetrics(); + } + } + public class BaseMessageViewHolder extends RecyclerView.ViewHolder { ViewBinding binding; @@ -150,6 +231,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 +257,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 +294,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/fragments/connectMessaging/ConnectMessageAttachmentItem.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentItem.kt new file mode 100644 index 0000000000..8b6c970248 --- /dev/null +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentItem.kt @@ -0,0 +1,51 @@ +package org.commcare.fragments.connectMessaging + +import android.content.Context +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 onAttachmentDownloadRequested(attachmentId: String) + + fun onAttachmentOpenRequested(attachment: ConnectMessageAttachmentItem) +} 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..65a099cef3 --- /dev/null +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt @@ -0,0 +1,174 @@ +package org.commcare.fragments.connectMessaging + +import android.graphics.BitmapFactory +import android.text.format.Formatter +import android.util.LruCache +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 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 fitted = + ConnectMessageMediaSizer.fitImage(imageSize.width, imageSize.height, maxMediaWidth, maxMediaHeight) + view = createImageView(attachment, fitted) + width = fitted.width + } + attachment.file != null && attachment.isAudio -> { + view = ConnectMessageAudioAttachmentView(context).apply { bind(attachment) } + width = maxMediaWidth + fillsBubbleWidth = true + } + attachment.file != null -> { + view = createFileView(attachment) + width = maxMediaWidth + } + else -> { + view = createPendingView(attachment) + width = context.resources.getDimensionPixelSize(R.dimen.connect_message_pending_tile_width) + } + } + 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) + 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, + ): View { + val binding = ViewConnectMessageAttachmentImageBinding.inflate(inflater, container, false) + val imageView = binding.root + imageView.layoutParams = ViewGroup.LayoutParams(size.width, size.height) + imageView.contentDescription = attachment.name + Glide + .with(imageView) + .load(attachment.file) + .override(size.width, size.height) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(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(attachment: ConnectMessageAttachmentItem): View { + val binding = ViewConnectMessageAttachmentPendingBinding.inflate(inflater, container, false) + binding.tvNameAndSize.text = + context.getString( + R.string.connect_messaging_attachment_name_and_size, + attachment.name, + Formatter.formatShortFileSize(context, attachment.sizeBytes), + ) + 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 (attachment.state) { + ConnectMessagingAttachmentState.QUEUED, 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.AVAILABLE -> { + binding.ivAction.setImageResource(R.drawable.ic_connect_message_download) + binding.tvLabel.setText(R.string.connect_messaging_attachment_download) + canRequestDownload = attachment.state == ConnectMessagingAttachmentState.WAITING + } + } + if (canRequestDownload) { + binding.root.setOnClickListener { listener.onAttachmentDownloadRequested(attachment.attachmentId) } + } + return binding.root + } + + private fun imageSize( + attachmentId: String, + file: File, + ): ConnectMessageMediaSizer.Size? { + imageSizes.get(attachmentId)?.let { return it } + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.path, options) + if (options.outWidth <= 0 || options.outHeight <= 0) { + return null + } + return ConnectMessageMediaSizer.Size(options.outWidth, options.outHeight).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/ConnectMessageChatData.java b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChatData.java index 80413bc814..7e2347a144 100644 --- a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChatData.java +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageChatData.java @@ -1,6 +1,8 @@ package org.commcare.fragments.connectMessaging; +import java.util.Collections; import java.util.Date; +import java.util.List; public class ConnectMessageChatData { private String messageId; @@ -10,15 +12,37 @@ public class ConnectMessageChatData { private Date timestamp; private int countUnread; private boolean isMessageRead; + private final List attachments; + private final boolean unsupportedVersion; // 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); + } + + public ConnectMessageChatData(String messageId, int type, String message, String userName, Date timestamp, + boolean isMessageRead, List attachments, + boolean unsupportedVersion) { this.messageId = messageId; this.type = type; this.message = message; this.userName = userName; this.timestamp = timestamp; this.isMessageRead = isMessageRead; + this.attachments = attachments; + this.unsupportedVersion = unsupportedVersion; + } + + public List getAttachments() { + return attachments; + } + + public boolean isUnsupportedVersion() { + return unsupportedVersion; + } + + public boolean hasRichContent() { + return !attachments.isEmpty() || unsupportedVersion; } // 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..7002e1838b 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; @@ -28,19 +29,25 @@ 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.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.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 +59,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; @@ -185,6 +192,7 @@ public void onPause() { // Stop the periodic API calls when the screen is not active handler.removeCallbacks(apiCallRunnable); + ConnectMessageAudioPlayer.INSTANCE.stop(); } @Nullable @@ -266,7 +274,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 +295,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 +305,12 @@ public void refreshUi() { if (context != null) { List messages = ConnectMessagingDatabaseHelper .getMessagingMessagesForChannel(channelId); + Map> attachmentsByMessageId = + ConnectMessagingAttachmentDatabaseHelper.getAttachmentsByMessageId(); List chats = new ArrayList<>(); for (ConnectMessagingMessageRecord message : messages) { - chats.add(fromMessage(message)); + chats.add(fromMessage(message, attachmentsByMessageId)); if (!message.getUserViewed()) { message.setUserViewed(true); @@ -328,17 +338,54 @@ 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<>(); + if (message.isRich()) { + List records = attachmentsByMessageId.get(message.getMessageId()); + if (records != null) { + for (ConnectMessagingAttachmentRecord record : records) { + attachments.add(ConnectMessageAttachmentItem.fromRecord(requireContext(), record)); + } + } + } 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()); + } + + @Override + public void onAttachmentDownloadRequested(@NonNull String attachmentId) { + if (ConnectMessagingAttachmentDatabaseHelper.requeue(attachmentId)) { + ConnectMessagingAttachmentDownloadScheduler.restartDownloads(requireContext()); + 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(); + } } private void scrollToLatestMessage() { 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..e1567cae5c --- /dev/null +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizer.kt @@ -0,0 +1,29 @@ +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_LIST = 0.5f + + data class Size( + val width: Int, + val height: Int, + ) + + fun fitImage( + intrinsicWidth: Int, + intrinsicHeight: Int, + maxWidth: Int, + maxHeight: Int, + ): Size { + require(intrinsicWidth > 0 && intrinsicHeight > 0) { + "Image dimensions must be positive, got ${intrinsicWidth}x$intrinsicHeight" + } + val aspectRatio = intrinsicWidth.toFloat() / intrinsicHeight + val width = min(maxWidth.toFloat(), maxHeight * aspectRatio) + val height = width / aspectRatio + return Size(max(1, width.roundToInt()), max(1, height.roundToInt())) + } +} diff --git a/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt index c948299ff4..772a8f1a93 100644 --- a/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt +++ b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt @@ -7,8 +7,13 @@ 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.ConnectMessageChatData import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -28,7 +33,7 @@ class ConnectMessageAdapterTest { @Before fun setUp() { - adapter = ConnectMessageAdapter(ArrayList()) + adapter = ConnectMessageAdapter(ArrayList(), IgnoringAttachmentListener) observer = RecordingObserver() adapter.registerAdapterDataObserver(observer) } @@ -139,8 +144,66 @@ class ConnectMessageAdapterTest { assertEquals(UNTOUCHED_TEXT, binding.tvChatMessage.text.toString()) } + @Test + fun `incoming message with attachments uses the rich row and a plain one does not`() { + adapter.updateData(listOf(getIncomingChat("plain"), getRichChat("rich", ConnectMessagingAttachmentState.QUEUED))) + + assertEquals(ConnectMessageAdapter.LEFTVIEW, adapter.getItemViewType(0)) + assertEquals(ConnectMessageAdapter.LEFT_RICH_VIEW, adapter.getItemViewType(1)) + } + + @Test + fun `attachment state change is a payload-only change on the owning row`() { + adapter.updateData(listOf(getRichChat("a", ConnectMessagingAttachmentState.QUEUED), getRichChat("b", ConnectMessagingAttachmentState.QUEUED))) + observer.events.clear() + + val hasNewMessages = + adapter.updateData( + listOf(getRichChat("a", ConnectMessagingAttachmentState.QUEUED), getRichChat("b", ConnectMessagingAttachmentState.FAILED)), + ) + + assertFalse(hasNewMessages) + assertEquals(listOf(Event.Changed(1, 1, hasPayload = true)), observer.events) + } + + @Test + fun `binding with the attachments payload redraws the attachments but not the text`() { + adapter.updateData(listOf(getRichChat("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(getRichChat("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()) + } + private fun themedContext(): Context = ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.ConnectTheme) + private fun getRichChat( + id: String, + attachmentState: ConnectMessagingAttachmentState, + ) = ConnectMessageChatData( + id, + ConnectMessageAdapter.LEFTVIEW, + "message $id", + "them", + Date(TIMESTAMP), + false, + listOf(ConnectMessageAttachmentItem("attachment-$id", "site-map.jpg", "image/jpeg", 1000, attachmentState, null)), + false, + ) + + private object IgnoringAttachmentListener : ConnectMessageAttachmentListener { + override fun onAttachmentDownloadRequested(attachmentId: String) = Unit + + override fun onAttachmentOpenRequested(attachment: ConnectMessageAttachmentItem) = Unit + } + private fun getReadIconResId(binding: ItemChatRightViewBinding) = Shadows.shadowOf(binding.imgMessageReadStatus.drawable).createdFromResId 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..38ad9c89c7 --- /dev/null +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt @@ -0,0 +1,134 @@ +package org.commcare.fragments.connectMessaging + +import android.content.Context +import android.view.ContextThemeWrapper +import android.view.View +import android.widget.LinearLayout +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 binder = + ConnectMessageAttachmentsBinder( + container, + object : ConnectMessageAttachmentListener { + override fun onAttachmentDownloadRequested(attachmentId: String) { + requestedDownloads.add(attachmentId) + } + + override fun onAttachmentOpenRequested(attachment: ConnectMessageAttachmentItem) { + openedAttachments.add(attachment.attachmentId) + } + }, + ) + + @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 `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 `waiting attachment shows a download tile that requests the download`() { + val tile = bindPendingTile(ConnectMessagingAttachmentState.WAITING) + + assertEquals(context.getString(R.string.connect_messaging_attachment_download), tile.tvLabel.text.toString()) + assertTrue(tile.tvNameAndSize.text.startsWith("pending.bin")) + tile.root.performClick() + assertEquals(listOf("pending"), requestedDownloads) + } + + @Test + fun `failed attachment 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("pending"), requestedDownloads) + } + + @Test + fun `downloading attachment shows progress and ignores taps`() { + val tile = bindPendingTile(ConnectMessagingAttachmentState.DOWNLOADING) + + assertEquals(View.VISIBLE, tile.progress.visibility) + assertEquals(View.GONE, tile.ivAction.visibility) + assertFalse(tile.root.hasOnClickListeners()) + } + + @Test + fun `expired attachment 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()) + } + + private fun bindPendingTile(state: ConnectMessagingAttachmentState): ViewConnectMessageAttachmentPendingBinding { + binder.bind( + listOf(ConnectMessageAttachmentItem("pending", "pending.bin", "image/png", 2048, state, null)), + MAX_WIDTH, + MAX_HEIGHT, + ) + 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 companion object { + const val MAX_WIDTH = 344 + const val MAX_HEIGHT = 361 + } +} 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..756b844a05 --- /dev/null +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizerTest.kt @@ -0,0 +1,32 @@ +package org.commcare.fragments.connectMessaging + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConnectMessageMediaSizerTest { + @Test + fun `wide image fills the available width`() { + val size = ConnectMessageMediaSizer.fitImage(4000, 3000, maxWidth = 344, maxHeight = 361) + + assertEquals(ConnectMessageMediaSizer.Size(344, 258), size) + } + + @Test + fun `tall image stops at the maximum height and narrows to keep its shape`() { + val size = ConnectMessageMediaSizer.fitImage(3000, 4000, maxWidth = 344, maxHeight = 361) + + assertEquals(ConnectMessageMediaSizer.Size(271, 361), size) + } + + @Test + fun `small image is scaled up to the available width`() { + val size = ConnectMessageMediaSizer.fitImage(100, 100, maxWidth = 344, maxHeight = 361) + + assertEquals(ConnectMessageMediaSizer.Size(344, 344), size) + } + + @Test(expected = IllegalArgumentException::class) + fun `image without dimensions is rejected`() { + ConnectMessageMediaSizer.fitImage(0, 100, maxWidth = 344, maxHeight = 361) + } +} From 0cee05daa09b9cd53dd06ea356979bf17f0589e6 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Mon, 5 Oct 2026 11:18:43 -0500 Subject: [PATCH 06/46] Remove unused attachment helpers and wrap long test lines [AI] Co-Authored-By: Claude Opus 5.5 --- .../connect/models/ConnectMessagingAttachmentRecord.kt | 6 ------ .../database/ConnectMessagingAttachmentDatabaseHelper.kt | 8 -------- .../org/commcare/adapters/ConnectMessageAdapterTest.kt | 4 +++- .../ConnectMessagingAttachmentDatabaseHelperTest.kt | 6 ++++-- .../parser/RetrieveNotificationsResponseParserTest.kt | 5 ++++- 5 files changed, 11 insertions(+), 18 deletions(-) diff --git a/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentRecord.kt b/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentRecord.kt index f79b858785..17a88ac194 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentRecord.kt +++ b/app/src/org/commcare/android/database/connect/models/ConnectMessagingAttachmentRecord.kt @@ -46,12 +46,6 @@ class ConnectMessagingAttachmentRecord : state = value.value } - val isImage: Boolean - get() = type.startsWith("image/") - - val isAudio: Boolean - get() = type.startsWith("audio/") - companion object { const val STORAGE_KEY = "connect_messaging_attachment" const val META_ATTACHMENT_ID = "attachment_id" diff --git a/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt b/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt index efe1b9766a..f3e51eb21d 100644 --- a/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt +++ b/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt @@ -11,14 +11,6 @@ object ConnectMessagingAttachmentDatabaseHelper { private fun storage(): SqlStorage = ConnectDatabaseHelper.getConnectStorage(ConnectMessagingAttachmentRecord::class.java) - @JvmStatic - fun getAttachmentsForMessage(messageId: String): List = - storage() - .getRecordsForValues( - arrayOf(ConnectMessagingAttachmentRecord.META_MESSAGE_ID), - arrayOf(messageId), - ).sortedBy { it.position } - @JvmStatic fun getAttachment(attachmentId: String): ConnectMessagingAttachmentRecord? = storage() diff --git a/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt index 772a8f1a93..c263efc716 100644 --- a/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt +++ b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt @@ -154,7 +154,9 @@ class ConnectMessageAdapterTest { @Test fun `attachment state change is a payload-only change on the owning row`() { - adapter.updateData(listOf(getRichChat("a", ConnectMessagingAttachmentState.QUEUED), getRichChat("b", ConnectMessagingAttachmentState.QUEUED))) + adapter.updateData( + listOf(getRichChat("a", ConnectMessagingAttachmentState.QUEUED), getRichChat("b", ConnectMessagingAttachmentState.QUEUED)), + ) observer.events.clear() val hasNewMessages = diff --git a/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelperTest.kt b/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelperTest.kt index 484c6efdf6..6b4a8c0ec3 100644 --- a/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelperTest.kt +++ b/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelperTest.kt @@ -55,7 +55,7 @@ class ConnectMessagingAttachmentDatabaseHelperTest { ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments(listOf(attachment("attachment-1", "message-1")), now) - val attachments = ConnectMessagingAttachmentDatabaseHelper.getAttachmentsForMessage("message-1") + val attachments = attachmentsOf("message-1") assertEquals(1, attachments.size) assertEquals(ConnectMessagingAttachmentState.AVAILABLE, attachments[0].downloadState) assertEquals(2, attachments[0].attempts) @@ -72,7 +72,7 @@ class ConnectMessagingAttachmentDatabaseHelperTest { assertEquals( listOf("attachment-a", "attachment-b"), - ConnectMessagingAttachmentDatabaseHelper.getAttachmentsForMessage("message-1").map { it.attachmentId }, + attachmentsOf("message-1").map { it.attachmentId }, ) } @@ -105,4 +105,6 @@ class ConnectMessagingAttachmentDatabaseHelperTest { } private fun stateOf(attachmentId: String) = ConnectMessagingAttachmentDatabaseHelper.getAttachment(attachmentId)!!.downloadState + + private fun attachmentsOf(messageId: String) = ConnectMessagingAttachmentDatabaseHelper.getAttachmentsByMessageId().getValue(messageId) } 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 24cdac3a6c..5a4ffae356 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 @@ -305,7 +305,10 @@ class RetrieveNotificationsResponseParserTest { @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 result = + parseMessages( + NotificationTestUtil.createMessagingNotificationWithValidEncryption("msg_001", "message_001", "channel_001", "Hello"), + ) val message = result.messages.single() assertEquals(ConnectMessagingMessageRecord.VERSION_PLAIN, message.version) From a58001341f6cac0be5eed8c0e21302d369c6add8 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Mon, 5 Oct 2026 11:18:44 -0500 Subject: [PATCH 07/46] Document rich messaging data flow and attachment design [AI] Co-Authored-By: Claude Opus 5.5 --- docs/connect/messaging-media.md | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/connect/messaging-media.md diff --git a/docs/connect/messaging-media.md b/docs/connect/messaging-media.md new file mode 100644 index 0000000000..201babcd53 --- /dev/null +++ b/docs/connect/messaging-media.md @@ -0,0 +1,73 @@ +# 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. Unknown versions keep only `content` + and show an update notice. An entry that fails to parse is logged, skipped and not acked, so + it can't block the rest of the sync. +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` (unique WorkManager work, scheduled + after every sync and restarted on a tap) 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. Network type and a large-file threshold are parameters in + `ConnectMessagingAttachmentDownloadConditions`; both are "any network" today. +4. **Chat.** Every state change sends `com.dimagi.messaging.update`; only the owning row + redraws. + +## Attachment states + +| State | Set when | Shown as | +|---|---|---| +| `queued` | First stored, if the message is under 7 days old and among the channel's newest 50 incoming messages; or tapped | Spinner tile | +| `waiting` | First stored, otherwise | Download tile | +| `downloading` | The job picks it up (reset to `queued` if the job died) | Spinner tile | +| `available` | Decrypted file on disk; kept after expiry | Image, audio player or file chip | +| `failed` | Third failed attempt (network, HTTP error, size mismatch, decryption) | Retry tile | +| `expired` | `expires_at` passed or the server returned 410 before download | "No longer available" tile | + +401/403 stop the pass without counting an attempt; the next sync tries again. + +## Design (dp) + +- **Row.** Messages with attachments drop the 70% text-bubble cap. Media first, text below it, + then the timestamp. Bubble padding 10, 8 between attachments and before the text. +- **Image.** `w = min(maxW, maxH × aspect)`, `h = w ÷ aspect`; `maxW` = list width − 38 + (row margins, tail, bubble padding), `maxH` = list height × 0.5. Corners 6. Loaded with + Glide without a disk cache, so no decrypted copies leave the attachment directory. +- **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.** Fixed 240-wide tile (min 128 high), corners 6, `connect_blue_color`: 56 + white circle with a `connect_message_receiver_bg` icon, a 12sp white label, and the file name + and size underneath. 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 image lightbox and media-specific channel-list previews from the design (rich messages + always carry plain `content`, which the preview shows). + +## Edge cases + +- **Entry and back:** unchanged; attachments live inside the existing chat screen. +- **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:** downloads wait for a network and retry with backoff; failures end in a retry + tile. From 6a3f997901526306224d0f96d45153d31673f529 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Mon, 5 Oct 2026 11:56:13 -0500 Subject: [PATCH 08/46] Add a messaging setting for automatic attachment downloads [AI] Settings in the channel list menu chooses between downloading attachments on any network (default), holding ones over 1 MB for Wi-Fi, or manual download only. Tapped attachments get their own job and always download on any network. Co-Authored-By: Claude Opus 5.5 --- .../fragment_connect_messaging_settings.xml | 66 +++++++++ .../nav_graph_connect_messaging.xml | 9 ++ app/res/values-es/strings.xml | 6 + app/res/values-fr/strings.xml | 6 + app/res/values-ha/strings.xml | 6 + app/res/values-hi/strings.xml | 6 + app/res/values-lt/strings.xml | 6 + app/res/values-no/strings.xml | 6 + app/res/values-pt/strings.xml | 6 + app/res/values-sw/strings.xml | 6 + app/res/values-ti/strings.xml | 6 + app/res/values/strings.xml | 6 + ...onnectMessagingAttachmentDatabaseHelper.kt | 13 +- .../database/ConnectUserDatabaseUtil.java | 2 + ...ectMessagingAttachmentDownloadScheduler.kt | 105 ++++++++----- .../ConnectMessagingAttachmentDownloader.kt | 41 ++++- ...onnectMessagingAttachmentDownloadWorker.kt | 21 ++- .../ConnectMessageChannelListFragment.java | 31 ++++ .../ConnectMessageFragment.java | 2 +- .../ConnectMessagingSettingsFragment.kt | 62 ++++++++ .../ConnectMessagingPreferences.kt | 48 ++++++ .../utils/PushNotificationApiHelper.kt | 6 +- .../commcare/android/util/ConnectTestUtils.kt | 8 +- ...ctMessagingAttachmentDatabaseHelperTest.kt | 49 +++++- ...essagingAttachmentDownloadSchedulerTest.kt | 110 ++++++++++++++ ...onnectMessagingAttachmentDownloaderTest.kt | 47 +++++- .../ConnectMessagingSettingsTest.kt | 140 ++++++++++++++++++ .../ConnectMessagingPreferencesTest.kt | 54 +++++++ 28 files changed, 815 insertions(+), 59 deletions(-) create mode 100644 app/res/layout/fragment_connect_messaging_settings.xml create mode 100644 app/src/org/commcare/fragments/connectMessaging/ConnectMessagingSettingsFragment.kt create mode 100644 app/src/org/commcare/preferences/ConnectMessagingPreferences.kt create mode 100644 app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloadSchedulerTest.kt create mode 100644 app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessagingSettingsTest.kt create mode 100644 app/unit-tests/src/org/commcare/preferences/ConnectMessagingPreferencesTest.kt 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/navigation/nav_graph_connect_messaging.xml b/app/res/navigation/nav_graph_connect_messaging.xml index 29aab22a9b..8730d53f6a 100644 --- a/app/res/navigation/nav_graph_connect_messaging.xml +++ b/app/res/navigation/nav_graph_connect_messaging.xml @@ -13,8 +13,17 @@ + + + Pausar audio No se pudo reproducir este audio 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 diff --git a/app/res/values-fr/strings.xml b/app/res/values-fr/strings.xml index 429a168f4a..8e09bc9f52 100644 --- a/app/res/values-fr/strings.xml +++ b/app/res/values-fr/strings.xml @@ -767,4 +767,10 @@ License. Mettre l\'audio en pause Impossible de lire cet audio 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 diff --git a/app/res/values-ha/strings.xml b/app/res/values-ha/strings.xml index b073794f53..a14bda9755 100644 --- a/app/res/values-ha/strings.xml +++ b/app/res/values-ha/strings.xml @@ -762,4 +762,10 @@ Don cikakken aiki na wurin zama na aikace-aikace da yawa, da fatan za a yi sabon Dakatar da sauti Ba a iya kunna wannan sautin ba 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 diff --git a/app/res/values-hi/strings.xml b/app/res/values-hi/strings.xml index bcd2a6e96e..825da42f02 100644 --- a/app/res/values-hi/strings.xml +++ b/app/res/values-hi/strings.xml @@ -764,4 +764,10 @@ License. ऑडियो रोकें यह ऑडियो नहीं चलाया जा सका यह संदेश देखने के लिए ऐप अपडेट करें + संदेश सेटिंग + वाई-फ़ाई और मोबाइल डेटा पर + जिन अटैचमेंट पर आप टैप करते हैं, वे हमेशा किसी भी नेटवर्क पर तुरंत डाउनलोड होते हैं। + अटैचमेंट अपने-आप डाउनलोड करें + बड़े अटैचमेंट (1 MB से ज़्यादा) केवल वाई-फ़ाई पर + केवल मैन्युअल डाउनलोड diff --git a/app/res/values-lt/strings.xml b/app/res/values-lt/strings.xml index 432a313774..c2cb1d8211 100644 --- a/app/res/values-lt/strings.xml +++ b/app/res/values-lt/strings.xml @@ -478,4 +478,10 @@ Pristabdyti garso įrašą Nepavyko paleisti šio garso įrašo 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 diff --git a/app/res/values-no/strings.xml b/app/res/values-no/strings.xml index 13724c5caf..c248eb41b2 100644 --- a/app/res/values-no/strings.xml +++ b/app/res/values-no/strings.xml @@ -454,4 +454,10 @@ Sett lyden på pause Lyden kunne ikke spilles av 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 diff --git a/app/res/values-pt/strings.xml b/app/res/values-pt/strings.xml index 79767de9d4..89cd13a5a8 100644 --- a/app/res/values-pt/strings.xml +++ b/app/res/values-pt/strings.xml @@ -774,4 +774,10 @@ Pausar áudio Não foi possível reproduzir este áudio 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 diff --git a/app/res/values-sw/strings.xml b/app/res/values-sw/strings.xml index 608c118906..96cac21646 100644 --- a/app/res/values-sw/strings.xml +++ b/app/res/values-sw/strings.xml @@ -772,4 +772,10 @@ Sitisha sauti Sauti hii haikuweza kuchezwa 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 diff --git a/app/res/values-ti/strings.xml b/app/res/values-ti/strings.xml index 91a103a3b5..0bc892fd06 100644 --- a/app/res/values-ti/strings.xml +++ b/app/res/values-ti/strings.xml @@ -756,4 +756,10 @@ ድምጺ ደው ኣብል እዚ ድምጺ ክጻወት ኣይከኣለን ነዚ መልእኽቲ ንምርኣይ ነቲ ኣፕ ኣሐድሶ + ቅጥዕታት መልእኽቲ + ብዋይፋይን ሞባይል ዳታን + እትጠውቖም ተተሓሒዝቲ ኩሉ ግዜ ብዝኾነ መርበብ ብቕጽበት ይወርዱ። + ተተሓሒዝቲ ብኣውቶማቲክ ኣውርድ + ዓበይቲ ተተሓሒዝቲ (ካብ 1 MB ንላዕሊ) ብዋይፋይ ጥራይ + ብኢድ ምውራድ ጥራይ diff --git a/app/res/values/strings.xml b/app/res/values/strings.xml index 498e940613..9f41cf475c 100644 --- a/app/res/values/strings.xml +++ b/app/res/values/strings.xml @@ -510,6 +510,12 @@ Pause audio This audio could not be played 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/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt b/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt index f3e51eb21d..a27b3c580d 100644 --- a/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt +++ b/app/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelper.kt @@ -36,9 +36,13 @@ object ConnectMessagingAttachmentDatabaseHelper { storage().write(attachment) } - fun markInterruptedDownloadsQueued() { - for (attachment in getAttachmentsInState(ConnectMessagingAttachmentState.DOWNLOADING)) { - attachment.downloadState = ConnectMessagingAttachmentState.QUEUED + fun returnPendingAttachmentsToWaiting() { + val pending = + getAttachmentsInState(ConnectMessagingAttachmentState.QUEUED) + + getAttachmentsInState(ConnectMessagingAttachmentState.DOWNLOADING) + for (attachment in pending) { + attachment.downloadState = ConnectMessagingAttachmentState.WAITING + attachment.attempts = 0 save(attachment) } } @@ -61,6 +65,7 @@ object ConnectMessagingAttachmentDatabaseHelper { @JvmOverloads fun storeNewAttachments( incoming: List, + automaticDownloadEnabled: Boolean, now: Date = Date(), ) { val newAttachments = incoming.filter { getAttachment(it.attachmentId) == null } @@ -94,7 +99,7 @@ object ConnectMessagingAttachmentDatabaseHelper { ConnectMessagingAttachmentAutoDownloadPolicy.initialState( message.timeStamp, message.expiresAt, - message.messageId in newestIncomingIds, + automaticDownloadEnabled && message.messageId in newestIncomingIds, now, ) attachment.attempts = 0 diff --git a/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java b/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java index a203ac1726..766409cca4 100644 --- a/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java +++ b/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java @@ -5,6 +5,7 @@ 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 { @@ -30,6 +31,7 @@ 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/ConnectMessagingAttachmentDownloadScheduler.kt b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloadScheduler.kt index d7f61bc0be..124c0a3ef9 100644 --- a/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloadScheduler.kt +++ b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloadScheduler.kt @@ -1,14 +1,21 @@ 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 @@ -18,93 +25,123 @@ data class ConnectMessagingAttachmentDownloadConditions( val largeFileNetworkType: NetworkType, ) { companion object { - @JvmField - val DEFAULT = - ConnectMessagingAttachmentDownloadConditions( + 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 = 1024L * 1024L, - largeFileNetworkType = NetworkType.CONNECTED, + largeFileThresholdBytes = LARGE_FILE_THRESHOLD_BYTES, + largeFileNetworkType = largeFileNetworkType, ) + } } } object ConnectMessagingAttachmentDownloadScheduler { - private const val WORK_NAME = "connect_messaging_attachment_download" - private const val SMALL_FILES_WORK_NAME = "${WORK_NAME}_small" - private const val LARGE_FILES_WORK_NAME = "${WORK_NAME}_large" + 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_" @JvmStatic - @JvmOverloads - fun scheduleQueuedDownloads( - context: Context, - conditions: ConnectMessagingAttachmentDownloadConditions = ConnectMessagingAttachmentDownloadConditions.DEFAULT, - ) { - enqueue(context, conditions, ExistingWorkPolicy.KEEP) + 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 - @JvmOverloads - fun restartDownloads( + fun downloadNow( context: Context, - conditions: ConnectMessagingAttachmentDownloadConditions = ConnectMessagingAttachmentDownloadConditions.DEFAULT, + attachmentId: String, ) { - enqueue(context, conditions, ExistingWorkPolicy.REPLACE) + enqueueWork( + context, + requestedWorkName(attachmentId), + NetworkType.CONNECTED, + workDataOf(ConnectMessagingAttachmentDownloadWorker.KEY_ATTACHMENT_ID to attachmentId), + ExistingWorkPolicy.REPLACE, + ) } @JvmStatic fun cancelDownloads(context: Context) { - val workManager = WorkManager.getInstance(context) - workManager.cancelUniqueWork(WORK_NAME) - workManager.cancelUniqueWork(SMALL_FILES_WORK_NAME) - workManager.cancelUniqueWork(LARGE_FILES_WORK_NAME) + WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG) } - private fun enqueue( + fun requestedWorkName(attachmentId: String) = REQUESTED_WORK_NAME_PREFIX + attachmentId + + private fun enqueueAutomaticDownloads( context: Context, - conditions: ConnectMessagingAttachmentDownloadConditions, policy: ExistingWorkPolicy, ) { + val conditions = ConnectMessagingAttachmentDownloadConditions.fromPreferences(context) ?: return if (conditions.networkType == conditions.largeFileNetworkType) { - enqueueWork(context, WORK_NAME, conditions.networkType, 0L..Long.MAX_VALUE, policy) + enqueueWork(context, ALL_FILES_WORK_NAME, conditions.networkType, sizeRangeData(0L..Long.MAX_VALUE), policy) return } enqueueWork( context, SMALL_FILES_WORK_NAME, conditions.networkType, - 0L..conditions.largeFileThresholdBytes, + sizeRangeData(0L..conditions.largeFileThresholdBytes), policy, ) enqueueWork( context, LARGE_FILES_WORK_NAME, conditions.largeFileNetworkType, - (conditions.largeFileThresholdBytes + 1)..Long.MAX_VALUE, + 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, - sizeRange: LongRange, + inputData: Data, policy: ExistingWorkPolicy, ) { val request = OneTimeWorkRequest .Builder(ConnectMessagingAttachmentDownloadWorker::class.java) + .addTag(WORK_TAG) .setConstraints(Constraints.Builder().setRequiredNetworkType(networkType).build()) .setBackoffCriteria( BackoffPolicy.EXPONENTIAL, PushNotificationApiHelper.SYNC_BACKOFF_DELAY_IN_MINS, TimeUnit.MINUTES, - ).setInputData( - workDataOf( - ConnectMessagingAttachmentDownloadWorker.KEY_MIN_SIZE_BYTES to sizeRange.first, - ConnectMessagingAttachmentDownloadWorker.KEY_MAX_SIZE_BYTES to sizeRange.last, - ), - ).build() + ).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 index 8b7967568f..3c9871de4d 100644 --- a/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloader.kt +++ b/app/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloader.kt @@ -10,6 +10,7 @@ import org.commcare.android.database.connect.models.ConnectMessagingMessageRecor import org.commcare.connect.database.ConnectMessagingAttachmentDatabaseHelper import org.commcare.connect.database.ConnectMessagingAttachmentFileStore import org.commcare.connect.database.ConnectMessagingDatabaseHelper +import org.commcare.preferences.ConnectMessagingPreferences import org.commcare.util.LogTypes import org.commcare.utils.FirebaseMessagingUtil import org.javarosa.core.services.Logger @@ -21,6 +22,9 @@ import java.util.Date class ConnectMessagingAttachmentDownloader( private val context: Context, private val fetcher: ConnectMessagingAttachmentFetcher, + private val isAutomaticDownloadEnabled: () -> Boolean = { + ConnectMessagingPreferences.isAutomaticDownloadEnabled(context) + }, private val clock: () -> Date = { Date() }, ) { enum class PassResult { @@ -35,15 +39,26 @@ class ConnectMessagingAttachmentDownloader( AUTH_FAILURE, } + private var pendingStateFor: (ConnectMessagingAttachmentRecord) -> ConnectMessagingAttachmentState = + { ConnectMessagingAttachmentState.QUEUED } + fun runPass( - sizeRange: LongRange, + isEligible: (ConnectMessagingAttachmentRecord) -> Boolean, + isRequestedByUser: Boolean, isStopped: () -> Boolean, ): PassResult { - ConnectMessagingAttachmentDatabaseHelper.markInterruptedDownloadsQueued() + pendingStateFor = { attachment: ConnectMessagingAttachmentRecord -> + if ((isRequestedByUser && isEligible(attachment)) || isAutomaticDownloadEnabled()) { + ConnectMessagingAttachmentState.QUEUED + } else { + ConnectMessagingAttachmentState.WAITING + } + } + resetInterruptedDownloads() val attempted = HashSet() var retryLater = false while (!isStopped()) { - val next = nextQueued(sizeRange, attempted) ?: break + val next = nextQueued(isEligible, attempted) ?: break attempted.add(next.attachment.attachmentId) when (download(next.attachment, next.message)) { Outcome.RETRY_LATER -> retryLater = true @@ -54,18 +69,27 @@ class ConnectMessagingAttachmentDownloader( return if (retryLater) PassResult.RETRY_LATER else PassResult.COMPLETE } + private fun resetInterruptedDownloads() { + for (attachment in ConnectMessagingAttachmentDatabaseHelper.getAttachmentsInState( + ConnectMessagingAttachmentState.DOWNLOADING, + )) { + attachment.downloadState = pendingStateFor(attachment) + ConnectMessagingAttachmentDatabaseHelper.save(attachment) + } + } + private class QueuedAttachment( val attachment: ConnectMessagingAttachmentRecord, val message: ConnectMessagingMessageRecord?, ) private fun nextQueued( - sizeRange: LongRange, + isEligible: (ConnectMessagingAttachmentRecord) -> Boolean, attempted: Set, ): QueuedAttachment? = ConnectMessagingAttachmentDatabaseHelper .getAttachmentsInState(ConnectMessagingAttachmentState.QUEUED) - .filter { it.attachmentId !in attempted && it.size in sizeRange } + .filter { it.attachmentId !in attempted && isEligible(it) } .map { QueuedAttachment(it, ConnectMessagingAttachmentDatabaseHelper.getMessage(it.messageId)) } .minWithOrNull(compareBy({ it.message?.timeStamp ?: Date(0) }, { it.attachment.position })) @@ -97,7 +121,7 @@ class ConnectMessagingAttachmentDownloader( HttpURLConnection.HTTP_OK -> store(attachment, response.body, channelKey) HttpURLConnection.HTTP_GONE -> finish(attachment, ConnectMessagingAttachmentState.EXPIRED) HttpURLConnection.HTTP_UNAUTHORIZED, HttpURLConnection.HTTP_FORBIDDEN -> { - update(attachment, ConnectMessagingAttachmentState.QUEUED) + update(attachment, pendingStateFor(attachment)) Outcome.AUTH_FAILURE } else -> countFailedAttempt(attachment, "HTTP ${response.statusCode}") @@ -130,8 +154,9 @@ class ConnectMessagingAttachmentDownloader( ): Outcome { attachment.attempts += 1 if (attachment.attempts < MAX_ATTEMPTS) { - update(attachment, ConnectMessagingAttachmentState.QUEUED) - return Outcome.RETRY_LATER + val pendingState = pendingStateFor(attachment) + update(attachment, pendingState) + return if (pendingState == ConnectMessagingAttachmentState.QUEUED) Outcome.RETRY_LATER else Outcome.FINISHED } Logger.log( LogTypes.TYPE_WARNING_NETWORK, diff --git a/app/src/org/commcare/connect/workers/ConnectMessagingAttachmentDownloadWorker.kt b/app/src/org/commcare/connect/workers/ConnectMessagingAttachmentDownloadWorker.kt index 0d2908c842..4892df4d94 100644 --- a/app/src/org/commcare/connect/workers/ConnectMessagingAttachmentDownloadWorker.kt +++ b/app/src/org/commcare/connect/workers/ConnectMessagingAttachmentDownloadWorker.kt @@ -5,10 +5,12 @@ 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.ConnectMessagingAttachmentDownloader import org.commcare.connect.messaging.PersonalIdAttachmentFetcher +import org.commcare.preferences.ConnectMessagingPreferences import org.commcare.utils.coroutines.DispatcherProvider class ConnectMessagingAttachmentDownloadWorker( @@ -20,17 +22,20 @@ class ConnectMessagingAttachmentDownloadWorker( if (!PersonalIdManager.getInstance().isloggedIn() || user == null) { return Result.success() } - val sizeRange = - inputData.getLong(KEY_MIN_SIZE_BYTES, 0L)..inputData.getLong(KEY_MAX_SIZE_BYTES, Long.MAX_VALUE) + val isRequestedByUser = inputData.getString(KEY_ATTACHMENT_ID) != null + if (!isRequestedByUser && !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(sizeRange) { isStopped } + downloader.runPass(isEligible, isRequestedByUser) { isStopped } } } return if (result == ConnectMessagingAttachmentDownloader.PassResult.RETRY_LATER) { @@ -40,8 +45,18 @@ class ConnectMessagingAttachmentDownloadWorker( } } + 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" } } 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/ConnectMessageFragment.java b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageFragment.java index 7002e1838b..aaefc77692 100644 --- a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageFragment.java +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageFragment.java @@ -366,7 +366,7 @@ private ConnectMessageChatData fromMessage(ConnectMessagingMessageRecord message @Override public void onAttachmentDownloadRequested(@NonNull String attachmentId) { if (ConnectMessagingAttachmentDatabaseHelper.requeue(attachmentId)) { - ConnectMessagingAttachmentDownloadScheduler.restartDownloads(requireContext()); + ConnectMessagingAttachmentDownloadScheduler.downloadNow(requireContext(), attachmentId); refreshUi(); } } 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/preferences/ConnectMessagingPreferences.kt b/app/src/org/commcare/preferences/ConnectMessagingPreferences.kt new file mode 100644 index 0000000000..dc0b9a04ba --- /dev/null +++ b/app/src/org/commcare/preferences/ConnectMessagingPreferences.kt @@ -0,0 +1,48 @@ +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 4dc75a2c4f..fe66581022 100644 --- a/app/src/org/commcare/utils/PushNotificationApiHelper.kt +++ b/app/src/org/commcare/utils/PushNotificationApiHelper.kt @@ -38,6 +38,7 @@ 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 java.util.concurrent.TimeUnit @@ -145,7 +146,10 @@ object PushNotificationApiHelper { } if (parseResult.attachments.isNotEmpty()) { - ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments(parseResult.attachments) + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments( + parseResult.attachments, + ConnectMessagingPreferences.isAutomaticDownloadEnabled(context), + ) } // Store non-messaging notifications 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 index 6b4a8c0ec3..a1c0635dc1 100644 --- a/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelperTest.kt +++ b/app/unit-tests/src/org/commcare/connect/database/ConnectMessagingAttachmentDatabaseHelperTest.kt @@ -30,7 +30,7 @@ class ConnectMessagingAttachmentDatabaseHelperTest { fun `attachment on a recent message is queued for download`() { storeIncomingMessage("message-1", daysAgo = 1) - ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments(listOf(attachment("attachment-1", "message-1")), now) + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments(listOf(attachment("attachment-1", "message-1")), true, now) assertEquals(ConnectMessagingAttachmentState.QUEUED, stateOf("attachment-1")) } @@ -39,7 +39,7 @@ class ConnectMessagingAttachmentDatabaseHelperTest { fun `attachment on an old message waits for a tap`() { storeIncomingMessage("message-1", daysAgo = 10) - ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments(listOf(attachment("attachment-1", "message-1")), now) + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments(listOf(attachment("attachment-1", "message-1")), true, now) assertEquals(ConnectMessagingAttachmentState.WAITING, stateOf("attachment-1")) } @@ -47,13 +47,13 @@ class ConnectMessagingAttachmentDatabaseHelperTest { @Test fun `redelivered attachment keeps its existing state and attempts`() { storeIncomingMessage("message-1", daysAgo = 1) - ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments(listOf(attachment("attachment-1", "message-1")), now) + 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")), now) + ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments(listOf(attachment("attachment-1", "message-1")), true, now) val attachments = attachmentsOf("message-1") assertEquals(1, attachments.size) @@ -67,6 +67,7 @@ class ConnectMessagingAttachmentDatabaseHelperTest { ConnectMessagingAttachmentDatabaseHelper.storeNewAttachments( listOf(attachment("attachment-b", "message-1", position = 1), attachment("attachment-a", "message-1", position = 0)), + true, now, ) @@ -76,6 +77,46 @@ class ConnectMessagingAttachmentDatabaseHelperTest { ) } + @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")) + } + + 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, 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..3ef4f5b581 --- /dev/null +++ b/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloadSchedulerTest.kt @@ -0,0 +1,110 @@ +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.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 + +@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 downloads on any network whatever the setting`() { + for (setting in AttachmentAutoDownload.values()) { + ConnectMessagingPreferences.setAttachmentAutoDownload(context, setting) + + ConnectMessagingAttachmentDownloadScheduler.downloadNow(context, "attachment-${setting.value}") + + assertEquals( + NetworkType.CONNECTED, + pendingNetworkType(ConnectMessagingAttachmentDownloadScheduler.requestedWorkName("attachment-${setting.value}")), + ) + } + } + + @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 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 index 871ee1331a..dfcef49ca5 100644 --- a/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloaderTest.kt +++ b/app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingAttachmentDownloaderTest.kt @@ -143,13 +143,56 @@ class ConnectMessagingAttachmentDownloaderTest { assertEquals(ConnectMessagingAttachmentState.WAITING, stateOf("tap-only")) } - private fun runPassWith(fetcher: ConnectMessagingAttachmentFetcher): ConnectMessagingAttachmentDownloader.PassResult { + @Test + fun `failed automatic download goes back to waiting once automatic download is off`() { + storeQueuedAttachment("attachment-1", "message-1") + + val result = runPassWith(automaticDownloadEnabled = false) { _, _, _ -> throw IOException("connection reset") } + + assertEquals(ConnectMessagingAttachmentDownloader.PassResult.COMPLETE, result) + assertEquals(ConnectMessagingAttachmentState.WAITING, stateOf("attachment-1")) + } + + @Test + fun `failed tapped download keeps retrying even with automatic download off`() { + storeQueuedAttachment("attachment-1", "message-1") + + val result = + runPassWith(automaticDownloadEnabled = false, requestedAttachmentId = "attachment-1") { _, _, _ -> + throw IOException("connection reset") + } + + assertEquals(ConnectMessagingAttachmentDownloader.PassResult.RETRY_LATER, result) + assertEquals(ConnectMessagingAttachmentState.QUEUED, stateOf("attachment-1")) + } + + @Test + fun `tapped download only fetches the tapped attachment and returns other interrupted ones to waiting`() { + storeQueuedAttachment("tapped", "message-1") + storeQueuedAttachment("other", "message-2", state = ConnectMessagingAttachmentState.DOWNLOADING) + + runPassWith(automaticDownloadEnabled = false, requestedAttachmentId = "tapped") { _, _, _ -> ok(encryptedVector) } + + assertEquals(listOf("tapped"), fetchedAttachmentIds) + assertEquals(ConnectMessagingAttachmentState.AVAILABLE, stateOf("tapped")) + assertEquals(ConnectMessagingAttachmentState.WAITING, stateOf("other")) + } + + private fun runPassWith( + automaticDownloadEnabled: Boolean = true, + requestedAttachmentId: String? = null, + fetcher: ConnectMessagingAttachmentFetcher, + ): ConnectMessagingAttachmentDownloader.PassResult { val recordingFetcher = ConnectMessagingAttachmentFetcher { messageId, attachmentId, expectedSize -> fetchedAttachmentIds.add(attachmentId) fetcher.fetch(messageId, attachmentId, expectedSize) } - return ConnectMessagingAttachmentDownloader(context, recordingFetcher).runPass(0L..Long.MAX_VALUE) { false } + val downloader = ConnectMessagingAttachmentDownloader(context, recordingFetcher, { automaticDownloadEnabled }) + return downloader.runPass( + { requestedAttachmentId == null || it.attachmentId == requestedAttachmentId }, + requestedAttachmentId != null, + ) { false } } private fun ok(body: ByteArray) = ConnectMessagingAttachmentFetchResponse(200, body) 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..bbe40d97f9 --- /dev/null +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessagingSettingsTest.kt @@ -0,0 +1,140 @@ +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.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 + + @Before + fun setUp() { + mockApi.start() + repeat(SYNC_RESPONSES) { + mockApi.server.enqueue(MockResponse().setBody("""{"notifications": [], "channels": []}""")) + } + signInToPersonalId(userId = "test-user", password = "test-password") + } + + @After + fun tearDown() { + mockApi.shutdown() + ConnectMessagingAttachmentDownloadScheduler.cancelDownloads(context) + ConnectMessagingPreferences.clear(context) + ConnectDatabaseHelper.teardown() + } + + @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)) + } +} From d4c078619a5190be8a607182bff446280b9e312c Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Mon, 5 Oct 2026 11:56:13 -0500 Subject: [PATCH 09/46] Document the automatic attachment download setting [AI] Co-Authored-By: Claude Opus 5.5 --- docs/connect/messaging-media.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/connect/messaging-media.md b/docs/connect/messaging-media.md index 201babcd53..646624d7c1 100644 --- a/docs/connect/messaging-media.md +++ b/docs/connect/messaging-media.md @@ -15,19 +15,31 @@ messages behave as before. Visual design: https://claude.ai/artifact/QvRs3amdJSE 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` (unique WorkManager work, scheduled - after every sync and restarted on a tap) 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. Network type and a large-file threshold are parameters in - `ConnectMessagingAttachmentDownloadConditions`; both are "any network" today. +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; a tapped attachment gets its own job on any network. 4. **Chat.** 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 pending downloads to `waiting` | + +Tapping an attachment always downloads it right away, whatever the setting. + ## Attachment states | State | Set when | Shown as | |---|---|---| -| `queued` | First stored, if the message is under 7 days old and among the channel's newest 50 incoming messages; or tapped | Spinner tile | +| `queued` | First stored, if automatic download is on, the message is under 7 days old and among the channel's newest 50 incoming messages; or tapped | Spinner tile | | `waiting` | First stored, otherwise | Download tile | | `downloading` | The job picks it up (reset to `queued` if the job died) | Spinner tile | | `available` | Decrypted file on disk; kept after expiry | Image, audio player or file chip | @@ -64,7 +76,8 @@ Dimensions, styles and icons are prefixed `connect_message_` / `ConnectMessage` ## Edge cases -- **Entry and back:** unchanged; attachments live inside the existing chat screen. +- **Entry and back:** attachments live inside the existing chat screen; settings open from the + channel list menu and Back returns to the list. - **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. From d74b82b4aa02ef70ddcfea60ba5ebe7cbee3bbc4 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Mon, 5 Oct 2026 12:09:42 -0500 Subject: [PATCH 10/46] Keep rich messages hidden until all their attachments are downloaded [AI] A message with attachments now shows one generic download tile, with no text or attachment details, until every attachment is available, and the channel list previews it as a media message to download. Tapping it queues each unfinished attachment; downloads stay independent. Co-Authored-By: Claude Opus 5.5 --- ...iew_connect_message_attachment_pending.xml | 8 -- app/res/values-es/strings.xml | 1 + app/res/values-fr/strings.xml | 1 + app/res/values-ha/strings.xml | 1 + app/res/values-hi/strings.xml | 1 + app/res/values-lt/strings.xml | 1 + app/res/values-no/strings.xml | 1 + app/res/values-pt/strings.xml | 1 + app/res/values-sw/strings.xml | 1 + app/res/values-ti/strings.xml | 1 + app/res/values/strings.xml | 2 +- app/res/values/styles.xml | 6 -- .../adapters/ConnectMessageAdapter.java | 22 +++-- ...onnectMessagingAttachmentDatabaseHelper.kt | 33 +++++-- .../ConnectMessagingDatabaseHelper.java | 27 +++++- .../ConnectMessagingMessagePackage.kt | 22 +++++ .../ConnectMessageAttachmentItem.kt | 2 +- .../ConnectMessageAttachmentsBinder.kt | 33 ++++--- .../ConnectMessageChatData.java | 23 ++++- .../ConnectMessageFragment.java | 25 +++-- .../adapters/ConnectMessageAdapterTest.kt | 70 ++++++++----- ...ctMessagingAttachmentDatabaseHelperTest.kt | 39 ++++++++ .../ConnectMessagingChannelPreviewTest.kt | 97 +++++++++++++++++++ .../ConnectMessagingMessagePackageTest.kt | 44 +++++++++ .../ConnectMessageAttachmentsBinderTest.kt | 24 ++--- 25 files changed, 388 insertions(+), 98 deletions(-) create mode 100644 app/src/org/commcare/connect/messaging/ConnectMessagingMessagePackage.kt create mode 100644 app/unit-tests/src/org/commcare/connect/database/ConnectMessagingChannelPreviewTest.kt create mode 100644 app/unit-tests/src/org/commcare/connect/messaging/ConnectMessagingMessagePackageTest.kt diff --git a/app/res/layout/view_connect_message_attachment_pending.xml b/app/res/layout/view_connect_message_attachment_pending.xml index f553bd64f2..d90dd6a703 100644 --- a/app/res/layout/view_connect_message_attachment_pending.xml +++ b/app/res/layout/view_connect_message_attachment_pending.xml @@ -51,12 +51,4 @@ android:gravity="center" android:text="@string/connect_messaging_attachment_tap_to_retry" android:visibility="gone" /> - - diff --git a/app/res/values-es/strings.xml b/app/res/values-es/strings.xml index 029f9b6213..cc4bebe553 100644 --- a/app/res/values-es/strings.xml +++ b/app/res/values-es/strings.xml @@ -774,4 +774,5 @@ 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 8e09bc9f52..fa4abc1b0e 100644 --- a/app/res/values-fr/strings.xml +++ b/app/res/values-fr/strings.xml @@ -773,4 +773,5 @@ License. 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 a14bda9755..6dfb3d0064 100644 --- a/app/res/values-ha/strings.xml +++ b/app/res/values-ha/strings.xml @@ -768,4 +768,5 @@ Don cikakken aiki na wurin zama na aikace-aikace da yawa, da fatan za a yi sabon 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 825da42f02..e7940860f2 100644 --- a/app/res/values-hi/strings.xml +++ b/app/res/values-hi/strings.xml @@ -770,4 +770,5 @@ License. अटैचमेंट अपने-आप डाउनलोड करें बड़े अटैचमेंट (1 MB से ज़्यादा) केवल वाई-फ़ाई पर केवल मैन्युअल डाउनलोड + [डाउनलोड करने के लिए मीडिया संदेश] diff --git a/app/res/values-lt/strings.xml b/app/res/values-lt/strings.xml index c2cb1d8211..05eae0d4bb 100644 --- a/app/res/values-lt/strings.xml +++ b/app/res/values-lt/strings.xml @@ -484,4 +484,5 @@ 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 c248eb41b2..41fb6b03ef 100644 --- a/app/res/values-no/strings.xml +++ b/app/res/values-no/strings.xml @@ -460,4 +460,5 @@ 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 89cd13a5a8..1a821db357 100644 --- a/app/res/values-pt/strings.xml +++ b/app/res/values-pt/strings.xml @@ -780,4 +780,5 @@ 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 96cac21646..95de577f0f 100644 --- a/app/res/values-sw/strings.xml +++ b/app/res/values-sw/strings.xml @@ -778,4 +778,5 @@ 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 0bc892fd06..df1d07f6bc 100644 --- a/app/res/values-ti/strings.xml +++ b/app/res/values-ti/strings.xml @@ -762,4 +762,5 @@ ተተሓሒዝቲ ብኣውቶማቲክ ኣውርድ ዓበይቲ ተተሓሒዝቲ (ካብ 1 MB ንላዕሊ) ብዋይፋይ ጥራይ ብኢድ ምውራድ ጥራይ + [ዝወርድ መልእኽቲ ሚድያ] diff --git a/app/res/values/strings.xml b/app/res/values/strings.xml index 9f41cf475c..8f2bae7761 100644 --- a/app/res/values/strings.xml +++ b/app/res/values/strings.xml @@ -504,7 +504,7 @@ Download failed Tap to retry No longer available - %1$s · %2$s + [Media message to download] No app on this phone can open this file Play audio Pause audio diff --git a/app/res/values/styles.xml b/app/res/values/styles.xml index b97bfe8e4a..9a1238abbe 100644 --- a/app/res/values/styles.xml +++ b/app/res/values/styles.xml @@ -499,12 +499,6 @@ center - - - - From 1a81c3bc7852c979565799b85fff5c8434ebd0d8 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Tue, 13 Oct 2026 14:32:16 -0500 Subject: [PATCH 35/46] Document that parse and storage errors fail the whole sync [AI] Co-Authored-By: Claude Opus 5.5 --- docs/connect/messaging-media.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/connect/messaging-media.md b/docs/connect/messaging-media.md index 1239531e25..845a014c19 100644 --- a/docs/connect/messaging-media.md +++ b/docs/connect/messaging-media.md @@ -11,8 +11,8 @@ messages behave as before. Visual design: https://claude.ai/artifact/QvRs3amdJSE 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. An entry that fails to parse is logged, skipped and not acked, so it can't block the - rest of the sync. + 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 From b49f70d787ba8b7c6a6a4d1473487876f8737a2e Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Tue, 13 Oct 2026 15:18:14 -0500 Subject: [PATCH 36/46] Size images by their EXIF orientation [AI] Phone photos stored sideways were sized from their raw pixels while Glide drew them rotated, so the crop cut off their top and bottom. Co-Authored-By: Claude Opus 5.5 --- .../ConnectMessageAttachmentsBinder.kt | 19 +++++- .../ConnectMessageAttachmentsBinderTest.kt | 39 ++++++++++++ .../ConnectMessageTestImages.kt | 60 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageTestImages.kt diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt index e8678daad6..34b4bbe0bc 100644 --- a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt @@ -8,6 +8,7 @@ import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import androidx.core.content.ContextCompat +import androidx.exifinterface.media.ExifInterface import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState @@ -15,7 +16,9 @@ import org.commcare.dalvik.R import org.commcare.dalvik.databinding.ViewConnectMessageAttachmentFileBinding import org.commcare.dalvik.databinding.ViewConnectMessageAttachmentImageBinding import org.commcare.dalvik.databinding.ViewConnectMessageAttachmentPendingBinding +import org.javarosa.core.services.Logger import java.io.File +import java.io.IOException class ConnectMessageAttachmentsBinder( private val container: LinearLayout, @@ -184,9 +187,23 @@ class ConnectMessageAttachmentsBinder( if (options.outWidth <= 0 || options.outHeight <= 0) { return null } - return ConnectMessageMediaSizer.Size(options.outWidth, options.outHeight).also { imageSizes.put(attachmentId, it) } + val size = + if (isStoredSideways(file)) { + ConnectMessageMediaSizer.Size(options.outHeight, options.outWidth) + } else { + ConnectMessageMediaSizer.Size(options.outWidth, options.outHeight) + } + return size.also { imageSizes.put(attachmentId, it) } } + 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 + } + private companion object { const val IMAGE_SIZE_CACHE_ENTRIES = 200 val imageSizes = LruCache(IMAGE_SIZE_CACHE_ENTRIES) diff --git a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt index 987f40959b..814ef53b13 100644 --- a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt @@ -4,6 +4,7 @@ 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 @@ -55,6 +56,26 @@ class ConnectMessageAttachmentsBinderTest { assertTrue(layout.fillsBubbleWidth) } + @Test + fun `phone photo stored sideways is sized by its upright orientation`() { + val photo = image("sideways-photo", storedWidth = 40, storedHeight = 30, 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 = 40, storedHeight = 30) + + 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 `downloaded file of another type shows a chip that opens it`() { binder.bind(listOf(available("doc", "application/pdf")), MAX_WIDTH, MAX_HEIGHT) @@ -145,6 +166,24 @@ class ConnectMessageAttachmentsBinderTest { ) } + 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/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()) + } +} From 204a1874d04ba3c5a3d873b69d1d2f4800fdc5f8 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Tue, 13 Oct 2026 15:18:14 -0500 Subject: [PATCH 37/46] Stop portrait images and the keyboard from narrowing message bubbles [AI] Text under media now wraps at the wider of the media and the normal text width, and the image height cap comes from the window rather than the list, which shrinks while the keyboard is open. Co-Authored-By: Claude Opus 5.5 --- .../adapters/ConnectMessageAdapter.java | 14 +++-- .../ConnectMessageMediaSizer.kt | 2 +- .../adapters/ConnectMessageAdapterTest.kt | 56 +++++++++++++++++++ 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/app/src/org/commcare/adapters/ConnectMessageAdapter.java b/app/src/org/commcare/adapters/ConnectMessageAdapter.java index 846b31b673..144713be79 100644 --- a/app/src/org/commcare/adapters/ConnectMessageAdapter.java +++ b/app/src/org/commcare/adapters/ConnectMessageAdapter.java @@ -204,25 +204,27 @@ public void bindAttachments(ConnectMessageChatData chat) { : ViewGroup.LayoutParams.WRAP_CONTENT; richBinding.llBubble.setLayoutParams(bubbleParams); richBinding.tvChatMessage.setMaxWidth(hasAttachments && !layout.getFillsBubbleWidth() - ? layout.getContentWidth() + ? Math.max(layout.getContentWidth(), bubbleContentWidth(TEXT_BUBBLE_WIDTH_FRACTION)) : Integer.MAX_VALUE); } private int maxMediaWidth() { + return bubbleContentWidth(MEDIA_BUBBLE_WIDTH_FRACTION); + } + + private int bubbleContentWidth(float bubbleWidthFraction) { int rowWidth = messageList.getWidth() > 0 ? messageList.getWidth() : displayMetrics().widthPixels; 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, rowWidth - rowMargins - tailWidth - bubblePadding); + return Math.max(1, (int)(rowWidth * bubbleWidthFraction) - rowMargins - tailWidth - bubblePadding); } private int maxMediaHeight() { - int listHeight = messageList.getHeight() > 0 - ? messageList.getHeight() - : displayMetrics().heightPixels; - return Math.max(1, (int)(listHeight * ConnectMessageMediaSizer.MAX_HEIGHT_FRACTION_OF_LIST)); + return Math.max(1, (int)(displayMetrics().heightPixels + * ConnectMessageMediaSizer.MAX_HEIGHT_FRACTION_OF_WINDOW)); } private DisplayMetrics displayMetrics() { diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizer.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizer.kt index e1567cae5c..2a647cd3bc 100644 --- a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizer.kt +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizer.kt @@ -5,7 +5,7 @@ import kotlin.math.min import kotlin.math.roundToInt object ConnectMessageMediaSizer { - const val MAX_HEIGHT_FRACTION_OF_LIST = 0.5f + const val MAX_HEIGHT_FRACTION_OF_WINDOW = 0.4f data class Size( val width: Int, diff --git a/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt index db46762abf..1e5be89b53 100644 --- a/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt +++ b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt @@ -17,6 +17,7 @@ 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 @@ -202,6 +203,35 @@ class ConnectMessageAdapterTest { 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 = 30, storedHeight = 80, 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 `text under a wide image can span the whole image`() { + val row = bindImageRow("landscape", storedWidth = 160, storedHeight = 90, text = LONG_TEXT) + + val image = row.llAttachments.getChildAt(0) + val imageWidth = image.layoutParams.width + assertEquals(imageWidth, row.tvChatMessage.maxWidth) + } + + @Test + fun `image size does not change when the list shrinks for the keyboard`() { + val keyboardClosed = bindImageRow("tall", storedWidth = 30, storedHeight = 80, listHeight = 1600) + val keyboardOpen = bindImageRow("tall", storedWidth = 30, storedHeight = 80, 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() @@ -230,6 +260,31 @@ class ConnectMessageAdapterTest { assertEquals(View.GONE, binding.tvChatMessage.visibility) } + private fun bindImageRow( + attachmentId: String, + storedWidth: Int, + storedHeight: Int, + text: String = "caption", + listHeight: Int = 1600, + ): ItemChatLeftRichViewBinding { + val file = ConnectMessageTestImages.writePng(File(themedContext().cacheDir, attachmentId), storedWidth, storedHeight) + val image = + ConnectMessageAttachmentItem( + attachmentId, + "$attachmentId.png", + "image/png", + file.length(), + ConnectMessagingAttachmentState.AVAILABLE, + file, + ) + adapter.updateData(listOf(getRichChat(attachmentId, listOf(image), null, text = text))) + val listWidth = themedContext().resources.displayMetrics.widthPixels + 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 themedContext(): Context = ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.ConnectTheme) private fun getPendingChat( @@ -339,5 +394,6 @@ class ConnectMessageAdapterTest { companion object { private const val TIMESTAMP = 1_000L private const val UNTOUCHED_TEXT = "untouched" + private val LONG_TEXT = "Please keep the baby warm and feeding well. ".repeat(12) } } From cc67f370ee3455961913a5398e8fe225e7555dfe Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Tue, 13 Oct 2026 15:18:14 -0500 Subject: [PATCH 38/46] Document the revised image and text sizing [AI] Co-Authored-By: Claude Opus 5.5 --- docs/connect/messaging-media.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/connect/messaging-media.md b/docs/connect/messaging-media.md index 845a014c19..e38783440a 100644 --- a/docs/connect/messaging-media.md +++ b/docs/connect/messaging-media.md @@ -73,11 +73,15 @@ resets the count. 401/403 stop the pass without counting an attempt. ## Design (dp) -- **Row.** Messages with attachments drop the 70% text-bubble cap. Media first, text below it, - then the timestamp. Bubble padding 10, 8 between attachments and before the text. +- **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. Bubble padding 10, 8 between attachments + and before the text. - **Image.** `w = min(maxW, maxH × aspect)`, `h = w ÷ aspect`; `maxW` = list width − 38 - (row margins, tail, bubble padding), `maxH` = list height × 0.5. Corners 6. Loaded with - Glide without a disk cache, so no decrypted copies leave the attachment directory. + (row margins, tail, bubble padding), `maxH` = window height × 0.4 (about half the chat list + with the keyboard closed, and unaffected by it). 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. - **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. From 7378aa9919cc0dbb9177d872c205c621a095ee92 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Tue, 13 Oct 2026 15:25:45 -0500 Subject: [PATCH 39/46] Size media from the list's width while it is first being measured [AI] Rows bound during the list's first measure saw a zero width and fell back to the screen width, which ignores the list's margins, so full-width images were clipped on the right until they were rebound. Co-Authored-By: Claude Opus 5.5 --- .../adapters/ConnectMessageAdapter.java | 14 ++++-- .../adapters/ConnectMessageAdapterTest.kt | 50 ++++++++++++++++--- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/app/src/org/commcare/adapters/ConnectMessageAdapter.java b/app/src/org/commcare/adapters/ConnectMessageAdapter.java index 144713be79..4bd71469ea 100644 --- a/app/src/org/commcare/adapters/ConnectMessageAdapter.java +++ b/app/src/org/commcare/adapters/ConnectMessageAdapter.java @@ -212,10 +212,18 @@ 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 = messageList.getWidth() > 0 - ? messageList.getWidth() - : displayMetrics().widthPixels; + 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(); diff --git a/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt index 1e5be89b53..97a1a61a07 100644 --- a/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt +++ b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt @@ -4,6 +4,7 @@ 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 @@ -221,10 +222,32 @@ class ConnectMessageAdapterTest { 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 = 160, storedHeight = 90))) + 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 = 160, storedHeight = 90, 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("tall", storedWidth = 30, storedHeight = 80, listHeight = 1600) - val keyboardOpen = bindImageRow("tall", storedWidth = 30, storedHeight = 80, listHeight = 700) + val keyboardClosed = bindImageRow("keyboard-closed", storedWidth = 30, storedHeight = 80, listHeight = 1600) + val keyboardOpen = bindImageRow("keyboard-open", storedWidth = 30, storedHeight = 80, listHeight = 700) val closedSize = keyboardClosed.llAttachments.getChildAt(0).layoutParams val openSize = keyboardOpen.llAttachments.getChildAt(0).layoutParams @@ -265,8 +288,22 @@ class ConnectMessageAdapterTest { 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( @@ -277,12 +314,7 @@ class ConnectMessageAdapterTest { ConnectMessagingAttachmentState.AVAILABLE, file, ) - adapter.updateData(listOf(getRichChat(attachmentId, listOf(image), null, text = text))) - val listWidth = themedContext().resources.displayMetrics.widthPixels - 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) + return getRichChat(attachmentId, listOf(image), null, text = text) } private fun themedContext(): Context = ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.ConnectTheme) @@ -394,6 +426,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) } } From c42c06cce4a0395f39867cc31b3e560776b2d621 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Tue, 13 Oct 2026 15:36:39 -0500 Subject: [PATCH 40/46] Center media above captions that are wider than it [AI] Co-Authored-By: Claude Opus 5.5 --- app/res/layout/item_chat_left_rich_view.xml | 1 + .../ConnectMessageAttachmentsBinder.kt | 2 ++ .../adapters/ConnectMessageAdapterTest.kt | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/app/res/layout/item_chat_left_rich_view.xml b/app/res/layout/item_chat_left_rich_view.xml index 4197235153..f9d0880a3e 100644 --- a/app/res/layout/item_chat_left_rich_view.xml +++ b/app/res/layout/item_chat_left_rich_view.xml @@ -37,6 +37,7 @@ android:id="@+id/llAttachments" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" android:layout_marginBottom="@dimen/connect_space_sm" android:orientation="vertical" android:visibility="gone" /> diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt index 34b4bbe0bc..bf6cfe18b2 100644 --- a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt @@ -3,6 +3,7 @@ package org.commcare.fragments.connectMessaging import android.graphics.BitmapFactory 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 @@ -96,6 +97,7 @@ class ConnectMessageAttachmentsBinder( 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 } diff --git a/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt index 97a1a61a07..01d6b9599f 100644 --- a/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt +++ b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt @@ -213,6 +213,24 @@ class ConnectMessageAdapterTest { 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 = 30, storedHeight = 80, 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 = 160, storedHeight = 90, text = LONG_TEXT) From 038cf3a0eb494425c5c569be30cfdf4f92b828a8 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Tue, 13 Oct 2026 15:36:53 -0500 Subject: [PATCH 41/46] Document centered media above wider captions [AI] Co-Authored-By: Claude Opus 5.5 --- docs/connect/messaging-media.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/connect/messaging-media.md b/docs/connect/messaging-media.md index e38783440a..58102c4c1e 100644 --- a/docs/connect/messaging-media.md +++ b/docs/connect/messaging-media.md @@ -75,8 +75,8 @@ resets the count. 401/403 stop the pass without counting an attempt. - **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. Bubble padding 10, 8 between attachments - and before the 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.** `w = min(maxW, maxH × aspect)`, `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). Dimensions follow the EXIF orientation, as From a5b16ca634991c384055245af604ac93938b978b Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Tue, 13 Oct 2026 17:38:31 -0500 Subject: [PATCH 42/46] Add an image lightbox and show chat images at their natural size [AI] Tapping a chat image opens a zoomable viewer (Subsampling Scale Image View). Chat images no longer stretch to the bubble width: they show at one image pixel per dp, with a 48dp minimum below which they aren't tappable, and the viewer uses the same natural size so an image never opens smaller than it appears in the chat. Co-Authored-By: Claude Opus 5.5 --- app/build.gradle | 1 + .../fragment_connect_message_image_viewer.xml | 28 +++ .../nav_graph_connect_messaging.xml | 13 ++ .../ConnectMessageAttachmentsBinder.kt | 44 ++-- .../ConnectMessageFragment.java | 6 + .../ConnectMessageImageDimensions.kt | 30 +++ .../ConnectMessageImageViewerFragment.kt | 105 ++++++++++ .../ConnectMessageImageZoom.kt | 31 +++ .../ConnectMessageMediaSizer.kt | 16 +- .../adapters/ConnectMessageAdapterTest.kt | 14 +- .../ConnectMessageAttachmentsBinderTest.kt | 26 ++- .../ConnectMessageImageViewerTest.kt | 194 ++++++++++++++++++ .../ConnectMessageImageZoomTest.kt | 59 ++++++ .../ConnectMessageMediaSizerTest.kt | 56 ++++- 14 files changed, 579 insertions(+), 44 deletions(-) create mode 100644 app/res/layout/fragment_connect_message_image_viewer.xml create mode 100644 app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageDimensions.kt create mode 100644 app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerFragment.kt create mode 100644 app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageZoom.kt create mode 100644 app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerTest.kt create mode 100644 app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageZoomTest.kt 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/layout/fragment_connect_message_image_viewer.xml b/app/res/layout/fragment_connect_message_image_viewer.xml new file mode 100644 index 0000000000..38acb40e27 --- /dev/null +++ b/app/res/layout/fragment_connect_message_image_viewer.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/app/res/navigation/nav_graph_connect_messaging.xml b/app/res/navigation/nav_graph_connect_messaging.xml index 8730d53f6a..b4346ed85e 100644 --- a/app/res/navigation/nav_graph_connect_messaging.xml +++ b/app/res/navigation/nav_graph_connect_messaging.xml @@ -33,5 +33,18 @@ android:name="channel_id" app:argType="string" app:nullable="false"/> + + + + + \ No newline at end of file diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt index bf6cfe18b2..12748bf9dc 100644 --- a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinder.kt @@ -1,6 +1,5 @@ package org.commcare.fragments.connectMessaging -import android.graphics.BitmapFactory import android.text.format.Formatter import android.util.LruCache import android.view.Gravity @@ -9,7 +8,6 @@ import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import androidx.core.content.ContextCompat -import androidx.exifinterface.media.ExifInterface import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import org.commcare.android.database.connect.models.ConnectMessagingAttachmentState @@ -17,9 +15,7 @@ import org.commcare.dalvik.R import org.commcare.dalvik.databinding.ViewConnectMessageAttachmentFileBinding import org.commcare.dalvik.databinding.ViewConnectMessageAttachmentImageBinding import org.commcare.dalvik.databinding.ViewConnectMessageAttachmentPendingBinding -import org.javarosa.core.services.Logger import java.io.File -import java.io.IOException class ConnectMessageAttachmentsBinder( private val container: LinearLayout, @@ -67,9 +63,20 @@ class ConnectMessageAttachmentsBinder( 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) - view = createImageView(attachment, 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 } @@ -110,6 +117,7 @@ class ConnectMessageAttachmentsBinder( private fun createImageView( attachment: ConnectMessageAttachmentItem, size: ConnectMessageMediaSizer.Size, + opensViewer: Boolean, ): View { val binding = ViewConnectMessageAttachmentImageBinding.inflate(inflater, container, false) val imageView = binding.root @@ -121,6 +129,9 @@ class ConnectMessageAttachmentsBinder( .override(size.width, size.height) .diskCacheStrategy(DiskCacheStrategy.NONE) .into(imageView) + if (opensViewer) { + imageView.setOnClickListener { listener.onAttachmentOpenRequested(attachment) } + } return imageView } @@ -184,28 +195,9 @@ class ConnectMessageAttachmentsBinder( file: File, ): ConnectMessageMediaSizer.Size? { imageSizes.get(attachmentId)?.let { return it } - val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeFile(file.path, options) - if (options.outWidth <= 0 || options.outHeight <= 0) { - return null - } - val size = - if (isStoredSideways(file)) { - ConnectMessageMediaSizer.Size(options.outHeight, options.outWidth) - } else { - ConnectMessageMediaSizer.Size(options.outWidth, options.outHeight) - } - return size.also { imageSizes.put(attachmentId, it) } + return ConnectMessageImageDimensions.read(file)?.also { imageSizes.put(attachmentId, it) } } - 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 - } - 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/ConnectMessageFragment.java b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageFragment.java index 1774e0451d..9c4cdeb138 100644 --- a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageFragment.java +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageFragment.java @@ -25,6 +25,7 @@ import androidx.fragment.app.Fragment; import androidx.lifecycle.Lifecycle; import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import androidx.navigation.Navigation; import androidx.recyclerview.widget.RecyclerView; import org.commcare.activities.connect.ConnectMessagingActivity; @@ -394,6 +395,11 @@ public void onAttachmentOpenRequested(@NonNull ConnectMessageAttachmentItem atta if (file == null) { return; } + if (attachment.isImage()) { + Navigation.findNavController(requireView()).navigate(ConnectMessageFragmentDirections + .actionConnectMessageFragmentToConnectMessageImageViewerFragment(attachment.getAttachmentId())); + return; + } Intent intent = new Intent(Intent.ACTION_VIEW); intent.setDataAndType(FileUtil.getUriForExternalFile(requireContext(), file), attachment.getMimeType()); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 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..bfa9f8b708 --- /dev/null +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerFragment.kt @@ -0,0 +1,105 @@ +package org.commcare.fragments.connectMessaging + +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.fragment.findNavController +import com.davemorrissey.labs.subscaleview.ImageSource +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import org.commcare.connect.database.ConnectMessagingAttachmentDatabaseHelper +import org.commcare.connect.database.ConnectMessagingAttachmentFileStore +import org.commcare.dalvik.databinding.FragmentConnectMessageImageViewerBinding +import org.commcare.utils.MarkupUtil +import java.io.File + +class ConnectMessageImageViewerFragment : Fragment() { + private var binding: FragmentConnectMessageImageViewerBinding? = null + private var title: String? = null + + 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 + if (attachment == null || message == null || imageSize == null) { + findNavController().popBackStack() + return + } + + title = + DateUtils.formatDateTime( + requireContext(), + message.timeStamp.time, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_MONTH, + ) + val viewerBinding = binding ?: return + bindCaption(message.displayText) + bindImage(viewerBinding.ivImage, file, imageSize, attachment.name) + } + + override fun onResume() { + super.onResume() + title?.let { requireActivity().title = it } + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + private fun bindCaption(text: String) { + val caption = binding?.tvCaption ?: return + if (text.isBlank()) { + caption.visibility = View.GONE + return + } + caption.visibility = View.VISIBLE + MarkupUtil.setMarkdown(caption, SpannableStringBuilder(text), SpannableStringBuilder()) + } + + private fun bindImage( + imageView: SubsamplingScaleImageView, + file: File, + imageSize: ConnectMessageMediaSizer.Size, + name: String, + ) { + imageView.contentDescription = name + imageView.orientation = SubsamplingScaleImageView.ORIENTATION_USE_EXIF + imageView.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM) + imageView.doOnLayout { + val limits = + ConnectMessageImageZoom.limitsFor( + imageSize.width, + imageSize.height, + it.width, + it.height, + resources.displayMetrics.density, + ) + imageView.minScale = limits.minScale + imageView.maxScale = limits.maxScale + imageView.setDoubleTapZoomScale(limits.doubleTapScale) + imageView.setImage(ImageSource.uri(Uri.fromFile(file))) + } + } +} 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 index 2a647cd3bc..fb2092bdb5 100644 --- a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizer.kt +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizer.kt @@ -17,13 +17,27 @@ object ConnectMessageMediaSizer { 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 width = min(maxWidth.toFloat(), maxHeight * aspectRatio) + 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/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt index 01d6b9599f..2fec1fa525 100644 --- a/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt +++ b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt @@ -206,7 +206,7 @@ class ConnectMessageAdapterTest { @Test fun `long text under a narrow portrait image keeps the normal text width`() { - val row = bindImageRow("portrait", storedWidth = 30, storedHeight = 80, text = LONG_TEXT) + val row = bindImageRow("portrait", storedWidth = 300, storedHeight = 800, text = LONG_TEXT) val image = row.llAttachments.getChildAt(0) val imageWidth = image.layoutParams.width @@ -215,7 +215,7 @@ class ConnectMessageAdapterTest { @Test fun `narrow image is centered above wider text`() { - val row = bindImageRow("centered", storedWidth = 30, storedHeight = 80, text = LONG_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), @@ -233,7 +233,7 @@ class ConnectMessageAdapterTest { @Test fun `text under a wide image can span the whole image`() { - val row = bindImageRow("landscape", storedWidth = 160, storedHeight = 90, text = LONG_TEXT) + val row = bindImageRow("landscape", storedWidth = 1600, storedHeight = 900, text = LONG_TEXT) val image = row.llAttachments.getChildAt(0) val imageWidth = image.layoutParams.width @@ -243,7 +243,7 @@ class ConnectMessageAdapterTest { @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 = 160, storedHeight = 90))) + adapter.updateData(listOf(getImageChat("measuring", storedWidth = 1600, storedHeight = 900))) val chatList = RecyclerView(themedContext()).apply { layoutManager = LinearLayoutManager(themedContext()) @@ -256,7 +256,7 @@ class ConnectMessageAdapterTest { ) val measuringRow = ItemChatLeftRichViewBinding.bind(chatList.getChildAt(0)) - val laidOutRow = bindImageRow("laid-out", storedWidth = 160, storedHeight = 90, listWidth = listWidth) + 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) @@ -264,8 +264,8 @@ class ConnectMessageAdapterTest { @Test fun `image size does not change when the list shrinks for the keyboard`() { - val keyboardClosed = bindImageRow("keyboard-closed", storedWidth = 30, storedHeight = 80, listHeight = 1600) - val keyboardOpen = bindImageRow("keyboard-open", storedWidth = 30, storedHeight = 80, listHeight = 700) + 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 diff --git a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt index 814ef53b13..2e1b7fb6df 100644 --- a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt @@ -58,7 +58,7 @@ class ConnectMessageAttachmentsBinderTest { @Test fun `phone photo stored sideways is sized by its upright orientation`() { - val photo = image("sideways-photo", storedWidth = 40, storedHeight = 30, ExifInterface.ORIENTATION_ROTATE_90) + val photo = image("sideways-photo", storedWidth = 400, storedHeight = 300, ExifInterface.ORIENTATION_ROTATE_90) binder.bind(listOf(photo), MAX_WIDTH, MAX_HEIGHT) @@ -68,7 +68,7 @@ class ConnectMessageAttachmentsBinderTest { @Test fun `photo without rotation keeps its stored orientation`() { - val photo = image("upright-photo", storedWidth = 40, storedHeight = 30) + val photo = image("upright-photo", storedWidth = 400, storedHeight = 300) binder.bind(listOf(photo), MAX_WIDTH, MAX_HEIGHT) @@ -76,6 +76,28 @@ class ConnectMessageAttachmentsBinderTest { 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) + container.getChildAt(0).performClick() + + assertEquals(listOf("tappable-photo"), openedAttachments) + } + + @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) 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..b3a3ffa2b9 --- /dev/null +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerTest.kt @@ -0,0 +1,194 @@ +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.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.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 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() + ShadowLooper.idleMainLooper() + + 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() + ShadowLooper.idleMainLooper() + + assertEquals(R.id.connectMessageFragment, navController().currentDestination?.id) + } + + @Test + fun `media-only message shows the image without a caption`() { + storeImageMessage(caption = "") + openChat() + + chatImage().performClick() + ShadowLooper.idleMainLooper() + + 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 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 { + 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) + val row = ItemChatLeftRichViewBinding.bind(chat.findViewHolderForAdapterPosition(0)!!.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 + } +} 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 index 756b844a05..668d5785ad 100644 --- a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizerTest.kt +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageMediaSizerTest.kt @@ -1,32 +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 image fills the available width`() { - val size = ConnectMessageMediaSizer.fitImage(4000, 3000, maxWidth = 344, maxHeight = 361) + fun `wide photo fills the available width`() { + val size = fit(4000, 3000) assertEquals(ConnectMessageMediaSizer.Size(344, 258), size) } @Test - fun `tall image stops at the maximum height and narrows to keep its shape`() { - val size = ConnectMessageMediaSizer.fitImage(3000, 4000, maxWidth = 344, maxHeight = 361) + 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 scaled up to the available width`() { - val size = ConnectMessageMediaSizer.fitImage(100, 100, maxWidth = 344, maxHeight = 361) + fun `small image is shown at its natural size in dp rather than stretched`() { + val size = fit(100, 60) - assertEquals(ConnectMessageMediaSizer.Size(344, 344), size) + 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`() { - ConnectMessageMediaSizer.fitImage(0, 100, maxWidth = 344, maxHeight = 361) + 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 } } From 77152daea6c2ac81956401826153ca0537dbf7fb Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Tue, 13 Oct 2026 17:38:32 -0500 Subject: [PATCH 43/46] Document the image lightbox and natural-size sizing [AI] Co-Authored-By: Claude Opus 5.5 --- docs/connect/messaging-media.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/connect/messaging-media.md b/docs/connect/messaging-media.md index 58102c4c1e..f2202c2497 100644 --- a/docs/connect/messaging-media.md +++ b/docs/connect/messaging-media.md @@ -77,11 +77,19 @@ resets the count. 401/403 stop the pass without counting an attempt. 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.** `w = min(maxW, maxH × aspect)`, `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). 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. +- **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. - **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. @@ -98,12 +106,13 @@ Dimensions, styles and icons are prefixed `connect_message_` / `ConnectMessage` - `format` is stored but every value uses the default layout (no gallery strip). - Download progress is indeterminate; cancelling isn't possible. -- The image lightbox from the design. +- 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. + 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. From 4dea2bc12394da95eb2a10b0de6e819940251562 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Tue, 13 Oct 2026 18:41:20 -0500 Subject: [PATCH 44/46] Animate the image lightbox from and back to the tapped image [AI] Only the image travels: a stand-in copy grows from the thumbnail to where the zoomable view shows it, while the chat fades out over the viewer's static black backdrop. Closing reverses it, fading the backdrop out as the image shrinks back. The zoomable view stays drawn while hidden so it finishes loading and can take over for pinch-zoom. Co-Authored-By: Claude Opus 5.5 --- .../fragment_connect_message_image_viewer.xml | 18 +- .../ConnectMessageAttachmentItem.kt | 6 + .../ConnectMessageAttachmentsBinder.kt | 3 +- .../ConnectMessageFragment.java | 35 +++- .../ConnectMessageImageViewerFragment.kt | 182 ++++++++++++++++-- .../adapters/ConnectMessageAdapterTest.kt | 5 + .../ConnectMessageAttachmentsBinderTest.kt | 14 +- .../ConnectMessageImageViewerTest.kt | 117 ++++++++++- 8 files changed, 348 insertions(+), 32 deletions(-) diff --git a/app/res/layout/fragment_connect_message_image_viewer.xml b/app/res/layout/fragment_connect_message_image_viewer.xml index 38acb40e27..d51b2cbc66 100644 --- a/app/res/layout/fragment_connect_message_image_viewer.xml +++ b/app/res/layout/fragment_connect_message_image_viewer.xml @@ -3,13 +3,27 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/black" tools:context="org.commcare.fragments.connectMessaging.ConnectMessageImageViewerFragment"> + + + android:layout_height="match_parent" + android:alpha="0" /> + + adapter = binding.rvChat.getAdapter(); if (adapter != null && adapter.getItemCount() > 0) { diff --git a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerFragment.kt b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerFragment.kt index bfa9f8b708..e0c51ce7dd 100644 --- a/app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerFragment.kt +++ b/app/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerFragment.kt @@ -1,5 +1,7 @@ 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 @@ -9,18 +11,59 @@ 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, @@ -42,20 +85,27 @@ class ConnectMessageImageViewerFragment : Fragment() { val message = attachment?.let { ConnectMessagingAttachmentDatabaseHelper.getMessage(it.messageId) } val file = ConnectMessagingAttachmentFileStore.fileFor(requireContext(), attachmentId) val imageSize = if (file.isFile) ConnectMessageImageDimensions.read(file) else null - if (attachment == null || message == null || imageSize == 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, ) - val viewerBinding = binding ?: return bindCaption(message.displayText) - bindImage(viewerBinding.ivImage, file, imageSize, attachment.name) + bindImage(viewerBinding, file, imageSize, attachment.name) } override fun onResume() { @@ -64,30 +114,38 @@ class ConnectMessageImageViewerFragment : Fragment() { } override fun onDestroyView() { + findNavController().removeOnDestinationChangedListener(returnListener) binding = null super.onDestroyView() } private fun bindCaption(text: String) { val caption = binding?.tvCaption ?: return - if (text.isBlank()) { - caption.visibility = View.GONE - return + hasCaption = text.isNotBlank() + if (hasCaption) { + MarkupUtil.setMarkdown(caption, SpannableStringBuilder(text), SpannableStringBuilder()) } - caption.visibility = View.VISIBLE - MarkupUtil.setMarkdown(caption, SpannableStringBuilder(text), SpannableStringBuilder()) } private fun bindImage( - imageView: SubsamplingScaleImageView, + viewerBinding: FragmentConnectMessageImageViewerBinding, file: File, imageSize: ConnectMessageMediaSizer.Size, name: String, ) { - imageView.contentDescription = name - imageView.orientation = SubsamplingScaleImageView.ORIENTATION_USE_EXIF - imageView.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM) - imageView.doOnLayout { + 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, @@ -96,10 +154,100 @@ class ConnectMessageImageViewerFragment : Fragment() { it.height, resources.displayMetrics.density, ) - imageView.minScale = limits.minScale - imageView.maxScale = limits.maxScale - imageView.setDoubleTapZoomScale(limits.doubleTapScale) - imageView.setImage(ImageSource.uri(Uri.fromFile(file))) + 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/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt index 2fec1fa525..721df4add2 100644 --- a/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt +++ b/app/unit-tests/src/org/commcare/adapters/ConnectMessageAdapterTest.kt @@ -360,6 +360,11 @@ class ConnectMessageAdapterTest { 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) = diff --git a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt index 2e1b7fb6df..0f095e8d3a 100644 --- a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageAttachmentsBinderTest.kt @@ -27,6 +27,7 @@ class ConnectMessageAttachmentsBinderTest { private val container = LinearLayout(context) private val requestedDownloads = mutableListOf() private val openedAttachments = mutableListOf() + private val openedImages = mutableListOf>() private val binder = ConnectMessageAttachmentsBinder( container, @@ -38,6 +39,13 @@ class ConnectMessageAttachmentsBinderTest { override fun onAttachmentOpenRequested(attachment: ConnectMessageAttachmentItem) { openedAttachments.add(attachment.attachmentId) } + + override fun onImageOpenRequested( + attachment: ConnectMessageAttachmentItem, + imageView: View, + ) { + openedImages.add(attachment.attachmentId to imageView) + } }, ) @@ -81,9 +89,11 @@ class ConnectMessageAttachmentsBinderTest { val photo = image("tappable-photo", storedWidth = 400, storedHeight = 300) binder.bind(listOf(photo), MAX_WIDTH, MAX_HEIGHT) - container.getChildAt(0).performClick() + val imageView = container.getChildAt(0) + imageView.performClick() - assertEquals(listOf("tappable-photo"), openedAttachments) + assertEquals(listOf("tappable-photo" to imageView), openedImages) + assertEquals(ConnectMessageImageViewerFragment.transitionNameFor("tappable-photo"), imageView.transitionName) } @Test diff --git a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerTest.kt b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerTest.kt index b3a3ffa2b9..50d3248003 100644 --- a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerTest.kt +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerTest.kt @@ -6,6 +6,7 @@ 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 @@ -29,6 +30,7 @@ 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 @@ -81,7 +83,7 @@ class ConnectMessageImageViewerTest { openChat() chatImage().performClick() - ShadowLooper.idleMainLooper() + settle() assertEquals(R.id.connectMessageImageViewerFragment, navController().currentDestination?.id) assertEquals(expectedTitle(), activity.title.toString()) @@ -90,18 +92,95 @@ class ConnectMessageImageViewerTest { assertEquals("Site map for Thursday", caption.text.toString().trim()) activity.onBackPressedDispatcher.onBackPressed() - ShadowLooper.idleMainLooper() + 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 `caption appears only once the image is in place`() { + storeImageMessage(caption = "Site map for Thursday") + openChat() + + chatImage().performClick() + ShadowLooper.idleMainLooper() + + assertEquals(View.GONE, activity.findViewById(R.id.tvCaption).visibility) + + settle() + + assertEquals(View.VISIBLE, activity.findViewById(R.id.tvCaption).visibility) + assertEquals(View.VISIBLE, activity.findViewById(R.id.vBackdrop).visibility) + } + + @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() - ShadowLooper.idleMainLooper() + settle() assertEquals(View.GONE, activity.findViewById(R.id.tvCaption).visibility) } @@ -150,6 +229,24 @@ class ConnectMessageImageViewerTest { } } + 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.runUiThreadTasksIncludingDelayedTasks() + } + private fun expectedTitle(): String { val message = ConnectMessagingAttachmentDatabaseHelper.getMessage(MESSAGE_ID)!! return DateUtils.formatDateTime( @@ -168,14 +265,23 @@ class ConnectMessageImageViewerTest { ShadowLooper.idleMainLooper() } - private fun chatImage(): View { + 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) - val row = ItemChatLeftRichViewBinding.bind(chat.findViewHolderForAdapterPosition(0)!!.itemView) + return chat + } + + private fun imageAt( + chat: RecyclerView, + position: Int, + ): View { + val row = ItemChatLeftRichViewBinding.bind(chat.findViewHolderForAdapterPosition(position)!!.itemView) return row.llAttachments.getChildAt(0) } @@ -190,5 +296,6 @@ class ConnectMessageImageViewerTest { const val SYNC_RESPONSES = 3 const val CHAT_WIDTH_PX = 1080 const val CHAT_HEIGHT_PX = 1920 + const val LATER_MESSAGES = 25 } } From fe42e85f33efbde63a7f55f6509c0c6842458771 Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Tue, 13 Oct 2026 18:41:20 -0500 Subject: [PATCH 45/46] Document the lightbox open and close animation [AI] Co-Authored-By: Claude Opus 5.5 --- docs/connect/messaging-media.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/connect/messaging-media.md b/docs/connect/messaging-media.md index f2202c2497..bd82a80c5c 100644 --- a/docs/connect/messaging-media.md +++ b/docs/connect/messaging-media.md @@ -89,7 +89,11 @@ resets the count. 401/403 stop the pass without counting an attempt. 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. + 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. From ec27c035007e8397b5151ebb3dda86caccfe08aa Mon Sep 17 00:00:00 2001 From: Clayton Sims Date: Tue, 13 Oct 2026 19:15:31 -0500 Subject: [PATCH 46/46] Settle lightbox tests on a fixed clock advance [AI] The viewer only resumes once its enter transition finishes, which starts when Glide's background load returns. Running just the queued tasks raced that load, so CI could assert before the title and caption were set. Drops an in-between caption check that raced the same way. Co-Authored-By: Claude Opus 5.5 --- .../ConnectMessageImageViewerTest.kt | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerTest.kt b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerTest.kt index 50d3248003..5cd460f2a3 100644 --- a/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerTest.kt +++ b/app/unit-tests/src/org/commcare/fragments/connectMessaging/ConnectMessageImageViewerTest.kt @@ -38,6 +38,7 @@ 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) @@ -111,22 +112,6 @@ class ConnectMessageImageViewerTest { assertEquals(image.transitionName, transitionImage.transitionName) } - @Test - fun `caption appears only once the image is in place`() { - storeImageMessage(caption = "Site map for Thursday") - openChat() - - chatImage().performClick() - ShadowLooper.idleMainLooper() - - assertEquals(View.GONE, activity.findViewById(R.id.tvCaption).visibility) - - settle() - - assertEquals(View.VISIBLE, activity.findViewById(R.id.tvCaption).visibility) - assertEquals(View.VISIBLE, activity.findViewById(R.id.vBackdrop).visibility) - } - @Test fun `going back hands the image to the shrinking copy`() { storeImageMessage(caption = "Site map for Thursday") @@ -244,7 +229,7 @@ class ConnectMessageImageViewerTest { } private fun settle() { - ShadowLooper.runUiThreadTasksIncludingDelayedTasks() + ShadowLooper.idleMainLooper(SETTLE_TIME_MS, TimeUnit.MILLISECONDS) } private fun expectedTitle(): String { @@ -297,5 +282,6 @@ class ConnectMessageImageViewerTest { const val CHAT_WIDTH_PX = 1080 const val CHAT_HEIGHT_PX = 1920 const val LATER_MESSAGES = 25 + const val SETTLE_TIME_MS = 2000L } }