refactor(ble): Centralize BLE logic into a core module (#4550)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-20 06:41:52 -06:00 committed by GitHub
parent 7a68802bc2
commit 6bfa5b5f70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
214 changed files with 3471 additions and 2405 deletions

View file

@ -26,17 +26,17 @@ import android.nfc.NdefMessage
import android.nfc.NfcAdapter
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.ReportDrawnWhen
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalView
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -46,7 +46,8 @@ import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.MainScreen
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.meshtastic.core.datastore.UiPreferencesDataSource
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.compose.LocalEnvironmentOwner
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.strings.Res
@ -58,59 +59,64 @@ import org.meshtastic.feature.intro.AppIntroductionScreen
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModels()
// This is aware of the Activity lifecycle and handles binding to the mesh service.
/**
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
* itself as a LifecycleObserver in its init block.
*/
@Inject internal lateinit var meshServiceClient: MeshServiceClient
@Inject internal lateinit var uiPreferencesDataSource: UiPreferencesDataSource
@Inject internal lateinit var androidEnvironment: AndroidEnvironment
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge(
// Disable three-button navbar scrim on pre-Q devices
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Disable three-button navbar scrim
window.setNavigationBarContrastEnforced(false)
}
super.onCreate(savedInstanceState)
setContent {
val theme by model.theme.collectAsState()
val theme by model.theme.collectAsStateWithLifecycle()
val dynamic = theme == MODE_DYNAMIC
val dark =
when (theme) {
AppCompatDelegate.MODE_NIGHT_YES -> true
AppCompatDelegate.MODE_NIGHT_NO -> false
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme()
else -> isSystemInDarkTheme()
}
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect { AppCompatDelegate.setDefaultNightMode(theme) }
}
// Apply modern edge-to-edge drawing with theme-aware system bars
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
)
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
if (appIntroCompleted) {
MainScreen(uIViewModel = model)
} else {
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() })
// Ensure the navigation bar remains seamless on modern Android versions
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
@Suppress("SpreadOperator")
CompositionLocalProvider(*(LocalEnvironmentOwner provides androidEnvironment)) {
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
// Signal to the system that the initial UI is "fully drawn"
// once we've decided whether to show the intro or the main screen.
ReportDrawnWhen { true }
if (appIntroCompleted) {
MainScreen(uIViewModel = model)
} else {
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() })
}
}
}
}
if (savedInstanceState == null) {
handleIntent(intent)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Listen for new intents (e.g. deep links, NFC) without overriding onNewIntent
addOnNewIntentListener { intent -> handleIntent(intent) }
handleIntent(intent)
}
@ -125,7 +131,12 @@ class MainActivity : AppCompatActivity() {
}
NfcAdapter.ACTION_NDEF_DISCOVERED -> {
val rawMessages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
val rawMessages =
IntentCompat.getParcelableArrayExtra(
intent,
NfcAdapter.EXTRA_NDEF_MESSAGES,
NdefMessage::class.java,
)
if (rawMessages != null) {
for (rawMsg in rawMessages) {
val msg = rawMsg as NdefMessage

View file

@ -17,21 +17,21 @@
package com.geeksville.mesh
import android.content.Context
import androidx.appcompat.app.AppCompatActivity.BIND_ABOVE_CLIENT
import androidx.appcompat.app.AppCompatActivity.BIND_AUTO_CREATE
import android.content.Context.BIND_ABOVE_CLIENT
import android.content.Context.BIND_AUTO_CREATE
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
import com.geeksville.mesh.android.BindFailedException
import com.geeksville.mesh.android.ServiceClient
import com.geeksville.mesh.concurrent.SequentialJob
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.startService
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.SequentialJob
import org.meshtastic.core.service.BindFailedException
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceClient
import org.meshtastic.core.service.ServiceRepository
import javax.inject.Inject
@ -84,6 +84,12 @@ constructor(
}
}
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
Logger.d { "Lifecycle: ON_STOP" }
close()
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
Logger.d { "Lifecycle: ON_DESTROY" }
@ -103,6 +109,6 @@ constructor(
Logger.e { "Failed to start service from activity - but ignoring because bind will work: ${ex.message}" }
}
connect(context, MeshService.createIntent(context), BIND_AUTO_CREATE + BIND_ABOVE_CLIENT)
connect(context, MeshService.createIntent(context), BIND_AUTO_CREATE or BIND_ABOVE_CLIENT)
}
}

View file

@ -1,36 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.android
import android.os.Build
/** Created by kevinh on 1/14/16. */
object BuildUtils {
// Are we running on the emulator?
val isEmulator
get() =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.startsWith("unknown") ||
Build.FINGERPRINT.contains("emulator") ||
setOf(Build.MODEL, Build.PRODUCT).contains("google_sdk") ||
Build.MODEL.contains("sdk_gphone64") ||
Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for") ||
Build.MANUFACTURER.contains("Genymotion") ||
Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")
}

View file

@ -1,31 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.android
import java.util.*
/**
* Created by kevinh on 1/13/16.
*/
object DateUtils {
fun dateUTC(year: Int, month: Int, day: Int): Date {
val cal = GregorianCalendar(TimeZone.getTimeZone("GMT"))
cal.set(year, month, day, 0, 0, 0);
return Date(cal.getTime().getTime())
}
}

View file

@ -1,53 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.android
import android.content.Context
import java.io.File
import java.io.FileOutputStream
import java.io.PrintWriter
/**
* Create a debug log on the SD card (if needed and allowed and app is configured for debugging (FIXME)
*
* write strings to that file
*/
class DebugLogFile(context: Context, name: String) {
val stream = FileOutputStream(File(context.getExternalFilesDir(null), name), true)
val file = PrintWriter(stream)
fun close() {
file.close()
}
fun log(s: String) {
file.println(s) // FIXME, optionally include timestamps
file.flush() // for debugging
}
}
/**
* Create a debug log on the SD card (if needed and allowed and app is configured for debugging (FIXME)
*
* write strings to that file
*/
class BinaryLogFile(context: Context, name: String) :
FileOutputStream(File(context.getExternalFilesDir(null), name), true) {
}

View file

@ -1,127 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.android
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.os.IInterface
import co.touchlab.kermit.Logger
import com.geeksville.mesh.util.exceptionReporter
import kotlinx.coroutines.delay
import java.io.Closeable
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
class BindFailedException : Exception("bindService failed")
/** A wrapper that cleans up the service binding process */
open class ServiceClient<T : IInterface>(private val stubFactory: (IBinder) -> T) : Closeable {
var serviceP: T? = null
// A getter that returns the bound service or throws if not bound
val service: T
get() {
waitConnect() // Wait for at least the initial connection to happen
return serviceP ?: throw Exception("Service not bound")
}
private var context: Context? = null
private var isClosed = true
private val lock = ReentrantLock()
private val condition = lock.newCondition()
/** Call this if you want to stall until the connection is completed */
fun waitConnect() {
// Wait until this service is connected
lock.withLock {
if (context == null) {
throw Exception("Haven't called connect")
}
if (serviceP == null) {
condition.await()
}
}
}
suspend fun connect(c: Context, intent: Intent, flags: Int) {
context = c
if (isClosed) {
isClosed = false
if (!c.bindService(intent, connection, flags)) {
// Some phones seem to ahve a race where if you unbind and quickly rebind bindService returns false.
// Try
// a short sleep to see if that helps
Logger.e { "Needed to use the second bind attempt hack" }
delay(500) // was 200ms, but received an autobug from a Galaxy Note4, android 6.0.1
if (!c.bindService(intent, connection, flags)) {
throw BindFailedException()
}
}
} else {
Logger.w { "Ignoring rebind attempt for service" }
}
}
override fun close() {
isClosed = true
try {
context?.unbindService(connection)
} catch (ex: IllegalArgumentException) {
// Autobugs show this can generate an illegal arg exception for "service not registered" during reinstall?
Logger.w { "Ignoring error in ServiceClient.close, probably harmless" }
}
serviceP = null
context = null
}
// Called when we become connected
open fun onConnected(service: T) {}
// called on loss of connection
open fun onDisconnected() {}
private val connection =
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) = exceptionReporter {
if (!isClosed) {
val s = stubFactory(binder)
serviceP = s
onConnected(s)
// after calling our handler, tell anyone who was waiting for this connection to complete
lock.withLock { condition.signalAll() }
} else {
// If we start to close a service, it seems that there is a possibility a onServiceConnected event
// is the queue
// for us. Be careful not to process that stale event
Logger.w { "A service connected while we were closing it, ignoring" }
}
}
override fun onServiceDisconnected(name: ComponentName?) = exceptionReporter {
serviceP = null
onDisconnected()
}
}
}

View file

@ -1,46 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.concurrent
import com.geeksville.mesh.util.Exceptions
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
private val errorHandler =
CoroutineExceptionHandler { _, exception ->
Exceptions.report(
exception,
"MeshService-coroutine",
"coroutine-exception"
)
}
/// Wrap launch with an exception handler, FIXME, move into a utility lib
fun CoroutineScope.handledLaunch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
) = this.launch(
context = context + com.geeksville.mesh.concurrent.errorHandler,
start = start,
block = block
)

View file

@ -1,43 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.concurrent
import co.touchlab.kermit.Logger
/**
* Sometimes when starting services we face situations where messages come in that require computation but we can't do
* that computation yet because we are still waiting for some long running init to complete.
*
* This class lets you queue up closures to run at a later date and later on you can call run() to run all the
* previously queued work.
*/
class DeferredExecution {
private val queue = mutableListOf<() -> Unit>()
// / Queue some new work
fun add(fn: () -> Unit) {
queue.add(fn)
}
// / run all work in the queue and clear it to be ready to accept new work
fun run() {
Logger.d { "Running deferred execution numjobs=${queue.size}" }
queue.forEach { it() }
queue.clear()
}
}

View file

@ -1,51 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.concurrent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
/**
* A helper class that manages a single [Job].
*
* When a new job is launched, the previous one is cancelled. This is useful for ensuring that only one operation of a
* certain type is running at a time.
*/
class SequentialJob @Inject constructor() {
private val job = AtomicReference<Job?>(null)
/**
* Cancels the previous job (if any) and launches a new one in the given [scope].
*
* The new job uses [handledLaunch] to ensure exceptions are reported.
*/
fun launch(scope: CoroutineScope, block: suspend CoroutineScope.() -> Unit) {
cancel()
val newJob = scope.handledLaunch(block = block)
job.set(newJob)
newJob.invokeOnCompletion { job.compareAndSet(newJob, null) }
}
/** Cancels the current job. */
fun cancel() {
job.getAndSet(null)?.cancel()
}
}

View file

@ -1,109 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.concurrent
import org.meshtastic.core.model.util.nowMillis
/** A deferred execution object (with various possible implementations) */
interface Continuation<in T> {
abstract fun resume(res: Result<T>)
// syntactic sugar
fun resumeSuccess(res: T) = resume(Result.success(res))
fun resumeWithException(ex: Throwable) = try {
resume(Result.failure(ex))
} catch (ex: Throwable) {
// Logger.e { "Ignoring $ex while resuming because we are the ones who threw it" }
throw ex
}
}
/** An async continuation that just calls a callback when the result is available */
class CallbackContinuation<in T>(private val cb: (Result<T>) -> Unit) : Continuation<T> {
override fun resume(res: Result<T>) = cb(res)
}
/**
* This is a blocking/threaded version of coroutine Continuation
*
* A little bit ugly, but the coroutine version has a nasty internal bug that showed up in my SyncBluetoothDevice so I
* needed a quick workaround.
*/
class SyncContinuation<T> : Continuation<T> {
private val lock = java.util.concurrent.locks.ReentrantLock()
private val condition = lock.newCondition()
private var result: Result<T>? = null
override fun resume(res: Result<T>) {
lock.lock()
try {
result = res
condition.signal()
} finally {
lock.unlock()
}
}
// Wait for the result (or throw an exception)
@Suppress("NestedBlockDepth")
fun await(timeoutMsecs: Long = 0): T {
lock.lock()
try {
val startT = nowMillis
while (result == null) {
if (timeoutMsecs > 0) {
val remaining = timeoutMsecs - (nowMillis - startT)
if (remaining <= 0) {
throw Exception("SyncContinuation timeout")
}
// await returns false if waiting time elapsed
condition.await(remaining, java.util.concurrent.TimeUnit.MILLISECONDS)
} else {
condition.await()
}
}
val r = result
if (r != null) {
return r.getOrThrow()
} else {
throw Exception("This shouldn't happen")
}
} finally {
lock.unlock()
}
}
}
/**
* Calls an init function which is responsible for saving our continuation so that some other thread can call resume or
* resume with exception.
*
* Essentially this is a blocking version of the (buggy) coroutine suspendCoroutine
*/
fun <T> suspend(timeoutMsecs: Long = -1, initfn: (SyncContinuation<T>) -> Unit): T {
val cont = SyncContinuation<T>()
// First call the init funct
initfn(cont)
// Now wait for the continuation to finish
return cont.await(timeoutMsecs)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.model
import android.hardware.usb.UsbManager
@ -23,6 +22,8 @@ import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.hoho.android.usbserial.driver.UsbSerialDriver
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.BondState
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.anonymize
/**
@ -33,34 +34,65 @@ import org.meshtastic.core.model.util.anonymize
* @param name The display name of the device.
* @param fullAddress The unique address of the device, prefixed with a type identifier.
* @param bonded Indicates whether the device is bonded (for BLE) or has permission (for USB).
* @param node The [Node] associated with this device, if found in the database.
*/
sealed class DeviceListEntry(open val name: String, open val fullAddress: String, open val bonded: Boolean) {
sealed class DeviceListEntry(
open val name: String,
open val fullAddress: String,
open val bonded: Boolean,
open val node: Node? = null,
) {
val address: String
get() = fullAddress.substring(1)
abstract fun copy(node: Node?): DeviceListEntry
override fun toString(): String =
"DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)"
"DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded, hasNode=${node != null})"
@Suppress("MissingPermission")
data class Ble(val peripheral: Peripheral) :
data class Ble(val peripheral: Peripheral, override val node: Node? = null) :
DeviceListEntry(
name = peripheral.name ?: "unnamed-${peripheral.address}",
fullAddress = "x${peripheral.address}",
bonded = peripheral.bondState.value == BondState.BONDED,
)
node = node,
) {
override fun copy(node: Node?): Ble = copy(peripheral = peripheral, node = node)
}
data class Usb(
private val radioInterfaceService: RadioInterfaceService,
private val usbManager: UsbManager,
val driver: UsbSerialDriver,
override val node: Node? = null,
) : DeviceListEntry(
name = driver.device.deviceName,
fullAddress = radioInterfaceService.toInterfaceAddress(InterfaceId.SERIAL, driver.device.deviceName),
bonded = usbManager.hasPermission(driver.device),
)
node = node,
) {
override fun copy(node: Node?): Usb =
copy(radioInterfaceService = radioInterfaceService, usbManager = usbManager, driver = driver, node = node)
}
data class Tcp(override val name: String, override val fullAddress: String) :
DeviceListEntry(name, fullAddress, true)
data class Tcp(override val name: String, override val fullAddress: String, override val node: Node? = null) :
DeviceListEntry(name, fullAddress, true, node) {
override fun copy(node: Node?): Tcp = copy(name = name, fullAddress = fullAddress, node = node)
}
data class Mock(override val name: String) : DeviceListEntry(name, "m", true)
data class Mock(override val name: String, override val node: Node? = null) :
DeviceListEntry(name, "m", true, node) {
override fun copy(node: Node?): Mock = copy(name = name, node = node)
}
}
/** Matches names like Meshtastic_1234. */
private val bleNameRegex = Regex(BLE_NAME_PATTERN)
/**
* Returns the short name of the device if it's a Meshtastic device, otherwise null.
*
* @return The short name (e.g., 1234) or null.
*/
fun Peripheral.getMeshtasticShortName(): String? = name?.let { bleNameRegex.find(it)?.groupValues?.get(1) }

View file

@ -16,12 +16,9 @@
*/
package com.geeksville.mesh.model
import android.app.Application
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import co.touchlab.kermit.Logger
@ -72,42 +69,11 @@ import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
// Given a human name, strip out the first letter of the first three words and return that as the
// initials for
// that user, ignoring emojis. If the original name is only one word, strip vowels from the original
// name and if the result is 3 or more characters, use the first three characters. If not, just take
// the first 3 characters of the original name.
fun getInitials(fullName: String): String {
val maxInitialLength = 4
val minWordCountForInitials = 2
val name = fullName.trim().withoutEmojis()
val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() }
val initials =
when (words.size) {
in 0 until minWordCountForInitials -> {
val nameWithoutVowels =
if (name.isNotEmpty()) {
name.first() + name.drop(1).filterNot { c -> c.lowercase() in "aeiou" }
} else {
""
}
if (nameWithoutVowels.length >= maxInitialLength) nameWithoutVowels else name
}
else -> words.map { it.first() }.joinToString("")
}
return initials.take(maxInitialLength)
}
private fun String.withoutEmojis(): String = filterNot { char -> char.isSurrogate() }
@Suppress("LongParameterList", "LargeClass", "UnusedPrivateProperty")
@HiltViewModel
@Suppress("LongParameterList", "TooManyFunctions")
class UIViewModel
@Inject
constructor(
private val app: Application,
private val nodeDB: NodeRepository,
private val serviceRepository: ServiceRepository,
radioInterfaceService: RadioInterfaceService,
@ -293,8 +259,7 @@ constructor(
serviceRepository.clearTracerouteResponse()
}
val neighborInfoResponse: LiveData<String?>
get() = serviceRepository.neighborInfoResponse.asLiveData()
val neighborInfoResponse: StateFlow<String?> = serviceRepository.neighborInfoResponse
fun clearNeighborInfoResponse() {
serviceRepository.clearNeighborInfoResponse()

View file

@ -1,55 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import com.geeksville.mesh.util.exceptionReporter
import javax.inject.Inject
/**
* A helper class to call onChanged when bluetooth is enabled or disabled
*/
class BluetoothBroadcastReceiver @Inject constructor(
private val bluetoothRepository: BluetoothRepository
) : BroadcastReceiver() {
internal val intentFilter get() = IntentFilter().apply {
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
}
override fun onReceive(context: Context, intent: Intent) = exceptionReporter {
if (intent.action == BluetoothAdapter.ACTION_STATE_CHANGED) {
when (intent.bluetoothAdapterState) {
// Simulate a disconnection if the user disables bluetooth entirely
BluetoothAdapter.STATE_OFF -> bluetoothRepository.refreshState()
BluetoothAdapter.STATE_ON -> bluetoothRepository.refreshState()
}
}
if (intent.action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
bluetoothRepository.refreshState()
}
}
private val Intent.bluetoothAdapterState: Int
get() = getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)
}

View file

@ -1,49 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.bluetooth
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.annotation.RequiresPermission
import com.geeksville.mesh.util.registerReceiverCompat
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
@RequiresPermission("android.permission.BLUETOOTH_CONNECT")
internal fun BluetoothDevice.createBond(context: Context): Flow<Int> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1)
trySend(state)
// we stay registered until bonding completes (either with BONDED or NONE)
if (state != BluetoothDevice.BOND_BONDING) {
close()
}
}
}
val filter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
context.registerReceiverCompat(receiver, filter)
createBond()
awaitClose { context.unregisterReceiver(receiver) }
}

View file

@ -1,181 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.bluetooth
import android.annotation.SuppressLint
import android.app.Application
import android.bluetooth.BluetoothAdapter
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import co.touchlab.kermit.Logger
import com.geeksville.mesh.repository.radio.BleConstants.BLE_NAME_PATTERN
import com.geeksville.mesh.repository.radio.BleConstants.BTM_SERVICE_UUID
import com.geeksville.mesh.util.registerReceiverCompat
import dagger.Lazy
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.client.distinctByPeripheral
import no.nordicsemi.kotlin.ble.core.Manager
import org.meshtastic.core.common.hasBluetoothPermission
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.toKotlinUuid
/** Repository responsible for maintaining and updating the state of Bluetooth availability. */
@Singleton
class BluetoothRepository
@Inject
constructor(
private val application: Application,
private val bluetoothBroadcastReceiverLazy: Lazy<BluetoothBroadcastReceiver>,
private val dispatchers: CoroutineDispatchers,
@ProcessLifecycle private val processLifecycle: Lifecycle,
private val centralManager: CentralManager,
) {
private val _state =
MutableStateFlow(
BluetoothState(
// Assume we have permission until we get our initial state update to prevent premature
// notifications to the user.
hasPermissions = true,
),
)
val state: StateFlow<BluetoothState> = _state.asStateFlow()
private val _scannedDevices = MutableStateFlow<List<Peripheral>>(emptyList())
val scannedDevices: StateFlow<List<Peripheral>> = _scannedDevices.asStateFlow()
private val _isScanning = MutableStateFlow(false)
val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
private var scanJob: Job? = null
init {
processLifecycle.coroutineScope.launch(dispatchers.default) {
updateBluetoothState()
bluetoothBroadcastReceiverLazy.get().let { receiver ->
application.registerReceiverCompat(receiver, receiver.intentFilter)
}
}
}
fun refreshState() {
processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() }
}
/** @return true for a valid Bluetooth address, false otherwise */
fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress)
/** Starts a BLE scan for Meshtastic devices. The results are published to the [scannedDevices] flow. */
@OptIn(ExperimentalUuidApi::class)
@SuppressLint("MissingPermission")
fun startScan() {
if (isScanning.value) return
scanJob?.cancel()
_scannedDevices.value = emptyList()
scanJob =
processLifecycle.coroutineScope.launch(dispatchers.default) {
centralManager
.scan(5.seconds) { ServiceUuid(BTM_SERVICE_UUID.toKotlinUuid()) }
.distinctByPeripheral()
.map { it.peripheral }
.onStart { _isScanning.value = true }
.onCompletion { _isScanning.value = false }
.catch { ex ->
Logger.w(ex) { "Bluetooth scan failed" }
_isScanning.value = false
}
.collect { peripheral ->
// Add or update the peripheral in our list
val currentList = _scannedDevices.value
_scannedDevices.value =
(currentList.filterNot { it.address == peripheral.address } + peripheral)
}
}
}
/** Stops the currently active BLE scan. */
fun stopScan() {
scanJob?.cancel()
scanJob = null
_isScanning.value = false
}
/**
* Initiates bonding with the given peripheral. This is a suspending function that completes when the bonding
* process is finished. After successful bonding, the repository's state is refreshed to include the new bonded
* device.
*
* @param peripheral The peripheral to bond with.
* @throws SecurityException if required Bluetooth permissions are not granted.
* @throws Exception if the bonding process fails.
*/
@SuppressLint("MissingPermission")
suspend fun bond(peripheral: Peripheral) {
peripheral.createBond()
refreshState()
}
@OptIn(ExperimentalUuidApi::class)
internal suspend fun updateBluetoothState() {
val hasPerms = application.hasBluetoothPermission()
val enabled = centralManager.state.value == Manager.State.POWERED_ON
val newState =
BluetoothState(
hasPermissions = hasPerms,
enabled = enabled,
bondedDevices = getBondedAppPeripherals(enabled),
)
_state.emit(newState)
Logger.d { "Detected our bluetooth access=$newState" }
}
@SuppressLint("MissingPermission")
private fun getBondedAppPeripherals(enabled: Boolean): List<Peripheral> =
if (enabled && application.hasBluetoothPermission()) {
centralManager.getBondedPeripherals().filter(::isMatchingPeripheral)
} else {
emptyList()
}
/** Checks if a peripheral is one of ours, either by its advertised name or by the services it provides. */
@OptIn(ExperimentalUuidApi::class)
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
val hasRequiredService =
peripheral.services(listOf(BTM_SERVICE_UUID.toKotlinUuid())).value?.isNotEmpty() ?: false
return nameMatches || hasRequiredService
}
}

View file

@ -1,44 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.bluetooth
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.native
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object BluetoothRepositoryModule {
@Provides
@Singleton
fun provideCentralManager(@ApplicationContext context: Context, coroutineScope: CoroutineScope): CentralManager =
CentralManager.native(context, coroutineScope)
@Provides
@Singleton
fun provideSingletonCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}

View file

@ -1,36 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.bluetooth
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import org.meshtastic.core.model.util.anonymize
/** A snapshot in time of the state of the bluetooth subsystem. */
data class BluetoothState(
/** Whether we have adequate permissions to query bluetooth state */
val hasPermissions: Boolean = false,
/** If we have adequate permissions and bluetooth is enabled */
val enabled: Boolean = false,
/** If enabled, a list of the currently bonded devices */
val bondedDevices: List<Peripheral> = emptyList(),
) {
override fun toString(): String =
"BluetoothState(hasPermissions=$hasPermissions, enabled=$enabled, bondedDevices=${bondedDevices.map {
it.anonymize
}})"
}

View file

