Stop trying to rotate the camera image based on device orientation.
The activity is now locked to portrait (screenOrientation="portrait"),
so the GL surface stays portrait-sized regardless of how the device
is held, and the camera passthrough goes back to the simple
texCoordsBack 90° rotation that was working before any of the
v1.1.6–1.1.13 attempts at landscape support.
Net effect: the camera image stays in the device's portrait frame
and visually follows the phone as it tilts (since there is no
inverse rotation cancelling it). The UI is also locked to the
portrait layout for now — a follow-up will add Modifier.graphicsLayer
rotations to the icon overlays so they stay readable when the phone
is held sideways. screenOrientation switched from fullSensor to
portrait; the rest of the file changes are reverts of the orientation
plumbing introduced in v1.1.6 and its follow-ups.
Bump to 1.1.14.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OrientationEventListener fires continuously on the raw accelerometer
tilt and crosses the ROTATION_90 / ROTATION_270 boundary at 45° — well
before the system actually rotates the activity. The renderer was
swapping its texcoord buffer at 45° tilt while the GL surface and
Compose layout were still in the previous orientation, so for the few
degrees between "OrientationEventListener fires" and "activity
rotates" the camera image rendered at the wrong rotation. Past that
window it snapped back into sync.
Use LocalConfiguration + Display.rotation to source the renderer's
rotation. Configuration only changes when the activity has actually
rotated, so the texcoord buffer flips in lock-step with the GL surface
and there is no transient mis-orientation. OrientationEventListener
is still used by capture for EXIF metadata.
Bump to 1.1.13.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-add landscape support, this time via four precomputed texcoord
buffers — one per Surface.ROTATION_* — instead of going through
SurfaceTexture.getTransformMatrix() (which doesn't honour
Preview.targetRotation for custom SurfaceProviders) or the manual
matrix composition attempts in v1.1.6–1.1.11.
For each device orientation the renderer picks the texcoord set that
both compensates for the 90° CW sensor mount and the activity's own
rotation under screenOrientation="fullSensor", so world-up stays at
clip-space-top. recomputeVertices swaps effective camera dimensions
between portrait and landscape so crop-to-fill picks the right aspect.
Verified empirically in the emulator across all four Display.rotation
values (sky-yellow band always lands at the top of the screen).
Bump to 1.1.12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bring back the v1.1.9 approach (apply an inverse rotation to the
texcoord sampling pattern so the camera image stays world-aligned
through device rotation), but with the right signs this time. The
previous angles were 180° off for both landscape orientations and
showed the camera content upside-down on a real device.
Verified each Display.rotation against the emulator's virtual scene
(sky-yellow → road-brown → buildings-dark): the sky/yellow band now
sits at the top of the screen in all four orientations.
Bump to 1.1.11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Roll back the rendering-pipeline changes from v1.1.6 (and the
subsequent attempts in 1.1.7/1.1.8/1.1.9): no more
SurfaceTexture transform-matrix-driven re-orientation, no more
rebinding on rotation, no more inverse-rotation correction. The
camera passthrough goes back to fixed portrait-oriented texcoords
and the crop-to-fill math treats the camera buffer as portrait
unconditionally.
The activity stays `fullSensor`, so the Compose UI and the GL
blur passes continue to follow the device orientation — only the
camera image itself is left untouched, which matches the
"normal camera doesn't flip the picture when rotating the phone"
behaviour requested.
Bump to 1.1.10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`SurfaceTexture.getTransformMatrix()` for a custom SurfaceProvider does
not vary with `Preview.targetRotation` — it only encodes the static
sensor-to-buffer transform. The v1.1.8 rebind-on-rotation fix did
update Preview's target rotation, but the matrix returned to the GL
renderer was identical across all four device orientations. Combined
with the activity rotating under fullSensor (so the GL clip-space "up"
direction tracks the device, not the world), one of the two landscape
orientations rendered the image upside down on a real device. The
emulator masked this because its virtual scene is roughly symmetric.
Compose the missing piece on the GL thread: rotate the texcoord
sampling pattern around its centre by the inverse of the activity
rotation before sampling the camera texture. The four orientations
now produce four distinct matrices, keeping world-up at screen-up in
all of them while leaving portrait unchanged.
Bump to 1.1.9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`Preview.targetRotation = …` on an already-bound use case does not
refresh the live SurfaceTexture's transform matrix or trigger a new
SurfaceRequest with a rotation-appropriate buffer size — the new
rotation only applies to subsequently bound streams. With our custom
SurfaceProvider, the result was that rotating to landscape left the
camera still writing portrait-oriented frames into the now-landscape
GL surface, so the preview appeared to rotate with the device instead
of switching to landscape.
Rebind the camera use cases when the target rotation changes so
CameraX fires a fresh SurfaceRequest with the correct resolution
and matrix. Also revert the OrientationDetector mapping back to its
original (matches Display.rotation for a fullSensor activity, which
is what setTargetRotation expects); the previous "fix" went the wrong
direction and was not the root cause.
Bump to 1.1.8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OrientationDetector mapped the OrientationEventListener angle to
Surface.ROTATION_* with 90 and 270 swapped relative to the CameraX
docs example. The angle reports the device's physical clockwise
rotation, whereas Surface.ROTATION_* describes the screen's logical
rotation (opposite direction), so the values must be inverted.
Symptom: rotating the phone clockwise rotated the live preview counter-
clockwise (and vice versa), instead of keeping world-up at screen-up.
The bug was previously masked because the only consumer of the value
(deviceRotation in capturePhoto) is unused — the live preview commit
(d321f07) wired this same value into CameraX's setTargetRotation, which
exposed the inverted mapping.
Bump to 1.1.7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace hardcoded portrait-only texture coordinate rotation with
SurfaceTexture.getTransformMatrix(), so the camera preview and capture
re-orient correctly when the device rotates. Also drive
Preview/ImageCapture targetRotation from the live display rotation, fix
the crop-to-fill aspect math to swap effective camera dimensions
between portrait and landscape, and make the slider control panel
scroll if it doesn't fit the shorter landscape height.
Bump to 1.1.6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the ~220-line stack blur algorithm from ImageCaptureHandler into
its own util/StackBlur.kt object, making it independently testable
and reducing ImageCaptureHandler from 759 to 531 lines.
Split CameraScreen.kt (873 lines) by extracting:
- ControlPanel.kt: ModeToggle, ControlPanel, SliderControl
- CaptureControls.kt: CaptureButton, LastPhotoThumbnail
CameraScreen.kt is now 609 lines focused on layout and state wiring.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the single-pass 9-tap directional blur with a three-pass
pipeline: passthrough (camera→FBO), horizontal blur (FBO-A→FBO-B),
vertical blur (FBO-B→screen). This produces a true 2D Gaussian with
a 13-tap kernel per pass, eliminating the visible banding/streaking
of the old approach.
Key changes:
- TiltShiftRenderer: FBO ping-pong with two color textures, separate
fullscreen quad for blur passes (no crop-to-fill), drawQuad helper
- TiltShiftShader: manages two programs (passthrough + blur), blur
program uses raw screen-space angle (no camera rotation adjustment)
- tiltshift_fragment.glsl: rewritten for sampler2D in screen space,
aspect correction on X axis (height-normalized), uBlurDirection
uniform for H/V selection, wider falloff (3x multiplier)
- New tiltshift_passthrough_fragment.glsl for camera→FBO copy
- TiltShiftOverlay: shrink PINCH_SIZE zone (1.3x, was 2.0x) so
pinch-to-zoom is reachable over more of the screen
- CameraManager: optimistic zoom update fixes pinch-to-zoom stalling
(stale zoomRatio base prevented delta accumulation)
Bump version to 1.1.3.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
First test coverage for the project: 17 tests covering BlurParameters
constraint clamping (size, blur, falloff, aspect ratio, position),
data class equality, field preservation across with* methods, and
LensController pre-initialization edge cases.
Adds JUnit 4.13.2 as a test dependency.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The -keep rule for no.naiv.tiltshift.effect.** was based on the
incorrect assumption that GLSL shaders use Java reflection. Shader
source is loaded from raw resources — all effect classes are reached
through normal Kotlin code and R8 can trace them. Removing the rule
lets R8 properly optimize the effect package.
Also extract SaveResult sealed class from PhotoSaver.kt into its own
file to match the documented architecture.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Accompanist Permissions (0.36.0) is deprecated and experimental. Migrate
to the stable ActivityResultContracts.RequestPermission /
RequestMultiplePermissions APIs already available via activity-compose.
Adds explicit state tracking with a cameraResultReceived flag to
correctly distinguish "never asked" from "permanently denied" — an
improvement over the previous Accompanist-based detection.
Bump version to 1.1.2.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Version is tracked in version.properties and read by build.gradle.kts.
Run ./bump-version.sh [major|minor|patch] before release builds.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Switch GLSurfaceView from RENDERMODE_CONTINUOUSLY to RENDERMODE_WHEN_DIRTY
with OnFrameAvailableListener, halving GPU work
- Add crop-to-fill aspect ratio correction so camera preview is not
stretched on displays with non-16:9 aspect ratios
- Add LifecycleEventObserver to pause/resume GLSurfaceView with Activity
lifecycle, preventing background rendering and GL context issues
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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>
Concurrency & bitmap lifecycle:
- Defer bitmap recycling by one cycle so Compose finishes drawing before
native memory is freed (preview bitmaps, thumbnails)
- Make galleryPreviewSource @Volatile for cross-thread visibility
- Join preview job before recycling source bitmap in cancelGalleryPreview()
to prevent use-after-free during CPU blur loop
- Add @Volatile to TiltShiftRenderer.currentTexCoords (UI/GL thread race)
- Fix error dismiss race with cancellable Job tracking
Lifecycle & resource management:
- Release GL resources via glSurfaceView.queueEvent (must run on GL thread)
- Pause GLSurfaceView when entering gallery preview mode
- Shut down captureExecutor in CameraManager.release() (thread leak)
- Use WeakReference for lifecycleOwnerRef to avoid Activity GC delay
- Fix thumbnail bitmap leak on coroutine cancellation (add to finally)
- Guarantee imageProxy.close() in finally block
Performance:
- Compute gradient mask at 1/4 resolution with bilinear upscale (~93%
less per-pixel trig work, ~75% less mask memory)
- Precompute cos/sin on CPU, pass as uCosAngle/uSinAngle uniforms
(eliminates per-fragment transcendental calls in GLSL)
- Unroll 9-tap Gaussian blur kernel (avoids integer-branched weight
lookup that de-optimizes on mobile GPUs)
- Add 80ms debounce to preview recomputation during slider drags
Silent failure fixes:
- Check bitmap.compress() return value; report error on failure
- Log all loadBitmapFromUri null paths (stream, dimensions, decode)
- Surface preview computation errors and ActivityNotFoundException to user
- Return boolean from writeExifToUri, log at ERROR level
- Wrap gallery preview downscale in try-catch (OOM protection)
Config:
- Add ACCESS_MEDIA_LOCATION permission (GPS EXIF on Android 10+)
- Accept coarse-only location grant for geotags
- Remove dead adjustResize (no effect with edge-to-edge)
- Set windowBackground to black (eliminates white flash on cold start)
- Add values-night theme for dark mode
- Remove overly broad ProGuard keeps (CameraX/GMS ship consumer rules)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Gallery imports now show the effect live as you adjust parameters,
matching the camera preview experience. Uses a downscaled (1024px)
source bitmap for fast recomputation via collectLatest, which
cancels stale frames when params change mid-computation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Gallery imports already have the original on-device, so only save
the tilt-shift processed version. Camera captures continue saving
both versions since the original only exists in memory.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Migrate all UI state from local remember{} to CameraViewModel for
surviving configuration changes. Add processing overlay indicator,
accessibility semantics on interactive elements, gallery preview
with Cancel/Apply flow, and consistent bottom bar layout.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Detect when camera permission is permanently denied (not granted and
rationale not shown) and offer an "Open Settings" button instead of
a non-functional "Grant" button. Use centralized AppColors.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Increase ZoomButton to 48dp minimum touch target, add Role.Button and
contentDescription/selected semantics, and use centralized AppColors.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace ContextCompat.getMainExecutor with a dedicated single-thread
executor for image capture callbacks. This prevents bitmap decode,
rotation, and tilt-shift effect processing from blocking the UI thread
and causing jank or ANR on slower devices.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Draw all guide lines with a dark outline behind the accent color
to ensure visibility over bright/amber camera scenes
- Clamp gesture zone focus size to MIN_FOCUS_SIZE_PX (150px) so
rotation zone remains usable at small focus sizes
- Add semantics contentDescription to Canvas for TalkBack
- Use AppColors for centralized color references
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Create CameraViewModel with AndroidViewModel to survive configuration
changes (rotation). All blur params, capture state, gallery preview,
and thumbnail state now live in StateFlow fields
- Create AppColors object to centralize the 12+ hardcoded color literals
into a single source of truth
- Add lifecycle-viewmodel-compose dependency
Fixes: state lost on rotation, orphaned capture coroutines on config
change, accent color maintenance risk
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Null the Compose state reference before recycling bitmaps to prevent
the renderer from drawing a recycled bitmap between recycle() and
the state update
- Wrap ACTION_VIEW startActivity in try-catch for devices without
an image viewer installed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace unsafe as Any / as SaveResult casts with a sealed
CaptureOutcome class for type-safe continuation handling
- Catch SecurityException separately with permission-specific messages
- Replace raw e.message with generic user-friendly error strings
- Add inJustDecodeBounds pre-check in loadBitmapFromUri to downsample
images exceeding 4096px, preventing OOM from huge gallery images
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Catch SecurityException separately for storage permission revocation
- Replace raw e.message with generic user-friendly error strings
- Replace thread-unsafe SimpleDateFormat with java.time.DateTimeFormatter
to prevent filename collisions under concurrent saves on Dispatchers.IO
- Remove deprecated MediaStore.Images.Media.DATA column query and the
path field from SaveResult.Success (unreliable on scoped storage)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Catch SecurityException separately with permission-specific message
- Replace raw e.message with generic user-friendly error strings
- Wrap cameraProviderFuture.get() in try-catch to handle CameraX
initialization failures instead of crashing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
A revoked location permission was previously caught and sent as null
with zero logging, making it indistinguishable from "no fix yet" and
impossible to diagnose.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use safe cast (as? VibratorManager) and wrap vibrate calls in
try-catch to prevent crashes on emulators, custom ROMs, or
devices without vibration hardware.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Uncomment *.jks and *.keystore in .gitignore to prevent
accidental keystore commits
- Disable android:allowBackup to prevent ADB data extraction
- Add distributionSha256Sum to gradle-wrapper.properties for
tamper detection of Gradle distributions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of immediately processing gallery images, show a preview where
users can adjust blur parameters before committing. Adds Cancel/Apply
buttons and hides camera-only controls during gallery preview mode.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Increase bottom padding and add systemGestureExclusion to prevent
accidental navigation gestures from the capture controls
- Save both original and processed images with shared timestamps
(TILTSHIFT_* and ORIGINAL_*) via new saveBitmapPair() pipeline
- Show animated thumbnail of last captured photo at bottom-right;
tap opens the image in the default photo viewer
- Add gallery picker button to process existing photos through the
tilt-shift pipeline with full EXIF rotation support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The feature provided no benefit on Pixel 7 Pro — both standard and
hi-res modes produced 12MP images since CameraX's standard resolution
list doesn't include the full sensor output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add useHighResCapture toggle to CameraManager that switches between
CameraX default resolution and HIGHEST_AVAILABLE_STRATEGY. Default
is off to avoid OOM from processing very large bitmaps (e.g. 50MP).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
With minSdk=35 all Build.VERSION.SDK_INT checks for API levels below
35 are always true. Remove all version branching in HapticFeedback
(API 29/31 checks) and PhotoSaver (API 29 checks). Keep only the
modern API calls and drop @Suppress("DEPRECATION") annotations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Delete ExifWriter.kt (instantiated but never called)
- Remove saveJpegFile() and unused imports from PhotoSaver
- Remove CameraFlipButton() and unused imports from LensSwitcher
- Remove companion object and unused imports from HapticFeedback
- Remove getZoomPresets() from LensController
- Update README to reflect ExifWriter removal and actual minSdk (35)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EXIF write failures are non-critical (the photo is already saved)
but should still be visible in logcat. Use Log.w with a proper TAG
instead of printStackTrace().
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add an error StateFlow to CameraManager so camera binding failures
are surfaced to the user instead of silently swallowed by
e.printStackTrace(). CameraScreen collects this flow and displays
errors using the existing red overlay UI. Added Log.e with proper
TAG for logcat visibility.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Track the current bitmap through the decode→rotate→effect pipeline
with a nullable variable. On exception, the in-flight bitmap is
recycled in the catch block to prevent native memory leaks. Errors
are now logged with Log.e and a proper companion TAG.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Track all intermediate bitmaps with nullable variables and recycle
them in a finally block. This prevents native memory leaks when an
OOM or other exception occurs mid-processing. Variables are set to
null after recycle or handoff to the caller.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
runBlocking on the camera callback thread could deadlock when
saveBitmap() needed the main thread. Split capturePhoto() into two
phases: synchronous CPU work (decode/rotate/effect) inside the
suspendCancellableCoroutine callback, and suspend-safe saveBitmap()
after the continuation resumes in coroutine context.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Load signing credentials from local.properties (not committed)
- Keystore stored in .signing/ directory (not committed)
- Release builds are now signed and installable
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add uIsFrontCamera uniform to shader and adjust coordinate
transformations for front camera's mirrored texture coordinates:
- Position transform: (1-posY, 1-posX) instead of (posY, 1-posX)
- Angle transform: -angle - 90° instead of +angle + 90°
Applied to linearFocusDistance, radialFocusDistance, and blur
direction calculations in sampleBlurred.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>