Skip to content

fix(android): prevent camera capture dialog from hanging forever#256

Open
tstapler wants to merge 4 commits into
mainfrom
backlog/stelekit-fix-camera-capture-hang
Open

fix(android): prevent camera capture dialog from hanging forever#256
tstapler wants to merge 4 commits into
mainfrom
backlog/stelekit-fix-camera-capture-hang

Conversation

@tstapler

Copy link
Copy Markdown
Owner

Summary

Tapping capture in the camera dialog could hang forever, forcing users to
force-kill the app. Four stacked root causes:

  • sensorDataFlow.firstOrNull() was untimed and never emitted because
    startSensing()/stopSensing() were never wired into the capture
    lifecycle in either duplicated implementation.
  • ExifOrientationFixer's synchronous full-res bitmap decode/rotate/encode
    ran on the calling dispatcher with no timeout, after the existing 10s
    withTimeout had already elapsed.
  • bindToLifecycle() inside a bare Runnable listener could throw and
    wedge the dialog, uncaught.
  • No cancel path existed once isCapturing == true — Cancel/dismiss were
    both gated on it.

What Changed

  • Extracted a shared MotionSensorProvider.snapshotSensorData() helper
    (bounded by withTimeoutOrNull) used identically by both
    CameraViewfinderDialog.android.kt and
    AndroidCameraProvider.capturePhoto(), so the bound can't drift between
    the two duplicated capture paths.
  • Wired startSensing()/stopSensing() into the dialog's
    DisposableEffect and around AndroidCameraProvider.capturePhoto().
  • Moved ExifOrientationFixer work onto PlatformDispatcher.IO and widened
    the 10s timeout to cover the whole pipeline (shutter + EXIF fix), not
    just takePicture().
  • Catch Throwable, not Exception, in ExifOrientationFixer and both
    capture call sites, so an OutOfMemoryError on a large frame surfaces as
    a failure instead of killing the process.
  • Guarded bindToLifecycle() with try/catch → onError instead of letting
    it throw uncaught.
  • Held the capture Job so Cancel/dismiss can cancel an in-flight capture
    instead of being disabled while capturing.
  • Timeout-guarded ImageImportService.import(...) in App.kt's
    CapturePreviewDialog.onSave so a stalled save can't leave the importing
    spinner stuck forever.

Test plan

  • ./gradlew :kmp:compileDebugKotlinAndroid — builds clean
  • ./gradlew :kmp:detekt — clean (caught and fixed a real
    DisposableEffect/onError staleness bug along the way)
  • ./gradlew :kmp:jvmTest — 3894 tests, only 4 pre-existing
    GraphManagerDatabaseLifecycleTest failures unrelated to this change
    (confirmed identical on unmodified main via git stash)
  • ./gradlew :kmp:testDebugUnitTest --tests "dev.stapler.stelekit.platform.sensor.*" — all green
  • ./gradlew :kmp:testDebugUnitTest --tests "*Qr*" --tests "*Transfer*" — all green, no regression to concurrent QR camera use
  • New regression tests: MotionSensorProviderTest.snapshotSensorData_shouldReturnNull_When_FlowNeverEmits / _shouldReturnLatestReading_When_FlowHasEmitted
  • Live-verified on a booted Pixel_Fold emulator (API 34): a real bindToLifecycle failure (0 available cameras) now fails fast with no crash/hang instead of wedging the UI
  • Full capture-success → preview → save → block-insert happy path — not verifiable in this sandbox (no working camera/webcam feed on the emulator); needs a device with real camera hardware

🤖 Generated with Claude Code

https://claude.ai/code/session_01THQr5q7RczysdVGRGijyXH

tstapler and others added 3 commits July 22, 2026 10:43
Four stacked root causes made the capture button spin indefinitely:
an untimed sensorDataFlow.firstOrNull() that never emits because
startSensing()/stopSensing() were never wired into the dialog/capture
lifecycle, synchronous EXIF orientation-fix work running on the caller's
dispatcher after the shutter timeout had already elapsed, an unguarded
bindToLifecycle() call that could throw uncaught on the camera executor,
and no cancel path once isCapturing was true.

- Extract MotionSensorProvider.snapshotSensorData() (bounded by
  withTimeoutOrNull) and share it between CameraViewfinderDialog.android.kt
  and AndroidCameraProvider.capturePhoto() so the bound can't drift
  between the two duplicated capture paths.
- Wire startSensing()/stopSensing() into the dialog's DisposableEffect and
  around AndroidCameraProvider.capturePhoto().
- Move ExifOrientationFixer work onto PlatformDispatcher.IO and widen the
  10s timeout to cover the whole pipeline (shutter + EXIF fix), not just
  takePicture().
- Catch Throwable (not Exception) in ExifOrientationFixer and both capture
  call sites so an OOM on a large frame surfaces as a failure instead of
  killing the process.
- Guard bindToLifecycle() with try/catch -> onError instead of letting it
  throw uncaught in a bare Runnable listener.