@ -17,7 +17,6 @@
package com.geeksville.mesh.repository.network
import co.touchlab.kermit.Logger
import com.geeksville.mesh.util.ignoreException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
@ -31,6 +30,7 @@ import org.eclipse.paho.client.mqttv3.MqttCallbackExtended
import org.eclipse.paho.client.mqttv3.MqttConnectOptions
import org.eclipse.paho.client.mqttv3.MqttMessage
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.util.subscribeList

View file

@ -1,139 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.radio
import com.geeksville.mesh.service.RadioNotConnectedException
import no.nordicsemi.kotlin.ble.client.exception.BluetoothUnavailableException
import no.nordicsemi.kotlin.ble.client.exception.ConnectionFailedException
import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException
import no.nordicsemi.kotlin.ble.client.exception.ScanningException
import no.nordicsemi.kotlin.ble.client.exception.ValueDoesNotMatchException
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.exception.BluetoothException
import no.nordicsemi.kotlin.ble.core.exception.GattException
import no.nordicsemi.kotlin.ble.core.exception.ManagerClosedException
/**
* Represents specific BLE failures, modeled after the iOS implementation's AccessoryError. This allows for more
* granular error handling and intelligent reconnection strategies.
*/
sealed class BleError(val message: String, val shouldReconnect: Boolean) {
/**
* An error indicating that the peripheral was not found. This is a non-recoverable error and should not trigger a
* reconnect.
*/
data object PeripheralNotFound : BleError("Peripheral not found", shouldReconnect = false)
/**
* An error indicating a failure during the connection attempt. This may be recoverable, so a reconnect attempt is
* warranted.
*/
class ConnectionFailed(exception: Throwable) :
BleError("Connection failed: ${exception.message}", shouldReconnect = true)
/**
* An error indicating a failure during the service discovery process. This may be recoverable, so a reconnect
* attempt is warranted.
*/
class DiscoveryFailed(message: String) : BleError("Discovery failed: $message", shouldReconnect = true)
/**
* An error indicating a disconnection initiated by the peripheral. This may be recoverable, so a reconnect attempt
* is warranted.
*/
class Disconnected(reason: ConnectionState.Disconnected.Reason?) :
BleError("Disconnected: ${reason ?: "Unknown reason"}", shouldReconnect = true)
/**
* Wraps a generic GattException. The reconnection strategy depends on the nature of the Gatt error.
*
* @param exception The underlying GattException.
*/
class GattError(exception: GattException) : BleError("Gatt exception: ${exception.message}", shouldReconnect = true)
/**
* Wraps a generic BluetoothException. The reconnection strategy depends on the nature of the Bluetooth error.
*
* @param exception The underlying BluetoothException.
*/
class BluetoothError(exception: BluetoothException) :
BleError("Bluetooth exception: ${exception.message}", shouldReconnect = true)
/** The BLE manager was closed. This is a non-recoverable error. */
class ManagerClosed(exception: ManagerClosedException) :
BleError("Manager closed: ${exception.message}", shouldReconnect = false)
/** A BLE operation failed. This may be recoverable. */
class OperationFailed(exception: OperationFailedException) :
BleError("Operation failed: ${exception.message}", shouldReconnect = true)
/**
* An invalid attribute was used. This usually happens when the GATT handles become stale (e.g. after a service
* change or an unexpected disconnect). This is recoverable via a fresh connection and discovery.
*/
class InvalidAttribute(exception: InvalidAttributeException) :
BleError("Invalid attribute: ${exception.message}", shouldReconnect = true)
/** An error occurred while scanning for devices. This may be recoverable. */
class Scanning(exception: ScanningException) :
BleError("Scanning error: ${exception.message}", shouldReconnect = true)
/** Bluetooth is unavailable on the device. This is a non-recoverable error. */
class BluetoothUnavailable(exception: BluetoothUnavailableException) :
BleError("Bluetooth unavailable: ${exception.message}", shouldReconnect = false)
/** The peripheral is not connected. This may be recoverable. */
class PeripheralNotConnected(exception: PeripheralNotConnectedException) :
BleError("Peripheral not connected: ${exception.message}", shouldReconnect = true)
/** A value did not match what was expected. This may be recoverable. */
class ValueDoesNotMatch(exception: ValueDoesNotMatchException) :
BleError("Value does not match: ${exception.message}", shouldReconnect = true)
/** A generic error for other exceptions that may occur. */
class GenericError(exception: Throwable) :
BleError("An unexpected error occurred: ${exception.message}", shouldReconnect = true)
companion object {
fun from(exception: Throwable): BleError = when (exception) {
is GattException -> {
when (exception) {
is ConnectionFailedException -> ConnectionFailed(exception)
is PeripheralNotConnectedException -> PeripheralNotConnected(exception)
is OperationFailedException -> OperationFailed(exception)
is ValueDoesNotMatchException -> ValueDoesNotMatch(exception)
else -> GattError(exception)
}
}
is BluetoothException -> {
when (exception) {
is BluetoothUnavailableException -> BluetoothUnavailable(exception)
is InvalidAttributeException -> InvalidAttribute(exception)
is ScanningException -> Scanning(exception)
else -> BluetoothError(exception)
}
}
is RadioNotConnectedException -> PeripheralNotFound
is ManagerClosedException -> ManagerClosed(exception)
else -> GenericError(exception)
}
}
}

View file

@ -17,16 +17,16 @@
package com.geeksville.mesh.repository.radio
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.model.getInitials
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.delay
import okio.ByteString.Companion.encodeUtf8
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.model.util.getInitials
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data

View file

