mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-09-29 10:12:51 +02:00
Support for PubKey QR showing and Scanning.
This commit is contained in:
@@ -110,6 +110,14 @@ dependencies {
|
|||||||
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
|
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
|
||||||
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
|
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
|
||||||
|
|
||||||
|
// For QR generation
|
||||||
|
implementation "com.google.zxing:core:3.5.0"
|
||||||
|
implementation "androidx.camera:camera-camera2:1.2.1"
|
||||||
|
implementation 'androidx.camera:camera-lifecycle:1.2.1'
|
||||||
|
implementation 'androidx.camera:camera-view:1.2.1'
|
||||||
|
implementation 'com.google.mlkit:vision-common:17.3.0'
|
||||||
|
implementation 'com.google.android.gms:play-services-mlkit-barcode-scanning:18.1.0'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||||
|
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -17,15 +18,18 @@
|
|||||||
android:largeHeap="true"
|
android:largeHeap="true"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
|
android:label="Amethyst"
|
||||||
android:name=".ui.MainActivity"
|
android:name=".ui.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask" android:allowTaskReparenting="true"
|
||||||
android:theme="@style/Theme.Amethyst">
|
android:theme="@style/Theme.Amethyst">
|
||||||
<intent-filter>
|
|
||||||
|
<intent-filter android:label="Amethyst">
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter android:label="Amethyst">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
@@ -36,6 +40,10 @@
|
|||||||
android:name="android.app.lib_name"
|
android:name="android.app.lib_name"
|
||||||
android:value="" />
|
android:value="" />
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||||
|
android:value="barcode" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
@@ -1,5 +1,6 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.navigation
|
package com.vitorpamplona.amethyst.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -7,6 +8,9 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -16,6 +20,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.Divider
|
import androidx.compose.material.Divider
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.IconButton
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.ScaffoldState
|
import androidx.compose.material.ScaffoldState
|
||||||
import androidx.compose.material.Surface
|
import androidx.compose.material.Surface
|
||||||
@@ -23,7 +28,10 @@ import androidx.compose.material.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -34,15 +42,35 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.font.FontWeight.Companion.W500
|
import androidx.compose.ui.text.font.FontWeight.Companion.W500
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.compose.rememberAsyncImagePainter
|
import coil.compose.rememberAsyncImagePainter
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
|
import com.google.zxing.qrcode.encoder.Encoder
|
||||||
|
import com.google.zxing.qrcode.encoder.QRCode
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.ZoomableAsyncImage
|
||||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nostr.postr.toNpub
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.RoundRect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import com.google.zxing.qrcode.encoder.ByteMatrix
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathFillType
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DrawerContent(navController: NavHostController,
|
fun DrawerContent(navController: NavHostController,
|
||||||
@@ -54,7 +82,7 @@ fun DrawerContent(navController: NavHostController,
|
|||||||
val account = accountState?.account ?: return
|
val account = accountState?.account ?: return
|
||||||
|
|
||||||
val accountUserState by account.userProfile().live.observeAsState()
|
val accountUserState by account.userProfile().live.observeAsState()
|
||||||
val accountUser = accountUserState?.user
|
val accountUser = accountUserState?.user ?: return
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -106,6 +134,8 @@ fun DrawerContent(navController: NavHostController,
|
|||||||
.weight(1F),
|
.weight(1F),
|
||||||
accountStateViewModel
|
accountStateViewModel
|
||||||
)
|
)
|
||||||
|
|
||||||
|
BottomContent(accountUser, scaffoldState, navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,5 +269,73 @@ fun NavigationRow(navController: NavHostController, scaffoldState: ScaffoldState
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BottomContent(user: User, scaffoldState: ScaffoldState, navController: NavController) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// store the dialog open or close state
|
||||||
|
var dialogOpen by remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier) {
|
||||||
|
Divider(
|
||||||
|
modifier = Modifier.padding(top = 15.dp),
|
||||||
|
thickness = 0.25.dp
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 15.dp),
|
||||||
|
) {
|
||||||
|
/*
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
when (AppCompatDelegate.getDefaultNightMode()) {
|
||||||
|
AppCompatDelegate.MODE_NIGHT_NO -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||||
|
AppCompatDelegate.MODE_NIGHT_YES -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||||
|
else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_theme),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
}*/
|
||||||
|
Box(modifier = Modifier.weight(1F))
|
||||||
|
IconButton(onClick = {
|
||||||
|
dialogOpen = true
|
||||||
|
coroutineScope.launch {
|
||||||
|
scaffoldState.drawerState.close()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_qrcode),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogOpen) {
|
||||||
|
ShowQRDialog(user,
|
||||||
|
onScan = {
|
||||||
|
dialogOpen = false
|
||||||
|
coroutineScope.launch {
|
||||||
|
scaffoldState.drawerState.close()
|
||||||
|
}
|
||||||
|
navController.navigate(it)
|
||||||
|
},
|
||||||
|
onClose = { dialogOpen = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@@ -0,0 +1,245 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.RoundRect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathFillType
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
|
import com.google.zxing.qrcode.encoder.ByteMatrix
|
||||||
|
import com.google.zxing.qrcode.encoder.Encoder
|
||||||
|
import com.google.zxing.qrcode.encoder.QRCode
|
||||||
|
|
||||||
|
|
||||||
|
const val QR_MARGIN_PX = 100f
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QrCodeDrawer(contents: String, modifier: Modifier = Modifier) {
|
||||||
|
val qrCode = remember(contents) {
|
||||||
|
createQrCode(contents = contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
val foregroundColor = MaterialTheme.colors.onSurface
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.defaultMinSize(48.dp, 48.dp)
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Calculate the height and width of each column/row
|
||||||
|
val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height
|
||||||
|
val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width
|
||||||
|
|
||||||
|
// Draw all of the finder patterns required by the QR spec. Calculate the ratio
|
||||||
|
// of the number of rows/columns to the width and height
|
||||||
|
drawQrCodeFinders(
|
||||||
|
sideLength = size.width,
|
||||||
|
finderPatternSize = Size(
|
||||||
|
width = columnWidth * FINDER_PATTERN_ROW_COUNT,
|
||||||
|
height = rowHeight * FINDER_PATTERN_ROW_COUNT
|
||||||
|
),
|
||||||
|
color = foregroundColor
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw data bits (encoded data part)
|
||||||
|
drawAllQrCodeDataBits(
|
||||||
|
bytes = qrCode.matrix,
|
||||||
|
size = Size(
|
||||||
|
width = columnWidth,
|
||||||
|
height = rowHeight
|
||||||
|
),
|
||||||
|
color = foregroundColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private typealias Coordinate = Pair<Int, Int>
|
||||||
|
|
||||||
|
private fun createQrCode(contents: String): QRCode {
|
||||||
|
require(contents.isNotEmpty())
|
||||||
|
|
||||||
|
return Encoder.encode(
|
||||||
|
contents,
|
||||||
|
ErrorCorrectionLevel.Q,
|
||||||
|
mapOf(
|
||||||
|
EncodeHintType.CHARACTER_SET to "UTF-8",
|
||||||
|
EncodeHintType.MARGIN to QR_MARGIN_PX,
|
||||||
|
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun newPath(withPath: Path.() -> Unit) = Path().apply {
|
||||||
|
fillType = PathFillType.EvenOdd
|
||||||
|
withPath(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun DrawScope.drawAllQrCodeDataBits(
|
||||||
|
bytes: ByteMatrix,
|
||||||
|
size: Size,
|
||||||
|
color: Color,
|
||||||
|
) {
|
||||||
|
setOf(
|
||||||
|
// data bits between top left finder pattern and top right finder pattern.
|
||||||
|
Pair(
|
||||||
|
first = Coordinate(first = FINDER_PATTERN_ROW_COUNT, second = 0),
|
||||||
|
second = Coordinate(
|
||||||
|
first = (bytes.width - FINDER_PATTERN_ROW_COUNT),
|
||||||
|
second = FINDER_PATTERN_ROW_COUNT
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// data bits below top left finder pattern and above bottom left finder pattern.
|
||||||
|
Pair(
|
||||||
|
first = Coordinate(first = 0, second = FINDER_PATTERN_ROW_COUNT),
|
||||||
|
second = Coordinate(
|
||||||
|
first = bytes.width,
|
||||||
|
second = bytes.height - FINDER_PATTERN_ROW_COUNT
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// data bits to the right of the bottom left finder pattern.
|
||||||
|
Pair(
|
||||||
|
first = Coordinate(
|
||||||
|
first = FINDER_PATTERN_ROW_COUNT,
|
||||||
|
second = (bytes.height - FINDER_PATTERN_ROW_COUNT)
|
||||||
|
),
|
||||||
|
second = Coordinate(
|
||||||
|
first = bytes.width,
|
||||||
|
second = bytes.height
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).forEach { section ->
|
||||||
|
for (y in section.first.second until section.second.second) {
|
||||||
|
for (x in section.first.first until section.second.first) {
|
||||||
|
if (bytes[x, y] == 1.toByte()) {
|
||||||
|
drawPath(
|
||||||
|
color = color,
|
||||||
|
path = newPath {
|
||||||
|
addRect(
|
||||||
|
rect = Rect(
|
||||||
|
offset = Offset(
|
||||||
|
x = QR_MARGIN_PX + x * size.width,
|
||||||
|
y = QR_MARGIN_PX + y * size.height
|
||||||
|
),
|
||||||
|
size = size
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const val FINDER_PATTERN_ROW_COUNT = 7
|
||||||
|
private const val INTERIOR_EXTERIOR_SHAPE_RATIO = 3f / FINDER_PATTERN_ROW_COUNT
|
||||||
|
private const val INTERIOR_EXTERIOR_OFFSET_RATIO = 2f / FINDER_PATTERN_ROW_COUNT
|
||||||
|
private const val INTERIOR_EXTERIOR_SHAPE_CORNER_RADIUS = 0.12f
|
||||||
|
private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_RATIO = 5f / FINDER_PATTERN_ROW_COUNT
|
||||||
|
private const val INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO = 1f / FINDER_PATTERN_ROW_COUNT
|
||||||
|
private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS = 0.5f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A valid QR code has three finder patterns (top left, top right, bottom left).
|
||||||
|
*
|
||||||
|
* @param qrCodeProperties how the QR code is drawn
|
||||||
|
* @param sideLength length, in pixels, of each side of the QR code
|
||||||
|
* @param finderPatternSize [Size] of each finder patten, based on the QR code spec
|
||||||
|
*/
|
||||||
|
internal fun DrawScope.drawQrCodeFinders(
|
||||||
|
sideLength: Float,
|
||||||
|
finderPatternSize: Size,
|
||||||
|
color: Color
|
||||||
|
) {
|
||||||
|
|
||||||
|
setOf(
|
||||||
|
// Draw top left finder pattern.
|
||||||
|
Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX),
|
||||||
|
// Draw top right finder pattern.
|
||||||
|
Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX),
|
||||||
|
// Draw bottom finder pattern.
|
||||||
|
Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height))
|
||||||
|
).forEach { offset ->
|
||||||
|
drawQrCodeFinder(
|
||||||
|
topLeft = offset,
|
||||||
|
finderPatternSize = finderPatternSize,
|
||||||
|
cornerRadius = CornerRadius.Zero,
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This func is responsible for drawing a single finder pattern, for a QR code
|
||||||
|
*/
|
||||||
|
private fun DrawScope.drawQrCodeFinder(
|
||||||
|
topLeft: Offset,
|
||||||
|
finderPatternSize: Size,
|
||||||
|
cornerRadius: CornerRadius,
|
||||||
|
color: Color
|
||||||
|
) {
|
||||||
|
drawPath(
|
||||||
|
color = color,
|
||||||
|
path = newPath {
|
||||||
|
// Draw the outer rectangle for the finder pattern.
|
||||||
|
addRoundRect(
|
||||||
|
roundRect = RoundRect(
|
||||||
|
rect = Rect(
|
||||||
|
offset = topLeft,
|
||||||
|
size = finderPatternSize
|
||||||
|
),
|
||||||
|
cornerRadius = cornerRadius
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw background for the finder pattern interior (this keeps the arc ratio consistent).
|
||||||
|
val innerBackgroundOffset = Offset(
|
||||||
|
x = finderPatternSize.width * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO,
|
||||||
|
y = finderPatternSize.height * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO
|
||||||
|
)
|
||||||
|
addRoundRect(
|
||||||
|
roundRect = RoundRect(
|
||||||
|
rect = Rect(
|
||||||
|
offset = topLeft + innerBackgroundOffset,
|
||||||
|
size = finderPatternSize * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_RATIO
|
||||||
|
),
|
||||||
|
cornerRadius = cornerRadius * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw the inner rectangle for the finder pattern.
|
||||||
|
val innerRectOffset = Offset(
|
||||||
|
x = finderPatternSize.width * INTERIOR_EXTERIOR_OFFSET_RATIO,
|
||||||
|
y = finderPatternSize.height * INTERIOR_EXTERIOR_OFFSET_RATIO
|
||||||
|
)
|
||||||
|
addRoundRect(
|
||||||
|
roundRect = RoundRect(
|
||||||
|
rect = Rect(
|
||||||
|
offset = topLeft + innerRectOffset,
|
||||||
|
size = finderPatternSize * INTERIOR_EXTERIOR_SHAPE_RATIO
|
||||||
|
),
|
||||||
|
cornerRadius = cornerRadius * INTERIOR_EXTERIOR_SHAPE_CORNER_RADIUS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,167 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.navigation
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.ImageFormat
|
||||||
|
import android.util.Size
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.ImageAnalysis
|
||||||
|
import androidx.camera.core.ImageProxy
|
||||||
|
import androidx.camera.core.Preview
|
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
|
import androidx.camera.view.PreviewView
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
|
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||||
|
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||||
|
import com.google.mlkit.vision.barcode.common.Barcode
|
||||||
|
import com.google.mlkit.vision.common.InputImage
|
||||||
|
import com.vitorpamplona.amethyst.service.Nip19
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QrCodeScanner(onScan: (String) -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
|
||||||
|
|
||||||
|
val cameraExecutor= Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
var hasCameraPermission by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.CAMERA
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
onResult = { granted ->
|
||||||
|
hasCameraPermission = granted
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val analyzer = QRCodeAnalyzer { result ->
|
||||||
|
result?.let {
|
||||||
|
try {
|
||||||
|
val nip19 = Nip19().uriToRoute(it)
|
||||||
|
val startingPage = when (nip19?.type) {
|
||||||
|
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||||
|
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startingPage != null) {
|
||||||
|
onScan(startingPage)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
// QR can be anythign. do not throw errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(key1 = true) {
|
||||||
|
launcher.launch(Manifest.permission.CAMERA)
|
||||||
|
onDispose() {
|
||||||
|
cameraProviderFuture.get().unbindAll()
|
||||||
|
cameraExecutor.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column() {
|
||||||
|
if (hasCameraPermission) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
val previewView = PreviewView(context)
|
||||||
|
|
||||||
|
cameraProviderFuture.addListener({
|
||||||
|
val cameraProvider = cameraProviderFuture.get()
|
||||||
|
bindPreview(analyzer, previewView, cameraExecutor, cameraProvider, lifecycleOwner)
|
||||||
|
}, ContextCompat.getMainExecutor(context))
|
||||||
|
|
||||||
|
return@AndroidView previewView
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bindPreview(
|
||||||
|
analyzer: ImageAnalysis.Analyzer,
|
||||||
|
previewView: PreviewView,
|
||||||
|
cameraExecutor: ExecutorService,
|
||||||
|
cameraProvider: ProcessCameraProvider,
|
||||||
|
lifecycleOwner: LifecycleOwner
|
||||||
|
) {
|
||||||
|
val preview = Preview.Builder().build()
|
||||||
|
|
||||||
|
val selector = CameraSelector.Builder()
|
||||||
|
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
preview.setSurfaceProvider(previewView.surfaceProvider)
|
||||||
|
|
||||||
|
val imageAnalysis = ImageAnalysis.Builder()
|
||||||
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
imageAnalysis.setAnalyzer(
|
||||||
|
cameraExecutor,
|
||||||
|
analyzer
|
||||||
|
)
|
||||||
|
|
||||||
|
cameraProvider.bindToLifecycle(
|
||||||
|
lifecycleOwner,
|
||||||
|
selector,
|
||||||
|
imageAnalysis,
|
||||||
|
preview
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class QRCodeAnalyzer(
|
||||||
|
private val onQrCodeScanned: (result: String?) -> Unit
|
||||||
|
) : ImageAnalysis.Analyzer {
|
||||||
|
|
||||||
|
private val scanningOptions = BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()
|
||||||
|
|
||||||
|
fun scanBarcodes(inputImage: InputImage) {
|
||||||
|
BarcodeScanning.getClient(scanningOptions).process(inputImage)
|
||||||
|
.addOnSuccessListener { barcodes ->
|
||||||
|
if (barcodes.isNotEmpty()) {
|
||||||
|
onQrCodeScanned(barcodes[0].displayValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.addOnFailureListener {
|
||||||
|
it.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
|
||||||
|
override fun analyze(imageProxy: ImageProxy) {
|
||||||
|
imageProxy.image?.let { image ->
|
||||||
|
val inputImage = InputImage.fromMediaImage(image, imageProxy.imageInfo.rotationDegrees)
|
||||||
|
scanBarcodes(inputImage)
|
||||||
|
}
|
||||||
|
imageProxy.close()
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,151 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.ButtonDefaults
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Surface
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.compose.rememberAsyncImagePainter
|
||||||
|
import com.vitorpamplona.amethyst.model.User
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||||
|
import nostr.postr.toNpub
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
|
||||||
|
var presenting by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onClose,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
|
) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(10.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
CloseButton(onCancel = onClose)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(10.dp),
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
if (presenting) {
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = user.profilePicture() ?: "https://robohash.org/ohno.png",
|
||||||
|
contentDescription = "Profile Image",
|
||||||
|
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
|
||||||
|
modifier = Modifier
|
||||||
|
.width(120.dp)
|
||||||
|
.height(120.dp)
|
||||||
|
.clip(shape = CircleShape)
|
||||||
|
.border(3.dp, MaterialTheme.colors.background, CircleShape)
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
user.bestDisplayName() ?: "",
|
||||||
|
modifier = Modifier.padding(top = 7.dp),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(" @${user.bestUsername()}", color = Color.LightGray)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(30.dp)
|
||||||
|
) {
|
||||||
|
QrCodeDrawer("nostr:${user.pubkey.toNpub()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(30.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { presenting = false },
|
||||||
|
shape = RoundedCornerShape(35.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(50.dp),
|
||||||
|
colors = ButtonDefaults
|
||||||
|
.buttonColors(
|
||||||
|
backgroundColor = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(text = "Scan QR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(30.dp)
|
||||||
|
) {
|
||||||
|
QrCodeScanner(onScan)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { presenting = true },
|
||||||
|
shape = RoundedCornerShape(35.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(50.dp),
|
||||||
|
colors = ButtonDefaults
|
||||||
|
.buttonColors(
|
||||||
|
backgroundColor = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(text = "Show QR")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user