Refactor QR scan and share and add QRS support

This commit is contained in:
世界
2026-01-01 23:50:31 +08:00
parent 4a2ffcd080
commit aaa3ef044a
31 changed files with 2475 additions and 735 deletions

View File

@@ -115,9 +115,6 @@
<activity <activity
android:name="io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity" android:name="io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity"
android:exported="false" /> android:exported="false" />
<activity
android:name="io.nekohasekai.sfa.ui.profile.QRScanActivity"
android:exported="false" />
<service <service
android:name=".bg.TileService" android:name=".bg.TileService"

View File

@@ -17,6 +17,7 @@ class NewProfileActivity : ComponentActivity() {
const val EXTRA_PROFILE_ID = "profile_id" const val EXTRA_PROFILE_ID = "profile_id"
const val EXTRA_IMPORT_NAME = "import_name" const val EXTRA_IMPORT_NAME = "import_name"
const val EXTRA_IMPORT_URL = "import_url" const val EXTRA_IMPORT_URL = "import_url"
const val EXTRA_QRS_DATA = "qrs_data"
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -25,6 +26,7 @@ class NewProfileActivity : ComponentActivity() {
val importName = intent.getStringExtra(EXTRA_IMPORT_NAME) val importName = intent.getStringExtra(EXTRA_IMPORT_NAME)
val importUrl = intent.getStringExtra(EXTRA_IMPORT_URL) val importUrl = intent.getStringExtra(EXTRA_IMPORT_URL)
val qrsData = intent.getByteArrayExtra(EXTRA_QRS_DATA)
setContent { setContent {
SFATheme { SFATheme {
@@ -35,6 +37,7 @@ class NewProfileActivity : ComponentActivity() {
NewProfileScreen( NewProfileScreen(
importName = importName, importName = importName,
importUrl = importUrl, importUrl = importUrl,
qrsData = qrsData,
onNavigateBack = { finish() }, onNavigateBack = { finish() },
onProfileCreated = { profileId -> onProfileCreated = { profileId ->
val resultIntent = val resultIntent =

View File

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

View File

@@ -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<QRSEncoder.QRSFrame>,
private val foregroundColor: Int,
private val backgroundColor: Int,
bufferSize: Int = 30,
) {
private val _state = MutableStateFlow(QRSGenerationState(totalFrames = frames.size))
val state: StateFlow<QRSGenerationState> = _state
private val actualBufferSize = bufferSize.coerceAtMost(frames.size)
private val bitmapBuffer = arrayOfNulls<Bitmap>(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()
}
}

View File

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

View File

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

View File

@@ -74,6 +74,7 @@ import io.nekohasekai.sfa.R
fun NewProfileScreen( fun NewProfileScreen(
importName: String? = null, importName: String? = null,
importUrl: String? = null, importUrl: String? = null,
qrsData: ByteArray? = null,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onProfileCreated: (profileId: Long) -> Unit, onProfileCreated: (profileId: Long) -> Unit,
viewModel: NewProfileViewModel = viewModel(), viewModel: NewProfileViewModel = viewModel(),
@@ -81,8 +82,12 @@ fun NewProfileScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(importName, importUrl) { LaunchedEffect(importName, importUrl, qrsData) {
viewModel.initializeFromQRImport(importName, importUrl) if (qrsData != null) {
viewModel.initializeFromQRSImport(importName, qrsData)
} else {
viewModel.initializeFromQRImport(importName, importUrl)
}
} }
// File picker launcher // File picker launcher

View File

@@ -33,6 +33,8 @@ data class NewProfileUiState(
// File import // File import
val importUri: Uri? = null, val importUri: Uri? = null,
val importFileName: String? = null, val importFileName: String? = null,
// QRS import
val qrsData: ByteArray? = null,
// State // State
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isSaving: 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) { fun updateName(name: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -158,7 +171,7 @@ class NewProfileViewModel(application: Application) : AndroidViewModel(applicati
// Validate based on profile type // Validate based on profile type
when (state.profileType) { when (state.profileType) {
ProfileType.Local -> { 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)) } _uiState.update { it.copy(importError = context.getString(R.string.profile_input_required)) }
hasError = true hasError = true
} }
@@ -234,22 +247,27 @@ class NewProfileViewModel(application: Application) : AndroidViewModel(applicati
when (state.profileSource) { when (state.profileSource) {
ProfileSource.CreateNew -> "{}" ProfileSource.CreateNew -> "{}"
ProfileSource.Import -> { ProfileSource.Import -> {
state.importUri?.let { uri -> if (state.qrsData != null) {
val sourceURL = uri.toString() val content = Libbox.decodeProfileContent(state.qrsData)
when { content.config
sourceURL.startsWith("content://") -> { } else {
val inputStream = context.contentResolver.openInputStream(uri) as InputStream state.importUri?.let { uri ->
inputStream.use { it.bufferedReader().readText() } 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")
}
} ?: "{}"
} }
} }

View File

@@ -31,6 +31,18 @@ class ProfileImportHandler(private val context: Context) {
data class Error(val message: String) : QRCodeParseResult() 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 = suspend fun importFromUri(uri: Uri): ImportResult =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { 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 = suspend fun parseQRCode(data: String): QRCodeParseResult =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { 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 { private suspend fun importProfile(content: ProfileContent): ImportResult {
val typedProfile = TypedProfile() val typedProfile = TypedProfile()
val profile = Profile(name = content.name, typed = typedProfile) val profile = Profile(name = content.name, typed = typedProfile)

View File

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

View File

@@ -1,6 +1,5 @@
package io.nekohasekai.sfa.compose.screen.dashboard package io.nekohasekai.sfa.compose.screen.dashboard
import android.graphics.Bitmap
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
@@ -34,8 +33,6 @@ fun DashboardCardRenderer(
onHideAddProfileSheet: () -> Unit = {}, onHideAddProfileSheet: () -> Unit = {},
onShowProfilePickerSheet: () -> Unit = {}, onShowProfilePickerSheet: () -> Unit = {},
onHideProfilePickerSheet: () -> Unit = {}, onHideProfilePickerSheet: () -> Unit = {},
shareQRCodeImage: (Bitmap, String) -> Unit = { _, _ -> },
saveQRCodeToGallery: (Bitmap, String) -> Unit = { _, _ -> },
commandClient: CommandClient? = null, commandClient: CommandClient? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -127,8 +124,6 @@ fun DashboardCardRenderer(
onImportFromFile = { /* Handled in ProfilesCard */ }, onImportFromFile = { /* Handled in ProfilesCard */ },
onScanQrCode = { /* Handled in ProfilesCard */ }, onScanQrCode = { /* Handled in ProfilesCard */ },
onCreateManually = { /* Handled in ProfilesCard */ }, onCreateManually = { /* Handled in ProfilesCard */ },
shareQRCodeImage = shareQRCodeImage,
saveQRCodeToGallery = saveQRCodeToGallery,
) )
} }
} }

View File

@@ -27,8 +27,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.base.UiEvent 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 io.nekohasekai.sfa.constant.Status
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -176,16 +174,6 @@ fun DashboardScreen(
onHideAddProfileSheet = viewModel::hideAddProfileSheet, onHideAddProfileSheet = viewModel::hideAddProfileSheet,
onShowProfilePickerSheet = viewModel::showProfilePickerSheet, onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet, onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
shareQRCodeImage = { bitmap, name ->
scope.launch {
shareQRCodeImage(context, bitmap, name)
}
},
saveQRCodeToGallery = { bitmap, name ->
scope.launch {
saveQRCodeToGallery(context, bitmap, name)
}
},
commandClient = viewModel.commandClient, commandClient = viewModel.commandClient,
modifier = modifier =
Modifier Modifier
@@ -225,16 +213,6 @@ fun DashboardScreen(
onHideAddProfileSheet = viewModel::hideAddProfileSheet, onHideAddProfileSheet = viewModel::hideAddProfileSheet,
onShowProfilePickerSheet = viewModel::showProfilePickerSheet, onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet, onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
shareQRCodeImage = { bitmap, name ->
scope.launch {
shareQRCodeImage(context, bitmap, name)
}
},
saveQRCodeToGallery = { bitmap, name ->
scope.launch {
saveQRCodeToGallery(context, bitmap, name)
}
},
commandClient = viewModel.commandClient, commandClient = viewModel.commandClient,
) )
} }

View File

@@ -1,6 +1,5 @@
package io.nekohasekai.sfa.compose.screen.dashboard package io.nekohasekai.sfa.compose.screen.dashboard
import android.graphics.Bitmap
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -45,6 +44,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.toArgb
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -60,7 +60,7 @@ import androidx.compose.ui.unit.dp
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.ProfileContent import io.nekohasekai.libbox.ProfileContent
import io.nekohasekai.sfa.R 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.ProfileIcons
import io.nekohasekai.sfa.compose.util.QRCodeGenerator import io.nekohasekai.sfa.compose.util.QRCodeGenerator
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
@@ -83,8 +83,6 @@ fun ProfilePickerSheet(
onProfileDelete: (Profile) -> Unit, onProfileDelete: (Profile) -> Unit,
onProfileMove: (Int, Int) -> Unit, onProfileMove: (Int, Int) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
shareQRCodeImage: suspend (Bitmap, String) -> Unit,
saveQRCodeToGallery: suspend (Bitmap, String) -> Unit,
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val context = LocalContext.current val context = LocalContext.current
@@ -173,9 +171,8 @@ fun ProfilePickerSheet(
profile.typed.remoteURL, profile.typed.remoteURL,
) )
} }
val qrBitmap = remember(link) { val surfaceColor = MaterialTheme.colorScheme.surface.toArgb()
QRCodeGenerator.generate(link) val qrBitmap = QRCodeGenerator.rememberPrimaryBitmap(link, backgroundColor = surfaceColor)
}
QRCodeDialog( QRCodeDialog(
bitmap = qrBitmap, bitmap = qrBitmap,
@@ -183,20 +180,6 @@ fun ProfilePickerSheet(
showQRCodeDialog = false showQRCodeDialog = false
qrCodeProfile = null qrCodeProfile = null
}, },
onShare = {
coroutineScope.launch {
shareQRCodeImage(qrBitmap, profile.name)
}
showQRCodeDialog = false
qrCodeProfile = null
},
onSave = {
coroutineScope.launch {
saveQRCodeToGallery(qrBitmap, profile.name)
showQRCodeDialog = false
qrCodeProfile = null
}
},
) )
} }
} }

View File

@@ -1,7 +1,7 @@
package io.nekohasekai.sfa.compose.screen.dashboard package io.nekohasekai.sfa.compose.screen.dashboard
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.QrCode2
import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Refresh 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.filled.Save
import androidx.compose.material.icons.outlined.CreateNewFolder import androidx.compose.material.icons.outlined.CreateNewFolder
import androidx.compose.material.icons.outlined.Description 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.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.toArgb
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -61,18 +65,22 @@ import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.ProfileContent import io.nekohasekai.libbox.ProfileContent
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.NewProfileActivity 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.ProfileImportHandler
import io.nekohasekai.sfa.compose.screen.configuration.QRCodeDialog
import io.nekohasekai.sfa.compose.util.QRCodeGenerator import io.nekohasekai.sfa.compose.util.QRCodeGenerator
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
import io.nekohasekai.sfa.database.Profile import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.TypedProfile import io.nekohasekai.sfa.database.TypedProfile
import io.nekohasekai.sfa.ktx.errorDialogBuilder import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ktx.shareProfile 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -98,8 +106,6 @@ fun ProfilesCard(
onImportFromFile: () -> Unit, onImportFromFile: () -> Unit,
onScanQrCode: () -> Unit, onScanQrCode: () -> Unit,
onCreateManually: () -> Unit, onCreateManually: () -> Unit,
shareQRCodeImage: suspend (Bitmap, String) -> Unit,
saveQRCodeToGallery: suspend (Bitmap, String) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -109,6 +115,17 @@ fun ProfilesCard(
var showQRCodeDialog by remember { mutableStateOf(false) } var showQRCodeDialog by remember { mutableStateOf(false) }
var qrCodeProfile by remember { mutableStateOf<Profile?>(null) } var qrCodeProfile by remember { mutableStateOf<Profile?>(null) }
var showQRSDialog by remember { mutableStateOf(false) }
var qrsProfile by remember { mutableStateOf<Profile?>(null) }
var qrsProfileData by remember { mutableStateOf<ByteArray?>(null) }
var showImportConfirmDialog by remember { mutableStateOf(false) }
var pendingImportName by remember { mutableStateOf<String?>(null) }
var pendingQrsData by remember { mutableStateOf<ByteArray?>(null) }
var pendingImportUri by remember { mutableStateOf<Uri?>(null) }
var showQRScanSheet by remember { mutableStateOf(false) }
val newProfileLauncher = val newProfileLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(), ActivityResultContracts.StartActivityForResult(),
@@ -137,62 +154,17 @@ fun ProfilesCard(
) { uri -> ) { uri ->
uri?.let { uri?.let {
coroutineScope.launch { coroutineScope.launch {
when (val result = importHandler.importFromUri(uri)) { when (val parseResult = importHandler.parseUri(uri)) {
is ProfileImportHandler.ImportResult.Success -> { is ProfileImportHandler.UriParseResult.Success -> {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
onProfileEdit(result.profile) pendingImportName = parseResult.name
pendingImportUri = uri
showImportConfirmDialog = true
} }
} }
is ProfileImportHandler.ImportResult.Error -> { is ProfileImportHandler.UriParseResult.Error -> {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
context.errorDialogBuilder(Exception(result.message)).show() context.errorDialogBuilder(Exception(parseResult.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()
}
} }
} }
} }
@@ -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) { LaunchedEffect(onImportFromFile, onScanQrCode) {
} }
@@ -334,12 +339,48 @@ fun ProfilesCard(
saveFileLauncher.launch("${it.name}.bpf") 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 = { onShareURL = {
selectedProfile?.let { selectedProfile?.let {
qrCodeProfile = it qrCodeProfile = it
showQRCodeDialog = true 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, onProfileDelete = onProfileDelete,
onProfileMove = onProfileMove, onProfileMove = onProfileMove,
onDismiss = onHideProfilePickerSheet, onDismiss = onHideProfilePickerSheet,
shareQRCodeImage = shareQRCodeImage,
saveQRCodeToGallery = saveQRCodeToGallery,
) )
} }
@@ -399,7 +438,7 @@ fun ProfilesCard(
ListItem( ListItem(
modifier = Modifier.clickable { modifier = Modifier.clickable {
onHideAddProfileSheet() onHideAddProfileSheet()
scanQrCodeLauncher.launch(null) showQRScanSheet = true
}, },
leadingContent = { leadingContent = {
Icon( Icon(
@@ -448,9 +487,8 @@ fun ProfilesCard(
profile.typed.remoteURL, profile.typed.remoteURL,
) )
} }
val qrBitmap = remember(link) { val surfaceColor = MaterialTheme.colorScheme.surface.toArgb()
QRCodeGenerator.generate(link) val qrBitmap = QRCodeGenerator.rememberPrimaryBitmap(link, backgroundColor = surfaceColor)
}
QRCodeDialog( QRCodeDialog(
bitmap = qrBitmap, bitmap = qrBitmap,
@@ -458,18 +496,148 @@ fun ProfilesCard(
showQRCodeDialog = false showQRCodeDialog = false
qrCodeProfile = null qrCodeProfile = null
}, },
onShare = { )
coroutineScope.launch { }
shareQRCodeImage(qrBitmap, profile.name)
} if (showQRSDialog && qrsProfile != null && qrsProfileData != null) {
showQRCodeDialog = false QRSDialog(
qrCodeProfile = null profileData = qrsProfileData!!,
profileName = qrsProfile!!.name,
onDismiss = {
showQRSDialog = false
qrsProfile = null
qrsProfileData = null
}, },
onSave = { )
coroutineScope.launch { }
saveQRCodeToGallery(qrBitmap, profile.name)
showQRCodeDialog = false if (showImportConfirmDialog && pendingImportName != null) {
qrCodeProfile = 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, onUpdate: () -> Unit,
onShareFile: () -> Unit, onShareFile: () -> Unit,
onSaveFile: () -> Unit, onSaveFile: () -> Unit,
onSaveJson: () -> Unit,
onShareJson: () -> Unit,
onShareURL: () -> Unit, onShareURL: () -> Unit,
onShareQRS: () -> Unit,
) { ) {
if (profile == null) return if (profile == null) return
@@ -591,7 +762,10 @@ private fun ProfileActionRow(
profile = profile, profile = profile,
onShareFile = onShareFile, onShareFile = onShareFile,
onSaveFile = onSaveFile, onSaveFile = onSaveFile,
onSaveJson = onSaveJson,
onShareJson = onShareJson,
onShareURL = onShareURL, onShareURL = onShareURL,
onShareQRS = onShareQRS,
) )
} }
} }
@@ -643,7 +817,10 @@ private fun ShareButton(
profile: Profile, profile: Profile,
onShareFile: () -> Unit, onShareFile: () -> Unit,
onSaveFile: () -> Unit, onSaveFile: () -> Unit,
onSaveJson: () -> Unit,
onShareJson: () -> Unit,
onShareURL: () -> Unit, onShareURL: () -> Unit,
onShareQRS: () -> Unit,
) { ) {
var expanded by remember { mutableStateOf(false) } 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) { if (profile.typed.type == TypedProfile.Type.Remote) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.profile_share_url)) }, 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,
)
},
)
} }
} }
} }

View File

@@ -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<Int, Int>? = 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<QRScanUiState> = _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<Application>()
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()
}
}

View File

@@ -2,10 +2,91 @@ package io.nekohasekai.sfa.compose.util
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Color 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.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.QRCodeWriter
object QRCodeGenerator { 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( fun generate(
content: String, content: String,
size: Int = 512, size: Int = 512,

View File

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

View File

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

View File

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

View File

@@ -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<ByteArray?> = arrayOfNulls(totalBlocks)
var decodedCount: Int = 0
internal val blockKeyMap: MutableMap<IntArrayKey, PendingBlock> = mutableMapOf()
internal val blockSubkeyMap: MutableMap<IntArrayKey, MutableSet<PendingBlock>> = mutableMapOf()
val blockIndexMap: MutableMap<Int, MutableSet<PendingBlock>> = mutableMapOf()
val blockDisposeMap: MutableMap<Int, MutableList<() -> Unit>> = mutableMapOf()
class PendingBlock(
var indices: MutableList<Int>,
var data: ByteArray,
)
}
fun encode(originalData: ByteArray, compressedData: ByteArray, compressedSize: Int): Sequence<EncodedBlock> = 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<DecodingState.PendingBlock>()
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<DecodingState.PendingBlock>
) {
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<Int>()
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<Int>): IntArrayKey {
return IntArrayKey(indices.sorted().toIntArray())
}
private fun propagateDecoding(state: DecodingState, decodedIdx: Int, queue: ArrayDeque<DecodingState.PendingBlock>) {
val toProcess = ArrayDeque<Int>()
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<ByteArray>, 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++
}
}
}

