From a2705ab7c7eeeb4a5c44d730d03d2c5a05e499a9 Mon Sep 17 00:00:00 2001 From: Jamie Lynch Date: Fri, 10 Jan 2025 11:05:38 +0000 Subject: [PATCH] file attachment business logic --- .../internal/logs/attachments/Attachment.kt | 89 ++++++++++++ .../logs/attachments/AttachmentCounter.kt | 19 +++ .../logs/attachments/AttachmentErrorCode.kt | 11 ++ .../logs/attachments/AttachmentTest.kt | 137 ++++++++++++++++++ .../logs/attachments/AttachmentCounterTest.kt | 24 +++ 5 files changed, 280 insertions(+) create mode 100644 embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/Attachment.kt create mode 100644 embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentCounter.kt create mode 100644 embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentErrorCode.kt create mode 100644 embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentTest.kt create mode 100644 embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentCounterTest.kt diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/Attachment.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/Attachment.kt new file mode 100644 index 0000000000..d5f9a23fec --- /dev/null +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/Attachment.kt @@ -0,0 +1,89 @@ +package io.embrace.android.embracesdk.internal.logs.attachments + +import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.ATTACHMENT_TOO_LARGE +import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.OVER_MAX_ATTACHMENTS +import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.UNKNOWN +import io.embrace.android.embracesdk.internal.utils.toNonNullMap +import java.util.UUID + +/** + * Holds attributes that describe an attachment to a log record. + */ +internal sealed class Attachment( + val size: Long, + val id: String, + val counter: AttachmentCounter, +) { + + internal companion object { + const val ATTR_KEY_SIZE = "emb.attachment_size" + const val ATTR_KEY_URL = "emb.attachment_url" + const val ATTR_KEY_ID = "emb.attachment_id" + const val ATTR_KEY_ERR_CODE = "emb.attachment_error_code" + private const val LIMIT_MB = 1 * 1024 * 1024 + } + + abstract val attributes: Map + + protected fun constructAttributes( + size: Long, + id: String, + errorCode: AttachmentErrorCode? = null + ) = mapOf( + ATTR_KEY_SIZE to size.toString(), + ATTR_KEY_ID to id, + ATTR_KEY_ERR_CODE to errorCode?.name + ).toNonNullMap() + + /** + * An attachment that is uploaded to Embrace's backend. + */ + class EmbraceHosted( + val bytes: ByteArray, + counter: AttachmentCounter, + ) : Attachment( + bytes.size.toLong(), + UUID.randomUUID().toString(), + counter + ) { + + private val errorCode: AttachmentErrorCode? = when { + !counter.incrementAndCheckAttachmentLimit() -> OVER_MAX_ATTACHMENTS + size > LIMIT_MB -> ATTACHMENT_TOO_LARGE + else -> null + } + + override val attributes: Map = constructAttributes(size, id, errorCode) + } + + /** + * An attachment that is uploaded to a user-supplied backend. + */ + class UserHosted( + size: Long, + id: String, + val url: String, + counter: AttachmentCounter, + ) : Attachment(size, id, counter) { + + private val errorCode: AttachmentErrorCode? = when { + !counter.incrementAndCheckAttachmentLimit() -> OVER_MAX_ATTACHMENTS + size < 0 -> UNKNOWN + url.isEmpty() -> UNKNOWN + isNotUuid() -> UNKNOWN + else -> null + } + + override val attributes: Map = + constructAttributes(size, id, errorCode).plus( + ATTR_KEY_URL to url + ) + + private fun isNotUuid(): Boolean = try { + UUID.fromString(id) + false + } catch (e: IllegalArgumentException) { + true + } + } +} diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentCounter.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentCounter.kt new file mode 100644 index 0000000000..8dd30d35be --- /dev/null +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentCounter.kt @@ -0,0 +1,19 @@ +package io.embrace.android.embracesdk.internal.logs.attachments + +import io.embrace.android.embracesdk.internal.session.MemoryCleanerListener +import java.util.concurrent.atomic.AtomicInteger + +/** + * Counts the number of attachments that should be added to log records. + */ +class AttachmentCounter(private val limit: Int = 5) : MemoryCleanerListener { + + private val count: AtomicInteger = AtomicInteger(0) + + override fun cleanCollections() = count.set(0) + + /** + * Increments the counter of attachments for this session and returns true if an attachment can be uploaded. + */ + fun incrementAndCheckAttachmentLimit(): Boolean = count.incrementAndGet() <= limit +} diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentErrorCode.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentErrorCode.kt new file mode 100644 index 0000000000..e3896ee8bb --- /dev/null +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentErrorCode.kt @@ -0,0 +1,11 @@ +package io.embrace.android.embracesdk.internal.logs.attachments + +/** + * Enumerates the states where an attachment could not be added to a log record. + */ +internal enum class AttachmentErrorCode { + ATTACHMENT_TOO_LARGE, + UNSUCCESSFUL_UPLOAD, + OVER_MAX_ATTACHMENTS, + UNKNOWN +} diff --git a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentTest.kt b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentTest.kt new file mode 100644 index 0000000000..8d7445ee4c --- /dev/null +++ b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentTest.kt @@ -0,0 +1,137 @@ +package io.embrace.android.embracesdk.internal.logs.attachments + +import io.embrace.android.embracesdk.internal.logs.attachments.Attachment.Companion.ATTR_KEY_ERR_CODE +import io.embrace.android.embracesdk.internal.logs.attachments.Attachment.Companion.ATTR_KEY_ID +import io.embrace.android.embracesdk.internal.logs.attachments.Attachment.Companion.ATTR_KEY_SIZE +import io.embrace.android.embracesdk.internal.logs.attachments.Attachment.Companion.ATTR_KEY_URL +import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.ATTACHMENT_TOO_LARGE +import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.OVER_MAX_ATTACHMENTS +import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.UNKNOWN +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import java.util.UUID + +internal class AttachmentTest { + + private companion object { + private const val URL = "https://example.com/my-attachment" + private const val SIZE = 2L + private const val LIMIT_MB = 1 * 1024 * 1024 + private val ID = UUID.randomUUID().toString() + private val BYTES = "{}".toByteArray() + } + + private val counter = AttachmentCounter(limit = Int.MAX_VALUE) + + @Test + fun `create embrace hosted attachment`() { + val attachment = Attachment.EmbraceHosted(BYTES, counter) + attachment.assertEmbraceHostedAttributesMatch() + } + + @Test + fun `embrace hosted attachment empty byte array`() { + val attachment = Attachment.EmbraceHosted(ByteArray(0), counter) + attachment.assertEmbraceHostedAttributesMatch(size = 0) + } + + @Test + fun `embrace hosted attachment at max size`() { + val attachment = Attachment.EmbraceHosted(ByteArray(LIMIT_MB), counter) + attachment.assertEmbraceHostedAttributesMatch(size = LIMIT_MB.toLong()) + } + + @Test + fun `embrace hosted attachment obeys max size constraints`() { + val size = LIMIT_MB + 1 + val attachment = Attachment.EmbraceHosted(ByteArray(size), counter) + attachment.assertEmbraceHostedAttributesMatch(size = size.toLong(), errorCode = ATTACHMENT_TOO_LARGE) + } + + @Test + fun `embrace hosted attachment exceeds session limit`() { + val smallCounter = AttachmentCounter(1) + val attachment = Attachment.EmbraceHosted(BYTES, smallCounter) + attachment.assertEmbraceHostedAttributesMatch() + + val size = LIMIT_MB + 1L + val bytes = ByteArray(size.toInt()) + val limitedAttachment = Attachment.EmbraceHosted(bytes, smallCounter) + limitedAttachment.assertEmbraceHostedAttributesMatch(size = size, errorCode = OVER_MAX_ATTACHMENTS) + } + + @Test + fun `create user hosted attachment`() { + val attachment = Attachment.UserHosted(SIZE, ID, URL, counter) + attachment.assertUserHostedAttributesMatch() + } + + @Test + fun `user hosted attachment empty size`() { + val size: Long = 0 + val attachment = Attachment.UserHosted(size, ID, URL, counter) + attachment.assertUserHostedAttributesMatch(size = size) + } + + @Test + fun `user hosted attachment invalid size`() { + val size: Long = -1 + val attachment = Attachment.UserHosted(size, ID, URL, counter) + attachment.assertUserHostedAttributesMatch(size = size, errorCode = UNKNOWN) + } + + @Test + fun `user hosted attachment invalid url`() { + val url = "" + val attachment = Attachment.UserHosted(SIZE, ID, url, counter) + attachment.assertUserHostedAttributesMatch(url = url, errorCode = UNKNOWN) + } + + @Test + fun `user hosted attachment invalid ID`() { + val id = "my-id" + val attachment = Attachment.UserHosted(SIZE, id, URL, counter) + attachment.assertUserHostedAttributesMatch(id = id, errorCode = UNKNOWN) + } + + @Test + fun `user hosted attachment has no max size constraints`() { + val size = 5000000L // 50MiB + val attachment = Attachment.UserHosted(size, ID, URL, counter) + attachment.assertUserHostedAttributesMatch(size = size) + } + + @Test + fun `user hosted attachment exceeds session limit`() { + val smallCounter = AttachmentCounter(1) + val attachment = Attachment.UserHosted(SIZE, ID, URL, smallCounter) + attachment.assertUserHostedAttributesMatch() + + val size = -1L + val limitedAttachment = Attachment.UserHosted(size, ID, URL, smallCounter) + limitedAttachment.assertUserHostedAttributesMatch(size = size, errorCode = OVER_MAX_ATTACHMENTS) + } + + private fun Attachment.assertEmbraceHostedAttributesMatch( + size: Long = SIZE, + errorCode: AttachmentErrorCode? = null, + ) { + val observedId = checkNotNull(attributes[ATTR_KEY_ID]) + assertNotNull(UUID.fromString(observedId)) + assertEquals(size, checkNotNull(attributes[ATTR_KEY_SIZE]).toLong()) + assertEquals(errorCode?.toString(), attributes[ATTR_KEY_ERR_CODE]) + } + + private fun Attachment.assertUserHostedAttributesMatch( + size: Long = SIZE, + url: String = URL, + id: String = ID, + errorCode: AttachmentErrorCode? = null, + ) { + assertEquals(size, checkNotNull(attributes[ATTR_KEY_SIZE]).toLong()) + assertEquals(id, checkNotNull(attributes[ATTR_KEY_ID])) + assertEquals(errorCode?.toString(), attributes[ATTR_KEY_ERR_CODE]) + assertEquals(url, checkNotNull(attributes[ATTR_KEY_URL])) + } +} diff --git a/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentCounterTest.kt b/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentCounterTest.kt new file mode 100644 index 0000000000..0704a974ab --- /dev/null +++ b/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentCounterTest.kt @@ -0,0 +1,24 @@ +package io.embrace.android.embracesdk.internal.logs.attachments + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class AttachmentCounterTest { + + @Test + fun `limit exceeded`() { + val counter = AttachmentCounter() + assertLimitRespected(counter) + + counter.cleanCollections() + assertLimitRespected(counter) + } + + private fun assertLimitRespected(counter: AttachmentCounter) { + repeat(5) { + assertTrue(counter.incrementAndCheckAttachmentLimit()) + } + assertFalse(counter.incrementAndCheckAttachmentLimit()) + } +}