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

91
.gitignore vendored Normal file
View file

@ -0,0 +1,91 @@
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/
*.ipr
*.iws
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/ (keep reports for CI)
# Android Profiling
*.hprof
# Kotlin
.kotlin/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

88
README.md Normal file
View file

@ -0,0 +1,88 @@
# Tilt-Shift Camera
A dedicated Android camera app for tilt-shift photography with real-time preview, touch-based controls, and proper EXIF handling.
## Features
- **Real-time tilt-shift effect preview** - See the blur effect as you compose your shot
- **Touch-based controls**:
- Single finger drag to move the focus line position
- Two-finger rotation to adjust blur angle
- Pinch gesture to adjust blur zone size
- Pinch in center to zoom camera
- **Zoom controls** - Quick presets (0.5x, 1x, 2x, 5x) plus pinch-to-zoom
- **Auto picture orientation detection** - Photos saved with correct EXIF orientation
- **GPS location tagging** - Optional EXIF GPS data from device location
- **Haptic feedback** - Tactile response for all interactions
- **Saves to gallery** - Photos saved to Pictures/TiltShift/ folder
## Requirements
- Android 8.0 (API 26) or higher
- Device with camera
- OpenGL ES 2.0 support
## Building
1. Open the project in Android Studio
2. Sync Gradle files
3. Build and run on a physical device (camera preview won't work on emulator)
Or from command line:
```bash
./gradlew assembleDebug
```
## Permissions
- **Camera** (required) - For capturing photos
- **Location** (optional) - For GPS tagging in EXIF data
- **Vibrate** - For haptic feedback
## Architecture
The app uses:
- **CameraX** - Jetpack camera library for camera preview and capture
- **OpenGL ES 2.0** - Real-time shader-based blur effect
- **Jetpack Compose** - Modern declarative UI
- **Kotlin Coroutines & Flow** - Asynchronous operations and state management
### Project Structure
```
app/src/main/java/no/naiv/tiltshift/
├── MainActivity.kt # Entry point with permission handling
├── camera/
│ ├── CameraManager.kt # CameraX setup and control
│ ├── LensController.kt # Lens/zoom switching
│ └── ImageCaptureHandler.kt # Photo capture with effect
├── effect/
│ ├── TiltShiftRenderer.kt # OpenGL renderer
│ ├── TiltShiftShader.kt # GLSL shader management
│ └── BlurParameters.kt # Effect state
├── ui/
│ ├── CameraScreen.kt # Main Compose screen
│ ├── TiltShiftOverlay.kt # Touch gesture handling & visualization
│ ├── ZoomControl.kt # Zoom UI component
│ └── LensSwitcher.kt # Lens selection UI
├── storage/
│ ├── PhotoSaver.kt # MediaStore integration
│ └── ExifWriter.kt # EXIF metadata handling
└── util/
├── OrientationDetector.kt
├── LocationProvider.kt
└── HapticFeedback.kt
```
## How the Tilt-Shift Effect Works
The tilt-shift effect simulates a selective focus lens that makes scenes appear miniature. The app achieves this through:
1. **Camera Preview** → OpenGL SurfaceTexture
2. **Fragment Shader** calculates distance from the focus line for each pixel
3. **Gradient blur** is applied based on distance - center stays sharp, edges blur
4. **Two-pass Gaussian blur** (optimized as separable passes) for quality and performance
## License
MIT

85
app/build.gradle.kts Normal file
View file

@ -0,0 +1,85 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "no.naiv.tiltshift"
compileSdk = 35
defaultConfig {
applicationId = "no.naiv.tiltshift"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
// Compose
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
// CameraX
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
// EXIF
implementation(libs.androidx.exifinterface)
// Location
implementation(libs.play.services.location)
// Permissions
implementation(libs.accompanist.permissions)
// Debug
debugImplementation(libs.androidx.ui.tooling)
}

10
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,10 @@
# Add project specific ProGuard rules here.
# Keep CameraX classes
-keep class androidx.camera.** { *; }
# Keep OpenGL shader-related code
-keep class no.naiv.tiltshift.effect.** { *; }
# Keep location provider
-keep class com.google.android.gms.location.** { *; }

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Camera permission -->
<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Location for EXIF GPS data -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Vibration for haptic feedback -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- OpenGL ES 2.0 -->
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TiltShiftCamera"
tools:targetApi="35">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.TiltShiftCamera">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,215 @@
package no.naiv.tiltshift
import android.Manifest
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Camera
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
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.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 no.naiv.tiltshift.ui.CameraScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Enable edge-to-edge display
enableEdgeToEdge()
// Hide system bars for immersive camera experience
WindowCompat.setDecorFitsSystemWindows(window, false)
val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.hide(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
setContent {
TiltShiftApp()
}
}
}
@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
)
)
// Request camera permission on launch
LaunchedEffect(Unit) {
if (!cameraPermission.status.isGranted) {
cameraPermission.launchPermissionRequest()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
when {
cameraPermission.status.isGranted -> {
// Camera permission granted - show camera
CameraScreen()
// Request location in background (for EXIF GPS)
LaunchedEffect(Unit) {
if (!locationPermissions.allPermissionsGranted) {
locationPermissions.launchMultiplePermissionRequest()
}
}
}
else -> {
// Show permission request UI
PermissionRequestScreen(
onRequestCamera = { cameraPermission.launchPermissionRequest() },
onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() },
cameraGranted = cameraPermission.status.isGranted,
locationGranted = locationPermissions.allPermissionsGranted
)
}
}
}
}
@Composable
private fun PermissionRequestScreen(
onRequestCamera: () -> Unit,
onRequestLocation: () -> Unit,
cameraGranted: Boolean,
locationGranted: Boolean
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Tilt-Shift Camera",
color = Color.White,
fontSize = 28.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Create beautiful miniature-style photos with customizable blur effects",
color = Color.Gray,
fontSize = 16.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(48.dp))
// Camera permission
PermissionItem(
icon = Icons.Default.Camera,
title = "Camera",
description = "Required to take photos",
isGranted = cameraGranted,
onRequest = onRequestCamera
)
Spacer(modifier = Modifier.height(16.dp))
// Location permission
PermissionItem(
icon = Icons.Default.LocationOn,
title = "Location",
description = "Optional - adds GPS data to photos",
isGranted = locationGranted,
onRequest = onRequestLocation
)
}
}
@Composable
private fun PermissionItem(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
description: String,
isGranted: Boolean,
onRequest: () -> Unit
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color(0xFF1E1E1E))
.padding(16.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (isGranted) Color(0xFF4CAF50) else Color(0xFFFFB300),
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = title,
color = Color.White,
fontWeight = FontWeight.Medium
)
Text(
text = description,
color = Color.Gray,
fontSize = 12.sp,
textAlign = TextAlign.Center
)
if (!isGranted) {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onRequest,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFFB300)
)
) {
Text("Grant", color = Color.Black)
}
}
}
}
}

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

