commit 07e10ac9c34d34484b2300cd75509b98f4d50a69 Author: Ole-Morten Duesund Date: Wed Jan 28 15:26:41 2026 +0100 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..036871b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2fabd70 --- /dev/null +++ b/README.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..01831dd --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f72d4d0 --- /dev/null +++ b/app/proguard-rules.pro @@ -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.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2a10e4c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/no/naiv/tiltshift/MainActivity.kt b/app/src/main/java/no/naiv/tiltshift/MainActivity.kt new file mode 100644 index 0000000..9f8b95b --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/MainActivity.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt new file mode 100644 index 0000000..ee6cbc4 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -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 = _zoomRatio.asStateFlow() + + private val _minZoomRatio = MutableStateFlow(1.0f) + val minZoomRatio: StateFlow = _minZoomRatio.asStateFlow() + + private val _maxZoomRatio = MutableStateFlow(1.0f) + val maxZoomRatio: StateFlow = _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 + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt new file mode 100644 index 0000000..baca3ec --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -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 + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/camera/LensController.kt b/app/src/main/java/no/naiv/tiltshift/camera/LensController.kt new file mode 100644 index 0000000..1939561 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/camera/LensController.kt @@ -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() + private var currentLensIndex = 0 + + /** + * Initializes available lenses based on device capabilities. + * Should be called after CameraProvider is available. + */ + fun initialize(cameraInfos: List) { + 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 = 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 { + return listOf(0.5f, 1.0f, 2.0f, 5.0f) + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/effect/BlurParameters.kt b/app/src/main/java/no/naiv/tiltshift/effect/BlurParameters.kt new file mode 100644 index 0000000..dd6e26d --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/effect/BlurParameters.kt @@ -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)) + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt new file mode 100644 index 0000000..18e35e3 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt @@ -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 + } + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt new file mode 100644 index 0000000..a63d928 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt @@ -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 + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt b/app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt new file mode 100644 index 0000000..4a7431a --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt @@ -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" + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt new file mode 100644 index 0000000..968ef55 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt @@ -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() + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt new file mode 100644 index 0000000..894b6f9 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -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(null) } + var renderer by remember { mutableStateOf(null) } + var glSurfaceView by remember { mutableStateOf(null) } + + var isCapturing by remember { mutableStateOf(false) } + var showSaveSuccess by remember { mutableStateOf(false) } + var showSaveError by remember { mutableStateOf(null) } + + var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) } + var currentLocation by remember { mutableStateOf(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) + ) + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt b/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt new file mode 100644 index 0000000..d356512 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt @@ -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, + 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) + ) + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt b/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt new file mode 100644 index 0000000..0783767 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt @@ -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() + ) + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt b/app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt new file mode 100644 index 0000000..a20cf47 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt b/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt new file mode 100644 index 0000000..551ede7 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt @@ -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) + } + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt b/app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt new file mode 100644 index 0000000..1065c45 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt @@ -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 = 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 + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/util/OrientationDetector.kt b/app/src/main/java/no/naiv/tiltshift/util/OrientationDetector.kt new file mode 100644 index 0000000..f7b49da --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/util/OrientationDetector.kt @@ -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 = 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 + } + } + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..af82910 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..ccca3c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..d378acd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..d378acd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/raw/tiltshift_fragment.glsl b/app/src/main/res/raw/tiltshift_fragment.glsl new file mode 100644 index 0000000..3b4b33e --- /dev/null +++ b/app/src/main/res/raw/tiltshift_fragment.glsl @@ -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); +} diff --git a/app/src/main/res/raw/tiltshift_vertex.glsl b/app/src/main/res/raw/tiltshift_vertex.glsl new file mode 100644 index 0000000..e009424 --- /dev/null +++ b/app/src/main/res/raw/tiltshift_vertex.glsl @@ -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; +} diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..569f2f3 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #FFFFFFFF + #FF000000 + #FFFFB300 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6c295e2 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Tilt-Shift Camera + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..c1f6d5a --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..5c98ad0 --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2cf094b --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..cd52516 --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..4844ffe Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37ee1eb --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..17a9170 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..70f9aec --- /dev/null +++ b/settings.gradle.kts @@ -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")