Skip to content

Conversation

@OrangeAndGreen
Copy link
Contributor

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

@coderabbitai
Copy link

coderabbitai bot commented Aug 29, 2025

📝 Walkthrough

Walkthrough

  • AndroidManifest.xml: versionName updated from "2.59" to "2.59.1".
  • IntegrityTokenViewModel: integrityTokenProvider made private; added providerStateFlow exposing _providerState as a Flow; imports updated.
  • IntegrityTokenApiRequestHelper: refactored fetchIntegrityToken to suspend on providerStateFlow.first { true }, branching on Success/Failure; token fetch now uses suspendCancellableCoroutine and returns Result<Pair<String, String>>; removed LiveData Observer usage; imports adjusted.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

skip-integration-tests

Suggested reviewers

  • shubham1g5
  • avazirna
  • Jignesh-dimagi

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 Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dv/integrity_fix_2

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a 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 when in withTimeout(...) (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.

📥 Commits

Reviewing files that changed from the base of the PR and between 0e594c2 and 55d041b.

📒 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 integrityTokenProvider private avoids accidental external use and simplifies the Flow-based API.

Comment on lines 121 to 154
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) }
}
Copy link

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*\(' -C2

Length 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/**' -C2

Length 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 to first(); 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.

Suggested change
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.

Comment on lines 8 to 9
import androidx.lifecycle.asFlow
import com.google.android.play.core.integrity.IntegrityManagerFactory
Copy link

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.

@OrangeAndGreen OrangeAndGreen merged commit 1641c74 into commcare_2.59 Aug 30, 2025
2 checks passed
@OrangeAndGreen OrangeAndGreen deleted the dv/integrity_fix_2 branch August 30, 2025 00:11
xmlns:tools="http://schemas.android.com/tools"
android:versionCode="106"
android:versionName="2.59">
android:versionName="2.59.1">
Copy link
Contributor

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

Copy link
Contributor

@shubham1g5 shubham1g5 left a 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

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

Successfully merging this pull request may close these issues.

4 participants