View file

@ -0,0 +1,54 @@
package no.naiv.tiltshift.effect
/**
* Parameters controlling the tilt-shift blur effect.
*
* @param angle The rotation angle of the blur gradient in radians (0 = horizontal blur bands)
* @param position The center position of the in-focus region (0.0 to 1.0, relative to screen)
* @param size The size of the in-focus region (0.0 to 1.0, as fraction of screen height)
* @param blurAmount The intensity of the blur effect (0.0 to 1.0)
*/
data class BlurParameters(
val angle: Float = 0f,
val position: Float = 0.5f,
val size: Float = 0.3f,
val blurAmount: Float = 0.8f
) {
companion object {
val DEFAULT = BlurParameters()
// Constraints
const val MIN_SIZE = 0.1f
const val MAX_SIZE = 0.8f
const val MIN_BLUR = 0.0f
const val MAX_BLUR = 1.0f
}
/**
* Returns a copy with the angle adjusted by the given delta.
*/
fun withAngleDelta(delta: Float): BlurParameters {
return copy(angle = angle + delta)
}
/**
* Returns a copy with the position clamped to valid range.
*/
fun withPosition(newPosition: Float): BlurParameters {
return copy(position = newPosition.coerceIn(0f, 1f))
}
/**
* Returns a copy with the size clamped to valid range.
*/
fun withSize(newSize: Float): BlurParameters {
return copy(size = newSize.coerceIn(MIN_SIZE, MAX_SIZE))
}
/**
* Returns a copy with the blur amount clamped to valid range.
*/
fun withBlurAmount(amount: Float): BlurParameters {
return copy(blurAmount = amount.coerceIn(MIN_BLUR, MAX_BLUR))
}
}

View file

@ -0,0 +1,159 @@
package no.naiv.tiltshift.effect
import android.content.Context
import android.graphics.SurfaceTexture
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.opengl.GLSurfaceView
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10
/**
* OpenGL renderer for applying tilt-shift effect to camera preview.
*
* This renderer receives camera frames via SurfaceTexture and applies
* the tilt-shift blur effect using GLSL shaders.
*/
class TiltShiftRenderer(
private val context: Context,
private val onSurfaceTextureAvailable: (SurfaceTexture) -> Unit
) : GLSurfaceView.Renderer {
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
private var surfaceWidth: Int = 0
private var surfaceHeight: Int = 0
// Current effect parameters (updated from UI thread)
@Volatile
var blurParameters: BlurParameters = BlurParameters.DEFAULT
// Quad vertices (full screen)
private val vertices = floatArrayOf(
-1f, -1f, // Bottom left
1f, -1f, // Bottom right
-1f, 1f, // Top left
1f, 1f // Top right
)
// Texture coordinates (flip Y for camera)
private val texCoords = floatArrayOf(
0f, 1f, // Bottom left
1f, 1f, // Bottom right
0f, 0f, // Top left
1f, 0f // Top right
)
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
GLES20.glClearColor(0f, 0f, 0f, 1f)
// Initialize shader
shader = TiltShiftShader(context)
shader.initialize()
// Create vertex buffer
vertexBuffer = ByteBuffer.allocateDirect(vertices.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(vertices)
vertexBuffer.position(0)
// Create texture coordinate buffer
texCoordBuffer = ByteBuffer.allocateDirect(texCoords.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(texCoords)
texCoordBuffer.position(0)
// Create camera texture
val textures = IntArray(1)
GLES20.glGenTextures(1, textures, 0)
cameraTextureId = textures[0]
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
// Create SurfaceTexture for camera frames
surfaceTexture = SurfaceTexture(cameraTextureId).also {
onSurfaceTextureAvailable(it)
}
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
GLES20.glViewport(0, 0, width, height)
surfaceWidth = width
surfaceHeight = height
}
override fun onDrawFrame(gl: GL10?) {
// Update texture with latest camera frame
surfaceTexture?.updateTexImage()
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
// Use shader and set parameters
shader.use(cameraTextureId, blurParameters, surfaceWidth, surfaceHeight)
// Set vertex positions
GLES20.glEnableVertexAttribArray(shader.aPositionLocation)
GLES20.glVertexAttribPointer(
shader.aPositionLocation,
2,
GLES20.GL_FLOAT,
false,
0,
vertexBuffer
)
// Set texture coordinates
GLES20.glEnableVertexAttribArray(shader.aTexCoordLocation)
GLES20.glVertexAttribPointer(
shader.aTexCoordLocation,
2,
GLES20.GL_FLOAT,
false,
0,
texCoordBuffer
)
// Draw quad
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
// Cleanup
GLES20.glDisableVertexAttribArray(shader.aPositionLocation)
GLES20.glDisableVertexAttribArray(shader.aTexCoordLocation)
}
/**
* Updates blur parameters. Thread-safe.
*/
fun updateParameters(params: BlurParameters) {
blurParameters = params
}
/**
* Releases OpenGL resources.
* Must be called from GL thread.
*/
fun release() {
shader.release()
surfaceTexture?.release()
surfaceTexture = null
if (cameraTextureId != 0) {
GLES20.glDeleteTextures(1, intArrayOf(cameraTextureId), 0)
cameraTextureId = 0
}
}
}

View file

@ -0,0 +1,126 @@
package no.naiv.tiltshift.effect
import android.content.Context
import android.opengl.GLES11Ext
import android.opengl.GLES20
import no.naiv.tiltshift.R
import java.io.BufferedReader
import java.io.InputStreamReader
/**
* Manages OpenGL shader programs for the tilt-shift effect.
*/
class TiltShiftShader(private val context: Context) {
var programId: Int = 0
private set
// Attribute locations
var aPositionLocation: Int = 0
private set
var aTexCoordLocation: Int = 0
private set
// Uniform locations
private var uTextureLocation: Int = 0
private var uAngleLocation: Int = 0
private var uPositionLocation: Int = 0
private var uSizeLocation: Int = 0
private var uBlurAmountLocation: Int = 0
private var uResolutionLocation: Int = 0
/**
* Compiles and links the shader program.
* 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()
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) {
val error = GLES20.glGetProgramInfoLog(programId)
GLES20.glDeleteProgram(programId)
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")
uAngleLocation = GLES20.glGetUniformLocation(programId, "uAngle")
uPositionLocation = GLES20.glGetUniformLocation(programId, "uPosition")
uSizeLocation = GLES20.glGetUniformLocation(programId, "uSize")
uBlurAmountLocation = GLES20.glGetUniformLocation(programId, "uBlurAmount")
uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution")
// 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) {
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.glUniform1f(uAngleLocation, params.angle)
GLES20.glUniform1f(uPositionLocation, params.position)
GLES20.glUniform1f(uSizeLocation, params.size)
GLES20.glUniform1f(uBlurAmountLocation, params.blurAmount)
GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat())
}
/**
* Releases shader resources.
*/
fun release() {
if (programId != 0) {
GLES20.glDeleteProgram(programId)
programId = 0
}
}
private fun loadShaderSource(resourceId: Int): String {
val inputStream = context.resources.openRawResource(resourceId)
val reader = BufferedReader(InputStreamReader(inputStream))
return reader.use { it.readText() }
}
private fun compileShader(type: Int, source: String): Int {
val shader = GLES20.glCreateShader(type)
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) {
val error = GLES20.glGetShaderInfoLog(shader)
GLES20.glDeleteShader(shader)
val shaderType = if (type == GLES20.GL_VERTEX_SHADER) "vertex" else "fragment"
throw RuntimeException("$shaderType shader compilation failed: $error")
}
return shader
}
}

