fuzzyworld

haunt / commit

Layer 2: RTT ranging, debug map, add/rename/delete points

b3ba2f3018c8a853f24d039279c1aa26c9e1d93f · Pavel Safronov · 2026-05-12 21:28:02 -0700 · parents 8afad5cf2605

Layer 2: RTT ranging, debug map, add/rename/delete points

RttRangingManager ranges against all 802.11mc APs from scan results,
returning sorted (BSSID, distanceMeters) pairs every 2 seconds.

Debug map (MapView) plots all points in RTT distance space:
  x = dist to AP[0], y = dist to AP[1] (BSSIDs sorted alphabetically)
Current position renders as a moving white crosshair with dashed ring;
saved points render as colored circles with labels — same visual language,
so the live position is just another point that never stops moving.

Add Point: dialog → snapshot current readings → random HSV color.
Rename: inline EditText, commits on focus loss.
Delete: trash button per row.
Persistence: JSON in filesDir via DebugPointStore.

DebugViewModel (AndroidViewModel) owns the 2s ranging loop and point list.
MainActivity collects StateFlows via lifecycleScope.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 app/build.gradle.kts                               |   4 +
 app/src/main/AndroidManifest.xml                   |   2 +-
 .../main/java/net/fuzzyworld/haunt/MainActivity.kt |  78 ++++++++---
 .../java/net/fuzzyworld/haunt/debug/DebugPoint.kt  |   8 ++
 .../net/fuzzyworld/haunt/debug/DebugPointStore.kt  |  26 ++++
 .../net/fuzzyworld/haunt/debug/DebugViewModel.kt   |  68 +++++++++
 .../java/net/fuzzyworld/haunt/debug/MapView.kt     | 153 +++++++++++++++++++++
 .../net/fuzzyworld/haunt/debug/PointsAdapter.kt    |  40 ++++++
 .../fuzzyworld/haunt/ranging/RttRangingManager.kt  |  46 +++++++
 app/src/main/res/layout/activity_main.xml          |  53 ++++---
 app/src/main/res/layout/item_debug_point.xml       |  34 +++++
 app/src/main/res/values/themes.xml                 |   7 +
 gradle.properties                                  |  11 ++
 gradle/libs.versions.toml                          |  11 +-
 gradle/wrapper/gradle-wrapper.properties           |   2 +-
 15 files changed, 489 insertions(+), 54 deletions(-)

diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 188f1b7..6da8c40 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -36,6 +36,10 @@ android {
 }
 
 dependencies {
+    implementation(libs.androidx.appcompat)
+    implementation(libs.androidx.lifecycle.viewmodel)
+    implementation(libs.androidx.lifecycle.runtime)
+    implementation(libs.androidx.recyclerview)
     implementation(libs.kotlinx.coroutines.android)
     implementation(libs.retrofit)
     implementation(libs.retrofit.gson)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 08a4ca7..ad9605e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -12,7 +12,7 @@
         android:allowBackup="true"
         android:label="@string/app_name"
         android:supportsRtl="true"
-        android:theme="@style/Theme.AppCompat.Light.DarkActionBar">
+        android:theme="@style/Theme.Haunt">
 
         <activity
             android:name=".MainActivity"
diff --git a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
index 7e8e5d6..3b1ca30 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
@@ -2,25 +2,36 @@ package net.fuzzyworld.haunt
 
 import android.Manifest
 import android.content.pm.PackageManager
-import android.net.wifi.rtt.WifiRttManager
 import android.os.Bundle
-import android.widget.Toast
+import android.widget.EditText
 import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.appcompat.app.AlertDialog
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.LinearLayoutManager
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
 import net.fuzzyworld.haunt.databinding.ActivityMainBinding
+import net.fuzzyworld.haunt.debug.DebugViewModel
+import net.fuzzyworld.haunt.debug.PointsAdapter
 
 class MainActivity : AppCompatActivity() {
 
     private lateinit var binding: ActivityMainBinding
+    private val vm: DebugViewModel by viewModels()
+
+    private val adapter = PointsAdapter(
+        onRename = { id, name -> vm.renamePoint(id, name) },
+        onDelete = { id -> vm.deletePoint(id) },
+    )
 
     private val requestPermission = registerForActivityResult(
         ActivityResultContracts.RequestPermission()
     ) { granted ->
-        if (granted) {
-            checkRttAvailability()
-        } else {
-            Toast.makeText(this, R.string.permission_required, Toast.LENGTH_LONG).show()
+        if (!granted) {
+            binding.readingsText.text = "Location permission required for RTT ranging"
         }
     }
 
@@ -29,27 +40,52 @@ class MainActivity : AppCompatActivity() {
         binding = ActivityMainBinding.inflate(layoutInflater)
         setContentView(binding.root)
 
-        binding.calibrateButton.setOnClickListener {
-            binding.modeText.text = "Mode: Calibrate (not yet implemented)"
-        }
-        binding.liveButton.setOnClickListener {
-            binding.modeText.text = "Mode: Live (not yet implemented)"
-        }
+        binding.pointsList.layoutManager = LinearLayoutManager(this)
+        binding.pointsList.adapter = adapter
+
+        binding.addPointButton.setOnClickListener { showAddPointDialog() }
 
         if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
-            == PackageManager.PERMISSION_GRANTED
+            != PackageManager.PERMISSION_GRANTED
         ) {
-            checkRttAvailability()
-        } else {
             requestPermission.launch(Manifest.permission.ACCESS_FINE_LOCATION)
         }
+
+        lifecycleScope.launch {
+            vm.readings.collectLatest { readings ->
+                binding.readingsText.text = if (readings.isEmpty()) {
+                    "Scanning… (no RTT-capable APs found)"
+                } else {
+                    readings.joinToString("\n") { "…${it.bssid.takeLast(8)}  ${String.format("%.2f", it.distanceMeters)} m" }
+                }
+                binding.mapView.currentReadings = readings.associate { it.bssid to it.distanceMeters }
+            }
+        }
+
+        lifecycleScope.launch {
+            vm.points.collectLatest { points ->
+                adapter.submitList(points)
+                binding.mapView.savedPoints = points
+            }
+        }
     }
 
-    private fun checkRttAvailability() {
-        val rttManager = getSystemService(WifiRttManager::class.java)
-        val available = rttManager?.isAvailable == true
-        binding.rttStatusText.text = getString(
-            if (available) R.string.rtt_available else R.string.rtt_unavailable
-        )
+    private fun showAddPointDialog() {
+        val input = EditText(this).apply {
+            hint = "Point name"
+            setPadding(48, 24, 48, 8)
+            val count = vm.points.value.size + 1
+            setText("Point $count")
+            selectAll()
+        }
+        AlertDialog.Builder(this)
+            .setTitle("Name this location")
+            .setView(input)
+            .setPositiveButton("Add") { _, _ ->
+                val name = input.text.toString().trim().ifEmpty { "Point" }
+                vm.addPoint(name)
+            }
+            .setNegativeButton("Cancel", null)
+            .show()
     }
 }
diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugPoint.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugPoint.kt
new file mode 100644
index 0000000..3747a2c
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugPoint.kt
@@ -0,0 +1,8 @@
+package net.fuzzyworld.haunt.debug
+
+data class DebugPoint(
+    val id: String,
+    val name: String,
+    val color: Int,
+    val readings: Map<String, Double>, // BSSID -> meters
+)
diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugPointStore.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugPointStore.kt
new file mode 100644
index 0000000..b1f49c1
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugPointStore.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 DebugPointStore(context: Context) {
+
+    private val file = File(context.filesDir, "debug_points.json")
+    private val gson = Gson()
+
+    fun load(): List<DebugPoint> {
+        if (!file.exists()) return emptyList()
+        return try {
+            val type = object : TypeToken<List<DebugPoint>>() {}.type
+            gson.fromJson<List<DebugPoint>>(file.readText(), type) ?: emptyList()
+        } catch (_: Exception) {
+            emptyList()
+        }
+    }
+
+    fun save(points: List<DebugPoint>) {
+        file.writeText(gson.toJson(points))
+    }
+}
diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt
new file mode 100644
index 0000000..fd79791
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt
@@ -0,0 +1,68 @@
+package net.fuzzyworld.haunt.debug
+
+import android.app.Application
+import android.graphics.Color
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import net.fuzzyworld.haunt.ranging.RttRangingManager
+import java.util.UUID
+
+class DebugViewModel(application: Application) : AndroidViewModel(application) {
+
+    private val rttManager = RttRangingManager(application)
+    private val store = DebugPointStore(application)
+
+    private val _readings = MutableStateFlow<List<RttRangingManager.ApReading>>(emptyList())
+    val readings: StateFlow<List<RttRangingManager.ApReading>> = _readings
+
+    private val _points = MutableStateFlow<List<DebugPoint>>(emptyList())
+    val points: StateFlow<List<DebugPoint>> = _points
+
+    val isRttAvailable: Boolean get() = rttManager.isAvailable
+
+    init {
+        _points.value = store.load()
+        startRanging()
+    }
+
+    private fun startRanging() {
+        viewModelScope.launch {
+            while (true) {
+                _readings.value = rttManager.rangeAll()
+                delay(2000)
+            }
+        }
+    }
+
+    fun addPoint(name: String) {
+        val current = _readings.value
+        if (current.isEmpty()) return
+        val point = DebugPoint(
+            id = UUID.randomUUID().toString(),
+            name = name,
+            color = randomColor(),
+            readings = current.associate { it.bssid to it.distanceMeters },
+        )
+        _points.value = _points.value + point
+        store.save(_points.value)
+    }
+
+    fun renamePoint(id: String, newName: String) {
+        _points.value = _points.value.map { if (it.id == id) it.copy(name = newName) else it }
+        store.save(_points.value)
+    }
+
+    fun deletePoint(id: String) {
+        _points.value = _points.value.filter { it.id != id }
+        store.save(_points.value)
+    }
+
+    private fun randomColor(): Int {
+        val hue = (Math.random() * 360).toFloat()
+        return Color.HSVToColor(floatArrayOf(hue, 0.70f, 0.80f))
+    }
+}
diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt
new file mode 100644
index 0000000..28a019c
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt
@@ -0,0 +1,153 @@
+package net.fuzzyworld.haunt.debug
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.DashPathEffect
+import android.graphics.Paint
+import android.graphics.Typeface
+import android.util.AttributeSet
+import android.view.View
+import kotlin.math.roundToInt
+
+class MapView @JvmOverloads constructor(
+    context: Context,
+    attrs: AttributeSet? = null,
+) : View(context, attrs) {
+
+    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() }
+
+    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 {
+        color = Color.parseColor("#E0E0E0")
+        strokeWidth = 1.5f
+    }
+    private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        color = Color.BLACK
+        textSize = 30f
+        typeface = Typeface.DEFAULT_BOLD
+    }
+    private val dimLabelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        color = Color.parseColor("#888888")
+        textSize = 24f
+    }
+    private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        style = Paint.Style.STROKE
+        strokeWidth = 3f
+        pathEffect = DashPathEffect(floatArrayOf(8f, 6f), 0f)
+    }
+
+    private val allBssids: List<String>
+        get() = (savedPoints.flatMap { it.readings.keys } + currentReadings.keys)
+            .distinct().sorted()
+
+    private fun coords(readings: Map<String, Double>): Pair<Double, Double>? {
+        val bssids = allBssids
+        val x = bssids.getOrNull(0)?.let { readings[it] } ?: return null
+        val y = bssids.getOrNull(1)?.let { readings[it] } ?: 0.0
+        return Pair(x, y)
+    }
+
+    override fun onDraw(canvas: Canvas) {
+        super.onDraw(canvas)
+        canvas.drawColor(Color.parseColor("#F2F2F2"))
+
+        val currentCoords = if (currentReadings.isNotEmpty()) coords(currentReadings) else null
+        val savedCoords = savedPoints.mapNotNull { p -> coords(p.readings)?.let { p to it } }
+
+        val allCoords = savedCoords.map { it.second } + listOfNotNull(currentCoords)
+
+        if (allCoords.isEmpty()) {
+            labelPaint.color = Color.parseColor("#AAAAAA")
+            labelPaint.textAlign = Paint.Align.CENTER
+            canvas.drawText("No points yet", width / 2f, height / 2f - 16f, labelPaint)
+            dimLabelPaint.textAlign = Paint.Align.CENTER
+            canvas.drawText("walk somewhere and tap Add", width / 2f, height / 2f + 24f, dimLabelPaint)
+            return
+        }
+
+        val padPx = 72f
+        val minX = (allCoords.minOf { it.first } - 1.0).coerceAtLeast(0.0)
+        val maxX = allCoords.maxOf { it.first } + 1.0
+        val minY = (allCoords.minOf { it.second } - 1.0).coerceAtLeast(0.0)
+        val maxY = allCoords.maxOf { it.second } + 1.0
+
+        val scaleX = (width - 2 * padPx) / (maxX - minX).toFloat()
+        val scaleY = (height - 2 * padPx) / (maxY - minY).toFloat()
+
+        fun sx(x: Double) = padPx + ((x - minX) * scaleX).toFloat()
+        fun sy(y: Double) = padPx + ((y - minY) * scaleY).toFloat()
+
+        // Grid
+        for (gx in minX.roundToInt()..maxX.roundToInt()) {
+            canvas.drawLine(sx(gx.toDouble()), 0f, sx(gx.toDouble()), height.toFloat(), gridPaint)
+            dimLabelPaint.textAlign = Paint.Align.CENTER
+            canvas.drawText("${gx}m", sx(gx.toDouble()), height - 6f, dimLabelPaint)
+        }
+        for (gy in minY.roundToInt()..maxY.roundToInt()) {
+            canvas.drawLine(0f, sy(gy.toDouble()), width.toFloat(), sy(gy.toDouble()), gridPaint)
+            dimLabelPaint.textAlign = Paint.Align.LEFT
+            canvas.drawText("${gy}m", 4f, sy(gy.toDouble()) - 6f, dimLabelPaint)
+        }
+
+        // Saved points
+        labelPaint.color = Color.BLACK
+        savedCoords.forEach { (point, xy) ->
+            val cx = sx(xy.first)
+            val cy = sy(xy.second)
+
+            fillPaint.color = point.color
+            canvas.drawCircle(cx, cy, 26f, fillPaint)
+            strokePaint.color = Color.WHITE
+            strokePaint.strokeWidth = 3f
+            canvas.drawCircle(cx, cy, 26f, strokePaint)
+
+            labelPaint.textAlign = Paint.Align.LEFT
+            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
+        if (currentCoords != null) {
+            val cx = sx(currentCoords.first)
+            val cy = sy(currentCoords.second)
+
+            fillPaint.color = Color.WHITE
+            canvas.drawCircle(cx, cy, 22f, fillPaint)
+
+            strokePaint.color = Color.BLACK
+            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")
+            canvas.drawCircle(cx, cy, 38f, ringPaint)
+
+            labelPaint.textAlign = Paint.Align.LEFT
+            labelPaint.color = Color.parseColor("#444444")
+            canvas.drawText("you", cx + 46f, cy + 11f, labelPaint)
+            labelPaint.color = Color.BLACK
+        }
+
+        // Axis labels
+        val bssids = allBssids
+        if (bssids.isNotEmpty()) {
+            dimLabelPaint.textAlign = Paint.Align.RIGHT
+            canvas.drawText("→ ${bssids[0].takeLast(5)}", width - 4f, height - 6f, dimLabelPaint)
+        }
+        if (bssids.size > 1) {
+            dimLabelPaint.textAlign = Paint.Align.LEFT
+            canvas.drawText("↓ ${bssids[1].takeLast(5)}", 4f, padPx - 10f, dimLabelPaint)
+        }
+    }
+}
diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt
new file mode 100644
index 0000000..daa3cfc
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt
@@ -0,0 +1,40 @@
+package net.fuzzyworld.haunt.debug
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import net.fuzzyworld.haunt.databinding.ItemDebugPointBinding
+
+class PointsAdapter(
+    private val onRename: (id: String, newName: String) -> Unit,
+    private val onDelete: (id: String) -> Unit,
+) : RecyclerView.Adapter<PointsAdapter.ViewHolder>() {
+
+    private var points: List<DebugPoint> = emptyList()
+
+    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 onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(points[position])
+    override fun getItemCount() = points.size
+
+    inner class ViewHolder(private val b: ItemDebugPointBinding) : RecyclerView.ViewHolder(b.root) {
+        fun bind(point: DebugPoint) {
+            b.colorSwatch.setBackgroundColor(point.color)
+            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) }
+        }
+    }
+}
diff --git a/app/src/main/java/net/fuzzyworld/haunt/ranging/RttRangingManager.kt b/app/src/main/java/net/fuzzyworld/haunt/ranging/RttRangingManager.kt
new file mode 100644
index 0000000..992e409
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/ranging/RttRangingManager.kt
@@ -0,0 +1,46 @@
+package net.fuzzyworld.haunt.ranging
+
+import android.content.Context
+import android.net.wifi.WifiManager
+import android.net.wifi.rtt.RangingRequest
+import android.net.wifi.rtt.RangingResult
+import android.net.wifi.rtt.RangingResultCallback
+import android.net.wifi.rtt.WifiRttManager
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+class RttRangingManager(private val context: Context) {
+
+    data class ApReading(val bssid: String, val distanceMeters: Double)
+
+    private val wifiManager = context.getSystemService(WifiManager::class.java)
+    private val rttManager = context.getSystemService(WifiRttManager::class.java)
+
+    val isAvailable: Boolean get() = rttManager?.isAvailable == true
+
+    suspend fun rangeAll(): List<ApReading> {
+        val manager = rttManager ?: return emptyList()
+        val scanResults = wifiManager?.scanResults?.filter { it.is80211mcResponder }
+        if (scanResults.isNullOrEmpty()) return emptyList()
+
+        return suspendCoroutine { cont ->
+            val request = RangingRequest.Builder().apply {
+                scanResults.forEach { addAccessPoint(it) }
+            }.build()
+
+            manager.startRanging(request, context.mainExecutor, object : RangingResultCallback() {
+                override fun onRangingResults(results: List<RangingResult>) {
+                    val readings = results
+                        .filter { it.status == RangingResult.STATUS_SUCCESS }
+                        .map { ApReading(it.macAddress.toString(), it.distanceMm / 1000.0) }
+                        .sortedBy { it.bssid }
+                    cont.resume(readings)
+                }
+
+                override fun onRangingFailure(code: Int) {
+                    cont.resume(emptyList())
+                }
+            })
+        }
+    }
+}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index c4108ad..0d4ea85 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -2,44 +2,39 @@
 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    android:orientation="vertical">
