|
| 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 | +} |
0 commit comments