View File

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

View File

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

View File

@@ -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<QRSFrame> {
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
}
}

View File

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

View File

@@ -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<ActivityQrScanBinding>() {
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<Nothing?, Intent?>() {
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
}
}
}
}

View File

@@ -1,12 +1,15 @@
package io.nekohasekai.sfa.ui.profile package io.nekohasekai.sfa.ui.profile
import android.util.Log
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import com.google.zxing.BinaryBitmap import com.google.zxing.BinaryBitmap
import com.google.zxing.ChecksumException
import com.google.zxing.FormatException
import com.google.zxing.NotFoundException 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.GlobalHistogramBinarizer
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader import com.google.zxing.qrcode.QRCodeReader
class ZxingQRCodeAnalyzer( class ZxingQRCodeAnalyzer(
@@ -14,37 +17,78 @@ class ZxingQRCodeAnalyzer(
private val onFailure: ((Exception) -> Unit), private val onFailure: ((Exception) -> Unit),
) : ImageAnalysis.Analyzer { ) : ImageAnalysis.Analyzer {
private val qrCodeReader = QRCodeReader() private val qrCodeReader = QRCodeReader()
private var yDataBuffer: ByteArray? = null
var qrsMode: Boolean = false
override fun analyze(image: ImageProxy) { override fun analyze(image: ImageProxy) {
try { try {
val bitmap = image.toBitmap() val source = image.toYUVSource()
val intArray = IntArray(bitmap.getWidth() * bitmap.getHeight())
bitmap.getPixels( // Fast path: HybridBinarizer
intArray, tryDecode(BinaryBitmap(HybridBinarizer(source)))?.let {
0, onSuccess(it.text)
bitmap.getWidth(), return
0, }
0,
bitmap.getWidth(), // In QRS mode, skip additional binarizer attempts for performance
bitmap.getHeight(), if (qrsMode) return
)
val source = RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), intArray) // Inverted HybridBinarizer (uses ZXing's native invert)
val result = tryDecode(BinaryBitmap(HybridBinarizer(source.invert())))?.let {
try { onSuccess(it.text)
qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source))) return
} catch (e: NotFoundException) { }
try {
qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert()))) // GlobalHistogramBinarizer (normal)
} catch (ignore: NotFoundException) { tryDecode(BinaryBitmap(GlobalHistogramBinarizer(source)))?.let {
return onSuccess(it.text)
} return
} }
Log.d("ZxingQRCodeAnalyzer", "barcode decode success: ${result.text}")
onSuccess(result.text) // 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) { } catch (e: Exception) {
onFailure(e) onFailure(e)
} finally { } finally {
qrCodeReader.reset()
image.close() 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
}
}
} }

