Compare commits

..

No commits in common. "5d80dcfcbef47252a90fbba9705cc868f9ee39a0" and "f53d6f0b1bfcbdfd96e336a6f4a41ed329350bed" have entirely different histories.

9 changed files with 406 additions and 629 deletions

View file

@ -18,7 +18,7 @@ A dedicated Android camera app for tilt-shift photography with real-time preview
## Requirements ## Requirements
- Android 15 (API 35) or higher - Android 8.0 (API 26) or higher
- Device with camera - Device with camera
- OpenGL ES 2.0 support - OpenGL ES 2.0 support
@ -66,7 +66,8 @@ app/src/main/java/no/naiv/tiltshift/
│ ├── ZoomControl.kt # Zoom UI component │ ├── ZoomControl.kt # Zoom UI component
│ └── LensSwitcher.kt # Lens selection UI │ └── LensSwitcher.kt # Lens selection UI
├── storage/ ├── storage/
│ └── PhotoSaver.kt # MediaStore integration & EXIF handling │ ├── PhotoSaver.kt # MediaStore integration
│ └── ExifWriter.kt # EXIF metadata handling
└── util/ └── util/
├── OrientationDetector.kt ├── OrientationDetector.kt
├── LocationProvider.kt ├── LocationProvider.kt

View file

