Skip to content

getStringOrNullFlow() may cause ANR on main thread despite immediate value expected #233

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
AseevEIDev opened this issue May 16, 2025 · 2 comments

Comments

@AseevEIDev
Copy link

Hi, I'm encountering unexpected behaviour with getStringOrNullFlow() that can lead to an ANR (Application Not Responding) when calling firstOrNull() from the main thread.

In our case, we use SharedPreferencesSettings to wrap Android's SharedPreferences, created like this:

SharedPreferencesSettings(
    context.getSharedPreferences(name, Context.MODE_PRIVATE),
    commit = true
)

We expect getStringOrNullFlow() to emit the current cached value immediately, which is important for certain use cases — especially when we want to synchronously grab a value on the main thread to skip loading states (e.g., showing a UI screen directly if a value already exists).

However, calling:

settings.getStringOrNullFlow("some_key").firstOrNull()

on the main thread can hang and eventually trigger an ANR, despite the fact that the flow should emit immediately via callbackFlow.

Crashlytics stacktrace:

main (runnable):tid=1 systid=16833 
       at kotlin.coroutines.CombinedContext.get(CoroutineContextImpl.kt:120)
       at kotlinx.coroutines.AbstractCoroutine.<init>(AbstractCoroutine.kt:50)
       at kotlinx.coroutines.internal.ScopeCoroutine.<init>(Scopes.kt:14)
       at kotlinx.coroutines.CoroutineScopeKt.coroutineScope(CoroutineScope.kt:285)
       at kotlinx.coroutines.flow.internal.ChannelFlow.collect$suspendImpl(ChannelFlow.kt:118)
       at kotlinx.coroutines.flow.internal.ChannelFlow.collect(ChannelFlow.kt:6)
       at kotlinx.coroutines.flow.DistinctFlowImpl.collect(Distinct.kt:68)
       at kotlinx.coroutines.flow.FlowKt__ReduceKt.firstOrNull(FlowKt__Reduce.kt:230)
       at kotlinx.coroutines.flow.FlowKt.firstOrNull(Flow.kt:1)
       at com.token.data.TokenRepositoryImpl.getSelectedToken(TokenRepositoryImpl.kt:64)
       at com.token.data.TokenRepository$DefaultImpls.getSelectedToken$default(TokenRepository.java:21)
       at com.transfer.domain.TransferChannelInteractorImpl.loadChannelWithDetailsForCountry(TransferChannelInteractorImpl.kt:42)
       at com.transfer.create.TopupCreateViewModel.getChannel(TopupCreateViewModel.kt:148)
       at com.transfer.create.TopupCreateViewModel.access$getChannel(TopupCreateViewModel.kt:38)
       at com.transfer.create.TopupCreateViewModel$observeQuote$1$invokeSuspend$$inlined$flatMapLatest$1.invokeSuspend(Merge.kt:196)
       at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
       at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:232)
       at android.os.Handler.handleCallback(Handler.java:991)
       at android.os.Handler.dispatchMessage(Handler.java:102)
       at android.os.Looper.loopOnce(Looper.java:232)
       at android.os.Looper.loop(Looper.java:317)
       at android.app.ActivityThread.main(ActivityThread.java:8934)
       at java.lang.reflect.Method.invoke(Native method)
       at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:591)
       at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:911)

Of course we can use settings.getStringOrNull("some_key") and it solves an issue, but it's not always convenient.

⚙️ Versions used
Kotlin: 2.1.10
Coroutines: 1.10.2
Settings: 1.3.0

@russhwolf
Copy link
Owner

It's hard for me to know what to do here without a reproducer. The stack trace also doesn't include anything from Multiplatform Settings. The first emission from getStringOrNullFlow() is getStringOrNull() so it surprises me that you would see different behavior.

One guess is maybe the issue is from commit = true which will block the calling thread. But I still wouldn't expect this to block for a significant amount of time. Do you have any metrics for how long you're blocking for when the ANR is triggered? Also, how often is this happening?

@AseevEIDev
Copy link
Author

I've managed to reproduce it only once. The app has been blocked completely (was waiting for 30 sec) and crashlytics logs are from my Pixel 8 on Android 15. But I have 5 other reports in Firebase Crashlytics.

I checked the codebase before creating the ticket and didn't find anything suspicious.

callbackFlow {
    send(getter(key, defaultValue))
    ...
}

I see that the flow is cold and it should emit the value on subscribe. So I don't understand how this flow could skip the initial value. I've also assumed that commit = true can be a reason, but I would expect it to freeze the app on write operation (not read).

At the moment I changed the logic to using getStringOrNull() which fixes my case.
So, if you also don't have a clue, I'll continue trying to reproduce this ANR locally and find the reason.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants