Capture two things future sessions need to know up front: (1) the activity is locked to portrait and the camera image stays in the device frame on purpose — eight releases of orientation-tracking attempts (v1.1.6 → v1.1.13) all got reverted, and per-orientation correctness now lives in the EXIF tag rather than the GL pipeline; (2) the Pixel 6 emulator's default virtual scene is too symmetric to verify rotation visually, `emu rotate` decrements rather than increments, and there is a startup window where camera bitmaps look black. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
121 lines
8 KiB
Markdown
121 lines
8 KiB
Markdown
# 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 with user-toggleable opt-out (persisted across restarts)
|
|
- 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 compileDebugKotlin # Quick compile check
|
|
./bump-version.sh patch # REQUIRED before every release build
|
|
./gradlew assembleRelease # Build release APK
|
|
adb install -r app/build/outputs/apk/release/naiv-tilt-shift-release.apk
|
|
```
|
|
|
|
**IMPORTANT:** Always run `./bump-version.sh [major|minor|patch]` before `assembleRelease`. The version is tracked in `version.properties` and read by `build.gradle.kts`. Commit the bumped `version.properties` before or with the release.
|
|
|
|
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
|
|
|
|
### Orientation policy (do not change without asking)
|
|
|
|
- Activity is `screenOrientation="portrait"` in the manifest. The GL surface and Compose layout therefore never rotate.
|
|
- The camera passthrough uses a fixed 90° texcoord rotation (`texCoordsBack`/`texCoordsFront`). There is no `setTargetRotation`/`setDisplayRotation`/`getTransformMatrix`-based rotation tracking — past attempts (v1.1.6 through v1.1.13) all introduced subtle bugs and were reverted.
|
|
- The camera image lives in the device's portrait frame and visually follows the phone as it tilts. Per-orientation correctness comes from the EXIF orientation tag written at capture (`OrientationDetector.degreesToExifOrientation(rotationToDegrees(deviceRotation), isFrontCamera)`), not from rotating the bitmap.
|
|
- `OrientationEventListener` (`viewModel.currentRotation`) is for EXIF only. It is **not** the same as `Display.rotation`: it fires at the 45° tilt threshold while the activity rotates later, so it must not drive anything that has to stay in sync with the GL surface.
|
|
- `SurfaceTexture.getTransformMatrix()` with a custom `SurfaceProvider` does not change on `Preview.targetRotation` updates; rebinding doesn't reliably help either. Don't go down that road again.
|
|
|
|
### Local Android testing
|
|
|
|
- AVD: `tilfluktsrom` (Pixel 6, API 35, Google APIs) at `~/.android/avd/`. Boot with `emulator -avd tilfluktsrom -no-snapshot-save -gpu swiftshader_indirect -no-audio`.
|
|
- `adb -s emulator-5554 emu rotate` cycles `ROTATION_0 → 3 → 2 → 1 → 0` (decrements by 90°), not the other way.
|
|
- The default virtual scene is too rotationally symmetric to verify which way is "up". For orientation testing, pass `-virtualscene-poster wall=/path/to/marker.png` and ensure the scene camera faces the wall, or just don't trust emulator screenshots as proof of correct orientation.
|
|
- Camera bitmaps captured at startup tend to look black for a few seconds while CameraX rebinds. Wait ≥10 s after `am start` before screenshotting.
|
|
|
|
## 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.
|
|
- Dependencies updated to March 2026 versions (AGP 9.1, Kotlin 2.3, Compose BOM 2026.03).
|
|
- Fragment shader uses `int` uniform branching in GLSL ES 1.00 — works but could be cleaner with ES 3.00.
|
|
|
|
## Issue tracking & task management
|
|
- Do NOT use the `TodoWrite` tool in this project. Issue tracking and progress is managed with **dcat** as specified in `AGENTS.md` — see that file for the full workflow (`dcat prime --opinionated`, `dcat list --agent-only`, status transitions, closing rules, etc.).
|