fuzzyworld

haunt / commit

Layer 1: project scaffold + RTT availability check

8afad5cf2605d5b0b2fb8f22dcf710a73cfab294 · Pavel Safronov · 2026-05-12 20:51:39 -0700

Layer 1: project scaffold + RTT availability check

Kotlin/Android project skeleton for net.fuzzyworld.haunt.
minSdk 28, targetSdk 35, AGP 8.7.3, Kotlin 2.0.

- Build files, libs.versions.toml (Retrofit, OkHttp, Coroutines, Gson)
- AndroidManifest with RTT permissions and wifi.rtt hardware feature
- DistanceTriple and ZoneFingerprint model data classes
- MainActivity requests ACCESS_FINE_LOCATION at runtime and surfaces
  WifiRttManager.isAvailable() so the hardware check can be confirmed
  before any ranging logic is added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 .gitignore                                         | 10 ++++
 app/build.gradle.kts                               | 44 +++++++++++++++++
 app/src/main/AndroidManifest.xml                   | 28 +++++++++++
 .../main/java/net/fuzzyworld/haunt/MainActivity.kt | 55 ++++++++++++++++++++++
 .../net/fuzzyworld/haunt/model/DistanceTriple.kt   | 19 ++++++++
 .../net/fuzzyworld/haunt/model/ZoneFingerprint.kt  |  7 +++
 app/src/main/res/layout/activity_main.xml          | 45 ++++++++++++++++++
 app/src/main/res/values/strings.xml                |  9 ++++
 build.gradle.kts                                   |  4 ++
 gradle/libs.versions.toml                          | 17 +++++++
 gradle/wrapper/gradle-wrapper.properties           |  7 +++
 settings.gradle.kts                                | 17 +++++++
 12 files changed, 262 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..10cfdbf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+*.iml
+.gradle
+/local.properties
+/.idea
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..188f1b7
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,44 @@
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.kotlin.android)
+}
+
+android {
+    namespace = "net.fuzzyworld.haunt"
+    compileSdk = 35
+
+    defaultConfig {
+        applicationId = "net.fuzzyworld.haunt"
+        minSdk = 28
+        targetSdk = 35
+        versionCode = 1
+        versionName = "1.0"
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = false
+        }
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+
+    kotlinOptions {
+        jvmTarget = "17"
+    }
+
+    buildFeatures {
+        viewBinding = true
+    }
+}
+
+dependencies {
+    implementation(libs.kotlinx.coroutines.android)
+    implementation(libs.retrofit)
+    implementation(libs.retrofit.gson)
+    implementation(libs.okhttp)
+    implementation(libs.gson)
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..08a4ca7
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
+    <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
+    <uses-permission android:name="android.permission.INTERNET" />
+
+    <uses-feature android:name="android.hardware.wifi.rtt" android:required="true" />
+
+    <application
+        android:allowBackup="true"
+        android:label="@string/app_name"
+        android:supportsRtl="true"
+        android:theme="@style/Theme.AppCompat.Light.DarkActionBar">
+
+        <activity
+            android:name=".MainActivity"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+
+    </application>
+
+</manifest>
diff --git a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
new file mode 100644
index 0000000..7e8e5d6
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
@@ -0,0 +1,55 @@
+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 androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import net.fuzzyworld.haunt.databinding.ActivityMainBinding
+
+class MainActivity : AppCompatActivity() {
+
+    private lateinit var binding: ActivityMainBinding
+
+    private val requestPermission = registerForActivityResult(
+        ActivityResultContracts.RequestPermission()
+    ) { granted ->
+        if (granted) {
+            checkRttAvailability()
+        } else {
+            Toast.makeText(this, R.string.permission_required, Toast.LENGTH_LONG).show()
+        }
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        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)"
+        }
+
+        if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
+            == PackageManager.PERMISSION_GRANTED
+        ) {
+            checkRttAvailability()
+        } else {
+            requestPermission.launch(Manifest.permission.ACCESS_FINE_LOCATION)
+        }
+    }
+
+    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
+        )
+    }
+}
diff --git a/app/src/main/java/net/fuzzyworld/haunt/model/DistanceTriple.kt b/app/src/main/java/net/fuzzyworld/haunt/model/DistanceTriple.kt
new file mode 100644
index 0000000..9043d05
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/model/DistanceTriple.kt
@@ -0,0 +1,19 @@
+package net.fuzzyworld.haunt.model
+
+import kotlin.math.pow
+import kotlin.math.sqrt
+
+data class DistanceTriple(val d1: Double, val d2: Double, val d3: Double) {
+
+    fun distanceTo(other: DistanceTriple): Double = sqrt(
+        (d1 - other.d1).pow(2) + (d2 - other.d2).pow(2) + (d3 - other.d3).pow(2)
+    )
+
+    companion object {
+        fun average(readings: List<DistanceTriple>) = DistanceTriple(
+            readings.map { it.d1 }.average(),
+            readings.map { it.d2 }.average(),
+            readings.map { it.d3 }.average(),
+        )
+    }
+}
diff --git a/app/src/main/java/net/fuzzyworld/haunt/model/ZoneFingerprint.kt b/app/src/main/java/net/fuzzyworld/haunt/model/ZoneFingerprint.kt
new file mode 100644
index 0000000..37c12a1
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/haunt/model/ZoneFingerprint.kt
@@ -0,0 +1,7 @@
+package net.fuzzyworld.haunt.model
+
+data class ZoneFingerprint(
+    val name: String,
+    val hueLightId: Int,
+    val triple: DistanceTriple,
+)
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..c4108ad
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,45 @@
+<?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="match_parent"
+    android:orientation="vertical">
+
+    <TextView
+        android:id="@+id/rttStatusText"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:padding="16dp"
+        android:textSize="16sp" />
+
+    <TextView
+        android:id="@+id/modeText"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:padding="16dp"
+        android:text="Mode: —"
+        android:textSize="14sp" />
+
+    <LinearLayout
+        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" />
+
+        <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>
+
+</LinearLayout>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..e62e520
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">Haunt</string>
+    <string name="tab_calibrate">Calibrate</string>
+    <string name="tab_live">Live</string>
+    <string name="rtt_available">WiFi RTT: available</string>
+    <string name="rtt_unavailable">WiFi RTT: NOT available on this device</string>
+    <string name="permission_required">Location permission required for RTT ranging</string>
+</resources>
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..7629126
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+    alias(libs.plugins.android.application) apply false
+    alias(libs.plugins.kotlin.android) apply false
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..e09e4d8
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,17 @@
+[versions]
+agp = "8.7.3"
+kotlin = "2.0.0"
+coroutines = "1.8.1"
+retrofit = "2.11.0"
+okhttp = "4.12.0"
+
+[libraries]
+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" }
+okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
+gson = { module = "com.google.code.gson:gson", version = "2.11.0" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..09523c0
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..fd6876b
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "haunt"
+include(":app")