From aaa3ef044a65784a07bd9d15fbaf11bf25f4ac23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 1 Jan 2026 23:50:31 +0800 Subject: [PATCH] Refactor QR scan and share and add QRS support --- app/src/main/AndroidManifest.xml | 3 - .../sfa/compose/NewProfileActivity.kt | 3 + .../sfa/compose/component/qr/QRCodeDialog.kt | 69 ++++ .../compose/component/qr/QRSBitmapState.kt | 107 +++++ .../sfa/compose/component/qr/QRSDialog.kt | 254 ++++++++++++ .../sfa/compose/component/qr/QRScanSheet.kt | 291 +++++++++++++ .../screen/configuration/NewProfileScreen.kt | 9 +- .../configuration/NewProfileViewModel.kt | 50 ++- .../configuration/ProfileImportHandler.kt | 76 ++++ .../screen/configuration/QRCodeDialog.kt | 134 ------ .../screen/dashboard/DashboardCardRenderer.kt | 5 - .../screen/dashboard/DashboardScreen.kt | 22 - .../screen/dashboard/ProfilePickerSheet.kt | 25 +- .../compose/screen/dashboard/ProfilesCard.kt | 367 +++++++++++++---- .../compose/screen/qrscan/QRScanViewModel.kt | 389 ++++++++++++++++++ .../sfa/compose/util/QRCodeGenerator.kt | 81 ++++ .../sfa/compose/util/QRCodeUtils.kt | 146 ------- .../java/io/nekohasekai/sfa/ktx/Shares.kt | 17 + .../sfa/qrs/ByteArrayExtensions.kt | 22 + .../java/io/nekohasekai/sfa/qrs/LubyCodec.kt | 334 +++++++++++++++ .../io/nekohasekai/sfa/qrs/QRSConstants.kt | 25 ++ .../java/io/nekohasekai/sfa/qrs/QRSDecoder.kt | 175 ++++++++ .../java/io/nekohasekai/sfa/qrs/QRSEncoder.kt | 121 ++++++ .../sfa/qrs/SolitonDistribution.kt | 19 + .../sfa/ui/profile/QRScanActivity.kt | 247 ----------- .../sfa/ui/profile/ZxingQRCodeAnalyzer.kt | 96 +++-- app/src/main/res/layout/activity_qr_scan.xml | 26 -- app/src/main/res/values-zh-rCN/strings.xml | 18 +- app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/strings.xml | 16 + .../sfa/vendor/MLKitQRCodeAnalyzer.kt | 62 ++- 31 files changed, 2475 insertions(+), 735 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRCodeDialog.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSBitmapState.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/QRCodeDialog.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeUtils.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/qrs/ByteArrayExtensions.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/qrs/LubyCodec.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/qrs/QRSDecoder.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/qrs/QRSEncoder.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/qrs/SolitonDistribution.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt delete mode 100644 app/src/main/res/layout/activity_qr_scan.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5a46530..2d0a015 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -115,9 +115,6 @@ - val resultIntent = diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRCodeDialog.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRCodeDialog.kt new file mode 100644 index 0000000..430949f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRCodeDialog.kt @@ -0,0 +1,69 @@ +package io.nekohasekai.sfa.compose.component.qr + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +@Composable +fun QRCodeDialog( + bitmap: Bitmap, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Card( + modifier = + Modifier + .fillMaxWidth(0.9f) + .wrapContentHeight(), + shape = RoundedCornerShape(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + shape = RoundedCornerShape(0.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = stringResource(io.nekohasekai.sfa.R.string.content_description_qr_code), + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSBitmapState.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSBitmapState.kt new file mode 100644 index 0000000..2b691ed --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSBitmapState.kt @@ -0,0 +1,107 @@ +package io.nekohasekai.sfa.compose.component.qr + +import android.graphics.Bitmap +import io.nekohasekai.sfa.compose.util.QRCodeGenerator +import io.nekohasekai.sfa.qrs.QRSEncoder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield + +data class QRSGenerationState( + val currentBitmap: Bitmap? = null, + val currentFrameIndex: Int = 0, + val generatedCount: Int = 0, + val totalFrames: Int = 0, + val isGenerating: Boolean = true, +) + +class QRSBitmapGenerator( + private val scope: CoroutineScope, + private val frames: List, + private val foregroundColor: Int, + private val backgroundColor: Int, + bufferSize: Int = 30, +) { + private val _state = MutableStateFlow(QRSGenerationState(totalFrames = frames.size)) + val state: StateFlow = _state + + private val actualBufferSize = bufferSize.coerceAtMost(frames.size) + private val bitmapBuffer = arrayOfNulls(actualBufferSize) + private var generationJob: Job? = null + @Volatile + private var currentFrameIndex = 0 + private var generatedUpTo = -1 + + fun start() { + if (frames.isEmpty()) { + _state.value = _state.value.copy(isGenerating = false) + return + } + + generationJob = scope.launch { + val firstBitmap = withContext(Dispatchers.Default) { + QRCodeGenerator.generate( + content = frames[0].content, + foregroundColor = foregroundColor, + backgroundColor = backgroundColor, + ) + } + bitmapBuffer[0] = firstBitmap + generatedUpTo = 0 + _state.value = _state.value.copy( + currentBitmap = firstBitmap, + generatedCount = 1, + isGenerating = frames.size > 1, + ) + + for (i in 1 until frames.size) { + yield() + val bitmap = withContext(Dispatchers.Default) { + QRCodeGenerator.generate( + content = frames[i].content, + foregroundColor = foregroundColor, + backgroundColor = backgroundColor, + ) + } + + val bufferIndex = i % actualBufferSize + val currentDisplayBufferIndex = currentFrameIndex % actualBufferSize + if (bufferIndex != currentDisplayBufferIndex) { + bitmapBuffer[bufferIndex]?.recycle() + } + bitmapBuffer[bufferIndex] = bitmap + generatedUpTo = i + + _state.value = _state.value.copy( + generatedCount = i + 1, + isGenerating = i < frames.size - 1, + ) + } + } + } + + fun advanceFrame() { + if (generatedUpTo < 0) return + + val nextIndex = (currentFrameIndex + 1) % frames.size + if (nextIndex <= generatedUpTo || generatedUpTo == frames.size - 1) { + currentFrameIndex = nextIndex + } + + val bufferIndex = currentFrameIndex % actualBufferSize + val bitmap = bitmapBuffer[bufferIndex] + _state.value = _state.value.copy( + currentBitmap = bitmap, + currentFrameIndex = currentFrameIndex, + ) + } + + fun cancel() { + generationJob?.cancel() + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt new file mode 100644 index 0000000..c67ce59 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt @@ -0,0 +1,254 @@ +package io.nekohasekai.sfa.compose.component.qr + +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.qrs.QRSConstants +import io.nekohasekai.sfa.qrs.QRSEncoder +import kotlinx.coroutines.delay + +@Composable +fun QRSDialog( + profileData: ByteArray, + profileName: String, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + var fps by remember { mutableIntStateOf(QRSConstants.DEFAULT_FPS) } + var sliceSize by remember { mutableIntStateOf(QRSConstants.DEFAULT_SLICE_SIZE) } + + val encoder = remember(sliceSize) { QRSEncoder(sliceSize) } + val dataWithMeta = remember(profileData, profileName) { + QRSEncoder.appendFileHeaderMeta( + data = profileData, + filename = "$profileName.bpf", + contentType = "application/octet-stream", + ) + } + val requiredFrames = remember(dataWithMeta, sliceSize) { + QRSConstants.calculateRequiredFrames(dataWithMeta.size, sliceSize) + } + val frames = remember(dataWithMeta, sliceSize, requiredFrames) { + encoder.encode(dataWithMeta, QRSConstants.OFFICIAL_URL_PREFIX) + .take(requiredFrames) + .toList() + } + + val frameInterval = remember(fps) { 1000L / fps } + + val generator = remember(frames) { + QRSBitmapGenerator( + scope = coroutineScope, + frames = frames, + foregroundColor = Color.BLACK, + backgroundColor = Color.WHITE, + bufferSize = QRSConstants.BITMAP_BUFFER_SIZE, + ) + } + + val generationState by generator.state.collectAsState() + + LaunchedEffect(generator) { + generator.start() + } + + DisposableEffect(generator) { + onDispose { + generator.cancel() + } + } + + LaunchedEffect(frameInterval, generationState.generatedCount) { + if (generationState.generatedCount > 0) { + while (true) { + delay(frameInterval) + generator.advanceFrame() + } + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.9f) + .wrapContentHeight(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + shape = RoundedCornerShape(0.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(androidx.compose.ui.graphics.Color.White), + contentAlignment = Alignment.Center, + ) { + generationState.currentBitmap?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = stringResource(R.string.content_description_qr_code), + modifier = Modifier.fillMaxSize(), + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.qrs_fps), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "$fps Hz", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Slider( + value = fps.toFloat(), + onValueChange = { fps = it.toInt() }, + valueRange = QRSConstants.MIN_FPS.toFloat()..QRSConstants.MAX_FPS.toFloat(), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.qrs_slice_size), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "$sliceSize", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Slider( + value = sliceSize.toFloat(), + onValueChange = { sliceSize = it.toInt() }, + valueRange = QRSConstants.MIN_SLICE_SIZE.toFloat()..QRSConstants.MAX_SLICE_SIZE.toFloat(), + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/qifi-dev/qrs")) + context.startActivity(intent) + }, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.qrs_what_is_qrs)) + } + + Button( + onClick = onDismiss, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.close)) + } + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt new file mode 100644 index 0000000..9a63b7c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt @@ -0,0 +1,291 @@ +package io.nekohasekai.sfa.compose.component.qr + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult +import io.nekohasekai.sfa.compose.screen.qrscan.QRScanViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QRScanSheet( + onDismiss: () -> Unit, + onScanResult: (QRScanResult) -> Unit, + viewModel: QRScanViewModel = viewModel(), +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val uiState by viewModel.uiState.collectAsState() + + var hasPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) + } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + hasPermission = true + } else { + onDismiss() + } + } + + LaunchedEffect(Unit) { + viewModel.resetQRSState() + if (!hasPermission) { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + LaunchedEffect(uiState.result) { + uiState.result?.let { result -> + viewModel.clearResult() + onScanResult(result) + } + } + + var showMenu by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.9f) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.profile_add_scan_qr_code), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + ) + + Box { + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more_options), + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { + Text( + (if (uiState.useFrontCamera) "✓ " else " ") + + stringResource(R.string.profile_add_scan_use_front_camera) + ) + }, + onClick = { + viewModel.toggleFrontCamera(lifecycleOwner) + showMenu = false + } + ) + DropdownMenuItem( + text = { + Text( + (if (uiState.torchEnabled) "✓ " else " ") + + stringResource(R.string.profile_add_scan_enable_torch) + ) + }, + onClick = { + viewModel.toggleTorch() + showMenu = false + } + ) + if (uiState.vendorAnalyzerAvailable) { + DropdownMenuItem( + text = { + Text( + (if (uiState.useVendorAnalyzer) "✓ " else " ") + + stringResource(R.string.profile_add_scan_use_vendor_analyzer) + ) + }, + onClick = { + viewModel.toggleVendorAnalyzer() + showMenu = false + } + ) + } + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + if (hasPermission) { + CameraPreview( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + lifecycleOwner = lifecycleOwner + ) + } + + if (uiState.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + if (uiState.qrsMode && uiState.qrsProgress != null) { + val (decoded, total) = uiState.qrsProgress!! + val progress = if (total > 0) decoded.toFloat() / total.toFloat() / 1.2f else 0f + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator( + progress = { progress.coerceIn(0f, 1f) }, + modifier = Modifier.size(96.dp), + color = Color.White, + strokeWidth = 8.dp, + trackColor = Color.White.copy(alpha = 0.3f), + ) + if (total > 0) { + Text( + text = "${minOf(99, (progress * 100).toInt())}%", + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold + ), + color = Color.White + ) + } + Text( + text = "QRS", + style = MaterialTheme.typography.headlineLarge.copy( + fontWeight = FontWeight.Bold + ), + color = Color.White, + modifier = Modifier.offset(y = (-88).dp) + ) + } + } + } + } + } + } + + if (uiState.errorMessage != null) { + AlertDialog( + onDismissRequest = { viewModel.dismissError() }, + title = { Text(stringResource(android.R.string.dialog_alert_title)) }, + text = { Text(uiState.errorMessage ?: "") }, + confirmButton = { + TextButton(onClick = { viewModel.dismissError() }) { + Text(stringResource(android.R.string.ok)) + } + } + ) + } +} + +@Composable +private fun CameraPreview( + modifier: Modifier = Modifier, + viewModel: QRScanViewModel, + lifecycleOwner: LifecycleOwner, +) { + var previewView by remember { mutableStateOf(null) } + + DisposableEffect(previewView) { + previewView?.let { view -> + view.implementationMode = PreviewView.ImplementationMode.COMPATIBLE + viewModel.startCamera(lifecycleOwner, view) + } + onDispose { } + } + + AndroidView( + modifier = modifier, + factory = { ctx -> + PreviewView(ctx).apply { + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + previewView = this + + previewStreamState.observe(lifecycleOwner) { state -> + if (state == PreviewView.StreamState.STREAMING) { + viewModel.onPreviewStreamStateChanged(true) + implementationMode = PreviewView.ImplementationMode.PERFORMANCE + } + } + } + } + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt index d2b3980..0b67243 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt @@ -74,6 +74,7 @@ import io.nekohasekai.sfa.R fun NewProfileScreen( importName: String? = null, importUrl: String? = null, + qrsData: ByteArray? = null, onNavigateBack: () -> Unit, onProfileCreated: (profileId: Long) -> Unit, viewModel: NewProfileViewModel = viewModel(), @@ -81,8 +82,12 @@ fun NewProfileScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - LaunchedEffect(importName, importUrl) { - viewModel.initializeFromQRImport(importName, importUrl) + LaunchedEffect(importName, importUrl, qrsData) { + if (qrsData != null) { + viewModel.initializeFromQRSImport(importName, qrsData) + } else { + viewModel.initializeFromQRImport(importName, importUrl) + } } // File picker launcher diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileViewModel.kt index 4be44d1..151cdad 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileViewModel.kt @@ -33,6 +33,8 @@ data class NewProfileUiState( // File import val importUri: Uri? = null, val importFileName: String? = null, + // QRS import + val qrsData: ByteArray? = null, // State val isLoading: Boolean = false, val isSaving: Boolean = false, @@ -71,6 +73,17 @@ class NewProfileViewModel(application: Application) : AndroidViewModel(applicati } } + fun initializeFromQRSImport(name: String?, qrsData: ByteArray) { + _uiState.update { + it.copy( + name = name ?: "", + profileType = ProfileType.Local, + profileSource = ProfileSource.Import, + qrsData = qrsData, + ) + } + } + fun updateName(name: String) { _uiState.update { it.copy( @@ -158,7 +171,7 @@ class NewProfileViewModel(application: Application) : AndroidViewModel(applicati // Validate based on profile type when (state.profileType) { ProfileType.Local -> { - if (state.profileSource == ProfileSource.Import && state.importUri == null) { + if (state.profileSource == ProfileSource.Import && state.importUri == null && state.qrsData == null) { _uiState.update { it.copy(importError = context.getString(R.string.profile_input_required)) } hasError = true } @@ -234,22 +247,27 @@ class NewProfileViewModel(application: Application) : AndroidViewModel(applicati when (state.profileSource) { ProfileSource.CreateNew -> "{}" ProfileSource.Import -> { - state.importUri?.let { uri -> - val sourceURL = uri.toString() - when { - sourceURL.startsWith("content://") -> { - val inputStream = context.contentResolver.openInputStream(uri) as InputStream - inputStream.use { it.bufferedReader().readText() } + if (state.qrsData != null) { + val content = Libbox.decodeProfileContent(state.qrsData) + content.config + } else { + state.importUri?.let { uri -> + val sourceURL = uri.toString() + when { + sourceURL.startsWith("content://") -> { + val inputStream = context.contentResolver.openInputStream(uri) as InputStream + inputStream.use { it.bufferedReader().readText() } + } + sourceURL.startsWith("file://") -> { + File(Uri.parse(sourceURL).path!!).readText() + } + sourceURL.startsWith("http://") || sourceURL.startsWith("https://") -> { + HTTPClient().use { it.getString(sourceURL) } + } + else -> throw Exception("Unsupported source: $sourceURL") } - sourceURL.startsWith("file://") -> { - File(Uri.parse(sourceURL).path!!).readText() - } - sourceURL.startsWith("http://") || sourceURL.startsWith("https://") -> { - HTTPClient().use { it.getString(sourceURL) } - } - else -> throw Exception("Unsupported source: $sourceURL") - } - } ?: "{}" + } ?: "{}" + } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt index 46899a1..3f04bee 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt @@ -31,6 +31,18 @@ class ProfileImportHandler(private val context: Context) { data class Error(val message: String) : QRCodeParseResult() } + sealed class QRSParseResult { + data class Success(val name: String) : QRSParseResult() + + data class Error(val message: String) : QRSParseResult() + } + + sealed class UriParseResult { + data class Success(val name: String) : UriParseResult() + + data class Error(val message: String) : UriParseResult() + } + suspend fun importFromUri(uri: Uri): ImportResult = withContext(Dispatchers.IO) { try { @@ -68,6 +80,38 @@ class ProfileImportHandler(private val context: Context) { } } + suspend fun parseUri(uri: Uri): UriParseResult = + withContext(Dispatchers.IO) { + try { + val data = + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: return@withContext UriParseResult.Error(context.getString(R.string.error_empty_file)) + + val filename = getFileNameFromUri(uri) + val dataString = String(data) + + if (isJsonConfiguration(dataString)) { + return@withContext UriParseResult.Success(name = filename) + } + + val content = + try { + Libbox.decodeProfileContent(data) + } catch (e: Exception) { + if (dataString.trimStart().startsWith("{") || dataString.trimStart().startsWith("[")) { + return@withContext UriParseResult.Success(name = filename) + } + return@withContext UriParseResult.Error( + context.getString(R.string.error_decode_profile, e.message), + ) + } + + UriParseResult.Success(name = content.name) + } catch (e: Exception) { + UriParseResult.Error(e.message ?: "Unknown error") + } + } + suspend fun parseQRCode(data: String): QRCodeParseResult = withContext(Dispatchers.IO) { try { @@ -150,6 +194,38 @@ class ProfileImportHandler(private val context: Context) { } } + suspend fun parseQRSData(data: ByteArray): QRSParseResult = + withContext(Dispatchers.IO) { + try { + val content = try { + Libbox.decodeProfileContent(data) + } catch (e: Exception) { + return@withContext QRSParseResult.Error( + context.getString(R.string.error_decode_profile, e.message), + ) + } + QRSParseResult.Success(name = content.name) + } catch (e: Exception) { + QRSParseResult.Error(e.message ?: "Unknown error") + } + } + + suspend fun importFromQRSData(data: ByteArray): ImportResult = + withContext(Dispatchers.IO) { + try { + val content = try { + Libbox.decodeProfileContent(data) + } catch (e: Exception) { + return@withContext ImportResult.Error( + context.getString(R.string.error_decode_profile, e.message), + ) + } + importProfile(content) + } catch (e: Exception) { + ImportResult.Error(e.message ?: "Unknown error") + } + } + private suspend fun importProfile(content: ProfileContent): ImportResult { val typedProfile = TypedProfile() val profile = Profile(name = content.name, typed = typedProfile) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/QRCodeDialog.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/QRCodeDialog.kt deleted file mode 100644 index df19ad4..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/QRCodeDialog.kt +++ /dev/null @@ -1,134 +0,0 @@ -package io.nekohasekai.sfa.compose.screen.configuration - -import android.graphics.Bitmap -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties - -@Composable -fun QRCodeDialog( - bitmap: Bitmap, - onDismiss: () -> Unit, - onShare: () -> Unit, - onSave: () -> Unit, -) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Card( - modifier = - Modifier - .fillMaxWidth(0.9f) - .wrapContentHeight(), - shape = RoundedCornerShape(16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(io.nekohasekai.sfa.R.string.share_profile), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // QR Code Image - Surface( - modifier = Modifier.size(256.dp), - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - ) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - .padding(8.dp), - contentAlignment = Alignment.Center, - ) { - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = stringResource(io.nekohasekai.sfa.R.string.content_description_qr_code), - modifier = Modifier.fillMaxSize(), - ) - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedButton( - onClick = onSave, - modifier = Modifier.weight(1f), - ) { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(io.nekohasekai.sfa.R.string.save)) - } - - Button( - onClick = onShare, - modifier = Modifier.weight(1f), - ) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(io.nekohasekai.sfa.R.string.profile_share)) - } - } - } - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt index f36fe71..19cd64e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt @@ -1,6 +1,5 @@ package io.nekohasekai.sfa.compose.screen.dashboard -import android.graphics.Bitmap import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import io.nekohasekai.sfa.constant.Status @@ -34,8 +33,6 @@ fun DashboardCardRenderer( onHideAddProfileSheet: () -> Unit = {}, onShowProfilePickerSheet: () -> Unit = {}, onHideProfilePickerSheet: () -> Unit = {}, - shareQRCodeImage: (Bitmap, String) -> Unit = { _, _ -> }, - saveQRCodeToGallery: (Bitmap, String) -> Unit = { _, _ -> }, commandClient: CommandClient? = null, modifier: Modifier = Modifier, ) { @@ -127,8 +124,6 @@ fun DashboardCardRenderer( onImportFromFile = { /* Handled in ProfilesCard */ }, onScanQrCode = { /* Handled in ProfilesCard */ }, onCreateManually = { /* Handled in ProfilesCard */ }, - shareQRCodeImage = shareQRCodeImage, - saveQRCodeToGallery = saveQRCodeToGallery, ) } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt index 7398a4e..9b50c4f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt @@ -27,8 +27,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.base.UiEvent -import io.nekohasekai.sfa.compose.util.saveQRCodeToGallery -import io.nekohasekai.sfa.compose.util.shareQRCodeImage import io.nekohasekai.sfa.constant.Status import kotlinx.coroutines.launch @@ -176,16 +174,6 @@ fun DashboardScreen( onHideAddProfileSheet = viewModel::hideAddProfileSheet, onShowProfilePickerSheet = viewModel::showProfilePickerSheet, onHideProfilePickerSheet = viewModel::hideProfilePickerSheet, - shareQRCodeImage = { bitmap, name -> - scope.launch { - shareQRCodeImage(context, bitmap, name) - } - }, - saveQRCodeToGallery = { bitmap, name -> - scope.launch { - saveQRCodeToGallery(context, bitmap, name) - } - }, commandClient = viewModel.commandClient, modifier = Modifier @@ -225,16 +213,6 @@ fun DashboardScreen( onHideAddProfileSheet = viewModel::hideAddProfileSheet, onShowProfilePickerSheet = viewModel::showProfilePickerSheet, onHideProfilePickerSheet = viewModel::hideProfilePickerSheet, - shareQRCodeImage = { bitmap, name -> - scope.launch { - shareQRCodeImage(context, bitmap, name) - } - }, - saveQRCodeToGallery = { bitmap, name -> - scope.launch { - saveQRCodeToGallery(context, bitmap, name) - } - }, commandClient = viewModel.commandClient, ) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt index cc8b6fa..8797021 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt @@ -1,6 +1,5 @@ package io.nekohasekai.sfa.compose.screen.dashboard -import android.graphics.Bitmap import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -45,6 +44,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.toArgb import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -60,7 +60,7 @@ import androidx.compose.ui.unit.dp import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.ProfileContent import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.screen.configuration.QRCodeDialog +import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog import io.nekohasekai.sfa.compose.util.ProfileIcons import io.nekohasekai.sfa.compose.util.QRCodeGenerator import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter @@ -83,8 +83,6 @@ fun ProfilePickerSheet( onProfileDelete: (Profile) -> Unit, onProfileMove: (Int, Int) -> Unit, onDismiss: () -> Unit, - shareQRCodeImage: suspend (Bitmap, String) -> Unit, - saveQRCodeToGallery: suspend (Bitmap, String) -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val context = LocalContext.current @@ -173,9 +171,8 @@ fun ProfilePickerSheet( profile.typed.remoteURL, ) } - val qrBitmap = remember(link) { - QRCodeGenerator.generate(link) - } + val surfaceColor = MaterialTheme.colorScheme.surface.toArgb() + val qrBitmap = QRCodeGenerator.rememberPrimaryBitmap(link, backgroundColor = surfaceColor) QRCodeDialog( bitmap = qrBitmap, @@ -183,20 +180,6 @@ fun ProfilePickerSheet( showQRCodeDialog = false qrCodeProfile = null }, - onShare = { - coroutineScope.launch { - shareQRCodeImage(qrBitmap, profile.name) - } - showQRCodeDialog = false - qrCodeProfile = null - }, - onSave = { - coroutineScope.launch { - saveQRCodeToGallery(qrBitmap, profile.name) - showQRCodeDialog = false - qrCodeProfile = null - } - }, ) } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt index c84f617..780fe70 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt @@ -1,7 +1,7 @@ package io.nekohasekai.sfa.compose.screen.dashboard import android.content.Intent -import android.graphics.Bitmap +import android.net.Uri import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.IosShare import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.DataObject import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.outlined.CreateNewFolder import androidx.compose.material.icons.outlined.Description @@ -37,13 +38,16 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.toArgb import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -61,18 +65,22 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.ProfileContent import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.NewProfileActivity +import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog +import io.nekohasekai.sfa.compose.component.qr.QRScanSheet +import io.nekohasekai.sfa.compose.component.qr.QRSDialog +import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler -import io.nekohasekai.sfa.compose.screen.configuration.QRCodeDialog import io.nekohasekai.sfa.compose.util.QRCodeGenerator import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter import io.nekohasekai.sfa.database.Profile import io.nekohasekai.sfa.database.TypedProfile import io.nekohasekai.sfa.ktx.errorDialogBuilder import io.nekohasekai.sfa.ktx.shareProfile -import io.nekohasekai.sfa.ui.profile.QRScanActivity +import io.nekohasekai.sfa.ktx.shareProfileAsJson import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.File @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -98,8 +106,6 @@ fun ProfilesCard( onImportFromFile: () -> Unit, onScanQrCode: () -> Unit, onCreateManually: () -> Unit, - shareQRCodeImage: suspend (Bitmap, String) -> Unit, - saveQRCodeToGallery: suspend (Bitmap, String) -> Unit, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -109,6 +115,17 @@ fun ProfilesCard( var showQRCodeDialog by remember { mutableStateOf(false) } var qrCodeProfile by remember { mutableStateOf(null) } + var showQRSDialog by remember { mutableStateOf(false) } + var qrsProfile by remember { mutableStateOf(null) } + var qrsProfileData by remember { mutableStateOf(null) } + + var showImportConfirmDialog by remember { mutableStateOf(false) } + var pendingImportName by remember { mutableStateOf(null) } + var pendingQrsData by remember { mutableStateOf(null) } + var pendingImportUri by remember { mutableStateOf(null) } + + var showQRScanSheet by remember { mutableStateOf(false) } + val newProfileLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(), @@ -137,62 +154,17 @@ fun ProfilesCard( ) { uri -> uri?.let { coroutineScope.launch { - when (val result = importHandler.importFromUri(uri)) { - is ProfileImportHandler.ImportResult.Success -> { + when (val parseResult = importHandler.parseUri(uri)) { + is ProfileImportHandler.UriParseResult.Success -> { withContext(Dispatchers.Main) { - onProfileEdit(result.profile) + pendingImportName = parseResult.name + pendingImportUri = uri + showImportConfirmDialog = true } } - is ProfileImportHandler.ImportResult.Error -> { + is ProfileImportHandler.UriParseResult.Error -> { withContext(Dispatchers.Main) { - context.errorDialogBuilder(Exception(result.message)).show() - } - } - } - } - } - } - - val scanQrCodeLauncher = - rememberLauncherForActivityResult( - QRScanActivity.Contract(), - ) { result -> - result?.let { intent -> - val data = intent.dataString - if (data != null) { - coroutineScope.launch { - when (val parseResult = importHandler.parseQRCode(data)) { - is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> { - withContext(Dispatchers.Main) { - val newProfileIntent = - Intent(context, NewProfileActivity::class.java).apply { - putExtra(NewProfileActivity.EXTRA_IMPORT_NAME, parseResult.name) - putExtra(NewProfileActivity.EXTRA_IMPORT_URL, parseResult.url) - } - newProfileLauncher.launch(newProfileIntent) - } - } - - is ProfileImportHandler.QRCodeParseResult.LocalProfile -> { - when (val importResult = importHandler.importFromQRCode(data)) { - is ProfileImportHandler.ImportResult.Success -> { - withContext(Dispatchers.Main) { - onProfileEdit(importResult.profile) - } - } - - is ProfileImportHandler.ImportResult.Error -> { - withContext(Dispatchers.Main) { - context.errorDialogBuilder(Exception(importResult.message)).show() - } - } - } - } - - is ProfileImportHandler.QRCodeParseResult.Error -> { - withContext(Dispatchers.Main) { - context.errorDialogBuilder(Exception(parseResult.message)).show() - } + context.errorDialogBuilder(Exception(parseResult.message)).show() } } } @@ -233,6 +205,39 @@ fun ProfilesCard( } } + val saveJsonFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/json"), + ) { uri -> + if (uri != null) { + val selectedProfile = profiles.find { it.id == selectedProfileId } + if (selectedProfile != null) { + coroutineScope.launch(Dispatchers.IO) { + try { + val jsonContent = File(selectedProfile.typed.path).readText() + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(jsonContent.toByteArray()) + } + withContext(Dispatchers.Main) { + Toast.makeText( + context, + context.getString(R.string.success_profile_saved), + Toast.LENGTH_SHORT, + ).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "${context.getString(R.string.failed_save_profile)}: ${e.message}", + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + } + } + LaunchedEffect(onImportFromFile, onScanQrCode) { } @@ -334,12 +339,48 @@ fun ProfilesCard( saveFileLauncher.launch("${it.name}.bpf") } }, + onSaveJson = { + selectedProfile?.let { + saveJsonFileLauncher.launch("${it.name}.json") + } + }, + onShareJson = { + selectedProfile?.let { + coroutineScope.launch(Dispatchers.IO) { + try { + context.shareProfileAsJson(it) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(e).show() + } + } + } + } + }, onShareURL = { selectedProfile?.let { qrCodeProfile = it showQRCodeDialog = true } }, + onShareQRS = { + selectedProfile?.let { profile -> + coroutineScope.launch(Dispatchers.IO) { + try { + val data = createProfileContent(profile) + withContext(Dispatchers.Main) { + qrsProfile = profile + qrsProfileData = data + showQRSDialog = true + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(e).show() + } + } + } + } + }, ) } } @@ -354,8 +395,6 @@ fun ProfilesCard( onProfileDelete = onProfileDelete, onProfileMove = onProfileMove, onDismiss = onHideProfilePickerSheet, - shareQRCodeImage = shareQRCodeImage, - saveQRCodeToGallery = saveQRCodeToGallery, ) } @@ -399,7 +438,7 @@ fun ProfilesCard( ListItem( modifier = Modifier.clickable { onHideAddProfileSheet() - scanQrCodeLauncher.launch(null) + showQRScanSheet = true }, leadingContent = { Icon( @@ -448,9 +487,8 @@ fun ProfilesCard( profile.typed.remoteURL, ) } - val qrBitmap = remember(link) { - QRCodeGenerator.generate(link) - } + val surfaceColor = MaterialTheme.colorScheme.surface.toArgb() + val qrBitmap = QRCodeGenerator.rememberPrimaryBitmap(link, backgroundColor = surfaceColor) QRCodeDialog( bitmap = qrBitmap, @@ -458,18 +496,148 @@ fun ProfilesCard( showQRCodeDialog = false qrCodeProfile = null }, - onShare = { - coroutineScope.launch { - shareQRCodeImage(qrBitmap, profile.name) - } - showQRCodeDialog = false - qrCodeProfile = null + ) + } + + if (showQRSDialog && qrsProfile != null && qrsProfileData != null) { + QRSDialog( + profileData = qrsProfileData!!, + profileName = qrsProfile!!.name, + onDismiss = { + showQRSDialog = false + qrsProfile = null + qrsProfileData = null }, - onSave = { - coroutineScope.launch { - saveQRCodeToGallery(qrBitmap, profile.name) - showQRCodeDialog = false - qrCodeProfile = null + ) + } + + if (showImportConfirmDialog && pendingImportName != null) { + AlertDialog( + onDismissRequest = { + showImportConfirmDialog = false + pendingImportName = null + pendingQrsData = null + pendingImportUri = null + }, + title = { Text(stringResource(R.string.import_profile_confirm_title)) }, + text = { Text(stringResource(R.string.import_profile_confirm_message, pendingImportName!!)) }, + confirmButton = { + TextButton( + onClick = { + showImportConfirmDialog = false + val qrsData = pendingQrsData + val importUri = pendingImportUri + pendingImportName = null + pendingQrsData = null + pendingImportUri = null + coroutineScope.launch { + if (qrsData != null) { + when (val result = importHandler.importFromQRSData(qrsData)) { + is ProfileImportHandler.ImportResult.Success -> { + withContext(Dispatchers.Main) { + onProfileEdit(result.profile) + } + } + is ProfileImportHandler.ImportResult.Error -> { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(Exception(result.message)).show() + } + } + } + } else if (importUri != null) { + when (val result = importHandler.importFromUri(importUri)) { + is ProfileImportHandler.ImportResult.Success -> { + withContext(Dispatchers.Main) { + onProfileEdit(result.profile) + } + } + is ProfileImportHandler.ImportResult.Error -> { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(Exception(result.message)).show() + } + } + } + } + } + }, + ) { + Text(stringResource(R.string.import_action)) + } + }, + dismissButton = { + TextButton( + onClick = { + showImportConfirmDialog = false + pendingImportName = null + pendingQrsData = null + pendingImportUri = null + }, + ) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + + if (showQRScanSheet) { + QRScanSheet( + onDismiss = { showQRScanSheet = false }, + onScanResult = { result -> + showQRScanSheet = false + when (result) { + is QRScanResult.QRSData -> { + coroutineScope.launch { + when (val parseResult = importHandler.parseQRSData(result.data)) { + is ProfileImportHandler.QRSParseResult.Success -> { + withContext(Dispatchers.Main) { + pendingImportName = parseResult.name + pendingQrsData = result.data + showImportConfirmDialog = true + } + } + is ProfileImportHandler.QRSParseResult.Error -> { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(Exception(parseResult.message)).show() + } + } + } + } + } + is QRScanResult.RemoteProfile -> { + coroutineScope.launch { + when (val parseResult = importHandler.parseQRCode(result.uri.toString())) { + is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> { + withContext(Dispatchers.Main) { + val newProfileIntent = + Intent(context, NewProfileActivity::class.java).apply { + putExtra(NewProfileActivity.EXTRA_IMPORT_NAME, parseResult.name) + putExtra(NewProfileActivity.EXTRA_IMPORT_URL, parseResult.url) + } + newProfileLauncher.launch(newProfileIntent) + } + } + is ProfileImportHandler.QRCodeParseResult.LocalProfile -> { + when (val importResult = importHandler.importFromQRCode(result.uri.toString())) { + is ProfileImportHandler.ImportResult.Success -> { + withContext(Dispatchers.Main) { + onProfileEdit(importResult.profile) + } + } + is ProfileImportHandler.ImportResult.Error -> { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(Exception(importResult.message)).show() + } + } + } + } + is ProfileImportHandler.QRCodeParseResult.Error -> { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(Exception(parseResult.message)).show() + } + } + } + } + } } }, ) @@ -560,7 +728,10 @@ private fun ProfileActionRow( onUpdate: () -> Unit, onShareFile: () -> Unit, onSaveFile: () -> Unit, + onSaveJson: () -> Unit, + onShareJson: () -> Unit, onShareURL: () -> Unit, + onShareQRS: () -> Unit, ) { if (profile == null) return @@ -591,7 +762,10 @@ private fun ProfileActionRow( profile = profile, onShareFile = onShareFile, onSaveFile = onSaveFile, + onSaveJson = onSaveJson, + onShareJson = onShareJson, onShareURL = onShareURL, + onShareQRS = onShareQRS, ) } } @@ -643,7 +817,10 @@ private fun ShareButton( profile: Profile, onShareFile: () -> Unit, onSaveFile: () -> Unit, + onSaveJson: () -> Unit, + onShareJson: () -> Unit, onShareURL: () -> Unit, + onShareQRS: () -> Unit, ) { var expanded by remember { mutableStateOf(false) } @@ -686,6 +863,34 @@ private fun ShareButton( ) }, ) + DropdownMenuItem( + text = { Text(stringResource(R.string.save_content_json)) }, + onClick = { + expanded = false + onSaveJson() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.DataObject, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.share_content_json)) }, + onClick = { + expanded = false + onShareJson() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.DataObject, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) if (profile.typed.type == TypedProfile.Type.Remote) { DropdownMenuItem( text = { Text(stringResource(R.string.profile_share_url)) }, @@ -702,6 +907,20 @@ private fun ShareButton( }, ) } + DropdownMenuItem( + text = { Text(stringResource(R.string.share_as_qrs)) }, + onClick = { + expanded = false + onShareQRS() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.QrCode2, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt new file mode 100644 index 0000000..07d4aec --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt @@ -0,0 +1,389 @@ +package io.nekohasekai.sfa.compose.screen.qrscan + +import android.app.Application +import android.net.Uri +import android.util.Base64 +import android.util.Log +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LifecycleOwner +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.qrs.QRSDecoder +import io.nekohasekai.sfa.qrs.readIntLE +import io.nekohasekai.sfa.ui.profile.ZxingQRCodeAnalyzer +import io.nekohasekai.sfa.vendor.Vendor +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +sealed class QRScanResult { + data class RemoteProfile(val uri: Uri) : QRScanResult() + data class QRSData(val data: ByteArray) : QRScanResult() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as QRSData + return data.contentEquals(other.data) + } + + override fun hashCode(): Int = data.contentHashCode() + } +} + +data class QRScanUiState( + val isLoading: Boolean = true, + val useFrontCamera: Boolean = false, + val torchEnabled: Boolean = false, + val useVendorAnalyzer: Boolean = true, + val vendorAnalyzerAvailable: Boolean = false, + val qrsMode: Boolean = false, + val qrsProgress: Pair? = null, + val errorMessage: String? = null, + val result: QRScanResult? = null, + val zoomRatio: Float = 1f, + val maxZoomRatio: Float = 1f, +) + +class QRScanViewModel(application: Application) : AndroidViewModel(application) { + companion object { + private const val TAG = "QRScanViewModel" + } + + private val _uiState = MutableStateFlow(QRScanUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val analysisExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private var cameraProvider: ProcessCameraProvider? = null + private var camera: Camera? = null + private var imageAnalysis: ImageAnalysis? = null + private var imageAnalyzer: ImageAnalysis.Analyzer? = null + private var cameraPreview: Preview? = null + + private var qrsDecoder: QRSDecoder? = null + private val showingError = AtomicBoolean(false) + private val qrsLock = Any() + + private val vendorAnalyzer: ImageAnalysis.Analyzer? = Vendor.createQRCodeAnalyzer( + onSuccess = { rawValue -> handleScanSuccess(rawValue) }, + onFailure = { exception -> handleScanFailure(exception) } + ) + + init { + _uiState.update { + it.copy( + vendorAnalyzerAvailable = vendorAnalyzer != null, + useVendorAnalyzer = vendorAnalyzer != null + ) + } + } + + private val onSuccess: (String) -> Unit = { rawValue: String -> + handleScanSuccess(rawValue) + } + + private val onFailure: (Exception) -> Unit = { exception -> + handleScanFailure(exception) + } + + private fun handleScanSuccess(rawValue: String) { + Log.d(TAG, "Scanned: ${rawValue.take(100)}...") + val qrsPayload = extractQRSPayload(rawValue) + Log.d(TAG, "extractQRSPayload result: ${qrsPayload?.size ?: "null"}") + if (qrsPayload != null) { + handleQRSFrame(qrsPayload) + } else { + if (_uiState.value.qrsMode) { + resetQRSState() + } + imageAnalysis?.clearAnalyzer() + processQRCode(rawValue) + } + } + + private fun handleScanFailure(exception: Exception) { + if (_uiState.value.qrsMode) { + return + } + imageAnalysis?.clearAnalyzer() + if (showingError.compareAndSet(false, true)) { + resetAnalyzer() + _uiState.update { it.copy(errorMessage = exception.message) } + } + } + + private fun resetAnalyzer() { + if (_uiState.value.useVendorAnalyzer && vendorAnalyzer != null) { + _uiState.update { it.copy(useVendorAnalyzer = false) } + imageAnalysis?.clearAnalyzer() + imageAnalyzer = ZxingQRCodeAnalyzer(onSuccess, onFailure) + imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) + } + } + + fun startCamera(lifecycleOwner: LifecycleOwner, previewView: PreviewView) { + val context = getApplication() + val cameraProviderFuture = try { + ProcessCameraProvider.getInstance(context) + } catch (e: Exception) { + _uiState.update { it.copy(errorMessage = e.message, isLoading = false) } + return + } + + cameraProviderFuture.addListener({ + try { + cameraProvider = cameraProviderFuture.get() + + cameraPreview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + imageAnalyzer = if (_uiState.value.useVendorAnalyzer && vendorAnalyzer != null) { + vendorAnalyzer + } else { + ZxingQRCodeAnalyzer(onSuccess, onFailure) + } + imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) + + bindCamera(lifecycleOwner) + } catch (e: Exception) { + _uiState.update { it.copy(errorMessage = e.message, isLoading = false) } + } + }, ContextCompat.getMainExecutor(context)) + } + + private fun bindCamera(lifecycleOwner: LifecycleOwner) { + val provider = cameraProvider ?: return + val preview = cameraPreview ?: return + val analysis = imageAnalysis ?: return + + provider.unbindAll() + + val cameraSelector = if (_uiState.value.useFrontCamera) { + CameraSelector.DEFAULT_FRONT_CAMERA + } else { + CameraSelector.DEFAULT_BACK_CAMERA + } + + try { + camera = provider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + analysis + ) + val maxZoom = camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 1f + _uiState.update { it.copy(maxZoomRatio = maxZoom, zoomRatio = 1f) } + } catch (e: Exception) { + _uiState.update { it.copy(errorMessage = e.message, isLoading = false) } + } + } + + fun onPreviewStreamStateChanged(isStreaming: Boolean) { + if (isStreaming) { + _uiState.update { it.copy(isLoading = false) } + } + } + + fun toggleFrontCamera(lifecycleOwner: LifecycleOwner) { + _uiState.update { it.copy(useFrontCamera = !it.useFrontCamera) } + bindCamera(lifecycleOwner) + } + + fun toggleTorch() { + val newTorchState = !_uiState.value.torchEnabled + camera?.cameraControl?.enableTorch(newTorchState) + _uiState.update { it.copy(torchEnabled = newTorchState) } + } + + fun setZoomRatio(ratio: Float) { + val clampedRatio = ratio.coerceIn(1f, _uiState.value.maxZoomRatio) + camera?.cameraControl?.setZoomRatio(clampedRatio) + _uiState.update { it.copy(zoomRatio = clampedRatio) } + } + + fun toggleVendorAnalyzer() { + if (vendorAnalyzer == null) return + + val newState = !_uiState.value.useVendorAnalyzer + _uiState.update { it.copy(useVendorAnalyzer = newState) } + + imageAnalysis?.clearAnalyzer() + imageAnalyzer = if (newState) { + vendorAnalyzer + } else { + ZxingQRCodeAnalyzer(onSuccess, onFailure) + } + imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) + } + + fun dismissError() { + showingError.set(false) + _uiState.update { it.copy(errorMessage = null) } + imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) + } + + fun clearResult() { + resetQRSState() + _uiState.update { it.copy(result = null) } + } + + private fun extractQRSPayload(content: String): ByteArray? { + val base64Data = when { + content.startsWith("http") && content.contains("#") -> { + content.substring(content.indexOf('#') + 1) + } + else -> content + } + + val decoded = try { + Base64.decode(base64Data, Base64.DEFAULT) + } catch (e: Exception) { + Log.d(TAG, "Base64 decode failed: ${e.message}") + return null + } + + Log.d(TAG, "Decoded size: ${decoded.size}") + if (decoded.size < 20) { + Log.d(TAG, "Too small: ${decoded.size} < 20") + return null + } + + val degree = decoded.readIntLE(0) + Log.d(TAG, "degree: $degree") + if (degree <= 0 || degree > 1000) { + Log.d(TAG, "Invalid degree: $degree") + return null + } + + val headerSize = 4 + 4 * degree + 12 + if (decoded.size < headerSize) { + Log.d(TAG, "Too small for header: ${decoded.size} < $headerSize") + return null + } + + val k = decoded.readIntLE(4 + 4 * degree) + Log.d(TAG, "k: $k") + if (k <= 0 || k > 100000) { + Log.d(TAG, "Invalid k: $k") + return null + } + + Log.d(TAG, "Valid QRS block detected!") + return decoded + } + + private fun handleQRSFrame(payload: ByteArray) = synchronized(qrsLock) { + Log.d(TAG, "Processing QRS frame") + if (qrsDecoder == null) { + qrsDecoder = QRSDecoder() + _uiState.update { it.copy(qrsMode = true) } + (imageAnalyzer as? ZxingQRCodeAnalyzer)?.qrsMode = true + Log.d(TAG, "Created new QRSDecoder, entered QRS mode") + } + + val progress = qrsDecoder!!.processFrame(payload) + Log.d(TAG, "processFrame result: $progress") + if (progress == null) { + Log.d(TAG, "processFrame returned null!") + return@synchronized + } + + _uiState.update { + it.copy(qrsProgress = Pair(progress.decodedBlocks, progress.totalBlocks)) + } + + if (progress.isComplete) { + if (progress.error != null) { + Log.e(TAG, "QRS complete with error: ${progress.error}, retrying...") + resetQRSState() + } else if (progress.data != null) { + imageAnalysis?.clearAnalyzer() + Log.d(TAG, "QRS complete! Data size: ${progress.data.size}") + importQRSProfile(progress.data) + } + } + } + + fun resetQRSState() = synchronized(qrsLock) { + qrsDecoder?.reset() + qrsDecoder = null + (imageAnalyzer as? ZxingQRCodeAnalyzer)?.qrsMode = false + _uiState.update { it.copy(qrsMode = false, qrsProgress = null) } + } + + private fun parseQRSFileFormat(data: ByteArray): ByteArray { + var offset = 0 + + val metaLength = ((data[offset].toInt() and 0xFF) shl 24) or + ((data[offset + 1].toInt() and 0xFF) shl 16) or + ((data[offset + 2].toInt() and 0xFF) shl 8) or + (data[offset + 3].toInt() and 0xFF) + offset += 4 + + offset += metaLength + + val dataLength = ((data[offset].toInt() and 0xFF) shl 24) or + ((data[offset + 1].toInt() and 0xFF) shl 16) or + ((data[offset + 2].toInt() and 0xFF) shl 8) or + (data[offset + 3].toInt() and 0xFF) + offset += 4 + + return data.copyOfRange(offset, offset + dataLength) + } + + private fun importQRSProfile(data: ByteArray) { + try { + val actualData = try { + parseQRSFileFormat(data) + } catch (e: Exception) { + Log.d(TAG, "Not official QRS format, using raw data") + data + } + Log.d(TAG, "Decoding profile content, size: ${actualData.size}") + Libbox.decodeProfileContent(actualData) + _uiState.update { it.copy(result = QRScanResult.QRSData(actualData)) } + } catch (e: Exception) { + _uiState.update { it.copy(errorMessage = e.message) } + resetQRSState() + imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) + } + } + + private fun processQRCode(value: String): Boolean { + try { + val uri = Uri.parse(value) + if (uri.scheme != "sing-box" || uri.host != "import-remote-profile") { + _uiState.update { it.copy(errorMessage = "Not a valid sing-box remote profile URI") } + imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) + return false + } + Libbox.parseRemoteProfileImportLink(uri.toString()) + _uiState.update { it.copy(result = QRScanResult.RemoteProfile(uri)) } + return true + } catch (e: Exception) { + if (showingError.compareAndSet(false, true)) { + _uiState.update { it.copy(errorMessage = e.message) } + } + } + return false + } + + override fun onCleared() { + super.onCleared() + analysisExecutor.shutdown() + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeGenerator.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeGenerator.kt index dea381f..c324186 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeGenerator.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeGenerator.kt @@ -2,10 +2,91 @@ package io.nekohasekai.sfa.compose.util import android.graphics.Bitmap import android.graphics.Color +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.toArgb import com.google.zxing.BarcodeFormat import com.google.zxing.qrcode.QRCodeWriter object QRCodeGenerator { + + private fun luminance(color: Int): Float { + val r = Color.red(color) / 255f + val g = Color.green(color) / 255f + val b = Color.blue(color) / 255f + return 0.299f * r + 0.587f * g + 0.114f * b + } + + private fun adjustBrightness(color: Int, factor: Float): Int { + val a = Color.alpha(color) + val r = (Color.red(color) * factor).toInt().coerceIn(0, 255) + val g = (Color.green(color) * factor).toInt().coerceIn(0, 255) + val b = (Color.blue(color) * factor).toInt().coerceIn(0, 255) + return Color.argb(a, r, g, b) + } + + fun ensureContrast(foreground: Int, background: Int, minRatio: Float = 4.5f): Int { + val bgLum = luminance(background) + var fg = foreground + var fgLum = luminance(fg) + + var ratio = if (fgLum > bgLum) { + (fgLum + 0.05f) / (bgLum + 0.05f) + } else { + (bgLum + 0.05f) / (fgLum + 0.05f) + } + + if (ratio >= minRatio) return fg + + val shouldDarken = bgLum > 0.5f + repeat(10) { + fg = if (shouldDarken) { + adjustBrightness(fg, 0.8f) + } else { + adjustBrightness(fg, 1.25f) + } + fgLum = luminance(fg) + ratio = if (fgLum > bgLum) { + (fgLum + 0.05f) / (bgLum + 0.05f) + } else { + (bgLum + 0.05f) / (fgLum + 0.05f) + } + if (ratio >= minRatio) return fg + } + return fg + } + + @Composable + fun rememberBitmap(content: String, size: Int = 512): Bitmap { + val isDarkTheme = isSystemInDarkTheme() + return remember(content, isDarkTheme) { + generate( + content = content, + size = size, + foregroundColor = if (isDarkTheme) Color.WHITE else Color.BLACK, + backgroundColor = Color.TRANSPARENT, + ) + } + } + + @Composable + fun rememberPrimaryBitmap(content: String, size: Int = 512, backgroundColor: Int): Bitmap { + val primaryColor = MaterialTheme.colorScheme.primary.toArgb() + val safeColor = remember(primaryColor, backgroundColor) { + ensureContrast(primaryColor, backgroundColor) + } + return remember(content, safeColor) { + generate( + content = content, + size = size, + foregroundColor = safeColor, + backgroundColor = Color.TRANSPARENT, + ) + } + } + fun generate( content: String, size: Int = 512, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeUtils.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeUtils.kt deleted file mode 100644 index e3fff20..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeUtils.kt +++ /dev/null @@ -1,146 +0,0 @@ -package io.nekohasekai.sfa.compose.util - -import android.content.ContentValues -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.os.Build -import android.os.Environment -import android.provider.MediaStore -import android.widget.Toast -import androidx.core.content.FileProvider -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileOutputStream - -suspend fun saveQRCodeToGallery( - context: Context, - bitmap: Bitmap, - profileName: String, -) = withContext(Dispatchers.IO) { - try { - val filename = "SingBox_QR_${profileName}_${System.currentTimeMillis()}.png" - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // For Android 10 and above, use MediaStore - val contentValues = - ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, filename) - put(MediaStore.MediaColumns.MIME_TYPE, "image/png") - put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/SingBox") - put(MediaStore.Images.Media.IS_PENDING, 1) - } - - val resolver = context.contentResolver - val imageUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) - - imageUri?.let { uri -> - resolver.openOutputStream(uri)?.use { outputStream -> - bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) - } - - contentValues.clear() - contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) - resolver.update(uri, contentValues, null, null) - - withContext(Dispatchers.Main) { - Toast.makeText( - context, - context.getString(io.nekohasekai.sfa.R.string.qr_code_saved_to_gallery), - Toast.LENGTH_SHORT, - ).show() - } - } - } else { - // For older Android versions - val imagesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) - val singboxDir = File(imagesDir, "SingBox") - if (!singboxDir.exists()) { - singboxDir.mkdirs() - } - - val imageFile = File(singboxDir, filename) - FileOutputStream(imageFile).use { outputStream -> - bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) - } - - // Notify gallery about the new image - MediaStore.Images.Media.insertImage( - context.contentResolver, - imageFile.absolutePath, - filename, - "SingBox QR Code", - ) - - withContext(Dispatchers.Main) { - Toast.makeText(context, "QR code saved to gallery", Toast.LENGTH_SHORT).show() - } - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText( - context, - context.getString(io.nekohasekai.sfa.R.string.failed_save_qr_code, e.message), - Toast.LENGTH_LONG, - ).show() - e.printStackTrace() - } - } -} - -suspend fun shareQRCodeImage( - context: Context, - bitmap: Bitmap, - profileName: String, -) = withContext(Dispatchers.IO) { - try { - // Save bitmap to cache directory - val cachePath = File(context.cacheDir, "images") - cachePath.mkdirs() - val file = File(cachePath, "qr_${profileName}_${System.currentTimeMillis()}.png") - - FileOutputStream(file).use { stream -> - bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) - } - - // Get URI for the file - val contentUri = - FileProvider.getUriForFile( - context, - "${context.packageName}.cache", - file, - ) - - // Create share intent - val shareIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "image/png" - putExtra(Intent.EXTRA_STREAM, contentUri) - putExtra( - Intent.EXTRA_TEXT, - context.getString(io.nekohasekai.sfa.R.string.profile_qr_code_text, profileName), - ) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - - withContext(Dispatchers.Main) { - context.startActivity( - Intent.createChooser( - shareIntent, - context.getString(io.nekohasekai.sfa.R.string.intent_share_qr_code), - ), - ) - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText( - context, - context.getString(io.nekohasekai.sfa.R.string.failed_share_qr_code, e.message), - Toast.LENGTH_LONG, - ).show() - e.printStackTrace() - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt index 8fd3902..85ab5f2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt @@ -44,3 +44,20 @@ suspend fun Context.shareProfile(profile: Profile) { ) } } + +suspend fun Context.shareProfileAsJson(profile: Profile) { + val configDirectory = File(cacheDir, "share").also { it.mkdirs() } + val jsonFile = File(configDirectory, "${profile.name}.json") + jsonFile.writeText(File(profile.typed.path).readText()) + val uri = FileProvider.getUriForFile(this, "$packageName.cache", jsonFile) + withContext(Dispatchers.Main) { + startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).setType("application/json") + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra(Intent.EXTRA_STREAM, uri), + getString(AppCompatR.string.abc_shareactionprovider_share_with), + ), + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/qrs/ByteArrayExtensions.kt b/app/src/main/java/io/nekohasekai/sfa/qrs/ByteArrayExtensions.kt new file mode 100644 index 0000000..83477c9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/qrs/ByteArrayExtensions.kt @@ -0,0 +1,22 @@ +package io.nekohasekai.sfa.qrs + +fun ByteArray.readIntLE(offset: Int): Int { + return (this[offset].toInt() and 0xFF) or + ((this[offset + 1].toInt() and 0xFF) shl 8) or + ((this[offset + 2].toInt() and 0xFF) shl 16) or + ((this[offset + 3].toInt() and 0xFF) shl 24) +} + +fun ByteArray.writeIntLE(offset: Int, value: Int) { + this[offset] = value.toByte() + this[offset + 1] = (value shr 8).toByte() + this[offset + 2] = (value shr 16).toByte() + this[offset + 3] = (value shr 24).toByte() +} + +fun ByteArray.writeIntBE(offset: Int, value: Int) { + this[offset] = (value shr 24).toByte() + this[offset + 1] = (value shr 16).toByte() + this[offset + 2] = (value shr 8).toByte() + this[offset + 3] = value.toByte() +} diff --git a/app/src/main/java/io/nekohasekai/sfa/qrs/LubyCodec.kt b/app/src/main/java/io/nekohasekai/sfa/qrs/LubyCodec.kt new file mode 100644 index 0000000..8dd0d74 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/qrs/LubyCodec.kt @@ -0,0 +1,334 @@ +package io.nekohasekai.sfa.qrs + +import java.util.zip.CRC32 +import kotlin.random.Random + +class LubyCodec( + private val sliceSize: Int = QRSConstants.DEFAULT_SLICE_SIZE, +) { + internal class IntArrayKey(val indices: IntArray) { + private val hash = indices.contentHashCode() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is IntArrayKey) return false + return indices.contentEquals(other.indices) + } + + override fun hashCode(): Int = hash + } + + data class EncodedBlock( + val degree: Int, + val indices: IntArray, + val totalBlocks: Int, + val compressedSize: Int, + val checksum: Long, + val data: ByteArray, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as EncodedBlock + return degree == other.degree && + indices.contentEquals(other.indices) && + totalBlocks == other.totalBlocks && + compressedSize == other.compressedSize && + checksum == other.checksum && + data.contentEquals(other.data) + } + + override fun hashCode(): Int { + var result = degree + result = 31 * result + indices.contentHashCode() + result = 31 * result + totalBlocks + result = 31 * result + compressedSize + result = 31 * result + checksum.hashCode() + result = 31 * result + data.contentHashCode() + return result + } + } + + class DecodingState( + val totalBlocks: Int, + val compressedSize: Int, + val checksum: Long, + ) { + val decodedBlocks: Array = arrayOfNulls(totalBlocks) + var decodedCount: Int = 0 + + internal val blockKeyMap: MutableMap = mutableMapOf() + internal val blockSubkeyMap: MutableMap> = mutableMapOf() + val blockIndexMap: MutableMap> = mutableMapOf() + val blockDisposeMap: MutableMap Unit>> = mutableMapOf() + + class PendingBlock( + var indices: MutableList, + var data: ByteArray, + ) + } + + fun encode(originalData: ByteArray, compressedData: ByteArray, compressedSize: Int): Sequence = sequence { + val k = (compressedData.size + sliceSize - 1) / sliceSize + if (k == 0) return@sequence + + val paddedData = compressedData.copyOf(k * sliceSize) + val blocks = (0 until k).map { i -> + paddedData.copyOfRange(i * sliceSize, (i + 1) * sliceSize) + } + + val crc = CRC32() + crc.update(originalData) + // Official: (raw_crc ^ k ^ 0xFFFFFFFF) + // Java CRC32.getValue() = raw_crc ^ 0xFFFFFFFF + // So: official = getValue() ^ 0xFFFFFFFF ^ k ^ 0xFFFFFFFF = getValue() ^ k + val checksum = (crc.value xor k.toLong()) and 0xFFFFFFFFL + + var seed = 0L + while (true) { + val random = Random(seed++) + val degree = SolitonDistribution.sample(k, random) + val indices = selectIndices(k, degree, random) + val blockData = xorBlocks(blocks, indices) + + yield( + EncodedBlock( + degree = degree, + indices = indices, + totalBlocks = k, + compressedSize = compressedSize, + checksum = checksum, + data = blockData, + ) + ) + } + } + + fun createDecodingState(firstBlock: EncodedBlock): DecodingState { + return DecodingState( + totalBlocks = firstBlock.totalBlocks, + compressedSize = firstBlock.compressedSize, + checksum = firstBlock.checksum, + ) + } + + fun processBlock(state: DecodingState, block: EncodedBlock): Boolean { + val queue = ArrayDeque() + queue.add(DecodingState.PendingBlock(block.indices.sorted().toMutableList(), block.data.clone())) + + while (queue.isNotEmpty()) { + val pending = queue.removeFirst() + processPendingBlock(state, pending, queue) + } + + return state.decodedCount == state.totalBlocks + } + + private fun processPendingBlock( + state: DecodingState, + pending: DecodingState.PendingBlock, + queue: ArrayDeque + ) { + var indices = pending.indices + val data = pending.data + + val key = indicesToKey(indices) + if (state.blockKeyMap.containsKey(key) || indices.all { state.decodedBlocks[it] != null }) { + return + } + + // XOR with already decoded blocks + if (indices.size > 1) { + val toRemove = mutableListOf() + for (idx in indices) { + state.decodedBlocks[idx]?.let { + xorInPlace(data, it) + toRemove.add(idx) + } + } + if (toRemove.isNotEmpty()) { + indices.removeAll(toRemove) + } + } + + // Try subset lookup: [1,2,3] XOR [1,2] = [3] + if (indices.size > 2) { + for (i in indices.indices) { + val subkey = indicesToKey(indices.filterIndexed { j, _ -> j != i }) + state.blockKeyMap[subkey]?.let { subblock -> + xorInPlace(data, subblock.data) + indices = mutableListOf(indices[i]) + pending.indices = indices + return@let + } + } + } + + // Still pending: store and register for future matching + if (indices.size > 1) { + val newKey = indicesToKey(indices) + state.blockKeyMap[newKey] = pending + + // Register for single-index lookups + for (idx in indices) { + state.blockIndexMap.getOrPut(idx) { mutableSetOf() }.add(pending) + } + + // Register subkeys for superset matching (degree > 2) + if (indices.size > 2) { + for (i in indices.indices) { + val subkey = indicesToKey(indices.filterIndexed { j, _ -> j != i }) + val dispose: () -> Unit = { state.blockSubkeyMap[subkey]?.remove(pending) } + state.blockSubkeyMap.getOrPut(subkey) { mutableSetOf() }.add(pending) + state.blockDisposeMap.getOrPut(indices[i]) { mutableListOf() }.add(dispose) + } + } + + // Check if this block can help decode any supersets + state.blockSubkeyMap[newKey]?.let { supersets -> + state.blockSubkeyMap.remove(newKey) + for (superblock in supersets.toList()) { + // Remove old registrations before modifying + val oldKey = indicesToKey(superblock.indices) + state.blockKeyMap.remove(oldKey) + for (idx in superblock.indices) { + state.blockIndexMap[idx]?.remove(superblock) + } + + xorInPlace(superblock.data, data) + superblock.indices.removeAll(indices) + + // Re-process through queue + queue.add(superblock) + } + } + } else if (indices.size == 1) { + val idx = indices[0] + if (state.decodedBlocks[idx] == null) { + state.decodedBlocks[idx] = data + state.decodedCount++ + propagateDecoding(state, idx, queue) + } + } + } + + private fun indicesToKey(indices: List): IntArrayKey { + return IntArrayKey(indices.sorted().toIntArray()) + } + + private fun propagateDecoding(state: DecodingState, decodedIdx: Int, queue: ArrayDeque) { + val toProcess = ArrayDeque() + toProcess.add(decodedIdx) + + while (toProcess.isNotEmpty()) { + val idx = toProcess.removeFirst() + val decodedData = state.decodedBlocks[idx] ?: continue + + // Dispose subkey registrations for this index + state.blockDisposeMap.remove(idx)?.forEach { it() } + + // Find and process blocks containing this index + val blocks = state.blockIndexMap.remove(idx) ?: continue + for (pending in blocks) { + val oldKey = indicesToKey(pending.indices) + state.blockKeyMap.remove(oldKey) + + xorInPlace(pending.data, decodedData) + pending.indices.remove(idx) + + // Remove from other index maps + for (otherIdx in pending.indices) { + state.blockIndexMap[otherIdx]?.remove(pending) + } + + if (pending.indices.size == 1) { + val newIdx = pending.indices[0] + if (state.decodedBlocks[newIdx] == null) { + state.decodedBlocks[newIdx] = pending.data + state.decodedCount++ + toProcess.add(newIdx) + } + } else if (pending.indices.size > 1) { + // Re-process through queue to properly update all registrations + queue.add(pending) + } + } + } + } + + fun assembleData(state: DecodingState): ByteArray { + val result = ByteArray(state.totalBlocks * sliceSize) + for (i in state.decodedBlocks.indices) { + state.decodedBlocks[i]?.copyInto(result, i * sliceSize) + } + return result + } + + fun verifyChecksum(originalData: ByteArray, expectedChecksum: Long, k: Int): Boolean { + val crc = CRC32() + crc.update(originalData) + // Official: (raw_crc ^ k ^ 0xFFFFFFFF) + // Java CRC32.getValue() = raw_crc ^ 0xFFFFFFFF + // So: official = getValue() ^ 0xFFFFFFFF ^ k ^ 0xFFFFFFFF = getValue() ^ k + val computed = (crc.value xor k.toLong()) and 0xFFFFFFFFL + return computed == expectedChecksum + } + + private fun selectIndices(k: Int, degree: Int, random: Random): IntArray { + val indices = (0 until k).shuffled(random).take(degree.coerceAtMost(k)) + return indices.toIntArray() + } + + private fun xorBlocks(blocks: List, indices: IntArray): ByteArray { + val result = blocks[indices[0]].clone() + for (i in 1 until indices.size) { + xorInPlace(result, blocks[indices[i]]) + } + return result + } + + private fun xorInPlace(dest: ByteArray, src: ByteArray) { + val len = minOf(dest.size, src.size) + var i = 0 + + // Process 8 bytes at a time using Long + while (i + 7 < len) { + val destLong = ((dest[i].toLong() and 0xFF) shl 56) or + ((dest[i + 1].toLong() and 0xFF) shl 48) or + ((dest[i + 2].toLong() and 0xFF) shl 40) or + ((dest[i + 3].toLong() and 0xFF) shl 32) or + ((dest[i + 4].toLong() and 0xFF) shl 24) or + ((dest[i + 5].toLong() and 0xFF) shl 16) or + ((dest[i + 6].toLong() and 0xFF) shl 8) or + (dest[i + 7].toLong() and 0xFF) + + val srcLong = ((src[i].toLong() and 0xFF) shl 56) or + ((src[i + 1].toLong() and 0xFF) shl 48) or + ((src[i + 2].toLong() and 0xFF) shl 40) or + ((src[i + 3].toLong() and 0xFF) shl 32) or + ((src[i + 4].toLong() and 0xFF) shl 24) or + ((src[i + 5].toLong() and 0xFF) shl 16) or + ((src[i + 6].toLong() and 0xFF) shl 8) or + (src[i + 7].toLong() and 0xFF) + + val result = destLong xor srcLong + + dest[i] = (result shr 56).toByte() + dest[i + 1] = (result shr 48).toByte() + dest[i + 2] = (result shr 40).toByte() + dest[i + 3] = (result shr 32).toByte() + dest[i + 4] = (result shr 24).toByte() + dest[i + 5] = (result shr 16).toByte() + dest[i + 6] = (result shr 8).toByte() + dest[i + 7] = result.toByte() + + i += 8 + } + + // Process remaining bytes + while (i < len) { + dest[i] = (dest[i].toInt() xor src[i].toInt()).toByte() + i++ + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt b/app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt new file mode 100644 index 0000000..a494ce8 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt @@ -0,0 +1,25 @@ +package io.nekohasekai.sfa.qrs + +object QRSConstants { + const val OFFICIAL_URL_PREFIX = "https://qrss.netlify.app/#" + + const val DEFAULT_FRAME_COUNT = 200 + const val BITMAP_BUFFER_SIZE = 30 + const val RECOVERY_FACTOR = 1.3 + + // FPS settings + const val DEFAULT_FPS = 10 + const val MIN_FPS = 1 + const val MAX_FPS = 60 + + // Slice Size settings + const val DEFAULT_SLICE_SIZE = 512 + const val MIN_SLICE_SIZE = 100 + const val MAX_SLICE_SIZE = 1500 + + fun calculateRequiredFrames(dataSize: Int, sliceSize: Int): Int { + val k = (dataSize + sliceSize - 1) / sliceSize + if (k == 0) return 1 + return (k * RECOVERY_FACTOR).toInt().coerceAtLeast(k + 5) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/qrs/QRSDecoder.kt b/app/src/main/java/io/nekohasekai/sfa/qrs/QRSDecoder.kt new file mode 100644 index 0000000..f51cd6a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/qrs/QRSDecoder.kt @@ -0,0 +1,175 @@ +package io.nekohasekai.sfa.qrs + +import java.io.ByteArrayOutputStream +import java.util.Base64 +import java.util.zip.Inflater + +class QRSDecoder { + private var codec: LubyCodec? = null + private var state: LubyCodec.DecodingState? = null + private val processedHashes = mutableSetOf() + + private val inflater = Inflater() + private val decompressBuffer = ByteArray(8192) + private val outputBuffer = ByteArrayOutputStream(32768) + + data class DecodeProgress( + val decodedBlocks: Int, + val totalBlocks: Int, + val isComplete: Boolean, + val data: ByteArray? = null, + val error: String? = null, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as DecodeProgress + return decodedBlocks == other.decodedBlocks && + totalBlocks == other.totalBlocks && + isComplete == other.isComplete && + data.contentEquals(other.data) && + error == other.error + } + + override fun hashCode(): Int { + var result = decodedBlocks + result = 31 * result + totalBlocks + result = 31 * result + isComplete.hashCode() + result = 31 * result + (data?.contentHashCode() ?: 0) + result = 31 * result + (error?.hashCode() ?: 0) + return result + } + } + + @Synchronized + fun processFrame(base64Content: String): DecodeProgress? { + val payload = try { + Base64.getDecoder().decode(base64Content) + } catch (e: Exception) { + return null + } + return processFrame(payload) + } + + @Synchronized + fun processFrame(payload: ByteArray): DecodeProgress? { + val hash = payload.contentHashCode() + if (hash in processedHashes) { + return state?.let { + DecodeProgress(it.decodedCount, it.totalBlocks, it.decodedCount == it.totalBlocks) + } + } + processedHashes.add(hash) + + val block = parsePayload(payload) ?: return null + + // Auto-detect dataset switch: if checksum changes, reset decoder + if (state != null && state!!.checksum != block.checksum) { + reset() + } + + if (codec == null) { + codec = LubyCodec(sliceSize = block.data.size) + state = codec!!.createDecodingState(block) + } + + val currentState = state!! + val complete = codec!!.processBlock(currentState, block) + + return if (complete) { + val assembledData = codec!!.assembleData(currentState) + val compressedData = assembledData.copyOf(currentState.compressedSize) + + val decompressedData = try { + decompress(compressedData) + } catch (e: Exception) { + null + } + + if (decompressedData != null) { + val checksumValid = codec!!.verifyChecksum( + decompressedData, currentState.checksum, currentState.totalBlocks + ) + if (checksumValid) { + return DecodeProgress( + currentState.decodedCount, currentState.totalBlocks, true, decompressedData + ) + } + } + + val rawChecksumValid = codec!!.verifyChecksum( + compressedData, currentState.checksum, currentState.totalBlocks + ) + if (rawChecksumValid) { + DecodeProgress(currentState.decodedCount, currentState.totalBlocks, true, compressedData) + } else { + DecodeProgress( + currentState.decodedCount, + currentState.totalBlocks, + true, + error = "Checksum verification failed", + ) + } + } else { + DecodeProgress(currentState.decodedCount, currentState.totalBlocks, false) + } + } + + @Synchronized + fun reset() { + codec = null + state = null + processedHashes.clear() + } + + val progress: DecodeProgress? + @Synchronized get() = state?.let { + DecodeProgress(it.decodedCount, it.totalBlocks, it.decodedCount == it.totalBlocks) + } + + private fun parsePayload(payload: ByteArray): LubyCodec.EncodedBlock? { + if (payload.size < 16) return null + + var offset = 0 + val degree = payload.readIntLE(offset) + offset += 4 + + if (degree <= 0 || payload.size < 4 + 4 * degree + 12) return null + + val indices = IntArray(degree) { + val idx = payload.readIntLE(offset) + offset += 4 + idx + } + + val totalBlocks = payload.readIntLE(offset) + offset += 4 + + val compressedSize = payload.readIntLE(offset) + offset += 4 + + val checksum = payload.readIntLE(offset).toLong() and 0xFFFFFFFFL + offset += 4 + + if (offset > payload.size) return null + + val data = payload.copyOfRange(offset, payload.size) + + return LubyCodec.EncodedBlock(degree, indices, totalBlocks, compressedSize, checksum, data) + } + + private fun decompress(data: ByteArray): ByteArray { + inflater.reset() + inflater.setInput(data) + outputBuffer.reset() + + while (!inflater.finished()) { + val count = inflater.inflate(decompressBuffer) + if (count == 0 && inflater.needsInput()) break + outputBuffer.write(decompressBuffer, 0, count) + } + + return outputBuffer.toByteArray() + } + +} diff --git a/app/src/main/java/io/nekohasekai/sfa/qrs/QRSEncoder.kt b/app/src/main/java/io/nekohasekai/sfa/qrs/QRSEncoder.kt new file mode 100644 index 0000000..175b29b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/qrs/QRSEncoder.kt @@ -0,0 +1,121 @@ +package io.nekohasekai.sfa.qrs + +import java.io.ByteArrayOutputStream +import java.util.Base64 +import java.util.zip.Deflater + +class QRSEncoder( + private val sliceSize: Int = QRSConstants.DEFAULT_SLICE_SIZE, +) { + private val codec = LubyCodec(sliceSize) + + companion object { + fun appendFileHeaderMeta( + data: ByteArray, + filename: String? = null, + contentType: String? = null, + ): ByteArray { + val meta = buildString { + append("{") + var hasContent = false + filename?.let { + append("\"filename\":\"") + append(escapeJson(it)) + append("\"") + hasContent = true + } + contentType?.let { + if (hasContent) append(",") + append("\"contentType\":\"") + append(escapeJson(it)) + append("\"") + } + append("}") + } + val metaBytes = meta.toByteArray(Charsets.ISO_8859_1) + + val result = ByteArray(4 + metaBytes.size + 4 + data.size) + var offset = 0 + + result.writeIntBE(offset, metaBytes.size) + offset += 4 + metaBytes.copyInto(result, offset) + offset += metaBytes.size + + result.writeIntBE(offset, data.size) + offset += 4 + data.copyInto(result, offset) + + return result + } + + private fun escapeJson(s: String): String { + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } + } + + data class QRSFrame( + val content: String, + val frameIndex: Int, + val totalBlocks: Int, + ) + + fun encode(data: ByteArray, urlPrefix: String = ""): Sequence { + val compressed = compress(data) + + return codec.encode(data, compressed, compressed.size).mapIndexed { index, block -> + val payload = buildPayload(block) + val base64 = Base64.getEncoder().encodeToString(payload) + QRSFrame("$urlPrefix$base64", index, block.totalBlocks) + } + } + + private fun compress(data: ByteArray): ByteArray { + val deflater = Deflater(Deflater.DEFAULT_COMPRESSION) + deflater.setInput(data) + deflater.finish() + + val outputStream = ByteArrayOutputStream(data.size) + val buffer = ByteArray(1024) + + while (!deflater.finished()) { + val count = deflater.deflate(buffer) + outputStream.write(buffer, 0, count) + } + + deflater.end() + return outputStream.toByteArray() + } + + private fun buildPayload(block: LubyCodec.EncodedBlock): ByteArray { + val headerSize = 4 + 4 * block.indices.size + 4 + 4 + 4 + val payload = ByteArray(headerSize + block.data.size) + var offset = 0 + + payload.writeIntLE(offset, block.degree) + offset += 4 + + for (idx in block.indices) { + payload.writeIntLE(offset, idx) + offset += 4 + } + + payload.writeIntLE(offset, block.totalBlocks) + offset += 4 + + payload.writeIntLE(offset, block.compressedSize) + offset += 4 + + payload.writeIntLE(offset, block.checksum.toInt()) + offset += 4 + + block.data.copyInto(payload, offset) + + return payload + } + +} diff --git a/app/src/main/java/io/nekohasekai/sfa/qrs/SolitonDistribution.kt b/app/src/main/java/io/nekohasekai/sfa/qrs/SolitonDistribution.kt new file mode 100644 index 0000000..d588aa4 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/qrs/SolitonDistribution.kt @@ -0,0 +1,19 @@ +package io.nekohasekai.sfa.qrs + +import kotlin.random.Random + +object SolitonDistribution { + fun sample(k: Int, random: Random): Int { + if (k <= 0) return 1 + + val p = random.nextDouble() + var cdf = 1.0 / k + if (p < cdf) return 1 + + for (d in 2..k) { + cdf += 1.0 / (d * (d - 1)) + if (p < cdf) return d + } + return k + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt deleted file mode 100644 index 3297434..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt +++ /dev/null @@ -1,247 +0,0 @@ -package io.nekohasekai.sfa.ui.profile - -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.activity.result.contract.ActivityResultContract -import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.core.Camera -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.Preview -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.databinding.ActivityQrScanBinding -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ui.shared.AbstractActivity -import io.nekohasekai.sfa.vendor.Vendor -import kotlinx.coroutines.launch -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors - -class QRScanActivity : AbstractActivity() { - private lateinit var analysisExecutor: ExecutorService - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.profile_add_scan_qr_code) - - analysisExecutor = Executors.newSingleThreadExecutor() - binding.previewView.implementationMode = PreviewView.ImplementationMode.COMPATIBLE - binding.previewView.previewStreamState.observe(this) { - if (it === PreviewView.StreamState.STREAMING) { - binding.progress.isVisible = false - binding.previewView.implementationMode = PreviewView.ImplementationMode.PERFORMANCE - } - } - if (ContextCompat.checkSelfPermission( - this, Manifest.permission.CAMERA, - ) == PackageManager.PERMISSION_GRANTED - ) { - startCamera() - } else { - requestPermissionLauncher.launch(Manifest.permission.CAMERA) - } - } - - private val requestPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> - if (isGranted) { - startCamera() - } else { - setResult(RESULT_CANCELED) - finish() - } - } - - private lateinit var imageAnalysis: ImageAnalysis - private lateinit var imageAnalyzer: ImageAnalysis.Analyzer - private val onSuccess: (String) -> Unit = { rawValue: String -> - imageAnalysis.clearAnalyzer() - if (!onSuccess(rawValue)) { - imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) - } - } - private val onFailure: (Exception) -> Unit = { - lifecycleScope.launch { - resetAnalyzer() - errorDialogBuilder("MLKit error: ${it.localizedMessage}").show() - } - } - private val vendorAnalyzer = Vendor.createQRCodeAnalyzer(onSuccess, onFailure) - private var useVendorAnalyzer = vendorAnalyzer != null - - private fun resetAnalyzer() { - if (useVendorAnalyzer) { - useVendorAnalyzer = false - imageAnalysis.clearAnalyzer() - imageAnalyzer = ZxingQRCodeAnalyzer(onSuccess, onFailure) - imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) - } - } - - private lateinit var cameraProvider: ProcessCameraProvider - private lateinit var cameraPreview: Preview - private lateinit var camera: Camera - - private fun startCamera() { - val cameraProviderFuture = - try { - ProcessCameraProvider.getInstance(this) - } catch (e: Exception) { - fatalError(e) - return - } - cameraProviderFuture.addListener({ - cameraProvider = - try { - cameraProviderFuture.get() - } catch (e: Exception) { - fatalError(e) - return@addListener - } - - cameraPreview = - Preview.Builder().build() - .also { it.setSurfaceProvider(binding.previewView.surfaceProvider) } - imageAnalysis = ImageAnalysis.Builder().build() - imageAnalyzer = vendorAnalyzer ?: ZxingQRCodeAnalyzer(onSuccess, onFailure) - imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) - cameraProvider.unbindAll() - - try { - camera = - cameraProvider.bindToLifecycle( - this, CameraSelector.DEFAULT_BACK_CAMERA, cameraPreview, imageAnalysis, - ) - } catch (e: Exception) { - fatalError(e) - } - }, ContextCompat.getMainExecutor(this)) - } - - private fun fatalError(e: Exception) { - lifecycleScope.launch { - errorDialogBuilder(e).setOnDismissListener { - setResult(RESULT_CANCELED) - finish() - }.show() - } - } - - private fun onSuccess(value: String): Boolean { - try { - importRemoteProfileFromString(value) - return true - } catch (e: Exception) { - lifecycleScope.launch { - errorDialogBuilder(e).show() - } - } - return false - } - - private fun importRemoteProfileFromString(uriString: String) { - val uri = Uri.parse(uriString) - if (uri.scheme != "sing-box" || uri.host != "import-remote-profile") error("Not a valid sing-box remote profile URI") - Libbox.parseRemoteProfileImportLink(uri.toString()) - setResult( - RESULT_OK, - Intent().apply { - setData(uri) - }, - ) - finish() - } - - override fun onPrepareOptionsMenu(menu: Menu?): Boolean { - if (!useVendorAnalyzer) { - menu!!.findItem(R.id.action_use_vendor_analyzer).also { - it.isEnabled = false - it.isChecked = false - } - } - return true - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.qr_scan_menu, menu) - if (useVendorAnalyzer) { - menu.findItem(R.id.action_use_vendor_analyzer).isChecked = true - } - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_use_front_camera -> { - item.isChecked = !item.isChecked - cameraProvider.unbindAll() - try { - camera = - cameraProvider.bindToLifecycle( - this, - if (!item.isChecked) CameraSelector.DEFAULT_BACK_CAMERA else CameraSelector.DEFAULT_FRONT_CAMERA, - cameraPreview, - imageAnalysis, - ) - } catch (e: Exception) { - fatalError(e) - } - } - - R.id.action_enable_torch -> { - item.isChecked = !item.isChecked - camera.cameraControl.enableTorch(item.isChecked) - } - - R.id.action_use_vendor_analyzer -> { - item.isChecked = !item.isChecked - imageAnalysis.clearAnalyzer() - imageAnalyzer = - if (item.isChecked) { - vendorAnalyzer!! - } else { - ZxingQRCodeAnalyzer(onSuccess, onFailure) - } - imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) - } - - else -> return super.onOptionsItemSelected(item) - } - return true - } - - override fun onDestroy() { - super.onDestroy() - analysisExecutor.shutdown() - } - - class Contract : ActivityResultContract() { - override fun createIntent( - context: Context, - input: Nothing?, - ): Intent = Intent(context, QRScanActivity::class.java) - - override fun parseResult( - resultCode: Int, - intent: Intent?, - ): Intent? { - return when (resultCode) { - RESULT_OK -> intent - else -> null - } - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt index 372b85d..6e9bc42 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt @@ -1,12 +1,15 @@ package io.nekohasekai.sfa.ui.profile -import android.util.Log import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy import com.google.zxing.BinaryBitmap +import com.google.zxing.ChecksumException +import com.google.zxing.FormatException import com.google.zxing.NotFoundException -import com.google.zxing.RGBLuminanceSource +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.Result import com.google.zxing.common.GlobalHistogramBinarizer +import com.google.zxing.common.HybridBinarizer import com.google.zxing.qrcode.QRCodeReader class ZxingQRCodeAnalyzer( @@ -14,37 +17,78 @@ class ZxingQRCodeAnalyzer( private val onFailure: ((Exception) -> Unit), ) : ImageAnalysis.Analyzer { private val qrCodeReader = QRCodeReader() + private var yDataBuffer: ByteArray? = null + + var qrsMode: Boolean = false override fun analyze(image: ImageProxy) { try { - val bitmap = image.toBitmap() - val intArray = IntArray(bitmap.getWidth() * bitmap.getHeight()) - bitmap.getPixels( - intArray, - 0, - bitmap.getWidth(), - 0, - 0, - bitmap.getWidth(), - bitmap.getHeight(), - ) - val source = RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), intArray) - val result = - try { - qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source))) - } catch (e: NotFoundException) { - try { - qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert()))) - } catch (ignore: NotFoundException) { - return - } - } - Log.d("ZxingQRCodeAnalyzer", "barcode decode success: ${result.text}") - onSuccess(result.text) + val source = image.toYUVSource() + + // Fast path: HybridBinarizer + tryDecode(BinaryBitmap(HybridBinarizer(source)))?.let { + onSuccess(it.text) + return + } + + // In QRS mode, skip additional binarizer attempts for performance + if (qrsMode) return + + // Inverted HybridBinarizer (uses ZXing's native invert) + tryDecode(BinaryBitmap(HybridBinarizer(source.invert())))?.let { + onSuccess(it.text) + return + } + + // GlobalHistogramBinarizer (normal) + tryDecode(BinaryBitmap(GlobalHistogramBinarizer(source)))?.let { + onSuccess(it.text) + return + } + + // GlobalHistogramBinarizer (inverted) + tryDecode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())))?.let { + onSuccess(it.text) + return + } + } catch (e: NotFoundException) { + // No QR code found in frame, ignore + } catch (e: ChecksumException) { + // Checksum error, ignore + } catch (e: FormatException) { + // Format error, ignore } catch (e: Exception) { onFailure(e) } finally { + qrCodeReader.reset() image.close() } } + + private fun ImageProxy.toYUVSource(): PlanarYUVLuminanceSource { + val yPlane = planes[0] + val yBuffer = yPlane.buffer + val rowStride = yPlane.rowStride + val size = width * height + + val yData = yDataBuffer?.takeIf { it.size >= size } ?: ByteArray(size).also { yDataBuffer = it } + if (rowStride == width) { + yBuffer.get(yData, 0, size) + } else { + for (row in 0 until height) { + yBuffer.position(row * rowStride) + yBuffer.get(yData, row * width, width) + } + } + return PlanarYUVLuminanceSource(yData, width, height, 0, 0, width, height, false) + } + + private fun tryDecode(bitmap: BinaryBitmap): Result? { + return try { + qrCodeReader.decode(bitmap) + } catch (_: NotFoundException) { + qrCodeReader.reset() + null + } + } } diff --git a/app/src/main/res/layout/activity_qr_scan.xml b/app/src/main/res/layout/activity_qr_scan.xml deleted file mode 100644 index e596e12..0000000 --- a/app/src/main/res/layout/activity_qr_scan.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f5bd6f5..4c203ac 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -159,11 +159,16 @@ JSON 查看器 JSON 编辑器 查看配置 - 另存为文件 + 保存为文件 分享为文件 + 保存配置 JSON 文件 + 分享配置 JSON 文件 未保存的更改 您有未保存的更改。要放弃它们吗? 配置文件二维码:%s + 导入配置 + 导入配置「%s」? + 导入 选中 @@ -372,6 +377,17 @@ 分享二维码 + + 分享为 QRS + 接收中:%1$d / %2$d 块 + 速度 + 间隔:%d 毫秒 + QRS 模式 + 帧率 + (%d 毫秒) + 分块大小 + 什么是 QRS + 在文档中查找 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index e85fe50..f6ec217 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,6 @@ #d81b60 + #CC1C1B1F #546e7a diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 76b7797..5e30655 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -163,9 +163,14 @@ View Configuration Save As File Share As File + Save Content JSON File + Share Content JSON File Unsaved Changes You have unsaved changes. Do you want to discard them? Profile QR Code: %s + Import Profile + Import profile \"%s\"? + Import Selected @@ -377,6 +382,17 @@ Share QR Code + + Share as QR Stream + Receiving: %1$d / %2$d blocks + Speed + Interval: %d ms + QR Stream Mode + FPS + (%d ms) + Slice Size + What is QRS + Find in document diff --git a/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt b/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt index 4c091b1..15d49d2 100644 --- a/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt +++ b/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt @@ -1,6 +1,6 @@ package io.nekohasekai.sfa.vendor -import android.util.Log +import android.graphics.Bitmap import androidx.camera.core.ExperimentalGetImage import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy @@ -17,21 +17,27 @@ class MLKitQRCodeAnalyzer( ) : ImageAnalysis.Analyzer { private val barcodeScanner = BarcodeScanning.getClient( - BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build(), + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build(), ) @Volatile private var failureOccurred = false private var failureTimestamp = 0L + private var pixelBuffer: IntArray? = null + @ExperimentalGetImage override fun analyze(image: ImageProxy) { - if (image.image == null) return + if (image.image == null) { + image.close() + return + } val nowMills = System.currentTimeMillis() if (failureOccurred && nowMills - failureTimestamp < 5000L) { failureTimestamp = nowMills - Log.d("MLKitQRCodeAnalyzer", "throttled analysis since error occurred in previous pass") image.close() return } @@ -39,24 +45,56 @@ class MLKitQRCodeAnalyzer( failureOccurred = false barcodeScanner.process(image.toInputImage()) .addOnSuccessListener { codes -> - if (codes.isNotEmpty()) { - val rawValue = codes.firstOrNull()?.rawValue - if (rawValue != null) { - Log.d("MLKitQRCodeAnalyzer", "barcode decode success: $rawValue") - onSuccess(rawValue) - } + val rawValue = codes.firstOrNull()?.rawValue + if (rawValue != null) { + onSuccess(rawValue) + image.close() + } else { + tryInvertedScan(image) } } .addOnFailureListener { failureOccurred = true failureTimestamp = System.currentTimeMillis() onFailure(it) - } - .addOnCompleteListener { image.close() } } + private fun tryInvertedScan(image: ImageProxy) { + val inverted = image.toInvertedBitmap() + barcodeScanner.process(InputImage.fromBitmap(inverted, image.imageInfo.rotationDegrees)) + .addOnSuccessListener { codes -> + codes.firstOrNull()?.rawValue?.let { onSuccess(it) } + } + .addOnCompleteListener { + inverted.recycle() + image.close() + } + } + + private fun ImageProxy.toInvertedBitmap(): Bitmap { + val yPlane = planes[0] + val yBuffer = yPlane.buffer.duplicate() + val rowStride = yPlane.rowStride + val width = width + val height = height + val size = width * height + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val pixels = pixelBuffer?.takeIf { it.size >= size } ?: IntArray(size).also { pixelBuffer = it } + + for (row in 0 until height) { + yBuffer.position(row * rowStride) + for (col in 0 until width) { + val y = 255 - (yBuffer.get().toInt() and 0xFF) + pixels[row * width + col] = (0xFF shl 24) or (y shl 16) or (y shl 8) or y + } + } + bitmap.setPixels(pixels, 0, width, 0, 0, width, height) + return bitmap + } + @ExperimentalGetImage @Suppress("UnsafeCallOnNullableType") private fun ImageProxy.toInputImage() = InputImage.fromMediaImage(image!!, imageInfo.rotationDegrees)