View file

@ -0,0 +1,96 @@
package no.naiv.tiltshift.storage
import android.location.Location
import androidx.exifinterface.media.ExifInterface
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Writes EXIF metadata to captured images.
*/
class ExifWriter {
private val dateTimeFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US)
/**
* Writes EXIF data to the specified image file.
*/
fun writeExifData(
file: File,
orientation: Int,
location: Location?,
make: String = "Android",
model: String = android.os.Build.MODEL
) {
try {
val exif = ExifInterface(file)
// Orientation
exif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
// Date/time
val dateTime = dateTimeFormat.format(Date())
exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime)
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime)
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, dateTime)
// Camera info
exif.setAttribute(ExifInterface.TAG_MAKE, make)
exif.setAttribute(ExifInterface.TAG_MODEL, model)
exif.setAttribute(ExifInterface.TAG_SOFTWARE, "Tilt-Shift Camera")
// GPS location
if (location != null) {
setLocationExif(exif, location)
}
exif.saveAttributes()
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun setLocationExif(exif: ExifInterface, location: Location) {
// Latitude
val latitude = location.latitude
val latRef = if (latitude >= 0) "N" else "S"
exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, convertToDMS(Math.abs(latitude)))
exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE_REF, latRef)
// Longitude
val longitude = location.longitude
val lonRef = if (longitude >= 0) "E" else "W"
exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, convertToDMS(Math.abs(longitude)))
exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF, lonRef)
// Altitude
if (location.hasAltitude()) {
val altitude = location.altitude
val altRef = if (altitude >= 0) "0" else "1"
exif.setAttribute(ExifInterface.TAG_GPS_ALTITUDE, "${Math.abs(altitude).toLong()}/1")
exif.setAttribute(ExifInterface.TAG_GPS_ALTITUDE_REF, altRef)
}
// Timestamp
val gpsTimeFormat = SimpleDateFormat("HH:mm:ss", Locale.US)
val gpsDateFormat = SimpleDateFormat("yyyy:MM:dd", Locale.US)
val timestamp = Date(location.time)
exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, gpsTimeFormat.format(timestamp))
exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, gpsDateFormat.format(timestamp))
}
/**
* Converts decimal degrees to DMS (degrees/minutes/seconds) format for EXIF.
*/
private fun convertToDMS(coordinate: Double): String {
val degrees = coordinate.toInt()
val minutesDecimal = (coordinate - degrees) * 60
val minutes = minutesDecimal.toInt()
val seconds = (minutesDecimal - minutes) * 60
// EXIF format: "degrees/1,minutes/1,seconds/1000"
return "$degrees/1,$minutes/1,${(seconds * 1000).toLong()}/1000"
}
}

View file

@ -0,0 +1,186 @@
package no.naiv.tiltshift.storage
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.location.Location
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Result of a photo save operation.
*/
sealed class SaveResult {
data class Success(val uri: Uri, val path: String) : SaveResult()
data class Error(val message: String, val exception: Exception? = null) : SaveResult()
}
/**
* Handles saving captured photos to the device gallery.
*/
class PhotoSaver(private val context: Context) {
private val exifWriter = ExifWriter()
private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
/**
* Saves a bitmap with the tilt-shift effect to the gallery.
*/
suspend fun saveBitmap(
bitmap: Bitmap,
orientation: Int,
location: Location?
): SaveResult = withContext(Dispatchers.IO) {
try {
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg"
// Create content values for MediaStore
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
// Insert into MediaStore
val contentResolver = context.contentResolver
val uri = contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry")
// Write bitmap to output stream
contentResolver.openOutputStream(uri)?.use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream)
} ?: return@withContext SaveResult.Error("Failed to open output stream")
// Write EXIF data
writeExifToUri(uri, orientation, location)
// Mark as complete (API 29+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(uri, contentValues, null, null)
}
// Get the file path for display
val path = getPathFromUri(uri)
SaveResult.Success(uri, path)
} catch (e: Exception) {
SaveResult.Error("Failed to save photo: ${e.message}", e)
}
}
/**
* Saves a JPEG file (from CameraX ImageCapture) to the gallery.
*/
suspend fun saveJpegFile(
sourceFile: File,
orientation: Int,
location: Location?
): SaveResult = withContext(Dispatchers.IO) {
try {
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg"
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val contentResolver = context.contentResolver
val uri = contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry")
// Copy file to MediaStore
contentResolver.openOutputStream(uri)?.use { outputStream ->
sourceFile.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
} ?: return@withContext SaveResult.Error("Failed to open output stream")
// Write EXIF data
writeExifToUri(uri, orientation, location)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(uri, contentValues, null, null)
}
// Clean up source file
sourceFile.delete()
val path = getPathFromUri(uri)
SaveResult.Success(uri, path)
} catch (e: Exception) {
SaveResult.Error("Failed to save photo: ${e.message}", e)
}
}
private fun writeExifToUri(uri: Uri, orientation: Int, location: Location?) {
try {
context.contentResolver.openFileDescriptor(uri, "rw")?.use { pfd ->
val exif = ExifInterface(pfd.fileDescriptor)
exif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
exif.setAttribute(ExifInterface.TAG_SOFTWARE, "Tilt-Shift Camera")
exif.setAttribute(ExifInterface.TAG_MAKE, "Android")
exif.setAttribute(ExifInterface.TAG_MODEL, Build.MODEL)
val dateFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US)
val dateTime = dateFormat.format(Date())
exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime)
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime)
location?.let { loc ->
exif.setGpsInfo(loc)
}
exif.saveAttributes()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun getPathFromUri(uri: Uri): String {
val projection = arrayOf(MediaStore.Images.Media.DATA)
context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
return cursor.getString(columnIndex) ?: uri.toString()
}
}
return uri.toString()
}
}

View file

@ -0,0 +1,345 @@
package no.naiv.tiltshift.ui
import android.graphics.SurfaceTexture
import android.location.Location
import android.opengl.GLSurfaceView
import android.view.Surface
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import no.naiv.tiltshift.camera.CameraManager
import no.naiv.tiltshift.camera.ImageCaptureHandler
import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.effect.TiltShiftRenderer
import no.naiv.tiltshift.storage.PhotoSaver
import no.naiv.tiltshift.storage.SaveResult
import no.naiv.tiltshift.util.HapticFeedback
import no.naiv.tiltshift.util.LocationProvider
import no.naiv.tiltshift.util.OrientationDetector
/**
* Main camera screen with tilt-shift controls.
*/
@Composable
fun CameraScreen(
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
// Camera and effect state
val cameraManager = remember { CameraManager(context) }
val photoSaver = remember { PhotoSaver(context) }
val captureHandler = remember { ImageCaptureHandler(context, photoSaver) }
val haptics = remember { HapticFeedback(context) }
val orientationDetector = remember { OrientationDetector(context) }
val locationProvider = remember { LocationProvider(context) }
// State
var blurParams by remember { mutableStateOf(BlurParameters.DEFAULT) }
var surfaceTexture by remember { mutableStateOf<SurfaceTexture?>(null) }
var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) }
var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) }
var isCapturing by remember { mutableStateOf(false) }
var showSaveSuccess by remember { mutableStateOf(false) }
var showSaveError by remember { mutableStateOf<String?>(null) }
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
var currentLocation by remember { mutableStateOf<Location?>(null) }
val zoomRatio by cameraManager.zoomRatio.collectAsState()
val minZoom by cameraManager.minZoomRatio.collectAsState()
val maxZoom by cameraManager.maxZoomRatio.collectAsState()
// Collect orientation updates
LaunchedEffect(Unit) {
orientationDetector.orientationFlow().collectLatest { rotation ->
currentRotation = rotation
}
}
// Collect location updates
LaunchedEffect(Unit) {
locationProvider.locationFlow().collectLatest { location ->
currentLocation = location
}
}
// Update renderer with blur params
LaunchedEffect(blurParams) {
renderer?.updateParameters(blurParams)
glSurfaceView?.requestRender()
}
// Start camera when surface texture is available
LaunchedEffect(surfaceTexture) {
surfaceTexture?.let {
cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
}
}
// Cleanup
DisposableEffect(Unit) {
onDispose {
cameraManager.release()
renderer?.release()
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
) {
// OpenGL Surface for camera preview with effect
AndroidView(
factory = { ctx ->
GLSurfaceView(ctx).apply {
setEGLContextClientVersion(2)
val newRenderer = TiltShiftRenderer(ctx) { st ->
surfaceTexture = st
}
renderer = newRenderer
setRenderer(newRenderer)
renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
glSurfaceView = this
}
},
modifier = Modifier.fillMaxSize()
)
// Tilt-shift overlay (gesture handling + visualization)
TiltShiftOverlay(
params = blurParams,
onParamsChange = { newParams ->
blurParams = newParams
haptics.tick()
},
onZoomChange = { zoomDelta ->
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
cameraManager.setZoom(newZoom)
},
modifier = Modifier.fillMaxSize()
)
// Top bar
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Zoom indicator
ZoomIndicator(currentZoom = zoomRatio)
// Settings button (placeholder)
IconButton(
onClick = { /* TODO: Settings */ }
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings",
tint = Color.White
)
}
}
// Bottom controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Zoom presets
ZoomControl(
currentZoom = zoomRatio,
minZoom = minZoom,
maxZoom = maxZoom,
onZoomSelected = { zoom ->
cameraManager.setZoom(zoom)
haptics.click()
}
)
Spacer(modifier = Modifier.height(24.dp))
// Capture button
CaptureButton(
isCapturing = isCapturing,
onClick = {
if (!isCapturing) {
isCapturing = true
haptics.heavyClick()
scope.launch {
val imageCapture = cameraManager.imageCapture
if (imageCapture != null) {
val result = captureHandler.capturePhoto(
imageCapture = imageCapture,
executor = cameraManager.getExecutor(),
blurParams = blurParams,
deviceRotation = currentRotation,
location = currentLocation,
isFrontCamera = false
)
when (result) {
is SaveResult.Success -> {
haptics.success()
showSaveSuccess = true
delay(1500)
showSaveSuccess = false
}
is SaveResult.Error -> {
haptics.error()
showSaveError = result.message
delay(2000)
showSaveError = null
}
}
}
isCapturing = false
}
}
}
)
}
// Success indicator
AnimatedVisibility(
visible = showSaveSuccess,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center)
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(Color(0xFF4CAF50)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Saved",
tint = Color.White,
modifier = Modifier.size(48.dp)
)
}
}
// Error indicator
AnimatedVisibility(
visible = showSaveError != null,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.Center)
.padding(32.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(androidx.compose.foundation.shape.RoundedCornerShape(16.dp))
.background(Color(0xFFF44336))
.padding(24.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Error",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = showSaveError ?: "Error",
color = Color.White,
fontSize = 14.sp
)
}
}
}
}
/**
* Capture button with animation for capturing state.
*/
@Composable
private fun CaptureButton(
isCapturing: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val outerSize = 72.dp
val innerSize = if (isCapturing) 48.dp else 60.dp
Box(
modifier = modifier
.size(outerSize)
.clip(CircleShape)
.border(4.dp, Color.White, CircleShape)
.clickable(enabled = !isCapturing, onClick = onClick),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(innerSize)
.clip(CircleShape)
.background(if (isCapturing) Color(0xFFFFB300) else Color.White)
)
}
}

