-
-
Notifications
You must be signed in to change notification settings - Fork 45
Fix bug in HQ-triggered integrity reporting #3316
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
Conversation
…roid Copy of SQLCipher migration (android-database-sqlcipher -> sqlcipher-android)
…nto commcare_2.59
Refactored IntegrityTokenApiRequestHelper with proper coroutine flow.
📝 WalkthroughWalkthrough
Sequence Diagram(s)sequenceDiagram
autonumber
actor Caller
participant Helper as IntegrityTokenApiRequestHelper
participant VM as IntegrityTokenViewModel
participant Provider as IntegrityTokenProvider
Caller->>Helper: fetchIntegrityToken(requestHash) [suspend]
Helper->>VM: providerStateFlow.first { true }
alt Provider ready (Success)
Helper->>VM: requestIntegrityToken(requestHash) [suspending via suspendCancellableCoroutine]
VM->>Provider: request token
Provider-->>VM: token or error (callback)
alt Token received
VM-->>Helper: token
Helper-->>Caller: Result.success(token, requestHash)
else Token failure
VM-->>Helper: exception
Helper-->>Caller: Result.failure(exception)
end
else Provider failure
VM-->>Helper: state.exception
Helper-->>Caller: Result.failure(exception)
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Tip 🔌 Remote MCP (Model Context Protocol) integration is now available!Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats. ✨ Finishing Touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
🧹 Nitpick comments (1)
app/src/org/commcare/android/integrity/IntegrityTokenApiRequestHelper.kt (1)
121-154: Optional: add a timeout to avoid indefinite suspension if provider never emits.Wrap the
wheninwithTimeout(...)(e.g., 15s) to fail fast under pathological conditions.+import kotlinx.coroutines.withTimeout @@ - return try { - // wait for provider readiness or failure - when (val state = viewModel.providerStateFlow.first()) { + return try { + // wait for provider readiness or failure with timeout safeguard + withTimeout(15_000) { + when (val state = viewModel.providerStateFlow.first()) { ... - } + } + }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (3)
app/AndroidManifest.xml(1 hunks)app/src/org/commcare/android/integrity/IntegrityTokenApiRequestHelper.kt(2 hunks)app/src/org/commcare/android/integrity/IntegrityTokenViewModel.kt(2 hunks)
🔇 Additional comments (2)
app/AndroidManifest.xml (1)
5-5: Confirm versionCode bump or release packaging handles it.You’ve bumped versionName to 2.59.1 but left versionCode at 106. For Play distribution, versionCode must monotonically increase across releases. If 106 was already shipped, this patch build will be rejected unless versionCode is incremented. Please confirm your release flow or bump versionCode accordingly.
app/src/org/commcare/android/integrity/IntegrityTokenViewModel.kt (1)
26-26: Good: encapsulate provider.Making
integrityTokenProviderprivate avoids accidental external use and simplifies the Flow-based API.
| suspend fun fetchIntegrityToken(requestHash: String): Result<Pair<String, String>> { | ||
| val viewModel = CommCareViewModelProvider.getIntegrityTokenViewModel() | ||
|
|
||
| fun requestToken() { | ||
| try { | ||
| viewModel.requestIntegrityToken(requestHash, false, object : IntegrityTokenViewModel.IntegrityTokenCallback { | ||
| override fun onTokenReceived( | ||
| requestHash: String, | ||
| integrityTokenResponse: StandardIntegrityManager.StandardIntegrityToken | ||
| ) { | ||
| cont.resume(Result.success(Pair(integrityTokenResponse.token(), requestHash))) | ||
| } | ||
| override fun onTokenFailure(exception: Exception) { | ||
| cont.resume(Result.failure(exception)) | ||
| } | ||
| }) | ||
| } catch (e: Exception) { | ||
| cont.resume(Result.failure(e)) | ||
| } | ||
| } | ||
|
|
||
| val observer = object : Observer<IntegrityTokenViewModel.TokenProviderState> { | ||
| override fun onChanged(value: IntegrityTokenViewModel.TokenProviderState) { | ||
| when (value) { | ||
| is IntegrityTokenViewModel.TokenProviderState.Success -> { | ||
| viewModel.providerState.removeObserver(this) | ||
| requestToken() | ||
| } | ||
| is IntegrityTokenViewModel.TokenProviderState.Failure -> { | ||
| viewModel.providerState.removeObserver(this) | ||
| cont.resume(Result.failure(value.exception)) | ||
| return try { | ||
| // wait for provider readiness or failure | ||
| when (val state = viewModel.providerStateFlow.first { true }) { | ||
| is IntegrityTokenViewModel.TokenProviderState.Success -> { | ||
| suspendCancellableCoroutine { cont -> | ||
| viewModel.requestIntegrityToken( | ||
| requestHash, | ||
| hasRetried = false, | ||
| object : IntegrityTokenViewModel.IntegrityTokenCallback { | ||
| override fun onTokenReceived( | ||
| requestHash: String, | ||
| integrityTokenResponse: StandardIntegrityManager.StandardIntegrityToken | ||
| ) { | ||
| cont.resume(Result.success(Pair(integrityTokenResponse.token(), requestHash))) | ||
| } | ||
|
|
||
| override fun onTokenFailure(exception: Exception) { | ||
| cont.resume(Result.failure(exception)) | ||
| } | ||
| } | ||
| ) | ||
| } | ||
| } | ||
| is IntegrityTokenViewModel.TokenProviderState.Failure -> { | ||
| Result.failure(state.exception) | ||
| } | ||
| } | ||
| } catch (e: Exception) { | ||
| Result.failure(e) | ||
| } | ||
| viewModel.providerState.observeForever(observer) | ||
| cont.invokeOnCancellation { viewModel.providerState.removeObserver(observer) } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Sanity check: any remaining observeForever usages elsewhere?
Given the original crash context, scan the codebase for lingering observeForever calls in non-UI paths.
🏁 Script executed:
#!/bin/bash
# Find observeForever usages (excluding tests)
rg -nP --type=kt --type=java -g '!**/test/**' '\bobserveForever\s*\(' -C2Length of output: 102
🏁 Script executed:
#!/bin/bash
# Retry searching for observeForever usages without relying on file type detection
rg -n 'observeForever' -g '*.kt' -g '*.java' -g '!**/test/**' -C2Length of output: 1515
Remove or replace remaining observeForever calls outside UI
• app/src/org/commcare/sync/FirebaseMessagingDataSyncer.java (lines 103–107)
• app/src/org/commcare/android/integrity/IntegrityTokenViewModel.kt (lines 92–94)
Use a lifecycle-aware observer or explicitly remove the observers to prevent memory leaks and threading issues.
🤖 Prompt for AI Agents
In app/src/org/commcare/android/integrity/IntegrityTokenApiRequestHelper.kt
around lines 121–154: the reviewer flagged remaining observeForever usages
elsewhere; remove or replace observeForever calls in the codebase (specifically
app/src/org/commcare/sync/FirebaseMessagingDataSyncer.java lines 103–107 and
app/src/org/commcare/android/integrity/IntegrityTokenViewModel.kt lines 92–94).
Fix by either switching to lifecycle-aware observation (observe with a
LifecycleOwner or collect StateFlow/Flow from a lifecycleScope) or, if
observeForever is required, keep a reference to the observer and call
removeObserver(...) when finished; update IntegrityTokenViewModel to expose a
StateFlow/Flow where possible so callers (like this helper or the syncer) can
collect in a lifecycle-aware scope instead of using observeForever.
🛠️ Refactor suggestion
Harden coroutine handoff: use first() and guard single resume.
first { true }is equivalent tofirst(); prefer the simpler call.- Guard continuation to avoid double-resume if callbacks fire more than once or after cancellation.
- when (val state = viewModel.providerStateFlow.first { true }) {
+ when (val state = viewModel.providerStateFlow.first()) {
is IntegrityTokenViewModel.TokenProviderState.Success -> {
- suspendCancellableCoroutine { cont ->
+ suspendCancellableCoroutine { cont ->
viewModel.requestIntegrityToken(
requestHash,
hasRetried = false,
object : IntegrityTokenViewModel.IntegrityTokenCallback {
override fun onTokenReceived(
requestHash: String,
integrityTokenResponse: StandardIntegrityManager.StandardIntegrityToken
) {
- cont.resume(Result.success(Pair(integrityTokenResponse.token(), requestHash)))
+ if (cont.isActive) {
+ cont.resume(Result.success(Pair(integrityTokenResponse.token(), requestHash)))
+ }
}
override fun onTokenFailure(exception: Exception) {
- cont.resume(Result.failure(exception))
+ if (cont.isActive) {
+ cont.resume(Result.failure(exception))
+ }
}
}
)
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| suspend fun fetchIntegrityToken(requestHash: String): Result<Pair<String, String>> { | |
| val viewModel = CommCareViewModelProvider.getIntegrityTokenViewModel() | |
| fun requestToken() { | |
| try { | |
| viewModel.requestIntegrityToken(requestHash, false, object : IntegrityTokenViewModel.IntegrityTokenCallback { | |
| override fun onTokenReceived( | |
| requestHash: String, | |
| integrityTokenResponse: StandardIntegrityManager.StandardIntegrityToken | |
| ) { | |
| cont.resume(Result.success(Pair(integrityTokenResponse.token(), requestHash))) | |
| } | |
| override fun onTokenFailure(exception: Exception) { | |
| cont.resume(Result.failure(exception)) | |
| } | |
| }) | |
| } catch (e: Exception) { | |
| cont.resume(Result.failure(e)) | |
| } | |
| } | |
| val observer = object : Observer<IntegrityTokenViewModel.TokenProviderState> { | |
| override fun onChanged(value: IntegrityTokenViewModel.TokenProviderState) { | |
| when (value) { | |
| is IntegrityTokenViewModel.TokenProviderState.Success -> { | |
| viewModel.providerState.removeObserver(this) | |
| requestToken() | |
| } | |
| is IntegrityTokenViewModel.TokenProviderState.Failure -> { | |
| viewModel.providerState.removeObserver(this) | |
| cont.resume(Result.failure(value.exception)) | |
| return try { | |
| // wait for provider readiness or failure | |
| when (val state = viewModel.providerStateFlow.first { true }) { | |
| is IntegrityTokenViewModel.TokenProviderState.Success -> { | |
| suspendCancellableCoroutine { cont -> | |
| viewModel.requestIntegrityToken( | |
| requestHash, | |
| hasRetried = false, | |
| object : IntegrityTokenViewModel.IntegrityTokenCallback { | |
| override fun onTokenReceived( | |
| requestHash: String, | |
| integrityTokenResponse: StandardIntegrityManager.StandardIntegrityToken | |
| ) { | |
| cont.resume(Result.success(Pair(integrityTokenResponse.token(), requestHash))) | |
| } | |
| override fun onTokenFailure(exception: Exception) { | |
| cont.resume(Result.failure(exception)) | |
| } | |
| } | |
| ) | |
| } | |
| } | |
| is IntegrityTokenViewModel.TokenProviderState.Failure -> { | |
| Result.failure(state.exception) | |
| } | |
| } | |
| } catch (e: Exception) { | |
| Result.failure(e) | |
| } | |
| viewModel.providerState.observeForever(observer) | |
| cont.invokeOnCancellation { viewModel.providerState.removeObserver(observer) } | |
| } | |
| suspend fun fetchIntegrityToken(requestHash: String): Result<Pair<String, String>> { | |
| val viewModel = CommCareViewModelProvider.getIntegrityTokenViewModel() | |
| return try { | |
| // wait for provider readiness or failure | |
| when (val state = viewModel.providerStateFlow.first()) { | |
| is IntegrityTokenViewModel.TokenProviderState.Success -> { | |
| suspendCancellableCoroutine { cont -> | |
| viewModel.requestIntegrityToken( | |
| requestHash, | |
| hasRetried = false, | |
| object : IntegrityTokenViewModel.IntegrityTokenCallback { | |
| override fun onTokenReceived( | |
| requestHash: String, | |
| integrityTokenResponse: StandardIntegrityManager.StandardIntegrityToken | |
| ) { | |
| if (cont.isActive) { | |
| cont.resume(Result.success(Pair(integrityTokenResponse.token(), requestHash))) | |
| } | |
| } | |
| override fun onTokenFailure(exception: Exception) { | |
| if (cont.isActive) { | |
| cont.resume(Result.failure(exception)) | |
| } | |
| } | |
| } | |
| ) | |
| } | |
| } | |
| is IntegrityTokenViewModel.TokenProviderState.Failure -> { | |
| Result.failure(state.exception) | |
| } | |
| } | |
| } catch (e: Exception) { | |
| Result.failure(e) | |
| } | |
| } |
🤖 Prompt for AI Agents
In app/src/org/commcare/android/integrity/IntegrityTokenApiRequestHelper.kt
around lines 121 to 154, simplify the provider wait by replacing first { true }
with first(), and protect the suspendCancellableCoroutine continuation from
being resumed multiple times or after cancellation by adding a single-resume
guard (e.g., an AtomicBoolean or volatile flag) and checking cont.isActive (or
using cont.invokeOnCancellation) before calling
cont.resume/cont.resumeWithException so the callback resumes the continuation
exactly once and avoids double-resume races.
| import androidx.lifecycle.asFlow | ||
| import com.google.android.play.core.integrity.IntegrityManagerFactory |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Ensure ktx dependency for LiveData.asFlow is present.
asFlow/Flow require androidx.lifecycle:lifecycle-livedata-ktx. Verify it’s declared in Gradle to avoid runtime/classpath issues.
Also applies to: 16-17
🏁 Script executed:
#!/bin/bash
# Verify lifecycle-livedata-ktx is declared
rg -nP --glob '!**/build/**' 'lifecycle-livedata-ktx|lifecycle-runtime-ktx|lifecycle-viewmodel-ktx' -g '*gradle*'Length of output: 219
Add lifecycle-livedata-ktx dependency
Add the LiveData KTX artifact so that LiveData.asFlow() is available at runtime. In app/build.gradle (around line 121), include:
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"🤖 Prompt for AI Agents
In app/src/org/commcare/android/integrity/IntegrityTokenViewModel.kt around
lines 8 to 9, LiveData.asFlow() is used but the lifecycle-livedata-ktx runtime
dependency is missing; open app/build.gradle around line 121 and add the
implementation dependency for androidx.lifecycle:lifecycle-livedata-ktx using
the existing lifecycle_version variable (implementation
"androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"), then
sync/refresh Gradle so LiveData.asFlow() is available at runtime.
…ead usage). Cleaned up a couple warnings.
| xmlns:tools="http://schemas.android.com/tools" | ||
| android:versionCode="106" | ||
| android:versionName="2.59"> | ||
| android:versionName="2.59.1"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this generally gets bumped by hotfix Scripts
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@OrangeAndGreen seems like this includes changes to Personal ID signup check as well ? Given we are not planning this QA, it would be great to test that as well and add to safety story on PR
https://dimagi.atlassian.net/browse/CCCT-1658
Product Description
No user visible changes.
Technical Summary
Fixes a crash that was coming out of the integrity reporting code that gets triggered by a flag in the HQ heartbeat.
The code was trying to observer a LiveData object while waiting to retrieve a token.
But calling observeForever from a background thread caused an IllegalStateException.
I exposed the view model state as a Flow and reworked the request helper to use the flow in the coroutine.
Feature Flag
HQ Heartbeat, Integrity Reporting
Safety Assurance
Safety story
Forced a test locally first to observe the crash, then to confirm it didn't happen after the fix.
Automated test coverage
None
QA Plan
None