diff --git a/android/build.gradle b/android/build.gradle
new file mode 100644
index 0000000..8ced4d2
--- /dev/null
+++ b/android/build.gradle
@@ -0,0 +1,46 @@
+plugins {
+ id 'com.android.application'
+ id 'kotlin-android'
+}
+
+android {
+ compileSdk 34
+ namespace 'com.saltylab.uwbtag'
+
+ defaultConfig {
+ applicationId "com.saltylab.uwbtag"
+ minSdk 26
+ targetSdk 34
+ versionCode 1
+ versionName "1.0"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ }
+ }
+
+ buildFeatures {
+ viewBinding true
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = '17'
+ }
+}
+
+dependencies {
+ implementation 'androidx.core:core-ktx:1.12.0'
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'com.google.android.material:material:1.11.0'
+ implementation 'androidx.recyclerview:recyclerview:1.3.2'
+ implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
+ implementation 'com.google.code.gson:gson:2.10.1'
+}
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..7c9f530
--- /dev/null
+++ b/android/src/main/AndroidManifest.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/kotlin/com/saltylab/uwbtag/UwbTagBleActivity.kt b/android/src/main/kotlin/com/saltylab/uwbtag/UwbTagBleActivity.kt
new file mode 100644
index 0000000..e7dfdf6
--- /dev/null
+++ b/android/src/main/kotlin/com/saltylab/uwbtag/UwbTagBleActivity.kt
@@ -0,0 +1,444 @@
+package com.saltylab.uwbtag
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.bluetooth.*
+import android.bluetooth.le.*
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.card.MaterialCardView
+import com.google.android.material.switchmaterial.SwitchMaterial
+import com.google.android.material.textfield.TextInputEditText
+import com.google.gson.Gson
+import com.saltylab.uwbtag.databinding.ActivityUwbTagBleBinding
+import java.util.UUID
+
+// ---------------------------------------------------------------------------
+// GATT service / characteristic UUIDs
+// ---------------------------------------------------------------------------
+private val SERVICE_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef0")
+private val CHAR_CONFIG_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef1") // read/write JSON config
+private val CHAR_STATUS_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef2") // notify: tag status string
+private val CHAR_BATT_UUID = UUID.fromString("12345678-1234-5678-1234-56789abcdef3") // notify: battery %
+private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
+
+// BLE scan timeout
+private const val SCAN_TIMEOUT_MS = 15_000L
+
+// Permissions request code
+private const val REQ_PERMISSIONS = 1001
+
+// ---------------------------------------------------------------------------
+// Data model
+// ---------------------------------------------------------------------------
+data class TagConfig(
+ val tag_name: String = "UWB_TAG_0001",
+ val sleep_timeout_s: Int = 300,
+ val display_brightness: Int = 50,
+ val uwb_channel: Int = 9,
+ val ranging_interval_ms: Int = 100,
+ val battery_report: Boolean = true
+)
+
+data class ScannedDevice(
+ val name: String,
+ val address: String,
+ var rssi: Int,
+ val device: BluetoothDevice
+)
+
+// ---------------------------------------------------------------------------
+// RecyclerView adapter for scanned devices
+// ---------------------------------------------------------------------------
+class DeviceAdapter(
+ private val onConnect: (ScannedDevice) -> Unit
+) : RecyclerView.Adapter() {
+
+ private val items = mutableListOf()
+
+ fun update(device: ScannedDevice) {
+ val idx = items.indexOfFirst { it.address == device.address }
+ if (idx >= 0) {
+ items[idx] = device
+ notifyItemChanged(idx)
+ } else {
+ items.add(device)
+ notifyItemInserted(items.size - 1)
+ }
+ }
+
+ fun clear() {
+ items.clear()
+ notifyDataSetChanged()
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
+ val view = LayoutInflater.from(parent.context)
+ .inflate(R.layout.item_ble_device, parent, false)
+ return VH(view)
+ }
+
+ override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(items[position])
+ override fun getItemCount() = items.size
+
+ inner class VH(view: View) : RecyclerView.ViewHolder(view) {
+ private val tvName = view.findViewById(R.id.tvDeviceName)
+ private val tvAddress = view.findViewById(R.id.tvDeviceAddress)
+ private val tvRssi = view.findViewById(R.id.tvRssi)
+ private val btnConn = view.findViewById