Release Vexta Wallet v0.6.3
This commit is contained in:
parent
339b2eed60
commit
207657fe13
3 changed files with 377 additions and 25 deletions
|
|
@ -22,8 +22,8 @@ android {
|
||||||
applicationId = "org.vextaproject.wallet"
|
applicationId = "org.vextaproject.wallet"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 32
|
versionCode = 33
|
||||||
versionName = "0.6.2"
|
versionName = "0.6.3"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
|
||||||
|
|
@ -1044,7 +1044,7 @@ class WalletActivity : FragmentActivity() {
|
||||||
100_000_000.0
|
100_000_000.0
|
||||||
)
|
)
|
||||||
|
|
||||||
updateIncomingTransactionsAndNotify(mergedTransactions)
|
// Incoming notifications are handled by WalletMonitoringService.
|
||||||
|
|
||||||
if (restoreAddressDiscovery) {
|
if (restoreAddressDiscovery) {
|
||||||
setCurrentReceiveAddressIndex(
|
setCurrentReceiveAddressIndex(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package org.vextaproject.wallet
|
package org.vextaproject.wallet
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
|
@ -7,48 +8,58 @@ import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import org.bitcoinj.core.ECKey
|
||||||
|
import org.bitcoinj.core.Utils
|
||||||
|
import org.bitcoinj.crypto.ChildNumber
|
||||||
|
import org.bitcoinj.crypto.DeterministicHierarchy
|
||||||
|
import org.bitcoinj.crypto.HDKeyDerivation
|
||||||
|
import org.bitcoinj.crypto.MnemonicCode
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
class WalletMonitoringService : Service() {
|
class WalletMonitoringService : Service() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val CHANNEL_ID = "vexta_wallet_monitoring"
|
private const val CHANNEL_ID = "vexta_wallet_monitoring"
|
||||||
|
private const val PAYMENT_CHANNEL_ID = "vexta_incoming_payments"
|
||||||
private const val NOTIFICATION_ID = 7001
|
private const val NOTIFICATION_ID = 7001
|
||||||
|
|
||||||
|
private const val PREFS = "vexta_wallet"
|
||||||
|
private const val KEY_ALIAS = "vexta_wallet_seed_key"
|
||||||
|
private const val PREF_CIPHERTEXT = "seed_ciphertext"
|
||||||
|
private const val PREF_IV = "seed_iv"
|
||||||
|
private const val PREF_RECEIVE_ADDRESS_INDEX =
|
||||||
|
"receive_address_index"
|
||||||
|
private const val PREF_KNOWN_INCOMING_TXIDS =
|
||||||
|
"known_incoming_txids"
|
||||||
|
private const val PREF_TX_NOTIFICATIONS_INITIALIZED =
|
||||||
|
"tx_notifications_initialized"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private var scanRunning = false
|
||||||
|
|
||||||
|
private val monitorRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
startBackgroundScan()
|
||||||
|
handler.postDelayed(this, 60_000L)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
val manager =
|
createChannels()
|
||||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
startForeground(NOTIFICATION_ID, monitoringNotification())
|
||||||
|
|
||||||
manager.createNotificationChannel(
|
handler.postDelayed(monitorRunnable, 5_000L)
|
||||||
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(
|
override fun onStartCommand(
|
||||||
|
|
@ -59,5 +70,346 @@ class WalletMonitoringService : Service() {
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onDestroy() {
|
||||||
|
handler.removeCallbacks(monitorRunnable)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
private fun startBackgroundScan() {
|
||||||
|
if (scanRunning || !walletExists()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scanRunning = 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val words = loadMnemonic()
|
||||||
|
.trim()
|
||||||
|
.split(Regex("\\s+"))
|
||||||
|
|
||||||
|
val highestAddressIndex =
|
||||||
|
getSharedPreferences(PREFS, MODE_PRIVATE)
|
||||||
|
.getInt(PREF_RECEIVE_ADDRESS_INDEX, 0)
|
||||||
|
.coerceAtLeast(0)
|
||||||
|
|
||||||
|
val allTransactions =
|
||||||
|
mutableListOf<BlockScanner.WalletTransaction>()
|
||||||
|
|
||||||
|
for (addressIndex in 0..highestAddressIndex) {
|
||||||
|
val script =
|
||||||
|
deriveWitnessScript(words, addressIndex)
|
||||||
|
|
||||||
|
val filters =
|
||||||
|
CompactFilterClient.scan(
|
||||||
|
this,
|
||||||
|
"87.106.99.23",
|
||||||
|
script
|
||||||
|
) { _, _ -> }
|
||||||
|
|
||||||
|
val blocks =
|
||||||
|
BlockScanner.scan(
|
||||||
|
this,
|
||||||
|
"87.106.99.23",
|
||||||
|
filters.matchingHeights,
|
||||||
|
script,
|
||||||
|
addressIndex
|
||||||
|
) { _, _ -> }
|
||||||
|
|
||||||
|
allTransactions.addAll(blocks.transactions)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mergedTransactions =
|
||||||
|
allTransactions
|
||||||
|
.groupBy { it.txid }
|
||||||
|
.map { (_, transactions) ->
|
||||||
|
BlockScanner.WalletTransaction(
|
||||||
|
txid = transactions.first().txid,
|
||||||
|
height = transactions.maxOf {
|
||||||
|
it.height
|
||||||
|
},
|
||||||
|
netSatoshis = transactions.sumOf {
|
||||||
|
it.netSatoshis
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.filter { it.netSatoshis != 0L }
|
||||||
|
|
||||||
|
updateIncomingTransactionsAndNotify(
|
||||||
|
mergedTransactions
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
} finally {
|
||||||
|
scanRunning = false
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateIncomingTransactionsAndNotify(
|
||||||
|
transactions: List<BlockScanner.WalletTransaction>
|
||||||
|
) {
|
||||||
|
val preferences =
|
||||||
|
getSharedPreferences(PREFS, MODE_PRIVATE)
|
||||||
|
|
||||||
|
val incoming =
|
||||||
|
transactions.filter { it.netSatoshis > 0L }
|
||||||
|
|
||||||
|
val currentTxids =
|
||||||
|
incoming.map { it.txid }.toSet()
|
||||||
|
|
||||||
|
val initialized =
|
||||||
|
preferences.getBoolean(
|
||||||
|
PREF_TX_NOTIFICATIONS_INITIALIZED,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!initialized) {
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(
|
||||||
|
PREF_TX_NOTIFICATIONS_INITIALIZED,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
.putStringSet(
|
||||||
|
PREF_KNOWN_INCOMING_TXIDS,
|
||||||
|
currentTxids
|
||||||
|
)
|
||||||
|
.apply()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val knownTxids =
|
||||||
|
preferences.getStringSet(
|
||||||
|
PREF_KNOWN_INCOMING_TXIDS,
|
||||||
|
emptySet()
|
||||||
|
)?.toMutableSet() ?: mutableSetOf()
|
||||||
|
|
||||||
|
incoming
|
||||||
|
.filter { it.txid !in knownTxids }
|
||||||
|
.sortedBy { it.height }
|
||||||
|
.forEach { transaction ->
|
||||||
|
showIncomingPaymentNotification(
|
||||||
|
transaction.netSatoshis
|
||||||
|
)
|
||||||
|
knownTxids.add(transaction.txid)
|
||||||
|
}
|
||||||
|
|
||||||
|
preferences.edit()
|
||||||
|
.putStringSet(
|
||||||
|
PREF_KNOWN_INCOMING_TXIDS,
|
||||||
|
knownTxids
|
||||||
|
)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showIncomingPaymentNotification(
|
||||||
|
receivedSatoshis: Long
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
checkSelfPermission(
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val amount = String.format(
|
||||||
|
"%.8f",
|
||||||
|
receivedSatoshis.toDouble() / 100_000_000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
val openWalletIntent = Intent(
|
||||||
|
this,
|
||||||
|
WalletActivity::class.java
|
||||||
|
).apply {
|
||||||
|
flags =
|
||||||
|
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||||
|
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
openWalletIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = Notification.Builder(
|
||||||
|
this,
|
||||||
|
PAYMENT_CHANNEL_ID
|
||||||
|
)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_more)
|
||||||
|
.setContentTitle("VTX payment received")
|
||||||
|
.setContentText("$amount VTX has been confirmed")
|
||||||
|
.setStyle(
|
||||||
|
Notification.BigTextStyle().bigText(
|
||||||
|
"A payment of $amount VTX was received " +
|
||||||
|
"and confirmed in your Vexta Wallet."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val manager =
|
||||||
|
getSystemService(
|
||||||
|
Context.NOTIFICATION_SERVICE
|
||||||
|
) as NotificationManager
|
||||||
|
|
||||||
|
manager.notify(
|
||||||
|
(System.currentTimeMillis() and 0x7fffffff).toInt(),
|
||||||
|
notification
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun walletExists(): Boolean {
|
||||||
|
val prefs =
|
||||||
|
getSharedPreferences(PREFS, MODE_PRIVATE)
|
||||||
|
|
||||||
|
return !prefs.getString(PREF_CIPHERTEXT, "")
|
||||||
|
.isNullOrBlank() &&
|
||||||
|
!prefs.getString(PREF_IV, "")
|
||||||
|
.isNullOrBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMnemonic(): String {
|
||||||
|
val prefs =
|
||||||
|
getSharedPreferences(PREFS, MODE_PRIVATE)
|
||||||
|
|
||||||
|
val ciphertext =
|
||||||
|
android.util.Base64.decode(
|
||||||
|
prefs.getString(PREF_CIPHERTEXT, "") ?: "",
|
||||||
|
android.util.Base64.NO_WRAP
|
||||||
|
)
|
||||||
|
|
||||||
|
val iv =
|
||||||
|
android.util.Base64.decode(
|
||||||
|
prefs.getString(PREF_IV, "") ?: "",
|
||||||
|
android.util.Base64.NO_WRAP
|
||||||
|
)
|
||||||
|
|
||||||
|
val keyStore =
|
||||||
|
KeyStore.getInstance("AndroidKeyStore").apply {
|
||||||
|
load(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val key =
|
||||||
|
keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||||
|
|
||||||
|
val cipher =
|
||||||
|
Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
|
||||||
|
cipher.init(
|
||||||
|
Cipher.DECRYPT_MODE,
|
||||||
|
key,
|
||||||
|
GCMParameterSpec(128, iv)
|
||||||
|
)
|
||||||
|
|
||||||
|
return cipher.doFinal(ciphertext)
|
||||||
|
.toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deriveWitnessScript(
|
||||||
|
words: List<String>,
|
||||||
|
addressIndex: Int
|
||||||
|
): ByteArray {
|
||||||
|
MnemonicCode.INSTANCE.check(words)
|
||||||
|
|
||||||
|
val seedBytes =
|
||||||
|
MnemonicCode.toSeed(words, "")
|
||||||
|
|
||||||
|
val masterKey =
|
||||||
|
HDKeyDerivation.createMasterPrivateKey(seedBytes)
|
||||||
|
|
||||||
|
val hierarchy =
|
||||||
|
DeterministicHierarchy(masterKey)
|
||||||
|
|
||||||
|
val path =
|
||||||
|
listOf(
|
||||||
|
ChildNumber(84, true),
|
||||||
|
ChildNumber(0, true),
|
||||||
|
ChildNumber(0, true),
|
||||||
|
ChildNumber.ZERO,
|
||||||
|
ChildNumber(addressIndex, false)
|
||||||
|
)
|
||||||
|
|
||||||
|
val key =
|
||||||
|
hierarchy.get(path, true, true)
|
||||||
|
|
||||||
|
val ecKey =
|
||||||
|
ECKey.fromPrivate(
|
||||||
|
key.privKeyBytes,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
val pubKeyHash =
|
||||||
|
Utils.sha256hash160(ecKey.pubKey)
|
||||||
|
|
||||||
|
return byteArrayOf(0x00, 0x14) + pubKeyHash
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannels() {
|
||||||
|
val manager =
|
||||||
|
getSystemService(
|
||||||
|
Context.NOTIFICATION_SERVICE
|
||||||
|
) as NotificationManager
|
||||||
|
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Wallet monitoring",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
PAYMENT_CHANNEL_ID,
|
||||||
|
"Incoming VTX payments",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun monitoringNotification(): Notification {
|
||||||
|
val intent =
|
||||||
|
Intent(this, WalletActivity::class.java)
|
||||||
|
|
||||||
|
val pendingIntent =
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
return 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue