diff --git a/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt index 06439c80a6..c69c5d5d50 100644 --- a/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt @@ -18,7 +18,8 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro } private val fusedLocationClient: FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context) - + private val systemFallback = SystemLocationProvider(context) + // Map to keep track of callbacks to remove them later private val activeCallbacks = mutableMapOf<(Location) -> Unit, LocationCallback>() private val activeCurrentLocationRequests = mutableSetOf() @@ -40,15 +41,19 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro try { fusedLocationClient.lastLocation .addOnSuccessListener { location -> - callback(location.takeIf { LiveLocationPrivacyGate.isEnabled }) + if (location != null && LiveLocationPrivacyGate.isEnabled) { + callback(location) + } else { + systemFallback.getLastKnownLocation(callback) + } } - .addOnFailureListener { e -> + .addOnFailureListener { Log.e(TAG, "Error getting last-known fused location") - callback(null) + systemFallback.getLastKnownLocation(callback) } - } catch (e: Exception) { + } catch (_: Exception) { Log.e(TAG, "Exception getting last-known fused location") - callback(null) + systemFallback.getLastKnownLocation(callback) } } @@ -72,20 +77,24 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro fusedLocationClient.getCurrentLocation(request, cancellation.token) .addOnSuccessListener { location -> - callback(location.takeIf { LiveLocationPrivacyGate.isEnabled }) + if (location != null && LiveLocationPrivacyGate.isEnabled) { + callback(location) + } else { + systemFallback.requestFreshLocation(callback) + } } - .addOnFailureListener { e -> + .addOnFailureListener { Log.e(TAG, "Error getting fresh fused location") - callback(null) + systemFallback.requestFreshLocation(callback) } .addOnCompleteListener { synchronized(activeCurrentLocationRequests) { activeCurrentLocationRequests.remove(cancellation) } } - } catch (e: Exception) { + } catch (_: Exception) { Log.e(TAG, "Exception getting fresh fused location") - callback(null) + systemFallback.requestFreshLocation(callback) } } @@ -119,27 +128,40 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro request, locationCallback, Looper.getMainLooper() - ) - Log.d(TAG, "Registered fused updates") + ).addOnSuccessListener { + Log.d(TAG, "Registered fused updates") + }.addOnFailureListener { + val shouldStartFallback = synchronized(activeCallbacks) { + activeCallbacks[callback] === locationCallback + } + if (shouldStartFallback) { + Log.w(TAG, "Fused updates unavailable; using system location provider") + systemFallback.requestLocationUpdates(intervalMs, minDistanceMeters, callback) + } + } - } catch (e: Exception) { + } catch (_: Exception) { Log.e(TAG, "Error requesting fused updates") + synchronized(activeCallbacks) { + activeCallbacks.remove(callback) + } + systemFallback.requestLocationUpdates(intervalMs, minDistanceMeters, callback) } } override fun removeLocationUpdates(callback: (Location) -> Unit) { + val locationCallback = synchronized(activeCallbacks) { + activeCallbacks.remove(callback) + } try { - val locationCallback = synchronized(activeCallbacks) { - activeCallbacks.remove(callback) - } - if (locationCallback != null) { fusedLocationClient.removeLocationUpdates(locationCallback) Log.d(TAG, "Removed fused updates") } - } catch (e: Exception) { + } catch (_: Exception) { Log.e(TAG, "Error removing fused updates") } + systemFallback.removeLocationUpdates(callback) } override fun cancel() { @@ -155,8 +177,9 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro activeCurrentLocationRequests.clear() } Log.d(TAG, "Cancelled all fused updates") - } catch (e: Exception) { + } catch (_: Exception) { Log.e(TAG, "Error cancelling fused provider") } + systemFallback.cancel() } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt index 9a8cf9e72d..57a1bbf6b4 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -127,7 +127,7 @@ private fun LocationNotesErrorSheet( ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Location permission is required for notes", + text = stringResource(R.string.location_notes_location_unavailable), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index a84c3c9470..771cc7a399 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -14,7 +14,6 @@ import androidx.camera.core.ImageProxy import androidx.camera.core.Preview import androidx.camera.core.SurfaceRequest import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.viewfinder.core.ImplementationMode import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -87,6 +86,7 @@ import com.google.zxing.qrcode.QRCodeWriter import kotlinx.coroutines.flow.MutableStateFlow import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -321,6 +321,7 @@ private fun ScanTabContent( onScan: (String) -> Unit ) { val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA) + var cameraUnavailable by remember { mutableStateOf(false) } Column( modifier = Modifier @@ -338,31 +339,52 @@ private fun ScanTabContent( .background(Color.Black), contentAlignment = Alignment.Center ) { - ScannerView(onScan = onScan) - - // Overlay border - Box( - modifier = Modifier - .size(280.dp) - .border(2.dp, accent.copy(alpha = 0.8f), RoundedCornerShape(16.dp)) - ) - - // Corner accents for the overlay - Box(modifier = Modifier.size(260.dp)) { - // This could be drawn with Canvas for cooler effect, but simple border is cleaner for now + if (cameraUnavailable) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.verify_camera_unavailable), + color = Color.White, + fontFamily = BitchatFontFamily, + textAlign = TextAlign.Center + ) + Button( + onClick = { cameraUnavailable = false }, + colors = ButtonDefaults.buttonColors(containerColor = accent) + ) { + Text( + text = stringResource(R.string.verify_retry_camera), + fontFamily = BitchatFontFamily + ) + } + } + } else { + ScannerView( + onScan = onScan, + onCameraUnavailable = { cameraUnavailable = true } + ) + + Box( + modifier = Modifier + .size(280.dp) + .border(2.dp, accent.copy(alpha = 0.8f), RoundedCornerShape(16.dp)) + ) + + Text( + text = stringResource(R.string.verify_scan_prompt_friend), + color = Color.White, + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 32.dp) + .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) } - - Text( - text = stringResource(R.string.verify_scan_prompt_friend), - color = Color.White, - fontFamily = BitchatFontFamily, - fontSize = 12.sp, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 32.dp) - .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(8.dp)) - .padding(horizontal = 12.dp, vertical = 8.dp) - ) } } else { Column( @@ -407,70 +429,104 @@ private fun ScanTabContent( @Composable private fun ScannerView( - onScan: (String) -> Unit + onScan: (String) -> Unit, + onCameraUnavailable: () -> Unit ) { - val context = LocalContext.current + val context = LocalContext.current.applicationContext val lifecycleOwner = LocalLifecycleOwner.current var lastValid by remember { mutableStateOf(null) } - val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } - val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() } + val cameraProviderFuture = remember(context) { ProcessCameraProvider.getInstance(context) } + val cameraExecutor: ExecutorService = remember(lifecycleOwner) { Executors.newSingleThreadExecutor() } val surfaceRequests = remember { MutableStateFlow(null) } val surfaceRequest by surfaceRequests.collectAsState(initial = null) val mainHandler = remember { Handler(Looper.getMainLooper()) } + val activeSession = remember { AtomicReference(null) } val onCodeState = rememberUpdatedState(onScan) - val analyzer = remember { + val onCameraUnavailableState = rememberUpdatedState(onCameraUnavailable) + val analyzer = remember(lifecycleOwner) { QRCodeAnalyzer { text -> + val session = activeSession.get() ?: return@QRCodeAnalyzer mainHandler.post { - if (text == lastValid) return@post + if (activeSession.get() !== session || text == lastValid) return@post lastValid = text onCodeState.value(text) } } } - DisposableEffect(Unit) { + DisposableEffect(cameraProviderFuture, lifecycleOwner, cameraExecutor, analyzer) { val executor = ContextCompat.getMainExecutor(context) var cameraProvider: ProcessCameraProvider? = null + var preview: Preview? = null + var analysis: ImageAnalysis? = null + val session = Any() + activeSession.set(session) + + fun isCurrentSession() = activeSession.get() === session cameraProviderFuture.addListener( { - val provider = cameraProviderFuture.get() - cameraProvider = provider - val preview = Preview.Builder().build().also { - it.setSurfaceProvider { request -> surfaceRequests.value = request } - } - val analysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .also { it.setAnalyzer(cameraExecutor, analyzer) } + if (!isCurrentSession()) return@addListener + + try { + val provider = cameraProviderFuture.get() + if (!isCurrentSession() || !provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA)) { + if (isCurrentSession()) onCameraUnavailableState.value() + return@addListener + } - runCatching { - provider.unbindAll() + val scannerPreview = Preview.Builder().build().also { + it.setSurfaceProvider { request -> + if (isCurrentSession()) { + surfaceRequests.value = request + } else { + request.willNotProvideSurface() + } + } + } + val scannerAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { it.setAnalyzer(cameraExecutor, analyzer) } + + cameraProvider = provider + preview = scannerPreview + analysis = scannerAnalysis provider.bindToLifecycle( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, - preview, - analysis + scannerPreview, + scannerAnalysis ) - }.onFailure { - Log.w("VerificationSheet", "Failed to bind camera: ${it.message}") + } catch (exception: Exception) { + if (isCurrentSession()) { + Log.e("VerificationSheet", "Unable to start QR camera", exception) + onCameraUnavailableState.value() + } } }, executor ) onDispose { + activeSession.compareAndSet(session, null) surfaceRequests.value = null - runCatching { cameraProvider?.unbindAll() } + analysis?.clearAnalyzer() + val provider = cameraProvider + val scannerPreview = preview + val scannerAnalysis = analysis + if (provider != null && scannerPreview != null && scannerAnalysis != null) { + runCatching { provider.unbind(scannerPreview, scannerAnalysis) } + } cameraExecutor.shutdown() + analyzer.close() } } surfaceRequest?.let { request -> CameraXViewfinder( surfaceRequest = request, - implementationMode = ImplementationMode.EMBEDDED, modifier = Modifier.fillMaxSize() ) } @@ -521,6 +577,10 @@ private class QRCodeAnalyzer( .build() ) + fun close() { + scanner.close() + } + @ExperimentalGetImage override fun analyze(imageProxy: ImageProxy) { val mediaImage = imageProxy.image ?: run { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a46d596f3d..77a0d15c0c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -442,6 +442,7 @@ Geo relays unavailable; notes paused No geo relays nearby Notes rely on geo relays. Check connection and try again. + Unable to determine your current location. Check device location and try again. Loading notes… No notes yet Be the first to add one for this spot. @@ -483,6 +484,8 @@ QR unavailable Camera permission is needed to scan QR codes Enable camera + Camera is unavailable. Please try again. + Retry camera Paste verification URL Validate Verification requested