diff --git a/app/src/main/java/com/hoc/flowmvi/initializer/TimberInitializer.kt b/app/src/main/java/com/hoc/flowmvi/initializer/TimberInitializer.kt index aa6666e9..eb33f0e2 100644 --- a/app/src/main/java/com/hoc/flowmvi/initializer/TimberInitializer.kt +++ b/app/src/main/java/com/hoc/flowmvi/initializer/TimberInitializer.kt @@ -3,6 +3,7 @@ package com.hoc.flowmvi.initializer import android.content.Context +import android.util.Log import androidx.startup.Initializer import com.hoc.flowmvi.BuildConfig import timber.log.Timber @@ -12,10 +13,34 @@ class TimberInitializer : Initializer { if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) } else { - // TODO(Timber): plant release tree + Timber.plant(ReleaseTree()) } Timber.d("TimberInitializer...") } override fun dependencies(): List>> = emptyList() } + +/** + * A Timber tree for release builds that only logs warnings and errors. + * This prevents sensitive information from being logged in production. + */ +private class ReleaseTree : Timber.Tree() { + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + if (priority == Log.VERBOSE || priority == Log.DEBUG || priority == Log.INFO) { + return + } + + // Log warnings and errors to system log + // In a production app, you might want to send these to a crash reporting service + // like Firebase Crashlytics, Sentry, etc. + if (priority == Log.ERROR && t != null) { + // You could send to crash reporting service here + Log.e(tag, message, t) + } else if (priority == Log.WARN) { + Log.w(tag, message) + } else if (priority == Log.ERROR) { + Log.e(tag, message) + } + } +} diff --git a/core-ui/src/main/java/com/hoc/flowmvi/core_ui/parcelable.kt b/core-ui/src/main/java/com/hoc/flowmvi/core_ui/parcelable.kt index 0e8e3d88..db636316 100644 --- a/core-ui/src/main/java/com/hoc/flowmvi/core_ui/parcelable.kt +++ b/core-ui/src/main/java/com/hoc/flowmvi/core_ui/parcelable.kt @@ -9,8 +9,7 @@ import android.os.Parcelable * https://stackoverflow.com/a/73311814/11191424 */ inline fun Intent.parcelable(key: String): T? = - // TODO: Use `>`, because https://issuetracker.google.com/issues/240585930#comment6 - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { getParcelableExtra(key, T::class.java) } else { @Suppress("DEPRECATION") @@ -21,8 +20,7 @@ inline fun Intent.parcelable(key: String): T? = * https://stackoverflow.com/a/73311814/11191424 */ inline fun Bundle.parcelable(key: String): T? = - // TODO: Use `>`, because https://issuetracker.google.com/issues/240585930#comment6 - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { getParcelable(key, T::class.java) } else { @Suppress("DEPRECATION") diff --git a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt index 87c1e7ff..eb2a4276 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt @@ -1,6 +1,7 @@ package com.hoc.flowmvi.data.mapper import arrow.core.nonFatalOrThrow +import arrow.core.toNonEmptySetOrNull import com.hoc.flowmvi.core.Mapper import com.hoc.flowmvi.data.remote.ErrorResponse import com.hoc.flowmvi.domain.model.UserError @@ -54,10 +55,54 @@ internal class UserErrorMapper( "user-not-found" -> UserError.UserNotFound(id = errorResponse.data as String) "validation-failed" -> UserError.ValidationFailed( - // TODO(hoc081098): Map validation errors from server response - errors = UserValidationError.VALUES_SET, + errors = mapValidationErrors(errorResponse.data), ) else -> UserError.Unexpected } } + + /** + * Maps validation errors from server response data to UserValidationError set. + * + * Expected data format can be: + * - null: returns all validation errors + * - List: maps string values to corresponding UserValidationError enum values + * - Map with "errors" key containing List: maps the list values + * + * String mappings: + * - "invalid-email-address" or "INVALID_EMAIL_ADDRESS" -> UserValidationError.INVALID_EMAIL_ADDRESS + * - "too-short-first-name" or "TOO_SHORT_FIRST_NAME" -> UserValidationError.TOO_SHORT_FIRST_NAME + * - "too-short-last-name" or "TOO_SHORT_LAST_NAME" -> UserValidationError.TOO_SHORT_LAST_NAME + */ + private fun mapValidationErrors(data: Any?): arrow.core.NonEmptySet { + if (data == null) { + // If no specific errors provided, return all validation errors + return UserValidationError.VALUES_SET + } + + val errorStrings = when (data) { + is List<*> -> data.mapNotNull { it?.toString() } + is Map<*, *> -> { + // Try to extract errors from a map structure like {"errors": ["invalid-email-address"]} + val errors = data["errors"] + when (errors) { + is List<*> -> errors.mapNotNull { it?.toString() } + else -> emptyList() + } + } + else -> emptyList() + } + + val validationErrors = errorStrings.mapNotNull { errorString -> + when (errorString.uppercase().replace("-", "_")) { + "INVALID_EMAIL_ADDRESS" -> UserValidationError.INVALID_EMAIL_ADDRESS + "TOO_SHORT_FIRST_NAME" -> UserValidationError.TOO_SHORT_FIRST_NAME + "TOO_SHORT_LAST_NAME" -> UserValidationError.TOO_SHORT_LAST_NAME + else -> null + } + }.toSet() + + // If we couldn't parse any valid errors, return all validation errors as fallback + return validationErrors.toNonEmptySetOrNull() ?: UserValidationError.VALUES_SET + } } diff --git a/data/src/test/java/com/hoc/flowmvi/data/mapper/UserErrorMapperTest.kt b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserErrorMapperTest.kt index 27b7844d..29a062f7 100644 --- a/data/src/test/java/com/hoc/flowmvi/data/mapper/UserErrorMapperTest.kt +++ b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserErrorMapperTest.kt @@ -171,6 +171,52 @@ class UserErrorMapperTest { ) } + @Test + fun test_validationErrorMapping_withNullData_returnsAllErrors() { + val result = errorMapper(buildHttpException("validation-failed", null)) + assertEquals(UserError.ValidationFailed(UserValidationError.VALUES_SET), result) + } + + @Test + fun test_validationErrorMapping_withListOfErrors() { + val data = listOf("invalid-email-address", "too-short-first-name") + val result = errorMapper(buildHttpException("validation-failed", data)) + assertEquals( + UserError.ValidationFailed( + nonEmptySetOf( + UserValidationError.INVALID_EMAIL_ADDRESS, + UserValidationError.TOO_SHORT_FIRST_NAME, + ), + ), + result, + ) + } + + @Test + fun test_validationErrorMapping_withMapContainingErrors() { + val data = mapOf("errors" to listOf("TOO_SHORT_LAST_NAME")) + val result = errorMapper(buildHttpException("validation-failed", data)) + assertEquals( + UserError.ValidationFailed(nonEmptySetOf(UserValidationError.TOO_SHORT_LAST_NAME)), + result, + ) + } + + @Test + fun test_validationErrorMapping_withInvalidData_returnsAllErrors() { + val data = listOf("unknown-error", "another-unknown") + val result = errorMapper(buildHttpException("validation-failed", data)) + // Falls back to all errors when none can be parsed + assertEquals(UserError.ValidationFailed(UserValidationError.VALUES_SET), result) + } + + @Test + fun test_validationErrorMapping_withMixedCaseErrors() { + val data = listOf("INVALID_EMAIL_ADDRESS", "too-short-first-name", "TOO-SHORT-LAST-NAME") + val result = errorMapper(buildHttpException("validation-failed", data)) + assertEquals(UserError.ValidationFailed(UserValidationError.VALUES_SET), result) + } + @Test fun test_withOtherwiseExceptions_returnsUnexpectedError() { assertEquals( diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt index cf7bd6a5..59f0e73d 100644 --- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt +++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt @@ -43,11 +43,11 @@ class MainActivity : AbstractMviActivity { - navigator.run { navigateToAdd() } + navigator.navigateToAdd() true } R.id.search_action -> { - navigator.run { navigateToSearch() } + navigator.navigateToSearch() true } else -> super.onOptionsItemSelected(item) @@ -106,7 +106,7 @@ class MainActivity : AbstractMviActivity = flatMapFirst { viewState.value.let { vs -> - if (vs.error !== null) { + if (vs.error != null) { executeSearch(vs.submittedQuery).takeUntil(searchableQueryFlow) } else { emptyFlow() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0a9acb3e..6b57fab7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] android-compile = "35" -android-gradle = "8.12.0" +android-gradle = "8.4.0" android-min = "21" android-target = "35" androidx-appcompat = "1.7.1"