View File

@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.camera.view.PreviewView
android:id="@+id/preview_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<LinearLayout
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?colorSurface"
android:gravity="center">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true" />
</LinearLayout>
<include layout="@layout/view_appbar" />
</FrameLayout>

View File

@@ -159,11 +159,16 @@
<string name="json_viewer">JSON 查看器</string> <string name="json_viewer">JSON 查看器</string>
<string name="json_editor">JSON 编辑器</string> <string name="json_editor">JSON 编辑器</string>
<string name="view_configuration">查看配置</string> <string name="view_configuration">查看配置</string>
<string name="save_as_file">存为文件</string> <string name="save_as_file">存为文件</string>
<string name="share_as_file">分享为文件</string> <string name="share_as_file">分享为文件</string>
<string name="save_content_json">保存配置 JSON 文件</string>
<string name="share_content_json">分享配置 JSON 文件</string>
<string name="unsaved_changes">未保存的更改</string> <string name="unsaved_changes">未保存的更改</string>
<string name="unsaved_changes_message">您有未保存的更改。要放弃它们吗?</string> <string name="unsaved_changes_message">您有未保存的更改。要放弃它们吗?</string>
<string name="profile_qr_code_text">配置文件二维码:%s</string> <string name="profile_qr_code_text">配置文件二维码:%s</string>
<string name="import_profile_confirm_title">导入配置</string>
<string name="import_profile_confirm_message">导入配置「%s」</string>
<string name="import_action">导入</string>
<!-- Groups --> <!-- Groups -->
<string name="group_selected_title">选中</string> <string name="group_selected_title">选中</string>
@@ -372,6 +377,17 @@
<!-- QR Code --> <!-- QR Code -->
<string name="intent_share_qr_code">分享二维码</string> <string name="intent_share_qr_code">分享二维码</string>
<!-- QR Stream (QRS) -->
<string name="share_as_qrs">分享为 QRS</string>
<string name="qrs_progress">接收中:%1$d / %2$d 块</string>
<string name="qrs_speed">速度</string>
<string name="qrs_interval_ms">间隔:%d 毫秒</string>
<string name="qrs_scanning_mode">QRS 模式</string>
<string name="qrs_fps">帧率</string>
<string name="qrs_fps_interval">%d 毫秒)</string>
<string name="qrs_slice_size">分块大小</string>
<string name="qrs_what_is_qrs">什么是 QRS</string>
<!-- Search --> <!-- Search -->
<string name="search_placeholder">在文档中查找</string> <string name="search_placeholder">在文档中查找</string>

