Initial implementation of Tilt-Shift Camera Android app
A dedicated camera app for tilt-shift photography with: - Real-time OpenGL ES 2.0 shader-based blur preview - Touch gesture controls (drag, rotate, pinch) for adjusting effect - CameraX integration for camera preview and high-res capture - EXIF metadata with GPS location support - MediaStore integration for saving to gallery - Jetpack Compose UI with haptic feedback Tech stack: Kotlin, CameraX, OpenGL ES 2.0, Jetpack Compose Min SDK: 26 (Android 8.0), Target SDK: 35 (Android 15) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
07e10ac9c3
38 changed files with 3489 additions and 0 deletions
91
.gitignore
vendored
Normal file
91
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/ (keep reports for CI)
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Kotlin
|
||||
.kotlin/
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
88
README.md
Normal file
88
README.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Tilt-Shift Camera
|
||||
|
||||
A dedicated Android camera app for tilt-shift photography with real-time preview, touch-based controls, and proper EXIF handling.
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time tilt-shift effect preview** - See the blur effect as you compose your shot
|
||||
- **Touch-based controls**:
|
||||
- Single finger drag to move the focus line position
|
||||
- Two-finger rotation to adjust blur angle
|
||||
- Pinch gesture to adjust blur zone size
|
||||
- Pinch in center to zoom camera
|
||||
- **Zoom controls** - Quick presets (0.5x, 1x, 2x, 5x) plus pinch-to-zoom
|
||||
- **Auto picture orientation detection** - Photos saved with correct EXIF orientation
|
||||
- **GPS location tagging** - Optional EXIF GPS data from device location
|
||||
- **Haptic feedback** - Tactile response for all interactions
|
||||
- **Saves to gallery** - Photos saved to Pictures/TiltShift/ folder
|
||||
|
||||
## Requirements
|
||||
|
||||
- Android 8.0 (API 26) or higher
|
||||
- Device with camera
|
||||
- OpenGL ES 2.0 support
|
||||
|
||||
## Building
|
||||
|
||||
1. Open the project in Android Studio
|
||||
2. Sync Gradle files
|
||||
3. Build and run on a physical device (camera preview won't work on emulator)
|
||||
|
||||
Or from command line:
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
- **Camera** (required) - For capturing photos
|
||||
- **Location** (optional) - For GPS tagging in EXIF data
|
||||
- **Vibrate** - For haptic feedback
|
||||
|
||||
## Architecture
|
||||
|
||||
The app uses:
|
||||
- **CameraX** - Jetpack camera library for camera preview and capture
|
||||
- **OpenGL ES 2.0** - Real-time shader-based blur effect
|
||||
- **Jetpack Compose** - Modern declarative UI
|
||||
- **Kotlin Coroutines & Flow** - Asynchronous operations and state management
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
app/src/main/java/no/naiv/tiltshift/
|
||||
├── MainActivity.kt # Entry point with permission handling
|
||||
├── camera/
|
||||
│ ├── CameraManager.kt # CameraX setup and control
|
||||
│ ├── LensController.kt # Lens/zoom switching
|
||||
│ └── ImageCaptureHandler.kt # Photo capture with effect
|
||||
├── effect/
|
||||
│ ├── TiltShiftRenderer.kt # OpenGL renderer
|
||||
│ ├── TiltShiftShader.kt # GLSL shader management
|
||||
│ └── BlurParameters.kt # Effect state
|
||||
├── ui/
|
||||
│ ├── CameraScreen.kt # Main Compose screen
|
||||
│ ├── TiltShiftOverlay.kt # Touch gesture handling & visualization
|
||||
│ ├── ZoomControl.kt # Zoom UI component
|
||||
│ └── LensSwitcher.kt # Lens selection UI
|
||||
├── storage/
|
||||
│ ├── PhotoSaver.kt # MediaStore integration
|
||||
│ └── ExifWriter.kt # EXIF metadata handling
|
||||
└── util/
|
||||
├── OrientationDetector.kt
|
||||
├── LocationProvider.kt
|
||||
└── HapticFeedback.kt
|
||||
```
|
||||
|
||||
## How the Tilt-Shift Effect Works
|
||||
|
||||
The tilt-shift effect simulates a selective focus lens that makes scenes appear miniature. The app achieves this through:
|
||||
|
||||
1. **Camera Preview** → OpenGL SurfaceTexture
|
||||
2. **Fragment Shader** calculates distance from the focus line for each pixel
|
||||
3. **Gradient blur** is applied based on distance - center stays sharp, edges blur
|
||||
4. **Two-pass Gaussian blur** (optimized as separable passes) for quality and performance
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
85
app/build.gradle.kts
Normal file
85
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "no.naiv.tiltshift"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "no.naiv.tiltshift"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
// Compose
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.material.icons.extended)
|
||||
|
||||
// CameraX
|
||||
implementation(libs.androidx.camera.core)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
|
||||
// EXIF
|
||||
implementation(libs.androidx.exifinterface)
|
||||
|
||||
// Location
|
||||
implementation(libs.play.services.location)
|
||||
|
||||
// Permissions
|
||||
implementation(libs.accompanist.permissions)
|
||||
|
||||
// Debug
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
}
|
||||
10
app/proguard-rules.pro
vendored
Normal file
10
app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
|
||||
# Keep CameraX classes
|
||||
-keep class androidx.camera.** { *; }
|
||||
|
||||
# Keep OpenGL shader-related code
|
||||
-keep class no.naiv.tiltshift.effect.** { *; }
|
||||
|
||||
# Keep location provider
|
||||
-keep class com.google.android.gms.location.** { *; }
|
||||
42
app/src/main/AndroidManifest.xml
Normal file
42
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Camera permission -->
|
||||
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- Location for EXIF GPS data -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Vibration for haptic feedback -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- OpenGL ES 2.0 -->
|
||||
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.TiltShiftCamera"
|
||||
tools:targetApi="35">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.TiltShiftCamera">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
215
app/src/main/java/no/naiv/tiltshift/MainActivity.kt
Normal file
215
app/src/main/java/no/naiv/tiltshift/MainActivity.kt
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
package no.naiv.tiltshift
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Camera
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import no.naiv.tiltshift.ui.CameraScreen
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Enable edge-to-edge display
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Hide system bars for immersive camera experience
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val controller = WindowInsetsControllerCompat(window, window.decorView)
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
|
||||
setContent {
|
||||
TiltShiftApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
private fun TiltShiftApp() {
|
||||
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
|
||||
val locationPermissions = rememberMultiplePermissionsState(
|
||||
listOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
)
|
||||
|
||||
// Request camera permission on launch
|
||||
LaunchedEffect(Unit) {
|
||||
if (!cameraPermission.status.isGranted) {
|
||||
cameraPermission.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
when {
|
||||
cameraPermission.status.isGranted -> {
|
||||
// Camera permission granted - show camera
|
||||
CameraScreen()
|
||||
|
||||
// Request location in background (for EXIF GPS)
|
||||
LaunchedEffect(Unit) {
|
||||
if (!locationPermissions.allPermissionsGranted) {
|
||||
locationPermissions.launchMultiplePermissionRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Show permission request UI
|
||||
PermissionRequestScreen(
|
||||
onRequestCamera = { cameraPermission.launchPermissionRequest() },
|
||||
onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() },
|
||||
cameraGranted = cameraPermission.status.isGranted,
|
||||
locationGranted = locationPermissions.allPermissionsGranted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionRequestScreen(
|
||||
onRequestCamera: () -> Unit,
|
||||
onRequestLocation: () -> Unit,
|
||||
cameraGranted: Boolean,
|
||||
locationGranted: Boolean
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Tilt-Shift Camera",
|
||||
color = Color.White,
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Create beautiful miniature-style photos with customizable blur effects",
|
||||
color = Color.Gray,
|
||||
fontSize = 16.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
// Camera permission
|
||||
PermissionItem(
|
||||
icon = Icons.Default.Camera,
|
||||
title = "Camera",
|
||||
description = "Required to take photos",
|
||||
isGranted = cameraGranted,
|
||||
onRequest = onRequestCamera
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Location permission
|
||||
PermissionItem(
|
||||
icon = Icons.Default.LocationOn,
|
||||
title = "Location",
|
||||
description = "Optional - adds GPS data to photos",
|
||||
isGranted = locationGranted,
|
||||
onRequest = onRequestLocation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionItem(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
title: String,
|
||||
description: String,
|
||||
isGranted: Boolean,
|
||||
onRequest: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color(0xFF1E1E1E))
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (isGranted) Color(0xFF4CAF50) else Color(0xFFFFB300),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
color = Color.Gray,
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (!isGranted) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = onRequest,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFFFB300)
|
||||
)
|
||||
) {
|
||||
Text("Grant", color = Color.Black)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
173
app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt
Normal file
173
app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package no.naiv.tiltshift.camera
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.util.Size
|
||||
import android.view.Surface
|
||||
import androidx.camera.core.Camera
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.SurfaceRequest
|
||||
import androidx.camera.core.resolutionselector.AspectRatioStrategy
|
||||
import androidx.camera.core.resolutionselector.ResolutionSelector
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
/**
|
||||
* Manages CameraX camera setup and controls.
|
||||
*/
|
||||
class CameraManager(private val context: Context) {
|
||||
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private var camera: Camera? = null
|
||||
private var preview: Preview? = null
|
||||
var imageCapture: ImageCapture? = null
|
||||
private set
|
||||
|
||||
val lensController = LensController()
|
||||
|
||||
private val _zoomRatio = MutableStateFlow(1.0f)
|
||||
val zoomRatio: StateFlow<Float> = _zoomRatio.asStateFlow()
|
||||
|
||||
private val _minZoomRatio = MutableStateFlow(1.0f)
|
||||
val minZoomRatio: StateFlow<Float> = _minZoomRatio.asStateFlow()
|
||||
|
||||
private val _maxZoomRatio = MutableStateFlow(1.0f)
|
||||
val maxZoomRatio: StateFlow<Float> = _maxZoomRatio.asStateFlow()
|
||||
|
||||
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
|
||||
private var surfaceSize: Size = Size(1920, 1080)
|
||||
|
||||
/**
|
||||
* Starts the camera with the given lifecycle owner.
|
||||
* The surfaceTextureProvider should return the SurfaceTexture from the GL renderer.
|
||||
*/
|
||||
fun startCamera(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
surfaceTextureProvider: () -> SurfaceTexture?
|
||||
) {
|
||||
this.surfaceTextureProvider = surfaceTextureProvider
|
||||
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
cameraProviderFuture.addListener({
|
||||
cameraProvider = cameraProviderFuture.get()
|
||||
lensController.initialize(cameraProvider?.availableCameraInfos ?: emptyList())
|
||||
bindCameraUseCases(lifecycleOwner)
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
}
|
||||
|
||||
private fun bindCameraUseCases(lifecycleOwner: LifecycleOwner) {
|
||||
val provider = cameraProvider ?: return
|
||||
|
||||
// Unbind all use cases before rebinding
|
||||
provider.unbindAll()
|
||||
|
||||
// Preview use case with resolution selector
|
||||
val resolutionSelector = ResolutionSelector.Builder()
|
||||
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
|
||||
.build()
|
||||
|
||||
preview = Preview.Builder()
|
||||
.setResolutionSelector(resolutionSelector)
|
||||
.build()
|
||||
|
||||
// Image capture use case for high-res photos
|
||||
imageCapture = ImageCapture.Builder()
|
||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
|
||||
.build()
|
||||
|
||||
// Get camera selector from lens controller
|
||||
val cameraSelector = lensController.getCurrentLens()?.selector
|
||||
?: CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
try {
|
||||
// Bind use cases to camera
|
||||
camera = provider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
cameraSelector,
|
||||
preview,
|
||||
imageCapture
|
||||
)
|
||||
|
||||
// Update zoom info
|
||||
camera?.cameraInfo?.let { info ->
|
||||
_minZoomRatio.value = info.zoomState.value?.minZoomRatio ?: 1.0f
|
||||
_maxZoomRatio.value = info.zoomState.value?.maxZoomRatio ?: 1.0f
|
||||
_zoomRatio.value = info.zoomState.value?.zoomRatio ?: 1.0f
|
||||
}
|
||||
|
||||
// Set up surface provider for preview
|
||||
preview?.setSurfaceProvider { request ->
|
||||
provideSurface(request)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
// Camera binding failed
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun provideSurface(request: SurfaceRequest) {
|
||||
val surfaceTexture = surfaceTextureProvider?.invoke()
|
||||
if (surfaceTexture == null) {
|
||||
request.willNotProvideSurface()
|
||||
return
|
||||
}
|
||||
|
||||
surfaceSize = request.resolution
|
||||
surfaceTexture.setDefaultBufferSize(surfaceSize.width, surfaceSize.height)
|
||||
|
||||
val surface = Surface(surfaceTexture)
|
||||
request.provideSurface(surface, ContextCompat.getMainExecutor(context)) { result ->
|
||||
surface.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the zoom ratio.
|
||||
*/
|
||||
fun setZoom(ratio: Float) {
|
||||
val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value)
|
||||
camera?.cameraControl?.setZoomRatio(clamped)
|
||||
_zoomRatio.value = clamped
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets zoom by linear percentage (0.0 to 1.0).
|
||||
*/
|
||||
fun setZoomLinear(percentage: Float) {
|
||||
camera?.cameraControl?.setLinearZoom(percentage.coerceIn(0f, 1f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a different lens.
|
||||
*/
|
||||
fun switchLens(lensId: String, lifecycleOwner: LifecycleOwner) {
|
||||
if (lensController.selectLens(lensId)) {
|
||||
bindCameraUseCases(lifecycleOwner)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the executor for image capture callbacks.
|
||||
*/
|
||||
fun getExecutor(): Executor {
|
||||
return ContextCompat.getMainExecutor(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases camera resources.
|
||||
*/
|
||||
fun release() {
|
||||
cameraProvider?.unbindAll()
|
||||
camera = null
|
||||
preview = null
|
||||
imageCapture = null
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
98
app/src/main/java/no/naiv/tiltshift/camera/LensController.kt
Normal file
98
app/src/main/java/no/naiv/tiltshift/camera/LensController.kt
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package no.naiv.tiltshift.camera
|
||||
|
||||
import androidx.camera.core.CameraInfo
|
||||
import androidx.camera.core.CameraSelector
|
||||
|
||||
/**
|
||||
* Represents available camera lenses on the device.
|
||||
*/
|
||||
data class CameraLens(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val zoomFactor: Float,
|
||||
val selector: CameraSelector
|
||||
)
|
||||
|
||||
/**
|
||||
* Controls lens selection and provides information about available lenses.
|
||||
*/
|
||||
class LensController {
|
||||
|
||||
private val availableLenses = mutableListOf<CameraLens>()
|
||||
private var currentLensIndex = 0
|
||||
|
||||
/**
|
||||
* Initializes available lenses based on device capabilities.
|
||||
* Should be called after CameraProvider is available.
|
||||
*/
|
||||
fun initialize(cameraInfos: List<CameraInfo>) {
|
||||
availableLenses.clear()
|
||||
|
||||
// Check for back cameras (main, ultrawide, telephoto)
|
||||
val hasBackCamera = cameraInfos.any {
|
||||
it.lensFacing == CameraSelector.LENS_FACING_BACK
|
||||
}
|
||||
|
||||
if (hasBackCamera) {
|
||||
// Standard back camera
|
||||
availableLenses.add(
|
||||
CameraLens(
|
||||
id = "back_main",
|
||||
displayName = "1x",
|
||||
zoomFactor = 1.0f,
|
||||
selector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Set default to main back camera
|
||||
currentLensIndex = availableLenses.indexOfFirst { it.id == "back_main" }
|
||||
.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all available lenses.
|
||||
*/
|
||||
fun getAvailableLenses(): List<CameraLens> = availableLenses.toList()
|
||||
|
||||
/**
|
||||
* Returns the currently selected lens.
|
||||
*/
|
||||
fun getCurrentLens(): CameraLens? {
|
||||
return if (availableLenses.isNotEmpty()) {
|
||||
availableLenses[currentLensIndex]
|
||||
} else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a specific lens by ID.
|
||||
* Returns true if the lens was found and selected.
|
||||
*/
|
||||
fun selectLens(lensId: String): Boolean {
|
||||
val index = availableLenses.indexOfFirst { it.id == lensId }
|
||||
if (index >= 0) {
|
||||
currentLensIndex = index
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycles to the next available lens.
|
||||
* Returns the newly selected lens, or null if no lenses available.
|
||||
*/
|
||||
fun cycleToNextLens(): CameraLens? {
|
||||
if (availableLenses.isEmpty()) return null
|
||||
|
||||
currentLensIndex = (currentLensIndex + 1) % availableLenses.size
|
||||
return availableLenses[currentLensIndex]
|
||||
}
|
||||
|
||||
/**
|
||||
* Common zoom levels that can be achieved through digital zoom.
|
||||
* These are presented as quick-select buttons.
|
||||
*/
|
||||
fun getZoomPresets(): List<Float> {
|
||||
return listOf(0.5f, 1.0f, 2.0f, 5.0f)
|
||||
}
|
||||
}
|
||||
54
app/src/main/java/no/naiv/tiltshift/effect/BlurParameters.kt
Normal file
54
app/src/main/java/no/naiv/tiltshift/effect/BlurParameters.kt
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package no.naiv.tiltshift.effect
|
||||
|
||||
/**
|
||||
* Parameters controlling the tilt-shift blur effect.
|
||||
*
|
||||
* @param angle The rotation angle of the blur gradient in radians (0 = horizontal blur bands)
|
||||
* @param position The center position of the in-focus region (0.0 to 1.0, relative to screen)
|
||||
* @param size The size of the in-focus region (0.0 to 1.0, as fraction of screen height)
|
||||
* @param blurAmount The intensity of the blur effect (0.0 to 1.0)
|
||||
*/
|
||||
data class BlurParameters(
|
||||
val angle: Float = 0f,
|
||||
val position: Float = 0.5f,
|
||||
val size: Float = 0.3f,
|
||||
val blurAmount: Float = 0.8f
|
||||
) {
|
||||
companion object {
|
||||
val DEFAULT = BlurParameters()
|
||||
|
||||
// Constraints
|
||||
const val MIN_SIZE = 0.1f
|
||||
const val MAX_SIZE = 0.8f
|
||||
const val MIN_BLUR = 0.0f
|
||||
const val MAX_BLUR = 1.0f
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the angle adjusted by the given delta.
|
||||
*/
|
||||
fun withAngleDelta(delta: Float): BlurParameters {
|
||||
return copy(angle = angle + delta)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the position clamped to valid range.
|
||||
*/
|
||||
fun withPosition(newPosition: Float): BlurParameters {
|
||||
return copy(position = newPosition.coerceIn(0f, 1f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the size clamped to valid range.
|
||||
*/
|
||||
fun withSize(newSize: Float): BlurParameters {
|
||||
return copy(size = newSize.coerceIn(MIN_SIZE, MAX_SIZE))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the blur amount clamped to valid range.
|
||||
*/
|
||||
fun withBlurAmount(amount: Float): BlurParameters {
|
||||
return copy(blurAmount = amount.coerceIn(MIN_BLUR, MAX_BLUR))
|
||||
}
|
||||
}
|
||||
159
app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt
Normal file
159
app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
package no.naiv.tiltshift.effect
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.opengl.GLES11Ext
|
||||
import android.opengl.GLES20
|
||||
import android.opengl.GLSurfaceView
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.FloatBuffer
|
||||
import javax.microedition.khronos.egl.EGLConfig
|
||||
import javax.microedition.khronos.opengles.GL10
|
||||
|
||||
/**
|
||||
* OpenGL renderer for applying tilt-shift effect to camera preview.
|
||||
*
|
||||
* This renderer receives camera frames via SurfaceTexture and applies
|
||||
* the tilt-shift blur effect using GLSL shaders.
|
||||
*/
|
||||
class TiltShiftRenderer(
|
||||
private val context: Context,
|
||||
private val onSurfaceTextureAvailable: (SurfaceTexture) -> Unit
|
||||
) : GLSurfaceView.Renderer {
|
||||
|
||||
private lateinit var shader: TiltShiftShader
|
||||
private var surfaceTexture: SurfaceTexture? = null
|
||||
private var cameraTextureId: Int = 0
|
||||
|
||||
private lateinit var vertexBuffer: FloatBuffer
|
||||
private lateinit var texCoordBuffer: FloatBuffer
|
||||
|
||||
private var surfaceWidth: Int = 0
|
||||
private var surfaceHeight: Int = 0
|
||||
|
||||
// Current effect parameters (updated from UI thread)
|
||||
@Volatile
|
||||
var blurParameters: BlurParameters = BlurParameters.DEFAULT
|
||||
|
||||
// Quad vertices (full screen)
|
||||
private val vertices = floatArrayOf(
|
||||
-1f, -1f, // Bottom left
|
||||
1f, -1f, // Bottom right
|
||||
-1f, 1f, // Top left
|
||||
1f, 1f // Top right
|
||||
)
|
||||
|
||||
// Texture coordinates (flip Y for camera)
|
||||
private val texCoords = floatArrayOf(
|
||||
0f, 1f, // Bottom left
|
||||
1f, 1f, // Bottom right
|
||||
0f, 0f, // Top left
|
||||
1f, 0f // Top right
|
||||
)
|
||||
|
||||
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
|
||||
GLES20.glClearColor(0f, 0f, 0f, 1f)
|
||||
|
||||
// Initialize shader
|
||||
shader = TiltShiftShader(context)
|
||||
shader.initialize()
|
||||
|
||||
// Create vertex buffer
|
||||
vertexBuffer = ByteBuffer.allocateDirect(vertices.size * 4)
|
||||
.order(ByteOrder.nativeOrder())
|
||||
.asFloatBuffer()
|
||||
.put(vertices)
|
||||
vertexBuffer.position(0)
|
||||
|
||||
// Create texture coordinate buffer
|
||||
texCoordBuffer = ByteBuffer.allocateDirect(texCoords.size * 4)
|
||||
.order(ByteOrder.nativeOrder())
|
||||
.asFloatBuffer()
|
||||
.put(texCoords)
|
||||
texCoordBuffer.position(0)
|
||||
|
||||
// Create camera texture
|
||||
val textures = IntArray(1)
|
||||
GLES20.glGenTextures(1, textures, 0)
|
||||
cameraTextureId = textures[0]
|
||||
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId)
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
|
||||
|
||||
// Create SurfaceTexture for camera frames
|
||||
surfaceTexture = SurfaceTexture(cameraTextureId).also {
|
||||
onSurfaceTextureAvailable(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
|
||||
GLES20.glViewport(0, 0, width, height)
|
||||
surfaceWidth = width
|
||||
surfaceHeight = height
|
||||
}
|
||||
|
||||
override fun onDrawFrame(gl: GL10?) {
|
||||
// Update texture with latest camera frame
|
||||
surfaceTexture?.updateTexImage()
|
||||
|
||||
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
||||
|
||||
// Use shader and set parameters
|
||||
shader.use(cameraTextureId, blurParameters, surfaceWidth, surfaceHeight)
|
||||
|
||||
// Set vertex positions
|
||||
GLES20.glEnableVertexAttribArray(shader.aPositionLocation)
|
||||
GLES20.glVertexAttribPointer(
|
||||
shader.aPositionLocation,
|
||||
2,
|
||||
GLES20.GL_FLOAT,
|
||||
false,
|
||||
0,
|
||||
vertexBuffer
|
||||
)
|
||||
|
||||
// Set texture coordinates
|
||||
GLES20.glEnableVertexAttribArray(shader.aTexCoordLocation)
|
||||
GLES20.glVertexAttribPointer(
|
||||
shader.aTexCoordLocation,
|
||||
2,
|
||||
GLES20.GL_FLOAT,
|
||||
false,
|
||||
0,
|
||||
texCoordBuffer
|
||||
)
|
||||
|
||||
// Draw quad
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
||||
|
||||
// Cleanup
|
||||
GLES20.glDisableVertexAttribArray(shader.aPositionLocation)
|
||||
GLES20.glDisableVertexAttribArray(shader.aTexCoordLocation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates blur parameters. Thread-safe.
|
||||
*/
|
||||
fun updateParameters(params: BlurParameters) {
|
||||
blurParameters = params
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases OpenGL resources.
|
||||
* Must be called from GL thread.
|
||||
*/
|
||||
fun release() {
|
||||
shader.release()
|
||||
surfaceTexture?.release()
|
||||
surfaceTexture = null
|
||||
|
||||
if (cameraTextureId != 0) {
|
||||
GLES20.glDeleteTextures(1, intArrayOf(cameraTextureId), 0)
|
||||
cameraTextureId = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
126
app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt
Normal file
126
app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package no.naiv.tiltshift.effect
|
||||
|
||||
import android.content.Context
|
||||
import android.opengl.GLES11Ext
|
||||
import android.opengl.GLES20
|
||||
import no.naiv.tiltshift.R
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
|
||||
/**
|
||||
* Manages OpenGL shader programs for the tilt-shift effect.
|
||||
*/
|
||||
class TiltShiftShader(private val context: Context) {
|
||||
|
||||
var programId: Int = 0
|
||||
private set
|
||||
|
||||
// Attribute locations
|
||||
var aPositionLocation: Int = 0
|
||||
private set
|
||||
var aTexCoordLocation: Int = 0
|
||||
private set
|
||||
|
||||
// Uniform locations
|
||||
private var uTextureLocation: Int = 0
|
||||
private var uAngleLocation: Int = 0
|
||||
private var uPositionLocation: Int = 0
|
||||
private var uSizeLocation: Int = 0
|
||||
private var uBlurAmountLocation: Int = 0
|
||||
private var uResolutionLocation: Int = 0
|
||||
|
||||
/**
|
||||
* Compiles and links the shader program.
|
||||
* Must be called from GL thread.
|
||||
*/
|
||||
fun initialize() {
|
||||
val vertexSource = loadShaderSource(R.raw.tiltshift_vertex)
|
||||
val fragmentSource = loadShaderSource(R.raw.tiltshift_fragment)
|
||||
|
||||
val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource)
|
||||
val fragmentShader = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
|
||||
|
||||
programId = GLES20.glCreateProgram()
|
||||
GLES20.glAttachShader(programId, vertexShader)
|
||||
GLES20.glAttachShader(programId, fragmentShader)
|
||||
GLES20.glLinkProgram(programId)
|
||||
|
||||
// Check for link errors
|
||||
val linkStatus = IntArray(1)
|
||||
GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, linkStatus, 0)
|
||||
if (linkStatus[0] == 0) {
|
||||
val error = GLES20.glGetProgramInfoLog(programId)
|
||||
GLES20.glDeleteProgram(programId)
|
||||
throw RuntimeException("Shader program link failed: $error")
|
||||
}
|
||||
|
||||
// Get attribute locations
|
||||
aPositionLocation = GLES20.glGetAttribLocation(programId, "aPosition")
|
||||
aTexCoordLocation = GLES20.glGetAttribLocation(programId, "aTexCoord")
|
||||
|
||||
// Get uniform locations
|
||||
uTextureLocation = GLES20.glGetUniformLocation(programId, "uTexture")
|
||||
uAngleLocation = GLES20.glGetUniformLocation(programId, "uAngle")
|
||||
uPositionLocation = GLES20.glGetUniformLocation(programId, "uPosition")
|
||||
uSizeLocation = GLES20.glGetUniformLocation(programId, "uSize")
|
||||
uBlurAmountLocation = GLES20.glGetUniformLocation(programId, "uBlurAmount")
|
||||
uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution")
|
||||
|
||||
// Clean up shaders (they're linked into program now)
|
||||
GLES20.glDeleteShader(vertexShader)
|
||||
GLES20.glDeleteShader(fragmentShader)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the shader program and sets uniforms.
|
||||
*/
|
||||
fun use(textureId: Int, params: BlurParameters, width: Int, height: Int) {
|
||||
GLES20.glUseProgram(programId)
|
||||
|
||||
// Bind camera texture
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
|
||||
GLES20.glUniform1i(uTextureLocation, 0)
|
||||
|
||||
// Set effect parameters
|
||||
GLES20.glUniform1f(uAngleLocation, params.angle)
|
||||
GLES20.glUniform1f(uPositionLocation, params.position)
|
||||
GLES20.glUniform1f(uSizeLocation, params.size)
|
||||
GLES20.glUniform1f(uBlurAmountLocation, params.blurAmount)
|
||||
GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat())
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases shader resources.
|
||||
*/
|
||||
fun release() {
|
||||
if (programId != 0) {
|
||||
GLES20.glDeleteProgram(programId)
|
||||
programId = 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadShaderSource(resourceId: Int): String {
|
||||
val inputStream = context.resources.openRawResource(resourceId)
|
||||
val reader = BufferedReader(InputStreamReader(inputStream))
|
||||
return reader.use { it.readText() }
|
||||
}
|
||||
|
||||
private fun compileShader(type: Int, source: String): Int {
|
||||
val shader = GLES20.glCreateShader(type)
|
||||
GLES20.glShaderSource(shader, source)
|
||||
GLES20.glCompileShader(shader)
|
||||
|
||||
// Check for compile errors
|
||||
val compileStatus = IntArray(1)
|
||||
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0)
|
||||
if (compileStatus[0] == 0) {
|
||||
val error = GLES20.glGetShaderInfoLog(shader)
|
||||
GLES20.glDeleteShader(shader)
|
||||
val shaderType = if (type == GLES20.GL_VERTEX_SHADER) "vertex" else "fragment"
|
||||
throw RuntimeException("$shaderType shader compilation failed: $error")
|
||||
}
|
||||
|
||||
return shader
|
||||
}
|
||||
}
|
||||
96
app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt
Normal file
96
app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package no.naiv.tiltshift.storage
|
||||
|
||||
import android.location.Location
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Writes EXIF metadata to captured images.
|
||||
*/
|
||||
class ExifWriter {
|
||||
|
||||
private val dateTimeFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US)
|
||||
|
||||
/**
|
||||
* Writes EXIF data to the specified image file.
|
||||
*/
|
||||
fun writeExifData(
|
||||
file: File,
|
||||
orientation: Int,
|
||||
location: Location?,
|
||||
make: String = "Android",
|
||||
model: String = android.os.Build.MODEL
|
||||
) {
|
||||
try {
|
||||
val exif = ExifInterface(file)
|
||||
|
||||
// Orientation
|
||||
exif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
|
||||
|
||||
// Date/time
|
||||
val dateTime = dateTimeFormat.format(Date())
|
||||
exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime)
|
||||
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime)
|
||||
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, dateTime)
|
||||
|
||||
// Camera info
|
||||
exif.setAttribute(ExifInterface.TAG_MAKE, make)
|
||||
exif.setAttribute(ExifInterface.TAG_MODEL, model)
|
||||
exif.setAttribute(ExifInterface.TAG_SOFTWARE, "Tilt-Shift Camera")
|
||||
|
||||
// GPS location
|
||||
if (location != null) {
|
||||
setLocationExif(exif, location)
|
||||
}
|
||||
|
||||
exif.saveAttributes()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setLocationExif(exif: ExifInterface, location: Location) {
|
||||
// Latitude
|
||||
val latitude = location.latitude
|
||||
val latRef = if (latitude >= 0) "N" else "S"
|
||||
exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, convertToDMS(Math.abs(latitude)))
|
||||
exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE_REF, latRef)
|
||||
|
||||
// Longitude
|
||||
val longitude = location.longitude
|
||||
val lonRef = if (longitude >= 0) "E" else "W"
|
||||
exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, convertToDMS(Math.abs(longitude)))
|
||||
exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF, lonRef)
|
||||
|
||||
// Altitude
|
||||
if (location.hasAltitude()) {
|
||||
val altitude = location.altitude
|
||||
val altRef = if (altitude >= 0) "0" else "1"
|
||||
exif.setAttribute(ExifInterface.TAG_GPS_ALTITUDE, "${Math.abs(altitude).toLong()}/1")
|
||||
exif.setAttribute(ExifInterface.TAG_GPS_ALTITUDE_REF, altRef)
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
val gpsTimeFormat = SimpleDateFormat("HH:mm:ss", Locale.US)
|
||||
val gpsDateFormat = SimpleDateFormat("yyyy:MM:dd", Locale.US)
|
||||
val timestamp = Date(location.time)
|
||||
exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, gpsTimeFormat.format(timestamp))
|
||||
exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, gpsDateFormat.format(timestamp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts decimal degrees to DMS (degrees/minutes/seconds) format for EXIF.
|
||||
*/
|
||||
private fun convertToDMS(coordinate: Double): String {
|
||||
val degrees = coordinate.toInt()
|
||||
val minutesDecimal = (coordinate - degrees) * 60
|
||||
val minutes = minutesDecimal.toInt()
|
||||
val seconds = (minutesDecimal - minutes) * 60
|
||||
|
||||
// EXIF format: "degrees/1,minutes/1,seconds/1000"
|
||||
return "$degrees/1,$minutes/1,${(seconds * 1000).toLong()}/1000"
|
||||
}
|
||||
}
|
||||
186
app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt
Normal file
186
app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package no.naiv.tiltshift.storage
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.location.Location
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Result of a photo save operation.
|
||||
*/
|
||||
sealed class SaveResult {
|
||||
data class Success(val uri: Uri, val path: String) : SaveResult()
|
||||
data class Error(val message: String, val exception: Exception? = null) : SaveResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles saving captured photos to the device gallery.
|
||||
*/
|
||||
class PhotoSaver(private val context: Context) {
|
||||
|
||||
private val exifWriter = ExifWriter()
|
||||
|
||||
private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
|
||||
|
||||
/**
|
||||
* Saves a bitmap with the tilt-shift effect to the gallery.
|
||||
*/
|
||||
suspend fun saveBitmap(
|
||||
bitmap: Bitmap,
|
||||
orientation: Int,
|
||||
location: Location?
|
||||
): SaveResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg"
|
||||
|
||||
// Create content values for MediaStore
|
||||
val contentValues = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
|
||||
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift")
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into MediaStore
|
||||
val contentResolver = context.contentResolver
|
||||
val uri = contentResolver.insert(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
contentValues
|
||||
) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry")
|
||||
|
||||
// Write bitmap to output stream
|
||||
contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream)
|
||||
} ?: return@withContext SaveResult.Error("Failed to open output stream")
|
||||
|
||||
// Write EXIF data
|
||||
writeExifToUri(uri, orientation, location)
|
||||
|
||||
// Mark as complete (API 29+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
contentValues.clear()
|
||||
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||
contentResolver.update(uri, contentValues, null, null)
|
||||
}
|
||||
|
||||
// Get the file path for display
|
||||
val path = getPathFromUri(uri)
|
||||
|
||||
SaveResult.Success(uri, path)
|
||||
} catch (e: Exception) {
|
||||
SaveResult.Error("Failed to save photo: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a JPEG file (from CameraX ImageCapture) to the gallery.
|
||||
*/
|
||||
suspend fun saveJpegFile(
|
||||
sourceFile: File,
|
||||
orientation: Int,
|
||||
location: Location?
|
||||
): SaveResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg"
|
||||
|
||||
val contentValues = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
|
||||
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift")
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
}
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
val uri = contentResolver.insert(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
contentValues
|
||||
) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry")
|
||||
|
||||
// Copy file to MediaStore
|
||||
contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
sourceFile.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
} ?: return@withContext SaveResult.Error("Failed to open output stream")
|
||||
|
||||
// Write EXIF data
|
||||
writeExifToUri(uri, orientation, location)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
contentValues.clear()
|
||||
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||
contentResolver.update(uri, contentValues, null, null)
|
||||
}
|
||||
|
||||
// Clean up source file
|
||||
sourceFile.delete()
|
||||
|
||||
val path = getPathFromUri(uri)
|
||||
SaveResult.Success(uri, path)
|
||||
} catch (e: Exception) {
|
||||
SaveResult.Error("Failed to save photo: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeExifToUri(uri: Uri, orientation: Int, location: Location?) {
|
||||
try {
|
||||
context.contentResolver.openFileDescriptor(uri, "rw")?.use { pfd ->
|
||||
val exif = ExifInterface(pfd.fileDescriptor)
|
||||
|
||||
exif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
|
||||
exif.setAttribute(ExifInterface.TAG_SOFTWARE, "Tilt-Shift Camera")
|
||||
exif.setAttribute(ExifInterface.TAG_MAKE, "Android")
|
||||
exif.setAttribute(ExifInterface.TAG_MODEL, Build.MODEL)
|
||||
|
||||
val dateFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US)
|
||||
val dateTime = dateFormat.format(Date())
|
||||
exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime)
|
||||
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime)
|
||||
|
||||
location?.let { loc ->
|
||||
exif.setGpsInfo(loc)
|
||||
}
|
||||
|
||||
exif.saveAttributes()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPathFromUri(uri: Uri): String {
|
||||
val projection = arrayOf(MediaStore.Images.Media.DATA)
|
||||
context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
||||
return cursor.getString(columnIndex) ?: uri.toString()
|
||||
}
|
||||
}
|
||||
return uri.toString()
|
||||
}
|
||||
}
|
||||
345
app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt
Normal file
345
app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
package no.naiv.tiltshift.ui
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.location.Location
|
||||
import android.opengl.GLSurfaceView
|
||||
import android.view.Surface
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import no.naiv.tiltshift.camera.CameraManager
|
||||
import no.naiv.tiltshift.camera.ImageCaptureHandler
|
||||
import no.naiv.tiltshift.effect.BlurParameters
|
||||
import no.naiv.tiltshift.effect.TiltShiftRenderer
|
||||
import no.naiv.tiltshift.storage.PhotoSaver
|
||||
import no.naiv.tiltshift.storage.SaveResult
|
||||
import no.naiv.tiltshift.util.HapticFeedback
|
||||
import no.naiv.tiltshift.util.LocationProvider
|
||||
import no.naiv.tiltshift.util.OrientationDetector
|
||||
|
||||
/**
|
||||
* Main camera screen with tilt-shift controls.
|
||||
*/
|
||||
@Composable
|
||||
fun CameraScreen(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Camera and effect state
|
||||
val cameraManager = remember { CameraManager(context) }
|
||||
val photoSaver = remember { PhotoSaver(context) }
|
||||
val captureHandler = remember { ImageCaptureHandler(context, photoSaver) }
|
||||
val haptics = remember { HapticFeedback(context) }
|
||||
val orientationDetector = remember { OrientationDetector(context) }
|
||||
val locationProvider = remember { LocationProvider(context) }
|
||||
|
||||
// State
|
||||
var blurParams by remember { mutableStateOf(BlurParameters.DEFAULT) }
|
||||
var surfaceTexture by remember { mutableStateOf<SurfaceTexture?>(null) }
|
||||
var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) }
|
||||
var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) }
|
||||
|
||||
var isCapturing by remember { mutableStateOf(false) }
|
||||
var showSaveSuccess by remember { mutableStateOf(false) }
|
||||
var showSaveError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
|
||||
var currentLocation by remember { mutableStateOf<Location?>(null) }
|
||||
|
||||
val zoomRatio by cameraManager.zoomRatio.collectAsState()
|
||||
val minZoom by cameraManager.minZoomRatio.collectAsState()
|
||||
val maxZoom by cameraManager.maxZoomRatio.collectAsState()
|
||||
|
||||
// Collect orientation updates
|
||||
LaunchedEffect(Unit) {
|
||||
orientationDetector.orientationFlow().collectLatest { rotation ->
|
||||
currentRotation = rotation
|
||||
}
|
||||
}
|
||||
|
||||
// Collect location updates
|
||||
LaunchedEffect(Unit) {
|
||||
locationProvider.locationFlow().collectLatest { location ->
|
||||
currentLocation = location
|
||||
}
|
||||
}
|
||||
|
||||
// Update renderer with blur params
|
||||
LaunchedEffect(blurParams) {
|
||||
renderer?.updateParameters(blurParams)
|
||||
glSurfaceView?.requestRender()
|
||||
}
|
||||
|
||||
// Start camera when surface texture is available
|
||||
LaunchedEffect(surfaceTexture) {
|
||||
surfaceTexture?.let {
|
||||
cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
cameraManager.release()
|
||||
renderer?.release()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
// OpenGL Surface for camera preview with effect
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
GLSurfaceView(ctx).apply {
|
||||
setEGLContextClientVersion(2)
|
||||
|
||||
val newRenderer = TiltShiftRenderer(ctx) { st ->
|
||||
surfaceTexture = st
|
||||
}
|
||||
renderer = newRenderer
|
||||
|
||||
setRenderer(newRenderer)
|
||||
renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
|
||||
|
||||
glSurfaceView = this
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
// Tilt-shift overlay (gesture handling + visualization)
|
||||
TiltShiftOverlay(
|
||||
params = blurParams,
|
||||
onParamsChange = { newParams ->
|
||||
blurParams = newParams
|
||||
haptics.tick()
|
||||
},
|
||||
onZoomChange = { zoomDelta ->
|
||||
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
|
||||
cameraManager.setZoom(newZoom)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
// Top bar
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Zoom indicator
|
||||
ZoomIndicator(currentZoom = zoomRatio)
|
||||
|
||||
// Settings button (placeholder)
|
||||
IconButton(
|
||||
onClick = { /* TODO: Settings */ }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Settings",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom controls
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Zoom presets
|
||||
ZoomControl(
|
||||
currentZoom = zoomRatio,
|
||||
minZoom = minZoom,
|
||||
maxZoom = maxZoom,
|
||||
onZoomSelected = { zoom ->
|
||||
cameraManager.setZoom(zoom)
|
||||
haptics.click()
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Capture button
|
||||
CaptureButton(
|
||||
isCapturing = isCapturing,
|
||||
onClick = {
|
||||
if (!isCapturing) {
|
||||
isCapturing = true
|
||||
haptics.heavyClick()
|
||||
|
||||
scope.launch {
|
||||
val imageCapture = cameraManager.imageCapture
|
||||
if (imageCapture != null) {
|
||||
val result = captureHandler.capturePhoto(
|
||||
imageCapture = imageCapture,
|
||||
executor = cameraManager.getExecutor(),
|
||||
blurParams = blurParams,
|
||||
deviceRotation = currentRotation,
|
||||
location = currentLocation,
|
||||
isFrontCamera = false
|
||||
)
|
||||
|
||||
when (result) {
|
||||
is SaveResult.Success -> {
|
||||
haptics.success()
|
||||
showSaveSuccess = true
|
||||
delay(1500)
|
||||
showSaveSuccess = false
|
||||
}
|
||||
is SaveResult.Error -> {
|
||||
haptics.error()
|
||||
showSaveError = result.message
|
||||
delay(2000)
|
||||
showSaveError = null
|
||||
}
|
||||
}
|
||||
}
|
||||
isCapturing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Success indicator
|
||||
AnimatedVisibility(
|
||||
visible = showSaveSuccess,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF4CAF50)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Saved",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Error indicator
|
||||
AnimatedVisibility(
|
||||
visible = showSaveError != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(32.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(16.dp))
|
||||
.background(Color(0xFFF44336))
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Error",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = showSaveError ?: "Error",
|
||||
color = Color.White,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture button with animation for capturing state.
|
||||
*/
|
||||
@Composable
|
||||
private fun CaptureButton(
|
||||
isCapturing: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val outerSize = 72.dp
|
||||
val innerSize = if (isCapturing) 48.dp else 60.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(outerSize)
|
||||
.clip(CircleShape)
|
||||
.border(4.dp, Color.White, CircleShape)
|
||||
.clickable(enabled = !isCapturing, onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(innerSize)
|
||||
.clip(CircleShape)
|
||||
.background(if (isCapturing) Color(0xFFFFB300) else Color.White)
|
||||
)
|
||||
}
|
||||
}
|
||||
117
app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt
Normal file
117
app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package no.naiv.tiltshift.ui
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CameraRear
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import no.naiv.tiltshift.camera.CameraLens
|
||||
|
||||
/**
|
||||
* Lens selection UI for switching between camera lenses.
|
||||
*/
|
||||
@Composable
|
||||
fun LensSwitcher(
|
||||
availableLenses: List<CameraLens>,
|
||||
currentLens: CameraLens?,
|
||||
onLensSelected: (CameraLens) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (availableLenses.size <= 1) {
|
||||
// Don't show switcher if only one lens available
|
||||
return
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
availableLenses.forEach { lens ->
|
||||
LensButton(
|
||||
lens = lens,
|
||||
isSelected = lens.id == currentLens?.id,
|
||||
onClick = { onLensSelected(lens) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LensButton(
|
||||
lens: CameraLens,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (isSelected) {
|
||||
Color(0xFFFFB300)
|
||||
} else {
|
||||
Color(0x80000000)
|
||||
},
|
||||
label = "lens_button_bg"
|
||||
)
|
||||
|
||||
val contentColor by animateColorAsState(
|
||||
targetValue = if (isSelected) Color.Black else Color.White,
|
||||
label = "lens_button_content"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = lens.displayName,
|
||||
color = contentColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple camera flip button (for future front camera support).
|
||||
*/
|
||||
@Composable
|
||||
fun CameraFlipButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0x80000000))
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CameraRear,
|
||||
contentDescription = "Switch Camera",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
257
app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt
Normal file
257
app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
package no.naiv.tiltshift.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.calculateCentroid
|
||||
import androidx.compose.foundation.gestures.calculateRotation
|
||||
import androidx.compose.foundation.gestures.calculateZoom
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import no.naiv.tiltshift.effect.BlurParameters
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Type of gesture being performed.
|
||||
*/
|
||||
private enum class GestureType {
|
||||
NONE,
|
||||
DRAG_POSITION, // Single finger drag to move focus position
|
||||
ROTATE, // Two-finger rotation
|
||||
PINCH_SIZE, // Pinch near blur edges to resize
|
||||
PINCH_ZOOM // Pinch in center to zoom camera
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay that shows tilt-shift effect controls and handles gestures.
|
||||
*/
|
||||
@Composable
|
||||
fun TiltShiftOverlay(
|
||||
params: BlurParameters,
|
||||
onParamsChange: (BlurParameters) -> Unit,
|
||||
onZoomChange: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var currentGesture by remember { mutableStateOf(GestureType.NONE) }
|
||||
var initialZoom by remember { mutableFloatStateOf(1f) }
|
||||
var initialAngle by remember { mutableFloatStateOf(0f) }
|
||||
var initialSize by remember { mutableFloatStateOf(0.3f) }
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val firstDown = awaitFirstDown(requireUnconsumed = false)
|
||||
currentGesture = GestureType.NONE
|
||||
|
||||
var previousCentroid = firstDown.position
|
||||
var previousPointerCount = 1
|
||||
var accumulatedRotation = 0f
|
||||
var accumulatedZoom = 1f
|
||||
|
||||
initialAngle = params.angle
|
||||
initialSize = params.size
|
||||
initialZoom = 1f
|
||||
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
val pointers = event.changes.filter { it.pressed }
|
||||
|
||||
if (pointers.isEmpty()) break
|
||||
|
||||
val centroid = if (pointers.size >= 2) {
|
||||
event.calculateCentroid()
|
||||
} else {
|
||||
pointers.first().position
|
||||
}
|
||||
|
||||
when {
|
||||
// Two or more fingers
|
||||
pointers.size >= 2 -> {
|
||||
val rotation = event.calculateRotation()
|
||||
val zoom = event.calculateZoom()
|
||||
|
||||
// Determine gesture type based on touch positions
|
||||
if (currentGesture == GestureType.NONE || currentGesture == GestureType.DRAG_POSITION) {
|
||||
currentGesture = determineGestureType(
|
||||
centroid,
|
||||
size.width.toFloat(),
|
||||
size.height.toFloat(),
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
when (currentGesture) {
|
||||
GestureType.ROTATE -> {
|
||||
accumulatedRotation += rotation
|
||||
val newAngle = initialAngle + accumulatedRotation
|
||||
onParamsChange(params.copy(angle = newAngle))
|
||||
}
|
||||
GestureType.PINCH_SIZE -> {
|
||||
accumulatedZoom *= zoom
|
||||
val newSize = (initialSize * accumulatedZoom)
|
||||
.coerceIn(BlurParameters.MIN_SIZE, BlurParameters.MAX_SIZE)
|
||||
onParamsChange(params.copy(size = newSize))
|
||||
}
|
||||
GestureType.PINCH_ZOOM -> {
|
||||
onZoomChange(zoom)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Single finger
|
||||
pointers.size == 1 -> {
|
||||
if (currentGesture == GestureType.NONE) {
|
||||
currentGesture = GestureType.DRAG_POSITION
|
||||
}
|
||||
|
||||
if (currentGesture == GestureType.DRAG_POSITION) {
|
||||
val deltaY = (centroid.y - previousCentroid.y) / size.height
|
||||
val newPosition = (params.position + deltaY).coerceIn(0f, 1f)
|
||||
onParamsChange(params.copy(position = newPosition))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousCentroid = centroid
|
||||
previousPointerCount = pointers.size
|
||||
|
||||
// Consume all pointer changes
|
||||
pointers.forEach { it.consume() }
|
||||
} while (event.type != PointerEventType.Release)
|
||||
|
||||
currentGesture = GestureType.NONE
|
||||
}
|
||||
}
|
||||
) {
|
||||
drawTiltShiftOverlay(params)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the type of two-finger gesture based on touch position.
|
||||
*/
|
||||
private fun determineGestureType(
|
||||
centroid: Offset,
|
||||
width: Float,
|
||||
height: Float,
|
||||
params: BlurParameters
|
||||
): GestureType {
|
||||
// Calculate distance from focus center line
|
||||
val focusCenterY = height * params.position
|
||||
val focusHalfHeight = height * params.size * 0.5f
|
||||
|
||||
// Rotate centroid to align with focus line
|
||||
val dx = centroid.x - width / 2f
|
||||
val dy = centroid.y - focusCenterY
|
||||
val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle)
|
||||
|
||||
val distFromCenter = kotlin.math.abs(rotatedY)
|
||||
|
||||
return when {
|
||||
// Near the edges of the blur zone -> size adjustment
|
||||
distFromCenter > focusHalfHeight * 0.7f && distFromCenter < focusHalfHeight * 1.5f -> {
|
||||
GestureType.PINCH_SIZE
|
||||
}
|
||||
// Inside the focus zone -> rotation
|
||||
distFromCenter < focusHalfHeight * 0.7f -> {
|
||||
GestureType.ROTATE
|
||||
}
|
||||
// Outside -> camera zoom
|
||||
else -> {
|
||||
GestureType.PINCH_ZOOM
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the tilt-shift visualization overlay.
|
||||
*/
|
||||
private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
|
||||
val centerY = height * params.position
|
||||
val focusHalfHeight = height * params.size * 0.5f
|
||||
val angleDegrees = params.angle * (180f / PI.toFloat())
|
||||
|
||||
// Colors for overlay
|
||||
val focusLineColor = Color(0xFFFFB300) // Amber
|
||||
val blurZoneColor = Color(0x40FFFFFF) // Semi-transparent white
|
||||
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
|
||||
|
||||
rotate(angleDegrees, pivot = Offset(width / 2f, centerY)) {
|
||||
// Draw blur zone indicators (top and bottom)
|
||||
drawRect(
|
||||
color = blurZoneColor,
|
||||
topLeft = Offset(0f, 0f),
|
||||
size = androidx.compose.ui.geometry.Size(width, centerY - focusHalfHeight)
|
||||
)
|
||||
drawRect(
|
||||
color = blurZoneColor,
|
||||
topLeft = Offset(0f, centerY + focusHalfHeight),
|
||||
size = androidx.compose.ui.geometry.Size(width, height - (centerY + focusHalfHeight))
|
||||
)
|
||||
|
||||
// Draw focus zone boundary lines
|
||||
drawLine(
|
||||
color = focusLineColor,
|
||||
start = Offset(0f, centerY - focusHalfHeight),
|
||||
end = Offset(width, centerY - focusHalfHeight),
|
||||
strokeWidth = 2.dp.toPx(),
|
||||
pathEffect = dashEffect
|
||||
)
|
||||
drawLine(
|
||||
color = focusLineColor,
|
||||
start = Offset(0f, centerY + focusHalfHeight),
|
||||
end = Offset(width, centerY + focusHalfHeight),
|
||||
strokeWidth = 2.dp.toPx(),
|
||||
pathEffect = dashEffect
|
||||
)
|
||||
|
||||
// Draw center focus line
|
||||
drawLine(
|
||||
color = focusLineColor,
|
||||
start = Offset(0f, centerY),
|
||||
end = Offset(width, centerY),
|
||||
strokeWidth = 3.dp.toPx()
|
||||
)
|
||||
|
||||
// Draw rotation indicator at center
|
||||
val indicatorRadius = 30.dp.toPx()
|
||||
drawCircle(
|
||||
color = focusLineColor.copy(alpha = 0.5f),
|
||||
radius = indicatorRadius,
|
||||
center = Offset(width / 2f, centerY),
|
||||
style = Stroke(width = 2.dp.toPx())
|
||||
)
|
||||
|
||||
// Draw angle tick mark
|
||||
val tickLength = 15.dp.toPx()
|
||||
drawLine(
|
||||
color = focusLineColor,
|
||||
start = Offset(width / 2f, centerY - indicatorRadius + tickLength),
|
||||
end = Offset(width / 2f, centerY - indicatorRadius - 5.dp.toPx()),
|
||||
strokeWidth = 3.dp.toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
119
app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt
Normal file
119
app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
package no.naiv.tiltshift.ui
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Zoom level presets control.
|
||||
*/
|
||||
@Composable
|
||||
fun ZoomControl(
|
||||
currentZoom: Float,
|
||||
minZoom: Float,
|
||||
maxZoom: Float,
|
||||
onZoomSelected: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// Define zoom presets based on device capabilities
|
||||
val presets = listOf(
|
||||
ZoomPreset(0.5f, "0.5"),
|
||||
ZoomPreset(1.0f, "1"),
|
||||
ZoomPreset(2.0f, "2"),
|
||||
ZoomPreset(5.0f, "5")
|
||||
).filter { it.zoom in minZoom..maxZoom }
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
presets.forEach { preset ->
|
||||
ZoomButton(
|
||||
preset = preset,
|
||||
isSelected = abs(currentZoom - preset.zoom) < 0.1f,
|
||||
onClick = { onZoomSelected(preset.zoom) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ZoomPreset(val zoom: Float, val label: String)
|
||||
|
||||
@Composable
|
||||
private fun ZoomButton(
|
||||
preset: ZoomPreset,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (isSelected) {
|
||||
Color(0xFFFFB300) // Amber when selected
|
||||
} else {
|
||||
Color(0x80000000) // Semi-transparent black
|
||||
},
|
||||
label = "zoom_button_bg"
|
||||
)
|
||||
|
||||
val textColor by animateColorAsState(
|
||||
targetValue = if (isSelected) Color.Black else Color.White,
|
||||
label = "zoom_button_text"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "${preset.label}x",
|
||||
color = textColor,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays current zoom level as a badge.
|
||||
*/
|
||||
@Composable
|
||||
fun ZoomIndicator(
|
||||
currentZoom: Float,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(CircleShape)
|
||||
.background(Color(0x80000000))
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "%.1fx".format(currentZoom),
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
98
app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt
Normal file
98
app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package no.naiv.tiltshift.util
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
|
||||
/**
|
||||
* Provides haptic feedback for user interactions.
|
||||
*/
|
||||
class HapticFeedback(private val context: Context) {
|
||||
|
||||
private val vibrator: Vibrator by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
vibratorManager.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Light tick for UI feedback (button press, slider change).
|
||||
*/
|
||||
fun tick() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(10L)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click feedback for confirmations.
|
||||
*/
|
||||
fun click() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(20L)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Heavy click for important actions (photo capture).
|
||||
*/
|
||||
fun heavyClick() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(40L)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Success feedback pattern.
|
||||
*/
|
||||
fun success() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val timings = longArrayOf(0, 30, 50, 30)
|
||||
val amplitudes = intArrayOf(0, 100, 0, 200)
|
||||
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(longArrayOf(0, 30, 50, 30), -1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error feedback pattern.
|
||||
*/
|
||||
fun error() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val timings = longArrayOf(0, 50, 30, 50, 30, 50)
|
||||
val amplitudes = intArrayOf(0, 150, 0, 150, 0, 150)
|
||||
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(longArrayOf(0, 50, 30, 50, 30, 50), -1)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Use system haptic feedback on a View for standard interactions.
|
||||
*/
|
||||
fun performHapticFeedback(view: View, feedbackConstant: Int = HapticFeedbackConstants.VIRTUAL_KEY) {
|
||||
view.performHapticFeedback(feedbackConstant)
|
||||
}
|
||||
}
|
||||
}
|
||||
78
app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt
Normal file
78
app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package no.naiv.tiltshift.util
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.os.Looper
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.gms.location.FusedLocationProviderClient
|
||||
import com.google.android.gms.location.LocationCallback
|
||||
import com.google.android.gms.location.LocationRequest
|
||||
import com.google.android.gms.location.LocationResult
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
/**
|
||||
* Provides location updates for EXIF GPS tagging.
|
||||
*/
|
||||
class LocationProvider(private val context: Context) {
|
||||
|
||||
private val fusedLocationClient: FusedLocationProviderClient =
|
||||
LocationServices.getFusedLocationProviderClient(context)
|
||||
|
||||
/**
|
||||
* Returns a Flow of location updates.
|
||||
* Updates are throttled to conserve battery - we only need periodic updates for photo tagging.
|
||||
*/
|
||||
fun locationFlow(): Flow<Location?> = callbackFlow {
|
||||
if (!hasLocationPermission()) {
|
||||
trySend(null)
|
||||
awaitClose()
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
val locationRequest = LocationRequest.Builder(
|
||||
Priority.PRIORITY_BALANCED_POWER_ACCURACY,
|
||||
30_000L // Update every 30 seconds
|
||||
).apply {
|
||||
setMinUpdateIntervalMillis(10_000L)
|
||||
setMaxUpdateDelayMillis(60_000L)
|
||||
}.build()
|
||||
|
||||
val callback = object : LocationCallback() {
|
||||
override fun onLocationResult(result: LocationResult) {
|
||||
result.lastLocation?.let { trySend(it) }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fusedLocationClient.requestLocationUpdates(
|
||||
locationRequest,
|
||||
callback,
|
||||
Looper.getMainLooper()
|
||||
)
|
||||
|
||||
// Also try to get last known location immediately
|
||||
fusedLocationClient.lastLocation.addOnSuccessListener { location ->
|
||||
location?.let { trySend(it) }
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
trySend(null)
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
fusedLocationClient.removeLocationUpdates(callback)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasLocationPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package no.naiv.tiltshift.util
|
||||
|
||||
import android.content.Context
|
||||
import android.view.OrientationEventListener
|
||||
import android.view.Surface
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
/**
|
||||
* Detects device orientation and provides it as a Flow.
|
||||
* Used to properly orient captured images.
|
||||
*/
|
||||
class OrientationDetector(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Returns the current device rotation as a Surface.ROTATION_* constant.
|
||||
* This can be converted to degrees for EXIF orientation.
|
||||
*/
|
||||
fun orientationFlow(): Flow<Int> = callbackFlow {
|
||||
val listener = object : OrientationEventListener(context) {
|
||||
private var lastRotation = -1
|
||||
|
||||
override fun onOrientationChanged(orientation: Int) {
|
||||
if (orientation == ORIENTATION_UNKNOWN) return
|
||||
|
||||
val rotation = when {
|
||||
orientation >= 315 || orientation < 45 -> Surface.ROTATION_0
|
||||
orientation >= 45 && orientation < 135 -> Surface.ROTATION_90
|
||||
orientation >= 135 && orientation < 225 -> Surface.ROTATION_180
|
||||
else -> Surface.ROTATION_270
|
||||
}
|
||||
|
||||
if (rotation != lastRotation) {
|
||||
lastRotation = rotation
|
||||
trySend(rotation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listener.enable()
|
||||
trySend(Surface.ROTATION_0) // Initial value
|
||||
|
||||
awaitClose {
|
||||
listener.disable()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Converts Surface rotation to degrees for EXIF.
|
||||
*/
|
||||
fun rotationToDegrees(rotation: Int): Int {
|
||||
return when (rotation) {
|
||||
Surface.ROTATION_0 -> 0
|
||||
Surface.ROTATION_90 -> 90
|
||||
Surface.ROTATION_180 -> 180
|
||||
Surface.ROTATION_270 -> 270
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts degrees to EXIF orientation constant.
|
||||
*/
|
||||
fun degreesToExifOrientation(degrees: Int, isFrontCamera: Boolean): Int {
|
||||
return when {
|
||||
isFrontCamera -> when (degrees) {
|
||||
0 -> android.media.ExifInterface.ORIENTATION_NORMAL
|
||||
90 -> android.media.ExifInterface.ORIENTATION_ROTATE_270
|
||||
180 -> android.media.ExifInterface.ORIENTATION_ROTATE_180
|
||||
270 -> android.media.ExifInterface.ORIENTATION_ROTATE_90
|
||||
else -> android.media.ExifInterface.ORIENTATION_NORMAL
|
||||
}
|
||||
else -> when (degrees) {
|
||||
0 -> android.media.ExifInterface.ORIENTATION_NORMAL
|
||||
90 -> android.media.ExifInterface.ORIENTATION_ROTATE_90
|
||||
180 -> android.media.ExifInterface.ORIENTATION_ROTATE_180
|
||||
270 -> android.media.ExifInterface.ORIENTATION_ROTATE_270
|
||||
else -> android.media.ExifInterface.ORIENTATION_NORMAL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#212121"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
</vector>
|
||||
49
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
49
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Camera body -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M30,40 L78,40 Q82,40 82,44 L82,72 Q82,76 78,76 L30,76 Q26,76 26,72 L26,44 Q26,40 30,40 Z"/>
|
||||
|
||||
<!-- Camera lens -->
|
||||
<path
|
||||
android:fillColor="#424242"
|
||||
android:pathData="M54,58 m-14,0 a14,14 0,1 1,28 0 a14,14 0,1 1,-28 0"/>
|
||||
|
||||
<path
|
||||
android:fillColor="#616161"
|
||||
android:pathData="M54,58 m-10,0 a10,10 0,1 1,20 0 a10,10 0,1 1,-20 0"/>
|
||||
|
||||
<!-- Lens reflection -->
|
||||
<path
|
||||
android:fillColor="#90CAF9"
|
||||
android:pathData="M48,52 Q52,48 58,52 Q54,56 48,52"/>
|
||||
|
||||
<!-- Flash -->
|
||||
<path
|
||||
android:fillColor="#FFB300"
|
||||
android:pathData="M70,44 L76,44 L76,50 L70,50 Z"/>
|
||||
|
||||
<!-- Tilt-shift blur indication (gradient bars) -->
|
||||
<path
|
||||
android:fillColor="#80FFFFFF"
|
||||
android:pathData="M26,32 L82,32 L82,36 L26,36 Z"/>
|
||||
|
||||
<path
|
||||
android:fillColor="#40FFFFFF"
|
||||
android:pathData="M26,28 L82,28 L82,30 L26,30 Z"/>
|
||||
|
||||
<path
|
||||
android:fillColor="#80FFFFFF"
|
||||
android:pathData="M26,80 L82,80 L82,84 L26,84 Z"/>
|
||||
|
||||
<path
|
||||
android:fillColor="#40FFFFFF"
|
||||
android:pathData="M26,86 L82,86 L82,88 L26,88 Z"/>
|
||||
|
||||
</vector>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
90
app/src/main/res/raw/tiltshift_fragment.glsl
Normal file
90
app/src/main/res/raw/tiltshift_fragment.glsl
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Fragment shader for tilt-shift effect
|
||||
// Applies gradient blur based on distance from focus line
|
||||
|
||||
#extension GL_OES_EGL_image_external : require
|
||||
|
||||
precision mediump float;
|
||||
|
||||
// Camera texture (external texture for camera preview)
|
||||
uniform samplerExternalOES uTexture;
|
||||
|
||||
// Effect parameters
|
||||
uniform float uAngle; // Rotation angle in radians
|
||||
uniform float uPosition; // Center position of focus (0-1)
|
||||
uniform float uSize; // Size of in-focus region (0-1)
|
||||
uniform float uBlurAmount; // Maximum blur intensity (0-1)
|
||||
uniform vec2 uResolution; // Texture resolution for proper sampling
|
||||
|
||||
varying vec2 vTexCoord;
|
||||
|
||||
// Blur kernel size (must be odd)
|
||||
const int KERNEL_SIZE = 9;
|
||||
const float KERNEL_HALF = 4.0;
|
||||
|
||||
// Gaussian weights for 9-tap blur (sigma ~= 2.0)
|
||||
const float weights[9] = float[](
|
||||
0.0162, 0.0540, 0.1216, 0.1933, 0.2258,
|
||||
0.1933, 0.1216, 0.0540, 0.0162
|
||||
);
|
||||
|
||||
// Calculate signed distance from the focus line
|
||||
float focusDistance(vec2 uv) {
|
||||
// Rotate coordinate system around center
|
||||
vec2 center = vec2(0.5, uPosition);
|
||||
vec2 rotated = uv - center;
|
||||
|
||||
float cosA = cos(uAngle);
|
||||
float sinA = sin(uAngle);
|
||||
|
||||
// After rotation, measure vertical distance from center line
|
||||
float rotatedY = -rotated.x * sinA + rotated.y * cosA;
|
||||
|
||||
return abs(rotatedY);
|
||||
}
|
||||
|
||||
// Calculate blur factor based on distance from focus
|
||||
float blurFactor(float dist) {
|
||||
// Smooth transition from in-focus to blurred
|
||||
float halfSize = uSize * 0.5;
|
||||
float transitionSize = halfSize * 0.5;
|
||||
|
||||
if (dist < halfSize) {
|
||||
return 0.0; // In focus region
|
||||
}
|
||||
|
||||
// Smooth falloff using smoothstep
|
||||
float normalizedDist = (dist - halfSize) / transitionSize;
|
||||
return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount;
|
||||
}
|
||||
|
||||
// Sample with Gaussian blur
|
||||
vec4 sampleBlurred(vec2 uv, float blur) {
|
||||
if (blur < 0.01) {
|
||||
return texture2D(uTexture, uv);
|
||||
}
|
||||
|
||||
vec4 color = vec4(0.0);
|
||||
vec2 texelSize = 1.0 / uResolution;
|
||||
|
||||
// Blur direction perpendicular to focus line
|
||||
float blurAngle = uAngle + 1.5707963; // +90 degrees
|
||||
vec2 blurDir = vec2(cos(blurAngle), sin(blurAngle));
|
||||
|
||||
// Scale blur radius by blur amount
|
||||
float radius = blur * 20.0;
|
||||
|
||||
for (int i = 0; i < KERNEL_SIZE; i++) {
|
||||
float offset = (float(i) - KERNEL_HALF);
|
||||
vec2 samplePos = uv + blurDir * texelSize * offset * radius;
|
||||
color += texture2D(uTexture, samplePos) * weights[i];
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
void main() {
|
||||
float dist = focusDistance(vTexCoord);
|
||||
float blur = blurFactor(dist);
|
||||
|
||||
gl_FragColor = sampleBlurred(vTexCoord, blur);
|
||||
}
|
||||
12
app/src/main/res/raw/tiltshift_vertex.glsl
Normal file
12
app/src/main/res/raw/tiltshift_vertex.glsl
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Vertex shader for tilt-shift effect
|
||||
// Passes through position and calculates texture coordinates
|
||||
|
||||
attribute vec4 aPosition;
|
||||
attribute vec2 aTexCoord;
|
||||
|
||||
varying vec2 vTexCoord;
|
||||
|
||||
void main() {
|
||||
gl_Position = aPosition;
|
||||
vTexCoord = aTexCoord;
|
||||
}
|
||||
6
app/src/main/res/values/colors.xml
Normal file
6
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="tiltshift_accent">#FFFFB300</color>
|
||||
</resources>
|
||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Tilt-Shift Camera</string>
|
||||
</resources>
|
||||
8
app/src/main/res/values/themes.xml
Normal file
8
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.TiltShiftCamera" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
</resources>
|
||||
6
build.gradle.kts
Normal file
6
build.gradle.kts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
}
|
||||
5
gradle.properties
Normal file
5
gradle.properties
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Project-wide Gradle settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
44
gradle/libs.versions.toml
Normal file
44
gradle/libs.versions.toml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
[versions]
|
||||
agp = "8.7.3"
|
||||
kotlin = "2.0.21"
|
||||
coreKtx = "1.15.0"
|
||||
lifecycleRuntimeKtx = "2.8.7"
|
||||
activityCompose = "1.9.3"
|
||||
composeBom = "2024.12.01"
|
||||
camerax = "1.4.1"
|
||||
accompanist = "0.36.0"
|
||||
exifinterface = "1.3.7"
|
||||
playServicesLocation = "21.3.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
|
||||
# CameraX
|
||||
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
|
||||
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
|
||||
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
|
||||
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
|
||||
|
||||
# EXIF
|
||||
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
|
||||
|
||||
# Location
|
||||
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
|
||||
|
||||
# Accompanist for permissions
|
||||
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
176
gradlew
vendored
Executable file
176
gradlew
vendored
Executable file
|
|
@ -0,0 +1,176 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=$(save "$@")
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
|
||||
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
|
||||
fi
|
||||
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||
cd "$(dirname "$0")"
|
||||
fi
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
84
gradlew.bat
vendored
Normal file
84
gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
24
settings.gradle.kts
Normal file
24
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "TiltShiftCamera"
|
||||
include(":app")
|
||||
Loading…
Add table
Add a link
Reference in a new issue