Skip to content

Commit 28a97a7

Browse files
Add strict mode in sample app and call lifecycle method on main thread (#36)
1 parent 35e43fa commit 28a97a7

File tree

6 files changed

+929
-2
lines changed

6 files changed

+929
-2
lines changed

app/src/main/java/io/getstream/android/core/sample/SampleApp.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package io.getstream.android.core.sample
1919
import android.annotation.SuppressLint
2020
import android.app.Application
2121
import android.os.Build
22+
import android.os.StrictMode
2223
import io.getstream.android.core.api.StreamClient
2324
import io.getstream.android.core.api.authentication.StreamTokenProvider
2425
import io.getstream.android.core.api.model.config.StreamClientSerializationConfig
@@ -49,6 +50,10 @@ class SampleApp : Application() {
4950
@SuppressLint("NotKeepingInstance")
5051
override fun onCreate() {
5152
super.onCreate()
53+
StrictMode.setThreadPolicy(
54+
StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build()
55+
)
56+
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder().detectAll().penaltyLog().build())
5257
instance = this
5358
streamClient =
5459
StreamClient(
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/*
2+
* Copyright (c) 2014-2025 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-core-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.android.core.api.utils
18+
19+
import android.os.Handler
20+
import android.os.Looper
21+
import io.getstream.android.core.annotations.StreamInternalApi
22+
import java.util.concurrent.CountDownLatch
23+
import java.util.concurrent.TimeUnit
24+
25+
/**
26+
* Executes the given [block] on the main (UI) thread and returns the result.
27+
*
28+
* This function provides thread-safe access to the main looper with proper synchronization:
29+
* - If already on the main thread, executes [block] immediately
30+
* - If on a different thread, posts to the main looper and blocks the caller until completion
31+
* - Returns a [Result] that captures success values or exceptions from [block]
32+
*
33+
* ### Thread Safety
34+
* This function is **blocking** for the calling thread when switching threads. It uses a
35+
* [CountDownLatch] to wait for the main thread to complete execution before returning.
36+
*
37+
* ### Timeout
38+
* If the main thread does not execute [block] within **5 seconds**, this function throws
39+
* [IllegalStateException]. This prevents indefinite blocking if the main thread is stuck.
40+
*
41+
* ### Exception Handling
42+
* - Exceptions thrown by [block] are captured and returned as `Result.failure`
43+
* - [CancellationException][kotlin.coroutines.cancellation.CancellationException] is rethrown to
44+
* preserve coroutine cancellation semantics
45+
* - If the main looper is not initialized, throws [IllegalStateException]
46+
*
47+
* ### Use Cases
48+
* - Updating UI components from background threads
49+
* - Adding/removing lifecycle observers (requires main thread)
50+
* - Accessing View properties that must be read on the main thread
51+
* - Synchronizing with main thread state before proceeding
52+
*
53+
* ### Example Usage
54+
*
55+
* ```kotlin
56+
* // From a background thread, safely add a lifecycle observer
57+
* runOnMainLooper {
58+
* lifecycle.addObserver(observer)
59+
* }.onFailure { error ->
60+
* logger.e(error) { "Failed to add lifecycle observer" }
61+
* }
62+
*
63+
* // Get a value from the UI thread
64+
* val result = runOnMainLooper {
65+
* view.width
66+
* }.getOrNull()
67+
*
68+
* // Execute multiple UI operations atomically
69+
* runOnMainLooper {
70+
* textView.text = "Loading..."
71+
* progressBar.visibility = View.VISIBLE
72+
* }
73+
* ```
74+
*
75+
* @param T the return type of [block]
76+
* @param block the code to execute on the main thread
77+
* @return [Result.success] with the return value of [block], or [Result.failure] if an exception
78+
* was thrown
79+
* @throws IllegalStateException if the main looper is not initialized or if execution times out
80+
* @see runOn for executing on a custom [Looper]
81+
*/
82+
@StreamInternalApi
83+
public inline fun <T> runOnMainLooper(crossinline block: () -> T): Result<T> =
84+
runCatchingCancellable {
85+
val mainLooper =
86+
Looper.getMainLooper() ?: throw IllegalStateException("Main looper is not initialized")
87+
runOn(mainLooper, block).getOrThrow()
88+
}
89+
90+
/**
91+
* Executes the given [block] on the specified [looper]'s thread and returns the result.
92+
*
93+
* This is a generalized version of [runOnMainLooper] that works with any [Looper]:
94+
* - If already on the target looper's thread, executes [block] immediately
95+
* - If on a different thread, posts to the target looper and blocks the caller until completion
96+
* - Returns a [Result] that captures success values or exceptions from [block]
97+
*
98+
* ### Thread Safety
99+
* This function is **blocking** for the calling thread when switching threads. It uses a
100+
* [CountDownLatch] to wait for the target looper's thread to complete execution before returning.
101+
*
102+
* ### Timeout
103+
* If the target looper does not execute [block] within **5 seconds**, this function throws
104+
* [IllegalStateException]. This prevents indefinite blocking if the target thread is stuck or the
105+
* looper is not running.
106+
*
107+
* ### Exception Handling
108+
* - Exceptions thrown by [block] are captured and returned as `Result.failure`
109+
* - [CancellationException][kotlin.coroutines.cancellation.CancellationException] is rethrown to
110+
* preserve coroutine cancellation semantics
111+
*
112+
* ### Use Cases
113+
* - Executing code on a custom [HandlerThread][android.os.HandlerThread]'s looper
114+
* - Synchronizing operations across multiple threads with known loopers
115+
* - Testing thread-specific behavior with custom test loopers
116+
*
117+
* ### Example Usage
118+
*
119+
* ```kotlin
120+
* // Execute on a custom background thread
121+
* val handlerThread = HandlerThread("worker").apply { start() }
122+
* val workerLooper = handlerThread.looper
123+
*
124+
* runOn(workerLooper) {
125+
* // This code runs on the worker thread
126+
* performExpensiveOperation()
127+
* }.onSuccess { result ->
128+
* println("Operation completed with result: $result")
129+
* }
130+
*
131+
* // Verify we're on the correct thread
132+
* val isCorrectThread = runOn(targetLooper) {
133+
* Looper.myLooper() == targetLooper
134+
* }.getOrDefault(false)
135+
*
136+
* // Chain thread operations
137+
* runOn(backgroundLooper) {
138+
* val data = fetchData()
139+
* runOnMainLooper {
140+
* updateUI(data)
141+
* }
142+
* }
143+
* ```
144+
*
145+
* ### Performance Notes
146+
* - When already on the target looper's thread, there is minimal overhead (just a looper check)
147+
* - When switching threads, the calling thread blocks until execution completes
148+
* - Consider using coroutines with appropriate dispatchers for non-blocking alternatives
149+
*
150+
* @param T the return type of [block]
151+
* @param looper the [Looper] on whose thread to execute [block]
152+
* @param block the code to execute on the looper's thread
153+
* @return [Result.success] with the return value of [block], or [Result.failure] if an exception
154+
* was thrown
155+
* @throws IllegalStateException if execution times out after 5 seconds
156+
* @see runOnMainLooper for a convenience function that always uses the main looper
157+
*/
158+
@StreamInternalApi
159+
public inline fun <T> runOn(looper: Looper, crossinline block: () -> T): Result<T> {
160+
return runCatchingCancellable {
161+
if (Looper.myLooper() == looper) {
162+
block()
163+
} else {
164+
val latch = CountDownLatch(1)
165+
var result: Result<T>? = null
166+
Handler(looper).post {
167+
try {
168+
result = Result.success(block())
169+
} catch (t: Throwable) {
170+
result = Result.failure(t)
171+
} finally {
172+
latch.countDown()
173+
}
174+
}
175+
176+
if (!latch.await(5, TimeUnit.SECONDS)) {
177+
throw IllegalStateException("Timed out waiting to post to main thread")
178+
}
179+
result!!.getOrThrow()
180+
}
181+
}
182+
}

stream-android-core/src/main/java/io/getstream/android/core/internal/observers/lifecycle/StreamLifecycleMonitorImpl.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import io.getstream.android.core.api.observers.lifecycle.StreamLifecycleListener
2525
import io.getstream.android.core.api.observers.lifecycle.StreamLifecycleMonitor
2626
import io.getstream.android.core.api.subscribe.StreamSubscription
2727
import io.getstream.android.core.api.subscribe.StreamSubscriptionManager
28+
import io.getstream.android.core.api.utils.runOnMainLooper
2829
import java.util.concurrent.atomic.AtomicBoolean
2930
import kotlinx.coroutines.ExperimentalCoroutinesApi
3031

@@ -45,14 +46,14 @@ internal class StreamLifecycleMonitorImpl(
4546
if (!started.compareAndSet(false, true)) {
4647
return@runCatching
4748
}
48-
lifecycle.addObserver(this)
49+
runOnMainLooper { lifecycle.addObserver(this) }.getOrThrow()
4950
}
5051

5152
override fun stop(): Result<Unit> = runCatching {
5253
if (!started.compareAndSet(true, false)) {
5354
return@runCatching
5455
}
55-
lifecycle.removeObserver(this)
56+
runOnMainLooper { lifecycle.removeObserver(this) }.getOrThrow()
5657
}
5758

5859
override fun onResume(owner: LifecycleOwner) {

stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
package io.getstream.android.core.api
2020

21+
import androidx.lifecycle.Lifecycle
22+
import androidx.lifecycle.LifecycleObserver
2123
import io.getstream.android.core.annotations.StreamInternalApi
2224
import io.getstream.android.core.api.authentication.StreamTokenManager
2325
import io.getstream.android.core.api.authentication.StreamTokenProvider
@@ -29,6 +31,7 @@ import io.getstream.android.core.api.model.config.StreamSocketConfig
2931
import io.getstream.android.core.api.model.connection.StreamConnectionState
3032
import io.getstream.android.core.api.model.value.StreamApiKey
3133
import io.getstream.android.core.api.model.value.StreamHttpClientInfoHeader
34+
import io.getstream.android.core.api.model.value.StreamToken
3235
import io.getstream.android.core.api.model.value.StreamUserId
3336
import io.getstream.android.core.api.model.value.StreamWsUrl
3437
import io.getstream.android.core.api.observers.lifecycle.StreamLifecycleMonitor
@@ -53,6 +56,7 @@ import io.getstream.android.core.internal.http.interceptor.StreamEndpointErrorIn
5356
import io.getstream.android.core.internal.serialization.StreamCompositeEventSerializationImpl
5457
import io.getstream.android.core.internal.socket.StreamSocketSession
5558
import io.getstream.android.core.internal.socket.StreamWebSocketImpl
59+
import io.getstream.android.core.internal.subscribe.StreamSubscriptionManagerImpl
5660
import io.getstream.android.core.testutil.assertFieldEquals
5761
import io.getstream.android.core.testutil.readPrivateField
5862
import io.mockk.mockk
@@ -288,4 +292,65 @@ internal class StreamClientFactoryTest {
288292
// Then
289293
assertEquals(listOf(customInterceptor), builder.interceptors())
290294
}
295+
296+
@Test
297+
fun `StreamClient factory default subscription manager listener limits`() {
298+
val context = mockk<android.content.Context>(relaxed = true)
299+
val fakeAndroidComponents =
300+
object : io.getstream.android.core.api.components.StreamAndroidComponentsProvider {
301+
override fun connectivityManager(): Result<android.net.ConnectivityManager> =
302+
Result.success(mockk(relaxed = true))
303+
304+
override fun wifiManager(): Result<android.net.wifi.WifiManager> =
305+
Result.success(mockk(relaxed = true))
306+
307+
override fun telephonyManager(): Result<android.telephony.TelephonyManager> =
308+
Result.success(mockk(relaxed = true))
309+
310+
override fun lifecycle(): Lifecycle =
311+
object : Lifecycle() {
312+
override fun addObserver(observer: LifecycleObserver) {}
313+
314+
override fun removeObserver(observer: LifecycleObserver) {}
315+
316+
override val currentState: Lifecycle.State
317+
get() = Lifecycle.State.CREATED
318+
}
319+
}
320+
val tokenProvider =
321+
object : StreamTokenProvider {
322+
override suspend fun loadToken(userId: StreamUserId): StreamToken =
323+
StreamToken.fromString("token")
324+
}
325+
326+
val client =
327+
StreamClient(
328+
scope = testScope,
329+
context = context,
330+
apiKey = StreamApiKey.fromString("key123"),
331+
userId = StreamUserId.fromString("user-123"),
332+
wsUrl = StreamWsUrl.fromString("wss://test.stream/video"),
333+
products = listOf("feeds"),
334+
clientInfoHeader =
335+
StreamHttpClientInfoHeader.create(
336+
product = "android",
337+
productVersion = "1.0",
338+
os = "android",
339+
apiLevel = 33,
340+
deviceModel = "Pixel",
341+
app = "test-app",
342+
appVersion = "1.0.0",
343+
),
344+
tokenProvider = tokenProvider,
345+
serializationConfig = serializationConfig,
346+
androidComponentsProvider = fakeAndroidComponents,
347+
logProvider = logProvider,
348+
)
349+
350+
val manager =
351+
(client as StreamClientImpl<*>).readPrivateField("subscriptionManager")
352+
as StreamSubscriptionManagerImpl<*>
353+
manager.assertFieldEquals("maxStrongSubscriptions", 250)
354+
manager.assertFieldEquals("maxWeakSubscriptions", 250)
355+
}
291356
}

0 commit comments

Comments
 (0)