Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<CancellationTokenSource>()
Expand All @@ -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)
}
}

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

Expand Down Expand Up @@ -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() {
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
158 changes: 109 additions & 49 deletions app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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<String?>(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<SurfaceRequest?>(null) }
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
val mainHandler = remember { Handler(Looper.getMainLooper()) }
val activeSession = remember { AtomicReference<Any?>(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()
Comment on lines 528 to 530

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the embedded viewfinder for the crossfade

Keep ImplementationMode.EMBEDDED here because this preview is rendered inside the tab Crossfade and a rounded clipped container. Without the argument, CameraXViewfinder defaults to the external/SurfaceView implementation, which does not support the alpha blending and clipping this UI relies on; when users switch between Scan and My QR, the camera surface can remain opaque during the transition or render outside the rounded bounds.

Useful? React with 👍 / 👎.

)
}
Expand Down Expand Up @@ -521,6 +577,10 @@ private class QRCodeAnalyzer(
.build()
)

fun close() {
scanner.close()
}

@ExperimentalGetImage
override fun analyze(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image ?: run {
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@
<string name="location_notes_relays_unavailable">Geo relays unavailable; notes paused</string>
<string name="location_notes_no_relays_title">No geo relays nearby</string>
<string name="location_notes_no_relays_desc">Notes rely on geo relays. Check connection and try again.</string>
<string name="location_notes_location_unavailable">Unable to determine your current location. Check device location and try again.</string>
<string name="loading_location_notes">Loading notes…</string>
<string name="location_notes_empty_title">No notes yet</string>
<string name="location_notes_empty_desc">Be the first to add one for this spot.</string>
Expand Down Expand Up @@ -483,6 +484,8 @@
<string name="verify_qr_unavailable">QR unavailable</string>
<string name="verify_camera_permission">Camera permission is needed to scan QR codes</string>
<string name="verify_request_camera">Enable camera</string>
<string name="verify_camera_unavailable">Camera is unavailable. Please try again.</string>
<string name="verify_retry_camera">Retry camera</string>
<string name="verify_paste_label">Paste verification URL</string>
<string name="verify_validate">Validate</string>
<string name="verify_scanned">Verification requested</string>
Expand Down