View File

@@ -1,5 +1,6 @@
<resources> <resources>
<color name="seed">#d81b60</color> <color name="seed">#d81b60</color>
<color name="surface_80">#CC1C1B1F</color>
<color name="blue_grey_600">#546e7a</color> <color name="blue_grey_600">#546e7a</color>

View File

@@ -163,9 +163,14 @@
<string name="view_configuration">View Configuration</string> <string name="view_configuration">View Configuration</string>
<string name="save_as_file">Save As File</string> <string name="save_as_file">Save As File</string>
<string name="share_as_file">Share As File</string> <string name="share_as_file">Share As File</string>
<string name="save_content_json">Save Content JSON File</string>
<string name="share_content_json">Share Content JSON File</string>
<string name="unsaved_changes">Unsaved Changes</string> <string name="unsaved_changes">Unsaved Changes</string>
<string name="unsaved_changes_message">You have unsaved changes. Do you want to discard them?</string> <string name="unsaved_changes_message">You have unsaved changes. Do you want to discard them?</string>
<string name="profile_qr_code_text">Profile QR Code: %s</string> <string name="profile_qr_code_text">Profile QR Code: %s</string>
<string name="import_profile_confirm_title">Import Profile</string>
<string name="import_profile_confirm_message">Import profile \"%s\"?</string>
<string name="import_action">Import</string>
<!-- Groups --> <!-- Groups -->
<string name="group_selected_title">Selected</string> <string name="group_selected_title">Selected</string>
@@ -377,6 +382,17 @@
<!-- QR Code --> <!-- QR Code -->
<string name="intent_share_qr_code">Share QR Code</string> <string name="intent_share_qr_code">Share QR Code</string>
<!-- QR Stream (QRS) -->
<string name="share_as_qrs">Share as QR Stream</string>
<string name="qrs_progress">Receiving: %1$d / %2$d blocks</string>
<string name="qrs_speed">Speed</string>
<string name="qrs_interval_ms">Interval: %d ms</string>
<string name="qrs_scanning_mode">QR Stream Mode</string>
<string name="qrs_fps">FPS</string>
<string name="qrs_fps_interval">(%d ms)</string>
<string name="qrs_slice_size">Slice Size</string>
<string name="qrs_what_is_qrs">What is QRS</string>
<!-- Search --> <!-- Search -->
<string name="search_placeholder">Find in document</string> <string name="search_placeholder">Find in document</string>

