Compare commits

..

10 commits

Author SHA1 Message Date
5d80dcfcbe Add interactive gallery preview before applying tilt-shift effect
Instead of immediately processing gallery images, show a preview where
users can adjust blur parameters before committing. Adds Cancel/Apply
buttons and hides camera-only controls during gallery preview mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:46:05 +01:00
780a8ab167 Add bottom bar gesture exclusion, dual image save, thumbnail preview, and gallery picker
- Increase bottom padding and add systemGestureExclusion to prevent
  accidental navigation gestures from the capture controls
- Save both original and processed images with shared timestamps
  (TILTSHIFT_* and ORIGINAL_*) via new saveBitmapPair() pipeline
- Show animated thumbnail of last captured photo at bottom-right;
  tap opens the image in the default photo viewer
- Add gallery picker button to process existing photos through the
  tilt-shift pipeline with full EXIF rotation support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:32:11 +01:00
7abb2ea5a0 Remove unused high-resolution capture option
The feature provided no benefit on Pixel 7 Pro — both standard and
hi-res modes produced 12MP images since CameraX's standard resolution
list doesn't include the full sensor output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:50:53 +01:00
5cba2fefc9 Add configurable high-resolution capture option
Add useHighResCapture toggle to CameraManager that switches between
CameraX default resolution and HIGHEST_AVAILABLE_STRATEGY. Default
is off to avoid OOM from processing very large bitmaps (e.g. 50MP).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:38:33 +01:00
c7fa8f16be Remove unnecessary SDK version checks
With minSdk=35 all Build.VERSION.SDK_INT checks for API levels below
35 are always true. Remove all version branching in HapticFeedback
(API 29/31 checks) and PhotoSaver (API 29 checks). Keep only the
modern API calls and drop @Suppress("DEPRECATION") annotations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:25:16 +01:00
41a95885c1 Remove dead code
- Delete ExifWriter.kt (instantiated but never called)
- Remove saveJpegFile() and unused imports from PhotoSaver
- Remove CameraFlipButton() and unused imports from LensSwitcher
- Remove companion object and unused imports from HapticFeedback
- Remove getZoomPresets() from LensController
- Update README to reflect ExifWriter removal and actual minSdk (35)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:24:17 +01:00
ef350e9fb7 Replace e.printStackTrace() with Log.w in PhotoSaver
EXIF write failures are non-critical (the photo is already saved)
but should still be visible in logcat. Use Log.w with a proper TAG
instead of printStackTrace().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:22:04 +01:00
6ed3e8e7b5 Propagate camera binding errors to UI
Add an error StateFlow to CameraManager so camera binding failures
are surfaced to the user instead of silently swallowed by
e.printStackTrace(). CameraScreen collects this flow and displays
errors using the existing red overlay UI. Added Log.e with proper
TAG for logcat visibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:21:38 +01:00
f0249fcd64 Add bitmap safety in onCaptureSuccess callback
Track the current bitmap through the decode→rotate→effect pipeline
with a nullable variable. On exception, the in-flight bitmap is
recycled in the catch block to prevent native memory leaks. Errors
are now logged with Log.e and a proper companion TAG.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:20:57 +01:00
593f2c5b1f Add bitmap safety in applyTiltShiftEffect()
Track all intermediate bitmaps with nullable variables and recycle
them in a finally block. This prevents native memory leaks when an
OOM or other exception occurs mid-processing. Variables are set to
null after recycle or handoff to the caller.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:20:16 +01:00
9 changed files with 629 additions and 406 deletions

View file

@ -18,7 +18,7 @@ A dedicated Android camera app for tilt-shift photography with real-time preview
## Requirements ## Requirements
- Android 8.0 (API 26) or higher - Android 15 (API 35) or higher
- Device with camera - Device with camera
- OpenGL ES 2.0 support - OpenGL ES 2.0 support
@ -66,8 +66,7 @@ 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 │ └── PhotoSaver.kt # MediaStore integration & EXIF handling
│ └── ExifWriter.kt # EXIF metadata handling
└── util/ └── util/
├── OrientationDetector.kt ├── OrientationDetector.kt
├── LocationProvider.kt ├── LocationProvider.kt

