fuzzyworld

haunt / commit

Add LAN device discovery via ARP + OUI + mDNS

cffb2c436e7af5440e73b21e9f08456ac7d0f4b1 · Pavel Safronov · 2026-05-13 12:43:10 -0700 · parents 6f2b3e18572b

Add LAN device discovery via ARP + OUI + mDNS

Scans /proc/net/arp every 30s to find all devices on the local network.
Identifies Raspberry Pi (by MAC OUI), Hue Bridge, and Apple devices.
Reverse DNS and mDNS service discovery (ssh, hue, workstation, afp) enrich
each entry with a hostname and service fingerprint. Discovered devices appear
in the list with type-specific icons; tapping the pin button places them on
the map at your current RTT position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 .../main/java/net/fuzzyworld/haunt/MainActivity.kt |  10 ++
 .../net/fuzzyworld/haunt/debug/DebugViewModel.kt   |  42 ++++++++
 .../java/net/fuzzyworld/haunt/debug/MapView.kt     |  23 +++-
 .../net/fuzzyworld/haunt/debug/PointsAdapter.kt    |  45 +++++++-
 .../net/fuzzyworld/haunt/network/NetworkDevice.kt  |  19 ++++
 .../net/fuzzyworld/haunt/network/NetworkScanner.kt | 117 +++++++++++++++++++++
 app/src/main/res/drawable/ic_laptop.xml            |  11 ++
 app/src/main/res/drawable/ic_network_device.xml    |  11 ++
 app/src/main/res/drawable/ic_raspberry.xml         |  11 ++
 9 files changed, 281 insertions(+), 8 deletions(-)

diff --git a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
index 9189b02..b8c80cf 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
@@ -42,6 +42,10 @@ class MainActivity : AppCompatActivity() {
         onRenameLight = { id, name -> vm.renameLightPoint(id, name) },
         onDeleteLight = { id -> vm.deleteLightPoint(id) },
         onRecolorLight = { id, color -> vm.recolorLightPoint(id, color) },
+        onPinDevice = { device ->
+            vm.pinNetworkDevice(device)
+            Toast.makeText(this, "Pinned ${device.displayName}", Toast.LENGTH_SHORT).show()
+        },
     )
 
     private val requestPermissions = registerForActivityResult(
@@ -103,6 +107,12 @@ class MainActivity : AppCompatActivity() {
             }
         }
 
+        lifecycleScope.launch {
+            vm.networkDevices.collectLatest { devices ->
+                adapter.submitNetworkDevices(devices)
+            }
+        }
+
     }
 
     // --- Add Point dialog ---
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 09df9ff..e0d06ea 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt
@@ -17,6 +17,9 @@ import kotlinx.coroutines.withContext
 import net.fuzzyworld.haunt.hue.HueBridgeConfig
 import net.fuzzyworld.haunt.hue.HueLightPoint
 import net.fuzzyworld.haunt.hue.HueLightStore
+import net.fuzzyworld.haunt.network.DeviceType
+import net.fuzzyworld.haunt.network.NetworkDevice
+import net.fuzzyworld.haunt.network.NetworkScanner
 import net.fuzzyworld.haunt.ranging.RttRangingManager
 import java.util.UUID
 
@@ -26,6 +29,7 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
     private val store = DebugPointStore(application)
     private val historyStore = HistoryStore(application)
     private val lightStore = HueLightStore(application)
+    private val networkScanner = NetworkScanner(application)
     private val prefs = application.getSharedPreferences("haunt_prefs", Context.MODE_PRIVATE)
 
     val hueConfig = HueBridgeConfig(application)
@@ -42,6 +46,9 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
     private val _lightPoints = MutableStateFlow<List<HueLightPoint>>(emptyList())
     val lightPoints: StateFlow<List<HueLightPoint>> = _lightPoints
 
+    private val _networkDevices = MutableStateFlow<List<NetworkDevice>>(emptyList())
+    val networkDevices: StateFlow<List<NetworkDevice>> = _networkDevices
+
     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
@@ -68,6 +75,7 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
             _history.value = loaded
         }
         startRanging()
+        startNetworkScanning()
     }
 
     private fun startRanging() {
@@ -96,6 +104,40 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
         }
     }
 