View file

@ -0,0 +1,117 @@
package no.naiv.tiltshift.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CameraRear
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import no.naiv.tiltshift.camera.CameraLens
/**
* Lens selection UI for switching between camera lenses.
*/
@Composable
fun LensSwitcher(
availableLenses: List<CameraLens>,
currentLens: CameraLens?,
onLensSelected: (CameraLens) -> Unit,
modifier: Modifier = Modifier
) {
if (availableLenses.size <= 1) {
// Don't show switcher if only one lens available
return
}
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
availableLenses.forEach { lens ->
LensButton(
lens = lens,
isSelected = lens.id == currentLens?.id,
onClick = { onLensSelected(lens) }
)
}
}
}
@Composable
private fun LensButton(
lens: CameraLens,
isSelected: Boolean,
onClick: () -> Unit
) {
val backgroundColor by animateColorAsState(
targetValue = if (isSelected) {
Color(0xFFFFB300)
} else {
Color(0x80000000)
},
label = "lens_button_bg"
)
val contentColor by animateColorAsState(
targetValue = if (isSelected) Color.Black else Color.White,
label = "lens_button_content"
)
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(backgroundColor)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Text(
text = lens.displayName,
color = contentColor,
fontSize = 14.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
}
}
/**
* Simple camera flip button (for future front camera support).
*/
@Composable
fun CameraFlipButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(48.dp)
.clip(CircleShape)
.background(Color(0x80000000))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.CameraRear,
contentDescription = "Switch Camera",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}

View file