View file

@ -2,6 +2,7 @@ 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
@ -24,6 +25,10 @@ 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
@ -32,6 +37,13 @@ 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()
@ -82,10 +94,11 @@ class CameraManager(private val context: Context) {
.setResolutionSelector(resolutionSelector) .setResolutionSelector(resolutionSelector)
.build() .build()
// Image capture use case for high-res photos // Image capture use case
imageCapture = ImageCapture.Builder() val captureBuilder = 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) {
@ -117,8 +130,8 @@ class CameraManager(private val context: Context) {
} }
} catch (e: Exception) { } catch (e: Exception) {
// Camera binding failed Log.e(TAG, "Camera binding failed", e)
e.printStackTrace() _error.value = "Camera failed: ${e.message}"
} }
} }

View file

@ -5,11 +5,15 @@ 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
@ -28,12 +32,19 @@ 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(val bitmap: Bitmap) private class ProcessedCapture(
val originalBitmap: Bitmap,
val processedBitmap: Bitmap
)
/** /**
* Captures a photo and applies the tilt-shift effect. * Captures a photo and applies the tilt-shift effect.
@ -58,26 +69,31 @@ 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
var bitmap = imageProxyToBitmap(imageProxy) currentBitmap = imageProxyToBitmap(imageProxy)
imageProxy.close() imageProxy.close()
if (bitmap == null) { if (currentBitmap == null) {
continuation.resume( continuation.resume(
SaveResult.Error("Failed to convert image") as Any SaveResult.Error("Failed to convert image") as Any
) )
return return
} }
bitmap = rotateBitmap(bitmap, imageRotation, isFrontCamera) currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera)
val processedBitmap = applyTiltShiftEffect(bitmap, blurParams) val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams)
bitmap.recycle() // Keep originalBitmap alive — both are recycled after saving
val original = currentBitmap
currentBitmap = null
continuation.resume(ProcessedCapture(processedBitmap)) continuation.resume(ProcessedCapture(original, 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
) )
@ -95,22 +111,46 @@ class ImageCaptureHandler(
) )
} }
// Phase 2: save to disk in the caller's coroutine context (suspend-safe) // Phase 2: save both original and processed to disk (suspend-safe)
if (captureResult is ProcessedCapture) { if (captureResult is ProcessedCapture) {
return try { return try {
photoSaver.saveBitmap( val thumbnail = createThumbnail(captureResult.processedBitmap)
captureResult.bitmap, val result = photoSaver.saveBitmapPair(
ExifInterface.ORIENTATION_NORMAL, original = captureResult.originalBitmap,
location processed = captureResult.processedBitmap,
orientation = ExifInterface.ORIENTATION_NORMAL,
location = location
) )
if (result is SaveResult.Success) {
result.copy(thumbnail = thumbnail)
} else {
thumbnail?.recycle()
result
}
} finally { } finally {
captureResult.bitmap.recycle() captureResult.originalBitmap.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.
*/ */
@ -149,35 +189,157 @@ 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
// Create output bitmap var result: Bitmap? = null
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) var scaled: Bitmap? = null
var blurred: Bitmap? = null
var blurredFullSize: Bitmap? = null
var mask: Bitmap? = null
// For performance, we use a scaled-down version for blur and composite try {
val scaleFactor = 4 // Blur a 1/4 size image for speed result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val scaleFactor = 4
val blurredWidth = width / scaleFactor val blurredWidth = width / scaleFactor
val blurredHeight = height / scaleFactor val blurredHeight = height / scaleFactor
// Create scaled bitmap for blur scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
val scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
// Apply stack blur (fast approximation) blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
val blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
scaled.recycle() scaled.recycle()
scaled = null
// Scale blurred back up blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true)
val blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true)
blurred.recycle() blurred.recycle()
blurred = null
// Create gradient mask based on tilt-shift parameters mask = createGradientMask(width, height, params)
val mask = createGradientMask(width, height, params)
// Composite: blend original with blurred based on mask // Composite: blend original with blurred based on mask
val pixels = IntArray(width * height) val pixels = IntArray(width * height)
@ -189,7 +351,9 @@ class ImageCaptureHandler(
mask.getPixels(maskPixels, 0, width, 0, 0, width, height) mask.getPixels(maskPixels, 0, width, 0, 0, width, height)
blurredFullSize.recycle() blurredFullSize.recycle()
blurredFullSize = null
mask.recycle() mask.recycle()
mask = null
for (i in pixels.indices) { for (i in pixels.indices) {
val maskAlpha = (maskPixels[i] and 0xFF) / 255f val maskAlpha = (maskPixels[i] and 0xFF) / 255f
@ -208,7 +372,17 @@ class ImageCaptureHandler(
} }
result.setPixels(pixels, 0, width, 0, 0, width, height) result.setPixels(pixels, 0, width, 0, 0, width, height)
return result
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()
}
} }
/** /**

View file

@ -88,11 +88,4 @@ 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

@ -1,96 +0,0 @@
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,28 +3,30 @@ 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(val uri: Uri, val path: String) : SaveResult() data class Success(
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()
} }
@ -33,7 +35,9 @@ sealed class SaveResult {
*/ */
class PhotoSaver(private val context: Context) { class PhotoSaver(private val context: Context) {
private val exifWriter = ExifWriter() companion object {
private const val TAG = "PhotoSaver"
}
private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
@ -45,100 +49,68 @@ class PhotoSaver(private val context: Context) {
orientation: Int, orientation: Int,
location: Location? location: Location?
): SaveResult = withContext(Dispatchers.IO) { ): SaveResult = withContext(Dispatchers.IO) {
try {
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg"
saveSingleBitmap(fileName, bitmap, orientation, location)
// 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. * 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 saveJpegFile( suspend fun saveBitmapPair(
sourceFile: File, original: Bitmap,
processed: Bitmap,
orientation: Int, orientation: Int,
location: Location? location: Location?
): SaveResult = withContext(Dispatchers.IO) { ): SaveResult = withContext(Dispatchers.IO) {
try { val timestamp = fileNameFormat.format(Date())
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" 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())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift") put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift")
put(MediaStore.Images.Media.IS_PENDING, 1) 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@withContext SaveResult.Error("Failed to create MediaStore entry") ) ?: return SaveResult.Error("Failed to create MediaStore entry")
// Copy file to MediaStore
contentResolver.openOutputStream(uri)?.use { outputStream -> contentResolver.openOutputStream(uri)?.use { outputStream ->
sourceFile.inputStream().use { inputStream -> bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream)
inputStream.copyTo(outputStream) } ?: return SaveResult.Error("Failed to open output stream")
}
} ?: return@withContext SaveResult.Error("Failed to open output stream")
// Write EXIF data
writeExifToUri(uri, orientation, location) writeExifToUri(uri, orientation, location)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.clear() contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(uri, contentValues, null, null) 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)
@ -169,7 +141,7 @@ class PhotoSaver(private val context: Context) {
exif.saveAttributes() exif.saveAttributes()
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() Log.w(TAG, "Failed to write EXIF data", e)
} }
} }

