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
android:name="io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity"
android:exported="false" />
<activity
android:name="io.nekohasekai.sfa.ui.profile.QRScanActivity"
android:exported="false" />
<service
android:name=".bg.TileService"

View File

@@ -17,6 +17,7 @@ class NewProfileActivity : ComponentActivity() {
const val EXTRA_PROFILE_ID = "profile_id"
const val EXTRA_IMPORT_NAME = "import_name"
const val EXTRA_IMPORT_URL = "import_url"
const val EXTRA_QRS_DATA = "qrs_data"
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -25,6 +26,7 @@ class NewProfileActivity : ComponentActivity() {
val importName = intent.getStringExtra(EXTRA_IMPORT_NAME)
val importUrl = intent.getStringExtra(EXTRA_IMPORT_URL)
val qrsData = intent.getByteArrayExtra(EXTRA_QRS_DATA)
setContent {
SFATheme {
@@ -35,6 +37,7 @@ class NewProfileActivity : ComponentActivity() {
NewProfileScreen(
importName = importName,
importUrl = importUrl,
qrsData = qrsData,
onNavigateBack = { finish() },
onProfileCreated = { profileId ->
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(
importName: String? = null,
importUrl: String? = null,
qrsData: ByteArray? = null,
onNavigateBack: () -> Unit,
onProfileCreated: (profileId: Long) -> Unit,
viewModel: NewProfileViewModel = viewModel(),
@@ -81,8 +82,12 @@ fun NewProfileScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(importName, importUrl) {
viewModel.initializeFromQRImport(importName, importUrl)
LaunchedEffect(importName, importUrl, qrsData) {
if (qrsData != null) {
viewModel.initializeFromQRSImport(importName, qrsData)
} else {
viewModel.initializeFromQRImport(importName, importUrl)
}
}
// File picker launcher

View File

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

View File

@@ -31,6 +31,18 @@ class ProfileImportHandler(private val context: Context) {
data class Error(val message: String) : QRCodeParseResult()
}
sealed class QRSParseResult {
data class Success(val name: String) : QRSParseResult()
data class Error(val message: String) : QRSParseResult()
}
sealed class UriParseResult {
data class Success(val name: String) : UriParseResult()
data class Error(val message: String) : UriParseResult()
}
suspend fun importFromUri(uri: Uri): ImportResult =
withContext(Dispatchers.IO) {
try {
@@ -68,6 +80,38 @@ class ProfileImportHandler(private val context: Context) {
}
}
suspend fun parseUri(uri: Uri): UriParseResult =
withContext(Dispatchers.IO) {
try {
val data =
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: return@withContext UriParseResult.Error(context.getString(R.string.error_empty_file))
val filename = getFileNameFromUri(uri)
val dataString = String(data)
if (isJsonConfiguration(dataString)) {
return@withContext UriParseResult.Success(name = filename)
}
val content =
try {
Libbox.decodeProfileContent(data)
} catch (e: Exception) {
if (dataString.trimStart().startsWith("{") || dataString.trimStart().startsWith("[")) {
return@withContext UriParseResult.Success(name = filename)
}
return@withContext UriParseResult.Error(
context.getString(R.string.error_decode_profile, e.message),
)
}
UriParseResult.Success(name = content.name)
} catch (e: Exception) {
UriParseResult.Error(e.message ?: "Unknown error")
}
}
suspend fun parseQRCode(data: String): QRCodeParseResult =
withContext(Dispatchers.IO) {
try {
@@ -150,6 +194,38 @@ class ProfileImportHandler(private val context: Context) {
}
}
suspend fun parseQRSData(data: ByteArray): QRSParseResult =
withContext(Dispatchers.IO) {
try {
val content = try {
Libbox.decodeProfileContent(data)
} catch (e: Exception) {
return@withContext QRSParseResult.Error(
context.getString(R.string.error_decode_profile, e.message),
)
}
QRSParseResult.Success(name = content.name)
} catch (e: Exception) {
QRSParseResult.Error(e.message ?: "Unknown error")
}
}
suspend fun importFromQRSData(data: ByteArray): ImportResult =
withContext(Dispatchers.IO) {
try {
val content = try {
Libbox.decodeProfileContent(data)
} catch (e: Exception) {
return@withContext ImportResult.Error(
context.getString(R.string.error_decode_profile, e.message),
)
}
importProfile(content)
} catch (e: Exception) {
ImportResult.Error(e.message ?: "Unknown error")
}
}
private suspend fun importProfile(content: ProfileContent): ImportResult {
val typedProfile = TypedProfile()
val profile = Profile(name = content.name, typed = typedProfile)

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
package io.nekohasekai.sfa.compose.screen.dashboard
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.IosShare
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.DataObject
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.outlined.CreateNewFolder
import androidx.compose.material.icons.outlined.Description
@@ -37,13 +38,16 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.toArgb
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -61,18 +65,22 @@ import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.ProfileContent
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.NewProfileActivity
import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog
import io.nekohasekai.sfa.compose.component.qr.QRScanSheet
import io.nekohasekai.sfa.compose.component.qr.QRSDialog
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult
import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler
import io.nekohasekai.sfa.compose.screen.configuration.QRCodeDialog
import io.nekohasekai.sfa.compose.util.QRCodeGenerator
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.TypedProfile
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ktx.shareProfile
import io.nekohasekai.sfa.ui.profile.QRScanActivity
import io.nekohasekai.sfa.ktx.shareProfileAsJson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -98,8 +106,6 @@ fun ProfilesCard(
onImportFromFile: () -> Unit,
onScanQrCode: () -> Unit,
onCreateManually: () -> Unit,
shareQRCodeImage: suspend (Bitmap, String) -> Unit,
saveQRCodeToGallery: suspend (Bitmap, String) -> Unit,
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
@@ -109,6 +115,17 @@ fun ProfilesCard(
var showQRCodeDialog by remember { mutableStateOf(false) }
var qrCodeProfile by remember { mutableStateOf<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 =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
@@ -137,62 +154,17 @@ fun ProfilesCard(
) { uri ->
uri?.let {
coroutineScope.launch {
when (val result = importHandler.importFromUri(uri)) {
is ProfileImportHandler.ImportResult.Success -> {
when (val parseResult = importHandler.parseUri(uri)) {
is ProfileImportHandler.UriParseResult.Success -> {
withContext(Dispatchers.Main) {
onProfileEdit(result.profile)
pendingImportName = parseResult.name
pendingImportUri = uri
showImportConfirmDialog = true
}
}
is ProfileImportHandler.ImportResult.Error -> {
is ProfileImportHandler.UriParseResult.Error -> {
withContext(Dispatchers.Main) {
context.errorDialogBuilder(Exception(result.message)).show()
}
}
}
}
}
}
val scanQrCodeLauncher =
rememberLauncherForActivityResult(
QRScanActivity.Contract(),
) { result ->
result?.let { intent ->
val data = intent.dataString
if (data != null) {
coroutineScope.launch {
when (val parseResult = importHandler.parseQRCode(data)) {
is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> {
withContext(Dispatchers.Main) {
val newProfileIntent =
Intent(context, NewProfileActivity::class.java).apply {
putExtra(NewProfileActivity.EXTRA_IMPORT_NAME, parseResult.name)
putExtra(NewProfileActivity.EXTRA_IMPORT_URL, parseResult.url)
}
newProfileLauncher.launch(newProfileIntent)
}
}
is ProfileImportHandler.QRCodeParseResult.LocalProfile -> {
when (val importResult = importHandler.importFromQRCode(data)) {
is ProfileImportHandler.ImportResult.Success -> {
withContext(Dispatchers.Main) {
onProfileEdit(importResult.profile)
}
}
is ProfileImportHandler.ImportResult.Error -> {
withContext(Dispatchers.Main) {
context.errorDialogBuilder(Exception(importResult.message)).show()
}
}
}
}
is ProfileImportHandler.QRCodeParseResult.Error -> {
withContext(Dispatchers.Main) {
context.errorDialogBuilder(Exception(parseResult.message)).show()
}
context.errorDialogBuilder(Exception(parseResult.message)).show()
}
}
}
@@ -233,6 +205,39 @@ fun ProfilesCard(
}
}
val saveJsonFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json"),
) { uri ->
if (uri != null) {
val selectedProfile = profiles.find { it.id == selectedProfileId }
if (selectedProfile != null) {
coroutineScope.launch(Dispatchers.IO) {
try {
val jsonContent = File(selectedProfile.typed.path).readText()
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(jsonContent.toByteArray())
}
withContext(Dispatchers.Main) {
Toast.makeText(
context,
context.getString(R.string.success_profile_saved),
Toast.LENGTH_SHORT,
).show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(
context,
"${context.getString(R.string.failed_save_profile)}: ${e.message}",
Toast.LENGTH_SHORT,
).show()
}
}
}
}
}
}
LaunchedEffect(onImportFromFile, onScanQrCode) {
}
@@ -334,12 +339,48 @@ fun ProfilesCard(
saveFileLauncher.launch("${it.name}.bpf")
}
},
onSaveJson = {
selectedProfile?.let {
saveJsonFileLauncher.launch("${it.name}.json")
}
},
onShareJson = {
selectedProfile?.let {
coroutineScope.launch(Dispatchers.IO) {
try {
context.shareProfileAsJson(it)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
context.errorDialogBuilder(e).show()
}
}
}
}
},
onShareURL = {
selectedProfile?.let {
qrCodeProfile = it
showQRCodeDialog = true
}
},
onShareQRS = {
selectedProfile?.let { profile ->
coroutineScope.launch(Dispatchers.IO) {
try {
val data = createProfileContent(profile)
withContext(Dispatchers.Main) {
qrsProfile = profile
qrsProfileData = data
showQRSDialog = true
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
context.errorDialogBuilder(e).show()
}
}
}
}
},
)
}
}
@@ -354,8 +395,6 @@ fun ProfilesCard(
onProfileDelete = onProfileDelete,
onProfileMove = onProfileMove,
onDismiss = onHideProfilePickerSheet,
shareQRCodeImage = shareQRCodeImage,
saveQRCodeToGallery = saveQRCodeToGallery,
)
}
@@ -399,7 +438,7 @@ fun ProfilesCard(
ListItem(
modifier = Modifier.clickable {
onHideAddProfileSheet()
scanQrCodeLauncher.launch(null)
showQRScanSheet = true
},
leadingContent = {
Icon(
@@ -448,9 +487,8 @@ fun ProfilesCard(
profile.typed.remoteURL,
)
}
val qrBitmap = remember(link) {
QRCodeGenerator.generate(link)
}
val surfaceColor = MaterialTheme.colorScheme.surface.toArgb()
val qrBitmap = QRCodeGenerator.rememberPrimaryBitmap(link, backgroundColor = surfaceColor)
QRCodeDialog(
bitmap = qrBitmap,
@@ -458,18 +496,148 @@ fun ProfilesCard(
showQRCodeDialog = false
qrCodeProfile = null
},
onShare = {
coroutineScope.launch {
shareQRCodeImage(qrBitmap, profile.name)
}
showQRCodeDialog = false
qrCodeProfile = null
)
}
if (showQRSDialog && qrsProfile != null && qrsProfileData != null) {
QRSDialog(
profileData = qrsProfileData!!,
profileName = qrsProfile!!.name,
onDismiss = {
showQRSDialog = false
qrsProfile = null
qrsProfileData = null
},
onSave = {
coroutineScope.launch {
saveQRCodeToGallery(qrBitmap, profile.name)
showQRCodeDialog = false
qrCodeProfile = null
)
}
if (showImportConfirmDialog && pendingImportName != null) {
AlertDialog(
onDismissRequest = {
showImportConfirmDialog = false
pendingImportName = null
pendingQrsData = null
pendingImportUri = null
},
title = { Text(stringResource(R.string.import_profile_confirm_title)) },
text = { Text(stringResource(R.string.import_profile_confirm_message, pendingImportName!!)) },
confirmButton = {
TextButton(
onClick = {
showImportConfirmDialog = false
val qrsData = pendingQrsData
val importUri = pendingImportUri
pendingImportName = null
pendingQrsData = null
pendingImportUri = null
coroutineScope.launch {
if (qrsData != null) {
when (val result = importHandler.importFromQRSData(qrsData)) {
is ProfileImportHandler.ImportResult.Success -> {
withContext(Dispatchers.Main) {
onProfileEdit(result.profile)
}
}
is ProfileImportHandler.ImportResult.Error -> {
withContext(Dispatchers.Main) {
context.errorDialogBuilder(Exception(result.message)).show()
}
}
}
} else if (importUri != null) {
when (val result = importHandler.importFromUri(importUri)) {
is ProfileImportHandler.ImportResult.Success -> {
withContext(Dispatchers.Main) {
onProfileEdit(result.profile)
}
}
is ProfileImportHandler.ImportResult.Error -> {
withContext(Dispatchers.Main) {
context.errorDialogBuilder(Exception(result.message)).show()
}
}
}
}
}
},
) {
Text(stringResource(R.string.import_action))
}
},
dismissButton = {
TextButton(
onClick = {
showImportConfirmDialog = false
pendingImportName = null
pendingQrsData = null
pendingImportUri = null
},
) {
Text(stringResource(R.string.cancel))
}
},
)
}
if (showQRScanSheet) {
QRScanSheet(
onDismiss = { showQRScanSheet = false },
onScanResult = { result ->
showQRScanSheet = false
when (result) {
is QRScanResult.QRSData -> {
coroutineScope.launch {
when (val parseResult = importHandler.parseQRSData(result.data)) {
is ProfileImportHandler.QRSParseResult.Success -> {
withContext(Dispatchers.Main) {
pendingImportName = parseResult.name
pendingQrsData = result.data
showImportConfirmDialog = true
}
}
is ProfileImportHandler.QRSParseResult.Error -> {
withContext(Dispatchers.Main) {
context.errorDialogBuilder(Exception(parseResult.message)).show()
}
}
}
}
}
is QRScanResult.RemoteProfile -> {
coroutineScope.launch {
when (val parseResult = importHandler.parseQRCode(result.uri.toString())) {
is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> {
withContext(Dispatchers.Main) {
val newProfileIntent =
Intent(context, NewProfileActivity::class.java).apply {
putExtra(NewProfileActivity.EXTRA_IMPORT_NAME, parseResult.name)
putExtra(NewProfileActivity.EXTRA_IMPORT_URL, parseResult.url)
}
newProfileLauncher.launch(newProfileIntent)
}
}
is ProfileImportHandler.QRCodeParseResult.LocalProfile -> {
when (val importResult = importHandler.importFromQRCode(result.uri.toString())) {
is ProfileImportHandler.ImportResult.Success -> {
withContext(Dispatchers.Main) {
onProfileEdit(importResult.profile)
}
}
is ProfileImportHandler.ImportResult.Error -> {
withContext(Dispatchers.Main) {
context.errorDialogBuilder(Exception(importResult.message)).show()
}
}
}
}
is ProfileImportHandler.QRCodeParseResult.Error -> {
withContext(Dispatchers.Main) {
context.errorDialogBuilder(Exception(parseResult.message)).show()
}
}
}
}
}
}
},
)
@@ -560,7 +728,10 @@ private fun ProfileActionRow(
onUpdate: () -> Unit,
onShareFile: () -> Unit,
onSaveFile: () -> Unit,
onSaveJson: () -> Unit,
onShareJson: () -> Unit,
onShareURL: () -> Unit,
onShareQRS: () -> Unit,
) {
if (profile == null) return
@@ -591,7 +762,10 @@ private fun ProfileActionRow(
profile = profile,
onShareFile = onShareFile,
onSaveFile = onSaveFile,
onSaveJson = onSaveJson,
onShareJson = onShareJson,
onShareURL = onShareURL,
onShareQRS = onShareQRS,
)
}
}
@@ -643,7 +817,10 @@ private fun ShareButton(
profile: Profile,
onShareFile: () -> Unit,
onSaveFile: () -> Unit,
onSaveJson: () -> Unit,
onShareJson: () -> Unit,
onShareURL: () -> Unit,
onShareQRS: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
@@ -686,6 +863,34 @@ private fun ShareButton(
)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.save_content_json)) },
onClick = {
expanded = false
onSaveJson()
},
leadingIcon = {
Icon(
imageVector = Icons.Default.DataObject,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.share_content_json)) },
onClick = {
expanded = false
onShareJson()
},
leadingIcon = {
Icon(
imageVector = Icons.Default.DataObject,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
)
if (profile.typed.type == TypedProfile.Type.Remote) {
DropdownMenuItem(
text = { Text(stringResource(R.string.profile_share_url)) },
@@ -702,6 +907,20 @@ private fun ShareButton(
},
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.share_as_qrs)) },
onClick = {
expanded = false
onShareQRS()
},
leadingIcon = {
Icon(
imageVector = Icons.Default.QrCode2,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
)
}
}
}

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.Color
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.toArgb
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
object QRCodeGenerator {
private fun luminance(color: Int): Float {
val r = Color.red(color) / 255f
val g = Color.green(color) / 255f
val b = Color.blue(color) / 255f
return 0.299f * r + 0.587f * g + 0.114f * b
}
private fun adjustBrightness(color: Int, factor: Float): Int {
val a = Color.alpha(color)
val r = (Color.red(color) * factor).toInt().coerceIn(0, 255)
val g = (Color.green(color) * factor).toInt().coerceIn(0, 255)
val b = (Color.blue(color) * factor).toInt().coerceIn(0, 255)
return Color.argb(a, r, g, b)
}
fun ensureContrast(foreground: Int, background: Int, minRatio: Float = 4.5f): Int {
val bgLum = luminance(background)
var fg = foreground
var fgLum = luminance(fg)
var ratio = if (fgLum > bgLum) {
(fgLum + 0.05f) / (bgLum + 0.05f)
} else {
(bgLum + 0.05f) / (fgLum + 0.05f)
}
if (ratio >= minRatio) return fg
val shouldDarken = bgLum > 0.5f
repeat(10) {
fg = if (shouldDarken) {
adjustBrightness(fg, 0.8f)
} else {
adjustBrightness(fg, 1.25f)
}
fgLum = luminance(fg)
ratio = if (fgLum > bgLum) {
(fgLum + 0.05f) / (bgLum + 0.05f)
} else {
(bgLum + 0.05f) / (fgLum + 0.05f)
}
if (ratio >= minRatio) return fg
}
return fg
}
@Composable
fun rememberBitmap(content: String, size: Int = 512): Bitmap {
val isDarkTheme = isSystemInDarkTheme()
return remember(content, isDarkTheme) {
generate(
content = content,
size = size,
foregroundColor = if (isDarkTheme) Color.WHITE else Color.BLACK,
backgroundColor = Color.TRANSPARENT,
)
}
}
@Composable
fun rememberPrimaryBitmap(content: String, size: Int = 512, backgroundColor: Int): Bitmap {
val primaryColor = MaterialTheme.colorScheme.primary.toArgb()
val safeColor = remember(primaryColor, backgroundColor) {
ensureContrast(primaryColor, backgroundColor)
}
return remember(content, safeColor) {
generate(
content = content,
size = size,
foregroundColor = safeColor,
backgroundColor = Color.TRANSPARENT,
)
}
}
fun generate(
content: String,
size: Int = 512,

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
import android.util.Log
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.google.zxing.BinaryBitmap
import com.google.zxing.ChecksumException
import com.google.zxing.FormatException
import com.google.zxing.NotFoundException
import com.google.zxing.RGBLuminanceSource
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.Result
import com.google.zxing.common.GlobalHistogramBinarizer
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader
class ZxingQRCodeAnalyzer(
@@ -14,37 +17,78 @@ class ZxingQRCodeAnalyzer(
private val onFailure: ((Exception) -> Unit),
) : ImageAnalysis.Analyzer {
private val qrCodeReader = QRCodeReader()
private var yDataBuffer: ByteArray? = null
var qrsMode: Boolean = false
override fun analyze(image: ImageProxy) {
try {
val bitmap = image.toBitmap()
val intArray = IntArray(bitmap.getWidth() * bitmap.getHeight())
bitmap.getPixels(
intArray,
0,
bitmap.getWidth(),
0,
0,
bitmap.getWidth(),
bitmap.getHeight(),
)
val source = RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), intArray)
val result =
try {
qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source)))
} catch (e: NotFoundException) {
try {
qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())))
} catch (ignore: NotFoundException) {
return
}
}
Log.d("ZxingQRCodeAnalyzer", "barcode decode success: ${result.text}")
onSuccess(result.text)
val source = image.toYUVSource()
// Fast path: HybridBinarizer
tryDecode(BinaryBitmap(HybridBinarizer(source)))?.let {
onSuccess(it.text)
return
}
// In QRS mode, skip additional binarizer attempts for performance
if (qrsMode) return
// Inverted HybridBinarizer (uses ZXing's native invert)
tryDecode(BinaryBitmap(HybridBinarizer(source.invert())))?.let {
onSuccess(it.text)
return
}
// GlobalHistogramBinarizer (normal)
tryDecode(BinaryBitmap(GlobalHistogramBinarizer(source)))?.let {
onSuccess(it.text)
return
}
// GlobalHistogramBinarizer (inverted)
tryDecode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())))?.let {
onSuccess(it.text)
return
}
} catch (e: NotFoundException) {
// No QR code found in frame, ignore
} catch (e: ChecksumException) {
// Checksum error, ignore
} catch (e: FormatException) {
// Format error, ignore
} catch (e: Exception) {
onFailure(e)
} finally {
qrCodeReader.reset()
image.close()
}
}
private fun ImageProxy.toYUVSource(): PlanarYUVLuminanceSource {
val yPlane = planes[0]
val yBuffer = yPlane.buffer
val rowStride = yPlane.rowStride
val size = width * height
val yData = yDataBuffer?.takeIf { it.size >= size } ?: ByteArray(size).also { yDataBuffer = it }
if (rowStride == width) {
yBuffer.get(yData, 0, size)
} else {
for (row in 0 until height) {
yBuffer.position(row * rowStride)
yBuffer.get(yData, row * width, width)
}
}
return PlanarYUVLuminanceSource(yData, width, height, 0, 0, width, height, false)
}
private fun tryDecode(bitmap: BinaryBitmap): Result? {
return try {
qrCodeReader.decode(bitmap)
} catch (_: NotFoundException) {
qrCodeReader.reset()
null
}
}
}

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_editor">JSON 编辑器</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="save_content_json">保存配置 JSON 文件</string>
<string name="share_content_json">分享配置 JSON 文件</string>
<string name="unsaved_changes">未保存的更改</string>
<string name="unsaved_changes_message">您有未保存的更改。要放弃它们吗?</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 -->
<string name="group_selected_title">选中</string>
@@ -372,6 +377,17 @@
<!-- QR Code -->
<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 -->
<string name="search_placeholder">在文档中查找</string>

View File

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

View File

@@ -163,9 +163,14 @@
<string name="view_configuration">View Configuration</string>
<string name="save_as_file">Save 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_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="import_profile_confirm_title">Import Profile</string>
<string name="import_profile_confirm_message">Import profile \"%s\"?</string>
<string name="import_action">Import</string>
<!-- Groups -->
<string name="group_selected_title">Selected</string>
@@ -377,6 +382,17 @@
<!-- QR Code -->
<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 -->
<string name="search_placeholder">Find in document</string>

View File

@@ -1,6 +1,6 @@
package io.nekohasekai.sfa.vendor
import android.util.Log
import android.graphics.Bitmap
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
@@ -17,21 +17,27 @@ class MLKitQRCodeAnalyzer(
) : ImageAnalysis.Analyzer {
private val barcodeScanner =
BarcodeScanning.getClient(
BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build(),
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build(),
)
@Volatile
private var failureOccurred = false
private var failureTimestamp = 0L
private var pixelBuffer: IntArray? = null
@ExperimentalGetImage
override fun analyze(image: ImageProxy) {
if (image.image == null) return
if (image.image == null) {
image.close()
return
}
val nowMills = System.currentTimeMillis()
if (failureOccurred && nowMills - failureTimestamp < 5000L) {
failureTimestamp = nowMills
Log.d("MLKitQRCodeAnalyzer", "throttled analysis since error occurred in previous pass")
image.close()
return
}
@@ -39,24 +45,56 @@ class MLKitQRCodeAnalyzer(
failureOccurred = false
barcodeScanner.process(image.toInputImage())
.addOnSuccessListener { codes ->
if (codes.isNotEmpty()) {
val rawValue = codes.firstOrNull()?.rawValue
if (rawValue != null) {
Log.d("MLKitQRCodeAnalyzer", "barcode decode success: $rawValue")
onSuccess(rawValue)
}
val rawValue = codes.firstOrNull()?.rawValue
if (rawValue != null) {
onSuccess(rawValue)
image.close()
} else {
tryInvertedScan(image)
}
}
.addOnFailureListener {
failureOccurred = true
failureTimestamp = System.currentTimeMillis()
onFailure(it)
}
.addOnCompleteListener {
image.close()
}
}
private fun tryInvertedScan(image: ImageProxy) {
val inverted = image.toInvertedBitmap()
barcodeScanner.process(InputImage.fromBitmap(inverted, image.imageInfo.rotationDegrees))
.addOnSuccessListener { codes ->
codes.firstOrNull()?.rawValue?.let { onSuccess(it) }
}
.addOnCompleteListener {
inverted.recycle()
image.close()
}
}
private fun ImageProxy.toInvertedBitmap(): Bitmap {
val yPlane = planes[0]
val yBuffer = yPlane.buffer.duplicate()
val rowStride = yPlane.rowStride
val width = width
val height = height
val size = width * height
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val pixels = pixelBuffer?.takeIf { it.size >= size } ?: IntArray(size).also { pixelBuffer = it }
for (row in 0 until height) {
yBuffer.position(row * rowStride)
for (col in 0 until width) {
val y = 255 - (yBuffer.get().toInt() and 0xFF)
pixels[row * width + col] = (0xFF shl 24) or (y shl 16) or (y shl 8) or y
}
}
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
return bitmap
}
@ExperimentalGetImage
@Suppress("UnsafeCallOnNullableType")
private fun ImageProxy.toInputImage() = InputImage.fromMediaImage(image!!, imageInfo.rotationDegrees)