@ -2,7 +2,6 @@ package no.naiv.tiltshift.camera
import android.content.Context import android.content.Context
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.util.Log
import android.util.Size import android.util.Size
import android.view.Surface import android.view.Surface
import androidx.camera.core.Camera import androidx.camera.core.Camera
@ -25,10 +24,6 @@ import java.util.concurrent.Executor
*/ */
class CameraManager(private val context: Context) { class CameraManager(private val context: Context) {
companion object {
private const val TAG = "CameraManager"
}
private var cameraProvider: ProcessCameraProvider? = null private var cameraProvider: ProcessCameraProvider? = null
private var camera: Camera? = null private var camera: Camera? = null
private var preview: Preview? = null private var preview: Preview? = null
@ -37,13 +32,6 @@ class CameraManager(private val context: Context) {
val lensController = LensController() val lensController = LensController()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
fun clearError() {
_error.value = null
}
private val _zoomRatio = MutableStateFlow(1.0f) private val _zoomRatio = MutableStateFlow(1.0f)
val zoomRatio: StateFlow<Float> = _zoomRatio.asStateFlow() val zoomRatio: StateFlow<Float> = _zoomRatio.asStateFlow()
@ -94,11 +82,10 @@ class CameraManager(private val context: Context) {
.setResolutionSelector(resolutionSelector) .setResolutionSelector(resolutionSelector)
.build() .build()
// Image capture use case // Image capture use case for high-res photos
val captureBuilder = ImageCapture.Builder() imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
.build()
imageCapture = captureBuilder.build()
// Select camera based on front/back preference // Select camera based on front/back preference
val cameraSelector = if (_isFrontCamera.value) { val cameraSelector = if (_isFrontCamera.value) {
@ -130,8 +117,8 @@ class CameraManager(private val context: Context) {
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Camera binding failed", e) // Camera binding failed
_error.value = "Camera failed: ${e.message}" e.printStackTrace()
} }
} }

View file

@ -5,15 +5,11 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Matrix import android.graphics.Matrix
import android.location.Location import android.location.Location
import android.net.Uri
import android.util.Log
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.storage.PhotoSaver import no.naiv.tiltshift.storage.PhotoSaver
@ -32,19 +28,12 @@ class ImageCaptureHandler(
private val photoSaver: PhotoSaver private val photoSaver: PhotoSaver
) { ) {
companion object {
private const val TAG = "ImageCaptureHandler"
}
/** /**
* Holds the processed bitmap ready for saving, produced inside the * Holds the processed bitmap ready for saving, produced inside the
* camera callback (synchronous CPU work) and consumed afterwards * camera callback (synchronous CPU work) and consumed afterwards
* in the caller's coroutine context. * in the caller's coroutine context.
*/ */
private class ProcessedCapture( private class ProcessedCapture(val bitmap: Bitmap)
val originalBitmap: Bitmap,
val processedBitmap: Bitmap
)
/** /**
* Captures a photo and applies the tilt-shift effect. * Captures a photo and applies the tilt-shift effect.
@ -69,31 +58,26 @@ class ImageCaptureHandler(
executor, executor,
object : ImageCapture.OnImageCapturedCallback() { object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(imageProxy: ImageProxy) { override fun onCaptureSuccess(imageProxy: ImageProxy) {
var currentBitmap: Bitmap? = null
try { try {
val imageRotation = imageProxy.imageInfo.rotationDegrees val imageRotation = imageProxy.imageInfo.rotationDegrees
currentBitmap = imageProxyToBitmap(imageProxy) var bitmap = imageProxyToBitmap(imageProxy)
imageProxy.close() imageProxy.close()
if (currentBitmap == null) { if (bitmap == null) {
continuation.resume( continuation.resume(
SaveResult.Error("Failed to convert image") as Any SaveResult.Error("Failed to convert image") as Any
) )
return return
} }
currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera) bitmap = rotateBitmap(bitmap, imageRotation, isFrontCamera)
val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams) val processedBitmap = applyTiltShiftEffect(bitmap, blurParams)
// Keep originalBitmap alive — both are recycled after saving bitmap.recycle()
val original = currentBitmap
currentBitmap = null
continuation.resume(ProcessedCapture(original, processedBitmap)) continuation.resume(ProcessedCapture(processedBitmap))
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Image processing failed", e)
currentBitmap?.recycle()
continuation.resume( continuation.resume(
SaveResult.Error("Capture failed: ${e.message}", e) as Any SaveResult.Error("Capture failed: ${e.message}", e) as Any
) )
@ -111,46 +95,22 @@ class ImageCaptureHandler(
) )
} }
// Phase 2: save both original and processed to disk (suspend-safe) // Phase 2: save to disk in the caller's coroutine context (suspend-safe)
if (captureResult is ProcessedCapture) { if (captureResult is ProcessedCapture) {
return try { return try {
val thumbnail = createThumbnail(captureResult.processedBitmap) photoSaver.saveBitmap(
val result = photoSaver.saveBitmapPair( captureResult.bitmap,
original = captureResult.originalBitmap, ExifInterface.ORIENTATION_NORMAL,
processed = captureResult.processedBitmap, location
orientation = ExifInterface.ORIENTATION_NORMAL,
location = location
) )
if (result is SaveResult.Success) {
result.copy(thumbnail = thumbnail)
} else {
thumbnail?.recycle()
result
}
} finally { } finally {
captureResult.originalBitmap.recycle() captureResult.bitmap.recycle()
captureResult.processedBitmap.recycle()
} }
} }
return captureResult as SaveResult return captureResult as SaveResult
} }
/**
* Creates a small thumbnail copy of a bitmap for in-app preview.
*/
private fun createThumbnail(source: Bitmap, maxSize: Int = 160): Bitmap? {
return try {
val scale = maxSize.toFloat() / maxOf(source.width, source.height)
val width = (source.width * scale).toInt()
val height = (source.height * scale).toInt()
Bitmap.createScaledBitmap(source, width, height, true)
} catch (e: Exception) {
Log.w(TAG, "Failed to create thumbnail", e)
null
}
}
/** /**
* Rotates a bitmap to the correct orientation. * Rotates a bitmap to the correct orientation.
*/ */
@ -189,200 +149,66 @@ class ImageCaptureHandler(
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} }
/**
* Loads a gallery image and applies EXIF rotation, returning the bitmap for preview.
* The caller owns the returned bitmap and is responsible for recycling it.
*/
suspend fun loadGalleryImage(imageUri: Uri): Bitmap? = withContext(Dispatchers.IO) {
try {
val bitmap = loadBitmapFromUri(imageUri)
?: return@withContext null
applyExifRotation(imageUri, bitmap)
} catch (e: Exception) {
Log.e(TAG, "Failed to load gallery image for preview", e)
null
}
}
/**
* Processes an existing image from the gallery through the tilt-shift pipeline.
* Loads the image, applies EXIF rotation, processes the effect, and saves both versions.
*/
suspend fun processExistingImage(
imageUri: Uri,
blurParams: BlurParameters,
location: Location?
): SaveResult = withContext(Dispatchers.IO) {
var originalBitmap: Bitmap? = null
var processedBitmap: Bitmap? = null
try {
originalBitmap = loadBitmapFromUri(imageUri)
?: return@withContext SaveResult.Error("Failed to load image")
originalBitmap = applyExifRotation(imageUri, originalBitmap)
processedBitmap = applyTiltShiftEffect(originalBitmap, blurParams)
val thumbnail = createThumbnail(processedBitmap)
val result = photoSaver.saveBitmapPair(
original = originalBitmap,
processed = processedBitmap,
orientation = ExifInterface.ORIENTATION_NORMAL,
location = location
)
if (result is SaveResult.Success) {
result.copy(thumbnail = thumbnail)
} else {
thumbnail?.recycle()
result
}
} catch (e: Exception) {
Log.e(TAG, "Gallery image processing failed", e)
SaveResult.Error("Processing failed: ${e.message}", e)
} finally {
originalBitmap?.recycle()
processedBitmap?.recycle()
}
}
/**
* Loads a bitmap from a content URI.
*/
private fun loadBitmapFromUri(uri: Uri): Bitmap? {
return try {
context.contentResolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bitmap from URI", e)
null
}
}
/**
* Reads EXIF orientation from a content URI and applies the
* required rotation/flip to the bitmap.
*/
private fun applyExifRotation(uri: Uri, bitmap: Bitmap): Bitmap {
val orientation = try {
context.contentResolver.openInputStream(uri)?.use { stream ->
ExifInterface(stream).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
} ?: ExifInterface.ORIENTATION_NORMAL
} catch (e: Exception) {
Log.w(TAG, "Failed to read EXIF orientation", e)
ExifInterface.ORIENTATION_NORMAL
}
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(270f)
matrix.postScale(-1f, 1f)
}
else -> return bitmap
}
val rotated = Bitmap.createBitmap(
bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true
)
if (rotated != bitmap) {
bitmap.recycle()
}
return rotated
}
/** /**
* Applies tilt-shift blur effect to a bitmap. * Applies tilt-shift blur effect to a bitmap.
* Supports both linear and radial modes. * Supports both linear and radial modes.
*
* All intermediate bitmaps are tracked and recycled in a finally block
* so that an OOM or other exception does not leak native memory.
*/ */
private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap { private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap {
val width = source.width val width = source.width
val height = source.height val height = source.height
var result: Bitmap? = null // Create output bitmap
var scaled: Bitmap? = null val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
var blurred: Bitmap? = null
var blurredFullSize: Bitmap? = null
var mask: Bitmap? = null
try { // For performance, we use a scaled-down version for blur and composite
result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val scaleFactor = 4 // Blur a 1/4 size image for speed
val blurredWidth = width / scaleFactor
val blurredHeight = height / scaleFactor
val scaleFactor = 4 // Create scaled bitmap for blur
val blurredWidth = width / scaleFactor val scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
val blurredHeight = height / scaleFactor
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()
blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25)) // Scale blurred back up
scaled.recycle() val blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true)
scaled = null blurred.recycle()
blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true) // Create gradient mask based on tilt-shift parameters
blurred.recycle() val mask = createGradientMask(width, height, params)
blurred = null
mask = createGradientMask(width, height, params) // Composite: blend original with blurred based on mask
val pixels = IntArray(width * height)
val blurredPixels = IntArray(width * height)
val maskPixels = IntArray(width * height)
// Composite: blend original with blurred based on mask source.getPixels(pixels, 0, width, 0, 0, width, height)
val pixels = IntArray(width * height) blurredFullSize.getPixels(blurredPixels, 0, width, 0, 0, width, height)
val blurredPixels = IntArray(width * height) mask.getPixels(maskPixels, 0, width, 0, 0, width, height)
val maskPixels = IntArray(width * height)
source.getPixels(pixels, 0, width, 0, 0, width, height) blurredFullSize.recycle()
blurredFullSize.getPixels(blurredPixels, 0, width, 0, 0, width, height) mask.recycle()
mask.getPixels(maskPixels, 0, width, 0, 0, width, height)
blurredFullSize.recycle() for (i in pixels.indices) {
blurredFullSize = null val maskAlpha = (maskPixels[i] and 0xFF) / 255f
mask.recycle() val origR = (pixels[i] shr 16) and 0xFF
mask = null 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
for (i in pixels.indices) { val r = (origR * (1 - maskAlpha) + blurR * maskAlpha).toInt()
val maskAlpha = (maskPixels[i] and 0xFF) / 255f val g = (origG * (1 - maskAlpha) + blurG * maskAlpha).toInt()
val origR = (pixels[i] shr 16) and 0xFF val b = (origB * (1 - maskAlpha) + blurB * maskAlpha).toInt()
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() pixels[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
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)
val output = result
result = null // prevent finally from recycling the returned bitmap
return output
} finally {
result?.recycle()
scaled?.recycle()
blurred?.recycle()
blurredFullSize?.recycle()
mask?.recycle()
} }
result.setPixels(pixels, 0, width, 0, 0, width, height)
return result
} }
/** /**

View file

@ -88,4 +88,11 @@ class LensController {
return availableLenses[currentLensIndex] return availableLenses[currentLensIndex]
} }
/**
* Common zoom levels that can be achieved through digital zoom.
* These are presented as quick-select buttons.
*/
fun getZoomPresets(): List<Float> {
return listOf(0.5f, 1.0f, 2.0f, 5.0f)
}
} }

