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
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue