Compare commits
7 commits
5d80dcfcbe
...
5e08fb9c13
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e08fb9c13 | |||
| 983bca3600 | |||
| cc133072fc | |||
| 18a4289b96 | |||
| cd51c1a843 | |||
| 4950feb751 | |||
| 0e9adebe78 |
9 changed files with 136 additions and 82 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -42,9 +42,8 @@ captures/
|
||||||
*.iws
|
*.iws
|
||||||
|
|
||||||
# Keystore files
|
# Keystore files
|
||||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
*.jks
|
||||||
#*.jks
|
*.keystore
|
||||||
#*.keystore
|
|
||||||
|
|
||||||
# External native build folder generated in Android Studio 2.2 and later
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
|
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,14 @@ class CameraManager(private val context: Context) {
|
||||||
|
|
||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||||
cameraProviderFuture.addListener({
|
cameraProviderFuture.addListener({
|
||||||
|
try {
|
||||||
cameraProvider = cameraProviderFuture.get()
|
cameraProvider = cameraProviderFuture.get()
|
||||||
lensController.initialize(cameraProvider?.availableCameraInfos ?: emptyList())
|
lensController.initialize(cameraProvider?.availableCameraInfos ?: emptyList())
|
||||||
bindCameraUseCases(lifecycleOwner)
|
bindCameraUseCases(lifecycleOwner)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "CameraX initialization failed", e)
|
||||||
|
_error.value = "Camera could not initialize. Please restart the app."
|
||||||
|
}
|
||||||
}, ContextCompat.getMainExecutor(context))
|
}, ContextCompat.getMainExecutor(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,9 +134,12 @@ class CameraManager(private val context: Context) {
|
||||||
provideSurface(request)
|
provideSurface(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "Camera permission denied at runtime", e)
|
||||||
|
_error.value = "Camera permission was revoked. Please grant it in Settings."
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Camera binding failed", e)
|
Log.e(TAG, "Camera binding failed", e)
|
||||||
_error.value = "Camera failed: ${e.message}"
|
_error.value = "Camera could not start. Please try again or restart the app."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,17 +34,18 @@ class ImageCaptureHandler(
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ImageCaptureHandler"
|
private const val TAG = "ImageCaptureHandler"
|
||||||
|
/** Maximum decoded image dimension to prevent OOM from huge gallery images. */
|
||||||
|
private const val MAX_IMAGE_DIMENSION = 4096
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the processed bitmap ready for saving, produced inside the
|
* Type-safe outcome of the camera capture callback.
|
||||||
* camera callback (synchronous CPU work) and consumed afterwards
|
* Eliminates unsafe `as Any` / `as SaveResult` casts.
|
||||||
* in the caller's coroutine context.
|
|
||||||
*/
|
*/
|
||||||
private class ProcessedCapture(
|
private sealed class CaptureOutcome {
|
||||||
val originalBitmap: Bitmap,
|
class Processed(val original: Bitmap, val processed: Bitmap) : CaptureOutcome()
|
||||||
val processedBitmap: Bitmap
|
class Failed(val result: SaveResult.Error) : CaptureOutcome()
|
||||||
)
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captures a photo and applies the tilt-shift effect.
|
* Captures a photo and applies the tilt-shift effect.
|
||||||
|
|
@ -64,7 +65,7 @@ class ImageCaptureHandler(
|
||||||
isFrontCamera: Boolean
|
isFrontCamera: Boolean
|
||||||
): SaveResult {
|
): SaveResult {
|
||||||
// Phase 1: capture and process the image synchronously in the callback
|
// Phase 1: capture and process the image synchronously in the callback
|
||||||
val captureResult = suspendCancellableCoroutine { continuation ->
|
val captureResult = suspendCancellableCoroutine<CaptureOutcome> { continuation ->
|
||||||
imageCapture.takePicture(
|
imageCapture.takePicture(
|
||||||
executor,
|
executor,
|
||||||
object : ImageCapture.OnImageCapturedCallback() {
|
object : ImageCapture.OnImageCapturedCallback() {
|
||||||
|
|
@ -78,7 +79,7 @@ class ImageCaptureHandler(
|
||||||
|
|
||||||
if (currentBitmap == null) {
|
if (currentBitmap == null) {
|
||||||
continuation.resume(
|
continuation.resume(
|
||||||
SaveResult.Error("Failed to convert image") as Any
|
CaptureOutcome.Failed(SaveResult.Error("Failed to convert image"))
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -86,25 +87,23 @@ class ImageCaptureHandler(
|
||||||
currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera)
|
currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera)
|
||||||
|
|
||||||
val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams)
|
val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams)
|
||||||
// Keep originalBitmap alive — both are recycled after saving
|
|
||||||
val original = currentBitmap
|
val original = currentBitmap
|
||||||
currentBitmap = null
|
currentBitmap = null
|
||||||
|
|
||||||
continuation.resume(ProcessedCapture(original, processedBitmap))
|
continuation.resume(CaptureOutcome.Processed(original, processedBitmap))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Image processing failed", e)
|
Log.e(TAG, "Image processing failed", e)
|
||||||
currentBitmap?.recycle()
|
currentBitmap?.recycle()
|
||||||
continuation.resume(
|
continuation.resume(
|
||||||
SaveResult.Error("Capture failed: ${e.message}", e) as Any
|
CaptureOutcome.Failed(SaveResult.Error("Failed to process image. Please try again.", e))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(exception: ImageCaptureException) {
|
override fun onError(exception: ImageCaptureException) {
|
||||||
|
Log.e(TAG, "Image capture failed", exception)
|
||||||
continuation.resume(
|
continuation.resume(
|
||||||
SaveResult.Error(
|
CaptureOutcome.Failed(SaveResult.Error("Failed to capture photo. Please try again.", exception))
|
||||||
"Capture failed: ${exception.message}", exception
|
|
||||||
) as Any
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -112,12 +111,14 @@ class ImageCaptureHandler(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: save both original and processed to disk (suspend-safe)
|
// Phase 2: save both original and processed to disk (suspend-safe)
|
||||||
if (captureResult is ProcessedCapture) {
|
return when (captureResult) {
|
||||||
return try {
|
is CaptureOutcome.Failed -> captureResult.result
|
||||||
val thumbnail = createThumbnail(captureResult.processedBitmap)
|
is CaptureOutcome.Processed -> {
|
||||||
|
try {
|
||||||
|
val thumbnail = createThumbnail(captureResult.processed)
|
||||||
val result = photoSaver.saveBitmapPair(
|
val result = photoSaver.saveBitmapPair(
|
||||||
original = captureResult.originalBitmap,
|
original = captureResult.original,
|
||||||
processed = captureResult.processedBitmap,
|
processed = captureResult.processed,
|
||||||
orientation = ExifInterface.ORIENTATION_NORMAL,
|
orientation = ExifInterface.ORIENTATION_NORMAL,
|
||||||
location = location
|
location = location
|
||||||
)
|
)
|
||||||
|
|
@ -128,12 +129,11 @@ class ImageCaptureHandler(
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
captureResult.originalBitmap.recycle()
|
captureResult.original.recycle()
|
||||||
captureResult.processedBitmap.recycle()
|
captureResult.processed.recycle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return captureResult as SaveResult
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -238,9 +238,12 @@ class ImageCaptureHandler(
|
||||||
thumbnail?.recycle()
|
thumbnail?.recycle()
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "Permission denied while processing gallery image", e)
|
||||||
|
SaveResult.Error("Permission denied. Please grant access and try again.", e)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Gallery image processing failed", e)
|
Log.e(TAG, "Gallery image processing failed", e)
|
||||||
SaveResult.Error("Processing failed: ${e.message}", e)
|
SaveResult.Error("Failed to process image. Please try again.", e)
|
||||||
} finally {
|
} finally {
|
||||||
originalBitmap?.recycle()
|
originalBitmap?.recycle()
|
||||||
processedBitmap?.recycle()
|
processedBitmap?.recycle()
|
||||||
|
|
@ -248,13 +251,33 @@ class ImageCaptureHandler(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads a bitmap from a content URI.
|
* Loads a bitmap from a content URI with dimension bounds checking
|
||||||
|
* to prevent OOM from extremely large images.
|
||||||
*/
|
*/
|
||||||
private fun loadBitmapFromUri(uri: Uri): Bitmap? {
|
private fun loadBitmapFromUri(uri: Uri): Bitmap? {
|
||||||
return try {
|
return try {
|
||||||
|
// First pass: read dimensions without decoding pixels
|
||||||
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
context.contentResolver.openInputStream(uri)?.use { stream ->
|
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
BitmapFactory.decodeStream(stream)
|
BitmapFactory.decodeStream(stream, null, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate sample size to stay within MAX_IMAGE_DIMENSION
|
||||||
|
val maxDim = maxOf(options.outWidth, options.outHeight)
|
||||||
|
val sampleSize = if (maxDim > MAX_IMAGE_DIMENSION) {
|
||||||
|
var sample = 1
|
||||||
|
while (maxDim / sample > MAX_IMAGE_DIMENSION) sample *= 2
|
||||||
|
sample
|
||||||
|
} else 1
|
||||||
|
|
||||||
|
// Second pass: decode with sample size
|
||||||
|
val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize }
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
|
BitmapFactory.decodeStream(stream, null, decodeOptions)
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "Permission denied loading bitmap from URI", e)
|
||||||
|
null
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to load bitmap from URI", e)
|
Log.e(TAG, "Failed to load bitmap from URI", e)
|
||||||
null
|
null
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ 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.text.SimpleDateFormat
|
import java.time.LocalDateTime
|
||||||
import java.util.Date
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,7 +23,6 @@ import java.util.Locale
|
||||||
sealed class SaveResult {
|
sealed class SaveResult {
|
||||||
data class Success(
|
data class Success(
|
||||||
val uri: Uri,
|
val uri: Uri,
|
||||||
val path: String,
|
|
||||||
val originalUri: Uri? = null,
|
val originalUri: Uri? = null,
|
||||||
val thumbnail: android.graphics.Bitmap? = null
|
val thumbnail: android.graphics.Bitmap? = null
|
||||||
) : SaveResult()
|
) : SaveResult()
|
||||||
|
|
@ -39,7 +38,7 @@ class PhotoSaver(private val context: Context) {
|
||||||
private const val TAG = "PhotoSaver"
|
private const val TAG = "PhotoSaver"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
|
private val fileNameFormat = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss", Locale.US)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves a bitmap with the tilt-shift effect to the gallery.
|
* Saves a bitmap with the tilt-shift effect to the gallery.
|
||||||
|
|
@ -49,7 +48,7 @@ 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"
|
val fileName = "TILTSHIFT_${fileNameFormat.format(LocalDateTime.now())}.jpg"
|
||||||
saveSingleBitmap(fileName, bitmap, orientation, location)
|
saveSingleBitmap(fileName, bitmap, orientation, location)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,7 +63,7 @@ class PhotoSaver(private val context: Context) {
|
||||||
orientation: Int,
|
orientation: Int,
|
||||||
location: Location?
|
location: Location?
|
||||||
): SaveResult = withContext(Dispatchers.IO) {
|
): SaveResult = withContext(Dispatchers.IO) {
|
||||||
val timestamp = fileNameFormat.format(Date())
|
val timestamp = fileNameFormat.format(LocalDateTime.now())
|
||||||
val processedFileName = "TILTSHIFT_${timestamp}.jpg"
|
val processedFileName = "TILTSHIFT_${timestamp}.jpg"
|
||||||
val originalFileName = "ORIGINAL_${timestamp}.jpg"
|
val originalFileName = "ORIGINAL_${timestamp}.jpg"
|
||||||
|
|
||||||
|
|
@ -112,10 +111,13 @@ class PhotoSaver(private val context: Context) {
|
||||||
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)
|
||||||
|
|
||||||
val path = getPathFromUri(uri)
|
SaveResult.Success(uri)
|
||||||
SaveResult.Success(uri, path)
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "Storage permission denied", e)
|
||||||
|
SaveResult.Error("Storage permission was revoked. Please grant it in Settings.", e)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
SaveResult.Error("Failed to save photo: ${e.message}", e)
|
Log.e(TAG, "Failed to save photo", e)
|
||||||
|
SaveResult.Error("Failed to save photo. Please try again.", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,8 +131,8 @@ class PhotoSaver(private val context: Context) {
|
||||||
exif.setAttribute(ExifInterface.TAG_MAKE, "Android")
|
exif.setAttribute(ExifInterface.TAG_MAKE, "Android")
|
||||||
exif.setAttribute(ExifInterface.TAG_MODEL, Build.MODEL)
|
exif.setAttribute(ExifInterface.TAG_MODEL, Build.MODEL)
|
||||||
|
|
||||||
val dateFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US)
|
val exifDateFormat = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss", Locale.US)
|
||||||
val dateTime = dateFormat.format(Date())
|
val dateTime = exifDateFormat.format(LocalDateTime.now())
|
||||||
exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime)
|
exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime)
|
||||||
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime)
|
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime)
|
||||||
|
|
||||||
|
|
@ -145,14 +147,4 @@ class PhotoSaver(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getPathFromUri(uri: Uri): String {
|
|
||||||
val projection = arrayOf(MediaStore.Images.Media.DATA)
|
|
||||||
context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
|
|
||||||
if (cursor.moveToFirst()) {
|
|
||||||
val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
|
||||||
return cursor.getString(columnIndex) ?: uri.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return uri.toString()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import android.graphics.SurfaceTexture
|
||||||
import android.location.Location
|
import android.location.Location
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.opengl.GLSurfaceView
|
import android.opengl.GLSurfaceView
|
||||||
|
import android.util.Log
|
||||||
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
|
||||||
|
|
@ -359,9 +360,10 @@ fun CameraScreen(
|
||||||
// Cancel button
|
// Cancel button
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
galleryBitmap?.recycle()
|
val oldBitmap = galleryBitmap
|
||||||
galleryBitmap = null
|
galleryBitmap = null
|
||||||
galleryImageUri = null
|
galleryImageUri = null
|
||||||
|
oldBitmap?.recycle()
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(56.dp)
|
.size(56.dp)
|
||||||
|
|
@ -392,9 +394,10 @@ fun CameraScreen(
|
||||||
when (result) {
|
when (result) {
|
||||||
is SaveResult.Success -> {
|
is SaveResult.Success -> {
|
||||||
haptics.success()
|
haptics.success()
|
||||||
lastThumbnailBitmap?.recycle()
|
val oldThumb = lastThumbnailBitmap
|
||||||
lastThumbnailBitmap = result.thumbnail
|
lastThumbnailBitmap = result.thumbnail
|
||||||
lastSavedUri = result.uri
|
lastSavedUri = result.uri
|
||||||
|
oldThumb?.recycle()
|
||||||
showSaveSuccess = true
|
showSaveSuccess = true
|
||||||
delay(1500)
|
delay(1500)
|
||||||
showSaveSuccess = false
|
showSaveSuccess = false
|
||||||
|
|
@ -406,9 +409,10 @@ fun CameraScreen(
|
||||||
showSaveError = null
|
showSaveError = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
galleryBitmap?.recycle()
|
val oldGalleryBitmap = galleryBitmap
|
||||||
galleryBitmap = null
|
galleryBitmap = null
|
||||||
galleryImageUri = null
|
galleryImageUri = null
|
||||||
|
oldGalleryBitmap?.recycle()
|
||||||
isCapturing = false
|
isCapturing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -492,9 +496,10 @@ fun CameraScreen(
|
||||||
when (result) {
|
when (result) {
|
||||||
is SaveResult.Success -> {
|
is SaveResult.Success -> {
|
||||||
haptics.success()
|
haptics.success()
|
||||||
lastThumbnailBitmap?.recycle()
|
val oldThumb = lastThumbnailBitmap
|
||||||
lastThumbnailBitmap = result.thumbnail
|
lastThumbnailBitmap = result.thumbnail
|
||||||
lastSavedUri = result.uri
|
lastSavedUri = result.uri
|
||||||
|
oldThumb?.recycle()
|
||||||
showSaveSuccess = true
|
showSaveSuccess = true
|
||||||
delay(1500)
|
delay(1500)
|
||||||
showSaveSuccess = false
|
showSaveSuccess = false
|
||||||
|
|
@ -524,11 +529,15 @@ fun CameraScreen(
|
||||||
thumbnail = lastThumbnailBitmap,
|
thumbnail = lastThumbnailBitmap,
|
||||||
onTap = {
|
onTap = {
|
||||||
lastSavedUri?.let { uri ->
|
lastSavedUri?.let { uri ->
|
||||||
|
try {
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
setDataAndType(uri, "image/jpeg")
|
setDataAndType(uri, "image/jpeg")
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
}
|
}
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
|
} catch (e: android.content.ActivityNotFoundException) {
|
||||||
|
Log.w("CameraScreen", "No activity found to view image", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,45 @@ package no.naiv.tiltshift.util
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.VibrationEffect
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
import android.os.VibratorManager
|
import android.os.VibratorManager
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides haptic feedback for user interactions.
|
* Provides haptic feedback for user interactions.
|
||||||
|
* Gracefully degrades on devices without vibration hardware.
|
||||||
*/
|
*/
|
||||||
class HapticFeedback(private val context: Context) {
|
class HapticFeedback(private val context: Context) {
|
||||||
|
|
||||||
private val vibrator by lazy {
|
companion object {
|
||||||
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
private const val TAG = "HapticFeedback"
|
||||||
vibratorManager.defaultVibrator
|
}
|
||||||
|
|
||||||
|
private val vibrator: Vibrator? by lazy {
|
||||||
|
val vibratorManager =
|
||||||
|
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
|
||||||
|
vibratorManager?.defaultVibrator
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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))
|
vibrateOrLog(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Click feedback for confirmations.
|
* Click feedback for confirmations.
|
||||||
*/
|
*/
|
||||||
fun click() {
|
fun click() {
|
||||||
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
|
vibrateOrLog(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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))
|
vibrateOrLog(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -41,7 +49,7 @@ class HapticFeedback(private val context: Context) {
|
||||||
fun success() {
|
fun success() {
|
||||||
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))
|
vibrateOrLog(VibrationEffect.createWaveform(timings, amplitudes, -1))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -50,6 +58,14 @@ class HapticFeedback(private val context: Context) {
|
||||||
fun error() {
|
fun error() {
|
||||||
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))
|
vibrateOrLog(VibrationEffect.createWaveform(timings, amplitudes, -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun vibrateOrLog(effect: VibrationEffect) {
|
||||||
|
try {
|
||||||
|
vibrator?.vibrate(effect)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Haptic feedback failed", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.location.Location
|
import android.location.Location
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.google.android.gms.location.FusedLocationProviderClient
|
import com.google.android.gms.location.FusedLocationProviderClient
|
||||||
import com.google.android.gms.location.LocationCallback
|
import com.google.android.gms.location.LocationCallback
|
||||||
|
|
@ -21,6 +22,10 @@ import kotlinx.coroutines.flow.callbackFlow
|
||||||
*/
|
*/
|
||||||
class LocationProvider(private val context: Context) {
|
class LocationProvider(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "LocationProvider"
|
||||||
|
}
|
||||||
|
|
||||||
private val fusedLocationClient: FusedLocationProviderClient =
|
private val fusedLocationClient: FusedLocationProviderClient =
|
||||||
LocationServices.getFusedLocationProviderClient(context)
|
LocationServices.getFusedLocationProviderClient(context)
|
||||||
|
|
||||||
|
|
@ -61,6 +66,7 @@ class LocationProvider(private val context: Context) {
|
||||||
location?.let { trySend(it) }
|
location?.let { trySend(it) }
|
||||||
}
|
}
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "Location permission revoked at runtime", e)
|
||||||
trySend(null)
|
trySend(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
1
gradle/wrapper/gradle-wrapper.properties
vendored
1
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||||
|
distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue