sforkowany z mirror/meshtastic-android
Porównaj commity
70 Commity
Autor | SHA1 | Data |
---|---|---|
sp9unb | a1e6eeb4ee | |
andrekir | 72f02b0deb | |
andrekir | 1fe669fb73 | |
andrekir | 8bc628de9f | |
andrekir | 1380924a37 | |
andrekir | 206d153c55 | |
andrekir | 7ca724142f | |
andrekir | 956db658e9 | |
andrekir | d01e8e8e74 | |
andrekir | 0f84804f9f | |
andrekir | 6fa8023bf7 | |
andrekir | e244aa4b9b | |
andrekir | 5214add39c | |
andrekir | 70f30b8f39 | |
andrekir | d38320ada6 | |
andrekir | 93ac0186fe | |
andrekir | 9869a9208b | |
andrekir | 6a72c65a83 | |
andrekir | 7da958578b | |
andrekir | 0a3a07f9ed | |
andrekir | d58e092333 | |
andrekir | 8643d50425 | |
andrekir | e2f63e015c | |
andrekir | 8151aceea4 | |
Andre K | a2388d1d12 | |
andrekir | a4baa93f4e | |
andrekir | e116a8a97c | |
andrekir | ab5f1ffac1 | |
Andre K | c3ab3c5ae9 | |
andrekir | b9be26e344 | |
andrekir | 135bcf8b8a | |
andrekir | 0c78bc4e49 | |
andrekir | a316495545 | |
andrekir | 8eb049c60e | |
andrekir | 7eeb0b4d6f | |
andrekir | 69c79c331f | |
andrekir | 6297cf2b62 | |
andrekir | ad278f918b | |
Andre K | 068f5e7544 | |
andrekir | 2502bee55f | |
andrekir | 8a750c122e | |
andrekir | 4b00fe9f2e | |
andrekir | 9a3e5a9456 | |
andrekir | 1a76a78d76 | |
andrekir | e35313fb8e | |
andrekir | 05a2364a27 | |
andrekir | 89a0a4c4ac | |
andrekir | 6515b2d3a7 | |
andrekir | 29d3572507 | |
Andre K | 70f7ffb5fc | |
andrekir | 7d1d793fb9 | |
andrekir | 3bbe3fd7f7 | |
andrekir | d1ce014a88 | |
andrekir | 41d0315b63 | |
andrekir | feed8262ea | |
andrekir | 4a6c0c0b40 | |
andrekir | a39390254a | |
andrekir | 7aa173d0d2 | |
Andre K | 9e78e516da | |
andrekir | 9dc1a45fe6 | |
andrekir | 16787b23c8 | |
Andre K | e5a860cb36 | |
andrekir | c821eb3681 | |
andrekir | ab46bf6ab9 | |
andrekir | 34eac6af18 | |
andrekir | 7834cb1f0c | |
andrekir | 6f5ed93db3 | |
andrekir | 8d5cca93f1 | |
andrekir | 145988ad75 | |
Andre K | 85e62eaab4 |
|
@ -34,8 +34,8 @@ android {
|
|||
applicationId "com.geeksville.mesh"
|
||||
minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works)
|
||||
targetSdkVersion 31
|
||||
versionCode 30109 // format is Mmmss (where M is 1+the numeric major number
|
||||
versionName "2.1.9"
|
||||
versionCode 30113 // format is Mmmss (where M is 1+the numeric major number
|
||||
versionName "2.1.13"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// per https://developer.android.com/studio/write/vector-asset-studio
|
||||
|
@ -84,7 +84,7 @@ android {
|
|||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.4.4"
|
||||
kotlinCompilerExtensionVersion = "1.4.7"
|
||||
}
|
||||
// Set both the Java and Kotlin compilers to target Java 8.
|
||||
compileOptions {
|
||||
|
@ -129,12 +129,12 @@ dependencies {
|
|||
// For loading and tinting drawables on older versions of the platform
|
||||
implementation "androidx.appcompat:appcompat-resources:$appcompat_version"
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.5.6'
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.5.7'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'com.google.android.material:material:1.8.0'
|
||||
implementation 'com.google.android.material:material:1.9.0'
|
||||
implementation 'androidx.viewpager2:viewpager2:1.0.0'
|
||||
implementation 'androidx.datastore:datastore:1.0.0'
|
||||
|
||||
|
@ -169,7 +169,7 @@ dependencies {
|
|||
androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
|
||||
|
||||
// Compose
|
||||
def composeBom = platform('androidx.compose:compose-bom:2023.03.00')
|
||||
def composeBom = platform('androidx.compose:compose-bom:2023.05.01')
|
||||
implementation composeBom
|
||||
androidTestImplementation composeBom
|
||||
|
||||
|
@ -199,13 +199,13 @@ dependencies {
|
|||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
|
||||
// kotlin serialization
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
|
||||
|
||||
// rate this app
|
||||
googleImplementation 'com.suddenh4x.ratingdialog:awesome-app-rating:2.6.0'
|
||||
|
||||
// Coroutines
|
||||
def coroutines_version = '1.6.4'
|
||||
def coroutines_version = '1.7.1'
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
|
||||
|
||||
|
@ -220,7 +220,7 @@ dependencies {
|
|||
googleImplementation 'com.google.android.gms:play-services-location:19.0.1'
|
||||
|
||||
// For Firebase Crashlytics & Analytics
|
||||
googleImplementation 'com.google.firebase:firebase-crashlytics:18.3.6'
|
||||
googleImplementation 'com.google.firebase:firebase-crashlytics:18.3.7'
|
||||
googleImplementation 'com.google.firebase:firebase-analytics:21.2.2'
|
||||
|
||||
// barcode support
|
||||
|
@ -233,7 +233,7 @@ dependencies {
|
|||
// Work Request - used to delay boot event handling
|
||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||
|
||||
implementation "androidx.core:core-splashscreen:1.0.0"
|
||||
implementation "androidx.core:core-splashscreen:1.0.1"
|
||||
|
||||
// CompletableFuture backport for API 14+
|
||||
implementation 'net.sourceforge.streamsupport:streamsupport-minifuture:1.7.4'
|
||||
|
|
|
@ -58,6 +58,9 @@ interface IMeshService {
|
|||
*/
|
||||
void setOwner(in MeshUser user);
|
||||
|
||||
void setRemoteOwner(in int destNum, in byte []payload);
|
||||
void getRemoteOwner(in int requestId, in int destNum);
|
||||
|
||||
/// Return my unique user ID string
|
||||
String getMyId();
|
||||
|
||||
|
@ -87,14 +90,30 @@ interface IMeshService {
|
|||
/// It sets a Config protobuf via admin packet
|
||||
void setConfig(in byte []payload);
|
||||
|
||||
/// This method is only intended for use in our GUI, so the user can set radio options
|
||||
/// It sets a ModuleConfig protobuf via admin packet
|
||||
void setModuleConfig(in byte []payload);
|
||||
/// Set and get a Config protobuf via admin packet
|
||||
void setRemoteConfig(in int destNum, in byte []payload);
|
||||
void getRemoteConfig(in int requestId, in int destNum, in int configTypeValue);
|
||||
|
||||
/// Set and get a ModuleConfig protobuf via admin packet
|
||||
void setModuleConfig(in int destNum, in byte []payload);
|
||||
void getModuleConfig(in int requestId, in int destNum, in int moduleConfigTypeValue);
|
||||
|
||||
/// Set and get the Ext Notification Ringtone string via admin packet
|
||||
void setRingtone(in int destNum, in String ringtone);
|
||||
void getRingtone(in int requestId, in int destNum);
|
||||
|
||||
/// Set and get the Canned Message Messages string via admin packet
|
||||
void setCannedMessages(in int destNum, in String messages);
|
||||
void getCannedMessages(in int requestId, in int destNum);
|
||||
|
||||
/// This method is only intended for use in our GUI, so the user can set radio options
|
||||
/// It sets a Channel protobuf via admin packet
|
||||
void setChannel(in byte []payload);
|
||||
|
||||
/// Set and get a Channel protobuf via admin packet
|
||||
void setRemoteChannel(in int destNum, in byte []payload);
|
||||
void getRemoteChannel(in int requestId, in int destNum, in int channelIndex);
|
||||
|
||||
/// Send beginEditSettings admin packet to nodeNum
|
||||
void beginEditSettings();
|
||||
|
||||
|
@ -102,22 +121,22 @@ interface IMeshService {
|
|||
void commitEditSettings();
|
||||
|
||||
/// Send position packet with wantResponse to nodeNum
|
||||
void requestPosition(in int idNum, in Position position);
|
||||
void requestPosition(in int destNum, in Position position);
|
||||
|
||||
/// Send traceroute packet with wantResponse to nodeNum
|
||||
void requestTraceroute(in int requestId, in int destNum);
|
||||
|
||||
/// Send Shutdown admin packet to nodeNum
|
||||
void requestShutdown(in int idNum);
|
||||
void requestShutdown(in int requestId, in int destNum);
|
||||
|
||||
/// Send Reboot admin packet to nodeNum
|
||||
void requestReboot(in int idNum);
|
||||
void requestReboot(in int requestId, in int destNum);
|
||||
|
||||
/// Send FactoryReset admin packet to nodeNum
|
||||
void requestFactoryReset(in int idNum);
|
||||
void requestFactoryReset(in int requestId, in int destNum);
|
||||
|
||||
/// Send NodedbReset admin packet to nodeNum
|
||||
void requestNodedbReset(in int idNum);
|
||||
void requestNodedbReset(in int requestId, in int destNum);
|
||||
|
||||
/// Returns a ChannelSet protobuf
|
||||
byte []getChannelSet();
|
||||
|
|
|
@ -32,13 +32,11 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
|
|||
import com.geeksville.mesh.android.*
|
||||
import com.geeksville.mesh.concurrent.handledLaunch
|
||||
import com.geeksville.mesh.databinding.ActivityMainBinding
|
||||
import com.geeksville.mesh.model.BTScanModel
|
||||
import com.geeksville.mesh.model.BluetoothViewModel
|
||||
import com.geeksville.mesh.model.ChannelSet
|
||||
import com.geeksville.mesh.model.DeviceVersion
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.repository.radio.BluetoothInterface
|
||||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
import com.geeksville.mesh.repository.radio.SerialInterface
|
||||
import com.geeksville.mesh.service.*
|
||||
import com.geeksville.mesh.ui.*
|
||||
|
@ -55,7 +53,6 @@ import kotlinx.coroutines.Job
|
|||
import kotlinx.coroutines.cancel
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
/*
|
||||
UI design
|
||||
|
@ -115,11 +112,7 @@ class MainActivity : AppCompatActivity(), Logging {
|
|||
private val mainScope = CoroutineScope(Dispatchers.Main + Job())
|
||||
|
||||
private val bluetoothViewModel: BluetoothViewModel by viewModels()
|
||||
private val scanModel: BTScanModel by viewModels()
|
||||
val model: UIViewModel by viewModels()
|
||||
|
||||
@Inject
|
||||
internal lateinit var radioInterfaceService: RadioInterfaceService
|
||||
private val model: UIViewModel by viewModels()
|
||||
|
||||
private val requestPermissionsLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
|
||||
|
@ -634,7 +627,6 @@ class MainActivity : AppCompatActivity(), Logging {
|
|||
unregisterMeshReceiver() // No point in receiving updates while the GUI is gone, we'll get them when the user launches the activity
|
||||
unbindMeshService()
|
||||
|
||||
scanModel.changeDeviceAddress.removeObservers(this)
|
||||
model.connectionState.removeObservers(this)
|
||||
bluetoothViewModel.enabled.removeObservers(this)
|
||||
model.requestChannelUrl.removeObservers(this)
|
||||
|
@ -645,26 +637,12 @@ class MainActivity : AppCompatActivity(), Logging {
|
|||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
scanModel.changeDeviceAddress.observe(this) { newAddr ->
|
||||
newAddr?.let {
|
||||
try {
|
||||
model.meshService?.let { service ->
|
||||
MeshService.changeDeviceAddress(this, service, newAddr)
|
||||
}
|
||||
scanModel.changeSelectedAddress(newAddr) // if it throws the change will be discarded
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("changeDeviceSelection failed, probably it is shutting down $ex.message")
|
||||
// ignore the failure and the GUI won't be updating anyways
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
model.connectionState.observe(this) { connected ->
|
||||
updateConnectionStatusImage(connected)
|
||||
}
|
||||
|
||||
bluetoothViewModel.enabled.observe(this) { enabled ->
|
||||
if (!enabled && !requestedEnable && scanModel.selectedBluetooth) {
|
||||
if (!enabled && !requestedEnable && model.selectedBluetooth) {
|
||||
requestedEnable = true
|
||||
if (hasBluetoothPermission()) {
|
||||
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
|
||||
|
@ -701,7 +679,7 @@ class MainActivity : AppCompatActivity(), Logging {
|
|||
errormsg("Bind of MeshService failed")
|
||||
}
|
||||
|
||||
val bonded = radioInterfaceService.getBondedDeviceAddress() != null
|
||||
val bonded = model.bondedAddress != null
|
||||
if (!bonded && usbDevice == null) // we will handle USB later
|
||||
showSettingsPage()
|
||||
}
|
||||
|
@ -727,6 +705,7 @@ class MainActivity : AppCompatActivity(), Logging {
|
|||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
||||
menu.findItem(R.id.stress_test).isVisible =
|
||||
BuildConfig.DEBUG // only show stress test for debug builds (for now)
|
||||
menu.findItem(R.id.radio_config).isEnabled = !model.isManaged
|
||||
return super.onPrepareOptionsMenu(menu)
|
||||
}
|
||||
|
||||
|
@ -769,8 +748,9 @@ class MainActivity : AppCompatActivity(), Logging {
|
|||
return true
|
||||
}
|
||||
R.id.radio_config -> {
|
||||
val node = model.ourNodeInfo.value ?: return true
|
||||
supportFragmentManager.beginTransaction()
|
||||
.add(R.id.mainActivityLayout, DeviceSettingsFragment())
|
||||
.add(R.id.mainActivityLayout, DeviceSettingsFragment(node))
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
return true
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.geeksville.mesh
|
|||
|
||||
import android.graphics.Color
|
||||
import android.os.Parcelable
|
||||
import com.geeksville.mesh.MeshProtos.User
|
||||
import com.geeksville.mesh.util.bearing
|
||||
import com.geeksville.mesh.util.latLongToMeter
|
||||
import com.geeksville.mesh.util.anonymize
|
||||
|
@ -27,6 +28,9 @@ data class MeshUser(
|
|||
return "MeshUser(id=${id.anonymize}, longName=${longName.anonymize}, shortName=${shortName.anonymize}, hwModel=${hwModelString}, isLicensed=${isLicensed})"
|
||||
}
|
||||
|
||||
fun toProto(): User = User.newBuilder().setId(id).setLongName(longName).setShortName(shortName)
|
||||
.setHwModel(hwModel).setIsLicensed(isLicensed).build()
|
||||
|
||||
/** a string version of the hardware model, converted into pretty lowercase and changing _ to -, and p to dot
|
||||
* or null if unset
|
||||
* */
|
||||
|
|
|
@ -108,7 +108,7 @@ class BTScanModel @Inject constructor(
|
|||
|
||||
private var scanner: BluetoothLeScanner? = null
|
||||
|
||||
val selectedBluetooth: Boolean get() = selectedAddress?.get(0) == 'x'
|
||||
val selectedBluetooth: Boolean get() = selectedAddress?.getOrNull(0) == 'x'
|
||||
|
||||
/// Use the string for the NopInterface
|
||||
val selectedNotNull: String get() = selectedAddress ?: "n"
|
||||
|
@ -141,9 +141,6 @@ class BTScanModel @Inject constructor(
|
|||
fullAddr,
|
||||
isBonded
|
||||
)
|
||||
// If nothing was selected, by default select the first valid thing we see
|
||||
if (selectedAddress == null && entry.bonded)
|
||||
changeDeviceAddress(fullAddr)
|
||||
addDevice(entry) // Add/replace entry
|
||||
}
|
||||
}
|
||||
|
@ -195,11 +192,6 @@ class BTScanModel @Inject constructor(
|
|||
)
|
||||
|
||||
devices.value = (testnodes.map { it.fullAddress to it }).toMap().toMutableMap()
|
||||
|
||||
// If nothing was selected, by default select the first thing we see
|
||||
if (selectedAddress == null)
|
||||
changeDeviceAddress(testnodes.first().fullAddress)
|
||||
|
||||
true
|
||||
} else {
|
||||
if (scanner == null) {
|
||||
|
@ -344,20 +336,10 @@ class BTScanModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private val _changeDeviceAddress = MutableLiveData<String?>(null)
|
||||
val changeDeviceAddress: LiveData<String?> get() = _changeDeviceAddress
|
||||
|
||||
/// Change to a new macaddr selection, updating GUI and radio
|
||||
fun changeDeviceAddress(newAddr: String) {
|
||||
info("Changing device to ${newAddr.anonymize}")
|
||||
_changeDeviceAddress.value = newAddr
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after activity observes changeDeviceAddress
|
||||
* Called immediately after activity calls MeshService.changeDeviceAddress
|
||||
*/
|
||||
fun changeSelectedAddress(newAddress: String) {
|
||||
_changeDeviceAddress.value = null
|
||||
selectedAddress = newAddress
|
||||
devices.value = devices.value // Force a GUI update
|
||||
}
|
||||
|
|
|
@ -15,6 +15,8 @@ import androidx.lifecycle.asLiveData
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.geeksville.mesh.android.Logging
|
||||
import com.geeksville.mesh.*
|
||||
import com.geeksville.mesh.ChannelProtos.ChannelSettings
|
||||
import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile
|
||||
import com.geeksville.mesh.ConfigProtos.Config
|
||||
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig
|
||||
import com.geeksville.mesh.database.MeshLogRepository
|
||||
|
@ -24,17 +26,17 @@ import com.geeksville.mesh.database.entity.MeshLog
|
|||
import com.geeksville.mesh.database.entity.QuickChatAction
|
||||
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
|
||||
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
|
||||
import com.geeksville.mesh.MeshProtos.User
|
||||
import com.geeksville.mesh.database.PacketRepository
|
||||
import com.geeksville.mesh.repository.datastore.ChannelSetRepository
|
||||
import com.geeksville.mesh.repository.datastore.LocalConfigRepository
|
||||
import com.geeksville.mesh.repository.datastore.ModuleConfigRepository
|
||||
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
|
||||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.util.GPSFormat
|
||||
import com.geeksville.mesh.util.positionToMeter
|
||||
import com.google.protobuf.MessageLite
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
@ -45,17 +47,17 @@ import kotlinx.coroutines.flow.mapLatest
|
|||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.osmdroid.bonuspack.kml.KmlDocument
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.FolderOverlay
|
||||
import java.io.BufferedWriter
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileOutputStream
|
||||
import java.io.FileWriter
|
||||
import java.io.InputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/// Given a human name, strip out the first letter of the first three words and return that as the initials for
|
||||
|
@ -84,11 +86,10 @@ fun getInitials(nameIn: String): String {
|
|||
@HiltViewModel
|
||||
class UIViewModel @Inject constructor(
|
||||
private val app: Application,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
private val meshLogRepository: MeshLogRepository,
|
||||
private val channelSetRepository: ChannelSetRepository,
|
||||
private val packetRepository: PacketRepository,
|
||||
private val localConfigRepository: LocalConfigRepository,
|
||||
private val moduleConfigRepository: ModuleConfigRepository,
|
||||
private val quickChatActionRepository: QuickChatActionRepository,
|
||||
private val preferences: SharedPreferences
|
||||
) : ViewModel(), Logging {
|
||||
|
@ -97,6 +98,9 @@ class UIViewModel @Inject constructor(
|
|||
var meshService: IMeshService? = null
|
||||
val nodeDB = NodeDB(this)
|
||||
|
||||
val bondedAddress get() = radioInterfaceService.getBondedDeviceAddress()
|
||||
val selectedBluetooth: Boolean get() = bondedAddress?.getOrNull(0) == 'x'
|
||||
|
||||
private val _meshLog = MutableStateFlow<List<MeshLog>>(emptyList())
|
||||
val meshLog: StateFlow<List<MeshLog>> = _meshLog
|
||||
|
||||
|
@ -120,6 +124,10 @@ class UIViewModel @Inject constructor(
|
|||
private val _ourNodeInfo = MutableStateFlow<NodeInfo?>(null)
|
||||
val ourNodeInfo: StateFlow<NodeInfo?> = _ourNodeInfo
|
||||
|
||||
private val requestId = MutableStateFlow<Int?>(null)
|
||||
private val _packetResponse = MutableStateFlow<MeshLog?>(null)
|
||||
val packetResponse: StateFlow<MeshLog?> = _packetResponse
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
meshLogRepository.getAllLogs().collect { logs ->
|
||||
|
@ -131,10 +139,10 @@ class UIViewModel @Inject constructor(
|
|||
_packets.value = packets
|
||||
}
|
||||
}
|
||||
localConfigRepository.localConfigFlow.onEach { config ->
|
||||
radioConfigRepository.localConfigFlow.onEach { config ->
|
||||
_localConfig.value = config
|
||||
}.launchIn(viewModelScope)
|
||||
moduleConfigRepository.moduleConfigFlow.onEach { config ->
|
||||
radioConfigRepository.moduleConfigFlow.onEach { config ->
|
||||
_moduleConfig.value = config
|
||||
}.launchIn(viewModelScope)
|
||||
viewModelScope.launch {
|
||||
|
@ -142,12 +150,18 @@ class UIViewModel @Inject constructor(
|
|||
_quickChatActions.value = actions
|
||||
}
|
||||
}
|
||||
channelSetRepository.channelSetFlow.onEach { channelSet ->
|
||||
radioConfigRepository.channelSetFlow.onEach { channelSet ->
|
||||
_channels.value = ChannelSet(channelSet)
|
||||
}.launchIn(viewModelScope)
|
||||
combine(nodeDB.nodes.asFlow(), nodeDB.myId.asFlow()) { nodes, id -> nodes[id] }.onEach {
|
||||
_ourNodeInfo.value = it
|
||||
}.launchIn(viewModelScope)
|
||||
|
||||
combine(meshLog, requestId) { packet, requestId ->
|
||||
if (requestId != null) _packetResponse.value =
|
||||
packet.firstOrNull { it.meshPacket?.decoded?.requestId == requestId }
|
||||
}.launchIn(viewModelScope)
|
||||
|
||||
debug("ViewModel created")
|
||||
}
|
||||
|
||||
|
@ -174,42 +188,12 @@ class UIViewModel @Inject constructor(
|
|||
.filterValues { it.data.waypoint!!.expire > System.currentTimeMillis() / 1000 }
|
||||
}.asLiveData()
|
||||
|
||||
private val _packetResponse = MutableStateFlow<MeshLog?>(null)
|
||||
val packetResponse: StateFlow<MeshLog?> = _packetResponse
|
||||
|
||||
/**
|
||||
* Called immediately after activity observes packetResponse
|
||||
*/
|
||||
fun clearPacketResponse() {
|
||||
_packetResponse.tryEmit(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the packet response to a given [packetId] or null after [timeout] milliseconds
|
||||
*/
|
||||
private suspend fun getResponseBy(packetId: Int, timeout: Long) = withContext(Dispatchers.IO) {
|
||||
withTimeoutOrNull(timeout) {
|
||||
var packet: MeshLog? = null
|
||||
while (packet == null) {
|
||||
packet = _meshLog.value.lastOrNull { it.meshPacket?.decoded?.requestId == packetId }
|
||||
if (packet == null) delay(1000)
|
||||
}
|
||||
packet
|
||||
}
|
||||
}
|
||||
|
||||
fun requestTraceroute(destNum: Int) = viewModelScope.launch {
|
||||
meshService?.let { service ->
|
||||
try {
|
||||
val packetId = service.packetId
|
||||
val waitFactor = (service.nodes.count { it.isOnline } - 1)
|
||||
.coerceAtMost(config.lora.hopLimit)
|
||||
service.requestTraceroute(packetId, destNum)
|
||||
_packetResponse.emit(getResponseBy(packetId, 20000L * waitFactor))
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("Request traceroute error: ${ex.message}")
|
||||
}
|
||||
}
|
||||
requestId.value = null
|
||||
_packetResponse.value = null
|
||||
}
|
||||
|
||||
fun generatePacketId(): Int? {
|
||||
|
@ -247,6 +231,99 @@ class UIViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun request(
|
||||
destNum: Int,
|
||||
requestAction: suspend (IMeshService, Int, Int) -> Unit,
|
||||
errorMessage: String,
|
||||
configType: Int = 0
|
||||
) = viewModelScope.launch {
|
||||
meshService?.let { service ->
|
||||
val packetId = service.packetId
|
||||
try {
|
||||
requestAction(service, packetId, destNum)
|
||||
requestId.value = packetId
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("$errorMessage: ${ex.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getOwner(destNum: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getRemoteOwner(packetId, dest) },
|
||||
"Request getOwner error"
|
||||
)
|
||||
|
||||
fun getChannel(destNum: Int, index: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getRemoteChannel(packetId, dest, index) },
|
||||
"Request getChannel error"
|
||||
)
|
||||
|
||||
fun getConfig(destNum: Int, configType: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getRemoteConfig(packetId, dest, configType) },
|
||||
"Request getConfig error",
|
||||
configType
|
||||
)
|
||||
|
||||
fun getModuleConfig(destNum: Int, configType: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getModuleConfig(packetId, dest, configType) },
|
||||
"Request getModuleConfig error",
|
||||
configType
|
||||
)
|
||||
|
||||
fun setRingtone(destNum: Int, ringtone: String) {
|
||||
meshService?.setRingtone(destNum, ringtone)
|
||||
}
|
||||
|
||||
fun getRingtone(destNum: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getRingtone(packetId, dest) },
|
||||
"Request getRingtone error"
|
||||
)
|
||||
|
||||
fun setCannedMessages(destNum: Int, messages: String) {
|
||||
meshService?.setCannedMessages(destNum, messages)
|
||||
}
|
||||
|
||||
fun getCannedMessages(destNum: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getCannedMessages(packetId, dest) },
|
||||
"Request getCannedMessages error"
|
||||
)
|
||||
|
||||
fun requestTraceroute(destNum: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.requestTraceroute(packetId, dest) },
|
||||
"Request traceroute error"
|
||||
)
|
||||
|
||||
fun requestShutdown(destNum: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.requestShutdown(packetId, dest) },
|
||||
"Request shutdown error"
|
||||
)
|
||||
|
||||
fun requestReboot(destNum: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.requestReboot(packetId, dest) },
|
||||
"Request reboot error"
|
||||
)
|
||||
|
||||
fun requestFactoryReset(destNum: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.requestFactoryReset(packetId, dest) },
|
||||
"Request factory reset error"
|
||||
)
|
||||
|
||||
fun requestNodedbReset(destNum: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.requestNodedbReset(packetId, dest) },
|
||||
"Request NodeDB reset error"
|
||||
)
|
||||
|
||||
fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) {
|
||||
try {
|
||||
meshService?.requestPosition(destNum, position)
|
||||
|
@ -322,12 +399,8 @@ class UIViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
val isRouter: Boolean = config.device.role == Config.DeviceConfig.Role.ROUTER
|
||||
|
||||
// We consider hasWifi = ESP32
|
||||
fun hasGPS() = myNodeInfo.value?.hasGPS == true
|
||||
fun hasWifi() = myNodeInfo.value?.hasWifi == true
|
||||
// managed mode disables all access to configuration
|
||||
val isManaged: Boolean get() = config.device.isManaged
|
||||
|
||||
/// hardware info about our local device (can be null)
|
||||
private val _myNodeInfo = MutableLiveData<MyNodeInfo?>()
|
||||
|
@ -353,125 +426,79 @@ class UIViewModel @Inject constructor(
|
|||
try {
|
||||
// Pull down our real node ID - This must be done AFTER reading the nodedb because we need the DB to find our nodeinof object
|
||||
nodeDB.setMyId(service.myId)
|
||||
val ownerName = nodes[service.myId]?.user?.longName
|
||||
_ownerName.value = ownerName
|
||||
} catch (ex: Exception) {
|
||||
warn("Ignoring failure to get myId, service is probably just uninited... ${ex.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun updateDeviceConfig(crossinline body: (Config.DeviceConfig) -> Config.DeviceConfig) {
|
||||
val data = body(config.device)
|
||||
setConfig(config { device = data })
|
||||
}
|
||||
|
||||
inline fun updatePositionConfig(crossinline body: (Config.PositionConfig) -> Config.PositionConfig) {
|
||||
val data = body(config.position)
|
||||
setConfig(config { position = data })
|
||||
}
|
||||
|
||||
inline fun updatePowerConfig(crossinline body: (Config.PowerConfig) -> Config.PowerConfig) {
|
||||
val data = body(config.power)
|
||||
setConfig(config { power = data })
|
||||
}
|
||||
|
||||
inline fun updateNetworkConfig(crossinline body: (Config.NetworkConfig) -> Config.NetworkConfig) {
|
||||
val data = body(config.network)
|
||||
setConfig(config { network = data })
|
||||
}
|
||||
|
||||
inline fun updateDisplayConfig(crossinline body: (Config.DisplayConfig) -> Config.DisplayConfig) {
|
||||
val data = body(config.display)
|
||||
setConfig(config { display = data })
|
||||
}
|
||||
|
||||
inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) {
|
||||
private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) {
|
||||
val data = body(config.lora)
|
||||
setConfig(config { lora = data })
|
||||
}
|
||||
|
||||
inline fun updateBluetoothConfig(crossinline body: (Config.BluetoothConfig) -> Config.BluetoothConfig) {
|
||||
val data = body(config.bluetooth)
|
||||
setConfig(config { bluetooth = data })
|
||||
}
|
||||
|
||||
// Set the radio config (also updates our saved copy in preferences)
|
||||
fun setConfig(config: Config) {
|
||||
meshService?.setConfig(config.toByteArray())
|
||||
}
|
||||
|
||||
inline fun updateMQTTConfig(crossinline body: (ModuleConfig.MQTTConfig) -> ModuleConfig.MQTTConfig) {
|
||||
val data = body(module.mqtt)
|
||||
setModuleConfig(moduleConfig { mqtt = data })
|
||||
fun setRemoteConfig(destNum: Int, config: Config) {
|
||||
meshService?.setRemoteConfig(destNum, config.toByteArray())
|
||||
}
|
||||
|
||||
inline fun updateSerialConfig(crossinline body: (ModuleConfig.SerialConfig) -> ModuleConfig.SerialConfig) {
|
||||
val data = body(module.serial)
|
||||
setModuleConfig(moduleConfig { serial = data })
|
||||
}
|
||||
|
||||
inline fun updateExternalNotificationConfig(crossinline body: (ModuleConfig.ExternalNotificationConfig) -> ModuleConfig.ExternalNotificationConfig) {
|
||||
val data = body(module.externalNotification)
|
||||
setModuleConfig(moduleConfig { externalNotification = data })
|
||||
}
|
||||
|
||||
inline fun updateStoreForwardConfig(crossinline body: (ModuleConfig.StoreForwardConfig) -> ModuleConfig.StoreForwardConfig) {
|
||||
val data = body(module.storeForward)
|
||||
setModuleConfig(moduleConfig { storeForward = data })
|
||||
}
|
||||
|
||||
inline fun updateRangeTestConfig(crossinline body: (ModuleConfig.RangeTestConfig) -> ModuleConfig.RangeTestConfig) {
|
||||
val data = body(module.rangeTest)
|
||||
setModuleConfig(moduleConfig { rangeTest = data })
|
||||
}
|
||||
|
||||
inline fun updateTelemetryConfig(crossinline body: (ModuleConfig.TelemetryConfig) -> ModuleConfig.TelemetryConfig) {
|
||||
val data = body(module.telemetry)
|
||||
setModuleConfig(moduleConfig { telemetry = data })
|
||||
}
|
||||
|
||||
inline fun updateCannedMessageConfig(crossinline body: (ModuleConfig.CannedMessageConfig) -> ModuleConfig.CannedMessageConfig) {
|
||||
val data = body(module.cannedMessage)
|
||||
setModuleConfig(moduleConfig { cannedMessage = data })
|
||||
}
|
||||
|
||||
inline fun updateAudioConfig(crossinline body: (ModuleConfig.AudioConfig) -> ModuleConfig.AudioConfig) {
|
||||
val data = body(module.audio)
|
||||
setModuleConfig(moduleConfig { audio = data })
|
||||
}
|
||||
|
||||
inline fun updateRemoteHardwareConfig(crossinline body: (ModuleConfig.RemoteHardwareConfig) -> ModuleConfig.RemoteHardwareConfig) {
|
||||
val data = body(module.remoteHardware)
|
||||
setModuleConfig(moduleConfig { remoteHardware = data })
|
||||
fun setModuleConfig(destNum: Int, config: ModuleConfig) {
|
||||
meshService?.setModuleConfig(destNum, config.toByteArray())
|
||||
}
|
||||
|
||||
fun setModuleConfig(config: ModuleConfig) {
|
||||
meshService?.setModuleConfig(config.toByteArray())
|
||||
setModuleConfig(myNodeNum ?: return, config)
|
||||
}
|
||||
|
||||
/// Convert the channels array to and from [AppOnlyProtos.ChannelSet]
|
||||
private var _channelSet: AppOnlyProtos.ChannelSet
|
||||
get() = channels.value.protobuf
|
||||
set(value) {
|
||||
(0 until max(_channelSet.settingsCount, value.settingsCount)).map { i ->
|
||||
channel {
|
||||
/**
|
||||
* Updates channels to match the [new] list. Only channels with changes are updated.
|
||||
*
|
||||
* @param destNum Destination node number.
|
||||
* @param old The current [ChannelSettings] list.
|
||||
* @param new The updated [ChannelSettings] list.
|
||||
*/
|
||||
fun updateChannels(
|
||||
destNum: Int,
|
||||
old: List<ChannelSettings>,
|
||||
new: List<ChannelSettings>,
|
||||
) {
|
||||
buildList {
|
||||
for (i in 0..maxOf(old.lastIndex, new.lastIndex)) {
|
||||
if (old.getOrNull(i) != new.getOrNull(i)) add(channel {
|
||||
role = when (i) {
|
||||
0 -> ChannelProtos.Channel.Role.PRIMARY
|
||||
in 1 until value.settingsCount -> ChannelProtos.Channel.Role.SECONDARY
|
||||
in 1..new.lastIndex -> ChannelProtos.Channel.Role.SECONDARY
|
||||
else -> ChannelProtos.Channel.Role.DISABLED
|
||||
}
|
||||
index = i
|
||||
settings = value.settingsList.getOrNull(i) ?: channelSettings { }
|
||||
}
|
||||
}.forEach {
|
||||
meshService?.setChannel(it.toByteArray())
|
||||
settings = new.getOrNull(i) ?: channelSettings { }
|
||||
})
|
||||
}
|
||||
}.forEach { setRemoteChannel(destNum, it) }
|
||||
|
||||
viewModelScope.launch {
|
||||
channelSetRepository.clearSettings()
|
||||
channelSetRepository.addAllSettings(value)
|
||||
}
|
||||
if (destNum == myNodeNum) viewModelScope.launch {
|
||||
radioConfigRepository.replaceAllSettings(new)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateChannels(
|
||||
old: List<ChannelSettings>,
|
||||
new: List<ChannelSettings>
|
||||
) {
|
||||
updateChannels(myNodeNum ?: return, old, new)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the [channels] array to and from [ChannelSet]
|
||||
*/
|
||||
private var _channelSet: AppOnlyProtos.ChannelSet
|
||||
get() = channels.value.protobuf
|
||||
set(value) {
|
||||
updateChannels(channelSet.settingsList, value.settingsList)
|
||||
|
||||
val newConfig = config { lora = value.loraConfig }
|
||||
if (config.lora != newConfig.lora) setConfig(newConfig)
|
||||
|
@ -480,15 +507,16 @@ class UIViewModel @Inject constructor(
|
|||
|
||||
/// Set the radio config (also updates our saved copy in preferences)
|
||||
fun setChannels(channelSet: ChannelSet) {
|
||||
debug("Setting new channels!")
|
||||
this._channelSet = channelSet.protobuf
|
||||
}
|
||||
|
||||
/// our name in hte radio
|
||||
/// Note, we generate owner initials automatically for now
|
||||
/// our activity will read this from prefs or set it to the empty string
|
||||
private val _ownerName = MutableLiveData<String?>()
|
||||
val ownerName: LiveData<String?> get() = _ownerName
|
||||
private fun setRemoteChannel(destNum: Int, channel: ChannelProtos.Channel) {
|
||||
try {
|
||||
meshService?.setRemoteChannel(destNum, channel.toByteArray())
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("Can't set channel on radio ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val provideLocation = object : MutableLiveData<Boolean>(preferences.getBoolean("provide-location", false)) {
|
||||
override fun setValue(value: Boolean) {
|
||||
|
@ -500,59 +528,22 @@ class UIViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun setOwner(user: MeshUser) = with(user) {
|
||||
fun setOwner(user: User) {
|
||||
setRemoteOwner(myNodeNum ?: return, user)
|
||||
}
|
||||
|
||||
longName.trim().let { ownerName ->
|
||||
// note: we allow an empty user string to be written to prefs
|
||||
_ownerName.value = ownerName
|
||||
preferences.edit { putString("owner", ownerName) }
|
||||
fun setRemoteOwner(destNum: Int, user: User) {
|
||||
try {
|
||||
// Note: we use ?. here because we might be running in the emulator
|
||||
meshService?.setRemoteOwner(destNum, user.toByteArray())
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("Can't set username on device, is device offline? ${ex.message}")
|
||||
}
|
||||
|
||||
// Note: we are careful to not set a new unique ID
|
||||
if (_ownerName.value!!.isNotEmpty())
|
||||
try {
|
||||
// Note: we use ?. here because we might be running in the emulator
|
||||
meshService?.setOwner(user)
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("Can't set username on device, is device offline? ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val adminChannelIndex: Int
|
||||
get() = channelSet.settingsList.map { it.name.lowercase() }.indexOf("admin")
|
||||
|
||||
fun requestShutdown(idNum: Int) {
|
||||
try {
|
||||
meshService?.requestShutdown(idNum)
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("RemoteException: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun requestReboot(idNum: Int) {
|
||||
try {
|
||||
meshService?.requestReboot(idNum)
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("RemoteException: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun requestFactoryReset(idNum: Int) {
|
||||
try {
|
||||
meshService?.requestFactoryReset(idNum)
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("RemoteException: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun requestNodedbReset(idNum: Int) {
|
||||
try {
|
||||
meshService?.requestNodedbReset(idNum)
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("RemoteException: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the persisted packet data out to a CSV file in the specified location.
|
||||
*/
|
||||
|
@ -584,7 +575,7 @@ class UIViewModel @Inject constructor(
|
|||
meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
|
||||
// If we get a NodeInfo packet, use it to update our position data (if valid)
|
||||
packet.nodeInfo?.let { nodeInfo ->
|
||||
positionToPos.invoke(nodeInfo.position)?.let { _ ->
|
||||
positionToPos.invoke(nodeInfo.position)?.let {
|
||||
nodePositions[nodeInfo.num] = nodeInfo.position
|
||||
}
|
||||
}
|
||||
|
@ -592,7 +583,7 @@ class UIViewModel @Inject constructor(
|
|||
packet.meshPacket?.let { proto ->
|
||||
// If the packet contains position data then use it to update, if valid
|
||||
packet.position?.let { position ->
|
||||
positionToPos.invoke(position)?.let { _ ->
|
||||
positionToPos.invoke(position)?.let {
|
||||
nodePositions[proto.from] = position
|
||||
}
|
||||
}
|
||||
|
@ -616,7 +607,7 @@ class UIViewModel @Inject constructor(
|
|||
val rxLat = rxPos?.latitude ?: ""
|
||||
val rxLong = rxPos?.longitude ?: ""
|
||||
val rxAlt = rxPos?.altitude ?: ""
|
||||
val rxSnr = "%f".format(proto.rxSnr)
|
||||
val rxSnr = "%f".format(proto.rxSnr, Locale.US)
|
||||
|
||||
// Calculate the distance if both positions are valid
|
||||
|
||||
|
@ -664,6 +655,82 @@ class UIViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private val _deviceProfile = MutableStateFlow<DeviceProfile?>(null)
|
||||
val deviceProfile: StateFlow<DeviceProfile?> = _deviceProfile
|
||||
|
||||
fun setDeviceProfile(deviceProfile: DeviceProfile?) {
|
||||
_deviceProfile.value = deviceProfile
|
||||
}
|
||||
|
||||
fun importProfile(file_uri: Uri) = viewModelScope.launch(Dispatchers.Main) {
|
||||
withContext(Dispatchers.IO) {
|
||||
var inputStream: InputStream? = null
|
||||
try {
|
||||
inputStream = app.contentResolver.openInputStream(file_uri)
|
||||
val bytes = inputStream?.readBytes()
|
||||
val protobuf = DeviceProfile.parseFrom(bytes)
|
||||
_deviceProfile.value = protobuf
|
||||
} catch (ex: Exception) {
|
||||
errormsg("Failed to import radio configs: ${ex.message}")
|
||||
} finally {
|
||||
inputStream?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun exportProfile(file_uri: Uri) = viewModelScope.launch {
|
||||
val profile = deviceProfile.value ?: return@launch
|
||||
writeToUri(file_uri, profile)
|
||||
_deviceProfile.value = null
|
||||
}
|
||||
|
||||
private suspend fun writeToUri(uri: Uri, message: MessageLite) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
|
||||
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
|
||||
message.writeTo(outputStream)
|
||||
}
|
||||
}
|
||||
} catch (ex: FileNotFoundException) {
|
||||
errormsg("Can't write file error: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun installProfile(protobuf: DeviceProfile) = with(protobuf) {
|
||||
_deviceProfile.value = null
|
||||
// meshService?.beginEditSettings()
|
||||
if (hasLongName() || hasShortName()) ourNodeInfo.value?.user?.let {
|
||||
val user = it.copy(
|
||||
longName = if (hasLongName()) longName else it.longName,
|
||||
shortName = if (hasShortName()) shortName else it.shortName
|
||||
)
|
||||
setOwner(user.toProto())
|
||||
}
|
||||
if (hasChannelUrl()) {
|
||||
setChannels(ChannelSet(Uri.parse(channelUrl)))
|
||||
}
|
||||
if (hasConfig()) {
|
||||
setConfig(config { device = config.device })
|
||||
setConfig(config { position = config.position })
|
||||
setConfig(config { power = config.power })
|
||||
setConfig(config { network = config.network })
|
||||
setConfig(config { display = config.display })
|
||||
setConfig(config { lora = config.lora })
|
||||
setConfig(config { bluetooth = config.bluetooth })
|
||||
}
|
||||
if (hasModuleConfig()) moduleConfig.let {
|
||||
setModuleConfig(moduleConfig { mqtt = it.mqtt })
|
||||
setModuleConfig(moduleConfig { serial = it.serial })
|
||||
setModuleConfig(moduleConfig { externalNotification = it.externalNotification })
|
||||
setModuleConfig(moduleConfig { storeForward = it.storeForward })
|
||||
setModuleConfig(moduleConfig { rangeTest = it.rangeTest })
|
||||
setModuleConfig(moduleConfig { telemetry = it.telemetry })
|
||||
setModuleConfig(moduleConfig { cannedMessage = it.cannedMessage })
|
||||
setModuleConfig(moduleConfig { audio = it.audio })
|
||||
setModuleConfig(moduleConfig { remoteHardware = it.remoteHardware })
|
||||
}
|
||||
// meshService?.commitEditSettings()
|
||||
}
|
||||
|
||||
fun parseUrl(url: String, map: MapView) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
|
|
|
@ -3,16 +3,17 @@ package com.geeksville.mesh.repository.datastore
|
|||
import androidx.datastore.core.DataStore
|
||||
import com.geeksville.mesh.android.Logging
|
||||
import com.geeksville.mesh.AppOnlyProtos.ChannelSet
|
||||
import com.geeksville.mesh.ChannelProtos
|
||||
import com.geeksville.mesh.ChannelProtos.Channel
|
||||
import com.geeksville.mesh.ChannelProtos.ChannelSettings
|
||||
import com.geeksville.mesh.ConfigProtos
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Class that handles saving and retrieving channel settings
|
||||
* Class that handles saving and retrieving [ChannelSet] data.
|
||||
*/
|
||||
class ChannelSetRepository @Inject constructor(
|
||||
private val channelSetStore: DataStore<ChannelSet>
|
||||
|
@ -40,15 +41,22 @@ class ChannelSetRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun addSettings(channel: ChannelProtos.Channel) {
|
||||
suspend fun addAllSettings(settingsList: List<ChannelSettings>) {
|
||||
channelSetStore.updateData { preference ->
|
||||
preference.toBuilder().addSettings(channel.settings).build()
|
||||
preference.toBuilder().addAllSettings(settingsList).build()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addAllSettings(channelSet: ChannelSet) {
|
||||
/**
|
||||
* Updates the [ChannelSettings] list with the provided channel.
|
||||
*/
|
||||
suspend fun updateChannelSettings(channel: Channel) {
|
||||
channelSetStore.updateData { preference ->
|
||||
preference.toBuilder().addAllSettings(channelSet.settingsList).build()
|
||||
if (preference.settingsCount > channel.index) {
|
||||
preference.toBuilder().setSettings(channel.index, channel.settings).build()
|
||||
} else {
|
||||
preference.toBuilder().addSettings(channel.settings).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -58,6 +66,6 @@ class ChannelSetRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun fetchInitialChannelSet() = channelSetStore.data.first()
|
||||
suspend fun fetchInitialChannelSet() = channelSetStore.data.firstOrNull()
|
||||
|
||||
}
|
||||
|
|
|
@ -5,19 +5,16 @@ import com.geeksville.mesh.android.Logging
|
|||
import com.geeksville.mesh.ConfigProtos.Config
|
||||
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Class that handles saving and retrieving config settings
|
||||
* Class that handles saving and retrieving [LocalConfig] data.
|
||||
*/
|
||||
class LocalConfigRepository @Inject constructor(
|
||||
private val localConfigStore: DataStore<LocalConfig>,
|
||||
private val channelSetRepository: ChannelSetRepository,
|
||||
) : Logging {
|
||||
val localConfigFlow: Flow<LocalConfig> = localConfigStore.data
|
||||
.catch { exception ->
|
||||
|
@ -30,17 +27,6 @@ class LocalConfigRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private val _setConfigFlow = MutableSharedFlow<Config>()
|
||||
val setConfigFlow: SharedFlow<Config> = _setConfigFlow
|
||||
|
||||
/**
|
||||
* Update LocalConfig and send ConfigProtos.Config Oneof to the radio
|
||||
*/
|
||||
suspend fun setConfig(config: Config) {
|
||||
setLocalConfig(config)
|
||||
_setConfigFlow.emit(config)
|
||||
}
|
||||
|
||||
suspend fun clearLocalConfig() {
|
||||
localConfigStore.updateData { preference ->
|
||||
preference.toBuilder().clear().build()
|
||||
|
@ -48,7 +34,7 @@ class LocalConfigRepository @Inject constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Update LocalConfig from each ConfigProtos.Config Oneof
|
||||
* Updates [LocalConfig] from each [Config] oneOf.
|
||||
*/
|
||||
suspend fun setLocalConfig(config: Config) {
|
||||
if (config.hasDevice()) setDeviceConfig(config.device)
|
||||
|
@ -94,7 +80,6 @@ class LocalConfigRepository @Inject constructor(
|
|||
localConfigStore.updateData { preference ->
|
||||
preference.toBuilder().setLora(config).build()
|
||||
}
|
||||
channelSetRepository.setLoraConfig(config)
|
||||
}
|
||||
|
||||
private suspend fun setBluetoothConfig(config: Config.BluetoothConfig) {
|
||||
|
|
|
@ -11,7 +11,7 @@ import java.io.IOException
|
|||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Class that handles saving and retrieving config settings
|
||||
* Class that handles saving and retrieving [LocalModuleConfig] data.
|
||||
*/
|
||||
class ModuleConfigRepository @Inject constructor(
|
||||
private val moduleConfigStore: DataStore<LocalModuleConfig>,
|
||||
|
@ -34,7 +34,7 @@ class ModuleConfigRepository @Inject constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Update LocalModuleConfig from each ModuleConfigProtos.ModuleConfig Oneof
|
||||
* Updates [LocalModuleConfig] from each [ModuleConfig] oneOf.
|
||||
*/
|
||||
suspend fun setLocalModuleConfig(config: ModuleConfig) {
|
||||
if (config.hasMqtt()) setMQTTConfig(config.mqtt)
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
package com.geeksville.mesh.repository.datastore
|
||||
|
||||
import com.geeksville.mesh.AppOnlyProtos.ChannelSet
|
||||
import com.geeksville.mesh.ChannelProtos.Channel
|
||||
import com.geeksville.mesh.ChannelProtos.ChannelSettings
|
||||
import com.geeksville.mesh.ConfigProtos.Config
|
||||
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
|
||||
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
|
||||
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Class responsible for radio configuration data.
|
||||
* Combines access to [ChannelSet], [LocalConfig] & [LocalModuleConfig] data stores.
|
||||
*/
|
||||
class RadioConfigRepository @Inject constructor(
|
||||
private val channelSetRepository: ChannelSetRepository,
|
||||
private val localConfigRepository: LocalConfigRepository,
|
||||
private val moduleConfigRepository: ModuleConfigRepository,
|
||||
) {
|
||||
/**
|
||||
* Flow representing the [ChannelSet] data store.
|
||||
*/
|
||||
val channelSetFlow: Flow<ChannelSet> = channelSetRepository.channelSetFlow
|
||||
|
||||
/**
|
||||
* Clears the [ChannelSet] data in the data store.
|
||||
*/
|
||||
suspend fun clearChannelSet() {
|
||||
channelSetRepository.clearChannelSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the [ChannelSettings] list with a new [settingsList].
|
||||
*/
|
||||
suspend fun replaceAllSettings(settingsList: List<ChannelSettings>) {
|
||||
channelSetRepository.clearSettings()
|
||||
channelSetRepository.addAllSettings(settingsList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the [ChannelSettings] list with the provided channel and returns the index of the
|
||||
* admin channel after the update (if not found, returns 0).
|
||||
* @param channel The [Channel] provided.
|
||||
* @return the index of the admin channel after the update (if not found, returns 0).
|
||||
*/
|
||||
suspend fun updateChannelSettings(channel: Channel) {
|
||||
return channelSetRepository.updateChannelSettings(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow representing the [LocalConfig] data store.
|
||||
*/
|
||||
val localConfigFlow: Flow<LocalConfig> = localConfigRepository.localConfigFlow
|
||||
|
||||
/**
|
||||
* Clears the [LocalConfig] data in the data store.
|
||||
*/
|
||||
suspend fun clearLocalConfig() {
|
||||
localConfigRepository.clearLocalConfig()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates [LocalConfig] from each [Config] oneOf.
|
||||
* @param config The [Config] to be set.
|
||||
*/
|
||||
suspend fun setLocalConfig(config: Config) {
|
||||
localConfigRepository.setLocalConfig(config)
|
||||
if (config.hasLora()) channelSetRepository.setLoraConfig(config.lora)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow representing the [LocalModuleConfig] data store.
|
||||
*/
|
||||
val moduleConfigFlow: Flow<LocalModuleConfig> = moduleConfigRepository.moduleConfigFlow
|
||||
|
||||
/**
|
||||
* Clears the [LocalModuleConfig] data in the data store.
|
||||
*/
|
||||
suspend fun clearLocalModuleConfig() {
|
||||
moduleConfigRepository.clearLocalModuleConfig()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates [LocalModuleConfig] from each [ModuleConfig] oneOf.
|
||||
* @param config The [ModuleConfig] to be set.
|
||||
*/
|
||||
suspend fun setLocalModuleConfig(config: ModuleConfig) {
|
||||
moduleConfigRepository.setLocalModuleConfig(config)
|
||||
}
|
||||
}
|
|
@ -21,9 +21,7 @@ import com.geeksville.mesh.database.PacketRepository
|
|||
import com.geeksville.mesh.database.entity.MeshLog
|
||||
import com.geeksville.mesh.database.entity.Packet
|
||||
import com.geeksville.mesh.model.DeviceVersion
|
||||
import com.geeksville.mesh.repository.datastore.ChannelSetRepository
|
||||
import com.geeksville.mesh.repository.datastore.LocalConfigRepository
|
||||
import com.geeksville.mesh.repository.datastore.ModuleConfigRepository
|
||||
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
|
||||
import com.geeksville.mesh.repository.location.LocationRepository
|
||||
import com.geeksville.mesh.repository.radio.BluetoothInterface
|
||||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
|
@ -75,13 +73,7 @@ class MeshService : Service(), Logging {
|
|||
lateinit var locationRepository: LocationRepository
|
||||
|
||||
@Inject
|
||||
lateinit var localConfigRepository: LocalConfigRepository
|
||||
|
||||
@Inject
|
||||
lateinit var moduleConfigRepository: ModuleConfigRepository
|
||||
|
||||
@Inject
|
||||
lateinit var channelSetRepository: ChannelSetRepository
|
||||
lateinit var radioConfigRepository: RadioConfigRepository
|
||||
|
||||
companion object : Logging {
|
||||
|
||||
|
@ -250,9 +242,9 @@ class MeshService : Service(), Logging {
|
|||
.launchIn(serviceScope)
|
||||
radioInterfaceService.receivedData.onEach(::onReceiveFromRadio)
|
||||
.launchIn(serviceScope)
|
||||
localConfigRepository.localConfigFlow.onEach { localConfig = it }
|
||||
radioConfigRepository.localConfigFlow.onEach { localConfig = it }
|
||||
.launchIn(serviceScope)
|
||||
channelSetRepository.channelSetFlow.onEach { channelSet = it }
|
||||
radioConfigRepository.channelSetFlow.onEach { channelSet = it }
|
||||
.launchIn(serviceScope)
|
||||
|
||||
// the rest of our init will happen once we are in radioConnection.onServiceConnected
|
||||
|
@ -464,7 +456,9 @@ class MeshService : Service(), Logging {
|
|||
private val myNodeID get() = toNodeID(myNodeNum)
|
||||
|
||||
/// Admin channel index
|
||||
private var adminChannelIndex: Int = 0
|
||||
private val adminChannelIndex: Int
|
||||
get() = channelSet.settingsList.indexOfFirst { it.name.lowercase() == "admin" }
|
||||
.coerceAtLeast(0)
|
||||
|
||||
/// Generate a new mesh packet builder with our node as the sender, and the specified node num
|
||||
private fun newMeshPacketTo(idNum: Int) = MeshPacket.newBuilder().apply {
|
||||
|
@ -481,8 +475,7 @@ class MeshService : Service(), Logging {
|
|||
*
|
||||
* If id is null we assume a broadcast message
|
||||
*/
|
||||
private fun newMeshPacketTo(id: String) =
|
||||
newMeshPacketTo(toNodeNum(id))
|
||||
private fun newMeshPacketTo(id: String) = newMeshPacketTo(toNodeNum(id))
|
||||
|
||||
/**
|
||||
* Helper to make it easy to build a subpacket in the proper protobufs
|
||||
|
@ -490,7 +483,7 @@ class MeshService : Service(), Logging {
|
|||
private fun MeshPacket.Builder.buildMeshPacket(
|
||||
wantAck: Boolean = false,
|
||||
id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one
|
||||
hopLimit: Int = 0,
|
||||
hopLimit: Int = localConfig.lora.hopLimit,
|
||||
channel: Int = 0,
|
||||
priority: MeshPacket.Priority = MeshPacket.Priority.UNSET,
|
||||
initFn: MeshProtos.Data.Builder.() -> Unit
|
||||
|
@ -512,9 +505,11 @@ class MeshService : Service(), Logging {
|
|||
* Helper to make it easy to build a subpacket in the proper protobufs
|
||||
*/
|
||||
private fun MeshPacket.Builder.buildAdminPacket(
|
||||
id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one
|
||||
wantResponse: Boolean = false,
|
||||
initFn: AdminProtos.AdminMessage.Builder.() -> Unit
|
||||
): MeshPacket = buildMeshPacket(
|
||||
id = id,
|
||||
wantAck = true,
|
||||
channel = adminChannelIndex,
|
||||
priority = MeshPacket.Priority.RELIABLE
|
||||
|
@ -625,6 +620,7 @@ class MeshService : Service(), Logging {
|
|||
|
||||
// Handle new style position info
|
||||
Portnums.PortNum.POSITION_APP_VALUE -> {
|
||||
if (data.wantResponse) return // ignore data from position requests
|
||||
var u = MeshProtos.Position.parseFrom(data.payload)
|
||||
// position updates from mesh usually don't include times. So promote rx time
|
||||
if (u.time == 0 && packet.rxTime != 0)
|
||||
|
@ -737,10 +733,11 @@ class MeshService : Service(), Logging {
|
|||
p: MeshProtos.Position,
|
||||
defaultTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
// Nodes periodically send out position updates, but those updates might not contain valid data so
|
||||
// Nodes periodically send out position updates, but those updates might not contain a lat & lon (because no GPS lock)
|
||||
// We like to look at the local node to see if it has been sending out valid lat/lon, so for the LOCAL node (only)
|
||||
// we don't record these nop position updates
|
||||
if (!Position(p).isValid() && currentSecond() - p.time > 2592000) // 30 days in seconds
|
||||
debug("Ignoring nop position update for node $fromNum")
|
||||
if (myNodeNum == fromNum && p.latitudeI == 0 && p.longitudeI == 0)
|
||||
debug("Ignoring nop position update for the local node")
|
||||
else
|
||||
updateNodeInfo(fromNum) {
|
||||
debug("update position: ${it.user?.longName?.toPIIString()} with ${p.toPIIString()}")
|
||||
|
@ -945,28 +942,25 @@ class MeshService : Service(), Logging {
|
|||
|
||||
private fun setLocalConfig(config: ConfigProtos.Config) {
|
||||
serviceScope.handledLaunch {
|
||||
localConfigRepository.setLocalConfig(config)
|
||||
radioConfigRepository.setLocalConfig(config)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setLocalModuleConfig(config: ModuleConfigProtos.ModuleConfig) {
|
||||
serviceScope.handledLaunch {
|
||||
moduleConfigRepository.setLocalModuleConfig(config)
|
||||
radioConfigRepository.setLocalModuleConfig(config)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearLocalConfig() {
|
||||
serviceScope.handledLaunch {
|
||||
localConfigRepository.clearLocalConfig()
|
||||
moduleConfigRepository.clearLocalModuleConfig()
|
||||
radioConfigRepository.clearLocalConfig()
|
||||
radioConfigRepository.clearLocalModuleConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private fun addChannelSettings(ch: ChannelProtos.Channel) {
|
||||
if (ch.index == 0 || ch.settings.name.lowercase() == "admin") adminChannelIndex = ch.index
|
||||
serviceScope.handledLaunch {
|
||||
channelSetRepository.addSettings(ch)
|
||||
}
|
||||
private fun updateChannelSettings(ch: ChannelProtos.Channel) = serviceScope.handledLaunch {
|
||||
radioConfigRepository.updateChannelSettings(ch)
|
||||
}
|
||||
|
||||
private fun currentSecond() = (System.currentTimeMillis() / 1000).toInt()
|
||||
|
@ -1206,7 +1200,7 @@ class MeshService : Service(), Logging {
|
|||
ch.toString()
|
||||
)
|
||||
insertMeshLog(packetToSave)
|
||||
if (ch.role != ChannelProtos.Channel.Role.DISABLED) addChannelSettings(ch)
|
||||
if (ch.role != ChannelProtos.Channel.Role.DISABLED) updateChannelSettings(ch)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1338,9 +1332,9 @@ class MeshService : Service(), Logging {
|
|||
|
||||
// We'll need to get a new set of channels and settings now
|
||||
serviceScope.handledLaunch {
|
||||
channelSetRepository.clearChannelSet()
|
||||
localConfigRepository.clearLocalConfig()
|
||||
moduleConfigRepository.clearLocalModuleConfig()
|
||||
radioConfigRepository.clearChannelSet()
|
||||
radioConfigRepository.clearLocalConfig()
|
||||
radioConfigRepository.clearLocalModuleConfig()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1406,19 +1400,6 @@ class MeshService : Service(), Logging {
|
|||
}.forEach(::requestConfig)
|
||||
}
|
||||
|
||||
private fun requestChannel(channelIndex: Int) {
|
||||
sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket(wantResponse = true) {
|
||||
getChannelRequest = channelIndex + 1
|
||||
})
|
||||
}
|
||||
|
||||
private fun setChannel(ch: ChannelProtos.Channel) {
|
||||
if (ch.index == 0 || ch.settings.name.lowercase() == "admin") adminChannelIndex = ch.index
|
||||
sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket(wantResponse = true) {
|
||||
setChannel = ch
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the modern (REV2) API configuration flow
|
||||
*/
|
||||
|
@ -1480,28 +1461,6 @@ class MeshService : Service(), Logging {
|
|||
}
|
||||
}
|
||||
|
||||
/** Send our current radio config to the device
|
||||
*/
|
||||
private fun setConfig(config: ConfigProtos.Config) {
|
||||
if (deviceVersion < minDeviceVersion) return
|
||||
debug("Setting new radio config!")
|
||||
sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket {
|
||||
setConfig = config
|
||||
})
|
||||
setLocalConfig(config) // Update our local copy
|
||||
}
|
||||
|
||||
/** Send our current module config to the device
|
||||
*/
|
||||
private fun setModuleConfig(config: ModuleConfigProtos.ModuleConfig) {
|
||||
if (deviceVersion < minDeviceVersion) return
|
||||
debug("Setting new module config!")
|
||||
sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket {
|
||||
setModuleConfig = config
|
||||
})
|
||||
setLocalModuleConfig(config) // Update our local copy
|
||||
}
|
||||
|
||||
/**
|
||||
* Send setOwner admin packet with [MeshProtos.User] protobuf
|
||||
*/
|
||||
|
@ -1639,6 +1598,19 @@ class MeshService : Service(), Logging {
|
|||
this@MeshService.setOwner(user)
|
||||
}
|
||||
|
||||
override fun setRemoteOwner(destNum: Int, payload: ByteArray) = toRemoteExceptions {
|
||||
val parsed = MeshProtos.User.parseFrom(payload)
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket {
|
||||
setOwner = parsed
|
||||
})
|
||||
}
|
||||
|
||||
override fun getRemoteOwner(id: Int, destNum: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
|
||||
getOwnerRequest = true
|
||||
})
|
||||
}
|
||||
|
||||
override fun send(p: DataPacket) {
|
||||
toRemoteExceptions {
|
||||
if (p.id == 0) p.id = generatePacketId()
|
||||
|
@ -1680,19 +1652,77 @@ class MeshService : Service(), Logging {
|
|||
this@MeshService.localConfig.toByteArray() ?: throw NoDeviceConfigException()
|
||||
}
|
||||
|
||||
/** Send our current radio config to the device
|
||||
*/
|
||||
override fun setConfig(payload: ByteArray) = toRemoteExceptions {
|
||||
val parsed = ConfigProtos.Config.parseFrom(payload)
|
||||
setConfig(parsed)
|
||||
setRemoteConfig(myNodeNum, payload)
|
||||
}
|
||||
|
||||
override fun setModuleConfig(payload: ByteArray) = toRemoteExceptions {
|
||||
val parsed = ModuleConfigProtos.ModuleConfig.parseFrom(payload)
|
||||
setModuleConfig(parsed)
|
||||
override fun setRemoteConfig(destNum: Int, payload: ByteArray) = toRemoteExceptions {
|
||||
debug("Setting new radio config!")
|
||||
val config = ConfigProtos.Config.parseFrom(payload)
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { setConfig = config })
|
||||
if (destNum == myNodeNum) setLocalConfig(config) // Update our local copy
|
||||
}
|
||||
|
||||
override fun getRemoteConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
|
||||
getConfigRequestValue = config
|
||||
})
|
||||
}
|
||||
|
||||
/** Send our current module config to the device
|
||||
*/
|
||||
override fun setModuleConfig(destNum: Int, payload: ByteArray) = toRemoteExceptions {
|
||||
debug("Setting new module config!")
|
||||
val config = ModuleConfigProtos.ModuleConfig.parseFrom(payload)
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { setModuleConfig = config })
|
||||
if (destNum == myNodeNum) setLocalModuleConfig(config) // Update our local copy
|
||||
}
|
||||
|
||||
override fun getModuleConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
|
||||
getModuleConfigRequestValue = config
|
||||
})
|
||||
}
|
||||
|
||||
override fun setRingtone(destNum: Int, ringtone: String) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket {
|
||||
setRingtoneMessage = ringtone
|
||||
})
|
||||
}
|
||||
|
||||
override fun getRingtone(id: Int, destNum: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
|
||||
getRingtoneRequest = true
|
||||
})
|
||||
}
|
||||
|
||||
override fun setCannedMessages(destNum: Int, messages: String) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket {
|
||||
setCannedMessageModuleMessages = messages
|
||||
})
|
||||
}
|
||||
|
||||
override fun getCannedMessages(id: Int, destNum: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
|
||||
getCannedMessageModuleMessagesRequest = true
|
||||
})
|
||||
}
|
||||
|
||||
override fun setChannel(payload: ByteArray?) = toRemoteExceptions {
|
||||
val parsed = ChannelProtos.Channel.parseFrom(payload)
|
||||
setChannel(parsed)
|
||||
setRemoteChannel(myNodeNum, payload)
|
||||
}
|
||||
|
||||
override fun setRemoteChannel(destNum: Int, payload: ByteArray?) = toRemoteExceptions {
|
||||
val channel = ChannelProtos.Channel.parseFrom(payload)
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { setChannel = channel })
|
||||
}
|
||||
|
||||
override fun getRemoteChannel(id: Int, destNum: Int, index: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
|
||||
getChannelRequest = index + 1
|
||||
})
|
||||
}
|
||||
|
||||
override fun beginEditSettings() = toRemoteExceptions {
|
||||
|
@ -1732,14 +1762,16 @@ class MeshService : Service(), Logging {
|
|||
stopLocationRequests()
|
||||
}
|
||||
|
||||
override fun requestPosition(idNum: Int, position: Position) =
|
||||
toRemoteExceptions {
|
||||
val (lat, lon, alt) = position
|
||||
override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions {
|
||||
if (position == Position(0.0, 0.0, 0)) {
|
||||
// request position
|
||||
if (idNum != 0) sendPosition(time = 1, destNum = idNum, wantResponse = true)
|
||||
// set local node's fixed position
|
||||
else sendPosition(time = 0, destNum = null, lat = lat, lon = lon, alt = alt)
|
||||
sendPosition(destNum = destNum, wantResponse = true)
|
||||
} else {
|
||||
// send fixed position (local only/no remote method, so we force destNum to null)
|
||||
val (lat, lon, alt) = position
|
||||
sendPosition(destNum = null, lat = lat, lon = lon, alt = alt)
|
||||
}
|
||||
}
|
||||
|
||||
override fun requestTraceroute(requestId: Int, destNum: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(destNum).buildMeshPacket(id = requestId) {
|
||||
|
@ -1749,26 +1781,26 @@ class MeshService : Service(), Logging {
|
|||
})
|
||||
}
|
||||
|
||||
override fun requestShutdown(idNum: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(idNum).buildAdminPacket {
|
||||
override fun requestShutdown(requestId: Int, destNum: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) {
|
||||
shutdownSeconds = 5
|
||||
})
|
||||
}
|
||||
|
||||
override fun requestReboot(idNum: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(idNum).buildAdminPacket {
|
||||
override fun requestReboot(requestId: Int, destNum: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) {
|
||||
rebootSeconds = 5
|
||||
})
|
||||
}
|
||||
|
||||
override fun requestFactoryReset(idNum: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(idNum).buildAdminPacket {
|
||||
override fun requestFactoryReset(requestId: Int, destNum: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) {
|
||||
factoryReset = 1
|
||||
})
|
||||
}
|
||||
|
||||
override fun requestNodedbReset(idNum: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(idNum).buildAdminPacket {
|
||||
override fun requestNodedbReset(requestId: Int, destNum: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) {
|
||||
nodedbReset = 1
|
||||
})
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ import androidx.compose.material.Text
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Check
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
import androidx.compose.material.icons.twotone.Edit
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
|
@ -49,10 +50,12 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geeksville.mesh.AppOnlyProtos
|
||||
import com.geeksville.mesh.analytics.DataPair
|
||||
import com.geeksville.mesh.android.GeeksvilleApplication
|
||||
import com.geeksville.mesh.android.Logging
|
||||
|
@ -64,6 +67,7 @@ import com.geeksville.mesh.android.BuildUtils.errormsg
|
|||
import com.geeksville.mesh.android.getCameraPermissions
|
||||
import com.geeksville.mesh.android.hasCameraPermission
|
||||
import com.geeksville.mesh.channelSet
|
||||
import com.geeksville.mesh.channelSettings
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.model.ChannelOption
|
||||
|
@ -71,16 +75,16 @@ import com.geeksville.mesh.model.ChannelSet
|
|||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.ui.components.DropDownPreference
|
||||
import com.geeksville.mesh.ui.components.EditTextPreference
|
||||
import com.geeksville.mesh.ui.components.PreferenceFooter
|
||||
import com.geeksville.mesh.ui.components.RegularPreference
|
||||
import com.geeksville.mesh.ui.components.config.ChannelSettingsItemList
|
||||
import com.geeksville.mesh.ui.components.config.EditChannelDialog
|
||||
import com.google.accompanist.themeadapter.appcompat.AppCompatTheme
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.protobuf.ByteString
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.SecureRandom
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ChannelFragment : ScreenFragment("Channel"), Logging {
|
||||
|
@ -114,14 +118,15 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
|||
|
||||
val connectionState by viewModel.connectionState.observeAsState()
|
||||
val connected = connectionState == MeshService.ConnectionState.CONNECTED
|
||||
val enabled = connected && !viewModel.isManaged
|
||||
|
||||
val channels by viewModel.channels.collectAsStateWithLifecycle()
|
||||
var channelSet by remember(channels) { mutableStateOf(channels.protobuf) }
|
||||
val isEditing = channelSet != channels.protobuf
|
||||
|
||||
val primaryChannel = ChannelSet(channelSet).primaryChannel
|
||||
val channelUrl = ChannelSet(channelSet).getChannelUrl()
|
||||
|
||||
var isEditing by remember(channelSet) { mutableStateOf(channelSet != channels.protobuf) }
|
||||
val modemPresetName = Channel(Channel.default.settings, channelSet.loraConfig).name
|
||||
|
||||
val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
if (result.contents != null) {
|
||||
|
@ -159,14 +164,9 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
|||
|
||||
/// Send new channel settings to the device
|
||||
fun installSettings(
|
||||
newChannel: ChannelProtos.ChannelSettings,
|
||||
newLoRaConfig: ConfigProtos.Config.LoRaConfig
|
||||
newChannelSet: AppOnlyProtos.ChannelSet
|
||||
) {
|
||||
val newSet = ChannelSet(
|
||||
channelSet {
|
||||
settings.add(newChannel)
|
||||
loraConfig = newLoRaConfig
|
||||
})
|
||||
val newSet = ChannelSet(newChannelSet)
|
||||
// Try to change the radio, if it fails, tell the user why and throw away their edits
|
||||
try {
|
||||
viewModel.setChannels(newSet)
|
||||
|
@ -183,6 +183,17 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
|||
}
|
||||
}
|
||||
|
||||
fun installSettings(
|
||||
newChannel: ChannelProtos.ChannelSettings,
|
||||
newLoRaConfig: ConfigProtos.Config.LoRaConfig
|
||||
) {
|
||||
val newSet = channelSet {
|
||||
settings.add(newChannel)
|
||||
loraConfig = newLoRaConfig
|
||||
}
|
||||
installSettings(newSet)
|
||||
}
|
||||
|
||||
fun resetButton() {
|
||||
// User just locked it, we should warn and then apply changes to radio
|
||||
MaterialAlertDialogBuilder(context)
|
||||
|
@ -205,48 +216,12 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
|||
}
|
||||
|
||||
fun sendButton() {
|
||||
channels.primaryChannel?.let { oldPrimary ->
|
||||
var newSettings = oldPrimary.settings
|
||||
val newName = channelSet.getSettings(0).name.trim()
|
||||
|
||||
// Find the new modem config
|
||||
var newModemPreset = channelSet.loraConfig.modemPreset
|
||||
if (newModemPreset == ConfigProtos.Config.LoRaConfig.ModemPreset.UNRECOGNIZED) // Huh? didn't find it - keep same
|
||||
newModemPreset = oldPrimary.loraConfig.modemPreset
|
||||
|
||||
// Generate a new AES256 key if the channel name is non-default (empty)
|
||||
val shouldUseRandomKey = newName.isNotEmpty()
|
||||
if (shouldUseRandomKey) {
|
||||
|
||||
// Install a new customized channel
|
||||
debug("ASSIGNING NEW AES256 KEY")
|
||||
val random = SecureRandom()
|
||||
val bytes = ByteArray(32)
|
||||
random.nextBytes(bytes)
|
||||
newSettings = newSettings.copy {
|
||||
name = newName
|
||||
psk = ByteString.copyFrom(bytes)
|
||||
}
|
||||
} else {
|
||||
debug("Switching back to default channel")
|
||||
newSettings = Channel.default.settings
|
||||
}
|
||||
|
||||
// No matter what apply the speed selection from the user
|
||||
val newLoRaConfig = viewModel.config.lora.copy {
|
||||
usePreset = true
|
||||
modemPreset = newModemPreset
|
||||
bandwidth = 0
|
||||
spreadFactor = 0
|
||||
codingRate = 0
|
||||
}
|
||||
|
||||
val humanName = Channel(newSettings, newLoRaConfig).humanName
|
||||
|
||||
primaryChannel?.let { primaryChannel ->
|
||||
val humanName = primaryChannel.humanName
|
||||
val message = buildString {
|
||||
append(context.getString(R.string.are_you_sure_channel))
|
||||
if (!shouldUseRandomKey)
|
||||
append("\n\n" + context.getString(R.string.warning_default_psk).format(humanName))
|
||||
if (primaryChannel.settings == Channel.default.settings)
|
||||
append("\n\n" + context.getString(R.string.warning_default_psk, humanName))
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
|
@ -255,36 +230,63 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
|||
.setNeutralButton(R.string.cancel) { _, _ ->
|
||||
channelSet = channels.protobuf
|
||||
}
|
||||
.setPositiveButton(context.getString(R.string.accept)) { _, _ ->
|
||||
// Generate a new channel with only the changes the user can change in the GUI
|
||||
installSettings(newSettings, newLoRaConfig)
|
||||
.setPositiveButton(R.string.accept) { _, _ ->
|
||||
installSettings(channelSet)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
var showEditChannelDialog: Int? by remember { mutableStateOf(null) }
|
||||
|
||||
if (showEditChannelDialog != null) {
|
||||
val index = showEditChannelDialog ?: return
|
||||
EditChannelDialog(
|
||||
channelSettings = with(channelSet) {
|
||||
if (settingsCount > index) getSettings(index) else channelSettings { }
|
||||
},
|
||||
modemPresetName = modemPresetName,
|
||||
onAddClick = {
|
||||
with(channelSet) {
|
||||
if (settingsCount > index) channelSet = copy { settings[index] = it }
|
||||
else channelSet = copy { settings.add(it) }
|
||||
}
|
||||
showEditChannelDialog = null
|
||||
},
|
||||
onDismissRequest = { showEditChannelDialog = null }
|
||||
)
|
||||
}
|
||||
|
||||
var showChannelEditor by remember { mutableStateOf(false) }
|
||||
if (showChannelEditor) ChannelSettingsItemList(
|
||||
settingsList = channelSet.settingsList,
|
||||
modemPresetName = modemPresetName,
|
||||
enabled = enabled,
|
||||
focusManager = focusManager,
|
||||
onNegativeClicked = {
|
||||
focusManager.clearFocus()
|
||||
showChannelEditor = false
|
||||
},
|
||||
positiveText = R.string.save,
|
||||
onPositiveClicked = {
|
||||
focusManager.clearFocus()
|
||||
showChannelEditor = false
|
||||
channelSet = channelSet.toBuilder().clearSettings().addAllSettings(it).build()
|
||||
}
|
||||
)
|
||||
|
||||
if (!showChannelEditor) LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
) {
|
||||
item {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
EditTextPreference(
|
||||
RegularPreference(
|
||||
title = stringResource(R.string.channel_name),
|
||||
value = if (isFocused) channelSet.getSettings(0).name else primaryChannel?.humanName.orEmpty(),
|
||||
maxSize = 11, // name max_size:12
|
||||
enabled = connected,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = {
|
||||
val newSettings = channelSet.getSettings(0).copy { name = it }
|
||||
channelSet = channelSet.copy { settings[0] = newSettings }
|
||||
},
|
||||
onFocusChanged = { isFocused = it.isFocused }
|
||||
subtitle = primaryChannel?.humanName.orEmpty(),
|
||||
onClick = { showChannelEditor = true },
|
||||
enabled = enabled,
|
||||
trailingIcon = Icons.TwoTone.Edit
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -294,7 +296,7 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
|||
?: painterResource(id = R.drawable.qrcode),
|
||||
contentDescription = stringResource(R.string.qr_code),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
alpha = if (connected) 1f else 0.25f,
|
||||
alpha = if (enabled) 1f else 0.25f,
|
||||
// colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
@ -316,9 +318,8 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
|||
// channelSet failed to update, isError true
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
enabled = connected,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
label = { Text("URL") },
|
||||
isError = isError,
|
||||
trailingIcon = {
|
||||
|
@ -364,7 +365,7 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
|||
|
||||
item {
|
||||
DropDownPreference(title = stringResource(id = R.string.channel_options),
|
||||
enabled = connected,
|
||||
enabled = enabled,
|
||||
items = ChannelOption.values()
|
||||
.map { it.modemPreset to stringResource(it.configRes) },
|
||||
selectedItem = channelSet.loraConfig.modemPreset,
|
||||
|
@ -376,11 +377,10 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
|||
|
||||
if (isEditing) item {
|
||||
PreferenceFooter(
|
||||
enabled = connected,
|
||||
enabled = enabled,
|
||||
onCancelClicked = {
|
||||
focusManager.clearFocus()
|
||||
channelSet = channels.protobuf
|
||||
isEditing = false
|
||||
},
|
||||
onSaveClicked = {
|
||||
focusManager.clearFocus()
|
||||
|
@ -390,7 +390,7 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
|||
} else {
|
||||
item {
|
||||
PreferenceFooter(
|
||||
enabled = connected,
|
||||
enabled = enabled,
|
||||
negativeText = R.string.reset,
|
||||
onNegativeClicked = {
|
||||
focusManager.clearFocus()
|
||||
|
@ -408,8 +408,8 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
|||
SnackbarHost(hostState = snackbarHostState)
|
||||
}
|
||||
|
||||
//@Preview(showBackground = true)
|
||||
//@Composable
|
||||
//private fun ChannelScreenPreview() {
|
||||
// ChannelScreen()
|
||||
//}
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ChannelScreenPreview() {
|
||||
// ChannelScreen()
|
||||
}
|
||||
|
|
|
@ -1,31 +1,51 @@
|
|||
package com.geeksville.mesh.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.ContentAlpha
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.KeyboardArrowRight
|
||||
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.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
@ -33,24 +53,40 @@ import androidx.core.content.ContextCompat
|
|||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.geeksville.mesh.AdminProtos
|
||||
import com.geeksville.mesh.AdminProtos.AdminMessage.ConfigType
|
||||
import com.geeksville.mesh.AdminProtos.AdminMessage.ModuleConfigType
|
||||
import com.geeksville.mesh.ChannelProtos
|
||||
import com.geeksville.mesh.ConfigProtos.Config
|
||||
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.NodeInfo
|
||||
import com.geeksville.mesh.Portnums
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.android.Logging
|
||||
import com.geeksville.mesh.config
|
||||
import com.geeksville.mesh.deviceProfile
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.moduleConfig
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.ui.components.PreferenceCategory
|
||||
import com.geeksville.mesh.ui.components.TextDividerPreference
|
||||
import com.geeksville.mesh.ui.components.config.AudioConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.BluetoothConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.CannedMessageConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.ChannelSettingsItemList
|
||||
import com.geeksville.mesh.ui.components.config.DeviceConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.DisplayConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.EditDeviceProfileDialog
|
||||
import com.geeksville.mesh.ui.components.config.ExternalNotificationConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.LoRaConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.MQTTConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.NetworkConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.PacketResponseStateDialog
|
||||
import com.geeksville.mesh.ui.components.config.PositionConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.PowerConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.RangeTestConfigItemList
|
||||
|
@ -63,7 +99,7 @@ import com.google.accompanist.themeadapter.appcompat.AppCompatTheme
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DeviceSettingsFragment : ScreenFragment("Device Settings"), Logging {
|
||||
class DeviceSettingsFragment(val node: NodeInfo) : ScreenFragment("Radio Configuration"), Logging {
|
||||
|
||||
private val model: UIViewModel by activityViewModels()
|
||||
|
||||
|
@ -77,139 +113,400 @@ class DeviceSettingsFragment : ScreenFragment("Device Settings"), Logging {
|
|||
setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground))
|
||||
setContent {
|
||||
AppCompatTheme {
|
||||
RadioConfigNavHost(model)
|
||||
RadioConfigNavHost(node, model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ConfigDest(val title: String, val route: String) {
|
||||
USER("User", "user"),
|
||||
DEVICE("Device", "device"),
|
||||
POSITION("Position", "position"),
|
||||
POWER("Power", "power"),
|
||||
NETWORK("Network", "network"),
|
||||
DISPLAY("Display", "display"),
|
||||
LORA("LoRa", "lora"),
|
||||
BLUETOOTH("Bluetooth", "bluetooth")
|
||||
enum class ConfigDest(val title: String, val route: String, val config: ConfigType) {
|
||||
DEVICE("Device", "device", ConfigType.DEVICE_CONFIG),
|
||||
POSITION("Position", "position", ConfigType.POSITION_CONFIG),
|
||||
POWER("Power", "power", ConfigType.POWER_CONFIG),
|
||||
NETWORK("Network", "network", ConfigType.NETWORK_CONFIG),
|
||||
DISPLAY("Display", "display", ConfigType.DISPLAY_CONFIG),
|
||||
LORA("LoRa", "lora", ConfigType.LORA_CONFIG),
|
||||
BLUETOOTH("Bluetooth", "bluetooth", ConfigType.BLUETOOTH_CONFIG);
|
||||
}
|
||||
|
||||
enum class ModuleDest(val title: String, val route: String) {
|
||||
MQTT("MQTT", "mqtt"),
|
||||
SERIAL("Serial", "serial"),
|
||||
EXT_NOTIFICATION("External Notification", "ext_notification"),
|
||||
STORE_FORWARD("Store & Forward", "store_forward"),
|
||||
RANGE_TEST("Range Test", "range_test"),
|
||||
TELEMETRY("Telemetry", "telemetry"),
|
||||
CANNED_MESSAGE("Canned Message", "canned_message"),
|
||||
AUDIO("Audio", "audio"),
|
||||
REMOTE_HARDWARE("Remote Hardware", "remote_hardware")
|
||||
enum class ModuleDest(val title: String, val route: String, val config: ModuleConfigType) {
|
||||
MQTT("MQTT", "mqtt", ModuleConfigType.MQTT_CONFIG),
|
||||
SERIAL("Serial", "serial", ModuleConfigType.SERIAL_CONFIG),
|
||||
EXTERNAL_NOTIFICATION("External Notification", "ext_not", ModuleConfigType.EXTNOTIF_CONFIG),
|
||||
STORE_FORWARD("Store & Forward", "store_forward", ModuleConfigType.STOREFORWARD_CONFIG),
|
||||
RANGE_TEST("Range Test", "range_test", ModuleConfigType.RANGETEST_CONFIG),
|
||||
TELEMETRY("Telemetry", "telemetry", ModuleConfigType.TELEMETRY_CONFIG),
|
||||
CANNED_MESSAGE("Canned Message", "canned_message", ModuleConfigType.CANNEDMSG_CONFIG),
|
||||
AUDIO("Audio", "audio", ModuleConfigType.AUDIO_CONFIG),
|
||||
REMOTE_HARDWARE("Remote Hardware", "remote_hardware", ModuleConfigType.REMOTEHARDWARE_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* This sealed class defines each possible state of a packet response.
|
||||
*/
|
||||
sealed class PacketResponseState {
|
||||
object Loading : PacketResponseState() {
|
||||
var total: Int = 0
|
||||
var completed: Int = 0
|
||||
}
|
||||
|
||||
data class Success(val packets: List<String>) : PacketResponseState()
|
||||
object Empty : PacketResponseState()
|
||||
data class Error(val error: String) : PacketResponseState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) {
|
||||
fun RadioConfigNavHost(node: NodeInfo, viewModel: UIViewModel = viewModel()) {
|
||||
val navController = rememberNavController()
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val connectionState by viewModel.connectionState.observeAsState()
|
||||
val connected = connectionState == MeshService.ConnectionState.CONNECTED
|
||||
|
||||
val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val localConfig by viewModel.localConfig.collectAsStateWithLifecycle()
|
||||
val moduleConfig by viewModel.moduleConfig.collectAsStateWithLifecycle()
|
||||
val destNum = node.num
|
||||
val isLocal = destNum == viewModel.myNodeNum
|
||||
val maxChannels = viewModel.myNodeInfo.value?.maxChannels ?: 8
|
||||
|
||||
var userConfig by remember { mutableStateOf(MeshProtos.User.getDefaultInstance()) }
|
||||
val channelList = remember { mutableStateListOf<ChannelProtos.ChannelSettings>() }
|
||||
var radioConfig by remember { mutableStateOf(Config.getDefaultInstance()) }
|
||||
var moduleConfig by remember { mutableStateOf(ModuleConfig.getDefaultInstance()) }
|
||||
|
||||
var location by remember(node) { mutableStateOf(node.position) }
|
||||
var ringtone by remember { mutableStateOf("") }
|
||||
var cannedMessageMessages by remember { mutableStateOf("") }
|
||||
|
||||
val configResponse by viewModel.packetResponse.collectAsStateWithLifecycle()
|
||||
val deviceProfile by viewModel.deviceProfile.collectAsStateWithLifecycle()
|
||||
var packetResponseState by remember { mutableStateOf<PacketResponseState>(PacketResponseState.Empty) }
|
||||
val isWaiting = packetResponseState !is PacketResponseState.Empty
|
||||
var showEditDeviceProfileDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val importConfigLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
showEditDeviceProfileDialog = true
|
||||
it.data?.data?.let { file_uri -> viewModel.importProfile(file_uri) }
|
||||
}
|
||||
}
|
||||
|
||||
val exportConfigLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
it.data?.data?.let { file_uri -> viewModel.exportProfile(file_uri) }
|
||||
}
|
||||
}
|
||||
|
||||
if (showEditDeviceProfileDialog) EditDeviceProfileDialog(
|
||||
title = if (deviceProfile != null) "Import configuration" else "Export configuration",
|
||||
deviceProfile = deviceProfile ?: with(viewModel) {
|
||||
deviceProfile {
|
||||
ourNodeInfo.value?.user?.let {
|
||||
longName = it.longName
|
||||
shortName = it.shortName
|
||||
}
|
||||
channelUrl = channels.value.getChannelUrl().toString()
|
||||
config = localConfig.value
|
||||
this.moduleConfig = module
|
||||
}
|
||||
},
|
||||
onAddClick = {
|
||||
showEditDeviceProfileDialog = false
|
||||
if (deviceProfile != null) {
|
||||
viewModel.installProfile(it)
|
||||
} else {
|
||||
viewModel.setDeviceProfile(it)
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/*"
|
||||
putExtra(Intent.EXTRA_TITLE, "${destNum.toUInt()}.cfg")
|
||||
}
|
||||
exportConfigLauncher.launch(intent)
|
||||
}
|
||||
},
|
||||
onDismissRequest = {
|
||||
showEditDeviceProfileDialog = false
|
||||
viewModel.setDeviceProfile(null)
|
||||
}
|
||||
)
|
||||
|
||||
if (isWaiting) PacketResponseStateDialog(
|
||||
packetResponseState,
|
||||
onDismiss = {
|
||||
packetResponseState = PacketResponseState.Empty
|
||||
viewModel.clearPacketResponse()
|
||||
}
|
||||
)
|
||||
|
||||
if (isWaiting) LaunchedEffect(configResponse) {
|
||||
val data = configResponse?.meshPacket?.decoded
|
||||
if (data?.portnumValue == Portnums.PortNum.ROUTING_APP_VALUE) {
|
||||
val parsed = MeshProtos.Routing.parseFrom(data.payload)
|
||||
packetResponseState = if (parsed.errorReason == MeshProtos.Routing.Error.NONE) {
|
||||
PacketResponseState.Success(emptyList())
|
||||
} else {
|
||||
PacketResponseState.Error(parsed.errorReason.toString())
|
||||
}
|
||||
}
|
||||
if (data?.portnumValue == Portnums.PortNum.ADMIN_APP_VALUE) {
|
||||
viewModel.clearPacketResponse()
|
||||
val parsed = AdminProtos.AdminMessage.parseFrom(data.payload)
|
||||
when (parsed.payloadVariantCase) {
|
||||
AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> {
|
||||
val response = parsed.getChannelResponse
|
||||
(packetResponseState as PacketResponseState.Loading).completed++
|
||||
// Stop once we get to the first disabled entry
|
||||
if (response.role != ChannelProtos.Channel.Role.DISABLED) {
|
||||
channelList.add(response.index, response.settings)
|
||||
if (response.index + 1 < maxChannels) {
|
||||
// Not done yet, request next channel
|
||||
viewModel.getChannel(destNum, response.index + 1)
|
||||
} else {
|
||||
// Received max channels, get lora config (for default channel names)
|
||||
viewModel.getConfig(destNum, ConfigType.LORA_CONFIG_VALUE)
|
||||
}
|
||||
} else {
|
||||
// Received last channel, get lora config (for default channel names)
|
||||
viewModel.getConfig(destNum, ConfigType.LORA_CONFIG_VALUE)
|
||||
}
|
||||
}
|
||||
|
||||
AdminProtos.AdminMessage.PayloadVariantCase.GET_OWNER_RESPONSE -> {
|
||||
packetResponseState = PacketResponseState.Empty
|
||||
userConfig = parsed.getOwnerResponse
|
||||
navController.navigate("user")
|
||||
}
|
||||
|
||||
AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> {
|
||||
// check destination: lora config or channel editor
|
||||
val goChannels = (packetResponseState as PacketResponseState.Loading).total > 1
|
||||
packetResponseState = PacketResponseState.Empty
|
||||
val response = parsed.getConfigResponse
|
||||
radioConfig = response
|
||||
if (goChannels) navController.navigate("channels")
|
||||
else enumValues<ConfigDest>().find { it.name == "${response.payloadVariantCase}" }
|
||||
?.let { navController.navigate(it.route) }
|
||||
}
|
||||
|
||||
AdminProtos.AdminMessage.PayloadVariantCase.GET_MODULE_CONFIG_RESPONSE -> {
|
||||
packetResponseState = PacketResponseState.Empty
|
||||
val response = parsed.getModuleConfigResponse
|
||||
moduleConfig = response
|
||||
enumValues<ModuleDest>().find { it.name == "${response.payloadVariantCase}" }
|
||||
?.let { navController.navigate(it.route) }
|
||||
}
|
||||
|
||||
AdminProtos.AdminMessage.PayloadVariantCase.GET_CANNED_MESSAGE_MODULE_MESSAGES_RESPONSE -> {
|
||||
cannedMessageMessages = parsed.getCannedMessageModuleMessagesResponse
|
||||
(packetResponseState as PacketResponseState.Loading).completed++
|
||||
viewModel.getModuleConfig(destNum, ModuleConfigType.CANNEDMSG_CONFIG_VALUE)
|
||||
}
|
||||
|
||||
AdminProtos.AdminMessage.PayloadVariantCase.GET_RINGTONE_RESPONSE -> {
|
||||
ringtone = parsed.getRingtoneResponse
|
||||
(packetResponseState as PacketResponseState.Loading).completed++
|
||||
viewModel.getModuleConfig(destNum, ModuleConfigType.EXTNOTIF_CONFIG_VALUE)
|
||||
}
|
||||
|
||||
else -> TODO()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(navController = navController, startDestination = "home") {
|
||||
composable("home") { RadioSettingsScreen(navController) }
|
||||
composable("home") {
|
||||
RadioSettingsScreen(
|
||||
enabled = connected && !isWaiting,
|
||||
isLocal = isLocal,
|
||||
headerText = node.user?.longName ?: stringResource(R.string.unknown_username),
|
||||
onRouteClick = { configType ->
|
||||
packetResponseState = PacketResponseState.Loading.apply {
|
||||
total = 1
|
||||
completed = 0
|
||||
}
|
||||
// clearAllConfigs() ?
|
||||
when (configType) {
|
||||
"USER" -> { viewModel.getOwner(destNum) }
|
||||
"CHANNELS" -> {
|
||||
val maxPackets = maxChannels + 1 // for lora config
|
||||
(packetResponseState as PacketResponseState.Loading).total = maxPackets
|
||||
channelList.clear()
|
||||
viewModel.getChannel(destNum, 0)
|
||||
}
|
||||
"IMPORT" -> {
|
||||
packetResponseState = PacketResponseState.Empty
|
||||
viewModel.setDeviceProfile(null)
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/*"
|
||||
}
|
||||
importConfigLauncher.launch(intent)
|
||||
}
|
||||
"EXPORT" -> {
|
||||
packetResponseState = PacketResponseState.Empty
|
||||
showEditDeviceProfileDialog = true
|
||||
}
|
||||
|
||||
"REBOOT" -> {
|
||||
viewModel.requestReboot(destNum)
|
||||
}
|
||||
|
||||
"SHUTDOWN" -> {
|
||||
viewModel.requestShutdown(destNum)
|
||||
}
|
||||
|
||||
"FACTORY_RESET" -> {
|
||||
viewModel.requestFactoryReset(destNum)
|
||||
}
|
||||
|
||||
"NODEDB_RESET" -> {
|
||||
viewModel.requestNodedbReset(destNum)
|
||||
}
|
||||
|
||||
is ConfigType -> {
|
||||
viewModel.getConfig(destNum, configType.number)
|
||||
}
|
||||
ModuleConfigType.CANNEDMSG_CONFIG -> {
|
||||
(packetResponseState as PacketResponseState.Loading).total = 2
|
||||
viewModel.getCannedMessages(destNum)
|
||||
}
|
||||
ModuleConfigType.EXTNOTIF_CONFIG -> {
|
||||
(packetResponseState as PacketResponseState.Loading).total = 2
|
||||
viewModel.getRingtone(destNum)
|
||||
}
|
||||
is ModuleConfigType -> {
|
||||
viewModel.getModuleConfig(destNum, configType.number)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable("channels") {
|
||||
ChannelSettingsItemList(
|
||||
settingsList = channelList,
|
||||
modemPresetName = Channel(Channel.default.settings, radioConfig.lora).name,
|
||||
enabled = connected,
|
||||
maxChannels = maxChannels,
|
||||
focusManager = focusManager,
|
||||
onPositiveClicked = { channelListInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateChannels(destNum, channelList, channelListInput)
|
||||
channelList.clear()
|
||||
channelList.addAll(channelListInput)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable("user") {
|
||||
UserConfigItemList(
|
||||
userConfig = ourNodeInfo?.user!!,
|
||||
userConfig = userConfig,
|
||||
enabled = connected,
|
||||
focusManager = focusManager,
|
||||
onSaveClicked = { userInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.setOwner(userInput)
|
||||
viewModel.setRemoteOwner(destNum, userInput)
|
||||
userConfig = userInput
|
||||
}
|
||||
)
|
||||
}
|
||||
composable("device") {
|
||||
DeviceConfigItemList(
|
||||
deviceConfig = localConfig.device,
|
||||
deviceConfig = radioConfig.device,
|
||||
enabled = connected,
|
||||
focusManager = focusManager,
|
||||
onSaveClicked = { deviceInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateDeviceConfig { deviceInput }
|
||||
val config = config { device = deviceInput }
|
||||
viewModel.setRemoteConfig(destNum, config)
|
||||
radioConfig = config
|
||||
}
|
||||
)
|
||||
}
|
||||
composable("position") {
|
||||
PositionConfigItemList(
|
||||
positionInfo = ourNodeInfo?.position,
|
||||
positionConfig = localConfig.position,
|
||||
isLocal = isLocal,
|
||||
location = location,
|
||||
positionConfig = radioConfig.position,
|
||||
enabled = connected,
|
||||
focusManager = focusManager,
|
||||
onSaveClicked = { positionPair ->
|
||||
onSaveClicked = { locationInput, positionInput ->
|
||||
focusManager.clearFocus()
|
||||
val (locationInput, positionInput) = positionPair
|
||||
if (locationInput != ourNodeInfo?.position && positionInput.fixedPosition)
|
||||
locationInput?.let { viewModel.requestPosition(0, it) }
|
||||
if (positionInput != localConfig.position) viewModel.updatePositionConfig { positionInput }
|
||||
if (locationInput != node.position && positionInput.fixedPosition) {
|
||||
locationInput?.let { viewModel.requestPosition(destNum, it) }
|
||||
location = locationInput
|
||||
}
|
||||
if (positionInput != radioConfig.position) {
|
||||
val config = config { position = positionInput }
|
||||
viewModel.setRemoteConfig(destNum, config)
|
||||
radioConfig = config
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable("power") {
|
||||
PowerConfigItemList(
|
||||
powerConfig = localConfig.power,
|
||||
powerConfig = radioConfig.power,
|
||||
enabled = connected,
|
||||
focusManager = focusManager,
|
||||
onSaveClicked = { powerInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updatePowerConfig { powerInput }
|
||||
val config = config { power = powerInput }
|
||||
viewModel.setRemoteConfig(destNum, config)
|
||||
radioConfig = config
|
||||
}
|
||||
)
|
||||
}
|
||||
composable("network") {
|
||||
NetworkConfigItemList(
|
||||
networkConfig = localConfig.network,
|
||||
networkConfig = radioConfig.network,
|
||||
enabled = connected,
|
||||
focusManager = focusManager,
|
||||
onSaveClicked = { networkInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateNetworkConfig { networkInput }
|
||||
val config = config { network = networkInput }
|
||||
viewModel.setRemoteConfig(destNum, config)
|
||||
radioConfig = config
|
||||
}
|
||||
)
|
||||
}
|
||||
composable("display") {
|
||||
DisplayConfigItemList(
|
||||
displayConfig = localConfig.display,
|
||||
displayConfig = radioConfig.display,
|
||||
enabled = connected,
|
||||
focusManager = focusManager,
|
||||
onSaveClicked = { displayInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateDisplayConfig { displayInput }
|
||||
val config = config { display = displayInput }
|
||||
viewModel.setRemoteConfig(destNum, config)
|
||||
radioConfig = config
|
||||
}
|
||||
)
|
||||
}
|
||||
composable("lora") {
|
||||
LoRaConfigItemList(
|
||||
loraConfig = localConfig.lora,
|
||||
loraConfig = radioConfig.lora,
|
||||
enabled = connected,
|
||||
focusManager = focusManager,
|
||||
onSaveClicked = { loraInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateLoraConfig { loraInput }
|
||||
val config = config { lora = loraInput }
|
||||
viewModel.setRemoteConfig(destNum, config)
|
||||
radioConfig = config
|
||||
}
|
||||
)
|
||||
}
|
||||
composable("bluetooth") {
|
||||
BluetoothConfigItemList(
|
||||
bluetoothConfig = localConfig.bluetooth,
|
||||
bluetoothConfig = radioConfig.bluetooth,
|
||||
enabled = connected,
|
||||
focusManager = focusManager,
|
||||
onSaveClicked = { bluetoothInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateBluetoothConfig { bluetoothInput }
|
||||
val config = config { bluetooth = bluetoothInput }
|
||||
viewModel.setRemoteConfig(destNum, config)
|
||||
radioConfig = config
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -220,7 +517,9 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) {
|
|||
focusManager = focusManager,
|
||||
onSaveClicked = { mqttInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateMQTTConfig { mqttInput }
|
||||
val config = moduleConfig { mqtt = mqttInput }
|
||||
viewModel.setModuleConfig(destNum, config)
|
||||
moduleConfig = config
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -231,18 +530,29 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) {
|
|||
focusManager = focusManager,
|
||||
onSaveClicked = { serialInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateSerialConfig { serialInput }
|
||||
val config = moduleConfig { serial = serialInput }
|
||||
viewModel.setModuleConfig(destNum, config)
|
||||
moduleConfig = config
|
||||
}
|
||||
)
|
||||
}
|
||||
composable("ext_notification") {
|
||||
composable("ext_not") {
|
||||
ExternalNotificationConfigItemList(
|
||||
externalNotificationConfig = moduleConfig.externalNotification,
|
||||
ringtone = ringtone,
|
||||
extNotificationConfig = moduleConfig.externalNotification,
|
||||
enabled = connected,
|
||||
focusManager = focusManager,
|
||||
onSaveClicked = { externalNotificationInput ->
|
||||
onSaveClicked = { ringtoneInput, extNotificationInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateExternalNotificationConfig { externalNotificationInput }
|
||||
if (ringtoneInput != ringtone) {
|
||||
viewModel.setRingtone(destNum, ringtoneInput)
|
||||
ringtone = ringtoneInput
|
||||
}
|
||||
if (extNotificationInput != moduleConfig.externalNotification) {
|
||||
val config = moduleConfig { externalNotification = extNotificationInput }
|
||||
viewModel.setModuleConfig(destNum, config)
|
||||
moduleConfig = config
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -253,7 +563,9 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) {
|
|||
focusManager = focusManager,
|
||||
onSaveClicked = { storeForwardInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateStoreForwardConfig { storeForwardInput }
|
||||
val config = moduleConfig { storeForward = storeForwardInput }
|
||||
viewModel.setModuleConfig(destNum, config)
|
||||
moduleConfig = config
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -264,7 +576,9 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) {
|
|||
focusManager = focusManager,
|
||||
onSaveClicked = { rangeTestInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateRangeTestConfig { rangeTestInput }
|
||||
val config = moduleConfig { rangeTest = rangeTestInput }
|
||||
viewModel.setModuleConfig(destNum, config)
|
||||
moduleConfig = config
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -275,18 +589,29 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) {
|
|||
focusManager = focusManager,
|
||||
onSaveClicked = { telemetryInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateTelemetryConfig { telemetryInput }
|
||||
val config = moduleConfig { telemetry = telemetryInput }
|
||||
viewModel.setModuleConfig(destNum, config)
|
||||
moduleConfig = config
|
||||
}
|
||||
)
|
||||
}
|
||||
composable("canned_message") {
|
||||
CannedMessageConfigItemList(
|
||||
messages = cannedMessageMessages,
|
||||
cannedMessageConfig = moduleConfig.cannedMessage,
|
||||
enabled = connected,
|
||||
focusManager = focusManager,
|
||||
onSaveClicked = { cannedMessageInput ->
|
||||
onSaveClicked = { messagesInput, cannedMessageInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateCannedMessageConfig { cannedMessageInput }
|
||||
if (messagesInput != cannedMessageMessages) {
|
||||
viewModel.setCannedMessages(destNum, messagesInput)
|
||||
cannedMessageMessages = messagesInput
|
||||
}
|
||||
if (cannedMessageInput != moduleConfig.cannedMessage) {
|
||||
val config = moduleConfig { cannedMessage = cannedMessageInput }
|
||||
viewModel.setModuleConfig(destNum, config)
|
||||
moduleConfig = config
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -297,7 +622,9 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) {
|
|||
focusManager = focusManager,
|
||||
onSaveClicked = { audioInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateAudioConfig { audioInput }
|
||||
val config = moduleConfig { audio = audioInput }
|
||||
viewModel.setModuleConfig(destNum, config)
|
||||
moduleConfig = config
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -308,7 +635,9 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) {
|
|||
focusManager = focusManager,
|
||||
onSaveClicked = { remoteHardwareInput ->
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateRemoteHardwareConfig { remoteHardwareInput }
|
||||
val config = moduleConfig { remoteHardware = remoteHardwareInput }
|
||||
viewModel.setModuleConfig(destNum, config)
|
||||
moduleConfig = config
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -316,57 +645,144 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun NavCard(title: String, onClick: () -> Unit) {
|
||||
fun NavCard(
|
||||
title: String,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val color = if (enabled) MaterialTheme.colors.onSurface
|
||||
else MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp, horizontal = 16.dp)
|
||||
.clickable { onClick() },
|
||||
.padding(vertical = 2.dp)
|
||||
.clickable(enabled = enabled) { onClick() },
|
||||
elevation = 4.dp
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp)
|
||||
modifier = Modifier.padding(vertical = 12.dp, horizontal = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = color,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
Icons.TwoTone.KeyboardArrowRight, "trailingIcon",
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
tint = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadioSettingsScreen(navController: NavHostController) {
|
||||
LazyColumn {
|
||||
item {
|
||||
PreferenceCategory(
|
||||
stringResource(id = R.string.device_settings), Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
fun NavCard(@StringRes title: Int, enabled: Boolean, onClick: () -> Unit) {
|
||||
NavCard(title = stringResource(title), enabled = enabled, onClick = onClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavButton(@StringRes title: Int, enabled: Boolean, onClick: () -> Unit) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
if (showDialog) AlertDialog(
|
||||
onDismissRequest = { },
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_twotone_warning_24),
|
||||
"warning",
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = "${stringResource(title)}?\n")
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_twotone_warning_24),
|
||||
"warning",
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
buttons = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { showDialog = false }
|
||||
) { Text(stringResource(R.string.cancel)) }
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
showDialog = false
|
||||
onClick()
|
||||
},
|
||||
) { Text(stringResource(R.string.send)) }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
enabled = enabled,
|
||||
onClick = { showDialog = true },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled)
|
||||
)
|
||||
) { Text(text = stringResource(title)) }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun RadioSettingsScreen(
|
||||
enabled: Boolean = true,
|
||||
isLocal: Boolean = true,
|
||||
headerText: String = "longName",
|
||||
onRouteClick: (Any) -> Unit = {},
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
stickyHeader { TextDividerPreference(headerText) }
|
||||
|
||||
item { PreferenceCategory(stringResource(R.string.device_settings)) }
|
||||
item { NavCard("User", enabled = enabled) { onRouteClick("USER") } }
|
||||
item { NavCard("Channels", enabled = enabled) { onRouteClick("CHANNELS") } }
|
||||
items(ConfigDest.values()) { configs ->
|
||||
NavCard(configs.title) { navController.navigate(configs.route) }
|
||||
}
|
||||
|
||||
item {
|
||||
PreferenceCategory(
|
||||
stringResource(id = R.string.module_settings), Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
NavCard(configs.title, enabled = enabled) { onRouteClick(configs.config) }
|
||||
}
|
||||
|
||||
item { PreferenceCategory(stringResource(R.string.module_settings)) }
|
||||
items(ModuleDest.values()) { modules ->
|
||||
NavCard(modules.title) { navController.navigate(modules.route) }
|
||||
NavCard(modules.title, enabled = enabled) { onRouteClick(modules.config) }
|
||||
}
|
||||
|
||||
if (isLocal) {
|
||||
item { PreferenceCategory("Import / Export") }
|
||||
item { NavCard("Import configuration", enabled = enabled) { onRouteClick("IMPORT") } }
|
||||
item { NavCard("Export configuration", enabled = enabled) { onRouteClick("EXPORT") } }
|
||||
}
|
||||
|
||||
item { NavButton(R.string.reboot, enabled) { onRouteClick("REBOOT") } }
|
||||
item { NavButton(R.string.shutdown, enabled) { onRouteClick("SHUTDOWN") } }
|
||||
item { NavButton(R.string.factory_reset, enabled) { onRouteClick("FACTORY_RESET") } }
|
||||
item { NavButton(R.string.nodedb_reset, enabled) { onRouteClick("NODEDB_RESET") } }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun RadioSettingsScreenPreview(){
|
||||
RadioSettingsScreen(NavHostController(LocalContext.current))
|
||||
fun RadioSettingsScreenPreview() {
|
||||
RadioSettingsScreen()
|
||||
}
|
||||
|
|
|
@ -140,8 +140,7 @@ class QuickChatSettingsFragment : ScreenFragment("Quick Chat Settings"), Logging
|
|||
val builder = MaterialAlertDialogBuilder(context)
|
||||
builder.setTitle(title)
|
||||
|
||||
val layout =
|
||||
LayoutInflater.from(requireContext()).inflate(R.layout.dialog_add_quick_chat, null)
|
||||
val layout = LayoutInflater.from(context).inflate(R.layout.dialog_add_quick_chat, null)
|
||||
|
||||
val nameInput: EditText = layout.findViewById(R.id.addQuickChatName)
|
||||
val messageInput: EditText = layout.findViewById(R.id.addQuickChatMessage)
|
||||
|
@ -149,7 +148,8 @@ class QuickChatSettingsFragment : ScreenFragment("Quick Chat Settings"), Logging
|
|||
val instantImage: ImageView = layout.findViewById(R.id.addQuickChatInsant)
|
||||
instantImage.visibility = if (modeSwitch.isChecked) View.VISIBLE else View.INVISIBLE
|
||||
|
||||
var nameHasChanged = false
|
||||
// don't change action name on edits
|
||||
var nameHasChanged = title == getString(R.string.quick_chat_edit)
|
||||
|
||||
modeSwitch.setOnCheckedChangeListener { _, _ ->
|
||||
if (modeSwitch.isChecked) {
|
||||
|
|
|
@ -13,6 +13,7 @@ import android.hardware.usb.UsbManager
|
|||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.RemoteException
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
@ -26,8 +27,8 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.geeksville.mesh.analytics.DataPair
|
||||
import com.geeksville.mesh.android.GeeksvilleApplication
|
||||
import com.geeksville.mesh.android.Logging
|
||||
|
@ -54,9 +55,7 @@ import com.geeksville.mesh.util.onEditorAction
|
|||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
|
@ -75,7 +74,6 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
|||
|
||||
@Inject
|
||||
internal lateinit var locationRepository: LocationRepository
|
||||
private var receivingLocationUpdates: Job? = null
|
||||
|
||||
private val myActivity get() = requireActivity() as MainActivity
|
||||
|
||||
|
@ -194,7 +192,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
|||
// We don't want to be notified of our own changes, so turn off listener while making them
|
||||
spinner.setSelection(regionIndex, false)
|
||||
spinner.onItemSelectedListener = regionSpinnerListener
|
||||
spinner.isEnabled = true
|
||||
spinner.isEnabled = !model.isManaged
|
||||
|
||||
// If actively connected possibly let the user update firmware
|
||||
refreshUpdateButton(model.isConnected())
|
||||
|
@ -283,15 +281,19 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
|||
regionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
spinner.adapter = regionAdapter
|
||||
|
||||
model.ownerName.observe(viewLifecycleOwner) { name ->
|
||||
binding.usernameEditText.isEnabled = !name.isNullOrEmpty()
|
||||
model.ourNodeInfo.asLiveData().observe(viewLifecycleOwner) { node ->
|
||||
val name = node?.user?.longName
|
||||
binding.usernameEditText.isEnabled = !name.isNullOrEmpty() && !model.isManaged
|
||||
binding.usernameEditText.setText(name)
|
||||
}
|
||||
|
||||
scanModel.devices.observe(viewLifecycleOwner) { devices ->
|
||||
updateDevicesButtons(devices)
|
||||
}
|
||||
|
||||
// Only let user edit their name or set software update while connected to a radio
|
||||
model.connectionState.observe(viewLifecycleOwner) {
|
||||
updateNodeInfo()
|
||||
updateDevicesButtons(scanModel.devices.value)
|
||||
}
|
||||
|
||||
model.localConfig.asLiveData().observe(viewLifecycleOwner) {
|
||||
|
@ -325,10 +327,6 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
|||
updateNodeInfo()
|
||||
}
|
||||
|
||||
scanModel.devices.observe(viewLifecycleOwner) { devices ->
|
||||
updateDevicesButtons(devices)
|
||||
}
|
||||
|
||||
scanModel.errorText.observe(viewLifecycleOwner) { errMsg ->
|
||||
if (errMsg != null) {
|
||||
binding.scanStatusText.text = errMsg
|
||||
|
@ -364,17 +362,19 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
|||
val n = binding.usernameEditText.text.toString().trim()
|
||||
model.ourNodeInfo.value?.user?.let {
|
||||
val user = it.copy(longName = n, shortName = getInitials(n))
|
||||
if (n.isNotEmpty()) model.setOwner(user)
|
||||
if (n.isNotEmpty()) model.setOwner(user.toProto())
|
||||
}
|
||||
requireActivity().hideKeyboard()
|
||||
}
|
||||
|
||||
// Observe receivingLocationUpdates state and update provideLocationCheckbox
|
||||
if (receivingLocationUpdates?.isActive == true) return
|
||||
else receivingLocationUpdates = locationRepository.receivingLocationUpdates
|
||||
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
|
||||
.onEach { binding.provideLocationCheckbox.isChecked = it }
|
||||
.launchIn(lifecycleScope)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
locationRepository.receivingLocationUpdates.collect {
|
||||
binding.provideLocationCheckbox.isChecked = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.provideLocationCheckbox.setOnCheckedChangeListener { view, isChecked ->
|
||||
// Don't check the box until the system setting changes
|
||||
|
@ -510,12 +510,24 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
|||
}
|
||||
}
|
||||
|
||||
private fun changeDeviceAddress(address: String) {
|
||||
try {
|
||||
model.meshService?.let { service ->
|
||||
MeshService.changeDeviceAddress(requireActivity(), service, address)
|
||||
}
|
||||
scanModel.changeSelectedAddress(address) // if it throws the change will be discarded
|
||||
} catch (ex: RemoteException) {
|
||||
errormsg("changeDeviceSelection failed, probably it is shutting down $ex.message")
|
||||
// ignore the failure and the GUI won't be updating anyways
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the GUI when a new device has been selected by the user
|
||||
/// Returns true if we were able to change to that item
|
||||
private fun onSelected(it: BTScanModel.DeviceListEntry): Boolean {
|
||||
// If the device is paired, let user select it, otherwise start the pairing flow
|
||||
if (it.bonded) {
|
||||
scanModel.changeDeviceAddress(it.fullAddress)
|
||||
changeDeviceAddress(it.fullAddress)
|
||||
return true
|
||||
} else {
|
||||
// Handle requesting USB or bluetooth permissions for the device
|
||||
|
@ -529,7 +541,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
|||
requestBonding(device) { state ->
|
||||
if (state == BluetoothDevice.BOND_BONDED) {
|
||||
scanModel.setErrorText(getString(R.string.pairing_completed))
|
||||
scanModel.changeDeviceAddress(it.fullAddress)
|
||||
changeDeviceAddress(it.fullAddress)
|
||||
} else {
|
||||
scanModel.setErrorText(getString(R.string.pairing_failed_try_again))
|
||||
}
|
||||
|
@ -555,7 +567,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
|
|||
)
|
||||
) {
|
||||
info("User approved USB access")
|
||||
scanModel.changeDeviceAddress(it.fullAddress)
|
||||
changeDeviceAddress(it.fullAddress)
|
||||
} else {
|
||||
errormsg("USB permission denied for device $device")
|
||||
}
|
||||
|
|
|
@ -56,6 +56,7 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
private var nodes = arrayOf<NodeInfo>()
|
||||
|
||||
private fun popup(view: View, position: Int) {
|
||||
if (!model.isConnected()) return
|
||||
val node = nodes[position]
|
||||
val user = node.user
|
||||
val showAdmin = position == 0 || model.adminChannelIndex > 0
|
||||
|
@ -63,6 +64,7 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
popup.inflate(R.menu.menu_nodes)
|
||||
popup.menu.setGroupVisible(R.id.group_remote, position > 0)
|
||||
popup.menu.setGroupVisible(R.id.group_admin, showAdmin)
|
||||
popup.menu.setGroupEnabled(R.id.group_admin, !model.isManaged)
|
||||
popup.setOnMenuItemClickListener { item: MenuItem ->
|
||||
when (item.itemId) {
|
||||
R.id.direct_message -> {
|
||||
|
@ -93,55 +95,12 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
model.requestTraceroute(node.num)
|
||||
}
|
||||
}
|
||||
R.id.reboot -> {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("${getString(R.string.reboot)}\n${user?.longName}?")
|
||||
.setIcon(R.drawable.ic_twotone_warning_24)
|
||||
.setNeutralButton(R.string.cancel) { _, _ ->
|
||||
}
|
||||
.setPositiveButton(getString(R.string.okay)) { _, _ ->
|
||||
debug("User clicked requestReboot")
|
||||
model.requestReboot(node.num)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
R.id.shutdown -> {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("${getString(R.string.shutdown)}\n${user?.longName}?")
|
||||
.setIcon(R.drawable.ic_twotone_warning_24)
|
||||
.setNeutralButton(R.string.cancel) { _, _ ->
|
||||
}
|
||||
.setPositiveButton(getString(R.string.okay)) { _, _ ->
|
||||
debug("User clicked requestShutdown")
|
||||
model.requestShutdown(node.num)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
R.id.factory_reset -> {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("${getString(R.string.factory_reset)}\n${user?.longName}?")
|
||||
.setIcon(R.drawable.ic_twotone_warning_24)
|
||||
.setMessage(R.string.factory_reset_description)
|
||||
.setNeutralButton(R.string.cancel) { _, _ ->
|
||||
}
|
||||
.setPositiveButton(R.string.okay) { _, _ ->
|
||||
debug("User clicked requestFactoryReset")
|
||||
model.requestFactoryReset(node.num)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
R.id.nodedb_reset -> {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("${getString(R.string.nodedb_reset)}\n${user?.longName}?")
|
||||
.setIcon(R.drawable.ic_twotone_warning_24)
|
||||
.setMessage(R.string.nodedb_reset_description)
|
||||
.setNeutralButton(R.string.cancel) { _, _ ->
|
||||
}
|
||||
.setPositiveButton(getString(R.string.okay)) { _, _ ->
|
||||
debug("User clicked requestNodedbReset")
|
||||
model.requestNodedbReset(node.num)
|
||||
}
|
||||
.show()
|
||||
R.id.remote_admin -> {
|
||||
debug("calling remote admin --> destNum: ${node.num}")
|
||||
parentFragmentManager.beginTransaction()
|
||||
.replace(R.id.mainActivityLayout, DeviceSettingsFragment(node))
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
true
|
||||
|
@ -254,20 +213,15 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
}
|
||||
|
||||
if (n.num == ourNodeInfo?.num) {
|
||||
val info = model.myNodeInfo.value
|
||||
if (info != null) {
|
||||
val text =
|
||||
String.format(
|
||||
"ChUtil %.1f%% AirUtilTX %.1f%%",
|
||||
n.deviceMetrics?.channelUtilization ?: info.channelUtilization,
|
||||
n.deviceMetrics?.airUtilTx ?: info.airUtilTx
|
||||
)
|
||||
holder.signalView.text = text
|
||||
holder.signalView.visibility = View.VISIBLE
|
||||
}
|
||||
val text = "ChUtil %.1f%% AirUtilTX %.1f%%".format(
|
||||
n.deviceMetrics?.channelUtilization,
|
||||
n.deviceMetrics?.airUtilTx
|
||||
)
|
||||
holder.signalView.text = text
|
||||
holder.signalView.visibility = View.VISIBLE
|
||||
} else {
|
||||
if ((n.snr < 100f) && (n.rssi < 0)) {
|
||||
val text = String.format("rssi:%d snr:%.1f", n.rssi, n.snr)
|
||||
val text = "rssi:%d snr:%.1f".format(n.rssi, n.snr)
|
||||
holder.signalView.text = text
|
||||
holder.signalView.visibility = View.VISIBLE
|
||||
} else {
|
||||
|
@ -301,7 +255,7 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
val (image, text) = when (battery) {
|
||||
in 0..100 -> Pair(
|
||||
R.drawable.ic_battery_full_24,
|
||||
String.format("%d%% %.2fV", battery, voltage ?: 0)
|
||||
"%d%% %.2fV".format(battery, voltage ?: 0)
|
||||
)
|
||||
101 -> Pair(R.drawable.ic_power_plug_24, "")
|
||||
else -> Pair(R.drawable.ic_battery_full_24, "?")
|
||||
|
@ -327,18 +281,26 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
binding.nodeListView.adapter = nodesAdapter
|
||||
binding.nodeListView.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
// ensure our local node is first (index 0)
|
||||
fun Map<String, NodeInfo>.perhapsReindexBy(nodeNum: Int?): Array<NodeInfo> =
|
||||
if (size > 1 && nodeNum != null && values.firstOrNull()?.num != nodeNum) {
|
||||
values.partition { node -> node.num == nodeNum }.let { it.first + it.second }
|
||||
} else {
|
||||
values
|
||||
}.toTypedArray()
|
||||
|
||||
model.nodeDB.nodes.observe(viewLifecycleOwner) {
|
||||
nodesAdapter.onNodesChanged(it.values.toTypedArray())
|
||||
nodesAdapter.onNodesChanged(it.perhapsReindexBy(model.myNodeNum))
|
||||
}
|
||||
|
||||
model.packetResponse.asLiveData().observe(viewLifecycleOwner) { meshLog ->
|
||||
meshLog?.meshPacket?.let { meshPacket ->
|
||||
val routeList = meshLog.routeDiscovery?.routeList
|
||||
val routeList = meshLog.routeDiscovery?.routeList ?: return@let
|
||||
fun nodeName(num: Int) = model.nodeDB.nodesByNum?.get(num)?.user?.longName
|
||||
|
||||
var routeStr = "${nodeName(meshPacket.from)} --> "
|
||||
routeList?.forEach { num -> routeStr += "${nodeName(num)} --> " }
|
||||
routeStr += "${nodeName(meshPacket.to)}"
|
||||
var routeStr = "${nodeName(meshPacket.to)} --> "
|
||||
routeList.forEach { num -> routeStr += "${nodeName(num)} --> " }
|
||||
routeStr += "${nodeName(meshPacket.from)}"
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.traceroute)
|
||||
|
|
|
@ -37,8 +37,8 @@ fun BitwisePreference(
|
|||
subtitle = value.toString(),
|
||||
onClick = { dropDownExpanded = !dropDownExpanded },
|
||||
enabled = enabled,
|
||||
trailingIcon = if (dropDownExpanded) Icons.TwoTone.KeyboardArrowDown
|
||||
else Icons.TwoTone.KeyboardArrowUp,
|
||||
trailingIcon = if (dropDownExpanded) Icons.TwoTone.KeyboardArrowUp
|
||||
else Icons.TwoTone.KeyboardArrowDown,
|
||||
)
|
||||
|
||||
Box {
|
||||
|
|
|
@ -0,0 +1,184 @@
|
|||
package com.geeksville.mesh.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.ContentAlpha
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ModuleConfigProtos.RemoteHardwarePin
|
||||
import com.geeksville.mesh.ModuleConfigProtos.RemoteHardwarePinType
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.remoteHardwarePin
|
||||
|
||||
@Composable
|
||||
inline fun <reified T> EditListPreference(
|
||||
title: String,
|
||||
list: List<T>,
|
||||
maxCount: Int,
|
||||
enabled: Boolean,
|
||||
keyboardActions: KeyboardActions,
|
||||
crossinline onValuesChanged: (List<T>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = remember(list) { mutableStateListOf<T>().apply { addAll(list) } }
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
modifier = modifier.padding(16.dp),
|
||||
text = title,
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = if (!enabled) MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) else Color.Unspecified,
|
||||
)
|
||||
listState.forEachIndexed { index, value ->
|
||||
// handle lora.ignoreIncoming: List<Int>
|
||||
if (value is Int) EditTextPreference(
|
||||
title = "${index + 1}/$maxCount",
|
||||
value = value,
|
||||
enabled = enabled,
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
listState[index] = it as T
|
||||
onValuesChanged(listState)
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
listState.removeAt(index)
|
||||
onValuesChanged(listState)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.TwoTone.Close,
|
||||
stringResource(R.string.delete),
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// handle remoteHardware.availablePins: List<RemoteHardwarePin>
|
||||
if (value is RemoteHardwarePin) {
|
||||
EditTextPreference(
|
||||
title = "GPIO pin",
|
||||
value = value.gpioPin,
|
||||
enabled = enabled,
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
if (it in 0..255) {
|
||||
listState[index] = value.copy { gpioPin = it } as T
|
||||
onValuesChanged(listState)
|
||||
}
|
||||
},
|
||||
)
|
||||
EditTextPreference(
|
||||
title = "Name",
|
||||
value = value.name,
|
||||
maxSize = 14, // name max_size:15
|
||||
enabled = enabled,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
listState[index] = value.copy { name = it } as T
|
||||
onValuesChanged(listState)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
listState.removeAt(index)
|
||||
onValuesChanged(listState)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.TwoTone.Close,
|
||||
stringResource(R.string.delete),
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
DropDownPreference(
|
||||
title = "Type",
|
||||
enabled = enabled,
|
||||
items = RemoteHardwarePinType.values()
|
||||
.filter { it != RemoteHardwarePinType.UNRECOGNIZED }
|
||||
.map { it to it.name },
|
||||
selectedItem = value.type,
|
||||
onItemSelected = {
|
||||
listState[index] = value.copy { type = it } as T
|
||||
onValuesChanged(listState)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
// Add element based on the type T
|
||||
val newElement = when (T::class) {
|
||||
Int::class -> 0 as T
|
||||
RemoteHardwarePin::class -> remoteHardwarePin {} as T
|
||||
else -> throw IllegalArgumentException("Unsupported type: ${T::class}")
|
||||
}
|
||||
listState.add(listState.size, newElement)
|
||||
},
|
||||
enabled = maxCount > listState.size,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled)
|
||||
)
|
||||
) { Text(text = stringResource(R.string.add)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun EditListPreferencePreview() {
|
||||
Column {
|
||||
EditListPreference(
|
||||
title = "Ignore incoming",
|
||||
list = listOf(12345, 67890),
|
||||
maxCount = 4,
|
||||
enabled = true,
|
||||
keyboardActions = KeyboardActions {},
|
||||
onValuesChanged = {},
|
||||
)
|
||||
EditListPreference(
|
||||
title = "Available pins",
|
||||
list = listOf(
|
||||
remoteHardwarePin {
|
||||
gpioPin = 12
|
||||
name = "Front door"
|
||||
type = RemoteHardwarePinType.DIGITAL_READ
|
||||
},
|
||||
),
|
||||
maxCount = 4,
|
||||
enabled = true,
|
||||
keyboardActions = KeyboardActions {},
|
||||
onValuesChanged = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -14,7 +14,6 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.twotone.Info
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
@ -35,6 +34,7 @@ fun EditTextPreference(
|
|||
keyboardActions: KeyboardActions,
|
||||
onValueChanged: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
var valueState by remember(value) { mutableStateOf(value.toUInt().toString()) }
|
||||
|
||||
|
@ -55,7 +55,8 @@ fun EditTextPreference(
|
|||
}
|
||||
},
|
||||
onFocusChanged = {},
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
trailingIcon = trailingIcon
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -101,6 +102,7 @@ fun EditTextPreference(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var valueState by remember(value) { mutableStateOf(value.toString()) }
|
||||
val decimalSeparators = setOf('.', ',', '٫', '、', '·') // set of possible decimal separators
|
||||
|
||||
EditTextPreference(
|
||||
title = title,
|
||||
|
@ -112,7 +114,7 @@ fun EditTextPreference(
|
|||
),
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
if (it.isEmpty()) valueState = it
|
||||
if (it.length <= 1 || it.first() in decimalSeparators) valueState = it
|
||||
else it.toDoubleOrNull()?.let { double ->
|
||||
valueState = it
|
||||
onValueChanged(double)
|
||||
|
@ -132,16 +134,18 @@ fun EditIPv4Preference(
|
|||
onValueChanged: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pattern = """\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b""".toRegex()
|
||||
|
||||
fun convertIntToIpAddress(int: Int): String {
|
||||
return "${int shr 24 and 0xff}.${int shr 16 and 0xff}.${int shr 8 and 0xff}.${int and 0xff}"
|
||||
}
|
||||
fun convertIpAddressToInt(ipAddress: String): Int? {
|
||||
return ipAddress.split(".")
|
||||
.map { it.toIntOrNull() }
|
||||
.fold(0) { total, next ->
|
||||
if (next == null) return null else total shl 8 or next
|
||||
}
|
||||
return "${int and 0xff}.${int shr 8 and 0xff}.${int shr 16 and 0xff}.${int shr 24 and 0xff}"
|
||||
}
|
||||
|
||||
fun convertIpAddressToInt(ipAddress: String): Int? = ipAddress.split(".")
|
||||
.map { it.toIntOrNull() }.reversed() // little-endian byte order
|
||||
.fold(0) { total, next ->
|
||||
if (next == null) return null else total shl 8 or next
|
||||
}
|
||||
|
||||
var valueState by remember(value) { mutableStateOf(convertIntToIpAddress(value)) }
|
||||
|
||||
EditTextPreference(
|
||||
|
@ -154,49 +158,14 @@ fun EditIPv4Preference(
|
|||
),
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
val pattern = """\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b""".toRegex()
|
||||
val isValid = pattern.matches(it)
|
||||
if (it.isEmpty() || !isValid) valueState = it
|
||||
else convertIpAddressToInt(it)?.let { int ->
|
||||
valueState = it
|
||||
onValueChanged(int)
|
||||
}
|
||||
valueState = it
|
||||
if (pattern.matches(it)) convertIpAddressToInt(it)?.let { int -> onValueChanged(int) }
|
||||
},
|
||||
onFocusChanged = {},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditListPreference(
|
||||
title: String,
|
||||
list: List<Int>,
|
||||
maxCount: Int,
|
||||
enabled: Boolean,
|
||||
keyboardActions: KeyboardActions,
|
||||
onValuesChanged: (List<Int>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = remember(list) { mutableStateListOf<Int>().apply { addAll(list) } }
|
||||
|
||||
Column(modifier = modifier) {
|
||||
for (i in 0..list.size.coerceAtMost(maxCount - 1)) {
|
||||
val value = listState.getOrNull(i)
|
||||
EditTextPreference(
|
||||
title = "$title ${i + 1}/$maxCount",
|
||||
value = value ?: 0,
|
||||
enabled = enabled,
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
if (value == null) listState.add(it) else listState[i] = it
|
||||
onValuesChanged(listState)
|
||||
},
|
||||
modifier = modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditTextPreference(
|
||||
title: String,
|
||||
|
@ -207,34 +176,9 @@ fun EditTextPreference(
|
|||
keyboardActions: KeyboardActions,
|
||||
onValueChanged: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
maxSize: Int // max_size - 1 (in bytes)
|
||||
) {
|
||||
EditTextPreference(
|
||||
title = title,
|
||||
value = value,
|
||||
maxSize = maxSize,
|
||||
enabled = enabled,
|
||||
isError = isError,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = onValueChanged,
|
||||
onFocusChanged = {},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditTextPreference(
|
||||
title: String,
|
||||
value: String,
|
||||
enabled: Boolean,
|
||||
isError: Boolean,
|
||||
keyboardOptions: KeyboardOptions,
|
||||
keyboardActions: KeyboardActions,
|
||||
onValueChanged: (String) -> Unit,
|
||||
onFocusChanged: (FocusState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
maxSize: Int = 0, // max_size - 1 (in bytes)
|
||||
onFocusChanged: (FocusState) -> Unit = {},
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
|
@ -257,7 +201,11 @@ fun EditTextPreference(
|
|||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
trailingIcon = {
|
||||
if (isError) Icon(Icons.TwoTone.Info, "Error", tint = MaterialTheme.colors.error)
|
||||
if (trailingIcon != null) {
|
||||
trailingIcon()
|
||||
} else {
|
||||
if (isError) Icon(Icons.TwoTone.Info, "Error", tint = MaterialTheme.colors.error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -299,7 +247,7 @@ private fun EditTextPreferencePreview() {
|
|||
)
|
||||
EditIPv4Preference(
|
||||
title = "IP Address",
|
||||
value = 3232235521.toInt(),
|
||||
value = 16820416,
|
||||
enabled = true,
|
||||
keyboardActions = KeyboardActions {},
|
||||
onValueChanged = {}
|
||||
|
|
|
@ -6,7 +6,6 @@ import androidx.compose.material.*
|
|||
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.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
@ -52,7 +51,6 @@ fun PreferenceFooter(
|
|||
enabled = enabled,
|
||||
onClick = onNegativeClicked,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = Color.Red.copy(alpha = 0.6f),
|
||||
disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled)
|
||||
)
|
||||
) {
|
||||
|
@ -69,7 +67,6 @@ fun PreferenceFooter(
|
|||
enabled = enabled,
|
||||
onClick = onPositiveClicked,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = Color.Green.copy(alpha = 0.6f),
|
||||
disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled)
|
||||
)
|
||||
) {
|
||||
|
|
|
@ -26,12 +26,14 @@ import com.geeksville.mesh.ui.components.SwitchPreference
|
|||
|
||||
@Composable
|
||||
fun CannedMessageConfigItemList(
|
||||
messages: String,
|
||||
cannedMessageConfig: CannedMessageConfig,
|
||||
enabled: Boolean,
|
||||
focusManager: FocusManager,
|
||||
onSaveClicked: (CannedMessageConfig) -> Unit,
|
||||
onSaveClicked: (messages: String, config: CannedMessageConfig) -> Unit,
|
||||
) {
|
||||
var cannedMessageInput by remember(cannedMessageConfig) { mutableStateOf(cannedMessageConfig) }
|
||||
var messagesInput by remember { mutableStateOf(messages) }
|
||||
var cannedMessageInput by remember { mutableStateOf(cannedMessageConfig) }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
|
@ -162,14 +164,29 @@ fun CannedMessageConfigItemList(
|
|||
}
|
||||
item { Divider() }
|
||||
|
||||
item {
|
||||
EditTextPreference(title = "Messages",
|
||||
value = messagesInput,
|
||||
maxSize = 200, // messages max_size:201
|
||||
enabled = enabled,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { messagesInput = it }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
PreferenceFooter(
|
||||
enabled = cannedMessageInput != cannedMessageConfig,
|
||||
enabled = cannedMessageInput != cannedMessageConfig || messagesInput != messages,
|
||||
onCancelClicked = {
|
||||
focusManager.clearFocus()
|
||||
messagesInput = messages
|
||||
cannedMessageInput = cannedMessageConfig
|
||||
},
|
||||
onSaveClicked = { onSaveClicked(cannedMessageInput) }
|
||||
onSaveClicked = { onSaveClicked(messagesInput,cannedMessageInput) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -179,9 +196,10 @@ fun CannedMessageConfigItemList(
|
|||
@Composable
|
||||
fun CannedMessageConfigPreview(){
|
||||
CannedMessageConfigItemList(
|
||||
messages = "",
|
||||
cannedMessageConfig = CannedMessageConfig.getDefaultInstance(),
|
||||
enabled = true,
|
||||
focusManager = LocalFocusManager.current,
|
||||
onSaveClicked = { },
|
||||
onSaveClicked = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,204 @@
|
|||
package com.geeksville.mesh.ui.components.config
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Chip
|
||||
import androidx.compose.material.ContentAlpha
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.FloatingActionButton
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Add
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusManager
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ChannelProtos.ChannelSettings
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.channelSettings
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.ui.components.PreferenceCategory
|
||||
import com.geeksville.mesh.ui.components.PreferenceFooter
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun ChannelCard(
|
||||
index: Int,
|
||||
title: String,
|
||||
enabled: Boolean,
|
||||
onEditClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.clickable(enabled = enabled) { onEditClick() },
|
||||
elevation = 4.dp
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp)
|
||||
) {
|
||||
Chip(onClick = onEditClick) { Text("$index") }
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = if (!enabled) MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) else Color.Unspecified,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = { onDeleteClick() }) {
|
||||
Icon(
|
||||
Icons.TwoTone.Close,
|
||||
stringResource(R.string.delete),
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChannelSettingsItemList(
|
||||
settingsList: List<ChannelSettings>,
|
||||
modemPresetName: String = "Default",
|
||||
maxChannels: Int = 8,
|
||||
enabled: Boolean,
|
||||
focusManager: FocusManager,
|
||||
onNegativeClicked: () -> Unit = { },
|
||||
@StringRes positiveText: Int = R.string.send,
|
||||
onPositiveClicked: (List<ChannelSettings>) -> Unit,
|
||||
) {
|
||||
val settingsListInput = remember {
|
||||
mutableStateListOf<ChannelSettings>().apply { addAll(settingsList) }
|
||||
}
|
||||
|
||||
val isEditing: Boolean = settingsList.size != settingsListInput.size
|
||||
|| settingsList.zip(settingsListInput).any { (item1, item2) -> item1 != item2 }
|
||||
|
||||
var showEditChannelDialog: Int? by remember { mutableStateOf(null) }
|
||||
|
||||
if (showEditChannelDialog != null) {
|
||||
val index = showEditChannelDialog ?: return
|
||||
EditChannelDialog(
|
||||
channelSettings = with(settingsListInput) {
|
||||
if (size > index) get(index) else channelSettings { }
|
||||
},
|
||||
modemPresetName = modemPresetName,
|
||||
onAddClick = {
|
||||
if (settingsListInput.size > index) settingsListInput[index] = it
|
||||
else settingsListInput.add(it)
|
||||
showEditChannelDialog = null
|
||||
},
|
||||
onDismissRequest = { showEditChannelDialog = null }
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(onClick = { }, enabled = false)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
item { PreferenceCategory(text = "Channels") }
|
||||
|
||||
itemsIndexed(settingsListInput) { index, channel ->
|
||||
ChannelCard(
|
||||
index = index,
|
||||
title = channel.name.ifEmpty { modemPresetName },
|
||||
enabled = enabled,
|
||||
onEditClick = { showEditChannelDialog = index },
|
||||
onDeleteClick = { settingsListInput.removeAt(index) }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
PreferenceFooter(
|
||||
// FIXME workaround until we use navigation in ChannelFragment
|
||||
enabled = isEditing || positiveText != R.string.send,
|
||||
negativeText = R.string.cancel,
|
||||
onNegativeClicked = {
|
||||
focusManager.clearFocus()
|
||||
settingsListInput.clear()
|
||||
settingsListInput.addAll(settingsList)
|
||||
onNegativeClicked()
|
||||
},
|
||||
positiveText = positiveText,
|
||||
onPositiveClicked = { onPositiveClicked(settingsListInput) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = maxChannels > settingsListInput.size,
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
enter = slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing)
|
||||
),
|
||||
exit = slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing)
|
||||
)
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
settingsListInput.add(channelSettings {
|
||||
psk = Channel.default.settings.psk
|
||||
})
|
||||
showEditChannelDialog = settingsListInput.lastIndex
|
||||
},
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) { Icon(Icons.TwoTone.Add, stringResource(R.string.add)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ChannelSettingsPreview() {
|
||||
ChannelSettingsItemList(
|
||||
settingsList = listOf(
|
||||
channelSettings {
|
||||
psk = Channel.default.settings.psk
|
||||
name = Channel.default.name
|
||||
},
|
||||
channelSettings {
|
||||
name = stringResource(R.string.channel_name)
|
||||
},
|
||||
),
|
||||
enabled = true,
|
||||
focusManager = LocalFocusManager.current,
|
||||
onPositiveClicked = { },
|
||||
)
|
||||
}
|
|
@ -113,6 +113,16 @@ fun DeviceConfigItemList(
|
|||
}
|
||||
item { Divider() }
|
||||
|
||||
item {
|
||||
SwitchPreference(title = "Managed mode",
|
||||
checked = deviceInput.isManaged,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
deviceInput = deviceInput.copy { isManaged = it }
|
||||
})
|
||||
}
|
||||
item { Divider() }
|
||||
|
||||
item {
|
||||
PreferenceFooter(
|
||||
enabled = deviceInput != deviceConfig,
|
||||
|
|
|
@ -0,0 +1,194 @@
|
|||
package com.geeksville.mesh.ui.components.config
|
||||
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.LocalContentAlpha
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Switch
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
import androidx.compose.material.icons.twotone.Refresh
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ChannelProtos
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.channelSettings
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.ui.components.EditTextPreference
|
||||
import com.google.accompanist.themeadapter.appcompat.AppCompatTheme
|
||||
import com.google.protobuf.ByteString
|
||||
import com.google.protobuf.kotlin.toByteString
|
||||
import java.security.SecureRandom
|
||||
|
||||
@Composable
|
||||
fun EditChannelDialog(
|
||||
channelSettings: ChannelProtos.ChannelSettings,
|
||||
onAddClick: (ChannelProtos.ChannelSettings) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
modemPresetName: String = "Default",
|
||||
) {
|
||||
val base64Flags = Base64.URL_SAFE + Base64.NO_WRAP
|
||||
fun encodeToString(input: ByteString) =
|
||||
Base64.encodeToString(input.toByteArray() ?: ByteArray(0), base64Flags)
|
||||
|
||||
var pskInput by remember { mutableStateOf(channelSettings.psk) }
|
||||
var pskString by remember(pskInput) { mutableStateOf(encodeToString(pskInput)) }
|
||||
val pskError = pskString != encodeToString(pskInput)
|
||||
|
||||
var nameInput by remember { mutableStateOf(channelSettings.name) }
|
||||
var uplinkInput by remember { mutableStateOf(channelSettings.uplinkEnabled) }
|
||||
var downlinkInput by remember { mutableStateOf(channelSettings.downlinkEnabled) }
|
||||
|
||||
fun getRandomKey() {
|
||||
val random = SecureRandom()
|
||||
val bytes = ByteArray(32)
|
||||
random.nextBytes(bytes)
|
||||
pskInput = ByteString.copyFrom(bytes)
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
text = {
|
||||
AppCompatTheme {
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
EditTextPreference(
|
||||
title = stringResource(R.string.channel_name),
|
||||
value = if (isFocused) nameInput else nameInput.ifEmpty { modemPresetName },
|
||||
maxSize = 11, // name max_size:12
|
||||
enabled = true,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { }),
|
||||
onValueChanged = { nameInput = it },
|
||||
onFocusChanged = { isFocused = it.isFocused },
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = pskString,
|
||||
onValueChange = {
|
||||
try {
|
||||
pskString = it // empty (no crypto), 128 or 256 bit only
|
||||
val decoded = Base64.decode(it, base64Flags).toByteString()
|
||||
if (decoded.size() in setOf(0, 16, 32)) pskInput = decoded
|
||||
} catch (ex: Throwable) {
|
||||
// Base64 decode failed, pskError true
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
enabled = true,
|
||||
label = { Text("PSK") },
|
||||
isError = pskError,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { }),
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (pskError) {
|
||||
pskInput = channelSettings.psk
|
||||
pskString = encodeToString(pskInput)
|
||||
} else getRandomKey()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
if (pskError) Icons.TwoTone.Close else Icons.TwoTone.Refresh,
|
||||
contentDescription = stringResource(R.string.reset),
|
||||
tint = if (pskError) MaterialTheme.colors.error
|
||||
else LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Uplink enabled", // TODO move to resource strings
|
||||
modifier = modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = uplinkInput,
|
||||
onCheckedChange = { uplinkInput = it },
|
||||
)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Downlink enabled", // TODO move to resource strings
|
||||
modifier = modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = downlinkInput,
|
||||
onCheckedChange = { downlinkInput = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
buttons = {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp)
|
||||
.weight(1f),
|
||||
onClick = onDismissRequest
|
||||
) { Text(stringResource(R.string.cancel)) }
|
||||
Button(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 24.dp)
|
||||
.weight(1f),
|
||||
onClick = {
|
||||
onAddClick(channelSettings {
|
||||
psk = pskInput
|
||||
name = nameInput.trim()
|
||||
uplinkEnabled = uplinkInput
|
||||
downlinkEnabled = downlinkInput
|
||||
})
|
||||
},
|
||||
enabled = !pskError,
|
||||
) { Text(stringResource(R.string.save)) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun EditChannelDialogPreview() {
|
||||
EditChannelDialog(
|
||||
channelSettings = channelSettings {
|
||||
psk = Channel.default.settings.psk
|
||||
name = Channel.default.name
|
||||
},
|
||||
onAddClick = { },
|
||||
onDismissRequest = { },
|
||||
)
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
package com.geeksville.mesh.ui.components.config
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ClientOnlyProtos
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.deviceProfile
|
||||
import com.geeksville.mesh.ui.components.SwitchPreference
|
||||
import com.google.accompanist.themeadapter.appcompat.AppCompatTheme
|
||||
|
||||
@Composable
|
||||
fun EditDeviceProfileDialog(
|
||||
title: String,
|
||||
deviceProfile: ClientOnlyProtos.DeviceProfile,
|
||||
onAddClick: (ClientOnlyProtos.DeviceProfile) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var longNameInput by remember(deviceProfile) { mutableStateOf(deviceProfile.hasLongName()) }
|
||||
var shortNameInput by remember(deviceProfile) { mutableStateOf(deviceProfile.hasShortName()) }
|
||||
var channelUrlInput by remember(deviceProfile) { mutableStateOf(deviceProfile.hasChannelUrl()) }
|
||||
var configInput by remember(deviceProfile) { mutableStateOf(deviceProfile.hasConfig()) }
|
||||
var moduleConfigInput by remember(deviceProfile) { mutableStateOf(deviceProfile.hasModuleConfig()) }
|
||||
|
||||
AlertDialog(
|
||||
title = { Text(title) },
|
||||
onDismissRequest = onDismissRequest,
|
||||
text = {
|
||||
AppCompatTheme {
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
SwitchPreference(title = "longName",
|
||||
checked = longNameInput,
|
||||
enabled = deviceProfile.hasLongName(),
|
||||
onCheckedChange = { longNameInput = it }
|
||||
)
|
||||
SwitchPreference(title = "shortName",
|
||||
checked = shortNameInput,
|
||||
enabled = deviceProfile.hasShortName(),
|
||||
onCheckedChange = { shortNameInput = it }
|
||||
)
|
||||
SwitchPreference(title = "channelUrl",
|
||||
checked = channelUrlInput,
|
||||
enabled = deviceProfile.hasChannelUrl(),
|
||||
onCheckedChange = { channelUrlInput = it }
|
||||
)
|
||||
SwitchPreference(title = "config",
|
||||
checked = configInput,
|
||||
enabled = deviceProfile.hasConfig(),
|
||||
onCheckedChange = { configInput = it }
|
||||
)
|
||||
SwitchPreference(title = "moduleConfig",
|
||||
checked = moduleConfigInput,
|
||||
enabled = deviceProfile.hasModuleConfig(),
|
||||
onCheckedChange = { moduleConfigInput = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
buttons = {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp)
|
||||
.weight(1f),
|
||||
onClick = onDismissRequest
|
||||
) { Text(stringResource(R.string.cancel)) }
|
||||
Button(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 24.dp)
|
||||
.weight(1f),
|
||||
onClick = {
|
||||
onAddClick(deviceProfile {
|
||||
if (longNameInput) longName = deviceProfile.longName
|
||||
if (shortNameInput) shortName = deviceProfile.shortName
|
||||
if (channelUrlInput) channelUrl = deviceProfile.channelUrl
|
||||
if (configInput) config = deviceProfile.config
|
||||
if (moduleConfigInput) moduleConfig = deviceProfile.moduleConfig
|
||||
})
|
||||
},
|
||||
) { Text(stringResource(R.string.save)) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun EditDeviceProfileDialogPreview() {
|
||||
EditDeviceProfileDialog(
|
||||
title = "Export configuration",
|
||||
deviceProfile = deviceProfile { },
|
||||
onAddClick = { },
|
||||
onDismissRequest = { },
|
||||
)
|
||||
}
|
|
@ -3,6 +3,7 @@ package com.geeksville.mesh.ui.components.config
|
|||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
|
@ -12,6 +13,8 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusManager
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.ExternalNotificationConfig
|
||||
import com.geeksville.mesh.copy
|
||||
|
@ -23,14 +26,14 @@ import com.geeksville.mesh.ui.components.TextDividerPreference
|
|||
|
||||
@Composable
|
||||
fun ExternalNotificationConfigItemList(
|
||||
externalNotificationConfig: ExternalNotificationConfig,
|
||||
ringtone: String,
|
||||
extNotificationConfig: ExternalNotificationConfig,
|
||||
enabled: Boolean,
|
||||
focusManager: FocusManager,
|
||||
onSaveClicked: (ExternalNotificationConfig) -> Unit,
|
||||
onSaveClicked: (ringtone: String, config: ExternalNotificationConfig) -> Unit,
|
||||
) {
|
||||
var externalNotificationInput by remember(externalNotificationConfig) {
|
||||
mutableStateOf(externalNotificationConfig)
|
||||
}
|
||||
var ringtoneInput by remember { mutableStateOf(ringtone) }
|
||||
var externalNotificationInput by remember { mutableStateOf(extNotificationConfig) }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
|
@ -183,14 +186,29 @@ fun ExternalNotificationConfigItemList(
|
|||
})
|
||||
}
|
||||
|
||||
item {
|
||||
EditTextPreference(title = "Ringtone",
|
||||
value = ringtoneInput,
|
||||
maxSize = 230, // ringtone max_size:231
|
||||
enabled = enabled,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { ringtoneInput = it }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
PreferenceFooter(
|
||||
enabled = externalNotificationInput != externalNotificationConfig,
|
||||
enabled = externalNotificationInput != extNotificationConfig || ringtoneInput != ringtone,
|
||||
onCancelClicked = {
|
||||
focusManager.clearFocus()
|
||||
externalNotificationInput = externalNotificationConfig
|
||||
ringtoneInput = ringtone
|
||||
externalNotificationInput = extNotificationConfig
|
||||
},
|
||||
onSaveClicked = { onSaveClicked(externalNotificationInput) }
|
||||
onSaveClicked = { onSaveClicked(ringtoneInput, externalNotificationInput) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -200,9 +218,10 @@ fun ExternalNotificationConfigItemList(
|
|||
@Composable
|
||||
fun ExternalNotificationConfigPreview(){
|
||||
ExternalNotificationConfigItemList(
|
||||
externalNotificationConfig = ExternalNotificationConfig.getDefaultInstance(),
|
||||
ringtone = "",
|
||||
extNotificationConfig = ExternalNotificationConfig.getDefaultInstance(),
|
||||
enabled = true,
|
||||
focusManager = LocalFocusManager.current,
|
||||
onSaveClicked = { },
|
||||
onSaveClicked = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
package com.geeksville.mesh.ui.components.config
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.LinearProgressIndicator
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.PacketResponseState
|
||||
|
||||
@Composable
|
||||
fun PacketResponseStateDialog(
|
||||
state: PacketResponseState,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { },
|
||||
title = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (state is PacketResponseState.Loading) {
|
||||
val progress = state.completed.toFloat() / state.total.toFloat()
|
||||
Text("%.0f%%".format(progress * 100))
|
||||
LinearProgressIndicator(
|
||||
progress = progress,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
color = MaterialTheme.colors.onSurface,
|
||||
)
|
||||
}
|
||||
if (state is PacketResponseState.Success) {
|
||||
Text("Success!")
|
||||
}
|
||||
if (state is PacketResponseState.Error) {
|
||||
Text("Error: ${state.error}")
|
||||
}
|
||||
}
|
||||
},
|
||||
buttons = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
) {
|
||||
if (state is PacketResponseState.Loading) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
} else {
|
||||
Text(stringResource(R.string.close))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PacketResponseStateDialogPreview() {
|
||||
PacketResponseStateDialog(
|
||||
state = PacketResponseState.Loading.apply {
|
||||
total = 17
|
||||
completed = 5
|
||||
},
|
||||
onDismiss = { }
|
||||
)
|
||||
}
|
|
@ -25,14 +25,15 @@ import com.geeksville.mesh.ui.components.SwitchPreference
|
|||
|
||||
@Composable
|
||||
fun PositionConfigItemList(
|
||||
positionInfo: Position?,
|
||||
isLocal: Boolean = false,
|
||||
location: Position?,
|
||||
positionConfig: PositionConfig,
|
||||
enabled: Boolean,
|
||||
focusManager: FocusManager,
|
||||
onSaveClicked: (Pair<Position?, PositionConfig>) -> Unit,
|
||||
onSaveClicked: (position: Position?, config: PositionConfig) -> Unit,
|
||||
) {
|
||||
var locationInput by remember(positionInfo) { mutableStateOf(positionInfo) }
|
||||
var positionInput by remember(positionConfig) { mutableStateOf(positionConfig) }
|
||||
var locationInput by remember { mutableStateOf(location) }
|
||||
var positionInput by remember { mutableStateOf(positionConfig) }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
|
@ -93,7 +94,7 @@ fun PositionConfigItemList(
|
|||
item {
|
||||
EditTextPreference(title = "Latitude",
|
||||
value = locationInput?.latitude ?: 0.0,
|
||||
enabled = enabled,
|
||||
enabled = enabled && isLocal,
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { value ->
|
||||
if (value >= -90 && value <= 90.0)
|
||||
|
@ -103,7 +104,7 @@ fun PositionConfigItemList(
|
|||
item {
|
||||
EditTextPreference(title = "Longitude",
|
||||
value = locationInput?.longitude ?: 0.0,
|
||||
enabled = enabled,
|
||||
enabled = enabled && isLocal,
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { value ->
|
||||
if (value >= -180 && value <= 180.0)
|
||||
|
@ -113,7 +114,7 @@ fun PositionConfigItemList(
|
|||
item {
|
||||
EditTextPreference(title = "Altitude (meters)",
|
||||
value = locationInput?.altitude ?: 0,
|
||||
enabled = enabled,
|
||||
enabled = enabled && isLocal,
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { value ->
|
||||
locationInput?.let { locationInput = it.copy(altitude = value) }
|
||||
|
@ -175,13 +176,13 @@ fun PositionConfigItemList(
|
|||
|
||||
item {
|
||||
PreferenceFooter(
|
||||
enabled = positionInput != positionConfig || locationInput != positionInfo,
|
||||
enabled = positionInput != positionConfig || locationInput != location,
|
||||
onCancelClicked = {
|
||||
focusManager.clearFocus()
|
||||
locationInput = positionInfo
|
||||
locationInput = location
|
||||
positionInput = positionConfig
|
||||
},
|
||||
onSaveClicked = { onSaveClicked(Pair(locationInput, positionInput)) }
|
||||
onSaveClicked = { onSaveClicked(locationInput, positionInput) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -191,10 +192,10 @@ fun PositionConfigItemList(
|
|||
@Composable
|
||||
fun PositionConfigPreview(){
|
||||
PositionConfigItemList(
|
||||
positionInfo = null,
|
||||
location = null,
|
||||
positionConfig = PositionConfig.getDefaultInstance(),
|
||||
enabled = true,
|
||||
focusManager = LocalFocusManager.current,
|
||||
onSaveClicked = { },
|
||||
onSaveClicked = { _, _ -> },
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.geeksville.mesh.ui.components.config
|
|||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
|
@ -14,6 +15,7 @@ import androidx.compose.ui.platform.LocalFocusManager
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.RemoteHardwareConfig
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.ui.components.EditListPreference
|
||||
import com.geeksville.mesh.ui.components.PreferenceCategory
|
||||
import com.geeksville.mesh.ui.components.PreferenceFooter
|
||||
import com.geeksville.mesh.ui.components.SwitchPreference
|
||||
|
@ -42,6 +44,30 @@ fun RemoteHardwareConfigItemList(
|
|||
}
|
||||
item { Divider() }
|
||||
|
||||
item {
|
||||
SwitchPreference(title = "Allow undefined pin access",
|
||||
checked = remoteHardwareInput.allowUndefinedPinAccess,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
remoteHardwareInput = remoteHardwareInput.copy { allowUndefinedPinAccess = it }
|
||||
})
|
||||
}
|
||||
item { Divider() }
|
||||
|
||||
item {
|
||||
EditListPreference(title = "Available pins",
|
||||
list = remoteHardwareInput.availablePinsList,
|
||||
maxCount = 4, // available_pins max_count:4
|
||||
enabled = enabled,
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValuesChanged = { list ->
|
||||
remoteHardwareInput = remoteHardwareInput.copy {
|
||||
availablePins.clear()
|
||||
availablePins.addAll(list)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
item {
|
||||
PreferenceFooter(
|
||||
enabled = remoteHardwareInput != remoteHardwareConfig,
|
||||
|
|
|
@ -17,20 +17,21 @@ import androidx.compose.ui.text.input.ImeAction
|
|||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.MeshUser
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.model.getInitials
|
||||
import com.geeksville.mesh.ui.components.EditTextPreference
|
||||
import com.geeksville.mesh.ui.components.PreferenceCategory
|
||||
import com.geeksville.mesh.ui.components.PreferenceFooter
|
||||
import com.geeksville.mesh.ui.components.RegularPreference
|
||||
import com.geeksville.mesh.ui.components.SwitchPreference
|
||||
import com.geeksville.mesh.user
|
||||
|
||||
@Composable
|
||||
fun UserConfigItemList(
|
||||
userConfig: MeshUser,
|
||||
userConfig: MeshProtos.User,
|
||||
enabled: Boolean,
|
||||
focusManager: FocusManager,
|
||||
onSaveClicked: (MeshUser) -> Unit,
|
||||
onSaveClicked: (MeshProtos.User) -> Unit,
|
||||
) {
|
||||
var userInput by remember(userConfig) { mutableStateOf(userConfig) }
|
||||
|
||||
|
@ -57,9 +58,9 @@ fun UserConfigItemList(
|
|||
),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = {
|
||||
userInput = userInput.copy(longName = it)
|
||||
userInput = userInput.copy { longName = it }
|
||||
if (getInitials(it).toByteArray().size <= 4) // short_name max_size:5
|
||||
userInput = userInput.copy(shortName = getInitials(it))
|
||||
userInput = userInput.copy { shortName = getInitials(it) }
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -73,7 +74,7 @@ fun UserConfigItemList(
|
|||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { userInput = userInput.copy(shortName = it) })
|
||||
onValueChanged = { userInput = userInput.copy { shortName = it } })
|
||||
}
|
||||
|
||||
item {
|
||||
|
@ -87,7 +88,7 @@ fun UserConfigItemList(
|
|||
SwitchPreference(title = "Licensed amateur radio",
|
||||
checked = userInput.isLicensed,
|
||||
enabled = enabled,
|
||||
onCheckedChange = { userInput = userInput.copy(isLicensed = it) })
|
||||
onCheckedChange = { userInput = userInput.copy { isLicensed = it } })
|
||||
}
|
||||
item { Divider() }
|
||||
|
||||
|
@ -107,13 +108,13 @@ fun UserConfigItemList(
|
|||
@Composable
|
||||
fun UserConfigPreview(){
|
||||
UserConfigItemList(
|
||||
userConfig = MeshUser(
|
||||
id = "!a280d9c8",
|
||||
longName = "Meshtastic d9c8",
|
||||
shortName = "d9c8",
|
||||
hwModel = MeshProtos.HardwareModel.RAK4631,
|
||||
userConfig = user {
|
||||
id = "!a280d9c8"
|
||||
longName = "Meshtastic d9c8"
|
||||
shortName = "d9c8"
|
||||
hwModel = MeshProtos.HardwareModel.RAK4631
|
||||
isLicensed = false
|
||||
),
|
||||
},
|
||||
enabled = true,
|
||||
focusManager = LocalFocusManager.current,
|
||||
onSaveClicked = { },
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit ef2bc66bba41e8ef98ea893e46eb36a2da40cb5e
|
||||
Subproject commit d7327c3de2a1dbd9ebb90864c703f97c673a4fc7
|
|
@ -17,20 +17,8 @@
|
|||
</group>
|
||||
<group android:id="@+id/group_admin">
|
||||
<item
|
||||
android:id="@+id/reboot"
|
||||
android:title="@string/reboot"
|
||||
app:showAsAction="withText" />
|
||||
<item
|
||||
android:id="@+id/shutdown"
|
||||
android:title="@string/shutdown"
|
||||
app:showAsAction="withText" />
|
||||
<item
|
||||
android:id="@+id/factory_reset"
|
||||
android:title="@string/factory_reset"
|
||||
app:showAsAction="withText" />
|
||||
<item
|
||||
android:id="@+id/nodedb_reset"
|
||||
android:title="@string/nodedb_reset"
|
||||
android:id="@+id/remote_admin"
|
||||
android:title="@string/device_settings"
|
||||
app:showAsAction="withText" />
|
||||
</group>
|
||||
</menu>
|
|
@ -9,21 +9,21 @@
|
|||
<string name="unknown_username">Unbekannter Nutzername</string>
|
||||
<string name="send">Senden</string>
|
||||
<string name="send_text">Text senden</string>
|
||||
<string name="warning_not_paired">Sie haben noch kein Meshtastic-kompatibles Funkgerät mit diesem Telefon gekoppelt. Bitte koppeln Sie ein Gerät und legen Sie Ihren Benutzernamen fest.\n\nDiese quelloffene App befindet sich im Alpha-Test. Wenn Sie Probleme finden, veröffentlichen Sie diese bitte auf unserer Website im Chat.\n\nWeitere Informationen finden Sie auf unserer Webseite - www.meshtastic.org.</string>
|
||||
<string name="warning_not_paired">Sie haben noch kein zu Meshtastic kompatibles Funkgerät mit diesem Telefon gekoppelt. Bitte koppeln Sie ein Gerät und legen Sie Ihren Benutzernamen fest.\n\nDiese quelloffene App befindet sich im Test. Wenn Sie Probleme finden, veröffentlichen Sie diese bitte auf unserer Website im Chat.\n\nWeitere Informationen finden Sie auf unserer Webseite - www.meshtastic.org.</string>
|
||||
<string name="you">Du</string>
|
||||
<string name="your_name">Dein Name</string>
|
||||
<string name="analytics_okay">Anonyme Nutzungsstatistiken und Absturzberichte senden.</string>
|
||||
<string name="looking_for_meshtastic_devices">Suche nach Meshtastic Geräten…</string>
|
||||
<string name="analytics_okay">Anonyme Nutzungsstatistiken und Absturzberichte.</string>
|
||||
<string name="looking_for_meshtastic_devices">Suche nach Meshtastic-Geräten …</string>
|
||||
<string name="starting_pairing">Koppelung beginnen</string>
|
||||
<string name="url_for_join">Eine URL zum Beitritt zu einem meshtastischen Netzwerk</string>
|
||||
<string name="url_for_join">URL zum Beitritt zu einem Meshtastic-Netzwerk</string>
|
||||
<string name="accept">Akzeptieren</string>
|
||||
<string name="cancel">Abbrechen</string>
|
||||
<string name="change_channel">Kanal wechseln</string>
|
||||
<string name="are_you_sure_channel">Möchten Sie wirklich den Kanal wechseln? Die gesamte Kommunikation mit anderen Knoten wird unterbrochen, bis Sie die neuen Kanaleinstellungen freigeben.</string>
|
||||
<string name="new_channel_rcvd">Neuen Kanal-Link empfangen</string>
|
||||
<string name="new_channel_rcvd">Neue Kanal-URL empfangen</string>
|
||||
<string name="do_you_want_switch">Möchten Sie zum Kanal \'%s\' wechseln?</string>
|
||||
<string name="permission_missing">Meshtastic benötigt Standortberechtigung und Standort muss eingeschaltet werden, um neue Geräte über Bluetooth zu finden. Sie können es später wieder deaktivieren.</string>
|
||||
<string name="radio_sleeping">Das Funkgerät war schlafend, der Kanal konnte nicht geändert werden</string>
|
||||
<string name="radio_sleeping">Das Funkgerät war im Schlafmodus, der Kanal konnte nicht geändert werden</string>
|
||||
<string name="report_bug">Fehler melden</string>
|
||||
<string name="report_a_bug">Fehler melden</string>
|
||||
<string name="report_bug_text">Bist du sicher, dass du einen Fehler melden möchtest? Nach dem Melden bitte auf meshtastic.discourse.group eine Nachricht veröffentlichen, damit wir die Übereinstimmung der Fehlermeldung und dessen, was Sie gefunden haben, feststellen können.</string>
|
||||
|
@ -31,26 +31,27 @@
|
|||
<string name="not_paired_yet">Sie haben noch kein gekoppeltes Funkgerät.</string>
|
||||
<string name="change_radio">Funkgerät wechseln</string>
|
||||
<string name="pairing_completed">Koppelung hergestellt, der Dienst wird gestartet</string>
|
||||
<string name="pairing_failed_try_again">Die Koppelung ist fehlgeschlagen, bitte wählen Sie erneut aus</string>
|
||||
<string name="pairing_failed_try_again">Die Koppelung ist fehlgeschlagen, bitte wähle erneut aus</string>
|
||||
<string name="location_disabled">Standortzugriff ist ausgeschaltet, es kann keine Position zum Mesh bereitgestellt werden.</string>
|
||||
<string name="share">Teilen</string>
|
||||
<string name="disconnected">Verbindung getrennt</string>
|
||||
<string name="device_sleeping">Gerät schläft</string>
|
||||
<string name="connected_count">Verbunden: %1$s von %2$s angeschlossen</string>
|
||||
<string name="connected_count">Verbunden: %1$s von %2$s online</string>
|
||||
<string name="list_of_nodes">Eine Liste der Knoten im Netzwerk</string>
|
||||
<string name="update_firmware">Firmware aktualisieren</string>
|
||||
<string name="connected">Mit Funkgerät verbunden</string>
|
||||
<string name="connected_to">Mit Funkgerät verbunden (%s)</string>
|
||||
<string name="not_connected">Nicht verbunden</string>
|
||||
<string name="connected_sleeping">Mit Funkgerät verbunden, aber es ist schlafend</string>
|
||||
<string name="connected_sleeping">Mit Funkgerät verbunden, aber es ist im Schlafmodus</string>
|
||||
<string name="update_to">Auf %s aktualisieren</string>
|
||||
<string name="app_too_old">Anwendungsaktualisierung erforderlich</string>
|
||||
<string name="must_update">Sie müssen diese App über den App Store (oder Github) aktualisieren. Sie ist zu alt, um mit dieser Funkgerät-Firmware zu kommunizieren. Bitte lesen Sie unsere <a href="https://meshtastic.org/docs/software/android/android-installation">Dokumentation</a> zu diesem Thema.</string>
|
||||
<string name="none">Nichts (deaktiviert)</string>
|
||||
<string name="modem_config_short">Kurze Reichweite / Schnell</string>
|
||||
<string name="modem_config_medium">Mittlere Reichweite / Schnell</string>
|
||||
<string name="modem_config_long">Hohe Reichweite / Schnell</string>
|
||||
<string name="modem_config_very_long">Sehr hohe Reichweite / Langsam</string>
|
||||
<string name="modem_config_long">Lange Reichweite / Schnell</string>
|
||||
<string name="modem_config_mod_long">Lange Reichweite / Medium</string>
|
||||
<string name="modem_config_very_long">Sehr lange Reichweite / Langsam</string>
|
||||
<string name="modem_config_unrecognized">UNERKANNT</string>
|
||||
<string name="meshtastic_service_notifications">Dienst-Benachrichtigungen</string>
|
||||
<string name="location_disabled_warning">Standort muss eingeschaltet werden (hohe Genauigkeit), um neue Geräte über Bluetooth zu finden. Sie können es später wieder ausschalten.</string>
|
||||
|
@ -74,7 +75,7 @@
|
|||
<string name="okay">Okay</string>
|
||||
<string name="must_set_region">Sie müssen eine Region festlegen!</string>
|
||||
<string name="region">Region</string>
|
||||
<string name="cant_change_no_radio">Konnte den Kanal nicht ändern, da das Radio noch nicht verbunden ist. Bitte versuchen Sie es erneut.</string>
|
||||
<string name="cant_change_no_radio">Konnte den Kanal nicht ändern, da das Funkgerät noch nicht verbunden ist. Bitte versuchen Sie es erneut.</string>
|
||||
<string name="save_messages">Exportiere rangetest.csv</string>
|
||||
<string name="reset">Zurücksetzen</string>
|
||||
<string name="scan">Scannen</string>
|
||||
|
@ -115,6 +116,7 @@
|
|||
<string name="resend">Erneut senden</string>
|
||||
<string name="shutdown">Herunterfahren</string>
|
||||
<string name="reboot">Neustarten</string>
|
||||
<string name="traceroute">Traceroute</string>
|
||||
<string name="intro_show">Einführung zeigen</string>
|
||||
<string name="intro_welcome">Willkommen bei Meshtastic</string>
|
||||
<string name="intro_welcome_text">Meshtastic ist eine quelloffene, netzunabhängige und verschlüsselte Kommunikationsplattform. Die Meshtastic-Funkgeräte bilden ein Mesh-Netzwerk und kommunizieren mithilfe des LoRa Protokolls, um Textnachrichten zu senden.</string>
|
||||
|
@ -134,7 +136,7 @@
|
|||
<string name="bluetooth_disabled">Bluetooth deaktiviert</string>
|
||||
<string name="permission_missing_31">Meshtastic benötigt die Berechtigung „Geräte in der Nähe“, um über Bluetooth Geräte zu finden und zu verbinden. Sie können es deaktivieren, wenn es nicht verwendet wird.</string>
|
||||
<string name="direct_message">Direktnachricht</string>
|
||||
<string name="nodedb_reset">NodeDB zurücksetzen</string>
|
||||
<string name="nodedb_reset">Node-Datenbank zurücksetzen</string>
|
||||
<string name="nodedb_reset_description">Dies löscht alle Knoten von dieser Liste.</string>
|
||||
<string name="map_select_download_region">Herunterlade-Region auswählen</string>
|
||||
<string name="map_5_miles">5 Meilen/8 km</string>
|
||||
|
|
|
@ -157,8 +157,8 @@
|
|||
<string name="map_start_download">Start Download</string>
|
||||
<string name="request_position">Request position</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="device_settings">Device settings</string>
|
||||
<string name="module_settings">Module settings</string>
|
||||
<string name="device_settings">Radio configuration</string>
|
||||
<string name="module_settings">Module configuration</string>
|
||||
<string name="add">Add</string>
|
||||
<string name="calculating">Calculating…</string>
|
||||
<string name="map_offline_manager">Offline Manager</string>
|
||||
|
|
|
@ -82,4 +82,5 @@
|
|||
<usb-device
|
||||
vendor-id="9114"
|
||||
product-id="17413" /> <!-- 0x239A / 0x4405: Adafruit (T-Echo) -->
|
||||
<usb-device vendor-id="11914" /> <!-- 0x2E8A / ……: Raspberry Pi -->
|
||||
</resources>
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
buildscript {
|
||||
ext {
|
||||
useCrashlytics = false
|
||||
kotlin_version = '1.8.10'
|
||||
hilt_version = '2.45'
|
||||
protobuf_version = '3.22.3'
|
||||
kotlin_version = '1.8.21'
|
||||
hilt_version = '2.46.1'
|
||||
protobuf_version = '3.23.1'
|
||||
}
|
||||
|
||||
repositories {
|
||||
|
@ -24,7 +24,7 @@ buildscript {
|
|||
if (project.findProperty("useCrashlytics") == true) {
|
||||
println("useCrashlytics classpath $useCrashlytics")
|
||||
classpath 'com.google.gms:google-services:4.3.15'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.2'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.5'
|
||||
}
|
||||
|
||||
// protobuf plugin - docs here https://github.com/google/protobuf-gradle-plugin
|
||||
|
|
Ładowanie…
Reference in New Issue