From a398f3b12c5d739cbff6acd4731c87e39268001d Mon Sep 17 00:00:00 2001 From: Koen <koen@pop-os.localdomain> Date: Thu, 3 Aug 2023 17:13:25 +0200 Subject: [PATCH] Implemented Polycentric ImageBundle. --- .idea/androidTestResultsUserPreferences.xml | 4 + .idea/misc.xml | 2 +- .../polycentric/core/ProcessHandleTests.kt | 85 ++++++++++++++++-- .../java/com/futo/polycentric/core/Models.kt | 15 +++- .../futo/polycentric/core/ProcessHandle.kt | 81 +++++++++-------- .../com/futo/polycentric/core/SystemState.kt | 7 +- .../core/serializers/ImageBundleSerializer.kt | 23 +++++ .../futo/polycentric/protos/protocol.proto | 16 +++- app/src/main/res/drawable/avatar.jpg | Bin 8021 -> 0 bytes .../res/drawable/{banner.jpg => image.jpg} | Bin 10 files changed, 182 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/futo/polycentric/core/serializers/ImageBundleSerializer.kt delete mode 100644 app/src/main/res/drawable/avatar.jpg rename app/src/main/res/drawable/{banner.jpg => image.jpg} (100%) diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index b624309..52521d3 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -122,8 +122,11 @@ <AndroidTestResultsTableState> <option name="preferredColumnWidths"> <map> + <entry key="29211JEGR13699" value="120" /> <entry key="Duration" value="90" /> + <entry key="Google Pixel 6a" value="120" /> <entry key="Pixel_3a_API_33_x86_64" value="120" /> + <entry key="Pixel_C_API_33" value="120" /> <entry key="Tests" value="360" /> </map> </option> @@ -195,6 +198,7 @@ <entry key="Duration" value="90" /> <entry key="Google Pixel 6a" value="120" /> <entry key="Pixel_3a_API_33_x86_64" value="120" /> + <entry key="Pixel_C_API_33" value="120" /> <entry key="R5CNC07TZCY" value="120" /> <entry key="Tests" value="360" /> <entry key="samsung SM-G998B" value="120" /> diff --git a/.idea/misc.xml b/.idea/misc.xml index 8978d23..773fe0f 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,6 @@ <project version="4"> <component name="ExternalStorageConfigurationManager" enabled="true" /> - <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK"> + <component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="jbr-17" project-jdk-type="JavaSDK"> <output url="file://$PROJECT_DIR$/build/classes" /> </component> <component name="ProjectType"> diff --git a/app/src/androidTest/java/com/futo/polycentric/core/ProcessHandleTests.kt b/app/src/androidTest/java/com/futo/polycentric/core/ProcessHandleTests.kt index fa3d036..9f2c1a7 100644 --- a/app/src/androidTest/java/com/futo/polycentric/core/ProcessHandleTests.kt +++ b/app/src/androidTest/java/com/futo/polycentric/core/ProcessHandleTests.kt @@ -2,16 +2,25 @@ package com.futo.polycentric.core import android.content.Context import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.util.Log import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.google.protobuf.ByteString +import com.goterl.lazysodium.interfaces.Sign import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith import userpackage.Protocol import userpackage.Protocol.QueryReferencesRequestCountReferences import java.io.ByteArrayOutputStream +import java.io.DataInputStream + + + @RunWith(AndroidJUnit4::class) class ProcessHandleTests { @@ -86,16 +95,78 @@ class ProcessHandleTests { Store.initializeMemoryStore() val processHandle = ProcessHandle.create() + processHandle.addServer(TestConstants.SERVER) - val fakeImage = byteArrayOf(1, 2, 3, 4) - val imagePointer = processHandle.publishBlob("image/png", fakeImage) - processHandle.setAvatar(imagePointer) + //Load image + val imageBytes: ByteArray + run { + val context = getInstrumentation().targetContext + val imageId = context.resources.getIdentifier("image", "drawable", context.packageName) + val bitmap = BitmapFactory.decodeResource(context.resources, imageId) + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream) + imageBytes = stream.toByteArray() + stream.close() + } - val serverState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(processHandle.system)) - val loadedAvatar = processHandle.loadBlob(serverState.avatar!!)!! + //Set avatar + val eventLogicalClock: Long + run { + eventLogicalClock = processHandle.publishAvatar(listOf( + ImageData("image/jpeg", 2560, 424, imageBytes) + )).logicalClock + } - assertEquals("image/png", loadedAvatar.mime) - assertArrayEquals(fakeImage, loadedAvatar.content) + processHandle.fullyBackfillServers() + + //Read avatar and compare + run { + val avatarProcessRangesToGet = Protocol.RangesForProcess.newBuilder() + .addRanges(Protocol.Range.newBuilder().setLow(eventLogicalClock).setHigh(eventLogicalClock).build()) + .setProcess(processHandle.processSecret.process.toProto()) + .build() + val avatarEvents = ApiMethods.getEvents(TestConstants.SERVER, processHandle.system.toProto(), Protocol.RangesForSystem.newBuilder() + .addRangesForProcesses(avatarProcessRangesToGet).build()) + + val avatarSignedEvent = avatarEvents.eventsList.first() + val avatarEvent = SignedEvent.fromProto(avatarSignedEvent).event + assertEquals(ContentType.AVATAR.value, avatarEvent.contentType) + assertNotNull(avatarEvent.lwwElement) + + val imageBundle = Protocol.ImageBundle.parseFrom(avatarEvent.lwwElement!!.value) + assertEquals(1, imageBundle.imageManifestsList.size) + + val imageManifest = imageBundle.imageManifestsList.first() + assertEquals("image/jpeg", imageManifest.mime) + assertEquals(2560, imageManifest.width) + assertEquals(424, imageManifest.height) + assertEquals(imageBytes.size.toLong(), imageManifest.byteCount) + assertEquals(processHandle.processSecret.process.toProto(), imageManifest.process) + + val blobProcessRangesToGet = Protocol.RangesForProcess.newBuilder() + .addAllRanges(imageManifest.sectionsList) + .setProcess(processHandle.processSecret.process.toProto()) + .build() + val blobEvents = ApiMethods.getEvents(TestConstants.SERVER, processHandle.system.toProto(), Protocol.RangesForSystem.newBuilder() + .addRangesForProcesses(blobProcessRangesToGet).build()).eventsList.map { SignedEvent.fromProto(it) } + + val totalArray = ByteArray(imageManifest.byteCount.toInt()) + var offset = 0 + + for (section in imageManifest.sectionsList) { + val sectionEvents = blobEvents.filter { it.event.logicalClock >= section.low && it.event.logicalClock <= section.high }.sortedBy { it.event.logicalClock } + sectionEvents.forEach { + assertEquals(ContentType.BLOB_SECTION.value, it.event.contentType) + val blobSection = Protocol.BlobSection.parseFrom(it.event.content) + val size = blobSection.content.size() + blobSection.content.copyTo(totalArray, offset) + offset += size + } + } + + assertEquals(imageManifest.byteCount.toInt(), offset) + assertArrayEquals(imageBytes, totalArray) + } } @Test diff --git a/app/src/main/java/com/futo/polycentric/core/Models.kt b/app/src/main/java/com/futo/polycentric/core/Models.kt index 58c96ea..90aec85 100644 --- a/app/src/main/java/com/futo/polycentric/core/Models.kt +++ b/app/src/main/java/com/futo/polycentric/core/Models.kt @@ -14,7 +14,6 @@ enum class ContentType(val value: Long) { FOLLOW(4), USERNAME(5), DESCRIPTION(6), - BLOB_META(7), BLOB_SECTION(8), AVATAR(9), SERVER(10), @@ -162,6 +161,20 @@ data class Pointer( } } +data class ImageData(val mimeType: String, val width: Int, val height: Int, val data: ByteArray) { + override fun equals(other: Any?): Boolean { + if (other is ImageData) { + return mimeType == other.mimeType && width == other.width && height == other.height && data.contentEquals(other.data) + } + + return false + } + + override fun hashCode(): Int { + return combineHashCodes(listOf(width, height, mimeType.hashCode(), data.contentHashCode())) + } +} + data class Opinion(val data: ByteArray) { companion object { fun makeOpinion(x: Int): Opinion { diff --git a/app/src/main/java/com/futo/polycentric/core/ProcessHandle.kt b/app/src/main/java/com/futo/polycentric/core/ProcessHandle.kt index 1d6fb69..f235a58 100644 --- a/app/src/main/java/com/futo/polycentric/core/ProcessHandle.kt +++ b/app/src/main/java/com/futo/polycentric/core/ProcessHandle.kt @@ -1,7 +1,10 @@ package com.futo.polycentric.core +import android.graphics.Bitmap +import android.util.Log import com.google.protobuf.ByteString import userpackage.Protocol +import java.lang.Integer.min import java.util.* class ProcessHandle constructor( @@ -81,10 +84,10 @@ class ProcessHandle constructor( ) } - fun setAvatar(avatar: Pointer): Pointer { + fun setAvatar(avatar: Protocol.ImageBundle): Pointer { return setCRDTItem( ContentType.AVATAR.value, - avatar.toProto().toByteArray(), + avatar.toByteArray(), ) } @@ -158,49 +161,53 @@ class ProcessHandle constructor( ) } - fun publishBlob(mime: String, content: ByteArray): Pointer { - val meta = publish( - ContentType.BLOB_META.value, - Protocol.BlobMeta.newBuilder() - .setSectionCount(1L) - .setMime(mime) - .build().toByteArray(), - null, - null, - mutableListOf() - ) + fun publishBlob(content: ByteArray): List<Range> { + val maxBytes = 1024 * 512 + var i = 0 + val ranges = mutableListOf<Range>() - publish( - ContentType.BLOB_SECTION.value, - Protocol.BlobSection.newBuilder() - .setMetaPointer(meta.logicalClock) - .setContent(ByteString.copyFrom(content)) - .build().toByteArray(), - null, - null, - mutableListOf() - ) + while (true) { + if (i >= content.size - 1) { + break; + } - return meta - } + val end = min(i + maxBytes, content.size - 1) + val bytesToUpload = content.sliceArray(IntRange(i, end)) + val pointer = publish( + ContentType.BLOB_SECTION.value, + Protocol.BlobSection.newBuilder() + .setContent(ByteString.copyFrom(bytesToUpload)) + .build().toByteArray(), + null, + null, + mutableListOf() + ) - fun loadBlob(pointer: Pointer): Blob? { - val signedEvent: SignedEvent = Store.instance.getSignedEvent(pointer) ?: return null - val event = signedEvent.event - if (event.contentType != ContentType.BLOB_META.value) { - return null + Ranges.insert(ranges, pointer.logicalClock) + i = end + 1 } - val meta = Protocol.BlobMeta.parseFrom(event.content) + return ranges + } - val nextSignedEvent: SignedEvent = Store.instance.getSignedEvent(pointer.system, pointer.process, pointer.logicalClock + 1L) ?: return null - val nextEvent = nextSignedEvent.event - if (nextEvent.contentType != ContentType.BLOB_SECTION.value) { - return null + fun publishAvatar(images: List<ImageData>): Pointer { + val imageBundleBuilder = Protocol.ImageBundle.newBuilder() + + for (image in images) { + val imageRanges = publishBlob(image.data) + val imageManifest = Protocol.ImageManifest.newBuilder() + .setMime(image.mimeType) + .setWidth(image.width.toLong()) + .setHeight(image.height.toLong()) + .setByteCount(image.data.size.toLong()) + .setProcess(processSecret.process.toProto()) + .addAllSections(imageRanges.map { it.toProto() }) + .build() + + imageBundleBuilder.addImageManifests(imageManifest) } - val section = Protocol.BlobSection.parseFrom(nextEvent.content) - return Blob(meta.mime, section.content.toByteArray()) + return setAvatar(imageBundleBuilder.build()) } private fun publish(contentType: Long, content: ByteArray, lwwElementSet: LWWElementSet?, lwwElement: LWWElement?, references: MutableList<Protocol.Reference>): Pointer { diff --git a/app/src/main/java/com/futo/polycentric/core/SystemState.kt b/app/src/main/java/com/futo/polycentric/core/SystemState.kt index 129f1d3..68d02c2 100644 --- a/app/src/main/java/com/futo/polycentric/core/SystemState.kt +++ b/app/src/main/java/com/futo/polycentric/core/SystemState.kt @@ -1,5 +1,6 @@ package com.futo.polycentric.core +import com.futo.polycentric.core.serializers.ImageBundleSerializer import kotlinx.serialization.Serializable import userpackage.Protocol @@ -10,7 +11,7 @@ class SystemState( val username: String, val description: String, val store: String, - val avatar: Pointer?, + @Serializable(with = ImageBundleSerializer::class) val avatar: Protocol.ImageBundle?, val banner: Pointer? ) { override fun toString(): String { @@ -29,7 +30,7 @@ class SystemState( var username = "" var description = "" var store = "" - var avatar: Pointer? = null + var avatar: Protocol.ImageBundle? = null var banner: Pointer? = null proto.crdtItems.forEach { item -> when (item.contentType) { @@ -43,7 +44,7 @@ class SystemState( store = item.value.decodeToString() } ContentType.AVATAR.value -> { - avatar = Pointer.fromProto(Protocol.Pointer.parseFrom(item.value)) + avatar = Protocol.ImageBundle.parseFrom(item.value) } ContentType.BANNER.value -> { banner = Pointer.fromProto(Protocol.Pointer.parseFrom(item.value)) diff --git a/app/src/main/java/com/futo/polycentric/core/serializers/ImageBundleSerializer.kt b/app/src/main/java/com/futo/polycentric/core/serializers/ImageBundleSerializer.kt new file mode 100644 index 0000000..6fcdc7c --- /dev/null +++ b/app/src/main/java/com/futo/polycentric/core/serializers/ImageBundleSerializer.kt @@ -0,0 +1,23 @@ +package com.futo.polycentric.core.serializers + +import com.futo.polycentric.core.base64UrlToByteArray +import com.futo.polycentric.core.toBase64Url +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import userpackage.Protocol + +class ImageBundleSerializer : KSerializer<Protocol.ImageBundle> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ImageBundle", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Protocol.ImageBundle) { + encoder.encodeString(value.toByteArray().toBase64Url()) + } + override fun deserialize(decoder: Decoder): Protocol.ImageBundle { + val value = decoder.decodeString(); + return Protocol.ImageBundle.parseFrom(value.base64UrlToByteArray()); + } +} \ No newline at end of file diff --git a/app/src/main/proto/com/futo/polycentric/protos/protocol.proto b/app/src/main/proto/com/futo/polycentric/protos/protocol.proto index b858ad6..214825d 100644 --- a/app/src/main/proto/com/futo/polycentric/protos/protocol.proto +++ b/app/src/main/proto/com/futo/polycentric/protos/protocol.proto @@ -54,8 +54,20 @@ message BlobMeta { } message BlobSection { - uint64 meta_pointer = 1; - bytes content = 2; + bytes content = 1; +} + +message ImageManifest { + string mime = 1; + uint64 width = 2; + uint64 height = 3; + uint64 byte_count = 4; + Process process = 5; + repeated Range sections = 6; +} + +message ImageBundle { + repeated ImageManifest image_manifests = 1; } message Event { diff --git a/app/src/main/res/drawable/avatar.jpg b/app/src/main/res/drawable/avatar.jpg deleted file mode 100644 index 6586937a09d2369a7b12676c118e232cfcfc8e4c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8021 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 TgvPmLR$FzMB;8@=e&zoGr)YyC diff --git a/app/src/main/res/drawable/banner.jpg b/app/src/main/res/drawable/image.jpg similarity index 100% rename from app/src/main/res/drawable/banner.jpg rename to app/src/main/res/drawable/image.jpg -- GitLab