Skip to content

Commit 14e55f1

Browse files
committed
[ECO-5482] Implemented createMap and createCounter methods for objects
1 parent 1355584 commit 14e55f1

10 files changed

Lines changed: 201 additions & 23 deletions

File tree

lib/src/main/java/io/ably/lib/objects/Adapter.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
public class Adapter implements LiveObjectsAdapter {
1616
private final AblyRealtime ably;
1717
private static final String TAG = LiveObjectsAdapter.class.getName();
18+
private volatile Long serverTimeOffset = null;
1819

1920
public Adapter(@NotNull AblyRealtime ably) {
2021
this.ably = ably;
@@ -77,4 +78,18 @@ public ChannelState getChannelState(@NotNull String channelName) {
7778
public @NotNull ConnectionManager getConnectionManager() {
7879
return ably.connection.connectionManager;
7980
}
81+
82+
@Override
83+
public long getServerTime() throws AblyException {
84+
if (serverTimeOffset == null) {
85+
synchronized (this) {
86+
if (serverTimeOffset == null) { // Double-checked locking to ensure thread safety
87+
long serverTime = ably.time();
88+
serverTimeOffset = serverTime - System.currentTimeMillis();
89+
return serverTime;
90+
}
91+
}
92+
}
93+
return System.currentTimeMillis() + serverTimeOffset;
94+
}
8095
}

lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import io.ably.lib.types.ChannelMode;
88
import io.ably.lib.types.ClientOptions;
99
import io.ably.lib.types.ProtocolMessage;
10+
import org.jetbrains.annotations.Blocking;
1011
import org.jetbrains.annotations.NotNull;
1112
import org.jetbrains.annotations.Nullable;
1213

@@ -73,5 +74,14 @@ public interface LiveObjectsAdapter {
7374
* @return the connection manager instance
7475
*/
7576
@NotNull ConnectionManager getConnectionManager();
77+
78+
/**
79+
* Retrieves the current time in milliseconds from the Ably server.
80+
* On first call, queries the server time and caches the offset from local time.
81+
* Subsequent calls return the local time adjusted by this offset.
82+
* Spec: RTO16
83+
*/
84+
@Blocking
85+
long getServerTime() throws AblyException;
7686
}
7787

live-objects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjects.kt

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package io.ably.lib.objects
22

3+
import io.ably.lib.objects.serialization.gson
34
import io.ably.lib.objects.state.ObjectsStateChange
45
import io.ably.lib.objects.state.ObjectsStateEvent
6+
import io.ably.lib.objects.type.ObjectType
57
import io.ably.lib.objects.type.counter.LiveCounter
8+
import io.ably.lib.objects.type.livecounter.DefaultLiveCounter
9+
import io.ably.lib.objects.type.livemap.DefaultLiveMap
610
import io.ably.lib.objects.type.map.LiveMap
711
import io.ably.lib.objects.type.map.LiveMapValue
812
import io.ably.lib.realtime.ChannelState
@@ -57,40 +61,28 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val
5761

5862
override fun getRoot(): LiveMap = runBlocking { getRootAsync() }
5963

60-
override fun createMap(): LiveMap {
61-
return createMap(mutableMapOf())
62-
}
64+
override fun createMap(): LiveMap = createMap(mutableMapOf())
6365

64-
override fun createMap(entries: MutableMap<String, LiveMapValue>): LiveMap {
65-
TODO("Not yet implemented")
66-
}
66+
override fun createMap(entries: MutableMap<String, LiveMapValue>): LiveMap = runBlocking { createMapAsync(entries) }
6767

68-
override fun createCounter(): LiveCounter {
69-
return createCounter(0)
70-
}
68+
override fun createCounter(): LiveCounter = createCounter(0)
7169

72-
override fun createCounter(initialValue: Number): LiveCounter {
73-
TODO("Not yet implemented")
74-
}
70+
override fun createCounter(initialValue: Number): LiveCounter = runBlocking { createCounterAsync(initialValue) }
7571

7672
override fun getRootAsync(callback: Callback<LiveMap>) {
7773
asyncScope.launchWithCallback(callback) { getRootAsync() }
7874
}
7975

80-
override fun createMapAsync(callback: Callback<LiveMap>) {
81-
TODO("Not yet implemented")
82-
}
76+
override fun createMapAsync(callback: Callback<LiveMap>) = createMapAsync(mutableMapOf(), callback)
8377

8478
override fun createMapAsync(entries: MutableMap<String, LiveMapValue>, callback: Callback<LiveMap>) {
85-
TODO("Not yet implemented")
79+
asyncScope.launchWithCallback(callback) { createMapAsync(entries) }
8680
}
8781

88-
override fun createCounterAsync(callback: Callback<LiveCounter>) {
89-
TODO("Not yet implemented")
90-
}
82+
override fun createCounterAsync(callback: Callback<LiveCounter>) = createCounterAsync(0, callback)
9183

9284
override fun createCounterAsync(initialValue: Number, callback: Callback<LiveCounter>) {
93-
TODO("Not yet implemented")
85+
asyncScope.launchWithCallback(callback) { createCounterAsync(initialValue) }
9486
}
9587

9688
override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription =
@@ -106,6 +98,81 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val
10698
objectsPool.get(ROOT_OBJECT_ID) as LiveMap
10799
}
108100

101+
private suspend fun createMapAsync(entries: MutableMap<String, LiveMapValue>): LiveMap {
102+
adapter.throwIfInvalidWriteApiConfiguration(channelName)
103+
104+
// Create initial value operation
105+
val initialMapValue = DefaultLiveMap.createInitialValue(entries)
106+
107+
// Create initial value JSON string
108+
val initialValueJSONString = gson.toJson(initialMapValue)
109+
110+
// Create object ID from initial value
111+
val (objectId, nonce) = fromInitialValue(ObjectType.Map, initialValueJSONString)
112+
113+
// Create ObjectMessage with the operation
114+
val msg = ObjectMessage(
115+
operation = ObjectOperation(
116+
action = ObjectOperationAction.MapCreate,
117+
objectId = objectId,
118+
map = initialMapValue.map,
119+
nonce = nonce,
120+
initialValue = initialValueJSONString,
121+
)
122+
)
123+
124+
// Publish the message
125+
publish(arrayOf(msg))
126+
127+
// Check if object already exists in pool (from echoed message)
128+
return objectsPool.get(objectId) as? LiveMap ?: withContext(sequentialScope.coroutineContext) {
129+
objectsPool.createZeroValueObjectIfNotExists(objectId) as LiveMap
130+
}
131+
}
132+
133+
private suspend fun createCounterAsync(initialValue: Number): LiveCounter {
134+
adapter.throwIfInvalidWriteApiConfiguration(channelName)
135+
136+
// Validate input parameter
137+
if (initialValue.toDouble().isNaN() || initialValue.toDouble().isInfinite()) {
138+
throw objectError("Counter value should be a valid number")
139+
}
140+
141+
val initialCounterValue = DefaultLiveCounter.createInitialValue(initialValue)
142+
// Create initial value operation
143+
val initialValueJSONString = gson.toJson(initialCounterValue)
144+
145+
// Create object ID from initial value
146+
val (objectId, nonce) = fromInitialValue(ObjectType.Counter, initialValueJSONString)
147+
148+
// Create ObjectMessage with the operation
149+
val msg = ObjectMessage(
150+
operation = ObjectOperation(
151+
action = ObjectOperationAction.CounterCreate,
152+
objectId = objectId,
153+
counter = initialCounterValue.counter,
154+
nonce = nonce,
155+
initialValue = initialValueJSONString
156+
)
157+
)
158+
159+
// Publish the message
160+
publish(arrayOf(msg))
161+
162+
// Check if object already exists in pool (from echoed message)
163+
return objectsPool.get(objectId) as? LiveCounter ?: withContext(sequentialScope.coroutineContext) {
164+
objectsPool.createZeroValueObjectIfNotExists(objectId) as LiveCounter
165+
}
166+
}
167+
168+
private suspend fun fromInitialValue(objectType: ObjectType, initialValue: String): Pair<String, String> {
169+
val nonce = generateNonce()
170+
val msTimestamp = withContext(Dispatchers.IO) {
171+
adapter.getServerTime()
172+
}
173+
return Pair(ObjectId.fromInitialValue(objectType, initialValue, nonce, msTimestamp).toString(), nonce)
174+
}
175+
109176
/**
110177
* Spec: RTO15
111178
*/

live-objects/src/main/kotlin/io/ably/lib/objects/Helpers.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.ably.lib.objects
22

3+
import io.ably.lib.objects.type.BaseLiveObject
4+
import io.ably.lib.objects.type.map.LiveMapValue
35
import io.ably.lib.realtime.ChannelState
46
import io.ably.lib.realtime.CompletionListener
57
import io.ably.lib.types.ChannelMode
@@ -103,3 +105,18 @@ internal class Binary(val data: ByteArray) {
103105
internal fun Binary.size(): Int {
104106
return data.size
105107
}
108+
109+
internal data class CounterCreatePayload(
110+
val counter: ObjectCounter
111+
)
112+
113+
internal data class MapCreatePayload(
114+
val map: ObjectMap
115+
)
116+
117+
internal fun fromLiveMapValue(value: LiveMapValue) : ObjectData {
118+
return when {
119+
value.isLiveMap || value.isLiveCounter -> ObjectData(objectId = (value.value as BaseLiveObject).objectId)
120+
else -> ObjectData(value = ObjectValue(value.value))
121+
}
122+
}

live-objects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package io.ably.lib.objects
22

33
import io.ably.lib.objects.type.ObjectType
4+
import java.nio.charset.StandardCharsets
5+
import java.security.MessageDigest
6+
import java.util.Base64
47

58
internal class ObjectId private constructor(
69
internal val type: ObjectType,
@@ -15,10 +18,19 @@ internal class ObjectId private constructor(
1518
}
1619

1720
companion object {
21+
22+
internal fun fromInitialValue(objectType: ObjectType, initialValue: String, nonce: String, msTimeStamp: Long): ObjectId {
23+
val valueForHash = "$initialValue:$nonce".toByteArray(StandardCharsets.UTF_8)
24+
val hashBytes = MessageDigest.getInstance("SHA-256").digest(valueForHash)
25+
val urlSafeHash = Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes)
26+
27+
return ObjectId(objectType, urlSafeHash, msTimeStamp)
28+
}
29+
1830
/**
1931
* Creates ObjectId instance from hashed object id string.
2032
*/
21-
fun fromString(objectId: String): ObjectId {
33+
internal fun fromString(objectId: String): ObjectId {
2234
if (objectId.isEmpty()) {
2335
throw objectError("Invalid object id: $objectId")
2436
}

live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import io.ably.lib.types.Callback
55
import io.ably.lib.types.ErrorInfo
66
import io.ably.lib.util.Log
77
import kotlinx.coroutines.*
8+
import java.nio.charset.StandardCharsets
89
import java.util.concurrent.CancellationException
910

1011
internal fun ablyException(
@@ -47,7 +48,7 @@ internal fun objectError(errorMessage: String, cause: Throwable? = null): AblyEx
4748
* e.g. "Hello" has a byte size of 5, while "你" has a byte size of 3 and "😊" has a byte size of 4.
4849
*/
4950
internal val String.byteSize: Int
50-
get() = this.toByteArray(Charsets.UTF_8).size
51+
get() = this.toByteArray(StandardCharsets.UTF_8).size
5152

5253
/**
5354
* A channel-specific coroutine scope for executing callbacks asynchronously in the LiveObjects system.
@@ -78,3 +79,11 @@ internal class ObjectsAsyncScope(channelName: String) {
7879
scope.coroutineContext.cancelChildren(cause)
7980
}
8081
}
82+
83+
/**
84+
* Generates a random nonce string for object creation.
85+
*/
86+
internal fun generateNonce(): String {
87+
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
88+
return (1..16).map { chars.random() }.joinToString("")
89+
}

live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,5 +105,14 @@ internal class DefaultLiveCounter private constructor(
105105
internal fun zeroValue(objectId: String, liveObjects: DefaultLiveObjects): DefaultLiveCounter {
106106
return DefaultLiveCounter(objectId, liveObjects)
107107
}
108+
109+
/**
110+
* Creates initial value operation for counter creation.
111+
*/
112+
internal fun createInitialValue(count: Number): CounterCreatePayload {
113+
return CounterCreatePayload(
114+
counter = ObjectCounter(count = count.toDouble())
115+
)
116+
}
108117
}
109118
}

live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,5 +153,24 @@ internal class DefaultLiveMap private constructor(
153153
internal fun zeroValue(objectId: String, objects: DefaultLiveObjects): DefaultLiveMap {
154154
return DefaultLiveMap(objectId, objects)
155155
}
156+
157+
/**
158+
* Creates an ObjectMap from map entries.
159+
*/
160+
internal fun createInitialValue(entries: MutableMap<String, LiveMapValue>): MapCreatePayload {
161+
val mapEntries = entries.mapValues { (_, value) ->
162+
ObjectMapEntry(
163+
tombstone = false,
164+
data = fromLiveMapValue(value)
165+
)
166+
}
167+
168+
return MapCreatePayload(
169+
map = ObjectMap(
170+
semantics = MapSemantics.LWW,
171+
entries = mapEntries
172+
)
173+
)
174+
}
156175
}
157176
}

live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,24 @@ class ObjectIdTest {
5252
assertEquals(92_000, exception.errorInfo?.code)
5353
assertEquals(500, exception.errorInfo?.statusCode)
5454
}
55+
56+
@Test
57+
fun testFromInitialValue() {
58+
val objectType = ObjectType.Map
59+
val initialValue = "test-value"
60+
val nonce = "test-nonce"
61+
val msTimestamp = 1640995200000L
62+
63+
val objectId = ObjectId.fromInitialValue(objectType, initialValue, nonce, msTimestamp)
64+
// Verify the string format follows the expected pattern: type:hash@timestamp
65+
val objectIdString = objectId.toString()
66+
assertTrue(objectIdString.startsWith("map:"))
67+
assertTrue(objectIdString.contains("@"))
68+
assertTrue(objectIdString.endsWith(msTimestamp.toString()))
69+
70+
val expectedHash = "GSjv-adTaJPL8-382qF3JuIyE4TCc6QKIIqb577pz00"
71+
// Verify the hash value matches expected
72+
val hashPart = objectIdString.substring(4, objectIdString.indexOf("@"))
73+
assertEquals(expectedHash, hashPart)
74+
}
5575
}

live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ class ObjectMessageSizeTest {
168168
}
169169
// Assert on error code and message
170170
assertEquals(40009, exception.errorInfo.code)
171-
val expectedMessage = "ObjectMessage size 66560 exceeds maximum allowed size of 65536 bytes"
171+
val expectedMessage = "ObjectMessages size 66560 exceeds maximum allowed size of 65536 bytes"
172172
assertEquals(expectedMessage, exception.errorInfo.message)
173173
}
174174
}

0 commit comments

Comments
 (0)