Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -12,10 +13,34 @@ class TimberInitializer : Initializer<Unit> {
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
// TODO(Timber): plant release tree
Timber.plant(ReleaseTree())
}
Timber.d("TimberInitializer...")
}

override fun dependencies(): List<Class<out Initializer<*>>> = 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)
}
}
}
6 changes: 2 additions & 4 deletions core-ui/src/main/java/com/hoc/flowmvi/core_ui/parcelable.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ import android.os.Parcelable
* https://stackoverflow.com/a/73311814/11191424
*/
inline fun <reified T : Parcelable> 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")
Expand All @@ -21,8 +20,7 @@ inline fun <reified T : Parcelable> Intent.parcelable(key: String): T? =
* https://stackoverflow.com/a/73311814/11191424
*/
inline fun <reified T : Parcelable> 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")
Expand Down
49 changes: 47 additions & 2 deletions data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<String>: maps string values to corresponding UserValidationError enum values
* - Map with "errors" key containing List<String>: 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<UserValidationError> {
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ class MainActivity : AbstractMviActivity<ViewIntent, ViewState, SingleEvent, Mai
override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
R.id.add_action -> {
navigator.run { navigateToAdd() }
navigator.navigateToAdd()
true
}
R.id.search_action -> {
navigator.run { navigateToSearch() }
navigator.navigateToSearch()
true
}
else -> super.onOptionsItemSelected(item)
Expand Down Expand Up @@ -106,7 +106,7 @@ class MainActivity : AbstractMviActivity<ViewIntent, ViewState, SingleEvent, Mai
userAdapter.submitList(viewState.userItems)

mainBinding.run {
errorGroup.isVisible = viewState.error !== null
errorGroup.isVisible = viewState.error != null
errorMessageTextView.text =
viewState.error?.let {
when (it) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class SearchActivity : AbstractMviActivity<ViewIntent, ViewState, SingleEvent, S
.setDuration(200),
)

errorGroup.isVisible = viewState.error !== null
errorGroup.isVisible = viewState.error != null
if (errorGroup.isVisible) {
errorMessageTextView.text =
viewState.error?.let {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ class SearchVM(
): Flow<PartialStateChange> =
flatMapFirst {
viewState.value.let { vs ->
if (vs.error !== null) {
if (vs.error != null) {
executeSearch(vs.submittedQuery).takeUntil(searchableQueryFlow)
} else {
emptyFlow()
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down