WIP: omg so much little stuff
This commit is contained in:
@@ -7,6 +7,7 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -21,6 +22,7 @@ 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
|
||||
@@ -39,6 +41,7 @@ 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
|
||||
@@ -50,10 +53,12 @@ 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
|
||||
@@ -118,7 +123,9 @@ enum class Screen {
|
||||
VERIFY_MNEMONIC,
|
||||
ENVIRONMENT_INFO,
|
||||
SEND_FORM,
|
||||
SEND_REVIEW
|
||||
SEND_REVIEW,
|
||||
SIGN_REQUEST,
|
||||
FETCHING_SIGN_REQUEST
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -132,7 +139,8 @@ fun GajuRouter(
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val initialScreen = remember {
|
||||
if (walletRepo.listWallets().isEmpty()) Screen.CREATE_WALLET else Screen.DASHBOARD
|
||||
val wallets = walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) }
|
||||
if (wallets.isEmpty()) Screen.CREATE_WALLET else Screen.DASHBOARD
|
||||
}
|
||||
|
||||
val screenStack = remember { mutableStateListOf(initialScreen) }
|
||||
@@ -184,6 +192,10 @@ fun GajuRouter(
|
||||
var pendingGasPrice by rememberSaveable { mutableStateOf("") }
|
||||
var pendingTTL by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
var pendingSignRequest by remember { mutableStateOf<Map<String, Any?>?>(null) }
|
||||
var pendingSignUrl by rememberSaveable { mutableStateOf("") }
|
||||
var activeFetchJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
when (currentScreen) {
|
||||
Screen.CREATE_WALLET -> {
|
||||
CreateWalletScreen(
|
||||
@@ -208,8 +220,10 @@ fun GajuRouter(
|
||||
pendingAccount = account
|
||||
setupRecovered = false
|
||||
navigateTo(Screen.ACCOUNT_NAMING)
|
||||
} catch (e: Exception) {
|
||||
onError("Failed to generate account: ${e.message}")
|
||||
} catch (e: Throwable) {
|
||||
val msg = e.message ?: e.toString()
|
||||
onError("Failed to generate account: $msg")
|
||||
android.util.Log.e("GajuRouter", "Account generation failed", e)
|
||||
}
|
||||
},
|
||||
onRecover = {
|
||||
@@ -326,6 +340,7 @@ fun GajuRouter(
|
||||
val wallets = remember(appState.walletsVersion) {
|
||||
walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) }
|
||||
}
|
||||
|
||||
DashboardScreen(
|
||||
wallets = wallets,
|
||||
selectedWalletIndex = activeWalletIndex,
|
||||
@@ -335,6 +350,7 @@ fun GajuRouter(
|
||||
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
|
||||
@@ -361,6 +377,13 @@ fun GajuRouter(
|
||||
"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) }
|
||||
@@ -538,16 +561,23 @@ fun GajuRouter(
|
||||
appStateManager.setBalanceFormat(type.name)
|
||||
},
|
||||
onNavigate = { dest ->
|
||||
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) {
|
||||
if (dest == "WIPE_DATA") {
|
||||
walletRepo.listWallets().forEach { walletRepo.deleteWallet(it) }
|
||||
appStateManager.notifyWalletsChanged()
|
||||
screenStack.clear()
|
||||
screenStack.add(screen)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -555,12 +585,51 @@ fun GajuRouter(
|
||||
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)
|
||||
pendingScanResult = result
|
||||
navigateTo(Screen.SEND_FORM)
|
||||
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}")
|
||||
val request = withContext(Dispatchers.IO) {
|
||||
Http.get(result.url) as? Map<String, Any?>
|
||||
}
|
||||
if (request != null) {
|
||||
pendingSignRequest = request
|
||||
// Remove fetching screen and add request screen
|
||||
screenStack.removeAt(screenStack.size - 1)
|
||||
navigateTo(Screen.SIGN_REQUEST)
|
||||
} else {
|
||||
onError("Failed to fetch signature request.")
|
||||
navigateBack()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (activeFetchJob?.isCancelled == false) {
|
||||
onError("Fetch failed: ${e.message}")
|
||||
navigateBack()
|
||||
}
|
||||
} finally {
|
||||
activeFetchJob = null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pendingScanResult = result
|
||||
navigateTo(Screen.SEND_FORM)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError("Invalid GRIDS URL: ${e.message}")
|
||||
}
|
||||
@@ -637,9 +706,9 @@ fun GajuRouter(
|
||||
client.nextNonce(activeAccount.gajuId) to client.topHeight()
|
||||
}
|
||||
|
||||
// 2. Derive seckey from airlock
|
||||
// 2. Derive seed from airlock
|
||||
val masterKey = KeyManager.getMasterKey(context)
|
||||
val seckey = AccountAirlock.derivePrivateKey(activeAccount.privateKeyEnvelope, masterKey)
|
||||
val seed = AccountAirlock.derivePrivateKey(activeAccount.privateKeyEnvelope, masterKey)
|
||||
|
||||
// 3. Build and sign tx
|
||||
val amountPucks = BigInteger(GajuFormat.read(pendingAmountStr))
|
||||
@@ -653,11 +722,11 @@ fun GajuRouter(
|
||||
height + pendingTTL.toLong(),
|
||||
nonce,
|
||||
pendingPayload,
|
||||
seckey
|
||||
seed
|
||||
)
|
||||
|
||||
// 4. Wipe seckey
|
||||
GajuNative.memzero(seckey)
|
||||
// 4. Wipe seed
|
||||
GajuNative.memzero(seed)
|
||||
|
||||
// 5. Post tx
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
@@ -671,14 +740,96 @@ fun GajuRouter(
|
||||
// Success! Go back to dashboard
|
||||
screenStack.clear()
|
||||
screenStack.add(Screen.DASHBOARD)
|
||||
} catch (e: Exception) {
|
||||
onError("Send failed: ${e.message}")
|
||||
} catch (e: Throwable) {
|
||||
val msg = e.message ?: e.toString()
|
||||
onError("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
|
||||
val type = request["type"] as? String ?: ""
|
||||
val payload = request["payload"] as? String ?: ""
|
||||
val publicId = request["public_id"] as? String
|
||||
|
||||
// If request specifies an ID, try to find that account
|
||||
val signAccount = if (publicId != null && publicId != "false") {
|
||||
wallets.flatMap { it.accounts }.find { it.gajuId == publicId }
|
||||
} else {
|
||||
activeAccount
|
||||
}
|
||||
|
||||
if (signAccount == null) {
|
||||
onError("No matching account found for signature.")
|
||||
navigateBack()
|
||||
return
|
||||
}
|
||||
|
||||
SignRequestScreen(
|
||||
type = type,
|
||||
originUrl = pendingSignUrl,
|
||||
accountLabel = signAccount.label,
|
||||
accountId = signAccount.gajuId,
|
||||
payload = payload,
|
||||
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["network_id"]
|
||||
signedRequest["type"] = type
|
||||
signedRequest["public_id"] = signAccount.gajuId // Must be the actual ID string, never 'false'
|
||||
|
||||
when (type) {
|
||||
"message" -> {
|
||||
val sig = AccountAirlock.signMessage(signAccount.privateKeyEnvelope, masterKey, payload)
|
||||
signedRequest["payload"] = payload
|
||||
signedRequest["signature"] = android.util.Base64.encodeToString(sig, android.util.Base64.NO_WRAP)
|
||||
}
|
||||
"binary" -> {
|
||||
val binary = android.util.Base64.decode(payload, android.util.Base64.DEFAULT)
|
||||
val sig = AccountAirlock.signBinary(signAccount.privateKeyEnvelope, masterKey, binary)
|
||||
signedRequest["payload"] = payload
|
||||
signedRequest["signature"] = android.util.Base64.encodeToString(sig, android.util.Base64.NO_WRAP)
|
||||
}
|
||||
"tx" -> {
|
||||
val nid = request["network_id"] as? String ?: activeWallet?.networkId ?: "groot.mainnet"
|
||||
val txBinary = android.util.Base64.decode(payload, android.util.Base64.DEFAULT)
|
||||
signedRequest["payload"] = AccountAirlock.signTx(signAccount.privateKeyEnvelope, masterKey, txBinary, nid)
|
||||
signedRequest["signed"] = true
|
||||
}
|
||||
}
|
||||
|
||||
android.util.Log.i("GajuRouter", "POSTing GRIDS response to $pendingSignUrl")
|
||||
withContext(Dispatchers.IO) {
|
||||
Http.post(pendingSignUrl, signedRequest)
|
||||
}
|
||||
|
||||
onError("Signature submitted successfully.")
|
||||
screenStack.clear()
|
||||
screenStack.add(Screen.DASHBOARD)
|
||||
} catch (e: Throwable) {
|
||||
val msg = e.message ?: e.toString()
|
||||
onError("Signing 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)
|
||||
|
||||
@@ -58,11 +58,6 @@ class WalletRepository(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun signMessage(account: Account, message: ByteArray): ByteArray {
|
||||
val masterKey = KeyManager.getMasterKey(context)
|
||||
return AccountAirlock.sign(account.privateKeyEnvelope, masterKey, message)
|
||||
}
|
||||
|
||||
suspend fun refreshAccount(walletId: String, accountId: String): Account? {
|
||||
val wallet = loadWallet(walletId) ?: return null
|
||||
val account = wallet.accounts.find { it.id == accountId } ?: return null
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/*
|
||||
* Copyright (c) 2026 QPQ AG <info@qpq.swiss>. All rights reserved.
|
||||
* Project: Gajumaru Mobile Wallet
|
||||
*/
|
||||
|
||||
package swiss.qpq.gajumobile.security;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -13,6 +8,7 @@ import javax.crypto.spec.SecretKeySpec;
|
||||
import swiss.qpq.gajumaru.core.crypto.Vault;
|
||||
import swiss.qpq.gajumaru.core.encoding.Mnemonic;
|
||||
import swiss.qpq.gajumaru.core.tools.CryptoUtils;
|
||||
import swiss.qpq.gajumaru.core.tools.TransactionService;
|
||||
import swiss.qpq.gajumobile.data.models.Account;
|
||||
|
||||
/**
|
||||
@@ -48,12 +44,6 @@ public final class AccountAirlock {
|
||||
|
||||
/**
|
||||
* Creates a new Account object from a mnemonic phrase.
|
||||
*
|
||||
* @param label The user-defined label for the account.
|
||||
* @param phrase The mnemonic phrase as a list of UTF-8 word byte arrays.
|
||||
* @param masterKey The hardware-backed Master Key handle.
|
||||
* @return The new Account object.
|
||||
* @throws Exception if creation or encryption fails.
|
||||
*/
|
||||
public static Account createAccount(
|
||||
String label,
|
||||
@@ -69,11 +59,17 @@ public final class AccountAirlock {
|
||||
seed = Mnemonic.decode(phrase);
|
||||
|
||||
// 2. Generate Public Key using Libsodium
|
||||
byte[] keypair = GajuNative.INSTANCE.cryptoSignSeedKeypair(seed);
|
||||
if (GajuNative.INSTANCE.getLoadError() != null) {
|
||||
throw new RuntimeException("Native library failed to load: " + GajuNative.INSTANCE.getLoadError());
|
||||
}
|
||||
byte[] keypair = GajuNative.cryptoSignSeedKeypair(seed);
|
||||
if (keypair == null) {
|
||||
throw new RuntimeException("Native keypair generation failed (returned null).");
|
||||
}
|
||||
byte[] publicKey = new byte[32];
|
||||
System.arraycopy(keypair, 0, publicKey, 0, 32);
|
||||
// keypair contains sk too, but we only need pk here for the Account model
|
||||
GajuNative.INSTANCE.memzero(keypair);
|
||||
GajuNative.memzero(keypair);
|
||||
|
||||
// 3. Generate random Data Encryption Key (DEK)
|
||||
SecureRandom random = new SecureRandom();
|
||||
@@ -82,11 +78,9 @@ public final class AccountAirlock {
|
||||
SecretKey dek = new SecretKeySpec(dekBytes, "AES");
|
||||
|
||||
// 4. Encrypt Private Key (seed) with DEK
|
||||
// We use the provider-generated IV here for maximum hardware compatibility
|
||||
Vault.Ciphertext dataCiphertext = Vault.encrypt(dek, seed);
|
||||
|
||||
// 5. Encrypt DEK with Master Key
|
||||
// Android Keystore REQUIRED to generate the IV when randomizedEncryption is true
|
||||
Vault.Ciphertext dekCiphertext = Vault.encrypt(masterKey, dekBytes);
|
||||
|
||||
EncryptedEnvelope envelope = new EncryptedEnvelope(
|
||||
@@ -100,86 +94,90 @@ public final class AccountAirlock {
|
||||
return new Account(id, label, publicKey, envelope, null);
|
||||
|
||||
} finally {
|
||||
// 6. Memory Hygiene: Wipe sensitive data from the heap
|
||||
if (seed != null) {
|
||||
CryptoUtils.wipe(seed);
|
||||
}
|
||||
if (dekBytes != null) {
|
||||
CryptoUtils.wipe(dekBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to convert a UI String into the internal airlock byte[][] format.
|
||||
*/
|
||||
public static byte[][] stringToPhrase(String phrase) {
|
||||
String[] words = phrase.trim().split("\\s+");
|
||||
byte[][] result = new byte[words.length][];
|
||||
for (int i = 0; i < words.length; i++) {
|
||||
result[i] = words[i].getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to wipe a phrase returned by the airlock.
|
||||
*/
|
||||
public static void wipePhrase(byte[][] phrase) {
|
||||
if (phrase != null) {
|
||||
for (byte[] word : phrase) {
|
||||
CryptoUtils.wipe(word);
|
||||
}
|
||||
if (seed != null) GajuNative.memzero(seed);
|
||||
if (dekBytes != null) CryptoUtils.wipe(dekBytes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an account's private key and signs a message.
|
||||
*/
|
||||
public static byte[] sign(
|
||||
public static byte[] signMessage(
|
||||
EncryptedEnvelope envelope,
|
||||
SecretKey masterKey,
|
||||
byte[] message
|
||||
String message
|
||||
) throws Exception {
|
||||
|
||||
byte[] plaintextDek = null;
|
||||
byte[] plaintextPrivateKey = null;
|
||||
|
||||
byte[] seed = derivePrivateKey(envelope, masterKey);
|
||||
try {
|
||||
plaintextDek = Vault.decrypt(
|
||||
masterKey,
|
||||
envelope.getDekIv(),
|
||||
envelope.getEncryptedDek()
|
||||
);
|
||||
SecretKey dek = new SecretKeySpec(plaintextDek, "AES");
|
||||
|
||||
plaintextPrivateKey = Vault.decrypt(
|
||||
dek,
|
||||
envelope.getDataIv(),
|
||||
envelope.getEncryptedData()
|
||||
);
|
||||
|
||||
// Libsodium SK is 64 bytes: seed || public_key
|
||||
byte[] keypair = GajuNative.INSTANCE.cryptoSignSeedKeypair(plaintextPrivateKey);
|
||||
byte[] keypair = GajuNative.cryptoSignSeedKeypair(seed);
|
||||
if (keypair == null) throw new RuntimeException("Native keypair generation failed.");
|
||||
byte[] secretKey = new byte[64];
|
||||
System.arraycopy(keypair, 32, secretKey, 0, 64);
|
||||
|
||||
byte[] sig = GajuNative.INSTANCE.cryptoSignDetached(message, secretKey);
|
||||
byte[] sig = TransactionService.signMessage(message, secretKey);
|
||||
|
||||
GajuNative.INSTANCE.memzero(keypair);
|
||||
GajuNative.INSTANCE.memzero(secretKey);
|
||||
|
||||
GajuNative.memzero(keypair);
|
||||
GajuNative.memzero(secretKey);
|
||||
return sig;
|
||||
|
||||
} finally {
|
||||
if (plaintextDek != null) CryptoUtils.wipe(plaintextDek);
|
||||
if (plaintextPrivateKey != null) GajuNative.INSTANCE.memzero(plaintextPrivateKey);
|
||||
GajuNative.memzero(seed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an account's private key and signs binary data.
|
||||
*/
|
||||
public static byte[] signBinary(
|
||||
EncryptedEnvelope envelope,
|
||||
SecretKey masterKey,
|
||||
byte[] data
|
||||
) throws Exception {
|
||||
byte[] seed = derivePrivateKey(envelope, masterKey);
|
||||
try {
|
||||
byte[] keypair = GajuNative.cryptoSignSeedKeypair(seed);
|
||||
if (keypair == null) throw new RuntimeException("Native keypair generation failed.");
|
||||
byte[] secretKey = new byte[64];
|
||||
System.arraycopy(keypair, 32, secretKey, 0, 64);
|
||||
|
||||
byte[] sig = TransactionService.signBinary(data, secretKey);
|
||||
|
||||
GajuNative.memzero(keypair);
|
||||
GajuNative.memzero(secretKey);
|
||||
return sig;
|
||||
} finally {
|
||||
GajuNative.memzero(seed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an account's private key and signs a transaction.
|
||||
*/
|
||||
public static String signTx(
|
||||
EncryptedEnvelope envelope,
|
||||
SecretKey masterKey,
|
||||
byte[] txData,
|
||||
String networkId
|
||||
) throws Exception {
|
||||
byte[] seed = derivePrivateKey(envelope, masterKey);
|
||||
try {
|
||||
byte[] keypair = GajuNative.cryptoSignSeedKeypair(seed);
|
||||
if (keypair == null) throw new RuntimeException("Native keypair generation failed.");
|
||||
byte[] secretKey = new byte[64];
|
||||
System.arraycopy(keypair, 32, secretKey, 0, 64);
|
||||
|
||||
String signedTx = TransactionService.signTx(txData, networkId, secretKey);
|
||||
|
||||
GajuNative.memzero(keypair);
|
||||
GajuNative.memzero(secretKey);
|
||||
return signedTx;
|
||||
} finally {
|
||||
GajuNative.memzero(seed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an account's private key (seed).
|
||||
* WARNING: Result must be wiped with CryptoUtils.wipe() immediately after use.
|
||||
* WARNING: Result must be wiped with GajuNative.memzero() immediately after use.
|
||||
*/
|
||||
public static byte[] derivePrivateKey(
|
||||
EncryptedEnvelope envelope,
|
||||
@@ -211,29 +209,34 @@ public final class AccountAirlock {
|
||||
EncryptedEnvelope envelope,
|
||||
SecretKey masterKey
|
||||
) throws Exception {
|
||||
|
||||
byte[] plaintextDek = null;
|
||||
byte[] plaintextPrivateKey = null;
|
||||
|
||||
byte[] plaintextPrivateKey = derivePrivateKey(envelope, masterKey);
|
||||
try {
|
||||
plaintextDek = Vault.decrypt(
|
||||
masterKey,
|
||||
envelope.getDekIv(),
|
||||
envelope.getEncryptedDek()
|
||||
);
|
||||
SecretKey dek = new SecretKeySpec(plaintextDek, "AES");
|
||||
|
||||
plaintextPrivateKey = Vault.decrypt(
|
||||
dek,
|
||||
envelope.getDataIv(),
|
||||
envelope.getEncryptedData()
|
||||
);
|
||||
|
||||
return Mnemonic.encode(plaintextPrivateKey);
|
||||
|
||||
} finally {
|
||||
if (plaintextDek != null) CryptoUtils.wipe(plaintextDek);
|
||||
if (plaintextPrivateKey != null) GajuNative.INSTANCE.memzero(plaintextPrivateKey);
|
||||
if (plaintextPrivateKey != null) GajuNative.memzero(plaintextPrivateKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to convert a UI String into the internal airlock byte[][] format.
|
||||
*/
|
||||
public static byte[][] stringToPhrase(String phrase) {
|
||||
String[] words = phrase.trim().split("\\s+");
|
||||
byte[][] result = new byte[words.length][];
|
||||
for (int i = 0; i < words.length; i++) {
|
||||
result[i] = words[i].getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to wipe a phrase returned by the airlock.
|
||||
*/
|
||||
public static void wipePhrase(byte[][] phrase) {
|
||||
if (phrase != null) {
|
||||
for (byte[] word : phrase) {
|
||||
CryptoUtils.wipe(word);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import androidx.lifecycle.LifecycleOwner
|
||||
|
||||
class AppLifecycleObserver(private val context: Context) : DefaultLifecycleObserver {
|
||||
|
||||
private var isRegistered = false
|
||||
|
||||
private val screenOffReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == Intent.ACTION_SCREEN_OFF) {
|
||||
@@ -20,12 +22,26 @@ class AppLifecycleObserver(private val context: Context) : DefaultLifecycleObser
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
super.onStart(owner)
|
||||
SecurityManager.onAppForegrounded()
|
||||
context.registerReceiver(screenOffReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF))
|
||||
if (!isRegistered) {
|
||||
context.registerReceiver(
|
||||
screenOffReceiver,
|
||||
IntentFilter(Intent.ACTION_SCREEN_OFF),
|
||||
Context.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
isRegistered = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
super.onStop(owner)
|
||||
SecurityManager.onAppBackgrounded()
|
||||
context.unregisterReceiver(screenOffReceiver)
|
||||
if (isRegistered) {
|
||||
try {
|
||||
context.unregisterReceiver(screenOffReceiver)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("AppLifecycle", "Unregister failed", e)
|
||||
}
|
||||
isRegistered = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +55,10 @@ object EncryptionService {
|
||||
// 3. Encrypt the DEK with the Master Key (Hardware-backed)
|
||||
val masterKey = KeyManager.getMasterKey(context)
|
||||
val dekCipher = Cipher.getInstance(AES_GCM)
|
||||
// Let Keystore generate the IV for the Master Key encryption
|
||||
dekCipher.init(Cipher.ENCRYPT_MODE, masterKey)
|
||||
val dekIv = ByteArray(IV_LENGTH).apply { SecureRandom().nextBytes(this) }
|
||||
// Now using manual IV for hardware key to ensure tag length consistency
|
||||
dekCipher.init(Cipher.ENCRYPT_MODE, masterKey, GCMParameterSpec(GCM_TAG_LENGTH, dekIv))
|
||||
val encryptedDek = dekCipher.doFinal(dek.encoded)
|
||||
val dekIv = dekCipher.iv
|
||||
|
||||
return EncryptedEnvelope(
|
||||
encryptedData = encryptedData,
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
package swiss.qpq.gajumobile.security
|
||||
|
||||
object GajuNative {
|
||||
var loadError: String? = null
|
||||
private set
|
||||
|
||||
init {
|
||||
System.loadLibrary("gaju-native")
|
||||
try {
|
||||
System.loadLibrary("gaju-native")
|
||||
android.util.Log.i("GajuNative", "Native library loaded successfully.")
|
||||
} catch (e: Throwable) {
|
||||
loadError = e.message ?: e.toString()
|
||||
android.util.Log.e("GajuNative", "Failed to load library: $loadError", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeros out the provided byte array using native memset.
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun memzero(array: ByteArray)
|
||||
|
||||
/**
|
||||
@@ -15,7 +25,8 @@ object GajuNative {
|
||||
* @param seed 32-byte seed.
|
||||
* @return 96-byte array containing public key (32 bytes) followed by secret key (64 bytes).
|
||||
*/
|
||||
external fun cryptoSignSeedKeypair(seed: ByteArray): ByteArray
|
||||
@JvmStatic
|
||||
external fun cryptoSignSeedKeypair(seed: ByteArray): ByteArray?
|
||||
|
||||
/**
|
||||
* Signs a message using Ed25519.
|
||||
@@ -23,7 +34,8 @@ object GajuNative {
|
||||
* @param secretKey 64-byte secret key.
|
||||
* @return 64-byte detached signature.
|
||||
*/
|
||||
external fun cryptoSignDetached(message: ByteArray, secretKey: ByteArray): ByteArray
|
||||
@JvmStatic
|
||||
external fun cryptoSignDetached(message: ByteArray, secretKey: ByteArray): ByteArray?
|
||||
|
||||
/**
|
||||
* Verifies an Ed25519 detached signature.
|
||||
@@ -32,5 +44,6 @@ object GajuNative {
|
||||
* @param publicKey 32-byte public key.
|
||||
* @return true if valid, false otherwise.
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun cryptoSignVerifyDetached(signature: ByteArray, message: ByteArray, publicKey: ByteArray): Boolean
|
||||
}
|
||||
|
||||
@@ -15,9 +15,15 @@ object KeyManager {
|
||||
|
||||
fun getMasterKey(context: Context): SecretKey {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
|
||||
|
||||
return if (keyStore.containsAlias(MASTER_KEY_ALIAS)) {
|
||||
keyStore.getKey(MASTER_KEY_ALIAS, null) as SecretKey
|
||||
try {
|
||||
keyStore.getKey(MASTER_KEY_ALIAS, null) as SecretKey
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("KeyManager", "Existing key inaccessible, regenerating", e)
|
||||
keyStore.deleteEntry(MASTER_KEY_ALIAS)
|
||||
generateMasterKey(context)
|
||||
}
|
||||
} else {
|
||||
generateMasterKey(context)
|
||||
}
|
||||
@@ -36,9 +42,8 @@ object KeyManager {
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.setUserAuthenticationRequired(true)
|
||||
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL)
|
||||
.setRandomizedEncryptionRequired(false) // Allow manual IVs for consistency
|
||||
.setUserAuthenticationRequired(false)
|
||||
|
||||
// Target StrongBox if available (Android 9+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
|
||||
@@ -58,6 +58,7 @@ fun DashboardScreen(
|
||||
balanceUnit: GajuFormat.Unit = GajuFormat.Unit.GAJU,
|
||||
onSend: () -> Unit,
|
||||
onReceive: () -> Unit,
|
||||
onGrids: () -> Unit,
|
||||
onAddAccount: (String) -> Unit,
|
||||
onAccountSettings: (String, String) -> Unit,
|
||||
onRefresh: (String, String) -> Unit,
|
||||
@@ -65,7 +66,29 @@ fun DashboardScreen(
|
||||
) {
|
||||
if (wallets.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No wallets found. Please create one.")
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("No wallets found. Please create one.")
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ActionCircle(
|
||||
text = "Create",
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
onClick = { onNavigate("CREATE_WALLET") }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
text = "If you created a wallet but don't see it, it may be corrupted.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 48.dp)
|
||||
)
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { onNavigate("WIPE_DATA") }
|
||||
) {
|
||||
Text("Wipe Broken Wallets", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -240,7 +263,15 @@ fun DashboardScreen(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("BALANCE", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 14.sp)
|
||||
Column {
|
||||
Text("BALANCE", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 14.sp)
|
||||
Text(
|
||||
text = "ON ${currentWallet.networkId.uppercase()}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
@@ -249,7 +280,7 @@ fun DashboardScreen(
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_check_circle), // Reuse an icon for refresh for now
|
||||
painter = painterResource(id = R.drawable.ic_refresh),
|
||||
contentDescription = "Refresh Balance",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
@@ -287,7 +318,7 @@ fun DashboardScreen(
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Send / Receive Buttons
|
||||
// Send / Receive / GRIDS Buttons
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -300,6 +331,12 @@ fun DashboardScreen(
|
||||
contentColor = MaterialTheme.colorScheme.onSecondary,
|
||||
onClick = onSend,
|
||||
)
|
||||
ActionCircle(
|
||||
text = "GRIDS",
|
||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiary,
|
||||
onClick = onGrids,
|
||||
)
|
||||
ActionCircle(
|
||||
text = "Receive",
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package swiss.qpq.gajumobile.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import swiss.qpq.gajumobile.ui.components.GajuButton
|
||||
import swiss.qpq.gajumobile.ui.components.GajuHeader
|
||||
|
||||
@Composable
|
||||
fun FetchingScreen(
|
||||
url: String,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(innerPadding)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
GajuHeader()
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(64.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 6.dp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = "RETRIEVING REQUEST",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Connecting to:",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
text = url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
GajuButton(
|
||||
text = "CANCEL",
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
containerColor = MaterialTheme.colorScheme.secondary,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ fun MnemonicRecoveryScreen(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
tonalElevation = 8.dp
|
||||
) {
|
||||
Row(
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(8.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
|
||||
@@ -75,7 +75,7 @@ fun MnemonicVerifyScreen(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
tonalElevation = 8.dp
|
||||
) {
|
||||
Row(
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(8.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
|
||||
@@ -14,13 +14,20 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -105,6 +112,68 @@ fun SettingsScreen(
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
var showWipeConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
if (showWipeConfirm) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
"WIPE ALL DATA?",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Text(
|
||||
"This will permanently delete all wallets, accounts, and keys. This action cannot be undone.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { showWipeConfirm = false },
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onErrorContainer)
|
||||
) {
|
||||
Text("CANCEL")
|
||||
}
|
||||
androidx.compose.material3.Button(
|
||||
onClick = { onNavigate("WIPE_DATA") },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
||||
) {
|
||||
Text("WIPE EVERYTHING")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = { showWipeConfirm = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_check_circle), // placeholder for trash
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("WIPE ALL DATA")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package swiss.qpq.gajumobile.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import swiss.qpq.gajumobile.ui.components.GajuButton
|
||||
import swiss.qpq.gajumobile.ui.components.GajuHeader
|
||||
|
||||
@Composable
|
||||
fun SignRequestScreen(
|
||||
type: String,
|
||||
originUrl: String,
|
||||
accountLabel: String,
|
||||
accountId: String,
|
||||
payload: String,
|
||||
onSign: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bottomBar = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
GajuButton(
|
||||
text = "CANCEL",
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.weight(1f),
|
||||
containerColor = MaterialTheme.colorScheme.secondary,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
GajuButton(
|
||||
text = "SIGN",
|
||||
onClick = onSign,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
GajuHeader()
|
||||
|
||||
// NOTE: CRE 2026-08-25
|
||||
// This eventually needs to expand to complete handling of contract call requests.
|
||||
// That will involve some additions to GRIDS dead drop message forms to include
|
||||
// contract call requests where the intent should be expressed and the signer
|
||||
// requestee should be burdened with forming the TX from the contract name/ID,
|
||||
// function name, and args. This would be significantly more robust to abuse than
|
||||
// the current "TX signature request" where the TX internals are opaque.
|
||||
Text(
|
||||
text = when (type) {
|
||||
"message" -> "MESSAGE SIGNATURE REQUEST"
|
||||
"binary" -> "BINARY DATA SIGNATURE REQUEST"
|
||||
"tx" -> "TRANSACTION SIGNATURE REQUEST"
|
||||
else -> "SIGNATURE REQUEST"
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
"The server at the URL below is requesting you sign the following.",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
SectionLabel("SIGNATURE ACCOUNT")
|
||||
Text(accountLabel, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
|
||||
Text(accountId, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
SectionLabel("ORIGINATING URL")
|
||||
Text(originUrl, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
SectionLabel(when (type) {
|
||||
"message" -> "MESSAGE"
|
||||
"binary" -> "BASE-64 DATA"
|
||||
"tx" -> "TRANSACTION DATA"
|
||||
else -> "PAYLOAD"
|
||||
})
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp, max = 300.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = payload,
|
||||
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionLabel(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.07,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||
</vector>
|
||||
+1
-1
Submodule gm-java updated: 954d8760ac...64c592285a
Reference in New Issue
Block a user