Add CLAUDE.md with architecture, patterns, and recent fixes

Documents the app's purpose, architecture, rendering pipeline,
build instructions, and key design decisions (bitmap lifecycle,
thread safety, error handling) established during the recent audit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-05 13:46:50 +01:00
commit c3e4dc0e79

102
CLAUDE.md Normal file
View file

@ -0,0 +1,102 @@
# Tilt-Shift Camera
Android camera app that applies a real-time tilt-shift (miniature/diorama) blur effect to the camera preview and to imported gallery images. Built with Kotlin, Jetpack Compose, CameraX, and OpenGL ES 2.0.
## What it does
- Live camera preview with GPU-accelerated tilt-shift blur via GLSL fragment shader
- Gallery import with CPU-based preview that updates in real time as you adjust parameters
- Supports both **linear** (band) and **radial** (elliptical) blur modes
- Gesture controls: drag to position, pinch to resize, two-finger rotate
- Slider panel for precise control of blur, falloff, size, angle, aspect ratio
- Multi-lens support on devices with multiple back cameras
- EXIF GPS tagging from device location
- Saves processed images to MediaStore (scoped storage)
## Architecture
```
no.naiv.tiltshift/
MainActivity.kt # Entry point, permissions, edge-to-edge setup
ui/
CameraScreen.kt # Main Compose UI, GL surface, controls
CameraViewModel.kt # State management, gallery preview loop, bitmap lifecycle
TiltShiftOverlay.kt # Gesture handling and visual guides
ZoomControl.kt # Zoom presets and indicator
LensSwitcher.kt # Multi-lens picker
theme/AppColors.kt # Color constants
camera/
CameraManager.kt # CameraX lifecycle, zoom, lens binding
ImageCaptureHandler.kt # Capture pipeline, CPU blur/mask, gallery processing
LensController.kt # Enumerates physical camera lenses
effect/
TiltShiftRenderer.kt # GLSurfaceView.Renderer for live camera preview
TiltShiftShader.kt # Compiles GLSL, sets uniforms (incl. precomputed trig)
BlurParameters.kt # Data class for all effect parameters
storage/
PhotoSaver.kt # MediaStore writes, EXIF metadata, IS_PENDING pattern
SaveResult.kt # Sealed class for save outcomes
util/
LocationProvider.kt # FusedLocationProvider flow (accepts coarse or fine)
OrientationDetector.kt # Device rotation for EXIF
HapticFeedback.kt # Null-safe vibration wrapper
```
### Rendering pipeline
- **Camera preview**: OpenGL ES 2.0 via `GLSurfaceView` + `TiltShiftRenderer`. Camera frames arrive as `GL_TEXTURE_EXTERNAL_OES` from a `SurfaceTexture`. The fragment shader (`tiltshift_fragment.glsl`) applies blur per-fragment using precomputed `uCosAngle`/`uSinAngle` uniforms and an unrolled 9-tap Gaussian kernel.
- **Gallery preview**: CPU-based. A 1024px-max downscaled source is kept in `galleryPreviewSource`. `CameraViewModel.startPreviewLoop()` uses `collectLatest` on blur params (with 80ms debounce) to reactively recompute the preview via `ImageCaptureHandler.applyTiltShiftPreview()`.
- **Final save**: Full-resolution CPU pipeline — stack blur at 1/4 scale, gradient mask at 1/4 scale with bilinear upscale, per-pixel compositing. Camera captures save both original + processed; gallery imports save only the processed version (original already on device).
## Build & run
```bash
./gradlew assembleRelease # Build release APK
./gradlew compileDebugKotlin # Quick compile check
adb install -r app/build/outputs/apk/release/naiv-tilt-shift-release.apk
```
Signing config is loaded from `local.properties` (not committed).
## Key design decisions and patterns
### Bitmap lifecycle (important!)
Bitmaps emitted to `StateFlow`s are **never eagerly recycled** immediately after replacement. Compose may still be drawing the old bitmap in the current frame. Instead:
- A `pendingRecyclePreview` / `pendingRecycleThumbnail` field holds the bitmap from the *previous* update
- On the *next* update, the pending bitmap is recycled (Compose has had a full frame to finish)
- Final cleanup happens in `cancelGalleryPreview()` (which `join()`s the preview job first) and `onCleared()`
### Thread safety
- `galleryPreviewSource` is `@Volatile` (accessed from Main thread, IO dispatcher, and cancel path)
- `TiltShiftRenderer.currentTexCoords` is `@Volatile` (written by UI thread, read by GL thread)
- `cancelGalleryPreview()` cancels + `join()`s the preview job before recycling the source bitmap, because `applyTiltShiftEffect` is a long CPU loop with no suspension points
- GL resources are released via `glSurfaceView.queueEvent {}` (must run on GL thread)
- `CameraManager.captureExecutor` is shut down in `release()` to prevent thread leaks
### Error handling
- `bitmap.compress()` return value is checked; failure reported to user
- `loadBitmapFromUri()` logs all null-return paths (stream open, dimensions, decode)
- Error/success dismiss indicators use cancellable `Job` tracking to prevent race conditions
- `writeExifToUri()` returns boolean and logs at ERROR level on failure
## Permissions
| Permission | Purpose | Notes |
|-----------|---------|-------|
| `CAMERA` | Camera preview and capture | Required |
| `ACCESS_FINE_LOCATION` | GPS EXIF tagging | Optional; coarse-only grant also works |
| `ACCESS_COARSE_LOCATION` | GPS EXIF tagging | Fallback if fine denied |
| `ACCESS_MEDIA_LOCATION` | Read GPS from gallery images | Required on Android 10+ |
| `VIBRATE` | Haptic feedback | Always granted |
## Known limitations / future work
- `minSdk = 35` (Android 15) — intentional for personal use. Lower to 26-29 if distributing.
- Accompanist Permissions (`0.36.0`) is deprecated; should migrate to first-party `activity-compose` API.
- No user-facing toggle to disable GPS tagging — location is embedded whenever permission is granted.
- Dependencies are pinned to late-2024 versions; periodic bumps recommended.
- Fragment shader uses `int` uniform branching in GLSL ES 1.00 — works but could be cleaner with ES 3.00.