Compare commits

..

7 commits

Author SHA1 Message Date
5e08fb9c13 Fix bitmap recycle race condition and startActivity crash
- Null the Compose state reference before recycling bitmaps to prevent
  the renderer from drawing a recycled bitmap between recycle() and
  the state update
- Wrap ACTION_VIEW startActivity in try-catch for devices without
  an image viewer installed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:56:29 +01:00
983bca3600 Type-safe capture pipeline and image dimension bounds
- Replace unsafe as Any / as SaveResult casts with a sealed
  CaptureOutcome class for type-safe continuation handling
- Catch SecurityException separately with permission-specific messages
- Replace raw e.message with generic user-friendly error strings
- Add inJustDecodeBounds pre-check in loadBitmapFromUri to downsample
  images exceeding 4096px, preventing OOM from huge gallery images

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:56:20 +01:00
cc133072fc Security-harden PhotoSaver
- Catch SecurityException separately for storage permission revocation
- Replace raw e.message with generic user-friendly error strings
- Replace thread-unsafe SimpleDateFormat with java.time.DateTimeFormatter
  to prevent filename collisions under concurrent saves on Dispatchers.IO
- Remove deprecated MediaStore.Images.Media.DATA column query and the
  path field from SaveResult.Success (unreliable on scoped storage)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:56:13 +01:00
18a4289b96 Harden CameraManager error handling
- Catch SecurityException separately with permission-specific message
- Replace raw e.message with generic user-friendly error strings
- Wrap cameraProviderFuture.get() in try-catch to handle CameraX
  initialization failures instead of crashing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:56:03 +01:00
cd51c1a843 Log SecurityException in LocationProvider instead of swallowing silently
A revoked location permission was previously caught and sent as null
with zero logging, making it indistinguishable from "no fix yet" and
impossible to diagnose.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:55:57 +01:00
4950feb751 Make HapticFeedback null-safe for devices without vibrator
Use safe cast (as? VibratorManager) and wrap vibrate calls in
try-catch to prevent crashes on emulators, custom ROMs, or
devices without vibration hardware.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:55:50 +01:00
0e9adebe78 Harden build and config security
- Uncomment *.jks and *.keystore in .gitignore to prevent
  accidental keystore commits
- Disable android:allowBackup to prevent ADB data extraction
- Add distributionSha256Sum to gradle-wrapper.properties for
  tamper detection of Gradle distributions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:55:24 +01:00
9 changed files with 136 additions and 82 deletions

5
.gitignore vendored
View file

@ -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

View file

@ -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"

View file

@ -73,9 +73,14 @@ class CameraManager(private val context: Context) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context) val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({ cameraProviderFuture.addListener({
cameraProvider = cameraProviderFuture.get() try {
lensController.initialize(cameraProvider?.availableCameraInfos ?: emptyList()) cameraProvider = cameraProviderFuture.get()
bindCameraUseCases(lifecycleOwner) lensController.initialize(cameraProvider?.availableCameraInfos ?: emptyList())
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."
} }
} }

View file

@ -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,28 +111,29 @@ 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 -> {
val result = photoSaver.saveBitmapPair( try {
original = captureResult.originalBitmap, val thumbnail = createThumbnail(captureResult.processed)
processed = captureResult.processedBitmap, val result = photoSaver.saveBitmapPair(
orientation = ExifInterface.ORIENTATION_NORMAL, original = captureResult.original,
location = location processed = captureResult.processed,
) orientation = ExifInterface.ORIENTATION_NORMAL,
if (result is SaveResult.Success) { location = location
result.copy(thumbnail = thumbnail) )
} else { if (result is SaveResult.Success) {
thumbnail?.recycle() result.copy(thumbnail = thumbnail)
result } else {
thumbnail?.recycle()
result
}
} finally {
captureResult.original.recycle()
captureResult.processed.recycle()
} }
} finally {
captureResult.originalBitmap.recycle()
captureResult.processedBitmap.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

View file

@ -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()
}
} }

View file

@ -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 ->
val intent = Intent(Intent.ACTION_VIEW).apply { try {
setDataAndType(uri, "image/jpeg") val intent = Intent(Intent.ACTION_VIEW).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) setDataAndType(uri, "image/jpeg")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
} catch (e: android.content.ActivityNotFoundException) {
Log.w("CameraScreen", "No activity found to view image", e)
} }
context.startActivity(intent)
} }
}, },
modifier = Modifier modifier = Modifier

View file

@ -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)
}
} }
} }

View file

@ -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)
} }

View file

@ -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