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:
parent
1ae0657bb0
commit
c7b496ec2b
7 changed files with 281 additions and 9 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue