874 lines
41 KiB
Kotlin
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)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|