diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index b62430929ac803a47eaa221b53765c2b9c520802..52521d349425f41e6aa69d1c96efb217fd0fabdc 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 8978d23db569daa721cb26dde7923f4c673d1fc9..773fe0fbde80a5dbea80f02ad9c211a31f18a53f 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 fa3d03619acf20ca9d452e0f50f3142d25741f0b..9f2c1a765b34fbc9132bbecb668e7f8ec22787b1 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 58c96ea782ce84f869385634ccc3c96026234809..90aec854906336e4679467d336184a312f634e0e 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 1d6fb69c0cfa77f2b8d701cfbc23a9ceb9aea016..f235a5895d7c98680777854ee9a247d58c7dcdb5 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 129f1d3f2c841f8793fa9476cc3df468cbc3e85a..68d02c27f5f8572a95c052fcfb25035723ed4ff7 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 0000000000000000000000000000000000000000..6fcdc7c8c4b1d43e01874604a4f2faecc5ac9b28 --- /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 b858ad6ddce5326cb80e3c01df585be4f4a1d866..214825d7e127b86c4ac1e268f9d927048292c500 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 Binary files a/app/src/main/res/drawable/avatar.jpg and /dev/null differ 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