Refactor QR scan and share and add QRS support
This commit is contained in:
@@ -115,9 +115,6 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name="io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity"
|
android:name="io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
<activity
|
|
||||||
android:name="io.nekohasekai.sfa.ui.profile.QRScanActivity"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".bg.TileService"
|
android:name=".bg.TileService"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class NewProfileActivity : ComponentActivity() {
|
|||||||
const val EXTRA_PROFILE_ID = "profile_id"
|
const val EXTRA_PROFILE_ID = "profile_id"
|
||||||
const val EXTRA_IMPORT_NAME = "import_name"
|
const val EXTRA_IMPORT_NAME = "import_name"
|
||||||
const val EXTRA_IMPORT_URL = "import_url"
|
const val EXTRA_IMPORT_URL = "import_url"
|
||||||
|
const val EXTRA_QRS_DATA = "qrs_data"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -25,6 +26,7 @@ class NewProfileActivity : ComponentActivity() {
|
|||||||
|
|
||||||
val importName = intent.getStringExtra(EXTRA_IMPORT_NAME)
|
val importName = intent.getStringExtra(EXTRA_IMPORT_NAME)
|
||||||
val importUrl = intent.getStringExtra(EXTRA_IMPORT_URL)
|
val importUrl = intent.getStringExtra(EXTRA_IMPORT_URL)
|
||||||
|
val qrsData = intent.getByteArrayExtra(EXTRA_QRS_DATA)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SFATheme {
|
SFATheme {
|
||||||
@@ -35,6 +37,7 @@ class NewProfileActivity : ComponentActivity() {
|
|||||||
NewProfileScreen(
|
NewProfileScreen(
|
||||||
importName = importName,
|
importName = importName,
|
||||||
importUrl = importUrl,
|
importUrl = importUrl,
|
||||||
|
qrsData = qrsData,
|
||||||
onNavigateBack = { finish() },
|
onNavigateBack = { finish() },
|
||||||
onProfileCreated = { profileId ->
|
onProfileCreated = { profileId ->
|
||||||
val resultIntent =
|
val resultIntent =
|
||||||
|
|||||||
@@ -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(
|
fun NewProfileScreen(
|
||||||
importName: String? = null,
|
importName: String? = null,
|
||||||
importUrl: String? = null,
|
importUrl: String? = null,
|
||||||
|
qrsData: ByteArray? = null,
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
onProfileCreated: (profileId: Long) -> Unit,
|
onProfileCreated: (profileId: Long) -> Unit,
|
||||||
viewModel: NewProfileViewModel = viewModel(),
|
viewModel: NewProfileViewModel = viewModel(),
|
||||||
@@ -81,8 +82,12 @@ fun NewProfileScreen(
|
|||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
LaunchedEffect(importName, importUrl) {
|
LaunchedEffect(importName, importUrl, qrsData) {
|
||||||
viewModel.initializeFromQRImport(importName, importUrl)
|
if (qrsData != null) {
|
||||||
|
viewModel.initializeFromQRSImport(importName, qrsData)
|
||||||
|
} else {
|
||||||
|
viewModel.initializeFromQRImport(importName, importUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// File picker launcher
|
// File picker launcher
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ data class NewProfileUiState(
|
|||||||
// File import
|
// File import
|
||||||
val importUri: Uri? = null,
|
val importUri: Uri? = null,
|
||||||
val importFileName: String? = null,
|
val importFileName: String? = null,
|
||||||
|
// QRS import
|
||||||
|
val qrsData: ByteArray? = null,
|
||||||
// State
|
// State
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
@@ -71,6 +73,17 @@ class NewProfileViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun initializeFromQRSImport(name: String?, qrsData: ByteArray) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
name = name ?: "",
|
||||||
|
profileType = ProfileType.Local,
|
||||||
|
profileSource = ProfileSource.Import,
|
||||||
|
qrsData = qrsData,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateName(name: String) {
|
fun updateName(name: String) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -158,7 +171,7 @@ class NewProfileViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
// Validate based on profile type
|
// Validate based on profile type
|
||||||
when (state.profileType) {
|
when (state.profileType) {
|
||||||
ProfileType.Local -> {
|
ProfileType.Local -> {
|
||||||
if (state.profileSource == ProfileSource.Import && state.importUri == null) {
|
if (state.profileSource == ProfileSource.Import && state.importUri == null && state.qrsData == null) {
|
||||||
_uiState.update { it.copy(importError = context.getString(R.string.profile_input_required)) }
|
_uiState.update { it.copy(importError = context.getString(R.string.profile_input_required)) }
|
||||||
hasError = true
|
hasError = true
|
||||||
}
|
}
|
||||||
@@ -234,22 +247,27 @@ class NewProfileViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
when (state.profileSource) {
|
when (state.profileSource) {
|
||||||
ProfileSource.CreateNew -> "{}"
|
ProfileSource.CreateNew -> "{}"
|
||||||
ProfileSource.Import -> {
|
ProfileSource.Import -> {
|
||||||
state.importUri?.let { uri ->
|
if (state.qrsData != null) {
|
||||||
val sourceURL = uri.toString()
|
val content = Libbox.decodeProfileContent(state.qrsData)
|
||||||
when {
|
content.config
|
||||||
sourceURL.startsWith("content://") -> {
|
} else {
|
||||||
val inputStream = context.contentResolver.openInputStream(uri) as InputStream
|
state.importUri?.let { uri ->
|
||||||
inputStream.use { it.bufferedReader().readText() }
|
val sourceURL = uri.toString()
|
||||||
|
when {
|
||||||
|
sourceURL.startsWith("content://") -> {
|
||||||
|
val inputStream = context.contentResolver.openInputStream(uri) as InputStream
|
||||||
|
inputStream.use { it.bufferedReader().readText() }
|
||||||
|
}
|
||||||
|
sourceURL.startsWith("file://") -> {
|
||||||
|
File(Uri.parse(sourceURL).path!!).readText()
|
||||||
|
}
|
||||||
|
sourceURL.startsWith("http://") || sourceURL.startsWith("https://") -> {
|
||||||
|
HTTPClient().use { it.getString(sourceURL) }
|
||||||
|
}
|
||||||
|
else -> throw Exception("Unsupported source: $sourceURL")
|
||||||
}
|
}
|
||||||
sourceURL.startsWith("file://") -> {
|
} ?: "{}"
|
||||||
File(Uri.parse(sourceURL).path!!).readText()
|
}
|
||||||
}
|
|
||||||
sourceURL.startsWith("http://") || sourceURL.startsWith("https://") -> {
|
|
||||||
HTTPClient().use { it.getString(sourceURL) }
|
|
||||||
}
|
|
||||||
else -> throw Exception("Unsupported source: $sourceURL")
|
|
||||||
}
|
|
||||||
} ?: "{}"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ class ProfileImportHandler(private val context: Context) {
|
|||||||
data class Error(val message: String) : QRCodeParseResult()
|
data class Error(val message: String) : QRCodeParseResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed class QRSParseResult {
|
||||||
|
data class Success(val name: String) : QRSParseResult()
|
||||||
|
|
||||||
|
data class Error(val message: String) : QRSParseResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class UriParseResult {
|
||||||
|
data class Success(val name: String) : UriParseResult()
|
||||||
|
|
||||||
|
data class Error(val message: String) : UriParseResult()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun importFromUri(uri: Uri): ImportResult =
|
suspend fun importFromUri(uri: Uri): ImportResult =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
@@ -68,6 +80,38 @@ class ProfileImportHandler(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun parseUri(uri: Uri): UriParseResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val data =
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
?: return@withContext UriParseResult.Error(context.getString(R.string.error_empty_file))
|
||||||
|
|
||||||
|
val filename = getFileNameFromUri(uri)
|
||||||
|
val dataString = String(data)
|
||||||
|
|
||||||
|
if (isJsonConfiguration(dataString)) {
|
||||||
|
return@withContext UriParseResult.Success(name = filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
val content =
|
||||||
|
try {
|
||||||
|
Libbox.decodeProfileContent(data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (dataString.trimStart().startsWith("{") || dataString.trimStart().startsWith("[")) {
|
||||||
|
return@withContext UriParseResult.Success(name = filename)
|
||||||
|
}
|
||||||
|
return@withContext UriParseResult.Error(
|
||||||
|
context.getString(R.string.error_decode_profile, e.message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
UriParseResult.Success(name = content.name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
UriParseResult.Error(e.message ?: "Unknown error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun parseQRCode(data: String): QRCodeParseResult =
|
suspend fun parseQRCode(data: String): QRCodeParseResult =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
@@ -150,6 +194,38 @@ class ProfileImportHandler(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun parseQRSData(data: ByteArray): QRSParseResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val content = try {
|
||||||
|
Libbox.decodeProfileContent(data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return@withContext QRSParseResult.Error(
|
||||||
|
context.getString(R.string.error_decode_profile, e.message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
QRSParseResult.Success(name = content.name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
QRSParseResult.Error(e.message ?: "Unknown error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importFromQRSData(data: ByteArray): ImportResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val content = try {
|
||||||
|
Libbox.decodeProfileContent(data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return@withContext ImportResult.Error(
|
||||||
|
context.getString(R.string.error_decode_profile, e.message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
importProfile(content)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ImportResult.Error(e.message ?: "Unknown error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun importProfile(content: ProfileContent): ImportResult {
|
private suspend fun importProfile(content: ProfileContent): ImportResult {
|
||||||
val typedProfile = TypedProfile()
|
val typedProfile = TypedProfile()
|
||||||
val profile = Profile(name = content.name, typed = typedProfile)
|
val profile = Profile(name = content.name, typed = typedProfile)
|
||||||
|
|||||||
@@ -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
|
package io.nekohasekai.sfa.compose.screen.dashboard
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.nekohasekai.sfa.constant.Status
|
import io.nekohasekai.sfa.constant.Status
|
||||||
@@ -34,8 +33,6 @@ fun DashboardCardRenderer(
|
|||||||
onHideAddProfileSheet: () -> Unit = {},
|
onHideAddProfileSheet: () -> Unit = {},
|
||||||
onShowProfilePickerSheet: () -> Unit = {},
|
onShowProfilePickerSheet: () -> Unit = {},
|
||||||
onHideProfilePickerSheet: () -> Unit = {},
|
onHideProfilePickerSheet: () -> Unit = {},
|
||||||
shareQRCodeImage: (Bitmap, String) -> Unit = { _, _ -> },
|
|
||||||
saveQRCodeToGallery: (Bitmap, String) -> Unit = { _, _ -> },
|
|
||||||
commandClient: CommandClient? = null,
|
commandClient: CommandClient? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -127,8 +124,6 @@ fun DashboardCardRenderer(
|
|||||||
onImportFromFile = { /* Handled in ProfilesCard */ },
|
onImportFromFile = { /* Handled in ProfilesCard */ },
|
||||||
onScanQrCode = { /* Handled in ProfilesCard */ },
|
onScanQrCode = { /* Handled in ProfilesCard */ },
|
||||||
onCreateManually = { /* Handled in ProfilesCard */ },
|
onCreateManually = { /* Handled in ProfilesCard */ },
|
||||||
shareQRCodeImage = shareQRCodeImage,
|
|
||||||
saveQRCodeToGallery = saveQRCodeToGallery,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||||
import io.nekohasekai.sfa.compose.util.saveQRCodeToGallery
|
|
||||||
import io.nekohasekai.sfa.compose.util.shareQRCodeImage
|
|
||||||
import io.nekohasekai.sfa.constant.Status
|
import io.nekohasekai.sfa.constant.Status
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -176,16 +174,6 @@ fun DashboardScreen(
|
|||||||
onHideAddProfileSheet = viewModel::hideAddProfileSheet,
|
onHideAddProfileSheet = viewModel::hideAddProfileSheet,
|
||||||
onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
|
onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
|
||||||
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
|
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
|
||||||
shareQRCodeImage = { bitmap, name ->
|
|
||||||
scope.launch {
|
|
||||||
shareQRCodeImage(context, bitmap, name)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
saveQRCodeToGallery = { bitmap, name ->
|
|
||||||
scope.launch {
|
|
||||||
saveQRCodeToGallery(context, bitmap, name)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
commandClient = viewModel.commandClient,
|
commandClient = viewModel.commandClient,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -225,16 +213,6 @@ fun DashboardScreen(
|
|||||||
onHideAddProfileSheet = viewModel::hideAddProfileSheet,
|
onHideAddProfileSheet = viewModel::hideAddProfileSheet,
|
||||||
onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
|
onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
|
||||||
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
|
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
|
||||||
shareQRCodeImage = { bitmap, name ->
|
|
||||||
scope.launch {
|
|
||||||
shareQRCodeImage(context, bitmap, name)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
saveQRCodeToGallery = { bitmap, name ->
|
|
||||||
scope.launch {
|
|
||||||
saveQRCodeToGallery(context, bitmap, name)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
commandClient = viewModel.commandClient,
|
commandClient = viewModel.commandClient,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package io.nekohasekai.sfa.compose.screen.dashboard
|
package io.nekohasekai.sfa.compose.screen.dashboard
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -45,6 +44,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -60,7 +60,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import io.nekohasekai.libbox.Libbox
|
import io.nekohasekai.libbox.Libbox
|
||||||
import io.nekohasekai.libbox.ProfileContent
|
import io.nekohasekai.libbox.ProfileContent
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.compose.screen.configuration.QRCodeDialog
|
import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog
|
||||||
import io.nekohasekai.sfa.compose.util.ProfileIcons
|
import io.nekohasekai.sfa.compose.util.ProfileIcons
|
||||||
import io.nekohasekai.sfa.compose.util.QRCodeGenerator
|
import io.nekohasekai.sfa.compose.util.QRCodeGenerator
|
||||||
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
|
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
|
||||||
@@ -83,8 +83,6 @@ fun ProfilePickerSheet(
|
|||||||
onProfileDelete: (Profile) -> Unit,
|
onProfileDelete: (Profile) -> Unit,
|
||||||
onProfileMove: (Int, Int) -> Unit,
|
onProfileMove: (Int, Int) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
shareQRCodeImage: suspend (Bitmap, String) -> Unit,
|
|
||||||
saveQRCodeToGallery: suspend (Bitmap, String) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -173,9 +171,8 @@ fun ProfilePickerSheet(
|
|||||||
profile.typed.remoteURL,
|
profile.typed.remoteURL,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val qrBitmap = remember(link) {
|
val surfaceColor = MaterialTheme.colorScheme.surface.toArgb()
|
||||||
QRCodeGenerator.generate(link)
|
val qrBitmap = QRCodeGenerator.rememberPrimaryBitmap(link, backgroundColor = surfaceColor)
|
||||||
}
|
|
||||||
|
|
||||||
QRCodeDialog(
|
QRCodeDialog(
|
||||||
bitmap = qrBitmap,
|
bitmap = qrBitmap,
|
||||||
@@ -183,20 +180,6 @@ fun ProfilePickerSheet(
|
|||||||
showQRCodeDialog = false
|
showQRCodeDialog = false
|
||||||
qrCodeProfile = null
|
qrCodeProfile = null
|
||||||
},
|
},
|
||||||
onShare = {
|
|
||||||
coroutineScope.launch {
|
|
||||||
shareQRCodeImage(qrBitmap, profile.name)
|
|
||||||
}
|
|
||||||
showQRCodeDialog = false
|
|
||||||
qrCodeProfile = null
|
|
||||||
},
|
|
||||||
onSave = {
|
|
||||||
coroutineScope.launch {
|
|
||||||
saveQRCodeToGallery(qrBitmap, profile.name)
|
|
||||||
showQRCodeDialog = false
|
|
||||||
qrCodeProfile = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package io.nekohasekai.sfa.compose.screen.dashboard
|
package io.nekohasekai.sfa.compose.screen.dashboard
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.net.Uri
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.IosShare
|
|||||||
import androidx.compose.material.icons.filled.QrCode2
|
import androidx.compose.material.icons.filled.QrCode2
|
||||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.DataObject
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.material.icons.filled.Save
|
||||||
import androidx.compose.material.icons.outlined.CreateNewFolder
|
import androidx.compose.material.icons.outlined.CreateNewFolder
|
||||||
import androidx.compose.material.icons.outlined.Description
|
import androidx.compose.material.icons.outlined.Description
|
||||||
@@ -37,13 +38,16 @@ import androidx.compose.material3.DropdownMenu
|
|||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -61,18 +65,22 @@ import io.nekohasekai.libbox.Libbox
|
|||||||
import io.nekohasekai.libbox.ProfileContent
|
import io.nekohasekai.libbox.ProfileContent
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.compose.NewProfileActivity
|
import io.nekohasekai.sfa.compose.NewProfileActivity
|
||||||
|
import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog
|
||||||
|
import io.nekohasekai.sfa.compose.component.qr.QRScanSheet
|
||||||
|
import io.nekohasekai.sfa.compose.component.qr.QRSDialog
|
||||||
|
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult
|
||||||
import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler
|
import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler
|
||||||
import io.nekohasekai.sfa.compose.screen.configuration.QRCodeDialog
|
|
||||||
import io.nekohasekai.sfa.compose.util.QRCodeGenerator
|
import io.nekohasekai.sfa.compose.util.QRCodeGenerator
|
||||||
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
|
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
|
||||||
import io.nekohasekai.sfa.database.Profile
|
import io.nekohasekai.sfa.database.Profile
|
||||||
import io.nekohasekai.sfa.database.TypedProfile
|
import io.nekohasekai.sfa.database.TypedProfile
|
||||||
import io.nekohasekai.sfa.ktx.errorDialogBuilder
|
import io.nekohasekai.sfa.ktx.errorDialogBuilder
|
||||||
import io.nekohasekai.sfa.ktx.shareProfile
|
import io.nekohasekai.sfa.ktx.shareProfile
|
||||||
import io.nekohasekai.sfa.ui.profile.QRScanActivity
|
import io.nekohasekai.sfa.ktx.shareProfileAsJson
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -98,8 +106,6 @@ fun ProfilesCard(
|
|||||||
onImportFromFile: () -> Unit,
|
onImportFromFile: () -> Unit,
|
||||||
onScanQrCode: () -> Unit,
|
onScanQrCode: () -> Unit,
|
||||||
onCreateManually: () -> Unit,
|
onCreateManually: () -> Unit,
|
||||||
shareQRCodeImage: suspend (Bitmap, String) -> Unit,
|
|
||||||
saveQRCodeToGallery: suspend (Bitmap, String) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
@@ -109,6 +115,17 @@ fun ProfilesCard(
|
|||||||
var showQRCodeDialog by remember { mutableStateOf(false) }
|
var showQRCodeDialog by remember { mutableStateOf(false) }
|
||||||
var qrCodeProfile by remember { mutableStateOf<Profile?>(null) }
|
var qrCodeProfile by remember { mutableStateOf<Profile?>(null) }
|
||||||
|
|
||||||
|
var showQRSDialog by remember { mutableStateOf(false) }
|
||||||
|
var qrsProfile by remember { mutableStateOf<Profile?>(null) }
|
||||||
|
var qrsProfileData by remember { mutableStateOf<ByteArray?>(null) }
|
||||||
|
|
||||||
|
var showImportConfirmDialog by remember { mutableStateOf(false) }
|
||||||
|
var pendingImportName by remember { mutableStateOf<String?>(null) }
|
||||||
|
var pendingQrsData by remember { mutableStateOf<ByteArray?>(null) }
|
||||||
|
var pendingImportUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
|
||||||
|
var showQRScanSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val newProfileLauncher =
|
val newProfileLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.StartActivityForResult(),
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
@@ -137,62 +154,17 @@ fun ProfilesCard(
|
|||||||
) { uri ->
|
) { uri ->
|
||||||
uri?.let {
|
uri?.let {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
when (val result = importHandler.importFromUri(uri)) {
|
when (val parseResult = importHandler.parseUri(uri)) {
|
||||||
is ProfileImportHandler.ImportResult.Success -> {
|
is ProfileImportHandler.UriParseResult.Success -> {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
onProfileEdit(result.profile)
|
pendingImportName = parseResult.name
|
||||||
|
pendingImportUri = uri
|
||||||
|
showImportConfirmDialog = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is ProfileImportHandler.ImportResult.Error -> {
|
is ProfileImportHandler.UriParseResult.Error -> {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
context.errorDialogBuilder(Exception(result.message)).show()
|
context.errorDialogBuilder(Exception(parseResult.message)).show()
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val scanQrCodeLauncher =
|
|
||||||
rememberLauncherForActivityResult(
|
|
||||||
QRScanActivity.Contract(),
|
|
||||||
) { result ->
|
|
||||||
result?.let { intent ->
|
|
||||||
val data = intent.dataString
|
|
||||||
if (data != null) {
|
|
||||||
coroutineScope.launch {
|
|
||||||
when (val parseResult = importHandler.parseQRCode(data)) {
|
|
||||||
is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
val newProfileIntent =
|
|
||||||
Intent(context, NewProfileActivity::class.java).apply {
|
|
||||||
putExtra(NewProfileActivity.EXTRA_IMPORT_NAME, parseResult.name)
|
|
||||||
putExtra(NewProfileActivity.EXTRA_IMPORT_URL, parseResult.url)
|
|
||||||
}
|
|
||||||
newProfileLauncher.launch(newProfileIntent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ProfileImportHandler.QRCodeParseResult.LocalProfile -> {
|
|
||||||
when (val importResult = importHandler.importFromQRCode(data)) {
|
|
||||||
is ProfileImportHandler.ImportResult.Success -> {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
onProfileEdit(importResult.profile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ProfileImportHandler.ImportResult.Error -> {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
context.errorDialogBuilder(Exception(importResult.message)).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ProfileImportHandler.QRCodeParseResult.Error -> {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
context.errorDialogBuilder(Exception(parseResult.message)).show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,6 +205,39 @@ fun ProfilesCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val saveJsonFileLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("application/json"),
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
val selectedProfile = profiles.find { it.id == selectedProfileId }
|
||||||
|
if (selectedProfile != null) {
|
||||||
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val jsonContent = File(selectedProfile.typed.path).readText()
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
|
outputStream.write(jsonContent.toByteArray())
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.success_profile_saved),
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
"${context.getString(R.string.failed_save_profile)}: ${e.message}",
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(onImportFromFile, onScanQrCode) {
|
LaunchedEffect(onImportFromFile, onScanQrCode) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,12 +339,48 @@ fun ProfilesCard(
|
|||||||
saveFileLauncher.launch("${it.name}.bpf")
|
saveFileLauncher.launch("${it.name}.bpf")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSaveJson = {
|
||||||
|
selectedProfile?.let {
|
||||||
|
saveJsonFileLauncher.launch("${it.name}.json")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShareJson = {
|
||||||
|
selectedProfile?.let {
|
||||||
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
context.shareProfileAsJson(it)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
context.errorDialogBuilder(e).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onShareURL = {
|
onShareURL = {
|
||||||
selectedProfile?.let {
|
selectedProfile?.let {
|
||||||
qrCodeProfile = it
|
qrCodeProfile = it
|
||||||
showQRCodeDialog = true
|
showQRCodeDialog = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onShareQRS = {
|
||||||
|
selectedProfile?.let { profile ->
|
||||||
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val data = createProfileContent(profile)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
qrsProfile = profile
|
||||||
|
qrsProfileData = data
|
||||||
|
showQRSDialog = true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
context.errorDialogBuilder(e).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,8 +395,6 @@ fun ProfilesCard(
|
|||||||
onProfileDelete = onProfileDelete,
|
onProfileDelete = onProfileDelete,
|
||||||
onProfileMove = onProfileMove,
|
onProfileMove = onProfileMove,
|
||||||
onDismiss = onHideProfilePickerSheet,
|
onDismiss = onHideProfilePickerSheet,
|
||||||
shareQRCodeImage = shareQRCodeImage,
|
|
||||||
saveQRCodeToGallery = saveQRCodeToGallery,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +438,7 @@ fun ProfilesCard(
|
|||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
onHideAddProfileSheet()
|
onHideAddProfileSheet()
|
||||||
scanQrCodeLauncher.launch(null)
|
showQRScanSheet = true
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -448,9 +487,8 @@ fun ProfilesCard(
|
|||||||
profile.typed.remoteURL,
|
profile.typed.remoteURL,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val qrBitmap = remember(link) {
|
val surfaceColor = MaterialTheme.colorScheme.surface.toArgb()
|
||||||
QRCodeGenerator.generate(link)
|
val qrBitmap = QRCodeGenerator.rememberPrimaryBitmap(link, backgroundColor = surfaceColor)
|
||||||
}
|
|
||||||
|
|
||||||
QRCodeDialog(
|
QRCodeDialog(
|
||||||
bitmap = qrBitmap,
|
bitmap = qrBitmap,
|
||||||
@@ -458,18 +496,148 @@ fun ProfilesCard(
|
|||||||
showQRCodeDialog = false
|
showQRCodeDialog = false
|
||||||
qrCodeProfile = null
|
qrCodeProfile = null
|
||||||
},
|
},
|
||||||
onShare = {
|
)
|
||||||
coroutineScope.launch {
|
}
|
||||||
shareQRCodeImage(qrBitmap, profile.name)
|
|
||||||
}
|
if (showQRSDialog && qrsProfile != null && qrsProfileData != null) {
|
||||||
showQRCodeDialog = false
|
QRSDialog(
|
||||||
qrCodeProfile = null
|
profileData = qrsProfileData!!,
|
||||||
|
profileName = qrsProfile!!.name,
|
||||||
|
onDismiss = {
|
||||||
|
showQRSDialog = false
|
||||||
|
qrsProfile = null
|
||||||
|
qrsProfileData = null
|
||||||
},
|
},
|
||||||
onSave = {
|
)
|
||||||
coroutineScope.launch {
|
}
|
||||||
saveQRCodeToGallery(qrBitmap, profile.name)
|
|
||||||
showQRCodeDialog = false
|
if (showImportConfirmDialog && pendingImportName != null) {
|
||||||
qrCodeProfile = null
|
AlertDialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
showImportConfirmDialog = false
|
||||||
|
pendingImportName = null
|
||||||
|
pendingQrsData = null
|
||||||
|
pendingImportUri = null
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.import_profile_confirm_title)) },
|
||||||
|
text = { Text(stringResource(R.string.import_profile_confirm_message, pendingImportName!!)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showImportConfirmDialog = false
|
||||||
|
val qrsData = pendingQrsData
|
||||||
|
val importUri = pendingImportUri
|
||||||
|
pendingImportName = null
|
||||||
|
pendingQrsData = null
|
||||||
|
pendingImportUri = null
|
||||||
|
coroutineScope.launch {
|
||||||
|
if (qrsData != null) {
|
||||||
|
when (val result = importHandler.importFromQRSData(qrsData)) {
|
||||||
|
is ProfileImportHandler.ImportResult.Success -> {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onProfileEdit(result.profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ProfileImportHandler.ImportResult.Error -> {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
context.errorDialogBuilder(Exception(result.message)).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (importUri != null) {
|
||||||
|
when (val result = importHandler.importFromUri(importUri)) {
|
||||||
|
is ProfileImportHandler.ImportResult.Success -> {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onProfileEdit(result.profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ProfileImportHandler.ImportResult.Error -> {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
context.errorDialogBuilder(Exception(result.message)).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.import_action))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showImportConfirmDialog = false
|
||||||
|
pendingImportName = null
|
||||||
|
pendingQrsData = null
|
||||||
|
pendingImportUri = null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showQRScanSheet) {
|
||||||
|
QRScanSheet(
|
||||||
|
onDismiss = { showQRScanSheet = false },
|
||||||
|
onScanResult = { result ->
|
||||||
|
showQRScanSheet = false
|
||||||
|
when (result) {
|
||||||
|
is QRScanResult.QRSData -> {
|
||||||
|
coroutineScope.launch {
|
||||||
|
when (val parseResult = importHandler.parseQRSData(result.data)) {
|
||||||
|
is ProfileImportHandler.QRSParseResult.Success -> {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
pendingImportName = parseResult.name
|
||||||
|
pendingQrsData = result.data
|
||||||
|
showImportConfirmDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ProfileImportHandler.QRSParseResult.Error -> {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
context.errorDialogBuilder(Exception(parseResult.message)).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is QRScanResult.RemoteProfile -> {
|
||||||
|
coroutineScope.launch {
|
||||||
|
when (val parseResult = importHandler.parseQRCode(result.uri.toString())) {
|
||||||
|
is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val newProfileIntent =
|
||||||
|
Intent(context, NewProfileActivity::class.java).apply {
|
||||||
|
putExtra(NewProfileActivity.EXTRA_IMPORT_NAME, parseResult.name)
|
||||||
|
putExtra(NewProfileActivity.EXTRA_IMPORT_URL, parseResult.url)
|
||||||
|
}
|
||||||
|
newProfileLauncher.launch(newProfileIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ProfileImportHandler.QRCodeParseResult.LocalProfile -> {
|
||||||
|
when (val importResult = importHandler.importFromQRCode(result.uri.toString())) {
|
||||||
|
is ProfileImportHandler.ImportResult.Success -> {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onProfileEdit(importResult.profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ProfileImportHandler.ImportResult.Error -> {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
context.errorDialogBuilder(Exception(importResult.message)).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ProfileImportHandler.QRCodeParseResult.Error -> {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
context.errorDialogBuilder(Exception(parseResult.message)).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -560,7 +728,10 @@ private fun ProfileActionRow(
|
|||||||
onUpdate: () -> Unit,
|
onUpdate: () -> Unit,
|
||||||
onShareFile: () -> Unit,
|
onShareFile: () -> Unit,
|
||||||
onSaveFile: () -> Unit,
|
onSaveFile: () -> Unit,
|
||||||
|
onSaveJson: () -> Unit,
|
||||||
|
onShareJson: () -> Unit,
|
||||||
onShareURL: () -> Unit,
|
onShareURL: () -> Unit,
|
||||||
|
onShareQRS: () -> Unit,
|
||||||
) {
|
) {
|
||||||
if (profile == null) return
|
if (profile == null) return
|
||||||
|
|
||||||
@@ -591,7 +762,10 @@ private fun ProfileActionRow(
|
|||||||
profile = profile,
|
profile = profile,
|
||||||
onShareFile = onShareFile,
|
onShareFile = onShareFile,
|
||||||
onSaveFile = onSaveFile,
|
onSaveFile = onSaveFile,
|
||||||
|
onSaveJson = onSaveJson,
|
||||||
|
onShareJson = onShareJson,
|
||||||
onShareURL = onShareURL,
|
onShareURL = onShareURL,
|
||||||
|
onShareQRS = onShareQRS,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -643,7 +817,10 @@ private fun ShareButton(
|
|||||||
profile: Profile,
|
profile: Profile,
|
||||||
onShareFile: () -> Unit,
|
onShareFile: () -> Unit,
|
||||||
onSaveFile: () -> Unit,
|
onSaveFile: () -> Unit,
|
||||||
|
onSaveJson: () -> Unit,
|
||||||
|
onShareJson: () -> Unit,
|
||||||
onShareURL: () -> Unit,
|
onShareURL: () -> Unit,
|
||||||
|
onShareQRS: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -686,6 +863,34 @@ private fun ShareButton(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.save_content_json)) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onSaveJson()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.DataObject,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.share_content_json)) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onShareJson()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.DataObject,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
if (profile.typed.type == TypedProfile.Type.Remote) {
|
if (profile.typed.type == TypedProfile.Type.Remote) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.profile_share_url)) },
|
text = { Text(stringResource(R.string.profile_share_url)) },
|
||||||
@@ -702,6 +907,20 @@ private fun ShareButton(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.share_as_qrs)) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onShareQRS()
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.QrCode2,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Bitmap
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import com.google.zxing.BarcodeFormat
|
import com.google.zxing.BarcodeFormat
|
||||||
import com.google.zxing.qrcode.QRCodeWriter
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
|
||||||
object QRCodeGenerator {
|
object QRCodeGenerator {
|
||||||
|
|
||||||
|
private fun luminance(color: Int): Float {
|
||||||
|
val r = Color.red(color) / 255f
|
||||||
|
val g = Color.green(color) / 255f
|
||||||
|
val b = Color.blue(color) / 255f
|
||||||
|
return 0.299f * r + 0.587f * g + 0.114f * b
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adjustBrightness(color: Int, factor: Float): Int {
|
||||||
|
val a = Color.alpha(color)
|
||||||
|
val r = (Color.red(color) * factor).toInt().coerceIn(0, 255)
|
||||||
|
val g = (Color.green(color) * factor).toInt().coerceIn(0, 255)
|
||||||
|
val b = (Color.blue(color) * factor).toInt().coerceIn(0, 255)
|
||||||
|
return Color.argb(a, r, g, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ensureContrast(foreground: Int, background: Int, minRatio: Float = 4.5f): Int {
|
||||||
|
val bgLum = luminance(background)
|
||||||
|
var fg = foreground
|
||||||
|
var fgLum = luminance(fg)
|
||||||
|
|
||||||
|
var ratio = if (fgLum > bgLum) {
|
||||||
|
(fgLum + 0.05f) / (bgLum + 0.05f)
|
||||||
|
} else {
|
||||||
|
(bgLum + 0.05f) / (fgLum + 0.05f)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratio >= minRatio) return fg
|
||||||
|
|
||||||
|
val shouldDarken = bgLum > 0.5f
|
||||||
|
repeat(10) {
|
||||||
|
fg = if (shouldDarken) {
|
||||||
|
adjustBrightness(fg, 0.8f)
|
||||||
|
} else {
|
||||||
|
adjustBrightness(fg, 1.25f)
|
||||||
|
}
|
||||||
|
fgLum = luminance(fg)
|
||||||
|
ratio = if (fgLum > bgLum) {
|
||||||
|
(fgLum + 0.05f) / (bgLum + 0.05f)
|
||||||
|
} else {
|
||||||
|
(bgLum + 0.05f) / (fgLum + 0.05f)
|
||||||
|
}
|
||||||
|
if (ratio >= minRatio) return fg
|
||||||
|
}
|
||||||
|
return fg
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberBitmap(content: String, size: Int = 512): Bitmap {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
return remember(content, isDarkTheme) {
|
||||||
|
generate(
|
||||||
|
content = content,
|
||||||
|
size = size,
|
||||||
|
foregroundColor = if (isDarkTheme) Color.WHITE else Color.BLACK,
|
||||||
|
backgroundColor = Color.TRANSPARENT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberPrimaryBitmap(content: String, size: Int = 512, backgroundColor: Int): Bitmap {
|
||||||
|
val primaryColor = MaterialTheme.colorScheme.primary.toArgb()
|
||||||
|
val safeColor = remember(primaryColor, backgroundColor) {
|
||||||
|
ensureContrast(primaryColor, backgroundColor)
|
||||||
|
}
|
||||||
|
return remember(content, safeColor) {
|
||||||
|
generate(
|
||||||
|
content = content,
|
||||||
|
size = size,
|
||||||
|
foregroundColor = safeColor,
|
||||||
|
backgroundColor = Color.TRANSPARENT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun generate(
|
fun generate(
|
||||||
content: String,
|
content: String,
|
||||||
size: Int = 512,
|
size: Int = 512,
|
||||||
|
|||||||
@@ -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
|
package io.nekohasekai.sfa.ui.profile
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.camera.core.ImageAnalysis
|
import androidx.camera.core.ImageAnalysis
|
||||||
import androidx.camera.core.ImageProxy
|
import androidx.camera.core.ImageProxy
|
||||||
import com.google.zxing.BinaryBitmap
|
import com.google.zxing.BinaryBitmap
|
||||||
|
import com.google.zxing.ChecksumException
|
||||||
|
import com.google.zxing.FormatException
|
||||||
import com.google.zxing.NotFoundException
|
import com.google.zxing.NotFoundException
|
||||||
import com.google.zxing.RGBLuminanceSource
|
import com.google.zxing.PlanarYUVLuminanceSource
|
||||||
|
import com.google.zxing.Result
|
||||||
import com.google.zxing.common.GlobalHistogramBinarizer
|
import com.google.zxing.common.GlobalHistogramBinarizer
|
||||||
|
import com.google.zxing.common.HybridBinarizer
|
||||||
import com.google.zxing.qrcode.QRCodeReader
|
import com.google.zxing.qrcode.QRCodeReader
|
||||||
|
|
||||||
class ZxingQRCodeAnalyzer(
|
class ZxingQRCodeAnalyzer(
|
||||||
@@ -14,37 +17,78 @@ class ZxingQRCodeAnalyzer(
|
|||||||
private val onFailure: ((Exception) -> Unit),
|
private val onFailure: ((Exception) -> Unit),
|
||||||
) : ImageAnalysis.Analyzer {
|
) : ImageAnalysis.Analyzer {
|
||||||
private val qrCodeReader = QRCodeReader()
|
private val qrCodeReader = QRCodeReader()
|
||||||
|
private var yDataBuffer: ByteArray? = null
|
||||||
|
|
||||||
|
var qrsMode: Boolean = false
|
||||||
|
|
||||||
override fun analyze(image: ImageProxy) {
|
override fun analyze(image: ImageProxy) {
|
||||||
try {
|
try {
|
||||||
val bitmap = image.toBitmap()
|
val source = image.toYUVSource()
|
||||||
val intArray = IntArray(bitmap.getWidth() * bitmap.getHeight())
|
|
||||||
bitmap.getPixels(
|
// Fast path: HybridBinarizer
|
||||||
intArray,
|
tryDecode(BinaryBitmap(HybridBinarizer(source)))?.let {
|
||||||
0,
|
onSuccess(it.text)
|
||||||
bitmap.getWidth(),
|
return
|
||||||
0,
|
}
|
||||||
0,
|
|
||||||
bitmap.getWidth(),
|
// In QRS mode, skip additional binarizer attempts for performance
|
||||||
bitmap.getHeight(),
|
if (qrsMode) return
|
||||||
)
|
|
||||||
val source = RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), intArray)
|
// Inverted HybridBinarizer (uses ZXing's native invert)
|
||||||
val result =
|
tryDecode(BinaryBitmap(HybridBinarizer(source.invert())))?.let {
|
||||||
try {
|
onSuccess(it.text)
|
||||||
qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source)))
|
return
|
||||||
} catch (e: NotFoundException) {
|
}
|
||||||
try {
|
|
||||||
qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())))
|
// GlobalHistogramBinarizer (normal)
|
||||||
} catch (ignore: NotFoundException) {
|
tryDecode(BinaryBitmap(GlobalHistogramBinarizer(source)))?.let {
|
||||||
return
|
onSuccess(it.text)
|
||||||
}
|
return
|
||||||
}
|
}
|
||||||
Log.d("ZxingQRCodeAnalyzer", "barcode decode success: ${result.text}")
|
|
||||||
onSuccess(result.text)
|
// GlobalHistogramBinarizer (inverted)
|
||||||
|
tryDecode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())))?.let {
|
||||||
|
onSuccess(it.text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e: NotFoundException) {
|
||||||
|
// No QR code found in frame, ignore
|
||||||
|
} catch (e: ChecksumException) {
|
||||||
|
// Checksum error, ignore
|
||||||
|
} catch (e: FormatException) {
|
||||||
|
// Format error, ignore
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
onFailure(e)
|
onFailure(e)
|
||||||
} finally {
|
} finally {
|
||||||
|
qrCodeReader.reset()
|
||||||
image.close()
|
image.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ImageProxy.toYUVSource(): PlanarYUVLuminanceSource {
|
||||||
|
val yPlane = planes[0]
|
||||||
|
val yBuffer = yPlane.buffer
|
||||||
|
val rowStride = yPlane.rowStride
|
||||||
|
val size = width * height
|
||||||
|
|
||||||
|
val yData = yDataBuffer?.takeIf { it.size >= size } ?: ByteArray(size).also { yDataBuffer = it }
|
||||||
|
if (rowStride == width) {
|
||||||
|
yBuffer.get(yData, 0, size)
|
||||||
|
} else {
|
||||||
|
for (row in 0 until height) {
|
||||||
|
yBuffer.position(row * rowStride)
|
||||||
|
yBuffer.get(yData, row * width, width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PlanarYUVLuminanceSource(yData, width, height, 0, 0, width, height, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryDecode(bitmap: BinaryBitmap): Result? {
|
||||||
|
return try {
|
||||||
|
qrCodeReader.decode(bitmap)
|
||||||
|
} catch (_: NotFoundException) {
|
||||||
|
qrCodeReader.reset()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_viewer">JSON 查看器</string>
|
||||||
<string name="json_editor">JSON 编辑器</string>
|
<string name="json_editor">JSON 编辑器</string>
|
||||||
<string name="view_configuration">查看配置</string>
|
<string name="view_configuration">查看配置</string>
|
||||||
<string name="save_as_file">另存为文件</string>
|
<string name="save_as_file">保存为文件</string>
|
||||||
<string name="share_as_file">分享为文件</string>
|
<string name="share_as_file">分享为文件</string>
|
||||||
|
<string name="save_content_json">保存配置 JSON 文件</string>
|
||||||
|
<string name="share_content_json">分享配置 JSON 文件</string>
|
||||||
<string name="unsaved_changes">未保存的更改</string>
|
<string name="unsaved_changes">未保存的更改</string>
|
||||||
<string name="unsaved_changes_message">您有未保存的更改。要放弃它们吗?</string>
|
<string name="unsaved_changes_message">您有未保存的更改。要放弃它们吗?</string>
|
||||||
<string name="profile_qr_code_text">配置文件二维码:%s</string>
|
<string name="profile_qr_code_text">配置文件二维码:%s</string>
|
||||||
|
<string name="import_profile_confirm_title">导入配置</string>
|
||||||
|
<string name="import_profile_confirm_message">导入配置「%s」?</string>
|
||||||
|
<string name="import_action">导入</string>
|
||||||
|
|
||||||
<!-- Groups -->
|
<!-- Groups -->
|
||||||
<string name="group_selected_title">选中</string>
|
<string name="group_selected_title">选中</string>
|
||||||
@@ -372,6 +377,17 @@
|
|||||||
<!-- QR Code -->
|
<!-- QR Code -->
|
||||||
<string name="intent_share_qr_code">分享二维码</string>
|
<string name="intent_share_qr_code">分享二维码</string>
|
||||||
|
|
||||||
|
<!-- QR Stream (QRS) -->
|
||||||
|
<string name="share_as_qrs">分享为 QRS</string>
|
||||||
|
<string name="qrs_progress">接收中:%1$d / %2$d 块</string>
|
||||||
|
<string name="qrs_speed">速度</string>
|
||||||
|
<string name="qrs_interval_ms">间隔:%d 毫秒</string>
|
||||||
|
<string name="qrs_scanning_mode">QRS 模式</string>
|
||||||
|
<string name="qrs_fps">帧率</string>
|
||||||
|
<string name="qrs_fps_interval">(%d 毫秒)</string>
|
||||||
|
<string name="qrs_slice_size">分块大小</string>
|
||||||
|
<string name="qrs_what_is_qrs">什么是 QRS</string>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<string name="search_placeholder">在文档中查找</string>
|
<string name="search_placeholder">在文档中查找</string>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="seed">#d81b60</color>
|
<color name="seed">#d81b60</color>
|
||||||
|
<color name="surface_80">#CC1C1B1F</color>
|
||||||
|
|
||||||
<color name="blue_grey_600">#546e7a</color>
|
<color name="blue_grey_600">#546e7a</color>
|
||||||
|
|
||||||
|
|||||||
@@ -163,9 +163,14 @@
|
|||||||
<string name="view_configuration">View Configuration</string>
|
<string name="view_configuration">View Configuration</string>
|
||||||
<string name="save_as_file">Save As File</string>
|
<string name="save_as_file">Save As File</string>
|
||||||
<string name="share_as_file">Share As File</string>
|
<string name="share_as_file">Share As File</string>
|
||||||
|
<string name="save_content_json">Save Content JSON File</string>
|
||||||
|
<string name="share_content_json">Share Content JSON File</string>
|
||||||
<string name="unsaved_changes">Unsaved Changes</string>
|
<string name="unsaved_changes">Unsaved Changes</string>
|
||||||
<string name="unsaved_changes_message">You have unsaved changes. Do you want to discard them?</string>
|
<string name="unsaved_changes_message">You have unsaved changes. Do you want to discard them?</string>
|
||||||
<string name="profile_qr_code_text">Profile QR Code: %s</string>
|
<string name="profile_qr_code_text">Profile QR Code: %s</string>
|
||||||
|
<string name="import_profile_confirm_title">Import Profile</string>
|
||||||
|
<string name="import_profile_confirm_message">Import profile \"%s\"?</string>
|
||||||
|
<string name="import_action">Import</string>
|
||||||
|
|
||||||
<!-- Groups -->
|
<!-- Groups -->
|
||||||
<string name="group_selected_title">Selected</string>
|
<string name="group_selected_title">Selected</string>
|
||||||
@@ -377,6 +382,17 @@
|
|||||||
<!-- QR Code -->
|
<!-- QR Code -->
|
||||||
<string name="intent_share_qr_code">Share QR Code</string>
|
<string name="intent_share_qr_code">Share QR Code</string>
|
||||||
|
|
||||||
|
<!-- QR Stream (QRS) -->
|
||||||
|
<string name="share_as_qrs">Share as QR Stream</string>
|
||||||
|
<string name="qrs_progress">Receiving: %1$d / %2$d blocks</string>
|
||||||
|
<string name="qrs_speed">Speed</string>
|
||||||
|
<string name="qrs_interval_ms">Interval: %d ms</string>
|
||||||
|
<string name="qrs_scanning_mode">QR Stream Mode</string>
|
||||||
|
<string name="qrs_fps">FPS</string>
|
||||||
|
<string name="qrs_fps_interval">(%d ms)</string>
|
||||||
|
<string name="qrs_slice_size">Slice Size</string>
|
||||||
|
<string name="qrs_what_is_qrs">What is QRS</string>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<string name="search_placeholder">Find in document</string>
|
<string name="search_placeholder">Find in document</string>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package io.nekohasekai.sfa.vendor
|
package io.nekohasekai.sfa.vendor
|
||||||
|
|
||||||
import android.util.Log
|
import android.graphics.Bitmap
|
||||||
import androidx.camera.core.ExperimentalGetImage
|
import androidx.camera.core.ExperimentalGetImage
|
||||||
import androidx.camera.core.ImageAnalysis
|
import androidx.camera.core.ImageAnalysis
|
||||||
import androidx.camera.core.ImageProxy
|
import androidx.camera.core.ImageProxy
|
||||||
@@ -17,21 +17,27 @@ class MLKitQRCodeAnalyzer(
|
|||||||
) : ImageAnalysis.Analyzer {
|
) : ImageAnalysis.Analyzer {
|
||||||
private val barcodeScanner =
|
private val barcodeScanner =
|
||||||
BarcodeScanning.getClient(
|
BarcodeScanning.getClient(
|
||||||
BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build(),
|
BarcodeScannerOptions.Builder()
|
||||||
|
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||||
|
.build(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var failureOccurred = false
|
private var failureOccurred = false
|
||||||
private var failureTimestamp = 0L
|
private var failureTimestamp = 0L
|
||||||
|
|
||||||
|
private var pixelBuffer: IntArray? = null
|
||||||
|
|
||||||
@ExperimentalGetImage
|
@ExperimentalGetImage
|
||||||
override fun analyze(image: ImageProxy) {
|
override fun analyze(image: ImageProxy) {
|
||||||
if (image.image == null) return
|
if (image.image == null) {
|
||||||
|
image.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val nowMills = System.currentTimeMillis()
|
val nowMills = System.currentTimeMillis()
|
||||||
if (failureOccurred && nowMills - failureTimestamp < 5000L) {
|
if (failureOccurred && nowMills - failureTimestamp < 5000L) {
|
||||||
failureTimestamp = nowMills
|
failureTimestamp = nowMills
|
||||||
Log.d("MLKitQRCodeAnalyzer", "throttled analysis since error occurred in previous pass")
|
|
||||||
image.close()
|
image.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -39,24 +45,56 @@ class MLKitQRCodeAnalyzer(
|
|||||||
failureOccurred = false
|
failureOccurred = false
|
||||||
barcodeScanner.process(image.toInputImage())
|
barcodeScanner.process(image.toInputImage())
|
||||||
.addOnSuccessListener { codes ->
|
.addOnSuccessListener { codes ->
|
||||||
if (codes.isNotEmpty()) {
|
val rawValue = codes.firstOrNull()?.rawValue
|
||||||
val rawValue = codes.firstOrNull()?.rawValue
|
if (rawValue != null) {
|
||||||
if (rawValue != null) {
|
onSuccess(rawValue)
|
||||||
Log.d("MLKitQRCodeAnalyzer", "barcode decode success: $rawValue")
|
image.close()
|
||||||
onSuccess(rawValue)
|
} else {
|
||||||
}
|
tryInvertedScan(image)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.addOnFailureListener {
|
.addOnFailureListener {
|
||||||
failureOccurred = true
|
failureOccurred = true
|
||||||
failureTimestamp = System.currentTimeMillis()
|
failureTimestamp = System.currentTimeMillis()
|
||||||
onFailure(it)
|
onFailure(it)
|
||||||
}
|
|
||||||
.addOnCompleteListener {
|
|
||||||
image.close()
|
image.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun tryInvertedScan(image: ImageProxy) {
|
||||||
|
val inverted = image.toInvertedBitmap()
|
||||||
|
barcodeScanner.process(InputImage.fromBitmap(inverted, image.imageInfo.rotationDegrees))
|
||||||
|
.addOnSuccessListener { codes ->
|
||||||
|
codes.firstOrNull()?.rawValue?.let { onSuccess(it) }
|
||||||
|
}
|
||||||
|
.addOnCompleteListener {
|
||||||
|
inverted.recycle()
|
||||||
|
image.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ImageProxy.toInvertedBitmap(): Bitmap {
|
||||||
|
val yPlane = planes[0]
|
||||||
|
val yBuffer = yPlane.buffer.duplicate()
|
||||||
|
val rowStride = yPlane.rowStride
|
||||||
|
val width = width
|
||||||
|
val height = height
|
||||||
|
val size = width * height
|
||||||
|
|
||||||
|
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
val pixels = pixelBuffer?.takeIf { it.size >= size } ?: IntArray(size).also { pixelBuffer = it }
|
||||||
|
|
||||||
|
for (row in 0 until height) {
|
||||||
|
yBuffer.position(row * rowStride)
|
||||||
|
for (col in 0 until width) {
|
||||||
|
val y = 255 - (yBuffer.get().toInt() and 0xFF)
|
||||||
|
pixels[row * width + col] = (0xFF shl 24) or (y shl 16) or (y shl 8) or y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
@ExperimentalGetImage
|
@ExperimentalGetImage
|
||||||
@Suppress("UnsafeCallOnNullableType")
|
@Suppress("UnsafeCallOnNullableType")
|
||||||
private fun ImageProxy.toInputImage() = InputImage.fromMediaImage(image!!, imageInfo.rotationDegrees)
|
private fun ImageProxy.toInputImage() = InputImage.fromMediaImage(image!!, imageInfo.rotationDegrees)
|
||||||
|
|||||||
Reference in New Issue
Block a user