diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt new file mode 100644 index 000000000..b5d91f705 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt @@ -0,0 +1,101 @@ +package com.vitorpamplona.amethyst.ui.components + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat.startActivity +import com.vitorpamplona.amethyst.R + +@Composable +fun InvoicePreview(lnInvoice: String) { + val amount = LnInvoiceUtil.getAmountInSats(lnInvoice) + + val context = LocalContext.current + + Column(modifier = Modifier + .fillMaxWidth() + .padding(start = 30.dp, end = 30.dp) + .clip(shape = RoundedCornerShape(10.dp)) + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) + ) { + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(30.dp) + ) { + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + ) { + Icon( + painter = painterResource(R.drawable.lightning), + null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified + ) + + Text( + text = "Lightning Invoice", + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp) + ) + } + + Divider() + + Text( + text = "${amount.toInt()} sats", + fontSize = 25.sp, + fontWeight = FontWeight.W500, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + ) + + Button( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + onClick = { + runCatching { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning://$lnInvoice")) + startActivity(context, intent, null) + } + }, + shape = RoundedCornerShape(15.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = "Pay", color = Color.White, fontSize = 20.sp) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LnInvoiceUtil.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LnInvoiceUtil.kt new file mode 100644 index 000000000..6beb91e54 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/LnInvoiceUtil.kt @@ -0,0 +1,156 @@ +package com.vitorpamplona.amethyst.ui.components + +import java.math.BigDecimal +import java.util.Locale +import java.util.regex.Pattern + +/** based on litecoinj */ +object LnInvoiceUtil { + private val invoicePattern = Pattern.compile("lnbc((?\\d+)(?[munp])?)?1[^1\\s]+") + + /** The Bech32 character set for encoding. */ + private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + /** The Bech32 character set for decoding. */ + private val CHARSET_REV = byteArrayOf( + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + 15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1, + -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, + 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1, + -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, + 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1 + ) + + + /** Find the polynomial with value coefficients mod the generator as 30-bit. */ + private fun polymod(values: ByteArray): Int { + var c = 1 + for (v_i in values) { + val c0 = c ushr 25 and 0xff + c = c and 0x1ffffff shl 5 xor (v_i.toInt() and 0xff) + if (c0 and 1 != 0) c = c xor 0x3b6a57b2 + if (c0 and 2 != 0) c = c xor 0x26508e6d + if (c0 and 4 != 0) c = c xor 0x1ea119fa + if (c0 and 8 != 0) c = c xor 0x3d4233dd + if (c0 and 16 != 0) c = c xor 0x2a1462b3 + } + return c + } + + /** Expand a HRP for use in checksum computation. */ + private fun expandHrp(hrp: String): ByteArray { + val hrpLength = hrp.length + val ret = ByteArray(hrpLength * 2 + 1) + for (i in 0 until hrpLength) { + val c = hrp[i].code and 0x7f // Limit to standard 7-bit ASCII + ret[i] = (c ushr 5 and 0x07).toByte() + ret[i + hrpLength + 1] = (c and 0x1f).toByte() + } + ret[hrpLength] = 0 + return ret + } + + /** Verify a checksum. */ + private fun verifyChecksum(hrp: String, values: ByteArray): Boolean { + val hrpExpanded: ByteArray = expandHrp(hrp) + val combined = ByteArray(hrpExpanded.size + values.size) + System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.size) + System.arraycopy(values, 0, combined, hrpExpanded.size, values.size) + return polymod(combined) == 1 + } + + class AddressFormatException(message: String): Exception(message) { + + } + + fun decodeUnlimitedLength(invoice: String): Boolean { + var lower = false + var upper = false + for (i in 0 until invoice.length) { + val c = invoice[i] + if (c.code < 33 || c.code > 126) throw AddressFormatException("Invalid character: $c, pos: $i") + if (c in 'a'..'z') { + if (upper) throw AddressFormatException("Invalid character: $c, pos: $i") + lower = true + } + if (c in 'A'..'Z') { + if (lower) throw AddressFormatException("Invalid character: $c, pos: $i") + upper = true + } + } + val pos = invoice.lastIndexOf('1') + if (pos < 1) throw AddressFormatException("Missing human-readable part") + val dataPartLength = invoice.length - 1 - pos + if (dataPartLength < 6) throw AddressFormatException("Data part too short: $dataPartLength") + val values = ByteArray(dataPartLength) + for (i in 0 until dataPartLength) { + val c = invoice[i + pos + 1] + if (CHARSET_REV.get(c.code).toInt() == -1) throw AddressFormatException("Invalid character: " + c + ", pos: " + (i + pos + 1)) + values[i] = CHARSET_REV.get(c.code) + } + val hrp = invoice.substring(0, pos).lowercase(Locale.ROOT) + if (!verifyChecksum(hrp, values)) throw AddressFormatException("Invalid Checksum") + return true + } + + /** + * Parses invoice amount according to + * https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md#human-readable-part + * @return invoice amount in bitcoins, zero if the invoice has no amount + * @throws RuntimeException if invoice format is incorrect + */ + fun getAmount(invoice: String): BigDecimal { + try { + decodeUnlimitedLength(invoice) // checksum must match + } catch (e: AddressFormatException) { + throw IllegalArgumentException("Cannot decode invoice", e) + } + + val matcher = invoicePattern.matcher(invoice) + require(matcher.matches()) { "Failed to match HRP pattern" } + val amountGroup = matcher.group("amount") + val multiplierGroup = matcher.group("multiplier") + if (amountGroup == null) { + return BigDecimal.ZERO + } + val amount = BigDecimal(amountGroup) + if (multiplierGroup == null) { + return amount + } + require(!(multiplierGroup == "p" && amountGroup[amountGroup.length - 1] != '0')) { "sub-millisatoshi amount" } + return amount.multiply(multiplier(multiplierGroup)) + } + + fun getAmountInSats(invoice: String): BigDecimal { + return getAmount(invoice).multiply(BigDecimal(100000000)) + } + + private fun multiplier(multiplier: String): BigDecimal { + return when (multiplier) { + "m" -> BigDecimal("0.001") + "u" -> BigDecimal("0.000001") + "n" -> BigDecimal("0.000000001") + "p" -> BigDecimal("0.000000000001") + else -> throw IllegalArgumentException("Invalid multiplier: $multiplier") + } + } + + /** + * Finds LN invoice in the provided input string and returns it. + * For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx" + * It will only return the first invoice found in the input. + * + * @return the invoice if it was found. null for null input or if no invoice is found + */ + fun findInvoice(input: String?): String? { + if (input == null) { + return null + } + val matcher = invoicePattern.matcher(input) + return if (matcher.find()) { + matcher.group() + } else null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 81dc3678b..2f4c0e39f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.components +import android.util.Patterns import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -27,6 +28,10 @@ val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm)$") val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$") val tagIndex = Pattern.compile("\\#\\[([0-9]*)\\]") +val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_-]+)") +val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_-]+)") +val urlPattern: Pattern = Patterns.WEB_URL + fun isValidURL(url: String?): Boolean { return try { URL(url).toURI() @@ -47,7 +52,10 @@ fun RichTextViewer(content: String, tags: List>?) { FlowRow() { paragraph.split(' ').forEach { word: String -> // Explicit URL - if (isValidURL(word)) { + val lnInvoice = LnInvoiceUtil.findInvoice(word) + if (lnInvoice != null) { + InvoicePreview(lnInvoice) + } else if (isValidURL(word)) { val removedParamsFromUrl = word.split("?")[0].toLowerCase() if (imageExtension.matcher(removedParamsFromUrl).matches()) { AsyncImage( diff --git a/app/src/main/res/drawable/lightning.xml b/app/src/main/res/drawable/lightning.xml new file mode 100644 index 000000000..4c0d3a694 --- /dev/null +++ b/app/src/main/res/drawable/lightning.xml @@ -0,0 +1,12 @@ + + + +