Users can now opt out of embedding GPS coordinates in photos. The toggle is persisted via SharedPreferences and defaults to enabled. When disabled, effectiveLocation returns null so no EXIF GPS tags are written. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
5.6 KiB
5.6 KiB
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 asGL_TEXTURE_EXTERNAL_OESfrom aSurfaceTexture. The fragment shader (tiltshift_fragment.glsl) applies blur per-fragment using precomputeduCosAngle/uSinAngleuniforms and an unrolled 9-tap Gaussian kernel. - Gallery preview: CPU-based. A 1024px-max downscaled source is kept in
galleryPreviewSource.CameraViewModel.startPreviewLoop()usescollectLateston blur params (with 80ms debounce) to reactively recompute the preview viaImageCaptureHandler.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
./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 StateFlows are never eagerly recycled immediately after replacement. Compose may still be drawing the old bitmap in the current frame. Instead:
- A
pendingRecyclePreview/pendingRecycleThumbnailfield 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()(whichjoin()s the preview job first) andonCleared()
Thread safety
galleryPreviewSourceis@Volatile(accessed from Main thread, IO dispatcher, and cancel path)TiltShiftRenderer.currentTexCoordsis@Volatile(written by UI thread, read by GL thread)cancelGalleryPreview()cancels +join()s the preview job before recycling the source bitmap, becauseapplyTiltShiftEffectis a long CPU loop with no suspension points- GL resources are released via
glSurfaceView.queueEvent {}(must run on GL thread) CameraManager.captureExecutoris shut down inrelease()to prevent thread leaks
Error handling
bitmap.compress()return value is checked; failure reported to userloadBitmapFromUri()logs all null-return paths (stream open, dimensions, decode)- Error/success dismiss indicators use cancellable
Jobtracking 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-partyactivity-composeAPI. - Dependencies are pinned to late-2024 versions; periodic bumps recommended.
- Fragment shader uses
intuniform branching in GLSL ES 1.00 — works but could be cleaner with ES 3.00.