From c7b496ec2b52528336855956ab48f93661096086 Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 11 Mar 2026 23:18:03 -0700 Subject: [PATCH] v7.5: wallet linking for MAP token rewards - Add walletAddress field to SettingsDataStore - Settings screen: input field, QR scanner, address validation - Include wallet_address in detection uploads to API - Show linked wallet status in main UI (green wallet icon) - Cardano address validation (addr1/stake1 prefixes) - Version bumped to 1.7.5 (versionCode 11) - APK built and signed at /root/.openclaw/workspace/projects/varroa-v7.5.apk --- app/build.gradle.kts | 10 +- .../java/com/adamaps/varroa/data/Models.kt | 8 +- .../adamaps/varroa/data/SettingsDataStore.kt | 8 +- .../varroa/service/AdaMapsUploadWorker.kt | 7 +- .../varroa/ui/dashboard/DashboardScreen.kt | 15 ++ .../varroa/ui/settings/SettingsScreen.kt | 191 +++++++++++++++++- .../varroa/util/CardanoAddressValidator.kt | 51 +++++ 7 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/adamaps/varroa/util/CardanoAddressValidator.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index df2239c..784fe95 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.adamaps.varroa" minSdk = 26 targetSdk = 34 - versionCode = 10 - versionName = "1.7.4" + versionCode = 11 + versionName = "1.7.5" vectorDrawables { useSupportLibrary = true @@ -78,5 +78,11 @@ dependencies { implementation(libs.work.runtime.ktx) // SSH connectivity for device_id fallback implementation("com.jcraft:jsch:0.1.55") + // QR Code scanning + implementation("com.google.zxing:core:3.5.2") + implementation("com.journeyapps:zxing-android-embedded:4.3.0") + implementation("androidx.camera:camera-camera2:1.3.0") + implementation("androidx.camera:camera-lifecycle:1.3.0") + implementation("androidx.camera:camera-view:1.3.0") debugImplementation(libs.androidx.ui.tooling) } diff --git a/app/src/main/java/com/adamaps/varroa/data/Models.kt b/app/src/main/java/com/adamaps/varroa/data/Models.kt index 2790353..cff3c94 100644 --- a/app/src/main/java/com/adamaps/varroa/data/Models.kt +++ b/app/src/main/java/com/adamaps/varroa/data/Models.kt @@ -62,14 +62,15 @@ data class AdaMapsDetection( @SerializedName("width") val width: Double?, @SerializedName("height") val height: Double?, @SerializedName("pos_confidence") val posConfidence: Double?, - @SerializedName("azimuth") val azimuth: Double? + @SerializedName("azimuth") val azimuth: Double?, + @SerializedName("wallet_address") val walletAddress: String? ) // Server expects flat array of detections, each with device_id // (NOT nested {device_id, detections: [...]}) typealias AdaMapsIngestRequest = List -fun BeeDetection.toAdaMapsDetection(deviceId: String) = AdaMapsDetection( +fun BeeDetection.toAdaMapsDetection(deviceId: String, walletAddress: String? = null) = AdaMapsDetection( deviceId = deviceId, id = id, classLabel = classLabel, @@ -82,7 +83,8 @@ fun BeeDetection.toAdaMapsDetection(deviceId: String) = AdaMapsDetection( width = width, height = height, posConfidence = posConfidence, - azimuth = azimuth + azimuth = azimuth, + walletAddress = walletAddress?.takeIf { it.isNotBlank() } ) // ── App state ───────────────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt index 0cfc0d3..c97e59b 100644 --- a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt +++ b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt @@ -21,7 +21,8 @@ data class VarroaSettings( val cameraEndpoint: String = "/api/1/camera/frame", val cameraRefreshSeconds: Int = 30, val forwardingEnabled: Boolean = true, - val cachedDeviceId: String = "unknown" + val cachedDeviceId: String = "unknown", + val walletAddress: String = "" ) class SettingsDataStore(private val context: Context) { @@ -35,6 +36,7 @@ class SettingsDataStore(private val context: Context) { private val KEY_CAMERA_REFRESH = intPreferencesKey("camera_refresh_seconds") private val KEY_FORWARDING_ENABLED = booleanPreferencesKey("forwarding_enabled") private val KEY_CACHED_DEVICE_ID = stringPreferencesKey("cached_device_id") + private val KEY_WALLET_ADDRESS = stringPreferencesKey("wallet_address") } val settings: Flow = context.dataStore.data.map { prefs -> @@ -46,7 +48,8 @@ class SettingsDataStore(private val context: Context) { cameraEndpoint = prefs[KEY_CAMERA_ENDPOINT] ?: "/api/1/camera/frame", cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30, forwardingEnabled = prefs[KEY_FORWARDING_ENABLED] ?: true, - cachedDeviceId = prefs[KEY_CACHED_DEVICE_ID] ?: "unknown" + cachedDeviceId = prefs[KEY_CACHED_DEVICE_ID] ?: "unknown", + walletAddress = prefs[KEY_WALLET_ADDRESS] ?: "" ) } @@ -60,6 +63,7 @@ class SettingsDataStore(private val context: Context) { prefs[KEY_CAMERA_REFRESH] = s.cameraRefreshSeconds prefs[KEY_FORWARDING_ENABLED] = s.forwardingEnabled prefs[KEY_CACHED_DEVICE_ID] = s.cachedDeviceId + prefs[KEY_WALLET_ADDRESS] = s.walletAddress } } diff --git a/app/src/main/java/com/adamaps/varroa/service/AdaMapsUploadWorker.kt b/app/src/main/java/com/adamaps/varroa/service/AdaMapsUploadWorker.kt index 19ac6d2..927e481 100644 --- a/app/src/main/java/com/adamaps/varroa/service/AdaMapsUploadWorker.kt +++ b/app/src/main/java/com/adamaps/varroa/service/AdaMapsUploadWorker.kt @@ -216,6 +216,10 @@ class AdaMapsUploadWorker( private suspend fun uploadBatch(batch: List): Result { Log.d(TAG, "Converting ${batch.size} DetectionEntities to AdaMapsDetection format...") + // Load settings to get wallet address + val settings = settingsStore.settings.first() + val walletAddress = settings.walletAddress.takeIf { it.isNotBlank() } + // Convert to ADAMaps format val request = batch.map { entity -> AdaMapsDetection( @@ -231,7 +235,8 @@ class AdaMapsUploadWorker( width = entity.width, height = entity.height, posConfidence = entity.posConfidence, - azimuth = entity.azimuth + azimuth = entity.azimuth, + walletAddress = walletAddress ) } diff --git a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt index 0fe7c3a..4e7d4ef 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt @@ -44,6 +44,7 @@ fun DashboardScreen( val deviceInfo by vm.deviceInfo.collectAsState() val gnss by vm.gnss.collectAsState() val cameraBytes by vm.cameraBytes.collectAsState() + val settings by vm.settings.collectAsState() // Collection state val isCollecting by vm.isCollecting.collectAsState() @@ -65,6 +66,9 @@ fun DashboardScreen( // Network status val networkStatus by vm.networkStatus.collectAsState() + + // Wallet status + val hasLinkedWallet = settings.walletAddress.isNotBlank() // Combined error display val displayError = beeError ?: uploadError @@ -85,6 +89,17 @@ fun DashboardScreen( }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Surface), actions = { + // Wallet status indicator + if (hasLinkedWallet) { + Icon( + Icons.Default.AccountBalance, + contentDescription = "Wallet linked", + tint = Color.Green, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + } + IconButton(onClick = onNavigateToSettings) { Icon(Icons.Default.Settings, contentDescription = "Settings", tint = Amber) } diff --git a/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt index f3c2a15..d6db977 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt @@ -1,5 +1,7 @@ package com.adamaps.varroa.ui.settings +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions @@ -7,19 +9,29 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.adamaps.varroa.data.VarroaSettings import com.adamaps.varroa.ui.theme.* +import com.adamaps.varroa.util.CardanoAddressValidator import com.adamaps.varroa.viewmodel.SettingsViewModel +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -37,6 +49,7 @@ fun SettingsScreen( var pollInterval by remember(currentSettings) { mutableStateOf(currentSettings.pollIntervalSeconds.toString()) } var cameraRefresh by remember(currentSettings) { mutableStateOf(currentSettings.cameraRefreshSeconds.toString()) } var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) } + var walletAddress by remember(currentSettings) { mutableStateOf(currentSettings.walletAddress) } // Show snackbar on save val snackbarHostState = remember { SnackbarHostState() } @@ -76,7 +89,8 @@ fun SettingsScreen( adamapsApiKey = adamapsApiKey.trim(), pollIntervalSeconds = pollInterval.toIntOrNull() ?: 30, cameraRefreshSeconds = cameraRefresh.toIntOrNull() ?: 30, - cameraEndpoint = cameraEndpoint.trim() + cameraEndpoint = cameraEndpoint.trim(), + walletAddress = walletAddress.trim() ) ) }) { @@ -119,6 +133,11 @@ fun SettingsScreen( ) } + WalletLinkingSection( + walletAddress = walletAddress, + onWalletAddressChange = { walletAddress = it } + ) + SettingsSection("CAMERA") { SettingsField( label = "Camera Endpoint", @@ -208,3 +227,173 @@ private fun SettingsField( ) ) } + +@Composable +private fun WalletLinkingSection( + walletAddress: String, + onWalletAddressChange: (String) -> Unit +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + + // QR Scanner + val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> + if (result.contents != null) { + val scannedAddress = result.contents.trim() + if (CardanoAddressValidator.isValidCardanoAddress(scannedAddress)) { + onWalletAddressChange(scannedAddress) + } + } + } + + // Validation state + val isValid = walletAddress.isBlank() || CardanoAddressValidator.isValidCardanoAddress(walletAddress) + val hasWallet = walletAddress.isNotBlank() && isValid + + SettingsSection("WALLET LINKING") { + // Status indicator + if (hasWallet) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Linked Wallet", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + Text( + CardanoAddressValidator.formatAddressForDisplay(walletAddress), + color = if (CardanoAddressValidator.isMainnetAddress(walletAddress)) Color.Green else Color.Yellow, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + Text( + if (CardanoAddressValidator.isMainnetAddress(walletAddress)) "Mainnet Address" else "Stake Address", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + } + IconButton(onClick = { onWalletAddressChange("") }) { + Icon(Icons.Default.Clear, contentDescription = "Clear", tint = Color.Red) + } + } + Spacer(Modifier.height(8.dp)) + } + + // Address input field + OutlinedTextField( + value = walletAddress, + onValueChange = onWalletAddressChange, + label = { Text("Cardano Address", fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, + placeholder = { Text("addr1... or stake1...", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 12.sp) }, + supportingText = { + when { + walletAddress.isNotBlank() && !isValid -> { + Text( + "Invalid address format. Use addr1... or stake1...", + color = Color.Red, + fontSize = 10.sp + ) + } + hasWallet -> { + Text( + "✓ Valid Cardano address", + color = Color.Green, + fontSize = 10.sp + ) + } + else -> { + Text( + "Link wallet to earn MAP tokens for detections", + color = Color.Gray, + fontSize = 10.sp + ) + } + } + }, + isError = walletAddress.isNotBlank() && !isValid, + singleLine = false, + maxLines = 3, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = if (isValid) Amber else Color.Red, + unfocusedBorderColor = if (isValid) SurfaceVariant else Color.Red, + focusedLabelColor = if (isValid) Amber else Color.Red, + unfocusedLabelColor = Color.Gray, + cursorColor = Amber, + focusedTextColor = OnSurface, + unfocusedTextColor = OnSurface + ) + ) + + Spacer(Modifier.height(12.dp)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // QR Scanner button + OutlinedButton( + onClick = { + val options = ScanOptions().apply { + setPrompt("Scan Cardano wallet address") + setBeepEnabled(false) + setOrientationLocked(false) + } + scanLauncher.launch(options) + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Amber, + containerColor = Color.Transparent + ), + border = androidx.compose.foundation.BorderStroke(1.dp, Amber) + ) { + Icon(Icons.Default.QrCodeScanner, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text("Scan QR", fontSize = 12.sp) + } + + // Paste button + OutlinedButton( + onClick = { + clipboardManager.getText()?.text?.let { text -> + val cleanText = text.trim() + if (CardanoAddressValidator.isValidCardanoAddress(cleanText)) { + onWalletAddressChange(cleanText) + } + } + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Amber, + containerColor = Color.Transparent + ), + border = androidx.compose.foundation.BorderStroke(1.dp, Amber) + ) { + Text("Paste", fontSize = 12.sp) + } + } + + Spacer(Modifier.height(8.dp)) + + // Info text + Text( + "MAP token rewards require a linked Cardano wallet.\n" + + "Detections will still upload without a wallet linked.", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + lineHeight = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/app/src/main/java/com/adamaps/varroa/util/CardanoAddressValidator.kt b/app/src/main/java/com/adamaps/varroa/util/CardanoAddressValidator.kt new file mode 100644 index 0000000..f9edba3 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/util/CardanoAddressValidator.kt @@ -0,0 +1,51 @@ +package com.adamaps.varroa.util + +object CardanoAddressValidator { + + /** + * Validate Cardano address format. + * Supports mainnet addresses (addr1) and stake addresses (stake1). + */ + fun isValidCardanoAddress(address: String): Boolean { + if (address.isBlank()) return false + + // Remove whitespace + val trimmedAddress = address.trim() + + // Check prefix - mainnet addresses start with addr1, stake addresses with stake1 + if (!trimmedAddress.startsWith("addr1") && !trimmedAddress.startsWith("stake1")) { + return false + } + + // Check length - Cardano addresses are typically 100-120 characters (bech32 encoded) + if (trimmedAddress.length < 90 || trimmedAddress.length > 130) { + return false + } + + // Basic character validation - bech32 uses specific character set + val validChars = "023456789acdefghjklmnpqrstuvwxyz" + return trimmedAddress.drop(5).all { it in validChars } // Skip prefix for character check + } + + /** + * Format address for display - show first 8 and last 8 characters + */ + fun formatAddressForDisplay(address: String): String { + if (address.length <= 20) return address + return "${address.take(8)}...${address.takeLast(8)}" + } + + /** + * Check if address is a mainnet address (addr1) + */ + fun isMainnetAddress(address: String): Boolean { + return address.trim().startsWith("addr1") + } + + /** + * Check if address is a stake address (stake1) + */ + fun isStakeAddress(address: String): Boolean { + return address.trim().startsWith("stake1") + } +} \ No newline at end of file