@ -0,0 +1,257 @@
package no.naiv.tiltshift.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculateRotation
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import no.naiv.tiltshift.effect.BlurParameters
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/**
* Type of gesture being performed.
*/
private enum class GestureType {
NONE,
DRAG_POSITION, // Single finger drag to move focus position
ROTATE, // Two-finger rotation
PINCH_SIZE, // Pinch near blur edges to resize
PINCH_ZOOM // Pinch in center to zoom camera
}
/**
* Overlay that shows tilt-shift effect controls and handles gestures.
*/
@Composable
fun TiltShiftOverlay(
params: BlurParameters,
onParamsChange: (BlurParameters) -> Unit,
onZoomChange: (Float) -> Unit,
modifier: Modifier = Modifier
) {
var currentGesture by remember { mutableStateOf(GestureType.NONE) }
var initialZoom by remember { mutableFloatStateOf(1f) }
var initialAngle by remember { mutableFloatStateOf(0f) }
var initialSize by remember { mutableFloatStateOf(0.3f) }
Canvas(
modifier = modifier
.fillMaxSize()
.pointerInput(Unit) {
awaitEachGesture {
val firstDown = awaitFirstDown(requireUnconsumed = false)
currentGesture = GestureType.NONE
var previousCentroid = firstDown.position
var previousPointerCount = 1
var accumulatedRotation = 0f
var accumulatedZoom = 1f
initialAngle = params.angle
initialSize = params.size
initialZoom = 1f
do {
val event = awaitPointerEvent()
val pointers = event.changes.filter { it.pressed }
if (pointers.isEmpty()) break
val centroid = if (pointers.size >= 2) {
event.calculateCentroid()
} else {
pointers.first().position
}
when {
// Two or more fingers
pointers.size >= 2 -> {
val rotation = event.calculateRotation()
val zoom = event.calculateZoom()
// Determine gesture type based on touch positions
if (currentGesture == GestureType.NONE || currentGesture == GestureType.DRAG_POSITION) {
currentGesture = determineGestureType(
centroid,
size.width.toFloat(),
size.height.toFloat(),
params
)
}
when (currentGesture) {
GestureType.ROTATE -> {
accumulatedRotation += rotation
val newAngle = initialAngle + accumulatedRotation
onParamsChange(params.copy(angle = newAngle))
}
GestureType.PINCH_SIZE -> {
accumulatedZoom *= zoom
val newSize = (initialSize * accumulatedZoom)
.coerceIn(BlurParameters.MIN_SIZE, BlurParameters.MAX_SIZE)
onParamsChange(params.copy(size = newSize))
}
GestureType.PINCH_ZOOM -> {
onZoomChange(zoom)
}
else -> {}
}
}
// Single finger
pointers.size == 1 -> {
if (currentGesture == GestureType.NONE) {
currentGesture = GestureType.DRAG_POSITION
}
if (currentGesture == GestureType.DRAG_POSITION) {
val deltaY = (centroid.y - previousCentroid.y) / size.height
val newPosition = (params.position + deltaY).coerceIn(0f, 1f)
onParamsChange(params.copy(position = newPosition))
}
}
}
previousCentroid = centroid
previousPointerCount = pointers.size
// Consume all pointer changes
pointers.forEach { it.consume() }
} while (event.type != PointerEventType.Release)
currentGesture = GestureType.NONE
}
}
) {
drawTiltShiftOverlay(params)
}
}
/**
* Determines the type of two-finger gesture based on touch position.
*/
private fun determineGestureType(
centroid: Offset,
width: Float,
height: Float,
params: BlurParameters
): GestureType {
// Calculate distance from focus center line
val focusCenterY = height * params.position
val focusHalfHeight = height * params.size * 0.5f
// Rotate centroid to align with focus line
val dx = centroid.x - width / 2f
val dy = centroid.y - focusCenterY
val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle)
val distFromCenter = kotlin.math.abs(rotatedY)
return when {
// Near the edges of the blur zone -> size adjustment
distFromCenter > focusHalfHeight * 0.7f && distFromCenter < focusHalfHeight * 1.5f -> {
GestureType.PINCH_SIZE
}
// Inside the focus zone -> rotation
distFromCenter < focusHalfHeight * 0.7f -> {
GestureType.ROTATE
}
// Outside -> camera zoom
else -> {
GestureType.PINCH_ZOOM
}
}
}
/**
* Draws the tilt-shift visualization overlay.
*/
private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
val width = size.width
val height = size.height
val centerY = height * params.position
val focusHalfHeight = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat())
// Colors for overlay
val focusLineColor = Color(0xFFFFB300) // Amber
val blurZoneColor = Color(0x40FFFFFF) // Semi-transparent white
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
rotate(angleDegrees, pivot = Offset(width / 2f, centerY)) {
// Draw blur zone indicators (top and bottom)
drawRect(
color = blurZoneColor,
topLeft = Offset(0f, 0f),
size = androidx.compose.ui.geometry.Size(width, centerY - focusHalfHeight)
)
drawRect(
color = blurZoneColor,
topLeft = Offset(0f, centerY + focusHalfHeight),
size = androidx.compose.ui.geometry.Size(width, height - (centerY + focusHalfHeight))
)
// Draw focus zone boundary lines
drawLine(
color = focusLineColor,
start = Offset(0f, centerY - focusHalfHeight),
end = Offset(width, centerY - focusHalfHeight),
strokeWidth = 2.dp.toPx(),
pathEffect = dashEffect
)
drawLine(
color = focusLineColor,
start = Offset(0f, centerY + focusHalfHeight),
end = Offset(width, centerY + focusHalfHeight),
strokeWidth = 2.dp.toPx(),
pathEffect = dashEffect
)
// Draw center focus line
drawLine(
color = focusLineColor,
start = Offset(0f, centerY),
end = Offset(width, centerY),
strokeWidth = 3.dp.toPx()
)
// Draw rotation indicator at center
val indicatorRadius = 30.dp.toPx()
drawCircle(
color = focusLineColor.copy(alpha = 0.5f),
radius = indicatorRadius,
center = Offset(width / 2f, centerY),
style = Stroke(width = 2.dp.toPx())
)
// Draw angle tick mark
val tickLength = 15.dp.toPx()
drawLine(
color = focusLineColor,
start = Offset(width / 2f, centerY - indicatorRadius + tickLength),
end = Offset(width / 2f, centerY - indicatorRadius - 5.dp.toPx()),
strokeWidth = 3.dp.toPx()
)
}
}

View file

@ -0,0 +1,119 @@
package no.naiv.tiltshift.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.abs
/**
* Zoom level presets control.
*/
@Composable
fun ZoomControl(
currentZoom: Float,
minZoom: Float,
maxZoom: Float,
onZoomSelected: (Float) -> Unit,
modifier: Modifier = Modifier
) {
// Define zoom presets based on device capabilities
val presets = listOf(
ZoomPreset(0.5f, "0.5"),
ZoomPreset(1.0f, "1"),
ZoomPreset(2.0f, "2"),
ZoomPreset(5.0f, "5")
).filter { it.zoom in minZoom..maxZoom }
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
presets.forEach { preset ->
ZoomButton(
preset = preset,
isSelected = abs(currentZoom - preset.zoom) < 0.1f,
onClick = { onZoomSelected(preset.zoom) }
)
}
}
}
private data class ZoomPreset(val zoom: Float, val label: String)
@Composable
private fun ZoomButton(
preset: ZoomPreset,
isSelected: Boolean,
onClick: () -> Unit
) {
val backgroundColor by animateColorAsState(
targetValue = if (isSelected) {
Color(0xFFFFB300) // Amber when selected
} else {
Color(0x80000000) // Semi-transparent black
},
label = "zoom_button_bg"
)
val textColor by animateColorAsState(
targetValue = if (isSelected) Color.Black else Color.White,
label = "zoom_button_text"
)
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(backgroundColor)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Text(
text = "${preset.label}x",
color = textColor,
fontSize = 12.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
}
}
/**
* Displays current zoom level as a badge.
*/
@Composable
fun ZoomIndicator(
currentZoom: Float,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clip(CircleShape)
.background(Color(0x80000000))
.padding(horizontal = 12.dp, vertical = 6.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "%.1fx".format(currentZoom),
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
}
}

View file

@ -0,0 +1,98 @@
package no.naiv.tiltshift.util
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.view.HapticFeedbackConstants
import android.view.View
/**
* Provides haptic feedback for user interactions.
*/
class HapticFeedback(private val context: Context) {
private val vibrator: Vibrator by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
}
/**
* Light tick for UI feedback (button press, slider change).
*/
fun tick() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(10L)
}
}
/**
* Click feedback for confirmations.
*/
fun click() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(20L)
}
}
/**
* Heavy click for important actions (photo capture).
*/
fun heavyClick() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(40L)
}
}
/**
* Success feedback pattern.
*/
fun success() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val timings = longArrayOf(0, 30, 50, 30)
val amplitudes = intArrayOf(0, 100, 0, 200)
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(longArrayOf(0, 30, 50, 30), -1)
}
}
/**
* Error feedback pattern.
*/
fun error() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val timings = longArrayOf(0, 50, 30, 50, 30, 50)
val amplitudes = intArrayOf(0, 150, 0, 150, 0, 150)
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(longArrayOf(0, 50, 30, 50, 30, 50), -1)
}
}
companion object {
/**
* Use system haptic feedback on a View for standard interactions.
*/
fun performHapticFeedback(view: View, feedbackConstant: Int = HapticFeedbackConstants.VIRTUAL_KEY) {
view.performHapticFeedback(feedbackConstant)
}
}
}

View file

@ -0,0 +1,78 @@
package no.naiv.tiltshift.util
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.os.Looper
import androidx.core.content.ContextCompat
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
/**
* Provides location updates for EXIF GPS tagging.
*/
class LocationProvider(private val context: Context) {
private val fusedLocationClient: FusedLocationProviderClient =
LocationServices.getFusedLocationProviderClient(context)
/**
* Returns a Flow of location updates.
* Updates are throttled to conserve battery - we only need periodic updates for photo tagging.
*/
fun locationFlow(): Flow<Location?> = callbackFlow {
if (!hasLocationPermission()) {
trySend(null)
awaitClose()
return@callbackFlow
}
val locationRequest = LocationRequest.Builder(
Priority.PRIORITY_BALANCED_POWER_ACCURACY,
30_000L // Update every 30 seconds
).apply {
setMinUpdateIntervalMillis(10_000L)
setMaxUpdateDelayMillis(60_000L)
}.build()
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { trySend(it) }
}
}
try {
fusedLocationClient.requestLocationUpdates(
locationRequest,
callback,
Looper.getMainLooper()
)
// Also try to get last known location immediately
fusedLocationClient.lastLocation.addOnSuccessListener { location ->
location?.let { trySend(it) }
}
} catch (e: SecurityException) {
trySend(null)
}
awaitClose {
fusedLocationClient.removeLocationUpdates(callback)
}
}
private fun hasLocationPermission(): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
}

View file