+    private fun startNetworkScanning() {
+        viewModelScope.launch {
+            while (true) {
+                try { _networkDevices.value = networkScanner.scan() } catch (_: Exception) {}
+                delay(30_000)
+            }
+        }
+    }
+
+    fun pinNetworkDevice(device: NetworkDevice) {
+        val current = _readings.value
+        if (current.isEmpty()) return
+        val color = when (device.type) {
+            DeviceType.RASPBERRY_PI -> Color.HSVToColor(floatArrayOf(120f, 0.65f, 0.70f))
+            DeviceType.APPLE        -> Color.HSVToColor(floatArrayOf(210f, 0.50f, 0.85f))
+            DeviceType.HUE_BRIDGE  -> Color.HSVToColor(floatArrayOf(45f,  0.80f, 0.90f))
+            DeviceType.UNKNOWN     -> randomColor()
+        }
+        val typePrefix = when (device.type) {
+            DeviceType.RASPBERRY_PI -> "pi"
+            DeviceType.APPLE        -> "apple"
+            DeviceType.HUE_BRIDGE  -> "hue"
+            DeviceType.UNKNOWN     -> "device"
+        }
+        val point = DebugPoint(
+            id = "net:$typePrefix:${device.id}",
+            name = device.displayName,
+            color = color,
+            readings = current.associate { it.bssid to it.distanceMeters },
+        )
+        _points.value = _points.value.filter { it.id != point.id } + point
+        store.save(_points.value)
+    }
+
     fun setYouColor(color: Int) {
         prefs.edit().putInt("you_color", color).apply()
         _youColor.value = color
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 aae5d36..2559cf2 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt
@@ -117,6 +117,19 @@ class MapView @JvmOverloads constructor(
         canvas.drawCircle(cx - radius * 0.18f, cy - radius * 0.28f, radius * 0.18f, fillPaint)
     }
 
+    private fun drawNetworkDeviceIcon(canvas: Canvas, cx: Float, cy: Float, color: Int, radius: Float) {
+        // Square with rounded corners (looks like a device/chip)
+        fillPaint.color = color
+        val r = radius * 0.82f
+        canvas.drawRoundRect(cx - r, cy - r, cx + r, cy + r, r * 0.3f, r * 0.3f, fillPaint)
+        strokePaint.color = Color.WHITE
+        strokePaint.strokeWidth = radius * 0.12f
+        canvas.drawRoundRect(cx - r, cy - r, cx + r, cy + r, r * 0.3f, r * 0.3f, strokePaint)
+        // Small center dot
+        fillPaint.color = Color.argb(120, 255, 255, 255)
+        canvas.drawCircle(cx, cy, r * 0.28f, fillPaint)
+    }
+
     private fun drawPointIcon(canvas: Canvas, cx: Float, cy: Float, color: Int, radius: Float) {
         fillPaint.color = color
         canvas.drawCircle(cx, cy, radius, fillPaint)
@@ -221,15 +234,15 @@ class MapView @JvmOverloads constructor(
             canvas.drawText(point.name, cx + 34f, cy + 11f, labelPaint)
         }
 
-        // Saved points — routers get AP icon, others get generic pin
+        // Saved points — routers get AP icon, network devices get small ring, others get generic pin
         labelPaint.color = Color.BLACK
         savedCoords.forEach { (point, xy) ->
             val cx = sx(xy.first)
             val cy = sy(xy.second)
-            if (point.id.startsWith("router:")) {
-                drawApIcon(canvas, cx, cy, point.color, 26f)
-            } else {
-                drawPointIcon(canvas, cx, cy, point.color, 26f)
+            when {
+                point.id.startsWith("router:") -> drawApIcon(canvas, cx, cy, point.color, 26f)
+                point.id.startsWith("net:") -> drawNetworkDeviceIcon(canvas, cx, cy, point.color, 26f)
+                else -> drawPointIcon(canvas, cx, cy, point.color, 26f)
             }
             labelPaint.textAlign = Paint.Align.LEFT
             canvas.drawText(point.name, cx + 34f, cy + 11f, labelPaint)
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 48bd4b7..e42a3c8 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt
@@ -14,6 +14,8 @@ import androidx.recyclerview.widget.RecyclerView
 import net.fuzzyworld.haunt.R
 import net.fuzzyworld.haunt.databinding.ItemDebugPointBinding
 import net.fuzzyworld.haunt.hue.HueLightPoint
+import net.fuzzyworld.haunt.network.DeviceType
+import net.fuzzyworld.haunt.network.NetworkDevice
 import kotlin.math.sqrt
 
 private val PRESET_COLORS = listOf(
@@ -74,21 +76,25 @@ class PointsAdapter(
     private val onRenameLight: (id: String, newName: String) -> Unit,
     private val onDeleteLight: (id: String) -> Unit,
     private val onRecolorLight: (id: String, color: Int) -> Unit,
+    private val onPinDevice: (NetworkDevice) -> Unit,
 ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 
     companion object {
         private const val TYPE_YOU = 0
         private const val TYPE_LIGHT = 1
         private const val TYPE_POINT = 2
+        private const val TYPE_DEVICE = 3
     }
 
     private var points: List<DebugPoint> = emptyList()
     private var lightPoints: List<HueLightPoint> = emptyList()
+    private var networkDevices: List<NetworkDevice> = emptyList()
     var youColor: Int = Color.GRAY
     var youReadings: Map<String, Double> = emptyMap()
 
     fun submitList(list: List<DebugPoint>) { points = list; notifyDataSetChanged() }
     fun submitLightList(list: List<HueLightPoint>) { lightPoints = list; notifyDataSetChanged() }
+    fun submitNetworkDevices(list: List<NetworkDevice>) { networkDevices = list; notifyDataSetChanged() }
 
     private fun distanceToYou(pointReadings: Map<String, Double>): Double? {
         val common = youReadings.keys.intersect(pointReadings.keys)
@@ -99,12 +105,13 @@ class PointsAdapter(
     private fun formatDistance(d: Double?) =
         if (d != null) "${"%.2f".format(d)} m away" else ""
 
-    override fun getItemCount() = 1 + lightPoints.size + points.size
+    override fun getItemCount() = 1 + lightPoints.size + points.size + networkDevices.size
 
     override fun getItemViewType(position: Int) = when {
         position == 0 -> TYPE_YOU
         position <= lightPoints.size -> TYPE_LIGHT
-        else -> TYPE_POINT
+        position <= lightPoints.size + points.size -> TYPE_POINT
+        else -> TYPE_DEVICE
     }
 
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
@@ -112,6 +119,7 @@ class PointsAdapter(
         return when (viewType) {
             TYPE_YOU -> YouViewHolder(binding)
             TYPE_LIGHT -> LightViewHolder(binding)
+            TYPE_DEVICE -> DeviceViewHolder(binding)
             else -> PointViewHolder(binding)
         }
     }
@@ -121,6 +129,7 @@ class PointsAdapter(
             is YouViewHolder -> holder.bind()
             is LightViewHolder -> holder.bind(lightPoints[position - 1])
             is PointViewHolder -> holder.bind(points[position - 1 - lightPoints.size])
+            is DeviceViewHolder -> holder.bind(networkDevices[position - 1 - lightPoints.size - points.size])
         }
     }
 
@@ -158,7 +167,13 @@ class PointsAdapter(
 
     inner class PointViewHolder(private val b: ItemDebugPointBinding) : RecyclerView.ViewHolder(b.root) {
         fun bind(point: DebugPoint) {
-            val iconRes = if (point.id.startsWith("router:")) R.drawable.ic_ap else R.drawable.ic_point
+            val iconRes = when {
+                point.id.startsWith("router:") -> R.drawable.ic_ap
+                point.id.startsWith("net:pi:") -> R.drawable.ic_raspberry
+                point.id.startsWith("net:apple:") -> R.drawable.ic_laptop
+                point.id.startsWith("net:") -> R.drawable.ic_network_device
+                else -> R.drawable.ic_point
+            }
             b.colorSwatch.setImageResource(iconRes)
             ImageViewCompat.setImageTintList(b.colorSwatch, ColorStateList.valueOf(point.color))
             b.colorSwatch.setOnClickListener {
@@ -174,4 +189,28 @@ class PointsAdapter(
             b.xyText.text = formatDistance(distanceToYou(point.readings))
         }
     }
+
+    inner class DeviceViewHolder(private val b: ItemDebugPointBinding) : RecyclerView.ViewHolder(b.root) {
+        fun bind(device: NetworkDevice) {
+            val iconRes = when (device.type) {
+                DeviceType.RASPBERRY_PI -> R.drawable.ic_raspberry
+                DeviceType.APPLE        -> R.drawable.ic_laptop
+                DeviceType.HUE_BRIDGE  -> R.drawable.ic_light
+                DeviceType.UNKNOWN     -> R.drawable.ic_network_device
+            }
+            val iconColor = Color.parseColor("#999999")
+            b.colorSwatch.setImageResource(iconRes)
+            ImageViewCompat.setImageTintList(b.colorSwatch, ColorStateList.valueOf(iconColor))
+            b.colorSwatch.setOnClickListener(null)
+
+            b.nameEdit.setText(device.displayName)
+            b.nameEdit.isEnabled = false
+            b.xyText.text = device.type.label
+
+            b.deleteButton.setImageResource(R.drawable.ic_point)
+            ImageViewCompat.setImageTintList(b.deleteButton, ColorStateList.valueOf(Color.parseColor("#4CAF50")))
+            b.deleteButton.visibility = View.VISIBLE
+            b.deleteButton.setOnClickListener { onPinDevice(device) }
+        }
+    }
 }
diff --git a/app/src/main/java/net/fuzzyworld/haunt/network/NetworkDevice.kt b/app/src/main/java/net/fuzzyworld/haunt/network/NetworkDevice.kt
new file mode 100644
index 0000000..45b97d5
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/network/NetworkDevice.kt
@@ -0,0 +1,19 @@
+package net.fuzzyworld.haunt.network
+
+data class NetworkDevice(
+    val ip: String,
+    val mac: String?,
+    val hostname: String?,
+    val services: Set<String>,
+    val type: DeviceType,
+) {
+    val displayName: String get() = hostname?.removeSuffix(".local") ?: ip
+    val id: String get() = mac ?: ip
+}
+
+enum class DeviceType(val label: String) {
+    HUE_BRIDGE("Hue Bridge"),
+    RASPBERRY_PI("Raspberry Pi"),
+    APPLE("Apple device"),
+    UNKNOWN("Network device"),
+}
diff --git a/app/src/main/java/net/fuzzyworld/haunt/network/NetworkScanner.kt b/app/src/main/java/net/fuzzyworld/haunt/network/NetworkScanner.kt
new file mode 100644
index 0000000..c15955c
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/network/NetworkScanner.kt
@@ -0,0 +1,117 @@
+package net.fuzzyworld.haunt.network
+
+import android.content.Context
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import java.net.InetAddress
+
+class NetworkScanner(context: Context) {
+
+    private val nsd = context.getSystemService(Context.NSD_SERVICE) as NsdManager
+
+    companion object {
+        private val MDNS_TYPES = listOf(
+            "_ssh._tcp.",
+            "_hue._tcp.",
+            "_workstation._tcp.",
+            "_afpovertcp._tcp.",
+        )
+
+        // First 3 MAC octets → device type
+        private val OUI_TYPES = mapOf(
+            "b8:27:eb" to DeviceType.RASPBERRY_PI,  // Pi 1–3
+            "dc:a6:32" to DeviceType.RASPBERRY_PI,  // Pi 4
+            "e4:5f:01" to DeviceType.RASPBERRY_PI,  // Pi 4 alt
+            "d8:3a:dd" to DeviceType.RASPBERRY_PI,  // Pi 5
+            "2c:cf:67" to DeviceType.RASPBERRY_PI,  // Pi Zero 2W
+            "00:17:88" to DeviceType.HUE_BRIDGE,
+            "ec:b5:fa" to DeviceType.HUE_BRIDGE,
+        )
+
+        private val APPLE_SERVICES = setOf("_afpovertcp._tcp.", "_workstation._tcp.")
+    }
+
+    suspend fun scan(): List<NetworkDevice> = coroutineScope {
+        val arpDeferred = async(Dispatchers.IO) { readArp() }
+        val mdnsDeferred = async { collectMdns(3_000L) }
+
+        val arpEntries = arpDeferred.await()
+        val mdnsMap = mdnsDeferred.await() // serviceName → serviceType
+
+        // Reverse DNS for all IPs in parallel
+        val withHostnames = arpEntries.map { (ip, mac) ->
+            async(Dispatchers.IO) {
+                val hostname = try {
+                    InetAddress.getByName(ip).canonicalHostName.takeIf { it != ip }
+                } catch (_: Exception) { null }
+                Triple(ip, mac, hostname)
+            }
+        }.awaitAll()
+
+        withHostnames.map { (ip, mac, hostname) ->
+            var type = OUI_TYPES[mac.take(8)] ?: DeviceType.UNKNOWN
+
+            // Refine from hostname patterns
+            if (type == DeviceType.UNKNOWN) {
+                val h = hostname?.lowercase() ?: ""
+                if ("raspberry" in h) type = DeviceType.RASPBERRY_PI
+            }
+
+            // Match mDNS records by hostname fragment
+            val baseName = hostname?.removeSuffix(".local") ?: ""
+            val matched = if (baseName.isNotEmpty()) {
+                mdnsMap.filter { (name, _) ->
+                    name.contains(baseName, ignoreCase = true)
+                }.values.toSet()
+            } else emptySet()
+
+            if (matched.any { it.startsWith("_hue") }) type = DeviceType.HUE_BRIDGE
+            if (type == DeviceType.UNKNOWN && matched.any { it in APPLE_SERVICES }) {
+                type = DeviceType.APPLE
+            }
+
+            NetworkDevice(ip = ip, mac = mac, hostname = hostname, services = matched, type = type)
+        }
+    }
+
+    private fun readArp(): List<Pair<String, String>> =
+        try {
+            java.io.File("/proc/net/arp").readLines().drop(1).mapNotNull { line ->
+                val p = line.trim().split(Regex("\\s+"))
+                // columns: IP, HW type, Flags, HW addr, Mask, Device
+                if (p.size >= 4 && p[3] != "00:00:00:00:00:00") p[0] to p[3].lowercase()
+                else null
+            }
+        } catch (_: Exception) { emptyList() }
+
+    private suspend fun collectMdns(timeoutMs: Long): Map<String, String> {
+        val results = mutableMapOf<String, String>() // serviceName → serviceType
+        val listeners = mutableListOf<NsdManager.DiscoveryListener>()
+
+        for (type in MDNS_TYPES) {
+            val listener = object : NsdManager.DiscoveryListener {
+                override fun onStartDiscoveryFailed(t: String, e: Int) {}
+                override fun onStopDiscoveryFailed(t: String, e: Int) {}
+                override fun onDiscoveryStarted(t: String) {}
+                override fun onDiscoveryStopped(t: String) {}
+                override fun onServiceFound(info: NsdServiceInfo) {
+                    synchronized(results) { results[info.serviceName] = type }
+                }
+                override fun onServiceLost(info: NsdServiceInfo) {}
+            }
+            try {
+                nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, listener)
+                listeners.add(listener)
+            } catch (_: Exception) {}
+        }
+
+        delay(timeoutMs)
+        listeners.forEach { try { nsd.stopServiceDiscovery(it) } catch (_: Exception) {} }
+        return results
+    }
+}
diff --git a/app/src/main/res/drawable/ic_laptop.xml b/app/src/main/res/drawable/ic_laptop.xml
new file mode 100644
index 0000000..281c517
--- /dev/null
+++ b/app/src/main/res/drawable/ic_laptop.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Laptop icon (Apple / Mac) -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M4,6H20A2,2 0,0 1,22 8V17A2,2 0,0 1,20 19H13V21H16V23H8V21H11V19H4A2,2 0,0 1,2 17V8A2,2 0,0 1,4 6M4,8V17H20V8H4Z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_network_device.xml b/app/src/main/res/drawable/ic_network_device.xml
new file mode 100644
index 0000000..ee91340
--- /dev/null
+++ b/app/src/main/res/drawable/ic_network_device.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generic network device -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M15,20A1,1 0,0 0,14 19H10A1,1 0,0 0,9 20V21H15V20M17,14H7A1,1 0,0 0,6 15V19A1,1 0,0 0,7 20H17A1,1 0,0 0,18 19V15A1,1 0,0 0,17 14M10,17H8V16H10V17M13,17H11V16H13V17M16,17H14V16H16V17M4,12H20V6H4V12M11,7H13V11H11V7M6,3H18A2,2 0,0 1,20 5V13H4V5A2,2 0,0 1,6 3Z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_raspberry.xml b/app/src/main/res/drawable/ic_raspberry.xml
new file mode 100644
index 0000000..ec6cc8d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_raspberry.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- CPU / chip icon (Raspberry Pi) -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M9,3H7V1H5V3H3V5H1V7H3V9H1V11H3V13H1V15H3V17H5V19H7V21H9V19H11V21H13V19H15V21H17V19H19V17H21V15H19V13H21V11H19V9H21V7H19V5H17V3H15V1H13V3H11V1H9V3M7,5H17V17H7V5M9,7V17H15V7H9M11,9H13V15H11V9Z" />
+</vector>