haunt / commit
Persistent history trail + configurable 'you' color
.../main/java/net/fuzzyworld/haunt/MainActivity.kt | 24 +++- .../net/fuzzyworld/haunt/debug/DebugViewModel.kt | 52 +++++-- .../net/fuzzyworld/haunt/debug/HistoryPoint.kt | 6 + .../net/fuzzyworld/haunt/debug/HistoryStore.kt | 26 ++++ .../java/net/fuzzyworld/haunt/debug/MapView.kt | 29 ++-- .../net/fuzzyworld/haunt/debug/PointsAdapter.kt | 156 +++++++++++++-------- 6 files changed, 211 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt index df5a0db..f1e81b3 100644 --- a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt +++ b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt @@ -26,14 +26,13 @@ class MainActivity : AppCompatActivity() { onRename = { id, name -> vm.renamePoint(id, name) }, onDelete = { id -> vm.deletePoint(id) }, onRecolor = { id, color -> vm.recolorPoint(id, color) }, + onRecolorYou = { color -> vm.setYouColor(color) }, ) private val requestPermission = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> - if (!granted) { - binding.readingsText.text = "Location permission required for RTT ranging" - } + if (!granted) binding.readingsText.text = "Location permission required for RTT ranging" } override fun onCreate(savedInstanceState: Bundle?) { @@ -59,7 +58,10 @@ class MainActivity : AppCompatActivity() { } else { readings.joinToString("\n") { "…${it.bssid.takeLast(8)} ${String.format("%.2f", it.distanceMeters)} m" } } - binding.mapView.currentReadings = readings.associate { it.bssid to it.distanceMeters } + val map = readings.associate { it.bssid to it.distanceMeters } + binding.mapView.currentReadings = map + adapter.youReadings = map + adapter.notifyItemChanged(0) } } @@ -70,6 +72,20 @@ class MainActivity : AppCompatActivity() { } } + lifecycleScope.launch { + vm.history.collectLatest { history -> + binding.mapView.history = history + } + } + + lifecycleScope.launch { + vm.youColor.collectLatest { color -> + binding.mapView.youColor = color + adapter.youColor = color + adapter.notifyItemChanged(0) + } + } + lifecycleScope.launch { vm.axisBssids.collectLatest { bssids -> adapter.axisBssids = bssids diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt index 680f00f..6d3d842 100644 --- a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt +++ b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt @@ -1,9 +1,11 @@ package net.fuzzyworld.haunt.debug import android.app.Application +import android.content.Context import android.graphics.Color import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -11,6 +13,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.fuzzyworld.haunt.ranging.RttRangingManager import java.util.UUID @@ -18,6 +21,8 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) { private val rttManager = RttRangingManager(application) private val store = DebugPointStore(application) + private val historyStore = HistoryStore(application) + private val prefs = application.getSharedPreferences("haunt_prefs", Context.MODE_PRIVATE) private val _readings = MutableStateFlow<List<RttRangingManager.ApReading>>(emptyList()) val readings: StateFlow<List<RttRangingManager.ApReading>> = _readings @@ -25,25 +30,32 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) { private val _points = MutableStateFlow<List<DebugPoint>>(emptyList()) val points: StateFlow<List<DebugPoint>> = _points + private val _history = MutableStateFlow<List<HistoryPoint>>(emptyList()) + val history: StateFlow<List<HistoryPoint>> = _history + + private val defaultYouColor = Color.HSVToColor(floatArrayOf(210f, 0.75f, 0.90f)) + private val _youColor = MutableStateFlow(prefs.getInt("you_color", defaultYouColor)) + val youColor: StateFlow<Int> = _youColor + val isRttAvailable: Boolean get() = rttManager.isAvailable - // Sorted union of all BSSIDs ever seen — defines the x/y axes for the map and list val axisBssids: StateFlow<List<String>> = combine(_readings, _points) { r, p -> (r.map { it.bssid } + p.flatMap { it.readings.keys }).distinct().sorted() }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - // Last known distance per BSSID — used to fill gaps when an AP temporarily drops out private val lastKnownReadings: MutableMap<String, Double> = mutableMapOf() - - // BSSIDs already auto-added as router points (persisted via point IDs tagged with the BSSID) private val autoAddedBssids: MutableSet<String> = mutableSetOf() + private var historyWriteCounter = 0 init { _points.value = store.load() - // Restore which BSSIDs were already auto-added so we don't duplicate on restart _points.value.filter { it.id.startsWith("router:") }.forEach { autoAddedBssids.add(it.id.removePrefix("router:")) } + viewModelScope.launch(Dispatchers.IO) { + val loaded = historyStore.load() + _history.value = loaded + } startRanging() } @@ -52,16 +64,41 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) { while (true) { val fresh = rttManager.rangeAll() fresh.forEach { lastKnownReadings[it.bssid] = it.distanceMeters } - val readings = lastKnownReadings.entries + val merged = lastKnownReadings.entries .map { RttRangingManager.ApReading(it.key, it.value) } .sortedBy { it.bssid } - _readings.value = readings + _readings.value = merged autoAddNearbyRouters(fresh) + + if (merged.isNotEmpty()) { + val hp = HistoryPoint( + readings = merged.associate { it.bssid to it.distanceMeters }, + timestamp = System.currentTimeMillis(), + ) + _history.value = _history.value + hp + historyWriteCounter++ + if (historyWriteCounter % 10 == 0) flushHistory() + } + delay(2000) } } } + fun setYouColor(color: Int) { + prefs.edit().putInt("you_color", color).apply() + _youColor.value = color + } + + private suspend fun flushHistory() = withContext(Dispatchers.IO) { + historyStore.save(_history.value) + } + + override fun onCleared() { + super.onCleared() + viewModelScope.launch(Dispatchers.IO) { historyStore.save(_history.value) } + } + private fun autoAddNearbyRouters(readings: List<RttRangingManager.ApReading>) { val nearby = readings.filter { it.distanceMeters <= 0.5 && it.bssid !in autoAddedBssids } if (nearby.isEmpty()) return @@ -107,7 +144,6 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) { store.save(_points.value) } - // Golden-angle stepping guarantees maximum hue separation between successive colors private var nextHue = (0..360).random().toFloat() private fun randomColor(): Int { nextHue = (nextHue + 137.5f) % 360f diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/HistoryPoint.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/HistoryPoint.kt new file mode 100644 index 0000000..45953dd --- /dev/null +++ b/app/src/main/java/net/fuzzyworld/haunt/debug/HistoryPoint.kt @@ -0,0 +1,6 @@ +package net.fuzzyworld.haunt.debug + +data class HistoryPoint( + val readings: Map<String, Double>, + val timestamp: Long, +) diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/HistoryStore.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/HistoryStore.kt new file mode 100644 index 0000000..25f4972 --- /dev/null +++ b/app/src/main/java/net/fuzzyworld/haunt/debug/HistoryStore.kt @@ -0,0 +1,26 @@ +package net.fuzzyworld.haunt.debug + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.File + +class HistoryStore(context: Context) { + + private val file = File(context.filesDir, "history.json") + private val gson = Gson() + + fun load(): MutableList<HistoryPoint> { + if (!file.exists()) return mutableListOf() + return try { + val type = object : TypeToken<MutableList<HistoryPoint>>() {}.type + gson.fromJson<MutableList<HistoryPoint>>(file.readText(), type) ?: mutableListOf() + } catch (_: Exception) { + mutableListOf() + } + } + + fun save(history: List<HistoryPoint>) { + file.writeText(gson.toJson(history)) + } +} diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt index 290c712..5ba1233 100644 --- a/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt +++ b/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt @@ -18,10 +18,15 @@ class MapView @JvmOverloads constructor( var savedPoints: List<DebugPoint> = emptyList() set(value) { field = value; invalidate() } - // Live position — rendered as a moving point on the same map, same visual language var currentReadings: Map<String, Double> = emptyMap() set(value) { field = value; invalidate() } + var history: List<HistoryPoint> = emptyList() + set(value) { field = value; invalidate() } + + var youColor: Int = Color.HSVToColor(floatArrayOf(210f, 0.75f, 0.90f)) + set(value) { field = value; invalidate() } + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } private val gridPaint = Paint().apply { @@ -60,8 +65,9 @@ class MapView @JvmOverloads constructor( val currentCoords = if (currentReadings.isNotEmpty()) coords(currentReadings) else null val savedCoords = savedPoints.mapNotNull { p -> coords(p.readings)?.let { p to it } } + val historyCoords = history.mapNotNull { coords(it.readings) } - val allCoords = savedCoords.map { it.second } + listOfNotNull(currentCoords) + val allCoords = savedCoords.map { it.second } + historyCoords + listOfNotNull(currentCoords) if (allCoords.isEmpty()) { labelPaint.color = Color.parseColor("#AAAAAA") @@ -96,6 +102,13 @@ class MapView @JvmOverloads constructor( canvas.drawText("${gy}m", 4f, sy(gy.toDouble()) - 6f, dimLabelPaint) } + // History trail — drawn first so it appears behind everything else + val historyColor = Color.argb(60, Color.red(youColor), Color.green(youColor), Color.blue(youColor)) + fillPaint.color = historyColor + historyCoords.forEach { (x, y) -> + canvas.drawCircle(sx(x), sy(y), 7f, fillPaint) + } + // Saved points labelPaint.color = Color.BLACK savedCoords.forEach { (point, xy) -> @@ -112,25 +125,23 @@ class MapView @JvmOverloads constructor( canvas.drawText(point.name, cx + 34f, cy + 11f, labelPaint) } - // Current position — same visual as a point but white fill + dashed ring to show it's live + // Current position — filled with youColor, crosshair + dashed ring on top if (currentCoords != null) { val cx = sx(currentCoords.first) val cy = sy(currentCoords.second) - fillPaint.color = Color.WHITE + fillPaint.color = youColor canvas.drawCircle(cx, cy, 22f, fillPaint) - strokePaint.color = Color.BLACK + strokePaint.color = Color.argb(180, 0, 0, 0) strokePaint.strokeWidth = 4f canvas.drawCircle(cx, cy, 22f, strokePaint) - // Crosshair strokePaint.strokeWidth = 2f canvas.drawLine(cx - 38f, cy, cx + 38f, cy, strokePaint) canvas.drawLine(cx, cy - 38f, cx, cy + 38f, strokePaint) - // Dashed ring to distinguish from saved points - ringPaint.color = Color.parseColor("#888888") + ringPaint.color = Color.argb(140, 0, 0, 0) canvas.drawCircle(cx, cy, 38f, ringPaint) labelPaint.textAlign = Paint.Align.LEFT @@ -139,7 +150,7 @@ class MapView @JvmOverloads constructor( labelPaint.color = Color.BLACK } - // Axis legend — top-right corner, away from all grid labels + // Axis legend — top-right corner val bssids = allBssids dimLabelPaint.textAlign = Paint.Align.RIGHT if (bssids.isNotEmpty()) { diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt index bbaaba4..7e4ec10 100644 --- a/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt +++ b/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt @@ -1,5 +1,6 @@ package net.fuzzyworld.haunt.debug +import android.content.Context import android.graphics.Color import android.graphics.drawable.GradientDrawable import android.view.LayoutInflater @@ -11,53 +12,125 @@ import androidx.recyclerview.widget.RecyclerView import net.fuzzyworld.haunt.databinding.ItemDebugPointBinding private val PRESET_COLORS = listOf( - Color.HSVToColor(floatArrayOf(0f, 0.75f, 0.90f)), // red - Color.HSVToColor(floatArrayOf(25f, 0.85f, 0.90f)), // orange - Color.HSVToColor(floatArrayOf(50f, 0.80f, 0.85f)), // yellow - Color.HSVToColor(floatArrayOf(90f, 0.70f, 0.80f)), // lime - Color.HSVToColor(floatArrayOf(130f, 0.70f, 0.75f)), // green - Color.HSVToColor(floatArrayOf(165f, 0.70f, 0.78f)), // teal - Color.HSVToColor(floatArrayOf(195f, 0.75f, 0.85f)), // cyan - Color.HSVToColor(floatArrayOf(215f, 0.75f, 0.90f)), // sky - Color.HSVToColor(floatArrayOf(240f, 0.70f, 0.90f)), // blue - Color.HSVToColor(floatArrayOf(270f, 0.70f, 0.85f)), // purple - Color.HSVToColor(floatArrayOf(310f, 0.70f, 0.85f)), // magenta - Color.HSVToColor(floatArrayOf(340f, 0.75f, 0.90f)), // pink + Color.HSVToColor(floatArrayOf(0f, 0.75f, 0.90f)), + Color.HSVToColor(floatArrayOf(25f, 0.85f, 0.90f)), + Color.HSVToColor(floatArrayOf(50f, 0.80f, 0.85f)), + Color.HSVToColor(floatArrayOf(90f, 0.70f, 0.80f)), + Color.HSVToColor(floatArrayOf(130f, 0.70f, 0.75f)), + Color.HSVToColor(floatArrayOf(165f, 0.70f, 0.78f)), + Color.HSVToColor(floatArrayOf(195f, 0.75f, 0.85f)), + Color.HSVToColor(floatArrayOf(215f, 0.75f, 0.90f)), + Color.HSVToColor(floatArrayOf(240f, 0.70f, 0.90f)), + Color.HSVToColor(floatArrayOf(270f, 0.70f, 0.85f)), + Color.HSVToColor(floatArrayOf(310f, 0.70f, 0.85f)), + Color.HSVToColor(floatArrayOf(340f, 0.75f, 0.90f)), ) +private fun showColorPicker(context: Context, currentColor: Int, onPick: (Int) -> Unit) { + val dp = context.resources.displayMetrics.density + val chipSize = (48 * dp).toInt() + val chipMargin = (8 * dp).toInt() + + val grid = GridLayout(context).apply { + columnCount = 4 + setPadding(chipMargin, chipMargin, chipMargin, chipMargin) + } + + var dialog: AlertDialog? = null + PRESET_COLORS.forEach { color -> + val chip = View(context).apply { + background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(color) + if (color == currentColor) setStroke((3 * dp).toInt(), Color.BLACK) + } + setOnClickListener { onPick(color); dialog?.dismiss() } + } + val lp = GridLayout.LayoutParams().apply { + width = chipSize; height = chipSize + setMargins(chipMargin, chipMargin, chipMargin, chipMargin) + } + grid.addView(chip, lp) + } + + dialog = AlertDialog.Builder(context) + .setTitle("Pick a color") + .setView(grid) + .setNegativeButton("Cancel", null) + .create() + dialog.show() +} + class PointsAdapter( private val onRename: (id: String, newName: String) -> Unit, private val onDelete: (id: String) -> Unit, private val onRecolor: (id: String, color: Int) -> Unit, -) : RecyclerView.Adapter<PointsAdapter.ViewHolder>() { + private val onRecolorYou: (color: Int) -> Unit, +) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { + + companion object { + private const val TYPE_YOU = 0 + private const val TYPE_POINT = 1 + } private var points: List<DebugPoint> = emptyList() var axisBssids: List<String> = emptyList() + var youColor: Int = Color.GRAY + var youReadings: Map<String, Double> = emptyMap() fun submitList(list: List<DebugPoint>) { points = list notifyDataSetChanged() } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val binding = ItemDebugPointBinding.inflate( - LayoutInflater.from(parent.context), parent, false - ) - return ViewHolder(binding) + override fun getItemCount() = points.size + 1 + override fun getItemViewType(position: Int) = if (position == 0) TYPE_YOU else TYPE_POINT + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val binding = ItemDebugPointBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return if (viewType == TYPE_YOU) YouViewHolder(binding) else PointViewHolder(binding) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is YouViewHolder -> holder.bind() + is PointViewHolder -> holder.bind(points[position - 1]) + } } - override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(points[position]) - override fun getItemCount() = points.size + inner class YouViewHolder(private val b: ItemDebugPointBinding) : RecyclerView.ViewHolder(b.root) { + fun bind() { + b.colorSwatch.setBackgroundColor(youColor) + b.colorSwatch.setOnClickListener { + showColorPicker(b.root.context, youColor) { color -> onRecolorYou(color) } + } + b.nameEdit.setText("you") + b.nameEdit.isEnabled = false + b.deleteButton.visibility = View.GONE + + val x = axisBssids.getOrNull(0)?.let { youReadings[it] } + val y = axisBssids.getOrNull(1)?.let { youReadings[it] } + b.xyText.text = when { + x != null && y != null -> "x: ${"%.2f".format(x)} m y: ${"%.2f".format(y)} m" + x != null -> "x: ${"%.2f".format(x)} m" + else -> "—" + } + } + } - inner class ViewHolder(private val b: ItemDebugPointBinding) : RecyclerView.ViewHolder(b.root) { + inner class PointViewHolder(private val b: ItemDebugPointBinding) : RecyclerView.ViewHolder(b.root) { fun bind(point: DebugPoint) { b.colorSwatch.setBackgroundColor(point.color) + b.colorSwatch.setOnClickListener { + showColorPicker(b.root.context, point.color) { color -> onRecolor(point.id, color) } + } + b.nameEdit.isEnabled = true + b.deleteButton.visibility = View.VISIBLE b.nameEdit.setText(point.name) b.nameEdit.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) onRename(point.id, b.nameEdit.text.toString().trim().ifEmpty { point.name }) } b.deleteButton.setOnClickListener { onDelete(point.id) } - b.colorSwatch.setOnClickListener { showColorPicker(point) } val x = axisBssids.getOrNull(0)?.let { point.readings[it] } val y = axisBssids.getOrNull(1)?.let { point.readings[it] } @@ -67,44 +140,5 @@ class PointsAdapter( else -> "" } } - - private fun showColorPicker(point: DebugPoint) { - val ctx = b.root.context - val dp = ctx.resources.displayMetrics.density - val chipSize = (48 * dp).toInt() - val chipMargin = (8 * dp).toInt() - - val grid = GridLayout(ctx).apply { - columnCount = 4 - setPadding(chipMargin, chipMargin, chipMargin, chipMargin) - } - - var dialog: AlertDialog? = null - PRESET_COLORS.forEach { color -> - val chip = View(ctx).apply { - background = GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(color) - if (color == point.color) setStroke((3 * dp).toInt(), Color.BLACK) - } - setOnClickListener { - onRecolor(point.id, color) - dialog?.dismiss() - } - } - val lp = GridLayout.LayoutParams().apply { - width = chipSize; height = chipSize - setMargins(chipMargin, chipMargin, chipMargin, chipMargin) - } - grid.addView(chip, lp) - } - - dialog = AlertDialog.Builder(ctx) - .setTitle("Pick a color") - .setView(grid) - .setNegativeButton("Cancel", null) - .create() - dialog.show() - } } }