From 207657fe13ea38ad59b116d0986e8d80f63ae803 Mon Sep 17 00:00:00 2001 From: Vexta Project Date: Fri, 14 Aug 2026 23:54:52 +0000 Subject: [PATCH] Release Vexta Wallet v0.6.3 --- app/build.gradle.kts | 4 +- .../org/vextaproject/wallet/WalletActivity.kt | 2 +- .../wallet/WalletMonitoringService.kt | 410 ++++++++++++++++-- 3 files changed, 384 insertions(+), 32 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1aba823..cb4a42e 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 = 32 - versionName = "0.6.2" + versionCode = 33 + versionName = "0.6.3" } signingConfigs { diff --git a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt index 6801fd3..02824e7 100644 --- a/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt +++ b/app/src/main/java/org/vextaproject/wallet/WalletActivity.kt @@ -1044,7 +1044,7 @@ class WalletActivity : FragmentActivity() { 100_000_000.0 ) - updateIncomingTransactionsAndNotify(mergedTransactions) + // Incoming notifications are handled by WalletMonitoringService. if (restoreAddressDiscovery) { setCurrentReceiveAddressIndex( diff --git a/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt b/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt index 4bf70e4..dc358e9 100644 --- a/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt +++ b/app/src/main/java/org/vextaproject/wallet/WalletMonitoringService.kt @@ -1,5 +1,6 @@ package org.vextaproject.wallet +import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -7,48 +8,58 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Handler 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() { companion object { 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 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() { super.onCreate() - val manager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + createChannels() + startForeground(NOTIFICATION_ID, monitoringNotification()) - 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) + handler.postDelayed(monitorRunnable, 5_000L) } override fun onStartCommand( @@ -59,5 +70,346 @@ class WalletMonitoringService : Service() { return START_STICKY } + 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() + + 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 + ) { + 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, + 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() + } }