diff --git a/chat-android/build.gradle.kts b/chat-android/build.gradle.kts index 9f116f4b..a33f9024 100644 --- a/chat-android/build.gradle.kts +++ b/chat-android/build.gradle.kts @@ -27,6 +27,7 @@ android { } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -45,7 +46,7 @@ buildConfig { dependencies { api(libs.ably.android) implementation(libs.gson) - + coreLibraryDesugaring(libs.desugar.jdk.libs) testImplementation(libs.junit) testImplementation(libs.mockk) testImplementation(libs.coroutine.test) diff --git a/chat-android/src/main/java/com/ably/chat/ChatApi.kt b/chat-android/src/main/java/com/ably/chat/ChatApi.kt index 2bc74d91..a463f797 100644 --- a/chat-android/src/main/java/com/ably/chat/ChatApi.kt +++ b/chat-android/src/main/java/com/ably/chat/ChatApi.kt @@ -15,7 +15,11 @@ private const val PROTOCOL_VERSION_PARAM_NAME = "v" private const val RESERVED_ABLY_CHAT_KEY = "ably-chat" private val apiProtocolParam = Param(PROTOCOL_VERSION_PARAM_NAME, API_PROTOCOL_VERSION.toString()) -internal class ChatApi(private val realtimeClient: RealtimeClient, private val clientId: String) { +internal class ChatApi( + private val realtimeClient: RealtimeClient, + private val clientId: String, + private val logger: Logger, +) { /** * Get messages from the Chat Backend @@ -134,6 +138,15 @@ internal class ChatApi(private val realtimeClient: RealtimeClient, private val c } override fun onError(reason: ErrorInfo?) { + logger.error( + "ChatApi.makeAuthorizedRequest(); failed to make request", + staticContext = mapOf( + "url" to url, + "statusCode" to reason?.statusCode.toString(), + "errorCode" to reason?.code.toString(), + "errorMessage" to reason?.message.toString(), + ), + ) // (CHA-M3e) continuation.resumeWithException(AblyException.fromErrorInfo(reason)) } @@ -159,6 +172,15 @@ internal class ChatApi(private val realtimeClient: RealtimeClient, private val c } override fun onError(reason: ErrorInfo?) { + logger.error( + "ChatApi.makeAuthorizedPaginatedRequest(); failed to make request", + staticContext = mapOf( + "url" to url, + "statusCode" to reason?.statusCode.toString(), + "errorCode" to reason?.code.toString(), + "errorMessage" to reason?.message.toString(), + ), + ) continuation.resumeWithException(AblyException.fromErrorInfo(reason)) } }, 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 e933666f..ba3e0ef3 100644 --- a/chat-android/src/main/java/com/ably/chat/ChatClient.kt +++ b/chat-android/src/main/java/com/ably/chat/ChatClient.kt @@ -1,4 +1,4 @@ -@file:Suppress("StringLiteralDuplication", "NotImplementedDeclaration") +@file:Suppress("NotImplementedDeclaration") package com.ably.chat @@ -45,7 +45,17 @@ internal class DefaultChatClient( override val clientOptions: ClientOptions, ) : ChatClient { - private val chatApi = ChatApi(realtime, clientId) + private val logger: Logger = if (clientOptions.logHandler != null) { + CustomLogger( + clientOptions.logHandler, + clientOptions.logLevel, + buildLogContext(), + ) + } else { + AndroidLogger(clientOptions.logLevel, buildLogContext()) + } + + private val chatApi = ChatApi(realtime, clientId, logger.withContext(tag = "AblyChatAPI")) override val rooms: Rooms = DefaultRooms( realtimeClient = realtime, @@ -58,4 +68,16 @@ internal class DefaultChatClient( override val clientId: String get() = realtime.auth.clientId + + private fun buildLogContext() = LogContext( + tag = "ChatClient", + staticContext = mapOf( + "clientId" to clientId, + "instanceId" to generateUUID(), + ), + dynamicContext = mapOf( + "connectionId" to { realtime.connection.id }, + "connectionState" to { realtime.connection.state.name }, + ), + ) } diff --git a/chat-android/src/main/java/com/ably/chat/ClientOptions.kt b/chat-android/src/main/java/com/ably/chat/ClientOptions.kt index adc33120..62e89c9a 100644 --- a/chat-android/src/main/java/com/ably/chat/ClientOptions.kt +++ b/chat-android/src/main/java/com/ably/chat/ClientOptions.kt @@ -1,7 +1,5 @@ package com.ably.chat -import io.ably.lib.util.Log.LogHandler - /** * Configuration options for the chat client. */ diff --git a/chat-android/src/main/java/com/ably/chat/Logger.kt b/chat-android/src/main/java/com/ably/chat/Logger.kt new file mode 100644 index 00000000..573cf5a3 --- /dev/null +++ b/chat-android/src/main/java/com/ably/chat/Logger.kt @@ -0,0 +1,136 @@ +package com.ably.chat + +import android.util.Log +import java.time.LocalDateTime + +fun interface LogHandler { + fun log(message: String, level: LogLevel, throwable: Throwable?, context: LogContext) +} + +data class LogContext( + val tag: String, + val staticContext: Map = mapOf(), + val dynamicContext: Map String> = mapOf(), +) + +internal interface Logger { + val context: LogContext + fun withContext( + tag: String? = null, + staticContext: Map = mapOf(), + dynamicContext: Map String> = mapOf(), + ): Logger + + fun log( + message: String, + level: LogLevel, + throwable: Throwable? = null, + newTag: String? = null, + newStaticContext: Map = mapOf(), + ) +} + +internal fun Logger.trace( + message: String, + throwable: Throwable? = null, + tag: String? = null, + staticContext: Map = mapOf(), +) { + log(message, LogLevel.Trace, throwable, tag, staticContext) +} + +internal fun Logger.debug( + message: String, + throwable: Throwable? = null, + tag: String? = null, + staticContext: Map = mapOf(), +) { + log(message, LogLevel.Debug, throwable, tag, staticContext) +} + +internal fun Logger.info(message: String, throwable: Throwable? = null, tag: String? = null, staticContext: Map = mapOf()) { + log(message, LogLevel.Info, throwable, tag, staticContext) +} + +internal fun Logger.warn(message: String, throwable: Throwable? = null, tag: String? = null, staticContext: Map = mapOf()) { + log(message, LogLevel.Warn, throwable, tag, staticContext) +} + +internal fun Logger.error( + message: String, + throwable: Throwable? = null, + tag: String? = null, + staticContext: Map = mapOf(), +) { + log(message, LogLevel.Error, throwable, tag, staticContext) +} + +internal fun LogContext.mergeWith( + tag: String? = null, + staticContext: Map = mapOf(), + dynamicContext: Map String> = mapOf(), +): LogContext { + return LogContext( + tag = tag ?: this.tag, + staticContext = this.staticContext + staticContext, + dynamicContext = this.dynamicContext + dynamicContext, + ) +} + +internal class AndroidLogger( + private val minimalVisibleLogLevel: LogLevel, + override val context: LogContext, +) : Logger { + + override fun withContext(tag: String?, staticContext: Map, dynamicContext: Map String>): Logger { + return AndroidLogger( + minimalVisibleLogLevel = minimalVisibleLogLevel, + context = context.mergeWith(tag, staticContext, dynamicContext), + ) + } + + override fun log(message: String, level: LogLevel, throwable: Throwable?, newTag: String?, newStaticContext: Map) { + if (level.logLevelValue <= minimalVisibleLogLevel.logLevelValue) return + val finalContext = context.mergeWith(newTag, newStaticContext) + val tag = finalContext.tag + val completeContext = finalContext.staticContext + finalContext.dynamicContext.mapValues { it.value() } + + val contextString = ", context: $completeContext" + val formattedMessage = "[${LocalDateTime.now()}] $tag ${level.name} ably-chat: ${message}$contextString" + when (level) { + // We use Logcat's info level for Trace and Debug + LogLevel.Trace -> Log.i(tag, formattedMessage, throwable) + LogLevel.Debug -> Log.i(tag, formattedMessage, throwable) + LogLevel.Info -> Log.i(tag, formattedMessage, throwable) + LogLevel.Warn -> Log.w(tag, formattedMessage, throwable) + LogLevel.Error -> Log.e(tag, formattedMessage, throwable) + LogLevel.Silent -> {} + } + } +} + +internal class CustomLogger( + private val logHandler: LogHandler, + private val minimalVisibleLogLevel: LogLevel, + override val context: LogContext, +) : Logger { + + override fun withContext(tag: String?, staticContext: Map, dynamicContext: Map String>): Logger { + return CustomLogger( + logHandler = logHandler, + minimalVisibleLogLevel = minimalVisibleLogLevel, + context = context.mergeWith(tag, staticContext, dynamicContext), + ) + } + + override fun log(message: String, level: LogLevel, throwable: Throwable?, newTag: String?, newStaticContext: Map) { + if (level.logLevelValue <= minimalVisibleLogLevel.logLevelValue) return + val finalContext = context.mergeWith(newTag, newStaticContext) + logHandler.log( + message = message, + level = level, + throwable = throwable, + context = finalContext, + ) + } +} diff --git a/chat-android/src/main/java/com/ably/chat/Utils.kt b/chat-android/src/main/java/com/ably/chat/Utils.kt index 8f573525..6ae03d67 100644 --- a/chat-android/src/main/java/com/ably/chat/Utils.kt +++ b/chat-android/src/main/java/com/ably/chat/Utils.kt @@ -5,6 +5,7 @@ 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 java.util.UUID import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine @@ -62,6 +63,8 @@ fun ChatChannelOptions(init: (ChannelOptions.() -> Unit)? = null): ChannelOption return options } +fun generateUUID() = UUID.randomUUID().toString() + /** * A value that can be evaluated at a later time, similar to `kotlinx.coroutines.Deferred` or a JavaScript Promise. * diff --git a/chat-android/src/test/java/com/ably/chat/ChatApiTest.kt b/chat-android/src/test/java/com/ably/chat/ChatApiTest.kt index e2df40df..269b06d8 100644 --- a/chat-android/src/test/java/com/ably/chat/ChatApiTest.kt +++ b/chat-android/src/test/java/com/ably/chat/ChatApiTest.kt @@ -13,7 +13,8 @@ import org.junit.Test class ChatApiTest { private val realtime = mockk(relaxed = true) - private val chatApi = ChatApi(realtime, "clientId") + private val chatApi = + ChatApi(realtime, "clientId", logger = EmptyLogger(LogContext(tag = "TEST"))) /** * @nospec diff --git a/chat-android/src/test/java/com/ably/chat/MessagesTest.kt b/chat-android/src/test/java/com/ably/chat/MessagesTest.kt index 0c5811ff..af6e2631 100644 --- a/chat-android/src/test/java/com/ably/chat/MessagesTest.kt +++ b/chat-android/src/test/java/com/ably/chat/MessagesTest.kt @@ -28,7 +28,7 @@ class MessagesTest { private val realtimeClient = mockk(relaxed = true) private val realtimeChannels = mockk(relaxed = true) private val realtimeChannel = spyk(buildRealtimeChannel()) - private val chatApi = spyk(ChatApi(realtimeClient, "clientId")) + private val chatApi = spyk(ChatApi(realtimeClient, "clientId", EmptyLogger(LogContext(tag = "TEST")))) private lateinit var messages: DefaultMessages private val channelStateListenerSlot = slot() diff --git a/chat-android/src/test/java/com/ably/chat/TestUtils.kt b/chat-android/src/test/java/com/ably/chat/TestUtils.kt index e493418b..e3449e2f 100644 --- a/chat-android/src/test/java/com/ably/chat/TestUtils.kt +++ b/chat-android/src/test/java/com/ably/chat/TestUtils.kt @@ -49,3 +49,8 @@ fun mockOccupancyApiResponse(realtimeClientMock: RealtimeClient, response: JsonE ) } } + +internal class EmptyLogger(override val context: LogContext) : Logger { + override fun withContext(tag: String?, staticContext: Map, dynamicContext: Map String>): Logger = this + override fun log(message: String, level: LogLevel, throwable: Throwable?, newTag: String?, newStaticContext: Map) = Unit +} diff --git a/example/build.gradle.kts b/example/build.gradle.kts index dc7b8010..bc3a226f 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -37,6 +37,7 @@ android { } } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } @@ -67,7 +68,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) - implementation(libs.konfetti.compose) + coreLibraryDesugaring(libs.desugar.jdk.libs) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 35bce2df..d718e4da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ [versions] ably-chat = "0.0.1" ably = "1.2.43" +desugar-jdk-libs = "2.1.2" junit = "4.13.2" agp = "8.5.2" detekt = "1.23.6" @@ -22,6 +23,7 @@ coroutine = "1.8.1" build-config = "5.4.0" [libraries] +desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar-jdk-libs" } junit = { group = "junit", name = "junit", version.ref = "junit" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } ably-android = { module = "io.ably:ably-android", version.ref = "ably" }