@ -0,0 +1,85 @@
package no.naiv.tiltshift.util
import android.content.Context
import android.view.OrientationEventListener
import android.view.Surface
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
/**
* Detects device orientation and provides it as a Flow.
* Used to properly orient captured images.
*/
class OrientationDetector(private val context: Context) {
/**
* Returns the current device rotation as a Surface.ROTATION_* constant.
* This can be converted to degrees for EXIF orientation.
*/
fun orientationFlow(): Flow<Int> = callbackFlow {
val listener = object : OrientationEventListener(context) {
private var lastRotation = -1
override fun onOrientationChanged(orientation: Int) {
if (orientation == ORIENTATION_UNKNOWN) return
val rotation = when {
orientation >= 315 || orientation < 45 -> Surface.ROTATION_0
orientation >= 45 && orientation < 135 -> Surface.ROTATION_90
orientation >= 135 && orientation < 225 -> Surface.ROTATION_180
else -> Surface.ROTATION_270
}
if (rotation != lastRotation) {
lastRotation = rotation
trySend(rotation)
}
}
}
listener.enable()
trySend(Surface.ROTATION_0) // Initial value
awaitClose {
listener.disable()
}
}
companion object {
/**
* Converts Surface rotation to degrees for EXIF.
*/
fun rotationToDegrees(rotation: Int): Int {
return when (rotation) {
Surface.ROTATION_0 -> 0
Surface.ROTATION_90 -> 90
Surface.ROTATION_180 -> 180
Surface.ROTATION_270 -> 270
else -> 0
}
}
/**
* Converts degrees to EXIF orientation constant.
*/
fun degreesToExifOrientation(degrees: Int, isFrontCamera: Boolean): Int {
return when {
isFrontCamera -> when (degrees) {
0 -> android.media.ExifInterface.ORIENTATION_NORMAL
90 -> android.media.ExifInterface.ORIENTATION_ROTATE_270
180 -> android.media.ExifInterface.ORIENTATION_ROTATE_180
270 -> android.media.ExifInterface.ORIENTATION_ROTATE_90
else -> android.media.ExifInterface.ORIENTATION_NORMAL
}
else -> when (degrees) {
0 -> android.media.ExifInterface.ORIENTATION_NORMAL
90 -> android.media.ExifInterface.ORIENTATION_ROTATE_90
180 -> android.media.ExifInterface.ORIENTATION_ROTATE_180
270 -> android.media.ExifInterface.ORIENTATION_ROTATE_270
else -> android.media.ExifInterface.ORIENTATION_NORMAL
}
}
}
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#212121"
android:pathData="M0,0h108v108h-108z"/>
</vector>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Camera body -->
<path
android:fillColor="#FFFFFF"
android:pathData="M30,40 L78,40 Q82,40 82,44 L82,72 Q82,76 78,76 L30,76 Q26,76 26,72 L26,44 Q26,40 30,40 Z"/>
<!-- Camera lens -->
<path
android:fillColor="#424242"
android:pathData="M54,58 m-14,0 a14,14 0,1 1,28 0 a14,14 0,1 1,-28 0"/>
<path
android:fillColor="#616161"
android:pathData="M54,58 m-10,0 a10,10 0,1 1,20 0 a10,10 0,1 1,-20 0"/>
<!-- Lens reflection -->
<path
android:fillColor="#90CAF9"
android:pathData="M48,52 Q52,48 58,52 Q54,56 48,52"/>
<!-- Flash -->
<path
android:fillColor="#FFB300"
android:pathData="M70,44 L76,44 L76,50 L70,50 Z"/>
<!-- Tilt-shift blur indication (gradient bars) -->
<path
android:fillColor="#80FFFFFF"
android:pathData="M26,32 L82,32 L82,36 L26,36 Z"/>
<path
android:fillColor="#40FFFFFF"
android:pathData="M26,28 L82,28 L82,30 L26,30 Z"/>
<path
android:fillColor="#80FFFFFF"
android:pathData="M26,80 L82,80 L82,84 L26,84 Z"/>
<path
android:fillColor="#40FFFFFF"
android:pathData="M26,86 L82,86 L82,88 L26,88 Z"/>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,90 @@
// Fragment shader for tilt-shift effect
// Applies gradient blur based on distance from focus line
#extension GL_OES_EGL_image_external : require
precision mediump float;
// Camera texture (external texture for camera preview)
uniform samplerExternalOES uTexture;
// Effect parameters
uniform float uAngle; // Rotation angle in radians
uniform float uPosition; // Center position of focus (0-1)
uniform float uSize; // Size of in-focus region (0-1)
uniform float uBlurAmount; // Maximum blur intensity (0-1)
uniform vec2 uResolution; // Texture resolution for proper sampling
varying vec2 vTexCoord;
// Blur kernel size (must be odd)
const int KERNEL_SIZE = 9;
const float KERNEL_HALF = 4.0;
// Gaussian weights for 9-tap blur (sigma ~= 2.0)
const float weights[9] = float[](
0.0162, 0.0540, 0.1216, 0.1933, 0.2258,
0.1933, 0.1216, 0.0540, 0.0162
);
// Calculate signed distance from the focus line
float focusDistance(vec2 uv) {
// Rotate coordinate system around center
vec2 center = vec2(0.5, uPosition);
vec2 rotated = uv - center;
float cosA = cos(uAngle);
float sinA = sin(uAngle);
// After rotation, measure vertical distance from center line
float rotatedY = -rotated.x * sinA + rotated.y * cosA;
return abs(rotatedY);
}
// Calculate blur factor based on distance from focus
float blurFactor(float dist) {
// Smooth transition from in-focus to blurred
float halfSize = uSize * 0.5;
float transitionSize = halfSize * 0.5;
if (dist < halfSize) {
return 0.0; // In focus region
}
// Smooth falloff using smoothstep
float normalizedDist = (dist - halfSize) / transitionSize;
return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount;
}
// Sample with Gaussian blur
vec4 sampleBlurred(vec2 uv, float blur) {
if (blur < 0.01) {
return texture2D(uTexture, uv);
}
vec4 color = vec4(0.0);
vec2 texelSize = 1.0 / uResolution;
// Blur direction perpendicular to focus line
float blurAngle = uAngle + 1.5707963; // +90 degrees
vec2 blurDir = vec2(cos(blurAngle), sin(blurAngle));
// Scale blur radius by blur amount
float radius = blur * 20.0;
for (int i = 0; i < KERNEL_SIZE; i++) {
float offset = (float(i) - KERNEL_HALF);
vec2 samplePos = uv + blurDir * texelSize * offset * radius;
color += texture2D(uTexture, samplePos) * weights[i];
}
return color;
}
void main() {
float dist = focusDistance(vTexCoord);
float blur = blurFactor(dist);
gl_FragColor = sampleBlurred(vTexCoord, blur);
}

View file

@ -0,0 +1,12 @@
// Vertex shader for tilt-shift effect
// Passes through position and calculates texture coordinates
attribute vec4 aPosition;
attribute vec2 aTexCoord;
varying vec2 vTexCoord;
void main() {
gl_Position = aPosition;
vTexCoord = aTexCoord;
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="white">#FFFFFFFF</color>
<color name="black">#FF000000</color>
<color name="tiltshift_accent">#FFFFB300</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Tilt-Shift Camera</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.TiltShiftCamera" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>

6
build.gradle.kts Normal file
View file

@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

5
gradle.properties Normal file
View file

@ -0,0 +1,5 @@
# Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

44
gradle/libs.versions.toml Normal file
View file

@ -0,0 +1,44 @@
[versions]
agp = "8.7.3"
kotlin = "2.0.21"
coreKtx = "1.15.0"
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"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# CameraX
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
# EXIF
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
# 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" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,5 @@
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

176
gradlew vendored Executable file
View file

@ -0,0 +1,176 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
fi
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"

84
gradlew.bat vendored Normal file
View file

@ -0,0 +1,84 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

24
settings.gradle.kts Normal file
View file

@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "TiltShiftCamera"
include(":app")