diff --git a/.editorconfig b/.editorconfig index b908e46c..f0486004 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,4 +7,4 @@ trim_trailing_whitespace = true charset = utf-8 [*.{kt,kts}] -ij_kotlin_imports_layout = android.**,androidx.**,*,java.**,javax.**,kotlin.**,kotlinx.**,io.ably.**,com.ably.**,^ \ No newline at end of file +ij_kotlin_imports_layout = *,^ \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index d69494f0..8cbc9db8 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,10 +1,7 @@ diff --git a/build.gradle.kts b/build.gradle.kts index 872ff85a..593d602a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.kotlin) apply false alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.build.config) apply false } dependencies { diff --git a/chat-android/build.gradle.kts b/chat-android/build.gradle.kts index 9fdde4f7..9f116f4b 100644 --- a/chat-android/build.gradle.kts +++ b/chat-android/build.gradle.kts @@ -1,8 +1,11 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.android.kotlin) + alias(libs.plugins.build.config) } +val version = libs.versions.ably.chat.get() + android { namespace = "com.ably.chat" compileSdk = 34 @@ -33,6 +36,12 @@ android { } } +buildConfig { + packageName("com.ably.chat") + useKotlinOutput { internalVisibility = true } + buildConfigField("APP_VERSION", provider { "\"${version}\"" }) +} + dependencies { api(libs.ably.android) implementation(libs.gson) diff --git a/chat-android/src/main/java/com/ably/chat/ChatClient.kt b/chat-android/src/main/java/com/ably/chat/ChatClient.kt index 6a1d06d9..b621db9b 100644 --- a/chat-android/src/main/java/com/ably/chat/ChatClient.kt +++ b/chat-android/src/main/java/com/ably/chat/ChatClient.kt @@ -1,7 +1,8 @@ +@file:Suppress("StringLiteralDuplication", "NotImplementedDeclaration") + package com.ably.chat import io.ably.lib.realtime.AblyRealtime -import io.ably.lib.types.ClientOptions typealias RealtimeClient = AblyRealtime @@ -35,3 +36,25 @@ interface ChatClient { */ val clientOptions: ClientOptions } + +fun ChatClient(realtimeClient: RealtimeClient, clientOptions: ClientOptions): ChatClient = DefaultChatClient(realtimeClient, clientOptions) + +internal class DefaultChatClient( + override val realtime: RealtimeClient, + override val clientOptions: ClientOptions, +) : ChatClient { + + private val chatApi = ChatApi(realtime, clientId) + + override val rooms: Rooms = DefaultRooms( + realtimeClient = realtime, + chatApi = chatApi, + clientOptions = clientOptions, + ) + + override val connection: Connection + get() = TODO("Not yet implemented") + + override val clientId: String + get() = realtime.auth.clientId +} diff --git a/chat-android/src/main/java/com/ably/chat/Messages.kt b/chat-android/src/main/java/com/ably/chat/Messages.kt index a43259c7..854b147b 100644 --- a/chat-android/src/main/java/com/ably/chat/Messages.kt +++ b/chat-android/src/main/java/com/ably/chat/Messages.kt @@ -1,3 +1,5 @@ +@file:Suppress("StringLiteralDuplication", "NotImplementedDeclaration") + package com.ably.chat import io.ably.lib.realtime.Channel @@ -173,3 +175,40 @@ data class SendMessageParams( */ val headers: MessageHeaders? = null, ) + +class DefaultMessages( + private val roomId: String, + private val realtimeClient: RealtimeClient, + private val chatApi: ChatApi, +) : Messages { + + /** + * the channel name for the chat messages channel. + */ + private val messagesChannelName = "$roomId::\$chat::\$chatMessages" + + override val channel: Channel + get() = realtimeClient.channels.get(messagesChannelName, ChatChannelOptions()) + + override fun subscribe(listener: Messages.Listener) { + TODO("Not yet implemented") + } + + override fun unsubscribe(listener: Messages.Listener) { + TODO("Not yet implemented") + } + + override suspend fun get(options: QueryOptions): PaginatedResult { + TODO("Not yet implemented") + } + + override suspend fun send(params: SendMessageParams): Message = chatApi.sendMessage(roomId, params) + + override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener) { + TODO("Not yet implemented") + } + + override fun offDiscontinuity(listener: EmitsDiscontinuities.Listener) { + TODO("Not yet implemented") + } +} diff --git a/chat-android/src/main/java/com/ably/chat/Occupancy.kt b/chat-android/src/main/java/com/ably/chat/Occupancy.kt index 0ebe721c..62a6bb09 100644 --- a/chat-android/src/main/java/com/ably/chat/Occupancy.kt +++ b/chat-android/src/main/java/com/ably/chat/Occupancy.kt @@ -1,3 +1,5 @@ +@file:Suppress("StringLiteralDuplication", "NotImplementedDeclaration") + package com.ably.chat import io.ably.lib.realtime.Channel @@ -63,3 +65,30 @@ data class OccupancyEvent( */ val presenceMembers: Int, ) + +internal class DefaultOccupancy( + private val messages: Messages, +) : Occupancy { + override val channel: Channel + get() = messages.channel + + override fun subscribe(listener: Occupancy.Listener) { + TODO("Not yet implemented") + } + + override fun unsubscribe(listener: Occupancy.Listener) { + TODO("Not yet implemented") + } + + override suspend fun get(): OccupancyEvent { + TODO("Not yet implemented") + } + + override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener) { + TODO("Not yet implemented") + } + + override fun offDiscontinuity(listener: EmitsDiscontinuities.Listener) { + TODO("Not yet implemented") + } +} diff --git a/chat-android/src/main/java/com/ably/chat/Presence.kt b/chat-android/src/main/java/com/ably/chat/Presence.kt index c2c55b1f..ba492962 100644 --- a/chat-android/src/main/java/com/ably/chat/Presence.kt +++ b/chat-android/src/main/java/com/ably/chat/Presence.kt @@ -1,3 +1,5 @@ +@file:Suppress("StringLiteralDuplication", "NotImplementedDeclaration") + package com.ably.chat import android.text.PrecomputedText.Params @@ -132,3 +134,47 @@ data class PresenceEvent( */ val data: PresenceData, ) + +internal class DefaultPresence( + private val messages: Messages, +) : Presence { + + override val channel: Channel + get() = messages.channel + + override suspend fun get(params: List): List { + TODO("Not yet implemented") + } + + override suspend fun isUserPresent(clientId: String): Boolean { + TODO("Not yet implemented") + } + + override suspend fun enter(data: PresenceData?) { + TODO("Not yet implemented") + } + + override suspend fun update(data: PresenceData?) { + TODO("Not yet implemented") + } + + override suspend fun leave(data: PresenceData?) { + TODO("Not yet implemented") + } + + override fun subscribe(listener: Presence.Listener) { + TODO("Not yet implemented") + } + + override fun unsubscribe(listener: Presence.Listener) { + TODO("Not yet implemented") + } + + override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener) { + TODO("Not yet implemented") + } + + override fun offDiscontinuity(listener: EmitsDiscontinuities.Listener) { + TODO("Not yet implemented") + } +} diff --git a/chat-android/src/main/java/com/ably/chat/Room.kt b/chat-android/src/main/java/com/ably/chat/Room.kt index 3f6616e5..8550eb7c 100644 --- a/chat-android/src/main/java/com/ably/chat/Room.kt +++ b/chat-android/src/main/java/com/ably/chat/Room.kt @@ -1,3 +1,5 @@ +@file:Suppress("StringLiteralDuplication", "NotImplementedDeclaration") + package com.ably.chat /** @@ -80,3 +82,52 @@ interface Room { */ suspend fun detach() } + +internal class DefaultRoom( + override val roomId: String, + override val options: RoomOptions, + realtimeClient: RealtimeClient, + chatApi: ChatApi, +) : Room { + + override val messages: Messages = DefaultMessages( + roomId = roomId, + realtimeClient = realtimeClient, + chatApi = chatApi, + ) + + override val presence: Presence = DefaultPresence( + messages = messages, + ) + + override val reactions: RoomReactions = DefaultRoomReactions( + roomId = roomId, + realtimeClient = realtimeClient, + ) + + override val typing: Typing = DefaultTyping( + roomId = roomId, + realtimeClient = realtimeClient, + ) + + override val occupancy: Occupancy = DefaultOccupancy( + messages = messages, + ) + + override val status: RoomStatus + get() { + TODO("Not yet implemented") + } + + override suspend fun attach() { + messages.channel.attachCoroutine() + typing.channel.attachCoroutine() + reactions.channel.attachCoroutine() + } + + override suspend fun detach() { + messages.channel.detachCoroutine() + typing.channel.detachCoroutine() + reactions.channel.detachCoroutine() + } +} diff --git a/chat-android/src/main/java/com/ably/chat/RoomReactions.kt b/chat-android/src/main/java/com/ably/chat/RoomReactions.kt index 3ced2e2f..f3a2d588 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomReactions.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomReactions.kt @@ -1,3 +1,5 @@ +@file:Suppress("StringLiteralDuplication", "NotImplementedDeclaration") + package com.ably.chat import io.ably.lib.realtime.Channel @@ -100,3 +102,33 @@ data class SendReactionParams( */ val headers: ReactionHeaders? = null, ) + +internal class DefaultRoomReactions( + roomId: String, + private val realtimeClient: RealtimeClient, +) : RoomReactions { + private val roomReactionsChannelName = "$roomId::\$chat::\$reactions" + + override val channel: Channel + get() = realtimeClient.channels.get(roomReactionsChannelName, ChatChannelOptions()) + + override suspend fun send(params: SendReactionParams) { + TODO("Not yet implemented") + } + + override fun subscribe(listener: RoomReactions.Listener) { + TODO("Not yet implemented") + } + + override fun unsubscribe(listener: RoomReactions.Listener) { + TODO("Not yet implemented") + } + + override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener) { + TODO("Not yet implemented") + } + + override fun offDiscontinuity(listener: EmitsDiscontinuities.Listener) { + TODO("Not yet implemented") + } +} diff --git a/chat-android/src/main/java/com/ably/chat/Rooms.kt b/chat-android/src/main/java/com/ably/chat/Rooms.kt index e4fd7ff6..44d917d0 100644 --- a/chat-android/src/main/java/com/ably/chat/Rooms.kt +++ b/chat-android/src/main/java/com/ably/chat/Rooms.kt @@ -1,5 +1,8 @@ package com.ably.chat +import io.ably.lib.types.AblyException +import io.ably.lib.types.ErrorInfo + /** * Manages the lifecycle of chat rooms. */ @@ -35,3 +38,41 @@ interface Rooms { */ suspend fun release(roomId: String) } + +/** + * Manages the chat rooms. + */ +internal class DefaultRooms( + private val realtimeClient: RealtimeClient, + private val chatApi: ChatApi, + override val clientOptions: ClientOptions, +) : Rooms { + private val roomIdToRoom: MutableMap = mutableMapOf() + + override fun get(roomId: String, options: RoomOptions): Room { + return synchronized(this) { + val room = roomIdToRoom.getOrPut(roomId) { + DefaultRoom( + roomId = roomId, + options = options, + realtimeClient = realtimeClient, + chatApi = chatApi, + ) + } + + if (room.options != options) { + throw AblyException.fromErrorInfo( + ErrorInfo("Room already exists with different options", HttpStatusCodes.BadRequest, ErrorCodes.BadRequest), + ) + } + + room + } + } + + override suspend fun release(roomId: String) { + synchronized(this) { + roomIdToRoom.remove(roomId) + } + } +} diff --git a/chat-android/src/main/java/com/ably/chat/Typing.kt b/chat-android/src/main/java/com/ably/chat/Typing.kt index c6532af5..fa75bc24 100644 --- a/chat-android/src/main/java/com/ably/chat/Typing.kt +++ b/chat-android/src/main/java/com/ably/chat/Typing.kt @@ -1,3 +1,5 @@ +@file:Suppress("StringLiteralDuplication", "NotImplementedDeclaration") + package com.ably.chat import io.ably.lib.realtime.Channel @@ -77,3 +79,41 @@ interface Typing : EmitsDiscontinuities { * Represents a typing event. */ data class TypingEvent(val currentlyTyping: Set) + +internal class DefaultTyping( + roomId: String, + private val realtimeClient: RealtimeClient, +) : Typing { + private val typingIndicatorsChannelName = "$roomId::\$chat::\$typingIndicators" + + override val channel: Channel + get() = realtimeClient.channels.get(typingIndicatorsChannelName, ChatChannelOptions()) + + override fun subscribe(listener: Typing.Listener) { + TODO("Not yet implemented") + } + + override fun unsubscribe(listener: Typing.Listener) { + TODO("Not yet implemented") + } + + override suspend fun get(): Set { + TODO("Not yet implemented") + } + + override suspend fun start() { + TODO("Not yet implemented") + } + + override suspend fun stop() { + TODO("Not yet implemented") + } + + override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener) { + TODO("Not yet implemented") + } + + override fun offDiscontinuity(listener: EmitsDiscontinuities.Listener) { + TODO("Not yet implemented") + } +} diff --git a/chat-android/src/main/java/com/ably/chat/Utils.kt b/chat-android/src/main/java/com/ably/chat/Utils.kt new file mode 100644 index 00000000..531a4eb3 --- /dev/null +++ b/chat-android/src/main/java/com/ably/chat/Utils.kt @@ -0,0 +1,46 @@ +package com.ably.chat + +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.CompletionListener +import io.ably.lib.types.AblyException +import io.ably.lib.types.ChannelOptions +import io.ably.lib.types.ErrorInfo +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +const val AGENT_PARAMETER_NAME = "agent" + +suspend fun Channel.attachCoroutine() = suspendCoroutine { continuation -> + attach(object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } + + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }) +} + +suspend fun Channel.detachCoroutine() = suspendCoroutine { continuation -> + detach(object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } + + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }) +} + +@Suppress("FunctionName") +fun ChatChannelOptions(init: (ChannelOptions.() -> Unit)? = null): ChannelOptions { + val options = ChannelOptions() + init?.let { options.it() } + options.params = options.params + mapOf( + AGENT_PARAMETER_NAME to "chat-kotlin/${BuildConfig.APP_VERSION}", + ) + return options +} diff --git a/detekt.yml b/detekt.yml index 3995e03b..95cd7e46 100644 --- a/detekt.yml +++ b/detekt.yml @@ -1000,7 +1000,7 @@ style: UseCheckOrError: active: true UseDataClass: - active: true + active: false allowVars: false UseEmptyCounterpart: active: false diff --git a/example/build.gradle.kts b/example/build.gradle.kts index 0f13d336..2307a660 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -1,3 +1,7 @@ +import java.io.FileInputStream +import java.io.InputStreamReader +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.android.kotlin) @@ -19,6 +23,8 @@ android { vectorDrawables { useSupportLibrary = true } + + buildConfigField("String", "ABLY_KEY", "\"${getLocalProperty("ABLY_KEY") ?: ""}\"") } buildTypes { @@ -38,6 +44,7 @@ android { jvmTarget = "1.8" } buildFeatures { + buildConfig = true compose = true } composeOptions { @@ -68,3 +75,13 @@ dependencies { debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.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/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml index adb97014..a503e359 100644 --- a/example/src/main/AndroidManifest.xml +++ b/example/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:theme="@style/Theme.AblyChatExample" tools:targetApi="31"> - \ No newline at end of file + diff --git a/example/src/main/java/com/ably/chat/example/MainActivity.kt b/example/src/main/java/com/ably/chat/example/MainActivity.kt index 792d3c6b..8fc363b0 100644 --- a/example/src/main/java/com/ably/chat/example/MainActivity.kt +++ b/example/src/main/java/com/ably/chat/example/MainActivity.kt @@ -4,24 +4,62 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.ably.chat.ChatApi +import com.ably.chat.Message +import com.ably.chat.QueryOptions +import com.ably.chat.QueryOptions.MessageOrder.OldestFirst +import com.ably.chat.RealtimeClient +import com.ably.chat.SendMessageParams import com.ably.chat.example.ui.theme.AblyChatExampleTheme +import io.ably.lib.types.ClientOptions +import java.util.UUID +import kotlinx.coroutines.launch + +val randomClientId = UUID.randomUUID().toString() class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val realtimeClient = RealtimeClient( + ClientOptions().apply { + key = BuildConfig.ABLY_KEY + clientId = randomClientId + logLevel = 2 + }, + ) + val chatApi = ChatApi(realtimeClient, randomClientId) enableEdgeToEdge() setContent { AblyChatExampleTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", + Chat( + chatApi, modifier = Modifier.padding(innerPadding), ) } @@ -31,17 +69,105 @@ class MainActivity : ComponentActivity() { } @Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier, - ) +fun Chat(chatApi: ChatApi, modifier: Modifier = Modifier) { + var messageText by remember { mutableStateOf(TextFieldValue("")) } + var sending by remember { mutableStateOf(false) } + var messages by remember { mutableStateOf(listOf()) } + val coroutineScope = rememberCoroutineScope() + + val roomId = "my-room" + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Button(modifier = modifier.align(Alignment.CenterHorizontally), onClick = { + coroutineScope.launch { + messages = chatApi.getMessages(roomId, QueryOptions(orderBy = OldestFirst)).items + } + }) { + Text("Load") + } + + LazyColumn( + modifier = Modifier.weight(1f).padding(16.dp), + userScrollEnabled = true, + ) { + items(messages.size) { index -> + MessageBubble(messages[index]) + } + } + + ChatInputField( + sending = sending, + messageInput = messageText, + onMessageChange = { messageText = it }, + ) { + sending = true + coroutineScope.launch { + chatApi.sendMessage( + roomId, + SendMessageParams( + text = messageText.text, + ), + ) + messageText = TextFieldValue("") + sending = false + } + } + } +} + +@Composable +fun MessageBubble(message: Message) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = if (message.clientId == randomClientId) Arrangement.End else Arrangement.Start, + ) { + Box( + modifier = Modifier + .background( + color = if (message.clientId != randomClientId) Color.Blue else Color.Gray, + shape = RoundedCornerShape(8.dp), + ) + .padding(12.dp), + ) { + Text( + text = message.text, + color = Color.White, + ) + } + } } -@Preview(showBackground = true) @Composable -fun GreetingPreview() { - AblyChatExampleTheme { - Greeting("Android") +fun ChatInputField( + sending: Boolean = false, + messageInput: TextFieldValue, + onMessageChange: (TextFieldValue) -> Unit, + onSendClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .imePadding(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextField( + value = messageInput, + onValueChange = onMessageChange, + readOnly = sending, + modifier = Modifier + .weight(1f) + .background(Color.White), + placeholder = { Text("Type a message...") }, + ) + Button(enabled = !sending, onClick = onSendClick) { + Text("Send") + } } } diff --git a/gradle.properties b/gradle.properties index 5a33dd51..5fd0d9f7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,4 +14,6 @@ org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true + +android.enableBuildConfigAsBytecode=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0bc87085..7b95041b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,11 +2,12 @@ # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format [versions] +ably-chat = "0.0.1" +ably = "1.2.41" junit = "4.13.2" agp = "8.5.2" detekt = "1.23.6" kotlin = "2.0.10" -ably = "1.2.41" androidx-test = "1.6.1" androidx-junit = "1.2.1" core-ktx = "1.13.1" @@ -17,6 +18,7 @@ compose-bom = "2024.06.00" gson = "2.11.0" mockk = "1.13.12" coroutine = "1.8.1" +build-config = "5.4.0" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -52,3 +54,4 @@ android-kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +build-config = { id = "com.github.gmazzo.buildconfig", version.ref = "build-config" }