Initial implementation of BLE-to-TCP serial bridge for Android
Android app that connects to a BLE UART device (Nordic UART Service by default, matching RNode BLE) and exposes it as a TCP byte pipe, so socat can present it to Reticulum as a serial port. Foreground service hosts a GATT client with MTU negotiation, chunked serialized writes, and auto-reconnect, plus a single-client last-wins TCP server. No root required on either end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
.DS_Store
|
||||
.kotlin/
|
||||
117
README.md
Normal file
117
README.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# BLE Serial Bridge
|
||||
|
||||
An Android app that connects to a BLE UART device (Nordic UART Service by
|
||||
default — the same service RNode firmware uses for BLE serial) and exposes it
|
||||
as a TCP byte pipe. With `socat` on the consuming side, that pipe becomes a
|
||||
real serial device node that [Reticulum](https://reticulum.network/) can use
|
||||
via its serial-based interfaces.
|
||||
|
||||
```
|
||||
┌─────────────┐ BLE/GATT ┌──────────────────┐ TCP ┌──────────────────────┐
|
||||
│ BLE device │◄────────────►│ BLE Serial Bridge │◄───────►│ socat → pty │
|
||||
│ (NUS UART, │ │ (this app, │ :7777 │ → rnsd / picocom / │
|
||||
│ RNode, …) │ │ foreground svc) │ │ anything serial │
|
||||
└─────────────┘ └──────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
No root required anywhere. The consumer can be Termux on the same phone
|
||||
(loopback) or a PC on the same network (enable "Allow LAN").
|
||||
|
||||
## Usage
|
||||
|
||||
1. Install and open the app, grant Bluetooth (and notification) permissions.
|
||||
2. If the device requires bonding (RNode BLE does — it displays a pairing
|
||||
PIN), pair it once in Android's Bluetooth settings first.
|
||||
3. Set the TCP port (default `7777`). Leave "Allow LAN" off for
|
||||
Termux-on-the-same-phone use; turn it on to serve a PC over WiFi.
|
||||
4. The UUID fields are pre-filled with Nordic UART Service. Change them only
|
||||
for devices with a custom UART-style service.
|
||||
5. Tap **Scan**, then tap your device. The bridge starts as a foreground
|
||||
service and auto-reconnects if the BLE link drops.
|
||||
|
||||
The TCP server accepts one client at a time; a new connection displaces the
|
||||
old one, so a restarted `socat` reconnects cleanly.
|
||||
|
||||
## Reticulum on the same phone (Termux)
|
||||
|
||||
```sh
|
||||
pkg install socat
|
||||
socat -d pty,link=$HOME/ttyBLE0,raw,echo=0 tcp:127.0.0.1:7777
|
||||
```
|
||||
|
||||
Then point an RNS interface at the pty. For an RNode over BLE:
|
||||
|
||||
```ini
|
||||
[[RNode BLE]]
|
||||
type = RNodeInterface
|
||||
enabled = true
|
||||
port = /data/data/com.termux/files/home/ttyBLE0
|
||||
frequency = 867200000
|
||||
bandwidth = 125000
|
||||
txpower = 7
|
||||
spreadingfactor = 8
|
||||
codingrate = 5
|
||||
```
|
||||
|
||||
For a generic KISS TNC behind a BLE UART:
|
||||
|
||||
```ini
|
||||
[[BLE KISS]]
|
||||
type = KISSInterface
|
||||
enabled = true
|
||||
port = /data/data/com.termux/files/home/ttyBLE0
|
||||
speed = 115200
|
||||
```
|
||||
|
||||
(`speed` is required by pyserial but meaningless here — the BLE link ignores
|
||||
it.)
|
||||
|
||||
Run `socat` and `rnsd` together, e.g. in two Termux sessions or under a
|
||||
process supervisor. Disable Android battery optimization for both this app
|
||||
and Termux, or the OS will kill the bridge.
|
||||
|
||||
## Reticulum on a PC (phone as BLE radio bridge)
|
||||
|
||||
Enable "Allow LAN" in the app, then on the PC:
|
||||
|
||||
```sh
|
||||
socat -d pty,link=/tmp/ttyBLE0,raw,echo=0 tcp:<phone-ip>:7777
|
||||
```
|
||||
|
||||
and use `/tmp/ttyBLE0` as the `port` in the RNS interface config as above.
|
||||
To get a system-style name, run socat as root with
|
||||
`pty,link=/dev/ttyBLE0,raw,echo=0`.
|
||||
|
||||
Note: "Allow LAN" binds to all interfaces with no authentication or
|
||||
encryption — anyone on the network can talk to your radio. Use it only on
|
||||
networks you trust.
|
||||
|
||||
## Sanity check without Reticulum
|
||||
|
||||
```sh
|
||||
socat -d pty,link=/tmp/ttyBLE0,raw,echo=0 tcp:<phone-ip>:7777 &
|
||||
picocom /tmp/ttyBLE0
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
Requires the Android SDK (API 36) and JDK 17+.
|
||||
|
||||
```sh
|
||||
gradle assembleDebug
|
||||
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
## Design notes
|
||||
|
||||
- **BLE side**: GATT central. Negotiates the largest MTU it can (up to 517),
|
||||
chunks writes to MTU−3, serializes them (one write in flight), and uses
|
||||
write-without-response when the characteristic supports it. Notifications
|
||||
on the RX characteristic feed the TCP side. Reconnects every 3 s if the
|
||||
link drops, for as long as the bridge is running.
|
||||
- **TCP side**: plain byte stream, no framing, no RFC 2217 — the BLE link has
|
||||
no baud rate to negotiate. `TCP_NODELAY` is set to keep KISS frame latency
|
||||
down.
|
||||
- **Defaults**: Nordic UART Service `6e400001-…`, write `6e400002-…`,
|
||||
notify `6e400003-…` — matches RNode BLE and most BLE-UART firmware
|
||||
(Adafruit, Espruino, many ESP32 sketches).
|
||||
41
app/build.gradle.kts
Normal file
41
app/build.gradle.kts
Normal file
@@ -0,0 +1,41 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.wntrmute.bleserial"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "dev.wntrmute.bleserial"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.16.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.0")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
}
|
||||
45
app/src/main/AndroidManifest.xml
Normal file
45
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Legacy Bluetooth permissions (Android 11 and below). -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<!-- Android 12+ Bluetooth permissions. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-feature android:name="android.hardware.bluetooth_le"
|
||||
android:required="true" />
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:theme="@style/Theme.BleSerial"
|
||||
android:allowBackup="false">
|
||||
|
||||
<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>
|
||||
|
||||
<service
|
||||
android:name=".BridgeService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice" />
|
||||
</application>
|
||||
</manifest>
|
||||
241
app/src/main/java/dev/wntrmute/bleserial/BleLink.kt
Normal file
241
app/src/main/java/dev/wntrmute/bleserial/BleLink.kt
Normal file
@@ -0,0 +1,241 @@
|
||||
package dev.wntrmute.bleserial
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.BluetoothStatusCodes
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* GATT client that treats a UART-style service (Nordic UART Service by
|
||||
* default) as a transparent byte pipe. Handles MTU negotiation, write
|
||||
* chunking and serialization, notification subscription, and automatic
|
||||
* reconnection.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class BleLink(
|
||||
private val context: Context,
|
||||
private val device: BluetoothDevice,
|
||||
private val serviceUuid: UUID,
|
||||
private val writeUuid: UUID,
|
||||
private val notifyUuid: UUID,
|
||||
private val onData: (ByteArray) -> Unit,
|
||||
private val onConnected: (Boolean) -> Unit,
|
||||
private val onEvent: (String) -> Unit,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "BleLink"
|
||||
private const val RECONNECT_DELAY_MS = 3000L
|
||||
private const val MAX_QUEUED_CHUNKS = 4096
|
||||
private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
}
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val lock = Object()
|
||||
|
||||
@Volatile private var closed = false
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var writeChar: BluetoothGattCharacteristic? = null
|
||||
private var ready = false
|
||||
private var writeInFlight = false
|
||||
private var mtu = 23
|
||||
private val txQueue = ArrayDeque<ByteArray>()
|
||||
|
||||
fun connect() {
|
||||
if (closed) return
|
||||
onEvent("Connecting to ${device.address}")
|
||||
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
closed = true
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
synchronized(lock) {
|
||||
ready = false
|
||||
writeInFlight = false
|
||||
txQueue.clear()
|
||||
}
|
||||
gatt?.close()
|
||||
gatt = null
|
||||
}
|
||||
|
||||
/** Queue payload for transmission, split into MTU-sized chunks. */
|
||||
fun send(data: ByteArray) {
|
||||
val payload = maxPayload()
|
||||
synchronized(lock) {
|
||||
if (!ready) return
|
||||
var off = 0
|
||||
while (off < data.size) {
|
||||
if (txQueue.size >= MAX_QUEUED_CHUNKS) {
|
||||
txQueue.removeFirst()
|
||||
}
|
||||
val end = minOf(off + payload, data.size)
|
||||
txQueue.addLast(data.copyOfRange(off, end))
|
||||
off = end
|
||||
}
|
||||
}
|
||||
kickWrites()
|
||||
}
|
||||
|
||||
private fun maxPayload() = mtu - 3
|
||||
|
||||
private fun kickWrites() {
|
||||
val g = gatt ?: return
|
||||
val ch: BluetoothGattCharacteristic
|
||||
val chunk: ByteArray
|
||||
synchronized(lock) {
|
||||
if (!ready || writeInFlight) return
|
||||
ch = writeChar ?: return
|
||||
chunk = txQueue.removeFirstOrNull() ?: return
|
||||
writeInFlight = true
|
||||
}
|
||||
val ok = if (Build.VERSION.SDK_INT >= 33) {
|
||||
g.writeCharacteristic(ch, chunk, ch.writeType) == BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
run {
|
||||
ch.value = chunk
|
||||
g.writeCharacteristic(ch)
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
synchronized(lock) { writeInFlight = false }
|
||||
Log.w(TAG, "writeCharacteristic failed to start")
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (closed) return
|
||||
gatt?.close()
|
||||
gatt = null
|
||||
synchronized(lock) {
|
||||
ready = false
|
||||
writeInFlight = false
|
||||
txQueue.clear()
|
||||
}
|
||||
onConnected(false)
|
||||
onEvent("Disconnected; retrying in ${RECONNECT_DELAY_MS / 1000}s")
|
||||
handler.postDelayed({ connect() }, RECONNECT_DELAY_MS)
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||
if (closed) return
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
onEvent("Connected; negotiating MTU")
|
||||
if (!g.requestMtu(517)) {
|
||||
g.discoverServices()
|
||||
}
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMtuChanged(g: BluetoothGatt, newMtu: Int, status: Int) {
|
||||
if (closed) return
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
mtu = newMtu
|
||||
}
|
||||
g.discoverServices()
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
if (closed) return
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
onEvent("Service discovery failed (status $status)")
|
||||
g.disconnect()
|
||||
return
|
||||
}
|
||||
val svc = g.getService(serviceUuid)
|
||||
if (svc == null) {
|
||||
onEvent("Service $serviceUuid not found on device")
|
||||
g.disconnect()
|
||||
return
|
||||
}
|
||||
val wc = svc.getCharacteristic(writeUuid)
|
||||
val nc = svc.getCharacteristic(notifyUuid)
|
||||
if (wc == null || nc == null) {
|
||||
onEvent("UART characteristics not found")
|
||||
g.disconnect()
|
||||
return
|
||||
}
|
||||
wc.writeType =
|
||||
if (wc.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) {
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
} else {
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
}
|
||||
synchronized(lock) { writeChar = wc }
|
||||
|
||||
g.setCharacteristicNotification(nc, true)
|
||||
val cccd = nc.getDescriptor(CCCD_UUID)
|
||||
if (cccd == null) {
|
||||
onEvent("Notify characteristic has no CCCD")
|
||||
g.disconnect()
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
g.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
run {
|
||||
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
g.writeDescriptor(cccd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, d: BluetoothGattDescriptor, status: Int) {
|
||||
if (closed) return
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
onEvent("Failed to enable notifications (status $status)")
|
||||
g.disconnect()
|
||||
return
|
||||
}
|
||||
synchronized(lock) { ready = true }
|
||||
onConnected(true)
|
||||
onEvent("Link ready (MTU $mtu)")
|
||||
kickWrites()
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
g: BluetoothGatt,
|
||||
ch: BluetoothGattCharacteristic,
|
||||
status: Int,
|
||||
) {
|
||||
synchronized(lock) { writeInFlight = false }
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.w(TAG, "write failed: status $status")
|
||||
}
|
||||
kickWrites()
|
||||
}
|
||||
|
||||
// API 33+ delivers notification payloads here.
|
||||
override fun onCharacteristicChanged(
|
||||
g: BluetoothGatt,
|
||||
ch: BluetoothGattCharacteristic,
|
||||
value: ByteArray,
|
||||
) {
|
||||
if (ch.uuid == notifyUuid) onData(value)
|
||||
}
|
||||
|
||||
// Pre-33 path; not invoked on API 33+.
|
||||
@Deprecated("Deprecated in API 33")
|
||||
@Suppress("OVERRIDE_DEPRECATION")
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) {
|
||||
if (Build.VERSION.SDK_INT >= 33) return
|
||||
@Suppress("DEPRECATION")
|
||||
val value = ch.value ?: return
|
||||
if (ch.uuid == notifyUuid) onData(value.copyOf())
|
||||
}
|
||||
}
|
||||
}
|
||||
228
app/src/main/java/dev/wntrmute/bleserial/BridgeService.kt
Normal file
228
app/src/main/java/dev/wntrmute/bleserial/BridgeService.kt
Normal file
@@ -0,0 +1,228 @@
|
||||
package dev.wntrmute.bleserial
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Foreground service that owns one BLE link and one TCP server and pipes
|
||||
* bytes between them.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class BridgeService : Service() {
|
||||
companion object {
|
||||
const val EXTRA_ADDRESS = "address"
|
||||
const val EXTRA_NAME = "name"
|
||||
const val ACTION_STOP = "dev.wntrmute.bleserial.STOP"
|
||||
private const val TAG = "BridgeService"
|
||||
private const val CHANNEL_ID = "bridge"
|
||||
private const val NOTIF_ID = 1
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var bleLink: BleLink? = null
|
||||
private var tcpServer: TcpServer? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
// Refresh the notification when connection state changes (counters
|
||||
// are excluded so per-packet traffic doesn't spam the notification).
|
||||
scope.launch {
|
||||
BridgeState.status
|
||||
.map { Triple(it.bleConnected, it.clientAddr, it.deviceName) }
|
||||
.distinctUntilChanged()
|
||||
.collect { if (BridgeState.status.value.running) updateNotification() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stopBridge()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val address = intent?.getStringExtra(EXTRA_ADDRESS)
|
||||
if (address == null) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
val name = intent.getStringExtra(EXTRA_NAME)
|
||||
|
||||
ServiceCompat.startForeground(
|
||||
this, NOTIF_ID, buildNotification(),
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
|
||||
stopBridge()
|
||||
startBridge(address, name)
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopBridge()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startBridge(address: String, name: String?) {
|
||||
val prefs = Prefs(this)
|
||||
val adapter = getSystemService(BluetoothManager::class.java).adapter
|
||||
if (adapter == null || !adapter.isEnabled) {
|
||||
fail("Bluetooth is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
val serviceUuid: UUID
|
||||
val writeUuid: UUID
|
||||
val notifyUuid: UUID
|
||||
try {
|
||||
serviceUuid = UUID.fromString(prefs.serviceUuid)
|
||||
writeUuid = UUID.fromString(prefs.writeUuid)
|
||||
notifyUuid = UUID.fromString(prefs.notifyUuid)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
fail("Invalid UUID in settings: ${e.message}")
|
||||
return
|
||||
}
|
||||
|
||||
val device = try {
|
||||
adapter.getRemoteDevice(address)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
fail("Invalid device address $address")
|
||||
return
|
||||
}
|
||||
|
||||
BridgeState.update {
|
||||
BridgeStatus(
|
||||
running = true,
|
||||
deviceName = name,
|
||||
deviceAddress = address,
|
||||
message = "Starting",
|
||||
)
|
||||
}
|
||||
|
||||
val server = TcpServer(
|
||||
bindAddr = prefs.bindAddr,
|
||||
port = prefs.port,
|
||||
onData = { data ->
|
||||
bleLink?.send(data)
|
||||
BridgeState.update { it.copy(bytesToBle = it.bytesToBle + data.size) }
|
||||
},
|
||||
onClient = { peer -> BridgeState.update { it.copy(clientAddr = peer) } },
|
||||
onEvent = { msg -> setMessage(msg) },
|
||||
)
|
||||
val listenAddr = try {
|
||||
server.start()
|
||||
} catch (e: IOException) {
|
||||
fail("TCP bind failed: ${e.message}")
|
||||
return
|
||||
}
|
||||
tcpServer = server
|
||||
BridgeState.update { it.copy(listenAddr = listenAddr) }
|
||||
|
||||
val link = BleLink(
|
||||
context = this,
|
||||
device = device,
|
||||
serviceUuid = serviceUuid,
|
||||
writeUuid = writeUuid,
|
||||
notifyUuid = notifyUuid,
|
||||
onData = { data ->
|
||||
tcpServer?.send(data)
|
||||
BridgeState.update { it.copy(bytesToTcp = it.bytesToTcp + data.size) }
|
||||
},
|
||||
onConnected = { up -> BridgeState.update { it.copy(bleConnected = up) } },
|
||||
onEvent = { msg -> setMessage(msg) },
|
||||
)
|
||||
bleLink = link
|
||||
link.connect()
|
||||
}
|
||||
|
||||
private fun stopBridge() {
|
||||
bleLink?.close()
|
||||
bleLink = null
|
||||
tcpServer?.stop()
|
||||
tcpServer = null
|
||||
BridgeState.reset()
|
||||
}
|
||||
|
||||
private fun fail(msg: String) {
|
||||
Log.w(TAG, msg)
|
||||
setMessage(msg)
|
||||
stopBridge()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun setMessage(msg: String) {
|
||||
Log.i(TAG, msg)
|
||||
BridgeState.update { it.copy(message = msg) }
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.notif_channel),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNotification(): android.app.Notification {
|
||||
val s = BridgeState.status.value
|
||||
val text = buildString {
|
||||
append(s.deviceName ?: s.deviceAddress ?: "—")
|
||||
append(if (s.bleConnected) " · BLE up" else " · BLE down")
|
||||
append(s.clientAddr?.let { " · client $it" } ?: " · no client")
|
||||
}
|
||||
val stopIntent = PendingIntent.getService(
|
||||
this, 0,
|
||||
Intent(this, BridgeService::class.java).setAction(ACTION_STOP),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val openIntent = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notif)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(text)
|
||||
.setContentIntent(openIntent)
|
||||
.setOngoing(true)
|
||||
.addAction(0, getString(R.string.stop), stopIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification() {
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.notify(NOTIF_ID, buildNotification())
|
||||
}
|
||||
}
|
||||
31
app/src/main/java/dev/wntrmute/bleserial/BridgeState.kt
Normal file
31
app/src/main/java/dev/wntrmute/bleserial/BridgeState.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package dev.wntrmute.bleserial
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
data class BridgeStatus(
|
||||
val running: Boolean = false,
|
||||
val deviceName: String? = null,
|
||||
val deviceAddress: String? = null,
|
||||
val bleConnected: Boolean = false,
|
||||
val listenAddr: String? = null,
|
||||
val clientAddr: String? = null,
|
||||
val bytesToTcp: Long = 0,
|
||||
val bytesToBle: Long = 0,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Single source of truth for bridge status, shared between the foreground
|
||||
* service (writer) and the activity (reader). Survives activity recreation.
|
||||
*/
|
||||
object BridgeState {
|
||||
private val mutable = MutableStateFlow(BridgeStatus())
|
||||
val status: StateFlow<BridgeStatus> = mutable.asStateFlow()
|
||||
|
||||
fun update(transform: (BridgeStatus) -> BridgeStatus) = mutable.update(transform)
|
||||
|
||||
fun reset() = mutable.update { BridgeStatus(message = it.message) }
|
||||
}
|
||||
208
app/src/main/java/dev/wntrmute/bleserial/MainActivity.kt
Normal file
208
app/src/main/java/dev/wntrmute/bleserial/MainActivity.kt
Normal file
@@ -0,0 +1,208 @@
|
||||
package dev.wntrmute.bleserial
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Button
|
||||
import android.widget.CheckBox
|
||||
import android.widget.EditText
|
||||
import android.widget.ListView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private lateinit var prefs: Prefs
|
||||
private lateinit var statusView: TextView
|
||||
private lateinit var scanButton: Button
|
||||
private lateinit var stopButton: Button
|
||||
private lateinit var portEdit: EditText
|
||||
private lateinit var lanCheck: CheckBox
|
||||
private lateinit var serviceUuidEdit: EditText
|
||||
private lateinit var writeUuidEdit: EditText
|
||||
private lateinit var notifyUuidEdit: EditText
|
||||
private lateinit var deviceList: ListView
|
||||
private lateinit var adapter: ArrayAdapter<String>
|
||||
|
||||
private val devices = mutableListOf<ScanResult>()
|
||||
private var scanning = false
|
||||
|
||||
private val permissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
|
||||
if (grants.values.all { it }) {
|
||||
startScan()
|
||||
} else {
|
||||
Toast.makeText(this, R.string.perms_needed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
prefs = Prefs(this)
|
||||
|
||||
statusView = findViewById(R.id.status)
|
||||
scanButton = findViewById(R.id.scan)
|
||||
stopButton = findViewById(R.id.stop)
|
||||
portEdit = findViewById(R.id.port)
|
||||
lanCheck = findViewById(R.id.allow_lan)
|
||||
serviceUuidEdit = findViewById(R.id.service_uuid)
|
||||
writeUuidEdit = findViewById(R.id.write_uuid)
|
||||
notifyUuidEdit = findViewById(R.id.notify_uuid)
|
||||
deviceList = findViewById(R.id.devices)
|
||||
|
||||
portEdit.setText(prefs.port.toString())
|
||||
lanCheck.isChecked = prefs.allowLan
|
||||
serviceUuidEdit.setText(prefs.serviceUuid)
|
||||
writeUuidEdit.setText(prefs.writeUuid)
|
||||
notifyUuidEdit.setText(prefs.notifyUuid)
|
||||
|
||||
adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1)
|
||||
deviceList.adapter = adapter
|
||||
deviceList.setOnItemClickListener { _, _, pos, _ ->
|
||||
stopScan()
|
||||
startBridge(devices[pos])
|
||||
}
|
||||
|
||||
scanButton.setOnClickListener {
|
||||
if (scanning) stopScan() else requestPermissionsAndScan()
|
||||
}
|
||||
stopButton.setOnClickListener {
|
||||
startService(
|
||||
Intent(this, BridgeService::class.java).setAction(BridgeService.ACTION_STOP),
|
||||
)
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
BridgeState.status.collect { render(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
stopScan()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
private fun render(s: BridgeStatus) {
|
||||
statusView.text = if (!s.running) {
|
||||
getString(R.string.status_idle, s.message ?: "")
|
||||
} else {
|
||||
getString(
|
||||
R.string.status_running,
|
||||
s.deviceName ?: s.deviceAddress ?: "?",
|
||||
if (s.bleConnected) "up" else "down",
|
||||
s.listenAddr ?: "?",
|
||||
s.clientAddr ?: "none",
|
||||
s.bytesToTcp,
|
||||
s.bytesToBle,
|
||||
s.message ?: "",
|
||||
)
|
||||
}
|
||||
stopButton.isEnabled = s.running
|
||||
}
|
||||
|
||||
private fun requiredPermissions(): Array<String> =
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
buildList {
|
||||
add(Manifest.permission.BLUETOOTH_SCAN)
|
||||
add(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}.toTypedArray()
|
||||
} else {
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
|
||||
private fun requestPermissionsAndScan() {
|
||||
val missing = requiredPermissions().filter {
|
||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing.isEmpty()) startScan() else permissionLauncher.launch(missing.toTypedArray())
|
||||
}
|
||||
|
||||
private fun startScan() {
|
||||
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
|
||||
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled) {
|
||||
Toast.makeText(this, R.string.bt_disabled, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
val scanner = bluetoothAdapter.bluetoothLeScanner ?: return
|
||||
devices.clear()
|
||||
adapter.clear()
|
||||
scanning = true
|
||||
scanButton.setText(R.string.stop_scan)
|
||||
scanner.startScan(
|
||||
null,
|
||||
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
|
||||
scanCallback,
|
||||
)
|
||||
}
|
||||
|
||||
private fun stopScan() {
|
||||
if (!scanning) return
|
||||
scanning = false
|
||||
scanButton.setText(R.string.scan)
|
||||
getSystemService(BluetoothManager::class.java)
|
||||
.adapter?.bluetoothLeScanner?.stopScan(scanCallback)
|
||||
}
|
||||
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val idx = devices.indexOfFirst { it.device.address == result.device.address }
|
||||
if (idx >= 0) {
|
||||
devices[idx] = result
|
||||
} else {
|
||||
devices.add(result)
|
||||
adapter.add(label(result))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
scanning = false
|
||||
scanButton.setText(R.string.scan)
|
||||
Toast.makeText(this@MainActivity, "Scan failed: $errorCode", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun label(r: ScanResult): String {
|
||||
val name = r.scanRecord?.deviceName ?: r.device.name ?: "(unnamed)"
|
||||
return "$name\n${r.device.address} ${r.rssi} dBm"
|
||||
}
|
||||
|
||||
private fun startBridge(result: ScanResult) {
|
||||
val port = portEdit.text.toString().toIntOrNull()
|
||||
if (port == null || port !in 1..65535) {
|
||||
Toast.makeText(this, R.string.bad_port, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
prefs.port = port
|
||||
prefs.allowLan = lanCheck.isChecked
|
||||
prefs.serviceUuid = serviceUuidEdit.text.toString().trim()
|
||||
prefs.writeUuid = writeUuidEdit.text.toString().trim()
|
||||
prefs.notifyUuid = notifyUuidEdit.text.toString().trim()
|
||||
|
||||
val intent = Intent(this, BridgeService::class.java)
|
||||
.putExtra(BridgeService.EXTRA_ADDRESS, result.device.address)
|
||||
.putExtra(
|
||||
BridgeService.EXTRA_NAME,
|
||||
result.scanRecord?.deviceName ?: result.device.name,
|
||||
)
|
||||
ContextCompat.startForegroundService(this, intent)
|
||||
}
|
||||
}
|
||||
39
app/src/main/java/dev/wntrmute/bleserial/Prefs.kt
Normal file
39
app/src/main/java/dev/wntrmute/bleserial/Prefs.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
package dev.wntrmute.bleserial
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
|
||||
/** Nordic UART Service — also used by RNode firmware for BLE serial. */
|
||||
object Defaults {
|
||||
const val SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
|
||||
const val WRITE_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
|
||||
const val NOTIFY_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
|
||||
const val PORT = 7777
|
||||
}
|
||||
|
||||
class Prefs(context: Context) {
|
||||
private val sp = context.getSharedPreferences("ble-serial", Context.MODE_PRIVATE)
|
||||
|
||||
var port: Int
|
||||
get() = sp.getInt("port", Defaults.PORT)
|
||||
set(v) = sp.edit { putInt("port", v) }
|
||||
|
||||
var allowLan: Boolean
|
||||
get() = sp.getBoolean("allow_lan", false)
|
||||
set(v) = sp.edit { putBoolean("allow_lan", v) }
|
||||
|
||||
var serviceUuid: String
|
||||
get() = sp.getString("service_uuid", Defaults.SERVICE_UUID)!!
|
||||
set(v) = sp.edit { putString("service_uuid", v) }
|
||||
|
||||
var writeUuid: String
|
||||
get() = sp.getString("write_uuid", Defaults.WRITE_UUID)!!
|
||||
set(v) = sp.edit { putString("write_uuid", v) }
|
||||
|
||||
var notifyUuid: String
|
||||
get() = sp.getString("notify_uuid", Defaults.NOTIFY_UUID)!!
|
||||
set(v) = sp.edit { putString("notify_uuid", v) }
|
||||
|
||||
val bindAddr: String
|
||||
get() = if (allowLan) "0.0.0.0" else "127.0.0.1"
|
||||
}
|
||||
107
app/src/main/java/dev/wntrmute/bleserial/TcpServer.kt
Normal file
107
app/src/main/java/dev/wntrmute/bleserial/TcpServer.kt
Normal file
@@ -0,0 +1,107 @@
|
||||
package dev.wntrmute.bleserial
|
||||
|
||||
import android.util.Log
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Single-client TCP server. Bytes from the client go to [onData]; bytes from
|
||||
* the BLE side are pushed in via [send]. A new connection displaces the
|
||||
* current client (last-wins), which lets socat reconnect cleanly.
|
||||
*/
|
||||
class TcpServer(
|
||||
private val bindAddr: String,
|
||||
private val port: Int,
|
||||
private val onData: (ByteArray) -> Unit,
|
||||
private val onClient: (String?) -> Unit,
|
||||
private val onEvent: (String) -> Unit,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "TcpServer"
|
||||
}
|
||||
|
||||
@Volatile private var closed = false
|
||||
@Volatile private var serverSocket: ServerSocket? = null
|
||||
@Volatile private var client: Socket? = null
|
||||
@Volatile private var clientOut: OutputStream? = null
|
||||
private var acceptThread: Thread? = null
|
||||
|
||||
/** Returns the actual listen address, or throws if the bind fails. */
|
||||
@Throws(IOException::class)
|
||||
fun start(): String {
|
||||
val ss = ServerSocket(port, 1, InetAddress.getByName(bindAddr))
|
||||
serverSocket = ss
|
||||
acceptThread = thread(name = "tcp-accept") { acceptLoop(ss) }
|
||||
return "$bindAddr:${ss.localPort}"
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
closed = true
|
||||
runCatching { serverSocket?.close() }
|
||||
dropClient()
|
||||
}
|
||||
|
||||
fun send(data: ByteArray): Boolean {
|
||||
val out = clientOut ?: return false
|
||||
return try {
|
||||
out.write(data)
|
||||
out.flush()
|
||||
true
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "client write failed: ${e.message}")
|
||||
dropClient()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun acceptLoop(ss: ServerSocket) {
|
||||
while (!closed) {
|
||||
val sock = try {
|
||||
ss.accept()
|
||||
} catch (e: IOException) {
|
||||
if (!closed) Log.w(TAG, "accept failed: ${e.message}")
|
||||
return
|
||||
}
|
||||
sock.tcpNoDelay = true
|
||||
dropClient()
|
||||
client = sock
|
||||
clientOut = sock.getOutputStream()
|
||||
val peer = "${sock.inetAddress.hostAddress}:${sock.port}"
|
||||
onClient(peer)
|
||||
onEvent("Client connected: $peer")
|
||||
thread(name = "tcp-read") { readLoop(sock) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun readLoop(sock: Socket) {
|
||||
val buf = ByteArray(4096)
|
||||
try {
|
||||
val input = sock.getInputStream()
|
||||
while (!closed) {
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
if (n > 0) onData(buf.copyOf(n))
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
// Fall through to cleanup.
|
||||
}
|
||||
if (client === sock) {
|
||||
dropClient()
|
||||
if (!closed) onEvent("Client disconnected")
|
||||
} else {
|
||||
runCatching { sock.close() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropClient() {
|
||||
val c = client ?: return
|
||||
client = null
|
||||
clientOut = null
|
||||
runCatching { c.close() }
|
||||
onClient(null)
|
||||
}
|
||||
}
|
||||
13
app/src/main/res/drawable/ic_launcher.xml
Normal file
13
app/src/main/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF1A73E8"
|
||||
android:pathData="M0,0h24v24h-24z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M14.88,16.29L13,18.17V14.41M13,5.83L14.88,7.71L13,9.58M17.71,7.71L12,2H11V9.58L6.41,5L5,6.41L10.59,12L5,17.58L6.41,19L11,14.41V22H12L17.71,16.29L13.41,12L17.71,7.71Z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_notif.xml
Normal file
10
app/src/main/res/drawable/ic_notif.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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="#FFFFFFFF"
|
||||
android:pathData="M14.88,16.29L13,18.17V14.41M13,5.83L14.88,7.71L13,9.58M17.71,7.71L12,2H11V9.58L6.41,5L5,6.41L10.59,12L5,17.58L6.41,19L11,14.41V22H12L17.71,16.29L13.41,12L17.71,7.71Z" />
|
||||
</vector>
|
||||
97
app/src/main/res/layout/activity_main.xml
Normal file
97
app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,97 @@
|
||||
<?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"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="13sp"
|
||||
android:text="@string/status_placeholder" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/port"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/port_hint"
|
||||
android:inputType="number"
|
||||
android:importantForAutofill="no" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/allow_lan"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/allow_lan" />
|
||||
</LinearLayout>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/service_uuid"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/service_uuid_hint"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:importantForAutofill="no" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/write_uuid"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/write_uuid_hint"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:importantForAutofill="no" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/notify_uuid"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/notify_uuid_hint"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:importantForAutofill="no" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/scan"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/scan" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/stop"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:enabled="false"
|
||||
android:text="@string/stop_bridge" />
|
||||
</LinearLayout>
|
||||
|
||||
<ListView
|
||||
android:id="@+id/devices"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
20
app/src/main/res/values/strings.xml
Normal file
20
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">BLE Serial Bridge</string>
|
||||
<string name="notif_channel">Bridge status</string>
|
||||
<string name="scan">Scan</string>
|
||||
<string name="stop_scan">Stop scan</string>
|
||||
<string name="stop">Stop</string>
|
||||
<string name="stop_bridge">Stop bridge</string>
|
||||
<string name="port_hint">TCP port</string>
|
||||
<string name="allow_lan">Allow LAN</string>
|
||||
<string name="service_uuid_hint">Service UUID</string>
|
||||
<string name="write_uuid_hint">Write (TX to device) characteristic UUID</string>
|
||||
<string name="notify_uuid_hint">Notify (RX from device) characteristic UUID</string>
|
||||
<string name="perms_needed">Bluetooth permissions are required to scan and connect.</string>
|
||||
<string name="bt_disabled">Bluetooth is disabled. Enable it and try again.</string>
|
||||
<string name="bad_port">Port must be between 1 and 65535.</string>
|
||||
<string name="status_placeholder">Idle.</string>
|
||||
<string name="status_idle">Idle. %1$s</string>
|
||||
<string name="status_running">Device: %1$s\nBLE: %2$s\nListening: %3$s\nClient: %4$s\nBLE→TCP: %5$d B TCP→BLE: %6$d B\n%7$s</string>
|
||||
</resources>
|
||||
4
app/src/main/res/values/themes.xml
Normal file
4
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.BleSerial" parent="Theme.Material3.DayNight" />
|
||||
</resources>
|
||||
4
build.gradle.kts
Normal file
4
build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.13.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
4
gradle.properties
Normal file
4
gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
18
settings.gradle.kts
Normal file
18
settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "ble-serial"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user