Release Vexta Wallet v0.6.0
This commit is contained in:
parent
e513de0d7c
commit
680a81cbe2
5 changed files with 833 additions and 13 deletions
|
|
@ -22,8 +22,8 @@ android {
|
|||
applicationId = "org.vextaproject.wallet"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 29
|
||||
versionName = "0.5.1"
|
||||
versionCode = 30
|
||||
versionName = "0.6.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
|
@ -55,6 +55,8 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
implementation("androidx.fragment:fragment-ktx:1.8.9")
|
||||
implementation("org.bitcoinj:bitcoinj-core:0.16.3")
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
implementation(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
|
|
@ -29,6 +31,15 @@
|
|||
android:noHistory="true"
|
||||
android:excludeFromRecents="true" />
|
||||
|
||||
<service
|
||||
android:name=".WalletMonitoringService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Monitors Vexta wallet for incoming blockchain payments while the app is in the background." />
|
||||
</service>
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||
android:value="barcode_ui" />
|
||||
|
|
|
|||
594
app/src/main/java/org/vextaproject/wallet/HeaderSync.kt
Normal file
594
app/src/main/java/org/vextaproject/wallet/HeaderSync.kt
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
package org.vextaproject.wallet
|
||||
|
||||
import android.content.Context
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
import java.math.BigInteger
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.MessageDigest
|
||||
import kotlin.random.Random
|
||||
|
||||
object HeaderSync {
|
||||
|
||||
private val MAGIC = byteArrayOf(0x56, 0x54, 0x58, 0x31)
|
||||
private const val PORT = 19333
|
||||
private const val PROTOCOL_VERSION = 70018
|
||||
|
||||
private const val GENESIS_HASH =
|
||||
"0000027626959d894ae1c8a6f9050cdcbdf58eb0037ab06345cb5502955370f1"
|
||||
|
||||
private const val GENESIS_TIME = 1782770400L
|
||||
private const val GENESIS_BITS = 0x1e0ffff0L
|
||||
|
||||
private const val AVERAGING_WINDOW = 10
|
||||
private const val TARGET_SPACING = 600L
|
||||
private const val TARGET_TIMESPAN = AVERAGING_WINDOW * TARGET_SPACING
|
||||
private const val MIN_TIMESPAN = TARGET_TIMESPAN * 92 / 100
|
||||
private const val MAX_TIMESPAN = TARGET_TIMESPAN * 116 / 100
|
||||
|
||||
private val POW_LIMIT =
|
||||
BigInteger.ONE.shiftLeft(256).subtract(BigInteger.ONE).shiftRight(20)
|
||||
|
||||
data class VersionInfo(
|
||||
val protocol: Int,
|
||||
val height: Int,
|
||||
val userAgent: String
|
||||
)
|
||||
|
||||
data class ChainHeader(
|
||||
val height: Int,
|
||||
val raw: ByteArray?,
|
||||
val hashWire: ByteArray,
|
||||
val hashDisplay: String,
|
||||
val time: Long,
|
||||
val bits: Long
|
||||
)
|
||||
|
||||
data class SyncResult(
|
||||
val received: Int,
|
||||
val cachedBefore: Int,
|
||||
val chainHeight: Int,
|
||||
val lastHash: String
|
||||
)
|
||||
|
||||
fun loadCachedChain(context: Context): MutableList<ChainHeader> {
|
||||
val chain = mutableListOf(
|
||||
ChainHeader(
|
||||
height = 0,
|
||||
raw = null,
|
||||
hashWire = hashHexToWireBytes(GENESIS_HASH),
|
||||
hashDisplay = GENESIS_HASH,
|
||||
time = GENESIS_TIME,
|
||||
bits = GENESIS_BITS
|
||||
)
|
||||
)
|
||||
|
||||
val file = File(context.filesDir, "vexta-headers.dat")
|
||||
|
||||
if (!file.exists()) {
|
||||
return chain
|
||||
}
|
||||
|
||||
val data = file.readBytes()
|
||||
|
||||
if (data.size % 80 != 0) {
|
||||
file.delete()
|
||||
return chain
|
||||
}
|
||||
|
||||
try {
|
||||
var offset = 0
|
||||
|
||||
while (offset < data.size) {
|
||||
val raw = data.copyOfRange(offset, offset + 80)
|
||||
appendVerifiedHeader(chain, raw)
|
||||
offset += 80
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
file.delete()
|
||||
return mutableListOf(chain.first())
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
fun saveChain(context: Context, chain: List<ChainHeader>) {
|
||||
val file = File(context.filesDir, "vexta-headers.dat")
|
||||
val temporary = File(context.filesDir, "vexta-headers.tmp")
|
||||
|
||||
temporary.outputStream().use { output ->
|
||||
chain.drop(1).forEach { header ->
|
||||
output.write(
|
||||
header.raw
|
||||
?: throw IllegalStateException("missing stored header")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
|
||||
if (!temporary.renameTo(file)) {
|
||||
throw IllegalStateException("unable to save header database")
|
||||
}
|
||||
}
|
||||
|
||||
fun syncFromPeer(
|
||||
host: String,
|
||||
chain: MutableList<ChainHeader>
|
||||
): SyncResult {
|
||||
Socket().use { socket ->
|
||||
socket.soTimeout = 15000
|
||||
socket.connect(InetSocketAddress(host, PORT), 5000)
|
||||
|
||||
val input = DataInputStream(socket.getInputStream())
|
||||
val output = socket.getOutputStream()
|
||||
|
||||
sendMessage(output, "version", createVersionPayload(host))
|
||||
|
||||
var versionInfo: VersionInfo? = null
|
||||
var gotVerack = false
|
||||
var requested = false
|
||||
val cachedBefore = chain.last().height
|
||||
|
||||
repeat(200) {
|
||||
val message = readMessage(input)
|
||||
|
||||
when (message.first) {
|
||||
"version" -> {
|
||||
versionInfo = parseVersionPayload(message.second)
|
||||
sendMessage(output, "verack", byteArrayOf())
|
||||
}
|
||||
|
||||
"verack" -> gotVerack = true
|
||||
|
||||
"ping" -> sendMessage(output, "pong", message.second)
|
||||
|
||||
"headers" -> {
|
||||
val received = parseHeaders(message.second, chain)
|
||||
val remote = versionInfo
|
||||
?: throw IllegalStateException("missing node version")
|
||||
|
||||
if (chain.last().height > remote.height) {
|
||||
throw IllegalStateException(
|
||||
"local verified chain exceeds peer height"
|
||||
)
|
||||
}
|
||||
|
||||
return SyncResult(
|
||||
received = received,
|
||||
cachedBefore = cachedBefore,
|
||||
chainHeight = chain.last().height,
|
||||
lastHash = chain.last().hashDisplay
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (versionInfo != null && gotVerack && !requested) {
|
||||
sendMessage(
|
||||
output,
|
||||
"getheaders",
|
||||
createGetHeadersPayload(chain.last().hashWire)
|
||||
)
|
||||
requested = true
|
||||
}
|
||||
}
|
||||
|
||||
throw IllegalStateException("headers response timeout")
|
||||
}
|
||||
}
|
||||
|
||||
fun parseHeaders(
|
||||
payload: ByteArray,
|
||||
chain: MutableList<ChainHeader>
|
||||
): Int {
|
||||
val buffer = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val count = readCompactSize(buffer)
|
||||
|
||||
if (count > 2000) {
|
||||
throw IllegalStateException("too many headers: $count")
|
||||
}
|
||||
|
||||
repeat(count) {
|
||||
if (buffer.remaining() < 81) {
|
||||
throw IllegalStateException("truncated headers message")
|
||||
}
|
||||
|
||||
val raw = ByteArray(80)
|
||||
buffer.get(raw)
|
||||
|
||||
val transactionCount = readCompactSize(buffer)
|
||||
|
||||
if (transactionCount != 0) {
|
||||
throw IllegalStateException(
|
||||
"headers message contains transactions"
|
||||
)
|
||||
}
|
||||
|
||||
appendVerifiedHeader(chain, raw)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
fun appendVerifiedHeader(
|
||||
chain: MutableList<ChainHeader>,
|
||||
raw: ByteArray
|
||||
) {
|
||||
val previous = chain.last()
|
||||
val previousHash = raw.copyOfRange(4, 36)
|
||||
|
||||
if (!previousHash.contentEquals(previous.hashWire)) {
|
||||
throw IllegalStateException(
|
||||
"header linkage failed at height ${previous.height + 1}"
|
||||
)
|
||||
}
|
||||
|
||||
val buffer = ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val time = buffer.getInt(68).toLong() and 0xffffffffL
|
||||
val bits = buffer.getInt(72).toLong() and 0xffffffffL
|
||||
|
||||
val target = compactToTarget(bits)
|
||||
|
||||
if (target <= BigInteger.ZERO || target > POW_LIMIT) {
|
||||
throw IllegalStateException(
|
||||
"invalid target at height ${previous.height + 1}"
|
||||
)
|
||||
}
|
||||
|
||||
val hashWire = doubleSha256(raw)
|
||||
val hashNumber = BigInteger(1, hashWire.reversedArray())
|
||||
|
||||
if (hashNumber > target) {
|
||||
throw IllegalStateException(
|
||||
"proof of work failed at height ${previous.height + 1}"
|
||||
)
|
||||
}
|
||||
|
||||
val expectedBits = expectedBits(chain)
|
||||
|
||||
if (bits != expectedBits) {
|
||||
throw IllegalStateException(
|
||||
"difficulty mismatch at height ${previous.height + 1}: " +
|
||||
"expected ${expectedBits.toString(16)}, " +
|
||||
"received ${bits.toString(16)}"
|
||||
)
|
||||
}
|
||||
|
||||
chain.add(
|
||||
ChainHeader(
|
||||
height = previous.height + 1,
|
||||
raw = raw,
|
||||
hashWire = hashWire,
|
||||
hashDisplay = hashWire.reversedArray().toHex(),
|
||||
time = time,
|
||||
bits = bits
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun expectedBits(chain: List<ChainHeader>): Long {
|
||||
val last = chain.last()
|
||||
|
||||
if (last.height < AVERAGING_WINDOW) {
|
||||
return targetToCompact(POW_LIMIT)
|
||||
}
|
||||
|
||||
val first = chain[last.height - AVERAGING_WINDOW]
|
||||
|
||||
var actualTimespan =
|
||||
medianTimePast(chain, last.height) -
|
||||
medianTimePast(chain, first.height)
|
||||
|
||||
actualTimespan =
|
||||
TARGET_TIMESPAN +
|
||||
(actualTimespan - TARGET_TIMESPAN) / 4
|
||||
|
||||
actualTimespan = actualTimespan.coerceIn(
|
||||
MIN_TIMESPAN,
|
||||
MAX_TIMESPAN
|
||||
)
|
||||
|
||||
var newTarget =
|
||||
compactToTarget(last.bits)
|
||||
.multiply(BigInteger.valueOf(actualTimespan))
|
||||
.divide(BigInteger.valueOf(TARGET_TIMESPAN))
|
||||
|
||||
if (newTarget > POW_LIMIT) {
|
||||
newTarget = POW_LIMIT
|
||||
}
|
||||
|
||||
return targetToCompact(newTarget)
|
||||
}
|
||||
|
||||
fun medianTimePast(
|
||||
chain: List<ChainHeader>,
|
||||
height: Int
|
||||
): Long {
|
||||
val start = maxOf(0, height - 10)
|
||||
val times = (start..height)
|
||||
.map { chain[it].time }
|
||||
.sorted()
|
||||
|
||||
return times[times.size / 2]
|
||||
}
|
||||
|
||||
fun compactToTarget(compact: Long): BigInteger {
|
||||
val size = ((compact ushr 24) and 0xff).toInt()
|
||||
var word = compact and 0x007fffffL
|
||||
|
||||
var value = BigInteger.valueOf(word)
|
||||
|
||||
value = if (size <= 3) {
|
||||
value.shiftRight(8 * (3 - size))
|
||||
} else {
|
||||
value.shiftLeft(8 * (size - 3))
|
||||
}
|
||||
|
||||
return if ((compact and 0x00800000L) != 0L) {
|
||||
value.negate()
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
fun targetToCompact(targetValue: BigInteger): Long {
|
||||
if (targetValue == BigInteger.ZERO) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val negative = targetValue.signum() < 0
|
||||
val target = targetValue.abs()
|
||||
|
||||
var size = (target.bitLength() + 7) / 8
|
||||
|
||||
var compact = if (size <= 3) {
|
||||
target.toLong() shl (8 * (3 - size))
|
||||
} else {
|
||||
target.shiftRight(8 * (size - 3)).toLong()
|
||||
}
|
||||
|
||||
if ((compact and 0x00800000L) != 0L) {
|
||||
compact = compact ushr 8
|
||||
size++
|
||||
}
|
||||
|
||||
compact = compact and 0x007fffffL
|
||||
compact = compact or (size.toLong() shl 24)
|
||||
|
||||
if (negative && (compact and 0x007fffffL) != 0L) {
|
||||
compact = compact or 0x00800000L
|
||||
}
|
||||
|
||||
return compact
|
||||
}
|
||||
|
||||
fun createGetHeadersPayload(locatorHash: ByteArray): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
|
||||
writeInt32LE(out, PROTOCOL_VERSION)
|
||||
writeCompactSize(out, 1)
|
||||
out.write(locatorHash)
|
||||
out.write(ByteArray(32))
|
||||
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun readMessage(input: DataInputStream): Pair<String, ByteArray> {
|
||||
val header = ByteArray(24)
|
||||
input.readFully(header)
|
||||
|
||||
if (!header.copyOfRange(0, 4).contentEquals(MAGIC)) {
|
||||
throw IllegalStateException("invalid Vexta network magic")
|
||||
}
|
||||
|
||||
val command = header.copyOfRange(4, 16)
|
||||
.takeWhile { it.toInt() != 0 }
|
||||
.toByteArray()
|
||||
.toString(Charsets.US_ASCII)
|
||||
|
||||
val payloadLength = ByteBuffer.wrap(header, 16, 4)
|
||||
.order(ByteOrder.LITTLE_ENDIAN)
|
||||
.int
|
||||
|
||||
if (payloadLength < 0 || payloadLength > 4_000_000) {
|
||||
throw IllegalStateException("invalid payload length")
|
||||
}
|
||||
|
||||
val expectedChecksum = header.copyOfRange(20, 24)
|
||||
val payload = ByteArray(payloadLength)
|
||||
input.readFully(payload)
|
||||
|
||||
val actualChecksum = doubleSha256(payload).copyOfRange(0, 4)
|
||||
|
||||
if (!expectedChecksum.contentEquals(actualChecksum)) {
|
||||
throw IllegalStateException("invalid message checksum")
|
||||
}
|
||||
|
||||
return command to payload
|
||||
}
|
||||
|
||||
fun createVersionPayload(peerHost: String): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
|
||||
writeInt32LE(out, PROTOCOL_VERSION)
|
||||
writeInt64LE(out, 0)
|
||||
writeInt64LE(out, System.currentTimeMillis() / 1000)
|
||||
|
||||
writeNetworkAddress(out, peerHost, PORT)
|
||||
writeNetworkAddress(out, "0.0.0.0", 0)
|
||||
|
||||
writeInt64LE(out, Random.nextLong())
|
||||
writeVarString(out, "/VextaAndroidWallet:0.1.0/")
|
||||
writeInt32LE(out, 0)
|
||||
out.write(0)
|
||||
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun parseVersionPayload(payload: ByteArray): VersionInfo {
|
||||
val buffer = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
if (payload.size < 80) {
|
||||
throw IllegalStateException("short version payload")
|
||||
}
|
||||
|
||||
val version = buffer.int
|
||||
buffer.long
|
||||
buffer.long
|
||||
buffer.position(buffer.position() + 26)
|
||||
buffer.position(buffer.position() + 26)
|
||||
buffer.long
|
||||
|
||||
val userAgent = readVarString(buffer)
|
||||
val height = if (buffer.remaining() >= 4) buffer.int else 0
|
||||
|
||||
return VersionInfo(version, height, userAgent)
|
||||
}
|
||||
|
||||
fun sendMessage(
|
||||
output: OutputStream,
|
||||
command: String,
|
||||
payload: ByteArray
|
||||
) {
|
||||
val message = ByteArrayOutputStream()
|
||||
|
||||
message.write(MAGIC)
|
||||
|
||||
val commandBytes = ByteArray(12)
|
||||
command.toByteArray(Charsets.US_ASCII).copyInto(
|
||||
destination = commandBytes,
|
||||
endIndex = minOf(command.length, 12)
|
||||
)
|
||||
|
||||
message.write(commandBytes)
|
||||
writeInt32LE(message, payload.size)
|
||||
message.write(doubleSha256(payload).copyOfRange(0, 4))
|
||||
message.write(payload)
|
||||
|
||||
output.write(message.toByteArray())
|
||||
output.flush()
|
||||
}
|
||||
|
||||
fun writeNetworkAddress(
|
||||
out: ByteArrayOutputStream,
|
||||
host: String,
|
||||
port: Int
|
||||
) {
|
||||
writeInt64LE(out, 0)
|
||||
|
||||
val address = InetAddress.getByName(host).address
|
||||
val ipv6 = ByteArray(16)
|
||||
|
||||
if (address.size == 4) {
|
||||
ipv6[10] = 0xff.toByte()
|
||||
ipv6[11] = 0xff.toByte()
|
||||
address.copyInto(ipv6, 12)
|
||||
} else {
|
||||
address.copyInto(ipv6)
|
||||
}
|
||||
|
||||
out.write(ipv6)
|
||||
out.write((port shr 8) and 0xff)
|
||||
out.write(port and 0xff)
|
||||
}
|
||||
|
||||
fun writeVarString(out: ByteArrayOutputStream, value: String) {
|
||||
val bytes = value.toByteArray(Charsets.UTF_8)
|
||||
writeCompactSize(out, bytes.size)
|
||||
out.write(bytes)
|
||||
}
|
||||
|
||||
fun readVarString(buffer: ByteBuffer): String {
|
||||
val length = readCompactSize(buffer)
|
||||
|
||||
if (length > buffer.remaining()) {
|
||||
throw IllegalStateException("invalid string length")
|
||||
}
|
||||
|
||||
val bytes = ByteArray(length)
|
||||
buffer.get(bytes)
|
||||
|
||||
return bytes.toString(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
fun writeCompactSize(out: ByteArrayOutputStream, value: Int) {
|
||||
when {
|
||||
value < 253 -> out.write(value)
|
||||
|
||||
value <= 0xffff -> {
|
||||
out.write(253)
|
||||
out.write(value and 0xff)
|
||||
out.write((value shr 8) and 0xff)
|
||||
}
|
||||
|
||||
else -> {
|
||||
out.write(254)
|
||||
writeInt32LE(out, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun readCompactSize(buffer: ByteBuffer): Int {
|
||||
if (!buffer.hasRemaining()) {
|
||||
throw IllegalStateException("missing compact size")
|
||||
}
|
||||
|
||||
return when (val first = buffer.get().toInt() and 0xff) {
|
||||
253 -> buffer.short.toInt() and 0xffff
|
||||
254 -> buffer.int
|
||||
|
||||
255 -> {
|
||||
val value = buffer.long
|
||||
|
||||
if (value < 0 || value > Int.MAX_VALUE) {
|
||||
throw IllegalStateException("compact size too large")
|
||||
}
|
||||
|
||||
value.toInt()
|
||||
}
|
||||
|
||||
else -> first
|
||||
}
|
||||
}
|
||||
|
||||
fun hashHexToWireBytes(hash: String): ByteArray =
|
||||
hash.removePrefix("0x")
|
||||
.chunked(2)
|
||||
.map { it.toInt(16).toByte() }
|
||||
.toByteArray()
|
||||
.reversedArray()
|
||||
|
||||
fun ByteArray.toHex(): String =
|
||||
joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
|
||||
fun writeInt32LE(out: ByteArrayOutputStream, value: Int) {
|
||||
out.write(
|
||||
ByteBuffer.allocate(4)
|
||||
.order(ByteOrder.LITTLE_ENDIAN)
|
||||
.putInt(value)
|
||||
.array()
|
||||
)
|
||||
}
|
||||
|
||||
fun writeInt64LE(out: ByteArrayOutputStream, value: Long) {
|
||||
out.write(
|
||||
ByteBuffer.allocate(8)
|
||||
.order(ByteOrder.LITTLE_ENDIAN)
|
||||
.putLong(value)
|
||||
.array()
|
||||
)
|
||||
}
|
||||
|
||||
fun doubleSha256(data: ByteArray): ByteArray {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
return digest.digest(digest.digest(data))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
package org.vextaproject.wallet
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import android.app.PendingIntent
|
||||
import android.app.NotificationManager
|
||||
import android.app.NotificationChannel
|
||||
|
|
@ -66,7 +69,7 @@ import javax.crypto.KeyGenerator
|
|||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class WalletActivity : Activity() {
|
||||
class WalletActivity : FragmentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val PREFS = "vexta_wallet"
|
||||
|
|
@ -124,11 +127,14 @@ class WalletActivity : Activity() {
|
|||
if (
|
||||
!isFinishing &&
|
||||
!isDestroyed &&
|
||||
hasWindowFocus() &&
|
||||
walletExists() &&
|
||||
walletBalanceView != null
|
||||
!blockchainScanRunning
|
||||
) {
|
||||
if (hasWindowFocus()) {
|
||||
startHeaderSyncAndScan()
|
||||
} else {
|
||||
startBackgroundHeaderSyncAndScan()
|
||||
}
|
||||
}
|
||||
|
||||
refreshHandler.postDelayed(this, 60_000L)
|
||||
|
|
@ -238,6 +244,15 @@ class WalletActivity : Activity() {
|
|||
createPaymentNotificationChannel()
|
||||
requestNotificationPermission()
|
||||
|
||||
val monitoringIntent =
|
||||
Intent(this, WalletMonitoringService::class.java)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(monitoringIntent)
|
||||
} else {
|
||||
startService(monitoringIntent)
|
||||
}
|
||||
|
||||
val filter = IntentFilter(
|
||||
DownloadManager.ACTION_DOWNLOAD_COMPLETE
|
||||
)
|
||||
|
|
@ -254,7 +269,12 @@ class WalletActivity : Activity() {
|
|||
}
|
||||
|
||||
if (walletExists()) {
|
||||
authenticateWallet(
|
||||
title = "Unlock Vexta Wallet",
|
||||
subtitle = "Use biometrics or your device screen lock"
|
||||
) {
|
||||
showMainWallet()
|
||||
}
|
||||
} else {
|
||||
showWelcome()
|
||||
}
|
||||
|
|
@ -680,6 +700,39 @@ class WalletActivity : Activity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun startBackgroundHeaderSyncAndScan() {
|
||||
if (blockchainScanRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
blockchainScanRunning = true
|
||||
|
||||
Thread {
|
||||
try {
|
||||
val chain = HeaderSync.loadCachedChain(this)
|
||||
|
||||
for (peer in listOf("87.106.99.23", "74.208.53.160")) {
|
||||
try {
|
||||
val result = HeaderSync.syncFromPeer(peer, chain)
|
||||
|
||||
if (result.received > 0) {
|
||||
HeaderSync.saveChain(this, chain)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
runOnUiThread {
|
||||
runBlockchainScan()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
runOnUiThread {
|
||||
blockchainScanRunning = false
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun startHeaderSyncAndScan() {
|
||||
if (blockchainScanRunning) {
|
||||
return
|
||||
|
|
@ -1878,6 +1931,10 @@ class WalletActivity : Activity() {
|
|||
return@primaryButton
|
||||
}
|
||||
|
||||
authenticateWallet(
|
||||
title = "Authorize payment",
|
||||
subtitle = "Authenticate before signing this VTX transaction"
|
||||
) {
|
||||
try {
|
||||
val words = loadMnemonic()
|
||||
.trim()
|
||||
|
|
@ -2094,6 +2151,7 @@ class WalletActivity : Activity() {
|
|||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
|
@ -2226,6 +2284,93 @@ class WalletActivity : Activity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun authenticateWallet(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onSuccess: () -> Unit
|
||||
) {
|
||||
val authenticators =
|
||||
BiometricManager.Authenticators.BIOMETRIC_WEAK or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
|
||||
val biometricManager = BiometricManager.from(this)
|
||||
|
||||
if (
|
||||
biometricManager.canAuthenticate(authenticators) !=
|
||||
BiometricManager.BIOMETRIC_SUCCESS
|
||||
) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Device security required")
|
||||
.setMessage(
|
||||
"Vexta Wallet requires biometrics or a device " +
|
||||
"PIN, pattern, or password. Configure a screen " +
|
||||
"lock in Android security settings and try again."
|
||||
)
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setPositiveButton("Security settings") { _, _ ->
|
||||
startActivity(
|
||||
Intent(Settings.ACTION_SECURITY_SETTINGS)
|
||||
)
|
||||
}
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
val executor = ContextCompat.getMainExecutor(this)
|
||||
|
||||
val prompt = BiometricPrompt(
|
||||
this,
|
||||
executor,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(
|
||||
result: BiometricPrompt.AuthenticationResult
|
||||
) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
|
||||
Toast.makeText(
|
||||
this@WalletActivity,
|
||||
"Authentication not recognized",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(
|
||||
errorCode: Int,
|
||||
errString: CharSequence
|
||||
) {
|
||||
super.onAuthenticationError(
|
||||
errorCode,
|
||||
errString
|
||||
)
|
||||
|
||||
if (
|
||||
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON
|
||||
) {
|
||||
Toast.makeText(
|
||||
this@WalletActivity,
|
||||
errString,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setAllowedAuthenticators(authenticators)
|
||||
.build()
|
||||
|
||||
prompt.authenticate(promptInfo)
|
||||
}
|
||||
|
||||
private fun confirmShowSeed() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Security warning")
|
||||
|
|
@ -2234,10 +2379,15 @@ class WalletActivity : Activity() {
|
|||
"Make sure nobody can see your screen."
|
||||
)
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setPositiveButton("Show") { _, _ ->
|
||||
.setPositiveButton("Authenticate") { _, _ ->
|
||||
authenticateWallet(
|
||||
title = "Show recovery phrase",
|
||||
subtitle = "Authenticate to reveal your recovery phrase"
|
||||
) {
|
||||
val words = loadMnemonic().split(" ")
|
||||
showSeedBackup(words)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
package org.vextaproject.wallet
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
|
||||
class WalletMonitoringService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "vexta_wallet_monitoring"
|
||||
private const val NOTIFICATION_ID = 7001
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
val manager =
|
||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Wallet monitoring",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
)
|
||||
|
||||
val intent = Intent(this, WalletActivity::class.java)
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setContentTitle("Vexta Wallet")
|
||||
.setContentText("Monitoring active")
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setCategory(Notification.CATEGORY_SERVICE)
|
||||
.build()
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int
|
||||
): Int {
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
}
|
||||
Loading…
Reference in a new issue