tilt-shift-camera/CLAUDE.md
Ole-Morten Duesund 5b9aedd109 Update all dependencies to March 2026 versions
Major version bumps:
- AGP 8.7.3 → 9.1.0 (remove kotlin-android plugin, now built-in)
- Kotlin 2.0.21 → 2.3.20
- Gradle 8.9 → 9.4.0
- Compose BOM 2024.12.01 → 2026.03.00
- compileSdk/targetSdk 35 → 36

Library updates:
- core-ktx 1.15.0 → 1.18.0
- lifecycle 2.8.7 → 2.10.0
- activity-compose 1.9.3 → 1.13.0
- CameraX 1.4.1 → 1.5.1
- exifinterface 1.3.7 → 1.4.2

AGP 9 migration: removed org.jetbrains.kotlin.android plugin (Kotlin
support is now built into AGP), removed kotlinOptions block (JVM
target handled by compileOptions).

Bump version to 1.1.4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:46:37 +01:00

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