Skip to content

Commit 6983ad5

Browse files
Improve IO Exception on interceptors improve linter about asStateFlow
1 parent 845bbf6 commit 6983ad5

File tree

5 files changed

+48
-23
lines changed

5 files changed

+48
-23
lines changed

stream-android-core-lint/src/main/java/io/getstream/android/core/lint/detectors/ExposeAsStateFlowDetector.kt

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,23 +51,23 @@ class ExposeAsStateFlowDetector : Detector(), Detector.UastScanner {
5151
object : UElementHandler() {
5252

5353
override fun visitField(node: UField) {
54-
// Kotlin 'val state: StateFlow<T> = ...' compiles to a field with initializer
54+
// Kotlin `val state: StateFlow<T> = ...` compiles to a field with initializer
5555
checkPropertyInitializer(context, node, node.type, node.uastInitializer, node)
5656
}
5757

5858
override fun visitVariable(node: UVariable) {
59-
// top-level Kotlin 'val state: StateFlow<T> = ...'
59+
// top-level Kotlin `val state: StateFlow<T> = ...`
6060
checkPropertyInitializer(context, node, node.type, node.uastInitializer, node)
6161
}
6262

6363
override fun visitMethod(node: UMethod) {
6464
// Kotlin property getter or Java getter
65-
// Only consider non-private, returning StateFlow<*>
6665
if (!isApiVisible(node)) return
6766
val returnType = node.returnType ?: return
6867
if (!isStateFlowType(context, returnType)) return
6968

7069
val returnExpr = findReturnExpression(node) ?: return
70+
7171
// Ignore already-safe: receiver.asStateFlow()
7272
if (isAlreadyAsStateFlowCall(returnExpr)) return
7373

@@ -153,16 +153,26 @@ class ExposeAsStateFlowDetector : Detector(), Detector.UastScanner {
153153
}
154154

155155
private fun isAlreadyAsStateFlowCall(expr: UExpression): Boolean {
156-
// Match: <receiver>.asStateFlow()
157-
val call = expr as? UCallExpression ?: return false
158-
val name = call.methodName ?: return false
159-
return name == "asStateFlow" && call.receiver != null
156+
// Match: <receiver>.asStateFlow() or (<receiver>)?.asStateFlow()
157+
return when (expr) {
158+
is UCallExpression -> expr.methodName == "asStateFlow"
159+
is UQualifiedReferenceExpression -> {
160+
val call = expr.selector as? UCallExpression
161+
call?.methodName == "asStateFlow"
162+
}
163+
else -> false
164+
}
160165
}
161166

162167
private fun isClearlyMutableQualified(expr: UExpression, context: JavaContext): Boolean {
163168
// Qualified reference like holder._state, where receiver or selector type is
164169
// MutableStateFlow
165170
val qualified = expr as? UQualifiedReferenceExpression ?: return false
171+
172+
// If this is X.asStateFlow(), it's already safe → don't mark as mutable
173+
val selCall = qualified.selector as? UCallExpression
174+
if (selCall?.methodName == "asStateFlow") return false
175+
166176
val selType = qualified.selector.getExpressionType()
167177
val recvType = qualified.receiver.getExpressionType()
168178
return (selType != null && isMutableStateFlowType(context, selType)) ||
@@ -186,12 +196,12 @@ class ExposeAsStateFlowDetector : Detector(), Detector.UastScanner {
186196
}
187197

188198
/**
189-
* Extracts the returned expression from a getter or method. Handles both expression bodies and
190-
* block bodies.
199+
* Extracts the returned expression from a getter or method. Handles both block bodies (`return
200+
* expr`) and expression-bodied getters (`get() = expr`).
191201
*/
192202
private fun findReturnExpression(method: UMethod): UExpression? {
193203
val body = method.uastBody ?: return null
194-
when (body) {
204+
return when (body) {
195205
is UBlockExpression -> {
196206
var last: UExpression? = null
197207
body.accept(
@@ -202,10 +212,10 @@ class ExposeAsStateFlowDetector : Detector(), Detector.UastScanner {
202212
}
203213
}
204214
)
205-
return last
215+
last
206216
}
217+
else -> body as? UExpression // expression-bodied getter: get() = _state.asStateFlow()
207218
}
208-
return null
209219
}
210220

211221
companion object {

stream-android-core/src/main/java/io/getstream/android/core/api/model/exceptions/Exceptions.kt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
package io.getstream.android.core.api.model.exceptions
1717

1818
import io.getstream.android.core.annotations.StreamCoreApi
19-
import okio.IOException
19+
import java.io.IOException
2020

2121
/**
2222
* Base exception for all Stream client errors.
@@ -39,10 +39,14 @@ open class StreamClientException(message: String = "", cause: Throwable? = null)
3939
* @property message The error message describing the failure.
4040
* @property apiError The structured error response returned by the Stream API, if available, or
4141
* `null` otherwise.
42+
* @property cause The original exception that caused this error, if available, or `null` otherwise.
4243
*/
4344
@StreamCoreApi
44-
class StreamEndpointException(message: String = "", val apiError: StreamEndpointErrorData?) :
45-
IOException(message, null)
45+
class StreamEndpointException(
46+
message: String = "",
47+
val apiError: StreamEndpointErrorData? = null,
48+
cause: Throwable? = null,
49+
) : IOException(message, cause)
4650

4751
/**
4852
* Exception representing multiple errors that occurred during a single operation.

stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import io.getstream.android.core.internal.socket.model.ConnectUserData
3636
import kotlinx.coroutines.CoroutineScope
3737
import kotlinx.coroutines.flow.MutableStateFlow
3838
import kotlinx.coroutines.flow.StateFlow
39+
import kotlinx.coroutines.flow.asStateFlow
3940
import kotlinx.coroutines.flow.update
4041

4142
internal class StreamClientImpl(
@@ -58,7 +59,7 @@ internal class StreamClientImpl(
5859

5960
private var handle: StreamSubscription? = null
6061
override val connectionState: StateFlow<StreamConnectionState>
61-
get() = mutableConnectionState
62+
get() = mutableConnectionState.asStateFlow()
6263

6364
override fun subscribe(listener: StreamClientListener): Result<StreamSubscription> =
6465
subscriptionManager.subscribe(listener)

stream-android-core/src/main/java/io/getstream/android/core/internal/http/interceptor/StreamApiKeyInterceptor.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616
package io.getstream.android.core.internal.http.interceptor
1717

18-
import io.getstream.android.core.api.model.exceptions.StreamClientException
18+
import io.getstream.android.core.api.model.exceptions.StreamEndpointException
1919
import io.getstream.android.core.api.model.value.StreamApiKey
2020
import okhttp3.Interceptor
2121
import okhttp3.Response
@@ -42,7 +42,7 @@ internal class StreamApiKeyInterceptor(private val apiKey: StreamApiKey) : Inter
4242

4343
if (apiKey.rawValue.isBlank()) {
4444
// StreamApiKey is self enforced via the fromString, but still we are defensive here
45-
throw StreamClientException("API key must not be blank!")
45+
throw StreamEndpointException("API key must not be blank!")
4646
}
4747

4848
val urlWithApiKey =

stream-android-core/src/main/java/io/getstream/android/core/internal/http/interceptor/StreamAuthInterceptor.kt

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ internal class StreamAuthInterceptor(
6161
}
6262

6363
override fun intercept(chain: Interceptor.Chain): Response {
64-
val token = runBlocking { tokenManager.loadIfAbsent() }.getOrThrow()
64+
val token =
65+
runBlocking { tokenManager.loadIfAbsent() }
66+
.getOrEndpointException("Failed to load token.")
6567
val original = chain.request()
6668
val authed = original.withAuthHeaders(authType, token.rawValue)
6769

@@ -77,12 +79,16 @@ internal class StreamAuthInterceptor(
7779
val alreadyRetried = original.header(HEADER_RETRIED_ON_AUTH) == "present"
7880

7981
if (parsed.isSuccess) {
80-
val error = parsed.getOrThrow()
82+
val error = parsed.getOrEndpointException("Failed to parse error body.")
8183
if (!alreadyRetried && isTokenInvalidErrorCode(error.code)) {
8284
// Refresh and retry once.
8385
firstResponse.close()
84-
tokenManager.invalidate().getOrThrow()
85-
val refreshed = runBlocking { tokenManager.refresh() }.getOrThrow()
86+
tokenManager
87+
.invalidate()
88+
.getOrEndpointException(message = "Failed to invalidate token")
89+
val refreshed =
90+
runBlocking { tokenManager.refresh() }
91+
.getOrEndpointException("Failed to refresh token")
8692

8793
val retried =
8894
original
@@ -96,7 +102,7 @@ internal class StreamAuthInterceptor(
96102

97103
// Non-token error or we already retried: surface a structured exception.
98104
firstResponse.close()
99-
throw StreamEndpointException("Failed request: ${original.url}", error)
105+
throw StreamEndpointException("Failed request: ${original.url}", error, null)
100106
} else {
101107
// Couldn’t parse error, still fail in a consistent way.
102108
firstResponse.close()
@@ -114,4 +120,8 @@ internal class StreamAuthInterceptor(
114120
.build()
115121

116122
fun isTokenInvalidErrorCode(code: Int): Boolean = code == 40 || code == 41 || code == 42
123+
124+
private fun <T> Result<T>.getOrEndpointException(message: String = ""): T = getOrElse {
125+
throw StreamEndpointException(message, null, it)
126+
}
117127
}

0 commit comments

Comments
 (0)