- Hold the capture Job so Cancel/dismiss can cancel an in-flight capture
  instead of being disabled while isCapturing.
- Timeout-guard ImageImportService.import() in App.kt's
  CapturePreviewDialog.onSave so a stalled save can't leave the importing
  spinner stuck forever.

Verified on a booted emulator: a broken camera binding (0 available
cameras) now fails fast with no hang or crash instead of wedging the UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THQr5q7RczysdVGRGijyXH
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THQr5q7RczysdVGRGijyXH
…camera-capture-hang

# Conflicts:
#	.backlog-context.md
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

JVM Load Benchmark (Desktop)

Synthetic in-memory benchmark measuring load performance for the desktop (JVM) app.
Comparing 997ea476 (this PR) vs 24856e14 (baseline)
Graph config: xlarge — 230 pages

Metric This PR Baseline Delta
Phase 1 TTI ↓ 0ms 1ms -1ms (-100%) ✅
Phase 2 background ↓ 0ms 0ms 0 (0%)
Phase 3 index ↓ 0ms 1ms -1ms (-100%) ✅
Total ↓ 0ms 2ms -2ms (-100%) ✅
Write p95 (baseline) ↓ 18ms 19ms -1ms (-5%) ✅
Write p95 (under load) ↓ n/a n/a
Jank factor ↓ n/a n/a
↓ lower is better
Flamegraphs (this PR) **Allocation** — object allocation pressure (JDBC/SQLite churn)

Alloc flamegraph not available

CPU — method-level hotspots by on-CPU time

CPU flamegraph not available

Top allocation hotspots (this PR) `37.4%` byte[]_[k] `7.6%` java.util.LinkedHashMap$Entry_[k] `7.2%` java.lang.String_[k] `6.4%` int[]_[k] `3.6%` java.lang.StringBuilder_[k]
Top CPU hotspots (this PR) `97.9%` /usr/lib/x86_64-linux-gnu/libc.so.6 `0.6%` /tmp/sqlite-3.51.3.0-b1d97c27-73c4-44c0-9a22-d013a389edd9-libsqlitejdbc.so `0.4%` fsync `0.2%` __libc_pwrite `0.1%` pthread_cond_signal

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Android Load Benchmark

Instrumented benchmark on an API 30 x86_64 emulator — 500-page synthetic graph.

Comparing 997ea476 (this PR) vs 24856e14 (baseline)
Device: API 30 x86_64 emulator — 530 pages loaded

Graph Load

Metric This PR Baseline Delta
Phase 1 TTI ↓ 39ms 37ms +2ms (+5%) ⚠️
Phase 3 index ↓ 3636ms 3782ms -146ms (-4%) ✅

Interactive Write Latency (during Phase 3)

Metric This PR Baseline Delta
Write p95 (baseline) ↓ 7ms 8ms -1ms (-12%) ✅
Write p95 (during phase 3) ↓ 16ms 12ms +4ms (+33%) ⚠️
Jank factor ↓ 2.29x 1.5x +0.79x (+53%) ⚠️
Concurrent writes ↑ 18 19 -1ms (-5%) ⚠️

SAF I/O Overhead (ContentProvider vs direct File read)

Measures Binder IPC cost added by ContentResolver per readFile() call.
Real SAF via ExternalStorageProvider will be higher on device; this is a lower bound.

Metric This PR Baseline Delta
Direct read / file ↓ 0.0ms 0.0ms 0 (0%)
Provider read / file ↓ 0.1ms 0.2ms 0ms (-24%) ✅
IPC overhead ratio ↓ 4x 5x -1x (-20%) ✅
↓ lower is better · ↑ higher is better

Four-agent parallel review of PR #256 surfaced two real correctness
issues and a coverage gap; addressed all three:

- Removed startSensing()/stopSensing() from AndroidCameraProvider.
  capturePhoto(): SensorModule.motionSensorProvider is a shared,
  non-reference-counted singleton, and this method has no reachable
  production caller today (see App.kt's unused executeCaptureAndImport).
  Wiring start/stop here would race an already-open
  CameraViewfinderDialog's own sensing session — one caller's stopSensing()
  could kill sensing the other still needs. snapshotSensorData() still
  applies the actual hang-prevention fix identically to both call sites.
- Guarded CameraViewfinderDialog's DisposableEffect startSensing() call
  with try/catch -> onError: an uncaught throw here previously bypassed
  the Throwable-hardening this same PR added elsewhere.
- Stopped re-wrapping the caught Throwable in takePhotoAndProcess's
  catch-all — Result.failure(e) instead of
  Result.failure(Exception(e.message)), so an OOM's real type/stack/cause
  chain survives instead of being discarded.
- Extracted withImportTimeout() so the App.kt post-capture import-timeout
  logic (previously untested despite being pure suspend/state logic) has
  a direct regression test proving a stalled import returns null instead
  of hanging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THQr5q7RczysdVGRGijyXH
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.

1 participant