View file

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

View file

@ -3,30 +3,28 @@ package no.naiv.tiltshift.storage
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.location.Location import android.location.Location
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
/** /**
* Result of a photo save operation. * Result of a photo save operation.
*/ */
sealed class SaveResult { sealed class SaveResult {
data class Success( data class Success(val uri: Uri, val path: String) : SaveResult()
val uri: Uri,
val path: String,
val originalUri: Uri? = null,
val thumbnail: android.graphics.Bitmap? = null
) : SaveResult()
data class Error(val message: String, val exception: Exception? = null) : SaveResult() data class Error(val message: String, val exception: Exception? = null) : SaveResult()
} }
@ -35,9 +33,7 @@ sealed class SaveResult {
*/ */
class PhotoSaver(private val context: Context) { class PhotoSaver(private val context: Context) {
companion object { private val exifWriter = ExifWriter()
private const val TAG = "PhotoSaver"
}
private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
@ -49,68 +45,100 @@ class PhotoSaver(private val context: Context) {
orientation: Int, orientation: Int,
location: Location? location: Location?
): SaveResult = withContext(Dispatchers.IO) { ): SaveResult = withContext(Dispatchers.IO) {
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" try {
saveSingleBitmap(fileName, bitmap, orientation, location) val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg"
}
/** // Create content values for MediaStore
* Saves both original and processed bitmaps to the gallery.
* Uses a shared timestamp so paired files sort together.
* Returns the processed image's URI as the primary result.
*/
suspend fun saveBitmapPair(
original: Bitmap,
processed: Bitmap,
orientation: Int,
location: Location?
): SaveResult = withContext(Dispatchers.IO) {
val timestamp = fileNameFormat.format(Date())
val processedFileName = "TILTSHIFT_${timestamp}.jpg"
val originalFileName = "ORIGINAL_${timestamp}.jpg"
val processedResult = saveSingleBitmap(processedFileName, processed, orientation, location)
if (processedResult is SaveResult.Error) return@withContext processedResult
val originalResult = saveSingleBitmap(originalFileName, original, orientation, location)
val originalUri = (originalResult as? SaveResult.Success)?.uri
(processedResult as SaveResult.Success).copy(originalUri = originalUri)
}
/**
* Core save logic: writes a single bitmap to MediaStore with EXIF data.
*/
private fun saveSingleBitmap(
fileName: String,
bitmap: Bitmap,
orientation: Int,
location: Location?
): SaveResult {
return try {
val contentValues = ContentValues().apply { val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName) put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000) put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift")
put(MediaStore.Images.Media.IS_PENDING, 1) 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 contentResolver = context.contentResolver
val uri = contentResolver.insert( val uri = contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues contentValues
) ?: return SaveResult.Error("Failed to create MediaStore entry") ) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry")
// Copy file to MediaStore
contentResolver.openOutputStream(uri)?.use { outputStream -> contentResolver.openOutputStream(uri)?.use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream) sourceFile.inputStream().use { inputStream ->
} ?: return SaveResult.Error("Failed to open output stream") inputStream.copyTo(outputStream)
}
} ?: return@withContext SaveResult.Error("Failed to open output stream")
// Write EXIF data
writeExifToUri(uri, orientation, location) writeExifToUri(uri, orientation, location)
contentValues.clear() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) contentValues.clear()
contentResolver.update(uri, contentValues, null, null) contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(uri, contentValues, null, null)
}
// Clean up source file
sourceFile.delete()
val path = getPathFromUri(uri) val path = getPathFromUri(uri)
SaveResult.Success(uri, path) SaveResult.Success(uri, path)
@ -141,7 +169,7 @@ class PhotoSaver(private val context: Context) {
exif.saveAttributes() exif.saveAttributes()
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to write EXIF data", e) e.printStackTrace()
} }
} }

