CCCT-2596 Extract BaseCameraActivity From MicroImageActivity#3778
CCCT-2596 Extract BaseCameraActivity From MicroImageActivity#3778Jignesh-dimagi wants to merge 3 commits into
Conversation
…mera [AI] Behavior-preserving extraction of the shared CameraX plumbing (CAMERA permission flow, provider acquisition, preview binding, action bar, error exit) into a new abstract BaseCameraActivity. MicroImageActivity keeps the PersonalID face-detection flow unchanged and implements the camera hooks. Both activities move to a new org.commcare.activities.camera package. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Suggested Review Order
|
📝 WalkthroughWalkthroughA new abstract Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/src/org/commcare/activities/camera/MicroImageActivity.java (1)
98-105: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick winMove
ImageAnalysisoff the main thread.setAnalyzer(ContextCompat.getMainExecutor(...), this)runsanalyze(...)on the UI thread, so the per-frame face-detection path can contend with preview rendering and input. Use a background executor here instead.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/src/org/commcare/activities/camera/MicroImageActivity.java` around lines 98 - 105, The ImageAnalysis analyzer in buildImageAnalysisUseCase is currently scheduled with ContextCompat.getMainExecutor(getApplicationContext()), which runs analyze(...) on the UI thread. Switch setAnalyzer(...) to use a background executor owned by MicroImageActivity so face detection work does not block preview rendering or input, and make sure that executor is managed with the activity lifecycle.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/src/org/commcare/activities/camera/BaseCameraActivity.kt`:
- Around line 89-103: The async camera startup path in
BaseCameraActivity.startCamera() only guards ProcessCameraProvider acquisition,
but bindUseCases(cameraProvider) can still throw if the activity is no longer
valid or the camera config is unsupported. Add a lifecycle/state check in
startCamera before calling bindUseCases, and wrap the bindUseCases call so any
IllegalStateException or IllegalArgumentException also goes through
logErrorAndExit with the same microimage.camera.start.failed error path.
In `@app/src/org/commcare/activities/camera/MicroImageActivity.java`:
- Around line 89-95: `buildCaptureUseCase(...)` is already receiving
`targetRotation`, but the manual `ImageCapture` path still ignores it, which can
leave rotation metadata incorrect on rotated devices. Update the
`buildImageCaptureUseCase(...)` flow used by `MicroImageActivity` so the
`ImageCapture` it constructs is configured with the provided rotation, and make
sure `buildCaptureUseCase(...)` forwards `targetRotation` into that branch just
like the face-detection analysis path.
---
Outside diff comments:
In `@app/src/org/commcare/activities/camera/MicroImageActivity.java`:
- Around line 98-105: The ImageAnalysis analyzer in buildImageAnalysisUseCase is
currently scheduled with ContextCompat.getMainExecutor(getApplicationContext()),
which runs analyze(...) on the UI thread. Switch setAnalyzer(...) to use a
background executor owned by MicroImageActivity so face detection work does not
block preview rendering or input, and make sure that executor is managed with
the activity lifecycle.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: ef4b7799-6036-446c-890f-0cb8bb80cb98
📒 Files selected for processing (7)
app/AndroidManifest.xmlapp/src/org/commcare/activities/camera/BaseCameraActivity.ktapp/src/org/commcare/activities/camera/MicroImageActivity.javaapp/src/org/commcare/fragments/personalId/PersonalIdPhotoCaptureFragment.javaapp/src/org/commcare/navdrawer/BaseDrawerController.ktapp/unit-tests/src/org/commcare/fragments/personalId/BasePersonalIdPhotoCaptureFragmentTest.ktapp/unit-tests/src/org/commcare/fragments/personalId/PersonalIdPhotoCaptureFragmentTest.kt
| protected fun startCamera() { | ||
| val cameraProviderFuture = ProcessCameraProvider.getInstance(this) | ||
| cameraProviderFuture.addListener({ | ||
| val cameraProvider: ProcessCameraProvider | ||
| try { | ||
| cameraProvider = cameraProviderFuture.get() | ||
| } catch (e: ExecutionException) { | ||
| logErrorAndExit("Error acquiring camera provider", "microimage.camera.start.failed", e) | ||
| return@addListener | ||
| } catch (e: InterruptedException) { | ||
| logErrorAndExit("Error acquiring camera provider", "microimage.camera.start.failed", e) | ||
| return@addListener | ||
| } | ||
| bindUseCases(cameraProvider) | ||
| }, ContextCompat.getMainExecutor(this)) |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== File outline =="
ast-grep outline app/src/org/commcare/activities/camera/BaseCameraActivity.kt --view expanded || true
echo
echo "== Relevant slice =="
sed -n '1,220p' app/src/org/commcare/activities/camera/BaseCameraActivity.kt | cat -n
echo
echo "== Search for camera binding patterns =="
rg -n "bindToLifecycle|ProcessCameraProvider|getInstance|logErrorAndExit|cameraProviderFuture" app/src/org/commcare/activities/camera app/src | sed -n '1,200p'Repository: dimagi/commcare-android
Length of output: 35604
🌐 Web query:
CameraX ProcessCameraProvider.bindToLifecycle throws IllegalStateException IllegalArgumentException lifecycle destroyed documentation
💡 Result:
The IllegalStateException and IllegalArgumentException errors encountered when calling ProcessCameraProvider.bindToLifecycle often relate to lifecycle state management or unsupported use case configurations. Lifecycle State Issues: If you attempt to bind use cases to a LifecycleOwner that is already in a DESTROYED state, CameraX will throw an IllegalArgumentException (e.g., "Trying to create LifecycleCamera with destroyed lifecycle") [1][2]. This often happens in asynchronous code where a camera provider is retrieved and processed after the associated Activity or Fragment has already been torn down [1]. To resolve this, always verify the state of the LifecycleOwner (e.g., check lifecycle.currentState!= Lifecycle.State.DESTROYED) before proceeding with the binding operation [1]. IllegalStateException: This exception is typically thrown when violating specific API constraints: 1. Lifecycle Conflicts: Attempting to bind a UseCase to a lifecycle when it is already bound to another, or binding to a LifecycleOwner that already has individual UseCases managed in an incompatible way [3][4]. 2. Configuration Violations: Invoking bindToLifecycle improperly, such as passing an invalid combination of UseCases or violating Singleton configuration rules [5][3]. 3. Camera Support: If the CameraSelector cannot resolve a camera that supports the requested UseCase combination, an IllegalArgumentException is thrown [5][6]. Best Practices: - Use a single call: Bind all necessary UseCases in one call to bindToLifecycle [7][8]. CameraX will automatically manage the lifecycle transitions and resource allocation [7]. - Check for DESTROYED state: Especially in async listeners (like those used with ListenableFuture), ensure the lifecycle is still valid before binding [1]. - Use the latest version: Google frequently patches these crashes in newer releases. For instance, specific crashes related to lifecycle binding and dynamic range profiles have been addressed in recent versions of the CameraX library [2][6]. - Manage View/Fragment Lifecycle: If you are using custom lifecycle owners or complex navigation, ensure that you are consistently using the correct lifecycle owner (e.g., viewLifecycleOwner in Fragments) [9][7]. If necessary, use unbindAll to clear existing bindings before re-binding if your application logic requires switching camera configurations dynamically [9][10].
Citations:
- 1: https://android.googlesource.com/platform/frameworks/support/+/690b7005f4ecd050f4b5886591fae80808abbbd7
- 2: https://developer.android.com/jetpack/androidx/releases/camera
- 3: https://android.googlesource.com/platform/frameworks/support/+/androidx-main/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/LifecycleCameraProvider.kt
- 4: https://developer.android.com/reference/androidx/camera/lifecycle/ProcessCameraProvider
- 5: https://developer.android.com/reference/kotlin/androidx/camera/lifecycle/ProcessCameraProvider
- 6: https://groups.google.com/a/android.com/g/camerax-developers/c/J7BmNT1JV6c
- 7: https://developer.android.com/media/camera/camerax/architecture
- 8: https://developer.android.com/media/camera/camerax/configuration
- 9: https://stackoverflow.com/questions/57126429/an-error-occurred-by-camerax-bindtolifecycle
- 10: https://developer.android.com/agents/skills/camera/camera1-to-camerax/skill
Guard the async CameraX bind path. bindUseCases(...) can still throw IllegalStateException/IllegalArgumentException if this activity is already torn down or the camera config is unsupported, and that bypasses logErrorAndExit(...) on the main thread. Add a lifecycle check before binding and route binding failures through the same exit path.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/org/commcare/activities/camera/BaseCameraActivity.kt` around lines 89
- 103, The async camera startup path in BaseCameraActivity.startCamera() only
guards ProcessCameraProvider acquisition, but bindUseCases(cameraProvider) can
still throw if the activity is no longer valid or the camera config is
unsupported. Add a lifecycle/state check in startCamera before calling
bindUseCases, and wrap the bindUseCases call so any IllegalStateException or
IllegalArgumentException also goes through logErrorAndExit with the same
microimage.camera.start.failed error path.
| @Override | ||
| public boolean onOptionsItemSelected(@NonNull MenuItem item) { | ||
| if (item.getItemId() == android.R.id.home) { | ||
| getOnBackPressedDispatcher().onBackPressed(); | ||
| return true; | ||
| } | ||
| return super.onOptionsItemSelected(item); | ||
| } | ||
|
|
||
| private void checkForCameraPermission() { | ||
| if (Permissions.missingAppPermission(this, Manifest.permission.CAMERA)) { | ||
| if (Permissions.shouldShowPermissionRationale(this, Manifest.permission.CAMERA)) { | ||
| CommCareAlertDialog dialog = | ||
| DialogCreationHelpers.buildPermissionRequestDialog(this, this, | ||
| -1, // actually not required due to launcher activity | ||
| getString(R.string.personalid_camera_permission_title), | ||
| getString(R.string.personalid_camera_permission_msg)); | ||
| dialog.showNonPersistentDialog(this); | ||
| } else { | ||
| requestNeededPermissions(-1); | ||
| } | ||
| } else { | ||
| startCamera(); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public void requestNeededPermissions(int requestCode) { | ||
| cameraPermissionRequestLauncher.launch(Manifest.permission.CAMERA); | ||
| } | ||
|
|
||
| private void startCamera() { | ||
| ListenableFuture<ProcessCameraProvider> cameraProviderFuture = ProcessCameraProvider.getInstance(this); | ||
|
|
||
| cameraProviderFuture.addListener(() -> { | ||
| ProcessCameraProvider cameraProvider; | ||
| try { | ||
| cameraProvider = cameraProviderFuture.get(); | ||
| } catch (ExecutionException | InterruptedException e) { | ||
| logErrorAndExit("Error acquiring camera provider", "microimage.camera.start.failed", e); | ||
| return; | ||
| } | ||
| bindUseCases(cameraProvider); | ||
| }, ContextCompat.getMainExecutor(this)); | ||
| } | ||
|
|
||
| private void bindUseCases(ProcessCameraProvider cameraProvider) { | ||
| int targetRotation = getWindowManager().getDefaultDisplay().getRotation(); | ||
| Size targetResolution = new Size(faceCaptureView.getImageWidth(), faceCaptureView.getImageHeight()); | ||
|
|
||
| // Preview use case | ||
| Preview preview = new Preview.Builder() | ||
| .setTargetResolution(targetResolution) | ||
| .setTargetRotation(targetRotation) | ||
| .build(); | ||
| preview.setSurfaceProvider(cameraView.getSurfaceProvider()); | ||
|
|
||
| UseCase imageAnalyzerOrCapture; | ||
| protected UseCase buildCaptureUseCase(Size targetResolution, int targetRotation) { | ||
| if (faceCaptureView.getCaptureMode() == FaceCaptureView.CaptureMode.FaceDetectionMode) { | ||
| imageAnalyzerOrCapture = buildImageAnalysisUseCase(targetResolution, targetRotation); | ||
| return buildImageAnalysisUseCase(targetResolution, targetRotation); | ||
| } else { | ||
| imageAnalyzerOrCapture = buildImageCaptureUseCase(targetResolution); | ||
| return buildImageCaptureUseCase(targetResolution); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- MicroImageActivity outline ---'
ast-grep outline app/src/org/commcare/activities/camera/MicroImageActivity.java --view expanded || true
echo '--- BaseCameraActivity outline ---'
ast-grep outline app/src/org/commcare/activities/camera/BaseCameraActivity.java --view expanded || true
echo '--- Relevant lines in MicroImageActivity ---'
nl -ba app/src/org/commcare/activities/camera/MicroImageActivity.java | sed -n '1,240p'
echo '--- Relevant lines in BaseCameraActivity ---'
nl -ba app/src/org/commcare/activities/camera/BaseCameraActivity.java | sed -n '1,260p'Repository: dimagi/commcare-android
Length of output: 1847
🏁 Script executed:
#!/bin/bash
set -euo pipefail
for f in app/src/org/commcare/activities/camera/MicroImageActivity.java app/src/org/commcare/activities/camera/BaseCameraActivity.java; do
echo "### $f"
rg -n "buildCaptureUseCase|buildImageCaptureUseCase|setTargetRotation|setAnalyzer|ImageAnalysis|ImageCapture" "$f" || true
doneRepository: dimagi/commcare-android
Length of output: 1789
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- locate BaseCameraActivity ---'
fd -a 'BaseCameraActivity.java' app src . 2>/dev/null || true
echo '--- search for relevant methods/usages ---'
rg -n "class BaseCameraActivity|buildImageCaptureUseCase|finalizeImageCapture|calcPreviewCaptureArea|setAnalyzer\\(|ImageAnalysis|ImageCapture" app src . --glob '*.java' --glob '*.kt' || trueRepository: dimagi/commcare-android
Length of output: 11976
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# locate the actual BaseCameraActivity file and inspect the relevant slice
fd -a 'BaseCameraActivity.java' . 2>/dev/null || true
# show only the Java files that mention the camera helpers
rg -n "buildImageCaptureUseCase|finalizeImageCapture|calcPreviewCaptureArea|setAnalyzer\\(" . --glob '*.java' || trueRepository: dimagi/commcare-android
Length of output: 1088
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- locate BaseCameraActivity ---'
fd -a 'BaseCameraActivity.java' . 2>/dev/null || true
echo '--- locate MicroImageActivity references ---'
rg -n "class MicroImageActivity|extends BaseCameraActivity|buildImageCaptureUseCase|finalizeImageCapture|calcPreviewCaptureArea|setAnalyzer\\(" app . --glob '*.java' || trueRepository: dimagi/commcare-android
Length of output: 2486
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- BaseCameraActivity.kt outline ---'
ast-grep outline app/src/org/commcare/activities/camera/BaseCameraActivity.kt --view expanded || true
echo '--- MicroImageActivity.java relevant slice ---'
sed -n '84,220p' app/src/org/commcare/activities/camera/MicroImageActivity.java
echo '--- BaseCameraActivity.kt relevant slice ---'
sed -n '1,260p' app/src/org/commcare/activities/camera/BaseCameraActivity.ktRepository: dimagi/commcare-android
Length of output: 13265
🌐 Web query:
CameraX ImageCapture setTargetRotation rotationDegrees imageProxy rotation metadata documentation analyzer setAnalyzer executor main thread
💡 Result:
In CameraX, managing image rotation and thread execution for analysis involves several key components: Target Rotation The setTargetRotation method (available on both ImageCapture and ImageAnalysis) is used to tell CameraX the orientation of the output image relative to the sensor [1][2]. You should update this value dynamically as the device orientation changes, typically using an OrientationEventListener to match the display's rotation [3][1]. This ensures that EXIF metadata in captured JPEGs and the rotationDegrees value in ImageProxy are correctly calibrated to reflect the desired output [1][4][2]. ImageProxy Rotation Metadata When you receive an ImageProxy (in ImageCapture or ImageAnalysis), you can retrieve the necessary rotation using imageProxy.imageInfo.rotationDegrees [3][5][6]. This value represents the clockwise rotation needed to align the image buffer with the target rotation [3][5]. For JPEG images, this value is already baked into the EXIF data [5]. Analyzer Execution The ImageAnalysis.setAnalyzer method requires an executor to define the thread on which the analysis runs [7][8]. Main Thread Execution: While you can provide a main thread executor (via CameraXExecutors.mainThreadExecutor [9]), it is generally discouraged to perform heavy image processing on the main thread, as this can block the UI and lead to dropped frames or performance issues. Recommended Approach: Use a dedicated background thread or a thread pool to ensure smooth performance [6][7]. If you do not provide a background executor, CameraX handles image processing internally, but when providing your own via setAnalyzer, ensure it is capable of handling the workload quickly to maintain frame rates and avoid backpressure issues [7]. To implement a simple analyzer, you provide the executor and the analyzer instance: imageAnalysis.setAnalyzer(myExecutor, { imageProxy -> val rotation = imageProxy.imageInfo.rotationDegrees // Perform processing... imageProxy.close // Important: Always close the ImageProxy })
Citations:
- 1: https://developer.android.com/reference/androidx/camera/core/ImageCapture
- 2: https://developer.android.com/reference/androidx/camera/core/ImageAnalysis
- 3: https://developer.android.com/media/camera/camerax/orientation-rotation
- 4: https://developer.android.com/reference/androidx/camera/core/ImageCapture.Builder
- 5: https://developer.android.com/reference/androidx/camera/core/ImageInfo
- 6: https://developer.android.com/media/camera/camerax/analyze
- 7: https://developer.android.com/reference/kotlin/androidx/camera/core/ImageAnalysis
- 8: https://android.googlesource.com/platform/frameworks/support/%2B/androidx-main/camera/camera-core/src/main/java/androidx/camera/core/ImageAnalysis.java
- 9: https://android.googlesource.com/platform/frameworks/support/+/f6891e026b34931275ddaaad549411ffaf09f06b/camera/camera-core/src/main/java/androidx/camera/core/impl/utils/executor/CameraXExecutors.java
Pass targetRotation into the manual ImageCapture branch. buildCaptureUseCase(...) now receives rotation from BaseCameraActivity, but buildImageCaptureUseCase(...) still builds ImageCapture without it. On rotated devices that can leave imageProxy.getImageInfo().getRotationDegrees() wrong and break the upright conversion/crop path.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/org/commcare/activities/camera/MicroImageActivity.java` around lines
89 - 95, `buildCaptureUseCase(...)` is already receiving `targetRotation`, but
the manual `ImageCapture` path still ignores it, which can leave rotation
metadata incorrect on rotated devices. Update the
`buildImageCaptureUseCase(...)` flow used by `MicroImageActivity` so the
`ImageCapture` it constructs is configured with the provided rotation, and make
sure `buildCaptureUseCase(...)` forwards `targetRotation` into that branch just
like the face-detection analysis path.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #3778 +/- ##
============================================
+ Coverage 25.84% 25.91% +0.07%
- Complexity 4387 4431 +44
============================================
Files 951 964 +13
Lines 57199 57706 +507
Branches 6812 6871 +59
============================================
+ Hits 14782 14954 +172
- Misses 40590 40915 +325
- Partials 1827 1837 +10 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| } | ||
|
|
||
| private fun bindUseCases(cameraProvider: ProcessCameraProvider) { | ||
| val targetRotation = windowManager.defaultDisplay.rotation |
There was a problem hiding this comment.
Can we use Context.getDisplay() instead since defaultDisplay is deprecated
| } catch (e: ExecutionException) { | ||
| logErrorAndExit("Error acquiring camera provider", "microimage.camera.start.failed", e) | ||
| return@addListener | ||
| } catch (e: InterruptedException) { | ||
| logErrorAndExit("Error acquiring camera provider", "microimage.camera.start.failed", e) | ||
| return@addListener | ||
| } |
There was a problem hiding this comment.
We could use one catch branch with an if statement here to check for these two exceptions and avoid repeating code
| val targetResolution = getTargetResolution() | ||
|
|
||
| val previewBuilder = Preview.Builder().setTargetRotation(targetRotation) | ||
| targetResolution?.let { previewBuilder.setTargetResolution(it) } |
There was a problem hiding this comment.
Thoughts on making abstract fun getTargetResolution() non-null and removing this null check?
| targetResolution?.let { previewBuilder.setTargetResolution(it) } | |
| targetResolution.let { previewBuilder.setTargetResolution(it) } |
| val targetResolution = getTargetResolution() | ||
|
|
||
| val previewBuilder = Preview.Builder().setTargetRotation(targetRotation) | ||
| targetResolution?.let { previewBuilder.setTargetResolution(it) } |
There was a problem hiding this comment.
setTargetResolution() is deprecated. We can use ResolutionSelector with ResolutionStrategy instead
| val previewBuilder = Preview.Builder().setTargetRotation(targetRotation) | ||
| targetResolution?.let { previewBuilder.setTargetResolution(it) } | ||
| val preview = previewBuilder.build() | ||
| preview.setSurfaceProvider(cameraView!!.surfaceProvider) |
There was a problem hiding this comment.
Nit: can treat this like a var
| preview.setSurfaceProvider(cameraView!!.surfaceProvider) | |
| preview.surfaceProvider = cameraView!!.surfaceProvider |
| Logger.exception(logMessage, e) | ||
| } | ||
| Toast.makeText(this, Localization.get(userMessageKey), Toast.LENGTH_LONG).show() | ||
| setResult(AppCompatActivity.RESULT_CANCELED) |
There was a problem hiding this comment.
Nit: can simplify this because it's imported
| setResult(AppCompatActivity.RESULT_CANCELED) | |
| setResult(RESULT_CANCELED) |
CCCT-2596
Technical Summary
The shared CameraX plumbing — CAMERA permission flow, provider acquisition, preview binding, action bar, and error exit — moves into a new abstract
BaseCameraActivity, with everything mode-specific (camera selector, target resolution, capture use case, view setup) exposed as hooks.MicroImageActivityis refactored onto the base with its PersonalID face-detection flow unchanged. Both activities also relocate fromorg.commcare.fragmentsto a neworg.commcare.activities.camerapackage, since they are activities, not fragments.Safety Assurance
Safety story
What gives me confidence:
MicroImageActivity's logic is unchanged, only relocated into base hooks.PersonalIdPhotoCaptureFragmentTestcovers the launch + result contract and passes; ktlint and a fullcompileCommcareDebugSourcesare clean, including the Kotlin↔Java interop.