View File

@@ -1,6 +1,6 @@
package io.nekohasekai.sfa.vendor package io.nekohasekai.sfa.vendor
import android.util.Log import android.graphics.Bitmap
import androidx.camera.core.ExperimentalGetImage import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
@@ -17,21 +17,27 @@ class MLKitQRCodeAnalyzer(
) : ImageAnalysis.Analyzer { ) : ImageAnalysis.Analyzer {
private val barcodeScanner = private val barcodeScanner =
BarcodeScanning.getClient( BarcodeScanning.getClient(
BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build(), BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build(),
) )
@Volatile @Volatile
private var failureOccurred = false private var failureOccurred = false
private var failureTimestamp = 0L private var failureTimestamp = 0L
private var pixelBuffer: IntArray? = null
@ExperimentalGetImage @ExperimentalGetImage
override fun analyze(image: ImageProxy) { override fun analyze(image: ImageProxy) {
if (image.image == null) return if (image.image == null) {
image.close()
return
}
val nowMills = System.currentTimeMillis() val nowMills = System.currentTimeMillis()
if (failureOccurred && nowMills - failureTimestamp < 5000L) { if (failureOccurred && nowMills - failureTimestamp < 5000L) {
failureTimestamp = nowMills failureTimestamp = nowMills
Log.d("MLKitQRCodeAnalyzer", "throttled analysis since error occurred in previous pass")
image.close() image.close()
return return
} }
@@ -39,24 +45,56 @@ class MLKitQRCodeAnalyzer(
failureOccurred = false failureOccurred = false
barcodeScanner.process(image.toInputImage()) barcodeScanner.process(image.toInputImage())
.addOnSuccessListener { codes -> .addOnSuccessListener { codes ->
if (codes.isNotEmpty()) { val rawValue = codes.firstOrNull()?.rawValue
val rawValue = codes.firstOrNull()?.rawValue if (rawValue != null) {
if (rawValue != null) { onSuccess(rawValue)
Log.d("MLKitQRCodeAnalyzer", "barcode decode success: $rawValue") image.close()
onSuccess(rawValue) } else {
} tryInvertedScan(image)
} }
} }
.addOnFailureListener { .addOnFailureListener {
failureOccurred = true failureOccurred = true
failureTimestamp = System.currentTimeMillis() failureTimestamp = System.currentTimeMillis()
onFailure(it) onFailure(it)
}
.addOnCompleteListener {
image.close() 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 @ExperimentalGetImage
@Suppress("UnsafeCallOnNullableType") @Suppress("UnsafeCallOnNullableType")
private fun ImageProxy.toInputImage() = InputImage.fromMediaImage(image!!, imageInfo.rotationDegrees) private fun ImageProxy.toInputImage() = InputImage.fromMediaImage(image!!, imageInfo.rotationDegrees)