@ -18,15 +18,10 @@ package com.geeksville.mesh.repository.radio
import android.annotation.SuppressLint
import co.touchlab.kermit.Logger
import com.geeksville.mesh.repository.radio.BleConstants.BTM_FROMNUM_CHARACTER
import com.geeksville.mesh.repository.radio.BleConstants.BTM_FROMRADIO_CHARACTER
import com.geeksville.mesh.repository.radio.BleConstants.BTM_LOGRADIO_CHARACTER
import com.geeksville.mesh.repository.radio.BleConstants.BTM_SERVICE_UUID
import com.geeksville.mesh.repository.radio.BleConstants.BTM_TORADIO_CHARACTER
import com.geeksville.mesh.service.RadioNotConnectedException
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
@ -36,26 +31,38 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType
import org.meshtastic.core.model.util.nowMillis
import java.util.UUID
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.toKotlinUuid
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
import org.meshtastic.core.ble.retryBleOperation
import org.meshtastic.core.common.util.nowMillis
import kotlin.time.Duration.Companion.seconds
private const val SCAN_RETRY_COUNT = 3
private const val SCAN_RETRY_DELAY_MS = 1000L
private const val CONNECTION_TIMEOUT_MS = 15_000L
private val SCAN_TIMEOUT = 5.seconds
/**
* A [IRadioInterface] implementation for BLE devices using Nordic Kotlin BLE Library.
@ -82,7 +89,7 @@ constructor(
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
serviceScope.launch {
try {
peripheral?.disconnect()
bleConnection.disconnect()
} catch (e: Exception) {
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
}
@ -90,11 +97,12 @@ constructor(
service.onDisconnect(BleError.from(throwable))
}
private val connectionScope = CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
private val drainMutex = Mutex()
private val writeMutex = Mutex()
private val connectionScope: CoroutineScope =
CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
private val bleConnection: BleConnection = BleConnection(centralManager, connectionScope, address)
private val drainMutex: Mutex = Mutex()
private val writeMutex: Mutex = Mutex()
private var peripheral: Peripheral? = null
private var connectionStartTime: Long = 0
private var packetsReceived: Int = 0
private var packetsSent: Int = 0
@ -150,10 +158,8 @@ constructor(
private suspend fun drainPacketQueueAndDispatch() {
drainMutex.withLock {
var drainedCount = 0
fromRadioPacketFlow()
.onEach { packet ->
drainedCount++
Logger.d { "[$address] Read packet from queue (${packet.size} bytes)" }
dispatchPacket(packet)
}
@ -164,217 +170,181 @@ constructor(
// --- Connection & Discovery Logic ---
private fun findPeripheral(): Peripheral =
centralManager.getBondedPeripherals().firstOrNull { it.address == address }
?: throw RadioNotConnectedException("Device not found at address $address")
/** Robustly finds the peripheral. First checks bonded devices, then performs a short scan if not found. */
private suspend fun findPeripheral(): Peripheral {
centralManager
.getBondedPeripherals()
.firstOrNull { it.address == address }
?.let {
return it
}
Logger.i { "[$address] Device not found in bonded list, scanning..." }
val scanner = BleScanner(centralManager)
repeat(SCAN_RETRY_COUNT) { attempt ->
val p = scanner.scan(SCAN_TIMEOUT).firstOrNull { it.address == address }
if (p != null) return p
if (attempt < SCAN_RETRY_COUNT - 1) {
delay(SCAN_RETRY_DELAY_MS)
}
}
throw RadioNotConnectedException("Device not found at address $address")
}
private fun connect() {
connectionScope.launch {
try {
connectionStartTime = nowMillis
Logger.i { "[$address] BLE connection attempt started at $connectionStartTime" }
Logger.i { "[$address] BLE connection attempt started" }
peripheral = retryCall { findAndConnectPeripheral() }
peripheral?.let {
val connectionTime = nowMillis - connectionStartTime
Logger.i { "[$address] BLE peripheral connected in ${connectionTime}ms" }
onConnected()
observePeripheralChanges()
discoverServicesAndSetupCharacteristics(it)
bleConnection.connectionState
.onEach { state ->
if (state is ConnectionState.Disconnected) {
onDisconnected(state)
}
}
.launchIn(connectionScope)
val p = retryBleOperation(tag = address) { findPeripheral() }
val state = bleConnection.connectAndAwait(p, CONNECTION_TIMEOUT_MS)
if (state !is ConnectionState.Connected) {
throw RadioNotConnectedException("Failed to connect to device at address $address")
}
onConnected()
discoverServicesAndSetupCharacteristics()
} catch (e: Exception) {
val failureTime = nowMillis - connectionStartTime
// BLE connection errors are common and often transient
Logger.w(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" }
service.onDisconnect(BleError.from(e))
}
}
}
private suspend fun findAndConnectPeripheral(): Peripheral {
val p = findPeripheral()
centralManager.connect(
peripheral = p,
options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
)
p.requestConnectionPriority(ConnectionPriority.HIGH)
return p
}
private suspend fun onConnected() {
try {
peripheral?.let { p ->
val rssi = retryCall { p.readRssi() }
Logger.d { "[$address] Connection established. RSSI: $rssi dBm" }
val phyInUse = retryCall { p.readPhy() }
Logger.d { "[$address] PHY in use: $phyInUse" }
bleConnection.peripheral?.let { p ->
val rssi = retryBleOperation(tag = address) { p.readRssi() }
Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
}
} catch (e: Exception) {
Logger.w(e) { "[$address] Failed to read initial connection properties" }
Logger.w(e) { "[$address] Failed to read initial connection RSSI" }
}
}
private fun observePeripheralChanges() {
peripheral?.let { p ->
p.phy.onEach { phy -> Logger.i { "[$address] BLE PHY changed to $phy" } }.launchIn(connectionScope)
private fun onDisconnected(state: ConnectionState.Disconnected) {
clearCharacteristics()
p.connectionParameters
.onEach { params -> Logger.i { "[$address] BLE connection parameters changed to $params" } }
.launchIn(connectionScope)
p.state
.onEach { state ->
Logger.i { "[$address] BLE connection state changed to $state" }
if (state is ConnectionState.Disconnected) {
clearCharacteristics()
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
Logger.w {
"[$address] BLE disconnected - Reason: ${state.reason}, " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
service.onDisconnect(BleError.Disconnected(reason = state.reason))
}
}
.launchIn(connectionScope)
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
Logger.w {
"[$address] BLE disconnected - Reason: ${state.reason}, " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
centralManager.state
.onEach { state -> Logger.i { "[$address] CentralManager state changed to $state" } }
.launchIn(connectionScope)
service.onDisconnect(BleError.Disconnected(reason = state.reason))
}
@Suppress("TooGenericExceptionCaught")
@OptIn(ExperimentalUuidApi::class)
private fun discoverServicesAndSetupCharacteristics(peripheral: Peripheral) {
connectionScope.launch {
peripheral
.services(listOf(BTM_SERVICE_UUID.toKotlinUuid()))
.onEach { services ->
val meshtasticService = services?.find { it.uuid == BTM_SERVICE_UUID.toKotlinUuid() }
private suspend fun discoverServicesAndSetupCharacteristics() {
try {
val chars =
bleConnection.discoverCharacteristics(
SERVICE_UUID,
listOf(
TORADIO_CHARACTERISTIC,
FROMNUM_CHARACTERISTIC,
FROMRADIO_CHARACTERISTIC,
LOGRADIO_CHARACTERISTIC,
),
)
if (meshtasticService != null) {
toRadioCharacteristic =
meshtasticService.characteristics.find { it.uuid == BTM_TORADIO_CHARACTER.toKotlinUuid() }
fromNumCharacteristic =
meshtasticService.characteristics.find { it.uuid == BTM_FROMNUM_CHARACTER.toKotlinUuid() }
fromRadioCharacteristic =
meshtasticService.characteristics.find { it.uuid == BTM_FROMRADIO_CHARACTER.toKotlinUuid() }
logRadioCharacteristic =
meshtasticService.characteristics.find { it.uuid == BTM_LOGRADIO_CHARACTER.toKotlinUuid() }
if (chars != null) {
toRadioCharacteristic = chars[TORADIO_CHARACTERISTIC]
fromNumCharacteristic = chars[FROMNUM_CHARACTERISTIC]
fromRadioCharacteristic = chars[FROMRADIO_CHARACTERISTIC]
logRadioCharacteristic = chars[LOGRADIO_CHARACTERISTIC]
if (
listOf(toRadioCharacteristic, fromNumCharacteristic, fromRadioCharacteristic).all {
it != null
}
) {
Logger.d {
"[$address] Found toRadio: ${toRadioCharacteristic?.uuid}, ${toRadioCharacteristic?.instanceId}"
}
Logger.d {
"[$address] Found fromNum: ${fromNumCharacteristic?.uuid}, ${fromNumCharacteristic?.instanceId}"
}
Logger.d {
"[$address] Found fromRadio: ${fromRadioCharacteristic?.uuid}, ${fromRadioCharacteristic?.instanceId}"
}
Logger.d {
"[$address] Found logRadio: ${logRadioCharacteristic?.uuid}, ${logRadioCharacteristic?.instanceId}"
}
setupNotifications()
service.onConnect()
} else {
Logger.w { "[$address] Discovery failed: missing required characteristics" }
service.onDisconnect(BleError.DiscoveryFailed("One or more characteristics not found"))
}
} else {
Logger.w { "[$address] Discovery failed: Meshtastic service not found" }
service.onDisconnect(BleError.DiscoveryFailed("Meshtastic service not found"))
}
}
.catch { e ->
Logger.w(e) { "[$address] Service discovery failed" }
try {
peripheral.disconnect()
} catch (e2: Exception) {
Logger.w(e2) { "[$address] Failed to disconnect in discovery catch" }
}
service.onDisconnect(BleError.from(e))
}
.launchIn(connectionScope)
Logger.d { "[$address] Characteristics discovered successfully" }
setupNotifications()
service.onConnect()
} else {
Logger.w { "[$address] Discovery failed: missing required characteristics" }
service.onDisconnect(BleError.DiscoveryFailed("One or more characteristics not found"))
}
} catch (e: Exception) {
Logger.w(e) { "[$address] Service discovery failed" }
bleConnection.disconnect()
service.onDisconnect(BleError.from(e))
}
}
// --- Notification Setup ---
@OptIn(ExperimentalUuidApi::class)
private suspend fun setupNotifications() {
retryCall { fromNumCharacteristic?.subscribe() }
?.onStart { Logger.d { "[$address] Subscribing to fromNumCharacteristic" } }
val fromNumReady = CompletableDeferred<Unit>()
val logRadioReady = CompletableDeferred<Unit>()
fromNumCharacteristic
?.subscribe {
Logger.d { "[$address] FromNum subscription active" }
fromNumReady.complete(Unit)
}
?.onEach { notifyBytes ->
Logger.d { "[$address] FromNum Notification (${notifyBytes.size} bytes), draining queue" }
connectionScope.launch { drainPacketQueueAndDispatch() }
}
?.catch { e ->
Logger.w(e) { "[$address] Error subscribing to fromNumCharacteristic" }
if (!fromNumReady.isCompleted) fromNumReady.completeExceptionally(e)
Logger.w(e) { "[$address] Error in fromNumCharacteristic subscription" }
service.onDisconnect(BleError.from(e))
}
?.launchIn(scope = connectionScope)
?.launchIn(connectionScope) ?: fromNumReady.complete(Unit)
retryCall { logRadioCharacteristic?.subscribe() }
?.onStart { Logger.d { "[$address] Subscribing to logRadioCharacteristic" } }
logRadioCharacteristic
?.subscribe {
Logger.d { "[$address] LogRadio subscription active" }
logRadioReady.complete(Unit)
}
?.onEach { notifyBytes ->
Logger.d { "[$address] LogRadio Notification (${notifyBytes.size} bytes), dispatching packet" }
dispatchPacket(notifyBytes)
}
?.catch { e ->
Logger.w(e) { "[$address] Error subscribing to logRadioCharacteristic" }
if (!logRadioReady.isCompleted) logRadioReady.completeExceptionally(e)
Logger.w(e) { "[$address] Error in logRadioCharacteristic subscription" }
service.onDisconnect(BleError.from(e))
}
?.launchIn(scope = connectionScope)
}
?.launchIn(connectionScope) ?: logRadioReady.complete(Unit)
private suspend fun <T> retryCall(block: suspend () -> T): T {
var currentAttempt = 0
while (true) {
try {
return block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
currentAttempt++
if (currentAttempt >= RETRY_COUNT) {
Logger.w(e) { "[$address] BLE operation failed after $RETRY_COUNT attempts, giving up" }
throw e
}
Logger.w(e) {
"[$address] BLE operation failed (attempt $currentAttempt/$RETRY_COUNT), " +
"retrying in ${RETRY_DELAY_MS}ms..."
}
delay(RETRY_DELAY_MS)
try {
withTimeout(CONNECTION_TIMEOUT_MS) {
fromNumReady.await()
logRadioReady.await()
}
Logger.d { "[$address] All notifications successfully subscribed" }
} catch (e: Exception) {
Logger.e(e) { "[$address] Timeout or error waiting for characteristic subscriptions" }
throw e
}
}
// --- IRadioInterface Implementation ---
/**
* Sends a packet to the radio.
* Sends a packet to the radio with retry support.
*
* @param p The packet to send.
*/
override fun handleSendToRadio(p: ByteArray) {
toRadioCharacteristic?.let { characteristic ->
if (peripheral == null) {
Logger.w { "[$address] BLE peripheral is null, cannot send packet" }
return@let
}
connectionScope.launch {
writeMutex.withLock {
try {
@ -384,14 +354,15 @@ constructor(
} else {
WriteType.WITH_RESPONSE
}
retryCall {
packetsSent++
bytesSent += p.size
Logger.d {
"[$address] Writing packet #$packetsSent to toRadioCharacteristic with $writeType - " +
"${p.size} bytes (Total TX: $bytesSent bytes)"
}
characteristic.write(p, writeType = writeType)
retryBleOperation(tag = address) { characteristic.write(p, writeType = writeType) }
packetsSent++
bytesSent += p.size
Logger.d {
"[$address] Successfully wrote packet #$packetsSent " +
"to toRadioCharacteristic with $writeType - " +
"${p.size} bytes (Total TX: $bytesSent bytes)"
}
drainPacketQueueAndDispatch()
} catch (e: InvalidAttributeException) {
@ -429,7 +400,7 @@ constructor(
"Packets TX: $packetsSent ($bytesSent bytes)"
}
connectionScope.cancel()
peripheral?.disconnect()
bleConnection.disconnect()
service.onDisconnect(true)
}
}
@ -445,18 +416,4 @@ constructor(
fromRadioCharacteristic = null
logRadioCharacteristic = null
}
companion object {
private const val RETRY_COUNT = 3
private const val RETRY_DELAY_MS = 500L
}
}
object BleConstants {
const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$"
val BTM_SERVICE_UUID: UUID = UUID.fromString("6ba1b218-15a8-461f-9fa8-5dcae273eafd")
val BTM_TORADIO_CHARACTER: UUID = UUID.fromString("f75c76d2-129e-4dad-a1dd-7866124401e7")
val BTM_FROMNUM_CHARACTER: UUID = UUID.fromString("ed9da18c-a800-4f66-a670-aa7547e34453")
val BTM_FROMRADIO_CHARACTER: UUID = UUID.fromString("2c55e69e-4993-11ed-b878-0242ac120002")
val BTM_LOGRADIO_CHARACTER: UUID = UUID.fromString("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,11 +14,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.radio
import co.touchlab.kermit.Logger
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.model.util.anonymize
import javax.inject.Inject

View file

@ -22,19 +22,11 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.android.BinaryLogFile
import com.geeksville.mesh.android.BuildUtils
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.util.ignoreException
import com.geeksville.mesh.util.toRemoteExceptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
@ -43,11 +35,19 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.common.core.simpleSharedFlow
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.common.util.BinaryLogFile
import org.meshtastic.core.common.util.BuildUtils
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toRemoteExceptions
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.Heartbeat
@ -82,10 +82,10 @@ constructor(
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _receivedData = MutableSharedFlow<ByteArray>()
private val _receivedData = simpleSharedFlow<ByteArray>()
val receivedData: SharedFlow<ByteArray> = _receivedData
private val _connectionError = MutableSharedFlow<BleError>()
private val _connectionError = simpleSharedFlow<BleError>()
val connectionError: SharedFlow<BleError> = _connectionError.asSharedFlow()
// Thread-safe StateFlow for tracking device address changes
@ -371,12 +371,7 @@ constructor(
serviceScope.handledLaunch { handleSendToRadio(a) }
}
private val _meshActivity =
MutableSharedFlow<MeshActivity>(
replay = 0, // No replay needed for event-like emissions
extraBufferCapacity = 1, // Buffer one event to avoid loss on rapid emissions
onBufferOverflow = BufferOverflow.DROP_OLDEST, // Drop oldest if buffer overflows
)
private val _meshActivity = simpleSharedFlow<MeshActivity>()
val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
private fun emitSendActivity() {

View file

@ -22,7 +22,7 @@ import com.geeksville.mesh.repository.usb.SerialConnectionListener
import com.geeksville.mesh.repository.usb.UsbRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.common.util.nowMillis
import java.util.concurrent.atomic.AtomicReference
/** An interface that assumes we are talking to a meshtastic device via USB serial */

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.radio
import android.hardware.usb.UsbManager
@ -22,24 +21,18 @@ import com.geeksville.mesh.repository.usb.UsbRepository
import com.hoho.android.usbserial.driver.UsbSerialDriver
import javax.inject.Inject
/**
* Serial/USB interface backend implementation.
*/
class SerialInterfaceSpec @Inject constructor(
/** Serial/USB interface backend implementation. */
class SerialInterfaceSpec
@Inject
constructor(
private val factory: SerialInterfaceFactory,
private val usbManager: dagger.Lazy<UsbManager>,
private val usbRepository: UsbRepository,
) : InterfaceSpec<SerialInterface> {
override fun createInterface(rest: String): SerialInterface {
return factory.create(rest)
}
override fun createInterface(rest: String): SerialInterface = factory.create(rest)
override fun addressValid(
rest: String
): Boolean {
usbRepository.serialDevicesWithDrivers.value.filterValues {
usbManager.get().hasPermission(it.device)
}
override fun addressValid(rest: String): Boolean {
usbRepository.serialDevices.value.filterValues { usbManager.get().hasPermission(it.device) }
findSerial(rest)?.let { d ->
return usbManager.get().hasPermission(d.device)
}
@ -47,7 +40,7 @@ class SerialInterfaceSpec @Inject constructor(
}
internal fun findSerial(rest: String): UsbSerialDriver? {
val deviceMap = usbRepository.serialDevicesWithDrivers.value
val deviceMap = usbRepository.serialDevices.value
return if (deviceMap.containsKey(rest)) {
deviceMap[rest]!!
} else {

View file

@ -17,15 +17,15 @@
package com.geeksville.mesh.repository.radio
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.util.Exceptions
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.util.Exceptions
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import java.io.BufferedInputStream

View file

@ -18,16 +18,15 @@ package com.geeksville.mesh.repository.usb
import android.hardware.usb.UsbManager
import co.touchlab.kermit.Logger
import com.geeksville.mesh.util.ignoreException
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.util.SerialInputOutputManager
import org.meshtastic.core.model.util.await
import org.meshtastic.core.common.util.ignoreException
import java.nio.BufferOverflowException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import kotlin.time.Duration.Companion.seconds
internal class SerialConnectionImpl(
private val usbManagerLazy: dagger.Lazy<UsbManager?>,
@ -65,7 +64,7 @@ internal class SerialConnectionImpl(
// Allow a short amount of time for the manager to quit (so the port can be cleanly closed)
if (waitForStopped) {
Logger.d { "Waiting for USB manager to stop..." }
ignoreException(silent = true) { closedLatch.await(1.seconds) }
ignoreException(silent = true) { closedLatch.await(1, TimeUnit.SECONDS) }
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.usb
import android.content.BroadcastReceiver
@ -24,8 +23,8 @@ import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import co.touchlab.kermit.Logger
import com.geeksville.mesh.util.exceptionReporter
import com.geeksville.mesh.util.getParcelableExtraCompat
import org.meshtastic.core.common.util.exceptionReporter
import org.meshtastic.core.common.util.getParcelableExtraCompat
import javax.inject.Inject
/** A helper class to call onChanged when bluetooth is enabled or disabled or when permissions are changed. */

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.usb
import android.content.BroadcastReceiver
@ -24,33 +23,32 @@ import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import androidx.core.app.PendingIntentCompat
import com.geeksville.mesh.util.registerReceiverCompat
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import org.meshtastic.core.common.util.registerReceiverCompat
private const val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION"
internal fun UsbManager.requestPermission(
context: Context,
device: UsbDevice,
): Flow<Boolean> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (ACTION_USB_PERMISSION == intent.action) {
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
trySend(granted)
close()
internal fun UsbManager.requestPermission(context: Context, device: UsbDevice): Flow<Boolean> = callbackFlow {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (ACTION_USB_PERMISSION == intent.action) {
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
trySend(granted)
close()
}
}
}
}
val permissionIntent = PendingIntentCompat.getBroadcast(
context,
0,
Intent(ACTION_USB_PERMISSION).apply { `package` = context.packageName },
0,
true
)
val permissionIntent =
PendingIntentCompat.getBroadcast(
context,
0,
Intent(ACTION_USB_PERMISSION).apply { `package` = context.packageName },
0,
true,
)
val filter = IntentFilter(ACTION_USB_PERMISSION)
context.registerReceiverCompat(receiver, filter)
requestPermission(device, permissionIntent)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.usb
import android.app.Application
@ -22,19 +21,18 @@ import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import com.geeksville.mesh.util.registerReceiverCompat
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialProber
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.util.registerReceiverCompat
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import javax.inject.Inject
@ -55,11 +53,7 @@ constructor(
) {
private val _serialDevices = MutableStateFlow(emptyMap<String, UsbDevice>())
@Suppress("unused") // Retained as public API
val serialDevices = _serialDevices.asStateFlow()
@Suppress("unused") // Retained as public API
val serialDevicesWithDrivers =
val serialDevices =
_serialDevices
.mapLatest { serialDevices ->
val serialProber = usbSerialProberLazy.get()
@ -69,16 +63,6 @@ constructor(
}
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
@Suppress("unused") // Retained as public API
val serialDevicesWithPermission =
_serialDevices
.mapLatest { serialDevices ->
usbManagerLazy.get()?.let { usbManager ->
serialDevices.filterValues { device -> usbManager.hasPermission(device) }
} ?: emptyMap()
}
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
init {
processLifecycle.coroutineScope.launch(dispatchers.default) {
refreshStateInternal()

View file

@ -24,8 +24,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.service.MeshServiceNotifications
import javax.inject.Inject

View file

@ -16,8 +16,6 @@
*/
package com.geeksville.mesh.service
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.util.ignoreException
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -25,13 +23,15 @@ import kotlinx.coroutines.SupervisorJob
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceAction

View file

@ -27,14 +27,14 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet

View file

@ -17,12 +17,12 @@
package com.geeksville.mesh.service
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MetadataEntity

View file

@ -16,7 +16,6 @@
*/
package com.geeksville.mesh.service
import com.geeksville.mesh.concurrent.handledLaunch
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -24,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.Channel

View file

@ -18,9 +18,7 @@ package com.geeksville.mesh.service
import android.app.Notification
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.meshtastic.core.strings.getString
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -32,10 +30,11 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
@ -44,6 +43,7 @@ import org.meshtastic.core.strings.connected_count
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.device_sleeping
import org.meshtastic.core.strings.disconnected
import org.meshtastic.core.strings.getString
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry

View file

@ -20,9 +20,7 @@ import android.util.Log
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.repository.radio.InterfaceId
import com.meshtastic.core.strings.getString
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -32,6 +30,9 @@ import kotlinx.coroutines.flow.first
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.Packet
@ -40,8 +41,6 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.service.MeshServiceNotifications
@ -50,6 +49,7 @@ import org.meshtastic.core.service.filter.MessageFilterService
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.critical_alert
import org.meshtastic.core.strings.error_duty_cycle
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.core.strings.waypoint_received
import org.meshtastic.proto.AdminMessage
@ -477,13 +477,12 @@ constructor(
val isAck = routingError == Routing.Error.NONE.value
val p = packetRepository.get().getPacketById(requestId)
val reaction = packetRepository.get().getReactionByPacketId(requestId)
val isMaxRetransmit = routingError == Routing.Error.MAX_RETRANSMIT.value
@Suppress("MaxLineLength")
Logger.d {
val statusInfo = "status=${p?.data?.status ?: reaction?.status}"
"[ackNak] req=$requestId routeErr=$routingError isAck=$isAck " +
"maxRetransmit=$isMaxRetransmit packetId=${p?.packetId ?: reaction?.packetId} dataId=${p?.data?.id} $statusInfo"
"packetId=${p?.packetId ?: reaction?.packetId} dataId=${p?.data?.id} $statusInfo"
}
val m =

View file

@ -20,7 +20,7 @@ import android.util.Log
import androidx.annotation.VisibleForTesting
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
import com.geeksville.mesh.ui.connections.NO_DEVICE_SELECTED
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.proto.Data

View file

@ -19,7 +19,6 @@ package com.geeksville.mesh.service
import android.util.Log
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.concurrent.handledLaunch
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -27,11 +26,12 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.util.isLora
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LogRecord
@ -39,10 +39,10 @@ import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import java.util.ArrayDeque
import java.util.Locale
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.uuid.Uuid
@Suppress("TooManyFunctions")
@Singleton
@ -125,7 +125,7 @@ constructor(
insertMeshLog(
MeshLog(
uuid = UUID.randomUUID().toString(),
uuid = Uuid.random().toString(),
message_type = type,
received_date = nowMillis,
raw_message = message,
@ -185,7 +185,7 @@ constructor(
val decoded = packet.decoded ?: return
val log =
MeshLog(
uuid = UUID.randomUUID().toString(),
uuid = Uuid.random().toString(),
message_type = "Packet",
received_date = nowMillis,
raw_message = packet.toString(),

View file

@ -17,13 +17,13 @@
package com.geeksville.mesh.service
import co.touchlab.kermit.Logger
import com.meshtastic.core.strings.getString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo

View file

@ -18,13 +18,14 @@ package com.geeksville.mesh.service
import androidx.annotation.VisibleForTesting
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import okio.ByteString
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.NodeEntity
@ -32,7 +33,6 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel

View file

@ -25,10 +25,8 @@ import android.os.IBinder
import androidx.core.app.ServiceCompat
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.util.toRemoteExceptions
import com.geeksville.mesh.ui.connections.NO_DEVICE_SELECTED
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -38,6 +36,8 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.toRemoteExceptions
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceVersion

View file

@ -41,23 +41,23 @@ import com.geeksville.mesh.R.raw
import com.geeksville.mesh.service.MarkAsReadReceiver.Companion.MARK_AS_READ_ACTION
import com.geeksville.mesh.service.ReactionReceiver.Companion.REACT_ACTION
import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
import com.meshtastic.core.strings.getString
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.low_battery_message
import org.meshtastic.core.strings.low_battery_title
import org.meshtastic.core.strings.mark_as_read

View file

@ -17,19 +17,19 @@
package com.geeksville.mesh.service
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import com.meshtastic.core.strings.getString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.traceroute_duration
import org.meshtastic.core.strings.traceroute_route_back_to_us
import org.meshtastic.core.strings.traceroute_route_towards_dest

View file

@ -17,7 +17,6 @@
package com.geeksville.mesh.service
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import dagger.Lazy
import kotlinx.coroutines.CompletableDeferred
@ -28,12 +27,13 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.service.ConnectionState
@ -41,13 +41,13 @@ import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.ToRadio
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
@Suppress("TooManyFunctions")
@Singleton
@ -90,7 +90,7 @@ constructor(
if (packet?.decoded != null) {
val packetToSave =
MeshLog(
uuid = UUID.randomUUID().toString(),
uuid = Uuid.random().toString(),
message_type = "Packet",
received_date = nowMillis,
raw_message = packet.toString(),

View file

@ -18,8 +18,6 @@
package com.geeksville.mesh.ui
import android.Manifest
import android.os.Build
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Animatable
@ -81,7 +79,6 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.channelsGraph
import com.geeksville.mesh.navigation.connectionsGraph
@ -93,12 +90,11 @@ import com.geeksville.mesh.navigation.settingsGraph
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.connections.DeviceType
import com.geeksville.mesh.ui.connections.ScannerViewModel
import com.geeksville.mesh.ui.connections.components.ConnectionsNavIcon
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import no.nordicsemi.android.common.permissions.notification.RequestNotificationPermission
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
@ -161,10 +157,10 @@ enum class TopLevelDestination(val label: StringResource, val icon: ImageVector,
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanModel = hiltViewModel()) {
fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: ScannerViewModel = hiltViewModel()) {
val navController = rememberNavController()
LaunchedEffect(uIViewModel) { uIViewModel.navigationDeepLink.collectLatest { uri -> navController.navigate(uri) } }
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
@ -172,16 +168,11 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
LaunchedEffect(connectionState, notificationPermissionState) {
if (connectionState == ConnectionState.Connected && !notificationPermissionState.status.isGranted) {
notificationPermissionState.launchPermissionRequest()
}
}
}
if (connectionState == ConnectionState.Connected) {
RequestNotificationPermission {
// Nordic handled the trigger for POST_NOTIFICATIONS when connected
}
sharedContactRequested?.let {
SharedContactDialog(sharedContact = it, onDismiss = { uIViewModel.clearSharedContactRequested() })
}

View file

@ -23,7 +23,6 @@ import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -36,13 +35,11 @@ import androidx.compose.material.icons.rounded.Language
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -54,7 +51,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.ui.connections.components.BLEDevices
import com.geeksville.mesh.ui.connections.components.ConnectingDeviceInfo
@ -64,7 +60,6 @@ import com.geeksville.mesh.ui.connections.components.EmptyStateContent
import com.geeksville.mesh.ui.connections.components.NetworkDevices
import com.geeksville.mesh.ui.connections.components.UsbDevices
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.navigation.Route
@ -80,7 +75,6 @@ import org.meshtastic.core.strings.must_set_region
import org.meshtastic.core.strings.no_device_selected
import org.meshtastic.core.strings.not_connected
import org.meshtastic.core.strings.set_your_region
import org.meshtastic.core.strings.warning_not_paired
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.TitledCard
@ -91,6 +85,7 @@ import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.proto.Config
import kotlin.uuid.ExperimentalUuidApi
fun String?.isValidAddress(): Boolean = if (this.isNullOrBlank()) {
false
@ -105,12 +100,12 @@ fun String?.isValidAddress(): Boolean = if (this.isNullOrBlank()) {
* Composable screen for managing device connections (BLE, TCP, USB). It handles permission requests for location and
* displays connection status.
*/
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class)
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalUuidApi::class)
@Suppress("CyclomaticComplexMethod", "LongMethod", "MagicNumber", "ModifierMissing", "ComposableParamOrder")
@Composable
fun ConnectionsScreen(
connectionsViewModel: ConnectionsViewModel = hiltViewModel(),
scanModel: BTScanModel = hiltViewModel(),
scanModel: ScannerViewModel = hiltViewModel(),
radioConfigViewModel: RadioConfigViewModel = hiltViewModel(),
onClickNodeChip: (Int) -> Unit,
onNavigateToNodeDetails: (Int) -> Unit,
@ -118,13 +113,10 @@ fun ConnectionsScreen(
) {
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
val config by connectionsViewModel.localConfig.collectAsStateWithLifecycle()
val scrollState = rememberScrollState()
val scanStatusText by scanModel.errorText.observeAsState("")
val scanStatusText by scanModel.errorText.collectAsStateWithLifecycle()
val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle()
val scanning by scanModel.spinner.collectAsStateWithLifecycle(false)
val ourNode by connectionsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val bluetoothState by connectionsViewModel.bluetoothState.collectAsStateWithLifecycle()
val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
@ -153,14 +145,6 @@ fun ConnectionsScreen(
)
}
// when scanning is true - wait 10000ms and then stop scanning
LaunchedEffect(scanning) {
if (scanning) {
delay(SCAN_PERIOD)
scanModel.stopScan()
}
}
LaunchedEffect(connectionState, regionUnset) {
when (connectionState) {
ConnectionState.Connected -> {
@ -189,13 +173,10 @@ fun ConnectionsScreen(
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier =
Modifier.fillMaxSize()
.verticalScroll(scrollState)
.height(IntrinsicSize.Max)
.padding(paddingValues)
.padding(16.dp),
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Spacer(modifier = Modifier.height(4.dp))
val uiState =
when {
connectionState.isConnected() && ourNode != null -> 2
@ -205,11 +186,7 @@ fun ConnectionsScreen(
else -> 0
}
Crossfade(
targetState = uiState,
label = "connection_state",
modifier = Modifier.padding(bottom = 16.dp),
) { state ->
Crossfade(targetState = uiState, label = "connection_state") { state ->
when (state) {
2 -> {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
@ -278,61 +255,47 @@ fun ConnectionsScreen(
selectedDeviceType = it
}
Spacer(modifier = Modifier.height(4.dp))
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
when (selectedDeviceType) {
DeviceType.BLE -> {
val (bonded, available) = bleDevices.partition { it.bonded }
BLEDevices(
connectionState = connectionState,
bondedDevices = bonded,
availableDevices = available,
selectedDevice = selectedDevice,
scanModel = scanModel,
bluetoothEnabled = bluetoothState.enabled,
)
}
DeviceType.TCP -> {
NetworkDevices(
connectionState = connectionState,
discoveredNetworkDevices = discoveredTcpDevices,
recentNetworkDevices = recentTcpDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
NetworkDevices(
connectionState = connectionState,
discoveredNetworkDevices = discoveredTcpDevices,
recentNetworkDevices = recentTcpDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
Spacer(modifier = Modifier.height(16.dp))
}
}
DeviceType.USB -> {
UsbDevices(
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
UsbDevices(
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Warning Not Paired
val hasShownNotPairedWarning by
connectionsViewModel.hasShownNotPairedWarning.collectAsStateWithLifecycle()
val (bonded, _) = bleDevices.partition { it.bonded }
val showWarningNotPaired =
!connectionState.isConnected() && !hasShownNotPairedWarning && bonded.isEmpty()
if (showWarningNotPaired) {
Text(
text = stringResource(Res.string.warning_not_paired),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
LaunchedEffect(Unit) { connectionsViewModel.suppressNoPairedWarning() }
}
}
}
scanStatusText?.let {
@ -354,5 +317,3 @@ fun ConnectionsScreen(
}
}
}
private const val SCAN_PERIOD: Long = 10000 // 10 seconds

View file

@ -17,7 +17,6 @@
package com.geeksville.mesh.ui.connections
import androidx.lifecycle.ViewModel
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -39,7 +38,6 @@ constructor(
radioConfigRepository: RadioConfigRepository,
serviceRepository: ServiceRepository,
nodeRepository: NodeRepository,
bluetoothRepository: BluetoothRepository,
private val uiPrefs: UiPrefs,
) : ViewModel() {
@ -52,8 +50,6 @@ constructor(
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
val bluetoothState = bluetoothRepository.state
private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning)
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,11 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.connections
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
/** Represent the different ways a device can connect to the phone. */
enum class DeviceType {
BLE,
TCP,

View file

@ -14,18 +14,18 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.model
package com.geeksville.mesh.ui.connections
import android.app.Application
import android.content.Context
import android.hardware.usb.UsbManager
import android.os.RemoteException
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.model.getMeshtasticShortName
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString
import com.geeksville.mesh.repository.radio.RadioInterfaceService
@ -43,6 +43,10 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.util.anonymize
@ -50,11 +54,12 @@ import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.meshtastic
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import java.util.Locale
import javax.inject.Inject
@HiltViewModel
@Suppress("LongParameterList", "TooManyFunctions")
class BTScanModel
class ScannerViewModel
@Inject
constructor(
private val application: Application,
@ -65,28 +70,28 @@ constructor(
private val networkRepository: NetworkRepository,
private val radioInterfaceService: RadioInterfaceService,
private val recentAddressesDataSource: RecentAddressesDataSource,
private val nodeRepository: NodeRepository,
private val databaseManager: DatabaseManager,
) : ViewModel() {
private val context: Context
get() = application.applicationContext
val showMockInterface: StateFlow<Boolean>
get() = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow()
val showMockInterface: StateFlow<Boolean> = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow()
private val _errorText = MutableStateFlow<String?>(null)
val errorText: StateFlow<String?> = _errorText.asStateFlow()
private val nodeDb: StateFlow<Map<Int, Node>> = nodeRepository.nodeDBbyNum
val errorText = MutableLiveData<String?>(null)
private val bondedBleDevicesFlow: StateFlow<List<DeviceListEntry.Ble>> =
bluetoothRepository.state
.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
private val scannedBleDevicesFlow: StateFlow<List<DeviceListEntry.Ble>> =
bluetoothRepository.scannedDevices
.map { peripherals -> peripherals.map { DeviceListEntry.Ble(it) } }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
// Flow for discovered TCP devices, using recent addresses for potential name enrichment
private val processedDiscoveredTcpDevicesFlow: StateFlow<List<DeviceListEntry.Tcp>> =
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) { tcpServices, recentList ->
val recentMap = recentList.associateBy({ it.address }, { it.name })
val recentMap = recentList.associateBy({ it.address }) { it.name }
tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
@ -98,7 +103,7 @@ constructor(
shortNameBytes?.let { String(it, Charsets.UTF_8) } ?: getString(Res.string.meshtastic)
val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && !displayName.split("_").none { it == deviceId }) {
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
@ -107,29 +112,57 @@ constructor(
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
/** A combined list of bonded and scanned BLE devices for the UI. */
/** A combined list of bonded BLE devices for the UI. */
val bleDevicesForUi: StateFlow<List<DeviceListEntry>> =
combine(bondedBleDevicesFlow, scannedBleDevicesFlow) { bonded, scanned ->
val bondedAddresses = bonded.map { it.fullAddress }.toSet()
val uniqueScanned = scanned.filterNot { it.fullAddress in bondedAddresses }
(bonded + uniqueScanned).sortedBy { it.name }
combine(bondedBleDevicesFlow, nodeDb) { bonded, db ->
bonded
.map { entry: DeviceListEntry.Ble ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
val suffix = entry.peripheral.getMeshtasticShortName()?.lowercase(Locale.ROOT)
suffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
}
.stateInWhileSubscribed(initialValue = emptyList())
private val usbDevicesFlow: StateFlow<List<DeviceListEntry.Usb>> =
usbRepository.serialDevicesWithDrivers
usbRepository.serialDevices
.map { usb -> usb.map { (_, d) -> DeviceListEntry.Usb(radioInterfaceService, usbManagerLazy.get(), d) } }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val mockDevice = DeviceListEntry.Mock("Demo Mode")
// Flow for recent TCP devices, filtered to exclude any currently discovered devices
/** UI StateFlow for USB devices. */
val usbDevicesForUi: StateFlow<List<DeviceListEntry>> =
combine(usbDevicesFlow, showMockInterface) { usb, showMock ->
usb + if (showMock) listOf(mockDevice) else emptyList()
val all: List<DeviceListEntry> = usb + if (showMock) listOf(mockDevice) else emptyList()
all.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
nodeDb.value.values.find { node ->
// Hard to match USB to node without connection, but we can try matching by name if it
// follows Meshtastic pattern
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
}
.stateInWhileSubscribed(initialValue = if (showMockInterface.value) listOf(mockDevice) else emptyList())
// Flow for recent TCP devices, filtered to exclude any currently discovered devices
private val filteredRecentTcpDevicesFlow: StateFlow<List<DeviceListEntry.Tcp>> =
combine(recentAddressesDataSource.recentAddresses, processedDiscoveredTcpDevicesFlow) {
recentList,
@ -143,13 +176,47 @@ constructor(
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
private val suffixLength = 4
/** UI StateFlow for discovered TCP devices. */
val discoveredTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
processedDiscoveredTcpDevicesFlow.stateInWhileSubscribed(initialValue = listOf())
combine(processedDiscoveredTcpDevicesFlow, networkRepository.resolvedList, nodeDb) { devices, resolved, db ->
devices.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.attributes?.get("id")?.let { String(it, Charsets.UTF_8) }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
}
.stateInWhileSubscribed(initialValue = listOf())
/** UI StateFlow for recently connected TCP devices that are not currently discovered. */
val recentTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
filteredRecentTcpDevicesFlow.stateInWhileSubscribed(initialValue = listOf())
combine(filteredRecentTcpDevicesFlow, nodeDb) { devices, db ->
devices.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
// For recent TCP, we don't have the TXT records, but maybe the name contains the ID
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
db.values.find { node ->
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
}
.stateInWhileSubscribed(initialValue = listOf())
val selectedAddressFlow: StateFlow<String?> = radioInterfaceService.currentDeviceAddressFlow
@ -158,35 +225,18 @@ constructor(
.map { it ?: NO_DEVICE_SELECTED }
.stateInWhileSubscribed(initialValue = selectedAddressFlow.value ?: NO_DEVICE_SELECTED)
val spinner: StateFlow<Boolean> = bluetoothRepository.isScanning
init {
serviceRepository.connectionProgress.onEach { errorText.value = it }.launchIn(viewModelScope)
Logger.d { "BTScanModel created" }
serviceRepository.connectionProgress.onEach { _errorText.value = it }.launchIn(viewModelScope)
Logger.d { "ScannerViewModel created" }
}
override fun onCleared() {
super.onCleared()
bluetoothRepository.stopScan()
Logger.d { "BTScanModel cleared" }
Logger.d { "ScannerViewModel cleared" }
}
fun setErrorText(text: String) {
errorText.value = text
}
fun stopScan() {
Logger.d { "stopping scan" }
bluetoothRepository.stopScan()
}
fun refreshPermissions() {
bluetoothRepository.refreshState()
}
fun startScan() {
Logger.d { "starting ble scan" }
bluetoothRepository.startScan()
_errorText.value = text
}
private fun changeDeviceAddress(address: String) {

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,248 +14,87 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.connections.components
import android.Manifest
import android.content.Intent
import android.os.Build
import android.provider.Settings.ACTION_BLUETOOTH_SETTINGS
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BluetoothDisabled
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Button
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.launch
import com.geeksville.mesh.ui.connections.ScannerViewModel
import no.nordicsemi.android.common.scanner.rememberFilterState
import no.nordicsemi.android.common.scanner.view.ScannerView
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.bluetooth_available_devices
import org.meshtastic.core.strings.bluetooth_disabled
import org.meshtastic.core.strings.bluetooth_paired_devices
import org.meshtastic.core.strings.grant_permissions
import org.meshtastic.core.strings.no_ble_devices
import org.meshtastic.core.strings.open_settings
import org.meshtastic.core.strings.permission_missing
import org.meshtastic.core.strings.permission_missing_31
import org.meshtastic.core.strings.scan
import org.meshtastic.core.strings.scanning_bluetooth
import org.meshtastic.core.ui.util.showToast
/**
* Composable that displays a list of Bluetooth Low Energy (BLE) devices and allows scanning. It handles Bluetooth
* permissions using `accompanist-permissions`.
* permissions and hardware state using Nordic Common Libraries' ScannerView.
*
* @param connectionState The current connection state of the MeshService.
* @param bondedDevices List of discovered BLE devices.
* @param availableDevices
* @param selectedDevice The full address of the currently selected device.
* @param scanModel The ViewModel responsible for Bluetooth scanning logic.
* @param bluetoothEnabled
*/
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BLEDevices(
connectionState: ConnectionState,
bondedDevices: List<DeviceListEntry>,
availableDevices: List<DeviceListEntry>,
selectedDevice: String,
scanModel: BTScanModel,
bluetoothEnabled: Boolean,
) {
LocalContext.current // Used implicitly by stringResource
val isScanning by scanModel.spinner.collectAsStateWithLifecycle(false)
// Define permissions needed for Bluetooth scanning based on Android version.
val bluetoothPermissionsList = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
listOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
)
}
}
val context = LocalContext.current
val permsMissing =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
stringResource(Res.string.permission_missing_31)
} else {
stringResource(Res.string.permission_missing)
}
val coroutineScope = rememberCoroutineScope()
val singlePermissionState =
rememberPermissionState(
permission = Manifest.permission.ACCESS_BACKGROUND_LOCATION,
onPermissionResult = { granted ->
scanModel.refreshPermissions()
scanModel.startScan()
},
)
val permissionsState =
rememberMultiplePermissionsState(
permissions = bluetoothPermissionsList,
onPermissionsResult = { permissions ->
val granted = permissions.values.all { it }
if (permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false)) {
coroutineScope.launch { context.showToast(permsMissing) }
singlePermissionState.launchPermissionRequest()
}
if (granted) {
scanModel.refreshPermissions()
scanModel.startScan()
} else {
coroutineScope.launch { context.showToast(permsMissing) }
fun BLEDevices(connectionState: ConnectionState, selectedDevice: String, scanModel: ScannerViewModel) {
val filterState =
rememberFilterState(
filter = {
Any {
ServiceUuid(SERVICE_UUID)
Name(Regex(BLE_NAME_PATTERN))
}
},
)
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val settingsLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {
scanModel.refreshPermissions()
scanModel.startScan()
}
Column {
Text(
text = stringResource(Res.string.bluetooth_available_devices),
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 16.dp).fillMaxWidth(),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (permissionsState.allPermissionsGranted) {
when {
!bluetoothEnabled -> {
val context = LocalContext.current
EmptyStateContent(
imageVector = Icons.Rounded.BluetoothDisabled,
text = stringResource(Res.string.bluetooth_disabled),
actionButton = {
val intent = Intent(ACTION_BLUETOOTH_SETTINGS)
if (intent.resolveActivity(context.packageManager) != null) {
Button(onClick = { settingsLauncher.launch(intent) }) {
Text(text = stringResource(Res.string.open_settings))
}
}
},
ScannerView(
state = filterState,
onScanResultSelected = { result -> scanModel.onSelected(DeviceListEntry.Ble(result.peripheral)) },
deviceItem = { result ->
val device =
remember(result.peripheral.address, bleDevices) {
bleDevices.find { it.fullAddress == "x${result.peripheral.address}" }
?: DeviceListEntry.Ble(result.peripheral)
}
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
) {
DeviceListItem(
connectionState =
connectionState.takeIf { device.fullAddress == selectedDevice }
?: ConnectionState.Disconnected,
device = device,
onSelect = { scanModel.onSelected(device) },
rssi = result.rssi,
)
}
else -> {
val scanButton: @Composable () -> Unit = {
Button(
enabled = !isScanning,
onClick = { checkPermissionsAndScan(permissionsState, scanModel, true) },
) {
Box {
// Still measure for the icon and text when scanning, so the button's size doesn't jump
// around.
Row(modifier = Modifier.alpha(if (isScanning) 0f else 1f)) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = stringResource(Res.string.scan),
)
Text(stringResource(Res.string.scan))
}
if (isScanning) {
CircularWavyProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.Center),
)
}
}
}
}
if (bondedDevices.isEmpty() && availableDevices.isEmpty()) {
EmptyStateContent(
imageVector = Icons.Rounded.BluetoothDisabled,
text =
if (isScanning) {
stringResource(Res.string.scanning_bluetooth)
} else {
stringResource(Res.string.no_ble_devices)
},
actionButton = scanButton,
)
} else {
bondedDevices.DeviceListSection(
title = stringResource(Res.string.bluetooth_paired_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
)
availableDevices.DeviceListSection(
title = stringResource(Res.string.bluetooth_available_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
)
scanButton()
}
}
}
} else {
// Show a message and a button to grant permissions if not all granted
EmptyStateContent(
text =
if (permissionsState.shouldShowRationale) {
stringResource(Res.string.permission_missing)
} else {
stringResource(Res.string.permission_missing_31)
},
actionButton = {
Button(onClick = { checkPermissionsAndScan(permissionsState, scanModel, bluetoothEnabled) }) {
Text(text = stringResource(Res.string.grant_permissions))
}
},
)
}
}
}
@OptIn(ExperimentalPermissionsApi::class)
private fun checkPermissionsAndScan(
permissionsState: MultiplePermissionsState,
scanModel: BTScanModel,
bluetoothEnabled: Boolean,
) {
if (permissionsState.allPermissionsGranted && bluetoothEnabled) {
scanModel.startScan()
} else {
permissionsState.launchMultiplePermissionRequest()
},
)
}
}

View file

@ -33,7 +33,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
@ -49,24 +48,28 @@ fun ConnectingDeviceInfo(
onClickDisconnect: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularWavyProgressIndicator(modifier = Modifier.size(40.dp))
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
Column {
Text(text = deviceName, style = MaterialTheme.typography.titleMedium)
Text(text = deviceAddress, style = MaterialTheme.typography.bodySmall)
Text(text = stringResource(Res.string.connecting), style = MaterialTheme.typography.labelSmall)
Text(text = deviceName, style = MaterialTheme.typography.headlineSmall)
Text(text = deviceAddress, style = MaterialTheme.typography.bodyLarge)
Text(
text = stringResource(Res.string.connecting),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
}
}
Button(
shape = RectangleShape,
modifier = Modifier.fillMaxWidth().height(40.dp),
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = MaterialTheme.shapes.medium,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.StatusRed,
@ -74,7 +77,7 @@ fun ConnectingDeviceInfo(
),
onClick = onClickDisconnect,
) {
Text(stringResource(Res.string.disconnect))
Text(stringResource(Res.string.disconnect), style = MaterialTheme.typography.titleMedium)
}
}
}

View file

@ -43,6 +43,7 @@ import co.touchlab.kermit.Logger
import com.geeksville.mesh.model.DeviceListEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.ui.view.RssiIcon
import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException
import org.jetbrains.compose.resources.stringResource
@ -51,7 +52,6 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.disconnect
import org.meshtastic.core.strings.firmware_version
import org.meshtastic.core.ui.component.MaterialBatteryInfo
import org.meshtastic.core.ui.component.MaterialBluetoothSignalInfo
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@ -60,8 +60,8 @@ import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.User
import kotlin.time.Duration.Companion.seconds
private const val RSSI_DELAY = 10
private const val RSSI_TIMEOUT = 5
private const val RSSI_DELAY = 2
private const val RSSI_TIMEOUT = 1
@Suppress("LongMethod", "LoopWithTooManyJumpStatements", "TooGenericExceptionCaught")
@Composable
@ -104,7 +104,7 @@ fun CurrentlyConnectedInfo(
) {
MaterialBatteryInfo(level = node.batteryLevel, voltage = node.voltage)
if (bleDevice is DeviceListEntry.Ble) {
MaterialBluetoothSignalInfo(rssi)
RssiIcon(rssi = rssi)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.Indication
@ -22,7 +21,10 @@ import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.BluetoothSearching
@ -36,15 +38,23 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
import kotlinx.coroutines.delay
import no.nordicsemi.android.common.ui.view.RssiIcon
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
@ -52,6 +62,9 @@ import org.meshtastic.core.strings.add
import org.meshtastic.core.strings.bluetooth
import org.meshtastic.core.strings.network
import org.meshtastic.core.strings.serial
import org.meshtastic.core.ui.component.NodeChip
private const val RSSI_UPDATE_RATE_MS = 2000L
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -62,7 +75,22 @@ fun DeviceListItem(
onSelect: () -> Unit,
modifier: Modifier = Modifier,
onDelete: (() -> Unit)? = null,
rssi: Int? = null,
) {
// Throttle the RSSI updates to match the connected device polling rate
var displayedRssi by remember { mutableIntStateOf(rssi ?: 0) }
LaunchedEffect(rssi) {
if (displayedRssi == 0) {
displayedRssi = rssi ?: 0
}
}
LaunchedEffect(Unit) {
while (true) {
delay(RSSI_UPDATE_RATE_MS)
displayedRssi = rssi ?: 0
}
}
val icon =
when (device) {
is DeviceListEntry.Ble ->
@ -91,31 +119,48 @@ fun DeviceListItem(
val interactionSource = remember { MutableInteractionSource() }
val indication: Indication = LocalIndication.current
ListItem(
modifier =
if (useSelectable && onDelete != null) {
modifier.fillMaxWidth().indication(interactionSource, indication).pointerInput(onDelete) {
detectTapGestures(onTap = { onSelect() }, onLongPress = { onDelete() })
}
} else if (useSelectable) {
modifier.fillMaxWidth().indication(interactionSource, indication).pointerInput(Unit) {
detectTapGestures(onTap = { onSelect() })
val clickableModifier =
if (useSelectable) {
Modifier.indication(interactionSource, indication).pointerInput(device.fullAddress, onDelete) {
detectTapGestures(onTap = { onSelect() }, onLongPress = onDelete?.let { { it() } })
}
} else {
modifier.fillMaxWidth()
},
headlineContent = { Text(device.name) },
leadingContent = { Icon(icon, contentDescription) },
supportingContent = {
if (device is DeviceListEntry.Tcp) {
Text(device.address)
Modifier
}
ListItem(
modifier = modifier.fillMaxWidth().then(clickableModifier).padding(vertical = 4.dp),
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
device.node?.let { node -> NodeChip(node = node) }
?: Text(text = device.name, style = MaterialTheme.typography.titleLarge)
}
},
leadingContent = {
Icon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier.size(32.dp),
tint =
if (connectionState.isConnected()) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
},
supportingContent = { Text(text = device.address, style = MaterialTheme.typography.bodyLarge) },
trailingContent = {
if (connectionState.isConnecting()) {
CircularWavyProgressIndicator(modifier = Modifier.size(24.dp))
} else {
RadioButton(selected = connectionState.isConnected(), onClick = null)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (rssi != null) {
RssiIcon(rssi = displayedRssi)
}
if (connectionState.isConnecting()) {
CircularWavyProgressIndicator(modifier = Modifier.size(32.dp))
} else {
RadioButton(selected = connectionState.isConnected(), onClick = null)
}
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,14 +14,21 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.TitledCard
@Composable
fun List<DeviceListEntry>.DeviceListSection(
@ -33,16 +40,30 @@ fun List<DeviceListEntry>.DeviceListSection(
onDelete: ((DeviceListEntry) -> Unit)? = null,
) {
if (isNotEmpty()) {
TitledCard(title = title, modifier = modifier) {
forEach { device ->
DeviceListItem(
connectionState =
connectionState.takeIf { device.fullAddress == selectedDevice } ?: ConnectionState.Disconnected,
device = device,
onSelect = { onSelect(device) },
onDelete = onDelete?.let { delete -> { delete(device) } },
modifier = Modifier.Companion,
)
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
text = title,
modifier = Modifier.padding(horizontal = 8.dp).fillMaxWidth(),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
)
this@DeviceListSection.forEach { device ->
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
) {
DeviceListItem(
connectionState =
connectionState.takeIf { device.fullAddress == selectedDevice }
?: ConnectionState.Disconnected,
device = device,
onSelect = { onSelect(device) },
onDelete = onDelete?.let { delete -> { delete(device) } },
modifier = Modifier,
)
}
}
}
}

View file

@ -47,9 +47,9 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.ui.connections.ScannerViewModel
import com.geeksville.mesh.ui.connections.isValidAddress
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@ -75,7 +75,7 @@ fun NetworkDevices(
discoveredNetworkDevices: List<DeviceListEntry>,
recentNetworkDevices: List<DeviceListEntry>,
selectedDevice: String,
scanModel: BTScanModel,
scanModel: ScannerViewModel,
) {
val searchDialogState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@ -108,9 +108,33 @@ fun NetworkDevices(
}
}
NetworkDevicesInternal(
connectionState = connectionState,
discoveredNetworkDevices = discoveredNetworkDevices,
recentNetworkDevices = recentNetworkDevices,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
onDelete = { device ->
deviceToDelete = device
showDeleteDialog = true
},
onClickAdd = { showSearchDialog = true },
)
}
@Composable
private fun NetworkDevicesInternal(
connectionState: ConnectionState,
discoveredNetworkDevices: List<DeviceListEntry>,
recentNetworkDevices: List<DeviceListEntry>,
selectedDevice: String,
onSelect: (DeviceListEntry) -> Unit,
onDelete: (DeviceListEntry) -> Unit,
onClickAdd: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val addButton: @Composable () -> Unit = {
Button(onClick = { showSearchDialog = true }) {
Button(onClick = onClickAdd) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(Res.string.add_network_device),
@ -134,11 +158,8 @@ fun NetworkDevices(
title = stringResource(Res.string.recent_network_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
onDelete = { device ->
deviceToDelete = device
showDeleteDialog = true
},
onSelect = onSelect,
onDelete = onDelete,
)
}
@ -147,7 +168,7 @@ fun NetworkDevices(
title = stringResource(Res.string.discovered_network_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
onSelect = onSelect,
)
}
@ -263,3 +284,23 @@ private fun SearchDialogPreview() {
private fun ConfirmDeleteDialogPreview() {
AppTheme { ConfirmDeleteDialog(fullAddressToDelete = "", onHideDialog = {}, onConfirm = {}) }
}
@PreviewLightDark
@Composable
private fun NetworkDevicesPreview() {
AppTheme {
NetworkDevicesInternal(
connectionState = ConnectionState.Disconnected,
discoveredNetworkDevices = listOf(DeviceListEntry.Tcp("Meshtastic", "t192.168.1.3")),
recentNetworkDevices =
listOf(
DeviceListEntry.Tcp("Home Node", "t192.168.1.100"),
DeviceListEntry.Tcp("Office", "t192.168.1.101"),
),
selectedDevice = "",
onSelect = {},
onDelete = {},
onClickAdd = {},
)
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,67 +14,44 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.UsbOff
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewLightDark
import com.geeksville.mesh.model.BTScanModel
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.ui.connections.ScannerViewModel
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.no_usb_devices
import org.meshtastic.core.strings.usb_devices
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun UsbDevices(
connectionState: ConnectionState,
usbDevices: List<DeviceListEntry>,
selectedDevice: String,
scanModel: BTScanModel,
scanModel: ScannerViewModel,
) {
UsbDevicesInternal(
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
onDeviceSelected = scanModel::onSelected,
)
}
@Composable
private fun UsbDevicesInternal(
connectionState: ConnectionState,
usbDevices: List<DeviceListEntry>,
selectedDevice: String,
onDeviceSelected: (DeviceListEntry) -> Unit,
) {
when {
usbDevices.isEmpty() ->
EmptyStateContent(imageVector = Icons.Rounded.UsbOff, text = stringResource(Res.string.no_usb_devices))
else ->
usbDevices.DeviceListSection(
title = stringResource(Res.string.usb_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = onDeviceSelected,
if (usbDevices.isEmpty()) {
Column(modifier = Modifier.fillMaxSize()) {
EmptyStateContent(
imageVector = Icons.Rounded.UsbOff,
text = stringResource(Res.string.no_usb_devices),
modifier = Modifier.height(160.dp),
)
}
}
@PreviewLightDark
@Composable
private fun UsbDevicesPreview() {
AppTheme {
UsbDevicesInternal(
connectionState = ConnectionState.Connected,
usbDevices = emptyList(),
selectedDevice = "",
onDeviceSelected = {},
}
} else {
usbDevices.DeviceListSection(
title = "USB",
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
)
}
}

View file

@ -29,7 +29,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.RadioButton
@ -66,11 +65,11 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.formatMuteRemainingTime
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.are_you_sure
import org.meshtastic.core.strings.cancel
@ -109,7 +108,7 @@ import org.meshtastic.core.ui.util.showToast
import org.meshtastic.proto.ChannelSet
import kotlin.time.Duration.Companion.days
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalPermissionsApi::class)
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun ContactsScreen(
@ -477,34 +476,31 @@ private fun ContactListViewPaged(
modifier: Modifier = Modifier,
channels: ChannelSet? = null,
) {
val haptics = LocalHapticFeedback.current
if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
return
val haptic = LocalHapticFeedback.current
Box(modifier = modifier.fillMaxSize()) {
if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
ContactListContentInternal(
contacts = contacts,
channelPlaceholders = channelPlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
listState = listState,
channels = channels,
haptic = haptic,
)
}
}
val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders)
ContactListContentInternal(
contacts = contacts,
visiblePlaceholders = visiblePlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
listState = listState,
modifier = modifier,
channels = channels,
haptics = haptics,
)
}
@Composable
private fun ContactListContentInternal(
contacts: LazyPagingItems<Contact>,
visiblePlaceholders: List<Contact>,
channelPlaceholders: List<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
@ -512,10 +508,23 @@ private fun ContactListContentInternal(
onNodeChipClick: (Contact) -> Unit,
listState: LazyListState,
channels: ChannelSet?,
haptics: HapticFeedback,
haptic: HapticFeedback,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier.fillMaxSize(), state = listState) {
val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders)
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) {
contactListPlaceholdersItems(
placeholders = visiblePlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
channels = channels,
haptic = haptic,
)
contactListPagedItems(
contacts = contacts,
selectedList = selectedList,
@ -524,53 +533,36 @@ private fun ContactListContentInternal(
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
channels = channels,
haptics = haptics,
haptic = haptic,
)
contactListPlaceholdersItems(
visiblePlaceholders = visiblePlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
channels = channels,
haptics = haptics,
)
contactListAppendLoadingItem(contacts = contacts)
contactListAppendLoadingItem(contacts)
}
}
private fun LazyListScope.contactListPlaceholdersItems(
visiblePlaceholders: List<Contact>,
placeholders: List<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
channels: ChannelSet?,
haptics: HapticFeedback,
haptic: HapticFeedback,
) {
items(
count = visiblePlaceholders.size,
key = { index -> "placeholder_${visiblePlaceholders[index].contactKey}" },
) { index ->
val placeholder = visiblePlaceholders[index]
val selected by remember { derivedStateOf { selectedList.contains(placeholder.contactKey) } }
val isActive = remember(placeholder.contactKey, activeContactKey) { placeholder.contactKey == activeContactKey }
items(count = placeholders.size, key = { index -> placeholders[index].contactKey }) { index ->
val contact = placeholders[index]
ContactItem(
contact = placeholder,
selected = selected,
isActive = isActive,
onClick = { onClick(placeholder) },
contact = contact,
selected = selectedList.contains(contact.contactKey),
isActive = contact.contactKey == activeContactKey,
onClick = { onClick(contact) },
onLongClick = {
onLongClick(placeholder)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick(contact)
},
onNodeChipClick = { onNodeChipClick(contact) },
channels = channels,
onNodeChipClick = { onNodeChipClick(placeholder) },
)
}
}
@ -583,45 +575,31 @@ private fun LazyListScope.contactListPagedItems(
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
channels: ChannelSet?,
haptics: HapticFeedback,
haptic: HapticFeedback,
) {
items(
count = contacts.itemCount,
key = { index ->
val contact = contacts[index]
contact?.let { "${it.contactKey}#$index" } ?: "contact_placeholder_$index"
},
) { index ->
val contact = contacts[index]
if (contact != null) {
val selected by remember { derivedStateOf { selectedList.contains(contact.contactKey) } }
val isActive = remember(contact.contactKey, activeContactKey) { contact.contactKey == activeContactKey }
items(count = contacts.itemCount, key = { index -> contacts[index]?.contactKey ?: index }) { index ->
contacts[index]?.let { contact ->
ContactItem(
contact = contact,
selected = selected,
isActive = isActive,
selected = selectedList.contains(contact.contactKey),
isActive = contact.contactKey == activeContactKey,
onClick = { onClick(contact) },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick(contact)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
},
channels = channels,
onNodeChipClick = { onNodeChipClick(contact) },
channels = channels,
)
}
}
}
private fun LazyListScope.contactListAppendLoadingItem(contacts: LazyPagingItems<Contact>) {
contacts.apply {
when {
loadState.append is LoadState.Loading -> {
item(key = "append_loading") {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
if (contacts.loadState.append is LoadState.Loading) {
item {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
@ -631,12 +609,7 @@ private fun LazyListScope.contactListAppendLoadingItem(contacts: LazyPagingItems
private fun rememberVisiblePlaceholders(
contacts: LazyPagingItems<Contact>,
channelPlaceholders: List<Contact>,
): List<Contact> {
val contactKeys by
remember(contacts.itemCount) {
derivedStateOf { (0 until contacts.itemCount).mapNotNull { contacts[it]?.contactKey }.toSet() }
}
return remember(channelPlaceholders, contactKeys) {
channelPlaceholders.filter { placeholder -> !contactKeys.contains(placeholder.contactKey) }
}
): List<Contact> = remember(contacts.itemCount, channelPlaceholders) {
val pagedKeys = (0 until contacts.itemCount).mapNotNull { contacts[it]?.contactKey }.toSet()
channelPlaceholders.filter { it.contactKey !in pagedKeys }
}

View file

@ -1,51 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.util
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Parcel
import android.os.Parcelable
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import androidx.core.os.ParcelCompat
inline fun <reified T : Parcelable> Parcel.readParcelableCompat(loader: ClassLoader?): T? =
ParcelCompat.readParcelable(this, loader, T::class.java)
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String?): T? =
IntentCompat.getParcelableExtra(this, key, T::class.java)
fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): PackageInfo =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong()))
} else {
@Suppress("DEPRECATION") getPackageInfo(packageName, flags)
}
fun Context.registerReceiverCompat(
receiver: BroadcastReceiver,
filter: IntentFilter,
flag: Int = ContextCompat.RECEIVER_EXPORTED,
) {
ContextCompat.registerReceiver(this, receiver, filter, flag)
}

View file

@ -1,73 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.util
import android.os.RemoteException
import android.util.Log
import co.touchlab.kermit.Logger
object Exceptions {
// / Set in Application.onCreate
var reporter: ((Throwable, String?, String?) -> Unit)? = null
/**
* Report an exception to our analytics provider (if installed - otherwise just log)
*
* After reporting return
*/
fun report(exception: Throwable, tag: String? = null, message: String? = null) {
Logger.e(exception) {
"Exceptions.report: $tag $message"
} // print the message to the log _before_ telling the crash reporter
reporter?.let { r -> r(exception, tag, message) }
}
}
/**
* This wraps (and discards) exceptions, but first it reports them to our bug tracking system and prints a message to
* the log.
*/
fun exceptionReporter(inner: () -> Unit) {
try {
inner()
} catch (ex: Throwable) {
// DO NOT THROW users expect we have fully handled/discarded the exception
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
}
}
/** This wraps (and discards) exceptions, but it does output a log message */
fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
try {
inner()
} catch (ex: Throwable) {
// DO NOT THROW users expect we have fully handled/discarded the exception
if (!silent) Logger.w(ex) { "ignoring exception" }
}
}
// / Convert any exceptions in this service call into a RemoteException that the client can
// / then handle
fun <T> toRemoteExceptions(inner: () -> T): T = try {
inner()
} catch (ex: Throwable) {
Log.e("toRemoteExceptions", "Uncaught exception, returning to remote client", ex)
when (ex) { // don't double wrap remote exceptions
is RemoteException -> throw ex
else -> throw RemoteException(ex.message)
}
}

View file

@ -1,27 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.util
import androidx.compose.ui.Modifier
/**
* Conditionally applies the [action] to the receiver [Modifier], if [precondition] is true.
* Returns the receiver as-is otherwise.
*/
inline fun Modifier.thenIf(precondition: Boolean, action: Modifier.() -> Modifier): Modifier =
if (precondition) action() else this

View file

@ -1,67 +0,0 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.geeksville.mesh.util;
import android.text.InputFilter;
import android.text.Spanned;
/**
* This filter will constrain edits so that the text length is not
* greater than the specified number of bytes using UTF-8 encoding.
*/
public class Utf8ByteLengthFilter implements InputFilter {
private final int mMaxBytes;
public Utf8ByteLengthFilter(int maxBytes) {
mMaxBytes = maxBytes;
}
public CharSequence filter(CharSequence source, int start, int end,
Spanned dest, int dstart, int dend) {
int srcByteCount = 0;
// count UTF-8 bytes in source substring
for (int i = start; i < end; i++) {
char c = source.charAt(i);
srcByteCount += (c < (char) 0x0080) ? 1 : (c < (char) 0x0800 ? 2 : 3);
}
int destLen = dest.length();
int destByteCount = 0;
// count UTF-8 bytes in destination excluding replaced section
for (int i = 0; i < destLen; i++) {
if (i < dstart || i >= dend) {
char c = dest.charAt(i);
destByteCount += (c < (char) 0x0080) ? 1 : (c < (char) 0x0800 ? 2 : 3);
}
}
int keepBytes = mMaxBytes - destByteCount;
if (keepBytes <= 0) {
return "";
} else if (keepBytes >= srcByteCount) {
return null; // use original dest string
} else {
// find end position of largest sequence that fits in keepBytes
for (int i = start; i < end; i++) {
char c = source.charAt(i);
keepBytes -= (c < (char) 0x0080) ? 1 : (c < (char) 0x0800 ? 2 : 3);
if (keepBytes < 0) {
return source.subSequence(start, i);
}
}
// If the entire substring fits, we should have returned null
// above, so this line should not be reached. If for some
// reason it is, return null to use the original dest string.
return null;
}
}
}