Initial implementation of Tilt-Shift Camera Android app

A dedicated camera app for tilt-shift photography with:
- Real-time OpenGL ES 2.0 shader-based blur preview
- Touch gesture controls (drag, rotate, pinch) for adjusting effect
- CameraX integration for camera preview and high-res capture
- EXIF metadata with GPS location support
- MediaStore integration for saving to gallery
- Jetpack Compose UI with haptic feedback

Tech stack: Kotlin, CameraX, OpenGL ES 2.0, Jetpack Compose
Min SDK: 26 (Android 8.0), Target SDK: 35 (Android 15)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-01-28 15:26:41 +01:00
commit 07e10ac9c3
38 changed files with 3489 additions and 0 deletions

View file

@ -0,0 +1,173 @@
package no.naiv.tiltshift.camera
import android.content.Context
import android.graphics.SurfaceTexture
import android.util.Size
import android.view.Surface
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceRequest
import androidx.camera.core.resolutionselector.AspectRatioStrategy
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.Executor
/**
* Manages CameraX camera setup and controls.
*/
class CameraManager(private val context: Context) {
private var cameraProvider: ProcessCameraProvider? = null
private var camera: Camera? = null
private var preview: Preview? = null
var imageCapture: ImageCapture? = null
private set
val lensController = LensController()
private val _zoomRatio = MutableStateFlow(1.0f)
val zoomRatio: StateFlow<Float> = _zoomRatio.asStateFlow()
private val _minZoomRatio = MutableStateFlow(1.0f)
val minZoomRatio: StateFlow<Float> = _minZoomRatio.asStateFlow()
private val _maxZoomRatio = MutableStateFlow(1.0f)
val maxZoomRatio: StateFlow<Float> = _maxZoomRatio.asStateFlow()
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
private var surfaceSize: Size = Size(1920, 1080)
/**
* Starts the camera with the given lifecycle owner.
* The surfaceTextureProvider should return the SurfaceTexture from the GL renderer.
*/
fun startCamera(
lifecycleOwner: LifecycleOwner,
surfaceTextureProvider: () -> SurfaceTexture?
) {
this.surfaceTextureProvider = surfaceTextureProvider
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
cameraProvider = cameraProviderFuture.get()
lensController.initialize(cameraProvider?.availableCameraInfos ?: emptyList())
bindCameraUseCases(lifecycleOwner)
}, ContextCompat.getMainExecutor(context))
}
private fun bindCameraUseCases(lifecycleOwner: LifecycleOwner) {
val provider = cameraProvider ?: return
// Unbind all use cases before rebinding
provider.unbindAll()
// Preview use case with resolution selector
val resolutionSelector = ResolutionSelector.Builder()
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
.build()
preview = Preview.Builder()
.setResolutionSelector(resolutionSelector)
.build()
// Image capture use case for high-res photos
imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
.build()
// Get camera selector from lens controller
val cameraSelector = lensController.getCurrentLens()?.selector
?: CameraSelector.DEFAULT_BACK_CAMERA
try {
// Bind use cases to camera
camera = provider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture
)
// Update zoom info
camera?.cameraInfo?.let { info ->
_minZoomRatio.value = info.zoomState.value?.minZoomRatio ?: 1.0f
_maxZoomRatio.value = info.zoomState.value?.maxZoomRatio ?: 1.0f
_zoomRatio.value = info.zoomState.value?.zoomRatio ?: 1.0f
}
// Set up surface provider for preview
preview?.setSurfaceProvider { request ->
provideSurface(request)
}
} catch (e: Exception) {
// Camera binding failed
e.printStackTrace()
}
}
private fun provideSurface(request: SurfaceRequest) {
val surfaceTexture = surfaceTextureProvider?.invoke()
if (surfaceTexture == null) {
request.willNotProvideSurface()
return
}
surfaceSize = request.resolution
surfaceTexture.setDefaultBufferSize(surfaceSize.width, surfaceSize.height)
val surface = Surface(surfaceTexture)
request.provideSurface(surface, ContextCompat.getMainExecutor(context)) { result ->
surface.release()
}
}
/**
* Sets the zoom ratio.
*/
fun setZoom(ratio: Float) {
val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value)
camera?.cameraControl?.setZoomRatio(clamped)
_zoomRatio.value = clamped
}
/**
* Sets zoom by linear percentage (0.0 to 1.0).
*/
fun setZoomLinear(percentage: Float) {
camera?.cameraControl?.setLinearZoom(percentage.coerceIn(0f, 1f))
}
/**
* Switches to a different lens.
*/
fun switchLens(lensId: String, lifecycleOwner: LifecycleOwner) {
if (lensController.selectLens(lensId)) {
bindCameraUseCases(lifecycleOwner)
}
}
/**
* Gets the executor for image capture callbacks.
*/
fun getExecutor(): Executor {
return ContextCompat.getMainExecutor(context)
}
/**
* Releases camera resources.
*/
fun release() {
cameraProvider?.unbindAll()
camera = null
preview = null
imageCapture = null
}
}

View file

@ -0,0 +1,434 @@
package no.naiv.tiltshift.camera
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RenderEffect
import android.graphics.Shader
import android.location.Location
import android.os.Build
import android.view.Surface
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy
import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.suspendCancellableCoroutine
import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.storage.PhotoSaver
import no.naiv.tiltshift.storage.SaveResult
import no.naiv.tiltshift.util.OrientationDetector
import java.io.File
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.math.cos
import kotlin.math.sin
/**
* Handles capturing photos with the tilt-shift effect applied.
*/
class ImageCaptureHandler(
private val context: Context,
private val photoSaver: PhotoSaver
) {
/**
* Captures a photo and applies the tilt-shift effect.
*/
suspend fun capturePhoto(
imageCapture: ImageCapture,
executor: Executor,
blurParams: BlurParameters,
deviceRotation: Int,
location: Location?,
isFrontCamera: Boolean
): SaveResult = suspendCancellableCoroutine { continuation ->
imageCapture.takePicture(
executor,
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(imageProxy: ImageProxy) {
try {
// Convert ImageProxy to Bitmap
val bitmap = imageProxyToBitmap(imageProxy)
imageProxy.close()
if (bitmap == null) {
continuation.resume(SaveResult.Error("Failed to convert image"))
return
}
// Apply tilt-shift effect to captured image
val processedBitmap = applyTiltShiftEffect(bitmap, blurParams)
bitmap.recycle()
// Determine EXIF orientation
val rotationDegrees = OrientationDetector.rotationToDegrees(deviceRotation)
val exifOrientation = OrientationDetector.degreesToExifOrientation(
rotationDegrees, isFrontCamera
)
// Save with EXIF data
kotlinx.coroutines.runBlocking {
val result = photoSaver.saveBitmap(
processedBitmap,
exifOrientation,
location
)
processedBitmap.recycle()
continuation.resume(result)
}
} catch (e: Exception) {
continuation.resume(SaveResult.Error("Capture failed: ${e.message}", e))
}
}
override fun onError(exception: ImageCaptureException) {
continuation.resume(
SaveResult.Error("Capture failed: ${exception.message}", exception)
)
}
}
)
}
private fun imageProxyToBitmap(imageProxy: ImageProxy): Bitmap? {
val buffer = imageProxy.planes[0].buffer
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}
/**
* Applies tilt-shift blur effect to a bitmap.
* This is a software fallback - on newer devices we could use RenderScript/RenderEffect.
*/
private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap {
val width = source.width
val height = source.height
// Create output bitmap
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
// For performance, we use a scaled-down version for blur and composite
val scaleFactor = 4 // Blur a 1/4 size image for speed
val blurredWidth = width / scaleFactor
val blurredHeight = height / scaleFactor
// Create scaled bitmap for blur
val scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
// Apply stack blur (fast approximation)
val blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
scaled.recycle()
// Scale blurred back up
val blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true)
blurred.recycle()
// Create gradient mask based on tilt-shift parameters
val mask = createGradientMask(width, height, params)
// Composite: blend original with blurred based on mask
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val pixels = IntArray(width * height)
val blurredPixels = IntArray(width * height)
val maskPixels = IntArray(width * height)
source.getPixels(pixels, 0, width, 0, 0, width, height)
blurredFullSize.getPixels(blurredPixels, 0, width, 0, 0, width, height)
mask.getPixels(maskPixels, 0, width, 0, 0, width, height)
blurredFullSize.recycle()
mask.recycle()
for (i in pixels.indices) {
val maskAlpha = (maskPixels[i] and 0xFF) / 255f
val origR = (pixels[i] shr 16) and 0xFF
val origG = (pixels[i] shr 8) and 0xFF
val origB = pixels[i] and 0xFF
val blurR = (blurredPixels[i] shr 16) and 0xFF
val blurG = (blurredPixels[i] shr 8) and 0xFF
val blurB = blurredPixels[i] and 0xFF
val r = (origR * (1 - maskAlpha) + blurR * maskAlpha).toInt()
val g = (origG * (1 - maskAlpha) + blurG * maskAlpha).toInt()
val b = (origB * (1 - maskAlpha) + blurB * maskAlpha).toInt()
pixels[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
}
result.setPixels(pixels, 0, width, 0, 0, width, height)
return result
}
/**
* Creates a gradient mask for the tilt-shift effect.
*/
private fun createGradientMask(width: Int, height: Int, params: BlurParameters): Bitmap {
val mask = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val pixels = IntArray(width * height)
val centerY = height * params.position
val focusHalfHeight = height * params.size * 0.5f
val transitionHeight = focusHalfHeight * 0.5f
val cosAngle = cos(params.angle)
val sinAngle = sin(params.angle)
for (y in 0 until height) {
for (x in 0 until width) {
// Rotate point around center
val dx = x - width / 2f
val dy = y - centerY
val rotatedY = -dx * sinAngle + dy * cosAngle
// Calculate blur amount based on distance from focus line
val dist = kotlin.math.abs(rotatedY)
val blurAmount = when {
dist < focusHalfHeight -> 0f
dist < focusHalfHeight + transitionHeight -> {
(dist - focusHalfHeight) / transitionHeight
}
else -> 1f
}
val gray = (blurAmount * 255).toInt().coerceIn(0, 255)
pixels[y * width + x] = (0xFF shl 24) or (gray shl 16) or (gray shl 8) or gray
}
}
mask.setPixels(pixels, 0, width, 0, 0, width, height)
return mask
}
/**
* Fast stack blur algorithm.
*/
private fun stackBlur(bitmap: Bitmap, radius: Int): Bitmap {
if (radius < 1) return bitmap.copy(Bitmap.Config.ARGB_8888, true)
val w = bitmap.width
val h = bitmap.height
val pix = IntArray(w * h)
bitmap.getPixels(pix, 0, w, 0, 0, w, h)
val wm = w - 1
val hm = h - 1
val wh = w * h
val div = radius + radius + 1
val r = IntArray(wh)
val g = IntArray(wh)
val b = IntArray(wh)
var rsum: Int
var gsum: Int
var bsum: Int
var x: Int
var y: Int
var i: Int
var p: Int
var yp: Int
var yi: Int
var yw: Int
val vmin = IntArray(maxOf(w, h))
var divsum = (div + 1) shr 1
divsum *= divsum
val dv = IntArray(256 * divsum)
for (i2 in 0 until 256 * divsum) {
dv[i2] = (i2 / divsum)
}
yi = 0
yw = 0
val stack = Array(div) { IntArray(3) }
var stackpointer: Int
var stackstart: Int
var sir: IntArray
var rbs: Int
val r1 = radius + 1
var routsum: Int
var goutsum: Int
var boutsum: Int
var rinsum: Int
var ginsum: Int
var binsum: Int
for (y2 in 0 until h) {
rinsum = 0
ginsum = 0
binsum = 0
routsum = 0
goutsum = 0
boutsum = 0
rsum = 0
gsum = 0
bsum = 0
for (i2 in -radius..radius) {
p = pix[yi + minOf(wm, maxOf(i2, 0))]
sir = stack[i2 + radius]
sir[0] = (p and 0xff0000) shr 16
sir[1] = (p and 0x00ff00) shr 8
sir[2] = (p and 0x0000ff)
rbs = r1 - kotlin.math.abs(i2)
rsum += sir[0] * rbs
gsum += sir[1] * rbs
bsum += sir[2] * rbs
if (i2 > 0) {
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
} else {
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
}
}
stackpointer = radius
for (x2 in 0 until w) {
r[yi] = dv[rsum]
g[yi] = dv[gsum]
b[yi] = dv[bsum]
rsum -= routsum
gsum -= goutsum
bsum -= boutsum
stackstart = stackpointer - radius + div
sir = stack[stackstart % div]
routsum -= sir[0]
goutsum -= sir[1]
boutsum -= sir[2]
if (y2 == 0) {
vmin[x2] = minOf(x2 + radius + 1, wm)
}
p = pix[yw + vmin[x2]]
sir[0] = (p and 0xff0000) shr 16
sir[1] = (p and 0x00ff00) shr 8
sir[2] = (p and 0x0000ff)
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
rsum += rinsum
gsum += ginsum
bsum += binsum
stackpointer = (stackpointer + 1) % div
sir = stack[(stackpointer) % div]
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
rinsum -= sir[0]
ginsum -= sir[1]
binsum -= sir[2]
yi++
}
yw += w
}
for (x2 in 0 until w) {
rinsum = 0
ginsum = 0
binsum = 0
routsum = 0
goutsum = 0
boutsum = 0
rsum = 0
gsum = 0
bsum = 0
yp = -radius * w
for (i2 in -radius..radius) {
yi = maxOf(0, yp) + x2
sir = stack[i2 + radius]
sir[0] = r[yi]
sir[1] = g[yi]
sir[2] = b[yi]
rbs = r1 - kotlin.math.abs(i2)
rsum += r[yi] * rbs
gsum += g[yi] * rbs
bsum += b[yi] * rbs
if (i2 > 0) {
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
} else {
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
}
if (i2 < hm) {
yp += w
}
}
yi = x2
stackpointer = radius
for (y2 in 0 until h) {
pix[yi] = (0xff000000.toInt() and pix[yi]) or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum]
rsum -= routsum
gsum -= goutsum
bsum -= boutsum
stackstart = stackpointer - radius + div
sir = stack[stackstart % div]
routsum -= sir[0]
goutsum -= sir[1]
boutsum -= sir[2]
if (x2 == 0) {
vmin[y2] = minOf(y2 + r1, hm) * w
}
p = x2 + vmin[y2]
sir[0] = r[p]
sir[1] = g[p]
sir[2] = b[p]
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
rsum += rinsum
gsum += ginsum
bsum += binsum
stackpointer = (stackpointer + 1) % div
sir = stack[stackpointer]
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
rinsum -= sir[0]
ginsum -= sir[1]
binsum -= sir[2]
yi += w
}
}
val result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
result.setPixels(pix, 0, w, 0, 0, w, h)
return result
}
}

View file

@ -0,0 +1,98 @@
package no.naiv.tiltshift.camera
import androidx.camera.core.CameraInfo
import androidx.camera.core.CameraSelector
/**
* Represents available camera lenses on the device.
*/
data class CameraLens(
val id: String,
val displayName: String,
val zoomFactor: Float,
val selector: CameraSelector
)
/**
* Controls lens selection and provides information about available lenses.
*/
class LensController {
private val availableLenses = mutableListOf<CameraLens>()
private var currentLensIndex = 0
/**
* Initializes available lenses based on device capabilities.
* Should be called after CameraProvider is available.
*/
fun initialize(cameraInfos: List<CameraInfo>) {
availableLenses.clear()
// Check for back cameras (main, ultrawide, telephoto)
val hasBackCamera = cameraInfos.any {
it.lensFacing == CameraSelector.LENS_FACING_BACK
}
if (hasBackCamera) {
// Standard back camera
availableLenses.add(
CameraLens(
id = "back_main",
displayName = "1x",
zoomFactor = 1.0f,
selector = CameraSelector.DEFAULT_BACK_CAMERA
)
)
}
// Set default to main back camera
currentLensIndex = availableLenses.indexOfFirst { it.id == "back_main" }
.coerceAtLeast(0)
}
/**
* Returns all available lenses.
*/
fun getAvailableLenses(): List<CameraLens> = availableLenses.toList()
/**
* Returns the currently selected lens.
*/
fun getCurrentLens(): CameraLens? {
return if (availableLenses.isNotEmpty()) {
availableLenses[currentLensIndex]
} else null
}
/**
* Selects a specific lens by ID.
* Returns true if the lens was found and selected.
*/
fun selectLens(lensId: String): Boolean {
val index = availableLenses.indexOfFirst { it.id == lensId }
if (index >= 0) {
currentLensIndex = index
return true
}
return false
}
/**
* Cycles to the next available lens.
* Returns the newly selected lens, or null if no lenses available.
*/
fun cycleToNextLens(): CameraLens? {
if (availableLenses.isEmpty()) return null
currentLensIndex = (currentLensIndex + 1) % availableLenses.size
return availableLenses[currentLensIndex]
}
/**
* Common zoom levels that can be achieved through digital zoom.
* These are presented as quick-select buttons.
*/
fun getZoomPresets(): List<Float> {
return listOf(0.5f, 1.0f, 2.0f, 5.0f)
}
}