diff --git a/.github/workflows/example-app.yml b/.github/workflows/example-app.yml new file mode 100644 index 000000000..454594810 --- /dev/null +++ b/.github/workflows/example-app.yml @@ -0,0 +1,42 @@ +name: Example App + +on: + pull_request: + push: + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + android-api-level: [ 29 ] + + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: Set up the JDK + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.android-api-level }} + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + # Print emulator logs if tests fail + script: ./gradlew :examples:connectedAndroidTest || (adb logcat -d System.out:I && exit 1) diff --git a/README.md b/README.md index ba9e483e3..fefa70f93 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,30 @@ realtimeClient.connection.on(ConnectionEvent.connected, connectionStateChange -> ``` --- +## Live Objects + +Ably Live Objects provide realtime, collaborative data structures that automatically synchronize state across all connected clients. Build interactive applications with shared data that updates instantly across devices. + +### Installation + +Add the following dependency to your `build.gradle` file: + +```groovy +dependencies { + runtimeOnly("io.ably:live-objects:1.2.54") +} +``` + +### Documentation and Examples + +- **[Live Objects Documentation](https://ably.com/docs/liveobjects)** - Complete guide to using Live Objects with code examples and API reference +- **[Example App](./examples)** - Interactive demo showcasing Live Objects with realtime color voting and collaborative task management + +The example app demonstrates: +- **Color Voting**: Realtime voting system with live vote counts synchronized across all devices +- **Task Management**: Collaborative task management where users can add, edit, and delete tasks that sync in realtime + +To run the example app, follow the setup instructions in the [examples README](./examples/README.md). ## Proxy support diff --git a/build.gradle.kts b/build.gradle.kts index c741178c7..a98b165b6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,6 +8,9 @@ plugins { alias(libs.plugins.maven.publish) apply false alias(libs.plugins.lombok) apply false alias(libs.plugins.test.retry) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false } subprojects { diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1 @@ +/build diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 000000000..f9e58fc35 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,107 @@ +# Example App using Live Objects + +This demo app showcases Ably Live Objects functionality with two interactive features: + +- **Color Voting**: Real-time voting system where users can vote for their favorite color (Red, Green, Blue) and see live vote counts synchronized across all devices +- **Task Management**: Collaborative task management where users can add, edit, and delete tasks that sync in real-time across all connected devices + +Follow the steps below to get started with the Live Objects demo app + +## Prerequisites + +Ensure you have the following installed: +- [Android Studio](https://developer.android.com/studio) (latest stable version) +- Java 17 or higher +- Android SDK with API Level 34 or higher + +Add your Ably key to the `local.properties` file: + +```properties +sdk.dir=/path/to/android/sdk + +EXAMPLES_ABLY_KEY=xxxx:yyyyyy +``` + +## Steps to Run the App + +1. Open in Android Studio + + - Open Android Studio. + - Select File > Open and navigate to the cloned repository. + - Open the project. + +2. Sync Gradle + + - Wait for Gradle to sync automatically. + - If it doesn’t, click on Sync Project with Gradle Files in the toolbar. + +3. Configure an Emulator or Device + + - Set up an emulator or connect a physical Android device. + - Ensure the device is configured with at least Android 5.0 (API 21). + +4. Run the App + + - Select your emulator or connected device in the device selector dropdown. + - Click on the Run button ▶️ in the toolbar or press Shift + F10. + +5. View the App + + Once the build is complete, the app will be installed and launched on the selected device or emulator. + +## What You'll See + +The app opens with two tabs: + +1. **Color Voting Tab**: + - Vote for Red, Green, or Blue colors + - See real-time vote counts that update instantly across all devices + - Reset all votes with the "Reset all" button + +2. **Task Management Tab**: + - Add new tasks using the text input and "Add Task" button + - Edit existing tasks by clicking the edit icon + - Delete individual tasks or remove all tasks at once + - See the total task count and real-time updates as tasks are modified + +To see the real-time synchronization in action, run the app on multiple devices or emulators with the same Ably key. + +## Building release APK + +This is useful to check ProGuard rules, app size, etc. + +1. Create signing keys for the Android app + +```shell +keytool -genkey -v -keystore release.keystore \ +-storepass \ +-alias \ +-keypass \ +-keyalg RSA -keysize 2048 -validity 25000 -dname "CN=Ably Example App,OU=Examples,O=Ably,L=London,ST=England,C=GB" +``` + +2. Update `local.properties` file: + +```properties +EXAMPLES_STORE_FILE=/absolute/path/to/release.keystore +EXAMPLES_STORE_PASSWORD= +EXAMPLES_KEY_ALIAS= +EXAMPLES_KEY_PASSWORD= +``` + +3. Build release APK + +```shell +./gradlew :examples:assembleRelease +``` + +4. Install to the device + +```shell +adb install -r examples/build/outputs/apk/release/examples-release.apk +``` + +## Troubleshooting + +- SDK Not Found: Install missing SDK versions from File > Settings > Appearance & Behavior > System Settings > Android SDK. +- Build Failures: Check the error logs and resolve dependencies or configuration issues. diff --git a/examples/build.gradle.kts b/examples/build.gradle.kts new file mode 100644 index 000000000..011b99e41 --- /dev/null +++ b/examples/build.gradle.kts @@ -0,0 +1,92 @@ +import java.io.FileInputStream +import java.io.InputStreamReader +import java.util.Properties + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.ably.example" + compileSdk = 35 + + defaultConfig { + applicationId = "com.ably.example" + minSdk = 29 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "ABLY_KEY", "\"${getLocalProperty("EXAMPLES_ABLY_KEY") ?: ""}\"") + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + + val keystorePath = getLocalProperty("EXAMPLES_STORE_FILE") + keystorePath?.let { + signingConfig = signingConfigs.create("release") { + keyAlias = getLocalProperty("EXAMPLES_KEY_ALIAS") + keyPassword = getLocalProperty("EXAMPLES_KEY_PASSWORD") + storeFile = file(it) + storePassword = getLocalProperty("EXAMPLES_STORE_PASSWORD") + } + } + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + buildConfig = true + } +} + +dependencies { + implementation(libs.core.ktx) + implementation(libs.lifecycle.runtime.ktx) + implementation(libs.activity.compose) + implementation(platform(libs.compose.bom)) + implementation(libs.ui) + implementation(libs.ui.graphics) + implementation(libs.ui.tooling.preview) + implementation(libs.material3) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + + implementation(project(":live-objects")) + implementation(project(":android")) + + implementation(libs.navigation.compose) + + testImplementation(libs.junit) + androidTestImplementation(libs.ext.junit) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.ui.test.junit4) + debugImplementation(libs.ui.tooling) + debugImplementation(libs.ui.test.manifest) +} + +fun getLocalProperty(key: String, file: String = "local.properties"): String? { + val properties = Properties() + val localProperties = File(file) + if (!localProperties.isFile) return null + InputStreamReader(FileInputStream(localProperties), Charsets.UTF_8).use { reader -> + properties.load(reader) + } + return properties.getProperty(key) +} diff --git a/examples/proguard-rules.pro b/examples/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/examples/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/examples/src/androidTest/kotlin/com/ably/example/ColorVotingScreenTest.kt b/examples/src/androidTest/kotlin/com/ably/example/ColorVotingScreenTest.kt new file mode 100644 index 000000000..f6651e298 --- /dev/null +++ b/examples/src/androidTest/kotlin/com/ably/example/ColorVotingScreenTest.kt @@ -0,0 +1,51 @@ +package com.ably.example + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ColorVotingScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun incrementRedColor() { + // Navigate to Color Voting tab + composeTestRule.onNodeWithText("Color Voting").performClick() + + // Wait for the screen to load + composeTestRule.waitForIdle() + + // Find and click the Vote button for Red color + val redVoteButton = composeTestRule.onNodeWithTag("vote_button_red") + + // Capture initial count + val initial = composeTestRule.onNodeWithTag("counter_red") + .fetchSemanticsNode() + .config[SemanticsProperties.Text].first().text.toInt() + + composeTestRule.waitUntil(timeoutMillis = 10_000) { + SemanticsProperties.Disabled !in redVoteButton.fetchSemanticsNode().config + } + + redVoteButton.performClick() + + // Wait for the counter to update with 5-seconds timeout + composeTestRule.waitUntil(timeoutMillis = 5_000) { + val updated = composeTestRule.onNodeWithTag("counter_red") + .fetchSemanticsNode() + .config[SemanticsProperties.Text].first().text.toInt() + updated == initial + 1 + } + } +} diff --git a/examples/src/androidTest/kotlin/com/ably/example/MainScreenTest.kt b/examples/src/androidTest/kotlin/com/ably/example/MainScreenTest.kt new file mode 100644 index 000000000..3c1c739a7 --- /dev/null +++ b/examples/src/androidTest/kotlin/com/ably/example/MainScreenTest.kt @@ -0,0 +1,23 @@ +package com.ably.example + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun tabsAreDisplayed() { + // Verify both tabs are displayed + composeTestRule.onNodeWithText("Color Voting").assertIsDisplayed() + composeTestRule.onNodeWithText("Task Management").assertIsDisplayed() + } +} diff --git a/examples/src/main/AndroidManifest.xml b/examples/src/main/AndroidManifest.xml new file mode 100644 index 000000000..d8893208c --- /dev/null +++ b/examples/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/examples/src/main/kotlin/com/ably/example/MainActivity.kt b/examples/src/main/kotlin/com/ably/example/MainActivity.kt new file mode 100644 index 000000000..aad0d93e4 --- /dev/null +++ b/examples/src/main/kotlin/com/ably/example/MainActivity.kt @@ -0,0 +1,70 @@ +package com.ably.example + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.ably.example.screen.MainScreen +import com.ably.example.ui.theme.AblyTheme +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.rest.AblyRest +import io.ably.lib.rest.Auth +import io.ably.lib.types.ClientOptions +import io.ably.lib.util.Log +import kotlinx.coroutines.runBlocking + +class MainActivity : ComponentActivity() { + private val realtimeClient: AblyRealtime by lazy { + AblyRealtime( + ClientOptions().apply { + if (BuildConfig.ABLY_KEY.isBlank()) { + authCallback = Auth.TokenCallback { + val apiKey = runBlocking { + val sandbox = Sandbox.getInstance() + sandbox.apiKey + } + AblyRest(ClientOptions().apply { + key = apiKey + environment = "sandbox" + }).auth.requestToken(null, null) + } + environment = "sandbox" + } else { + key = BuildConfig.ABLY_KEY + } + logLevel = Log.VERBOSE + autoConnect = false + } + ) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + AblyTheme { + MainScreen(realtimeClient) + } + } + } + + override fun onStart() { + super.onStart() + realtimeClient.connect() + } + + override fun onStop() { + super.onStop() + realtimeClient.close() + } + + override fun onResume() { + super.onResume() + realtimeClient.connect() + } + + override fun onPause() { + super.onPause() + realtimeClient.close() + } +} diff --git a/examples/src/main/kotlin/com/ably/example/Sandbox.kt b/examples/src/main/kotlin/com/ably/example/Sandbox.kt new file mode 100644 index 000000000..10427e138 --- /dev/null +++ b/examples/src/main/kotlin/com/ably/example/Sandbox.kt @@ -0,0 +1,60 @@ +package com.ably.example + +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.network.sockets.* +import io.ktor.client.network.sockets.SocketTimeoutException +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* + +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 var cachedInstance: Sandbox? = null + + suspend fun createInstance(): Sandbox { + val response: HttpResponse = client.post("https://sandbox.realtime.ably-nonprod.net/apps") { + contentType(ContentType.Application.Json) + setBody(loadAppCreationRequestBody().toString()) + } + val body = JsonParser.parseString(response.bodyAsText()) + + return Sandbox( + appId = body.asJsonObject["appId"].asString, + apiKey = body.asJsonObject["keys"].asJsonArray[0].asJsonObject["keyStr"].asString, + ) + } + + suspend fun getInstance(): Sandbox { + cachedInstance?.let { return it } + val created = createInstance() + cachedInstance = created + return created + } + } +} + +private suspend fun loadAppCreationRequestBody(): JsonElement = + JsonParser.parseString( + client.get("https://raw.githubusercontent.com/ably/ably-common/refs/heads/main/test-resources/test-app-setup.json") { + contentType(ContentType.Application.Json) + }.bodyAsText(), + ).asJsonObject.get("post_apps") diff --git a/examples/src/main/kotlin/com/ably/example/Utils.kt b/examples/src/main/kotlin/com/ably/example/Utils.kt new file mode 100644 index 000000000..70838c3c3 --- /dev/null +++ b/examples/src/main/kotlin/com/ably/example/Utils.kt @@ -0,0 +1,302 @@ +package com.ably.example + +import androidx.compose.runtime.* +import io.ably.lib.objects.ObjectsCallback +import io.ably.lib.objects.RealtimeObjects +import io.ably.lib.objects.type.counter.LiveCounter +import io.ably.lib.objects.type.counter.LiveCounterUpdate +import io.ably.lib.objects.type.map.LiveMap +import io.ably.lib.objects.type.map.LiveMapUpdate +import io.ably.lib.objects.type.map.LiveMapUpdate.Change.UPDATED +import io.ably.lib.objects.type.map.LiveMapValue +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.ChannelStateListener +import io.ably.lib.types.AblyException +import io.ably.lib.types.ChannelMode +import io.ably.lib.types.ChannelOptions +import io.ably.lib.types.ErrorInfo +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +private suspend fun RealtimeObjects.getRootCoroutines(): LiveMap = suspendCancellableCoroutine { continuation -> + getRootAsync(object : ObjectsCallback { + override fun onSuccess(result: LiveMap?) { + continuation.resume(result!!) + } + + override fun onError(exception: AblyException?) { + continuation.cancel(exception) + } + }) +} + +private suspend fun RealtimeObjects.createCounterCoroutine(): LiveCounter = + suspendCancellableCoroutine { continuation -> + createCounterAsync(object : ObjectsCallback { + override fun onSuccess(result: LiveCounter?) { + continuation.resume(result!!) + } + + override fun onError(exception: AblyException?) { + continuation.cancel(exception) + } + }) + } + +private suspend fun RealtimeObjects.createMapCoroutine(): LiveMap = suspendCancellableCoroutine { continuation -> + createMapAsync(object : ObjectsCallback { + override fun onSuccess(result: LiveMap?) { + continuation.resume(result!!) + } + + override fun onError(exception: AblyException?) { + continuation.cancel(exception) + } + }) +} + +suspend fun LiveCounter.incrementCoroutine(amount: Int): Unit = supressCoroutineExceptions { + suspendCancellableCoroutine { continuation -> + incrementAsync(amount, object : ObjectsCallback { + override fun onSuccess(result: Void?) { + continuation.resume(Unit) + } + + override fun onError(exception: AblyException?) { + continuation.cancel(exception) + } + }) + } +} + +suspend fun LiveCounter.decrementCoroutine(amount: Int): Unit = supressCoroutineExceptions { + suspendCancellableCoroutine { continuation -> + decrementAsync(amount, object : ObjectsCallback { + override fun onSuccess(result: Void?) { + continuation.resume(Unit) + } + + override fun onError(exception: AblyException?) { + continuation.cancel(exception) + } + }) + } +} + +suspend fun Channel.updateOptions(options: ChannelOptions): Unit = supressCoroutineExceptions { + suspendCancellableCoroutine { continuation -> + setOptions(options, object : io.ably.lib.realtime.CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } + + override fun onError(reason: ErrorInfo?) { + continuation.cancel(AblyException.fromErrorInfo(reason)) + } + }) + } +} + +suspend fun getOrCreateCounter(channel: Channel, root: LiveMap?, path: String): LiveCounter { + val mapValue = root?.get(path) + if (mapValue == null) { + val counter = channel.objects.createCounterCoroutine() + root?.setCoroutine(path, LiveMapValue.of(counter)) + return counter + } else { + return mapValue.asLiveCounter + } +} + +suspend fun getOrCreateMap(channel: Channel, root: LiveMap?, path: String): LiveMap { + val mapValue = root?.get(path) + if (mapValue == null) { + val map = channel.objects.createMapCoroutine() + root?.setCoroutine(path, LiveMapValue.of(map)) + return map + } else { + return mapValue.asLiveMap + } +} + +suspend fun LiveMap.setCoroutine(key: String, value: LiveMapValue) = supressCoroutineExceptions { + suspendCancellableCoroutine { continuation -> + setAsync(key, value, object : ObjectsCallback { + override fun onSuccess(result: Void?) { + continuation.resume(Unit) + } + + override fun onError(exception: AblyException?) { + continuation.cancel(exception) + } + }) + } +} + +suspend fun LiveMap.removeCoroutine(key: String) = supressCoroutineExceptions { + suspendCancellableCoroutine { continuation -> + removeAsync(key, object : ObjectsCallback { + override fun onSuccess(result: Void?) { + continuation.resume(Unit) + } + + override fun onError(exception: AblyException?) { + continuation.cancel(exception) + } + }) + } +} + +@Composable +fun observeCounter(channel: Channel, root: LiveMap?, path: String): CounterState { + var counter by remember { mutableStateOf(null) } + var counterValue by remember { mutableStateOf(null) } + + LaunchedEffect(root) { + supressCoroutineExceptions { + counter = getOrCreateCounter(channel, root, path) + } + } + + DisposableEffect(counter) { + counterValue = counter?.value()?.toInt() + + val listener: (LiveCounterUpdate) -> Unit = { + counter?.value()?.let { + counterValue = it.toInt() + } + } + + counter?.subscribe(listener) + + onDispose { + counter?.unsubscribe(listener) + } + } + + DisposableEffect(root) { + val listener: (LiveMapUpdate) -> Unit = { rootUpdate -> + val counterHasBeenRemoved = rootUpdate.update + .filter { (_, change) -> change == UPDATED } + .any { (keyName) -> keyName == path } + + if (counterHasBeenRemoved) root?.get(path)?.asLiveCounter?.let { counter = it } + } + + root?.subscribe(listener) + + onDispose { + root?.unsubscribe(listener) + } + } + + return CounterState(counterValue, counter) { + coroutineScope { + launch { + counter = channel.objects.createCounterCoroutine().also { + root?.setCoroutine(path, LiveMapValue.of(it)) + } + } + } + } +} + +data class CounterState(val value: Int?, val counter: LiveCounter?, val reset: suspend () -> Unit) + +@Composable +fun observeChannelState(channel: Channel): ChannelState { + var channelState by remember { mutableStateOf(channel.state) } + + DisposableEffect(channel) { + val listener: (ChannelStateListener.ChannelStateChange) -> Unit = { + channelState = it.current + } + + channel.on(listener) + + onDispose { + channel.off(listener) + } + } + + return channelState +} + +@Composable +fun observeMap(channel: Channel, root: LiveMap?, path: String): Pair, LiveMap?> { + var map by remember { mutableStateOf(null) } + var mapValue by remember { mutableStateOf>(mapOf()) } + + LaunchedEffect(root) { + supressCoroutineExceptions { + map = getOrCreateMap(channel, root, path) + } + } + + DisposableEffect(map) { + map?.entries()?.associate { (key, value) -> key to value.asString }?.let { + mapValue = it + } + + val listener: (LiveMapUpdate) -> Unit = { + map?.entries()?.associate { (key, value) -> key to value.asString }?.let { + mapValue = it + } + } + + map?.subscribe(listener) + + onDispose { + map?.unsubscribe(listener) + } + } + + return mapValue to map +} + +@Composable +fun observeRootObject(channel: Channel): LiveMap? { + val channelState = observeChannelState(channel) + var root: LiveMap? by remember { mutableStateOf(null) } + + LaunchedEffect(channelState) { + if (channelState == ChannelState.attached) { + supressCoroutineExceptions { + root = channel.objects.getRootCoroutines() + } + } + } + + return root +} + +@Composable +fun getRealtimeChannel(realtimeClient: AblyRealtime, channelName: String): Channel { + val channel = realtimeClient.channels.get(channelName) + + DisposableEffect(channel) { + channel.setOptions(ChannelOptions().apply { + attachOnSubscribe = false + modes = arrayOf(ChannelMode.object_publish, ChannelMode.object_subscribe) + }) + + channel.attach() + + onDispose { + channel.detach() + } + } + + return channel +} + +suspend fun supressCoroutineExceptions(block: suspend () -> Unit) { + try { + block() + } catch (_: Exception) { + } +} diff --git a/examples/src/main/kotlin/com/ably/example/screen/ColorVotingScreen.kt b/examples/src/main/kotlin/com/ably/example/screen/ColorVotingScreen.kt new file mode 100644 index 000000000..b4c7f5f6f --- /dev/null +++ b/examples/src/main/kotlin/com/ably/example/screen/ColorVotingScreen.kt @@ -0,0 +1,166 @@ +package com.ably.example.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ably.example.getRealtimeChannel +import com.ably.example.incrementCoroutine +import com.ably.example.observeCounter +import com.ably.example.observeRootObject +import io.ably.lib.realtime.AblyRealtime +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ColorVotingScreen(realtimeClient: AblyRealtime) { + val scope = rememberCoroutineScope() + val channel = getRealtimeChannel(realtimeClient, "objects-live-counter") + + val root = observeRootObject(channel) + + val (redCount, redCounter, resetRed) = observeCounter(channel, root,"red") + val (greenCount, greenCounter, resetGreen) = observeCounter(channel, root,"green") + val (blueCount, blueCounter, resetBlue) = observeCounter(channel, root,"blue") + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text( + text = "Vote for your favorite color", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 16.dp) + ) + + ColorVoteCard( + color = Color.Red, + colorName = "Red", + count = redCount ?: 0, + enabled = greenCounter != null, + onVote = { + scope.launch { + redCounter?.incrementCoroutine(1) + } + } + ) + + ColorVoteCard( + color = Color.Green, + colorName = "Green", + count = greenCount ?: 0, + enabled = greenCounter != null, + onVote = { + scope.launch { + greenCounter?.incrementCoroutine(1) + } + } + ) + + ColorVoteCard( + color = Color.Blue, + colorName = "Blue", + count = blueCount ?: 0, + enabled = blueCounter != null, + onVote = { + scope.launch { + blueCounter?.incrementCoroutine(1) + } + } + ) + + Button( + enabled = redCounter != null && greenCounter != null && blueCounter != null, + onClick = { + scope.launch { + resetRed() + resetBlue() + resetGreen() + } + }, + ) { + Text( + text = "Reset all", + color = Color.White, + fontWeight = FontWeight.Medium + ) + } + } +} + +@Composable +fun ColorVoteCard( + color: Color, + colorName: String, + count: Int, + enabled: Boolean, + onVote: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .background(color, RoundedCornerShape(8.dp)) + ) + Text( + text = colorName, + fontSize = 18.sp, + fontWeight = FontWeight.Medium + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = count.toString(), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.testTag("counter_${colorName.lowercase()}") + ) + OutlinedButton( + onClick = onVote, + enabled = enabled, + modifier = Modifier.testTag("vote_button_${colorName.lowercase()}") + ) { + Text( + text = "Vote", + fontWeight = FontWeight.Medium + ) + } + } + } + } +} diff --git a/examples/src/main/kotlin/com/ably/example/screen/MainScreen.kt b/examples/src/main/kotlin/com/ably/example/screen/MainScreen.kt new file mode 100644 index 000000000..48d075e4d --- /dev/null +++ b/examples/src/main/kotlin/com/ably/example/screen/MainScreen.kt @@ -0,0 +1,59 @@ +package com.ably.example.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import io.ably.lib.realtime.AblyRealtime + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen(realtimeClient: AblyRealtime) { + var selectedTab by remember { mutableIntStateOf(0) } + val isSandbox = realtimeClient.options.environment == "sandbox" + + val tabs = listOf( + TabItem("Color Voting", Icons.Default.Favorite), + TabItem("Task Management", Icons.AutoMirrored.Filled.List), + ) + + Column(modifier = Modifier.fillMaxSize()) { + TopAppBar( + title = { + Text("Ably Live Objects Demo ${if (isSandbox) "(sandbox)" else ""}") + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + ) + + TabRow( + selectedTabIndex = selectedTab, + modifier = Modifier.fillMaxWidth() + ) { + tabs.forEachIndexed { index, tab -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(tab.title) }, + icon = { Icon(tab.icon, contentDescription = tab.title) } + ) + } + } + + when (selectedTab) { + 0 -> ColorVotingScreen(realtimeClient) + 1 -> TaskManagementScreen(realtimeClient) + } + } +} + +data class TabItem( + val title: String, + val icon: ImageVector, +) diff --git a/examples/src/main/kotlin/com/ably/example/screen/TaskManagementScreen.kt b/examples/src/main/kotlin/com/ably/example/screen/TaskManagementScreen.kt new file mode 100644 index 000000000..9079aaedc --- /dev/null +++ b/examples/src/main/kotlin/com/ably/example/screen/TaskManagementScreen.kt @@ -0,0 +1,263 @@ +package com.ably.example.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ably.example.getRealtimeChannel +import com.ably.example.observeMap +import com.ably.example.observeRootObject +import com.ably.example.removeCoroutine +import com.ably.example.setCoroutine +import io.ably.lib.objects.type.map.LiveMapValue +import io.ably.lib.realtime.AblyRealtime +import kotlinx.coroutines.launch +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) +@Composable +fun TaskManagementScreen(realtimeClient: AblyRealtime) { + var taskText by remember { mutableStateOf("") } + var editingTaskId by remember { mutableStateOf(null) } + var editingText by remember { mutableStateOf("") } + + val scope = rememberCoroutineScope() + + val channel = getRealtimeChannel(realtimeClient, "objects-live-map") + val root = observeRootObject(channel) + + val (taskIdToTask, liveTasks) = observeMap(channel, root, "tasks") + + val taskEntries = remember(taskIdToTask) { + taskIdToTask.entries.sortedBy { it.key } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Task Management", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = taskText, + onValueChange = { taskText = it }, + label = { Text("Enter new task") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + if (taskText.isNotBlank()) { + scope.launch { + val taskId = "${System.currentTimeMillis()}_${Uuid.random().toHexString()}" + liveTasks?.setCoroutine(taskId, LiveMapValue.of(taskText.trim())) + taskText = "" + } + } + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Add, contentDescription = "Add") + Spacer(modifier = Modifier.width(8.dp)) + Text("Add Task") + } + + OutlinedButton( + onClick = { + scope.launch { + taskIdToTask.forEach { task -> + liveTasks?.removeCoroutine(task.key) + } + } + }, + modifier = Modifier.weight(1f) + ) { + Text("Remove All") + } + } + } + } + + Card( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Tasks (${taskIdToTask.size})", + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 12.dp) + ) + + if (taskIdToTask.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No tasks yet. Add one above!", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(taskIdToTask.size) { index -> + val task = taskEntries.elementAt(index) + TaskItemCard( + task = task, + isEditing = editingTaskId == task.key, + editingText = editingText, + onEditingTextChange = { editingText = it }, + onEdit = { + editingTaskId = task.key + editingText = task.value + }, + onSave = { + scope.launch { + liveTasks?.setCoroutine(task.key, LiveMapValue.of(editingText.trim())) + editingTaskId = null + editingText = "" + } + }, + onCancel = { + editingTaskId = null + editingText = "" + }, + onDelete = { + scope.launch { + liveTasks?.removeCoroutine(task.key) + } + } + ) + } + } + } + } + } + } +} + +@Composable +fun TaskItemCard( + task: Map.Entry, + isEditing: Boolean, + editingText: String, + onEditingTextChange: (String) -> Unit, + onEdit: () -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, + onDelete: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth().padding(all = 2.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(8.dp) + ) { + if (isEditing) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = editingText, + onValueChange = onEditingTextChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onSave, + modifier = Modifier.weight(1f) + ) { + Text("Save") + } + OutlinedButton( + onClick = onCancel, + modifier = Modifier.weight(1f) + ) { + Text("Cancel") + } + } + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = task.value, + modifier = Modifier.weight(1f), + fontSize = 16.sp + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + IconButton(onClick = onEdit) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit", + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + } +} diff --git a/examples/src/main/kotlin/com/ably/example/ui/theme/Color.kt b/examples/src/main/kotlin/com/ably/example/ui/theme/Color.kt new file mode 100644 index 000000000..621834cb1 --- /dev/null +++ b/examples/src/main/kotlin/com/ably/example/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.ably.example.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) diff --git a/examples/src/main/kotlin/com/ably/example/ui/theme/Theme.kt b/examples/src/main/kotlin/com/ably/example/ui/theme/Theme.kt new file mode 100644 index 000000000..6e9726b94 --- /dev/null +++ b/examples/src/main/kotlin/com/ably/example/ui/theme/Theme.kt @@ -0,0 +1,46 @@ +package com.ably.example.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 +) + +@Composable +fun AblyTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/examples/src/main/kotlin/com/ably/example/ui/theme/Type.kt b/examples/src/main/kotlin/com/ably/example/ui/theme/Type.kt new file mode 100644 index 000000000..091be1dd4 --- /dev/null +++ b/examples/src/main/kotlin/com/ably/example/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.ably.example.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) diff --git a/examples/src/main/res/drawable/launch_background.xml b/examples/src/main/res/drawable/launch_background.xml new file mode 100644 index 000000000..304732f88 --- /dev/null +++ b/examples/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/examples/src/main/res/mipmap-anydpi/ic_launcher.xml b/examples/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 000000000..036d09bc5 --- /dev/null +++ b/examples/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/examples/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/examples/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 000000000..036d09bc5 --- /dev/null +++ b/examples/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/examples/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/examples/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..928637e9c Binary files /dev/null and b/examples/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/examples/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/examples/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..854705f11 Binary files /dev/null and b/examples/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/examples/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/examples/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..9b97812d6 Binary files /dev/null and b/examples/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/examples/src/main/res/values/colors.xml b/examples/src/main/res/values/colors.xml new file mode 100644 index 000000000..ca1931bca --- /dev/null +++ b/examples/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/examples/src/main/res/values/ic_launcher_background.xml b/examples/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..9f24f9be4 --- /dev/null +++ b/examples/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #03020d + \ No newline at end of file diff --git a/examples/src/main/res/values/strings.xml b/examples/src/main/res/values/strings.xml new file mode 100644 index 000000000..7649b0314 --- /dev/null +++ b/examples/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Ably Example + diff --git a/examples/src/main/res/values/themes.xml b/examples/src/main/res/values/themes.xml new file mode 100644 index 000000000..6405a75fb --- /dev/null +++ b/examples/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +