Compare commits

...

4 commits

Author SHA1 Message Date
f3baa723be Implement two-pass separable Gaussian blur for camera preview
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>
2026-03-18 17:38:50 +01:00
aab1ff38a4 Add unit tests for BlurParameters and LensController
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>
2026-03-18 16:48:21 +01:00
c58c45c52c Remove unnecessary ProGuard keep rule and extract SaveResult to own file
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>
2026-03-18 16:45:22 +01:00
878c23bf89 Replace Accompanist Permissions with first-party activity-compose API
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>
2026-03-18 16:43:56 +01:00
16 changed files with 681 additions and 344 deletions

View file

@ -99,6 +99,5 @@ Bitmaps emitted to `StateFlow`s are **never eagerly recycled** immediately after
## 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.

View file

@ -112,8 +112,8 @@ dependencies {
// Location
implementation(libs.play.services.location)
// Permissions
implementation(libs.accompanist.permissions)
// Test
testImplementation(libs.junit)
// Debug
debugImplementation(libs.androidx.ui.tooling)

View file

@ -1,5 +1,2 @@
# Add project specific ProGuard rules here.
# CameraX and GMS Location ship their own consumer ProGuard rules.
# Keep OpenGL shader-related code (accessed via reflection by GLSL pipeline)
-keep class no.naiv.tiltshift.effect.** { *; }

View file

@ -2,12 +2,15 @@ package no.naiv.tiltshift
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -26,23 +29,24 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import no.naiv.tiltshift.ui.CameraScreen
import no.naiv.tiltshift.ui.theme.AppColors
@ -66,21 +70,47 @@ class MainActivity : ComponentActivity() {
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun TiltShiftApp() {
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
val locationPermissions = rememberMultiplePermissionsState(
listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
val context = LocalContext.current
val activity = context as? ComponentActivity
var cameraGranted by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED
)
)
}
var locationGranted by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
== PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
== PackageManager.PERMISSION_GRANTED
)
}
// Track whether the camera permission dialog has returned a result,
// so we can distinguish "never asked" from "permanently denied"
var cameraResultReceived by remember { mutableStateOf(false) }
val cameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
cameraGranted = granted
cameraResultReceived = true
}
val locationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
locationGranted = permissions.values.any { it }
}
// Request camera permission on launch
LaunchedEffect(Unit) {
if (!cameraPermission.status.isGranted) {
cameraPermission.launchPermissionRequest()
if (!cameraGranted) {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
@ -90,28 +120,47 @@ private fun TiltShiftApp() {
.background(Color.Black)
) {
when {
cameraPermission.status.isGranted -> {
cameraGranted -> {
// Camera permission granted - show camera
CameraScreen()
// Request location in background (for EXIF GPS)
LaunchedEffect(Unit) {
if (!locationPermissions.allPermissionsGranted) {
locationPermissions.launchMultiplePermissionRequest()
if (!locationGranted) {
locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
}
}
}
else -> {
// Permanently denied: not granted AND rationale not shown
val cameraPermanentlyDenied = !cameraPermission.status.isGranted &&
!cameraPermission.status.shouldShowRationale
// Permanently denied: user has responded to the dialog, but permission
// is still denied and the system won't show the dialog again
val cameraPermanentlyDenied = cameraResultReceived &&
activity?.let {
!ActivityCompat.shouldShowRequestPermissionRationale(
it, Manifest.permission.CAMERA
)
} ?: false
// Show permission request UI
PermissionRequestScreen(
onRequestCamera = { cameraPermission.launchPermissionRequest() },
onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() },
cameraGranted = cameraPermission.status.isGranted,
locationGranted = locationPermissions.allPermissionsGranted,
onRequestCamera = {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
},
onRequestLocation = {
locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
},
cameraGranted = false,
locationGranted = locationGranted,
cameraPermanentlyDenied = cameraPermanentlyDenied
)
}

View file

@ -174,24 +174,14 @@ class CameraManager(private val context: Context) {
}
/**
* Sets the zoom ratio. Updates UI state only after the camera confirms the change.
* Sets the zoom ratio. Updates UI state immediately so that rapid pinch-to-zoom
* gestures accumulate correctly (each frame uses the latest ratio as its base).
* If the camera rejects the value, the next successful set corrects the state.
*/
fun setZoom(ratio: Float) {
val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value)
val future = camera?.cameraControl?.setZoomRatio(clamped)
if (future != null) {
future.addListener({
try {
future.get()
_zoomRatio.value = clamped
} catch (e: Exception) {
Log.w(TAG, "Zoom operation failed", e)
}
}, ContextCompat.getMainExecutor(context))
} else {
// Optimistic update when camera not available (e.g. during init)
_zoomRatio.value = clamped
}
_zoomRatio.value = clamped
camera?.cameraControl?.setZoomRatio(clamped)
}
/**

View file

@ -5,6 +5,7 @@ import android.graphics.SurfaceTexture
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.opengl.GLSurfaceView
import android.util.Log
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
@ -12,10 +13,16 @@ import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10
/**
* OpenGL renderer for applying tilt-shift effect to camera preview.
* OpenGL renderer for applying tilt-shift effect to camera preview
* using a two-pass separable Gaussian blur.
*
* This renderer receives camera frames via SurfaceTexture and applies
* the tilt-shift blur effect using GLSL shaders.
* Rendering pipeline (3 draw calls per frame):
* 1. **Passthrough**: camera texture FBO-A (handles coordinate transform via vertex/texcoord)
* 2. **Horizontal blur**: FBO-A FBO-B (13-tap Gaussian, tilt-shift mask)
* 3. **Vertical blur**: FBO-B screen (13-tap Gaussian, tilt-shift mask)
*
* The passthrough decouples the camera's rotated coordinate system from the blur
* passes, which work entirely in screen space.
*/
class TiltShiftRenderer(
private val context: Context,
@ -23,16 +30,30 @@ class TiltShiftRenderer(
private val onFrameAvailable: () -> Unit
) : GLSurfaceView.Renderer {
companion object {
private const val TAG = "TiltShiftRenderer"
}
private lateinit var shader: TiltShiftShader
private var surfaceTexture: SurfaceTexture? = null
private var cameraTextureId: Int = 0
private lateinit var vertexBuffer: FloatBuffer
private lateinit var texCoordBuffer: FloatBuffer
// Camera quad: crop-to-fill vertices + rotated texcoords (pass 1 only)
private lateinit var cameraVertexBuffer: FloatBuffer
private lateinit var cameraTexCoordBuffer: FloatBuffer
// Fullscreen quad for blur passes (no crop, standard texcoords)
private lateinit var fullscreenVertexBuffer: FloatBuffer
private lateinit var fullscreenTexCoordBuffer: FloatBuffer
private var surfaceWidth: Int = 0
private var surfaceHeight: Int = 0
// FBO resources: one framebuffer, two color textures for ping-pong
private var fboId: Int = 0
private var fboTexA: Int = 0
private var fboTexB: Int = 0
// Current effect parameters (updated from UI thread)
@Volatile
var blurParameters: BlurParameters = BlurParameters.DEFAULT
@ -69,27 +90,33 @@ class TiltShiftRenderer(
@Volatile
private var currentTexCoords = texCoordsBack
@Volatile
private var updateTexCoordBuffer = false
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
GLES20.glClearColor(0f, 0f, 0f, 1f)
// Initialize shader
shader = TiltShiftShader(context)
shader.initialize()
// Allocate vertex buffer (8 floats = 4 vertices × 2 components)
vertexBuffer = ByteBuffer.allocateDirect(8 * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
// Fill with default full-screen quad; will be recomputed when camera resolution is known
vertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
vertexBuffer.position(0)
// Camera quad vertex buffer (crop-to-fill, recomputed when resolution is known)
cameraVertexBuffer = allocateFloatBuffer(8)
cameraVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
cameraVertexBuffer.position(0)
// Create texture coordinate buffer
texCoordBuffer = ByteBuffer.allocateDirect(currentTexCoords.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(currentTexCoords)
texCoordBuffer.position(0)
// Camera texcoord buffer (rotated for portrait)
cameraTexCoordBuffer = allocateFloatBuffer(8)
cameraTexCoordBuffer.put(currentTexCoords)
cameraTexCoordBuffer.position(0)
// Fullscreen quad for blur passes (standard coords)
fullscreenVertexBuffer = allocateFloatBuffer(8)
fullscreenVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
fullscreenVertexBuffer.position(0)
fullscreenTexCoordBuffer = allocateFloatBuffer(8)
fullscreenTexCoordBuffer.put(floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f))
fullscreenTexCoordBuffer.position(0)
// Create camera texture
val textures = IntArray(1)
@ -114,88 +141,75 @@ class TiltShiftRenderer(
surfaceWidth = width
surfaceHeight = height
vertexBufferDirty = true
recreateFBOs(width, height)
}
override fun onDrawFrame(gl: GL10?) {
// Update texture with latest camera frame
surfaceTexture?.updateTexImage()
// Recompute vertex buffer for crop-to-fill when camera or surface dimensions change
if (vertexBufferDirty) {
recomputeVertices()
vertexBufferDirty = false
}
// Update texture coordinate buffer if camera changed
if (updateTexCoordBuffer) {
texCoordBuffer.clear()
texCoordBuffer.put(currentTexCoords)
texCoordBuffer.position(0)
cameraTexCoordBuffer.clear()
cameraTexCoordBuffer.put(currentTexCoords)
cameraTexCoordBuffer.position(0)
updateTexCoordBuffer = false
}
val params = blurParameters
// --- Pass 1: Camera → FBO-A (passthrough with crop-to-fill) ---
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fboId)
GLES20.glFramebufferTexture2D(
GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
GLES20.GL_TEXTURE_2D, fboTexA, 0
)
GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
// Use shader and set parameters
shader.use(cameraTextureId, blurParameters, surfaceWidth, surfaceHeight, isFrontCamera)
// Set vertex positions
GLES20.glEnableVertexAttribArray(shader.aPositionLocation)
GLES20.glVertexAttribPointer(
shader.aPositionLocation,
2,
GLES20.GL_FLOAT,
false,
0,
vertexBuffer
shader.usePassthrough(cameraTextureId)
drawQuad(
shader.passthroughPositionLoc, shader.passthroughTexCoordLoc,
cameraVertexBuffer, cameraTexCoordBuffer
)
// Set texture coordinates
GLES20.glEnableVertexAttribArray(shader.aTexCoordLocation)
GLES20.glVertexAttribPointer(
shader.aTexCoordLocation,
2,
GLES20.GL_FLOAT,
false,
0,
texCoordBuffer
// --- Pass 2: FBO-A → FBO-B (horizontal blur) ---
GLES20.glFramebufferTexture2D(
GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
GLES20.GL_TEXTURE_2D, fboTexB, 0
)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
shader.useBlurPass(fboTexA, params, surfaceWidth, surfaceHeight, 1f, 0f)
drawQuad(
shader.blurPositionLoc, shader.blurTexCoordLoc,
fullscreenVertexBuffer, fullscreenTexCoordBuffer
)
// Draw quad
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
// Cleanup
GLES20.glDisableVertexAttribArray(shader.aPositionLocation)
GLES20.glDisableVertexAttribArray(shader.aTexCoordLocation)
// --- Pass 3: FBO-B → screen (vertical blur) ---
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0)
GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
shader.useBlurPass(fboTexB, params, surfaceWidth, surfaceHeight, 0f, 1f)
drawQuad(
shader.blurPositionLoc, shader.blurTexCoordLoc,
fullscreenVertexBuffer, fullscreenTexCoordBuffer
)
}
/**
* Updates blur parameters. Thread-safe.
*/
fun updateParameters(params: BlurParameters) {
blurParameters = params
}
/**
* Sets whether using front camera. Updates texture coordinates accordingly.
* Thread-safe - actual buffer update happens on next frame.
*/
fun setFrontCamera(front: Boolean) {
if (isFrontCamera != front) {
isFrontCamera = front
currentTexCoords = if (front) texCoordsFront else texCoordsBack
// Buffer will be updated on next draw
updateTexCoordBuffer = true
}
}
@Volatile
private var updateTexCoordBuffer = false
/**
* Sets the camera preview resolution for crop-to-fill aspect ratio correction.
* Thread-safe vertex buffer is recomputed on the next frame.
*/
fun setCameraResolution(width: Int, height: Int) {
if (cameraWidth != width || cameraHeight != height) {
cameraWidth = width
@ -204,45 +218,6 @@ class TiltShiftRenderer(
}
}
/**
* Recomputes vertex positions to achieve crop-to-fill.
*
* The camera sensor is landscape; after the 90° rotation applied via texture coordinates,
* the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex
* quad so the camera frame fills the screen without stretching the GPU clips the overflow.
*/
private fun recomputeVertices() {
var scaleX = 1f
var scaleY = 1f
if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) {
// After 90° rotation: portrait width = cameraHeight, portrait height = cameraWidth
val cameraRatio = cameraHeight.toFloat() / cameraWidth
val screenRatio = surfaceWidth.toFloat() / surfaceHeight
if (cameraRatio > screenRatio) {
// Camera wider than screen → crop sides
scaleX = cameraRatio / screenRatio
} else {
// Camera taller than screen → crop top/bottom
scaleY = screenRatio / cameraRatio
}
}
vertexBuffer.clear()
vertexBuffer.put(floatArrayOf(
-scaleX, -scaleY,
scaleX, -scaleY,
-scaleX, scaleY,
scaleX, scaleY
))
vertexBuffer.position(0)
}
/**
* Releases OpenGL resources.
* Must be called from GL thread.
*/
fun release() {
shader.release()
surfaceTexture?.release()
@ -252,5 +227,117 @@ class TiltShiftRenderer(
GLES20.glDeleteTextures(1, intArrayOf(cameraTextureId), 0)
cameraTextureId = 0
}
deleteFBOs()
}
// --- Private helpers ---
private fun drawQuad(
positionLoc: Int,
texCoordLoc: Int,
vertices: FloatBuffer,
texCoords: FloatBuffer
) {
GLES20.glEnableVertexAttribArray(positionLoc)
GLES20.glVertexAttribPointer(positionLoc, 2, GLES20.GL_FLOAT, false, 0, vertices)
GLES20.glEnableVertexAttribArray(texCoordLoc)
GLES20.glVertexAttribPointer(texCoordLoc, 2, GLES20.GL_FLOAT, false, 0, texCoords)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
GLES20.glDisableVertexAttribArray(positionLoc)
GLES20.glDisableVertexAttribArray(texCoordLoc)
}
/**
* Recomputes camera vertex positions to achieve crop-to-fill.
*
* The camera sensor is landscape; after the 90° rotation applied via texture coordinates,
* the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex
* quad so the camera frame fills the surface without stretching the GPU clips the overflow.
*/
private fun recomputeVertices() {
var scaleX = 1f
var scaleY = 1f
if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) {
val cameraRatio = cameraHeight.toFloat() / cameraWidth
val screenRatio = surfaceWidth.toFloat() / surfaceHeight
if (cameraRatio > screenRatio) {
scaleX = cameraRatio / screenRatio
} else {
scaleY = screenRatio / cameraRatio
}
}
cameraVertexBuffer.clear()
cameraVertexBuffer.put(floatArrayOf(
-scaleX, -scaleY,
scaleX, -scaleY,
-scaleX, scaleY,
scaleX, scaleY
))
cameraVertexBuffer.position(0)
}
private fun recreateFBOs(width: Int, height: Int) {
deleteFBOs()
// Create two color textures for ping-pong
val texIds = IntArray(2)
GLES20.glGenTextures(2, texIds, 0)
fboTexA = texIds[0]
fboTexB = texIds[1]
for (texId in texIds) {
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texId)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexImage2D(
GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA,
width, height, 0,
GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null
)
}
// Create single FBO (we swap the attached texture for ping-pong)
val fbos = IntArray(1)
GLES20.glGenFramebuffers(1, fbos, 0)
fboId = fbos[0]
// Verify with texture A
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fboId)
GLES20.glFramebufferTexture2D(
GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
GLES20.GL_TEXTURE_2D, fboTexA, 0
)
val status = GLES20.glCheckFramebufferStatus(GLES20.GL_FRAMEBUFFER)
if (status != GLES20.GL_FRAMEBUFFER_COMPLETE) {
Log.e(TAG, "FBO incomplete: $status")
}
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0)
}
private fun deleteFBOs() {
if (fboId != 0) {
GLES20.glDeleteFramebuffers(1, intArrayOf(fboId), 0)
fboId = 0
}
if (fboTexA != 0 || fboTexB != 0) {
GLES20.glDeleteTextures(2, intArrayOf(fboTexA, fboTexB), 0)
fboTexA = 0
fboTexB = 0
}
}
private fun allocateFloatBuffer(floatCount: Int): FloatBuffer {
return ByteBuffer.allocateDirect(floatCount * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
}
}

View file

@ -4,57 +4,167 @@ import android.content.Context
import android.opengl.GLES11Ext
import android.opengl.GLES20
import no.naiv.tiltshift.R
import kotlin.math.cos
import kotlin.math.sin
import java.io.BufferedReader
import java.io.InputStreamReader
import kotlin.math.cos
import kotlin.math.sin
/**
* Manages OpenGL shader programs for the tilt-shift effect.
* Manages OpenGL shader programs for the two-pass tilt-shift effect.
*
* Two programs:
* - **Passthrough**: copies camera texture (external OES) to an FBO, handling the
* coordinate transform via vertex/texcoord setup.
* - **Blur**: applies a directional Gaussian blur with tilt-shift mask.
* Used twice per frame (horizontal then vertical) via the [uBlurDirection] uniform.
*/
class TiltShiftShader(private val context: Context) {
var programId: Int = 0
private set
// --- Passthrough program (camera → FBO) ---
// Attribute locations
var aPositionLocation: Int = 0
private set
var aTexCoordLocation: Int = 0
private set
private var passthroughProgramId: Int = 0
// Uniform locations
private var uTextureLocation: Int = 0
private var uModeLocation: Int = 0
private var uIsFrontCameraLocation: Int = 0
private var uAngleLocation: Int = 0
private var uPositionXLocation: Int = 0
private var uPositionYLocation: Int = 0
private var uSizeLocation: Int = 0
private var uBlurAmountLocation: Int = 0
private var uFalloffLocation: Int = 0
private var uAspectRatioLocation: Int = 0
private var uResolutionLocation: Int = 0
private var uCosAngleLocation: Int = 0
private var uSinAngleLocation: Int = 0
var passthroughPositionLoc: Int = 0
private set
var passthroughTexCoordLoc: Int = 0
private set
private var passthroughTextureLoc: Int = 0
// --- Blur program (FBO → FBO/screen) ---
private var blurProgramId: Int = 0
var blurPositionLoc: Int = 0
private set
var blurTexCoordLoc: Int = 0
private set
private var blurTextureLoc: Int = 0
private var blurModeLoc: Int = 0
private var blurPositionXLoc: Int = 0
private var blurPositionYLoc: Int = 0
private var blurSizeLoc: Int = 0
private var blurAmountLoc: Int = 0
private var blurFalloffLoc: Int = 0
private var blurAspectRatioLoc: Int = 0
private var blurResolutionLoc: Int = 0
private var blurCosAngleLoc: Int = 0
private var blurSinAngleLoc: Int = 0
private var blurDirectionLoc: Int = 0
/**
* Compiles and links the shader program.
* Compiles and links both shader programs.
* Must be called from GL thread.
*/
fun initialize() {
val vertexSource = loadShaderSource(R.raw.tiltshift_vertex)
val fragmentSource = loadShaderSource(R.raw.tiltshift_fragment)
val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource)
val fragmentShader = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
programId = GLES20.glCreateProgram()
// Passthrough program
val passthroughFragSource = loadShaderSource(R.raw.tiltshift_passthrough_fragment)
val passthroughFragShader = compileShader(GLES20.GL_FRAGMENT_SHADER, passthroughFragSource)
passthroughProgramId = linkProgram(vertexShader, passthroughFragShader)
GLES20.glDeleteShader(passthroughFragShader)
passthroughPositionLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aPosition")
passthroughTexCoordLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aTexCoord")
passthroughTextureLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uTexture")
// Blur program
val blurFragSource = loadShaderSource(R.raw.tiltshift_fragment)
val blurFragShader = compileShader(GLES20.GL_FRAGMENT_SHADER, blurFragSource)
blurProgramId = linkProgram(vertexShader, blurFragShader)
GLES20.glDeleteShader(blurFragShader)
blurPositionLoc = GLES20.glGetAttribLocation(blurProgramId, "aPosition")
blurTexCoordLoc = GLES20.glGetAttribLocation(blurProgramId, "aTexCoord")
blurTextureLoc = GLES20.glGetUniformLocation(blurProgramId, "uTexture")
blurModeLoc = GLES20.glGetUniformLocation(blurProgramId, "uMode")
blurPositionXLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionX")
blurPositionYLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionY")
blurSizeLoc = GLES20.glGetUniformLocation(blurProgramId, "uSize")
blurAmountLoc = GLES20.glGetUniformLocation(blurProgramId, "uBlurAmount")
blurFalloffLoc = GLES20.glGetUniformLocation(blurProgramId, "uFalloff")
blurAspectRatioLoc = GLES20.glGetUniformLocation(blurProgramId, "uAspectRatio")
blurResolutionLoc = GLES20.glGetUniformLocation(blurProgramId, "uResolution")
blurCosAngleLoc = GLES20.glGetUniformLocation(blurProgramId, "uCosAngle")
blurSinAngleLoc = GLES20.glGetUniformLocation(blurProgramId, "uSinAngle")
blurDirectionLoc = GLES20.glGetUniformLocation(blurProgramId, "uBlurDirection")
// Vertex shader is linked into both programs and can be freed
GLES20.glDeleteShader(vertexShader)
}
/**
* Activates the passthrough program and binds the camera texture.
*/
fun usePassthrough(cameraTextureId: Int) {
GLES20.glUseProgram(passthroughProgramId)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId)
GLES20.glUniform1i(passthroughTextureLoc, 0)
}
/**
* Activates the blur program and sets all uniforms for one blur pass.
*
* @param fboTextureId The FBO color attachment to sample from.
* @param params Current blur parameters.
* @param width Surface width in pixels.
* @param height Surface height in pixels.
* @param dirX Blur direction X component (1 for horizontal pass, 0 for vertical).
* @param dirY Blur direction Y component (0 for horizontal pass, 1 for vertical).
*/
fun useBlurPass(
fboTextureId: Int,
params: BlurParameters,
width: Int,
height: Int,
dirX: Float,
dirY: Float
) {
GLES20.glUseProgram(blurProgramId)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, fboTextureId)
GLES20.glUniform1i(blurTextureLoc, 0)
GLES20.glUniform1i(blurModeLoc, if (params.mode == BlurMode.RADIAL) 1 else 0)
GLES20.glUniform1f(blurPositionXLoc, params.positionX)
GLES20.glUniform1f(blurPositionYLoc, params.positionY)
GLES20.glUniform1f(blurSizeLoc, params.size)
GLES20.glUniform1f(blurAmountLoc, params.blurAmount)
GLES20.glUniform1f(blurFalloffLoc, params.falloff)
GLES20.glUniform1f(blurAspectRatioLoc, params.aspectRatio)
GLES20.glUniform2f(blurResolutionLoc, width.toFloat(), height.toFloat())
// Raw screen-space angle (no camera rotation adjustment needed — FBO is already
// in screen orientation after the passthrough pass)
GLES20.glUniform1f(blurCosAngleLoc, cos(params.angle))
GLES20.glUniform1f(blurSinAngleLoc, sin(params.angle))
GLES20.glUniform2f(blurDirectionLoc, dirX, dirY)
}
/**
* Releases both shader programs.
*/
fun release() {
if (passthroughProgramId != 0) {
GLES20.glDeleteProgram(passthroughProgramId)
passthroughProgramId = 0
}
if (blurProgramId != 0) {
GLES20.glDeleteProgram(blurProgramId)
blurProgramId = 0
}
}
private fun linkProgram(vertexShader: Int, fragmentShader: Int): Int {
val programId = GLES20.glCreateProgram()
GLES20.glAttachShader(programId, vertexShader)
GLES20.glAttachShader(programId, fragmentShader)
GLES20.glLinkProgram(programId)
// Check for link errors
val linkStatus = IntArray(1)
GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, linkStatus, 0)
if (linkStatus[0] == 0) {
@ -63,72 +173,7 @@ class TiltShiftShader(private val context: Context) {
throw RuntimeException("Shader program link failed: $error")
}
// Get attribute locations
aPositionLocation = GLES20.glGetAttribLocation(programId, "aPosition")
aTexCoordLocation = GLES20.glGetAttribLocation(programId, "aTexCoord")
// Get uniform locations
uTextureLocation = GLES20.glGetUniformLocation(programId, "uTexture")
uModeLocation = GLES20.glGetUniformLocation(programId, "uMode")
uIsFrontCameraLocation = GLES20.glGetUniformLocation(programId, "uIsFrontCamera")
uAngleLocation = GLES20.glGetUniformLocation(programId, "uAngle")
uPositionXLocation = GLES20.glGetUniformLocation(programId, "uPositionX")
uPositionYLocation = GLES20.glGetUniformLocation(programId, "uPositionY")
uSizeLocation = GLES20.glGetUniformLocation(programId, "uSize")
uBlurAmountLocation = GLES20.glGetUniformLocation(programId, "uBlurAmount")
uFalloffLocation = GLES20.glGetUniformLocation(programId, "uFalloff")
uAspectRatioLocation = GLES20.glGetUniformLocation(programId, "uAspectRatio")
uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution")
uCosAngleLocation = GLES20.glGetUniformLocation(programId, "uCosAngle")
uSinAngleLocation = GLES20.glGetUniformLocation(programId, "uSinAngle")
// Clean up shaders (they're linked into program now)
GLES20.glDeleteShader(vertexShader)
GLES20.glDeleteShader(fragmentShader)
}
/**
* Uses the shader program and sets uniforms.
*/
fun use(textureId: Int, params: BlurParameters, width: Int, height: Int, isFrontCamera: Boolean = false) {
GLES20.glUseProgram(programId)
// Bind camera texture
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
GLES20.glUniform1i(uTextureLocation, 0)
// Set effect parameters
GLES20.glUniform1i(uModeLocation, if (params.mode == BlurMode.RADIAL) 1 else 0)
GLES20.glUniform1i(uIsFrontCameraLocation, if (isFrontCamera) 1 else 0)
GLES20.glUniform1f(uAngleLocation, params.angle)
GLES20.glUniform1f(uPositionXLocation, params.positionX)
GLES20.glUniform1f(uPositionYLocation, params.positionY)
GLES20.glUniform1f(uSizeLocation, params.size)
GLES20.glUniform1f(uBlurAmountLocation, params.blurAmount)
GLES20.glUniform1f(uFalloffLocation, params.falloff)
GLES20.glUniform1f(uAspectRatioLocation, params.aspectRatio)
GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat())
// Precompute angle trig on CPU to avoid per-fragment transcendental calls.
// The adjusted angle accounts for the 90deg coordinate transform.
val adjustedAngle = if (isFrontCamera) {
-params.angle - (Math.PI / 2).toFloat()
} else {
params.angle + (Math.PI / 2).toFloat()
}
GLES20.glUniform1f(uCosAngleLocation, cos(adjustedAngle))
GLES20.glUniform1f(uSinAngleLocation, sin(adjustedAngle))
}
/**
* Releases shader resources.
*/
fun release() {
if (programId != 0) {
GLES20.glDeleteProgram(programId)
programId = 0
}
return programId
}
private fun loadShaderSource(resourceId: Int): String {
@ -142,7 +187,6 @@ class TiltShiftShader(private val context: Context) {
GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader)
// Check for compile errors
val compileStatus = IntArray(1)
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0)
if (compileStatus[0] == 0) {

View file

@ -17,18 +17,6 @@ import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* Result of a photo save operation.
*/
sealed class SaveResult {
data class Success(
val uri: Uri,
val originalUri: Uri? = null,
val thumbnail: android.graphics.Bitmap? = null
) : SaveResult()
data class Error(val message: String, val exception: Exception? = null) : SaveResult()
}
/**
* Handles saving captured photos to the device gallery.
*/

View file

@ -0,0 +1,16 @@
package no.naiv.tiltshift.storage
import android.graphics.Bitmap
import android.net.Uri
/**
* Result of a photo save operation.
*/
sealed class SaveResult {
data class Success(
val uri: Uri,
val originalUri: Uri? = null,
val thumbnail: Bitmap? = null
) : SaveResult()
data class Error(val message: String, val exception: Exception? = null) : SaveResult()
}

View file

@ -232,9 +232,9 @@ private fun determineGestureType(
return when {
// Very center of focus zone -> rotation (small area)
distFromCenter < focusSize * 0.3f -> GestureType.ROTATE
// Near the blur effect -> size adjustment (large area)
distFromCenter < focusSize * 2.0f -> GestureType.PINCH_SIZE
// Far outside -> camera zoom
// Near the blur boundary -> size adjustment
distFromCenter < focusSize * 1.3f -> GestureType.PINCH_SIZE
// Outside the effect -> camera zoom
else -> GestureType.PINCH_ZOOM
}
}

View file

@ -1,71 +1,58 @@
#extension GL_OES_EGL_image_external : require
// Fragment shader for tilt-shift effect
// Supports both linear and radial blur modes
// Fragment shader for tilt-shift blur pass (two-pass separable Gaussian)
// Reads from a sampler2D (FBO texture already in screen orientation).
// Used twice: once for horizontal blur, once for vertical blur.
precision mediump float;
// Camera texture (external texture for camera preview)
uniform samplerExternalOES uTexture;
uniform sampler2D uTexture;
// Effect parameters
uniform int uMode; // 0 = linear, 1 = radial
uniform int uIsFrontCamera; // 0 = back camera, 1 = front camera
uniform float uAngle; // Rotation angle in radians
uniform float uPositionX; // Horizontal center of focus (0-1)
uniform float uPositionY; // Vertical center of focus (0-1)
uniform float uPositionX; // Horizontal center of focus (0-1, screen space)
uniform float uPositionY; // Vertical center of focus (0-1, screen space, 0 = top)
uniform float uSize; // Size of in-focus region (0-1)
uniform float uBlurAmount; // Maximum blur intensity (0-1)
uniform float uFalloff; // Transition sharpness (0-1, higher = more gradual)
uniform float uAspectRatio; // Ellipse aspect ratio for radial mode
uniform vec2 uResolution; // Texture resolution for proper sampling
uniform vec2 uResolution; // Surface resolution for proper sampling
// Precomputed trig for the adjusted angle (avoids per-fragment cos/sin calls)
// Precomputed trig for the raw screen-space angle
uniform float uCosAngle;
uniform float uSinAngle;
// Blur direction: (1,0) for horizontal pass, (0,1) for vertical pass
uniform vec2 uBlurDirection;
varying vec2 vTexCoord;
// Calculate signed distance from the focus region for LINEAR mode
float linearFocusDistance(vec2 uv) {
// Center point of the focus region
// Transform from screen coordinates to texture coordinates
// Back camera: Screen (x,y) -> Texture (y, 1-x)
// Front camera: Screen (x,y) -> Texture (1-y, 1-x) (additional X flip for mirror)
vec2 center;
if (uIsFrontCamera == 1) {
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
} else {
center = vec2(uPositionY, 1.0 - uPositionX);
}
vec2 offset = uv - center;
// Calculate distance from the focus region for LINEAR mode
// Works in screen space: X right (0-1), Y down (0-1)
// Distances are normalized to the Y axis (height) to match the overlay,
// which defines focus size as a fraction of screen height.
float linearFocusDistance(vec2 screenPos) {
vec2 center = vec2(uPositionX, uPositionY);
vec2 offset = screenPos - center;
// Correct for screen aspect ratio to make coordinate space square
// Scale X into the same physical units as Y (height-normalized)
float screenAspect = uResolution.x / uResolution.y;
offset.y *= screenAspect;
offset.x *= screenAspect;
// Use precomputed cos/sin for the adjusted angle
// Perpendicular distance to the rotated focus line
float rotatedY = -offset.x * uSinAngle + offset.y * uCosAngle;
return abs(rotatedY);
}
// Calculate signed distance from the focus region for RADIAL mode
float radialFocusDistance(vec2 uv) {
// Center point of the focus region
vec2 center;
if (uIsFrontCamera == 1) {
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
} else {
center = vec2(uPositionY, 1.0 - uPositionX);
}
vec2 offset = uv - center;
// Calculate distance from the focus region for RADIAL mode
float radialFocusDistance(vec2 screenPos) {
vec2 center = vec2(uPositionX, uPositionY);
vec2 offset = screenPos - center;
// Correct for screen aspect ratio
// Scale X into the same physical units as Y (height-normalized)
float screenAspect = uResolution.x / uResolution.y;
offset.y *= screenAspect;
offset.x *= screenAspect;
// Use precomputed cos/sin for rotation
// Rotate offset
vec2 rotated = vec2(
offset.x * uCosAngle - offset.y * uSinAngle,
offset.x * uSinAngle + offset.y * uCosAngle
@ -74,83 +61,59 @@ float radialFocusDistance(vec2 uv) {
// Apply ellipse aspect ratio
rotated.x /= uAspectRatio;
// Distance from center (elliptical)
return length(rotated);
}
// Calculate blur factor based on distance from focus
float blurFactor(float dist) {
float halfSize = uSize * 0.5;
// Falloff range scales with the falloff parameter
float transitionSize = halfSize * uFalloff;
float transitionSize = halfSize * uFalloff * 3.0;
if (dist < halfSize) {
return 0.0; // In focus region
return 0.0;
}
// Smooth falloff using smoothstep
float normalizedDist = (dist - halfSize) / max(transitionSize, 0.001);
return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount;
}
// Sample with Gaussian blur (9-tap, sigma ~= 2.0, unrolled for GLSL ES 1.00 compatibility)
vec4 sampleBlurred(vec2 uv, float blur) {
if (blur < 0.01) {
return texture2D(uTexture, uv);
}
vec2 texelSize = 1.0 / uResolution;
// For radial mode, blur in radial direction from center
// For linear mode, blur perpendicular to focus line
vec2 blurDir;
if (uMode == 1) {
// Radial: blur away from center
vec2 center;
if (uIsFrontCamera == 1) {
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
} else {
center = vec2(uPositionY, 1.0 - uPositionX);
}
vec2 toCenter = uv - center;
float len = length(toCenter);
if (len > 0.001) {
blurDir = toCenter / len;
} else {
blurDir = vec2(1.0, 0.0);
}
} else {
// Linear: blur perpendicular to focus line using precomputed trig
blurDir = vec2(uCosAngle, uSinAngle);
}
// Scale blur radius by blur amount
float radius = blur * 20.0;
vec2 step = blurDir * texelSize * radius;
// Unrolled 9-tap Gaussian blur (avoids integer-branched weight lookup)
vec4 color = vec4(0.0);
color += texture2D(uTexture, uv + step * -4.0) * 0.0162;
color += texture2D(uTexture, uv + step * -3.0) * 0.0540;
color += texture2D(uTexture, uv + step * -2.0) * 0.1216;
color += texture2D(uTexture, uv + step * -1.0) * 0.1933;
color += texture2D(uTexture, uv) * 0.2258;
color += texture2D(uTexture, uv + step * 1.0) * 0.1933;
color += texture2D(uTexture, uv + step * 2.0) * 0.1216;
color += texture2D(uTexture, uv + step * 3.0) * 0.0540;
color += texture2D(uTexture, uv + step * 4.0) * 0.0162;
return color;
}
void main() {
// Convert FBO texture coords to screen space (flip Y: GL bottom-up → screen top-down)
vec2 screenPos = vec2(vTexCoord.x, 1.0 - vTexCoord.y);
float dist;
if (uMode == 1) {
dist = radialFocusDistance(vTexCoord);
dist = radialFocusDistance(screenPos);
} else {
dist = linearFocusDistance(vTexCoord);
dist = linearFocusDistance(screenPos);
}
float blur = blurFactor(dist);
gl_FragColor = sampleBlurred(vTexCoord, blur);
if (blur < 0.01) {
gl_FragColor = texture2D(uTexture, vTexCoord);
return;
}
// 13-tap separable Gaussian (sigma ~= 2.5)
// Each pass blurs in one direction; combined gives a full 2D Gaussian.
vec2 texelSize = 1.0 / uResolution;
float radius = blur * 20.0;
vec2 step = uBlurDirection * texelSize * radius;
vec4 color = vec4(0.0);
color += texture2D(uTexture, vTexCoord + step * -6.0) * 0.0090;
color += texture2D(uTexture, vTexCoord + step * -5.0) * 0.0218;
color += texture2D(uTexture, vTexCoord + step * -4.0) * 0.0448;
color += texture2D(uTexture, vTexCoord + step * -3.0) * 0.0784;
color += texture2D(uTexture, vTexCoord + step * -2.0) * 0.1169;
color += texture2D(uTexture, vTexCoord + step * -1.0) * 0.1486;
color += texture2D(uTexture, vTexCoord) * 0.1610;
color += texture2D(uTexture, vTexCoord + step * 1.0) * 0.1486;
color += texture2D(uTexture, vTexCoord + step * 2.0) * 0.1169;
color += texture2D(uTexture, vTexCoord + step * 3.0) * 0.0784;
color += texture2D(uTexture, vTexCoord + step * 4.0) * 0.0448;
color += texture2D(uTexture, vTexCoord + step * 5.0) * 0.0218;
color += texture2D(uTexture, vTexCoord + step * 6.0) * 0.0090;
gl_FragColor = color;
}

View file

@ -0,0 +1,15 @@
#extension GL_OES_EGL_image_external : require
// Passthrough fragment shader: copies camera texture to FBO
// This separates the camera coordinate transform (handled by vertex/texcoord setup)
// from the blur passes, which then work entirely in screen space.
precision mediump float;
uniform samplerExternalOES uTexture;
varying vec2 vTexCoord;
void main() {
gl_FragColor = texture2D(uTexture, vTexCoord);
}

View file

@ -0,0 +1,38 @@
package no.naiv.tiltshift.camera
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class LensControllerTest {
@Test
fun `getCurrentLens returns null before initialization`() {
val controller = LensController()
assertNull(controller.getCurrentLens())
}
@Test
fun `getAvailableLenses returns empty before initialization`() {
val controller = LensController()
assertTrue(controller.getAvailableLenses().isEmpty())
}
@Test
fun `selectLens returns false for unknown lens`() {
val controller = LensController()
assertFalse(controller.selectLens("nonexistent"))
}
@Test
fun `cycleToNextLens returns null when no lenses`() {
val controller = LensController()
assertNull(controller.cycleToNextLens())
}
// Note: initialize() requires CameraInfo instances which need Android framework.
// Integration tests with Robolectric or on-device tests would cover that path.
}

View file

@ -0,0 +1,151 @@
package no.naiv.tiltshift.effect
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
import kotlin.math.PI
class BlurParametersTest {
@Test
fun `DEFAULT has expected values`() {
val default = BlurParameters.DEFAULT
assertEquals(BlurMode.LINEAR, default.mode)
assertEquals(0f, default.angle, 0f)
assertEquals(0.5f, default.positionX, 0f)
assertEquals(0.5f, default.positionY, 0f)
assertEquals(0.3f, default.size, 0f)
assertEquals(0.8f, default.blurAmount, 0f)
assertEquals(0.5f, default.falloff, 0f)
assertEquals(1.0f, default.aspectRatio, 0f)
}
// --- withSize ---
@Test
fun `withSize clamps below minimum`() {
val params = BlurParameters.DEFAULT.withSize(0.01f)
assertEquals(BlurParameters.MIN_SIZE, params.size, 0f)
}
@Test
fun `withSize clamps above maximum`() {
val params = BlurParameters.DEFAULT.withSize(5.0f)
assertEquals(BlurParameters.MAX_SIZE, params.size, 0f)
}
@Test
fun `withSize accepts value in range`() {
val params = BlurParameters.DEFAULT.withSize(0.5f)
assertEquals(0.5f, params.size, 0f)
}
// --- withBlurAmount ---
@Test
fun `withBlurAmount clamps below minimum`() {
val params = BlurParameters.DEFAULT.withBlurAmount(-1f)
assertEquals(BlurParameters.MIN_BLUR, params.blurAmount, 0f)
}
@Test
fun `withBlurAmount clamps above maximum`() {
val params = BlurParameters.DEFAULT.withBlurAmount(2f)
assertEquals(BlurParameters.MAX_BLUR, params.blurAmount, 0f)
}
// --- withFalloff ---
@Test
fun `withFalloff clamps below minimum`() {
val params = BlurParameters.DEFAULT.withFalloff(0f)
assertEquals(BlurParameters.MIN_FALLOFF, params.falloff, 0f)
}
@Test
fun `withFalloff clamps above maximum`() {
val params = BlurParameters.DEFAULT.withFalloff(5f)
assertEquals(BlurParameters.MAX_FALLOFF, params.falloff, 0f)
}
// --- withAspectRatio ---
@Test
fun `withAspectRatio clamps below minimum`() {
val params = BlurParameters.DEFAULT.withAspectRatio(0.1f)
assertEquals(BlurParameters.MIN_ASPECT, params.aspectRatio, 0f)
}
@Test
fun `withAspectRatio clamps above maximum`() {
val params = BlurParameters.DEFAULT.withAspectRatio(10f)
assertEquals(BlurParameters.MAX_ASPECT, params.aspectRatio, 0f)
}
// --- withPosition ---
@Test
fun `withPosition clamps to 0-1 range`() {
val params = BlurParameters.DEFAULT.withPosition(-0.5f, 1.5f)
assertEquals(0f, params.positionX, 0f)
assertEquals(1f, params.positionY, 0f)
}
@Test
fun `withPosition accepts values in range`() {
val params = BlurParameters.DEFAULT.withPosition(0.3f, 0.7f)
assertEquals(0.3f, params.positionX, 0f)
assertEquals(0.7f, params.positionY, 0f)
}
// --- withAngle ---
@Test
fun `withAngle sets arbitrary angle`() {
val angle = PI.toFloat() / 4
val params = BlurParameters.DEFAULT.withAngle(angle)
assertEquals(angle, params.angle, 0f)
}
// --- copy preserves other fields ---
@Test
fun `with methods preserve other fields`() {
val custom = BlurParameters(
mode = BlurMode.RADIAL,
angle = 1.5f,
positionX = 0.2f,
positionY = 0.8f,
size = 0.4f,
blurAmount = 0.6f,
falloff = 0.7f,
aspectRatio = 2.0f
)
val updated = custom.withSize(0.5f)
assertEquals(BlurMode.RADIAL, updated.mode)
assertEquals(1.5f, updated.angle, 0f)
assertEquals(0.2f, updated.positionX, 0f)
assertEquals(0.8f, updated.positionY, 0f)
assertEquals(0.5f, updated.size, 0f)
assertEquals(0.6f, updated.blurAmount, 0f)
assertEquals(0.7f, updated.falloff, 0f)
assertEquals(2.0f, updated.aspectRatio, 0f)
}
// --- data class equality ---
@Test
fun `data class equality works`() {
val a = BlurParameters(mode = BlurMode.LINEAR, size = 0.5f)
val b = BlurParameters(mode = BlurMode.LINEAR, size = 0.5f)
assertEquals(a, b)
}
@Test
fun `different params are not equal`() {
val a = BlurParameters(mode = BlurMode.LINEAR)
val b = BlurParameters(mode = BlurMode.RADIAL)
assertNotEquals(a, b)
}
}

View file

@ -6,9 +6,9 @@ lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
camerax = "1.4.1"
accompanist = "0.36.0"
exifinterface = "1.3.7"
playServicesLocation = "21.3.0"
junit = "4.13.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -36,8 +36,8 @@ androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterfa
# Location
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
# Accompanist for permissions
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }
# Test
junit = { group = "junit", name = "junit", version.ref = "junit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

View file

@ -1,4 +1,4 @@
versionMajor=1
versionMinor=1
versionPatch=1
versionCode=3
versionPatch=3
versionCode=5