View file

@ -1,15 +1,20 @@
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
@ -25,10 +30,14 @@ 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
@ -46,8 +55,11 @@ 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
@ -97,13 +109,53 @@ 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) {
@ -143,6 +195,8 @@ fun CameraScreen(
onDispose { onDispose {
cameraManager.release() cameraManager.release()
renderer?.release() renderer?.release()
lastThumbnailBitmap?.recycle()
galleryBitmap?.recycle()
} }
} }
@ -151,6 +205,19 @@ fun CameraScreen(
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
) { ) {
// Main view: gallery preview image or camera GL surface
if (isGalleryPreview) {
galleryBitmap?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "Gallery preview",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
)
}
} else {
// OpenGL Surface for camera preview with effect // OpenGL Surface for camera preview with effect
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
@ -170,6 +237,7 @@ fun CameraScreen(
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
}
// Tilt-shift overlay (gesture handling + visualization) // Tilt-shift overlay (gesture handling + visualization)
TiltShiftOverlay( TiltShiftOverlay(
@ -179,8 +247,10 @@ 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()
) )
@ -197,10 +267,15 @@ 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 = {
@ -214,6 +289,7 @@ fun CameraScreen(
tint = Color.White tint = Color.White
) )
} }
}
// Toggle controls button // Toggle controls button
IconButton( IconButton(
@ -270,9 +346,89 @@ fun CameraScreen(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.navigationBarsPadding() .navigationBarsPadding()
.padding(bottom = 24.dp), .padding(bottom = 48.dp)
.systemGestureExclusion(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (isGalleryPreview) {
// Gallery preview mode: Cancel | Apply
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(48.dp)
) {
// Cancel button
IconButton(
onClick = {
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
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) // Zoom presets (only show for back camera)
if (!isFrontCamera) { if (!isFrontCamera) {
ZoomControl( ZoomControl(
@ -288,6 +444,31 @@ fun CameraScreen(
Spacer(modifier = Modifier.height(24.dp)) 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 // Capture button
CaptureButton( CaptureButton(
isCapturing = isCapturing, isCapturing = isCapturing,
@ -311,6 +492,9 @@ fun CameraScreen(
when (result) { when (result) {
is SaveResult.Success -> { is SaveResult.Success -> {
haptics.success() haptics.success()
lastThumbnailBitmap?.recycle()
lastThumbnailBitmap = result.thumbnail
lastSavedUri = result.uri
showSaveSuccess = true showSaveSuccess = true
delay(1500) delay(1500)
showSaveSuccess = false showSaveSuccess = false
@ -328,7 +512,30 @@ fun CameraScreen(
} }
} }
) )
// Spacer for visual symmetry with gallery button
Spacer(modifier = Modifier.size(52.dp))
} }
}
}
// Last captured photo thumbnail (hidden in gallery preview mode)
if (!isGalleryPreview) LastPhotoThumbnail(
thumbnail = lastThumbnailBitmap,
onTap = {
lastSavedUri?.let { uri ->
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "image/jpeg")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(bottom = 48.dp, end = 16.dp)
)
// Success indicator // Success indicator
AnimatedVisibility( AnimatedVisibility(
@ -484,6 +691,7 @@ private fun ControlPanel(
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) } onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
) )
} }
} }
} }
@ -552,3 +760,34 @@ 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,9 +9,6 @@ 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
@ -90,28 +87,3 @@ 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,98 +1,55 @@
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: Vibrator by lazy { private val vibrator by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator 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() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) 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() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) 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() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK)) vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(40L)
}
} }
/** /**
* Success feedback pattern. * Success feedback pattern.
*/ */
fun success() { fun success() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val timings = longArrayOf(0, 30, 50, 30) val timings = longArrayOf(0, 30, 50, 30)
val amplitudes = intArrayOf(0, 100, 0, 200) val amplitudes = intArrayOf(0, 100, 0, 200)
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) 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() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val timings = longArrayOf(0, 50, 30, 50, 30, 50) val timings = longArrayOf(0, 50, 30, 50, 30, 50)
val amplitudes = intArrayOf(0, 150, 0, 150, 0, 150) val amplitudes = intArrayOf(0, 150, 0, 150, 0, 150)
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) 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)
}
} }
} }