diff --git a/README.md b/README.md index c9356159..e128fba4 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ Android Commons is a curated set of libraries designed to accelerate Android dev | `commons-android-compose` | Jetpack Compose utilities | - | [![Maven Central](https://img.shields.io/maven-central/v/com.raxdenstudios/commons-android-compose.svg?label=version)](https://central.sonatype.com/artifact/com.raxdenstudios/commons-android-compose) | | `commons-android-test` | Android testing utilities | - | [![Maven Central](https://img.shields.io/maven-central/v/com.raxdenstudios/commons-android-test.svg?label=version)](https://central.sonatype.com/artifact/com.raxdenstudios/commons-android-test) | | **Async & Concurrency** | -| `commons-coroutines` | Kotlin Coroutines extensions | - | [![Maven Central](https://img.shields.io/maven-central/v/com.raxdenstudios/commons-coroutines.svg?label=version)](https://central.sonatype.com/artifact/com.raxdenstudios/commons-coroutines) | -| `commons-coroutines-test` | Coroutines testing utilities | - | [![Maven Central](https://img.shields.io/maven-central/v/com.raxdenstudios/commons-coroutines-test.svg?label=version)](https://central.sonatype.com/artifact/com.raxdenstudios/commons-coroutines-test) | +| `commons-coroutines` | Kotlin Coroutines extensions | [๐Ÿ“– Docs](libraries/coroutines/README.md) | [![Maven Central](https://img.shields.io/maven-central/v/com.raxdenstudios/commons-coroutines.svg?label=version)](https://central.sonatype.com/artifact/com.raxdenstudios/commons-coroutines) | +| `commons-coroutines-test` | Coroutines testing utilities | [๐Ÿ“– Docs](libraries/coroutines/README.md) | [![Maven Central](https://img.shields.io/maven-central/v/com.raxdenstudios/commons-coroutines-test.svg?label=version)](https://central.sonatype.com/artifact/com.raxdenstudios/commons-coroutines-test) | | **Networking** | | `commons-network` | Network utilities and interceptors | [๐Ÿ“– Docs](libraries/network/README.md) | [![Maven Central](https://img.shields.io/maven-central/v/com.raxdenstudios/commons-network.svg?label=version)](https://central.sonatype.com/artifact/com.raxdenstudios/commons-network) | | **Pagination** | diff --git a/libraries/core/src/test/java/com/raxdenstudios/commons/core/NetworkErrorTest.kt b/libraries/core/src/test/java/com/raxdenstudios/commons/core/NetworkErrorTest.kt new file mode 100644 index 00000000..08d4d31d --- /dev/null +++ b/libraries/core/src/test/java/com/raxdenstudios/commons/core/NetworkErrorTest.kt @@ -0,0 +1,156 @@ +package com.raxdenstudios.commons.core + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class NetworkErrorTest { + + @Test + fun `Client error should have correct properties`() { + val error = NetworkError.Client( + code = 400, + body = "Bad Request", + message = "Invalid input" + ) + + assertThat(error.code).isEqualTo(400) + assertThat(error.body).isEqualTo("Bad Request") + assertThat(error.message).isEqualTo("Invalid input") + } + + @Test + fun `Client error with null body should work`() { + val error = NetworkError.Client( + code = 401, + body = null, + message = "Unauthorized" + ) + + assertThat(error.code).isEqualTo(401) + assertThat(error.body).isNull() + assertThat(error.message).isEqualTo("Unauthorized") + } + + @Test + fun `Server error should have correct properties`() { + val error = NetworkError.Server( + code = 500, + body = "Internal Server Error", + message = "Server crashed" + ) + + assertThat(error.code).isEqualTo(500) + assertThat(error.body).isEqualTo("Internal Server Error") + assertThat(error.message).isEqualTo("Server crashed") + } + + @Test + fun `Server error with null body should work`() { + val error = NetworkError.Server( + code = 503, + body = null, + message = "Service Unavailable" + ) + + assertThat(error.code).isEqualTo(503) + assertThat(error.body).isNull() + assertThat(error.message).isEqualTo("Service Unavailable") + } + + @Test + fun `Network error should have correct properties`() { + val error = NetworkError.Network( + body = "Connection timeout", + message = "Network timeout" + ) + + assertThat(error.body).isEqualTo("Connection timeout") + assertThat(error.message).isEqualTo("Network timeout") + } + + @Test + fun `Network error with null body should work`() { + val error = NetworkError.Network( + body = null, + message = "No internet connection" + ) + + assertThat(error.body).isNull() + assertThat(error.message).isEqualTo("No internet connection") + } + + @Test + fun `Unknown error should have correct properties`() { + val error = NetworkError.Unknown( + code = 999, + body = "Unknown error body", + message = "Unknown error occurred" + ) + + assertThat(error.code).isEqualTo(999) + assertThat(error.body).isEqualTo("Unknown error body") + assertThat(error.message).isEqualTo("Unknown error occurred") + } + + @Test + fun `Unknown error with null code and body should work`() { + val error = NetworkError.Unknown( + code = null, + body = null, + message = "Unknown error" + ) + + assertThat(error.code).isNull() + assertThat(error.body).isNull() + assertThat(error.message).isEqualTo("Unknown error") + } + + @Test + fun `NetworkError should work with different body types`() { + data class ErrorBody(val errorCode: String, val details: String) + + val errorBody = ErrorBody("ERR_001", "Validation failed") + val error = NetworkError.Client( + code = 400, + body = errorBody, + message = "Validation error" + ) + + assertThat(error.body).isEqualTo(errorBody) + assertThat(error.body?.errorCode).isEqualTo("ERR_001") + assertThat(error.body?.details).isEqualTo("Validation failed") + } + + @Test + fun `NetworkError sealed interface should allow polymorphic usage`() { + val errors: List> = listOf( + NetworkError.Client(code = 400, message = "Bad Request"), + NetworkError.Server(code = 500, message = "Server Error"), + NetworkError.Network(message = "Network Error"), + NetworkError.Unknown(message = "Unknown Error") + ) + + assertThat(errors).hasSize(4) + assertThat(errors[0]).isInstanceOf(NetworkError.Client::class.java) + assertThat(errors[1]).isInstanceOf(NetworkError.Server::class.java) + assertThat(errors[2]).isInstanceOf(NetworkError.Network::class.java) + assertThat(errors[3]).isInstanceOf(NetworkError.Unknown::class.java) + } + + @Test + fun `NetworkError can be used in when expression`() { + val error: NetworkError = NetworkError.Client( + code = 404, + message = "Not Found" + ) + + val result = when (error) { + is NetworkError.Client -> "Client error: ${error.code}" + is NetworkError.Server -> "Server error: ${error.code}" + is NetworkError.Network -> "Network error" + is NetworkError.Unknown -> "Unknown error" + } + + assertThat(result).isEqualTo("Client error: 404") + } +} diff --git a/libraries/core/src/test/java/com/raxdenstudios/commons/core/ext/KotlinExtensionTest.kt b/libraries/core/src/test/java/com/raxdenstudios/commons/core/ext/KotlinExtensionTest.kt new file mode 100644 index 00000000..012845bb --- /dev/null +++ b/libraries/core/src/test/java/com/raxdenstudios/commons/core/ext/KotlinExtensionTest.kt @@ -0,0 +1,84 @@ +package com.raxdenstudios.commons.core.ext + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class KotlinExtensionTest { + + @Test + fun `exhaustive should return the same value when not null`() { + val value: String? = "test" + + val result = value.exhaustive + + assertThat(result).isEqualTo("test") + } + + @Test + fun `exhaustive should return null when value is null`() { + val value: String? = null + + val result = value.exhaustive + + assertThat(result).isNull() + } + + @Test + fun `exhaustive should work with when expressions on boolean`() { + val value: Boolean = true + + val output = when (value) { + true -> "success" + false -> "failure" + }.exhaustive + + assertThat(output).isEqualTo("success") + } + + @Test + fun `exhaustive should work with nullable when expressions`() { + val value: String? = "test" + + val result = when (value) { + null -> null + else -> value.uppercase() + }.exhaustive + + assertThat(result).isEqualTo("TEST") + } + + @Test + fun `exhaustive should preserve type information`() { + val number: Int? = 42 + + val result: Int? = number.exhaustive + + assertThat(result).isEqualTo(42) + } + + @Test + fun `exhaustive should work with different types`() { + val stringValue: String? = "hello".exhaustive + val intValue: Int? = 123.exhaustive + val booleanValue: Boolean? = true.exhaustive + val listValue: List? = listOf("a", "b").exhaustive + + assertThat(stringValue).isEqualTo("hello") + assertThat(intValue).isEqualTo(123) + assertThat(booleanValue).isTrue() + assertThat(listValue).containsExactly("a", "b") + } + + @Test + fun `exhaustive should help ensure when expressions are exhaustive`() { + val value: Boolean = true + + // The exhaustive property ensures the when expression is exhaustive + val result = when (value) { + true -> "yes" + false -> "no" + }.exhaustive + + assertThat(result).isEqualTo("yes") + } +} diff --git a/libraries/coroutines/README.md b/libraries/coroutines/README.md new file mode 100644 index 00000000..d69b0504 --- /dev/null +++ b/libraries/coroutines/README.md @@ -0,0 +1,420 @@ +# Coroutines Module + +Kotlin Coroutines utilities and extensions for safer and more convenient coroutine handling. + +## ๐Ÿ“ฆ Installation + +```kotlin +dependencies { + implementation(platform("com.raxdenstudios:commons-bom:")) + implementation("com.raxdenstudios:commons-coroutines") + + // For testing + testImplementation("com.raxdenstudios:commons-coroutines-test") +} +``` + +## ๐Ÿš€ Usage + +### Safe Launch + +Launch coroutines with automatic exception handling: + +```kotlin +class MyViewModel : ViewModel() { + + init { + viewModelScope.safeLaunch { + // Your coroutine code + // Exceptions are automatically caught and logged + val data = repository.getData() + _state.value = State.Success(data) + } + } +} +``` + +### Launch with Error Callback + +Handle errors with a custom callback: + +```kotlin +viewModelScope.launch( + onError = { throwable -> + _state.value = State.Error(throwable.message) + analytics.logError(throwable) + } +) { + val data = repository.getData() + _state.value = State.Success(data) +} +``` + +### Custom Exception Handler + +Use your own exception handler: + +```kotlin +val customHandler = CoroutineExceptionHandler { _, throwable -> + Log.e("MyApp", "Coroutine error", throwable) + crashlytics.recordException(throwable) +} + +viewModelScope.safeLaunch(exceptionHandler = customHandler) { + // Your code +} +``` + +### Global Logger Configuration + +Set up a global logger for all coroutine exceptions: + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + defaultCoroutineLogger = object : CoroutineLogger { + override fun logError(tag: String, message: String?, throwable: Throwable) { + Log.e(tag, message, throwable) + Crashlytics.getInstance().recordException(throwable) + } + } + } +} +``` + +## ๐ŸŽฏ Answer Extensions + +Work with `Answer` type in coroutines: + +### coRunCatching + +Wrap suspend functions in Answer: + +```kotlin +val result: Answer = coRunCatching { + api.getUser(userId) +} + +when (result) { + is Answer.Success -> showUser(result.value) + is Answer.Failure -> showError(result.value) +} +``` + +### Transform Success Values + +```kotlin +val userAnswer: Answer = getUserAnswer() + +val nameAnswer: Answer = userAnswer.coThen { user -> + user.name +} +``` + +### Transform Failure Values + +```kotlin +val result: Answer = getDataAnswer() + +val mapped: Answer = result.coThenFailure { apiError -> + UiError.from(apiError) +} +``` + +### FlatMap Operations + +```kotlin +val userAnswer: Answer = getUserAnswer() + +val profileAnswer: Answer = userAnswer.coFlatMap { user -> + getProfileAnswer(user.id) +} +``` + +### Side Effects + +```kotlin +userAnswer + .onCoSuccess { user -> + analytics.trackUserLoaded(user.id) + } + .onCoFailure { error -> + analytics.trackError(error) + } +``` + +## ๐ŸŒŠ Flow Extensions + +Transform Flow emissions with Answer: + +### Map Flow Values + +```kotlin +val userFlow: Flow> = getUserFlow() + +val nameFlow: Flow> = userFlow.then { user -> + user.name +} +``` + +### Map Flow Errors + +```kotlin +val dataFlow: Flow> = getDataFlow() + +val uiFlow: Flow> = dataFlow.thenFailure { apiError -> + UiError.from(apiError) +} +``` + +### Convert Flow to Answer + +```kotlin +val itemsFlow: Flow> = database.observeItems() + +val answerFlow: Flow, Throwable>> = itemsFlow.toAnswer() + +answerFlow.collect { answer -> + when (answer) { + is Answer.Success -> updateUI(answer.value) + is Answer.Failure -> showError(answer.value) + } +} +``` + +## ๐Ÿ”ง Dispatcher Provider + +Abstraction for coroutine dispatchers (useful for testing): + +```kotlin +interface DispatcherProvider { + val main: CoroutineDispatcher + val io: CoroutineDispatcher + val default: CoroutineDispatcher +} + +// Implementation +class DefaultDispatcherProvider : DispatcherProvider { + override val main = Dispatchers.Main + override val io = Dispatchers.IO + override val default = Dispatchers.Default +} + +// Usage in ViewModel +class MyViewModel( + private val dispatchers: DispatcherProvider +) : ViewModel() { + + fun loadData() { + viewModelScope.launch(dispatchers.io) { + val data = repository.getData() + withContext(dispatchers.main) { + _state.value = State.Success(data) + } + } + } +} +``` + +## ๐ŸŽญ Coroutine Scope Provider + +Abstraction for coroutine scopes: + +```kotlin +interface CoroutineScopeProvider { + val main: CoroutineScope + val io: CoroutineScope + val default: CoroutineScope +} + +// Usage +class MyRepository( + private val scopes: CoroutineScopeProvider +) { + fun loadData() { + scopes.io.launch { + // Background work + } + } +} +``` + +## โœจ Features + +- โœ… Safe coroutine launching with automatic exception handling +- โœ… Custom error callbacks +- โœ… Global logger configuration +- โœ… Answer type integration for functional error handling +- โœ… Flow extensions for Answer transformations +- โœ… Dispatcher and Scope abstractions for testability +- โœ… Comprehensive unit tests +- โœ… Kotlin DSL for clean syntax + +## ๐Ÿ“– API Reference + +### Extension Functions + +#### CoroutineScope Extensions + +```kotlin +fun CoroutineScope.safeLaunch( + exceptionHandler: CoroutineExceptionHandler = defaultExceptionHandler, + block: suspend CoroutineScope.() -> Unit +): Job + +fun CoroutineScope.launch( + onError: (Throwable) -> Unit = {}, + block: suspend CoroutineScope.() -> Unit +): Job +``` + +#### Answer Extensions + +```kotlin +suspend fun T.coRunCatching( + function: suspend T.() -> R +): Answer + +suspend fun Answer.coThen( + function: suspend (value: T) -> R +): Answer + +suspend fun Answer.coThenFailure( + function: suspend (value: E) -> R +): Answer + +suspend fun Answer.coFlatMap( + function: suspend (value: T) -> Answer +): Answer + +suspend fun Answer.coFlatMapFailure( + function: suspend (value: E) -> Answer +): Answer + +suspend fun Answer.onCoSuccess( + function: suspend (success: T) -> Unit +): Answer + +suspend fun Answer.onCoFailure( + function: suspend (failure: E) -> Unit +): Answer +``` + +#### Flow Extensions + +```kotlin +fun Flow>.then( + function: suspend (value: T) -> R +): Flow> + +fun Flow>.thenFailure( + function: (value: E) -> R +): Flow> + +fun Flow.toAnswer(): Flow> +``` + +### Interfaces + +#### CoroutineLogger + +```kotlin +interface CoroutineLogger { + fun logError(tag: String, message: String?, throwable: Throwable) +} + +// Global instance +var defaultCoroutineLogger: CoroutineLogger? +``` + +#### DispatcherProvider + +```kotlin +interface DispatcherProvider { + val main: CoroutineDispatcher + val io: CoroutineDispatcher + val default: CoroutineDispatcher +} +``` + +#### CoroutineScopeProvider + +```kotlin +interface CoroutineScopeProvider { + val main: CoroutineScope + val io: CoroutineScope + val default: CoroutineScope +} +``` + +## ๐Ÿ’ก Best Practices + +### Always Use Safe Launch in Production + +```kotlin +// โœ… Good - exceptions are handled +viewModelScope.safeLaunch { + loadData() +} + +// โŒ Bad - unhandled exceptions can crash the app +viewModelScope.launch { + loadData() +} +``` + +### Provide Error Callbacks for User-Facing Errors + +```kotlin +viewModelScope.launch( + onError = { error -> + _errorState.value = error.toUserMessage() + } +) { + val data = repository.getData() + _state.value = data +} +``` + +### Use Dispatcher Abstraction for Testing + +```kotlin +class MyViewModel( + private val dispatchers: DispatcherProvider = DefaultDispatcherProvider() +) : ViewModel() { + // Easy to test with TestDispatcherProvider +} +``` + +### Combine Answer with Flow for Reactive Error Handling + +```kotlin +repository.observeData() + .toAnswer() + .then { data -> data.processedValue } + .collect { answer -> + when (answer) { + is Answer.Success -> updateUI(answer.value) + is Answer.Failure -> showError(answer.value) + } + } +``` + +## ๐Ÿงช Testing + +The module includes comprehensive unit tests for all functionality. + +```bash +./gradlew :libraries:coroutines:testDebugUnitTest +``` + +### Test Coverage + +- โœ… safeLaunch with and without exceptions +- โœ… launch with error callbacks +- โœ… Custom exception handlers +- โœ… All Answer extension functions +- โœ… Flow transformations +- โœ… Error propagation