diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 74a90dbfd..0ae15c491 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -19,4 +19,4 @@ jobs:
distribution: 'temurin'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v3
- - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests
+ - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectUnitTests
diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml
index 8ec98e980..89368a8a8 100644
--- a/.github/workflows/integration-test.yml
+++ b/.github/workflows/integration-test.yml
@@ -90,3 +90,21 @@ jobs:
uses: gradle/actions/setup-gradle@v3
- run: ./gradlew :java:testRealtimeSuite -Pokhttp
+
+ check-liveobjects:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: 'recursive'
+
+ - name: Set up the JDK
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v3
+
+ - run: ./gradlew runLiveObjectIntegrationTests
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f1e77a7c5..b51e79c9e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,6 +1,6 @@
[versions]
agp = "8.5.2"
-junit = "4.12"
+junit = "4.13.2"
gson = "2.9.0"
msgpack = "0.8.11"
java-websocket = "1.5.3"
@@ -21,7 +21,9 @@ okhttp = "4.12.0"
test-retry = "1.6.0"
kotlin = "2.1.10"
coroutine = "1.9.0"
+mockk = "1.14.2"
turbine = "1.2.0"
+ktor = "3.1.0"
jetbrains-annoations = "26.0.2"
[libraries]
@@ -47,12 +49,16 @@ android-retrostreams = { group = "net.sourceforge.streamsupport", name = "androi
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
coroutine-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutine" }
coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine" }
+mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
+ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
+ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
jetbrains = { group = "org.jetbrains", name = "annotations", version.ref = "jetbrains-annoations" }
[bundles]
common = ["msgpack", "vcdiff-core"]
tests = ["junit", "hamcrest-all", "nanohttpd", "nanohttpd-nanolets", "nanohttpd-websocket", "mockito-core", "concurrentunit", "slf4j-simple"]
+kotlin-tests = ["junit", "mockk", "coroutine-test", "nanohttpd", "turbine", "ktor-client-cio", "ktor-client-core"]
instrumental-android = ["android-test-runner", "android-test-rules", "dexmaker", "dexmaker-dx", "dexmaker-mockito", "android-retrostreams"]
[plugins]
diff --git a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java
index 92e0fdbd8..9a317bdc3 100644
--- a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java
+++ b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java
@@ -48,7 +48,7 @@ public class AblyRealtime extends AblyRest {
*
* This field is initialized only if the LiveObjects plugin is present in the classpath.
*/
- private final LiveObjectsPlugin liveObjectsPlugin;
+ public final LiveObjectsPlugin liveObjectsPlugin;
/**
* Constructs a Realtime client object using an Ably API key or token string.
@@ -73,9 +73,7 @@ public AblyRealtime(ClientOptions options) throws AblyException {
final InternalChannels channels = new InternalChannels();
this.channels = channels;
- liveObjectsPlugin = tryInitializeLiveObjectsPlugin();
-
- connection = new Connection(this, channels, platformAgentProvider, liveObjectsPlugin);
+ connection = new Connection(this, channels, platformAgentProvider);
if (!StringUtils.isNullOrEmpty(options.recover)) {
RecoveryKeyContext recoveryKeyContext = RecoveryKeyContext.decode(options.recover);
@@ -85,6 +83,8 @@ public AblyRealtime(ClientOptions options) throws AblyException {
}
}
+ liveObjectsPlugin = tryInitializeLiveObjectsPlugin();
+
if(options.autoConnect) connection.connect();
}
diff --git a/lib/src/main/java/io/ably/lib/realtime/Connection.java b/lib/src/main/java/io/ably/lib/realtime/Connection.java
index 3ba28a434..c1ca65c70 100644
--- a/lib/src/main/java/io/ably/lib/realtime/Connection.java
+++ b/lib/src/main/java/io/ably/lib/realtime/Connection.java
@@ -1,6 +1,5 @@
package io.ably.lib.realtime;
-import io.ably.lib.objects.LiveObjectsPlugin;
import io.ably.lib.realtime.ConnectionStateListener.ConnectionStateChange;
import io.ably.lib.transport.ConnectionManager;
import io.ably.lib.types.AblyException;
@@ -123,10 +122,10 @@ public void close() {
* internal
*****************/
- Connection(AblyRealtime ably, ConnectionManager.Channels channels, PlatformAgentProvider platformAgentProvider, LiveObjectsPlugin liveObjectsPlugin) throws AblyException {
+ Connection(AblyRealtime ably, ConnectionManager.Channels channels, PlatformAgentProvider platformAgentProvider) throws AblyException {
this.ably = ably;
this.state = ConnectionState.initialized;
- this.connectionManager = new ConnectionManager(ably, this, channels, platformAgentProvider, liveObjectsPlugin);
+ this.connectionManager = new ConnectionManager(ably, this, channels, platformAgentProvider);
}
public void onConnectionStateChange(ConnectionStateChange stateChange) {
diff --git a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java
index f6f20e9eb..3750c6027 100644
--- a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java
+++ b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java
@@ -14,7 +14,6 @@
import io.ably.lib.debug.DebugOptions;
import io.ably.lib.debug.DebugOptions.RawProtocolListener;
import io.ably.lib.http.HttpHelpers;
-import io.ably.lib.objects.LiveObjectsPlugin;
import io.ably.lib.plugins.PluginConnectionAdapter;
import io.ably.lib.realtime.AblyRealtime;
import io.ably.lib.realtime.Channel;
@@ -81,13 +80,6 @@ public class ConnectionManager implements ConnectListener, PluginConnectionAdapt
*/
private boolean cleaningUpAfterEnteringTerminalState = false;
- /**
- * A nullable reference to the LiveObjects plugin.
- *
- * This field is initialized only if the LiveObjects plugin is present in the classpath.
- */
- private final LiveObjectsPlugin liveObjectsPlugin;
-
/**
* Methods on the channels map owned by the {@link AblyRealtime} instance
* which the {@link ConnectionManager} needs access to.
@@ -773,12 +765,11 @@ public void run() {
* ConnectionManager
***********************/
- public ConnectionManager(final AblyRealtime ably, final Connection connection, final Channels channels, final PlatformAgentProvider platformAgentProvider, LiveObjectsPlugin liveObjectsPlugin) throws AblyException {
+ public ConnectionManager(final AblyRealtime ably, final Connection connection, final Channels channels, final PlatformAgentProvider platformAgentProvider) throws AblyException {
this.ably = ably;
this.connection = connection;
this.channels = channels;
this.platformAgentProvider = platformAgentProvider;
- this.liveObjectsPlugin = liveObjectsPlugin;
ClientOptions options = ably.options;
this.hosts = new Hosts(options.realtimeHost, Defaults.HOST_REALTIME, options);
@@ -1232,9 +1223,9 @@ public void onMessage(ITransport transport, ProtocolMessage message) throws Ably
break;
case object:
case object_sync:
- if (liveObjectsPlugin != null) {
+ if (ably.liveObjectsPlugin != null) {
try {
- liveObjectsPlugin.handle(message);
+ ably.liveObjectsPlugin.handle(message);
} catch (Throwable t) {
Log.e(TAG, "LiveObjectsPlugin threw while handling message", t);
}
diff --git a/lib/src/test/java/io/ably/lib/test/realtime/ConnectionManagerTest.java b/lib/src/test/java/io/ably/lib/test/realtime/ConnectionManagerTest.java
index eaaaead97..383270153 100644
--- a/lib/src/test/java/io/ably/lib/test/realtime/ConnectionManagerTest.java
+++ b/lib/src/test/java/io/ably/lib/test/realtime/ConnectionManagerTest.java
@@ -137,7 +137,7 @@ public void connectionmanager_fallback_none_withoutconnection() throws AblyExcep
Connection connection = Mockito.mock(Connection.class);
final ConnectionManager.Channels channels = Mockito.mock(ConnectionManager.Channels.class);
- ConnectionManager connectionManager = new ConnectionManager(ably, connection, channels, new EmptyPlatformAgentProvider(), null) {
+ ConnectionManager connectionManager = new ConnectionManager(ably, connection, channels, new EmptyPlatformAgentProvider()) {
@Override
protected boolean checkConnectivity() {
return false;
diff --git a/live-objects/build.gradle.kts b/live-objects/build.gradle.kts
index 745a9a47c..6aa0d641d 100644
--- a/live-objects/build.gradle.kts
+++ b/live-objects/build.gradle.kts
@@ -1,3 +1,5 @@
+import org.gradle.api.tasks.testing.logging.TestExceptionFormat
+
plugins {
`java-library`
alias(libs.plugins.kotlin.jvm)
@@ -9,14 +11,38 @@ repositories {
dependencies {
implementation(project(":java"))
- testImplementation(kotlin("test"))
implementation(libs.coroutine.core)
- testImplementation(libs.coroutine.test)
+ testImplementation(kotlin("test"))
+ testImplementation(libs.bundles.kotlin.tests)
+}
+
+tasks.withType().configureEach {
+ testLogging {
+ exceptionFormat = TestExceptionFormat.FULL
+ }
+ jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED")
+ jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
+ beforeTest(closureOf { logger.lifecycle("-> $this") })
+ outputs.upToDateWhen { false }
+ // Skip tests for the "release" build type so we don't run tests twice
+ if (name.lowercase().contains("release")) {
+ enabled = false
+ }
+}
+
+tasks.register("runLiveObjectUnitTests") {
+ filter {
+ includeTestsMatching("io.ably.lib.objects.unit.*")
+ exclude("**/UnitTest.class")
+ }
}
-tasks.test {
- useJUnitPlatform()
+tasks.register("runLiveObjectIntegrationTests") {
+ filter {
+ includeTestsMatching("io.ably.lib.objects.integration.*")
+ exclude("**/IntegrationTest.class") // Exclude the base integration test class
+ }
}
kotlin {
diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt
new file mode 100644
index 000000000..148b8abf4
--- /dev/null
+++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt
@@ -0,0 +1,11 @@
+package io.ably.lib.objects
+
+internal enum class ErrorCode(public val code: Int) {
+ BadRequest(40_000),
+ InternalError(50_000),
+}
+
+internal enum class HttpStatusCode(public val code: Int) {
+ BadRequest(400),
+ InternalServerError(500),
+}
diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt
new file mode 100644
index 000000000..930a35f84
--- /dev/null
+++ b/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt
@@ -0,0 +1,36 @@
+package io.ably.lib.objects
+
+import io.ably.lib.types.AblyException
+import io.ably.lib.types.ErrorInfo
+
+internal fun ablyException(
+ errorMessage: String,
+ errorCode: ErrorCode,
+ statusCode: HttpStatusCode = HttpStatusCode.BadRequest,
+ cause: Throwable? = null,
+): AblyException {
+ val errorInfo = createErrorInfo(errorMessage, errorCode, statusCode)
+ return createAblyException(errorInfo, cause)
+}
+
+internal fun ablyException(
+ errorInfo: ErrorInfo,
+ cause: Throwable? = null,
+): AblyException = createAblyException(errorInfo, cause)
+
+private fun createErrorInfo(
+ errorMessage: String,
+ errorCode: ErrorCode,
+ statusCode: HttpStatusCode,
+) = ErrorInfo(errorMessage, statusCode.code, errorCode.code)
+
+private fun createAblyException(
+ errorInfo: ErrorInfo,
+ cause: Throwable?,
+) = cause?.let { AblyException.fromErrorInfo(it, errorInfo) }
+ ?: AblyException.fromErrorInfo(errorInfo)
+
+internal fun clientError(errorMessage: String) = ablyException(errorMessage, ErrorCode.BadRequest, HttpStatusCode.BadRequest)
+
+internal fun serverError(errorMessage: String) = ablyException(errorMessage, ErrorCode.InternalError, HttpStatusCode.InternalServerError)
+
diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt
new file mode 100644
index 000000000..16dc44495
--- /dev/null
+++ b/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt
@@ -0,0 +1,61 @@
+package io.ably.lib.objects
+
+import java.lang.reflect.Field
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+
+suspend fun assertWaiter(timeoutInMs: Long = 10_000, block: suspend () -> Boolean) {
+ withContext(Dispatchers.Default) {
+ withTimeout(timeoutInMs) {
+ do {
+ val success = block()
+ delay(100)
+ } while (!success)
+ }
+ }
+}
+
+fun Any.setPrivateField(name: String, value: Any?) {
+ val valueField = javaClass.findField(name)
+ valueField.isAccessible = true
+ valueField.set(this, value)
+}
+
+fun Any.getPrivateField(name: String): T {
+ val valueField = javaClass.findField(name)
+ valueField.isAccessible = true
+ @Suppress("UNCHECKED_CAST")
+ return valueField.get(this) as T
+}
+
+private fun Class<*>.findField(name: String): Field {
+ var result = kotlin.runCatching { getDeclaredField(name) }
+ var currentClass = this
+ while (result.isFailure && currentClass.superclass != null) // stop when we got field or reached top of class hierarchy
+ {
+ currentClass = currentClass.superclass!!
+ result = kotlin.runCatching { currentClass.getDeclaredField(name) }
+ }
+ if (result.isFailure) {
+ throw result.exceptionOrNull() as Exception
+ }
+ return result.getOrNull() as Field
+}
+
+suspend fun Any.invokePrivateSuspendMethod(methodName: String, vararg args: Any?): T = suspendCancellableCoroutine { cont ->
+ val suspendMethod = javaClass.declaredMethods.find { it.name == methodName }
+ ?: error("Method '$methodName' not found")
+ suspendMethod.isAccessible = true
+ suspendMethod.invoke(this, *args, cont)
+}
+
+
+fun Any.invokePrivateMethod(methodName: String, vararg args: Any?): T {
+ val method = javaClass.declaredMethods.find { it.name == methodName }
+ method?.isAccessible = true
+ @Suppress("UNCHECKED_CAST")
+ return method?.invoke(this, *args) as T
+}
diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt
new file mode 100644
index 000000000..46a831b78
--- /dev/null
+++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt
@@ -0,0 +1,17 @@
+package io.ably.lib.objects.integration
+
+import io.ably.lib.objects.integration.setup.IntegrationTest
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.test.assertNotNull
+
+open class LiveObjectTest : IntegrationTest() {
+
+ @Test
+ fun testChannelObjectGetterTest() = runTest {
+ val channelName = generateChannelName()
+ val channel = getRealtimeChannel(channelName)
+ val objects = channel.objects
+ assertNotNull(objects)
+ }
+}
diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt
new file mode 100644
index 000000000..57ca28476
--- /dev/null
+++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt
@@ -0,0 +1,93 @@
+package io.ably.lib.objects.integration.setup
+
+import io.ably.lib.realtime.AblyRealtime
+import io.ably.lib.realtime.Channel
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.AfterClass
+import org.junit.BeforeClass
+import org.junit.Rule
+import org.junit.rules.Timeout
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import java.util.UUID
+
+@RunWith(Parameterized::class)
+open class IntegrationTest {
+ @Parameterized.Parameter
+ lateinit var testParams: String
+
+ @JvmField
+ @Rule
+ val timeout: Timeout = Timeout.seconds(10)
+
+ private val realtimeClients = mutableMapOf()
+
+ /**
+ * Retrieves a realtime channel for the specified channel name and client ID
+ * If a client with the given clientID does not exist, a new client is created using the provided options.
+ * The channel is attached and ensured to be in the attached state before returning.
+ *
+ * @param channelName Name of the channel
+ * @param clientId The ID of the client to use or create. Defaults to "client1".
+ * @return The attached realtime channel.
+ * @throws Exception If the channel fails to attach or the client fails to connect.
+ */
+ internal suspend fun getRealtimeChannel(channelName: String, clientId: String = "client1"): Channel {
+ val client = realtimeClients.getOrPut(clientId) {
+ sandbox.createRealtimeClient {
+ this.clientId = clientId
+ useBinaryProtocol = testParams == "msgpack_protocol"
+ }. apply { ensureConnected() }
+ }
+ return client.channels.get(channelName).apply {
+ attach()
+ ensureAttached()
+ }
+ }
+
+ /**
+ * Generates a unique channel name for testing purposes.
+ * This is mainly to avoid channel name/state/history collisions across tests in same file.
+ */
+ internal fun generateChannelName(): String {
+ return "test-channel-${UUID.randomUUID()}"
+ }
+
+ @After
+ fun afterEach() {
+ for (ablyRealtime in realtimeClients.values) {
+ for ((channelName, channel) in ablyRealtime.channels.entrySet()) {
+ channel.off()
+ ablyRealtime.channels.release(channelName)
+ }
+ ablyRealtime.close()
+ }
+ realtimeClients.clear()
+ }
+
+ companion object {
+ private lateinit var sandbox: Sandbox
+
+ @JvmStatic
+ @Parameterized.Parameters(name = "{0}")
+ fun data(): Iterable {
+ return listOf("msgpack_protocol", "json_protocol")
+ }
+
+ @JvmStatic
+ @BeforeClass
+ @Throws(Exception::class)
+ fun setUpBeforeClass() {
+ runBlocking {
+ sandbox = Sandbox.createInstance()
+ }
+ }
+
+ @JvmStatic
+ @AfterClass
+ @Throws(Exception::class)
+ fun tearDownAfterClass() {
+ }
+ }
+}
diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt
new file mode 100644
index 000000000..0e1827ec2
--- /dev/null
+++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt
@@ -0,0 +1,107 @@
+package io.ably.lib.objects.integration.setup
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonParser
+import io.ably.lib.objects.ablyException
+import io.ably.lib.realtime.*
+import io.ably.lib.types.ClientOptions
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.cio.CIO
+import io.ktor.client.network.sockets.ConnectTimeoutException
+import io.ktor.client.network.sockets.SocketTimeoutException
+import io.ktor.client.plugins.HttpRequestRetry
+import io.ktor.client.plugins.HttpRequestTimeoutException
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.bodyAsText
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+import io.ktor.http.isSuccess
+import kotlinx.coroutines.CompletableDeferred
+import java.nio.file.Files
+import java.nio.file.Paths
+
+private val client = HttpClient(CIO) {
+ install(HttpRequestRetry) {
+ maxRetries = 5
+ retryIf { _, response ->
+ !response.status.isSuccess()
+ }
+ retryOnExceptionIf { _, cause ->
+ cause is ConnectTimeoutException ||
+ cause is HttpRequestTimeoutException ||
+ cause is SocketTimeoutException
+ }
+ exponentialDelay()
+ }
+}
+
+class Sandbox private constructor(val appId: String, val apiKey: String) {
+ companion object {
+ private fun loadAppCreationJson(): JsonElement {
+ val filePath = Paths.get("../lib/src/test/resources/ably-common/test-resources/test-app-setup.json")
+ val fileContent = Files.readString(filePath)
+ return JsonParser.parseString(fileContent).asJsonObject.get("post_apps")
+ }
+
+ internal suspend fun createInstance(): Sandbox {
+ val response: HttpResponse = client.post("https://sandbox.realtime.ably-nonprod.net/apps") {
+ contentType(ContentType.Application.Json)
+ setBody(loadAppCreationJson().toString())
+ }
+ val body = JsonParser.parseString(response.bodyAsText())
+
+ return Sandbox(
+ appId = body.asJsonObject["appId"].asString,
+ // From JS chat repo at 7985ab7 — "The key we need to use is the one at index 5, which gives enough permissions to interact with Chat and Channels"
+ apiKey = body.asJsonObject["keys"].asJsonArray[0].asJsonObject["keyStr"].asString,
+ )
+ }
+ }
+}
+
+
+internal fun Sandbox.createRealtimeClient(options: ClientOptions.() -> Unit): AblyRealtime {
+ val clientOptions = ClientOptions().apply {
+ apply(options)
+ key = apiKey
+ environment = "sandbox"
+ }
+ return AblyRealtime(clientOptions)
+}
+
+internal suspend fun AblyRealtime.ensureConnected() {
+ if (this.connection.state == ConnectionState.connected) {
+ return
+ }
+ val connectedDeferred = CompletableDeferred()
+ this.connection.on {
+ if (it.event == ConnectionEvent.connected) {
+ connectedDeferred.complete(Unit)
+ this.connection.off()
+ } else if (it.event != ConnectionEvent.connecting) {
+ connectedDeferred.completeExceptionally(ablyException(it.reason))
+ this.connection.off()
+ this.close()
+ }
+ }
+ connectedDeferred.await()
+}
+
+internal suspend fun Channel.ensureAttached() {
+ if (this.state == ChannelState.attached) {
+ return
+ }
+ val attachedDeferred = CompletableDeferred()
+ this.on {
+ if (it.event == ChannelEvent.attached) {
+ attachedDeferred.complete(Unit)
+ this.off()
+ } else if (it.event != ChannelEvent.attaching) {
+ attachedDeferred.completeExceptionally(ablyException(it.reason))
+ this.off()
+ }
+ }
+ attachedDeferred.await()
+}
diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt
new file mode 100644
index 000000000..9d6ed9ad4
--- /dev/null
+++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt
@@ -0,0 +1,15 @@
+package io.ably.lib.objects.unit
+
+import io.ably.lib.objects.unit.setup.UnitTest
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.test.assertNotNull
+
+class LiveObjectTest : UnitTest() {
+ @Test
+ fun testChannelObjectGetterTest() = runTest {
+ val channel = getMockRealtimeChannel("test-channel")
+ val objects = channel.objects
+ assertNotNull(objects)
+ }
+}
diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/setup/UnitTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/setup/UnitTest.kt
new file mode 100644
index 000000000..e831978e2
--- /dev/null
+++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/setup/UnitTest.kt
@@ -0,0 +1,48 @@
+package io.ably.lib.objects.unit.setup
+
+import io.ably.lib.realtime.AblyRealtime
+import io.ably.lib.realtime.Channel
+import io.ably.lib.realtime.ChannelState
+import io.ably.lib.types.ClientOptions
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.spyk
+import org.junit.After
+import org.junit.runner.RunWith
+import org.junit.runners.BlockJUnit4ClassRunner
+
+// This class serves as a base for unit tests related to Live Objects.
+// It can be extended to include common setup or utility methods for unit tests.
+@RunWith(BlockJUnit4ClassRunner::class)
+open class UnitTest {
+
+ private val realtimeClients = mutableMapOf()
+ internal fun getMockRealtimeChannel(channelName: String, clientId: String = "client1"): Channel {
+ val client = realtimeClients.getOrPut(clientId) {
+ AblyRealtime(ClientOptions().apply {
+ autoConnect = false
+ key = "keyName:Value"
+ this.clientId = clientId
+ })
+ }
+ val channel = client.channels.get(channelName)
+ return spyk(channel) {
+ every { attach() } answers {
+ state = ChannelState.attached
+ }
+ every { detach() } answers {
+ state = ChannelState.detached
+ }
+ every { subscribe(any(), any()) } returns mockk(relaxUnitFun = true)
+ every { subscribe(any>(), any()) } returns mockk(relaxUnitFun = true)
+ every { subscribe(any()) } returns mockk(relaxUnitFun = true)
+ }.apply {
+ state = ChannelState.attached
+ }
+ }
+
+ @After
+ fun afterEach() {
+ realtimeClients.clear()
+ }
+}