1@file:Suppress("MatchingDeclarationName")
3package soy.iko.opencode.ui.components
5import android.graphics.Bitmap
6import androidx.compose.foundation.Image
7import androidx.compose.foundation.background
8import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Spacer
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.material3.AlertDialog
14import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.Text
16import androidx.compose.material3.TextButton
17import androidx.compose.runtime.Composable
18import androidx.compose.runtime.remember
19import androidx.compose.ui.Alignment
20import androidx.compose.ui.Modifier
21import androidx.compose.ui.graphics.Color
22import androidx.compose.ui.graphics.asImageBitmap
23import androidx.compose.ui.res.stringResource
24import androidx.compose.ui.text.style.TextAlign
25import androidx.compose.ui.unit.dp
26import com.google.zxing.BarcodeFormat
27import com.google.zxing.BinaryBitmap
28import com.google.zxing.DecodeHintType
29import com.google.zxing.MultiFormatReader
30import com.google.zxing.MultiFormatWriter
31import com.google.zxing.RGBLuminanceSource
32import com.google.zxing.common.HybridBinarizer
33import kotlinx.serialization.Serializable
34import kotlinx.serialization.json.Json
35import soy.iko.opencode.R
36import soy.iko.opencode.data.model.ServerProfile
39 * Wire format for sharing a [ServerProfile] as a QR code (and re-importing it). Only the
40 * connection-relevant fields travel — [ServerProfile.id] and [ServerProfile.lastUsed] are
41 * device-local and excluded. Marked with a [v] version tag so a future format change can be
42 * detected and rejected instead of mis-parsed.
45data class ServerProfileQr(
47 val label: String = "",
49 val username: String? = null,
50 val password: String? = null,
51 val requireHttps: Boolean = false,
52 val certPin: String? = null,
55/** Lenient JSON for QR encode/decode (independent of [soy.iko.opencode.data.network.OpencodeJson],
56 * which is tuned for the server's polymorphic payloads). */
57private val qrJson = Json { ignoreUnknownKeys = true; isLenient = true }
59/** Serialize a [ServerProfile] to the QR payload string. */
60fun ServerProfile.toQrPayload(): String = qrJson.encodeToString(
61 ServerProfileQr.serializer(),
67 requireHttps = requireHttps,
72/** Parse a scanned/decoded QR payload back into a [ServerProfileQr], or null if it isn't one of
73 * our payloads (so an arbitrary QR doesn't blow up the import flow). */
74fun parseQrPayload(payload: String): ServerProfileQr? = runCatching {
75 qrJson.decodeFromString(ServerProfileQr.serializer(), payload)
78/** Render a QR code carrying [content] at [sizePx] pixels, or null if encoding fails. Uses ZXing's
79 * core (no camera/UI deps) so it stays a pure-Java encode. */
80fun encodeQrBitmap(content: String, sizePx: Int): Bitmap? = runCatching {
81 val matrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, sizePx, sizePx)
82 val width = matrix.width
83 val height = matrix.height
84 val pixels = IntArray(width * height)
85 for (y in 0 until height) {
86 val offset = y * width
87 for (x in 0 until width) {
88 pixels[offset + x] = if (matrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
91 Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply {
92 setPixels(pixels, 0, width, 0, 0, width, height)
96/** Decode a QR code from a decoded [Bitmap] (e.g. an image the user picked to import a server),
97 * returning its text content or null if no QR was found. */
98fun decodeQrFromBitmap(bitmap: Bitmap): String? = runCatching {
99 val width = bitmap.width
100 val height = bitmap.height
101 val pixels = IntArray(width * height)
102 bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
103 val source = RGBLuminanceSource(width, height, pixels)
104 val binary = BinaryBitmap(HybridBinarizer(source))
105 val reader = MultiFormatReader()
106 reader.setHints(mapOf(DecodeHintType.TRY_HARDER to true))
107 reader.decodeWithState(binary).text
110/** Decode a server-profile QR from an image [uri] (the result of an image picker). Runs Bitmap
111 * decode + ZXing scan on the caller's dispatcher. Returns null if the image couldn't be read or
112 * its QR isn't one of our server payloads. */
113suspend fun decodeServerQr(context: android.content.Context, uri: android.net.Uri): ServerProfileQr? =
114 kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
116 val bitmap = android.provider.MediaStore.Images.Media.getBitmap(context.contentResolver, uri)
117 ?: return@runCatching null
118 decodeQrFromBitmap(bitmap)?.let { parseQrPayload(it) }
122/** A Compose QR code image. The QR is generated off the composition; encoding a few hundred
123 * modules is sub-millisecond so no background dispatching is warranted. Drawn on a white
124 * background (QR scanners need the light module) with a small inner pad so the code isn't flush
125 * with the dialog edges. */
127fun QrImage(content: String, sizePx: Int, modifier: Modifier = Modifier) {
128 val bitmap = remember(content, sizePx) { encodeQrBitmap(content, sizePx) }
129 if (bitmap != null) {
131 bitmap = bitmap.asImageBitmap(),
132 contentDescription = null,
135 .background(Color.White)
141/** Dialog that shows a server profile as a scannable QR code, for transferring a profile (URL +
142 * credentials + cert pin) to another device without re-typing. The payload includes the password
143 * when present; a warning makes that explicit. */
145fun QrShareDialog(profile: ServerProfile, onDismiss: () -> Unit) {
146 val payload = remember(profile.id, profile.baseUrl, profile.password) { profile.toQrPayload() }
148 onDismissRequest = onDismiss,
149 confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.ok)) } },
150 title = { Text(profile.displayLabel) },
153 horizontalAlignment = Alignment.CenterHorizontally,
154 modifier = Modifier.fillMaxWidth(),
156 QrImage(content = payload, sizePx = 256)
157 Spacer(Modifier.size(8.dp))
160 style = MaterialTheme.typography.bodySmall,
161 color = MaterialTheme.colorScheme.onSurfaceVariant,
162 textAlign = TextAlign.Center,
164 if (profile.password != null) {
166 stringResource(R.string.qr_includes_password_warning),
167 style = MaterialTheme.typography.labelSmall,
168 color = MaterialTheme.colorScheme.error,
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list