View file

@ -1,20 +1,15 @@
package no.naiv.tiltshift.ui package no.naiv.tiltshift.ui
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.location.Location import android.location.Location
import android.net.Uri
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.view.Surface import android.view.Surface
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -30,14 +25,10 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FlipCameraAndroid import androidx.compose.material.icons.filled.FlipCameraAndroid
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
@ -55,11 +46,8 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.foundation.Image
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -109,53 +97,13 @@ fun CameraScreen(
var showSaveError by remember { mutableStateOf<String?>(null) } var showSaveError by remember { mutableStateOf<String?>(null) }
var showControls by remember { mutableStateOf(false) } var showControls by remember { mutableStateOf(false) }
// Thumbnail state for last captured photo
var lastSavedUri by remember { mutableStateOf<Uri?>(null) }
var lastThumbnailBitmap by remember { mutableStateOf<Bitmap?>(null) }
// Gallery preview mode: non-null means we're previewing a gallery image
var galleryBitmap by remember { mutableStateOf<Bitmap?>(null) }
var galleryImageUri by remember { mutableStateOf<Uri?>(null) }
val isGalleryPreview = galleryBitmap != null
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) } var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
var currentLocation by remember { mutableStateOf<Location?>(null) } var currentLocation by remember { mutableStateOf<Location?>(null) }
// Gallery picker: load image for interactive preview before processing
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
if (uri != null && !isCapturing && !isGalleryPreview) {
scope.launch {
val bitmap = captureHandler.loadGalleryImage(uri)
if (bitmap != null) {
galleryBitmap = bitmap
galleryImageUri = uri
} else {
haptics.error()
showSaveError = "Failed to load image"
delay(2000)
showSaveError = null
}
}
}
}
val zoomRatio by cameraManager.zoomRatio.collectAsState() val zoomRatio by cameraManager.zoomRatio.collectAsState()
val minZoom by cameraManager.minZoomRatio.collectAsState() val minZoom by cameraManager.minZoomRatio.collectAsState()
val maxZoom by cameraManager.maxZoomRatio.collectAsState() val maxZoom by cameraManager.maxZoomRatio.collectAsState()
val isFrontCamera by cameraManager.isFrontCamera.collectAsState() val isFrontCamera by cameraManager.isFrontCamera.collectAsState()
val cameraError by cameraManager.error.collectAsState()
// Show camera errors via the existing error UI
LaunchedEffect(cameraError) {
cameraError?.let { message ->
showSaveError = message
cameraManager.clearError()
delay(2000)
showSaveError = null
}
}
// Collect orientation updates // Collect orientation updates
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -195,8 +143,6 @@ fun CameraScreen(
onDispose { onDispose {
cameraManager.release() cameraManager.release()
renderer?.release() renderer?.release()
lastThumbnailBitmap?.recycle()
galleryBitmap?.recycle()
} }
} }
@ -205,39 +151,25 @@ fun CameraScreen(
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
) { ) {
// Main view: gallery preview image or camera GL surface // OpenGL Surface for camera preview with effect
if (isGalleryPreview) { AndroidView(
galleryBitmap?.let { bmp -> factory = { ctx ->
Image( GLSurfaceView(ctx).apply {
bitmap = bmp.asImageBitmap(), setEGLContextClientVersion(2)
contentDescription = "Gallery preview",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
)
}
} else {
// OpenGL Surface for camera preview with effect
AndroidView(
factory = { ctx ->
GLSurfaceView(ctx).apply {
setEGLContextClientVersion(2)
val newRenderer = TiltShiftRenderer(ctx) { st -> val newRenderer = TiltShiftRenderer(ctx) { st ->
surfaceTexture = st surfaceTexture = st
}
renderer = newRenderer
setRenderer(newRenderer)
renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
glSurfaceView = this
} }
}, renderer = newRenderer
modifier = Modifier.fillMaxSize()
) setRenderer(newRenderer)
} renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
glSurfaceView = this
}
},
modifier = Modifier.fillMaxSize()
)
// Tilt-shift overlay (gesture handling + visualization) // Tilt-shift overlay (gesture handling + visualization)
TiltShiftOverlay( TiltShiftOverlay(
@ -247,10 +179,8 @@ fun CameraScreen(
haptics.tick() haptics.tick()
}, },
onZoomChange = { zoomDelta -> onZoomChange = { zoomDelta ->
if (!isGalleryPreview) { val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) cameraManager.setZoom(newZoom)
cameraManager.setZoom(newZoom)
}
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@ -267,28 +197,22 @@ fun CameraScreen(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (!isGalleryPreview) { // Zoom indicator
// Zoom indicator ZoomIndicator(currentZoom = zoomRatio)
ZoomIndicator(currentZoom = zoomRatio)
} else {
Spacer(modifier = Modifier.width(1.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (!isGalleryPreview) { // Camera flip button
// Camera flip button IconButton(
IconButton( onClick = {
onClick = { cameraManager.switchCamera()
cameraManager.switchCamera() haptics.click()
haptics.click()
}
) {
Icon(
imageVector = Icons.Default.FlipCameraAndroid,
contentDescription = "Switch Camera",
tint = Color.White
)
} }
) {
Icon(
imageVector = Icons.Default.FlipCameraAndroid,
contentDescription = "Switch Camera",
tint = Color.White
)
} }
// Toggle controls button // Toggle controls button
@ -346,196 +270,65 @@ fun CameraScreen(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.navigationBarsPadding() .navigationBarsPadding()
.padding(bottom = 48.dp) .padding(bottom = 24.dp),
.systemGestureExclusion(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (isGalleryPreview) { // Zoom presets (only show for back camera)
// Gallery preview mode: Cancel | Apply if (!isFrontCamera) {
Row( ZoomControl(
verticalAlignment = Alignment.CenterVertically, currentZoom = zoomRatio,
horizontalArrangement = Arrangement.spacedBy(48.dp) minZoom = minZoom,
) { maxZoom = maxZoom,
// Cancel button onZoomSelected = { zoom ->
IconButton( cameraManager.setZoom(zoom)
onClick = { haptics.click()
galleryBitmap?.recycle()
galleryBitmap = null
galleryImageUri = null
},
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(Color(0x80000000))
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Cancel",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
} }
)
// Apply button Spacer(modifier = Modifier.height(24.dp))
IconButton(
onClick = {
val uri = galleryImageUri ?: return@IconButton
if (!isCapturing) {
isCapturing = true
haptics.heavyClick()
scope.launch {
val result = captureHandler.processExistingImage(
imageUri = uri,
blurParams = blurParams,
location = currentLocation
)
when (result) {
is SaveResult.Success -> {
haptics.success()
lastThumbnailBitmap?.recycle()
lastThumbnailBitmap = result.thumbnail
lastSavedUri = result.uri
showSaveSuccess = true
delay(1500)
showSaveSuccess = false
}
is SaveResult.Error -> {
haptics.error()
showSaveError = result.message
delay(2000)
showSaveError = null
}
}
galleryBitmap?.recycle()
galleryBitmap = null
galleryImageUri = null
isCapturing = false
}
}
},
enabled = !isCapturing,
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(Color(0xFFFFB300))
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Apply effect",
tint = Color.Black,
modifier = Modifier.size(28.dp)
)
}
}
} else {
// Camera mode: Zoom presets + Gallery | Capture | Spacer
// Zoom presets (only show for back camera)
if (!isFrontCamera) {
ZoomControl(
currentZoom = zoomRatio,
minZoom = minZoom,
maxZoom = maxZoom,
onZoomSelected = { zoom ->
cameraManager.setZoom(zoom)
haptics.click()
}
)
Spacer(modifier = Modifier.height(24.dp))
}
// Gallery button | Capture button | Spacer for symmetry
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
// Gallery picker button
IconButton(
onClick = {
if (!isCapturing) {
galleryLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
},
enabled = !isCapturing,
modifier = Modifier.size(52.dp)
) {
Icon(
imageVector = Icons.Default.PhotoLibrary,
contentDescription = "Pick from gallery",
tint = Color.White,
modifier = Modifier.size(28.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 = isFrontCamera
)
when (result) {
is SaveResult.Success -> {
haptics.success()
lastThumbnailBitmap?.recycle()
lastThumbnailBitmap = result.thumbnail
lastSavedUri = result.uri
showSaveSuccess = true
delay(1500)
showSaveSuccess = false
}
is SaveResult.Error -> {
haptics.error()
showSaveError = result.message
delay(2000)
showSaveError = null
}
}
}
isCapturing = false
}
}
}
)
// Spacer for visual symmetry with gallery button
Spacer(modifier = Modifier.size(52.dp))
}
} }
}
// Last captured photo thumbnail (hidden in gallery preview mode) // Capture button
if (!isGalleryPreview) LastPhotoThumbnail( CaptureButton(
thumbnail = lastThumbnailBitmap, isCapturing = isCapturing,
onTap = { onClick = {
lastSavedUri?.let { uri -> if (!isCapturing) {
val intent = Intent(Intent.ACTION_VIEW).apply { isCapturing = true
setDataAndType(uri, "image/jpeg") haptics.heavyClick()
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
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 = isFrontCamera
)
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
}
} }
context.startActivity(intent)
} }
}, )
modifier = Modifier }
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(bottom = 48.dp, end = 16.dp)
)
// Success indicator // Success indicator
AnimatedVisibility( AnimatedVisibility(
@ -691,7 +484,6 @@ private fun ControlPanel(
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) } onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
) )
} }
} }
} }
@ -760,34 +552,3 @@ private fun CaptureButton(
) )
} }
} }
/**
* Rounded thumbnail of the last captured photo.
* Tapping opens the image in the default photo viewer.
*/
@Composable
private fun LastPhotoThumbnail(
thumbnail: Bitmap?,
onTap: () -> Unit,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = thumbnail != null,
enter = fadeIn() + scaleIn(initialScale = 0.6f),
exit = fadeOut(),
modifier = modifier
) {
thumbnail?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "Last captured photo",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(52.dp)
.clip(RoundedCornerShape(10.dp))
.border(2.dp, Color.White, RoundedCornerShape(10.dp))
.clickable(onClick = onTap)
)
}
}
}

