tilt-shift-camera/CLAUDE.md
Ole-Morten Duesund e9bef0607f Add GPS geotagging toggle to camera top bar
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>
2026-03-05 13:50:33 +01:00

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 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

./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 / 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.
  • 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.