+    android:orientation="vertical"
+    android:fitsSystemWindows="true">
 
     <TextView
-        android:id="@+id/rttStatusText"
+        android:id="@+id/readingsText"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:padding="16dp"
-        android:textSize="16sp" />
+        android:paddingHorizontal="12dp"
+        android:paddingTop="8dp"
+        android:paddingBottom="4dp"
+        android:fontFamily="monospace"
+        android:textSize="12sp"
+        android:text="Scanning…" />
 
-    <TextView
-        android:id="@+id/modeText"
+    <net.fuzzyworld.haunt.debug.MapView
+        android:id="@+id/mapView"
         android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:padding="16dp"
-        android:text="Mode: —"
-        android:textSize="14sp" />
+        android:layout_height="0dp"
+        android:layout_weight="1" />
 
-    <LinearLayout
+    <Button
+        android:id="@+id/addPointButton"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:orientation="horizontal"
-        android:padding="16dp">
-
-        <Button
-            android:id="@+id/calibrateButton"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="1"
-            android:text="@string/tab_calibrate" />
+        android:layout_marginHorizontal="12dp"
+        android:layout_marginVertical="6dp"
+        android:text="Add Point Here" />
 
-        <Button
-            android:id="@+id/liveButton"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_weight="1"
-            android:layout_marginStart="8dp"
-            android:text="@string/tab_live" />
-
-    </LinearLayout>
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/pointsList"
+        android:layout_width="match_parent"
+        android:layout_height="180dp"
+        android:clipToPadding="false"
+        android:paddingBottom="8dp" />
 
 </LinearLayout>