View file

@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -87,3 +90,28 @@ private fun LensButton(
) )
} }
} }
/**
* Simple camera flip button (for future front camera support).
*/
@Composable
fun CameraFlipButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(48.dp)
.clip(CircleShape)
.background(Color(0x80000000))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.CameraRear,
contentDescription = "Switch Camera",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}

View file

@ -1,55 +1,98 @@
package no.naiv.tiltshift.util package no.naiv.tiltshift.util
import android.content.Context import android.content.Context
import android.os.Build
import android.os.VibrationEffect import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager import android.os.VibratorManager
import android.view.HapticFeedbackConstants
import android.view.View
/** /**
* Provides haptic feedback for user interactions. * Provides haptic feedback for user interactions.
*/ */
class HapticFeedback(private val context: Context) { class HapticFeedback(private val context: Context) {
private val vibrator by lazy { private val vibrator: Vibrator by lazy {
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
vibratorManager.defaultVibrator 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). * Light tick for UI feedback (button press, slider change).
*/ */
fun tick() { fun tick() {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_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. * Click feedback for confirmations.
*/ */
fun click() { fun click() {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_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). * Heavy click for important actions (photo capture).
*/ */
fun heavyClick() { fun heavyClick() {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK)) 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. * Success feedback pattern.
*/ */
fun success() { fun success() {
val timings = longArrayOf(0, 30, 50, 30) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val amplitudes = intArrayOf(0, 100, 0, 200) val timings = longArrayOf(0, 30, 50, 30)
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) 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. * Error feedback pattern.
*/ */
fun error() { fun error() {
val timings = longArrayOf(0, 50, 30, 50, 30, 50) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val amplitudes = intArrayOf(0, 150, 0, 150, 0, 150) val timings = longArrayOf(0, 50, 30, 50, 30, 50)
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) 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)
}
} }
} }