Porównaj commity

...

70 Commity

Autor SHA1 Wiadomość Data
sp9unb a1e6eeb4ee fix decimal in snr value in rangetest.csv 2023-05-30 00:15:51 +02:00
andrekir 72f02b0deb 2.1.13 2023-05-26 17:56:17 -03:00
andrekir 1fe669fb73 feat: add RemoteHardwarePin config 2023-05-26 17:45:25 -03:00
andrekir 8bc628de9f feat: add Raspberry Pi usb-device vendor-id 2023-05-26 17:25:33 -03:00
andrekir 1380924a37 refactor: remove BTScanModel from MainActivity 2023-05-26 17:24:23 -03:00
andrekir 206d153c55 chore: update proto submodule to v2.1.13 2023-05-26 16:29:43 -03:00
andrekir 7ca724142f refactor: simplify setOwner logic 2023-05-26 16:18:02 -03:00
andrekir 956db658e9 refactor: remove RadioInterfaceService from MainActivity 2023-05-24 06:43:58 -03:00
andrekir d01e8e8e74 refactor: clean up myNodeInfo from UsersFragment 2023-05-24 06:39:26 -03:00
andrekir 0f84804f9f chore: update Compose Compiler to 1.4.7 2023-05-24 06:27:45 -03:00
andrekir 6fa8023bf7 chore: update Protobuf to 3.23.1 2023-05-24 06:25:49 -03:00
andrekir e244aa4b9b chore: update Hilt to 2.46.1 2023-05-24 06:25:09 -03:00
andrekir 5214add39c chore: update Kotlin Serialization to 1.5.1 2023-05-24 06:24:34 -03:00
andrekir 70f30b8f39 chore: update Kotlin Coroutines to 1.7.1 2023-05-24 06:23:58 -03:00
andrekir d38320ada6 chore: update Firebase Crashlytics Gradle to 2.9.5 2023-05-24 06:22:16 -03:00
andrekir 93ac0186fe fix: incorrect admin channel index retrieval logic
was returning -1 instead of 0 when no admin channel configured.
2023-05-24 06:17:32 -03:00
andrekir 9869a9208b refactor: improve service admin channel index logic 2023-05-21 19:46:40 -03:00
andrekir 6a72c65a83 fix: channel config request logic 2023-05-21 19:31:18 -03:00
andrekir 7da958578b refactor: improve channel editor isEditing logic 2023-05-21 19:09:00 -03:00
andrekir 0a3a07f9ed fix: channel list display issues
- show modem preset name if channel name is empty for remote nodes
- fix channel list not showing last channel (index 7)
2023-05-21 18:32:33 -03:00
andrekir d58e092333 fix: show modem preset name if channel name is empty
(or "Default" if not available)
2023-05-21 09:19:55 -03:00
andrekir 8643d50425 feat: update German localization strings 2023-05-21 06:12:06 -03:00
andrekir e2f63e015c fix: reindex node list when local node isn't first (index 0) 2023-05-21 06:08:34 -03:00
andrekir 8151aceea4 fix: ensure proper channel updates to `ChannelSetRepository` 2023-05-21 06:04:53 -03:00
Andre K a2388d1d12
refactor: combine config data stores into `RadioConfigRepository` (#636) 2023-05-20 11:42:15 -03:00
andrekir a4baa93f4e fix: remove `remember` from `isEditing` variable 2023-05-20 11:32:49 -03:00
andrekir e116a8a97c refactor: update EditListPreference 2023-05-16 17:47:59 -03:00
andrekir ab5f1ffac1 refactor: use OutlinedButton for radio configs 2023-05-16 17:47:20 -03:00
Andre K c3ab3c5ae9
feat: implement `PacketResponseState.Success` (#634) 2023-05-15 17:49:13 -03:00
andrekir b9be26e344 2.1.12 2023-05-13 18:45:30 -03:00
andrekir 135bcf8b8a fix: revert unintended changes from a316495545 2023-05-13 18:35:16 -03:00
andrekir 0c78bc4e49 feat: add managed mode 2023-05-13 18:18:49 -03:00
andrekir a316495545 refactor: move shutdown/reboot/etc to radio configs 2023-05-13 18:14:47 -03:00
andrekir 8eb049c60e chore: update Core-Ktx to 1.10.1 2023-05-13 18:06:21 -03:00
andrekir 7eeb0b4d6f fix: revert to ChannelSet addSettings method without index
fixes throwing Non-fatal Exception: java.lang.IndexOutOfBoundsException: Index: N, Size: n
2023-05-13 17:51:56 -03:00
andrekir 69c79c331f chore: update proto submodule to v2.1.12 2023-05-13 10:18:13 -03:00
andrekir 6297cf2b62 fix: set fixed position for local node only 2023-05-12 18:34:29 -03:00
andrekir ad278f918b feat: update German localization strings 2023-05-12 18:30:57 -03:00
Andre K 068f5e7544
feat: implement `PacketResponseState.Error` (#633) 2023-05-12 18:29:31 -03:00
andrekir 2502bee55f fix: update handleReceivedPosition
ignore received Position packets with `wantResponse = true` (position requests). set `destNum` for remote nodes (fixed position). also reverts 24e5454fae
2023-05-10 22:17:09 -03:00
andrekir 8a750c122e fix: ensure FAB layer above LazyColumn in ChannelSettingsItemList 2023-05-10 21:43:18 -03:00
andrekir 4b00fe9f2e 2.1.11 2023-05-08 17:58:52 -03:00
andrekir 9a3e5a9456 chore: update Firebase Crashlytics to 18.3.7 2023-05-08 17:43:56 -03:00
andrekir 1a76a78d76 chore: update Material lib to 1.9.0 2023-05-08 17:42:50 -03:00
andrekir e35313fb8e chore: update Core-Ktx to 1.10.0 2023-05-08 17:39:51 -03:00
andrekir 05a2364a27 chore: update Fragment to 1.5.7 2023-05-08 17:37:01 -03:00
andrekir 89a0a4c4ac chore: update Splashscreen to 1.0.1 2023-05-08 17:35:10 -03:00
andrekir 6515b2d3a7 fix #629: keep saved names when editing actions 2023-05-08 17:34:06 -03:00
andrekir 29d3572507 fix: replace filterNotNull() with null check 2023-05-08 17:33:21 -03:00
Andre K 70f7ffb5fc
feat: implement `PacketResponseState.Loading` (#630) 2023-05-08 17:31:07 -03:00
andrekir 7d1d793fb9 refactor: collect receivingLocationUpdates with repeatOnLifecycle 2023-05-07 05:34:14 -03:00
andrekir 3bbe3fd7f7 refactor: simplify packetResponse using filterNotNull and firstOrNull 2023-05-07 05:33:18 -03:00
andrekir d1ce014a88 fix: allow empty (no crypto) and 128 bit PSKs 2023-05-06 08:18:56 -03:00
andrekir 41d0315b63 fix: handle deleted channels in ChannelSet DataStore
adds `removeSettings` method to delete channels with `Role.DISABLED`
2023-05-06 08:08:17 -03:00
andrekir feed8262ea 2.1.10 2023-05-02 07:30:36 -03:00
andrekir 4a6c0c0b40 fix: prevent clicking through composable background 2023-05-02 07:24:01 -03:00
andrekir a39390254a refactor: revert PreferenceFooter to default theme colors 2023-05-02 07:22:51 -03:00
andrekir 7aa173d0d2 chore: update proto submodule to v2.1.10 2023-05-02 07:20:54 -03:00
Andre K 9e78e516da
feat: add configs import/export (#628) 2023-05-02 07:18:22 -03:00
andrekir 9dc1a45fe6 fix: correct traceroute to/from order 2023-04-29 07:26:52 -03:00
andrekir 16787b23c8 fix: BitwisePreference trailing icons 2023-04-29 07:19:22 -03:00
Andre K e5a860cb36
feat: add channel editor (#627) 2023-04-29 07:14:30 -03:00
andrekir c821eb3681 fix #625: handle Samsung Keyboard dot-minus key in TextField validation
Samsung Keyboard numerical keypad features a combined '.-' key that outputs a dot (.) on first press and replaces it with a minus (-) on second press. there is no option to output each symbol separately (short or long press, etc).

updated validation logic to handle dot symbol at the start of the input string.
2023-04-26 18:21:27 -03:00
andrekir ab46bf6ab9 refactor: simplify routeDiscovery conditional 2023-04-26 17:56:10 -03:00
andrekir 34eac6af18 fix: change MeshPacket default `hopLimit` to match LoRa config instead of 0 2023-04-25 19:18:03 -03:00
andrekir 7834cb1f0c fix: use little-endian byte order for protobuf fixed32 values 2023-04-24 22:23:40 -03:00
andrekir 6f5ed93db3 refactor: add conditional to LaunchedEffect 2023-04-24 22:15:38 -03:00
andrekir 8d5cca93f1 style: fix name and formatting 2023-04-24 22:13:44 -03:00
andrekir 145988ad75 refactor: improve parameter naming and type in config constructors 2023-04-24 22:11:36 -03:00
Andre K 85e62eaab4
feat: add remote node configuration (#626) 2023-04-22 12:06:25 -03:00
36 zmienionych plików z 2139 dodań i 789 usunięć

Wyświetl plik

@ -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'

Wyświetl plik

@ -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();

Wyświetl plik

@ -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

Wyświetl plik

@ -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
* */

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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) {

Wyświetl plik

@ -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()
}

Wyświetl plik

@ -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) {

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)
}
}

Wyświetl plik

@ -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
})
}

Wyświetl plik

@ -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()
}

Wyświetl plik

@ -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()
}

Wyświetl plik

@ -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) {

Wyświetl plik

@ -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")
}

Wyświetl plik

@ -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)

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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 = {},
)
}
}

Wyświetl plik

@ -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 = {}

Wyświetl plik

@ -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)
)
) {

Wyświetl plik

@ -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 = { _, _ -> },
)
}

Wyświetl plik

@ -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 = { },
)
}

Wyświetl plik

@ -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,

Wyświetl plik

@ -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 = { },
)
}

Wyświetl plik

@ -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 = { },
)
}

Wyświetl plik

@ -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 = { _, _ -> },
)
}

Wyświetl plik

@ -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 = { }
)
}

Wyświetl plik

@ -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 = { _, _ -> },
)
}

Wyświetl plik

@ -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,

Wyświetl plik

@ -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

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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