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
This commit is contained in:
Kayos 2026-03-11 23:18:03 -07:00
parent 1ae0657bb0
commit c7b496ec2b
7 changed files with 281 additions and 9 deletions

View file

@ -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)
}

View file

@ -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<AdaMapsDetection>
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 ─────────────────────────────────────────────────────────────────

View file

@ -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<VarroaSettings> = 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
}
}

View file

@ -216,6 +216,10 @@ class AdaMapsUploadWorker(
private suspend fun uploadBatch(batch: List<DetectionEntity>): 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
)
}

View file

@ -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)
}

View file

@ -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()
)
}
}

View file

@ -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")
}
}