Files
GajuMobile-Android/app/src/main/java/swiss/qpq/gajumobile/MainActivity.kt
T
2026-08-27 20:32:35 +09:00

874 lines
41 KiB
Kotlin

package swiss.qpq.gajumobile
import android.os.Bundle
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import swiss.qpq.gajumobile.data.AppStateManager
import swiss.qpq.gajumobile.data.WalletRepository
import swiss.qpq.gajumobile.data.models.Account
import swiss.qpq.gajumobile.data.models.GridsSignRequest
import swiss.qpq.gajumobile.data.models.GridsSignType
import swiss.qpq.gajumobile.data.models.Wallet
import swiss.qpq.gajumobile.data.models.toGridsSignRequest
import swiss.qpq.gajumobile.security.AppLifecycleObserver
import swiss.qpq.gajumobile.security.BiometricHelper
import swiss.qpq.gajumobile.security.SecurityManager
import swiss.qpq.gajumobile.security.KeyManager
import swiss.qpq.gajumobile.security.AccountAirlock
import swiss.qpq.gajumobile.security.GajuNative
import swiss.qpq.gajumobile.ui.screens.AccountNamingScreen
import swiss.qpq.gajumobile.ui.screens.CreateWalletScreen
import swiss.qpq.gajumobile.ui.screens.DashboardScreen
import swiss.qpq.gajumobile.ui.screens.DeleteConfirmationScreen
import swiss.qpq.gajumobile.ui.screens.EnvironmentInfoScreen
import swiss.qpq.gajumobile.ui.screens.ExplorerScreen
import swiss.qpq.gajumobile.ui.screens.FetchingScreen
import swiss.qpq.gajumobile.ui.screens.LockScreen
import swiss.qpq.gajumobile.ui.screens.ManageAccountScreen
import swiss.qpq.gajumobile.ui.screens.ManageWalletsScreen
import swiss.qpq.gajumobile.ui.screens.MnemonicRecoveryScreen
import swiss.qpq.gajumobile.ui.screens.MnemonicVerifyScreen
import swiss.qpq.gajumobile.ui.screens.QRReceiveScreen
import swiss.qpq.gajumobile.ui.screens.QRScannerScreen
import swiss.qpq.gajumobile.ui.screens.SendFormScreen
import swiss.qpq.gajumobile.ui.screens.SendReviewScreen
import swiss.qpq.gajumobile.ui.screens.SettingsScreen
import swiss.qpq.gajumobile.ui.screens.SetupChoiceScreen
import swiss.qpq.gajumobile.ui.screens.SignRequestScreen
import swiss.qpq.gajumobile.ui.screens.ViewMnemonicScreen
import swiss.qpq.gajumobile.ui.theme.MyApplicationTheme
import swiss.qpq.gajumaru.core.formatting.GajuFormat
import swiss.qpq.gajumaru.core.tools.Grids
import swiss.qpq.gajumaru.core.tools.Http
import swiss.qpq.gajumaru.core.tools.NodeClient
import swiss.qpq.gajumaru.core.tools.TransactionService
import java.math.BigInteger
import java.util.UUID
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
window.isNavigationBarContrastEnforced = false
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
ProcessLifecycleOwner.get().lifecycle.addObserver(AppLifecycleObserver(this))
setContent {
val context = LocalContext.current
val isLocked by SecurityManager.isLocked.collectAsState()
var lockErrorMessage by remember { mutableStateOf<String?>(null) }
val walletRepo = remember { WalletRepository(context) }
val appStateManager = remember { AppStateManager(context) }
MyApplicationTheme {
if (isLocked) {
LockScreen(errorMessage = lockErrorMessage) {
BiometricHelper.showBiometricPrompt(
activity = this,
onSuccess = {
lockErrorMessage = null
SecurityManager.unlock()
},
) { err ->
lockErrorMessage = err
}
}
} else {
val routerError: (String) -> Unit = { message ->
Toast.makeText(this@MainActivity, message, Toast.LENGTH_LONG).show()
}
GajuRouter(this@MainActivity, walletRepo, appStateManager, routerError)
}
}
}
}
}
enum class Screen {
CREATE_WALLET,
SETUP_CHOICE,
MNEMONIC_RECOVERY,
ACCOUNT_NAMING,
DASHBOARD,
QR_SCANNER,
QR_RECEIVE,
SETTINGS,
MANAGE_ACCOUNT,
MANAGE_WALLETS,
DELETE_CONFIRM,
EXPLORER,
VIEW_MNEMONIC,
VERIFY_MNEMONIC,
ENVIRONMENT_INFO,
SEND_FORM,
SEND_REVIEW,
SIGN_REQUEST,
FETCHING_SIGN_REQUEST
}
@Composable
fun GajuRouter(
context: android.content.Context,
walletRepo: WalletRepository,
appStateManager: AppStateManager,
onError: (String) -> Unit,
) {
val appState by appStateManager.state.collectAsState()
val scope = rememberCoroutineScope()
val initialScreen = remember {
val wallets = walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) }
if (wallets.isEmpty()) Screen.CREATE_WALLET else Screen.DASHBOARD
}
val screenStack = remember { mutableStateListOf(initialScreen) }
val currentScreen = screenStack.last()
fun navigateTo(screen: Screen) {
screenStack.add(screen)
}
fun navigateBack() {
if (screenStack.size > 1) {
screenStack.removeAt(screenStack.size - 1)
}
}
BackHandler(enabled = screenStack.size > 1) {
navigateBack()
}
var pendingWalletName by rememberSaveable { mutableStateOf("") }
var pendingNetworkId by rememberSaveable { mutableStateOf("groot.mainnet") }
var pendingAccount by remember { mutableStateOf<Account?>(null) }
var setupRecovered by rememberSaveable { mutableStateOf(value = false) }
var selectedWalletId by rememberSaveable { mutableStateOf("") }
var selectedAccountId by rememberSaveable { mutableStateOf("") }
var deleteType by rememberSaveable { mutableStateOf("") }
var deleteName by rememberSaveable { mutableStateOf("") }
var viewMnemonicAccount by remember { mutableStateOf<Account?>(null) }
var viewMnemonicPhrase by remember { mutableStateOf<Array<ByteArray>?>(null) }
var verifyMnemonicAccount by remember { mutableStateOf<Account?>(null) }
var verifyMnemonicPhrase by remember { mutableStateOf<Array<ByteArray>?>(null) }
var activeWalletIndex by rememberSaveable { mutableIntStateOf(0) }
var activeAccountIndex by rememberSaveable { mutableIntStateOf(0) }
var nodeStatusResult by remember { mutableStateOf<Map<String, Any?>?>(null) }
var nodeStatusError by remember { mutableStateOf<String?>(null) }
var pendingScanResult by remember { mutableStateOf<Grids.ParseResult?>(null) }
var pendingRecipient by rememberSaveable { mutableStateOf("") }
var pendingAmountStr by rememberSaveable { mutableStateOf("") }
var pendingPayload by rememberSaveable { mutableStateOf("") }
var pendingGas by rememberSaveable { mutableStateOf("") }
var pendingGasPrice by rememberSaveable { mutableStateOf("") }
var pendingTTL by rememberSaveable { mutableStateOf("") }
var pendingSignRequest by remember { mutableStateOf<GridsSignRequest?>(null) }
var pendingSignUrl by rememberSaveable { mutableStateOf("") }
var activeFetchJob by remember { mutableStateOf<Job?>(null) }
when (currentScreen) {
Screen.CREATE_WALLET -> {
CreateWalletScreen(
onWalletCreated = { name, networkId ->
pendingWalletName = name
pendingNetworkId = networkId
selectedWalletId = ""
navigateTo(Screen.SETUP_CHOICE)
},
onBack = if (screenStack.size > 1) { { navigateBack() } } else null,
)
}
Screen.SETUP_CHOICE -> {
SetupChoiceScreen(
onCreate = {
try {
val masterKey = KeyManager.getMasterKey(context)
val phrase = AccountAirlock.generateMnemonic()
val account = AccountAirlock.createAccount("New Account", phrase, masterKey)
AccountAirlock.wipePhrase(phrase)
pendingAccount = account
setupRecovered = false
navigateTo(Screen.ACCOUNT_NAMING)
} catch (e: Throwable) {
val msg = e.message ?: e.toString()
onError(context.getString(R.string.err_generate_account, msg))
android.util.Log.e("GajuRouter", "Account generation failed", e)
}
},
onRecover = {
setupRecovered = true
navigateTo(Screen.MNEMONIC_RECOVERY)
},
onBack = { navigateBack() },
)
}
Screen.MNEMONIC_RECOVERY -> {
MnemonicRecoveryScreen(
onMnemonicConfirmed = { phrase ->
try {
val masterKey = KeyManager.getMasterKey(context)
val account = AccountAirlock.createAccount("Recovered Account", phrase, masterKey)
AccountAirlock.wipePhrase(phrase)
pendingAccount = account
navigateTo(Screen.ACCOUNT_NAMING)
} catch (e: Exception) {
onError(context.getString(R.string.err_recover_account, e.message ?: ""))
}
},
onBack = { navigateBack() },
)
}
Screen.ACCOUNT_NAMING -> {
val account = pendingAccount ?: return
val akId = account.gajuId
AccountNamingScreen(
publicKey = akId,
onAccountNamed = { name ->
val finalAccount = account.copy(label = name)
if (selectedWalletId.isNotEmpty()) {
val wallet = walletRepo.loadWallet(selectedWalletId)
wallet?.let {
walletRepo.saveWallet(it.copy(accounts = it.accounts + finalAccount))
}
} else {
val endpoints = if (pendingNetworkId == "groot.mainnet") Wallet.DEFAULT_MAINNET else Wallet.DEFAULT_TESTNET
val wallet = Wallet(
id = UUID.randomUUID().toString(),
name = pendingWalletName.ifBlank { "My Wallet" },
networkId = pendingNetworkId,
endpoints = endpoints,
accounts = listOf(finalAccount),
)
walletRepo.saveWallet(wallet)
}
if (!setupRecovered) {
appStateManager.markUnverified(finalAccount.id)
}
selectedWalletId = ""
appStateManager.notifyWalletsChanged()
screenStack.clear()
screenStack.add(Screen.DASHBOARD)
},
onBack = { navigateBack() },
)
}
Screen.VIEW_MNEMONIC -> {
val acc = viewMnemonicAccount ?: return
val phrase = viewMnemonicPhrase ?: return
ViewMnemonicScreen(label = acc.label, phrase = phrase) {
AccountAirlock.wipePhrase(phrase)
viewMnemonicAccount = null
viewMnemonicPhrase = null
navigateBack()
}
}
Screen.VERIFY_MNEMONIC -> {
val acc = verifyMnemonicAccount ?: return
val phrase = verifyMnemonicPhrase ?: return
MnemonicVerifyScreen(
label = acc.label,
expectedPhrase = phrase,
onVerified = {
appStateManager.clearVerified(acc.id)
AccountAirlock.wipePhrase(phrase)
verifyMnemonicAccount = null
verifyMnemonicPhrase = null
navigateBack()
},
onBack = {
AccountAirlock.wipePhrase(phrase)
verifyMnemonicAccount = null
verifyMnemonicPhrase = null
navigateBack()
}
)
}
Screen.DELETE_CONFIRM -> {
DeleteConfirmationScreen(
type = deleteType,
name = deleteName,
onConfirm = {
if (deleteType == "WALLET") {
walletRepo.deleteWallet(selectedWalletId)
} else {
walletRepo.deleteAccount(selectedWalletId, selectedAccountId)
}
appStateManager.notifyWalletsChanged()
// Pop back to Dashboard by removing the confirmation and the management screen
repeat(2) {
if (screenStack.size > 1) screenStack.removeAt(screenStack.size - 1)
}
},
onCancel = { navigateBack() }
)
}
Screen.DASHBOARD -> {
val wallets = remember(appState.walletsVersion) {
walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) }
}
DashboardScreen(
wallets = wallets,
selectedWalletIndex = activeWalletIndex,
onWalletIndexChange = { activeWalletIndex = it },
selectedAccountIndex = activeAccountIndex,
onAccountIndexChange = { activeAccountIndex = it },
balanceFormat = GajuFormat.Type.valueOf(appState.balanceFormat),
balanceUnit = GajuFormat.Unit.valueOf(appState.balanceUnit),
onSend = { navigateTo(Screen.QR_SCANNER) },
onGrids = { navigateTo(Screen.QR_SCANNER) },
onReceive = { navigateTo(Screen.QR_RECEIVE) },
onAddAccount = { walletId ->
selectedWalletId = walletId
navigateTo(Screen.SETUP_CHOICE)
},
onAccountSettings = { walletId, accountId ->
selectedWalletId = walletId
selectedAccountId = accountId
navigateTo(Screen.MANAGE_ACCOUNT)
},
onRefresh = { walletId, accountId ->
scope.launch {
val updated = walletRepo.refreshAccount(walletId, accountId)
if (updated != null) {
appStateManager.notifyWalletsChanged()
} else {
onError(context.getString(R.string.err_refresh_failed))
}
}
},
onNavigate = { dest ->
val screen = when (dest) {
"settings" -> Screen.SETTINGS
"transactions" -> Screen.EXPLORER
"environment_info" -> Screen.ENVIRONMENT_INFO
"manage_wallets" -> Screen.MANAGE_WALLETS
"CREATE_WALLET" -> Screen.CREATE_WALLET
"WIPE_DATA" -> {
walletRepo.listWallets().forEach { walletRepo.deleteWallet(it) }
appStateManager.notifyWalletsChanged()
navigateTo(Screen.CREATE_WALLET)
null
}
else -> null
}
screen?.let { navigateTo(it) }
},
)
}
Screen.MANAGE_WALLETS -> {
val wallets = remember(appState.walletsVersion) {
walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) }
}
ManageWalletsScreen(
wallets = wallets,
onAddWallet = {
selectedWalletId = ""
navigateTo(Screen.CREATE_WALLET)
},
onRenameWallet = { id, newName ->
val wallet = walletRepo.loadWallet(id)
wallet?.let {
walletRepo.saveWallet(it.copy(name = newName))
appStateManager.notifyWalletsChanged()
}
},
onDeleteWallet = { id ->
val wallet = walletRepo.loadWallet(id)
wallet?.let {
selectedWalletId = id
deleteType = "WALLET"
deleteName = it.name
navigateTo(Screen.DELETE_CONFIRM)
}
},
onUpdateEndpoints = { id, endpoints ->
val wallet = walletRepo.loadWallet(id)
wallet?.let {
walletRepo.saveWallet(it.copy(endpoints = endpoints))
appStateManager.notifyWalletsChanged()
}
},
onAddEndpoint = { walletId, host, port, useTls ->
scope.launch {
try {
val wallet = walletRepo.loadWallet(walletId) ?: return@launch
val endpoint = swiss.qpq.gajumobile.data.models.Endpoint(host, port, useTls)
val client = NodeClient(listOf(NodeClient.Endpoint(host, port, useTls)))
val status = withContext(Dispatchers.IO) { client.status() }
val reportedNetworkId = status["network_id"] as? String
if ((reportedNetworkId != null && reportedNetworkId != wallet.networkId)) {
val allWallets = walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) }
val targetWallet = allWallets.find { it.networkId == reportedNetworkId }
if (targetWallet != null) {
val targetEndpoints = targetWallet.endpoints + endpoint
walletRepo.saveWallet(targetWallet.copy(endpoints = targetEndpoints))
appStateManager.notifyWalletsChanged()
onError(context.getString(R.string.err_network_mismatch_added, reportedNetworkId, targetWallet.name))
} else {
nodeStatusError = context.getString(R.string.err_critical_mismatch_create, reportedNetworkId, wallet.networkId)
}
} else {
val newList = wallet.endpoints + endpoint
walletRepo.saveWallet(wallet.copy(endpoints = newList))
appStateManager.notifyWalletsChanged()
onError(context.getString(R.string.msg_endpoint_verified))
}
} catch (e: Exception) {
// Even if status check fails, we might still want to add it?
// User said: "We should test the endpoints and fall back to HTTP"
// If it fails completely, maybe don't add it or warn the user.
nodeStatusError = "Failed to verify endpoint: ${e.message}. It has not been added."
}
}
},
onCheckStatus = { walletId, index ->
scope.launch {
try {
val wallet = walletRepo.loadWallet(walletId) ?: return@launch
val endpoint = wallet.endpoints.getOrNull(index) ?: return@launch
val client = NodeClient(listOf(NodeClient.Endpoint(endpoint.host, endpoint.port, endpoint.useTls)))
val status = withContext(Dispatchers.IO) { client.status() }
val reportedNetworkId = status["network_id"] as? String
if ((reportedNetworkId != null && reportedNetworkId != wallet.networkId)) {
// Mismatch logic
val allWallets = walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) }
val targetWallet = allWallets.find { it.networkId == reportedNetworkId }
if (targetWallet != null) {
// Move endpoint
val sourceEndpoints = wallet.endpoints.toMutableList().apply { removeAt(index) }
val targetEndpoints = targetWallet.endpoints + endpoint
walletRepo.saveWallet(wallet.copy(endpoints = sourceEndpoints))
walletRepo.saveWallet(targetWallet.copy(endpoints = targetEndpoints))
appStateManager.notifyWalletsChanged()
onError(context.getString(R.string.err_network_mismatch_moved, reportedNetworkId, targetWallet.name))
} else {
// Warn for discard
nodeStatusError = context.getString(R.string.err_critical_mismatch_fix, reportedNetworkId, wallet.networkId)
}
} else {
nodeStatusResult = status
}
} catch (e: Exception) {
nodeStatusError = e.message ?: "Unknown error"
}
}
},
statusResult = nodeStatusResult,
statusError = nodeStatusError,
onClearStatus = {
nodeStatusResult = null
nodeStatusError = null
},
onBack = { navigateBack() },
)
}
Screen.MANAGE_ACCOUNT -> {
val wallet = remember(selectedWalletId, appState.walletsVersion) {
walletRepo.loadWallet(selectedWalletId)
} ?: return
val account = wallet.accounts.find { it.id == selectedAccountId } ?: return
val isUnverified = appState.unverifiedAccountIds.contains(account.id)
ManageAccountScreen(
account = account,
walletName = wallet.name,
balanceFormat = GajuFormat.Type.valueOf(appState.balanceFormat),
balanceUnit = GajuFormat.Unit.valueOf(appState.balanceUnit),
isUnverified = isUnverified,
onViewMnemonic = {
try {
val masterKey = KeyManager.getMasterKey(context)
val phrase = AccountAirlock.deriveMnemonic(account.privateKeyEnvelope, masterKey)
viewMnemonicAccount = account
viewMnemonicPhrase = phrase
navigateTo(Screen.VIEW_MNEMONIC)
} catch (e: Exception) {
onError(context.getString(R.string.err_auth_failed, e.message ?: ""))
}
},
onVerifyMnemonic = {
try {
val masterKey = KeyManager.getMasterKey(context)
val phrase = AccountAirlock.deriveMnemonic(account.privateKeyEnvelope, masterKey)
verifyMnemonicAccount = account
verifyMnemonicPhrase = phrase
navigateTo(Screen.VERIFY_MNEMONIC)
} catch (e: Exception) {
onError(context.getString(R.string.err_auth_failed, e.message ?: ""))
}
},
onRename = { newName ->
val updatedAccounts = wallet.accounts.map {
if (it.id == account.id) it.copy(label = newName) else it
}
walletRepo.saveWallet(wallet.copy(accounts = updatedAccounts))
appStateManager.notifyWalletsChanged()
},
onDelete = {
deleteType = "ACCOUNT"
deleteName = account.label
navigateTo(Screen.DELETE_CONFIRM)
}
)
}
Screen.SETTINGS -> {
SettingsScreen(
balanceFormat = GajuFormat.Type.valueOf(appState.balanceFormat),
onFormatChange = { type ->
appStateManager.setBalanceFormat(type.name)
},
onNavigate = { dest ->
if (dest == "WIPE_DATA") {
walletRepo.listWallets().forEach { walletRepo.deleteWallet(it) }
appStateManager.notifyWalletsChanged()
screenStack.clear()
screenStack.add(Screen.CREATE_WALLET)
} else {
val screen = when (dest) {
"dashboard" -> Screen.DASHBOARD
"transactions" -> Screen.EXPLORER
"environment_info" -> Screen.ENVIRONMENT_INFO
"manage_wallets" -> Screen.MANAGE_WALLETS
else -> null
}
if (screen != null) {
screenStack.clear()
screenStack.add(screen)
}
}
}
)
}
Screen.ENVIRONMENT_INFO -> {
EnvironmentInfoScreen(onBack = { navigateBack() })
}
Screen.FETCHING_SIGN_REQUEST -> {
FetchingScreen(
url = pendingSignUrl,
onCancel = {
activeFetchJob?.cancel()
navigateBack()
}
)
}
Screen.QR_SCANNER -> QRScannerScreen(
onScanResult = { url ->
try {
val result = Grids.parse(url)
if (result.verb == Grids.Verb.SIGN) {
pendingSignUrl = result.url
navigateTo(Screen.FETCHING_SIGN_REQUEST)
activeFetchJob = scope.launch {
try {
android.util.Log.i("GajuRouter", "Fetching GRIDS request from: ${result.url}")
@Suppress("UNCHECKED_CAST")
val response = withContext(Dispatchers.IO) {
Http.get(result.url) as? Map<String, Any?>
}
if (response != null) {
pendingSignRequest = response.toGridsSignRequest()
// Remove fetching screen and add request screen
screenStack.removeAt(screenStack.size - 1)
navigateTo(Screen.SIGN_REQUEST)
} else {
onError(context.getString(R.string.err_fetch_sig_failed))
navigateBack()
}
} catch (e: Exception) {
if (activeFetchJob?.isCancelled == false) {
onError(context.getString(R.string.err_fetch_failed, e.message ?: ""))
navigateBack()
}
} finally {
activeFetchJob = null
}
}
} else {
pendingScanResult = result
navigateTo(Screen.SEND_FORM)
}
} catch (e: Exception) {
onError(context.getString(R.string.err_invalid_grids_url, e.message ?: ""))
}
},
onGenerate = { navigateTo(Screen.QR_RECEIVE) },
onUpload = {},
onManualEntry = {
pendingScanResult = null
navigateTo(Screen.SEND_FORM)
},
onBack = { navigateBack() },
)
Screen.SEND_FORM -> {
val wallets = walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) }
val activeWallet = wallets.getOrNull(activeWalletIndex)
val activeAccount = activeWallet?.accounts?.getOrNull(activeAccountIndex)
if (activeAccount == null) {
onError(context.getString(R.string.err_no_active_account))
navigateBack()
return
}
val result = pendingScanResult
val initialRecipient = result?.recipient ?: ""
val initialAmount = if (result?.amount != null) {
val type = GajuFormat.Type.valueOf(appState.balanceFormat)
val unit = GajuFormat.Unit.valueOf(appState.balanceUnit)
GajuFormat.amount(GajuFormat.standardSpec(type, unit), result.amount)
} else ""
val initialPayload = if (result?.payload != null) String(result.payload, Charsets.UTF_8) else ""
SendFormScreen(
fromAccount = activeAccount,
balanceFormat = GajuFormat.Type.valueOf(appState.balanceFormat),
balanceUnit = GajuFormat.Unit.valueOf(appState.balanceUnit),
initialRecipient = initialRecipient,
initialAmount = initialAmount,
initialPayload = initialPayload,
onReview = { recipient, amountStr, payload, gas, gasPrice, ttl ->
pendingRecipient = recipient
pendingAmountStr = amountStr
pendingPayload = payload
pendingGas = gas
pendingGasPrice = gasPrice
pendingTTL = ttl
navigateTo(Screen.SEND_REVIEW)
},
onBack = { navigateBack() },
)
}
Screen.SEND_REVIEW -> {
val wallets = walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) }
val activeWallet = wallets.getOrNull(activeWalletIndex)
val activeAccount = activeWallet?.accounts?.getOrNull(activeAccountIndex)
if (activeAccount == null) {
onError(context.getString(R.string.err_no_active_account))
navigateBack()
return
}
SendReviewScreen(
senderLabel = activeAccount.label,
recipientLabel = "Recipient",
recipientId = pendingRecipient,
amountStr = pendingAmountStr,
onSend = {
scope.launch {
try {
// 1. Get next nonce and current height
val (nonce, height) = withContext(Dispatchers.IO) {
val client = NodeClient(activeWallet.endpoints.map { NodeClient.Endpoint(it.host, it.port) })
client.nextNonce(activeAccount.gajuId) to client.topHeight()
}
// 2. Derive seed from airlock
val masterKey = KeyManager.getMasterKey(context)
val seed = AccountAirlock.derivePrivateKey(activeAccount.privateKeyEnvelope, masterKey)
// 3. Build and sign tx
val amountPucks = BigInteger(GajuFormat.read(pendingAmountStr))
val signedTx = TransactionService.buildSpendTx(
activeWallet.networkId,
activeAccount.gajuId,
pendingRecipient,
amountPucks,
BigInteger(pendingGasPrice),
BigInteger(pendingGas),
height + pendingTTL.toLong(),
nonce,
pendingPayload,
seed
)
// 4. Wipe seed
GajuNative.memzero(seed)
// 5. Post tx
val result = withContext(Dispatchers.IO) {
val client = NodeClient(activeWallet.endpoints.map { NodeClient.Endpoint(it.host, it.port) })
client.postTx(signedTx)
}
val hash = result["hash"] as? String ?: "Unknown"
onError(context.getString(R.string.msg_tx_posted, hash))
// Success! Go back to dashboard
screenStack.clear()
screenStack.add(Screen.DASHBOARD)
} catch (e: Throwable) {
val msg = e.message ?: e.toString()
onError(context.getString(R.string.err_send_failed, msg))
android.util.Log.e("GajuRouter", "Send failed", e)
}
}
},
onBack = { navigateBack() }
)
}
Screen.SIGN_REQUEST -> {
val wallets = walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) }
val activeWallet = wallets.getOrNull(activeWalletIndex)
val activeAccount = activeWallet?.accounts?.getOrNull(activeAccountIndex)
val request = pendingSignRequest ?: return
// If request specifies an ID, try to find that account
val signAccount = if (request.publicId != null && request.publicId != "false") {
wallets.flatMap { it.accounts }.find { it.gajuId == request.publicId }
} else {
activeAccount
}
if (signAccount == null) {
onError(context.getString(R.string.err_no_matching_account))
navigateBack()
return
}
SignRequestScreen(
request = request,
originUrl = pendingSignUrl,
accountLabel = signAccount.label,
accountId = signAccount.gajuId,
onSign = {
scope.launch {
try {
val masterKey = KeyManager.getMasterKey(context)
val signedRequest = mutableMapOf<String, Any?>()
// Essential keys present in all GRIDS responses
signedRequest["grids"] = request.grids
signedRequest["chain"] = request.chain
signedRequest["network_id"] = request.networkId
signedRequest["type"] = request.type.toProtocolString()
signedRequest["public_id"] = signAccount.gajuId // Must be the actual ID string, never 'false'
when (request.type) {
GridsSignType.MESSAGE -> {
val sig = AccountAirlock.signMessage(signAccount.privateKeyEnvelope, masterKey, request.payload)
signedRequest["payload"] = request.payload
signedRequest["signature"] = android.util.Base64.encodeToString(sig, android.util.Base64.NO_WRAP)
}
GridsSignType.BINARY -> {
val binary = swiss.qpq.gajumaru.core.encoding.ApiEncoder.decode(request.payload).payload()
val sig = AccountAirlock.signBinary(signAccount.privateKeyEnvelope, masterKey, binary)
signedRequest["payload"] = request.payload
signedRequest["signature"] = android.util.Base64.encodeToString(sig, android.util.Base64.NO_WRAP)
}
GridsSignType.TX -> {
val nid = request.networkId ?: activeWallet?.networkId ?: "groot.mainnet"
val txBinary = swiss.qpq.gajumaru.core.encoding.ApiEncoder.decode(request.payload).payload()
signedRequest["payload"] = AccountAirlock.signTx(signAccount.privateKeyEnvelope, masterKey, txBinary, nid)
signedRequest["signed"] = true
}
else -> {
throw Exception("Cannot sign unknown request type")
}
}
android.util.Log.i("GajuRouter", "POSTing GRIDS response to $pendingSignUrl")
withContext(Dispatchers.IO) {
Http.post(pendingSignUrl, signedRequest)
}
onError(context.getString(R.string.msg_sig_submitted))
screenStack.clear()
screenStack.add(Screen.DASHBOARD)
} catch (e: Throwable) {
val msg = e.message ?: e.toString()
onError(context.getString(R.string.err_sig_failed, msg))
android.util.Log.e("GajuRouter", "Signing failed", e)
}
}
},
onCancel = { navigateBack() }
)
}
Screen.QR_RECEIVE -> {
val wallets = walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) }
val activeWallet = wallets.getOrNull(activeWalletIndex)
val activeAccount = activeWallet?.accounts?.getOrNull(activeAccountIndex)
if (activeAccount == null) {
onError(context.getString(R.string.err_no_active_account))
navigateBack()
return
}
QRReceiveScreen(
account = activeAccount,
networkId = activeWallet.networkId,
balanceFormat = GajuFormat.Type.valueOf(appState.balanceFormat),
balanceUnit = GajuFormat.Unit.valueOf(appState.balanceUnit),
onDone = { navigateBack() },
)
}
Screen.EXPLORER -> {
ExplorerScreen(
onNavigate = { dest ->
val screen = when (dest) {
"dashboard" -> Screen.DASHBOARD
"settings" -> Screen.SETTINGS
"manage_wallets" -> Screen.MANAGE_WALLETS
else -> null
}
if (screen != null) {
screenStack.clear()
screenStack.add(screen)
}
}
)
}
}
}