Skip to content

Commit 45a1fb1

Browse files
authored
Introduce FailFast utils to provide offensive programing (#5373)
1 parent a84343e commit 45a1fb1

File tree

8 files changed

+264
-2
lines changed

8 files changed

+264
-2
lines changed

app/src/debug/kotlin/io/homeassistant/companion/android/developer/DevPlaygroundActivity.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
2020
import androidx.compose.ui.tooling.preview.Preview
2121
import androidx.compose.ui.unit.dp
2222
import io.homeassistant.companion.android.barcode.BarcodeScannerActivity
23+
import io.homeassistant.companion.android.common.util.FailFast
2324
import io.homeassistant.companion.android.settings.SettingsActivity
2425
import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme
2526

@@ -75,6 +76,13 @@ private fun DevPlayGroundScreen(context: Context? = null) {
7576
}) {
7677
Text("Start barcode")
7778
}
79+
Button(modifier = Modifier.padding(top = 16.dp), onClick = {
80+
FailFast.failWhen(true) {
81+
"This should stop the process."
82+
}
83+
}) {
84+
Text("Fail fast")
85+
}
7886
}
7987
}
8088

app/src/full/kotlin/io/homeassistant/companion/android/location/HighAccuracyLocationService.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.google.android.gms.location.LocationServices
2121
import com.google.android.gms.location.Priority
2222
import io.homeassistant.companion.android.common.R as commonR
2323
import io.homeassistant.companion.android.common.util.CHANNEL_HIGH_ACCURACY
24+
import io.homeassistant.companion.android.common.util.FailFast
2425
import io.homeassistant.companion.android.sensors.LocationSensorManager
2526
import io.homeassistant.companion.android.util.ForegroundServiceLauncher
2627
import kotlin.math.abs
@@ -148,7 +149,11 @@ class HighAccuracyLocationService : Service() {
148149
notification = notificationBuilder.build()
149150

150151
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) FOREGROUND_SERVICE_TYPE_LOCATION else 0
151-
LAUNCHER.onServiceCreated(this, notificationId, notification, type)
152+
FailFast.failOnCatch {
153+
// Sometimes the service cannot be started as foreground due to the app being in a state where
154+
// this is not allowed. We haven't identified how to avoid starting the service in this state yet.
155+
LAUNCHER.onServiceCreated(this, notificationId, notification, type)
156+
}
152157

153158
Timber.d("High accuracy location service created -> onCreate")
154159
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package io.homeassistant.companion.android.common.util
2+
3+
val DefaultFailFastHandler = CrashFailFastHandler

common/src/main/kotlin/io/homeassistant/companion/android/common/data/servers/ServerManagerImpl.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import io.homeassistant.companion.android.common.data.servers.ServerManager.Comp
1111
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository
1212
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepositoryFactory
1313
import io.homeassistant.companion.android.common.data.wifi.WifiHelper
14+
import io.homeassistant.companion.android.common.util.FailFast
1415
import io.homeassistant.companion.android.database.sensor.SensorDao
1516
import io.homeassistant.companion.android.database.server.Server
1617
import io.homeassistant.companion.android.database.server.ServerDao
@@ -89,7 +90,12 @@ class ServerManagerImpl @Inject constructor(
8990
mutableServers.values.any {
9091
it.type == ServerType.DEFAULT &&
9192
it.connection.isRegistered() &&
92-
authenticationRepository(it.id).getSessionState() == SessionState.CONNECTED
93+
FailFast.failOnCatch(
94+
message = {
95+
"""Failed to get authenticationRepository for ${it.id}. Current repository ids: ${authenticationRepos.keys}."""
96+
},
97+
fallback = false,
98+
) { authenticationRepository(it.id).getSessionState() == SessionState.CONNECTED }
9399
}
94100

95101
override suspend fun addServer(server: Server): Int {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package io.homeassistant.companion.android.common.util
2+
3+
import io.homeassistant.companion.android.common.util.FailFast.setHandler
4+
5+
/**
6+
* A handler for [FailFast] exceptions.
7+
*
8+
* Implement this interface to define a custom handler if you want to do something different than [DefaultFailFastHandler].
9+
* Don't forget to register the handler in [FailFast.setHandler].
10+
*/
11+
interface FailFastHandler {
12+
fun handleException(exception: Exception, additionalMessage: String? = null)
13+
}
14+
15+
private class FailFastException : Exception {
16+
constructor(message: String) : super(message)
17+
constructor(exception: Throwable) : super(exception)
18+
19+
init {
20+
// We remove any reference to FailFast from the stack trace to make it easier to find the root cause
21+
stackTrace = stackTrace.filterNot { it.className == FailFast::class.java.name }.toTypedArray()
22+
}
23+
}
24+
25+
/**
26+
* A utility object for implementing the "fail fast" [principle](https://en.wikipedia.org/wiki/Fail-fast_system).
27+
*
28+
* This object provides methods to check conditions and handle exceptions,
29+
* allowing to identify and address issues sooner in the development lifecycle.
30+
*
31+
* By default, it uses [DefaultFailFastHandler] to log exceptions. This behavior can be
32+
* customized by providing a different [FailFastHandler] implementation using [setHandler].
33+
*
34+
* [DefaultFailFastHandler] behavior is different based on the build target debug or release.
35+
*/
36+
object FailFast {
37+
private var handler: FailFastHandler = DefaultFailFastHandler
38+
39+
/**
40+
* Sets a custom [FailFastHandler] to handle exceptions.
41+
*
42+
* This method allows you to define how FailFast should handle exceptions
43+
* by providing your own implementation of the [FailFastHandler] interface.
44+
*
45+
* @param handler The [FailFastHandler] to use for handling exceptions.
46+
*/
47+
fun setHandler(handler: FailFastHandler) {
48+
this.handler = handler
49+
}
50+
51+
/**
52+
* Checks a condition and triggers the configured [FailFastHandler] if the condition is true.
53+
*
54+
* This method is used to assert conditions that should not occur during normal execution.
55+
* If the `condition` evaluates to `true`, a [FailFastException] is created with the provided
56+
* `message` and passed to the [FailFastHandler].
57+
*
58+
* @param condition The boolean condition to check. If true, the handler is triggered.
59+
* @param message A lambda function that returns the message for the [FailFastException].
60+
* This is evaluated only if the condition is true.
61+
*/
62+
fun failWhen(condition: Boolean, message: () -> String) {
63+
if (condition) {
64+
handler.handleException(FailFastException(message()))
65+
}
66+
}
67+
68+
/**
69+
* Executes a block of code and triggers the configured [FailFastHandler] if an exception occurs.
70+
*
71+
* This method is a convenience wrapper around a try-catch block. If an exception is caught
72+
* during the execution of the `block`, a [FailFastException] is created wrapping the original
73+
* exception and passed to the [FailFastHandler] along with an optional `message`.
74+
*
75+
* @param message A lambda function that returns an optional additional message for the handler.
76+
* Defaults to a lambda returning `null`.
77+
* @param block The block of code to execute.
78+
*/
79+
fun failOnCatch(message: () -> String? = { null }, block: () -> Unit) {
80+
failOnCatch<Unit>(message, Unit, block)
81+
}
82+
83+
/**
84+
* Executes a block of code that returns a value and triggers the configured [FailFastHandler] if an exception occurs, returning a fallback value.
85+
*
86+
* This method is similar to the other `failOnCatch` but is designed for blocks of code that return a value.
87+
* If an exception is caught during the execution of the `block`, a [FailFastException] is created
88+
* wrapping the original exception and passed to the [FailFastHandler] along with an optional `message`.
89+
* The `fallback` value is then returned if the handler did not kill the process.
90+
*
91+
* @param T The type of the value returned by the `block` and the `fallback` value.
92+
* @param message A lambda function that returns an optional additional message for the handler.
93+
* Defaults to a lambda returning `null`.
94+
* @param fallback The value to return if an exception occurs.
95+
* @param block The block of code to execute, which is expected to return a value of type `T`.
96+
* @return The result of the `block` execution, or the `fallback` value if an exception occurs.
97+
*/
98+
fun <T> failOnCatch(message: () -> String? = { null }, fallback: T, block: () -> T): T {
99+
return try {
100+
block()
101+
} catch (e: Throwable) {
102+
handler.handleException(FailFastException(e), message())
103+
fallback
104+
}
105+
}
106+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package io.homeassistant.companion.android.common.util
2+
3+
import kotlin.system.exitProcess
4+
import timber.log.Timber
5+
6+
private const val HEADER = """
7+
██████████████████████
8+
!!! CRITICAL FAILURE: FAIL-FAST !!!
9+
██████████████████████
10+
"""
11+
12+
private const val SEPARATOR = """----------------------------------------------------------------"""
13+
14+
object CrashFailFastHandler : FailFastHandler {
15+
override fun handleException(exception: Exception, additionalMessage: String?) {
16+
Timber.e(
17+
exception,
18+
buildString {
19+
appendLine(HEADER.trimIndent())
20+
appendLine()
21+
appendLine(
22+
"""
23+
An unrecoverable error has occurred, and the FailFast mechanism
24+
has been triggered. The application cannot continue and will now exit.
25+
26+
ACTION REQUIRED: This error must be investigated and resolved.
27+
Review the accompanying stack trace for details.
28+
""".trimIndent(),
29+
)
30+
appendLine(SEPARATOR)
31+
appendLine()
32+
additionalMessage?.let {
33+
appendLine(it)
34+
appendLine(SEPARATOR)
35+
appendLine()
36+
}
37+
},
38+
)
39+
exitProcess(1)
40+
}
41+
}
42+
43+
object LogOnlyFailFastHandler : FailFastHandler {
44+
override fun handleException(exception: Exception, additionalMessage: String?) {
45+
Timber.e(
46+
exception,
47+
buildString {
48+
appendLine(HEADER.trimIndent())
49+
appendLine()
50+
appendLine(
51+
"""
52+
The error has been ignored to avoid a crash, but it should be handled.
53+
Please create a bug report at https://github.com/home-assistant/android/issues/new.
54+
""".trimIndent(),
55+
)
56+
appendLine()
57+
additionalMessage?.let {
58+
appendLine(it)
59+
appendLine(SEPARATOR)
60+
appendLine()
61+
}
62+
},
63+
)
64+
// no-op
65+
}
66+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package io.homeassistant.companion.android.common.util
2+
3+
val DefaultFailFastHandler = LogOnlyFailFastHandler
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package io.homeassistant.companion.android.common.util
2+
3+
import org.junit.jupiter.api.AfterEach
4+
import org.junit.jupiter.api.Assertions.assertEquals
5+
import org.junit.jupiter.api.BeforeEach
6+
import org.junit.jupiter.api.Test
7+
import org.junit.jupiter.api.assertNull
8+
9+
class FailFastTest {
10+
private var exceptionCaught: Exception? = null
11+
12+
@BeforeEach
13+
fun setUp() {
14+
FailFast.setHandler(object : FailFastHandler {
15+
override fun handleException(exception: Exception, additionalMessage: String?) {
16+
exceptionCaught = exception
17+
}
18+
})
19+
}
20+
21+
@AfterEach
22+
fun tearDown() {
23+
exceptionCaught = null
24+
}
25+
26+
@Test
27+
fun `Given a throw when invoking failOnCatch then properly propagate the error with custom stacktrace`() {
28+
FailFast.failOnCatch {
29+
throw IllegalArgumentException("expected error")
30+
}
31+
32+
assertEquals("java.lang.IllegalArgumentException: expected error", exceptionCaught?.message)
33+
assertEquals(FailFastTest::class.java.name, exceptionCaught?.stackTrace?.first()?.className)
34+
}
35+
36+
@Test
37+
fun `Given a throw when invoking failOnCatch then properly propagate the error and returns fallback value`() {
38+
val returnedValue = FailFast.failOnCatch<Int>(fallback = 42) {
39+
throw IllegalArgumentException("expected error")
40+
}
41+
assertEquals("java.lang.IllegalArgumentException: expected error", exceptionCaught?.message)
42+
assertEquals(42, returnedValue)
43+
}
44+
45+
@Test
46+
fun `Given no throw when invoking failOnCatch then returns value`() {
47+
val returnedValue = FailFast.failOnCatch<Int>(fallback = 0) {
48+
42
49+
}
50+
assertNull(exceptionCaught)
51+
assertEquals(42, returnedValue)
52+
}
53+
54+
@Test
55+
fun `Given condition not met when invoking failWhen then returns value`() {
56+
FailFast.failWhen(false) { "fail" }
57+
assertNull(exceptionCaught)
58+
}
59+
60+
@Test
61+
fun `Given condition met when invoking failWhen then properly propagate the error`() {
62+
FailFast.failWhen(true) { "expected error" }
63+
assertEquals("expected error", exceptionCaught?.message)
64+
}
65+
}

0 commit comments

Comments
 (0)