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 varroa-v7.5.apk
This commit is contained in:
Sulkta 2026-03-11 23:18:03 -07:00
parent 109d7c19df
commit ccd2e01008
7 changed files with 281 additions and 9 deletions

View file

@ -13,8 +13,8 @@ android {
applicationId = "com.adamaps.varroa" applicationId = "com.adamaps.varroa"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 10 versionCode = 11
versionName = "1.7.4" versionName = "1.7.5"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@ -78,5 +78,11 @@ dependencies {
implementation(libs.work.runtime.ktx) implementation(libs.work.runtime.ktx)
// SSH connectivity for device_id fallback // SSH connectivity for device_id fallback
implementation("com.jcraft:jsch:0.1.55") 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) debugImplementation(libs.androidx.ui.tooling)
} }

View file

@ -62,14 +62,15 @@ data class AdaMapsDetection(
@SerializedName("width") val width: Double?, @SerializedName("width") val width: Double?,
@SerializedName("height") val height: Double?, @SerializedName("height") val height: Double?,
@SerializedName("pos_confidence") val posConfidence: 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 // Server expects flat array of detections, each with device_id
// (NOT nested {device_id, detections: [...]}) // (NOT nested {device_id, detections: [...]})
typealias AdaMapsIngestRequest = List<AdaMapsDetection> typealias AdaMapsIngestRequest = List<AdaMapsDetection>
fun BeeDetection.toAdaMapsDetection(deviceId: String) = AdaMapsDetection( fun BeeDetection.toAdaMapsDetection(deviceId: String, walletAddress: String? = null) = AdaMapsDetection(
deviceId = deviceId, deviceId = deviceId,
id = id, id = id,
classLabel = classLabel, classLabel = classLabel,
@ -82,7 +83,8 @@ fun BeeDetection.toAdaMapsDetection(deviceId: String) = AdaMapsDetection(
width = width, width = width,
height = height, height = height,
posConfidence = posConfidence, posConfidence = posConfidence,
azimuth = azimuth azimuth = azimuth,
walletAddress = walletAddress?.takeIf { it.isNotBlank() }
) )
// ── App state ───────────────────────────────────────────────────────────────── // ── App state ─────────────────────────────────────────────────────────────────

View file

@ -21,7 +21,8 @@ data class VarroaSettings(
val cameraEndpoint: String = "/api/1/camera/frame", val cameraEndpoint: String = "/api/1/camera/frame",
val cameraRefreshSeconds: Int = 30, val cameraRefreshSeconds: Int = 30,
val forwardingEnabled: Boolean = true, val forwardingEnabled: Boolean = true,
val cachedDeviceId: String = "unknown" val cachedDeviceId: String = "unknown",
val walletAddress: String = ""
) )
class SettingsDataStore(private val context: Context) { 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_CAMERA_REFRESH = intPreferencesKey("camera_refresh_seconds")
private val KEY_FORWARDING_ENABLED = booleanPreferencesKey("forwarding_enabled") private val KEY_FORWARDING_ENABLED = booleanPreferencesKey("forwarding_enabled")
private val KEY_CACHED_DEVICE_ID = stringPreferencesKey("cached_device_id") 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 -> 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", cameraEndpoint = prefs[KEY_CAMERA_ENDPOINT] ?: "/api/1/camera/frame",
cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30, cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30,
forwardingEnabled = prefs[KEY_FORWARDING_ENABLED] ?: true, 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_CAMERA_REFRESH] = s.cameraRefreshSeconds
prefs[KEY_FORWARDING_ENABLED] = s.forwardingEnabled prefs[KEY_FORWARDING_ENABLED] = s.forwardingEnabled
prefs[KEY_CACHED_DEVICE_ID] = s.cachedDeviceId 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 { private suspend fun uploadBatch(batch: List<DetectionEntity>): Result {
Log.d(TAG, "Converting ${batch.size} DetectionEntities to AdaMapsDetection format...") 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 // Convert to ADAMaps format
val request = batch.map { entity -> val request = batch.map { entity ->
AdaMapsDetection( AdaMapsDetection(
@ -231,7 +235,8 @@ class AdaMapsUploadWorker(
width = entity.width, width = entity.width,
height = entity.height, height = entity.height,
posConfidence = entity.posConfidence, 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 deviceInfo by vm.deviceInfo.collectAsState()
val gnss by vm.gnss.collectAsState() val gnss by vm.gnss.collectAsState()
val cameraBytes by vm.cameraBytes.collectAsState() val cameraBytes by vm.cameraBytes.collectAsState()
val settings by vm.settings.collectAsState()
// Collection state // Collection state
val isCollecting by vm.isCollecting.collectAsState() val isCollecting by vm.isCollecting.collectAsState()
@ -65,6 +66,9 @@ fun DashboardScreen(
// Network status // Network status
val networkStatus by vm.networkStatus.collectAsState() val networkStatus by vm.networkStatus.collectAsState()
// Wallet status
val hasLinkedWallet = settings.walletAddress.isNotBlank()
// Combined error display // Combined error display
val displayError = beeError ?: uploadError val displayError = beeError ?: uploadError
@ -85,6 +89,17 @@ fun DashboardScreen(
}, },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Surface), colors = TopAppBarDefaults.topAppBarColors(containerColor = Surface),
actions = { 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) { IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = "Settings", tint = Amber) Icon(Icons.Default.Settings, contentDescription = "Settings", tint = Amber)
} }

View file

@ -1,5 +1,7 @@
package com.adamaps.varroa.ui.settings package com.adamaps.varroa.ui.settings
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.adamaps.varroa.data.VarroaSettings import com.adamaps.varroa.data.VarroaSettings
import com.adamaps.varroa.ui.theme.* import com.adamaps.varroa.ui.theme.*
import com.adamaps.varroa.util.CardanoAddressValidator
import com.adamaps.varroa.viewmodel.SettingsViewModel import com.adamaps.varroa.viewmodel.SettingsViewModel
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -37,6 +49,7 @@ fun SettingsScreen(
var pollInterval by remember(currentSettings) { mutableStateOf(currentSettings.pollIntervalSeconds.toString()) } var pollInterval by remember(currentSettings) { mutableStateOf(currentSettings.pollIntervalSeconds.toString()) }
var cameraRefresh by remember(currentSettings) { mutableStateOf(currentSettings.cameraRefreshSeconds.toString()) } var cameraRefresh by remember(currentSettings) { mutableStateOf(currentSettings.cameraRefreshSeconds.toString()) }
var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) } var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) }
var walletAddress by remember(currentSettings) { mutableStateOf(currentSettings.walletAddress) }
// Show snackbar on save // Show snackbar on save
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@ -76,7 +89,8 @@ fun SettingsScreen(
adamapsApiKey = adamapsApiKey.trim(), adamapsApiKey = adamapsApiKey.trim(),
pollIntervalSeconds = pollInterval.toIntOrNull() ?: 30, pollIntervalSeconds = pollInterval.toIntOrNull() ?: 30,
cameraRefreshSeconds = cameraRefresh.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") { SettingsSection("CAMERA") {
SettingsField( SettingsField(
label = "Camera Endpoint", 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")
}
}