From 07e10ac9c34d34484b2300cd75509b98f4d50a69 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 28 Jan 2026 15:26:41 +0100 Subject: [PATCH] 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 --- .gitignore | 91 ++++ README.md | 88 ++++ app/build.gradle.kts | 85 ++++ app/proguard-rules.pro | 10 + app/src/main/AndroidManifest.xml | 42 ++ .../java/no/naiv/tiltshift/MainActivity.kt | 215 +++++++++ .../no/naiv/tiltshift/camera/CameraManager.kt | 173 +++++++ .../tiltshift/camera/ImageCaptureHandler.kt | 434 ++++++++++++++++++ .../naiv/tiltshift/camera/LensController.kt | 98 ++++ .../naiv/tiltshift/effect/BlurParameters.kt | 54 +++ .../tiltshift/effect/TiltShiftRenderer.kt | 159 +++++++ .../naiv/tiltshift/effect/TiltShiftShader.kt | 126 +++++ .../no/naiv/tiltshift/storage/ExifWriter.kt | 96 ++++ .../no/naiv/tiltshift/storage/PhotoSaver.kt | 186 ++++++++ .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 345 ++++++++++++++ .../java/no/naiv/tiltshift/ui/LensSwitcher.kt | 117 +++++ .../no/naiv/tiltshift/ui/TiltShiftOverlay.kt | 257 +++++++++++ .../java/no/naiv/tiltshift/ui/ZoomControl.kt | 119 +++++ .../no/naiv/tiltshift/util/HapticFeedback.kt | 98 ++++ .../naiv/tiltshift/util/LocationProvider.kt | 78 ++++ .../tiltshift/util/OrientationDetector.kt | 85 ++++ .../res/drawable/ic_launcher_background.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 49 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/raw/tiltshift_fragment.glsl | 90 ++++ app/src/main/res/raw/tiltshift_vertex.glsl | 12 + app/src/main/res/values/colors.xml | 6 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 8 + build.gradle.kts | 6 + gradle.properties | 5 + gradle/libs.versions.toml | 44 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 57186 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 176 +++++++ gradlew.bat | 84 ++++ settings.gradle.kts | 24 + 38 files changed, 3489 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/no/naiv/tiltshift/MainActivity.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/camera/LensController.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/effect/BlurParameters.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/util/OrientationDetector.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/raw/tiltshift_fragment.glsl create mode 100644 app/src/main/res/raw/tiltshift_vertex.glsl create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts 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 0000000000000000000000000000000000000000..4844ffe1ec1b11b61b80bafeb2fb7ceee388e545 GIT binary patch literal 57186 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 NKR?FsKk;3U=O0@HZASnA literal 0 HcmV?d00001 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")