mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
7a68802bc2
commit
6bfa5b5f70
214 changed files with 3471 additions and 2405 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}})"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue