Skip to content

Commit 7aab3fb

Browse files
committedOct 14, 2024
[ECO-4996] chore: added spec adjustments
Reviewed sending and receiving message implementation and make sure all spec points are covered
1 parent b0943ab commit 7aab3fb

File tree

13 files changed

+269
-16
lines changed

13 files changed

+269
-16
lines changed
 

‎chat-android/src/main/java/com/ably/chat/ChatApi.kt

+31
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import kotlin.coroutines.suspendCoroutine
1212

1313
private const val API_PROTOCOL_VERSION = 3
1414
private const val PROTOCOL_VERSION_PARAM_NAME = "v"
15+
private const val RESERVED_ABLY_CHAT_KEY = "ably-chat"
1516
private val apiProtocolParam = Param(PROTOCOL_VERSION_PARAM_NAME, API_PROTOCOL_VERSION.toString())
1617

1718
internal class ChatApi(private val realtimeClient: RealtimeClient, private val clientId: String) {
@@ -47,11 +48,15 @@ internal class ChatApi(private val realtimeClient: RealtimeClient, private val c
4748
* @return sent message instance
4849
*/
4950
suspend fun sendMessage(roomId: String, params: SendMessageParams): Message {
51+
validateSendMessageParams(params)
52+
5053
val body = JsonObject().apply {
5154
addProperty("text", params.text)
55+
// (CHA-M3b)
5256
params.headers?.let {
5357
add("headers", it.toJson())
5458
}
59+
// (CHA-M3b)
5560
params.metadata?.let {
5661
add("metadata", it.toJson())
5762
}
@@ -62,6 +67,7 @@ internal class ChatApi(private val realtimeClient: RealtimeClient, private val c
6267
"POST",
6368
body,
6469
)?.let {
70+
// (CHA-M3a)
6571
Message(
6672
timeserial = it.requireString("timeserial"),
6773
clientId = clientId,
@@ -74,6 +80,30 @@ internal class ChatApi(private val realtimeClient: RealtimeClient, private val c
7480
} ?: throw AblyException.fromErrorInfo(ErrorInfo("Send message endpoint returned empty value", HttpStatusCodes.InternalServerError))
7581
}
7682

83+
private fun validateSendMessageParams(params: SendMessageParams) {
84+
// (CHA-M3c)
85+
if (params.metadata?.containsKey(RESERVED_ABLY_CHAT_KEY) == true) {
86+
throw AblyException.fromErrorInfo(
87+
ErrorInfo(
88+
"Metadata contains reserved 'ably-chat' key",
89+
HttpStatusCodes.BadRequest,
90+
ErrorCodes.InvalidRequestBody,
91+
),
92+
)
93+
}
94+
95+
// (CHA-M3d)
96+
if (params.headers?.keys?.any { it.startsWith(RESERVED_ABLY_CHAT_KEY) } == true) {
97+
throw AblyException.fromErrorInfo(
98+
ErrorInfo(
99+
"Headers contains reserved key with reserved 'ably-chat' prefix",
100+
HttpStatusCodes.BadRequest,
101+
ErrorCodes.InvalidRequestBody,
102+
),
103+
)
104+
}
105+
}
106+
77107
/**
78108
* return occupancy for specified room
79109
*/
@@ -104,6 +134,7 @@ internal class ChatApi(private val realtimeClient: RealtimeClient, private val c
104134
}
105135

106136
override fun onError(reason: ErrorInfo?) {
137+
// (CHA-M3e)
107138
continuation.resumeWithException(AblyException.fromErrorInfo(reason))
108139
}
109140
},

‎chat-android/src/main/java/com/ably/chat/ErrorCodes.kt

+10
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ object ErrorCodes {
9292
* The request cannot be understood
9393
*/
9494
const val BadRequest = 40_000
95+
96+
/**
97+
* Invalid request body
98+
*/
99+
const val InvalidRequestBody = 40_001
100+
101+
/**
102+
* Internal error
103+
*/
104+
const val InternalError = 50_000
95105
}
96106

97107
/**

‎chat-android/src/main/java/com/ably/chat/Message.kt

+18
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,21 @@ data class Message(
6868
*/
6969
val headers: MessageHeaders,
7070
)
71+
72+
/**
73+
* (CHA-M2a)
74+
* @return true if the timeserial of the corresponding realtime channel message comes first.
75+
*/
76+
fun Message.isBefore(other: Message): Boolean = Timeserial.parse(timeserial) < Timeserial.parse(other.timeserial)
77+
78+
/**
79+
* (CHA-M2b)
80+
* @return true if the timeserial of the corresponding realtime channel message comes second.
81+
*/
82+
fun Message.isAfter(other: Message): Boolean = Timeserial.parse(timeserial) > Timeserial.parse(other.timeserial)
83+
84+
/**
85+
* (CHA-M2c)
86+
* @return true if they have the same timeserial.
87+
*/
88+
fun Message.isAtTheSameTime(other: Message): Boolean = Timeserial.parse(timeserial) == Timeserial.parse(other.timeserial)

‎chat-android/src/main/java/com/ably/chat/Messages.kt

+33-3
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,11 @@ data class SendMessageParams(
180180
)
181181

182182
interface MessagesSubscription : Subscription {
183+
/**
184+
* (CHA-M5j)
185+
* Get the previous messages that were sent to the room before the listener was subscribed.
186+
* @return paginated result of messages, in newest-to-oldest order.
187+
*/
183188
suspend fun getPreviousMessages(start: Long? = null, end: Long? = null, limit: Int = 100): PaginatedResult<Message>
184189
}
185190

@@ -195,6 +200,18 @@ internal class DefaultMessagesSubscription(
195200

196201
override suspend fun getPreviousMessages(start: Long?, end: Long?, limit: Int): PaginatedResult<Message> {
197202
val fromSerial = fromSerialProvider().await()
203+
204+
// (CHA-M5j)
205+
if (end != null && end > Timeserial.parse(fromSerial).timestamp) {
206+
throw AblyException.fromErrorInfo(
207+
ErrorInfo(
208+
"The `end` parameter is specified and is more recent than the subscription point timeserial",
209+
HttpStatusCodes.BadRequest,
210+
ErrorCodes.BadRequest,
211+
),
212+
)
213+
}
214+
198215
val queryOptions = QueryOptions(start = start, end = end, limit = limit, orderBy = NewestFirst)
199216
return chatApi.getMessages(
200217
roomId = roomId,
@@ -217,6 +234,7 @@ internal class DefaultMessages(
217234
private var lock = Any()
218235

219236
/**
237+
* (CHA-M1)
220238
* the channel name for the chat messages channel.
221239
*/
222240
private val messagesChannelName = "$roomId::\$chat::\$chatMessages"
@@ -249,8 +267,9 @@ internal class DefaultMessages(
249267
)
250268
listener.onEvent(MessageEvent(type = MessageEventType.Created, message = chatMessage))
251269
}
252-
270+
// (CHA-M4d)
253271
channel.subscribe(MessageEventType.Created.eventName, messageListener)
272+
// (CHA-M5) setting subscription point
254273
associateWithCurrentChannelSerial(deferredChannelSerial)
255274

256275
return DefaultMessagesSubscription(
@@ -293,10 +312,11 @@ internal class DefaultMessages(
293312
private fun associateWithCurrentChannelSerial(channelSerialProvider: DeferredValue<String>) {
294313
if (channel.state === ChannelState.attached) {
295314
channelSerialProvider.completeWith(requireChannelSerial())
315+
return
296316
}
297317

298318
channel.once(ChannelState.attached) {
299-
channelSerialProvider.completeWith(requireChannelSerial())
319+
channelSerialProvider.completeWith(requireAttachSerial())
300320
}
301321
}
302322

@@ -307,6 +327,13 @@ internal class DefaultMessages(
307327
)
308328
}
309329

330+
private fun requireAttachSerial(): String {
331+
return channel.properties.attachSerial
332+
?: throw AblyException.fromErrorInfo(
333+
ErrorInfo("Channel has been attached, but attachSerial is not defined", HttpStatusCodes.BadRequest, ErrorCodes.BadRequest),
334+
)
335+
}
336+
310337
private fun addListener(listener: Messages.Listener, deferredChannelSerial: DeferredValue<String>) {
311338
synchronized(lock) {
312339
listeners += listener to deferredChannelSerial
@@ -319,9 +346,12 @@ internal class DefaultMessages(
319346
}
320347
}
321348

349+
/**
350+
* (CHA-M5c), (CHA-M5d)
351+
*/
322352
private fun updateChannelSerialsAfterDiscontinuity() {
323353
val deferredChannelSerial = DeferredValue<String>()
324-
associateWithCurrentChannelSerial(deferredChannelSerial)
354+
deferredChannelSerial.completeWith(requireAttachSerial())
325355

326356
synchronized(lock) {
327357
listeners = listeners.mapValues {

‎chat-android/src/main/java/com/ably/chat/Room.kt

+1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ interface Room {
5252
val occupancy: Occupancy
5353

5454
/**
55+
* (CHA-RS2)
5556
* Returns an object that can be used to observe the status of the room.
5657
*
5758
* @returns The status observable.

‎chat-android/src/main/java/com/ably/chat/RoomOptions.kt

+4-4
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,25 @@ data class RoomOptions(
99
* use {@link RoomOptionsDefaults.presence} to enable presence with default options.
1010
* @defaultValue undefined
1111
*/
12-
val presence: PresenceOptions = PresenceOptions(),
12+
val presence: PresenceOptions? = null,
1313

1414
/**
1515
* The typing options for the room. To enable typing in the room, set this property. You may use
1616
* {@link RoomOptionsDefaults.typing} to enable typing with default options.
1717
*/
18-
val typing: TypingOptions = TypingOptions(),
18+
val typing: TypingOptions? = null,
1919

2020
/**
2121
* The reactions options for the room. To enable reactions in the room, set this property. You may use
2222
* {@link RoomOptionsDefaults.reactions} to enable reactions with default options.
2323
*/
24-
val reactions: RoomReactionsOptions = RoomReactionsOptions,
24+
val reactions: RoomReactionsOptions? = null,
2525

2626
/**
2727
* The occupancy options for the room. To enable occupancy in the room, set this property. You may use
2828
* {@link RoomOptionsDefaults.occupancy} to enable occupancy with default options.
2929
*/
30-
val occupancy: OccupancyOptions = OccupancyOptions,
30+
val occupancy: OccupancyOptions? = null,
3131
)
3232

3333
/**

‎chat-android/src/main/java/com/ably/chat/RoomStatus.kt

+13
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import io.ably.lib.types.ErrorInfo
77
*/
88
interface RoomStatus {
99
/**
10+
* (CHA-RS2a)
1011
* The current status of the room.
1112
*/
1213
val current: RoomLifecycle
1314

1415
/**
16+
* (CHA-RS2b)
1517
* The current error, if any, that caused the room to enter the current status.
1618
*/
1719
val error: ErrorInfo?
@@ -36,57 +38,68 @@ interface RoomStatus {
3638
}
3739

3840
/**
41+
* (CHA-RS1)
3942
* The different states that a room can be in throughout its lifecycle.
4043
*/
4144
enum class RoomLifecycle(val stateName: String) {
4245
/**
46+
* (CHA-RS1a)
4347
* A temporary state for when the library is first initialized.
4448
*/
4549
Initialized("initialized"),
4650

4751
/**
52+
* (CHA-RS1b)
4853
* The library is currently attempting to attach the room.
4954
*/
5055
Attaching("attaching"),
5156

5257
/**
58+
* (CHA-RS1c)
5359
* The room is currently attached and receiving events.
5460
*/
5561
Attached("attached"),
5662

5763
/**
64+
* (CHA-RS1d)
5865
* The room is currently detaching and will not receive events.
5966
*/
6067
Detaching("detaching"),
6168

6269
/**
70+
* (CHA-RS1e)
6371
* The room is currently detached and will not receive events.
6472
*/
6573
Detached("detached"),
6674

6775
/**
76+
* (CHA-RS1f)
6877
* The room is in an extended state of detachment, but will attempt to re-attach when able.
6978
*/
7079
Suspended("suspended"),
7180

7281
/**
82+
* (CHA-RS1g)
7383
* The room is currently detached and will not attempt to re-attach. User intervention is required.
7484
*/
7585
Failed("failed"),
7686

7787
/**
88+
* (CHA-RS1h)
7889
* The room is in the process of releasing. Attempting to use a room in this state may result in undefined behavior.
7990
*/
8091
Releasing("releasing"),
8192

8293
/**
94+
* (CHA-RS1i)
8395
* The room has been released and is no longer usable.
8496
*/
8597
Released("released"),
8698
}
8799

88100
/**
89101
* Represents a change in the status of the room.
102+
* (CHA-RS4)
90103
*/
91104
data class RoomStatusChange(
92105
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.ably.chat
2+
3+
import io.ably.lib.types.AblyException
4+
import io.ably.lib.types.ErrorInfo
5+
6+
/**
7+
* Represents a parsed timeserial.
8+
*/
9+
data class Timeserial(
10+
/**
11+
* The series ID of the timeserial.
12+
*/
13+
val seriesId: String,
14+
15+
/**
16+
* The timestamp of the timeserial.
17+
*/
18+
val timestamp: Long,
19+
20+
/**
21+
* The counter of the timeserial.
22+
*/
23+
val counter: Int,
24+
25+
/**
26+
* The index of the timeserial.
27+
*/
28+
val index: Int?,
29+
) : Comparable<Timeserial> {
30+
@Suppress("ReturnCount")
31+
override fun compareTo(other: Timeserial): Int {
32+
val timestampDiff = timestamp.compareTo(other.timestamp)
33+
if (timestampDiff != 0) return timestampDiff
34+
35+
// Compare the counter
36+
val counterDiff = counter.compareTo(other.counter)
37+
if (counterDiff != 0) return counterDiff
38+
39+
// Compare the seriesId lexicographically
40+
val seriesIdDiff = seriesId.compareTo(other.seriesId)
41+
if (seriesIdDiff != 0) return seriesIdDiff
42+
43+
// Compare the index, if present
44+
return if (index != null && other.index != null) index.compareTo(other.index) else 0
45+
}
46+
47+
companion object {
48+
@Suppress("DestructuringDeclarationWithTooManyEntries")
49+
fun parse(timeserial: String): Timeserial {
50+
val matched = """(\w+)@(\d+)-(\d+)(?::(\d+))?""".toRegex().matchEntire(timeserial)
51+
?: throw AblyException.fromErrorInfo(
52+
ErrorInfo("invalid timeserial", HttpStatusCodes.InternalServerError, ErrorCodes.InternalError),
53+
)
54+
55+
val (seriesId, timestamp, counter, index) = matched.destructured
56+
57+
return Timeserial(
58+
seriesId = seriesId,
59+
timestamp = timestamp.toLong(),
60+
counter = counter.toInt(),
61+
index = if (index.isNotBlank()) index.toInt() else null,
62+
)
63+
}
64+
}
65+
}

‎chat-android/src/main/java/com/ably/chat/Utils.kt

+2
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ fun ChatChannelOptions(init: (ChannelOptions.() -> Unit)? = null): ChannelOption
4242
options.params = (options.params ?: mapOf()) + mapOf(
4343
AGENT_PARAMETER_NAME to "chat-kotlin/${BuildConfig.APP_VERSION}",
4444
)
45+
// (CHA-M4a)
46+
options.attachOnSubscribe = false
4547
return options
4648
}
4749

0 commit comments

Comments
 (0)