Refactor QR scan and share and add QRS support
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
} ?: "{}"
|
||||
} ?: "{}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
334
app/src/main/java/io/nekohasekai/sfa/qrs/LubyCodec.kt
Normal file
334
app/src/main/java/io/nekohasekai/sfa/qrs/LubyCodec.kt
Normal 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++
|
||||
}
|
||||
}
|
||||
}
|
||||
25
app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt
Normal file
25
app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
175
app/src/main/java/io/nekohasekai/sfa/qrs/QRSDecoder.kt
Normal file
175
app/src/main/java/io/nekohasekai/sfa/qrs/QRSDecoder.kt
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
121
app/src/main/java/io/nekohasekai/sfa/qrs/QRSEncoder.kt
Normal file
121
app/src/main/java/io/nekohasekai/sfa/qrs/QRSEncoder.kt
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<resources>
|
||||
<color name="seed">#d81b60</color>
|
||||
<color name="surface_80">#CC1C1B1F</color>
|
||||
|
||||
<color name="blue_grey_600">#546e7a</color>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user