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)