diff --git a/app/src/main/res/layout/item_debug_point.xml b/app/src/main/res/layout/item_debug_point.xml
new file mode 100644
index 0000000..435275a
--- /dev/null
+++ b/app/src/main/res/layout/item_debug_point.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="horizontal"
+    android:gravity="center_vertical"
+    android:paddingHorizontal="12dp"
+    android:paddingVertical="6dp">
+
+    <View
+        android:id="@+id/colorSwatch"
+        android:layout_width="20dp"
+        android:layout_height="20dp"
+        android:layout_marginEnd="10dp" />
+
+    <EditText
+        android:id="@+id/nameEdit"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:background="@null"
+        android:singleLine="true"
+        android:textSize="15sp"
+        android:imeOptions="actionDone" />
+
+    <ImageButton
+        android:id="@+id/deleteButton"
+        android:layout_width="36dp"
+        android:layout_height="36dp"
+        android:background="?attr/selectableItemBackgroundBorderless"
+        android:src="@android:drawable/ic_menu_delete"
+        android:contentDescription="Delete point" />
+
+</LinearLayout>
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..b089df1
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <style name="Theme.Haunt" parent="Theme.AppCompat.Light.DarkActionBar">
+        <!-- dark icons on the light status bar -->
+        <item name="android:windowLightStatusBar">true</item>
+    </style>
+</resources>
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..be3e216
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,11 @@
+android.builtInKotlin=false
+android.defaults.buildfeatures.resvalues=true
+android.dependency.useConstraints=true
+android.enableAppCompileTimeRClass=false
+android.newDsl=false
+android.r8.optimizedResourceShrinking=false
+android.r8.strictFullModeForKeepRules=false
+android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
+android.uniquePackageNames=false
+android.useAndroidX=true
+android.usesSdkInManifest.disallowed=false
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e09e4d8..a9eb983 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,11 +1,18 @@
 [versions]
-agp = "8.7.3"
-kotlin = "2.0.0"
+agp = "9.2.1"
+kotlin = "2.2.10"
 coroutines = "1.8.1"
 retrofit = "2.11.0"
 okhttp = "4.12.0"
+appcompat = "1.7.0"
+lifecycle = "2.8.7"
+recyclerview = "1.3.2"
 
 [libraries]
+androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
+androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
 kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
 retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
 retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 09523c0..c61a118 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
 networkTimeout=10000
 validateDistributionUrl=true
 zipStoreBase=GRADLE_USER_HOME