diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f33725a..7fe78ab 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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(
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bd5ba98..f056927 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,8 @@
+
+
+
+
+
+
diff --git a/app/src/main/java/org/vextaproject/wallet/HeaderSync.kt b/app/src/main/java/org/vextaproject/wallet/HeaderSync.kt
new file mode 100644
index 0000000..7a5818b
--- /dev/null
+++ b/app/src/main/java/org/vextaproject/wallet/HeaderSync.kt
@@ -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 {
+ 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) {
+ 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
+ ): 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
+ ): 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,
+ 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): 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,
+ 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 {
+ 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))
+ }
+}
diff --git a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt
index ab86743..9358eca 100644
--- a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt
+++ b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt
@@ -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
) {
- startHeaderSyncAndScan()
+ 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()) {
- showMainWallet()
+ 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,8 +1931,12 @@ class WalletActivity : Activity() {
return@primaryButton
}
- try {
- val words = loadMnemonic()
+ authenticateWallet(
+ title = "Authorize payment",
+ subtitle = "Authenticate before signing this VTX transaction"
+ ) {
+ try {
+ val words = loadMnemonic()
.trim()
.split(Regex("\\s+"))
@@ -2093,6 +2150,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,9 +2379,14 @@ class WalletActivity : Activity() {
"Make sure nobody can see your screen."
)
.setNegativeButton("Cancel", null)
- .setPositiveButton("Show") { _, _ ->
- val words = loadMnemonic().split(" ")
- showSeedBackup(words)
+ .setPositiveButton("Authenticate") { _, _ ->
+ authenticateWallet(
+ title = "Show recovery phrase",
+ subtitle = "Authenticate to reveal your recovery phrase"
+ ) {
+ val words = loadMnemonic().split(" ")
+ showSeedBackup(words)
+ }
}
.show()
}
diff --git a/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt b/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt
new file mode 100644
index 0000000..4bf70e4
--- /dev/null
+++ b/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt
@@ -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
+}